diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index b777e26bb..0b528ae10 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -31,7 +31,7 @@ jobs: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + ref: 8dcdacd5153e64560d109541a47d806f26f048c0 path: craft-fonts - name: Build and test in a FreeBSD VM uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1 diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 2b53ae6cd..0e2388a62 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -54,4 +54,15 @@ jobs: 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 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.lightcraft.yml')); assert m['id'] == 'ai.storyteller.lightcraft' and m['command'] == 'lightcraft'" + 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 69fcf7da9..b9c3a1ea3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,9 +2,11 @@ # are configured) and creates or updates the draft GitHub Release "LightCraft v". # See docs/releasing.md. # -# All jobs 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 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, 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. name: Release on: @@ -36,7 +38,7 @@ jobs: version: name: version runs-on: ubuntu-latest - environment: release + # 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 }} @@ -75,7 +77,7 @@ jobs: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately + ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately path: craft-fonts - uses: dtolnay/rust-toolchain@stable with: @@ -133,7 +135,7 @@ jobs: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately + ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately path: craft-fonts - uses: dtolnay/rust-toolchain@stable with: @@ -174,7 +176,6 @@ jobs: # 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 }} - environment: release strategy: fail-fast: false matrix: @@ -191,14 +192,14 @@ jobs: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately + 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 + 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" @@ -223,6 +224,9 @@ jobs: 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@v4 with: @@ -230,11 +234,98 @@ jobs: 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@v4 + - uses: actions/download-artifact@v4 + 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@v4 + with: + name: release-flatpak-${{ matrix.arch }} + path: dist/release/*.flatpak + 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@v4 + # Fonts (optional build input; required for releases): see craftrules standards/fonts.md. + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + 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 + 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@v4 + with: + name: release-freebsd-x86_64 + path: dist/release/* + if-no-files-found: error + web: name: Web (wasm) needs: version runs-on: ubuntu-latest - environment: release env: LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }} LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} @@ -245,7 +336,7 @@ jobs: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately + ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately path: craft-fonts - uses: dtolnay/rust-toolchain@stable with: @@ -276,7 +367,7 @@ jobs: release: name: Draft GitHub Release - needs: [version, macos, windows, linux, web] + needs: [version, macos, windows, linux, flatpak, freebsd, web] runs-on: ubuntu-latest environment: release permissions: diff --git a/AGENTS.md b/AGENTS.md index 4c6ddfdea..3ab653c77 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,7 +63,10 @@ of it. Full standard: `../craftrules/standards/never-crash.md` - **Never use any iconography, image, artwork, font, sound or other asset from Adobe products** (no Lightroom/Creative Cloud icons, no screenshots, no presets/profiles/LUTs, no UI bitmaps — not even as a temporary placeholder or "reference copy"). Observing Adobe's UI to imitate *layout and behaviour* is allowed; copying or tracing its assets is not. - **This includes Adobe's open-licensed assets**: no Source Sans/Serif/Code or Source Han fonts, no Adobe Fonts, no Adobe-published icon sets, sample photos, colour profiles or LUTs — even when OFL/MIT. The UI font is Inter (OFL); - Japanese fonts come from craft-fonts (below). + Japanese and Chinese fonts come from craft-fonts (below). **One exception (maintainer decision, 2026-10-07): Noto + Sans/Serif CJK** (Google-branded, OFL, co-developed with Adobe as Source Han) is allowed via craft-fonts for Chinese + text, because nearly every OFL Chinese face derives from it. Use it unmodified under its OFL; this does not open the + door to Source Han under Adobe's name or to any other Adobe asset. - Every asset in the repository must be one of: **our own original work** (e.g. icons drawn in code as vectors, procedurally generated demo photos), **public domain / CC0**, **Creative Commons** (CC-BY / CC-BY-SA with attribution honoured), **OFL** (fonts), or **permissive open-source** (MIT/Apache-2.0/BSD/ISC) — or contributed by a person who created the asset and licenses it openly. - **Exception: `docs/brand/`.** The ArtCraft name, wordmark and logos there are ArtCraft Team trademarks, not open source and not covered by LightCraft's MIT OR Apache-2.0 licence (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`); their terms are in `docs/brand/LICENSE-brand.txt`. Use them only unmodified and never redraw, recolour or derive from them. - **Every asset must have an entry in `assets/ATTRIBUTION.md`** (path, title, author/creator, source URL or "original work", licence, date added, modifications) and its licence text when required (e.g. `assets/fonts/OFL-*.txt`). Add the entry in the same commit as the asset. Assets without an attribution entry must not be committed. @@ -72,7 +75,7 @@ of it. Full standard: `../craftrules/standards/never-crash.md` uses it as the optional build input `CRAFT_FONTS_DIR`: `git clone https://github.com/storytold/craft-fonts ../craft-fonts` then `CRAFT_FONTS_DIR=../craft-fonts cargo run -p lightcraft` (or any cargo/xtask command). `crates/engine/build.rs` embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); the UI - (`theme::font_definitions`) and the export watermark renderer use its Japanese faces as fallbacks after Inter. Unset, + (`theme::font_definitions`) and the export watermark renderer use its CJK faces (picked by script) as fallbacks after Inter. Unset, `CRAFT_FONTS` is empty: everything builds, tests and runs, but Japanese text has no glyphs. Releases always build with it (`release.yml`, `CRAFT_FONTS_REQUIRED=1`) and ship the fonts' OFL licences. Tests that need these fonts skip without it; the FreeBSD CI job runs them with it. Rules: craftrules diff --git a/CLAUDE.md b/CLAUDE.md index eb5b9430b..bdf7eceb5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,7 +63,10 @@ of it. Full standard: `../craftrules/standards/never-crash.md` - **Never use any iconography, image, artwork, font, sound or other asset from Adobe products** (no Lightroom/Creative Cloud icons, no screenshots, no presets/profiles/LUTs, no UI bitmaps — not even as a temporary placeholder or "reference copy"). Observing Adobe's UI to imitate *layout and behaviour* is allowed; copying or tracing its assets is not. - **This includes Adobe's open-licensed assets**: no Source Sans/Serif/Code or Source Han fonts, no Adobe Fonts, no Adobe-published icon sets, sample photos, colour profiles or LUTs — even when OFL/MIT. The UI font is Inter (OFL); - Japanese fonts come from craft-fonts (below). + Japanese and Chinese fonts come from craft-fonts (below). **One exception (maintainer decision, 2026-10-07): Noto + Sans/Serif CJK** (Google-branded, OFL, co-developed with Adobe as Source Han) is allowed via craft-fonts for Chinese + text, because nearly every OFL Chinese face derives from it. Use it unmodified under its OFL; this does not open the + door to Source Han under Adobe's name or to any other Adobe asset. - Every asset in the repository must be one of: **our own original work** (e.g. icons drawn in code as vectors, procedurally generated demo photos), **public domain / CC0**, **Creative Commons** (CC-BY / CC-BY-SA with attribution honoured), **OFL** (fonts), or **permissive open-source** (MIT/Apache-2.0/BSD/ISC) — or contributed by a person who created the asset and licenses it openly. - **Exception: `docs/brand/`.** The ArtCraft name, wordmark and logos there are ArtCraft Team trademarks, not open source and not covered by LightCraft's MIT OR Apache-2.0 licence (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`); their terms are in `docs/brand/LICENSE-brand.txt`. Use them only unmodified and never redraw, recolour or derive from them. - **Every asset must have an entry in `assets/ATTRIBUTION.md`** (path, title, author/creator, source URL or "original work", licence, date added, modifications) and its licence text when required (e.g. `assets/fonts/OFL-*.txt`). Add the entry in the same commit as the asset. Assets without an attribution entry must not be committed. @@ -72,7 +75,7 @@ of it. Full standard: `../craftrules/standards/never-crash.md` uses it as the optional build input `CRAFT_FONTS_DIR`: `git clone https://github.com/storytold/craft-fonts ../craft-fonts` then `CRAFT_FONTS_DIR=../craft-fonts cargo run -p lightcraft` (or any cargo/xtask command). `crates/engine/build.rs` embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); the UI - (`theme::font_definitions`) and the export watermark renderer use its Japanese faces as fallbacks after Inter. Unset, + (`theme::font_definitions`) and the export watermark renderer use its CJK faces (picked by script) as fallbacks after Inter. Unset, `CRAFT_FONTS` is empty: everything builds, tests and runs, but Japanese text has no glyphs. Releases always build with it (`release.yml`, `CRAFT_FONTS_REQUIRED=1`) and ship the fonts' OFL licences. Tests that need these fonts skip without it; the FreeBSD CI job runs them with it. Rules: craftrules diff --git a/Cargo.lock b/Cargo.lock index dae7db484..86d4bca64 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2779,7 +2779,7 @@ dependencies = [ [[package]] name = "lightcraft" -version = "0.3.0" +version = "0.4.0" dependencies = [ "eframe", "egui", @@ -2797,7 +2797,7 @@ dependencies = [ [[package]] name = "lightcraft-catalog" -version = "0.3.0" +version = "0.4.0" dependencies = [ "crc32fast", "lightcraft-develop", @@ -2812,7 +2812,7 @@ dependencies = [ [[package]] name = "lightcraft-cli" -version = "0.3.0" +version = "0.4.0" dependencies = [ "dhat", "lightcraft-codecs", @@ -2827,7 +2827,7 @@ dependencies = [ [[package]] name = "lightcraft-codecs" -version = "0.3.0" +version = "0.4.0" dependencies = [ "bytemuck", "image", @@ -2852,7 +2852,7 @@ dependencies = [ [[package]] name = "lightcraft-color" -version = "0.3.0" +version = "0.4.0" dependencies = [ "lightcraft-geom", "proptest", @@ -2861,7 +2861,7 @@ dependencies = [ [[package]] name = "lightcraft-develop" -version = "0.3.0" +version = "0.4.0" dependencies = [ "lightcraft-color", "lightcraft-geom", @@ -2873,9 +2873,10 @@ dependencies = [ [[package]] name = "lightcraft-engine" -version = "0.3.0" +version = "0.4.0" dependencies = [ "ab_glyph", + "harfrust", "libc", "lightcraft-catalog", "lightcraft-codecs", @@ -2891,18 +2892,20 @@ dependencies = [ "lightcraft-raw", "lightcraft-scenes", "lightcraft-segment", + "lightcraft-tiff", "log", "miniz_oxide 0.8.9", "rayon", "serde", "serde_json", "thiserror 2.0.21", + "unicode-segmentation", "web-time", ] [[package]] name = "lightcraft-fetch" -version = "0.3.0" +version = "0.4.0" dependencies = [ "log", "rustls", @@ -2913,7 +2916,7 @@ dependencies = [ [[package]] name = "lightcraft-geom" -version = "0.3.0" +version = "0.4.0" dependencies = [ "proptest", "serde", @@ -2921,7 +2924,7 @@ dependencies = [ [[package]] name = "lightcraft-gpu" -version = "0.3.0" +version = "0.4.0" dependencies = [ "bytemuck", "lightcraft-color", @@ -2937,7 +2940,7 @@ dependencies = [ [[package]] name = "lightcraft-mcp" -version = "0.3.0" +version = "0.4.0" dependencies = [ "lightcraft-codecs", "lightcraft-engine", @@ -2948,7 +2951,7 @@ dependencies = [ [[package]] name = "lightcraft-merge" -version = "0.3.0" +version = "0.4.0" dependencies = [ "lightcraft-codecs", "lightcraft-color", @@ -2964,7 +2967,7 @@ dependencies = [ [[package]] name = "lightcraft-meta" -version = "0.3.0" +version = "0.4.0" dependencies = [ "lightcraft-geom", "lightcraft-tiff", @@ -2976,7 +2979,7 @@ dependencies = [ [[package]] name = "lightcraft-pipeline" -version = "0.3.0" +version = "0.4.0" dependencies = [ "image", "lightcraft-color", @@ -2992,7 +2995,7 @@ dependencies = [ [[package]] name = "lightcraft-preview" -version = "0.3.0" +version = "0.4.0" dependencies = [ "jpeg-encoder", "lightcraft-raster", @@ -3004,7 +3007,7 @@ dependencies = [ [[package]] name = "lightcraft-raster" -version = "0.3.0" +version = "0.4.0" dependencies = [ "libc", "lightcraft-color", @@ -3016,7 +3019,7 @@ dependencies = [ [[package]] name = "lightcraft-raw" -version = "0.3.0" +version = "0.4.0" dependencies = [ "jpeg-encoder", "lightcraft-color", @@ -3028,6 +3031,7 @@ dependencies = [ "proptest", "rayon", "serde", + "sha2", "thiserror 2.0.21", "zune-core", "zune-jpeg", @@ -3035,7 +3039,7 @@ dependencies = [ [[package]] name = "lightcraft-scenes" -version = "0.3.0" +version = "0.4.0" dependencies = [ "image", "lightcraft-color", @@ -3046,7 +3050,7 @@ dependencies = [ [[package]] name = "lightcraft-segment" -version = "0.3.0" +version = "0.4.0" dependencies = [ "candle-core", "candle-nn", @@ -3059,11 +3063,11 @@ dependencies = [ [[package]] name = "lightcraft-sysmem" -version = "0.3.0" +version = "0.4.0" [[package]] name = "lightcraft-tiff" -version = "0.3.0" +version = "0.4.0" dependencies = [ "proptest", "serde", @@ -3072,7 +3076,7 @@ dependencies = [ [[package]] name = "lightcraft-ui-egui" -version = "0.3.0" +version = "0.4.0" dependencies = [ "egui", "lightcraft-catalog", @@ -3094,7 +3098,7 @@ dependencies = [ [[package]] name = "lightcraft-web" -version = "0.3.0" +version = "0.4.0" dependencies = [ "crc32fast", "eframe", @@ -6482,7 +6486,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.3.0" +version = "0.4.0" dependencies = [ "brotli", "flate2", diff --git a/Cargo.toml b/Cargo.toml index 0c4b7d053..77b206669 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["crates/*", "apps/*", "xtask"] [workspace.package] -version = "0.3.0" +version = "0.4.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" diff --git a/README.md b/README.md index f1f2e4bc8..5aacbee0d 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ Status · Quick start · Roadmap · + Downloads · Crafting Apps

@@ -257,7 +258,7 @@ lightcraft --control 7980 ~/Pictures/trip ## Fast, native, private -- **Pure Rust, no C.** Our own RAW decoders (DNG, Canon CR2, Sony ARW, Nikon NEF, Fujifilm RAF incl. X-Trans, +- **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. - **Scene-referred & wide-gamut.** Linear Rec.2020 float internally, Bradford-adapted white balance, gamut mapping @@ -287,8 +288,8 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the - **As a day-to-day Lightroom replacement we're nearer 60–70%.** It's great for JPEG/DNG and most Nikon / Sony / older-Canon raws on one machine. - **The biggest gaps:** - - **camera colour calibration:** raws other than DNG develop with a neutral colour matrix today, so colour is muted; - - **CR3 and compressed Fujifilm / Olympus raws:** these open as embedded previews only; + - **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCE-7M4, X-H2S and X-T4 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix; + - **compressed Olympus raws and unsupported CR3 variants:** these use embedded JPEG previews when present. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md); - **AI masks and denoise:** subject and sky selection are classical heuristics; - **HDR, video and the Classic Print / Book / Map modules.** - **What's next:** see [where we're going](ROADMAP.md#where-were-going). @@ -301,9 +302,9 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the | Masking: brush, linear, radial, luminance/colour range, add/subtract/intersect | ✅ (AI subject/sky use classical heuristics for now) | | 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 | ✅ · our own calibration for other raws ⬜ (top priority; ARW, NEF and RW2 start from a look fitted to their own JPEG, other raws from a neutral fallback) | +| Camera colour: DNG files use their own matrices | ✅ DNG · 🟡 own Sony/Fujifilm profiles; measured calibration database missing | | Native macOS menu bar (generated from the command registry), control channel + every widget addressable, headless UI snapshots | ✅ | -| RAW: DNG, CR2, ARW, NEF (uncompressed + lossless/lossy compressed), Fujifilm RAF (uncompressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed); embedded previews for every format incl. CR3 | ✅ · CR3, compressed RAF/ORF decode ⬜ | +| RAW: DNG, CR2, CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), ARW, NEF (uncompressed + lossless/lossy compressed), Fujifilm RAF (uncompressed + lossless/lossy compressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed); embedded previews for every format incl. CR3 | 🟡 · CR3, compressed ORF decode ⬜ | | Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise, Super Resolution ⬜ | | Remove / Heal / Clone spots (auto source), Visualize Spots, Red Eye and Pet Eye (auto pupil detection, catchlight) | ✅ · content-aware fill, spot pin editing 🚧 | | Export: JPEG / PNG / TIFF / WebP / AVIF / DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | ✅ · HDR export ⬜ | @@ -311,7 +312,7 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the | 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 | ✅ | | XMP sidecars (read/write, auto-write), reading `crs:` develop settings, preset files (`.lcpreset`, XMP presets) | ✅ | -| Optics (distortion, vignetting, auto + manual CA, defringe, DNG-embedded lens corrections), Geometry (transforms, Constrain Crop), Upright (Auto/Level/Vertical/Full/Guided) | ✅ · camera lens profiles (our own) ⬜ | +| Optics (distortion, vignetting, auto + manual CA, defringe, lens corrections embedded in DNG files and Panasonic / Leica RW2 / RWL distortion data), Geometry (transforms, Constrain Crop), Upright (Auto/Level/Vertical/Full/Guided) | ✅ · camera lens profiles (our own) ⬜ | | Photo Merge: HDR (auto-align, deghost), Panorama (spherical/cylindrical/perspective, boundary warp, auto crop), HDR Panorama → DNG | ✅ | | GPU pipeline (wgpu compute, CPU-exact within 1/255), CPU fallback on device limits / errors | ✅ · WebGPU in the browser 🚧 | | AI: segmentation masks, AI denoise, super resolution, faces; HDR editing; video | ⬜ (see [roadmap](ROADMAP.md#where-were-going)) | @@ -352,9 +353,23 @@ CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft Without it LightCraft builds and runs the same, but Chinese and Japanese text have no glyphs. Fonts are never committed to this repo; see [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md). -**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語) or **Settings → General → +**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Deutsch) 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 +(Linux `$XDG_CONFIG_HOME/lightcraft/logs/`, by default `~/.config/lightcraft/logs/`; macOS +`~/Library/Application Support/LightCraft/logs/`; Windows `%APPDATA%\LightCraft\logs\`), never in the library. A +launch from a desktop menu or the Dock has no terminal, so attach this file to a bug report (**Help ▸ Open Log +Folder** shows it in the file manager). 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; the file stops growing at 16 MiB, `--version` and `--help` write none, and runs with +`LIGHTCRAFT_NO_PREFS` log to standard error only. By default LightCraft's own crates log at `info` and everything else +at `warn`. `LIGHTCRAFT_LOG=info` or `debug` works as before (that level for LightCraft's own crates, warnings and +errors from the rest; any other value: warnings and errors only) and wins over `RUST_LOG`, which otherwise replaces +the default with env_logger-style directives such as `RUST_LOG=debug` or `RUST_LOG=warn,lightcraft_pipeline=trace` (a +directive ending in `*` covers every target starting with it, as in `lightcraft*=debug`). Panics are recorded there +too, and still in `lightcraft-panics.log` in the temp folder. The logger is `apps/lightcraft/src/logging.rs`. + The web build needs the `wasm32-unknown-unknown` target and the matching `wasm-bindgen` CLI (`cargo xtask web` prints the exact install command); see [docs/web.md](docs/web.md). @@ -414,6 +429,53 @@ Questions, ideas or a bug you'd like to talk through first? Bring them to [Disco
+## Downloads + +**New to LightCraft?** Download it from the [LightCraft page on getartcraft.com](https://getartcraft.com/apps/lightcraft). That's the easiest way to install it. + +**Want a specific build or format?** On GitHub, the [latest release](https://github.com/storytold/lightcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/lightcraft/releases) has earlier versions and their notes. `` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file. + +### Windows + +| Build | Installer | Portable | +|---|---|---| +| x64 (64-bit Intel/AMD) | `lightcraft--windows-x64.msi` | `lightcraft--windows-x64-portable.zip` | +| arm64 (Snapdragon and other ARM PCs) | `lightcraft--windows-arm64.msi` | `lightcraft--windows-arm64-portable.zip` | +| x86 (32-bit) | `lightcraft--windows-x86.msi` | `lightcraft--windows-x86-portable.zip` | + +Installers and executables are code-signed. + +### macOS + +| Build | File | Notes | +|---|---|---| +| App, universal (Apple silicon + Intel) | `lightcraft--macos-universal.dmg` | Signed and notarized | +| Command-line tool, universal | `lightcraft-cli--macos-universal.zip` | Signed and notarized | + +### Linux + +| 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) | +| 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` | | +| Tarball | `lightcraft--linux-x86_64.tar.gz` | `lightcraft--linux-aarch64.tar.gz` | Unpack anywhere | + +### FreeBSD + +| Build | File | +|---|---| +| x86_64 | `lightcraft--freebsd-x86_64.tar.gz` | + +### Web (WebAssembly) + +| Build | File | Notes | +|---|---|---| +| Static site | `lightcraft-web-.zip` | Runs in a modern browser; host it on any static server | + +
+ ## The Crafting Apps LightCraft is one of the **Crafting Apps**: free, open-source creative tools from the diff --git a/ROADMAP.md b/ROADMAP.md index aeb07567b..5e793e8be 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,11 +6,11 @@ projects (DrawCraft reached its first four milestones in ≈ 4½ h) and are revi ## Where we stand -*Honest assessment, 2026-10-05. Agents: read this before picking work. The checklist in +*Honest assessment, 2026-10-08. Agents: read this before picking work. The checklist in [`docs/parity.md`](docs/parity.md) counts features that **exist**; this section is about whether a photographer can **switch** from Lightroom. Update it when a gap below closes.* -**In one line:** the checklist says **79%** (P0 98.5%, P1 95.9%, P2 39%), but measured by whether a working +**In one line:** the checklist says **79.7%** (P0 98.2%, P1 95.6%, P2 41.6%), but measured by whether a working photographer could replace Lightroom without noticing, we are at roughly **60–70%**. The remaining gap is mostly **quality of results and camera coverage**, not missing buttons. @@ -22,14 +22,14 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78). | Dimension | Estimate | What's true today | Biggest gaps | |---|---:|---|---| -| **Feature checklist** | 79% | P0 core and P1 nearly complete: import (Add / Copy / Move, templates, devices), library, grid/loupe/compare/survey, every Edit slider, curves, colour grading, masking tools, crop/Upright, heal/clone, presets/profiles, versions/history, sync, export, menus, shortcuts | P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim | -| **RAW coverage** (formats people shoot) | ~55% | DNG (all kinds), CR2, ARW, NEF (uncompressed + Huffman lossless/lossy), uncompressed RAF/ORF, RW2 / RWL / Panasonic RAW (every raw format, checked on 178 files from 118 Panasonic and Leica bodies), PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed RAF/ORF, Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Per-model verification is thin (~55 corpus files vs >1,000 models) | -| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF and RW2 have a guarded per-file embedded-JPEG colour estimate (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) | +| **Feature checklist** | 79.7% | P0 core and P1 nearly complete: import (Add / Copy / Move, templates, devices), library, grid/loupe/compare/survey, every Edit slider, curves, colour grading, masking tools, crop/Upright, heal/clone, presets/profiles, versions/history, sync, export, menus, shortcuts | P0: full-resolution zoom detail; P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim | +| **RAW coverage** (formats people shoot) | ~55% | DNG (all kinds), CR2, CR3 lossless Bayer and version 0x100/0x200 C-RAW (M50/R100/R8 pixel-exact corpus), ARW, NEF (uncompressed + Huffman lossless/lossy), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans), uncompressed ORF, RW2 / RWL / Panasonic RAW (every raw format, checked on 178 files from 118 Panasonic and Leica bodies), PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed ORF, Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Per-model verification is thin (~55 corpus files vs >1,000 models) | +| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF, RW2, RAF, CR3, CR2 and PEF have guarded camera-JPEG colour estimates, with bundled ILCE-7M4, X-H2S and X-T4 profiles (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) | | **AI & computational** | ~15–20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics | Real segmentation masks (subject, sky, people, objects, landscape, depth), AI denoise, super resolution, lens blur, generative remove, faces/people, natural-language search. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) | | **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering | | **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic) | **Map view, Book, Print, Slideshow module, Web, publish services**: ~40 tracker rows ⬜ | | **HDR & video** | 0% | | HDR edit/display/export; video play/trim/edit/export | -| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; English, Simplified/Traditional Chinese and Japanese UI (see docs/localization.md); remaining technical errors; Traditional Chinese uses the Simplified Chinese font until craft-fonts has a TC face; accessibility partial; headless UI tests time out under machine load | +| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; English, Simplified/Traditional Chinese, Japanese, Brazilian Portuguese and German UI (see docs/localization.md); remaining technical errors; Traditional Chinese uses the Simplified Chinese font until craft-fonts has a TC face; accessibility partial; headless UI tests time out under machine load | ### By kind of user @@ -37,7 +37,8 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78). |---|---:|---| | JPEG / DNG shooter, single machine | ~85% | Fidelity polish, AI masks | | Nikon / Sony / Panasonic / older-Canon raw shooter | ~65% | Camera colour fidelity and coverage (ARW, NEF and RW2 preview estimates are only a starting point); Panasonic compacts and kit zooms also miss their embedded distortion correction | -| Canon CR3 / Fujifilm / Olympus shooter | ~35% | Their raws open as embedded previews only (CR3: the full-size JPEG with full metadata) | +| Fujifilm raw shooter | ~65% | Uncompressed and lossless/lossy compressed RAF decoded; camera colour calibration remains missing. Verified on 13 bodies, including 14/16-bit GFX; see `docs/raf-compression.md` | +| Canon CR3 / Olympus compressed-raw shooter | ~45% | CR3 lossless Bayer and version 0x100/0x200 C-RAW develop from sensor data (M50, R100, R8 verified pixel for pixel); other CRX variants and compressed ORF open as embedded JPEG previews. Camera colour and broader model verification remain | | Lightroom Classic power user | ~45% | Print, Book, Map, publish, tethering | | Relies on AI (masks, denoise) | ~25% | Object / Describe masks via optional SAM 3 (download not yet hosted); no AI denoise | @@ -46,9 +47,10 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78). Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/parity.md) → *Top gaps*. 1. **Camera colour calibration of our own** (LR-PROF-CAMERACOLOR, P0): fit each camera to its own embedded JPEG, use - matrices the files carry themselves, then chart shots. Sony ARW, Nikon NEF and Panasonic RW2 have the file-local fit - (matrix + tone curve from their own JPEG); generalise it to the other makes' raws (PEF, ORF…), then validate fidelity. -2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): **CR3** first, then compressed RAF / ORF, NEF + matrices the files carry themselves, then chart shots. Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2 + and Pentax PEF have the file-local fit (matrix + tone curve from their own JPEG; CR2 and PEF since issue #310, not yet + checked on the corpus); then ORF, then validate fidelity. +2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): Remaining **unverified CR3 CRX** variants, compressed ORF, NEF lossy-after-split, sRAW. Decided 2026-10-05: write our own decoders from prose descriptions (never decoder source, no LGPL dependency); compressed NEF (#86) is the template. 3. **Verified camera coverage** (LR-IMP-CAMERA-COVERAGE, P0): a CC0 sample per model in the corpus, each decoded and @@ -67,6 +69,10 @@ Already closed in the week of 2026-10-03: compressed NEF (#10), Move import (#29 Local roots / cleanup, grid and catalog performance at 85k photos (#35, #37), failed-save reporting, GPU export hardening (#78), copyright metadata (#51), GPX geotagging (#60), import tag help and folder templates (#31, #32). +Image navigation now supports native pinch zoom and two-finger pan in Detail, Compare and Reference views. +The whole-image preview still has a configurable size cap (2,560 px default), so 100% on larger photos does not +yet guarantee original-pixel detail; visible-region rendering remains a gap (LR-VIEW-ZOOM). + ## Milestones **Status legend:** ✅ done · 🚧 in progress · ⬜ not started @@ -84,24 +90,24 @@ hardening (#78), copyright metadata (#51), GPX geotagging (#60), import tag help | M8 | Heal / Remove | content-aware remove (PatchMatch), heal, clone, brush spots, visualize spots, red/pet eye | 6–10 | 🚧 (heal, clone, auto source, visualize spots, red/pet eye ✅; PatchMatch remove ⬜) | | M9 | Presets, profiles, versions, sync | preset browser + amount, create/import presets, profile browser, versions, history, copy/paste/sync settings | 5–8 | ✅ | | M10 | Export & share | export dialog (JPEG/PNG/TIFF/DNG/AVIF/JXL/original), sizing, sharpening, metadata, watermark, naming, batch jobs, XMP sidecars, HDR export | 6–10 | 🚧 (all formats incl. DNG/original, sizing, presets, background jobs ✅; JXL encode, HDR export ⬜) | -| 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 uncompressed, RW2 (every raw format), PEF, ORF uncompressed ✅; **camera colour calibration** 🚧 (guarded ARW, NEF and RW2 preview fitting; measured database still missing), CR3, compressed ORF/RAF ⬜) | +| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (CR3 lossless CRX and C-RAW, RAF uncompressed and lossless/lossy compressed, RW2 (every raw format), PEF, ORF uncompressed ✅; **camera colour calibration** 🚧 (guarded ARW, NEF, RW2 and RAF preview fitting; measured database still missing), CR3, compressed ORF ⬜) | | M12 | AI & smart features | subject/sky/background/people/object masks, semantic search, faces/People (permissively licensed models, pure-Rust inference) | 20–40 | ⬜ | | M13 | Merge | HDR merge (deghost), panorama (projections, boundary warp, fill edges), HDR panorama | 10–15 | ✅ | | M14 | Video | import/playback/trim via FilmCraft crates, global edits + presets on video, video export | 6–10 | ⬜ | | M15 | Classic modules | Map, Book, Slideshow, Print, Web; smart collections, stacks, virtual copies, publish services, tethering | 25–40 | 🚧 (smart albums, stacks, virtual copies, compare/survey ✅; Map/Book/Slideshow/Print/Web ⬜) | -| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; English/Chinese (Simplified, Traditional)/Japanese 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/German localisation 🟡; accessibility and further locales ⬜) | -## Parity estimate (feature count updated 2026-10-05; effort estimate from 2026-10-02) +## Parity estimate (feature count updated 2026-10-08; effort estimate from 2026-10-02) **By feature count** — from `docs/parity.md` (one row per Lightroom feature, menu item and shortcut; `cargo xtask parity` prints this line on every run, so it stays current): | Scope | Weighted completion | Rows | 2026-10-02 | |---|---:|---:|---:| -| P0 (core) | **98.5%** | 200 | 98.7% | -| P1 (important parity) | **95.9%** | 147 | 94.4% | -| P2 (later / AI / niche, incl. Classic modules) | **39.2%** | 158 | 29.4% | -| **All in-scope rows** | **79.2%** | 505 | 75.6% | +| P0 (core) | **98.2%** | 200 | 98.7% | +| P1 (important parity) | **95.6%** | 149 | 94.4% | +| P2 (later / AI / niche, incl. Classic modules) | **41.6%** | 160 | 29.4% | +| **All in-scope rows** | **79.7%** | 509 | 75.6% | The 2026-10-05 count includes three new 🟡 rows that make quality gaps visible (camera colour calibration, verified camera coverage, render fidelity), which is why P0 dipped slightly. @@ -118,7 +124,7 @@ trip, slideshow, auto import…) in ≈ 12 h on 2026-10-02; four to six parallel | Work package | Tracker rows | Agent-hours | Risk | |---|---|---:|---| | Remaining P0/P1 UI and library features (folder rename/move, keyword painter, people view…) | ≈ 8 | 5–10 | low | -| Raw codecs: CR3 (CRX), compressed ORF / RAF, NEF lossy-after-split, HEIC/AVIF decode, JPEG XL DNG | LR-IMP-FORMATS | 40–80 | **high** — clean-room black-box analysis, no permissive specs | +| Raw codecs: CR3 (CRX), compressed ORF, NEF lossy-after-split, HEIC/AVIF decode, JPEG XL DNG | LR-IMP-FORMATS | 40–80 | **high** — clean-room black-box analysis, no permissive specs | | Lens-profile database of our own (calibration targets, fitting, data) | LR-EDIT-OPTICS-PROFILE | 15–30 | data collection | | Video: playback, trim, edits, export (pure-Rust decode, ideally shared with FilmCraft) | R. Video | 20–40 | medium | | AI: subject / sky / background / people / object masks, object-aware remove, AI denoise, super resolution, lens blur, people & faces, natural-language search, culling | ≈ 30 | 80–150 | **high** — permissively licensed weights, pure-Rust inference, maybe training | @@ -165,31 +171,45 @@ The milestone estimates in the table above were made before work started and are Sony ILCE-7M4 downsized lossless ARW (subsampled YCbCr 4:2:0 / 4:2:2 tiles) now decodes to linear RGB; private 3:2 & 4:3 examples verified through native rendering & full-resolution 16-bit export. +CR3 lossless Bayer and version 0x100/0x200 C-RAW sensor decoding is verified pixel-exactly on Canon M50, R100 and R8; any other CR3, or one that fails to decode, opens from its embedded JPEG as before. See [`docs/cr3.md`](docs/cr3.md) for coding limits, reference provenance and colour limitations. + Decoded (CC0 corpus from raw.pixls.us, `cargo xtask corpus --download`, `crates/raw/tests/corpus.rs`): DNG (uncompressed, LJ92, lossy JPEG / Smart Previews, Deflate, float, linear), CR2, ARW (uncompressed, ARW2, LJ92; as-shot white balance and black level of pre-2017 bodies from the enciphered -maker-note `Tag2010` and the encrypted `SR2SubIFD`, both recovered by black-box analysis, `crates/raw/src/vendor/arw.rs`), NEF/NRW uncompressed and Huffman-compressed (lossless, lossy type 1/2, 12/14-bit), RAF uncompressed (Bayer and -X-Trans), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed formats 4 and 6, the prefix-coded strips of format 8, +maker-note `Tag2010` and the encrypted `SR2SubIFD`, both recovered by black-box analysis, `crates/raw/src/vendor/arw.rs`), NEF/NRW uncompressed and Huffman-compressed (lossless, lossy type 1/2, 12/14-bit), RAF uncompressed and lossless/lossy compressed (Bayer and +X-Trans, 14/16-bit real-file verification), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed formats 4 and 6, the prefix-coded strips of format 8, packed formats 2, 5 and 7, the 16-bit words of the 2005–2007 bodies; all recovered by black-box analysis of 178 CC0 files from -118 bodies, `crates/raw/src/vendor/rw2.rs`), PEF (uncompressed and Huffman), ORF uncompressed (16-bit and 12-bit packed). Every -supported container also yields its embedded JPEG preview (CR3 too), and the engine shows that preview for raw variants -it can't decode yet. +118 bodies, `crates/raw/src/vendor/rw2.rs`), PEF (uncompressed and Huffman), ORF uncompressed (16-bit and 12-bit packed). Supported containers yield their embedded JPEG preview when present (including CR3). The engine uses it for RAW variants it cannot decode yet; HEVC-only CR3 previews are not decoded. Not decoded yet — preview only (no permissively licensed description; black-box analysis incomplete): - **Nikon "lossy after split" NEF** (non-zero split row in maker note `0x0096`, e.g. some D3400/D5000/D5200/D5500/D5600 files): rows above the split decode with the regular lossy table; from the split row on a different code is used that our black-box analysis has not recovered yet (none of the four regular tables fits, also not with byte alignment or reset predictors at the split row). The other Nikon Huffman variants are decoded (`crates/raw/src/vendor/nefc.rs` documents the analysis). -- **Olympus compressed ORF**, **Fujifilm compressed RAF**, **Canon CR3/CRX** (M11.1), **Canon sRAW/mRAW**, lossy DNG. +- **Olympus compressed ORF**, **Canon CR3 unverified CRX variants** (M11.1), **Canon sRAW/mRAW**, lossy DNG. -**Camera colour matrices:** ARW, NEF and RW2 files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged +**Camera colour matrices:** ARW, NEF, RW2, RAF, CR3, CR2 and PEF files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged `matrix_is_fallback`) with the file's as-shot white-balance multipliers. Clean sources to evaluate next: manufacturer matrices stored in the files themselves (Olympus ImageProcessing `ColorMatrix`, Pentax equivalents; Panasonic RW2 files carry none) and our own chart-based calibration (M11.4). Adobe matrices are never used. +## Lightroom Classic catalog migration + +File → Import Lightroom Catalog… reads `.lrcat` plus committed WAL pages directly through a pure-Rust +SQLite reader. Originals stay in place; ratings, flags, labels, XMP metadata, hierarchical keywords, +collections/sets, virtual copies and supported develop settings migrate into LightCraft. Existing edits +are preserved by default, reimport identities are persistent, and source settings/history/snapshots are +archived before catalog mutation. Missing originals remain available for relinking. + +Rendering is approximate: unsupported Adobe profiles/AI/process settings are reported and archived; +history/snapshots remain source data, and smart collections import current membership. The Lightroom +database is read-only; LightCraft owns subsequent edits. See `docs/lightroom-catalog-import.md`. + ## Log - 2026-09-30: roadmap created; M0 in progress; research docs (Lightroom reference, Rust imaging ecosystem) complete. - 2026-09-30 (later): app running with the full Lightroom-style UI; pipeline v0; DNG/CR2/ARW; README showcase. ≈ 8 h elapsed. - 2026-10-02: parity estimate added (xtask parity prints weighted completion); milestone statuses refreshed. +- 2026-10-08: Fujifilm camera colour: guarded JPEG matching and relative WB, plus bundled X-H2S/X-T4 profiles from 201/545 photos. On 150 withheld files the pooled profiles improve mean ΔE slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); dark X-H2S samples 8.74 → 2.25. Measured calibration remains open. +- 2026-10-08: Fujifilm lossless/lossy compressed RAF: pure-Rust striped decoder; full sensor arrays verified against a black-box reference on 17 CC0 files and 7 supplied X-H2S / X-T4 files (13 camera bodies, 14/16-bit Bayer and X-Trans). Camera colour calibration remains a separate gap. - 2026-10-01: RAW II formats: RAF (uncompressed Bayer + X-Trans), RW2 (packed), PEF (incl. Huffman), ORF (uncompressed); embedded previews for every container incl. CR3; raw corpus test with 37 CC0 samples. - 2026-10-03 – 10-05: community PRs (#42–#51, #60) and 25+ issues landed: compressed NEF, Move import, folder templates, Local roots and cleanup, 85k-photo grid and catalog performance, background compaction, failed-save errors, GPU @@ -198,6 +218,7 @@ own chart-based calibration (M11.4). Adobe matrices are never used. - 2026-10-07: Panasonic RW2 / Leica RWL / Panasonic RAW decode in every raw format (compressed 4/6/8, packed 2/5/7, 16-bit words), black levels, in-camera aspect crops and mapped-out defects established on 178 CC0 files from 118 bodies; RW2 gets the file-local camera look (accepted on 140 of 174 files with a preview). +- 2026-10-08: independent Rust CR3 lossless and version 0x100/0x200 C-RAW decoding, six exact full-sensor regressions on M50/R100/R8; any CR3 decode error falls back to the embedded JPEG; guarded CR3 camera-JPEG colour fitting with independent framing validation (`docs/cr3.md`). Unverified CRX variants and broader model verification remain. ## Chinese and Japanese interfaces, and text watermarks diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index b9e2641c9..6c5e6e442 100644 --- a/apps/lightcraft-cli/src/main.rs +++ b/apps/lightcraft-cli/src/main.rs @@ -71,6 +71,8 @@ USAGE: per line: {\"method\": \"ui.set\", \"params\": {\"view\": \"detail\"}}. Replies go to stdout. `ui.screenshot` without a path writes -o (then OUT-2.png, OUT-3.png…); `ui.settle {timeoutMs?}` waits for renders. + A failed request (\"ok\": false) does not stop the script, but the exit + status is non-zero when any request failed. -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) @@ -87,7 +89,7 @@ USAGE: lightcraft-cli controls [--json] list every develop control id with its range lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS/FILES… Fit a colour profile per camera model from raw files and their embedded camera JPEGs - (Sony ARW, Nikon NEF): up to N files spread over the folders (default 300; 0 = all), pooled per + (Sony ARW, Nikon NEF, Fujifilm RAF): up to N files spread over the folders (default 300; 0 = all), pooled per model, written as .json to DIR (default: the profiles folder LightCraft reads, /camera-profiles, or $LIGHTCRAFT_CAMERA_PROFILES). Raws of a profiled model then take their colour from the profile and only their tone from their own JPEG. @@ -100,6 +102,14 @@ static ALLOC: dhat::Alloc = dhat::Alloc; /// Why `--library DIR` can't be opened; for a library open in another program, how to work with /// that one instead (issue #99). +/// Print the open library's warnings (an unlockable library, damaged settings files) on stderr: +/// stdout carries command results / MCP protocol messages only. +fn library_warnings(session: &mut Session, who: &str) { + for w in session.take_library_warnings() { + eprintln!("{who}: warning: {w}"); + } +} + fn library_error(dir: &str, e: lightcraft_engine::EngineError) -> String { match e { lightcraft_engine::EngineError::LibraryInUse(why) => format!( @@ -122,6 +132,9 @@ fn main() -> ExitCode { dhat::Profiler::builder().file_name(file).build() }; alloc_release::install(); + // Warnings (a GPU render redone on the CPU, an unknown backend name) on stderr; LIGHTCRAFT_LOG + // or RUST_LOG picks another level (#168). + lightcraft_engine::logging::install("lightcraft-cli"); let args: Vec = std::env::args().skip(1).collect(); let r = match args.first().map(String::as_str) { Some("run") => run(&args[1..]), @@ -154,7 +167,8 @@ fn main() -> ExitCode { /// Raw files below `path` (or `path` itself), skipping hidden and NAS metadata folders. fn raw_files(path: &Path, out: &mut Vec, depth: usize) { - let is_raw = |p: &Path| p.extension().and_then(|e| e.to_str()).is_some_and(|e| ["arw", "nef", "nrw"].iter().any(|x| e.eq_ignore_ascii_case(x))); + let is_raw = + |p: &Path| p.extension().and_then(|e| e.to_str()).is_some_and(|e| ["arw", "nef", "nrw", "raf"].iter().any(|x| e.eq_ignore_ascii_case(x))); if path.is_file() { if is_raw(path) { out.push(path.to_path_buf()); @@ -311,6 +325,7 @@ fn mcp(args: &[String]) -> Result<(), String> { let mut h = Headless::default(); let r = h.session.open_library(dir, demo).map_err(|e| library_error(dir, e))?; eprintln!("lightcraft-cli mcp: opened library {dir} ({r:?})"); + library_warnings(&mut h.session, "lightcraft-cli mcp"); h } None if demo => Headless::demo(), @@ -547,6 +562,7 @@ fn run(args: &[String]) -> Result<(), String> { Some(dir) => { let mut h = Headless::default(); h.session.open_library(dir, demo).map_err(|e| library_error(dir, e))?; + library_warnings(&mut h.session, "lightcraft-cli"); h } None if demo => Headless::demo(), @@ -659,7 +675,7 @@ fn render(args: &[String]) -> Result<(), String> { for (k, v) in opts { p[k] = v; } - let mut o = ExportOptions::from_json(&p); + let mut o = ExportOptions::from_params(&p).map_err(|e| e.to_string())?; if p.get("format").is_none() { let ext = Path::new(&output).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default(); o.format = ExportFormat::parse(&ext) @@ -755,6 +771,9 @@ fn snapshot(args: &[String]) -> Result<(), String> { Some(p.with_file_name(format!("{stem}-{shots}.{ext}")).to_string_lossy().to_string()) }; let mut wrote_output = false; + // Issue #166: a scripted request that fails still gets its reply printed and the run carries + // on (like `run --keep-going`), but the exit status reports it — CI judges by exit status. + let mut failed = 0usize; if let Some(script) = &script { let text = std::fs::read_to_string(script).map_err(|e| format!("{script}: {e}"))?; let mut out = std::io::stdout().lock(); @@ -787,6 +806,9 @@ fn snapshot(args: &[String]) -> Result<(), String> { } _ => h.request(&method, params, timeout), }; + if reply["ok"] != true { + failed += 1; + } if let Some(o) = reply.as_object_mut() { o.insert("id".into(), id); // wall time of the request (incl. the frames it ran), and since the start @@ -823,7 +845,7 @@ fn snapshot(args: &[String]) -> Result<(), String> { } } eprintln!("lightcraft-cli snapshot: done in {:.2} s ({} frames)", t0.elapsed().as_secs_f64(), h.frames()); - Ok(()) + if failed > 0 { Err(format!("{failed} scripted request(s) failed")) } else { Ok(()) } } fn commands(args: &[String]) -> Result<(), String> { diff --git a/apps/lightcraft-cli/tests/cli.rs b/apps/lightcraft-cli/tests/cli.rs index 7cd0710b0..d1b178e49 100644 --- a/apps/lightcraft-cli/tests/cli.rs +++ b/apps/lightcraft-cli/tests/cli.rs @@ -19,6 +19,19 @@ fn gradient_png(path: &std::path::Path) { std::fs::write(path, png).unwrap(); } +#[test] +fn calibrate_reads_raf_inputs_and_reports_bad_files() { + let input = tmp("invalid.RAF"); + std::fs::write(&input, b"FUJIFILMCCD-RAW").unwrap(); + let out = tmp("raf-profiles"); + let result = Command::new(BIN).args(["calibrate", "--out"]).arg(&out).arg(&input).output().unwrap(); + assert!(!result.status.success()); + let stderr = String::from_utf8_lossy(&result.stderr); + assert!(stderr.contains("1 of 1 raw files"), "RAF was silently ignored: {stderr}"); + assert!(stderr.contains("invalid.RAF") && stderr.contains("no profile written"), "{stderr}"); + assert!(!out.join("X-T4.json").exists(), "bad input must not produce a profile"); +} + fn mean(path: &std::path::Path) -> f64 { let d = lightcraft_codecs::decode(&std::fs::read(path).unwrap(), Default::default()).unwrap(); let img = d.to_srgb8(); @@ -154,10 +167,14 @@ fn snapshot_subcommand_renders_the_ui_headlessly() { std::fs::write( &script, format!( - "# comment\n{}\n{}\n{}\n{}\n", + "# comment\n{}\n{}\n{}\n{}\n{}\n{}\n", json!({"method": "ui.set", "params": {"view": "photoGrid"}}), + // (settled: under load a grid shot taken before its thumbnails is darker than the + // dimmed export dialog over them, and the comparison below fails) + json!({"method": "ui.settle", "params": {"timeoutMs": 20000}}), json!({"method": "ui.screenshot"}), json!({"method": "engine.execute", "params": {"command": "dialog.export"}}), + json!({"method": "ui.settle", "params": {"timeoutMs": 20000}}), json!({"method": "ui.screenshot", "params": {"path": b.to_str().unwrap()}}), ), ) @@ -168,7 +185,7 @@ fn snapshot_subcommand_renders_the_ui_headlessly() { .unwrap(); assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr)); let replies: Vec = String::from_utf8_lossy(&o.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect(); - assert_eq!(replies.len(), 4); + assert_eq!(replies.len(), 6); assert!(replies.iter().all(|r| r["ok"] == true), "{replies:?}"); for (p, dimmed) in [(&a, false), (&b, true)] { let d = lightcraft_codecs::decode(&std::fs::read(p).unwrap(), Default::default()).unwrap(); @@ -183,6 +200,39 @@ fn snapshot_subcommand_renders_the_ui_headlessly() { assert_eq!((d.width, d.height), (640, 480)); } +/// Issue #166: a scripted request that fails makes `snapshot` exit non-zero. The failed reply is +/// still printed, the later lines still run, and the final `-o` screenshot is still written. +#[test] +fn snapshot_script_failure_exits_non_zero() { + let script = tmp("snap-fail.jsonl"); + let out = tmp("snap-fail.png"); + std::fs::write( + &script, + format!( + "{}\n{}\n", + json!({"method": "ui.set", "params": {"view": "grid"}}), // not a view: `photoGrid`, `detail`, … + json!({"method": "ui.set", "params": {"view": "photoGrid"}}), + ), + ) + .unwrap(); + let _ = std::fs::remove_file(&out); + 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(), "a failed scripted request must fail the run\n{stderr}"); + assert!(stderr.contains("1 scripted request(s) failed"), "{stderr}"); + let replies: Vec = String::from_utf8_lossy(&o.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + assert_eq!(replies.len(), 2, "{replies:?}"); + assert_eq!(replies[0]["ok"], false, "{replies:?}"); + assert!(replies[0]["error"].as_str().unwrap_or("").contains("grid"), "{replies:?}"); + assert_eq!(replies[1]["ok"], true, "{replies:?}"); + // continue-and-report: the final screenshot is still written + let d = lightcraft_codecs::decode(&std::fs::read(&out).unwrap(), Default::default()).unwrap(); + assert_eq!((d.width, d.height), (320, 200)); +} + /// Issue #136: with the GPU switched off from the environment the UI starts and renders on the CPU, /// without creating a GPU device (no driver is loaded), and says why. #[test] @@ -336,3 +386,29 @@ fn connect_failure_hint_names_the_attempted_port() { assert!(err.contains("--control 18437"), "the hint must name the port it tried: {err}"); assert!(!err.contains("--control 7980"), "{err}"); } + +/// The CLI logs warnings on stderr (#168): with `RUST_LOG=warn` an unknown GPU backend name is +/// reported, and `RUST_LOG=off` silences it. Before, no logger was installed and nothing appeared. +#[test] +fn warnings_are_logged_on_stderr() { + let input = tmp("log-in.png"); + gradient_png(&input); + let out = tmp("log-out.jpg"); + let run = |level: &str| { + let o = Command::new(BIN) + .args(["render", input.to_str().unwrap(), "-o", out.to_str().unwrap()]) + .env("LIGHTCRAFT_GPU_BACKEND", "bogus") + .env("RUST_LOG", level) + .env_remove("LIGHTCRAFT_LOG") + .env_remove("LIGHTCRAFT_GPU") + .output() + .unwrap(); + let stderr = String::from_utf8_lossy(&o.stderr).into_owned(); + assert!(o.status.success(), "{stderr}"); + stderr + }; + let warn = run("warn"); + assert!(warn.contains("LIGHTCRAFT_GPU_BACKEND=bogus names no known backend"), "{warn}"); + let off = run("off"); + assert!(!off.contains("names no known backend"), "{off}"); +} diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index 2efabebb5..9ffbf170f 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -168,6 +168,7 @@ fn services(originals: Originals, backend: Option, files: Files, frozen let (backup_backend, backup_files) = (backend.clone(), files); let restore_backend = backend.clone(); Services { + picker: None, backup_library: Some(Box::new(move |session: &mut Session| { let Some(b) = backup_backend.clone() else { return Err("nothing is stored in this browser session (?store=memory)".into()) }; // the photos' own names for the originals in the zip @@ -207,6 +208,7 @@ fn services(originals: Originals, backend: Option, files: Files, frozen // Preset files: browser pickers are asynchronous; not wired on the web yet. pick_preset_files: None, pick_tracklog: None, + pick_lightroom_catalog: None, save_preset_file: None, pick_curve_preset_files: None, save_curve_preset_file: None, @@ -217,6 +219,7 @@ fn services(originals: Originals, backend: Option, files: Files, frozen lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default() })), reveal: None, + log_file: None, open_with: None, open_url: Some(Box::new(|url: &str| { let w = web_sys::window().ok_or("no window")?; @@ -429,8 +432,17 @@ impl WebApp { cores.saturating_sub(1).clamp(1, 4) }); let cache = app.session.media.rendered.clone(); - let workers = - (n > 0).then(|| Workers::start(n, backend.as_ref().map_or("memory", |b| b.kind()), backend.clone(), index, &cache, cc.egui_ctx.clone())); + let workers = (n > 0).then(|| { + Workers::start( + n, + backend.as_ref().map_or("memory", |b| b.kind()), + backend.clone(), + index, + &cache, + app.session.cache_bytes(), + cc.egui_ctx.clone(), + ) + }); if let Some(w) = &workers { app.renderer.set_offload(Box::new(w.clone())); } @@ -601,11 +613,16 @@ impl eframe::App for WebApp { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { for text in safety::take_notices() { let now = ctx.input(|i| i.time); - self.app.ui.toast = Some((text, now + NOTICE_SECS)); + self.app.ui.toast = Some((text, now + NOTICE_SECS, None)); } self.run_inbox(); self.import_dropped(ctx); self.load_originals(ctx); + if let Some(w) = &self.workers { + // `library.preferences ▸ cacheMb` is read by the session; the worker index prunes on + // its own thread, so it gets the budget every frame and applies it on the next store. + w.set_budget(self.app.session.cache_bytes()); + } self.app.logic(ctx); self.save(); if let Some(b) = self.bench.as_mut() diff --git a/apps/lightcraft-web/src/wire.rs b/apps/lightcraft-web/src/wire.rs index 0474e2a08..81a76e6c9 100644 --- a/apps/lightcraft-web/src/wire.rs +++ b/apps/lightcraft-web/src/wire.rs @@ -53,6 +53,9 @@ pub struct WireJob { /// The request's diagnostic overlay (`Overlay::to_parts`). #[serde(default)] pub overlay: (u8, f64), + /// Render only this window `[x, y, w, h]` of the output `max_w × max_h` describes (a zoomed view). + #[serde(default)] + pub window: Option<[usize; 4]>, } impl WireJob { @@ -83,6 +86,7 @@ impl WireJob { thumb_cached, stages: job.stages.is_some().then(|| view.to_string()), overlay: job.request.overlay.to_parts(), + window: job.request.window.map(|w| [w.x, w.y, w.w, w.h]), } } @@ -108,6 +112,7 @@ impl WireJob { space: lightcraft_engine::pipeline::OutputSpace::Srgb, depth: lightcraft_engine::pipeline::OutputDepth::U8, proof: None, + window: self.window.map(|[x, y, w, h]| lightcraft_engine::pipeline::PixelWindow { x, y, w, h }), } } @@ -348,6 +353,36 @@ mod tests { assert!(reopened.contains(&new)); } + // Issue #323: a worker given a zoomed view's window renders that window, not the whole frame + #[test] + fn a_window_job_crosses_the_wire_and_renders_the_same_window() { + use lightcraft_engine::pipeline::PixelWindow; + let mut s = Session::with_demo(); + let id = s.visible_cloned()[0]; + let win = PixelWindow { x: 300, y: 200, w: 160, h: 120 }; + let job = s.region_job(id, 1920, 1280, win, true).unwrap(); + let wire = WireJob::from_job(&job, false, "Region"); + let wire: WireJob = serde_json::from_str(&serde_json::to_string(&wire).unwrap()).unwrap(); + assert_eq!(wire.request().window, Some(win)); + let mut core = WorkerCore::default(); + let a = core.render(&wire, None).unwrap(); + let b = job.run().rendered.unwrap(); + assert_eq!((a.image.width, a.image.height), (160, 120)); + assert_eq!(a.image.data, b.image.data); + } + + // a job from before windows existed has none + #[test] + fn a_job_without_a_window_field_has_no_window() { + let mut s = Session::with_demo(); + let id = s.visible_cloned()[0]; + let wire = WireJob::from_job(&s.thumb_job(id, 128).unwrap(), false, "Thumb"); + let mut v = serde_json::to_value(&wire).unwrap(); + v.as_object_mut().unwrap().remove("window"); + let old: WireJob = serde_json::from_value(v).unwrap(); + assert_eq!(old.request().window, None); + } + #[test] fn wire_job_renders_like_the_engine() { let mut s = Session::with_demo(); @@ -393,6 +428,7 @@ mod tests { thumb_cached: false, stages: Some("Main".into()), overlay: (0, 0.0), + window: None, }; let mut core = WorkerCore::default(); assert_eq!(core.needs_original(&job).as_deref(), Some(hash.as_str())); diff --git a/apps/lightcraft-web/src/workers.rs b/apps/lightcraft-web/src/workers.rs index a90ed61e5..52a323289 100644 --- a/apps/lightcraft-web/src/workers.rs +++ b/apps/lightcraft-web/src/workers.rs @@ -18,7 +18,7 @@ use std::sync::Arc; use js_sys::{Array, Object, Reflect, Uint8Array, Uint32Array}; use lightcraft_engine::catalog::PhotoId; -use lightcraft_engine::media::{DISK_CACHE_BYTES, RenderJob, RenderResult, SourceLevel}; +use lightcraft_engine::media::{RenderJob, RenderResult, SourceLevel}; use lightcraft_engine::pipeline::Rendered; use lightcraft_preview::{Hash128, PreviewCache}; use lightcraft_raster::{Histogram, Rgba8}; @@ -183,6 +183,9 @@ struct Inner { /// (photo, level) → worker that last decoded it. affinity: HashMap<(PhotoId, SourceLevel), usize>, index: ThumbIndex, + /// The session's thumbnail cache budget in bytes. Kept here because the prune happens on a + /// worker message, where the session is out of reach; the app refreshes it every frame. + budget: u64, backend: Option, ctx: egui::Context, /// Jobs finished by workers / inline (for diagnostics). @@ -214,7 +217,15 @@ impl Workers { /// Start `n` workers (none: every job runs inline). `store` is the backend kind for workers. /// `index` lists the stored thumbnails of `cache`, the session's rendered-thumbnail cache /// now: watching it from the start, a clear before the first render request still counts. - pub fn start(n: usize, store: &str, backend: Option, index: ThumbIndex, cache: &Arc, ctx: egui::Context) -> Workers { + pub fn start( + n: usize, + store: &str, + backend: Option, + index: ThumbIndex, + cache: &Arc, + budget: u64, + ctx: egui::Context, + ) -> Workers { let w = Workers(Rc::new(RefCell::new(Inner { preview_generation: CacheWatch::new(cache), workers: Vec::new(), @@ -222,6 +233,7 @@ impl Workers { next_id: 0, affinity: HashMap::new(), index, + budget, backend, ctx, remote_done: 0, @@ -328,7 +340,10 @@ impl Workers { } if stored > 0 { g.index.insert(&hex, stored); - let gone = g.index.prune(DISK_CACHE_BYTES); + // The session's own budget (`library.preferences ▸ cacheMb`), not the native + // default: the browser accepted the setting, so it has to be the one enforced + // (#245). + let gone = g.index.prune(g.budget); if !gone.is_empty() && let Some(be) = g.backend.clone() { @@ -386,6 +401,12 @@ impl Workers { let ready = g.workers.iter().filter(|w| w.ready && !w.dead).count(); (alive, ready, g.remote_done, g.inline_done) } + + /// The budget the thumbnail index is pruned to. The app refreshes it from the session every + /// frame, so `library.preferences ▸ cacheMb` applies to the next stored thumbnail. + pub fn set_budget(&self, bytes: u64) { + self.0.borrow_mut().budget = bytes; + } } impl RenderOffload for Workers { diff --git a/apps/lightcraft/src/control_server.rs b/apps/lightcraft/src/control_server.rs index ff16be2f0..16bd5df0f 100644 --- a/apps/lightcraft/src/control_server.rs +++ b/apps/lightcraft/src/control_server.rs @@ -28,11 +28,11 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver { let listener = match TcpListener::bind(("127.0.0.1", port)) { Ok(l) => l, Err(e) => { - eprintln!("lightcraft: control server failed to bind 127.0.0.1:{port}: {e}"); + log::error!("control server failed to bind 127.0.0.1:{port}: {e}"); return rx; } }; - eprintln!("lightcraft: control server listening on 127.0.0.1:{port}"); + log::info!("control server listening on 127.0.0.1:{port}"); std::thread::spawn(move || accept_loop(listener, tx, ctx)); rx } diff --git a/apps/lightcraft/src/dialog_filter.rs b/apps/lightcraft/src/dialog_filter.rs new file mode 100644 index 000000000..87760f799 --- /dev/null +++ b/apps/lightcraft/src/dialog_filter.rs @@ -0,0 +1,145 @@ +//! File-dialog filters that match extensions whatever their letter case. +//! +//! On Linux, `rfd` turns each extension into a `*.ext` glob for the GTK dialog and for the +//! desktop portal (`xdg-desktop-portal` FileChooser), and both match globs case-sensitively, so +//! a filter of `nef` hid `DSC_1234.NEF` (issue #342). Windows and macOS ignore case. Every +//! dialog filter of the app goes through [`FileDialogExt::add_filter_nocase`] (a test checks +//! that nothing else calls `add_filter`), so no extension can hit this. + +use rfd::FileDialog; + +/// The patterns to hand to `rfd` for `exts`, which are plain extensions without the dot. +/// +/// Windows and macOS take the extension literally and ignore case: lower case, de-duplicated. +/// Linux (`linux`): `rfd` formats each entry as `*.{entry}`, which GTK 3 and the portal match +/// as a glob, so an entry may carry glob syntax. Each extension gives the lower-case form, the +/// UPPER-case form (kept in case a portal backend does not do `[...]` classes) and a bracket +/// form such as `[nN][eE][fF]` that matches every mix (`.Nef`, `.nEf`, ...). +pub fn dialog_extensions_for(exts: &[&str], linux: bool) -> Vec { + let mut out: Vec = Vec::new(); + let mut push = |s: String| { + if !s.is_empty() && !out.contains(&s) { + out.push(s); + } + }; + for ext in exts { + let ext = ext.trim_start_matches('.'); + let lower = ext.to_lowercase(); + push(lower.clone()); + if !linux { + continue; + } + push(ext.to_uppercase()); + // `[` `]` `*` `?` in an extension would already be glob syntax: leave those alone + if lower.chars().any(|c| c.is_alphabetic()) && !lower.contains(['[', ']', '*', '?', '\\']) { + push( + lower + .chars() + .map(|c| { + let up: String = c.to_uppercase().collect(); + if up.chars().count() == 1 && up != c.to_string() { format!("[{c}{up}]") } else { c.to_string() } + }) + .collect(), + ); + } + } + out +} + +/// [`dialog_extensions_for`] for the platform this is built for. +pub fn dialog_extensions(exts: &[&str]) -> Vec { + dialog_extensions_for(exts, cfg!(all(unix, not(target_os = "macos")))) +} + +/// `add_filter` that matches the extensions in any letter case. +pub trait FileDialogExt { + fn add_filter_nocase(self, name: impl Into, exts: &[&str]) -> Self; +} + +impl FileDialogExt for FileDialog { + fn add_filter_nocase(self, name: impl Into, exts: &[&str]) -> Self { + self.add_filter(name, &dialog_extensions(exts)) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn other_platforms_get_lower_case_once() { + assert_eq!(dialog_extensions_for(&["NEF", "nef", ".Jpg", "jpg", ""], false), ["nef", "jpg"]); + } + + #[test] + fn linux_gets_lower_upper_and_any_mix() { + assert_eq!(dialog_extensions_for(&["nef"], true), ["nef", "NEF", "[nN][eE][fF]"]); + // digits have no case; a duplicate adds nothing + assert_eq!(dialog_extensions_for(&["mp4", "MP4"], true), ["mp4", "MP4", "[mM][pP]4"]); + assert_eq!(dialog_extensions_for(&["7z"], true), ["7z", "7Z", "7[zZ]"]); + assert_eq!(dialog_extensions_for(&["123"], true), ["123"]); + } + + #[test] + fn linux_glob_syntax_is_not_wrapped_again() { + assert_eq!(dialog_extensions_for(&["[nN]ef"], true), ["[nn]ef", "[NN]EF"]); + } + + /// The Linux patterns match like GTK's glob (`*.{entry}`): checked against a tiny matcher of + /// the subset used here (verified against real GTK 3.24 `GtkFileFilter` in the PR notes). + fn glob_ext(entry: &str, ext: &str) -> bool { + let mut pat = entry.chars().peekable(); + let mut s = ext.chars(); + while let Some(p) = pat.next() { + let Some(c) = s.next() else { return false }; + if p == '[' { + let mut hit = false; + for q in pat.by_ref() { + if q == ']' { + break; + } + hit |= q == c; + } + if !hit { + return false; + } + } else if p != c { + return false; + } + } + s.next().is_none() + } + + #[test] + fn linux_patterns_match_every_case_mix() { + let pats = dialog_extensions_for(&["nef", "cr2", "xmp"], true); + let matches = |name: &str| pats.iter().any(|p| glob_ext(p, name)); + for ok in ["nef", "NEF", "Nef", "nEF", "CR2", "Cr2", "XMP", "xMp"] { + assert!(matches(ok), "{ok}"); + } + for bad in ["nex", "ne", "nefs", "jpg", "cr3"] { + assert!(!matches(bad), "{bad}"); + } + } + + /// The app's own extension list (the one the folder scan uses) is covered whole. + #[test] + fn import_extensions_are_all_covered_in_both_cases() { + let pats = dialog_extensions_for(lightcraft_engine::import::EXTENSIONS, true); + for e in lightcraft_engine::import::EXTENSIONS { + assert!(pats.iter().any(|p| glob_ext(p, &e.to_uppercase())), "{e}"); + assert!(pats.iter().any(|p| p == e), "{e}"); + } + } + + /// Every dialog filter goes through the helper: `main.rs` must not call rfd's own + /// `add_filter`. + #[test] + fn no_dialog_bypasses_the_helper() { + for (file, src) in [("main.rs", include_str!("main.rs")), ("control_server.rs", include_str!("control_server.rs"))] { + assert!(!src.contains(".add_filter("), "{file} calls rfd's add_filter; use add_filter_nocase"); + } + assert!(include_str!("main.rs").contains(".add_filter_nocase(")); + } +} diff --git a/apps/lightcraft/src/logging.rs b/apps/lightcraft/src/logging.rs new file mode 100644 index 000000000..718392f35 --- /dev/null +++ b/apps/lightcraft/src/logging.rs @@ -0,0 +1,689 @@ +//! The desktop app's logger: `log::` records go to standard error and to a log file. +//! +//! It replaces the stderr-only `StderrLog` that `main.rs` used to install. A launch from a +//! desktop menu or the Dock has no terminal, so the file is what a bug report can attach: +//! `/logs/lightcraft.log`, next to `ui.json` (`config_dir` in `main.rs`; Linux +//! `$XDG_CONFIG_HOME/lightcraft/logs`, by default `~/.config/lightcraft/logs`). Never in the +//! library folder. Each start moves the previous log to `lightcraft.1.log` (and that one to `.2`), +//! so the log of a run that crashed survives the next launch. Runs with `LIGHTCRAFT_NO_PREFS` +//! (tests, scripts) log to standard error only, so they don't rotate away the user's own logs. +//! +//! Levels ([`filter_spec`]): `info` for LightCraft's own crates, `warn` for everything else (wgpu +//! and naga are chatty). `LIGHTCRAFT_LOG` keeps the meaning it had with the old logger: `info` or +//! `debug` lowers LightCraft's own crates to that level, any other value means warnings and errors +//! only. Without it, `RUST_LOG` replaces the default with env_logger-style directives: `debug`, +//! `warn,lightcraft_pipeline=trace`, `wgpu_core=info`. A directive ending in `*` matches every +//! target that starts with it (`lightcraft*=debug`). +//! +//! Records logged before the settings folder is known are kept (up to [`MAX_PENDING`]) and +//! written once the file is attached. Writing never panics: a file that can't be created or +//! written leaves standard error as the only sink. Panics that escape everything are added to the +//! file by [`AppLogger::record_panic`] (the default hook already printed them on standard error). +//! +//! This is the desktop app's logger only; `lightcraft-cli` doesn't share a process with it. + +use std::fs::File; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock, PoisonError, TryLockError}; +use std::time::SystemTime; + +use log::LevelFilter; + +/// The current log file's name inside the log folder. +pub const LOG_FILE: &str = "lightcraft.log"; +/// How many previous logs are kept (`lightcraft.1.log` … `lightcraft..log`). +pub const KEEP: usize = 2; +/// The log file stops growing past this size (a runaway warning can't fill the disk). +pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024; +/// Records kept in memory until the log file is attached. +pub const MAX_PENDING: usize = 512; +/// The built-in filter when neither `LIGHTCRAFT_LOG` nor `RUST_LOG` says otherwise. +pub const DEFAULT_FILTER: &str = "warn,lightcraft*=info"; + +/// The filter directives for this run from `LIGHTCRAFT_LOG` and `RUST_LOG` (blank = unset). +/// +/// `LIGHTCRAFT_LOG` wins when set and means what it did for the old stderr logger: `info` or +/// `debug` for LightCraft's own crates (warnings and errors from everything else), any other value +/// warnings and errors only. Else `RUST_LOG` as env_logger directives, else [`DEFAULT_FILTER`]. +pub fn filter_spec(lightcraft_log: Option<&str>, rust_log: Option<&str>) -> String { + let set = |v: Option<&str>| v.map(str::trim).filter(|v| !v.is_empty()).map(str::to_owned); + if let Some(level) = set(lightcraft_log) { + return match level.as_str() { + "debug" => "warn,lightcraft*=debug".to_owned(), + "info" => "warn,lightcraft*=info".to_owned(), + _ => "warn".to_owned(), + }; + } + set(rust_log).unwrap_or_else(|| DEFAULT_FILTER.to_owned()) +} + +/// Per-target level filter parsed from env_logger-style directives. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Filter { + default: LevelFilter, + directives: Vec<(String, LevelFilter)>, +} + +impl Filter { + /// Parse `spec`; unknown levels and empty parts are skipped. Without a bare level, targets no + /// directive names log errors only. + pub fn parse(spec: &str) -> Filter { + let mut f = Filter { default: LevelFilter::Error, directives: Vec::new() }; + for part in spec.split(',').map(str::trim).filter(|p| !p.is_empty()) { + match part.split_once('=') { + Some((name, level)) => { + let name = name.trim(); + if let (false, Some(level)) = (name.is_empty(), parse_level(level)) { + f.directives.push((name.to_owned(), level)); + } + } + None => match parse_level(part) { + Some(level) => f.default = level, + // A bare target name: everything from it (env_logger does the same). + None => f.directives.push((part.to_owned(), LevelFilter::Trace)), + }, + } + } + f + } + + /// The level that applies to `target` (the most specific matching directive wins). + pub fn level_for(&self, target: &str) -> LevelFilter { + self.directives.iter().filter(|(name, _)| matches(name, target)).max_by_key(|(name, _)| name.len()).map_or(self.default, |(_, level)| *level) + } + + /// The most verbose level any target can reach (for `log::set_max_level`). + pub fn max(&self) -> LevelFilter { + self.directives.iter().map(|(_, level)| *level).fold(self.default, Ord::max) + } +} + +fn parse_level(s: &str) -> Option { + s.trim().parse().ok() +} + +/// `name` matches `target` itself and its submodules (`a` matches `a` and `a::b`, not `ab`); +/// `name*` matches every target starting with `name`. +fn matches(name: &str, target: &str) -> bool { + match name.strip_suffix('*') { + Some(prefix) => target.starts_with(prefix), + None => target.strip_prefix(name).is_some_and(|rest| rest.is_empty() || rest.starts_with("::")), + } +} + +/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch. +pub fn timestamp(t: SystemTime) -> String { + let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); + let secs = since.as_secs(); + let (days, rem) = (secs / 86_400, secs % 86_400); + let (y, m, d) = civil_from_days(days); + format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis()) +} + +/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's +/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). Saturating, so no clock +/// value can overflow it. +fn civil_from_days(days: u64) -> (u64, u64, u64) { + let z = days.saturating_add(719_468); + let era = z / 146_097; + let doe = z % 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2)); + (y, m, d) +} + +fn numbered(dir: &Path, n: usize) -> PathBuf { + if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("lightcraft.{n}.log")) } +} + +/// Shift the previous logs up one (`.log` → `.1.log` → … → `..log`, the oldest dropped) +/// and create a fresh, empty log file in `dir` (created if missing). +pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> { + std::fs::create_dir_all(dir)?; + for n in (1..=KEEP).rev() { + match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e), + _ => {} + } + } + let path = numbered(dir, 0); + let file = File::create(&path)?; + Ok((path, file)) +} + +/// One formatted log line: ` [] : \n`. +pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String { + let message = message.strip_suffix('\n').unwrap_or(message); + format!("{ts} {level:<5} [{thread}] {target}: {message}\n") +} + +/// Where formatted lines go besides standard error: kept in memory until a file is attached, +/// then appended to it until it reaches its size cap. +pub struct Sink { + file: Option, + pending: Vec, + dropped: usize, + written: u64, + max_bytes: u64, + capped: bool, +} + +impl Sink { + pub fn new(max_bytes: u64) -> Sink { + Sink { file: None, pending: Vec::new(), dropped: 0, written: 0, max_bytes, capped: false } + } + + /// Append `line` to the file, or keep it until [`Sink::attach`]. + pub fn write(&mut self, line: &str) { + if self.file.is_none() { + if self.capped { + // No file is coming ([`Sink::no_file`]): standard error had the line. + } else if self.pending.len() < MAX_PENDING { + self.pending.push(line.to_owned()); + } else { + self.dropped = self.dropped.saturating_add(1); + } + return; + } + self.append(line); + } + + /// Start writing to `file` (a fresh one: the size cap starts over), first the lines kept so far. + pub fn attach(&mut self, file: File) { + self.file = Some(file); + self.written = 0; + self.capped = false; + for line in std::mem::take(&mut self.pending) { + self.append(&line); + } + if self.dropped > 0 { + let note = format!("{} earlier log lines were dropped before the log file was opened\n", self.dropped); + self.dropped = 0; + self.append(¬e); + } + } + + /// There will be no log file: forget the lines kept for it and keep no more. + pub fn no_file(&mut self) { + self.file = None; + self.pending = Vec::new(); + self.dropped = 0; + self.capped = true; + } + + fn append(&mut self, line: &str) { + if self.capped { + return; + } + let Some(file) = self.file.as_mut() else { return }; + let len = u64::try_from(line.len()).unwrap_or(u64::MAX); + let text = if self.written.saturating_add(len) > self.max_bytes { + self.capped = true; + format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes) + } else { + self.written = self.written.saturating_add(len); + line.to_owned() + }; + // A full disk or a vanished file must not take the app down: drop the file sink. + if file.write_all(text.as_bytes()).is_err() { + self.file = None; + self.capped = true; + } + } +} + +/// The installed logger: a [`Filter`], standard error and a [`Sink`]. +pub struct AppLogger { + filter: Filter, + stderr: bool, + sink: Mutex, +} + +impl AppLogger { + pub fn new(filter: Filter, stderr: bool) -> AppLogger { + AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)) } + } + + /// Rotate the logs in `dir` and send records to the fresh file; returns its path. On an error + /// standard error stays the only sink. + pub fn attach_dir(&self, dir: &Path) -> Result { + // A thread that panicked while logging leaves the sink usable: take it back. + let mut sink = self.sink.lock().unwrap_or_else(PoisonError::into_inner); + match rotate(dir) { + Ok((path, file)) => { + sink.attach(file); + Ok(path) + } + Err(e) => { + sink.no_file(); + Err(format!("{}: {e}", dir.display())) + } + } + } + + /// No log file for this run (`LIGHTCRAFT_NO_PREFS`): log to standard error only. + pub fn no_file(&self) { + self.sink.lock().unwrap_or_else(PoisonError::into_inner).no_file(); + } + + /// Add a panic report to the log file only (the default panic hook has printed it on standard + /// error), whatever the filter. Skipped when the sink is busy: the panic may have happened on + /// this very thread while it held the lock, and waiting would hang the app. + pub fn record_panic(&self, thread: &str, report: &str) { + let line = format_line(×tamp(SystemTime::now()), log::Level::Error, thread, "lightcraft", report); + let mut sink = match self.sink.try_lock() { + Ok(sink) => sink, + Err(TryLockError::Poisoned(p)) => p.into_inner(), + Err(TryLockError::WouldBlock) => return, + }; + sink.write(&line); + } +} + +impl log::Log for AppLogger { + fn enabled(&self, metadata: &log::Metadata<'_>) -> bool { + metadata.level() <= self.filter.level_for(metadata.target()) + } + + fn log(&self, record: &log::Record<'_>) { + if !self.enabled(record.metadata()) { + return; + } + // Formatted before the sink is locked, so a message that logs while it is formatted can't deadlock. + let thread = std::thread::current(); + let line = + format_line(×tamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string()); + if self.stderr { + // No terminal (a Windows GUI build, a closed pipe) is not an error worth reporting. + let _ = std::io::stderr().write_all(line.as_bytes()); + } + self.sink.lock().unwrap_or_else(PoisonError::into_inner).write(&line); + } + + fn flush(&self) {} +} + +/// Install the logger (filter from [`filter_spec`]); `None` when another logger was installed +/// first. Call [`AppLogger::attach_dir`] (or [`AppLogger::no_file`]) once the arguments are parsed. +pub fn install() -> Option<&'static AppLogger> { + static LOGGER: OnceLock = OnceLock::new(); + let var = |name| std::env::var(name).ok(); + let filter = Filter::parse(&filter_spec(var("LIGHTCRAFT_LOG").as_deref(), var("RUST_LOG").as_deref())); + let max = filter.max(); + let logger = LOGGER.get_or_init(|| AppLogger::new(filter, true)); + log::set_logger(logger).ok()?; + log::set_max_level(max); + Some(logger) +} + +/// Chain a panic hook after the current one (the engine guard's: standard error plus +/// `lightcraft-panics.log` in the temp folder) that also puts the panic in the log file. +pub fn record_panics(logger: &'static AppLogger) { + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + previous(info); + let thread = std::thread::current(); + let at = info.location().map(|l| format!(" at {}:{}", l.file(), l.line())).unwrap_or_default(); + let report = format!("panic{at}: {}", lightcraft_engine::guard::panic_message(info.payload())); + logger.record_panic(thread.name().unwrap_or("?"), &report); + })); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::{Duration, UNIX_EPOCH}; + + fn temp_dir(tag: &str) -> PathBuf { + static N: AtomicUsize = AtomicUsize::new(0); + let d = std::env::temp_dir().join(format!("lightcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); + let _ = std::fs::remove_dir_all(&d); + d + } + + fn read(path: &Path) -> String { + std::fs::read_to_string(path).expect("read log") + } + + fn record(logger: &AppLogger, level: log::Level, target: &str, msg: &str) { + use log::Log; + logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build()); + } + + /// The logger this replaced (`StderrLog`): warnings and errors from every target, `info` and + /// `debug` only from LightCraft's own crates, and only when `LIGHTCRAFT_LOG` asked for them. + fn old_stderr_log_enabled(lightcraft_log: &str, level: log::Level, target: &str) -> bool { + let max = match lightcraft_log { + "debug" => LevelFilter::Debug, + "info" => LevelFilter::Info, + _ => LevelFilter::Warn, + }; + level <= max && (level <= log::Level::Warn || target.starts_with("lightcraft")) + } + + #[test] + fn the_default_filter_shows_lightcraft_info_and_other_crates_warnings() { + let f = Filter::parse(DEFAULT_FILTER); + assert_eq!(f.level_for("lightcraft"), LevelFilter::Info); + assert_eq!(f.level_for("lightcraft::control_server"), LevelFilter::Info); + assert_eq!(f.level_for("lightcraft_engine::guard"), LevelFilter::Info); + assert_eq!(f.level_for("lightcraft_ui_egui::render"), LevelFilter::Info); + assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn); + assert_eq!(f.level_for("naga"), LevelFilter::Warn); + assert_eq!(f.max(), LevelFilter::Info); + assert_eq!(filter_spec(None, None), DEFAULT_FILTER); + // An empty variable is the same as an unset one. + assert_eq!(filter_spec(Some(""), Some(" ")), DEFAULT_FILTER); + } + + /// `LIGHTCRAFT_LOG` keeps working exactly as it did with the old stderr-only logger. + #[test] + fn lightcraft_log_keeps_its_meaning() { + let targets = + ["lightcraft", "lightcraft::control_server", "lightcraft_engine::segment", "lightcraft_gpu", "wgpu_core::device", "naga", "eframe"]; + let levels = [log::Level::Error, log::Level::Warn, log::Level::Info, log::Level::Debug, log::Level::Trace]; + for value in ["info", "debug", "warn", "error", "verbose", "INFO"] { + let f = Filter::parse(&filter_spec(Some(value), None)); + for target in targets { + for level in levels { + assert_eq!(level <= f.level_for(target), old_stderr_log_enabled(value, level, target), "LIGHTCRAFT_LOG={value} {level} {target}"); + } + } + } + assert_eq!(Filter::parse(&filter_spec(Some("debug"), None)).max(), LevelFilter::Debug); + assert_eq!(Filter::parse(&filter_spec(Some("warn"), None)).max(), LevelFilter::Warn); + } + + #[test] + fn rust_log_replaces_the_default_and_lightcraft_log_wins_over_it() { + assert_eq!(filter_spec(None, Some("warn,wgpu_core=info")), "warn,wgpu_core=info"); + let f = Filter::parse(&filter_spec(None, Some("debug"))); + assert_eq!(f.level_for("eframe"), LevelFilter::Debug); + // The app's own variable is the more specific choice. + let f = Filter::parse(&filter_spec(Some("info"), Some("trace"))); + assert_eq!(f.level_for("eframe"), LevelFilter::Warn); + assert_eq!(f.level_for("lightcraft_engine"), LevelFilter::Info); + } + + #[test] + fn directives_follow_env_logger_and_the_most_specific_one_wins() { + let f = Filter::parse("info,wgpu_core=error,lightcraft_pipeline=trace,lightcraft_pipeline::tiles=off"); + assert_eq!(f.level_for("eframe"), LevelFilter::Info); + assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error); + assert_eq!(f.level_for("lightcraft_pipeline::stage"), LevelFilter::Trace); + assert_eq!(f.level_for("lightcraft_pipeline::tiles"), LevelFilter::Off); + assert_eq!(f.max(), LevelFilter::Trace); + // A module name is matched at `::` boundaries, not as a bare prefix. + assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info); + // A trailing `*` is a prefix. + assert_eq!(Filter::parse("lightcraft*=debug").level_for("lightcraft_raw::cr2"), LevelFilter::Debug); + assert_eq!(Filter::parse("lightcraft*=debug").level_for("eframe"), LevelFilter::Error); + // A bare target name sets that target to the most verbose level, as env_logger does. + assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace); + assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error); + // Levels are case-insensitive and surrounding blanks are ignored. + assert_eq!(Filter::parse(" WARN , lightcraft = Debug ").level_for("lightcraft"), LevelFilter::Debug); + assert_eq!(Filter::parse(" WARN , lightcraft = Debug ").level_for("eframe"), LevelFilter::Warn); + } + + #[test] + fn hostile_specs_never_panic_and_fall_back_sensibly() { + let long = "x".repeat(100_000); + let many = "a=info,".repeat(10_000); + for spec in [ + "", + ",,,", + "=", + "==", + "=debug", + "lightcraft=", + "nonsense=loud", + "🦀=info", + "*", + "*=", + "=*", + "a=b=c", + "warn,,lightcraft=DEBUG", + "\0", + "é::ü=trace", + &long, + &many, + ] { + let f = Filter::parse(spec); + for target in ["", "lightcraft", "🦀", "a::b", "*"] { + let _ = f.level_for(target); + } + let _ = f.max(); + let _ = filter_spec(Some(spec), Some(spec)); + } + assert_eq!(Filter::parse("warn,,lightcraft=DEBUG").level_for("lightcraft"), LevelFilter::Debug); + // An unknown level is skipped: that target keeps the default. + assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error); + assert_eq!(Filter::parse("lightcraft=").level_for("lightcraft"), LevelFilter::Error); + assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error); + assert_eq!(Filter::parse("🦀=info").level_for("🦀::claw"), LevelFilter::Info); + } + + #[test] + fn timestamps_are_utc_with_milliseconds() { + assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z"); + // Leap day, and the day after it. + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z"); + // 2000 is a leap year (divisible by 400), 2100 is not. + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z"); + // Before the epoch (a clock set wrong) clamps instead of panicking. + assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); + } + + #[test] + fn a_line_carries_time_level_thread_target_and_message() { + assert_eq!( + format_line("2026-10-08T07:59:17.728Z", log::Level::Info, "main", "lightcraft", "library /photos: 12 photos"), + "2026-10-08T07:59:17.728Z INFO [main] lightcraft: library /photos: 12 photos\n" + ); + // A multi-line message keeps its lines; the record still ends in one newline. + assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n")); + } + + #[test] + fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() { + let dir = temp_dir("rotate"); + for run in 1..=4 { + let (path, _file) = rotate(&dir).expect("rotate"); + assert_eq!(path, dir.join(LOG_FILE)); + assert_eq!(read(&path), ""); + std::fs::write(&path, format!("run {run}")).expect("write"); + } + assert_eq!(read(&dir.join(LOG_FILE)), "run 4"); + assert_eq!(read(&dir.join("lightcraft.1.log")), "run 3"); + assert_eq!(read(&dir.join("lightcraft.2.log")), "run 2"); + assert!(!dir.join("lightcraft.3.log").exists(), "only {KEEP} old logs are kept"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() { + let dir = temp_dir("blocked"); + std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp"); + std::fs::write(&dir, "a file where the directory should be").expect("block"); + assert!(rotate(&dir).is_err()); + let _ = std::fs::remove_file(&dir); + } + + #[test] + fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() { + let dir = temp_dir("pending"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(MAX_FILE_BYTES); + sink.write("early 1\n"); + sink.write("early 2\n"); + sink.attach(file); + sink.write("late\n"); + assert_eq!(read(&path), "early 1\nearly 2\nlate\n"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() { + let dir = temp_dir("bounded"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(MAX_FILE_BYTES); + for i in 0..MAX_PENDING + 5 { + sink.write(&format!("line {i}\n")); + } + sink.attach(file); + let text = read(&path); + assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING); + assert!(text.contains("5 earlier log lines were dropped"), "{text}"); + assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n"))); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn exactly_a_full_pending_buffer_drops_nothing() { + let dir = temp_dir("full"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(MAX_FILE_BYTES); + for i in 0..MAX_PENDING { + sink.write(&format!("line {i}\n")); + } + sink.attach(file); + let text = read(&path); + assert_eq!(text.lines().count(), MAX_PENDING); + assert!(!text.contains("dropped"), "{text}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn without_a_file_nothing_is_kept_in_memory() { + let mut sink = Sink::new(MAX_FILE_BYTES); + sink.write("early\n"); + sink.no_file(); + sink.write("late\n"); + assert!(sink.pending.is_empty() && sink.dropped == 0); + } + + #[test] + fn the_file_stops_at_its_size_cap_with_one_note() { + let dir = temp_dir("cap"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(100); + sink.attach(file); + for i in 0..50 { + sink.write(&format!("line {i:02} with some padding\n")); + } + let text = read(&path); + assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}"); + assert_eq!(text.matches("log file reached").count(), 1, "{text}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_logger_filters_records_and_writes_them_to_the_attached_file() { + use log::Log; + let dir = temp_dir("logger"); + let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false); + record(&logger, log::Level::Info, "lightcraft", "before the settings directory"); + record(&logger, log::Level::Info, "wgpu_core::device", "too chatty"); + let path = logger.attach_dir(&dir).expect("attach"); + assert_eq!(path, dir.join(LOG_FILE)); + record(&logger, log::Level::Warn, "wgpu_hal::vulkan", "a real warning"); + record(&logger, log::Level::Error, "lightcraft_engine::guard", "`develop.reset` failed unexpectedly: boom"); + record(&logger, log::Level::Debug, "lightcraft", "below info"); + let text = read(&path); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.len(), 3, "{text}"); + assert!(lines[0].contains(" INFO [") && lines[0].ends_with("lightcraft: before the settings directory"), "{text}"); + assert!(lines[1].ends_with("wgpu_hal::vulkan: a real warning"), "{text}"); + assert!(lines[2].contains(" ERROR [") && lines[2].ends_with("lightcraft_engine::guard: `develop.reset` failed unexpectedly: boom"), "{text}"); + assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("lightcraft_ui_egui").build())); + assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build())); + // Attaching again rotates: the first log becomes `.1`. + logger.attach_dir(&dir).expect("attach again"); + assert_eq!(read(&dir.join("lightcraft.1.log")), text); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn records_from_many_threads_are_whole_lines() { + let dir = temp_dir("threads"); + let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false)); + let path = logger.attach_dir(&dir).expect("attach"); + let workers: Vec<_> = (0..8) + .map(|t| { + let l = std::sync::Arc::clone(&logger); + std::thread::spawn(move || { + for i in 0..100 { + record(&l, log::Level::Info, "lightcraft", &format!("thread {t} record {i}")); + } + }) + }) + .collect(); + for w in workers { + w.join().expect("worker"); + } + let text = read(&path); + assert_eq!(text.lines().count(), 800); + assert!(text.lines().all(|l| l.contains(" lightcraft: thread ")), "{text}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() { + let dir = temp_dir("unusable"); + std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp"); + std::fs::write(&dir, "not a directory").expect("block"); + let logger = AppLogger::new(Filter::parse("info"), false); + record(&logger, log::Level::Info, "lightcraft", "early"); + assert!(logger.attach_dir(&dir).is_err()); + record(&logger, log::Level::Error, "x", "still fine"); + // Nothing waits in memory for a file that won't come. + assert!(logger.sink.lock().expect("sink").pending.is_empty()); + let _ = std::fs::remove_file(&dir); + } + + #[test] + fn a_poisoned_sink_still_logs() { + let dir = temp_dir("poison"); + let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false)); + let l = std::sync::Arc::clone(&logger); + let _ = std::thread::spawn(move || { + let _guard = l.sink.lock().expect("lock"); + panic!("poison the sink"); + }) + .join(); + assert!(logger.sink.is_poisoned()); + let path = logger.attach_dir(&dir).expect("attach"); + record(&logger, log::Level::Warn, "lightcraft", "after the poison"); + assert!(read(&path).contains("lightcraft: after the poison")); + let _ = std::fs::remove_dir_all(&dir); + } + + /// The panic hook's report lands in the file, and never deadlocks when the panic happened + /// while this thread held the sink. + #[test] + fn a_panic_report_goes_to_the_file_and_never_deadlocks() { + let dir = temp_dir("panic"); + let logger = AppLogger::new(Filter::parse("off"), false); + let path = logger.attach_dir(&dir).expect("attach"); + logger.record_panic("render", "panicked at crates/x/src/lib.rs:3:5:\nboom"); + { + let _held = logger.sink.lock().expect("lock"); + logger.record_panic("main", "while the sink is held"); + } + let text = read(&path); + assert!(text.contains(" ERROR [render] lightcraft: panicked at crates/x/src/lib.rs:3:5:\nboom\n"), "{text}"); + assert!(!text.contains("while the sink is held"), "{text}"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 4bb1c6ff9..4c7bb4242 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -24,9 +24,12 @@ mod alloc_release; mod control_server; +mod dialog_filter; +mod logging; #[cfg(target_os = "macos")] mod native_menu; +use dialog_filter::FileDialogExt as _; use lightcraft_engine::Session; use lightcraft_ui_egui::panels::library_problem::LibraryProblem; use lightcraft_ui_egui::{LightcraftApp, Services, UiState}; @@ -63,10 +66,10 @@ impl eframe::App for App { } fn on_exit(&mut self) { if let Err(e) = self.1.save(&self.0) { - eprintln!("lightcraft: {e}"); + log::error!("{e}"); } if let Err(e) = self.0.session.close_library() { - eprintln!("lightcraft: saving the library failed: {e}"); + log::error!("saving the library failed: {e}"); } } } @@ -93,10 +96,23 @@ fn gpu_marker_path() -> Option { /// Arm the GPU crash sentinel; if the previous launch left it behind (it died inside the GPU /// driver while creating the device), the notice to show — GPU rendering then starts off. -fn gpu_crash_check() -> Option { - let marker = gpu_marker_path(); - let left = marker.as_deref().and_then(lightcraft_engine::gpu::backend::take_init_marker); - lightcraft_engine::gpu::backend::set_init_marker(marker); +/// +/// A `--memory` session writes nothing (issue #169): it neither arms the sentinel — which would +/// create the settings folder — nor removes a marker it finds. It still honours one, so the GPU +/// starts off there too, and the next ordinary launch reports and clears it. +fn gpu_crash_check(in_memory: bool) -> Option { + gpu_crash_check_at(gpu_marker_path(), in_memory) +} + +fn gpu_crash_check_at(marker: Option, in_memory: bool) -> Option { + use lightcraft_engine::gpu::backend; + let left = if in_memory { + marker.as_deref().and_then(backend::read_init_marker) + } else { + let left = marker.as_deref().and_then(backend::take_init_marker); + backend::set_init_marker(marker); + left + }; left.map(|what| gpu_crash_notice(&what)) } @@ -112,19 +128,28 @@ fn config_dir() -> Option { lightcraft_engine::camera_profiles::config_dir() } +/// `/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 { + if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() { + return None; + } + config_dir().map(|d| d.join("ui.json")) +} + /// The saved UI state and app settings (`/ui.json`), if any, and a warning for the user /// when the file exists but can't be used (issue #103): a damaged file is kept as /// `ui.json.corrupt-` first, so the next save can't lose the library location in it; /// one that can't be read at all is not written this session (`keep_file`). -fn load_prefs() -> (Option, Option, bool) { - if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() { - return (None, None, false); - } - let Some(path) = config_dir().map(|d| d.join("ui.json")) else { return (None, None, false) }; - load_prefs_at(&path) +/// +/// A `--memory` session reads the settings (so it looks like the user's app) but writes nothing +/// (issue #164), so a damaged file is left as it is there, not set aside. +fn load_prefs(in_memory: bool) -> (Option, Option, bool) { + let Some(path) = prefs_path() else { return (None, None, false) }; + load_prefs_at(&path, !in_memory) } -fn load_prefs_at(path: &std::path::Path) -> (Option, Option, bool) { +fn load_prefs_at(path: &std::path::Path, set_aside_damaged: bool) -> (Option, Option, bool) { let bytes = match std::fs::read(path) { Ok(b) => b, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return (None, None, false), @@ -135,6 +160,10 @@ fn load_prefs_at(path: &std::path::Path) -> (Option, Option, bo }; match serde_json::from_slice::(&bytes) { Ok(ui) => (Some(ui.sanitized()), None, false), + Err(e) if !set_aside_damaged => { + let msg = format!("The app settings ({}) are damaged ({e}). Defaults are used; nothing is saved in this session.", path.display()); + (None, Some(msg), true) + } Err(e) => { let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); let keep = path.with_file_name(format!("ui.json.corrupt-{secs}")); @@ -177,8 +206,15 @@ fn write_atomic(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> { /// Saves `ui.json` (app settings, incl. the library to open at launch): as soon as the library /// changes, every few seconds when anything else changed, and at exit. +/// +/// Nothing is written while the session is one that promises to save nothing: `--memory` +/// (`path` is `None`; issue #164), or the one running because the library couldn't be opened — the +/// question window and Continue Without Saving (issue #100; `app.openLibrary` ends it, and the +/// library it opened is saved then). #[derive(Default)] struct PrefsWriter { + /// Where to write; `None` writes nothing (`--memory`, `LIGHTCRAFT_NO_PREFS`, no home folder). + path: Option, written: Vec, library: String, checked: f64, @@ -190,19 +226,26 @@ struct PrefsWriter { impl PrefsWriter { fn save(&mut self, app: &LightcraftApp) -> Result<(), String> { - if self.keep_file || std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() { + self.save_ui(&app.ui, app.library_problem.is_some()) + } + + /// Write `ui` if it changed since the last write; `temporary` (the library couldn't be + /// opened) saves nothing. + fn save_ui(&mut self, ui: &UiState, temporary: bool) -> Result<(), String> { + if self.keep_file || temporary { return Ok(()); } - let Some(d) = config_dir() else { return Ok(()) }; - let bytes = serde_json::to_vec_pretty(&app.ui).map_err(|e| e.to_string())?; + let Some(path) = self.path.clone() else { return Ok(()) }; + let bytes = serde_json::to_vec_pretty(ui).map_err(|e| e.to_string())?; if bytes == self.written { return Ok(()); } - std::fs::create_dir_all(&d) - .and_then(|()| write_atomic(&d.join("ui.json"), &bytes)) + path.parent() + .map_or(Ok(()), std::fs::create_dir_all) + .and_then(|()| write_atomic(&path, &bytes)) .map_err(|e| format!("saving the app settings failed: {e}"))?; self.written = bytes; - self.library = app.ui.settings.library_path.clone(); + self.library = ui.settings.library_path.clone(); Ok(()) } @@ -216,8 +259,8 @@ impl PrefsWriter { match self.save(app) { Ok(()) => self.failing = false, Err(e) => { - eprintln!("lightcraft: {e}"); if !self.failing { + log::warn!("{e}"); app.notices.push(format!("{e}. LightCraft keeps trying.")); } self.failing = true; @@ -228,8 +271,61 @@ impl PrefsWriter { } } -fn services() -> Services { +/// The Windows command that opens `url` in the default browser. `explorer ` opened File +/// Explorer instead for links with a query or fragment, like the Map button's OpenStreetMap link +/// (issue #234). The URL protocol handler hands the URL to the browser as is; no shell runs, so +/// `?`, `&` and `#` need no escaping. +fn windows_open_url_command(url: &str) -> std::process::Command { + let mut c = std::process::Command::new("rundll32"); + c.args(["url.dll,FileProtocolHandler", url]); + c +} + +/// The platform services; `ctx` repaints when a file dialog closes, and `log_file` is +/// for Help ▸ Open Log Folder. +fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services { Services { + // Commands' file dialogs run on their own thread (#191): a dialog on the UI thread + // stopped the window answering the compositor, which then reported the app as hung. + picker: Some(Box::new(move |req: lightcraft_ui_egui::pick::PickRequest| { + use lightcraft_ui_egui::pick::PickKind; + let (tx, rx) = std::sync::mpsc::channel(); + let repaint = ctx.clone(); + std::thread::Builder::new() + .name("file-dialog".into()) + .spawn(move || { + let mut d = rfd::FileDialog::new(); + if let Some(t) = &req.title { + d = d.set_title(t); + } + if let Some((name, exts)) = &req.filter { + d = d.add_filter_nocase(name.clone(), exts); + } + if let Some(n) = &req.file_name { + d = d.set_file_name(n); + } + let s = |p: std::path::PathBuf| p.to_string_lossy().to_string(); + let paths: Vec = match req.kind { + PickKind::Files => d.pick_files().unwrap_or_default().into_iter().map(s).collect(), + PickKind::File => d.pick_file().map(s).into_iter().collect(), + PickKind::Folder => d.pick_folder().map(s).into_iter().collect(), + PickKind::Save => d.save_file().map(s).into_iter().collect(), + }; + // the app may have quit meanwhile: nobody to tell + let _ = tx.send(paths); + repaint.request_repaint(); + }) + .map_err(|e| format!("could not start the file dialog: {e}"))?; + Ok(rx) + })), + log_file: log_file.map(|p| p.display().to_string()), + pick_lightroom_catalog: Some(Box::new(|| { + rfd::FileDialog::new() + .set_title(lightcraft_ui_egui::i18n::tr("Import Lightroom Catalog")) + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Lightroom Classic Catalog"), &["lrcat"]) + .pick_file() + .map(|p| p.to_string_lossy().to_string()) + })), pick_folder: Some(Box::new(|| { rfd::FileDialog::new().set_title(lightcraft_ui_egui::i18n::tr("Open Library")).pick_folder().map(|p| p.to_string_lossy().to_string()) })), @@ -262,7 +358,7 @@ fn services() -> Services { let status = if cfg!(target_os = "macos") { std::process::Command::new("open").arg(url).status() } else if cfg!(target_os = "windows") { - std::process::Command::new("explorer").arg(url).status() + windows_open_url_command(url).status() } else { std::process::Command::new("xdg-open").arg(url).status() }; @@ -272,7 +368,8 @@ fn services() -> Services { let status = if cfg!(target_os = "macos") { std::process::Command::new("open").args(["-R", path]).status() } else if cfg!(target_os = "windows") { - std::process::Command::new("explorer").arg(format!("/select,{path}")).status() + let win_path = path.replace('/', "\\"); + std::process::Command::new("explorer").arg(format!("/select,{win_path}")).status() } else { let dir = std::path::Path::new(path).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_else(|| ".".into()); std::process::Command::new("xdg-open").arg(dir).status() @@ -283,13 +380,7 @@ fn services() -> Services { })), pick_files: Some(Box::new(|| { rfd::FileDialog::new() - .add_filter( - lightcraft_ui_egui::i18n::tr("Photos"), - &[ - "jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "rwl", "raw", - "pef", "psd", "jxl", "gif", "bmp", - ], - ) + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Photos"), lightcraft_engine::import::EXTENSIONS) .pick_files() .unwrap_or_default() .into_iter() @@ -299,7 +390,7 @@ fn services() -> Services { pick_preset_files: Some(Box::new(|| { rfd::FileDialog::new() .set_title(lightcraft_ui_egui::i18n::tr("Import Presets")) - .add_filter( + .add_filter_nocase( lightcraft_ui_egui::i18n::tr("Presets & Profiles"), &["lcpreset", "xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack", "cube"], ) @@ -312,7 +403,7 @@ fn services() -> Services { pick_tracklog: Some(Box::new(|| { rfd::FileDialog::new() .set_title(lightcraft_ui_egui::i18n::tr("Auto-Tag from Tracklog")) - .add_filter(lightcraft_ui_egui::i18n::tr("GPS Track Log"), &["gpx"]) + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("GPS Track Log"), &["gpx"]) .pick_file() .map(|p| vec![p.to_string_lossy().to_string()]) .unwrap_or_default() @@ -320,7 +411,7 @@ fn services() -> Services { save_preset_file: Some(Box::new(|name: &str| { rfd::FileDialog::new() .set_title(lightcraft_ui_egui::i18n::tr("Export Presets")) - .add_filter(lightcraft_ui_egui::i18n::tr("LightCraft Preset"), &["lcpreset"]) + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("LightCraft Preset"), &["lcpreset"]) .set_file_name(name) .save_file() .map(|p| p.to_string_lossy().to_string()) @@ -328,7 +419,7 @@ fn services() -> Services { pick_curve_preset_files: Some(Box::new(|| { rfd::FileDialog::new() .set_title(lightcraft_ui_egui::i18n::tr("Import Point Curve Presets")) - .add_filter(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve", "json"]) + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve", "json"]) .pick_files() .unwrap_or_default() .into_iter() @@ -338,7 +429,7 @@ fn services() -> Services { save_curve_preset_file: Some(Box::new(|name: &str| { rfd::FileDialog::new() .set_title(lightcraft_ui_egui::i18n::tr("Export Point Curve Presets")) - .add_filter(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve"]) + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve"]) .set_file_name(name) .save_file() .map(|p| p.to_string_lossy().to_string()) @@ -366,7 +457,7 @@ fn open_session(in_memory: bool, dir: Option, seed_demo: boo } let unopened = || Session::new().with_fs().with_system_clock(); let Some(dir) = dir else { - eprintln!("lightcraft: no library location (set --library or LIGHTCRAFT_LIBRARY)"); + log::warn!("no library location (set --library or LIGHTCRAFT_LIBRARY)"); return (unopened(), Some(LibraryProblem::new("", "There is no home folder to keep the library in. Choose a folder for it."))); }; let t0 = std::time::Instant::now(); @@ -374,8 +465,8 @@ fn open_session(in_memory: bool, dir: Option, seed_demo: boo match s.open_library(&dir, seed_demo) { Ok(r) => { let (replayed, torn) = (r.replayed, r.torn_bytes); - eprintln!( - "lightcraft: library {}: {} photos, {replayed} log records replayed{} ({:.1} ms)", + log::info!( + "library {}: {} photos, {replayed} log records replayed{} ({:.1} ms)", dir.display(), s.catalog.len(), if torn > 0 { ", torn tail repaired" } else { "" }, @@ -384,7 +475,7 @@ fn open_session(in_memory: bool, dir: Option, seed_demo: boo (s, None) } Err(e) => { - eprintln!("lightcraft: can't open library {}: {e}", dir.display()); + log::error!("can't open library {}: {e}", dir.display()); (unopened(), Some(LibraryProblem::new(dir.to_string_lossy(), e.to_string()))) } } @@ -409,41 +500,65 @@ ENVIRONMENT: LIGHTCRAFT_SAM3_DIR=DIR the SAM 3 model for Object / Describe masks (default: /models/sam3; optional: LightCraft offers to download it when first needed) LIGHTCRAFT_SAM3_MIRRORS=URL,… where to download the SAM 3 model from (base URLs, tried in order) + LIGHTCRAFT_LOG=info|debug more log detail from LightCraft itself (default: info from LightCraft, warnings + from other crates); else RUST_LOG=. Records go to stderr and to + /logs/lightcraft.log (previous runs: lightcraft.1.log, lightcraft.2.log) "; -/// Warnings and errors (failed commands, AI mask analysis) on stderr; `LIGHTCRAFT_LOG=info` -/// (or `debug`) shows more. -struct StderrLog(log::LevelFilter); - -impl log::Log for StderrLog { - fn enabled(&self, m: &log::Metadata) -> bool { - m.level() <= self.0 && (m.level() <= log::Level::Warn || m.target().starts_with("lightcraft")) - } - fn log(&self, r: &log::Record) { - if self.enabled(r.metadata()) { - eprintln!("lightcraft: {} {}: {}", r.level(), r.target(), r.args()); - } - } - fn flush(&self) {} +/// The launch arguments' files and folders to import (absolute), and a notice for each one that +/// is not imported (see [`lightcraft_engine::import::launch_path`]; issue #374). +fn launch_imports(args: &[String]) -> (Vec, Vec) { + let cwd = std::env::current_dir().ok(); + let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).filter(|h| !h.is_empty()).map(std::path::PathBuf::from); + launch_imports_in(args, cwd.as_deref(), home.as_deref()) } -fn install_log() { - let level = match std::env::var("LIGHTCRAFT_LOG").unwrap_or_default().as_str() { - "debug" => log::LevelFilter::Debug, - "info" => log::LevelFilter::Info, - _ => log::LevelFilter::Warn, - }; - static LOGGER: std::sync::OnceLock = std::sync::OnceLock::new(); - if log::set_logger(LOGGER.get_or_init(|| StderrLog(level))).is_ok() { - log::set_max_level(level); +fn launch_imports_in(args: &[String], cwd: Option<&std::path::Path>, home: Option<&std::path::Path>) -> (Vec, Vec) { + use lightcraft_engine::import::{LaunchPath, launch_path}; + let (mut files, mut notices) = (Vec::new(), Vec::new()); + for a in args { + match launch_path(a, cwd, home, lightcraft_engine::devices::dcim_in) { + Some(LaunchPath::Import(p)) => files.push(p), + Some(LaunchPath::Refused { path, why }) => { + log::warn!("not importing {path} given at launch: {why}"); + notices + .push(format!("{path} was not imported: it is {why}. To import from it, choose a folder inside it with File ▸ Import Photos…")); + } + None => {} + } } + (files, notices) +} + +/// Where the log files live: `logs` in the settings folder, next to `ui.json` (see `logging`). +/// None with `LIGHTCRAFT_NO_PREFS` (tests, scripts), so those runs don't rotate away the user's logs. +fn log_dir() -> Option { + if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() { + return None; + } + config_dir().map(|d| d.join("logs")) +} + +/// The control port `value` names; a value that isn't a port is logged and ignored (`what` is +/// the option or variable it came from). Logged before the log file opens, so it is kept for it. +fn control_port_from(what: &str, value: Option) -> Option { + let value = value?; + let port = value.trim().parse().ok(); + if port.is_none() { + log::warn!("{what} {value:?} is not a port number; no control server"); + } + port } fn main() -> eframe::Result { + // First, so every start-up record is kept for the log file (`logging`). + let logger = logging::install(); lightcraft_engine::guard::install_hook(std::env::temp_dir().join("lightcraft-panics.log")); - install_log(); + if let Some(logger) = logger { + logging::record_panics(logger); + } alloc_release::install(); - let mut control_port: Option = std::env::var("LIGHTCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()); + let mut control_port = control_port_from("LIGHTCRAFT_CONTROL_PORT", std::env::var("LIGHTCRAFT_CONTROL_PORT").ok().filter(|v| !v.is_empty())); let mut files = Vec::new(); let mut seed_demo = true; let mut in_memory = false; @@ -451,7 +566,7 @@ fn main() -> eframe::Result { let mut args = std::env::args().skip(1); while let Some(a) = args.next() { match a.as_str() { - "--control" => control_port = args.next().and_then(|p| p.parse().ok()), + "--control" => control_port = control_port_from("--control", Some(args.next().unwrap_or_default())), "--library" => library_dir = args.next().map(std::path::PathBuf::from), "--no-demo" => seed_demo = false, "--memory" | "--demo" => in_memory = true, @@ -470,7 +585,31 @@ fn main() -> eframe::Result { _ => files.push(a), } } - let (prefs, prefs_warning, keep_prefs_file) = load_prefs(); + // The log file: opened after the arguments, so `--version`, `--help` and a usage error leave + // no file behind. Records logged until now are written to it first. + let mut log_file = None; + if let Some(logger) = logger { + // a --memory session writes nothing (issue #164), a log file included + match log_dir().filter(|_| !in_memory) { + Some(dir) => match logger.attach_dir(&dir) { + Ok(path) => { + log::info!("LightCraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()); + log_file = Some(path); + } + // The file sink has given up by now, so this goes to standard error only. + Err(e) => log::warn!("no log file: {e}"), + }, + None => logger.no_file(), + } + } + // what the arguments name, resolved now: an empty or relative argument never becomes a drive + // root, and a whole drive or home folder is not walked (issue #374) + let (files, launch_notices) = launch_imports(&files); + let (prefs, prefs_warning, keep_prefs_file) = load_prefs(in_memory); + // the saved language from the start, so a failed start is reported in it too + if let Some(ui) = &prefs { + lightcraft_ui_egui::i18n::set_language(ui.language); + } // --library, else the library last opened from Settings, else the default location let library_dir = library_dir.or_else(|| { prefs @@ -482,7 +621,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(); + let gpu_crash = gpu_crash_check(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); let options = eframe::NativeOptions { @@ -500,7 +639,9 @@ fn main() -> eframe::Result { wgpu_options: window_wgpu_options(), ..Default::default() }; - eframe::run_native( + // the app's copy: `log_file` is still named in the message of a start that fails + let app_log_file = log_file.clone(); + let started = eframe::run_native( "LightCraft", options, Box::new(move |cc| { @@ -511,15 +652,17 @@ fn main() -> eframe::Result { std::env::var_os("LIGHTCRAFT_SAM3_DIR").map(std::path::PathBuf::from).or_else(|| config_dir().map(|d| d.join("models").join("sam3"))); // the user's own download locations, one base URL per line (LIGHTCRAFT_SAM3_MIRRORS too) session.segmenter.mirrors_file = config_dir().map(|d| d.join("models").join("sam3-mirrors.txt")); - let mut app = LightcraftApp::new(session, services()); + let mut app = LightcraftApp::new(session, services(cc.egui_ctx.clone(), app_log_file.as_deref())); if let Some(ui) = prefs { app.ui = ui; } lightcraft_ui_egui::i18n::set_language(app.ui.language); app.integrated_titlebar = cfg!(target_os = "macos"); app.notices.extend(prefs_warning); + app.notices.extend(launch_notices); // what's on disk now: only changes are written let writer = PrefsWriter { + path: if in_memory { None } else { prefs_path() }, written: serde_json::to_vec_pretty(&app.ui).unwrap_or_default(), library: app.ui.settings.library_path.clone(), keep_file: keep_prefs_file, @@ -539,7 +682,12 @@ fn main() -> eframe::Result { p.pending_import = files; app.library_problem = Some(p); } else if !files.is_empty() { - let _ = app.run("library.import", serde_json::json!({"paths": files})); + // listed and read on the import worker once the window shows (issue #374: never + // here, before the first frame, where a large folder kept the window from + // appearing) + if let Err(e) = lightcraft_ui_egui::import::start_paths(&mut app, files) { + log::warn!("import at launch: {e}"); + } app.ui.view = lightcraft_ui_egui::state::ViewMode::PhotoGrid; } // native menu bar generated from the command registry (macOS; elsewhere the menus are @@ -553,7 +701,28 @@ fn main() -> eframe::Result { menu, ))) }), - ) + ); + if let Err(e) = &started { + startup_failed(&e.to_string(), log_file.as_deref()); + } + started +} + +/// 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). +fn startup_failed(error: &str, log_file: Option<&std::path::Path>) { + log::error!("LightCraft could not open its window: {error}"); + 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::startup_failed_message(error, 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(); } #[cfg(test)] @@ -568,6 +737,24 @@ mod tests { d } + /// Issue #374: launch arguments that would walk a whole drive or home folder are reported, + /// not imported; empty ones are ignored; the rest are imported as absolute paths. + #[test] + fn launch_arguments_never_walk_a_drive_or_the_home_folder() { + let home = dir("launch"); + std::fs::create_dir_all(home.join("Trip")).unwrap(); + let args: Vec = ["", " ", ".", "Trip", &home.to_string_lossy()].iter().map(|s| s.to_string()).collect(); + let (files, notices) = launch_imports_in(&args, Some(&home), Some(&home)); + assert_eq!(files, [home.join("Trip").to_string_lossy().to_string()]); + assert_eq!(notices.len(), 2, "{notices:?}"); + assert!(notices.iter().all(|n| n.contains("was not imported")), "{notices:?}"); + // the working folder is a drive root (a launcher's choice): `.` is never the whole drive + let root = home.ancestors().last().unwrap().to_path_buf(); + let (files, notices) = launch_imports_in(&[".".to_string()], Some(&root), None); + assert!(files.iter().all(|f| f.ends_with("DCIM")) && files.len() + notices.len() == 1, "{files:?} {notices:?}"); + let _ = std::fs::remove_dir_all(&home); + } + /// Issue #103: a damaged ui.json (which holds the library location) is kept aside and /// reported, not silently replaced by the defaults. #[test] @@ -575,7 +762,7 @@ mod tests { let d = dir("damaged"); let path = d.join("ui.json"); std::fs::write(&path, br#"{"settings": {"library_path": "/Volumes/Photos/Lib"#).unwrap(); - let (ui, warning, keep) = load_prefs_at(&path); + let (ui, warning, keep) = load_prefs_at(&path, true); assert!(ui.is_none() && !keep); let warning = warning.unwrap(); assert!(warning.contains("damaged") && warning.contains("ui.json.corrupt-"), "{warning}"); @@ -583,7 +770,80 @@ mod tests { let kept: Vec<_> = std::fs::read_dir(&d).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().to_string()).collect(); assert!(kept.len() == 1 && kept[0].starts_with("ui.json.corrupt-"), "{kept:?}"); // a missing file is just "no settings yet" - assert_eq!(load_prefs_at(&path).1, None); + assert_eq!(load_prefs_at(&path, true).1, None); + let _ = std::fs::remove_dir_all(&d); + } + + /// Issue #164: a `--memory` session reads the settings but writes nothing — a damaged file is + /// reported and left where it is, not set aside. + #[test] + fn memory_session_leaves_a_damaged_ui_json_alone() { + let d = dir("damaged-memory"); + let path = d.join("ui.json"); + let bytes = br#"{"settings": {"library_path": "/Volumes/Photos/Lib"#; + std::fs::write(&path, bytes).unwrap(); + let (ui, warning, keep) = load_prefs_at(&path, false); + assert!(ui.is_none() && keep); + let warning = warning.unwrap(); + assert!(warning.contains("damaged") && warning.contains("nothing is saved"), "{warning}"); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1); + let _ = std::fs::remove_dir_all(&d); + } + + /// Issue #164: the writer saves changed settings to its file — and nothing at all without one + /// (`--memory`) or while the session is the temporary one offered when the library can't open. + #[test] + fn prefs_writer_writes_nothing_in_a_memory_or_temporary_session() { + let d = dir("writer"); + let path = d.join("sub").join("ui.json"); + let mut ui = UiState::default(); + // an ordinary session: a change is written, the folder created on the way + let mut w = PrefsWriter { path: Some(path.clone()), ..Default::default() }; + ui.thumb_size += 50.0; + w.save_ui(&ui, false).unwrap(); + let saved = std::fs::read(&path).unwrap(); + assert!(String::from_utf8_lossy(&saved).contains("thumbSize"), "{}", String::from_utf8_lossy(&saved)); + // the temporary session (library problem): a further change is not written… + ui.thumb_size += 50.0; + w.save_ui(&ui, true).unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), saved); + // …until a library opens + w.save_ui(&ui, false).unwrap(); + assert_ne!(std::fs::read(&path).unwrap(), saved); + // --memory: no path, so no file and no folder, whatever changes + let d2 = dir("writer-memory"); + let _ = std::fs::remove_dir_all(&d2); + let mut w = PrefsWriter { path: None, ..Default::default() }; + ui.thumb_size += 50.0; + w.save_ui(&ui, false).unwrap(); + assert!(w.written.is_empty() && !d2.exists()); + let _ = std::fs::remove_dir_all(&d); + } + + /// Issue #169: a `--memory` session neither arms the GPU crash sentinel (which would create + /// the settings folder) nor removes a marker a crashed launch left; it still reports it, and + /// the next ordinary launch finds it, reports it and clears it. + #[test] + fn memory_session_leaves_the_gpu_marker_and_folder_alone() { + let d = dir("gpu-marker-memory"); + let _ = std::fs::remove_dir_all(&d); + let m = d.join("gpu-init.marker"); + // nothing left behind: no notice, and the folder is not created + assert_eq!(gpu_crash_check_at(Some(m.clone()), true), None); + assert!(!d.exists(), "a --memory session must not create the settings folder"); + // a marker left by a crashed launch: reported, kept + std::fs::create_dir_all(&d).unwrap(); + std::fs::write(&m, "GPU device creation started (backends METAL)").unwrap(); + let notice = gpu_crash_check_at(Some(m.clone()), true).unwrap(); + assert!(notice.contains("GPU rendering is now off"), "{notice}"); + assert!(m.exists(), "the marker stays for the next ordinary launch"); + // the next ordinary launch reports it once and removes it + assert!(gpu_crash_check_at(Some(m.clone()), false).is_some()); + lightcraft_engine::gpu::backend::set_init_marker(None); + assert!(!m.exists()); + assert_eq!(gpu_crash_check_at(Some(m.clone()), false), None); + lightcraft_engine::gpu::backend::set_init_marker(None); let _ = std::fs::remove_dir_all(&d); } @@ -616,6 +876,27 @@ mod tests { assert!(!b.is_empty()); } + /// Issue #234: Windows opens links with the URL protocol handler (the default browser), not + /// File Explorer, and passes the whole map link, `?`, `&` and `#` included, as one argument. + #[test] + fn windows_links_open_in_the_browser() { + let url = "https://www.openstreetmap.org/?mlat=48.856600&mlon=2.352200#map=15/48.856600/2.352200"; + let c = windows_open_url_command(url); + assert_eq!(c.get_program(), "rundll32"); + let args: Vec<&std::ffi::OsStr> = c.get_args().collect(); + assert_eq!(args, ["url.dll,FileProtocolHandler", url]); + } + + #[test] + fn a_control_port_that_is_not_a_number_is_ignored() { + assert_eq!(control_port_from("--control", Some("7980".into())), Some(7980)); + assert_eq!(control_port_from("--control", Some(" 18001 ".into())), Some(18001)); + assert_eq!(control_port_from("--control", Some("nope".into())), None); + assert_eq!(control_port_from("--control", Some(String::new())), None); + assert_eq!(control_port_from("--control", Some("70000".into())), None); + assert_eq!(control_port_from("LIGHTCRAFT_CONTROL_PORT", None), None); + } + #[test] fn ui_json_is_written_atomically() { let d = dir("atomic"); @@ -630,7 +911,7 @@ mod tests { assert_eq!(std::fs::read(&path).unwrap(), b"{\"new\": true}"); let ui = UiState::default(); write_atomic(&path, &serde_json::to_vec_pretty(&ui).unwrap()).unwrap(); - let (loaded, warning, _) = load_prefs_at(&path); + let (loaded, warning, _) = load_prefs_at(&path, true); assert!(loaded.is_some() && warning.is_none()); let _ = std::fs::remove_dir_all(&d); } diff --git a/apps/lightcraft/src/native_menu.rs b/apps/lightcraft/src/native_menu.rs index 557c9abfb..647f7e27b 100644 --- a/apps/lightcraft/src/native_menu.rs +++ b/apps/lightcraft/src/native_menu.rs @@ -2,9 +2,12 @@ //! the in-window menus ([`lightcraft_ui_egui::menubar`]): the registry's menu paths, shortcuts, //! enabled and checked state, and live labels ("Undo Exposure"). //! -//! Key handling: a native key equivalent consumes the key press before egui sees it, so -//! - shortcuts installed natively are listed in `LightcraftApp::native_shortcuts` and skipped by -//! the egui shortcut handler (nothing fires twice); +//! Key handling: AppKit offers a key press to the menu bar only when it carries ⌘ or ⌃, or is a +//! function key (F1…). Every other key (`E`, `1`, `⇧P`, `⌥Y`…) goes straight to the window, whose +//! winit view always takes it, so a menu item never fires from it. Hence +//! - shortcuts the menu bar really receives ([`menu_delivers`]) are listed in +//! `LightcraftApp::native_shortcuts` and skipped by the egui shortcut handler (nothing fires +//! twice); the others stay on their menu items for display and egui runs them; //! - while a text field has keyboard focus, accelerators without ⌘ (`G`, `1`, `Delete`…) and the //! text-editing ones (⌘A/⌘C/⌘V/⌘X/⌘Z) are removed, so typing and text editing work; they come //! back when the field loses focus. @@ -51,6 +54,9 @@ pub struct NativeMenu { /// Structure of the installed menu (ids, kinds, submenu names); a change rebuilds it. structure: String, text_focus: bool, + /// The keyboard shortcuts editor is recording a key: every accelerator is off so the key + /// press reaches it. + capturing: bool, } /// `Cmd+Shift+Z` → a muda accelerator (`None` for keys we leave to egui, e.g. Escape). @@ -146,6 +152,18 @@ fn yields_to_text(sc: &str) -> bool { !sc.contains("Cmd") && !sc.contains("Ctrl") || TEXT_EDIT.contains(&sc) } +/// Whether AppKit hands this shortcut to the menu bar: only key presses with ⌘ or ⌃, and function +/// keys. A plain `C` or `⇧P` never reaches it (the window's view takes the key), so egui has to run +/// those even though their menu item shows the key. +fn menu_delivers(sc: &str) -> bool { + sc.split('+').any(|part| matches!(part, "Cmd" | "Ctrl") || part.strip_prefix('F').is_some_and(|n| n.parse::().is_ok())) +} + +/// The shortcuts the menu bar runs itself, for `LightcraftApp::native_shortcuts`. +fn owned_by_menu<'a>(installed: impl Iterator, text_focus: bool) -> HashSet { + installed.filter(|sc| menu_delivers(sc) && !(text_focus && yields_to_text(sc))).map(str::to_string).collect() +} + /// `&` marks a mnemonic in muda labels. fn label_text(s: &str) -> String { lightcraft_ui_egui::i18n::tr(s).replace('&', "&&") @@ -196,7 +214,7 @@ impl NativeMenu { let _ = tx.send(e.id.0); repaint.request_repaint(); })); - let mut m = NativeMenu { menu: Menu::new(), items: HashMap::new(), rx, structure: String::new(), text_focus: false }; + let mut m = NativeMenu { menu: Menu::new(), items: HashMap::new(), rx, structure: String::new(), text_focus: false, capturing: false }; m.rebuild(app); app.native_menu = true; m @@ -266,6 +284,7 @@ impl NativeMenu { } self.menu.init_for_nsapp(); self.text_focus = false; + self.capturing = false; self.publish_shortcuts(app); } @@ -329,13 +348,9 @@ impl NativeMenu { /// Tell the egui shortcut handler which shortcuts the menu bar currently owns. fn publish_shortcuts(&self, app: &mut LightcraftApp) { - app.native_shortcuts = self - .items - .values() - .filter(|i| i.accel.is_some()) - .filter_map(|i| i.shortcut.clone()) - .filter(|sc| !(self.text_focus && yields_to_text(sc))) - .collect::>(); + let installed = self.items.values().filter(|i| i.accel.is_some()).filter_map(|i| i.shortcut.as_deref()); + // (none while the keymap editor records a shortcut: every key goes to it) + app.native_shortcuts = if self.capturing { HashSet::new() } else { owned_by_menu(installed, self.text_focus) }; app.native_shortcuts.insert(SETTINGS_KEY.to_string()); } @@ -397,14 +412,16 @@ impl NativeMenu { walk(&bar.iter().flat_map(|(_, v)| v.clone()).collect::>(), &mut self.items); let focus = ctx.egui_wants_keyboard_input(); - if focus != self.text_focus { + let capturing = app.recording_shortcut.is_some(); + if focus != self.text_focus || capturing != self.capturing { self.text_focus = focus; + self.capturing = capturing; for it in self.items.values() { let Some(sc) = it.shortcut.as_deref() else { continue }; - if it.accel.is_none() || !yields_to_text(sc) { + if it.accel.is_none() { continue; } - let accel = if focus { None } else { it.accel }; + let accel = if capturing || (focus && yields_to_text(sc)) { None } else { it.accel }; let _ = match &it.handle { Handle::Plain(h) => h.set_accelerator(accel), Handle::Check(h) => h.set_accelerator(accel), @@ -467,4 +484,39 @@ mod tests { assert!(accelerator(sc).is_some(), "{sc}"); } } + + /// Single keys shown in the menu bar (E, C, ⇧P, ratings…) never reach it on macOS, so egui must + /// keep handling them; ⌘ / ⌃ combinations and function keys are the menu bar's own. + #[test] + fn single_key_shortcuts_stay_with_egui() { + assert!(menu_delivers("Cmd+Shift+H") && menu_delivers("Ctrl+H") && menu_delivers("F2") && menu_delivers("Cmd+F11")); + assert!(!menu_delivers("E") && !menu_delivers("Shift+P") && !menu_delivers("Alt+Y") && !menu_delivers("1") && !menu_delivers("F")); + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + fn all(n: &[MenuNode], out: &mut Vec) { + for x in n { + match x { + MenuNode::Item { shortcut: Some(s), .. } if accelerator(s).is_some() => out.push(s.clone()), + MenuNode::Submenu { children, .. } => all(children, out), + _ => {} + } + } + } + let mut installed = Vec::new(); + for (_, v) in menu_bar(&app) { + all(&v, &mut installed); + } + for sc in ["E", "C", "Shift+P", "Cmd+Shift+H", "F2"] { + assert!(installed.iter().any(|s| s == sc), "{sc} is in the menu bar"); + } + let owned = owned_by_menu(installed.iter().map(String::as_str), false); + for sc in ["E", "C", "Shift+P", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "P", "U"] { + assert!(installed.iter().any(|s| s == sc), "{sc} is displayed in the menu"); + assert!(!owned.contains(sc), "{sc} must be left to egui"); + } + for sc in ["Cmd+Shift+H", "F2"] { + assert!(owned.contains(sc), "{sc} is run by the menu bar"); + } + // while typing, the menu bar gives up F2 too (the text field has it) + assert!(!owned_by_menu(installed.iter().map(String::as_str), true).contains("F2")); + } } diff --git a/assets/ATTRIBUTION.md b/assets/ATTRIBUTION.md index 208cc39cd..202eb27ec 100644 --- a/assets/ATTRIBUTION.md +++ b/assets/ATTRIBUTION.md @@ -11,6 +11,8 @@ covers a whole directory), or if a licence file referenced in the Licence column | `assets/fonts/Inter-*.ttf` (Regular, Medium, SemiBold) | Inter | Rasmus Andersson | https://github.com/rsms/inter | SIL Open Font License 1.1 (`assets/fonts/OFL-Inter.txt`) | 2026-09-30 | none | | `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/X-H2S.json`, `assets/camera-profiles/X-T4.json` | Fujifilm camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by Sebastian Pfluegelmeier | original work: fitted by `lightcraft-cli calibrate` to 201 X-H2S and 545 X-T4 RAFs and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-08 | none | | `crates/scenes/` (generated images) | Procedural demo photographs | LightCraft contributors | original work (generated at runtime, no source imagery) | MIT OR Apache-2.0 | 2026-09-30 | n/a | | `crates/ui-egui/src/icons.rs` | UI icons drawn as vector paths in code | LightCraft contributors | original work | MIT OR Apache-2.0 | 2026-09-30 | n/a | | `docs/images/*-tetons.jpg`, `docs/images/grid-pd.jpg` (screenshots containing it) | "The Tetons and the Snake River" | Ansel Adams (1942), U.S. National Archives | https://commons.wikimedia.org/wiki/File:Adams_The_Tetons_and_the_Snake_River.jpg | Public domain (U.S. federal government work) | 2026-09-30 | developed in LightCraft; shown inside app screenshots | diff --git a/assets/camera-profiles/X-H2S.json b/assets/camera-profiles/X-H2S.json new file mode 100644 index 000000000..59cd498d0 --- /dev/null +++ b/assets/camera-profiles/X-H2S.json @@ -0,0 +1,2111 @@ +{ + "version": 1, + "model": "X-H2S", + "files": 201, + "samples": 722133, + "matrix": [ + [1.2458876370972227, -0.3237571865289368, 0.062138487539840526], + [-0.08719440905566955, 1.1839386569330799, -0.0901410829920053], + [-0.09235873400444561, -0.6687925210227519, 1.7553435681239122] + ], + "hue_sat": { + "hue_divisions": 72, + "sat_divisions": 5, + "val_divisions": 5, + "data": [ + [0.0, 1.0, 1.0], + [8.728679, 1.0206304, 1.0], + [4.3200827, 0.9860159, 1.0], + [1.2806753, 1.0042948, 1.0], + [0.15212817, 1.0054297, 1.0], + [0.0, 1.0, 1.0], + [6.898361, 1.0119873, 1.0], + [3.6430912, 0.98571527, 1.0], + [0.54247886, 1.0148934, 1.0], + [-0.28426996, 1.0097318, 1.0], + [0.0, 1.0, 1.0], + [4.7296433, 1.0000898, 1.0], + [1.1935737, 1.0203358, 1.0], + [-0.5411634, 1.0170898, 1.0], + [-0.88588536, 0.9812703, 1.0], + [0.0, 1.0, 1.0], + [2.8998873, 0.98929864, 1.0], + [0.34247065, 1.0188023, 1.0], + [-0.70872885, 1.0079176, 1.0], + [-1.3457271, 0.97846293, 1.0], + [0.0, 1.0, 1.0], + [2.7941706, 1.0029006, 1.0], + [0.090435915, 1.0282906, 1.0], + [-0.9954686, 1.0179157, 1.0], + [-1.8290771, 0.98781705, 1.0], + [0.0, 1.0, 1.0], + [2.6052246, 1.0248086, 1.0], + [-0.16740479, 1.0330777, 1.0], + [-1.1437633, 1.0250585, 1.0], + [-1.6554425, 1.005749, 1.0], + [0.0, 1.0, 1.0], + [2.6925323, 1.0300746, 1.0], + [-0.18578847, 1.0318998, 1.0], + [-1.167182, 1.0260776, 1.0], + [-1.8700649, 1.013574, 1.0], + [0.0, 1.0, 1.0], + [3.3137257, 1.026785, 1.0], + [0.27151796, 1.026643, 1.0], + [-1.0162086, 1.0205064, 1.0], + [-1.555533, 1.0105867, 1.0], + [0.0, 1.0, 1.0], + [3.3468738, 1.0230751, 1.0], + [0.98764545, 1.02391, 1.0], + [-0.4541654, 1.0172719, 1.0], + [-1.3170965, 1.0072583, 1.0], + [0.0, 1.0, 1.0], + [3.2349977, 1.0158701, 1.0], + [0.95254457, 1.0192627, 1.0], + [-0.34923795, 1.0145884, 1.0], + [-1.1261563, 1.0043564, 1.0], + [0.0, 1.0, 1.0], + [3.7663198, 1.0100601, 1.0], + [1.4589342, 1.0104818, 1.0], + [-0.23215643, 1.0029329, 1.0], + [-1.0081172, 0.9913272, 1.0], + [0.0, 1.0, 1.0], + [3.838287, 1.020117, 1.0], + [2.1576033, 1.0143449, 1.0], + [0.8445625, 0.99050206, 1.0], + [0.42045176, 0.9640189, 1.0], + [0.0, 1.0, 1.0], + [3.444411, 1.0406119, 1.0], + [2.0267544, 1.0230625, 1.0], + [1.115889, 0.97797954, 1.0], + [0.7384578, 0.92473024, 1.0], + [0.0, 1.0, 1.0], + [3.2759016, 1.0458477, 1.0], + [1.8555862, 1.0206501, 1.0], + [1.1933137, 0.97871643, 1.0], + [0.86039966, 0.922511, 1.0], + [0.0, 1.0, 1.0], + [2.155127, 1.0392207, 1.0], + [1.3352914, 1.0169463, 1.0], + [1.1311455, 0.9890133, 1.0], + [1.274365, 0.9731691, 1.0], + [0.0, 1.0, 1.0], + [0.030139316, 1.0277171, 1.0], + [-0.1536572, 1.0162318, 1.0], + [0.75929344, 0.9932093, 1.0], + [1.2689896, 0.98043805, 1.0], + [0.0, 1.0, 1.0], + [-1.4671046, 1.0425197, 1.0], + [-0.62572336, 1.0360416, 1.0], + [1.11761, 1.0142438, 1.0], + [1.6834764, 0.99684334, 1.0], + [0.0, 1.0, 1.0], + [-2.7358959, 1.0736592, 1.0], + [-0.26553202, 1.0607176, 1.0], + [1.8074034, 1.0324123, 1.0], + [2.1980815, 1.0121138, 1.0], + [0.0, 1.0, 1.0], + [-4.240199, 1.087688, 1.0], + [-0.6343778, 1.0749793, 1.0], + [1.2906064, 1.047931, 1.0], + [1.5339005, 1.0187962, 1.0], + [0.0, 1.0, 1.0], + [-5.0441365, 1.0897394, 1.0], + [-1.5529838, 1.085552, 1.0], + [-0.05806478, 1.0628853, 1.0], + [0.27003333, 1.0148032, 1.0], + [0.0, 1.0, 1.0], + [-6.358739, 1.1044925, 1.0], + [-3.0616302, 1.0820334, 1.0], + [-1.1493404, 1.0458001, 1.0], + [-0.044788126, 1.0036042, 1.0], + [0.0, 1.0, 1.0], + [-7.314695, 1.1287619, 1.0], + [-3.2918382, 1.0697256, 1.0], + [-0.6346949, 1.0162474, 1.0], + [-0.015576859, 1.0004573, 1.0], + [0.0, 1.0, 1.0], + [-7.820848, 1.1305687, 1.0], + [-2.4455993, 1.0498186, 1.0], + [-0.19949687, 1.0041214, 1.0], + [-0.0027498177, 1.0000476, 1.0], + [0.0, 1.0, 1.0], + [-7.5484633, 1.1130339, 1.0], + [-2.1600857, 1.0477536, 1.0], + [-0.11881551, 1.0030932, 1.0], + [-0.0005820335, 1.0000135, 1.0], + [0.0, 1.0, 1.0], + [-8.035158, 1.1102359, 1.0], + [-2.1405637, 1.0350051, 1.0], + [-0.084177606, 1.0013684, 1.0], + [-0.00032260726, 1.0000027, 1.0], + [0.0, 1.0, 1.0], + [-7.6952314, 1.0635511, 1.0], + [-1.064063, 1.0112427, 1.0], + [-0.008954739, 0.99997956, 1.0], + [ + -1.8368839e-05, + 0.99999607, + 1.0 + ], + [0.0, 1.0, 1.0], + [-9.061495, 1.0261137, 1.0], + [-1.4261262, 1.0039278, 1.0], + [-0.013760631, 1.0001167, 1.0], + [ + -1.3433922e-05, + 1.0000004, + 1.0 + ], + [0.0, 1.0, 1.0], + [-9.943509, 0.99616253, 1.0], + [-2.180308, 0.9981994, 1.0], + [-0.032691147, 0.99998057, 1.0], + [ + -4.4223594e-05, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [-10.810746, 0.98468745, 1.0], + [-2.652356, 0.9928594, 1.0], + [-0.043256707, 0.9998229, 1.0], + [ + -5.016976e-05, + 0.9999997, + 1.0 + ], + [0.0, 1.0, 1.0], + [-10.338566, 0.96656805, 1.0], + [-3.6949215, 0.9859071, 1.0], + [-0.102222234, 0.9995505, 1.0], + [-0.00015470105, 0.9999992, 1.0], + [0.0, 1.0, 1.0], + [-12.014268, 0.9764745, 1.0], + [-6.8061976, 0.9887849, 1.0], + [-0.31399488, 0.9994232, 1.0], + [-0.0005296378, 0.9999988, 1.0], + [0.0, 1.0, 1.0], + [-13.442714, 0.9890778, 1.0], + [-9.357521, 0.99581265, 1.0], + [-0.5783634, 0.9992511, 1.0], + [-0.0011424923, 0.99999434, 1.0], + [0.0, 1.0, 1.0], + [-14.343322, 0.98522204, 1.0], + [-9.916013, 0.9911534, 1.0], + [-0.5881451, 0.9987247, 1.0], + [-0.0013036833, 0.9999904, 1.0], + [0.0, 1.0, 1.0], + [-13.69773, 0.9788999, 1.0], + [-8.763287, 0.9857234, 1.0], + [-0.47650328, 0.9978328, 1.0], + [-0.001562948, 0.9999825, 1.0], + [0.0, 1.0, 1.0], + [-12.636312, 0.9741085, 1.0], + [-6.735057, 0.9883704, 1.0], + [-0.2865094, 0.99915266, 1.0], + [-0.00070323877, 0.9999954, 1.0], + [0.0, 1.0, 1.0], + [-11.292832, 0.97211325, 1.0], + [-5.0721035, 0.9860957, 1.0], + [-0.18372871, 0.99921757, 1.0], + [-0.00035769286, 0.9999973, 1.0], + [0.0, 1.0, 1.0], + [-9.231987, 0.9687564, 1.0], + [-3.7118556, 0.98878354, 1.0], + [-0.12847522, 0.99951947, 1.0], + [-0.00029055856, 0.99999857, 1.0], + [0.0, 1.0, 1.0], + [-9.55005, 0.95274824, 1.0], + [-3.3982742, 0.98425007, 1.0], + [-0.10297954, 0.99951214, 1.0], + [-0.00026502798, 0.9999987, 1.0], + [0.0, 1.0, 1.0], + [-8.93991, 0.92654765, 1.0], + [-3.4434712, 0.972461, 1.0], + [-0.15123777, 0.99885994, 1.0], + [-0.00096332224, 0.99999297, 1.0], + [0.0, 1.0, 1.0], + [-8.364199, 0.89885736, 1.0], + [-2.9382148, 0.96036947, 1.0], + [-0.15202415, 0.99773234, 1.0], + [-0.0011220135, 0.9999822, 1.0], + [0.0, 1.0, 1.0], + [-6.8977294, 0.8749669, 1.0], + [-3.0306811, 0.9445574, 1.0], + [-0.31894, 0.9935104, 1.0], + [-0.0031635074, 0.99992883, 1.0], + [0.0, 1.0, 1.0], + [-6.643872, 0.86401826, 1.0], + [-2.5939164, 0.94932747, 1.0], + [-0.23428063, 0.9957458, 1.0], + [-0.0025634407, 0.99994916, 1.0], + [0.0, 1.0, 1.0], + [-7.108995, 0.8512457, 1.0], + [-2.3661106, 0.9332001, 1.0], + [-0.08739928, 0.99674493, 1.0], + [-0.00063831237, 0.9999771, 1.0], + [0.0, 1.0, 1.0], + [-5.545464, 0.89238304, 1.0], + [-2.0838609, 0.93224514, 1.0], + [-0.011354147, 0.9921285, 1.0], + [0.0021260942, 0.99993163, 1.0], + [0.0, 1.0, 1.0], + [-2.3110693, 0.930202, 1.0], + [1.2572228, 0.9713286, 1.0], + [1.7645134, 0.9932074, 1.0], + [0.10183909, 0.9997941, 1.0], + [0.0, 1.0, 1.0], + [0.30794027, 0.9202272, 1.0], + [2.0130813, 0.9786798, 1.0], + [1.9920294, 0.9952443, 1.0], + [0.14857435, 0.9998033, 1.0], + [0.0, 1.0, 1.0], + [0.46019936, 0.93292373, 1.0], + [1.2379118, 0.97167575, 1.0], + [1.1637435, 1.0017741, 1.0], + [0.11729451, 1.0008876, 1.0], + [0.0, 1.0, 1.0], + [-0.30377665, 0.95070875, 1.0], + [0.92464393, 0.99626124, 1.0], + [3.7344003, 1.0415727, 1.0], + [1.4934452, 1.0185289, 1.0], + [0.0, 1.0, 1.0], + [-0.6724653, 0.905078, 1.0], + [0.8874332, 0.97638196, 1.0], + [4.093791, 1.045553, 1.0], + [1.6834058, 1.0226531, 1.0], + [0.0, 1.0, 1.0], + [-1.17155, 0.822445, 1.0], + [-0.51097, 0.88816243, 1.0], + [0.56960315, 0.99968165, 1.0], + [0.080809385, 1.0010427, 1.0], + [0.0, 1.0, 1.0], + [-1.2161565, 0.81127447, 1.0], + [-0.40078506, 0.86335325, 1.0], + [0.068287045, 0.9943868, 1.0], + [0.0020908245, 1.0000015, 1.0], + [0.0, 1.0, 1.0], + [-2.4894366, 0.7998197, 1.0], + [-0.84341663, 0.8705174, 1.0], + [0.040301893, 0.99392956, 1.0], + [0.0006503587, 0.99992245, 1.0], + [0.0, 1.0, 1.0], + [-2.780393, 0.78455186, 1.0], + [-0.33670017, 0.8948624, 1.0], + [0.007884285, 0.99553126, 1.0], + [-0.0005095186, 0.99989825, 1.0], + [0.0, 1.0, 1.0], + [-2.9800856, 0.77555025, 1.0], + [-0.36861885, 0.92584366, 1.0], + [-0.015655512, 0.99895173, 1.0], + [-0.000318789, 0.9999976, 1.0], + [0.0, 1.0, 1.0], + [-2.9218316, 0.80435187, 1.0], + [-0.36963376, 0.9587903, 1.0], + [-0.020300027, 0.9996703, 1.0], + [-0.00028825158, 1.0000017, 1.0], + [0.0, 1.0, 1.0], + [-0.56449103, 0.8612327, 1.0], + [0.17715399, 0.9788929, 1.0], + [0.0057121958, 1.0000331, 1.0], + [ + -3.5405683e-05, + 1.0000029, + 1.0 + ], + [0.0, 1.0, 1.0], + [2.8323374, 0.89369875, 1.0], + [1.0687087, 0.9809675, 1.0], + [0.034107335, 0.9997653, 1.0], + [0.000120967496, 1.0000002, 1.0], + [0.0, 1.0, 1.0], + [3.5763772, 0.93426466, 1.0], + [0.66850424, 0.99200565, 1.0], + [0.022399126, 0.9997675, 1.0], + [0.0001369294, 0.9999979, 1.0], + [0.0, 1.0, 1.0], + [3.6716483, 0.9446645, 1.0], + [0.60636437, 0.99354786, 1.0], + [0.0044138604, 1.0000226, 1.0], + [ + -1.8536522e-05, + 1.0000005, + 1.0 + ], + [0.0, 1.0, 1.0], + [2.2337399, 0.9710475, 1.0], + [0.42016056, 0.9981193, 1.0], + [0.012040432, 1.0000653, 1.0], + [ + 3.5203393e-05, + 1.0000005, + 1.0 + ], + [0.0, 1.0, 1.0], + [2.295084, 0.97946674, 1.0], + [0.29066184, 0.9992383, 1.0], + [0.0036088976, 1.0000501, 1.0], + [ + 4.384876e-06, + 1.0000002, + 1.0 + ], + [0.0, 1.0, 1.0], + [1.125425, 0.9786852, 1.0], + [0.20953342, 0.99828696, 1.0], + [0.004922661, 0.99999815, 1.0], + [ + 1.0277095e-05, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [1.2043383, 0.9779079, 1.0], + [0.090610325, 0.99730194, 1.0], + [-0.00017681491, 0.99997425, 1.0], + [ + -4.1671146e-06, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [3.1792917, 0.9613604, 1.0], + [0.35856792, 0.99540615, 1.0], + [0.0028171474, 0.9999634, 1.0], + [ + -2.471271e-06, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [4.075094, 0.9539685, 1.0], + [0.5917587, 0.9927337, 1.0], + [0.005103707, 0.99990827, 1.0], + [ + -3.1705735e-05, + 0.9999999, + 1.0 + ], + [0.0, 1.0, 1.0], + [3.5010443, 0.9536551, 1.0], + [0.20673071, 0.98983246, 1.0], + [-0.032583736, 0.99970436, 1.0], + [-0.00028767975, 0.99999875, 1.0], + [0.0, 1.0, 1.0], + [5.2023478, 0.96730983, 1.0], + [0.6703333, 0.9937887, 1.0], + [-0.020854607, 1.0000542, 1.0], + [-0.00025753156, 1.0000008, 1.0], + [0.0, 1.0, 1.0], + [5.3636384, 0.9527599, 1.0], + [0.81285733, 0.9896896, 1.0], + [0.017544644, 0.9998711, 1.0], + [0.000114887676, 1.0000008, 1.0], + [0.0, 1.0, 1.0], + [6.417876, 0.9383751, 1.0], + [1.1611464, 0.98046106, 1.0], + [0.018655559, 0.9995545, 1.0], + [-0.0002014685, 0.9999993, 1.0], + [0.0, 1.0, 1.0], + [6.7440786, 0.9787412, 1.0], + [1.8940457, 0.9993158, 1.0], + [0.10403728, 1.0014251, 1.0], + [-0.0007007577, 1.0000236, 1.0], + [0.0, 1.0, 1.0], + [9.234887, 0.9963478, 1.0], + [2.5921743, 1.0027596, 1.0], + [-0.3284632, 1.0038372, 1.0], + [-0.043428678, 1.0003042, 1.0], + [0.0, 1.0, 1.0], + [11.043382, 1.0092762, 1.0], + [3.6184309, 0.99392426, 1.0], + [-0.3205221, 1.0031682, 1.0], + [-0.059132684, 1.0006497, 1.0], + [0.0, 1.0, 1.0], + [5.97402, 1.0034658, 1.0], + [-0.6740785, 0.9949814, 1.0], + [-1.0243659, 1.0299757, 1.0], + [0.10299488, 1.034073, 1.0], + [0.0, 1.0, 1.0], + [4.5237284, 0.9901157, 1.0], + [1.2369099, 1.005908, 1.0], + [-0.32235593, 1.0317756, 1.0], + [-0.8128157, 1.0050477, 1.0], + [0.0, 1.0, 1.0], + [2.642822, 0.9752414, 1.0], + [0.046472717, 1.0169463, 1.0], + [-1.0525423, 1.0133541, 1.0], + [-1.3776581, 0.9691162, 1.0], + [0.0, 1.0, 1.0], + [1.2084394, 0.972613, 1.0], + [-0.25541478, 1.0089694, 1.0], + [-1.0787785, 1.0010968, 1.0], + [-2.0386431, 0.965853, 1.0], + [0.0, 1.0, 1.0], + [1.3990965, 0.9883769, 1.0], + [-0.23538761, 1.011664, 1.0], + [-1.0311636, 1.0079699, 1.0], + [-2.0373921, 0.98409766, 1.0], + [0.0, 1.0, 1.0], + [1.3430843, 1.0004642, 1.0], + [-0.35947296, 1.0128645, 1.0], + [-1.0458986, 1.0115215, 1.0], + [-1.5997602, 1.0003799, 1.0], + [0.0, 1.0, 1.0], + [1.5115094, 1.0079246, 1.0], + [-0.35197452, 1.0135599, 1.0], + [-1.0123414, 1.0121523, 1.0], + [-1.5962777, 1.0062771, 1.0], + [0.0, 1.0, 1.0], + [2.2201242, 1.0069045, 1.0], + [-0.05537218, 1.0074033, 1.0], + [-0.9185825, 1.0038182, 1.0], + [-1.3931786, 0.99976593, 1.0], + [0.0, 1.0, 1.0], + [2.6776147, 1.0080688, 1.0], + [0.7692571, 1.0131639, 1.0], + [-0.5027828, 1.0076567, 1.0], + [-1.2549909, 0.9997216, 1.0], + [0.0, 1.0, 1.0], + [2.5154178, 0.996633, 1.0], + [0.9251866, 1.0093694, 1.0], + [-0.16704215, 1.0091307, 1.0], + [-0.9250501, 1.0001701, 1.0], + [0.0, 1.0, 1.0], + [2.786738, 0.98252994, 1.0], + [1.17775, 0.9951249, 1.0], + [-0.040805407, 0.995746, 1.0], + [-0.8885909, 0.9822554, 1.0], + [0.0, 1.0, 1.0], + [2.4449787, 0.9717951, 1.0], + [1.5592619, 0.9895185, 1.0], + [0.8975454, 0.97139394, 1.0], + [0.8434216, 0.91258097, 1.0], + [0.0, 1.0, 1.0], + [1.3327093, 0.96855474, 1.0], + [1.2922856, 0.9951774, 1.0], + [1.1538825, 0.95140374, 1.0], + [1.0445648, 0.882907, 1.0], + [0.0, 1.0, 1.0], + [1.3042054, 0.97626907, 1.0], + [1.2261003, 1.0032405, 1.0], + [1.1920351, 0.95930433, 1.0], + [1.0248796, 0.87122154, 1.0], + [0.0, 1.0, 1.0], + [1.1218151, 0.99428594, 1.0], + [0.9402701, 1.0072844, 1.0], + [1.2057712, 0.9790373, 1.0], + [1.715661, 0.9378413, 1.0], + [0.0, 1.0, 1.0], + [-0.66689795, 0.9936543, 1.0], + [-0.3043008, 1.0076888, 1.0], + [0.8821287, 0.98223394, 1.0], + [1.5125725, 0.9544597, 1.0], + [0.0, 1.0, 1.0], + [-2.1180842, 1.007544, 1.0], + [-0.6135006, 1.0289868, 1.0], + [1.3090985, 1.0115852, 1.0], + [2.0874233, 0.9913464, 1.0], + [0.0, 1.0, 1.0], + [-3.3278608, 1.03237, 1.0], + [-0.34648758, 1.0507596, 1.0], + [1.7782654, 1.0327485, 1.0], + [2.468833, 1.0156778, 1.0], + [0.0, 1.0, 1.0], + [-4.748517, 1.0421953, 1.0], + [-0.9545988, 1.0644007, 1.0], + [1.0075394, 1.0452629, 1.0], + [1.8186473, 1.0213822, 1.0], + [0.0, 1.0, 1.0], + [-5.6511426, 1.04099, 1.0], + [-2.212496, 1.0738522, 1.0], + [-0.5295884, 1.058841, 1.0], + [0.39001313, 1.0159221, 1.0], + [0.0, 1.0, 1.0], + [-7.2649274, 1.0555432, 1.0], + [-4.1198425, 1.08102, 1.0], + [-2.3006728, 1.0733076, 1.0], + [-0.51555675, 1.0098453, 1.0], + [0.0, 1.0, 1.0], + [-8.993211, 1.0847929, 1.0], + [-5.831633, 1.0979817, 1.0], + [-3.0940223, 1.066904, 1.0], + [-0.9517784, 0.9851261, 1.0], + [0.0, 1.0, 1.0], + [-10.3772, 1.0938902, 1.0], + [-6.41151, 1.0909004, 1.0], + [-1.8480648, 1.0164669, 1.0], + [-0.16703343, 0.9933557, 1.0], + [0.0, 1.0, 1.0], + [-11.117343, 1.0931859, 1.0], + [-5.9912677, 1.0973685, 1.0], + [-0.5732509, 1.0049413, 1.0], + [0.08346253, 0.995802, 1.0], + [0.0, 1.0, 1.0], + [-12.642463, 1.1002907, 1.0], + [-6.1474624, 1.0736011, 1.0], + [-0.3243047, 0.9991013, 1.0], + [0.016711053, 0.99943656, 1.0], + [0.0, 1.0, 1.0], + [-13.478187, 1.0329441, 1.0], + [-4.5098143, 0.9993377, 1.0], + [-0.088909596, 0.99232316, 1.0], + [0.0015094848, 0.9998537, 1.0], + [0.0, 1.0, 1.0], + [-14.786204, 0.9694178, 1.0], + [-6.1401577, 0.9893014, 1.0], + [-0.16727397, 1.0001292, 1.0], + [-0.0004984068, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-15.986527, 0.92521626, 1.0], + [-7.2159486, 0.9676191, 1.0], + [-0.17465894, 0.99944806, 1.0], + [-0.00039394363, 0.9999998, 1.0], + [0.0, 1.0, 1.0], + [-16.760332, 0.91941845, 1.0], + [-7.364063, 0.9667283, 1.0], + [-0.14445107, 0.9989603, 1.0], + [0.0004303302, 0.9999847, 1.0], + [0.0, 1.0, 1.0], + [-15.557542, 0.91452426, 1.0], + [-7.8338785, 0.9618465, 1.0], + [-0.3343677, 0.9981042, 1.0], + [ + -4.918289e-05, + 0.99998575, + 1.0 + ], + [0.0, 1.0, 1.0], + [-15.201462, 0.9339844, 1.0], + [-10.613217, 0.96940356, 1.0], + [-1.136429, 0.99680275, 1.0], + [-0.0021724077, 0.99999255, 1.0], + [0.0, 1.0, 1.0], + [-15.266676, 0.95900613, 1.0], + [-12.692006, 0.9837428, 1.0], + [-2.1463373, 0.9947698, 1.0], + [-0.005627853, 0.9999566, 1.0], + [0.0, 1.0, 1.0], + [-16.444113, 0.95752037, 1.0], + [-13.965291, 0.97963464, 1.0], + [-2.2071412, 0.9926996, 1.0], + [-0.0063773347, 0.99993426, 1.0], + [0.0, 1.0, 1.0], + [-16.428102, 0.944903, 1.0], + [-13.466559, 0.9741484, 1.0], + [-1.5537314, 0.99316627, 1.0], + [-0.0054058074, 0.99994093, 1.0], + [0.0, 1.0, 1.0], + [-15.743757, 0.9297916, 1.0], + [-11.245777, 0.96999633, 1.0], + [-1.0017846, 0.9963959, 1.0], + [-0.0027330904, 0.99998176, 1.0], + [0.0, 1.0, 1.0], + [-14.582258, 0.92690057, 1.0], + [-9.555166, 0.9580048, 1.0], + [-0.91683203, 0.9949841, 1.0], + [-0.0023285556, 0.9999827, 1.0], + [0.0, 1.0, 1.0], + [-13.66012, 0.9144587, 1.0], + [-8.907967, 0.96176255, 1.0], + [-0.75339365, 0.9969989, 1.0], + [-0.0020785667, 0.99999136, 1.0], + [0.0, 1.0, 1.0], + [-14.766155, 0.8885144, 1.0], + [-8.151366, 0.95314914, 1.0], + [-0.43513343, 0.9979274, 1.0], + [-0.0013458556, 0.99999374, 1.0], + [0.0, 1.0, 1.0], + [-12.756684, 0.84383404, 1.0], + [-8.069312, 0.9194366, 1.0], + [-0.73601234, 0.9940366, 1.0], + [-0.006278685, 0.99995303, 1.0], + [0.0, 1.0, 1.0], + [-12.3996105, 0.78645676, 1.0], + [-7.4046135, 0.88479775, 1.0], + [-0.60771656, 0.99156505, 1.0], + [-0.005061967, 0.9999345, 1.0], + [0.0, 1.0, 1.0], + [-9.395499, 0.7727399, 1.0], + [-6.246277, 0.86766297, 1.0], + [-0.77940494, 0.98375356, 1.0], + [-0.007498043, 0.9998306, 1.0], + [0.0, 1.0, 1.0], + [-8.069854, 0.7712122, 1.0], + [-5.7335796, 0.8622934, 1.0], + [-0.7198741, 0.98598284, 1.0], + [-0.008054674, 0.9998355, 1.0], + [0.0, 1.0, 1.0], + [-7.1986036, 0.78911334, 1.0], + [-4.2859526, 0.8467513, 1.0], + [-0.30990627, 0.98300517, 1.0], + [-0.001539077, 0.99987894, 1.0], + [0.0, 1.0, 1.0], + [-4.362009, 0.862973, 1.0], + [-1.2797484, 0.89488745, 1.0], + [0.57525235, 0.9574352, 1.0], + [0.020433621, 0.9993585, 1.0], + [0.0, 1.0, 1.0], + [-0.44913286, 0.92161065, 1.0], + [2.1760025, 0.9585874, 1.0], + [3.1253667, 0.97889954, 1.0], + [0.5430722, 0.998097, 1.0], + [0.0, 1.0, 1.0], + [1.5043082, 0.92482764, 1.0], + [2.4694016, 0.9740288, 1.0], + [2.8363268, 0.99162847, 1.0], + [0.7368365, 0.9988949, 1.0], + [0.0, 1.0, 1.0], + [1.2908894, 0.9381854, 1.0], + [1.9291751, 0.9809296, 1.0], + [3.2904375, 1.0165812, 1.0], + [1.4833065, 1.0123935, 1.0], + [0.0, 1.0, 1.0], + [0.42789957, 0.9608102, 1.0], + [2.0859613, 1.0199693, 1.0], + [5.4975877, 1.0738639, 1.0], + [5.1542764, 1.0715116, 1.0], + [0.0, 1.0, 1.0], + [0.041113988, 0.91577923, 1.0], + [2.5786953, 1.0219108, 1.0], + [5.4007845, 1.0863035, 1.0], + [5.081802, 1.0821114, 1.0], + [0.0, 1.0, 1.0], + [-0.7529762, 0.8233519, 1.0], + [0.38908187, 0.9149488, 1.0], + [2.9440103, 1.0415642, 1.0], + [1.2238824, 1.0184511, 1.0], + [0.0, 1.0, 1.0], + [-1.0758475, 0.8046038, 1.0], + [-0.122891106, 0.8494543, 1.0], + [0.6972854, 0.9855657, 1.0], + [0.0899615, 0.9960086, 1.0], + [0.0, 1.0, 1.0], + [-2.1830697, 0.79670805, 1.0], + [-0.57822895, 0.83725864, 1.0], + [0.27999595, 0.9634356, 1.0], + [0.011383898, 0.99835503, 1.0], + [0.0, 1.0, 1.0], + [-2.842725, 0.77273744, 1.0], + [-0.43526837, 0.85823804, 1.0], + [-0.0893142, 0.9760571, 1.0], + [-0.014267705, 0.99880594, 1.0], + [0.0, 1.0, 1.0], + [-2.5974367, 0.7690728, 1.0], + [-0.6484193, 0.92034054, 1.0], + [-0.26290956, 1.0013702, 1.0], + [-0.007495878, 1.0000707, 1.0], + [0.0, 1.0, 1.0], + [-2.6498926, 0.7932041, 1.0], + [-0.8644455, 0.94679594, 1.0], + [-0.3005925, 1.001227, 1.0], + [-0.0067892326, 1.0000719, 1.0], + [0.0, 1.0, 1.0], + [-1.1335323, 0.85572964, 1.0], + [0.07130498, 0.97290087, 1.0], + [-0.052292693, 1.0024012, 1.0], + [-0.0014092727, 1.0000482, 1.0], + [0.0, 1.0, 1.0], + [3.0666177, 0.8645106, 1.0], + [2.0084338, 0.9645754, 1.0], + [0.19086616, 0.99913764, 1.0], + [0.0013613411, 1.000007, 1.0], + [0.0, 1.0, 1.0], + [5.092513, 0.8992678, 1.0], + [2.0034046, 0.97106105, 1.0], + [0.21043883, 0.99723476, 1.0], + [0.0017143496, 0.9999766, 1.0], + [0.0, 1.0, 1.0], + [3.0797167, 0.9267911, 1.0], + [0.22682886, 0.9934631, 1.0], + [-0.04511432, 1.0006658, 1.0], + [-0.0003056895, 1.000005, 1.0], + [0.0, 1.0, 1.0], + [3.0111675, 0.94898635, 1.0], + [1.3779145, 1.0007775, 1.0], + [0.07663839, 1.0008199, 1.0], + [0.00024265566, 1.0000049, 1.0], + [0.0, 1.0, 1.0], + [3.3374429, 0.95532244, 1.0], + [0.7815224, 0.9982896, 1.0], + [0.02490354, 1.0002367, 1.0], + [ + 7.3306524e-05, + 1.0000012, + 1.0 + ], + [0.0, 1.0, 1.0], + [1.6006174, 0.96363807, 1.0], + [0.8803053, 0.99620026, 1.0], + [0.03250935, 1.0000377, 1.0], + [ + 7.561181e-05, + 1.0000002, + 1.0 + ], + [0.0, 1.0, 1.0], + [1.2088186, 0.9428956, 1.0], + [-0.21422692, 0.9855742, 1.0], + [-0.030162197, 0.9994372, 1.0], + [-0.00015745845, 0.99999803, 1.0], + [0.0, 1.0, 1.0], + [5.1379385, 0.9243572, 1.0], + [0.5781135, 0.98177475, 1.0], + [-0.04092876, 0.9992675, 1.0], + [-0.00032025157, 0.9999967, 1.0], + [0.0, 1.0, 1.0], + [6.595079, 0.90528804, 1.0], + [2.2092948, 0.97474945, 1.0], + [0.051008992, 0.9995308, 1.0], + [-0.000108188826, 0.9999993, 1.0], + [0.0, 1.0, 1.0], + [4.917749, 0.9147641, 1.0], + [-0.3735081, 0.9730886, 1.0], + [-0.24302472, 0.9982874, 1.0], + [-0.002041071, 0.99998975, 1.0], + [0.0, 1.0, 1.0], + [5.282022, 0.9485167, 1.0], + [-0.5685607, 0.985533, 1.0], + [-0.31762916, 0.9988836, 1.0], + [-0.002783568, 0.99999034, 1.0], + [0.0, 1.0, 1.0], + [4.8673153, 0.9184925, 1.0], + [0.37535796, 0.9620167, 1.0], + [0.03746142, 0.9971347, 1.0], + [0.0030534149, 1.0000186, 1.0], + [0.0, 1.0, 1.0], + [7.4104285, 0.91220593, 1.0], + [0.87249917, 0.95596325, 1.0], + [-0.48440197, 0.99392086, 1.0], + [-0.019409077, 0.9999061, 1.0], + [0.0, 1.0, 1.0], + [6.944506, 0.9487564, 1.0], + [0.5558285, 0.9802875, 1.0], + [-1.4248892, 1.0021002, 1.0], + [-0.15107189, 1.0004932, 1.0], + [0.0, 1.0, 1.0], + [7.4704447, 0.9741639, 1.0], + [-2.879535, 1.0111853, 1.0], + [-4.005509, 1.0224288, 1.0], + [-2.128151, 1.013158, 1.0], + [0.0, 1.0, 1.0], + [7.3452177, 1.0056307, 1.0], + [-3.1270623, 1.0118408, 1.0], + [-4.1959133, 1.0224493, 1.0], + [-2.526543, 1.0163975, 1.0], + [0.0, 1.0, 1.0], + [-3.885383, 0.89095396, 1.0], + [-6.0336304, 0.9250444, 1.0], + [-4.8112493, 0.9800796, 1.0], + [-1.296847, 1.0041145, 1.0], + [0.0, 1.0, 1.0], + [-1.1661447, 0.89456373, 1.0], + [-2.9906945, 0.92001104, 1.0], + [-1.5090454, 0.99253356, 1.0], + [-1.2905122, 0.96820384, 1.0], + [0.0, 1.0, 1.0], + [-0.34511527, 0.87384003, 1.0], + [-1.0981119, 0.935078, 1.0], + [-1.5757431, 0.97081584, 1.0], + [-1.8983483, 0.9408328, 1.0], + [0.0, 1.0, 1.0], + [-0.63075507, 0.87999564, 1.0], + [-0.70884484, 0.933653, 1.0], + [-1.3498812, 0.95319027, 1.0], + [-2.5344388, 0.93276036, 1.0], + [0.0, 1.0, 1.0], + [0.7205443, 0.90210545, 1.0], + [0.062813476, 0.93098074, 1.0], + [-0.51643556, 0.9495486, 1.0], + [-1.4865465, 0.9489686, 1.0], + [0.0, 1.0, 1.0], + [0.76368576, 0.89917886, 1.0], + [0.10259387, 0.9347847, 1.0], + [-0.40181014, 0.95541286, 1.0], + [-0.8396916, 0.9608325, 1.0], + [0.0, 1.0, 1.0], + [1.0973828, 0.91251415, 1.0], + [0.22598426, 0.94344103, 1.0], + [-0.28614137, 0.9616241, 1.0], + [-0.73642546, 0.9707769, 1.0], + [0.0, 1.0, 1.0], + [1.713114, 0.91217285, 1.0], + [0.56806993, 0.93098885, 1.0], + [-0.07742618, 0.949184, 1.0], + [-0.55610317, 0.9599895, 1.0], + [0.0, 1.0, 1.0], + [2.2137406, 0.9127354, 1.0], + [1.1848869, 0.9180361, 1.0], + [0.20251377, 0.93949586, 1.0], + [-0.40885153, 0.94527155, 1.0], + [0.0, 1.0, 1.0], + [2.0143402, 0.91241056, 1.0], + [1.3650892, 0.9393329, 1.0], + [0.6422094, 0.9600322, 1.0], + [0.011677059, 0.95110327, 1.0], + [0.0, 1.0, 1.0], + [2.0051758, 0.8948882, 1.0], + [1.3498341, 0.9142505, 1.0], + [0.893142, 0.93700206, 1.0], + [0.27319187, 0.9396592, 1.0], + [0.0, 1.0, 1.0], + [1.1864032, 0.887447, 1.0], + [1.0613244, 0.89364576, 1.0], + [1.2507672, 0.89813834, 1.0], + [1.0013293, 0.86100835, 1.0], + [0.0, 1.0, 1.0], + [-0.5468271, 0.87609977, 1.0], + [0.2019898, 0.90091765, 1.0], + [1.2255975, 0.87712115, 1.0], + [1.2425985, 0.83051956, 1.0], + [0.0, 1.0, 1.0], + [-1.6023275, 0.8365268, 1.0], + [-0.24021581, 0.90386933, 1.0], + [0.9349438, 0.87908584, 1.0], + [0.80752313, 0.8075074, 1.0], + [0.0, 1.0, 1.0], + [-2.221242, 0.8157836, 1.0], + [-0.39425692, 0.92637694, 1.0], + [0.8056268, 0.93297124, 1.0], + [1.3850795, 0.88745916, 1.0], + [0.0, 1.0, 1.0], + [-3.3293705, 0.8298025, 1.0], + [-1.2209973, 0.939168, 1.0], + [0.27317744, 0.9352172, 1.0], + [0.54171324, 0.923213, 1.0], + [0.0, 1.0, 1.0], + [-4.3448424, 0.8548377, 1.0], + [-1.4867651, 0.96641356, 1.0], + [0.6099653, 0.9759955, 1.0], + [0.59837306, 0.95445424, 1.0], + [0.0, 1.0, 1.0], + [-5.492541, 0.88700557, 1.0], + [-1.374813, 0.9962723, 1.0], + [1.030289, 1.0119152, 1.0], + [1.5211387, 0.99829376, 1.0], + [0.0, 1.0, 1.0], + [-5.779272, 0.918289, 1.0], + [-1.9571413, 0.99485767, 1.0], + [-0.3243052, 0.98670727, 1.0], + [0.08840849, 0.96744764, 1.0], + [0.0, 1.0, 1.0], + [-5.946428, 0.92973804, 1.0], + [-3.30334, 0.94437987, 1.0], + [-2.2054818, 0.92658573, 1.0], + [-1.9096534, 0.9025644, 1.0], + [0.0, 1.0, 1.0], + [-7.0771604, 0.938371, 1.0], + [-4.9650874, 0.9262841, 1.0], + [-5.804127, 0.87206984, 1.0], + [-10.723357, 0.7699574, 1.0], + [0.0, 1.0, 1.0], + [-8.103424, 0.9506851, 1.0], + [-7.6311193, 0.9232969, 1.0], + [-13.356695, 0.7412918, 1.0], + [-18.471521, 0.6411481, 1.0], + [0.0, 1.0, 1.0], + [-8.404529, 0.9482244, 1.0], + [-5.100853, 0.9020949, 1.0], + [-3.5057256, 0.7494264, 1.0], + [-5.474798, 0.7397798, 1.0], + [0.0, 1.0, 1.0], + [-8.278467, 0.9477641, 1.0], + [-1.6004895, 0.8717935, 1.0], + [3.8334162, 0.7214794, 1.0], + [3.0950382, 0.8104765, 1.0], + [0.0, 1.0, 1.0], + [-8.883697, 0.95728064, 1.0], + [0.22832659, 0.8919602, 1.0], + [3.9137595, 0.8611687, 1.0], + [1.0353252, 0.95515287, 1.0], + [0.0, 1.0, 1.0], + [-8.987924, 0.9267598, 1.0], + [-0.7117046, 0.9237348, 1.0], + [1.0760044, 0.9687251, 1.0], + [0.19669743, 0.9952294, 1.0], + [0.0, 1.0, 1.0], + [-11.256816, 0.895007, 1.0], + [-3.5309718, 0.9615622, 1.0], + [-0.049140226, 0.9978403, 1.0], + [0.003940524, 0.9998941, 1.0], + [0.0, 1.0, 1.0], + [-12.83839, 0.8533601, 1.0], + [-3.8871167, 0.9450403, 1.0], + [-0.04521785, 0.99777806, 1.0], + [0.0017813657, 0.99995136, 1.0], + [0.0, 1.0, 1.0], + [-10.2904825, 0.87262124, 1.0], + [-0.73294914, 0.93938565, 1.0], + [0.94196904, 0.9771655, 1.0], + [0.050941736, 0.9988109, 1.0], + [0.0, 1.0, 1.0], + [-10.646717, 0.87451744, 1.0], + [-1.9415165, 0.94662905, 1.0], + [0.6730928, 0.9824702, 1.0], + [0.04025293, 0.99905807, 1.0], + [0.0, 1.0, 1.0], + [-11.218059, 0.87239087, 1.0], + [-4.397181, 0.9521776, 1.0], + [-0.14049566, 0.9981121, 1.0], + [0.00026845204, 0.99998254, 1.0], + [0.0, 1.0, 1.0], + [-10.286129, 0.89371806, 1.0], + [-5.3461366, 0.96262884, 1.0], + [-0.24486518, 0.9982041, 1.0], + [-0.00065676763, 0.9999931, 1.0], + [0.0, 1.0, 1.0], + [-9.945648, 0.8883441, 1.0], + [-5.1836414, 0.97065026, 1.0], + [-0.20957097, 0.99866295, 1.0], + [-0.0005872021, 0.9999922, 1.0], + [0.0, 1.0, 1.0], + [-8.575455, 0.85818356, 1.0], + [-3.7089288, 0.96788406, 1.0], + [-0.118012555, 0.99915797, 1.0], + [-0.0004706569, 0.99999267, 1.0], + [0.0, 1.0, 1.0], + [-6.362028, 0.81099313, 1.0], + [-1.9053118, 0.9541133, 1.0], + [-0.02172425, 0.9980922, 1.0], + [0.00013715065, 0.9999891, 1.0], + [0.0, 1.0, 1.0], + [-6.124928, 0.8288149, 1.0], + [-2.1002314, 0.9534652, 1.0], + [-0.041706882, 0.99746007, 1.0], + [0.00017853655, 0.9999867, 1.0], + [0.0, 1.0, 1.0], + [-5.7694325, 0.8521812, 1.0], + [-2.324253, 0.9688911, 1.0], + [-0.09758008, 0.9992199, 1.0], + [-0.0002588657, 0.999998, 1.0], + [0.0, 1.0, 1.0], + [-6.6154294, 0.8325412, 1.0], + [-1.5618141, 0.9723158, 1.0], + [-0.043864716, 0.999613, 1.0], + [-0.00013398798, 0.9999991, 1.0], + [0.0, 1.0, 1.0], + [-6.2191544, 0.77018803, 1.0], + [-1.651971, 0.9547857, 1.0], + [-0.07113765, 0.9991722, 1.0], + [-0.00058576465, 0.99999535, 1.0], + [0.0, 1.0, 1.0], + [-6.46673, 0.72007173, 1.0], + [-2.0999687, 0.9299423, 1.0], + [-0.060667057, 0.99887276, 1.0], + [-0.00039300308, 0.9999954, 1.0], + [0.0, 1.0, 1.0], + [-2.3606741, 0.70400625, 1.0], + [-1.1170449, 0.90795213, 1.0], + [-0.03983348, 0.9983942, 1.0], + [-0.0002703255, 0.99999326, 1.0], + [0.0, 1.0, 1.0], + [-1.6689389, 0.665112, 1.0], + [-1.5962267, 0.8485116, 1.0], + [-0.071202874, 0.9962268, 1.0], + [-0.00039650878, 0.9999874, 1.0], + [0.0, 1.0, 1.0], + [-2.192851, 0.6639008, 1.0], + [-1.9858385, 0.77799946, 1.0], + [-0.08988286, 0.9801069, 1.0], + [0.0011868065, 0.99986833, 1.0], + [0.0, 1.0, 1.0], + [-1.2615559, 0.7249733, 1.0], + [-0.09431905, 0.7996679, 1.0], + [0.35236925, 0.902335, 1.0], + [0.014255428, 0.9975748, 1.0], + [0.0, 1.0, 1.0], + [1.5921153, 0.8544056, 1.0], + [2.169442, 0.8899757, 1.0], + [1.4621226, 0.8872571, 1.0], + [0.09632246, 0.9899705, 1.0], + [0.0, 1.0, 1.0], + [1.8741682, 0.87441283, 1.0], + [2.3036768, 0.93104565, 1.0], + [1.8774738, 0.9538973, 1.0], + [0.09678561, 0.99769336, 1.0], + [0.0, 1.0, 1.0], + [1.6764153, 0.8590117, 1.0], + [2.3436446, 0.93695617, 1.0], + [2.9313862, 0.9975119, 1.0], + [0.7593898, 1.0038697, 1.0], + [0.0, 1.0, 1.0], + [1.5126485, 0.8899826, 1.0], + [3.1817915, 0.9964198, 1.0], + [4.897578, 1.0497705, 1.0], + [3.8805265, 1.0446904, 1.0], + [0.0, 1.0, 1.0], + [1.2394414, 0.8644259, 1.0], + [3.212901, 1.0230495, 1.0], + [4.632083, 1.0699781, 1.0], + [3.9237301, 1.0605509, 1.0], + [0.0, 1.0, 1.0], + [0.5773077, 0.82117635, 1.0], + [1.2358187, 0.97362995, 1.0], + [2.1459916, 1.0216151, 1.0], + [0.7130695, 1.0066472, 1.0], + [0.0, 1.0, 1.0], + [0.42648524, 0.82499254, 1.0], + [0.69195044, 0.9376137, 1.0], + [0.50218654, 0.9798264, 1.0], + [0.12657197, 0.9915114, 1.0], + [0.0, 1.0, 1.0], + [-0.7465619, 0.86613846, 1.0], + [0.25071248, 0.94280666, 1.0], + [0.06618124, 0.98196954, 1.0], + [0.008201971, 0.9988316, 1.0], + [0.0, 1.0, 1.0], + [-0.30600023, 0.8721118, 1.0], + [0.4314889, 0.94861966, 1.0], + [0.009828173, 0.9878695, 1.0], + [-0.006427021, 0.9995776, 1.0], + [0.0, 1.0, 1.0], + [0.5700442, 0.89965767, 1.0], + [-0.05676093, 0.9809169, 1.0], + [-0.15799609, 1.001165, 1.0], + [-0.005288328, 1.0000738, 1.0], + [0.0, 1.0, 1.0], + [0.82145816, 0.9085153, 1.0], + [-0.31160617, 0.9824265, 1.0], + [-0.2175388, 1.0011835, 1.0], + [-0.004642329, 1.0000556, 1.0], + [0.0, 1.0, 1.0], + [0.453476, 0.92736363, 1.0], + [0.20573618, 0.9796587, 1.0], + [-0.02427894, 0.9999128, 1.0], + [-0.00065988675, 1.0000101, 1.0], + [0.0, 1.0, 1.0], + [0.7588439, 0.9440933, 1.0], + [0.684478, 0.9797199, 1.0], + [0.072028, 0.9987622, 1.0], + [0.0004616782, 0.9999967, 1.0], + [0.0, 1.0, 1.0], + [1.706155, 0.96692014, 1.0], + [0.67578906, 0.99002194, 1.0], + [0.055119734, 0.99926174, 1.0], + [0.0003961114, 0.99999535, 1.0], + [0.0, 1.0, 1.0], + [0.7588563, 0.9780419, 1.0], + [-0.09298713, 0.99886334, 1.0], + [-0.01754928, 1.0001751, 1.0], + [-0.00010249701, 1.0000014, 1.0], + [0.0, 1.0, 1.0], + [0.8756794, 0.9665713, 1.0], + [0.21679287, 0.9911038, 1.0], + [0.010155896, 0.9995159, 1.0], + [ + 2.8347398e-05, + 0.9999979, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.7446922, 0.9719108, 1.0], + [0.13179037, 0.9942989, 1.0], + [0.005878921, 0.99972135, 1.0], + [ + 2.3439836e-05, + 0.99999875, + 1.0 + ], + [0.0, 1.0, 1.0], + [-0.03203987, 0.9929243, 1.0], + [0.038900487, 0.99922925, 1.0], + [0.0021857598, 0.9999744, 1.0], + [ + 5.484122e-06, + 0.9999999, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.4139118, 0.96163833, 1.0], + [-0.21519373, 0.9870976, 1.0], + [-0.020248298, 0.9992978, 1.0], + [ + -9.694598e-05, + 0.99999726, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.1697552, 0.9555298, 1.0], + [-0.3862619, 0.98755735, 1.0], + [-0.044676315, 0.9992296, 1.0], + [-0.0002547122, 0.9999961, 1.0], + [0.0, 1.0, 1.0], + [0.12476056, 0.9306088, 1.0], + [0.16077553, 0.9884132, 1.0], + [0.004473155, 0.9998182, 1.0], + [ + -1.2910279e-05, + 0.9999996, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.34852365, 0.9528473, 1.0], + [-0.21723595, 0.9923173, 1.0], + [-0.032549698, 0.99970585, 1.0], + [-0.00026074066, 0.99999833, 1.0], + [0.0, 1.0, 1.0], + [-0.93782365, 0.9629782, 1.0], + [-0.97855735, 0.9888486, 1.0], + [-0.09944643, 0.9992393, 1.0], + [-0.0006539717, 0.99999535, 1.0], + [0.0, 1.0, 1.0], + [-1.6884682, 0.9228505, 1.0], + [-1.57592, 0.95246685, 1.0], + [-0.1084121, 0.9967328, 1.0], + [0.00042106112, 0.9999968, 1.0], + [0.0, 1.0, 1.0], + [0.49395004, 0.9275926, 1.0], + [-1.7809842, 0.9529799, 1.0], + [-0.6778947, 0.9934386, 1.0], + [-0.02107622, 0.99988353, 1.0], + [0.0, 1.0, 1.0], + [0.09461049, 0.91591424, 1.0], + [-2.842822, 0.9585913, 1.0], + [-2.0642266, 1.0008389, 1.0], + [-0.19923098, 1.0007129, 1.0], + [0.0, 1.0, 1.0], + [-1.4209429, 0.9632619, 1.0], + [-4.301751, 1.0129267, 1.0], + [-4.282477, 1.0219837, 1.0], + [-2.4989915, 1.014272, 1.0], + [0.0, 1.0, 1.0], + [-2.8516593, 0.94976884, 1.0], + [-4.918388, 0.9980298, 1.0], + [-4.6032376, 1.0179507, 1.0], + [-2.9246483, 1.0153408, 1.0], + [0.0, 1.0, 1.0], + [-5.434877, 0.8649714, 1.0], + [-6.194555, 0.86495477, 1.0], + [-2.6064827, 0.9455794, 1.0], + [0.18676785, 0.99121183, 1.0], + [0.0, 1.0, 1.0], + [-3.0596585, 0.88212174, 1.0], + [-0.39083886, 0.8480833, 1.0], + [7.699114, 0.8111516, 1.0], + [4.9935384, 0.8855417, 1.0], + [0.0, 1.0, 1.0], + [1.1838806, 0.8388054, 1.0], + [7.2978683, 0.7670886, 1.0], + [8.46766, 0.78871816, 1.0], + [2.179444, 0.9128828, 1.0], + [0.0, 1.0, 1.0], + [1.2858877, 0.8069495, 1.0], + [1.8508788, 0.7823992, 1.0], + [0.85906374, 0.84280854, 1.0], + [-0.8319525, 0.9454967, 1.0], + [0.0, 1.0, 1.0], + [1.1529174, 0.7913341, 1.0], + [1.0985173, 0.77566886, 1.0], + [0.7970306, 0.8064273, 1.0], + [-0.13033624, 0.95262057, 1.0], + [0.0, 1.0, 1.0], + [1.2466996, 0.7730009, 1.0], + [1.4481469, 0.79517776, 1.0], + [1.4872804, 0.8149948, 1.0], + [0.50854456, 0.9299336, 1.0], + [0.0, 1.0, 1.0], + [2.0708513, 0.77292246, 1.0], + [1.9574028, 0.80550534, 1.0], + [1.8944045, 0.8277315, 1.0], + [0.885336, 0.9109165, 1.0], + [0.0, 1.0, 1.0], + [2.8570101, 0.7737381, 1.0], + [2.1413271, 0.81192267, 1.0], + [1.4845073, 0.8555065, 1.0], + [0.67854315, 0.9129586, 1.0], + [0.0, 1.0, 1.0], + [3.5446448, 0.7237353, 1.0], + [3.1861176, 0.7315414, 1.0], + [2.4211752, 0.7813792, 1.0], + [1.090997, 0.862102, 1.0], + [0.0, 1.0, 1.0], + [3.3204086, 0.69664717, 1.0], + [3.060574, 0.7266127, 1.0], + [2.3519804, 0.81517905, 1.0], + [0.86642855, 0.92962444, 1.0], + [0.0, 1.0, 1.0], + [2.7798967, 0.6820568, 1.0], + [2.8921554, 0.7140203, 1.0], + [3.4070027, 0.7793045, 1.0], + [1.9032202, 0.9059005, 1.0], + [0.0, 1.0, 1.0], + [1.8094573, 0.6692679, 1.0], + [3.514479, 0.6703009, 1.0], + [3.2144916, 0.7359185, 1.0], + [0.7362445, 0.8973399, 1.0], + [0.0, 1.0, 1.0], + [0.8017239, 0.7016305, 1.0], + [4.4307218, 0.6483909, 1.0], + [1.597159, 0.63064265, 1.0], + [-0.7608446, 0.6738216, 1.0], + [0.0, 1.0, 1.0], + [-0.70194286, 0.71021265, 1.0], + [4.076452, 0.64545816, 1.0], + [2.4849653, 0.58867955, 1.0], + [-0.3030934, 0.5878087, 1.0], + [0.0, 1.0, 1.0], + [-2.423018, 0.7011179, 1.0], + [0.867048, 0.7857347, 1.0], + [1.8778415, 0.7980667, 1.0], + [0.994016, 0.74506015, 1.0], + [0.0, 1.0, 1.0], + [-3.9815702, 0.71286356, 1.0], + [-1.3673462, 0.86698985, 1.0], + [-0.31344816, 0.9293362, 1.0], + [-0.5929167, 0.9181718, 1.0], + [0.0, 1.0, 1.0], + [-4.8956604, 0.7347033, 1.0], + [-1.5237117, 0.8810682, 1.0], + [-1.8345463, 0.87915176, 1.0], + [-4.8288293, 0.8226873, 1.0], + [0.0, 1.0, 1.0], + [-5.5750155, 0.75573695, 1.0], + [-2.32776, 0.83855045, 1.0], + [-7.9848466, 0.7422793, 1.0], + [-11.596745, 0.70366794, 1.0], + [0.0, 1.0, 1.0], + [-4.497558, 0.7963771, 1.0], + [-2.6339295, 0.79546416, 1.0], + [-6.573479, 0.6898624, 1.0], + [-10.560926, 0.66375583, 1.0], + [0.0, 1.0, 1.0], + [-4.5946136, 0.8173761, 1.0], + [-3.3373203, 0.80128264, 1.0], + [-1.2990564, 0.73161983, 1.0], + [-0.411279, 0.7264181, 1.0], + [0.0, 1.0, 1.0], + [-4.839973, 0.827455, 1.0], + [-8.91897, 0.7633143, 1.0], + [-13.195236, 0.68692213, 1.0], + [-15.08099, 0.667959, 1.0], + [0.0, 1.0, 1.0], + [-7.8307505, 0.81448054, 1.0], + [-18.429901, 0.6384616, 1.0], + [-21.627077, 0.5857334, 1.0], + [-21.719206, 0.57701963, 1.0], + [0.0, 1.0, 1.0], + [-5.3877606, 0.87045765, 1.0], + [-11.953717, 0.6845442, 1.0], + [-14.84228, 0.6076244, 1.0], + [-14.956815, 0.59963316, 1.0], + [0.0, 1.0, 1.0], + [-2.2047, 0.89726675, 1.0], + [-0.18282485, 0.7400877, 1.0], + [-0.45509633, 0.6334427, 1.0], + [-4.008627, 0.66572005, 1.0], + [0.0, 1.0, 1.0], + [-0.6257489, 0.8809391, 1.0], + [4.4138246, 0.7427594, 1.0], + [6.3582187, 0.7134476, 1.0], + [2.7748961, 0.7984047, 1.0], + [0.0, 1.0, 1.0], + [-0.90505534, 0.92275494, 1.0], + [4.2922206, 0.83137786, 1.0], + [4.8596277, 0.8543205, 1.0], + [1.9056705, 0.939401, 1.0], + [0.0, 1.0, 1.0], + [-1.7767366, 0.95586765, 1.0], + [0.3932802, 0.9633871, 1.0], + [0.60861015, 0.97952, 1.0], + [0.036355883, 0.9975114, 1.0], + [0.0, 1.0, 1.0], + [-1.3862658, 0.93128735, 1.0], + [1.6583297, 0.9430399, 1.0], + [1.3840419, 0.9670389, 1.0], + [0.106311135, 0.99748856, 1.0], + [0.0, 1.0, 1.0], + [1.2226677, 0.91598487, 1.0], + [4.22652, 0.9187779, 1.0], + [1.5662466, 0.96636087, 1.0], + [0.076074705, 0.99824005, 1.0], + [0.0, 1.0, 1.0], + [-0.6022197, 0.9403912, 1.0], + [1.2155503, 0.962949, 1.0], + [0.8285263, 0.9807966, 1.0], + [0.044100095, 0.99897105, 1.0], + [0.0, 1.0, 1.0], + [-1.7198387, 0.9403748, 1.0], + [-0.6352572, 0.9795508, 1.0], + [-0.052131, 0.998167, 1.0], + [0.0001652719, 0.9999738, 1.0], + [0.0, 1.0, 1.0], + [-0.5542123, 0.9565107, 1.0], + [-0.109918736, 0.9894566, 1.0], + [-0.009283989, 0.999551, 1.0], + [ + -6.8360925e-05, + 0.9999978, + 1.0 + ], + [0.0, 1.0, 1.0], + [-0.7696542, 0.95847034, 1.0], + [-0.15155797, 0.9900539, 1.0], + [-0.06100524, 0.9982844, 1.0], + [-0.0020035189, 0.9999522, 1.0], + [0.0, 1.0, 1.0], + [-1.1908454, 0.9578101, 1.0], + [-1.193139, 0.9622795, 1.0], + [-0.66812485, 0.9839621, 1.0], + [-0.022401229, 0.99947536, 1.0], + [0.0, 1.0, 1.0], + [-0.7816994, 0.93443125, 1.0], + [0.6398307, 0.96510935, 1.0], + [-0.044041365, 0.9943015, 1.0], + [-0.003998923, 0.9998783, 1.0], + [0.0, 1.0, 1.0], + [-0.28142613, 0.93776274, 1.0], + [0.8375012, 0.9702105, 1.0], + [0.101512685, 0.997176, 1.0], + [0.00067303015, 0.9999818, 1.0], + [0.0, 1.0, 1.0], + [-0.8797605, 0.95135915, 1.0], + [-0.126102, 0.993933, 1.0], + [-0.003883867, 0.99978, 1.0], + [ + -8.009816e-06, + 0.99999905, + 1.0 + ], + [0.0, 1.0, 1.0], + [-2.6810584, 0.86311865, 1.0], + [-0.17959136, 0.9858264, 1.0], + [-0.0015349581, 0.9998344, 1.0], + [ + -8.907976e-06, + 0.99999964, + 1.0 + ], + [0.0, 1.0, 1.0], + [-5.445669, 0.7283394, 1.0], + [-0.6088305, 0.9556886, 1.0], + [-0.01624582, 0.9992147, 1.0], + [ + -9.05368e-05, + 0.9999974, + 1.0 + ], + [0.0, 1.0, 1.0], + [-6.5090322, 0.71607465, 1.0], + [-0.7856189, 0.9618718, 1.0], + [-0.0063526426, 0.99975485, 1.0], + [ + -1.7714505e-05, + 0.9999995, + 1.0 + ], + [0.0, 1.0, 1.0], + [-1.7068195, 0.77349, 1.0], + [-0.24131422, 0.9724856, 1.0], + [-0.0030667144, 0.99979204, 1.0], + [ + -4.3706127e-06, + 0.9999998, + 1.0 + ], + [0.0, 1.0, 1.0], + [-1.1626506, 0.7329441, 1.0], + [-0.49740094, 0.9462425, 1.0], + [-0.013326823, 0.999034, 1.0], + [ + -2.82295e-05, + 0.99999803, + 1.0 + ], + [0.0, 1.0, 1.0], + [-0.86920273, 0.6843783, 1.0], + [-0.8369342, 0.8680424, 1.0], + [-0.055886846, 0.99398565, 1.0], + [-0.00019886444, 0.99997324, 1.0], + [0.0, 1.0, 1.0], + [-2.0519063, 0.67568576, 1.0], + [-1.380502, 0.7593411, 1.0], + [-0.23168321, 0.93081456, 1.0], + [-0.0014318114, 0.99881375, 1.0], + [0.0, 1.0, 1.0], + [-0.6460104, 0.7239269, 1.0], + [-0.27629834, 0.769188, 1.0], + [-0.002961831, 0.8593207, 1.0], + [0.0041828407, 0.993566, 1.0], + [0.0, 1.0, 1.0], + [0.37849802, 0.775351, 1.0], + [0.36891446, 0.8593253, 1.0], + [0.03710465, 0.9495408, 1.0], + [0.0007436436, 0.99869233, 1.0], + [0.0, 1.0, 1.0], + [0.4106183, 0.7918468, 1.0], + [0.9571653, 0.9164912, 1.0], + [0.2806054, 0.98760474, 1.0], + [0.026692087, 0.99853516, 1.0], + [0.0, 1.0, 1.0], + [0.68634075, 0.8532397, 1.0], + [1.1827903, 0.9357391, 1.0], + [0.869629, 0.95853925, 1.0], + [0.24507365, 0.9850702, 1.0], + [0.0, 1.0, 1.0], + [0.6320396, 0.9290106, 1.0], + [0.90500677, 0.964906, 1.0], + [0.9593051, 0.9864873, 1.0], + [0.21237753, 0.9966006, 1.0], + [0.0, 1.0, 1.0], + [0.048774928, 0.95484895, 1.0], + [-0.77230346, 0.96596956, 1.0], + [-0.36607814, 0.9833947, 1.0], + [-0.027992101, 0.9972649, 1.0], + [0.0, 1.0, 1.0], + [0.07467206, 0.9574848, 1.0], + [-0.46401745, 0.9480488, 1.0], + [-0.7730967, 0.85603, 1.0], + [-0.13825144, 0.9314603, 1.0], + [0.0, 1.0, 1.0], + [-0.20196562, 0.9510938, 1.0], + [0.4332324, 0.8811578, 1.0], + [0.7512844, 0.86460304, 1.0], + [0.38913283, 0.9498416, 1.0], + [0.0, 1.0, 1.0], + [0.07075552, 0.9259933, 1.0], + [1.2225338, 0.89603615, 1.0], + [0.39722675, 0.97127604, 1.0], + [0.020897377, 0.99866724, 1.0], + [0.0, 1.0, 1.0], + [1.8001552, 0.9517485, 1.0], + [1.2267344, 0.9665981, 1.0], + [0.074866876, 0.99737835, 1.0], + [0.00035822866, 0.9999817, 1.0], + [0.0, 1.0, 1.0], + [1.1735111, 0.9407677, 1.0], + [0.55925053, 0.9782085, 1.0], + [0.014851813, 0.99942917, 1.0], + [ + -2.6381907e-05, + 0.9999996, + 1.0 + ], + [0.0, 1.0, 1.0], + [-0.491996, 0.96088326, 1.0], + [-0.10901659, 0.9911518, 1.0], + [-0.0014201105, 0.9998647, 1.0], + [ + -7.0408996e-06, + 0.9999999, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.9082432, 0.97429425, 1.0], + [0.14978711, 0.9955272, 1.0], + [0.0022982752, 0.99993473, 1.0], + [ + 5.9549325e-06, + 0.9999999, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.3517478, 0.99564123, 1.0], + [0.06255331, 0.99933046, 1.0], + [0.0009568419, 0.9999891, 1.0], + [ + 2.612675e-06, + 0.99999994, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.74196255, 1.0120044, 1.0], + [0.081937216, 1.0010135, 1.0], + [0.00051152834, 0.99999976, 1.0], + [ + -1.3411326e-07, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.2244991, 0.99848604, 1.0], + [0.034186296, 0.9979303, 1.0], + [0.001426202, 0.9998331, 1.0], + [ + 6.448161e-06, + 0.99999917, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.5212085, 0.99461675, 1.0], + [0.039274894, 0.9988406, 1.0], + [0.00067081454, 0.9999321, 1.0], + [ + 2.6707798e-06, + 0.9999997, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.42774403, 1.0063812, 1.0], + [0.02219702, 1.0003272, 1.0], + [ + 5.577047e-05, + 1.0000001, + 1.0 + ], + [ + -3.6048577e-09, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.1645134, 0.9992673, 1.0], + [0.006846053, 0.999736, 1.0], + [-0.00019761767, 0.99998826, 1.0], + [ + -1.0566964e-06, + 0.99999994, + 1.0 + ], + [0.0, 1.0, 1.0], + [-0.0024718763, 0.99704987, 1.0], + [-0.005746108, 0.99963886, 1.0], + [-0.0005149844, 0.99998856, 1.0], + [ + -2.8267918e-06, + 0.99999994, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.01222888, 0.99399185, 1.0], + [-0.0011779555, 0.99944973, 1.0], + [ + -4.2567906e-05, + 0.99999565, + 1.0 + ], + [ + -2.0611503e-07, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.4803884, 0.9846379, 1.0], + [0.026714282, 0.9990032, 1.0], + [ + -4.8978454e-06, + 0.99999505, + 1.0 + ], + [ + -7.071696e-07, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.06271327, 0.9943767, 1.0], + [-0.012053411, 0.99946314, 1.0], + [-0.0010432169, 0.99998796, 1.0], + [ + -4.96212e-06, + 0.99999994, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.030970754, 0.9985594, 1.0], + [-0.044691127, 0.99863976, 1.0], + [-0.002984863, 0.999931, 1.0], + [ + -1.1928371e-05, + 0.99999976, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.13066156, 0.99966115, 1.0], + [-0.03325378, 0.99870265, 1.0], + [-0.012181685, 0.9998752, 1.0], + [-0.00035528053, 0.999998, 1.0], + [0.0, 1.0, 1.0], + [-0.006449914, 0.99292916, 1.0], + [-0.10845718, 0.99747235, 1.0], + [-0.06701636, 1.0000327, 1.0], + [-0.0040320284, 1.0000145, 1.0], + [0.0, 1.0, 1.0], + [-0.2692093, 0.9923292, 1.0], + [-1.1617728, 1.0010196, 1.0], + [-1.1378351, 1.0052049, 1.0], + [-0.107098795, 1.0005559, 1.0], + [0.0, 1.0, 1.0], + [-2.1599848, 0.94598794, 1.0], + [-3.3414283, 0.9492445, 1.0], + [-1.9137881, 0.9936844, 1.0], + [-0.16074239, 1.0003223, 1.0], + [0.0, 1.0, 1.0], + [-0.12844217, 0.99728847, 1.0], + [-0.06282799, 0.9969152, 1.0], + [0.1619825, 0.9955758, 1.0], + [0.11950376, 0.9972032, 1.0], + [0.0, 1.0, 1.0], + [0.35873035, 0.9887963, 1.0], + [1.8258559, 0.95673186, 1.0], + [4.998067, 0.88979626, 1.0], + [2.386227, 0.9458393, 1.0], + [0.0, 1.0, 1.0], + [0.864645, 0.9789599, 1.0], + [3.940015, 0.91087675, 1.0], + [5.492297, 0.8787958, 1.0], + [1.6959858, 0.961089, 1.0], + [0.0, 1.0, 1.0], + [1.0089777, 0.9739419, 1.0], + [1.5740615, 0.9393496, 1.0], + [0.7091527, 0.9656322, 1.0], + [0.058232725, 0.9972437, 1.0], + [0.0, 1.0, 1.0], + [0.45170605, 0.9703701, 1.0], + [0.8162551, 0.9256496, 1.0], + [0.38978133, 0.9669807, 1.0], + [0.015846139, 0.99831927, 1.0], + [0.0, 1.0, 1.0], + [0.8393841, 0.93710715, 1.0], + [2.4146032, 0.8306746, 1.0], + [1.9834396, 0.877878, 1.0], + [0.15351856, 0.990385, 1.0], + [0.0, 1.0, 1.0], + [1.5492482, 0.916484, 1.0], + [3.833322, 0.7736226, 1.0], + [3.7509248, 0.7980663, 1.0], + [0.42413434, 0.97344464, 1.0], + [0.0, 1.0, 1.0], + [2.5753381, 0.9001595, 1.0], + [1.4414841, 0.89536625, 1.0], + [0.870943, 0.9281202, 1.0], + [0.78198856, 0.9027962, 1.0], + [0.0, 1.0, 1.0], + [3.626003, 0.8573594, 1.0], + [1.744929, 0.8717852, 1.0], + [0.45063186, 0.95120394, 1.0], + [0.49293122, 0.93314904, 1.0], + [0.0, 1.0, 1.0], + [3.8157206, 0.79073906, 1.0], + [1.8466086, 0.8735757, 1.0], + [0.3013591, 0.97690207, 1.0], + [0.38631627, 0.9668622, 1.0], + [0.0, 1.0, 1.0], + [4.498729, 0.75619996, 1.0], + [4.230379, 0.81058335, 1.0], + [1.221213, 0.9268467, 1.0], + [1.0963763, 0.9156937, 1.0], + [0.0, 1.0, 1.0], + [3.932148, 0.7442637, 1.0], + [6.609813, 0.7236575, 1.0], + [2.5758474, 0.83543426, 1.0], + [0.2271159, 0.97122496, 1.0], + [0.0, 1.0, 1.0], + [3.9551125, 0.7783704, 1.0], + [6.2856493, 0.6595728, 1.0], + [2.7745192, 0.6280471, 1.0], + [2.0338192, 0.6878951, 1.0], + [0.0, 1.0, 1.0], + [2.0266984, 0.87015027, 1.0], + [4.2741194, 0.7685621, 1.0], + [3.0171409, 0.6355655, 1.0], + [2.7988803, 0.59572875, 1.0], + [0.0, 1.0, 1.0], + [0.79312897, 0.9363041, 1.0], + [0.91887796, 0.95661134, 1.0], + [0.67517465, 0.9250825, 1.0], + [0.37668255, 0.869786, 1.0], + [0.0, 1.0, 1.0], + [0.437517, 0.9439974, 1.0], + [0.038780373, 0.9556593, 1.0], + [0.267114, 0.9353768, 1.0], + [1.6325206, 0.92748356, 1.0], + [0.0, 1.0, 1.0], + [-0.43791065, 0.9496331, 1.0], + [-1.3674537, 0.87884694, 1.0], + [-2.1990373, 0.8128939, 1.0], + [-1.9359235, 0.82100236, 1.0], + [0.0, 1.0, 1.0], + [-0.76871, 0.9194859, 1.0], + [-1.2029064, 0.84553814, 1.0], + [-5.327206, 0.77611315, 1.0], + [-8.786878, 0.7737814, 1.0], + [0.0, 1.0, 1.0], + [-1.2917868, 0.9232655, 1.0], + [-1.4194634, 0.878315, 1.0], + [-5.7113304, 0.78513825, 1.0], + [-11.315231, 0.7345054, 1.0], + [0.0, 1.0, 1.0], + [-1.282819, 0.963271, 1.0], + [-0.7401497, 0.92713356, 1.0], + [0.003420251, 0.86226225, 1.0], + [-1.9279886, 0.87573004, 1.0], + [0.0, 1.0, 1.0], + [-1.4500295, 0.96384203, 1.0], + [-3.9545982, 0.9027838, 1.0], + [-4.0920296, 0.87749696, 1.0], + [-5.584249, 0.82730573, 1.0], + [0.0, 1.0, 1.0], + [-2.7861502, 0.91505295, 1.0], + [-9.366375, 0.7534717, 1.0], + [-12.683253, 0.7126221, 1.0], + [-13.721524, 0.69018114, 1.0], + [0.0, 1.0, 1.0], + [-0.63505775, 0.9360138, 1.0], + [-5.646698, 0.7840974, 1.0], + [-11.826459, 0.6914667, 1.0], + [-12.043715, 0.64471924, 1.0], + [0.0, 1.0, 1.0], + [-0.20236929, 0.98128676, 1.0], + [-0.9277988, 0.9150858, 1.0], + [0.17753038, 0.775079, 1.0], + [3.4420626, 0.68469423, 1.0], + [0.0, 1.0, 1.0], + [0.59386843, 0.9422246, 1.0], + [1.948908, 0.89293414, 1.0], + [1.7242484, 0.8869361, 1.0], + [1.5372764, 0.90924776, 1.0], + [0.0, 1.0, 1.0], + [-0.17006855, 0.9595094, 1.0], + [0.9244115, 0.9223015, 1.0], + [-2.5031877, 0.88388723, 1.0], + [-1.2975974, 0.9453137, 1.0], + [0.0, 1.0, 1.0], + [0.43563363, 0.9744029, 1.0], + [1.7651839, 0.9294651, 1.0], + [0.3709067, 0.95219886, 1.0], + [-0.23438755, 0.991713, 1.0], + [0.0, 1.0, 1.0], + [0.8836351, 0.92381245, 1.0], + [3.3614104, 0.8902626, 1.0], + [2.6299663, 0.9327052, 1.0], + [0.20268272, 0.9949902, 1.0], + [0.0, 1.0, 1.0], + [0.03636021, 0.96812797, 1.0], + [0.9191721, 0.98153824, 1.0], + [0.49294955, 0.9890738, 1.0], + [0.035599157, 0.9991702, 1.0], + [0.0, 1.0, 1.0], + [-0.36295575, 0.98267245, 1.0], + [-0.65720224, 0.9795889, 1.0], + [-0.06744636, 0.9974913, 1.0], + [0.00014511583, 0.9999672, 1.0], + [0.0, 1.0, 1.0], + [-1.7780958, 0.92661333, 1.0], + [-3.7994225, 0.90834874, 1.0], + [-0.5296, 0.98757935, 1.0], + [-0.004139361, 0.99990284, 1.0], + [0.0, 1.0, 1.0], + [0.4182173, 0.94612914, 1.0], + [-0.2263425, 0.96315044, 1.0], + [-0.060515344, 0.9972809, 1.0], + [-0.00054953585, 0.9999832, 1.0], + [0.0, 1.0, 1.0], + [0.2474353, 0.96370727, 1.0], + [0.050504524, 0.9636764, 1.0], + [-0.08704806, 0.9956043, 1.0], + [-0.0032428538, 0.9999153, 1.0], + [0.0, 1.0, 1.0], + [0.20877357, 0.9770072, 1.0], + [-1.6285077, 0.9417298, 1.0], + [-1.0749032, 0.9740643, 1.0], + [-0.036731955, 0.9991393, 1.0], + [0.0, 1.0, 1.0], + [0.5124754, 0.97665536, 1.0], + [0.05648161, 0.97246367, 1.0], + [-0.20485052, 0.99366885, 1.0], + [-0.007456854, 0.99982077, 1.0], + [0.0, 1.0, 1.0], + [-1.0751122, 0.9738437, 1.0], + [-1.0979657, 0.9731047, 1.0], + [-0.07396637, 0.99813634, 1.0], + [-0.00031904137, 0.9999918, 1.0], + [0.0, 1.0, 1.0], + [-0.81148493, 0.9809923, 1.0], + [-0.79679507, 0.98153543, 1.0], + [-0.052809343, 0.9987658, 1.0], + [-0.00021324423, 0.999995, 1.0], + [0.0, 1.0, 1.0], + [-0.70272887, 0.97712904, 1.0], + [-0.23628409, 0.99248105, 1.0], + [-0.017436834, 0.99954605, 1.0], + [ + -9.100488e-05, + 0.9999978, + 1.0 + ], + [0.0, 1.0, 1.0], + [-2.112568, 0.92536825, 1.0], + [-1.793664, 0.952624, 1.0], + [-0.15880403, 0.99615294, 1.0], + [-0.0008353359, 0.9999803, 1.0], + [0.0, 1.0, 1.0], + [-1.0425283, 0.9575445, 1.0], + [-0.37356365, 0.9898527, 1.0], + [-0.027834127, 0.99933815, 1.0], + [-0.00014425212, 0.9999966, 1.0], + [0.0, 1.0, 1.0], + [-0.5929818, 0.9799135, 1.0], + [-0.06785171, 0.9980503, 1.0], + [-0.0006129217, 0.99998415, 1.0], + [ + -7.431623e-07, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [-1.0108821, 0.97179604, 1.0], + [-0.12948345, 0.99659497, 1.0], + [-0.0011306758, 0.9999685, 1.0], + [ + -7.843986e-07, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [-0.1479146, 0.98383874, 1.0], + [-0.026573375, 0.9968805, 1.0], + [-0.0007648126, 0.9999066, 1.0], + [ + -2.9774496e-06, + 0.99999964, + 1.0 + ], + [0.0, 1.0, 1.0], + [-0.42330608, 0.9586368, 1.0], + [-0.117418036, 0.97570026, 1.0], + [-0.006413941, 0.9977138, 1.0], + [ + -4.5600293e-05, + 0.9999782, + 1.0 + ], + [0.0, 1.0, 1.0], + [-0.482407, 0.9569524, 1.0], + [-0.5354113, 0.95456654, 1.0], + [-0.05666969, 0.99288625, 1.0], + [-0.00037526168, 0.9998962, 1.0], + [0.0, 1.0, 1.0], + [-1.2456969, 0.96252, 1.0], + [-1.9524531, 0.9508849, 1.0], + [-0.2124356, 0.9941987, 1.0], + [-0.0013579516, 0.9999511, 1.0], + [0.0, 1.0, 1.0], + [-0.12160804, 0.98543537, 1.0], + [-0.27839974, 0.983876, 1.0], + [-0.14200354, 0.99454284, 1.0], + [-0.012517998, 0.9993907, 1.0], + [0.0, 1.0, 1.0], + [-0.08368426, 0.9217887, 1.0], + [-1.2399791, 0.8625204, 1.0], + [-1.6894629, 0.9284568, 1.0], + [-0.18025236, 0.99156326, 1.0], + [0.0, 1.0, 1.0], + [0.5968979, 0.94195247, 1.0], + [0.19960582, 0.85841644, 1.0], + [-0.6310517, 0.9365722, 1.0], + [-0.0635393, 0.99637383, 1.0], + [0.0, 1.0, 1.0], + [-1.7087493, 0.96301836, 1.0], + [-7.374809, 0.8499901, 1.0], + [-4.2648234, 0.88394064, 1.0], + [-0.40903544, 0.97660697, 1.0], + [0.0, 1.0, 1.0], + [-0.3982448, 0.97629553, 1.0], + [-3.0891116, 0.7622017, 1.0], + [-2.504011, 0.6110344, 1.0], + [-0.68706274, 0.7084606, 1.0], + [0.0, 1.0, 1.0], + [0.5045769, 0.84708446, 1.0], + [1.9575346, 0.64301115, 1.0], + [2.4821124, 0.6190746, 1.0], + [2.1545558, 0.7520362, 1.0], + [0.0, 1.0, 1.0], + [3.1298854, 0.77756673, 1.0], + [5.5874057, 0.67800367, 1.0], + [2.4975712, 0.8394186, 1.0], + [0.17680934, 0.98891705, 1.0], + [0.0, 1.0, 1.0], + [6.144584, 0.8652469, 1.0], + [4.7808557, 0.8791068, 1.0], + [0.3867177, 0.98648083, 1.0], + [0.0025304207, 0.999883, 1.0], + [0.0, 1.0, 1.0], + [4.867331, 0.8436379, 1.0], + [2.2105703, 0.93576556, 1.0], + [0.06521808, 0.99830455, 1.0], + [0.00012717527, 0.999997, 1.0], + [0.0, 1.0, 1.0], + [1.0654385, 0.91793096, 1.0], + [0.26658812, 0.9786744, 1.0], + [0.0042792116, 0.9996769, 1.0], + [ + 4.5931947e-06, + 0.9999997, + 1.0 + ], + [0.0, 1.0, 1.0], + [1.074042, 0.9739217, 1.0], + [0.176678, 0.99581814, 1.0], + [0.0017133928, 0.9999589, 1.0], + [ + 1.0157613e-06, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [-1.01776, 0.9737646, 1.0], + [-0.09971152, 0.9972118, 1.0], + [-0.00057499425, 0.999982, 1.0], + [ + -1.9621856e-07, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [-0.4001637, 0.9907639, 1.0], + [-0.03467, 0.9991947, 1.0], + [-0.0001814811, 0.99999577, 1.0], + [ + -5.804475e-08, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.042297646, 1.0002224, 1.0], + [0.0034461436, 1.0000255, 1.0], + [ + 2.1965232e-05, + 0.99999976, + 1.0 + ], + [ + 2.5862306e-08, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.1646268, 0.9987905, 1.0], + [0.010173208, 0.99992174, 1.0], + [ + 4.039547e-05, + 0.9999995, + 1.0 + ], + [ + 1.7013736e-08, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.48721975, 0.9939957, 1.0], + [0.028629638, 0.9996232, 1.0], + [0.0001032999, 0.99999857, 1.0], + [ + 2.3236245e-08, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.26688322, 0.99534684, 1.0], + [0.01570361, 0.9997175, 1.0], + [ + 5.7026642e-05, + 0.9999989, + 1.0 + ], + [ + 1.27192274e-08, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.0029496485, 0.999937, 1.0], + [0.00017224046, 0.9999962, 1.0], + [ + 5.5217765e-07, + 1.0, + 1.0 + ], + [ + -2.9122849e-10, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.047425542, 0.99909365, 1.0], + [0.0026520186, 0.9999489, 1.0], + [ + 9.205303e-06, + 0.9999998, + 1.0 + ], + [ + 1.95425e-09, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.56498164, 0.98940337, 1.0], + [0.03208864, 0.9993928, 1.0], + [0.00011148855, 0.99999785, 1.0], + [ + 2.4027702e-08, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.26117882, 0.99926835, 1.0], + [0.014332054, 0.9999543, 1.0], + [ + 4.8232454e-05, + 0.9999998, + 1.0 + ], + [ + 8.686053e-09, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.7915088, 1.0046446, 1.0], + [0.04312717, 1.0002536, 1.0], + [0.00014175105, 1.0000008, 1.0], + [ + 2.4375671e-08, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.14177647, 0.99992174, 1.0], + [0.0075591076, 0.9999955, 1.0], + [ + 2.2167036e-05, + 0.99999994, + 1.0 + ], + [ + -8.405434e-08, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [-0.06938606, 0.9985813, 1.0], + [-0.0027954027, 0.9999414, 1.0], + [ + -2.462243e-05, + 0.9999999, + 1.0 + ], + [ + -1.0864502e-06, + 1.0, + 1.0 + ], + [0.0, 1.0, 1.0], + [-0.23859352, 0.995206, 1.0], + [-0.010182198, 0.99980026, 1.0], + [-0.00044199303, 1.0000005, 1.0], + [ + -2.9140341e-05, + 1.0000001, + 1.0 + ], + [0.0, 1.0, 1.0], + [-0.04641313, 0.99916106, 1.0], + [-0.020731159, 0.9994961, 1.0], + [-0.0036757442, 0.99992925, 1.0], + [-0.0001021458, 0.9999989, 1.0] + ], + "srgb_value": true + } +} diff --git a/assets/camera-profiles/X-T4.json b/assets/camera-profiles/X-T4.json new file mode 100644 index 000000000..92f8dd3ef --- /dev/null +++ b/assets/camera-profiles/X-T4.json @@ -0,0 +1,1863 @@ +{ + "version": 1, + "model": "X-T4", + "files": 545, + "samples": 1951939, + "matrix": [ + [1.1956707020637634, -0.20567495430470473, -0.001118128888055736], + [-0.07689679823427831, 1.1536404662027415, -0.07257103371689055], + [0.012366539151109635, -0.8454877840052113, 1.8346862279899163] + ], + "hue_sat": { + "hue_divisions": 72, + "sat_divisions": 5, + "val_divisions": 5, + "data": [ + [0.0, 1.0, 1.0], + [8.488724, 1.1072181, 1.0], + [6.027911, 1.1025184, 1.0], + [5.126879, 1.1056141, 1.0], + [2.7562134, 1.0608389, 1.0], + [0.0, 1.0, 1.0], + [6.9790807, 1.0982808, 1.0], + [4.307873, 1.0916511, 1.0], + [2.7828488, 1.0824455, 1.0], + [1.8936595, 1.0677147, 1.0], + [0.0, 1.0, 1.0], + [5.7833548, 1.0808423, 1.0], + [2.821706, 1.0722075, 1.0], + [1.2096823, 1.0580945, 1.0], + [0.3733568, 1.0406352, 1.0], + [0.0, 1.0, 1.0], + [4.533599, 1.0569755, 1.0], + [1.7481736, 1.0532024, 1.0], + [0.47299206, 1.0389953, 1.0], + [-0.8960845, 1.0057793, 1.0], + [0.0, 1.0, 1.0], + [3.2647514, 1.037265, 1.0], + [0.57080513, 1.0367802, 1.0], + [-0.491963, 1.0290552, 1.0], + [-1.6263541, 1.0026131, 1.0], + [0.0, 1.0, 1.0], + [2.1541393, 1.0179027, 1.0], + [-0.6506915, 1.019257, 1.0], + [-1.7829689, 1.0136136, 1.0], + [-3.3493202, 0.9885524, 1.0], + [0.0, 1.0, 1.0], + [1.7049778, 0.99872667, 1.0], + [-1.5122603, 0.9989183, 1.0], + [-2.9810934, 0.99024343, 1.0], + [-5.581267, 0.9519912, 1.0], + [0.0, 1.0, 1.0], + [1.690269, 0.9788303, 1.0], + [-1.8288996, 0.97644824, 1.0], + [-3.569127, 0.96698815, 1.0], + [-5.301545, 0.9436633, 1.0], + [0.0, 1.0, 1.0], + [1.675697, 0.9631066, 1.0], + [-1.8190717, 0.9620807, 1.0], + [-3.9894445, 0.94959897, 1.0], + [-5.4966874, 0.93077904, 1.0], + [0.0, 1.0, 1.0], + [2.015561, 0.9440323, 1.0], + [-1.5841109, 0.9433394, 1.0], + [-3.7623508, 0.9329007, 1.0], + [-4.8697934, 0.91531616, 1.0], + [0.0, 1.0, 1.0], + [1.7726275, 0.9228537, 1.0], + [-0.8683006, 0.90954083, 1.0], + [-3.5469055, 0.89416665, 1.0], + [-4.6822357, 0.8788996, 1.0], + [0.0, 1.0, 1.0], + [0.8594914, 0.9101041, 1.0], + [-0.8338466, 0.89342636, 1.0], + [-2.8553827, 0.8670687, 1.0], + [-3.9147246, 0.842152, 1.0], + [0.0, 1.0, 1.0], + [0.91838557, 0.9096098, 1.0], + [-0.22313553, 0.8916189, 1.0], + [-2.1062787, 0.84502786, 1.0], + [-4.6637325, 0.79085016, 1.0], + [0.0, 1.0, 1.0], + [1.7867746, 0.9109965, 1.0], + [0.5220267, 0.88554436, 1.0], + [-1.6875609, 0.8377822, 1.0], + [-2.5638952, 0.8012038, 1.0], + [0.0, 1.0, 1.0], + [1.608996, 0.9175911, 1.0], + [0.51752204, 0.87700754, 1.0], + [-0.87092155, 0.8299583, 1.0], + [-0.6205537, 0.8152296, 1.0], + [0.0, 1.0, 1.0], + [0.60483766, 0.92897344, 1.0], + [0.48028103, 0.86985594, 1.0], + [0.6325482, 0.83478713, 1.0], + [1.2977381, 0.8260493, 1.0], + [0.0, 1.0, 1.0], + [-1.3837581, 0.9366167, 1.0], + [-0.17185253, 0.8691589, 1.0], + [1.6557579, 0.8430431, 1.0], + [3.0675054, 0.8435281, 1.0], + [0.0, 1.0, 1.0], + [-2.8841403, 0.9447056, 1.0], + [-0.14756455, 0.88780934, 1.0], + [2.745795, 0.8501864, 1.0], + [4.020532, 0.83667177, 1.0], + [0.0, 1.0, 1.0], + [-3.4971426, 0.9572608, 1.0], + [0.27770972, 0.9148666, 1.0], + [3.7949884, 0.8749599, 1.0], + [5.156509, 0.8579785, 1.0], + [0.0, 1.0, 1.0], + [-4.1615067, 0.9798452, 1.0], + [0.7165952, 0.9488004, 1.0], + [3.0187547, 0.91653454, 1.0], + [2.6265273, 0.9383036, 1.0], + [0.0, 1.0, 1.0], + [-5.221659, 1.0131094, 1.0], + [0.13435012, 0.97644806, 1.0], + [1.240259, 0.96669924, 1.0], + [0.052574184, 0.99833775, 1.0], + [0.0, 1.0, 1.0], + [-6.439757, 1.0429982, 1.0], + [-0.75906193, 0.98914695, 1.0], + [0.6928731, 0.98318547, 1.0], + [0.014971263, 0.9996664, 1.0], + [0.0, 1.0, 1.0], + [-7.9659386, 1.0793759, 1.0], + [-1.7617505, 1.0095686, 1.0], + [0.22509712, 0.9961085, 1.0], + [0.004788822, 0.99993336, 1.0], + [0.0, 1.0, 1.0], + [-8.894038, 1.111571, 1.0], + [-2.515549, 1.037413, 1.0], + [-0.13141948, 1.0021963, 1.0], + [-0.00069948833, 1.000002, 1.0], + [0.0, 1.0, 1.0], + [-8.479235, 1.1109792, 1.0], + [-1.8521774, 1.0352653, 1.0], + [-0.016141478, 1.001208, 1.0], + [ + -4.3190834e-05, + 1.0000027, + 1.0 + ], + [0.0, 1.0, 1.0], + [-8.549912, 1.0822867, 1.0], + [-2.329735, 1.0172695, 1.0], + [-0.078001685, 0.9996079, 1.0], + [-0.0005751441, 0.9999892, 1.0], + [0.0, 1.0, 1.0], + [-10.067066, 1.0514193, 1.0], + [-2.9787543, 1.0056072, 1.0], + [-0.068143584, 0.999506, 1.0], + [-0.0004552759, 0.9999932, 1.0], + [0.0, 1.0, 1.0], + [-8.974208, 1.0131854, 1.0], + [-2.0702703, 0.9893971, 1.0], + [-0.0065514506, 0.9995635, 1.0], + [ + -1.1568422e-05, + 0.9999987, + 1.0 + ], + [0.0, 1.0, 1.0], + [-9.526247, 0.9756266, 1.0], + [-2.8014424, 0.9778582, 1.0], + [-0.027753195, 0.9994349, 1.0], + [ + -2.2518001e-05, + 0.99999917, + 1.0 + ], + [0.0, 1.0, 1.0], + [-9.626008, 0.9605885, 1.0], + [-3.5689998, 0.97556823, 1.0], + [-0.06463481, 0.9995251, 1.0], + [ + -8.669659e-05, + 0.99999934, + 1.0 + ], + [0.0, 1.0, 1.0], + [-9.666746, 0.9549443, 1.0], + [-3.2697423, 0.9675152, 1.0], + [-0.077041924, 0.99725926, 1.0], + [-0.0004004262, 0.99997205, 1.0], + [0.0, 1.0, 1.0], + [-10.91483, 0.93973595, 1.0], + [-4.0554028, 0.9567627, 1.0], + [-0.19969644, 0.994213, 1.0], + [-0.0015807059, 0.99994254, 1.0], + [0.0, 1.0, 1.0], + [-10.917845, 0.9374126, 1.0], + [-3.937062, 0.9639455, 1.0], + [-0.10804518, 0.99682367, 1.0], + [-0.0003749917, 0.9999778, 1.0], + [0.0, 1.0, 1.0], + [-10.878841, 0.9248424, 1.0], + [-4.854392, 0.9475052, 1.0], + [-0.3740493, 0.99154884, 1.0], + [-0.0027292988, 0.9999195, 1.0], + [0.0, 1.0, 1.0], + [-11.24207, 0.9229496, 1.0], + [-5.3360095, 0.955147, 1.0], + [-0.303692, 0.9946303, 1.0], + [-0.0016415697, 0.9999533, 1.0], + [0.0, 1.0, 1.0], + [-12.459016, 0.92287266, 1.0], + [-6.2727904, 0.9671372, 1.0], + [-0.26351833, 0.9981922, 1.0], + [-0.00071758503, 0.9999905, 1.0], + [0.0, 1.0, 1.0], + [-14.29214, 0.91273934, 1.0], + [-7.047706, 0.96466416, 1.0], + [-0.29217795, 0.9984951, 1.0], + [-0.0011865492, 0.9999872, 1.0], + [0.0, 1.0, 1.0], + [-15.4999695, 0.87166667, 1.0], + [-7.790251, 0.9259767, 1.0], + [-0.44737285, 0.9925323, 1.0], + [-0.0028230716, 0.9999344, 1.0], + [0.0, 1.0, 1.0], + [-16.484762, 0.81370276, 1.0], + [-7.1786904, 0.8921729, 1.0], + [-0.2108328, 0.9926036, 1.0], + [ + 8.717539e-05, + 0.9999606, + 1.0 + ], + [0.0, 1.0, 1.0], + [-16.285719, 0.7696234, 1.0], + [-8.000221, 0.86041737, 1.0], + [-0.25902525, 0.9928877, 1.0], + [ + 3.6861604e-06, + 0.99996454, + 1.0 + ], + [0.0, 1.0, 1.0], + [-14.937389, 0.7474696, 1.0], + [-8.215962, 0.8344298, 1.0], + [-0.29087847, 0.9932387, 1.0], + [-0.0004977129, 0.9999889, 1.0], + [0.0, 1.0, 1.0], + [-13.858105, 0.7273871, 1.0], + [-8.646881, 0.80850846, 1.0], + [-0.6027111, 0.98492134, 1.0], + [-0.0006273363, 0.9999705, 1.0], + [0.0, 1.0, 1.0], + [-12.701234, 0.724614, 1.0], + [-7.9289045, 0.8156208, 1.0], + [-0.8558639, 0.97617847, 1.0], + [0.0064291214, 0.9999933, 1.0], + [0.0, 1.0, 1.0], + [-11.146729, 0.7225693, 1.0], + [-6.5765376, 0.82539093, 1.0], + [-1.0245144, 0.96958196, 1.0], + [0.013567956, 1.0000076, 1.0], + [0.0, 1.0, 1.0], + [-8.884148, 0.7417882, 1.0], + [-4.3417435, 0.87207294, 1.0], + [-0.85739046, 0.98081857, 1.0], + [-0.0033849054, 0.999951, 1.0], + [0.0, 1.0, 1.0], + [-6.049297, 0.8116962, 1.0], + [-1.4851714, 0.9950118, 1.0], + [0.49591282, 1.0545783, 1.0], + [1.0066286, 1.0165282, 1.0], + [0.0, 1.0, 1.0], + [-4.6716075, 0.83988357, 1.0], + [-1.0457978, 0.99501514, 1.0], + [3.4836724, 1.0588785, 1.0], + [6.21208, 1.0532575, 1.0], + [0.0, 1.0, 1.0], + [-3.4979618, 0.86866015, 1.0], + [-1.4233817, 0.9817547, 1.0], + [1.8918269, 1.0534106, 1.0], + [4.6594706, 1.0482085, 1.0], + [0.0, 1.0, 1.0], + [-3.3181837, 0.8540786, 1.0], + [-2.0863492, 0.9573726, 1.0], + [-0.3537899, 1.0474013, 1.0], + [0.934481, 1.0493503, 1.0], + [0.0, 1.0, 1.0], + [-3.0748718, 0.7988069, 1.0], + [-1.966728, 0.8981509, 1.0], + [-0.28818163, 1.0324, 1.0], + [1.1813463, 1.0708457, 1.0], + [0.0, 1.0, 1.0], + [-2.8137631, 0.7544842, 1.0], + [-1.7292049, 0.86010194, 1.0], + [-0.7809513, 1.0063258, 1.0], + [-0.045840506, 1.0474083, 1.0], + [0.0, 1.0, 1.0], + [-2.5037665, 0.7084344, 1.0], + [-1.2390419, 0.79690814, 1.0], + [-0.40586868, 0.9500636, 1.0], + [-0.25943267, 1.0091549, 1.0], + [0.0, 1.0, 1.0], + [-2.5305736, 0.6992817, 1.0], + [-0.84799874, 0.7868287, 1.0], + [-0.26535538, 0.9460887, 1.0], + [-0.26857254, 1.0045226, 1.0], + [0.0, 1.0, 1.0], + [-1.7894933, 0.696515, 1.0], + [-0.087908834, 0.79940414, 1.0], + [-0.40865606, 0.9581447, 1.0], + [-0.26790136, 1.0027667, 1.0], + [0.0, 1.0, 1.0], + [1.0415573, 0.69235516, 1.0], + [2.7619398, 0.79386014, 1.0], + [0.7820542, 0.95513344, 1.0], + [-0.07779682, 1.000791, 1.0], + [0.0, 1.0, 1.0], + [4.733248, 0.70772463, 1.0], + [5.005565, 0.8130484, 1.0], + [1.6844413, 0.95230424, 1.0], + [0.0011346041, 0.999593, 1.0], + [0.0, 1.0, 1.0], + [7.1720886, 0.72739035, 1.0], + [6.0618978, 0.83798486, 1.0], + [1.9718703, 0.959339, 1.0], + [0.01772261, 0.99964523, 1.0], + [0.0, 1.0, 1.0], + [8.698657, 0.7538606, 1.0], + [6.9594636, 0.85568684, 1.0], + [2.449368, 0.96119237, 1.0], + [0.03495715, 0.99959546, 1.0], + [0.0, 1.0, 1.0], + [9.8315, 0.8065308, 1.0], + [7.1267076, 0.8956971, 1.0], + [2.3986976, 0.97244316, 1.0], + [0.03165836, 0.9996826, 1.0], + [0.0, 1.0, 1.0], + [10.795148, 0.85122234, 1.0], + [7.7294583, 0.93523824, 1.0], + [3.1217756, 0.9856404, 1.0], + [0.057580743, 0.9998344, 1.0], + [0.0, 1.0, 1.0], + [10.398136, 0.8843981, 1.0], + [7.9121175, 0.9568833, 1.0], + [3.1463122, 0.99417585, 1.0], + [0.05408725, 1.0000057, 1.0], + [0.0, 1.0, 1.0], + [10.596379, 0.89801466, 1.0], + [8.130247, 0.9633411, 1.0], + [3.3988564, 0.9945132, 1.0], + [0.05017191, 1.0000184, 1.0], + [0.0, 1.0, 1.0], + [10.625336, 0.9014762, 1.0], + [7.98765, 0.958355, 1.0], + [3.875602, 0.9900766, 1.0], + [0.07159707, 0.99994385, 1.0], + [0.0, 1.0, 1.0], + [10.804855, 0.9026359, 1.0], + [8.302903, 0.9591842, 1.0], + [4.20411, 0.9905614, 1.0], + [0.10111151, 0.9999875, 1.0], + [0.0, 1.0, 1.0], + [11.296136, 0.91712755, 1.0], + [8.392348, 0.9658389, 1.0], + [4.6424003, 0.9887021, 1.0], + [0.13342388, 0.9997721, 1.0], + [0.0, 1.0, 1.0], + [11.479186, 0.93038946, 1.0], + [8.325115, 0.96937376, 1.0], + [4.8091784, 0.9889414, 1.0], + [0.14431237, 0.99973685, 1.0], + [0.0, 1.0, 1.0], + [11.55654, 0.93699944, 1.0], + [8.343578, 0.97098297, 1.0], + [4.5094857, 0.9892612, 1.0], + [0.11552952, 0.9997806, 1.0], + [0.0, 1.0, 1.0], + [11.673957, 0.95636433, 1.0], + [9.015992, 0.9808939, 1.0], + [5.2865705, 0.99124783, 1.0], + [0.15847602, 0.99981487, 1.0], + [0.0, 1.0, 1.0], + [11.381441, 0.97744256, 1.0], + [9.125418, 0.99072415, 1.0], + [5.898516, 0.99096805, 1.0], + [0.22721227, 0.99941134, 1.0], + [0.0, 1.0, 1.0], + [11.39636, 0.99879706, 1.0], + [8.978779, 1.001649, 1.0], + [5.9936876, 0.99107313, 1.0], + [0.47186422, 0.99851125, 1.0], + [0.0, 1.0, 1.0], + [10.871172, 1.0297832, 1.0], + [8.347715, 1.0252823, 1.0], + [5.9642944, 1.0091741, 1.0], + [0.6254432, 1.0003536, 1.0], + [0.0, 1.0, 1.0], + [9.91249, 1.0697851, 1.0], + [7.514703, 1.0643711, 1.0], + [6.2715187, 1.0660433, 1.0], + [1.3713064, 1.0171726, 1.0], + [0.0, 1.0, 1.0], + [6.9206777, 1.0631331, 1.0], + [5.4058423, 1.0743114, 1.0], + [4.9153433, 1.0924674, 1.0], + [3.5594451, 1.0767422, 1.0], + [0.0, 1.0, 1.0], + [5.296089, 1.0543188, 1.0], + [3.7133682, 1.0690163, 1.0], + [2.6709373, 1.0747231, 1.0], + [1.8147451, 1.0627362, 1.0], + [0.0, 1.0, 1.0], + [4.072669, 1.0297099, 1.0], + [2.2282882, 1.0441604, 1.0], + [1.04859, 1.0460346, 1.0], + [0.09582316, 1.0271128, 1.0], + [0.0, 1.0, 1.0], + [3.131797, 0.99783087, 1.0], + [1.3904452, 1.0142667, 1.0], + [0.21789947, 1.0169302, 1.0], + [-1.3347801, 0.99124795, 1.0], + [0.0, 1.0, 1.0], + [2.1123412, 0.9821653, 1.0], + [0.47084513, 0.9980239, 1.0], + [-0.4589091, 1.0047126, 1.0], + [-2.6004968, 0.97831535, 1.0], + [0.0, 1.0, 1.0], + [1.2340026, 0.9715437, 1.0], + [-0.52651185, 0.9844928, 1.0], + [-1.4068123, 0.99132365, 1.0], + [-3.105275, 0.9731577, 1.0], + [0.0, 1.0, 1.0], + [0.8440855, 0.9556401, 1.0], + [-1.2418174, 0.96413547, 1.0], + [-2.314578, 0.97029686, 1.0], + [-4.555726, 0.9510563, 1.0], + [0.0, 1.0, 1.0], + [0.8005227, 0.9413437, 1.0], + [-1.6244266, 0.9424764, 1.0], + [-2.8907351, 0.94735694, 1.0], + [-4.531418, 0.938058, 1.0], + [0.0, 1.0, 1.0], + [0.84144706, 0.9381546, 1.0], + [-1.8843055, 0.9437712, 1.0], + [-3.7513957, 0.93906224, 1.0], + [-5.2546344, 0.9245488, 1.0], + [0.0, 1.0, 1.0], + [1.1232786, 0.91807526, 1.0], + [-1.6892345, 0.927733, 1.0], + [-3.5301387, 0.9231299, 1.0], + [-4.4934497, 0.90747577, 1.0], + [0.0, 1.0, 1.0], + [0.7078936, 0.88705426, 1.0], + [-1.1164439, 0.8847049, 1.0], + [-3.1222675, 0.881657, 1.0], + [-4.24306, 0.8661209, 1.0], + [0.0, 1.0, 1.0], + [-0.3021957, 0.866729, 1.0], + [-1.4405441, 0.8674825, 1.0], + [-2.6025193, 0.8471425, 1.0], + [-2.9510996, 0.78768075, 1.0], + [0.0, 1.0, 1.0], + [-0.6495463, 0.84840906, 1.0], + [-1.1817076, 0.8663429, 1.0], + [-2.0115397, 0.8132654, 1.0], + [-3.20892, 0.75081503, 1.0], + [0.0, 1.0, 1.0], + [-0.08545443, 0.8417304, 1.0], + [-0.41332915, 0.86041677, 1.0], + [-1.7788253, 0.8134252, 1.0], + [-2.212696, 0.76719844, 1.0], + [0.0, 1.0, 1.0], + [0.047263395, 0.8536788, 1.0], + [-0.28024718, 0.85070366, 1.0], + [-1.1217487, 0.80729675, 1.0], + [-0.9744078, 0.782097, 1.0], + [0.0, 1.0, 1.0], + [-0.5738716, 0.8675571, 1.0], + [-0.26259437, 0.8452582, 1.0], + [0.15979776, 0.8179859, 1.0], + [0.7517238, 0.8056368, 1.0], + [0.0, 1.0, 1.0], + [-2.153183, 0.8773336, 1.0], + [-0.59632957, 0.845526, 1.0], + [1.2569299, 0.83155745, 1.0], + [2.6460948, 0.83558846, 1.0], + [0.0, 1.0, 1.0], + [-3.344423, 0.89766663, 1.0], + [-0.4878973, 0.86092067, 1.0], + [2.0878763, 0.8382237, 1.0], + [3.5387704, 0.8289335, 1.0], + [0.0, 1.0, 1.0], + [-3.6364002, 0.91960084, 1.0], + [-0.20694885, 0.88383955, 1.0], + [2.819625, 0.86090726, 1.0], + [4.849336, 0.84782916, 1.0], + [0.0, 1.0, 1.0], + [-3.991286, 0.9524578, 1.0], + [0.28421873, 0.9292402, 1.0], + [2.098149, 0.8972288, 1.0], + [2.5357125, 0.92512876, 1.0], + [0.0, 1.0, 1.0], + [-4.56505, 0.9899462, 1.0], + [0.38352713, 0.9732783, 1.0], + [1.4336663, 0.9498769, 1.0], + [-2.1187909, 0.9426851, 1.0], + [0.0, 1.0, 1.0], + [-5.3135715, 1.0177003, 1.0], + [-0.23551735, 0.99192464, 1.0], + [1.1284361, 0.97364134, 1.0], + [-0.43181676, 0.97194505, 1.0], + [0.0, 1.0, 1.0], + [-6.6075764, 1.0397655, 1.0], + [-1.0467725, 1.0000273, 1.0], + [0.6370652, 0.9854795, 1.0], + [0.66872966, 0.9790045, 1.0], + [0.0, 1.0, 1.0], + [-7.4358993, 1.0649753, 1.0], + [-2.0699987, 1.0154411, 1.0], + [0.22289409, 0.9912232, 1.0], + [0.68752974, 0.9844199, 1.0], + [0.0, 1.0, 1.0], + [-7.9629416, 1.0769956, 1.0], + [-2.8259487, 1.0380164, 1.0], + [0.17933619, 0.9962173, 1.0], + [0.24746062, 0.9950691, 1.0], + [0.0, 1.0, 1.0], + [-8.186394, 1.0412176, 1.0], + [-3.2612817, 1.0117664, 1.0], + [0.16850522, 0.9932496, 1.0], + [0.2498481, 0.9976146, 1.0], + [0.0, 1.0, 1.0], + [-9.472168, 0.9964655, 1.0], + [-4.180181, 0.9870781, 1.0], + [-0.0750533, 0.9970658, 1.0], + [0.012545125, 0.9995864, 1.0], + [0.0, 1.0, 1.0], + [-8.411514, 0.9593027, 1.0], + [-2.9796257, 0.97248125, 1.0], + [-0.018407283, 0.9964052, 1.0], + [-0.04716424, 0.9984274, 1.0], + [0.0, 1.0, 1.0], + [-8.102845, 0.922432, 1.0], + [-3.318696, 0.95474064, 1.0], + [-0.020426346, 0.9982194, 1.0], + [-0.001956191, 0.9996863, 1.0], + [0.0, 1.0, 1.0], + [-8.526674, 0.89696586, 1.0], + [-4.260207, 0.9472845, 1.0], + [-0.11072428, 0.99930483, 1.0], + [-0.00018295995, 0.9999972, 1.0], + [0.0, 1.0, 1.0], + [-8.737394, 0.89237434, 1.0], + [-3.8464966, 0.9399867, 1.0], + [-0.07978496, 0.99751663, 1.0], + [-0.00027686163, 0.9999805, 1.0], + [0.0, 1.0, 1.0], + [-9.886306, 0.8757939, 1.0], + [-4.6172514, 0.92852616, 1.0], + [-0.1890676, 0.9948679, 1.0], + [-0.001193005, 0.999959, 1.0], + [0.0, 1.0, 1.0], + [-10.259174, 0.8847142, 1.0], + [-5.02464, 0.9355583, 1.0], + [-0.14631894, 0.996452, 1.0], + [-0.00036141326, 0.9999813, 1.0], + [0.0, 1.0, 1.0], + [-9.869882, 0.8831311, 1.0], + [-5.362167, 0.93398, 1.0], + [-0.34822685, 0.9924211, 1.0], + [-0.0020351785, 0.9999336, 1.0], + [0.0, 1.0, 1.0], + [-10.093717, 0.8826345, 1.0], + [-5.5686555, 0.93492806, 1.0], + [-0.21373157, 0.9918344, 1.0], + [-0.0002989677, 0.99993354, 1.0], + [0.0, 1.0, 1.0], + [-11.619686, 0.8809789, 1.0], + [-7.5092616, 0.9418452, 1.0], + [-0.3233299, 0.9970841, 1.0], + [0.001180963, 0.9999634, 1.0], + [0.0, 1.0, 1.0], + [-13.424544, 0.8696788, 1.0], + [-8.506053, 0.93757474, 1.0], + [-0.2895198, 0.99745727, 1.0], + [0.004820106, 0.99991095, 1.0], + [0.0, 1.0, 1.0], + [-14.621148, 0.8231381, 1.0], + [-9.321265, 0.88769966, 1.0], + [-0.47915274, 0.99181235, 1.0], + [-0.0022949055, 0.99993384, 1.0], + [0.0, 1.0, 1.0], + [-15.68596, 0.7722066, 1.0], + [-8.95147, 0.8554953, 1.0], + [-0.21178812, 0.9913755, 1.0], + [0.0008263445, 0.9999525, 1.0], + [0.0, 1.0, 1.0], + [-15.074831, 0.728671, 1.0], + [-9.225928, 0.8146718, 1.0], + [-0.2110684, 0.9879508, 1.0], + [0.0014654138, 0.9999165, 1.0], + [0.0, 1.0, 1.0], + [-12.948474, 0.7042668, 1.0], + [-9.001779, 0.7873532, 1.0], + [-0.40309152, 0.9885863, 1.0], + [-0.00052242767, 0.9999782, 1.0], + [0.0, 1.0, 1.0], + [-10.635279, 0.68401897, 1.0], + [-7.905069, 0.7697101, 1.0], + [-0.7297403, 0.9731825, 1.0], + [-0.00023977055, 0.99993455, 1.0], + [0.0, 1.0, 1.0], + [-8.394399, 0.6700475, 1.0], + [-6.597605, 0.77691966, 1.0], + [-1.2440937, 0.95707667, 1.0], + [0.0048901793, 0.9999025, 1.0], + [0.0, 1.0, 1.0], + [-7.0069995, 0.65731025, 1.0], + [-5.66303, 0.7795728, 1.0], + [-1.7324567, 0.9417374, 1.0], + [-0.008971522, 0.9993332, 1.0], + [0.0, 1.0, 1.0], + [-5.887643, 0.6666744, 1.0], + [-3.5603712, 0.8430337, 1.0], + [-1.0138998, 0.9716629, 1.0], + [-0.04949627, 0.9985733, 1.0], + [0.0, 1.0, 1.0], + [-4.3985267, 0.7247905, 1.0], + [-1.2181568, 0.9698552, 1.0], + [0.889513, 1.0484955, 1.0], + [3.5816274, 1.0411543, 1.0], + [0.0, 1.0, 1.0], + [-3.7034328, 0.7695667, 1.0], + [-0.7939945, 0.97303194, 1.0], + [4.931476, 1.0582556, 1.0], + [7.859906, 1.0638645, 1.0], + [0.0, 1.0, 1.0], + [-3.0874662, 0.82422346, 1.0], + [-1.2831166, 0.97711766, 1.0], + [2.3523912, 1.0499644, 1.0], + [6.217497, 1.0628635, 1.0], + [0.0, 1.0, 1.0], + [-2.7996006, 0.83586675, 1.0], + [-1.5887228, 0.9804846, 1.0], + [-0.12895624, 1.0526341, 1.0], + [1.8886707, 1.0742506, 1.0], + [0.0, 1.0, 1.0], + [-2.4595652, 0.7937858, 1.0], + [-1.5304415, 0.9256194, 1.0], + [0.49791723, 1.0557373, 1.0], + [1.8103733, 1.0766511, 1.0], + [0.0, 1.0, 1.0], + [-1.8303367, 0.74952114, 1.0], + [-1.2938573, 0.8971074, 1.0], + [-0.48455238, 1.0300556, 1.0], + [0.37591517, 1.0647826, 1.0], + [0.0, 1.0, 1.0], + [-0.7799921, 0.69642293, 1.0], + [-0.50904167, 0.8235331, 1.0], + [-0.7142233, 0.98446524, 1.0], + [-0.95801145, 1.0281906, 1.0], + [0.0, 1.0, 1.0], + [-0.12392041, 0.689838, 1.0], + [0.14127903, 0.8031821, 1.0], + [-0.71944845, 0.98211265, 1.0], + [-1.0947827, 1.0201542, 1.0], + [0.0, 1.0, 1.0], + [0.6446894, 0.6899457, 1.0], + [0.84962654, 0.8166399, 1.0], + [-0.5199237, 0.9733036, 1.0], + [-0.75620574, 1.0087903, 1.0], + [0.0, 1.0, 1.0], + [3.0502963, 0.68614894, 1.0], + [3.0366545, 0.81591415, 1.0], + [0.6730178, 0.9554072, 1.0], + [-0.007607221, 0.9978117, 1.0], + [0.0, 1.0, 1.0], + [6.0329676, 0.6941915, 1.0], + [5.0321407, 0.81927717, 1.0], + [1.6395164, 0.94279826, 1.0], + [-0.03492225, 0.9970755, 1.0], + [0.0, 1.0, 1.0], + [7.5941086, 0.71911955, 1.0], + [5.390384, 0.8432774, 1.0], + [1.8999803, 0.94711256, 1.0], + [0.0023088262, 0.9986426, 1.0], + [0.0, 1.0, 1.0], + [8.8637085, 0.74631906, 1.0], + [6.2989063, 0.85505736, 1.0], + [2.735379, 0.94543207, 1.0], + [0.06463228, 0.9988073, 1.0], + [0.0, 1.0, 1.0], + [9.588615, 0.7898098, 1.0], + [6.376941, 0.8845687, 1.0], + [2.3718834, 0.9601361, 1.0], + [0.013906611, 0.9995857, 1.0], + [0.0, 1.0, 1.0], + [10.782804, 0.83546484, 1.0], + [7.3467307, 0.92574006, 1.0], + [3.498415, 0.9810678, 1.0], + [0.10678408, 0.99972445, 1.0], + [0.0, 1.0, 1.0], + [10.249238, 0.86850566, 1.0], + [7.6262197, 0.94297904, 1.0], + [4.036777, 0.9895052, 1.0], + [0.16037866, 0.99999046, 1.0], + [0.0, 1.0, 1.0], + [9.931091, 0.8846399, 1.0], + [7.519002, 0.94786286, 1.0], + [4.074718, 0.9868774, 1.0], + [0.13248277, 1.0000677, 1.0], + [0.0, 1.0, 1.0], + [9.689591, 0.8865812, 1.0], + [7.432243, 0.9456806, 1.0], + [4.7070894, 0.983843, 1.0], + [0.18639432, 0.9999364, 1.0], + [0.0, 1.0, 1.0], + [10.043292, 0.8922917, 1.0], + [7.8223534, 0.9484012, 1.0], + [5.358868, 0.9835407, 1.0], + [0.31019974, 1.0000116, 1.0], + [0.0, 1.0, 1.0], + [10.045664, 0.9007508, 1.0], + [7.642868, 0.95041484, 1.0], + [5.4579797, 0.9804828, 1.0], + [0.39148968, 0.99986535, 1.0], + [0.0, 1.0, 1.0], + [9.769167, 0.9075519, 1.0], + [7.2386246, 0.95056725, 1.0], + [5.2662854, 0.976752, 1.0], + [0.4462296, 0.9994238, 1.0], + [0.0, 1.0, 1.0], + [9.814191, 0.9142886, 1.0], + [7.2126784, 0.94775087, 1.0], + [5.001264, 0.9686987, 1.0], + [0.38073996, 0.9989886, 1.0], + [0.0, 1.0, 1.0], + [10.10585, 0.9291066, 1.0], + [7.8851285, 0.9544348, 1.0], + [6.079623, 0.96844214, 1.0], + [0.6109284, 0.9989012, 1.0], + [0.0, 1.0, 1.0], + [9.871696, 0.94608027, 1.0], + [7.800825, 0.96270525, 1.0], + [6.1262703, 0.9673819, 1.0], + [0.84064543, 0.997365, 1.0], + [0.0, 1.0, 1.0], + [9.842098, 0.9636502, 1.0], + [7.603165, 0.9709367, 1.0], + [5.927902, 0.9726488, 1.0], + [2.3444896, 0.9899, 1.0], + [0.0, 1.0, 1.0], + [9.367189, 0.9913651, 1.0], + [7.1136265, 0.9881401, 1.0], + [5.5312037, 0.9773815, 1.0], + [2.3738406, 0.9883841, 1.0], + [0.0, 1.0, 1.0], + [8.445051, 1.028042, 1.0], + [6.4724793, 1.0249939, 1.0], + [5.7201777, 1.02951, 1.0], + [2.6472864, 1.022244, 1.0], + [0.0, 1.0, 1.0], + [4.8764477, 0.92064744, 1.0], + [4.063755, 0.9517052, 1.0], + [3.4358697, 0.9500948, 1.0], + [1.6742893, 0.9403114, 1.0], + [0.0, 1.0, 1.0], + [3.8074338, 0.9376684, 1.0], + [3.1544921, 0.99583703, 1.0], + [2.8396516, 1.019307, 1.0], + [2.761816, 0.9831139, 1.0], + [0.0, 1.0, 1.0], + [2.9735732, 0.9055372, 1.0], + [1.964001, 0.96994215, 1.0], + [1.1794817, 1.0004675, 1.0], + [0.23656735, 0.9900725, 1.0], + [0.0, 1.0, 1.0], + [2.4316142, 0.85408896, 1.0], + [1.4779971, 0.9194293, 1.0], + [0.36998236, 0.96382165, 1.0], + [-0.78495336, 0.9628938, 1.0], + [0.0, 1.0, 1.0], + [1.7202682, 0.85409456, 1.0], + [0.85657984, 0.90512234, 1.0], + [-0.034063853, 0.94638556, 1.0], + [-2.4795413, 0.9543113, 1.0], + [0.0, 1.0, 1.0], + [1.1699636, 0.85612476, 1.0], + [0.2226863, 0.8977348, 1.0], + [-0.55164605, 0.9310963, 1.0], + [-2.9155931, 0.9300699, 1.0], + [0.0, 1.0, 1.0], + [0.9195899, 0.8302417, 1.0], + [-0.01082096, 0.86319506, 1.0], + [-0.8897206, 0.90112203, 1.0], + [-2.8909357, 0.91573393, 1.0], + [0.0, 1.0, 1.0], + [0.70681113, 0.81225044, 1.0], + [-0.2829643, 0.83714634, 1.0], + [-1.2980901, 0.8815819, 1.0], + [-2.9096456, 0.90339696, 1.0], + [0.0, 1.0, 1.0], + [0.45244727, 0.8261228, 1.0], + [-0.86100143, 0.8436378, 1.0], + [-2.2569923, 0.88340265, 1.0], + [-3.456668, 0.8849395, 1.0], + [0.0, 1.0, 1.0], + [0.58151424, 0.81086206, 1.0], + [-1.0969541, 0.8453545, 1.0], + [-2.3675127, 0.877735, 1.0], + [-3.063623, 0.8733827, 1.0], + [0.0, 1.0, 1.0], + [0.1315448, 0.76973885, 1.0], + [-0.7705917, 0.7837816, 1.0], + [-1.8659682, 0.8216326, 1.0], + [-2.8834462, 0.8402058, 1.0], + [0.0, 1.0, 1.0], + [-0.9416674, 0.7494247, 1.0], + [-1.3754656, 0.76372516, 1.0], + [-1.4642401, 0.7619152, 1.0], + [-1.5173858, 0.72746414, 1.0], + [0.0, 1.0, 1.0], + [-1.9809289, 0.73669344, 1.0], + [-1.8224959, 0.76059735, 1.0], + [-1.6169965, 0.69803905, 1.0], + [-3.6714156, 0.63129187, 1.0], + [0.0, 1.0, 1.0], + [-2.4930844, 0.7037204, 1.0], + [-1.7829679, 0.7479987, 1.0], + [-1.4971536, 0.70428216, 1.0], + [-3.4572604, 0.5994394, 1.0], + [0.0, 1.0, 1.0], + [-3.0422633, 0.6668609, 1.0], + [-2.1922023, 0.74247324, 1.0], + [-1.6100665, 0.78744763, 1.0], + [-1.2063087, 0.78941995, 1.0], + [0.0, 1.0, 1.0], + [-3.2440093, 0.6231938, 1.0], + [-2.0041919, 0.73567647, 1.0], + [-0.86709636, 0.8052706, 1.0], + [-0.4469063, 0.8399889, 1.0], + [0.0, 1.0, 1.0], + [-4.5079556, 0.6042883, 1.0], + [-2.3472598, 0.7395057, 1.0], + [-0.054738186, 0.82054764, 1.0], + [0.41392553, 0.85697615, 1.0], + [0.0, 1.0, 1.0], + [-6.4233527, 0.63508, 1.0], + [-1.9719031, 0.75168645, 1.0], + [0.4196573, 0.8112182, 1.0], + [1.0182172, 0.85615927, 1.0], + [0.0, 1.0, 1.0], + [-5.9163876, 0.7051664, 1.0], + [-1.4782107, 0.7433952, 1.0], + [-0.40683872, 0.8031918, 1.0], + [-0.4788465, 0.8863584, 1.0], + [0.0, 1.0, 1.0], + [-3.908668, 0.785487, 1.0], + [-0.9831386, 0.79933167, 1.0], + [-4.1048765, 0.81978315, 1.0], + [-11.719547, 0.7330626, 1.0], + [0.0, 1.0, 1.0], + [-3.066866, 0.8465281, 1.0], + [-1.0374848, 0.8885559, 1.0], + [-13.052467, 0.68214613, 1.0], + [-21.323591, 0.57109445, 1.0], + [0.0, 1.0, 1.0], + [-2.7582388, 0.8883443, 1.0], + [0.5189229, 0.91708815, 1.0], + [-0.7883722, 0.7302985, 1.0], + [-6.2000527, 0.6300695, 1.0], + [0.0, 1.0, 1.0], + [-2.8354056, 0.9174532, 1.0], + [2.34739, 0.910805, 1.0], + [9.42942, 0.74594164, 1.0], + [11.120875, 0.67275065, 1.0], + [0.0, 1.0, 1.0], + [-2.2180405, 0.92904836, 1.0], + [1.5843687, 0.9051751, 1.0], + [12.040191, 0.7458105, 1.0], + [14.777283, 0.7015992, 1.0], + [0.0, 1.0, 1.0], + [-2.2773137, 0.935868, 1.0], + [1.4081348, 0.9290241, 1.0], + [9.324613, 0.8047278, 1.0], + [8.094886, 0.833392, 1.0], + [0.0, 1.0, 1.0], + [-2.0836408, 0.9049924, 1.0], + [5.6842046, 0.8789433, 1.0], + [10.288447, 0.81913996, 1.0], + [6.26537, 0.92870164, 1.0], + [0.0, 1.0, 1.0], + [-3.6201372, 0.9030173, 1.0], + [2.3488736, 0.92669946, 1.0], + [3.109163, 0.9226741, 1.0], + [0.50225174, 0.9823943, 1.0], + [0.0, 1.0, 1.0], + [-3.0695627, 0.8845679, 1.0], + [1.9500862, 0.95027864, 1.0], + [1.1459787, 0.8927759, 1.0], + [-1.3981293, 0.94081604, 1.0], + [0.0, 1.0, 1.0], + [-2.2040372, 0.8723052, 1.0], + [1.018672, 0.96575177, 1.0], + [1.4323145, 0.95249796, 1.0], + [-0.18759486, 0.9809426, 1.0], + [0.0, 1.0, 1.0], + [-2.4124901, 0.850615, 1.0], + [-0.05903271, 0.97407734, 1.0], + [0.12506364, 0.9955236, 1.0], + [-0.016985215, 0.9992087, 1.0], + [0.0, 1.0, 1.0], + [-3.8181849, 0.844787, 1.0], + [-0.22574311, 0.9738625, 1.0], + [0.1223641, 0.9969789, 1.0], + [0.0039951545, 0.99990684, 1.0], + [0.0, 1.0, 1.0], + [-3.4693308, 0.8397684, 1.0], + [-0.39281702, 0.9756446, 1.0], + [0.02598722, 0.999133, 1.0], + [0.0010384705, 0.999975, 1.0], + [0.0, 1.0, 1.0], + [-2.6781962, 0.85489655, 1.0], + [-0.38995895, 0.9785057, 1.0], + [-0.0037281574, 0.999837, 1.0], + [ + 1.6256523e-06, + 0.9999995, + 1.0 + ], + [0.0, 1.0, 1.0], + [-3.335379, 0.87124074, 1.0], + [-0.48180598, 0.9825704, 1.0], + [-0.005992216, 0.99981517, 1.0], + [ + -1.742749e-05, + 0.9999991, + 1.0 + ], + [0.0, 1.0, 1.0], + [-4.184813, 0.85200375, 1.0], + [-0.6187777, 0.9787277, 1.0], + [0.0022274007, 0.9996338, 1.0], + [0.00046732937, 0.9999927, 1.0], + [0.0, 1.0, 1.0], + [-5.3020663, 0.83856326, 1.0], + [-0.4638571, 0.97142124, 1.0], + [0.7337303, 0.990346, 1.0], + [0.0751591, 0.9990356, 1.0], + [0.0, 1.0, 1.0], + [-5.3410516, 0.80858886, 1.0], + [0.40069363, 0.95397055, 1.0], + [2.218948, 0.9716582, 1.0], + [0.2358686, 0.99697757, 1.0], + [0.0, 1.0, 1.0], + [-6.416645, 0.7545048, 1.0], + [-1.2956624, 0.939975, 1.0], + [0.12502326, 0.9976219, 1.0], + [0.013717032, 0.9998224, 1.0], + [0.0, 1.0, 1.0], + [-8.273487, 0.72121274, 1.0], + [-1.9356983, 0.91340506, 1.0], + [0.12353294, 0.9965626, 1.0], + [0.004541849, 0.99992204, 1.0], + [0.0, 1.0, 1.0], + [-8.028335, 0.684168, 1.0], + [-1.6612952, 0.8862172, 1.0], + [0.61427313, 0.9880001, 1.0], + [0.020742713, 0.99964684, 1.0], + [0.0, 1.0, 1.0], + [-5.686304, 0.6284216, 1.0], + [-2.6316142, 0.840866, 1.0], + [0.030721515, 0.9969365, 1.0], + [0.0030909944, 0.9999094, 1.0], + [0.0, 1.0, 1.0], + [-3.3644114, 0.5920495, 1.0], + [-2.1064131, 0.7581428, 1.0], + [0.08250783, 0.9909103, 1.0], + [0.004092879, 0.99986243, 1.0], + [0.0, 1.0, 1.0], + [-1.6098745, 0.564251, 1.0], + [-3.0388198, 0.6718815, 1.0], + [-0.6501952, 0.9627276, 1.0], + [-0.006046299, 0.9997709, 1.0], + [0.0, 1.0, 1.0], + [-2.3306668, 0.5407966, 1.0], + [-4.1029615, 0.6064727, 1.0], + [-2.8782537, 0.8844327, 1.0], + [-0.073128805, 0.9976695, 1.0], + [0.0, 1.0, 1.0], + [-3.6157587, 0.5336421, 1.0], + [-4.0551357, 0.6346509, 1.0], + [-4.1803465, 0.81513524, 1.0], + [-0.43286413, 0.9813739, 1.0], + [0.0, 1.0, 1.0], + [-3.80694, 0.55238813, 1.0], + [-2.9400527, 0.75945127, 1.0], + [-1.957085, 0.91473925, 1.0], + [0.23186429, 0.98100156, 1.0], + [0.0, 1.0, 1.0], + [-3.2804413, 0.5958404, 1.0], + [-2.4060252, 0.811464, 1.0], + [2.1061044, 0.98812366, 1.0], + [5.310169, 1.0376091, 1.0], + [0.0, 1.0, 1.0], + [-2.648095, 0.6832738, 1.0], + [-2.096382, 0.89832383, 1.0], + [-0.57513076, 0.9792816, 1.0], + [1.4691317, 1.0191329, 1.0], + [0.0, 1.0, 1.0], + [-2.191342, 0.7055293, 1.0], + [-1.838788, 0.9088253, 1.0], + [-0.9401996, 0.9815046, 1.0], + [1.2051747, 1.0288774, 1.0], + [0.0, 1.0, 1.0], + [-1.2887207, 0.6609743, 1.0], + [-1.1732396, 0.80482507, 1.0], + [0.4986351, 0.99515104, 1.0], + [1.5360615, 1.0385249, 1.0], + [0.0, 1.0, 1.0], + [0.40449002, 0.6293669, 1.0], + [-0.38308343, 0.7883763, 1.0], + [-0.03740224, 0.9558314, 1.0], + [0.6230026, 1.0127206, 1.0], + [0.0, 1.0, 1.0], + [1.6413921, 0.6115583, 1.0], + [1.5963948, 0.72960037, 1.0], + [0.6866751, 0.8849578, 1.0], + [0.2589341, 0.9375699, 1.0], + [0.0, 1.0, 1.0], + [2.4193525, 0.6249198, 1.0], + [2.7416816, 0.7095477, 1.0], + [1.3268055, 0.84555423, 1.0], + [0.3328897, 0.9368017, 1.0], + [0.0, 1.0, 1.0], + [3.1844013, 0.6277898, 1.0], + [2.8372567, 0.71899444, 1.0], + [1.2267267, 0.85043055, 1.0], + [0.04637693, 0.97700346, 1.0], + [0.0, 1.0, 1.0], + [4.5027733, 0.6327071, 1.0], + [3.217072, 0.7416387, 1.0], + [1.577103, 0.8646953, 1.0], + [0.19375536, 0.9870552, 1.0], + [0.0, 1.0, 1.0], + [6.149598, 0.64911604, 1.0], + [4.1019955, 0.74544936, 1.0], + [1.431085, 0.8806098, 1.0], + [0.047591608, 0.993709, 1.0], + [0.0, 1.0, 1.0], + [7.1056666, 0.67726934, 1.0], + [4.2305317, 0.78717184, 1.0], + [1.067521, 0.92705023, 1.0], + [0.015493356, 0.997994, 1.0], + [0.0, 1.0, 1.0], + [7.474384, 0.7088058, 1.0], + [3.5543373, 0.80445033, 1.0], + [0.19845833, 0.95335525, 1.0], + [-0.025593635, 0.999446, 1.0], + [0.0, 1.0, 1.0], + [7.1915207, 0.7416353, 1.0], + [3.4266174, 0.83173305, 1.0], + [-0.5223778, 0.9669094, 1.0], + [-0.15612096, 0.99790776, 1.0], + [0.0, 1.0, 1.0], + [7.5696697, 0.77016944, 1.0], + [3.989671, 0.85700655, 1.0], + [-1.2651324, 0.954754, 1.0], + [-0.73263675, 0.9871268, 1.0], + [0.0, 1.0, 1.0], + [6.1636243, 0.7912714, 1.0], + [3.4775562, 0.8605731, 1.0], + [0.2865357, 0.98138404, 1.0], + [-0.07876425, 0.9983868, 1.0], + [0.0, 1.0, 1.0], + [6.3284674, 0.805469, 1.0], + [3.75536, 0.8683642, 1.0], + [0.60794705, 0.98088616, 1.0], + [0.008510712, 0.9998971, 1.0], + [0.0, 1.0, 1.0], + [7.4278216, 0.8018612, 1.0], + [4.286087, 0.863598, 1.0], + [0.393694, 0.9731425, 1.0], + [-0.026216365, 0.999259, 1.0], + [0.0, 1.0, 1.0], + [7.8491583, 0.8168845, 1.0], + [4.428734, 0.8584228, 1.0], + [-0.6927958, 0.9467803, 1.0], + [-0.17571971, 0.9967572, 1.0], + [0.0, 1.0, 1.0], + [6.8759727, 0.80086964, 1.0], + [4.1140614, 0.8347237, 1.0], + [1.008583, 0.9550637, 1.0], + [0.01853103, 0.99938357, 1.0], + [0.0, 1.0, 1.0], + [6.002123, 0.7990564, 1.0], + [3.352922, 0.81814086, 1.0], + [1.1990601, 0.94414854, 1.0], + [0.047310542, 0.9994817, 1.0], + [0.0, 1.0, 1.0], + [5.735746, 0.79181194, 1.0], + [2.7306662, 0.7838909, 1.0], + [0.5309012, 0.8979575, 1.0], + [-0.041853912, 0.99701124, 1.0], + [0.0, 1.0, 1.0], + [6.215762, 0.7899453, 1.0], + [3.104759, 0.7658282, 1.0], + [1.124937, 0.8786092, 1.0], + [0.021710817, 0.997453, 1.0], + [0.0, 1.0, 1.0], + [6.1507196, 0.79735833, 1.0], + [3.023819, 0.7685839, 1.0], + [1.5032152, 0.8632985, 1.0], + [0.11779032, 0.9967827, 1.0], + [0.0, 1.0, 1.0], + [6.1006694, 0.80814075, 1.0], + [3.1378238, 0.78737235, 1.0], + [1.8207941, 0.86908215, 1.0], + [-1.1852233, 0.9505832, 1.0], + [0.0, 1.0, 1.0], + [5.903391, 0.8252152, 1.0], + [2.9287477, 0.79821754, 1.0], + [0.5213138, 0.83226186, 1.0], + [-3.60744, 0.8885766, 1.0], + [0.0, 1.0, 1.0], + [5.579378, 0.86621565, 1.0], + [3.267353, 0.85239756, 1.0], + [1.4851551, 0.86720455, 1.0], + [-0.043647174, 0.96538955, 1.0], + [0.0, 1.0, 1.0], + [2.251857, 0.8048092, 1.0], + [3.868587, 0.73451906, 1.0], + [7.095376, 0.7049663, 1.0], + [4.2607813, 0.8634737, 1.0], + [0.0, 1.0, 1.0], + [3.7544513, 0.81845486, 1.0], + [6.617525, 0.80338174, 1.0], + [12.432594, 0.7366481, 1.0], + [8.489516, 0.81540203, 1.0], + [0.0, 1.0, 1.0], + [2.860258, 0.80569935, 1.0], + [4.0649204, 0.84190506, 1.0], + [8.932321, 0.78642964, 1.0], + [4.9175606, 0.85279024, 1.0], + [0.0, 1.0, 1.0], + [2.7026193, 0.7538319, 1.0], + [2.5008564, 0.812968, 1.0], + [3.5890124, 0.8474743, 1.0], + [2.7400997, 0.859288, 1.0], + [0.0, 1.0, 1.0], + [2.3664002, 0.7394628, 1.0], + [1.7941258, 0.78985816, 1.0], + [1.1166536, 0.8809234, 1.0], + [0.11488261, 0.9726579, 1.0], + [0.0, 1.0, 1.0], + [2.4183512, 0.7125459, 1.0], + [1.8356653, 0.76030463, 1.0], + [1.5251784, 0.8337058, 1.0], + [-0.23323414, 0.96463525, 1.0], + [0.0, 1.0, 1.0], + [2.1229203, 0.6823615, 1.0], + [1.7064534, 0.7226445, 1.0], + [1.2662644, 0.78341854, 1.0], + [-0.34037447, 0.921522, 1.0], + [0.0, 1.0, 1.0], + [1.922368, 0.670744, 1.0], + [1.3548895, 0.71063024, 1.0], + [0.73747265, 0.767286, 1.0], + [-0.32575443, 0.8069876, 1.0], + [0.0, 1.0, 1.0], + [2.468624, 0.6560331, 1.0], + [1.1733623, 0.7006847, 1.0], + [0.91913277, 0.77642107, 1.0], + [1.3170806, 0.78835416, 1.0], + [0.0, 1.0, 1.0], + [2.7764182, 0.62865984, 1.0], + [1.8569264, 0.67932403, 1.0], + [0.09585123, 0.8226712, 1.0], + [0.114856094, 0.89764315, 1.0], + [0.0, 1.0, 1.0], + [1.6984649, 0.5723032, 1.0], + [3.1778703, 0.5982749, 1.0], + [1.6580566, 0.7152358, 1.0], + [0.38139626, 0.8871746, 1.0], + [0.0, 1.0, 1.0], + [0.49222204, 0.556624, 1.0], + [2.705092, 0.5625561, 1.0], + [-0.2695512, 0.56588423, 1.0], + [-2.8712876, 0.6261747, 1.0], + [0.0, 1.0, 1.0], + [0.27390626, 0.6263597, 1.0], + [4.712818, 0.58389944, 1.0], + [-1.3854016, 0.5315325, 1.0], + [-6.950099, 0.5193624, 1.0], + [0.0, 1.0, 1.0], + [-0.50358576, 0.6688169, 1.0], + [4.557341, 0.7064863, 1.0], + [-0.214069, 0.5653314, 1.0], + [-4.7740607, 0.5246774, 1.0], + [0.0, 1.0, 1.0], + [-2.4929795, 0.6411706, 1.0], + [-1.6451489, 0.8176667, 1.0], + [0.34974566, 0.8819101, 1.0], + [-0.32462633, 0.8140249, 1.0], + [0.0, 1.0, 1.0], + [-2.7058835, 0.59305847, 1.0], + [-1.4331628, 0.818264, 1.0], + [-1.1053259, 0.91194034, 1.0], + [-3.1190734, 0.90261114, 1.0], + [0.0, 1.0, 1.0], + [-4.1035285, 0.5795477, 1.0], + [-2.1045144, 0.7871938, 1.0], + [-6.331526, 0.7170312, 1.0], + [-10.788408, 0.664596, 1.0], + [0.0, 1.0, 1.0], + [-5.7934265, 0.6102454, 1.0], + [-3.6331925, 0.7699751, 1.0], + [-3.415193, 0.68833, 1.0], + [-3.7475498, 0.64053464, 1.0], + [0.0, 1.0, 1.0], + [-5.1393547, 0.6529689, 1.0], + [-5.753634, 0.708333, 1.0], + [-5.8755636, 0.6708419, 1.0], + [-3.0788336, 0.67883736, 1.0], + [0.0, 1.0, 1.0], + [-4.6082006, 0.7062166, 1.0], + [-6.588885, 0.72459567, 1.0], + [-7.484304, 0.6874229, 1.0], + [-13.227061, 0.65968275, 1.0], + [0.0, 1.0, 1.0], + [-3.7472286, 0.78321165, 1.0], + [-7.7237554, 0.7579945, 1.0], + [-14.800508, 0.60856223, 1.0], + [-19.890417, 0.54714346, 1.0], + [0.0, 1.0, 1.0], + [-1.57658, 0.81595886, 1.0], + [4.2668858, 0.7361184, 1.0], + [1.7692925, 0.6172541, 1.0], + [-2.8298097, 0.5611174, 1.0], + [0.0, 1.0, 1.0], + [1.9543127, 0.84595275, 1.0], + [9.600064, 0.7089187, 1.0], + [13.987817, 0.61992806, 1.0], + [14.091181, 0.5941112, 1.0], + [0.0, 1.0, 1.0], + [0.4278858, 0.898544, 1.0], + [3.9224489, 0.8269029, 1.0], + [14.653015, 0.6571721, 1.0], + [15.524843, 0.62407255, 1.0], + [0.0, 1.0, 1.0], + [0.7066278, 0.91855973, 1.0], + [4.9186754, 0.8462665, 1.0], + [14.977205, 0.6851345, 1.0], + [11.15482, 0.7066212, 1.0], + [0.0, 1.0, 1.0], + [1.253572, 0.9393247, 1.0], + [5.97083, 0.88211775, 1.0], + [9.293864, 0.8153296, 1.0], + [3.8026578, 0.90733314, 1.0], + [0.0, 1.0, 1.0], + [0.71835244, 0.9160959, 1.0], + [2.0100238, 0.91115075, 1.0], + [3.6926022, 0.90838015, 1.0], + [0.7498695, 0.97529143, 1.0], + [0.0, 1.0, 1.0], + [0.24475421, 0.8969049, 1.0], + [1.8341327, 0.92656446, 1.0], + [3.3835375, 0.8649656, 1.0], + [0.05131399, 0.9339182, 1.0], + [0.0, 1.0, 1.0], + [-0.3472424, 0.9435528, 1.0], + [1.3106259, 0.955991, 1.0], + [-1.6902066, 0.89264774, 1.0], + [-2.4834325, 0.9318436, 1.0], + [0.0, 1.0, 1.0], + [0.1349813, 0.96349823, 1.0], + [3.2832685, 0.92533123, 1.0], + [1.5165656, 0.94987315, 1.0], + [-0.21866378, 0.9915875, 1.0], + [0.0, 1.0, 1.0], + [-0.1588628, 0.96242386, 1.0], + [4.3031096, 0.8986765, 1.0], + [2.426284, 0.9446792, 1.0], + [0.0851209, 0.998018, 1.0], + [0.0, 1.0, 1.0], + [-0.21821694, 0.97361916, 1.0], + [1.0029128, 0.96765107, 1.0], + [0.6752989, 0.98369634, 1.0], + [0.024960788, 0.9994141, 1.0], + [0.0, 1.0, 1.0], + [1.142324, 0.9429116, 1.0], + [-0.08796144, 0.9916399, 1.0], + [-0.021721277, 0.99964666, 1.0], + [ + 5.6598337e-06, + 0.9999943, + 1.0 + ], + [0.0, 1.0, 1.0], + [0.42995253, 0.95589715, 1.0], + [-0.1302341, 0.99092823, 1.0], + [-0.014143473, 0.99967444, 1.0], + [ + -6.91973e-05, + 0.9999987, + 1.0 + ], + [0.0, 1.0, 1.0], + [-0.6089942, 0.95499057, 1.0], + [-0.2533017, 0.9897083, 1.0], + [-0.01806424, 0.9994577, 1.0], + [0.00011731918, 0.9999946, 1.0], + [0.0, 1.0, 1.0], + [-0.41291016, 0.9776058, 1.0], + [0.20236667, 0.99470246, 1.0], + [0.38761485, 0.9949851, 1.0], + [0.038843628, 0.99950147, 1.0], + [0.0, 1.0, 1.0], + [-0.44329658, 0.9657365, 1.0], + [0.7018292, 0.98709005, 1.0], + [1.1931813, 0.98478085, 1.0], + [0.12222669, 0.9984329, 1.0], + [0.0, 1.0, 1.0], + [-0.563516, 0.94921166, 1.0], + [-0.04889182, 0.9925632, 1.0], + [0.071504414, 0.9989917, 1.0], + [0.007119859, 0.99990845, 1.0], + [0.0, 1.0, 1.0], + [-1.4962281, 0.8972278, 1.0], + [0.15459283, 0.9727671, 1.0], + [0.23805064, 0.99562365, 1.0], + [0.0077146757, 0.9998695, 1.0], + [0.0, 1.0, 1.0], + [-2.4667382, 0.8505024, 1.0], + [2.129667, 0.9446288, 1.0], + [1.1396877, 0.98031807, 1.0], + [0.037179574, 0.9993321, 1.0], + [0.0, 1.0, 1.0], + [-3.781338, 0.73189, 1.0], + [1.1288502, 0.93383217, 1.0], + [0.53486246, 0.9789267, 1.0], + [0.12766436, 0.9938904, 1.0], + [0.0, 1.0, 1.0], + [-6.5518723, 0.62532294, 1.0], + [0.68554205, 0.8983733, 1.0], + [0.57966214, 0.96441585, 1.0], + [0.20091358, 0.98821115, 1.0], + [0.0, 1.0, 1.0], + [-4.343184, 0.5767734, 1.0], + [-1.3167001, 0.8343934, 1.0], + [0.050232526, 0.99427885, 1.0], + [0.0041727684, 0.9994736, 1.0], + [0.0, 1.0, 1.0], + [-3.2084956, 0.5238724, 1.0], + [-3.5061476, 0.6398532, 1.0], + [-0.76263064, 0.96318024, 1.0], + [-0.01846399, 0.9992613, 1.0], + [0.0, 1.0, 1.0], + [-4.0521426, 0.50757915, 1.0], + [-4.9473753, 0.56081027, 1.0], + [-3.688444, 0.8328823, 1.0], + [-0.18685617, 0.99239963, 1.0], + [0.0, 1.0, 1.0], + [-4.2548413, 0.50943226, 1.0], + [-4.365784, 0.6119638, 1.0], + [-3.4715931, 0.8569653, 1.0], + [-0.3267715, 0.9877998, 1.0], + [0.0, 1.0, 1.0], + [-3.9077795, 0.5238228, 1.0], + [-3.707032, 0.67760634, 1.0], + [-2.9548562, 0.87688464, 1.0], + [-0.83595866, 0.9781673, 1.0], + [0.0, 1.0, 1.0], + [-2.8748026, 0.59341663, 1.0], + [-3.0543437, 0.8072005, 1.0], + [-3.223465, 0.8836812, 1.0], + [-2.364663, 0.9595582, 1.0], + [0.0, 1.0, 1.0], + [-2.5147824, 0.5750547, 1.0], + [-2.7698386, 0.72502124, 1.0], + [-2.2416823, 0.87920475, 1.0], + [-0.027188452, 0.96920735, 1.0], + [0.0, 1.0, 1.0], + [-1.3069018, 0.5413889, 1.0], + [-1.3765919, 0.6018836, 1.0], + [-0.23360418, 0.9007077, 1.0], + [0.44479728, 0.97555995, 1.0], + [0.0, 1.0, 1.0], + [0.36504295, 0.5780787, 1.0], + [0.102457404, 0.6328473, 1.0], + [0.9009599, 0.8177817, 1.0], + [0.7650351, 0.900801, 1.0], + [0.0, 1.0, 1.0], + [2.2459548, 0.6080276, 1.0], + [2.6340177, 0.6246803, 1.0], + [2.156162, 0.7627034, 1.0], + [1.6283973, 0.8479486, 1.0], + [0.0, 1.0, 1.0], + [2.3978112, 0.6188074, 1.0], + [2.9135118, 0.5810156, 1.0], + [2.4169562, 0.67806387, 1.0], + [0.88490367, 0.9132394, 1.0], + [0.0, 1.0, 1.0], + [2.6887186, 0.6619631, 1.0], + [2.912676, 0.61564636, 1.0], + [2.531864, 0.7293305, 1.0], + [0.44374037, 0.97021365, 1.0], + [0.0, 1.0, 1.0], + [3.5004687, 0.7542064, 1.0], + [2.6793096, 0.72422296, 1.0], + [1.9161915, 0.8289456, 1.0], + [0.20282242, 0.98497564, 1.0], + [0.0, 1.0, 1.0], + [3.6729927, 0.81156904, 1.0], + [1.9421659, 0.77600074, 1.0], + [0.16385932, 0.8949159, 1.0], + [0.00030535206, 0.99532956, 1.0], + [0.0, 1.0, 1.0], + [0.78105825, 0.8560677, 1.0], + [0.10841931, 0.88307595, 1.0], + [-0.057357736, 0.968077, 1.0], + [-0.0019446412, 0.9990688, 1.0], + [0.0, 1.0, 1.0], + [-0.46066833, 0.862668, 1.0], + [-0.5855417, 0.8481995, 1.0], + [-0.05946241, 0.96510565, 1.0], + [-0.00071739085, 0.9993665, 1.0], + [0.0, 1.0, 1.0], + [0.67381376, 0.87415904, 1.0], + [0.24980347, 0.875317, 1.0], + [-0.49778527, 0.9727575, 1.0], + [-0.17107673, 0.996763, 1.0], + [0.0, 1.0, 1.0], + [1.0746922, 0.91508603, 1.0], + [-1.2298028, 0.89923114, 1.0], + [-3.1673076, 0.9375756, 1.0], + [-1.0668249, 0.9814089, 1.0], + [0.0, 1.0, 1.0], + [0.26751196, 0.9166898, 1.0], + [-0.44020677, 0.9547969, 1.0], + [-0.52170914, 0.9885321, 1.0], + [-0.13126421, 0.99768424, 1.0], + [0.0, 1.0, 1.0], + [1.475818, 0.9037874, 1.0], + [0.21531126, 0.94464564, 1.0], + [-0.20985661, 0.99219394, 1.0], + [-0.009496927, 0.99976933, 1.0], + [0.0, 1.0, 1.0], + [1.0757535, 0.907198, 1.0], + [-1.7803211, 0.9137079, 1.0], + [-1.6718693, 0.9619583, 1.0], + [-0.09668204, 0.9980284, 1.0], + [0.0, 1.0, 1.0], + [1.4640148, 0.89358455, 1.0], + [-2.225918, 0.8979506, 1.0], + [-3.6723483, 0.93562967, 1.0], + [-0.34880057, 0.99417984, 1.0], + [0.0, 1.0, 1.0], + [1.519818, 0.8728914, 1.0], + [-0.15754203, 0.9012406, 1.0], + [-0.82398623, 0.97935987, 1.0], + [-0.07315472, 0.998723, 1.0], + [0.0, 1.0, 1.0], + [-0.08069864, 0.8122788, 1.0], + [-1.1186706, 0.80712515, 1.0], + [-0.32798508, 0.97505164, 1.0], + [-0.013122095, 0.99961483, 1.0], + [0.0, 1.0, 1.0], + [-0.58201814, 0.7335289, 1.0], + [-2.675131, 0.7052787, 1.0], + [-3.4144542, 0.8866787, 1.0], + [-0.34905022, 0.99161583, 1.0], + [0.0, 1.0, 1.0], + [-0.20724466, 0.6997621, 1.0], + [-1.3870329, 0.6642682, 1.0], + [-2.3293695, 0.8806666, 1.0], + [-0.22909772, 0.9941094, 1.0], + [0.0, 1.0, 1.0], + [-0.38550782, 0.6612821, 1.0], + [-2.025744, 0.6200057, 1.0], + [-1.6411146, 0.8548279, 1.0], + [-0.15217084, 0.9952627, 1.0], + [0.0, 1.0, 1.0], + [-1.2272899, 0.6092909, 1.0], + [-2.3132815, 0.57801414, 1.0], + [-4.862611, 0.7366766, 1.0], + [-4.110943, 0.9027854, 1.0], + [0.0, 1.0, 1.0], + [-0.5921729, 0.6219872, 1.0], + [-1.8060966, 0.59441036, 1.0], + [-5.2765656, 0.6901705, 1.0], + [-5.4457603, 0.8478828, 1.0], + [0.0, 1.0, 1.0], + [-0.6031663, 0.71592474, 1.0], + [-3.0251799, 0.6357208, 1.0], + [-2.314684, 0.7075568, 1.0], + [-0.55420774, 0.92152005, 1.0], + [0.0, 1.0, 1.0], + [0.9571505, 0.96764106, 1.0], + [2.2270002, 0.93239284, 1.0], + [4.9888797, 0.8701656, 1.0], + [5.671471, 0.871926, 1.0], + [0.0, 1.0, 1.0], + [1.6529764, 0.9643956, 1.0], + [3.7584913, 0.91487896, 1.0], + [8.399242, 0.8215937, 1.0], + [7.2129803, 0.84500617, 1.0], + [0.0, 1.0, 1.0], + [0.71301705, 0.95700616, 1.0], + [3.5324132, 0.91654754, 1.0], + [7.481569, 0.83848864, 1.0], + [3.3779297, 0.9219365, 1.0], + [0.0, 1.0, 1.0], + [2.1027052, 0.9415878, 1.0], + [3.9730468, 0.90671736, 1.0], + [2.484185, 0.9380085, 1.0], + [0.4668634, 0.98106366, 1.0], + [0.0, 1.0, 1.0], + [1.8107373, 0.95316005, 1.0], + [2.2031755, 0.9406371, 1.0], + [0.9073857, 0.97643065, 1.0], + [0.074082, 0.9965095, 1.0], + [0.0, 1.0, 1.0], + [1.9055933, 0.9425168, 1.0], + [2.002828, 0.9310888, 1.0], + [1.4750477, 0.9285675, 1.0], + [0.8887092, 0.93962455, 1.0], + [0.0, 1.0, 1.0], + [3.262398, 0.89178324, 1.0], + [4.3024693, 0.8579417, 1.0], + [3.0110278, 0.89283866, 1.0], + [0.7838177, 0.94552124, 1.0], + [0.0, 1.0, 1.0], + [3.9675722, 0.85747033, 1.0], + [2.8633819, 0.8719699, 1.0], + [1.8338549, 0.8609559, 1.0], + [-0.20613429, 0.8572672, 1.0], + [0.0, 1.0, 1.0], + [5.608068, 0.8017892, 1.0], + [2.561957, 0.8876735, 1.0], + [0.74627113, 0.9300394, 1.0], + [-0.3343548, 0.9052658, 1.0], + [0.0, 1.0, 1.0], + [6.1944766, 0.7262175, 1.0], + [4.09076, 0.79534775, 1.0], + [0.22131784, 0.9268037, 1.0], + [-0.41391882, 0.8326499, 1.0], + [0.0, 1.0, 1.0], + [4.064076, 0.70480764, 1.0], + [6.553729, 0.66739255, 1.0], + [1.9781996, 0.8487641, 1.0], + [-0.121733144, 0.9128515, 1.0], + [0.0, 1.0, 1.0], + [5.1924176, 0.7348373, 1.0], + [6.2945476, 0.59355253, 1.0], + [2.6655605, 0.603478, 1.0], + [-0.39269662, 0.750913, 1.0], + [0.0, 1.0, 1.0], + [4.218477, 0.84765875, 1.0], + [6.473049, 0.63901764, 1.0], + [2.3956764, 0.55744296, 1.0], + [-4.668969, 0.5518637, 1.0], + [0.0, 1.0, 1.0], + [1.4455693, 0.9488281, 1.0], + [2.86164, 0.8824624, 1.0], + [-0.83002204, 0.66669524, 1.0], + [-5.9115415, 0.5832427, 1.0], + [0.0, 1.0, 1.0], + [-0.36723047, 0.958581, 1.0], + [-0.9645545, 0.9544808, 1.0], + [-0.22951192, 0.9522579, 1.0], + [-0.5863564, 0.93541694, 1.0], + [0.0, 1.0, 1.0], + [-1.2512479, 0.9246585, 1.0], + [-0.7575997, 0.8948588, 1.0], + [-0.021703921, 0.8694443, 1.0], + [-1.218604, 0.917733, 1.0], + [0.0, 1.0, 1.0], + [-0.7749454, 0.96086174, 1.0], + [-1.9861422, 0.88336587, 1.0], + [-2.37847, 0.7608838, 1.0], + [-4.573041, 0.7435837, 1.0], + [0.0, 1.0, 1.0], + [-0.47515392, 0.96518725, 1.0], + [-1.1331214, 0.9392888, 1.0], + [-1.6403626, 0.82869226, 1.0], + [-0.24246822, 0.77455837, 1.0], + [0.0, 1.0, 1.0], + [-0.11114399, 0.92392385, 1.0], + [-0.93227565, 0.86591965, 1.0], + [0.2098795, 0.77408874, 1.0], + [4.1791086, 0.76218206, 1.0], + [0.0, 1.0, 1.0], + [-0.3893674, 0.9528764, 1.0], + [0.30993137, 0.88314533, 1.0], + [0.1185184, 0.7693184, 1.0], + [-5.573007, 0.7812261, 1.0], + [0.0, 1.0, 1.0], + [-0.3838659, 0.979484, 1.0], + [-1.1585885, 0.9468169, 1.0], + [-6.0387793, 0.80375, 1.0], + [-10.658616, 0.6974271, 1.0], + [0.0, 1.0, 1.0], + [0.61331856, 0.96465194, 1.0], + [3.4350507, 0.88050884, 1.0], + [4.5260396, 0.7807682, 1.0], + [4.9431553, 0.6703685, 1.0], + [0.0, 1.0, 1.0], + [0.6757144, 0.9519377, 1.0], + [3.6062489, 0.79651386, 1.0], + [8.332298, 0.73199075, 1.0], + [11.87797, 0.70557594, 1.0], + [0.0, 1.0, 1.0], + [-1.2273861, 0.95738363, 1.0], + [-3.0259614, 0.86301917, 1.0], + [4.467369, 0.7776754, 1.0], + [10.518545, 0.73643816, 1.0], + [0.0, 1.0, 1.0], + [-1.0061618, 0.9638054, 1.0], + [-0.028563451, 0.8975071, 1.0], + [4.998733, 0.8193289, 1.0], + [0.36073107, 0.7914785, 1.0], + [0.0, 1.0, 1.0], + [0.01982086, 0.9933504, 1.0], + [0.64965045, 0.97897077, 1.0], + [-0.023847675, 0.92317396, 1.0], + [-5.3040524, 0.8655476, 1.0], + [0.0, 1.0, 1.0], + [-0.7715526, 0.9489036, 1.0], + [-1.1035258, 0.9647486, 1.0], + [-0.073881075, 0.9852907, 1.0], + [-0.5774172, 0.98273665, 1.0], + [0.0, 1.0, 1.0], + [0.47609085, 0.9533993, 1.0], + [-0.4830082, 0.9705133, 1.0], + [0.31816888, 0.98172605, 1.0], + [0.09138787, 0.9937235, 1.0], + [0.0, 1.0, 1.0], + [0.7656475, 0.9784108, 1.0], + [1.1379092, 0.9705946, 1.0], + [-0.40117097, 0.9814611, 1.0], + [-0.43116137, 0.98941934, 1.0], + [0.0, 1.0, 1.0], + [0.96292067, 0.9768109, 1.0], + [1.9278172, 0.95556206, 1.0], + [0.4385697, 0.9872155, 1.0], + [-0.031007234, 0.9986333, 1.0], + [0.0, 1.0, 1.0], + [-0.5708252, 0.977658, 1.0], + [0.44023803, 0.94770473, 1.0], + [0.6502315, 0.98133624, 1.0], + [0.023992987, 0.99942213, 1.0], + [0.0, 1.0, 1.0], + [-1.115171, 0.97515327, 1.0], + [-1.192545, 0.96181446, 1.0], + [0.07483103, 0.9923174, 1.0], + [0.0070394175, 0.99980354, 1.0], + [0.0, 1.0, 1.0], + [-1.0444499, 0.98199964, 1.0], + [-2.1357777, 0.9839715, 1.0], + [-0.24349906, 0.99832034, 1.0], + [-0.0015810649, 0.9999879, 1.0], + [0.0, 1.0, 1.0], + [-1.7282641, 0.9509198, 1.0], + [-1.8230197, 0.96066856, 1.0], + [-0.12977597, 0.9974213, 1.0], + [-0.00060932315, 0.99998885, 1.0], + [0.0, 1.0, 1.0], + [-1.1258417, 0.9449696, 1.0], + [-1.9602667, 0.95090944, 1.0], + [-0.17851818, 0.9957969, 1.0], + [-0.00095068174, 0.9999777, 1.0], + [0.0, 1.0, 1.0], + [-0.020462122, 0.9958111, 1.0], + [-0.103778236, 0.99696887, 1.0], + [-0.007417411, 0.999726, 1.0], + [0.00020599048, 0.99999535, 1.0], + [0.0, 1.0, 1.0], + [0.00027978048, 0.9997287, 1.0], + [0.0051376326, 0.9999058, 1.0], + [0.008358893, 0.99989253, 1.0], + [0.0008256137, 0.9999894, 1.0], + [0.0, 1.0, 1.0], + [-0.002835088, 0.9997184, 1.0], + [-0.00021424032, 0.9999535, 1.0], + [0.0004976028, 0.99999297, 1.0], + [ + 4.8389087e-05, + 0.9999994, + 1.0 + ], + [0.0, 1.0, 1.0], + [-0.015410396, 0.99877673, 1.0], + [0.010854519, 0.9995375, 1.0], + [0.0058286395, 0.9998951, 1.0], + [0.00017566227, 0.999997, 1.0], + [0.0, 1.0, 1.0], + [0.15632677, 0.99270207, 1.0], + [0.5055092, 0.9882981, 1.0], + [0.09153723, 0.99786544, 1.0], + [0.0029066182, 0.9998918, 1.0], + [0.0, 1.0, 1.0], + [0.3650484, 0.975003, 1.0], + [1.0115135, 0.9615986, 1.0], + [0.085873, 0.96660244, 1.0], + [0.11893891, 0.99087006, 1.0], + [0.0, 1.0, 1.0], + [-0.7944044, 0.95407045, 1.0], + [-1.6605172, 0.94695765, 1.0], + [-2.2631047, 0.9047574, 1.0], + [-0.049214106, 0.97751004, 1.0], + [0.0, 1.0, 1.0], + [-1.1152909, 0.90077907, 1.0], + [-0.33290938, 0.98415893, 1.0], + [-0.4078292, 0.98850363, 1.0], + [-0.035879742, 0.9983943, 1.0], + [0.0, 1.0, 1.0], + [-2.5095966, 0.73562944, 1.0], + [-1.7563529, 0.92154866, 1.0], + [-0.6108345, 0.9851728, 1.0], + [-0.017620184, 0.9995598, 1.0], + [0.0, 1.0, 1.0], + [-3.7723317, 0.584941, 1.0], + [-3.2452734, 0.78533214, 1.0], + [-1.7837398, 0.94404143, 1.0], + [-0.27508318, 0.9898979, 1.0], + [0.0, 1.0, 1.0], + [-3.912875, 0.56384224, 1.0], + [-2.3403068, 0.76557755, 1.0], + [-2.328987, 0.90948534, 1.0], + [-0.72994906, 0.9717644, 1.0], + [0.0, 1.0, 1.0], + [-3.6593647, 0.61010647, 1.0], + [-2.3331697, 0.79162896, 1.0], + [-1.2066433, 0.8530142, 1.0], + [-0.5269507, 0.9523624, 1.0], + [0.0, 1.0, 1.0], + [-2.3541603, 0.7243888, 1.0], + [-2.905786, 0.7482802, 1.0], + [-2.578955, 0.7870731, 1.0], + [-0.48410028, 0.9308829, 1.0], + [0.0, 1.0, 1.0], + [-1.8553971, 0.7095888, 1.0], + [-3.4988222, 0.7074026, 1.0], + [-2.3901432, 0.84288883, 1.0], + [-2.6604743, 0.8977006, 1.0], + [0.0, 1.0, 1.0], + [-1.1934421, 0.68950033, 1.0], + [-1.7103893, 0.6494678, 1.0], + [-2.905974, 0.830583, 1.0], + [-3.459951, 0.88124573, 1.0], + [0.0, 1.0, 1.0], + [-0.9005052, 0.7782541, 1.0], + [0.25286996, 0.58092237, 1.0], + [3.6827495, 0.60284424, 1.0], + [1.3592215, 0.71437454, 1.0], + [0.0, 1.0, 1.0], + [2.7498083, 0.6792007, 1.0], + [5.0188756, 0.5581819, 1.0], + [6.5940504, 0.59141374, 1.0], + [5.501629, 0.72167873, 1.0], + [0.0, 1.0, 1.0], + [11.003128, 0.6373507, 1.0], + [10.752663, 0.5938981, 1.0], + [5.730028, 0.70469797, 1.0], + [1.2751229, 0.91109717, 1.0], + [0.0, 1.0, 1.0], + [8.977964, 0.6975431, 1.0], + [8.752965, 0.70448804, 1.0], + [5.0871115, 0.8300587, 1.0], + [1.5494138, 0.9552021, 1.0], + [0.0, 1.0, 1.0], + [5.414441, 0.82363, 1.0], + [3.8264759, 0.86136043, 1.0], + [1.5306748, 0.9412156, 1.0], + [0.11795472, 0.99586946, 1.0], + [0.0, 1.0, 1.0], + [4.8325124, 0.9293107, 1.0], + [1.3051184, 0.8975878, 1.0], + [-0.40878016, 0.95939577, 1.0], + [-0.022907255, 0.99856335, 1.0], + [0.0, 1.0, 1.0], + [1.3105308, 0.95905125, 1.0], + [-0.39071774, 0.9465973, 1.0], + [-0.46491575, 0.9753931, 1.0], + [-0.017971309, 0.9991197, 1.0], + [0.0, 1.0, 1.0], + [1.1686826, 0.94627494, 1.0], + [-0.10396043, 0.96546596, 1.0], + [-0.020627985, 0.9919678, 1.0], + [-0.00016111118, 0.99983495, 1.0], + [0.0, 1.0, 1.0], + [0.9874247, 0.97295517, 1.0], + [-0.12256643, 0.97974455, 1.0], + [-0.035051867, 0.996143, 1.0], + [-0.0033689938, 0.99987954, 1.0], + [0.0, 1.0, 1.0], + [2.1092582, 0.97678125, 1.0], + [-0.4759864, 0.9802004, 1.0], + [-0.21539624, 0.99497736, 1.0], + [-0.023042068, 0.9995811, 1.0], + [0.0, 1.0, 1.0], + [-0.024750752, 0.9861515, 1.0], + [-0.19931263, 0.99379665, 1.0], + [-0.05572195, 0.9987022, 1.0], + [-0.0031600085, 0.9999404, 1.0], + [0.0, 1.0, 1.0], + [0.7919422, 0.9732687, 1.0], + [-0.09644, 0.9860887, 1.0], + [-0.18857616, 0.9951589, 1.0], + [-0.008815362, 0.9997931, 1.0], + [0.0, 1.0, 1.0], + [0.6597246, 0.9796911, 1.0], + [-1.5101999, 0.96022296, 1.0], + [-1.2019504, 0.97245467, 1.0], + [-0.0584563, 0.99866176, 1.0], + [0.0, 1.0, 1.0], + [0.50696546, 0.98583317, 1.0], + [-0.6666319, 0.9827294, 1.0], + [-0.8286432, 0.98483706, 1.0], + [-0.065979086, 0.9988241, 1.0], + [0.0, 1.0, 1.0], + [0.67447394, 0.99499726, 1.0], + [-0.10373823, 0.9929153, 1.0], + [-0.19383939, 0.9964306, 1.0], + [-0.016325893, 0.9997172, 1.0], + [0.0, 1.0, 1.0], + [0.009086539, 0.99369854, 1.0], + [-0.13390036, 0.9848741, 1.0], + [-0.047612753, 0.9982364, 1.0], + [-0.0026982238, 0.99993587, 1.0], + [0.0, 1.0, 1.0], + [0.25034446, 0.95816684, 1.0], + [-1.1009699, 0.93954855, 1.0], + [-1.2868019, 0.968089, 1.0], + [-0.10685055, 0.99749947, 1.0], + [0.0, 1.0, 1.0], + [-0.17949252, 0.957553, 1.0], + [-1.1589056, 0.9355635, 1.0], + [-1.1646432, 0.97002524, 1.0], + [-0.09039519, 0.9978748, 1.0], + [0.0, 1.0, 1.0], + [-0.26302215, 0.9246536, 1.0], + [-1.1882069, 0.8875307, 1.0], + [-0.49334013, 0.97781765, 1.0], + [-0.041776404, 0.99893105, 1.0], + [0.0, 1.0, 1.0], + [-0.36667982, 0.84665173, 1.0], + [-1.4176713, 0.7761855, 1.0], + [-1.7322344, 0.91246706, 1.0], + [-0.9948195, 0.97528285, 1.0], + [0.0, 1.0, 1.0], + [-0.33352765, 0.8812866, 1.0], + [-1.3768619, 0.7867589, 1.0], + [-2.1349912, 0.8586197, 1.0], + [-1.0613863, 0.96087015, 1.0], + [0.0, 1.0, 1.0], + [-0.16149376, 0.95193124, 1.0], + [-0.5565226, 0.8689309, 1.0], + [-0.71723574, 0.8644273, 1.0], + [-0.036870863, 0.9551357, 1.0] + ], + "srgb_value": true + } +} diff --git a/crates/catalog/src/folders.rs b/crates/catalog/src/folders.rs new file mode 100644 index 000000000..61a8f8c4a --- /dev/null +++ b/crates/catalog/src/folders.rs @@ -0,0 +1,226 @@ +//! The library's folder tree: where on disk the imported photos live. +//! +//! A *folder* here is a folder holding at least one photo of the library (imported, not deleted, +//! not merely browsed in Local; demo scenes have no folder). The tree lists those folders and the +//! ones above them, never a folder that holds nothing of the library, with how many photos each +//! holds — its subfolders included, like a year in By Date counts its months. +//! +//! Every folder is a row of its own, the ones above the imported photos included +//! (`/Users` → `me` → `Pictures`), so a row is exactly the folder it names. A folder spelled two +//! ways (`/` or `\`, repeated or trailing separators, `.`) is one folder (see +//! [`crate::query::folder_key`]). +//! +//! Each disk is a *volume*, a top-level row of its own: a drive letter (`D:`), a Windows share +//! (`//server/share`), a mounted disk or share (macOS `/Volumes/tokyo`, Linux `/media/me/usb`, +//! `/mnt/disk`, `/run/user/1000/gvfs/smb-share:…`, `/net/host`) or, for everything else, the +//! startup disk (`/`). Only the spelling of the stored paths tells them apart: no disk is +//! touched, so a share that is asleep or gone lists and counts like one that is there, and the +//! sidebar never waits on the network. +//! +//! Limits of reading only the stored spelling of a path: file names are compared as written +//! (Windows folds case, other systems do not), so on a case-insensitive disk `/Volumes/tokyo` and +//! `/Volumes/TOKYO` are two volumes here; and one place reached by two spellings +//! (`/Volumes/Macintosh HD/Users/me` and `/Users/me`, `/media/me/usb` and `/run/media/me/usb`) is +//! two rows. Each row is still consistent: choosing it shows exactly the photos it counts. +//! +//! This is the library's own view of its photos; browsing any folder on disk without importing +//! is Local's job (see [`crate::local`]). + +use std::collections::{BTreeMap, HashMap}; + +use serde::Serialize; + +use crate::query::{folder_key, folder_within, split_path}; +use crate::{Catalog, Source}; + +/// Folder levels kept per photo. No real path comes near it; a deeper one is counted at its +/// ancestor this many levels down, which bounds the tree's depth (and so every walk of it) +/// whatever a catalog holds. +pub const MAX_DEPTH: usize = 64; + +/// One row of the library's folder tree. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct FolderNode { + /// What the row says: the folder's name; for a volume, its name (`nas`, `D:`, + /// `//server/share`, `/` for the startup disk). + pub name: String, + /// The folder the row stands for, written out from the names of the photos' paths (forward + /// slashes, no verbatim prefix); hand it back to [`crate::Filter::library_folder`] to see its + /// photos. + pub path: String, + /// Photos in this folder or any folder inside it. + pub count: usize, + /// Photos directly in this folder. + pub own: usize, + /// A disk rather than a folder in one; its `path` is where it is mounted. + pub volume: bool, + /// Whether choosing the row (`Filter::library_folder`) shows exactly its `count` photos. Not + /// so for the startup disk and for folders that hold other disks (`/Volumes`, `/mnt`…): their + /// path covers those disks' photos too, so choosing them is not offered. + pub selectable: bool, + pub children: Vec, +} + +/// A folder while the tree is being built (children by identity key). +struct Level { + name: String, + path: String, + own: usize, + volume: bool, + children: Vec, +} + +/// Where a stored folder path lies: its volume and the folder names below it. +struct Placed { + mount: String, + volume: String, + names: Vec, +} + +/// A gvfs mount's name made readable: `smb-share:server=tokyo,share=photos` → `photos on tokyo`; +/// any other (`sftp:host=nas`) as it is. +fn mount_name(n: &str) -> String { + let field = |key: &str| { + let rest = n.strip_prefix("smb-share:")?; + rest.split(',').find_map(|kv| kv.strip_prefix(key)?.strip_prefix('=')).filter(|v| !v.is_empty()) + }; + match (field("share"), field("server")) { + (Some(share), Some(server)) => format!("{share} on {server}"), + _ => n.to_string(), + } +} + +/// The volume `dir` lies on and the folder names below it, read the way [`folder_key`] reads a +/// path (so a row and the filter that chooses it never disagree). `None` for a path that is not +/// absolute or names no share. +fn place(dir: &str) -> Option { + let sp = split_path(dir, false); + let names: Vec<&str> = sp.parts.iter().map(String::as_str).collect(); + let named = |mount: String, volume: &str, used: usize| (mount, volume.to_string(), used); + let (mount, volume, used) = if sp.prefix.starts_with("//") { + // `//server/share`: a server alone (no share) is no place to keep photos + if sp.prefix.ends_with('/') { + return None; + } + named(sp.prefix.clone(), &sp.prefix, 0) + } else if !sp.prefix.is_empty() { + named(sp.prefix.clone(), &sp.prefix, 0) + } else if sp.absolute { + match names.as_slice() { + ["Volumes", n, ..] => named(format!("/Volumes/{n}"), n, 2), + ["run", "media", u, n, ..] => named(format!("/run/media/{u}/{n}"), n, 4), + ["media", u, n, ..] => named(format!("/media/{u}/{n}"), n, 3), + ["mnt", n, ..] => named(format!("/mnt/{n}"), n, 2), + // Linux desktops mount network places per user, naming server and share + ["run", "user", uid, "gvfs", n, ..] => named(format!("/run/user/{uid}/gvfs/{n}"), &mount_name(n), 5), + // NFS automounts: Linux `/net/`, macOS `/Network/Servers/` + ["net", host, ..] => named(format!("/net/{host}"), host, 2), + ["Network", "Servers", host, ..] => named(format!("/Network/Servers/{host}"), host, 3), + _ => named("/".to_string(), "/", 0), + } + } else { + return None; + }; + Some(Placed { mount, volume, names: names.iter().skip(used).take(MAX_DEPTH).map(|n| n.to_string()).collect() }) +} + +/// The disk or share (`/Volumes/nas`, `D:`, `/` for the startup disk) a file-backed photo is on, +/// from its stored path alone. +pub fn volume_of(p: &crate::Photo) -> Option { + let Source::File { path } = &p.source else { return None }; + let dir = path.rfind(['/', '\\']).and_then(|i| path.get(..i.max(1)))?; + place(dir).map(|pl| folder_key(&pl.mount)) +} + +/// Whether `path` is a whole disk or share (`/`, `C:\`, `\\srv\share`, `/Volumes/nas`), not a +/// folder in one. +pub fn is_disk_root(path: &str) -> bool { + place(path).is_some_and(|p| p.names.is_empty()) +} + +/// Whether `path` is the startup disk's root (`/`), whose path also covers every other disk. +pub fn is_startup_disk(path: &str) -> bool { + place(path).is_some_and(|p| p.names.is_empty() && p.mount == "/") +} + +/// A folder named by its last two names (`photos/travel`), so two folders called `travel` are +/// told apart; the path itself when it has no names (`/`). +pub fn folder_label(path: &str) -> String { + let names: Vec<&str> = path.split(['/', '\\']).filter(|n| !n.is_empty()).collect(); + if names.is_empty() { path.to_string() } else { names.iter().skip(names.len().saturating_sub(2)).copied().collect::>().join("/") } +} + +impl Catalog { + /// The library's volumes with their folders and photo counts: volumes by name + /// (case-insensitive), each folder with its subfolders the same way. + pub fn folder_tree(&self) -> Vec { + // photos per containing folder, as spelled (sorted, so the spelling that names a folder + // never depends on hash order) + let mut dirs: BTreeMap<&str, usize> = BTreeMap::new(); + for p in self.photos().filter(|p| p.in_library()) { + if let Source::File { path } = &p.source + && let Some(i) = path.rfind(['/', '\\']) + // a photo straight in the root has an empty folder part: the root itself + && let Some(dir) = path.get(..i.max(1)) + { + *dirs.entry(dir).or_default() += 1; + } + } + let mut levels: HashMap = HashMap::new(); + let mut roots: Vec = Vec::new(); + for (dir, n) in dirs { + let Some(placed) = place(dir) else { continue }; + let vkey = folder_key(&placed.mount); + if !levels.contains_key(&vkey) { + levels.insert( + vkey.clone(), + Level { name: placed.volume.clone(), path: placed.mount.clone(), own: 0, volume: true, children: Vec::new() }, + ); + roots.push(vkey.clone()); + } + let mut prefix = if placed.mount == "/" { String::new() } else { placed.mount.clone() }; + let mut parent = vkey; + for name in &placed.names { + prefix.push('/'); + prefix.push_str(name); + let key = folder_key(&prefix); + if !levels.contains_key(&key) { + levels.insert(key.clone(), Level { name: name.clone(), path: prefix.clone(), own: 0, volume: false, children: Vec::new() }); + if let Some(l) = levels.get_mut(&parent) { + l.children.push(key.clone()); + } + } + parent = key; + } + if let Some(l) = levels.get_mut(&parent) { + l.own += n; + } + } + let mut tree: Vec = roots.iter().filter_map(|k| build(k, &levels)).collect(); + sort(&mut tree); + // a folder that holds another disk's mount covers that disk's photos too + let mounts: Vec = tree.iter().filter(|v| v.path != "/").map(|v| v.path.clone()).collect(); + mark_selectable(&mut tree, &mounts); + tree + } +} + +/// The node for `key` with everything below it (at most [`MAX_DEPTH`] deep). +fn build(key: &str, levels: &HashMap) -> Option { + let l = levels.get(key)?; + let mut children: Vec = l.children.iter().filter_map(|k| build(k, levels)).collect(); + sort(&mut children); + let count = l.own + children.iter().map(|c| c.count).sum::(); + Some(FolderNode { name: l.name.clone(), path: l.path.clone(), count, own: l.own, volume: l.volume, selectable: true, children }) +} + +fn mark_selectable(nodes: &mut [FolderNode], mounts: &[String]) { + for n in nodes { + n.selectable = !(n.volume && n.path == "/") && (n.volume || !mounts.iter().any(|m| folder_within(m, &n.path))); + mark_selectable(&mut n.children, mounts); + } +} + +fn sort(v: &mut [FolderNode]) { + v.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()).then_with(|| a.name.cmp(&b.name))); +} diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index 208a86d09..2934c7a60 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -14,6 +14,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod dates; +pub mod folders; pub mod journal; pub mod keywords; pub mod local; @@ -29,6 +30,7 @@ use std::collections::BTreeMap; use std::sync::Arc; pub use dates::{DateRun, GroupBy}; +pub use folders::FolderNode; pub use journal::{Journal, LoadReport, PersistStats, SnapshotPolicy, SnapshotTiming}; pub use keywords::KeywordNode; use lightcraft_develop::DevelopSettings; @@ -612,6 +614,22 @@ impl Catalog { Op::Batch { ops } } + /// [`Self::delete_permanently_ops`] for several photos at once. One op list built against the + /// catalog as it is now: albums lose all of them in one write each, and stacks are shortened + /// (or dissolved) once, however many of their photos go. + pub fn delete_photos_permanently_ops(&self, ids: &[PhotoId]) -> Op { + let gone: std::collections::HashSet = ids.iter().copied().collect(); + let mut ops: Vec = self + .albums + .values() + .filter(|a| a.photos.iter().any(|p| gone.contains(p))) + .map(|a| Op::SetAlbumPhotos { id: a.id, photos: a.photos.iter().copied().filter(|p| !gone.contains(p)).collect() }) + .collect(); + ops.extend(self.remove_from_stacks_ops(ids)); + ops.extend(gone.iter().map(|id| Op::RemovePhoto { id: *id })); + Op::Batch { ops } + } + // ---- persistence /// Full snapshot as JSON. @@ -655,6 +673,8 @@ mod tests; #[cfg(test)] mod tests_background; #[cfg(test)] +mod tests_folders; +#[cfg(test)] mod tests_format_version; #[cfg(test)] mod tests_journal; diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index fe8d1b059..ae53b486a 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -2,7 +2,7 @@ use std::sync::Arc; -use lightcraft_develop::DevelopSettings; +use lightcraft_develop::{DevelopSettings, WbMode}; use serde::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] @@ -336,12 +336,13 @@ impl Photo { pub fn develops_raw(&self) -> bool { self.kind == MediaKind::Raw && self.preview_only.is_none() } - /// The current ARW, NEF and RW2 readers have vendor WB multipliers but no measured camera + /// The current ARW, NEF, RW2, RAF, CR3, CR2 and PEF readers have vendor WB multipliers but no measured camera /// illuminant. Use adjustments relative to the camera's as-shot look, as for rendered /// photographs (the engine's `camera_preview::file_local_look` covers the same formats; RWL and /// RAW are Leica's and the oldest Panasonic bodies' names for RW2 files). pub fn relative_wb(&self) -> bool { - self.develops_raw() && ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW"].iter().any(|f| self.format.eq_ignore_ascii_case(f)) + self.develops_raw() + && ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF"].iter().any(|f| self.format.eq_ignore_ascii_case(f)) } /// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's /// default preset applied on top of them ([`Photo::import_look`]). @@ -370,7 +371,12 @@ impl Photo { } /// Edited by the user: settings differ from what import gave the photo. pub fn is_edited(&self) -> bool { - *self.develop != self.import_defaults() && !self.develop.is_unedited() + let mut defaults = self.import_defaults(); + // As Shot reads the source's current WB; stored numbers can predate a decoder update. + if self.develop.wb.mode == WbMode::AsShot && defaults.wb.mode == WbMode::AsShot { + defaults.wb = self.develop.wb; + } + *self.develop != defaults && !self.develop.is_unedited() } /// Capture time if known, else import time (sort key). pub fn date(&self) -> &str { @@ -430,7 +436,7 @@ mod edited_tests { } #[test] - fn sony_and_nikon_raws_use_relative_white_balance() { + fn supported_raws_use_relative_white_balance() { for (name, format, relative) in [ ("a.arw", "ARW", true), ("a.nef", "NEF", true), @@ -438,6 +444,8 @@ mod edited_tests { ("a.rw2", "RW2", true), ("a.rwl", "RWL", true), ("a.raw", "RAW", true), + ("a.raf", "rAf", true), + ("a.cr3", "cr3", 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"); @@ -446,6 +454,12 @@ mod edited_tests { assert_eq!(p.relative_wb(), relative, "{format}"); let wb = p.camera_defaults().wb; assert_eq!((wb.temp, wb.tint), if relative { (6500.0, 0.0) } else { (5200.0, 4.0) }, "{format}"); + p.develop = Arc::new(DevelopSettings::for_raw(5200.0, 4.0)); + assert!(!p.is_edited(), "{format}: old As Shot values are not an edit"); + let mut custom = (*p.develop).clone(); + custom.wb.mode = WbMode::Custom; + p.develop = Arc::new(custom); + assert!(p.is_edited(), "{format}: Custom WB is still an edit"); // shown from the embedded preview: a rendered image, not a relative-WB raw p.preview_only = Some("unsupported".into()); assert!(!p.relative_wb()); diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index d06e13700..47223d5d8 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -37,6 +37,10 @@ pub struct Filter { pub deleted: bool, /// Capture date prefix (`2026`, `2026-04`, `2026-04-12`). pub date: Option, + /// A folder the library's photos were imported from: those photos and the ones imported from + /// the folders inside it (see [`crate::folders`]). Photos only browsed in Local are never part + /// of it, unlike [`Filter::folder`]. + pub library_folder: Option, /// A keyword; hierarchical keywords match their children too (`travel` finds `travel|italy`). pub keyword: Option, /// A person: photos with a named face region of this name (case-insensitive), as read from XMP. @@ -186,6 +190,7 @@ impl Filter { ("camera", &self.camera), ("lens", &self.lens), ("date", &self.date), + ("folder", &self.library_folder), ("imported", &self.imported), ] { if let Some(x) = val { @@ -213,6 +218,19 @@ impl Filter { } pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool { + self.matches_in(p, cat, self.library_root().as_deref()) + } + + /// [`Filter::library_folder`] as a folder identity ([`folder_key`]); `None` without a choice. + fn library_root(&self) -> Option { + // as given: a folder may be named with spaces at its edge, and trimming would make it + // another folder + 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 { if p.deleted != self.deleted { return false; } @@ -285,6 +303,11 @@ impl Filter { { return false; } + if let Some(root) = root + && !photo_in_root(p, root) + { + return false; + } if let Some(d) = &self.imported && !p.imported.starts_with(d.as_str()) { @@ -317,7 +340,8 @@ impl Filter { impl Catalog { /// Photos matching `filter`, in `sort` order (ties broken by id for stability). pub fn query(&self, filter: &Filter, sort: &Sort) -> Vec { - let mut v: Vec<&Photo> = self.photos().map(|p| p.as_ref()).filter(|p| filter.matches(p, self)).collect(); + 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.date().cmp(b.date()), @@ -384,7 +408,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(); - for p in self.photos().filter(|p| filter.matches(p, self)) { + let root = filter.library_root(); + for p in self.photos().filter(|p| filter.matches_in(p, self, root.as_deref())) { 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 }; @@ -438,12 +463,18 @@ pub fn in_folder(path: &str, dir: &str, deep: bool) -> bool { !rest.is_empty() && (deep || !rest.contains(['/', '\\'])) } -/// A folder path's identity, for telling whether two spellings name the same folder: `/` and -/// `\\` are both separators, repeated separators, `.` and a trailing separator are dropped, -/// `..` is resolved lexically, a Windows verbatim prefix (`\\?\`) is removed and the drive -/// letter lower-cased; on Windows (case-insensitive file names) the whole path is lower-cased. -/// No file-system access: the paths compared should already be absolute. -pub fn folder_key(path: &str) -> String { +/// A path read the way [`folder_key`] reads it: the part `..` can't climb above (`//server/share`, +/// `c:`; empty for `/` and relative paths), the names below it, and whether it is absolute. +pub(crate) struct SplitPath { + pub prefix: String, + pub parts: Vec, + pub absolute: bool, +} + +/// Both separators, repeated separators (also between a server and its share), `.` and a +/// trailing separator dropped, `..` resolved lexically, a Windows verbatim prefix removed; +/// `lower_drive` lower-cases a drive letter (the identity does, a display name wants it as is). +pub(crate) fn split_path(path: &str, lower_drive: bool) -> SplitPath { let mut s = path.replace('\\', "/"); if let Some(rest) = s.strip_prefix("//?/").or_else(|| s.strip_prefix("//./")) { s = match rest.strip_prefix("UNC/") { @@ -451,28 +482,39 @@ pub fn folder_key(path: &str) -> String { None => rest.to_string(), }; } - // the part `..` can't climb above: `//server/share`, `c:` or `/` let (prefix, rest) = if let Some(unc) = s.strip_prefix("//").filter(|u| !u.is_empty() && !u.starts_with('/')) { - let mut it = unc.splitn(3, '/'); - let (server, share, rest) = (it.next().unwrap_or(""), it.next().unwrap_or(""), it.next().unwrap_or("")); - (format!("//{server}/{share}"), rest.to_string()) - } else if s.len() >= 2 && s.as_bytes()[1] == b':' && s.as_bytes()[0].is_ascii_alphabetic() { - (s[..2].to_ascii_lowercase(), s[2..].to_string()) + let mut segs = unc.split('/').filter(|x| !x.is_empty()); + let (server, share) = (segs.next().unwrap_or(""), segs.next().unwrap_or("")); + (format!("//{server}/{share}"), segs.collect::>().join("/")) + } else if let (true, Some(drive), Some(rest)) = (matches!(s.as_bytes(), [a, b':', ..] if a.is_ascii_alphabetic()), s.get(..2), s.get(2..)) { + (if lower_drive { drive.to_ascii_lowercase() } else { drive.to_string() }, rest.to_string()) } else { (String::new(), s.clone()) }; let absolute = rest.starts_with('/') || !prefix.is_empty(); - let mut parts: Vec<&str> = Vec::new(); + let mut parts: Vec = Vec::new(); for c in rest.split('/') { match c { "" | "." => {} - ".." if parts.last().is_some_and(|p| *p != "..") => { + ".." if parts.last().is_some_and(|p| p != "..") => { parts.pop(); } ".." if absolute => {} - c => parts.push(c), + c => parts.push(c.to_string()), } } + SplitPath { prefix, parts, absolute } +} + +/// A folder path's identity, for telling whether two spellings name the same folder: `/` and +/// `\\` are both separators, repeated separators, `.` and a trailing separator are dropped, +/// `..` is resolved lexically, a Windows verbatim prefix (`\\?\`) is removed and the drive +/// letter lower-cased; on Windows (case-insensitive file names) the whole path is lower-cased. +/// File names are otherwise compared as written, so on a case-insensitive disk elsewhere +/// (macOS, a Samba share) two spellings that differ in case are two folders here. +/// No file-system access: the paths compared should already be absolute. +pub fn folder_key(path: &str) -> String { + let SplitPath { prefix, parts, absolute } = split_path(path, true); let key = if absolute { format!("{prefix}/{}", parts.join("/")) } else { parts.join("/") }; if cfg!(windows) { key.to_lowercase() } else { key } } @@ -480,8 +522,51 @@ pub fn folder_key(path: &str) -> String { /// Whether `path` is folder `root` or lies somewhere inside it, however either is spelled /// (see [`folder_key`]). pub fn folder_within(path: &str, root: &str) -> bool { - let (p, r) = (folder_key(path), folder_key(root)); - p == r || (p.starts_with(&r) && (r.ends_with('/') || p[r.len()..].starts_with('/'))) + key_within(&folder_key(path), &folder_key(root)) +} + +/// The names below folder `root` that lead to `path`, as the caller spelled them (`None` when +/// `path` is not at or inside `root`; empty when it is `root`). Compared by [`folder_key`], but +/// the names keep their case, which the key lowers on Windows. +pub fn folder_rest(path: &str, root: &str) -> Option> { + if !key_within(&folder_key(path), &folder_key(root)) { + return None; + } + let depth = split_path(root, false).parts.len(); + Some(split_path(path, false).parts.into_iter().skip(depth).collect()) +} + +/// Whether a library photo (not one only browsed in Local) lies in the folder whose +/// [`folder_key`] is `root`. A plain POSIX path (no `\`, `.`, `..`, repeated or trailing +/// separators) is its own identity, so it is read without building a key for it: the check runs +/// for every photo of the library on each refresh, and this keeps it cheap. +pub(crate) fn photo_in_root(p: &Photo, root: &str) -> bool { + if p.local { + return false; + } + let crate::Source::File { path } = &p.source else { return false }; + let plain = path.starts_with('/') + && !path.starts_with("//") + && !path.ends_with('/') + && !path.contains(['\\']) + && !path.contains("//") + && !path.contains("/."); + if !cfg!(windows) && plain && root.starts_with('/') && !root.starts_with("//") { + let r = root.trim_end_matches('/'); + return path.strip_prefix(r).is_some_and(|rest| rest.starts_with('/')); + } + crate::local::folder_of(p).is_some_and(|f| key_within(&f, root)) +} + +/// [`folder_within`] for two paths already turned into their [`folder_key`]. +pub(crate) fn key_within(p: &str, r: &str) -> bool { + // `.` or `a/..` name no folder: not "everything" + if r.is_empty() { + return false; + } + // a disk's root key ends in `/` (`c:/`, `//srv/share/`), a folder's does not: same folder + let (p, r) = (p.trim_end_matches('/'), r.trim_end_matches('/')); + p == r || p.strip_prefix(r).is_some_and(|rest| rest.starts_with('/')) } /// The kind of Photo Merge result a file is, from the name merges give it (`IMG_1-HDR.dng`, diff --git a/crates/catalog/src/tests_folders.rs b/crates/catalog/src/tests_folders.rs new file mode 100644 index 000000000..556f77def --- /dev/null +++ b/crates/catalog/src/tests_folders.rs @@ -0,0 +1,404 @@ +//! The library's folder tree: where the imported photos live on disk (see [`crate::folders`]). +//! +//! Scenarios, in the words of someone browsing their library: +//! +//! * Given photos imported from several folders, the tree lists those folders and no others, +//! each with how many photos it holds, under the disk (volume) they are on. +//! * A folder's count includes the folders inside it. +//! * Every folder is a row of its own, the ones above the imported photos included. +//! * Photos only browsed in Local, deleted photos and demo scenes are not "imported from" anywhere. +//! * However a folder is spelled, it is one folder. +//! * Choosing a folder shows what was imported from it and from the folders inside it — exactly +//! as many photos as the row says. + +use crate::*; + +fn add(c: &mut Catalog, path: &str) -> PhotoId { + add_with(c, path, |_| {}) +} + +fn add_with(c: &mut Catalog, path: &str, tweak: impl FnOnce(&mut Photo)) -> PhotoId { + let id = c.alloc_photo_id(); + let name = path.rsplit(['/', '\\']).next().unwrap_or(path); + let mut p = Photo::new(id, Source::File { path: path.into() }, name, "JPEG", 60, 40, "2026-01-01T10:00:00"); + tweak(&mut p); + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + id +} + +fn library(paths: &[&str]) -> Catalog { + let mut c = Catalog::new(); + paths.iter().for_each(|p| { + add(&mut c, p); + }); + c +} + +/// `(name, count, own)` of each node, depth first. +fn outline(nodes: &[FolderNode]) -> Vec<(String, usize, usize)> { + fn walk(n: &FolderNode, out: &mut Vec<(String, usize, usize)>) { + out.push((n.name.clone(), n.count, n.own)); + n.children.iter().for_each(|c| walk(c, out)); + } + let mut out = Vec::new(); + nodes.iter().for_each(|n| walk(n, &mut out)); + out +} + +fn row(name: &str, count: usize, own: usize) -> (String, usize, usize) { + (name.to_string(), count, own) +} + +fn rows(paths: &[&str]) -> Vec<(String, usize, usize)> { + outline(&library(paths).folder_tree()) +} + +#[test] +fn an_empty_library_has_no_folders() { + assert!(Catalog::new().folder_tree().is_empty()); +} + +#[test] +fn only_folders_with_imported_photos_are_listed_with_their_counts() { + let c = library(&["/pics/trip/a.jpg", "/pics/trip/b.jpg", "/pics/home/c.jpg"]); + let tree = c.folder_tree(); + assert_eq!(outline(&tree), vec![row("/", 3, 0), row("pics", 3, 0), row("home", 1, 1), row("trip", 2, 2)]); + assert!(tree[0].volume && !tree[0].children[0].volume); + assert_eq!(tree[0].children[0].children[1].path, "/pics/trip", "a row knows its folder, for choosing it"); +} + +#[test] +fn a_folders_count_includes_the_folders_inside_it() { + let r = rows(&["/pics/trip/a.jpg", "/pics/trip/day1/b.jpg", "/pics/trip/day1/c.jpg", "/pics/trip/day2/d.jpg"]); + assert_eq!(r, vec![row("/", 4, 0), row("pics", 4, 0), row("trip", 4, 1), row("day1", 2, 2), row("day2", 1, 1)]); +} + +#[test] +fn every_folder_is_a_row_of_its_own_even_when_it_holds_nothing_itself() { + let c = library(&["/Users/me/Pictures/2024/a.jpg", "/Users/me/Pictures/2025/b.jpg", "/Volumes/nas/photos/c.jpg"]); + let tree = c.folder_tree(); + assert_eq!( + outline(&tree), + vec![ + row("/", 2, 0), + row("Users", 2, 0), + row("me", 2, 0), + row("Pictures", 2, 0), + row("2024", 1, 1), + row("2025", 1, 1), + row("nas", 1, 0), + row("photos", 1, 1) + ] + ); + assert_eq!(tree[0].children[0].children[0].children[0].path, "/Users/me/Pictures", "a row is exactly the folder it names"); +} + +#[test] +fn a_folder_with_photos_of_its_own_is_not_folded_into_its_child() { + assert_eq!(rows(&["/pics/a.jpg", "/pics/trip/b.jpg"]), vec![row("/", 2, 0), row("pics", 2, 1), row("trip", 1, 1)]); +} + +#[test] +fn browsed_deleted_and_demo_photos_are_not_imported_from_anywhere() { + let mut c = Catalog::new(); + add(&mut c, "/pics/kept/a.jpg"); + add_with(&mut c, "/pics/browsed/b.jpg", |p| p.local = true); + add_with(&mut c, "/pics/gone/c.jpg", |p| p.deleted = true); + let id = c.alloc_photo_id(); + let demo = Photo::new(id, Source::Demo { scene: 1 }, "demo", "JPEG", 60, 40, "2026-01-01T10:00:00"); + c.apply(Op::AddPhoto { photo: Box::new(demo) }).unwrap(); + assert_eq!(outline(&c.folder_tree()), vec![row("/", 1, 0), row("pics", 1, 0), row("kept", 1, 1)]); +} + +#[test] +fn however_a_folder_is_spelled_it_is_one_folder() { + let r = rows(&["/pics/trip/a.jpg", "/pics//trip/./b.jpg", "/pics\\trip\\c.jpg", "/pics/x/../trip/d.jpg"]); + assert_eq!(r, vec![row("/", 4, 0), row("pics", 4, 0), row("trip", 4, 4)]); +} + +#[test] +fn each_disk_is_a_volume_of_its_own() { + // macOS, Linux and Windows habits for where disks appear + let r = rows(&["/Volumes/nas/p/1.jpg", "/media/me/usb/2.jpg", "/run/media/me/card/3.jpg", "/mnt/disk/4.jpg"]); + assert_eq!(r, vec![row("card", 1, 1), row("disk", 1, 1), row("nas", 1, 0), row("p", 1, 1), row("usb", 1, 1)]); + let r = rows(&["D:\\Photos\\2024\\a.jpg", "D:\\Photos\\2025\\b.jpg", "C:\\x\\c.jpg"]); + assert_eq!(r, vec![row("C:", 1, 0), row("x", 1, 1), row("D:", 2, 0), row("Photos", 2, 0), row("2024", 1, 1), row("2025", 1, 1)]); +} + +#[test] +fn a_network_share_is_a_volume_and_two_spellings_are_one() { + let r = rows(&["\\\\srv\\s1\\a\\1.jpg", "\\\\srv\\s2\\b\\2.jpg"]); + assert_eq!(r, vec![row("//srv/s1", 1, 0), row("a", 1, 1), row("//srv/s2", 1, 0), row("b", 1, 1)]); + // the verbatim form Windows APIs give is the same share + let r = rows(&["\\\\?\\UNC\\srv\\s1\\a\\1.jpg", "\\\\srv\\s1\\a\\2.jpg"]); + assert_eq!(r, vec![row("//srv/s1", 2, 0), row("a", 2, 2)]); +} + +#[test] +fn a_samba_share_mounted_on_a_mac_is_a_volume_named_after_the_share() { + // Finder mounts every share under /Volumes, and a second mount of the same share gets a suffix + let r = rows(&["/Volumes/tokyo/Photos/2024/a.jpg", "/Volumes/tokyo/Photos/2025/b.jpg", "/Volumes/tokyo-1/old/c.jpg", "/Volumes/東京 NAS/d.jpg"]); + assert_eq!( + r, + vec![ + row("tokyo", 2, 0), + row("Photos", 2, 0), + row("2024", 1, 1), + row("2025", 1, 1), + row("tokyo-1", 1, 0), + row("old", 1, 1), + row("東京 NAS", 1, 1) + ] + ); + let tree = library(&["/Volumes/tokyo/Photos/a.jpg"]).folder_tree(); + assert_eq!((tree[0].path.as_str(), tree[0].volume), ("/Volumes/tokyo", true), "choosing the share shows everything on it"); +} + +#[test] +fn shares_the_other_systems_mount_are_volumes_too() { + // Linux desktops: gvfs mounts per user, with the server and share in the mount's name + let gvfs = "/run/user/1000/gvfs/smb-share:server=tokyo,share=photos"; + let r = rows(&[&format!("{gvfs}/2024/a.jpg"), "/run/user/1000/gvfs/sftp:host=nas/b.jpg"]); + assert_eq!(r, vec![row("photos on tokyo", 1, 0), row("2024", 1, 1), row("sftp:host=nas", 1, 1)]); + // NFS automounts: Linux /net/, macOS /Network/Servers/ + let r = rows(&["/net/tokyo/export/a.jpg", "/Network/Servers/osaka/vol/b.jpg"]); + assert_eq!(r, vec![row("osaka", 1, 0), row("vol", 1, 1), row("tokyo", 1, 0), row("export", 1, 1)]); + // Windows: a share by name or by address, the WSL file system, a mapped drive + let r = rows(&[r"\\tokyo.local\photos\a.jpg", r"\\192.168.1.5\scans\b.jpg", r"\\wsl$\Ubuntu\home\me\c.jpg", r"Z:\d.jpg"]); + assert_eq!( + r, + vec![ + row("//192.168.1.5/scans", 1, 1), + row("//tokyo.local/photos", 1, 1), + row("//wsl$/Ubuntu", 1, 0), + row("home", 1, 0), + row("me", 1, 1), + row("Z:", 1, 1) + ] + ); +} + +#[test] +fn a_share_that_is_not_mounted_costs_nothing() { + // the tree is made from the catalog's own records: it never looks at a disk, so a share that + // is asleep or gone lists (and counts) exactly like one that is there + let c = library(&["/Volumes/definitely-not-mounted/a/b.jpg", r"\\unreachable.invalid\share\c.jpg"]); + let tree = c.folder_tree(); + assert_eq!(tree.iter().map(|n| n.count).sum::(), 2); +} + +#[test] +fn a_photo_in_the_root_of_a_disk_counts_for_the_disk() { + assert_eq!(rows(&["/x.jpg", "/d/y.jpg"]), vec![row("/", 2, 1), row("d", 1, 1)]); + assert_eq!(rows(&["C:\\x.jpg"]), vec![row("C:", 1, 1)]); +} + +#[test] +fn photos_with_no_folder_are_left_out() { + assert!(rows(&["x.jpg", ""]).is_empty()); +} + +#[test] +fn folders_are_listed_by_name_ignoring_case() { + let names: Vec = rows(&["/p/b/1.jpg", "/p/A/2.jpg", "/p/c/3.jpg"]).into_iter().skip(2).map(|r| r.0).collect(); + assert_eq!(names, ["A", "b", "c"]); +} + +#[test] +fn absurdly_deep_paths_neither_crash_nor_lose_photos() { + let deep = format!("/{}/a.jpg", vec!["d"; 5000].join("/")); + let tree = library(&[&deep]).folder_tree(); + assert_eq!(tree.iter().map(|n| n.count).sum::(), 1); + assert!(outline(&tree).len() <= crate::folders::MAX_DEPTH + 2); +} + +#[test] +fn choosing_a_folder_shows_what_was_imported_from_it_and_below() { + let mut c = Catalog::new(); + let trip = add(&mut c, "/pics/trip/a.jpg"); + let day = add(&mut c, "/pics/trip/day1/b.jpg"); + let sibling = add(&mut c, "/pics/trip2/c.jpg"); // shares the name's start, not the folder + let browsed = add_with(&mut c, "/pics/trip/d.jpg", |p| p.local = true); + let f = Filter { library_folder: Some("/pics//trip/".into()), ..Default::default() }; + let shown = |id: PhotoId| f.matches(c.photo(id).unwrap(), &c); + assert!(shown(trip) && shown(day)); + assert!(!shown(sibling), "/pics/trip2 is another folder"); + assert!(!shown(browsed), "browsed photos are not part of the library's folders"); + assert_eq!(f.describe(), "folder /pics//trip/"); +} + +#[test] +fn recently_deleted_shows_the_deleted_photos_of_the_chosen_folder() { + let mut c = Catalog::new(); + let gone = add_with(&mut c, "/pics/trip/a.jpg", |p| p.deleted = true); + let kept = add(&mut c, "/pics/trip/b.jpg"); + let f = Filter { library_folder: Some("/pics/trip".into()), deleted: true, ..Default::default() }; + assert!(f.matches(c.photo(gone).unwrap(), &c)); + assert!(!f.matches(c.photo(kept).unwrap(), &c)); +} + +#[test] +fn an_empty_folder_choice_is_no_choice() { + let c = library(&["/pics/a.jpg"]); + let f = Filter { library_folder: Some(" ".into()), ..Default::default() }; + assert_eq!(c.query(&f, &Sort::default()).len(), 1); +} + +#[test] +fn every_row_shows_exactly_as_many_photos_as_it_says() { + let c = library(&[ + "/Users/me/Pictures/2024/a.jpg", + "/Users/me/Pictures/2024/b.jpg", + "/Users/me/Pictures/2025/c.jpg", + "/Users/me/Pictures/d.jpg", + "/Volumes/nas/photos/e.jpg", + "/Volumes/nas/f.jpg", + "/x.jpg", + "/a/../b/g.jpg", + "D:\\Photos\\2024\\h.jpg", + "d:/Photos/i.jpg", + "\\\\srv\\s1\\a\\j.jpg", + "\\\\?\\UNC\\srv\\s2\\k.jpg", + "/pics/trip/l.jpg", + "/pics/trip2/m.jpg", + "/Volumes/tokyo/Photos/2024/n.jpg", + "/Volumes/tokyo/o.jpg", + "/run/user/1000/gvfs/smb-share:server=tokyo,share=photos/p.jpg", + "/net/osaka/export/q.jpg", + r"\\wsl$\Ubuntu\home\me\r.jpg", + // odd spellings the tree and the filter must read the same way + "//server/share/../other/1.jpg", + "//server/share/ok/2.jpg", + "//a b//A/f.jpg", + r"\\wsl$\Ubuntu\..\a\1.jpg", + // a folder that holds other disks as well as photos of its own + "/Volumes/1.jpg", + "/mnt/2.jpg", + "/mnt/disk/3.jpg", + ]); + fn check(c: &Catalog, nodes: &[FolderNode], seen: &mut usize) { + for n in nodes { + // the startup volume's row only opens: its path "/" would cover every other disk + if n.selectable { + let f = Filter { library_folder: Some(n.path.clone()), ..Default::default() }; + assert_eq!(c.query(&f, &Sort::default()).len(), n.count, "{} ({})", n.name, n.path); + *seen += 1; + } + check(c, &n.children, seen); + } + } + let mut seen = 0; + check(&c, &c.folder_tree(), &mut seen); + assert!(seen > 10, "{seen} rows checked"); +} + +fn find<'a>(nodes: &'a [FolderNode], path: &str) -> Option<&'a FolderNode> { + nodes.iter().find_map(|n| if n.path == path { Some(n) } else { find(&n.children, path) }) +} + +#[test] +fn a_row_that_would_cover_other_disks_is_not_offered_for_choosing() { + let tree = library(&["/Users/me/a.jpg", "/Volumes/1.jpg", "/Volumes/tokyo/x.jpg", "/mnt/2.jpg"]).folder_tree(); + let sel = |p: &str| find(&tree, p).map(|n| n.selectable); + assert_eq!(sel("/"), Some(false), "the startup disk's path covers every disk"); + assert_eq!(sel("/Volumes"), Some(false), "it holds the tokyo disk"); + assert_eq!(sel("/Volumes/tokyo"), Some(true)); + assert_eq!(sel("/Users/me"), Some(true)); + assert_eq!(sel("/mnt"), Some(true), "no disk is mounted below it in this library"); +} + +#[test] +fn the_tree_and_the_filter_read_odd_spellings_the_same_way() { + // `..` cannot climb above a share, and repeated separators between server and share are one + let tree = library(&["//server/share/../other/1.jpg", "//a b//A/f.jpg"]).folder_tree(); + assert_eq!(outline(&tree), vec![row("//a b/A", 1, 1), row("//server/share", 1, 0), row("other", 1, 1)]); +} + +#[test] +fn a_path_that_names_no_folder_chooses_nothing() { + let c = library(&["/pics/a.jpg", "C:\\b\\c.jpg"]); + for dot in [".", "./", "a/..", "../.."] { + let f = Filter { library_folder: Some(dot.into()), ..Default::default() }; + assert_eq!(c.query(&f, &Sort::default()).len(), 0, "{dot:?} is no folder, not everything"); + } +} + +#[test] +fn a_folder_is_labelled_by_its_last_two_names() { + use crate::folders::folder_label; + assert_eq!(folder_label("/Volumes/tokyo/photos/travel"), "photos/travel"); + assert_eq!(folder_label("/Volumes/tokyo/"), "Volumes/tokyo"); + assert_eq!(folder_label(r"C:\Photos\2024"), "Photos/2024"); + assert_eq!(folder_label("/"), "/"); + assert_eq!(folder_label(""), ""); +} + +#[test] +fn disk_roots_are_told_from_folders() { + use crate::folders::{is_disk_root, is_startup_disk}; + for p in ["/", "C:", "C:\\", r"\\?\C:\", r"\\srv\share", "/Volumes/nas", "/mnt/disk", "/net/host"] { + assert!(is_disk_root(p), "{p}"); + } + for p in ["/Volumes", "/Users", "C:\\Photos", r"\\srv\share\x", "/Volumes/nas/p", "."] { + assert!(!is_disk_root(p), "{p}"); + } + assert!(is_startup_disk("/") && is_startup_disk("//") && !is_startup_disk("/Volumes/nas") && !is_startup_disk("C:")); +} + +#[test] +fn a_folder_name_with_spaces_at_its_edge_is_its_own_folder() { + // legal on macOS and Linux: `/p/shoot ` is not `/p/shoot` + let c = library(&["/p/shoot /a.jpg", "/p/shoot/b.jpg", "/p/other/c.jpg"]); + let tree = c.folder_tree(); + let shoot_space = find(&tree, "/p/shoot ").expect("its own row"); + let shoot = find(&tree, "/p/shoot").expect("and the other"); + for (node, want) in [(shoot_space, "a.jpg"), (shoot, "b.jpg")] { + let f = Filter { library_folder: Some(node.path.clone()), ..Default::default() }; + let ids = c.query(&f, &Sort::default()); + let names: Vec<&str> = ids.iter().filter_map(|id| c.photo(*id)).map(|p| p.file_name.as_str()).collect(); + assert_eq!(names, [want], "{:?} shows its own photo", node.path); + } +} + +#[test] +fn a_local_photo_never_counts_as_imported_even_when_a_disk_folder_is_also_shown() { + let mut c = Catalog::new(); + let browsed = add_with(&mut c, "/pics/trip/b.jpg", |p| p.local = true); + let f = Filter { folder: Some("/pics/trip".into()), library_folder: Some("/pics/trip".into()), ..Default::default() }; + assert!(!f.matches(c.photo(browsed).unwrap(), &c)); +} + +#[test] +fn the_quick_check_agrees_with_the_careful_one() { + use crate::query::{folder_key, key_within, photo_in_root}; + let paths = [ + "/a/b/c.jpg", + "/a/b/d/e.jpg", + "/a/bc/x.jpg", + "/a/./b/x.jpg", + "/a/../a/b/x.jpg", + "/a//b/x.jpg", + "/a\\b\\x.jpg", + "/a/b ", + "/a/b /x.jpg", + "/.hidden/x.jpg", + "/a/.b/x.jpg", + "/x.jpg", + "//srv/share/x.jpg", + "C:\\a\\b\\x.jpg", + "relative/a/b/x.jpg", + "/Volumes/tokyo/a/x.jpg", + "/日本/写真/x.jpg", + ]; + let roots = ["/", "/a", "/a/b", "/a/b/", "/a/bc", "/a/b ", "/Volumes/tokyo", "//srv/share", "C:\\a", "/日本", "relative", "/a/../a/b"]; + for p in paths { + let mut c = Catalog::new(); + let id = add(&mut c, p); + let photo = c.photo(id).unwrap(); + for r in roots { + let root = folder_key(r); + let slow = crate::local::folder_of(photo).is_some_and(|f| key_within(&f, &root)); + assert_eq!(photo_in_root(photo, &root), slow, "{p:?} in {r:?}"); + } + } +} diff --git a/crates/codecs/README.md b/crates/codecs/README.md index d826b5ec1..18743ad03 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -14,6 +14,7 @@ let d: Decoded = decode(&bytes, DecodeOptions::default())?; let working: Rgb32f = to_working(&d); // linear Rec.2020 D65 (Bradford) let fast = decode(&bytes, DecodeOptions::fit(2048, 2048))?; // JPEG: DCT-domain 1/2..1/8 scaling let t: Thumbnail = decode_thumbnail(&bytes, 256)?; // embedded EXIF/MPF preview or scaled decode +let h: Header = read_header(&bytes)?; // stored width/height + orientation, no pixels decoded let icc = icc::write_named(NamedSpace::DisplayP3); // or icc::write_matrix_trc(&space, &Trc::…) let meta = EncodeMeta { icc: Some(&icc), exif: Some(&exif), xmp: Some(&xmp) }; @@ -56,6 +57,10 @@ or CMYK profile converted by the CMS straight to linear Rec.2020), `Container` ( EXIF `R03` Adobe RGB hint), `Untagged` (assumed sRGB; untagged float data assumed linear), `IccUnsupported` (profile present but unusable → **sRGB fallback**), `Naive` (CMYK without profile). +`decode_jpeg_with_fallback(bytes, opts, space)` lets an enclosing RAW container supply the colour +space of a JPEG with no ICC or EXIF metadata. It applies the transfer curve before linear-light +resizing; a JPEG carrying its own metadata retains its existing interpretation. + ICC profiles are parsed with `moxcms` (v2/v4, `curv`/`para` TRCs, `chad`); sRGB, Display P3, 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. diff --git a/crates/codecs/src/icc.rs b/crates/codecs/src/icc.rs index 651122592..ec9e8efe3 100644 --- a/crates/codecs/src/icc.rs +++ b/crates/codecs/src/icc.rs @@ -146,7 +146,19 @@ pub fn write_matrix_trc(space: &RgbSpace, trc: &Trc) -> Vec { }; p.description = Some(ProfileText::Localizable(vec![LocalizableString::new("en".into(), "US".into(), desc)])); p.copyright = Some(ProfileText::Localizable(vec![LocalizableString::new("en".into(), "US".into(), "No copyright, use freely".into())])); - align_tags(&p.encode().unwrap_or_default()) + align_tags(&fixed_date(p.encode().unwrap_or_default())) +} + +/// Zero the header's creation date/time (ICC.1 §7.2.8, bytes 24..36) so a profile's bytes depend +/// only on its colour space and curve. `moxcms` stamps the wall clock there on every `encode` +/// (ignoring `ColorProfile::creation_date_time`), which made two exports of one photo differ in +/// the embedded profile and nowhere else, so output hashes could not be compared (#180). Readers +/// do not interpret the field; all zeros is the conventional "unspecified" value. +fn fixed_date(mut b: Vec) -> Vec { + if let Some(date) = b.get_mut(24..36) { + date.fill(0); + } + b } /// Re-lay a profile's tag data on 4-byte boundaries (ICC.1 §7.3.1). The encoder packs tags back to @@ -300,6 +312,23 @@ mod tests { let _ = SRGB; } + /// Two profiles for one space are byte-identical, and the header date is fixed: exports of the + /// same photo with the same settings hash the same (#180). + #[test] + fn written_profiles_are_deterministic() { + for n in NamedSpace::ALL { + let a = write_named(n); + let b = write_named(n); + assert_eq!(a, b, "{n:?}"); + assert_eq!(&a[24..36], &[0u8; 12], "{n:?} header date/time"); + // the profile ID is "not computed", not a hash of a timestamp + assert_eq!(&a[84..100], &[0u8; 16], "{n:?} profile ID"); + } + let lin = write_matrix_trc(&PROPHOTO, &Trc::Linear); + assert_eq!(lin, write_matrix_trc(&PROPHOTO, &Trc::Linear)); + assert_eq!(&lin[24..36], &[0u8; 12]); + } + #[test] fn garbage_is_none() { assert!(parse(&[]).is_none()); diff --git a/crates/codecs/src/jpeg.rs b/crates/codecs/src/jpeg.rs index 0096f9059..92409af35 100644 --- a/crates/codecs/src/jpeg.rs +++ b/crates/codecs/src/jpeg.rs @@ -105,12 +105,67 @@ pub(crate) fn parse_markers(b: &[u8]) -> Option { } pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result { + decode_with_fallback(bytes, opts, None) +} + +/// Stored dimensions and EXIF orientation from the markers, without decoding the scan data. The +/// file is refused, as a decode would refuse it, when it has no frame header, an unsupported frame +/// type or component count, or when its markers don't run to an end-of-image marker after the +/// image data (a truncated file). Damage *inside* the entropy-coded data can't be seen this way. +pub(crate) fn header(bytes: &[u8]) -> Result<(u32, u32, u16)> { + let m = parse_markers(bytes).ok_or_else(|| Error::Malformed(F, "missing SOI".into()))?; + if m.components == 0 { + return Err(Error::Malformed(F, "no frame header".into())); + } + check_size(F, m.width as u64, m.height as u64, &DecodeOptions::default())?; + if !matches!(m.sof, 0xC0..=0xC3) { + return Err(Error::Unsupported(F, "arithmetic-coded or hierarchical JPEG")); + } + if !matches!(m.components, 1 | 3 | 4) { + return Err(Error::Malformed(F, format!("{} colour components", m.components))); + } + if m.first_scan_components == 0 || !reaches_eoi(bytes) { + return Err(Error::Malformed(F, "truncated: the image data doesn't end with an end-of-image marker".into())); + } + let orientation = m.exif.as_deref().map(exif::summarize).unwrap_or_default().orientation.unwrap_or(1); + Ok((m.width, m.height, orientation)) +} + +/// Whether the markers after SOI, followed through every segment and scan, reach an EOI marker +/// (the first one: the primary image's, ahead of any MPF images appended to the file). Segments +/// are skipped by their length; in scan data `FF 00` (stuffing), `FF FF` (fill) and RSTn are not +/// markers that end it. +fn reaches_eoi(b: &[u8]) -> bool { + let mut p = 2usize; + loop { + // entropy-coded data (or garbage between segments, as `parse_markers` tolerates it) + let Some(skip) = b.get(p..).and_then(find_ff) else { return false }; + p = p.saturating_add(skip); + let Some(&marker) = b.get(p.saturating_add(1)) else { return false }; + p = match marker { + 0xD9 => return true, + 0xFF => p.saturating_add(1), + 0x00 | 0x01 | 0xD0..=0xD8 => p.saturating_add(2), + _ => { + let Some(len) = b.get(p.saturating_add(2)..p.saturating_add(4)).map(|l| u16::from_be_bytes([l[0], l[1]]) as usize) else { + return false; + }; + if len < 2 { + return false; + } + p.saturating_add(2).saturating_add(len) + } + }; + } +} + +pub(crate) fn decode_with_fallback(bytes: &[u8], opts: &DecodeOptions, fallback: Option) -> Result { let m = parse_markers(bytes).ok_or_else(|| Error::Malformed(F, "missing SOI".into()))?; if m.components == 0 { return Err(Error::Malformed(F, "no frame header".into())); } check_size(F, m.width as u64, m.height as u64, opts)?; - let scale_to = opts.max_size.filter(|&(mw, mh)| mw > 0 && mh > 0 && (m.width >= 2 * mw || m.height >= 2 * mh)); + 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). let non_interleaved = matches!(m.sof, 0xC0 | 0xC1) && m.first_scan_components < m.components; @@ -119,7 +174,10 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result { } else { decode_zune(bytes, &m)? }; - let meta = Meta { icc: m.icc, exif: m.exif, xmp: m.xmp, ..Default::default() }; + // A raw container may carry the colour declaration of an otherwise untagged preview. + // Apply it before linearization/resizing; existing JPEG metadata always wins. + let hint = fallback.filter(|_| m.icc.is_none() && m.exif.is_none()).map(|space| crate::SourceSpace::named(space, crate::SpaceOrigin::Container)); + let meta = Meta { icc: m.icc, exif: m.exif, xmp: m.xmp, hint, ..Default::default() }; finish(F, raw, meta, (m.width, m.height), opts) } @@ -168,6 +226,21 @@ fn decode_jpeg_decoder(bytes: &[u8], m: &Markers, scale_to: Option<(u32, u32)>) Ok(Raw { width: w, height: h, model, alpha: false, premultiplied: false, buf, bit_depth: depth }) } +/// 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 { + const ONES: u64 = 0x0101_0101_0101_0101; + let (words, tail) = b.as_chunks::<8>(); + for (i, w) in words.iter().enumerate() { + // a zero byte in !w is an 0xFF byte in w + let x = !u64::from_ne_bytes(*w); + if x.wrapping_sub(ONES) & !x & (ONES << 7) != 0 { + return w.iter().position(|&v| v == 0xFF).map(|j| i * 8 + j); + } + } + tail.iter().position(|&v| v == 0xFF).map(|j| words.len() * 8 + j) +} + /// The box to request from `jpeg-decoder::scale` so the decoded image still covers `mw × mh` once /// fitted with the source aspect ratio. fn scaled_request(w: u32, h: u32, mw: u32, mh: u32) -> (u16, u16) { @@ -246,12 +319,75 @@ fn mpf_images(m: &Markers) -> Vec<(usize, usize)> { mod tests { use super::*; + #[test] + fn container_fallback_matches_tagged_jpeg_before_resizing_and_keeps_own_metadata() { + use crate::{ChromaSubsampling, EncodeImage, EncodeMeta, NamedSpace, Samples, SpaceOrigin, encode_jpeg}; + let pixels: Vec = (0..16 * 16).flat_map(|i| if i % 2 == 0 { [120, 180, 90] } else { [30, 100, 200] }).collect(); + let encode = |icc, exif| { + encode_jpeg( + &EncodeImage::new(16, 16, 3, Samples::U8(&pixels)), + 100, + ChromaSubsampling::S444, + &EncodeMeta { icc, exif, ..Default::default() }, + ) + .unwrap() + }; + let untagged = encode(None, None); + let adobe = crate::icc::write_named(NamedSpace::AdobeRgb); + let tagged = encode(Some(&adobe), None); + for opts in [DecodeOptions::default(), DecodeOptions::fit(3, 3)] { + let with = crate::decode_jpeg_with_fallback(&untagged, opts, NamedSpace::AdobeRgb).unwrap(); + let expected = crate::decode(&tagged, opts).unwrap(); + assert_eq!(with.space.origin, SpaceOrigin::Container); + let (a, b) = (with.to_working(), expected.to_working()); + for (p, q) in a.data.iter().zip(&b.data) { + assert!(p.iter().zip(q).all(|(a, b)| (a - b).abs() < 0.0001), "{p:?} != {q:?}"); + } + } + let srgb = crate::icc::write_named(NamedSpace::Srgb); + let exif = crate::exif::minimal_exif(6); + for bytes in [encode(Some(&srgb), None), encode(None, Some(&exif)), encode(Some(b"bad ICC"), None)] { + let opts = DecodeOptions::fit(5, 5); + let with = crate::decode_jpeg_with_fallback(&bytes, opts, NamedSpace::AdobeRgb).unwrap(); + let expected = crate::decode(&bytes, opts).unwrap(); + assert_eq!(with.space, expected.space); + assert_eq!(with.image, expected.image); + assert_eq!(with.orientation, expected.orientation); + } + assert!(crate::decode_jpeg_with_fallback(b"bad JPEG", DecodeOptions::default(), NamedSpace::AdobeRgb).is_err()); + } + #[test] fn scaled_request_covers() { assert_eq!(scaled_request(6000, 4000, 256, 256), (256, 171)); assert_eq!(scaled_request(100, 100, 256, 256), (100, 100)); } + #[test] + fn find_ff_matches_a_plain_search() { + let mut b: Vec = (0..300u32).map(|i| (i * 7 % 255) as u8).collect(); + for at in [None, Some(0), Some(7), Some(8), Some(15), Some(203), Some(296), Some(299)] { + if let Some(i) = at { + b[i] = 0xFF; + } + for start in [0, 1, 5, 9, 290, 299, 300] { + let s = &b[start..]; + assert_eq!(find_ff(s), s.iter().position(|&v| v == 0xFF), "{at:?} from {start}"); + } + } + } + + #[test] + fn unbounded_fit_box_decodes_at_full_size() { + // a full-size load asks for the largest box (`usize::MAX` saturated to `u32::MAX`): no DCT scaling, + // and no overflow working out whether to scale + use crate::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg}; + let pixels = vec![128u8; 32 * 24 * 3]; + let jpeg = encode_jpeg(&EncodeImage::new(32, 24, 3, Samples::U8(&pixels)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap(); + let d = crate::decode(&jpeg, DecodeOptions::fit(u32::MAX, u32::MAX)).unwrap(); + assert_eq!((d.width, d.height), (32, 24)); + } + #[test] fn markers_on_garbage() { assert!(parse_markers(b"").is_none()); diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 708aa9b79..15990329a 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -6,6 +6,8 @@ //! profiles (matrix/TRC exactly, LUT/CMYK via the `moxcms` CMS), 16-bit and float precision are //! preserved. EXIF/XMP/ICC blobs are passed through untouched; orientation is reported, never applied. //! - [`to_working`] converts a decode result to linear Rec.2020 D65 (the pipeline working space). +//! - [`read_header`] reads a file's stored dimensions and orientation from its headers, without +//! decoding pixels (import probes). //! - [`decode_thumbnail`] is the fast path for grid thumbnails (EXIF thumbnail or DCT-scaled decode). //! - [`encode`] writes JPEG, PNG, TIFF, lossless WebP and (native, feature `avif`) AVIF, embedding //! ICC/EXIF/XMP. [`icc::write_matrix_trc`] builds profiles for export. @@ -156,6 +158,60 @@ pub fn decode(bytes: &[u8], opts: DecodeOptions) -> Result { std::panic::catch_unwind(|| decode_unguarded(bytes, format, &opts)).unwrap_or_else(|_| Err(Error::Malformed(format, "decoder panicked".into()))) } +/// What a file's headers say about its image: enough to catalogue it without decoding pixels. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Header { + pub format: Format, + /// Full-resolution stored dimensions, orientation **not** applied ([`Decoded::source_width`]). + pub width: u32, + pub height: u32, + /// EXIF/TIFF orientation 1..=8, 1 when absent ([`Decoded::orientation`]). + pub orientation: u16, +} + +/// Read the stored dimensions and orientation of any format [`decode`] supports, agreeing with +/// what a decode reports, without decoding pixels: JPEG, PNG, TIFF, WebP and PSD are read from +/// their headers (markers, chunks, IFD, resources). GIF, BMP and JPEG XL fall back to a small +/// decode. +/// +/// The header readers also refuse what a decode would refuse before reaching the pixels (an +/// unsupported layout, compression or frame type) and files whose image data is cut short +/// (truncated: the data runs past the end of the file, or a JPEG's markers don't reach its +/// end-of-image marker). Damage *inside* compressed data that is all present is only found by a +/// decode. Malformed input yields an error, never a panic (as for [`decode`], a panic inside a +/// third-party parser is caught on unwinding targets). +pub fn read_header(bytes: &[u8]) -> Result
{ + let format = sniff(bytes).ok_or(Error::UnknownFormat)?; + std::panic::catch_unwind(|| read_header_unguarded(bytes, format)) + .unwrap_or_else(|_| Err(Error::Malformed(format, "header reader panicked".into()))) +} + +/// [`read_header`] without the panic guard (for fuzzing our own code paths). +#[doc(hidden)] +pub fn read_header_unguarded(bytes: &[u8], format: Format) -> Result
{ + let (width, height, orientation) = match format { + Format::Jpeg => jpeg::header(bytes)?, + Format::Png => png_codec::header(bytes)?, + Format::Tiff => tiff_codec::header(bytes)?, + Format::WebP => webp::header(bytes)?, + Format::Psd => psd::header(bytes)?, + // rare here (or, for JPEG XL, without a header reader yet): a small decode, as before + _ => { + let d = decode_unguarded(bytes, format, &DecodeOptions::fit(64, 64))?; + (d.source_width, d.source_height, d.orientation) + } + }; + Ok(Header { format, width, height, orientation }) +} + +/// Decode a JPEG preview with its enclosing container's colour space as a fallback. +/// A JPEG carrying ICC or EXIF metadata keeps its own interpretation. The fallback is applied +/// before transfer decoding and linear-light resizing, never to already-resampled pixels. +pub fn decode_jpeg_with_fallback(bytes: &[u8], opts: DecodeOptions, fallback: NamedSpace) -> Result { + std::panic::catch_unwind(|| jpeg::decode_with_fallback(bytes, &opts, Some(fallback))) + .unwrap_or_else(|_| Err(Error::Malformed(Format::Jpeg, "decoder panicked".into()))) +} + /// [`decode`] without the panic guard (for fuzzing our own code paths). #[doc(hidden)] pub fn decode_unguarded(bytes: &[u8], format: Format, opts: &DecodeOptions) -> Result { diff --git a/crates/codecs/src/png_codec.rs b/crates/codecs/src/png_codec.rs index 529d120d9..600f60b05 100644 --- a/crates/codecs/src/png_codec.rs +++ b/crates/codecs/src/png_codec.rs @@ -57,6 +57,75 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result { finish(F, raw, meta, (w, h), opts) } +/// Stored dimensions and EXIF orientation (`eXIf`, wherever it is) from the chunk headers, without +/// inflating the image data. Refused, as a decode would refuse it: an invalid IHDR, an indexed +/// image without a palette, no IDAT, or a file that ends before the chunk after the last IDAT +/// begins (truncated image data). Chunk CRCs and the deflate stream are not checked. +pub(crate) fn header(b: &[u8]) -> Result<(u32, u32, u16)> { + if !b.starts_with(b"\x89PNG\r\n\x1a\n") { + return Err(err("bad signature")); + } + let be32 = |p: usize| b.get(p..p.checked_add(4)?).map(|v| u32::from_be_bytes([v[0], v[1], v[2], v[3]])); + if be32(8) != Some(13) || b.get(12..16) != Some(b"IHDR") { + return Err(err("missing IHDR")); + } + let ihdr = b.get(16..29).ok_or_else(|| err("truncated IHDR"))?; + let (w, h) = (u32::from_be_bytes([ihdr[0], ihdr[1], ihdr[2], ihdr[3]]), u32::from_be_bytes([ihdr[4], ihdr[5], ihdr[6], ihdr[7]])); + let (depth, color, compression, filter, interlace) = (ihdr[8], ihdr[9], ihdr[10], ihdr[11], ihdr[12]); + if w > i32::MAX as u32 || h > i32::MAX as u32 { + return Err(err("dimensions out of range")); + } + check_size(F, w as u64, h as u64, &DecodeOptions::default())?; + let depth_ok = match color { + 0 => matches!(depth, 1 | 2 | 4 | 8 | 16), + 3 => matches!(depth, 1 | 2 | 4 | 8), + 2 | 4 | 6 => matches!(depth, 8 | 16), + _ => false, + }; + if !depth_ok || compression != 0 || filter != 0 || interlace > 1 { + return Err(err("invalid IHDR")); + } + let (mut p, mut palette, mut idat, mut after_idat, mut exif) = (8usize, false, false, false, None); + while let Some(len) = be32(p) { + let Some(kind) = p.checked_add(4).and_then(|k| b.get(k..k.checked_add(4)?)) else { break }; + if idat && kind != b"IDAT" { + // a chunk follows the image data: the IDAT run is complete + after_idat = true; + } + if kind == b"IEND" { + break; + } + let start = p.saturating_add(8); + let data = (len <= i32::MAX as u32).then(|| start.checked_add(len as usize).and_then(|end| b.get(start..end))).flatten(); + let Some(data) = data else { + if after_idat { + break; // a damaged chunk after the image data: a decode ignores it too + } + return Err(err("truncated")); + }; + match kind { + b"PLTE" => palette = true, + b"IDAT" if after_idat => {} // a stray IDAT after other chunks: the first run is the image + b"IDAT" => { + if color == 3 && !palette { + return Err(err("indexed image without a palette")); + } + idat = true; + } + b"eXIf" if exif.is_none() => exif = Some(data.strip_prefix(b"Exif\0\0").unwrap_or(data)), + _ => {} + } + p = start.saturating_add(data.len()).saturating_add(4); + } + if !idat { + return Err(err("no image data")); + } + if !after_idat { + return Err(err("truncated image data")); + } + Ok((w, h, exif.map(crate::exif::summarize).unwrap_or_default().orientation.unwrap_or(1))) +} + fn container_hint(info: &png::Info) -> Option { if let Some(c) = &info.coding_independent_code_points { let named = match c.color_primaries { diff --git a/crates/codecs/src/psd.rs b/crates/codecs/src/psd.rs index de668915a..3d26984ef 100644 --- a/crates/codecs/src/psd.rs +++ b/crates/codecs/src/psd.rs @@ -69,8 +69,8 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result { let (icc, exif, xmp) = resources(res); // Layer and mask information: only the layer count sign matters (merged transparency). - let lm_len = if psb { r.u64()? as usize } else { r.u32()? as usize }; - let lm = r.take(lm_len)?; + let lm_len = if psb { r.u64()? } else { u64::from(r.u32()?) }; + let lm = r.take(usize::try_from(lm_len).map_err(|_| err("truncated"))?)?; let merged_transparency = if lm.len() >= if psb { 10 } else { 6 } { let off = if psb { 8 } else { 4 }; let count = i16::from_be_bytes([lm[off], lm[off + 1]]); @@ -88,33 +88,6 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result { 1 => return Err(Error::Unsupported(F, "1-bit bitmap PSD")), _ => return Err(err("bad depth")), }; - let plane = width * height * bpc; - let row_bytes = width * bpc; - let mut planes: Vec> = Vec::with_capacity(channels); - match compression { - 0 => { - for _ in 0..channels { - planes.push(r.take(plane)?.to_vec()); - } - } - 1 => { - let counts_len = channels * height; - let mut counts = Vec::with_capacity(counts_len); - for _ in 0..counts_len { - counts.push(if psb { r.u32()? as usize } else { r.u16()? as usize }); - } - for c in 0..channels { - let mut out = Vec::with_capacity(plane); - for y in 0..height { - let src = r.take(counts[c * height + y])?; - unpackbits(src, row_bytes, &mut out); - } - planes.push(out); - } - } - _ => return Err(Error::Unsupported(F, "ZIP-compressed merged image data")), - } - let (model, n_color) = match mode { 1 | 8 => (Model::Gray, 1), 2 => (Model::Rgb, 1), @@ -128,50 +101,106 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result { return Err(err("too few channels")); } let alpha = merged_transparency && channels > n_color && mode != 2; + // Only the colour (+ alpha) planes are decoded; extra channels (spot colours, masks) are + // stored after them and never touched. let used = n_color + alpha as usize; - let n = width * height; + let too_large = || Error::TooLarge(width as u64, height as u64); + let n = width.checked_mul(height).ok_or_else(too_large)?; + let row_bytes = width.checked_mul(bpc).ok_or_else(too_large)?; + let plane = n.checked_mul(bpc).ok_or_else(too_large)?; + let mut planes: Vec> = Vec::with_capacity(used); + match compression { + 0 => { + // Raw planes are borrowed from the input, so their size is bounded by the file. + for _ in 0..used { + planes.push(r.take(plane)?.to_vec()); + } + } + 1 => { + // Row byte-count table: one entry per row per channel (all channels, since the data + // follows it). Taken from the input before anything is allocated from it. + let entry = if psb { 4 } else { 2 }; + let table_len = channels.checked_mul(height).and_then(|c| c.checked_mul(entry)).ok_or_else(|| err("truncated"))?; + let table = r.take(table_len)?; + let count = |i: usize| -> usize { + let at = i.saturating_mul(entry); + match table.get(at..at.saturating_add(entry)) { + Some(&[a, b]) => usize::from(u16::from_be_bytes([a, b])), + Some(&[a, b, c, d]) => u32::from_be_bytes([a, b, c, d]) as usize, + _ => 0, + } + }; + // PackBits expands at most 64× (a 2-byte repeat run yields 128 bytes), so a row needs + // at least this many encoded bytes. Requiring it (and the data being present) before + // allocating bounds the decoded planes by the file size: a tiny file can no longer + // declare gigabytes of zero-padded or repeat-run rows. + let min_row = row_bytes.div_ceil(128).saturating_mul(2); + let mut data_len = 0usize; + for i in 0..used.saturating_mul(height) { + let c = count(i); + if c < min_row { + return Err(err("RLE row too short for its width")); + } + data_len = data_len.saturating_add(c); + } + if data_len > bytes.len().saturating_sub(r.p) { + return Err(err("truncated")); + } + for c in 0..used { + let mut out = zeroed::(plane, too_large)?; + for (y, row) in out.chunks_exact_mut(row_bytes).enumerate() { + let src = r.take(count(c * height + y))?; + unpackbits(src, row)?; + } + planes.push(out); + } + } + _ => return Err(Error::Unsupported(F, "ZIP-compressed merged image data")), + } + let samples = n.checked_mul(used).ok_or_else(too_large)?; // Interleave to device samples. let buf = if mode == 2 { // Indexed → 8-bit RGB via the 768-byte palette. - if color_mode_data.len() < 768 || depth != 8 { + let (Some(pal), true) = (color_mode_data.get(..768), depth == 8) else { return Err(err("bad indexed palette")); - } - let mut v = Vec::with_capacity(n * 3); - for &i in &planes[0] { + }; + let idx = planes.first().ok_or_else(|| err("missing plane"))?; + let mut v = zeroed::(n.checked_mul(3).ok_or_else(too_large)?, too_large)?; + for (o, &i) in v.as_chunks_mut::<3>().0.iter_mut().zip(idx) { let i = i as usize; - v.extend_from_slice(&[color_mode_data[i], color_mode_data[256 + i], color_mode_data[512 + i]]); + *o = [pal[i], pal[256 + i], pal[512 + i]]; } Buf::U8(v) } else { + let cmyk = model == Model::Cmyk; match bpc { 1 => { - let mut v = vec![0u8; n * used]; - for (c, p) in planes.iter().take(used).enumerate() { - for i in 0..n { - let x = p[i]; + let mut v = zeroed::(samples, too_large)?; + for (c, p) in planes.iter().enumerate() { + for (o, &x) in v.iter_mut().skip(c).step_by(used).zip(p) { // PSD CMYK stores 255 = no ink; our CMYK convention is 0 = no ink. - v[i * used + c] = if model == Model::Cmyk && c < 4 { 255 - x } else { x }; + *o = if cmyk && c < 4 { 255 - x } else { x }; } } Buf::U8(v) } 2 => { - let mut v = vec![0u16; n * used]; - for (c, p) in planes.iter().take(used).enumerate() { - for i in 0..n { - let x = u16::from_be_bytes([p[2 * i], p[2 * i + 1]]); - v[i * used + c] = if model == Model::Cmyk && c < 4 { 65535 - x } else { x }; + let mut v = zeroed::(samples, too_large)?; + for (c, p) in planes.iter().enumerate() { + for (o, s) in v.iter_mut().skip(c).step_by(used).zip(p.as_chunks::<2>().0) { + let x = u16::from_be_bytes(*s); + *o = if cmyk && c < 4 { 65535 - x } else { x }; } } Buf::U16(v) } _ => { - let mut v = vec![0f32; n * used]; - for (c, p) in planes.iter().take(used).enumerate() { - for i in 0..n { - let x = f32::from_be_bytes([p[4 * i], p[4 * i + 1], p[4 * i + 2], p[4 * i + 3]]); - v[i * used + c] = if model == Model::Cmyk && c < 4 { 1.0 - x } else { x }; + let mut v = zeroed::(samples, too_large)?; + for (c, p) in planes.iter().enumerate() { + for (o, s) in v.iter_mut().skip(c).step_by(used).zip(p.as_chunks::<4>().0) { + let x = f32::from_be_bytes(*s); + *o = if cmyk && c < 4 { 1.0 - x } else { x }; } } Buf::F32(v) @@ -182,25 +211,111 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result { finish(F, raw, Meta { icc, exif, xmp, ..Default::default() }, (width as u32, height as u32), opts) } -/// PackBits: append exactly `row_bytes` bytes (zero-padded if the run data is short). -fn unpackbits(mut src: &[u8], row_bytes: usize, out: &mut Vec) { - let target = out.len() + row_bytes; - while out.len() < target && !src.is_empty() { - let h = src[0] as i8; - src = &src[1..]; - if h >= 0 { - let n = (h as usize + 1).min(src.len()).min(target - out.len()); - out.extend_from_slice(&src[..n]); - src = &src[(h as usize + 1).min(src.len())..]; - } else if h != -128 { - let n = ((1 - h as isize) as usize).min(target - out.len()); - if let Some(&b) = src.first() { - out.extend(std::iter::repeat_n(b, n)); - src = &src[1..]; +/// Stored dimensions and EXIF orientation (resource 1058) from the header and image resources, +/// without reading the merged image data. Refused, as a decode would refuse it: an unsupported +/// depth, colour mode or compression, or merged image data that runs past the end of the file +/// (truncated). The run-length data itself is not unpacked. +pub(crate) fn header(bytes: &[u8]) -> Result<(u32, u32, u16)> { + let mut r = Rd { b: bytes, p: 0 }; + if r.take(4)? != b"8BPS" { + return Err(err("bad signature")); + } + let psb = match r.u16()? { + 1 => false, + 2 => true, + _ => return Err(err("bad version")), + }; + r.take(6)?; + let channels = r.u16()? as usize; + let height = r.u32()? as usize; + let width = r.u32()? as usize; + let depth = r.u16()?; + let mode = r.u16()?; + check_size(F, width as u64, height as u64, &DecodeOptions::default())?; + if channels == 0 || channels > 56 { + return Err(err("bad channel count")); + } + let cm_len = r.u32()? as usize; + let color_mode_data = r.take(cm_len)?; + let res_len = r.u32()? as usize; + let (_, exif, _) = resources(r.take(res_len)?); + let lm_len = if psb { r.u64()? as usize } else { r.u32()? as usize }; + r.take(lm_len)?; + let compression = r.u16()?; + let bpc = match depth { + 8 => 1usize, + 16 => 2, + 32 => 4, + 1 => return Err(Error::Unsupported(F, "1-bit bitmap PSD")), + _ => return Err(err("bad depth")), + }; + let n_color = match mode { + 1 | 8 | 7 | 2 => 1, + 3 => 3, + 4 => 4, + 9 => return Err(Error::Unsupported(F, "Lab PSD")), + _ => return Err(err("unknown colour mode")), + }; + if channels < n_color { + return Err(err("too few channels")); + } + if mode == 2 && (color_mode_data.len() < 768 || depth != 8) { + return Err(err("bad indexed palette")); + } + let rest = bytes.len().saturating_sub(r.p); + let need = match compression { + 0 => width.checked_mul(height).and_then(|n| n.checked_mul(bpc)).and_then(|n| n.checked_mul(channels)), + 1 => { + // the row byte counts, then the rows they count + let rows = channels.checked_mul(height).ok_or_else(|| err("truncated"))?; + let table = rows.checked_mul(if psb { 4 } else { 2 }).filter(|&t| t <= rest).ok_or_else(|| err("truncated"))?; + let mut total = table; + for _ in 0..rows { + total = total.saturating_add(if psb { r.u32()? as usize } else { r.u16()? as usize }); } + Some(total) + } + _ => return Err(Error::Unsupported(F, "ZIP-compressed merged image data")), + }; + if need.is_none_or(|n| n > rest) { + return Err(err("truncated")); + } + let orientation = exif.as_deref().map(crate::exif::summarize).unwrap_or_default().orientation.unwrap_or(1); + Ok((width as u32, height as u32, orientation)) +} + +/// A zero-filled buffer whose allocation failure is an error, not an abort. +fn zeroed(len: usize, too_large: impl Fn() -> Error) -> Result> { + let mut v = Vec::new(); + v.try_reserve_exact(len).map_err(|_| too_large())?; + v.resize(len, T::default()); + Ok(v) +} + +/// PackBits: decode one row into `row` (pre-zeroed, so short run data leaves zero padding). +/// A run that would write past the end of the row is an error; trailing bytes are ignored. +fn unpackbits(mut src: &[u8], row: &mut [u8]) -> Result<()> { + let mut at = 0; + while at < row.len() { + let Some((&h, rest)) = src.split_first() else { break }; + src = rest; + let h = h as i8; + if h >= 0 { + let n = h as usize + 1; + let lit = src.get(..n).unwrap_or(src); + let dst = row.get_mut(at..at + lit.len()).ok_or_else(|| err("RLE row overruns its width"))?; + dst.copy_from_slice(lit); + at += lit.len(); + src = src.get(n..).unwrap_or(&[]); + } else if h != -128 { + let n = (1 - h as isize) as usize; + let Some((&b, rest)) = src.split_first() else { break }; + src = rest; + row.get_mut(at..at + n).ok_or_else(|| err("RLE row overruns its width"))?.fill(b); + at += n; } } - out.resize(target, 0); + Ok(()) } fn resources(mut b: &[u8]) -> (Option>, Option>, Option) { @@ -234,11 +349,89 @@ mod tests { #[test] fn packbits() { - let mut out = Vec::new(); - unpackbits(&[0xFE, 0xAA, 0x02, 0x80, 0x00, 0x2A], 6, &mut out); - assert_eq!(out, vec![0xAA, 0xAA, 0xAA, 0x80, 0x00, 0x2A]); - let mut out = Vec::new(); - unpackbits(&[0x05, 1], 4, &mut out); - assert_eq!(out, vec![1, 0, 0, 0]); + let mut out = [0u8; 6]; + unpackbits(&[0xFE, 0xAA, 0x02, 0x80, 0x00, 0x2A], &mut out).unwrap(); + assert_eq!(out, [0xAA, 0xAA, 0xAA, 0x80, 0x00, 0x2A]); + // Short run data is zero-padded. + let mut out = [0u8; 4]; + unpackbits(&[0x01, 1], &mut out).unwrap(); + assert_eq!(out, [1, 0, 0, 0]); + // Truncated literal run: copies what is there. + let mut out = [0u8; 4]; + unpackbits(&[0x03, 7, 8], &mut out).unwrap(); + assert_eq!(out, [7, 8, 0, 0]); + } + + #[test] + fn packbits_overrun_is_an_error() { + // Repeat run of 128 into a 4-byte row. + let mut out = [0u8; 4]; + assert!(unpackbits(&[0x81, 0xAA], &mut out).is_err()); + // Literal run of 6 into a 4-byte row. + assert!(unpackbits(&[0x05, 1, 2, 3, 4, 5, 6], &mut out).is_err()); + } + + /// PSD header + empty colour-mode/resources/layer sections + compression id. + fn header(channels: u16, w: u32, h: u32, depth: u16, mode: u16, compression: u16) -> Vec { + let mut b = b"8BPS".to_vec(); + b.extend_from_slice(&1u16.to_be_bytes()); + b.extend_from_slice(&[0; 6]); + b.extend_from_slice(&channels.to_be_bytes()); + b.extend_from_slice(&h.to_be_bytes()); + b.extend_from_slice(&w.to_be_bytes()); + b.extend_from_slice(&depth.to_be_bytes()); + b.extend_from_slice(&mode.to_be_bytes()); + b.extend_from_slice(&[0; 12]); // colour mode data, resources, layer & mask: all empty + b.extend_from_slice(&compression.to_be_bytes()); + b + } + + #[test] + fn huge_rle_with_truncated_count_table_is_rejected_before_allocating() { + // 1 × 2^30 px, 56 channels: the row table alone would be 56 Gi entries; the file has 4 bytes. + let mut b = header(56, 1, 1 << 30, 32, 3, 1); + b.extend_from_slice(&[0, 0, 0, 0]); + let e = decode(&b, &DecodeOptions::default()).unwrap_err(); + assert!(matches!(e, Error::Malformed(_, ref m) if m == "truncated"), "{e:?}"); + } + + #[test] + fn huge_rle_of_empty_rows_is_rejected_before_allocating() { + // 2^30 × 1 px RGB at 32 bits: every row count is 0, which used to zero-pad 3 × 4 GiB. + let mut b = header(3, 1 << 30, 1, 32, 3, 1); + b.extend_from_slice(&[0; 6]); + let e = decode(&b, &DecodeOptions::default()).unwrap_err(); + assert!(matches!(e, Error::Malformed(_, ref m) if m.contains("too short")), "{e:?}"); + // 1 Gpx RGB 8-bit whose (plausible) row counts claim more data than the file holds. + let mut b = header(3, 1 << 20, 1 << 10, 8, 3, 1); + b.extend_from_slice(&[0xFF; 3 * 1024 * 2]); + let e = decode(&b, &DecodeOptions::default()).unwrap_err(); + assert!(matches!(e, Error::Malformed(_, ref m) if m == "truncated"), "{e:?}"); + } + + #[test] + fn rle_repeat_run_past_row_width_is_an_error() { + // 4 × 1 gray 8-bit; the single row is a 128-byte repeat run. + let mut b = header(1, 4, 1, 8, 1, 1); + b.extend_from_slice(&2u16.to_be_bytes()); + b.extend_from_slice(&[0x81, 0x55]); + let e = decode(&b, &DecodeOptions::default()).unwrap_err(); + assert!(matches!(e, Error::Malformed(_, ref m) if m.contains("overruns")), "{e:?}"); + } + + #[test] + fn rle_gray16_and_extra_channels_decode() { + // 2 × 2 gray 16-bit with an extra (unused) channel whose data is absent: only the used + // plane is decoded. Row: repeat run of 4 bytes 0x80 -> samples 0x8080. + let mut b = header(2, 2, 2, 16, 1, 1); + for _ in 0..4 { + b.extend_from_slice(&2u16.to_be_bytes()); + } + b.extend_from_slice(&[0xFD, 0x80, 0xFD, 0x80]); + let d = decode(&b, &DecodeOptions::default()).unwrap(); + assert_eq!((d.width, d.height), (2, 2)); + let p = d.image.get(1, 1); + let q = d.image.get(0, 0); + assert!(p[0] > 0.0 && (p[0] - q[0]).abs() < 1e-6, "{p:?}"); } } diff --git a/crates/codecs/src/tiff_codec.rs b/crates/codecs/src/tiff_codec.rs index 3166c2dc4..b077c214a 100644 --- a/crates/codecs/src/tiff_codec.rs +++ b/crates/codecs/src/tiff_codec.rs @@ -1,5 +1,6 @@ -//! TIFF decode via the `tiff` crate: 8/16/32-bit integer, 16/32/64-bit float, gray/RGB/CMYK/palette, -//! alpha (associated or not), ICC (34675), XMP (700), orientation (274). First image only. +//! TIFF decode via the `tiff` crate: 1/2/4/8/16/32/64-bit integer, 16/32/64-bit float, gray/RGB/CMYK, +//! alpha (associated or not), ICC (34675), XMP (700), orientation (274). First image only. Palette +//! (indexed-colour) images are refused by the `tiff` crate itself, on every read path. use crate::convert::{Buf, Meta, Model, Raw, check_size, finish}; use crate::{DecodeOptions, Decoded, Error, Format, Result}; @@ -14,10 +15,7 @@ fn err(e: impl std::fmt::Display) -> Error { } pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result { - let mut limits = Limits::default(); - limits.decoding_buffer_size = opts.max_pixels.saturating_mul(4 * 8).min(usize::MAX as u64) as usize; - limits.ifd_value_size = 32 << 20; - let mut d = Decoder::new(std::io::Cursor::new(bytes)).map_err(err)?.with_limits(limits); + let mut d = Decoder::new(std::io::Cursor::new(bytes)).map_err(err)?.with_limits(limits(opts)); let (w, h) = d.dimensions().map_err(err)?; check_size(F, w as u64, h as u64, opts)?; let ct = d.colortype().map_err(err)?; @@ -25,26 +23,12 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result { let icc = tag_bytes(&mut d, Tag::IccProfile); let xmp = tag_bytes(&mut d, Tag::from_u16_exhaustive(700)).map(|v| String::from_utf8_lossy(&v).trim_end_matches('\0').to_string()); let orientation = d.get_tag_u32(Tag::Orientation).ok().map(|v| v as u16); - let photometric = d.get_tag_u32(Tag::PhotometricInterpretation).ok(); let planar = d.get_tag_u32(Tag::PlanarConfiguration).ok() == Some(2); let extra = d.get_tag_u16_vec(Tag::ExtraSamples).ok().unwrap_or_default(); let colormap = d.get_tag_u16_vec(Tag::ColorMap).ok(); let compression = d.get_tag_u32(Tag::Compression).ok(); - let (model, n_color, n_samples, bits) = match ct { - ColorType::Gray(b) => (Model::Gray, 1, 1, b), - ColorType::GrayA(b) => (Model::Gray, 1, 2, b), - ColorType::RGB(b) => (Model::Rgb, 3, 3, b), - ColorType::RGBA(b) => (Model::Rgb, 3, 4, b), - ColorType::CMYK(b) => (Model::Cmyk, 4, 4, b), - ColorType::CMYKA(b) => (Model::Cmyk, 4, 5, b), - ColorType::Palette(b) => (Model::Rgb, 1, 1, b), - // JPEG-compressed YCbCr is converted to RGB by the JPEG decoder. - ColorType::YCbCr(b) if compression == Some(7) => (Model::Rgb, 3, 3, b), - ColorType::Multiband { bit_depth, num_samples } if num_samples >= 3 => (Model::Rgb, 3, num_samples as usize, bit_depth), - ColorType::Multiband { bit_depth, num_samples } if num_samples >= 1 => (Model::Gray, 1, num_samples as usize, bit_depth), - other => return Err(Error::Unsupported(F, unsupported_name(other))), - }; + let (model, n_color, n_samples, bits) = layout(ct, compression, planar)?; let has_alpha = n_samples > n_color && !matches!(ct, ColorType::Palette(_)); let premultiplied = has_alpha && extra.first() == Some(&1); @@ -52,6 +36,7 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result { let (wu, hu) = (w as usize, h as usize); let n = wu * hu; let mut buf = match res { + DecodingResult::U8(v) if bits < 8 => Buf::U8(unpack(&v, wu, hu, n_samples, bits)?), DecodingResult::U8(v) => Buf::U8(v), DecodingResult::U16(v) => Buf::U16(v), // Rare integer widths: keep 16 significant bits (plenty for display-referred data). @@ -94,14 +79,8 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result { return finish(F, raw, meta, (w, h), opts); } - // WhiteIsZero → invert. - if photometric == Some(0) { - match &mut buf { - Buf::U8(v) => v.iter_mut().for_each(|x| *x = 255 - *x), - Buf::U16(v) => v.iter_mut().for_each(|x| *x = 65535 - *x), - Buf::F32(v) => v.iter_mut().for_each(|x| *x = 1.0 - *x), - } - } + // WhiteIsZero needs nothing here: the `tiff` crate inverts those samples as it reads them (and + // refuses WhiteIsZero layouts it can't invert), so inverting again would show a negative. // Drop extra samples beyond colour + one alpha. let keep = n_color + has_alpha as usize; @@ -113,6 +92,67 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result { finish(F, raw, meta, (w, h), opts) } +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; + limits.ifd_value_size = 32 << 20; + limits +} + +/// The sample layout of a colour type we can decode: (model, colour samples, samples per pixel, +/// bits per sample). +fn layout(ct: ColorType, compression: Option, planar: bool) -> Result<(Model, usize, usize, u8)> { + let l = match ct { + ColorType::Gray(b) => (Model::Gray, 1, 1, b), + ColorType::GrayA(b) => (Model::Gray, 1, 2, b), + ColorType::RGB(b) => (Model::Rgb, 3, 3, b), + ColorType::RGBA(b) => (Model::Rgb, 3, 4, b), + ColorType::CMYK(b) => (Model::Cmyk, 4, 4, b), + ColorType::CMYKA(b) => (Model::Cmyk, 4, 5, b), + ColorType::Palette(b) => (Model::Rgb, 1, 1, b), + // JPEG-compressed YCbCr is converted to RGB by the JPEG decoder. + ColorType::YCbCr(b) if compression == Some(7) => (Model::Rgb, 3, 3, b), + ColorType::Multiband { bit_depth, num_samples } if num_samples >= 3 => (Model::Rgb, 3, num_samples as usize, bit_depth), + ColorType::Multiband { bit_depth, num_samples } if num_samples >= 1 => (Model::Gray, 1, num_samples as usize, bit_depth), + other => return Err(Error::Unsupported(F, unsupported_name(other))), + }; + if l.3 < 8 && planar && l.2 > 1 { + return Err(Error::Unsupported(F, "planar TIFF with samples narrower than a byte")); + } + Ok(l) +} + +/// Stored dimensions and orientation from the first IFD, without reading the image data. Refused, +/// as a decode would refuse it: a layout or compression we can't decode, or strips/tiles that +/// start past the end of the file (truncated). The compressed data itself is not checked. +pub(crate) fn header(bytes: &[u8]) -> Result<(u32, u32, u16)> { + let mut d = Decoder::new(std::io::Cursor::new(bytes)).map_err(err)?.with_limits(limits(&DecodeOptions::default())); + let (w, h) = d.dimensions().map_err(err)?; + check_size(F, w as u64, h as u64, &DecodeOptions::default())?; + let ct = d.colortype().map_err(err)?; + let compression = d.get_tag_u32(Tag::Compression).ok(); + let planar = d.get_tag_u32(Tag::PlanarConfiguration).ok() == Some(2); + layout(ct, compression, planar)?; + // what this build of the `tiff` crate decompresses: none, CCITT G4, LZW, JPEG, Deflate, PackBits + if !matches!(compression.unwrap_or(1), 1 | 4 | 5 | 7 | 8 | 32773 | 32946) { + return Err(Error::Unsupported(F, "compression method")); + } + let chunks = |offsets: Tag, counts: Tag, d: &mut Decoder>| { + Some((d.get_tag_u64_vec(offsets).ok()?, d.get_tag_u64_vec(counts).ok()?)) + }; + let (offsets, counts) = chunks(Tag::StripOffsets, Tag::StripByteCounts, &mut d) + .or_else(|| chunks(Tag::TileOffsets, Tag::TileByteCounts, &mut d)) + .ok_or_else(|| err("no image data"))?; + // Only where each chunk starts: some writers overstate StripByteCounts, and the `tiff` crate + // decodes such files (it stops once it has the pixels), so a chunk's end proves nothing. + let len = bytes.len() as u64; + if offsets.is_empty() || offsets.iter().zip(&counts).any(|(&o, &c)| c > 0 && o >= len) { + return Err(err("truncated: image data past the end of the file")); + } + let orientation = d.get_tag_u32(Tag::Orientation).ok().map(|v| v as u16).filter(|o| (1..=8).contains(o)).unwrap_or(1); + Ok((w, h, orientation)) +} + /// A BYTE/UNDEFINED tag as bytes (the decoder may surface unknown byte tags as unsigned values). fn tag_bytes(d: &mut Decoder, tag: Tag) -> Option> { use tiff::decoder::ifd::Value; @@ -140,6 +180,29 @@ fn unsupported_name(ct: ColorType) -> &'static str { } } +/// Samples narrower than a byte (1-bit bilevel and fax scans, 2- and 4-bit gray) arrive packed, most +/// significant bits first, each row padded to a whole byte. Spread them out one per byte, scaled to +/// 0–255 like any 8-bit sample. +fn unpack(packed: &[u8], width: usize, height: usize, samples: usize, bits: u8) -> Result> { + if !matches!(bits, 1 | 2 | 4) { + return Err(Error::Unsupported(F, "TIFF samples of 3, 5, 6 or 7 bits")); + } + let bits = bits as usize; + let per_row = width.checked_mul(samples).ok_or_else(|| err("row too wide"))?; + let row_bytes = per_row.checked_mul(bits).ok_or_else(|| err("row too wide"))?.div_ceil(8); + if row_bytes == 0 || packed.len() / row_bytes < height { + return Err(err("sample buffer too short")); + } + let max = (1u8 << bits) - 1; + let scale = 255 / max; // 255, 85, 17: exact + let mut out = Vec::with_capacity(per_row.saturating_mul(height)); + for row in packed.chunks_exact(row_bytes).take(height) { + let values = row.iter().flat_map(|&b| (1..=8 / bits).map(move |i| (b >> (8 - bits * i)) & max)); + out.extend(values.take(per_row).map(|v| v * scale)); + } + Ok(out) +} + fn interleave(buf: Buf, n: usize, s: usize) -> Buf { fn go(v: Vec, n: usize, s: usize) -> Vec { if v.len() < n * s { diff --git a/crates/codecs/src/webp.rs b/crates/codecs/src/webp.rs index 8da561017..189146ab8 100644 --- a/crates/codecs/src/webp.rs +++ b/crates/codecs/src/webp.rs @@ -27,3 +27,30 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result { let raw = Raw { width: w as usize, height: h as usize, model: Model::Rgb, alpha, premultiplied: false, buf: Buf::U8(buf), bit_depth: 8 }; finish(F, raw, Meta { icc, exif, xmp, ..Default::default() }, (w, h), opts) } + +/// Stored dimensions and EXIF orientation from the RIFF chunks and the bitstream header, without +/// decoding the image. Refused, as a decode would refuse it: no image chunk (`VP8 `, `VP8L` or an +/// animation frame), or one that runs past the end of the file (truncated). The compressed data +/// itself is not checked. +pub(crate) fn header(bytes: &[u8]) -> Result<(u32, u32, u16)> { + let mut p = 12usize; + loop { + let Some(h) = bytes.get(p..p.saturating_add(8)) else { return Err(err("no image data")) }; + let size = u32::from_le_bytes([h[4], h[5], h[6], h[7]]) as usize; + let end = p.saturating_add(8).saturating_add(size); + if matches!(&h[0..4], b"VP8 " | b"VP8L" | b"ANMF") { + if end > bytes.len() { + return Err(err("truncated image data")); + } + break; + } + p = end.saturating_add(size & 1); + } + let mut d = image_webp::WebPDecoder::new(std::io::Cursor::new(bytes)).map_err(err)?; + d.set_memory_limit(bytes.len()); + let (w, h) = d.dimensions(); + check_size(F, w as u64, h as u64, &DecodeOptions::default())?; + let exif = d.exif_metadata().ok().flatten(); + let exif = exif.as_deref().map(|e| e.strip_prefix(b"Exif\0\0").unwrap_or(e)); + Ok((w, h, exif.map(crate::exif::summarize).unwrap_or_default().orientation.unwrap_or(1))) +} diff --git a/crates/codecs/tests/fuzz.rs b/crates/codecs/tests/fuzz.rs index cf858d6b5..15b236b0c 100644 --- a/crates/codecs/tests/fuzz.rs +++ b/crates/codecs/tests/fuzz.rs @@ -30,8 +30,10 @@ fn try_all(bytes: &[u8]) { if let Some(f) = sniff(bytes) { let _ = decode_unguarded(bytes, f, &small_opts()); let _ = decode_unguarded(bytes, f, &DecodeOptions { max_size: Some((8, 8)), max_pixels: 1 << 22 }); + let _ = read_header_unguarded(bytes, f); } let _ = decode(bytes, small_opts()); + let _ = read_header(bytes); let _ = decode_thumbnail_with(bytes, &ThumbnailOptions { max_pixels: small_opts().max_pixels, ..ThumbnailOptions::new(16) }); let _ = lightcraft_codecs::icc::parse(bytes); } diff --git a/crates/codecs/tests/header.rs b/crates/codecs/tests/header.rs new file mode 100644 index 000000000..6ff12d08b --- /dev/null +++ b/crates/codecs/tests/header.rs @@ -0,0 +1,366 @@ +//! `read_header` (import probes) agrees with a decode on dimensions and orientation, refuses what a +//! decode refuses (truncated files included), and returns errors — never panics — on hostile input. + +use lightcraft_codecs::exif::minimal_exif; +use lightcraft_codecs::*; +use lightcraft_raster::Rgba8; + +fn pixels(w: usize, h: usize) -> Rgba8 { + Rgba8::from_fn(w, h, |x, y| [(x * 37 % 256) as u8, (y * 53 % 256) as u8, ((x ^ y) * 9 % 256) as u8, (128 + x % 100) as u8]) +} + +fn meta(exif: &Option>) -> EncodeMeta<'_> { + EncodeMeta { exif: exif.as_deref(), xmp: Some(""), ..Default::default() } +} + +/// A TIFF with an Orientation tag (`encode_tiff` writes none), via the `tiff` crate's encoder. +fn tiff_oriented(img: &Rgba8, orientation: u16) -> Vec { + use tiff::encoder::{TiffEncoder, colortype}; + let rgb: Vec = img.data.iter().flat_map(|p| [p[0], p[1], p[2]]).collect(); + let mut cur = std::io::Cursor::new(Vec::new()); + let mut enc = TiffEncoder::new(&mut cur).unwrap(); + let mut im = enc.new_image::(img.width as u32, img.height as u32).unwrap(); + im.encoder().write_tag(tiff::tags::Tag::Orientation, orientation).unwrap(); + im.write_data(&rgb).unwrap(); + cur.into_inner() +} + +/// A minimal 8-bit RGB PSD (raw or PackBits-literal merged data) with an EXIF resource. +fn psd(w: usize, h: usize, orientation: Option, rle: bool) -> Vec { + let mut b = Vec::new(); + b.extend_from_slice(b"8BPS"); + b.extend_from_slice(&1u16.to_be_bytes()); + b.extend_from_slice(&[0; 6]); + b.extend_from_slice(&3u16.to_be_bytes()); + b.extend_from_slice(&(h as u32).to_be_bytes()); + b.extend_from_slice(&(w as u32).to_be_bytes()); + b.extend_from_slice(&8u16.to_be_bytes()); + b.extend_from_slice(&3u16.to_be_bytes()); + b.extend_from_slice(&0u32.to_be_bytes()); // colour mode data + let mut res = Vec::new(); + if let Some(o) = orientation { + let exif = minimal_exif(o); + res.extend_from_slice(b"8BIM"); + res.extend_from_slice(&1058u16.to_be_bytes()); + res.extend_from_slice(&[0, 0]); // empty pascal name, padded + res.extend_from_slice(&(exif.len() as u32).to_be_bytes()); + res.extend_from_slice(&exif); + if exif.len() % 2 == 1 { + res.push(0); + } + } + b.extend_from_slice(&(res.len() as u32).to_be_bytes()); + b.extend_from_slice(&res); + b.extend_from_slice(&0u32.to_be_bytes()); // layer and mask info + b.extend_from_slice(&(rle as u16).to_be_bytes()); + let plane = |c: usize| (0..w * h).map(move |i| ((i * (c + 3) * 11) % 256) as u8); + if rle { + // every row as literal runs of at most 128 bytes (PackBits' longest) + let row_len = w + w.div_ceil(128); + for _ in 0..3 * h { + b.extend_from_slice(&(row_len as u16).to_be_bytes()); + } + for c in 0..3 { + let p: Vec = plane(c).collect(); + for row in p.chunks(w) { + for run in row.chunks(128) { + b.push(run.len() as u8 - 1); + b.extend_from_slice(run); + } + } + } + } else { + for c in 0..3 { + b.extend(plane(c)); + } + } + b +} + +/// Generated files of every format with a header reader, plus a fallback one (BMP via `image`). +fn fixtures(w: usize, h: usize) -> Vec<(String, Vec)> { + let img = pixels(w, h); + let e = EncodeImage::rgba8(&img); + let rgb: Vec = img.data.iter().flat_map(|p| [p[0], p[1], p[2]]).collect(); + let rgb_e = EncodeImage::new(w as u32, h as u32, 3, Samples::U8(&rgb)); + let u16s: Vec = (0..w * h * 3).map(|i| (i * 331) as u16).collect(); + let mut out = Vec::new(); + for o in [None, Some(1), Some(6), Some(8)] { + let exif = o.map(minimal_exif); + let tag = format!("o{}", o.unwrap_or(0)); + out.push((format!("jpeg 420 {tag}"), encode_jpeg(&rgb_e, 85, ChromaSubsampling::S420, &meta(&exif)).unwrap())); + out.push((format!("jpeg 444 {tag}"), encode_jpeg(&rgb_e, 85, ChromaSubsampling::S444, &meta(&exif)).unwrap())); + out.push((format!("png rgba {tag}"), encode_png(&e, &meta(&exif)).unwrap())); + out.push((format!("png 16-bit {tag}"), encode_png(&EncodeImage::new(w as u32, h as u32, 3, Samples::U16(&u16s)), &meta(&exif)).unwrap())); + out.push((format!("webp {tag}"), encode_webp_lossless(&e, &meta(&exif)).unwrap())); + out.push((format!("psd {tag}"), psd(w, h, o, false))); + out.push((format!("psd rle {tag}"), psd(w, h, o, true))); + out.push((format!("tiff {tag}"), tiff_oriented(&img, o.unwrap_or(1)))); + } + out.push(("tiff lzw".into(), encode_tiff(&e, TiffCompression::Lzw, &EncodeMeta::default()).unwrap())); + out.push(("tiff packbits".into(), encode_tiff(&e, TiffCompression::PackBits, &EncodeMeta::default()).unwrap())); + let mut bmp = Vec::new(); + image::codecs::bmp::BmpEncoder::new(&mut bmp).encode(&rgb, w as u32, h as u32, image::ExtendedColorType::Rgb8).unwrap(); + out.push(("bmp".into(), bmp)); + out +} + +/// What the import probe learnt before it read headers: a small decode. +fn decoded(bytes: &[u8]) -> Result
{ + let d = decode(bytes, DecodeOptions::fit(64, 64))?; + Ok(Header { format: d.format, width: d.source_width, height: d.source_height, orientation: d.orientation }) +} + +#[test] +fn header_matches_decode() { + for (w, h) in [(160, 96), (96, 160), (7, 5)] { + for (name, bytes) in fixtures(w, h) { + let header = read_header(&bytes).unwrap_or_else(|e| panic!("{name} {w}×{h}: {e}")); + assert_eq!(header, decoded(&bytes).unwrap(), "{name} {w}×{h}"); + assert_eq!((header.width, header.height), (w as u32, h as u32), "{name}"); + } + } + let o = |name: &str| fixtures(16, 8).into_iter().find(|f| f.0 == name).map(|f| read_header(&f.1).unwrap().orientation).unwrap(); + assert_eq!((o("jpeg 420 o6"), o("png rgba o8"), o("webp o6"), o("psd o8"), o("tiff o6"), o("jpeg 444 o0")), (6, 8, 6, 8, 6, 1)); +} + +/// Whenever the decode the probe used to run accepts a file (here: every cut of every fixture), +/// the header agrees with it: reading headers never refuses a file that decodes. The reverse +/// is checked for truncation, which the header readers detect. +#[test] +fn truncated_files_agree_with_decode() { + // 160 px wide: large JPEGs take the DCT-scaled (strict) decode path the probe took for photos + for (name, bytes) in fixtures(160, 96) { + let n = bytes.len(); + let mut cuts: Vec = (0..100).map(|i| n * i / 100).collect(); + cuts.extend(n.saturating_sub(64)..n); + for cut in cuts { + let part = &bytes[..cut]; + if let Ok(d) = decoded(part) { + assert_eq!(read_header(part).ok(), Some(d), "{name} cut at {cut}/{n}"); + } + } + for cut in [n / 4, n / 2, n * 9 / 10] { + assert!(read_header(&bytes[..cut]).is_err(), "{name}: cut at {cut}/{n} accepted"); + } + } +} + +#[test] +fn truncation_details() { + let f = fixtures(160, 96); + let get = |name: &str| f.iter().find(|x| x.0 == name).map(|x| x.1.clone()).unwrap(); + let jpeg = get("jpeg 420 o6"); + assert!(read_header(&jpeg[..jpeg.len() - 2]).is_err(), "a JPEG without its EOI is truncated"); + let mut trailing = jpeg.clone(); + trailing.extend_from_slice(&[0; 100]); + assert_eq!(read_header(&trailing).unwrap().orientation, 6, "bytes after the EOI are fine"); + let png = get("png rgba o6"); + assert!(read_header(&png[..png.len() - 2]).is_ok(), "a damaged IEND is ignored, as by a decode"); + assert!(read_header(&png[..png.len() - 12]).is_err(), "nothing after the image data: truncated"); +} + +/// eXIf after the image data counts, as it does for a decode (which reads the whole file). +#[test] +fn png_exif_after_idat() { + let img = pixels(9, 4); + let plain = encode_png(&EncodeImage::rgba8(&img), &EncodeMeta::default()).unwrap(); + let exif = minimal_exif(6); + let mut chunk = (exif.len() as u32).to_be_bytes().to_vec(); + chunk.extend_from_slice(b"eXIf"); + chunk.extend_from_slice(&exif); + chunk.extend_from_slice(&[0; 4]); // CRC: not checked by either reader + let iend = plain.len() - 12; + let mut b = plain[..iend].to_vec(); + b.extend_from_slice(&chunk); + b.extend_from_slice(&plain[iend..]); + assert_eq!(read_header(&b).unwrap().orientation, 6); +} + +fn jpeg_segments(sof: u8, w: u16, h: u16, comps: u8) -> Vec { + let mut b = vec![0xFF, 0xD8, 0xFF, sof, 0, 8 + 3 * comps as u16 as u8, 8]; + b.extend_from_slice(&h.to_be_bytes()); + b.extend_from_slice(&w.to_be_bytes()); + b.push(comps); + for c in 0..comps { + b.extend_from_slice(&[c + 1, 0x11, 0]); + } + b.extend_from_slice(&[0xFF, 0xDA, 0, 8, 1, 1, 0, 0, 63, 0]); + b.extend_from_slice(&[0x12, 0xFF, 0x00, 0x34, 0xFF, 0xD0, 0x56]); + b.extend_from_slice(&[0xFF, 0xD9]); + b +} + +fn png_ihdr(w: u32, h: u32, depth: u8, color: u8) -> Vec { + let mut b = b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR".to_vec(); + b.extend_from_slice(&w.to_be_bytes()); + b.extend_from_slice(&h.to_be_bytes()); + b.extend_from_slice(&[depth, color, 0, 0, 0, 0, 0, 0, 0]); + b +} + +fn png_chunk(b: &mut Vec, kind: &[u8; 4], len: u32, data: &[u8]) { + b.extend_from_slice(&len.to_be_bytes()); + b.extend_from_slice(kind); + b.extend_from_slice(data); + b.extend_from_slice(&[0; 4]); +} + +#[test] +fn hostile_headers_are_errors() { + // a well-formed synthetic JPEG header is read + assert_eq!(read_header(&jpeg_segments(0xC0, 33, 21, 3)).unwrap(), Header { format: Format::Jpeg, width: 33, height: 21, orientation: 1 }); + let jpeg_bad = [ + jpeg_segments(0xC0, 0, 21, 3), // zero width + jpeg_segments(0xC0, 33, 0, 3), // zero height (DNL) + jpeg_segments(0xC0, 65535, 65535, 3), // 4.3 Gpx + jpeg_segments(0xC9, 33, 21, 3), // arithmetic coding + jpeg_segments(0xC5, 33, 21, 3), // hierarchical + jpeg_segments(0xC0, 33, 21, 2), // two components + jpeg_segments(0xC0, 33, 21, 3)[..30].to_vec(), + vec![0xFF, 0xD8, 0xFF, 0xE1, 0, 0], // segment length 0 + vec![0xFF, 0xD8, 0xFF, 0xE1, 0xFF, 0xFF], // segment past the end + vec![0xFF, 0xD8, 0xFF, 0xD9], + ]; + for (i, b) in jpeg_bad.iter().enumerate() { + assert!(read_header(b).is_err(), "JPEG case {i}"); + } + let mut no_sos = jpeg_segments(0xC0, 33, 21, 3); + no_sos.truncate(2 + 2 + 17); + no_sos.extend_from_slice(&[0xFF, 0xD9]); + assert!(read_header(&no_sos).is_err(), "JPEG without a scan"); + + let mut ok = png_ihdr(5, 3, 8, 2); + png_chunk(&mut ok, b"IDAT", 1, &[0]); + png_chunk(&mut ok, b"IEND", 0, &[]); + assert_eq!(read_header(&ok).unwrap(), Header { format: Format::Png, width: 5, height: 3, orientation: 1 }); + let png_case = |ihdr: Vec, chunks: &[(&[u8; 4], u32, &[u8])]| { + let mut b = ihdr; + for (k, l, d) in chunks { + png_chunk(&mut b, k, *l, d); + } + b + }; + let idat: (&[u8; 4], u32, &[u8]) = (b"IDAT", 1, &[0]); + let iend: (&[u8; 4], u32, &[u8]) = (b"IEND", 0, &[]); + let png_bad = [ + png_case(png_ihdr(0, 3, 8, 2), &[idat, iend]), + png_case(png_ihdr(5, 0, 8, 2), &[idat, iend]), + png_case(png_ihdr(0x8000_0000, 1, 8, 2), &[idat, iend]), + png_case(png_ihdr(65536, 65536, 8, 2), &[idat, iend]), + png_case(png_ihdr(5, 3, 4, 2), &[idat, iend]), // RGB at 4 bits + png_case(png_ihdr(5, 3, 8, 5), &[idat, iend]), // no colour type 5 + png_case(png_ihdr(5, 3, 8, 3), &[idat, iend]), // indexed without PLTE + png_case(png_ihdr(5, 3, 8, 2), &[iend]), // no IDAT + png_case(png_ihdr(5, 3, 8, 2), &[idat]), // nothing after the image data + png_case(png_ihdr(5, 3, 8, 2), &[(b"IDAT", 0xFFFF_FFFF, &[0]), iend]), + png_case(png_ihdr(5, 3, 8, 2), &[(b"tEXt", 0x7FFF_FFFF, &[0]), idat, iend]), + png_ihdr(5, 3, 8, 2)[..20].to_vec(), + b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDX\0\0\0\x05\0\0\0\x03\x08\x02\0\0\0".to_vec(), + ]; + for (i, b) in png_bad.iter().enumerate() { + assert!(read_header(b).is_err(), "PNG case {i}"); + } + + let tiff = encode_tiff(&EncodeImage::rgba8(&pixels(6, 4)), TiffCompression::None, &EncodeMeta::default()).unwrap(); + let ifd = u32::from_le_bytes([tiff[4], tiff[5], tiff[6], tiff[7]]) as usize; + let mut tiff_bad = vec![b"II*\0\xff\xff\xff\x7f".to_vec(), b"II*\0\x08\0\0\0".to_vec(), b"II*\0\x04\0\0\0".to_vec()]; + // cut short after the IFD + assert!(ifd < tiff.len() / 2, "the IFD comes before the image data"); + tiff_bad.push(tiff[..tiff.len() - 10].to_vec()); + for (i, b) in tiff_bad.iter().enumerate() { + assert!(read_header(b).is_err(), "TIFF case {i}"); + } + // a strip offset pointing past the end of the file + let t = |b: &[u8], p: usize| u16::from_le_bytes([b[p], b[p + 1]]); + let n = t(&tiff, ifd) as usize; + let mut far = tiff.clone(); + for e in 0..n { + let p = ifd + 2 + e * 12; + if t(&far, p) == 273 && t(&far, p + 2) == 4 && u32::from_le_bytes([far[p + 4], far[p + 5], far[p + 6], far[p + 7]]) == 1 { + far[p + 8..p + 12].copy_from_slice(&0x7FFF_0000u32.to_le_bytes()); + } + } + assert_ne!(far, tiff, "the strip offset was found"); + assert!(read_header(&far).is_err(), "strip past the end"); + + let webp_bad = [ + b"RIFF\0\0\0\0WEBP".to_vec(), + b"RIFF\0\0\0\0WEBPVP8L\xff\xff\xff\xff\x2f".to_vec(), + b"RIFF\0\0\0\0WEBPXXXX\xff\xff\xff\xff".to_vec(), + b"RIFF\0\0\0\0WEBPVP8 \x04\0\0\0\0\0\0\0".to_vec(), + ]; + for (i, b) in webp_bad.iter().enumerate() { + assert!(read_header(b).is_err(), "WebP case {i}"); + } + + let good = psd(4, 3, Some(6), true); + assert_eq!(read_header(&good).unwrap().orientation, 6); + let set = |pos: usize, v: &[u8]| { + let mut b = good.clone(); + b[pos..pos + v.len()].copy_from_slice(v); + b + }; + let res_len = u32::from_be_bytes([good[30], good[31], good[32], good[33]]) as usize; + let compression = 34 + res_len + 4; + let mut psd_bad = vec![ + set(12, &0u16.to_be_bytes()), // no channels + set(12, &57u16.to_be_bytes()), // too many + set(12, &2u16.to_be_bytes()), // too few for RGB + set(14, &0x7FFF_FFFFu32.to_be_bytes()), // absurd height + set(18, &0u32.to_be_bytes()), // zero width + set(22, &1u16.to_be_bytes()), // 1-bit + set(22, &7u16.to_be_bytes()), // bad depth + set(24, &9u16.to_be_bytes()), // Lab + set(24, &77u16.to_be_bytes()), // unknown mode + set(24, &2u16.to_be_bytes()), // indexed without a palette + set(compression, &2u16.to_be_bytes()), // ZIP + set(26, &0xFFFF_FFFFu32.to_be_bytes()), // colour mode data past the end + set(30, &0xFFFF_FFF0u32.to_be_bytes()), // resources past the end + ]; + // a 1 × 2^30 RLE image declared in a tiny file: refused without reading 2^30 row counts + let mut tall = good.clone(); + tall[14..18].copy_from_slice(&(1u32 << 30).to_be_bytes()); + tall[18..22].copy_from_slice(&1u32.to_be_bytes()); + psd_bad.push(tall); + psd_bad.push(good[..good.len() - 1].to_vec()); + for (i, b) in psd_bad.iter().enumerate() { + assert!(read_header(b).is_err(), "PSD case {i}"); + } + + for b in [&b""[..], b"\xFF\xD8\xFF", b"\x89PNG\r\n\x1a\n", b"II*\0", b"MM\0*", b"8BPS\0\x01", b"GIF89a", b"not an image at all"] { + assert!(read_header(b).is_err(), "{b:?}"); + } +} + +/// The same with a few random bytes changed: whenever the decode accepts the damaged file, the +/// header agrees with it (deterministic seed). +#[test] +fn mutated_files_agree_with_decode() { + let mut s: u64 = 0x1234_5678_9abc_def1; + let mut rnd = || { + s ^= s << 13; + s ^= s >> 7; + s ^= s << 17; + s + }; + let (mut decodable, mut disagreements) = (0, Vec::new()); + for (name, bytes) in fixtures(160, 96) { + for _ in 0..300 { + let mut b = bytes.clone(); + for _ in 0..1 + rnd() % 3 { + let i = rnd() as usize % b.len(); + b[i] = rnd() as u8; + } + if let Ok(d) = decoded(&b) { + decodable += 1; + let h = read_header(&b); + if h.as_ref().ok() != Some(&d) { + disagreements.push(format!("{name}: decode {d:?}, header {h:?}")); + } + } + } + } + assert!(decodable > 1000, "{decodable}"); + assert!(disagreements.is_empty(), "{disagreements:#?}"); +} diff --git a/crates/codecs/tests/roundtrip.rs b/crates/codecs/tests/roundtrip.rs index 4bce59de8..a700ddf6c 100644 --- a/crates/codecs/tests/roundtrip.rs +++ b/crates/codecs/tests/roundtrip.rs @@ -245,6 +245,80 @@ fn tiff_cmyk_and_orientation() { assert!(red[0] > 0.99 && red[1] < 0.01 && red[2] < 0.01, "{red:?}"); } +/// 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 { + const STRIP_AT: u32 = 8 + 2 + 9 * 12 + 4; + let short = |tag: u16, v: u16| [&tag.to_le_bytes()[..], &3u16.to_le_bytes(), &1u32.to_le_bytes(), &v.to_le_bytes(), &[0, 0]].concat(); + let long = |tag: u16, v: u32| [&tag.to_le_bytes()[..], &4u16.to_le_bytes(), &1u32.to_le_bytes(), &v.to_le_bytes()].concat(); + let mut b = b"II*\0".to_vec(); + b.extend(8u32.to_le_bytes()); + b.extend(9u16.to_le_bytes()); + b.extend(short(256, width)); + b.extend(short(257, height)); + b.extend(short(258, bits)); + b.extend(short(259, 1)); + b.extend(short(262, photometric)); + b.extend(long(273, STRIP_AT)); + b.extend(short(277, 1)); + b.extend(short(278, height)); + b.extend(long(279, strip.len() as u32)); + b.extend(0u32.to_le_bytes()); + assert_eq!(b.len(), STRIP_AT as usize); + b.extend_from_slice(strip); + b +} + +/// WhiteIsZero (photometric 0) gray: the lowest sample is white. The `tiff` crate already inverts these +/// samples, and a second inversion here used to show every WhiteIsZero scan as a negative. +#[test] +fn tiff_white_is_zero_is_inverted_once() { + // Two pixels: the lowest sample, then the highest, at 8 and 16 bits. + for (bits, strip) in [(8, vec![0u8, 255]), (16, [0u16.to_le_bytes(), 65535u16.to_le_bytes()].concat())] { + for (photometric, first) in [(0, 1.0), (1, 0.0)] { + let d = decode(&packed_tiff(2, 1, bits, photometric, &strip), DecodeOptions::default()).unwrap(); + let (a, b) = (d.image.get(0, 0)[1], d.image.get(1, 0)[1]); + assert!((a - first).abs() < 1e-6 && (b - (1.0 - first)).abs() < 1e-6, "{bits}-bit, photometric {photometric}: {a} {b}"); + } + } +} + +/// 1-, 2- and 4-bit samples (bilevel scans, fax images, low-depth gray) arrive packed eight, four or +/// two to a byte with each row padded to a byte; they used to be rejected as "sample buffer too short". +#[test] +fn tiff_samples_narrower_than_a_byte() { + let gray = |d: &Decoded, x: usize, y: usize| d.image.get(x, y)[1]; + + // 10 × 2 bilevel: rows are 2 bytes, the last 6 bits of each are padding (set, to prove they are ignored). + // Row 0 alternates set/clear bits; row 1 is clear but for its last pixel. + let rows = [0b1010_1010, 0b1011_1111, 0b0000_0000, 0b0111_1111]; + // What a set bit decodes to: white for BlackIsZero (1), black for WhiteIsZero (0). + for (photometric, set_bit) in [(1, 1.0), (0, 0.0)] { + let d = decode(&packed_tiff(10, 2, 1, photometric, &rows), DecodeOptions::default()).unwrap(); + assert_eq!((d.image.width, d.image.height, d.bit_depth), (10, 2, 1)); + assert!(d.grayscale); + let clear_bit = 1.0 - set_bit; + for x in 0..10 { + let want = (if x % 2 == 0 { set_bit } else { clear_bit }, if x == 9 { set_bit } else { clear_bit }); + assert!((gray(&d, x, 0) - want.0).abs() < 1e-6, "row 0, x {x}, photometric {photometric}"); + assert!((gray(&d, x, 1) - want.1).abs() < 1e-6, "row 1, x {x}, photometric {photometric}"); + } + } + + // 2-bit: 0, 1, 2, 3 → 0, 85, 170, 255. 4-bit: 0, 15, 8 → 0, 255, 136 (the row's last nibble is padding). + let d = decode(&packed_tiff(4, 1, 2, 1, &[0b00_01_10_11]), DecodeOptions::default()).unwrap(); + for (x, v) in [0, 85, 170, 255].into_iter().enumerate() { + assert!((gray(&d, x, 0) - srgb_to_linear(v as f32 / 255.0)).abs() < 1e-6, "2-bit x {x}"); + } + let d = decode(&packed_tiff(3, 1, 4, 1, &[0x0F, 0x8F]), DecodeOptions::default()).unwrap(); + for (x, v) in [0, 255, 136].into_iter().enumerate() { + assert!((gray(&d, x, 0) - srgb_to_linear(v as f32 / 255.0)).abs() < 1e-6, "4-bit x {x}"); + } + + // A strip shorter than its rows is an error, never a panic. + assert!(decode(&packed_tiff(10, 2, 1, 1, &rows[..3]), DecodeOptions::default()).is_err()); +} + #[test] fn webp_lossless_exact() { let img = gradient(31, 19); diff --git a/crates/color/src/cct.rs b/crates/color/src/cct.rs index abd241dde..9cb2b96c5 100644 --- a/crates/color/src/cct.rs +++ b/crates/color/src/cct.rs @@ -2,7 +2,9 @@ //! //! The Planckian locus uses the Kim et al. (2002) cubic approximation (1667–25000 K); outside that //! range it is extended linearly in mired space. Tint is a signed offset perpendicular to the locus in -//! CIE 1960 (u, v): **positive tint = magenta** (below the locus), `Duv = −tint / TINT_SCALE`. +//! CIE 1960 (u, v). Tint describes the **correction**: positive adds magenta, negative adds green. +//! A positive correction neutralises a green illuminant above the locus, so its source white uses +//! `Duv = tint / TINT_SCALE`. Bradford adaptation removes that source cast. use crate::{Mat3, RgbSpace, Xy, bradford}; @@ -65,11 +67,12 @@ fn locus_normal(t: f64) -> (f64, f64) { if ny < 0.0 { (-nx, -ny) } else { (nx, ny) } } -/// Chromaticity of the white point for temperature `t` (K) and `tint`. +/// Source illuminant chromaticity for temperature `t` (K) and correction `tint`. +/// Positive tint means a green source white, whose correction adds magenta to the image. pub fn temp_tint_to_xy(t: f64, tint: f64) -> Xy { let (u, v) = locus_uv(t); let (nu, nv) = locus_normal(t); - let duv = -tint / TINT_SCALE; + let duv = tint / TINT_SCALE; uv_to_xy(u + nu * duv, v + nv * duv) } @@ -104,7 +107,7 @@ pub fn xy_to_temp_tint(p: Xy) -> (f64, f64) { let (lu, lv) = locus_uv(t); let (nu, nv) = locus_normal(t); let duv = (u - lu) * nu + (v - lv) * nv; - (t, -duv * TINT_SCALE) + (t, duv * TINT_SCALE) } /// Matrix (linear RGB in `space` → same space) that white-balances a scene lit by `src` so that it @@ -141,10 +144,18 @@ mod tests { let warm = temp_tint_to_xy(3000.0, 0.0); let cool = temp_tint_to_xy(9000.0, 0.0); assert!(warm.x > cool.x); - // positive tint is magenta: lower v + // A positive magenta correction specifies a greener source illuminant: higher v. let (_, v0) = xy_to_uv(temp_tint_to_xy(5000.0, 0.0)); let (_, v1) = xy_to_uv(temp_tint_to_xy(5000.0, 50.0)); - assert!(v1 < v0); + assert!(v1 > v0); + } + + /// Daylight lies above the Planckian locus, so D65 reads as a small positive tint, as + /// Lightroom's Daylight preset (5500 K, +10) has it (#188). + #[test] + fn daylight_has_a_positive_tint() { + let (_, tint) = xy_to_temp_tint(crate::D65); + assert!((5.0..15.0).contains(&tint), "{tint}"); } #[test] diff --git a/crates/develop/src/lib.rs b/crates/develop/src/lib.rs index ff4daedd9..bfce1893e 100644 --- a/crates/develop/src/lib.rs +++ b/crates/develop/src/lib.rs @@ -68,6 +68,32 @@ impl DevelopSettings { !self.disabled_sections.iter().any(|s| s == section) } + /// The settings as rendered: an Edit-panel section whose eye is off contributes nothing (issue + /// #316). Light (with its tone curve), Color (white balance, presence, the mixers, colour grading + /// and point colour) and Detail are reset to their defaults here; Effects, Optics, Geometry and + /// Calibration the pipeline switches off where it applies them. Borrowed when every section is on. + pub fn effective(&self) -> std::borrow::Cow<'_, DevelopSettings> { + let off = |section: &str| !self.section_enabled(section); + if !(off("light") || off("color") || off("detail")) { + return std::borrow::Cow::Borrowed(self); + } + let mut d = self.clone(); + if off("light") { + d.reset_section(Section::Light); + d.reset_section(Section::Curve); + } + if off("color") { + for section in [Section::Color, Section::Mixer, Section::BwMix, Section::Grading, Section::PointColor] { + d.reset_section(section); + } + d.point_colors.clear(); + } + if off("detail") { + d.reset_section(Section::Detail); + } + std::borrow::Cow::Owned(d) + } + pub fn set_section_enabled(&mut self, section: &str, on: bool) { self.disabled_sections.retain(|s| s != section); if !on { diff --git a/crates/develop/src/settings.rs b/crates/develop/src/settings.rs index 800f229b5..910cd07dd 100644 --- a/crates/develop/src/settings.rs +++ b/crates/develop/src/settings.rs @@ -158,6 +158,7 @@ impl WbMode { pub struct WhiteBalance { pub mode: WbMode, pub temp: f64, + /// Correction direction: negative adds green, positive adds magenta. pub tint: f64, } @@ -539,8 +540,9 @@ impl Default for Optics { } } -/// Lens corrections embedded in a DNG file (`OpcodeList3`: `WarpRectilinear`, `FixVignetteRadial`), converted -/// to the oriented, default-cropped image. This is camera/file data (stored on the photo record, not in the develop +/// Lens corrections embedded in the file, converted to the oriented, default-cropped image: a DNG's `OpcodeList3` +/// (`WarpRectilinear`, `FixVignetteRadial`), or a raw reader's equivalent of the camera's own correction (Panasonic / +/// Leica RW2 distortion, `lightcraft_raw`'s `vendor/rw2.rs`). This is camera/file data (stored on the photo record, not in the develop /// settings); "Enable Profile Corrections" applies it, scaled by the profile distortion/vignetting amounts. /// LightCraft never uses Adobe LCP lens profiles. #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index c2d4e8a80..a916d1806 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -30,6 +30,9 @@ serde_json = { workspace = true } thiserror = { workspace = true } log = { workspace = true } ab_glyph = { workspace = true } +# Apply the font’s vertical substitutions and origins to Japanese watermarks. +harfrust = { version = "0.12", default-features = false, features = ["std"] } +unicode-segmentation = "1.13" lightcraft-segment = { workspace = true, optional = true } [features] @@ -38,3 +41,4 @@ sam = ["dep:lightcraft-segment"] [dev-dependencies] libc = { workspace = true } +lightcraft-tiff = { workspace = true } diff --git a/crates/engine/examples/render_bench.rs b/crates/engine/examples/render_bench.rs index ac6a9a60d..55dc2b11d 100644 --- a/crates/engine/examples/render_bench.rs +++ b/crates/engine/examples/render_bench.rs @@ -93,7 +93,9 @@ fn main() { for path in &args { let bytes = std::fs::read(path).expect("read"); println!("{path}"); - let raw = lightcraft_raw::decode(&bytes).expect("decode"); + let mut raw = lightcraft_raw::decode(&bytes).expect("decode"); + // as the loader does: lens corrections are applied by the pipeline, not in the raw develop + raw.opcodes.list3.retain(|op| !op.is_lens_correction()); println!(" {}×{} cpp {} cfa {:?}", raw.width, raw.height, raw.cpp, raw.cfa.as_ref().map(|c| c.name())); println!(" {:<36} {}", "raw decode:", best(n, || drop(lightcraft_raw::decode(&bytes).expect("decode")))); println!(" {:<36} {}", "normalize:", best(n, || drop(raw.normalized().expect("norm")))); diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index cd0d475d2..55ea2d808 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -1,5 +1,5 @@ //! Estimate the starting look of a raw without a camera colour matrix (Sony ARW, Nikon NEF, Panasonic -//! RW2) from its own JPEG. Colour and luminance are fitted separately; the JPEG supplies correspondences only, +//! RW2, Fujifilm RAF, Canon CR3) from its own JPEG. Colour and luminance are fitted separately; the JPEG supplies correspondences only, //! never output pixels or a replacement for RAW editing. //! A global matrix can't follow the camera's hue-dependent rendering (the best matrix rendered a //! lime shirt olive that the camera kept lime): a hue/saturation table fitted to the residuals @@ -31,7 +31,10 @@ pub(crate) const PROFILE_PROXY: usize = 192; /// as-shot look (`docs/camera-preview-colour.md`). The catalog's `Photo::relative_wb` matches the /// same formats by file extension. pub(crate) fn file_local_look(format: RawFormat) -> bool { - matches!(format, RawFormat::Arw | RawFormat::Nef | RawFormat::Nrw | RawFormat::Rw2) + matches!( + format, + RawFormat::Arw | RawFormat::Nef | RawFormat::Nrw | RawFormat::Rw2 | RawFormat::Raf | RawFormat::Cr3 | RawFormat::Cr2 | RawFormat::Pef + ) } pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> Option { @@ -40,13 +43,13 @@ pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransf } let (sensor, reference) = proxies(raw, bytes, transform, PROXY)?; // A camera profile pooled from many photos knows colours this photo shows too little of; - // only the tone and chroma curves are fitted per photo (DRO and picture styles vary). + // try its colour first and fit tone/chroma per photo (DRO and picture styles vary). let profile = raw.metadata.model.as_deref().and_then(crate::camera_profiles::get); let colour = profile.as_ref().and_then(|p| Some((p.matrix().mul(&transform.matrix.inverse()?), p.hue_sat.clone()))); let look = fit_pairs_with(&sensor, &reference, colour)?; if lightcraft_pipeline::profiling() { eprintln!( - "[profile] {:?} camera look: {:?}, {:?}, hue/sat table {}, camera profile {}", + "[profile] {:?} camera look: {:?}, {:?}, hue/sat table {}, camera profile available {}", raw.format, look.matrix.0, look.tone, @@ -60,9 +63,8 @@ pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransf /// Same-size proxies of the sensor (white-balanced, baseline exposure, through `transform`'s /// matrix: the generic camera ≈ sRGB model) and of the file's embedded camera JPEG (linear Rec.2020). fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usize) -> Option<(Rgb32f, Rgb32f)> { - let jpeg = lightcraft_raw::embedded_preview(bytes)?; let edge = (2 * size).max(384) as u32; - let decoded = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: 64_000_000 }).ok()?; + let decoded = crate::files::decode_raw_preview(bytes, lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: 64_000_000 })?; let mut reference = decoded.to_working(); let (a, crop) = (raw.active_area, raw.crop.clipped(raw.active_area.width, raw.active_area.height)); if crop.width == 0 || crop.height == 0 || reference.width == 0 || reference.height == 0 { @@ -84,14 +86,161 @@ fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usiz } // Fixed, bounded proxy: the selected look cannot depend on thumbnail/export resolution. let k = (crop.width.max(crop.height).div_ceil(edge as usize).max(2)).div_ceil(2) * 2; - let sensor = sensor_proxy(raw, k, edge as usize)?; + let mut sensor = sensor_proxy(raw, k, edge as usize)?; + let gain = 2f32.powf(transform.baseline_exposure as f32); + let to_working = |p: [f32; 3]| transform.matrix.apply_f32(std::array::from_fn(|i| p[i] * transform.wb[i] * gain)); + if raw.format == RawFormat::Cr3 { + // Orient the camera JPEG and the sensor identically before collecting pixel correspondences. + sensor = sensor.into_oriented(raw.orientation); + reference = reference.into_oriented(raw.orientation); + sensor.map_in_place(to_working); + // Canon's JPEG can be cropped differently from the sensor. Estimate only that common + // framing from edge directions, independently of the subsequent colour fit. + sensor = align_cr3_framing(sensor, &reference); + } let mut sensor = fit(&sensor, size, size, Filter::Box); let reference = fit(&reference, sensor.width, sensor.height, Filter::Box); - let gain = 2f32.powf(transform.baseline_exposure as f32); - sensor.map_in_place(|p| transform.matrix.apply_f32(std::array::from_fn(|i| p[i] * transform.wb[i] * gain))); + if raw.format != RawFormat::Cr3 { + sensor.map_in_place(to_working); + } Some((sensor, reference)) } +#[derive(Clone, Copy, Debug)] +struct Cr3Framing { + scale: f32, + /// Translation as a fraction of the oriented frame's width and height. + offset: [f32; 2], +} + +impl Cr3Framing { + const IDENTITY: Self = Self { scale: 1.0, offset: [0.0; 2] }; + + fn source(self, x: f32, y: f32, w: usize, h: usize) -> (f32, f32) { + let (w, h) = (w as f32, h as f32); + ((x - w * 0.5) * self.scale + w * (0.5 + self.offset[0]), (y - h * 0.5) * self.scale + h * (0.5 + self.offset[1])) + } +} + +/// Register the JPEG's remaining framing on a fixed proxy. Edge directions survive different +/// white balance and monotone camera tone; no target RGB values train this three-parameter fit. +/// A separate third of the edges must confirm a substantial improvement and a close match. +/// A weak, flat or unrelated preview leaves the sensor framing unchanged. +fn estimate_cr3_framing(sensor: &Rgb32f, reference: &Rgb32f) -> Option { + if sensor.width.checked_mul(sensor.height)? != sensor.data.len() || reference.width.checked_mul(reference.height)? != reference.data.len() { + return None; + } + let sensor = fit(sensor, PROXY, PROXY, Filter::Box); + let (w, h) = (sensor.width, sensor.height); + if w < 16 || h < 16 { + return None; + } + let reference = fit(reference, w, h, Filter::Box); + if (reference.width, reference.height) != (w, h) { + return None; + } + let mut gradients = Rgb32f::new(w, h); + let mut edges = Vec::new(); + for y in 1..h - 1 { + for x in 1..w - 1 { + let derivative = |image: &Rgb32f| { + [ + luminance_2020(image.data[y * w + x + 1]) - luminance_2020(image.data[y * w + x - 1]), + luminance_2020(image.data[(y + 1) * w + x]) - luminance_2020(image.data[(y - 1) * w + x]), + ] + }; + let d = derivative(&sensor); + gradients.data[y * w + x] = [d[0], d[1], 0.0]; + let d = derivative(&reference); + let magnitude = d[0].hypot(d[1]); + let value = luminance_2020(reference.data[y * w + x]); + if (0.02..0.9).contains(&value) && magnitude.is_finite() && magnitude > 0.03 { + edges.push((x as f32 + 0.5, y as f32 + 0.5, d, magnitude, (x * 17 + y * 11) % 3 == 0)); + } + } + } + if edges.len() < 192 { + return None; + } + let score = |framing: Cr3Framing, held_out: bool| -> Option { + let (mut agreement, mut weight, mut valid, mut total) = (0.0, 0.0, 0usize, 0usize); + for &(x, y, target, magnitude, holdout) in &edges { + if holdout != held_out { + continue; + } + total += 1; + let (sx, sy) = framing.source(x, y, w, h); + if sx < 1.5 || sy < 1.5 || sx > w as f32 - 1.5 || sy > h as f32 - 1.5 { + continue; + } + let sample = gradients.sample_bilinear(sx, sy); + let length = sample[0].hypot(sample[1]); + if !length.is_finite() || length <= 0.01 { + continue; + } + let k = f64::from(magnitude.min(0.25)); + agreement += k * f64::from((sample[0] * target[0] + sample[1] * target[1]) / (length * magnitude)); + weight += k; + valid += 1; + } + (valid >= 64 && valid * 5 >= total * 4 && weight > 0.0).then_some(agreement / weight) + }; + let before = score(Cr3Framing::IDENTITY, false)?; + let mut best = (before, Cr3Framing::IDENTITY); + // At most 10% zoom and two proxy pixels of translation; a coarse search followed by one + // local refinement prevents unbounded optimisation on the photograph's contents. + for s in -10..=10 { + for tx in -4..=4 { + for ty in -4..=4 { + let framing = Cr3Framing { scale: 1.0 + s as f32 * 0.01, offset: [tx as f32 * 0.5 / w as f32, ty as f32 * 0.5 / h as f32] }; + if let Some(value) = score(framing, false) + && value > best.0 + { + best = (value, framing); + } + } + } + } + let coarse = best.1; + for s in -5..=5 { + for tx in -4..=4 { + for ty in -4..=4 { + let framing = Cr3Framing { + scale: coarse.scale + s as f32 * 0.002, + offset: [coarse.offset[0] + tx as f32 * 0.125 / w as f32, coarse.offset[1] + ty as f32 * 0.125 / h as f32], + }; + if !(0.9..=1.1).contains(&framing.scale) || framing.offset[0].abs() > 2.0 / w as f32 || framing.offset[1].abs() > 2.0 / h as f32 { + continue; + } + if let Some(value) = score(framing, false) + && value > best.0 + { + best = (value, framing); + } + } + } + } + let (held_before, held_after) = (score(Cr3Framing::IDENTITY, true)?, score(best.1, true)?); + if best.0 < 0.9 || best.0 < before + 0.04 || held_after < 0.9 || held_after < held_before + 0.04 { + return None; + } + if lightcraft_pipeline::profiling() { + eprintln!("[profile] CR3 JPEG framing {:?}, held-out edge agreement {held_before:.4} -> {held_after:.4}", best.1); + } + Some(best.1) +} + +fn align_cr3_framing(sensor: Rgb32f, reference: &Rgb32f) -> Rgb32f { + let Some(framing) = estimate_cr3_framing(&sensor, reference) else { return sensor }; + let (w, h) = (sensor.width, sensor.height); + let mut aligned = Rgb32f::new(w, h); + for (i, pixel) in aligned.data.iter_mut().enumerate() { + let (x, y) = framing.source((i % w) as f32 + 0.5, (i / w) as f32 + 0.5, w, h); + *pixel = if x >= 0.5 && y >= 0.5 && x <= w as f32 - 0.5 && y <= h as f32 - 0.5 { sensor.sample_bilinear(x, y) } else { [f32::NAN; 3] }; + } + aligned +} + /// 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. @@ -102,7 +251,7 @@ pub(crate) fn profile_pairs(raw: &RawImage, bytes: &[u8]) -> Option Option { fit_pairs_with(sensor, reference, None) } +/// Largest luminance ratio within the 3×3 neighbourhood of pixel `i` (1 on flat areas, large on edges). +fn local_contrast(image: &Rgb32f, i: usize) -> f32 { + let (w, h) = (image.width, image.height); + if w == 0 { + return f32::INFINITY; + } + let (x, y) = (i % w, i / w); + let (mut lo, mut hi) = (f32::INFINITY, 0f32); + for row in y.saturating_sub(1)..(y + 2).min(h) { + for col in x.saturating_sub(1)..(x + 2).min(w) { + let l = image.data.get(row * w + col).map_or(0.0, |p| luminance_2020(*p)); + lo = lo.min(l); + hi = hi.max(l); + } + } + if hi.is_finite() { hi / lo.max(1e-4) } else { f32::INFINITY } +} + /// Training pairs (sensor → JPEG, unclipped midtones) and the wider set including highlights -/// (for the chroma curve); `None` when too few, or the photo has too little colour. +/// (for the chroma curve); `None` when too few, or the photo has too little colour. With +/// `edge_limit`, pixels whose 3×3 neighbourhood in either proxy spans a larger luminance ratio +/// 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) -> Option<(Pairs, Pairs)> { +fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limit: Option) -> Option<(Pairs, Pairs)> { if (sensor.width, sensor.height) != (reference.width, reference.height) || sensor.data.len() != reference.data.len() { return None; } @@ -161,7 +331,10 @@ fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f) -> Option<(Pairs, Pairs)> // Highlights too (camera JPEGs bleach colours toward white there), for the chroma curve only. let mut bright = Vec::new(); let mut colour = 0; - for (input, output) in sensor.data.iter().zip(&reference.data) { + for (i, (input, output)) in sensor.data.iter().zip(&reference.data).enumerate() { + if edge_limit.is_some_and(|limit| local_contrast(sensor, i) > limit || local_contrast(reference, i) > limit) { + continue; + } let y = luminance_2020(*output); if !input.iter().all(|v| v.is_finite() && *v > 0.001 && *v < 1.5) || !output.iter().all(|v| v.is_finite() && *v >= 0.0) { continue; @@ -174,7 +347,7 @@ fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f) -> Option<(Pairs, Pairs)> } let min = output.iter().copied().fold(f32::INFINITY, f32::min); let max = output.iter().copied().fold(0.0, f32::max); - colour += usize::from(max - min > 0.05); + colour += usize::from(max - min > min_chroma); pairs.push((input.map(f64::from), output.map(f64::from))); } (pairs.len() >= 256 && colour >= pairs.len() / 20).then_some((pairs, bright)) @@ -215,11 +388,36 @@ fn fit_matrix(pairs: &[([f64; 3], [f64; 3])]) -> Option { matrix.0.iter().flatten().all(|v| v.is_finite() && v.abs() < 8.0).then_some(matrix) } +/// Neighbourhood luminance ratio above which a pixel counts as an edge for the second attempt +/// of [`fit_pairs_with`]. Camera JPEGs are often lens-corrected (Sony "Distortion Comp.: Auto", +/// compacts and kit zooms): on a 51 mm ILCE-7RM2 shot of a glass façade the JPEG is up to 15 px +/// of 1440 (about one proxy pixel) off the raw, and the fit failed the gate on the mismatched +/// window frames alone (held-out RMS 0.111; 0.059 for the same shot with distortion correction +/// off). Away from edges: 0.049. Issue #232. +const EDGE_CONTRAST: f32 = 3.0; + /// The photo's look: its own matrix (with and without a hue/saturation table), or the given /// colour model (a camera profile's, in the sensor proxy's space), each completed with a tone -/// and chroma curve fitted to this photo. +/// and chroma curve fitted to this photo. When the fit on all pixels fails the acceptance gates, +/// it is tried once more on the pixels away from edges (same gates): there colour pairs stay +/// valid when the camera JPEG's geometry differs slightly from the raw's. fn fit_pairs_with(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option)>) -> Option { - let (pairs, bright) = collect_pairs(sensor, reference)?; + let has_profile = colour.is_some(); + // A different picture style can make a camera profile fail the gates. Preserve the + // photo's own colour fit before resorting to the neutral fallback. + fit_candidate(sensor, reference, colour).or_else(|| has_profile.then(|| fit_candidate(sensor, reference, None)).flatten()) +} + +/// One candidate colour model, fitted on all pixels and, when that fails the gates, once more on +/// the pixels away from edges (issue #232). +fn fit_candidate(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option)>) -> Option { + fit_pairs_on(sensor, reference, colour.clone(), None).or_else(|| fit_pairs_on(sensor, reference, colour, Some(EDGE_CONTRAST))) +} + +fn fit_pairs_on(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option)>, edge_limit: Option) -> Option { + // A known colour model needs enough signal for tone fitting, not a scene rich enough to + // learn a new colour matrix. Still reject monochrome references. + let (pairs, bright) = collect_pairs(sensor, reference, if colour.is_some() { 0.005 } else { 0.05 }, edge_limit)?; let candidates = match colour { Some(given) => vec![given], None => { @@ -270,9 +468,10 @@ fn fit_pairs_with(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Opt .sum(); if lightcraft_pipeline::profiling() { eprintln!( - "[profile] camera look holdout RMS {:.5} -> {:.5} ({samples} channels)", + "[profile] camera look holdout RMS {:.5} -> {:.5} ({samples} channels{})", (before / samples as f64).sqrt(), - (after / samples as f64).sqrt() + (after / samples as f64).sqrt(), + if edge_limit.is_some() { ", away from edges" } else { "" } ); } if samples == 0 || after >= before * MIN_IMPROVEMENT || after / samples as f64 > MAX_HOLDOUT_RMS.powi(2) { @@ -740,7 +939,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).unwrap().0); + pool.extend(collect_pairs(&sensor, &reference, 0.05, None).unwrap().0); } let (matrix, _) = fit_profile(&pool).unwrap(); // the camera's colours (luminance-normalised: the tone curve sets brightness) @@ -767,6 +966,48 @@ mod tests { assert!(error.sqrt() < 0.02, "this photo's own tone is followed: RMS {}", error.sqrt()); } + #[test] + fn profile_fits_tone_without_learning_colour_from_a_dull_scene() { + let matrix = Mat3([[2.0, -0.02, 0.0], [0.0, 2.0, 0.0], [0.0, -0.03, 2.0]]); + let mut sensor = Rgb32f::new(64, 48); + let mut reference = sensor.clone(); + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + *src = [0.02 + (i % 997) as f32 * 0.00045; 3]; + let p = matrix.apply_f32(*src); + let y = luminance_2020(p); + *dst = p.map(|v| v * (1.0 - (-2.5 * y).exp()) / y); + } + assert!(fit_pairs(&sensor, &reference).is_none(), "not enough colour to learn a matrix"); + let look = fit_pairs_with(&sensor, &reference, Some((matrix, None))).unwrap(); + assert_eq!(look.matrix, matrix); + reference.map_in_place(|p| [luminance_2020(p); 3]); + assert!(fit_pairs_with(&sensor, &reference, Some((matrix, None))).is_none(), "monochrome JPEGs remain rejected"); + } + + #[test] + fn rejected_profile_preserves_a_usable_per_photo_fit() { + let matrix = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); + let wrong = Mat3([[0.0, 0.0, 1.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]]); + let mut sensor = Rgb32f::new(64, 64); + let mut reference = sensor.clone(); + let mut seed = 0x2545_f491_u32; + for (src, dst) in sensor.data.iter_mut().zip(&mut reference.data) { + *src = std::array::from_fn(|_| { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + 0.05 + (seed % 1000) as f32 * 0.0003 + }); + let p = matrix.apply_f32(*src); + let y = luminance_2020(p); + *dst = p.map(|v| (v * (1.0 - (-2.5 * y).exp()) / y).clamp(0.0, 0.999)); + } + let per_photo = fit_pairs(&sensor, &reference).unwrap(); + let look = fit_pairs_with(&sensor, &reference, Some((wrong, None))).unwrap(); + assert_eq!(look.matrix, per_photo.matrix, "a rejected camera profile must not suppress the per-photo fit"); + assert_eq!(look.tone, per_photo.tone); + } + #[test] fn hue_sat_passes_black_and_non_finite_pixels_through() { let table = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false }; @@ -778,9 +1019,73 @@ mod tests { } #[test] - fn sony_nikon_and_panasonic_raws_get_a_file_local_look() { - assert!([RawFormat::Arw, RawFormat::Nef, RawFormat::Nrw, RawFormat::Rw2].into_iter().all(file_local_look)); - assert!(![RawFormat::Dng, RawFormat::Cr2, RawFormat::Raf].into_iter().any(file_local_look)); + fn supported_raws_get_a_file_local_look() { + // every decoded raw without a colour matrix of its own (issue #310: CR2 rendered flat with the fallback) + let local = [RawFormat::Arw, RawFormat::Nef, RawFormat::Nrw, RawFormat::Rw2, RawFormat::Raf, RawFormat::Cr3, RawFormat::Cr2, RawFormat::Pef]; + assert!(local.into_iter().all(file_local_look)); + // DNG carries its own colour model + assert!(!file_local_look(RawFormat::Dng)); + } + + #[test] + fn cr3_camera_framing_is_measured_independently_of_tone_and_rejects_unrelated_edges() { + let (w, h) = (192, 128); + let mut sensor = Rgb32f::new(w, h); + for (i, pixel) in sensor.data.iter_mut().enumerate() { + let (x, y) = ((i % w) as f32, (i / w) as f32); + let value = 0.35 + 0.13 * (x * 0.15 + y * 0.22).sin() + 0.1 * (x * 0.33 - y * 0.14).sin() + 0.07 * (x * 0.09 - y * 0.31).cos(); + *pixel = [value; 3]; + } + let expected = Cr3Framing { scale: 0.96, offset: [0.01, -0.006] }; + let mut reference = Rgb32f::new(w, h); + for (i, pixel) in reference.data.iter_mut().enumerate() { + let (x, y) = expected.source((i % w) as f32 + 0.5, (i / w) as f32 + 0.5, w, h); + *pixel = sensor.sample_bilinear(x, y).map(|v| v.powf(0.7)); + } + let measured = estimate_cr3_framing(&sensor, &reference).expect("coherent held-out edges locate camera framing despite nonlinear tone"); + assert!((measured.scale - expected.scale).abs() < 0.006, "{measured:?}"); + assert!((measured.offset[0] - expected.offset[0]).abs() < 0.003, "{measured:?}"); + assert!((measured.offset[1] - expected.offset[1]).abs() < 0.003, "{measured:?}"); + assert!(estimate_cr3_framing(&sensor, &sensor).is_none(), "already aligned pixels stay unchanged"); + reference.data.fill([0.2; 3]); + assert!(estimate_cr3_framing(&sensor, &reference).is_none(), "flat previews cannot establish framing"); + for (i, pixel) in reference.data.iter_mut().enumerate() { + let (x, y) = ((i % w) as f32, (i / w) as f32); + *pixel = [0.35 + 0.13 * (x * 0.41 - y * 0.07).sin() + 0.1 * (x * 0.04 + y * 0.39).cos(); 3]; + } + assert!(estimate_cr3_framing(&sensor, &reference).is_none(), "unrelated edges cannot change the framing"); + } + + /// The guarded fit is assessed on real sensor data, never on the JPEG fallback. A corpus + /// download or an unfinished codec cannot make this test claim a successful RAW calibration. + #[test] + fn corpus_cr3_gets_a_framing_aligned_camera_look() { + 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/cr3-canon-r100-raw.cr3"); + let Ok(bytes) = std::fs::read(&path) else { + eprintln!("skip: {} 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}"); + return; + } + Err(error) => panic!("CR3 corpus failed to decode: {error}"), + }; + 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); + 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(); + assert!(info.raw && info.relative_wb); + assert_eq!((info.as_shot_temp, info.as_shot_tint), (6500.0, 0.0)); + assert_eq!(info.camera_tone.is_some(), look.is_some()); } /// A public D7500 NEF (skipped without the corpus): its look is fitted to its own JPEG and white @@ -818,6 +1123,90 @@ mod tests { assert!(info.relative_wb && info.as_shot_temp == 6500.0 && info.as_shot_tint == 0.0); } + /// Issue #232 (skipped without the corpus): the public ILCE-7RM2 sample's camera JPEG is + /// distortion-corrected, and on its glass façade the fit on all pixels misses the gate + /// (held-out RMS 0.113): it used to open grey with the neutral fallback. + #[test] + fn corpus_arw_with_a_lens_corrected_preview_gets_a_camera_look() { + 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-a7rm2-12bit-uncompressed.arw"); + let Ok(bytes) = std::fs::read(&path) else { + eprintln!("skip: {} absent", path.display()); + return; + }; + let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); + assert!(info.camera_tone.is_some(), "no camera look fitted"); + } + + /// Issue #232: an ILCE-7RM2 camera JPEG is lens-corrected ("Distortion Comp.: Auto"), so on a + /// façade of window frames the raw and its JPEG are a pixel or two apart. The colours agree + /// everywhere away from the edges, and the look must still be found there. + #[test] + fn fits_a_slightly_misaligned_camera_jpeg_away_from_edges() { + let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); + let (w, h, block, shift) = (96, 64, 8, 2); + // Patches of 8×8 pixels, alternately dark and bright (frames and panes), varied in colour. + let scene = |x: usize, y: usize| -> [f32; 3] { + let (bx, by) = (x / block, y / block); + let ev = if (bx + by) % 2 == 0 { 0.04 + (bx % 3) as f32 * 0.01 } else { 0.3 + (by % 4) as f32 * 0.08 }; + [ev * (0.7 + ((bx * 7 + by * 3) % 11) as f32 * 0.05), ev, ev * (0.7 + ((bx * 5 + by) % 13) as f32 * 0.04)] + }; + let camera = |p: [f32; 3]| { + let q = known.apply_f32(p); + let y = luminance_2020(q); + q.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, y); + // The JPEG shows the scene `shift` pixels to the right of where the raw has it. + reference.data[y * w + x] = camera(scene(x.saturating_sub(shift), y)); + } + } + let fit = fit_pairs(&sensor, &reference).expect("look fitted away from the misaligned edges"); + // The look itself is right: on the aligned scene it reproduces the camera. + let tone = ToneMap::camera(&fit.tone, 0.0, 0.0, 0.0); + let (mut error, mut n) = (0.0, 0); + for y in 0..h { + for x in 0..w { + let p = displayed(fit.matrix.apply(scene(x, y).map(f64::from)), &tone); + let t = camera(scene(x, y)); + error += (0..3).map(|c| (p[c] - f64::from(t[c])).powi(2)).sum::(); + n += 3; + } + } + assert!((error / n as f64).sqrt() < 0.03, "{}", (error / n as f64).sqrt()); + // Unrelated colours stay rejected away from edges too (covered below); a flat JPEG as well. + reference.data.fill([0.2; 3]); + assert!(fit_pairs(&sensor, &reference).is_none()); + } + + /// X-Trans and 16-bit Bayer RAFs use the same fixed fit at thumbnail and export sizes. + /// The X-T20 fails the gates on all pixels (held-out RMS 0.114 against 0.10) and passes away + /// from edges (0.056, issue #232), so it is fitted too. Skips cleanly without these CC0 + /// corpus files. + #[test] + fn corpus_raf_colour_and_white_balance() { + let dir = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw"); + for (name, accepted) in [("raf-fuji-xt2-865.raf", true), ("raf-fuji-gfx100s-4503.raf", true), ("raf-fuji-xt20-compressed.raf", true)] { + let Ok(bytes) = std::fs::read(dir.join(name)) else { continue }; + let header = crate::files::probe_bytes(name, &bytes).unwrap(); + assert_eq!(header.as_shot_wb, Some((6500.0, 0.0)), "{name}"); + let (_, small) = crate::files::load_bytes(&bytes, 400).unwrap(); + let (_, large) = crate::files::load_bytes(&bytes, 1200).unwrap(); + assert_eq!(small.camera_tone.is_some(), accepted, "{name}"); + assert_eq!(small.camera_tone, large.camera_tone, "{name}: resolution changed the fit"); + assert!(small.raw && small.relative_wb && small.as_shot_temp == 6500.0 && small.as_shot_tint == 0.0, "{name}"); + } + } + #[test] fn rejects_monochrome_invalid_and_unrelated_previews() { let mut sensor = Rgb32f::new(32, 32); diff --git a/crates/engine/src/camera_profiles.rs b/crates/engine/src/camera_profiles.rs index 29efa4b88..97fb8ef2f 100644 --- a/crates/engine/src/camera_profiles.rs +++ b/crates/engine/src/camera_profiles.rs @@ -1,6 +1,6 @@ //! Camera colour profiles of our own, pooled from many photos of one camera model. //! -//! A raw file without colour matrices (Sony ARW, Nikon NEF) gets its look fitted to its own embedded camera +//! A raw file without colour matrices (Sony ARW, Nikon NEF, Fujifilm RAF) gets its look fitted to its own embedded camera //! JPEG (`camera_preview`), but one photo shows too little of some colours: a lime shirt covering a //! few dozen proxy pixels next to a hillside of foliage at the same hue. `lightcraft-cli calibrate` //! pools the colour pairs of many photos per model and fits one matrix and hue/saturation/value @@ -24,7 +24,11 @@ const MAX_FILE: u64 = 4 << 20; /// Profiles built into LightCraft (`assets/camera-profiles/`, see `assets/ATTRIBUTION.md`): /// `(model, JSON)`. -pub const BUNDLED: &[(&str, &str)] = &[("ILCE-7M4", include_str!("../../../assets/camera-profiles/ILCE-7M4.json"))]; +pub const BUNDLED: &[(&str, &str)] = &[ + ("ILCE-7M4", include_str!("../../../assets/camera-profiles/ILCE-7M4.json")), + ("X-H2S", include_str!("../../../assets/camera-profiles/X-H2S.json")), + ("X-T4", include_str!("../../../assets/camera-profiles/X-T4.json")), +]; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct CameraProfile { @@ -196,10 +200,12 @@ pub struct Pool { const PAIRS_PER_FILE: usize = 4000; impl Pool { - /// Add one raw file's colour pairs. `Ok(None)` when the file can't contribute (not an ARW or - /// NEF without colour matrices, no usable camera JPEG, too little colour). + /// Add one raw file's colour pairs. `Ok(None)` when the file can't contribute (not an ARW, + /// NEF or RAF without colour matrices, no usable camera JPEG, too little colour). pub fn add(&mut self, bytes: &[u8]) -> Result, String> { - let raw = lightcraft_raw::decode(bytes).map_err(|e| e.to_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 + 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 step = pairs.len().div_ceil(PAIRS_PER_FILE).max(1); diff --git a/crates/engine/src/cmd/browse.rs b/crates/engine/src/cmd/browse.rs index a8a9b448e..e5074d9ed 100644 --- a/crates/engine/src/cmd/browse.rs +++ b/crates/engine/src/cmd/browse.rs @@ -251,6 +251,9 @@ pub(crate) fn rename_folder_on_disk(from: &str, to: &str) -> std::result::Result /// After a folder moved from `from` to `to`: a browsed folder at or below it follows. pub(crate) fn follow_folder(s: &mut Session, from: &str, to: &str) { + // a library folder chosen at or below it follows too, as a source or as a filter + follow_library_folder(&mut s.library_folder, from, to); + follow_library_folder(&mut s.filter.library_folder, from, to); if let Some(b) = &mut s.browse && let Ok(rel) = Path::new(&b.path).strip_prefix(from) { @@ -260,6 +263,17 @@ pub(crate) fn follow_folder(s: &mut Session, from: &str, to: &str) { } } +/// A chosen library folder at or below `from` becomes the same place below `to`. Compared the +/// way the filter does, by folder identity, so a differently spelled choice is not widened to +/// the folder itself. +fn follow_library_folder(chosen: &mut Option, from: &str, to: &str) { + let Some(c) = chosen.as_deref() else { return }; + if let Some(rest) = lightcraft_catalog::query::folder_rest(c, from) { + // joined by the platform, so the result is spelled like `to` and like a path read from disk + *chosen = Some(rest.iter().fold(std::path::PathBuf::from(to), |p, name| p.join(name)).to_string_lossy().to_string()); + } +} + /// Rename or move a folder on disk (everything in it goes along, sidecars included) and relink /// the photos inside, as one undo step: undo renames the folder back and restores the photos' /// paths, redo repeats the move — each refused (and reported) when the destination is taken. diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs index 8f197429c..e0aaaf957 100644 --- a/crates/engine/src/cmd/develop.rs +++ b/crates/engine/src/cmd/develop.rs @@ -2,7 +2,7 @@ use lightcraft_catalog::{Op, PhotoId, Version}; use lightcraft_develop::{DevelopSettings, Preset, Section, SettingsGroup, Treatment, Upright, WbMode, controls}; -use lightcraft_geom::{CropGeometry, Point, Rect, crop_fit_angle}; +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}; @@ -37,6 +37,32 @@ fn crop_dims(s: &Session, id: PhotoId) -> (f64, f64) { if o.swaps_axes() { (h, w) } else { (w, h) } } +/// `v` as a list of numbers; `None` unless it is an array of nothing but numbers. +fn numbers(v: Option<&Value>) -> Option> { + v?.as_array()?.iter().map(Value::as_f64).collect() +} + +/// A width × height lock stored as hundredths. Rejects non-finite, non-positive and +/// extreme (beyond 20:1) shapes, which would give an empty or unusable crop. +fn aspect_tuple(c: &str, x: f64, y: f64) -> Result<(u32, u32)> { + let ratio = x / y; + if !(x.is_finite() + && y.is_finite() + && x > 0.0 + && y > 0.0 + && ratio.is_finite() + && (1.0 / lightcraft_geom::MAX_RATIO..=lightcraft_geom::MAX_RATIO).contains(&ratio)) + { + return Err(bad(c, "aspect needs two positive numbers with a ratio between 1:20 and 20:1")); + } + let to = |v: f64| (v * 100.0).round(); + let (a, b) = (to(x), to(y)); + if a < 1.0 || b < 1.0 || a > f64::from(u32::MAX) || b > f64::from(u32::MAX) { + return Err(bad(c, "aspect numbers are out of range")); + } + Ok((a as u32, b as u32)) +} + fn set_aspect(d: &mut DevelopSettings, w: f64, h: f64, aspect: Option<(u32, u32)>) { d.crop.aspect = aspect; let a = aspect.map(|(aw, ah)| { @@ -395,6 +421,46 @@ pub fn specs() -> Vec { Ok(()) }) }), + cmd!( + "crop.drag", + "Drag Crop Handle", + [], + None, + "{handle: topLeft|topRight|bottomRight|bottomLeft|top|right|bottom|left|move | 0..8, from: [x,y], to: [x,y] (normalized, straightened frame), start?: [x0,y0,x1,y1] (default: the current crop)} — drags a crop handle; stops at the image edge, keeps the opposite edges, honours the aspect lock", + has_active, + |s, p| { + let id = active(s, "crop.drag")?; + let (w, h) = crop_dims(s, id); + let handle = match p.get("handle") { + Some(Value::String(n)) => CropHandle::from_name(n), + Some(v) => v.as_u64().and_then(|i| u8::try_from(i).ok()).and_then(CropHandle::from_index), + None => None, + } + .ok_or_else(|| bad("crop.drag", "unknown handle"))?; + let point = |k: &str| -> Result { + match numbers(p.get(k)).as_deref() { + Some([x, y]) if x.is_finite() && y.is_finite() => Ok(Point::new(*x, *y)), + _ => Err(bad("crop.drag", format!("`{k}` needs [x, y]"))), + } + }; + let (from, to) = (point("from")?, point("to")?); + let given = match (p.get("start"), numbers(p.get("start")).as_deref()) { + (None | Some(Value::Null), _) => None, + (_, Some(&[x0, y0, x1, y1])) if [x0, y0, x1, y1].iter().all(|v| v.is_finite()) => { + Some(Rect::new(x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1))) + } + _ => return Err(bad("crop.drag", "`start` needs 4 numbers")), + }; + edit(s, "crop.drag", "Crop", |d| { + let start = CropGeometry { rect: given.unwrap_or(d.crop.geometry.rect), angle: d.crop.geometry.angle }; + // A locked crop keeps the shape it has: the lock stores a width:height tuple whose + // orientation is reinterpreted per image, so the frame itself is the truth. + let ratio = d.crop.aspect.and(Some(start.rect_px(w, h).aspect())).filter(|a| a.is_finite() && *a > 0.0); + d.crop.geometry = drag_crop(start, handle, from, to, w, h, ratio); + Ok(()) + }) + } + ), cmd!( "crop.straighten", "Straighten", @@ -470,11 +536,12 @@ pub fn specs() -> Vec { let (x, y) = a.split_once('x').ok_or_else(|| bad("crop.aspect", "use WxH"))?; let (x, y): (f64, f64) = (x.parse().map_err(|_| bad("crop.aspect", "bad number"))?, y.parse().map_err(|_| bad("crop.aspect", "bad number"))?); - Some(((x * 100.0) as u32, (y * 100.0) as u32)) - } - Some(Value::Array(a)) if a.len() == 2 => { - Some(((a[0].as_f64().unwrap_or(1.0) * 100.0) as u32, (a[1].as_f64().unwrap_or(1.0) * 100.0) as u32)) + Some(aspect_tuple("crop.aspect", x, y)?) } + Some(v @ Value::Array(_)) => match numbers(Some(v)).as_deref() { + Some(&[x, y]) => Some(aspect_tuple("crop.aspect", x, y)?), + _ => return Err(bad("crop.aspect", "use [w, h] with two numbers")), + }, _ => return Err(bad("crop.aspect", "missing aspect")), }; edit(s, "crop.aspect", "Crop Aspect", |d| { diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 11a11f6c7..a8b25023b 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -23,7 +23,8 @@ impl Session { .into_iter() .find(|(x, _)| x.name.eq_ignore_ascii_case(name.trim())) .ok_or_else(|| format!("unknown export preset `{name}` (see export.presets)"))?; - let mut out = preset.params; + // a preset saved by another version may hold keys this one doesn't know: not the caller's typo + let mut out = crate::export::ExportOptions::known_keys_only(&preset.params); if let (Some(o), Some(own)) = (out.as_object_mut(), p.as_object()) { for (k, v) in own.iter().filter(|(k, _)| *k != "preset") { o.insert(k.clone(), v.clone()); @@ -54,6 +55,8 @@ fn save(s: &mut Session, p: &Value) -> Result { if let Some(o) = params.as_object_mut() { o.retain(|k, _| !NOT_SETTINGS.contains(&k.as_str())); } + // a preset that app.export would refuse is refused here, where the typo is on screen + crate::export::ExportOptions::validate(ID, ¶ms)?; let preset = ExportPreset { name: name.to_string(), params }; match s.export_presets.iter_mut().find(|x| x.name.eq_ignore_ascii_case(name)) { Some(x) => *x = preset, diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 57632ea82..436ad123b 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -1,7 +1,7 @@ //! Library commands: view source, filter/sort, selection, ratings/flags/labels, rotate, delete, //! metadata, albums, import. -use lightcraft_catalog::{Album, AlbumId, ColorLabel, CopyrightStatus, Flag, GroupBy, Op, PhotoId, Sort, SortKey}; +use lightcraft_catalog::{Album, AlbumId, ColorLabel, CopyrightStatus, Filter, Flag, GroupBy, Op, PhotoId, Sort, SortKey}; use serde_json::{Value, json}; use super::{CommandSpec, always, bad, bool_or, cmd, has_active, has_selection, ok, str_param}; @@ -66,7 +66,14 @@ pub fn import_params(s: &Session, p: &Value) -> Result { { return Err(bad("library.import", format!("unknown metadata preset `{n}`"))); } + let on_deleted = match str_param(p, "onDeleted") { + Some(v) => { + crate::import::OnDeleted::parse(v).ok_or_else(|| bad("library.import", format!("unknown onDeleted `{v}` (skip|restore|fresh)")))? + } + None => Default::default(), + }; let opts = crate::import::ImportOptions { + on_deleted, mode, preset, keywords: strs(p, "keywords"), @@ -87,7 +94,9 @@ pub fn import_params(s: &Session, p: &Value) -> Result { /// `album`). pub fn import_batch_done(s: &mut Session, report: crate::import::ImportReport, mut album: Option, album_name: Option<&str>) -> Result { let mut report = serde_json::to_value(report).unwrap_or_default(); - let imported: Vec = report["imported"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default(); + // photos an import restored from Recently Deleted take part like new ones (album, selection) + let imported: Vec = + ["imported", "restored"].iter().flat_map(|k| report[*k].as_array().into_iter().flatten().filter_map(Value::as_u64)).collect(); if album.is_none() && let Some(name) = album_name && !imported.is_empty() @@ -251,7 +260,7 @@ pub fn specs() -> Vec { "Show Source", [], None, - "{kind: all|recentlyAdded|album|recentlyDeleted|picks|missing, id?: albumId}", + "{kind: all|recentlyAdded|album|recentlyDeleted|picks|missing|libraryFolder, id?: albumId, path?: a path from library.folders (for libraryFolder)}", always, |s, p| { let kind = str_param(p, "kind").unwrap_or("all"); @@ -268,6 +277,25 @@ pub fn specs() -> Vec { } LibrarySource::Album(a) } + "libraryFolder" => { + let path = str_param(p, "path").filter(|d| !d.trim().is_empty() && !lightcraft_catalog::query::folder_key(d).is_empty()); + let path = path.ok_or_else(|| bad("library.source", "libraryFolder needs the `path` of a folder from library.folders"))?; + if lightcraft_catalog::folders::is_startup_disk(path) { + return Err(bad("library.source", "the startup disk's path covers every disk: choose a folder in it")); + } + let f = Filter { library_folder: Some(path.to_string()), ..Default::default() }; + let ids = s.catalog.query(&f, &Sort::default()); + if ids.is_empty() { + return Err(bad("library.source", format!("{path}: no photo in the library was imported from it"))); + } + if covers_other_disks(s, path, &ids) { + return Err(bad("library.source", format!("{path} holds other disks as well: choose a disk or a folder on one"))); + } + s.library_folder = Some(path.to_string()); + // one folder at a time: a folder filter left over would be ignored + s.filter.library_folder = None; + LibrarySource::LibraryFolder + } other => return Err(bad("library.source", format!("unknown source `{other}`"))), }; let vis = s.visible_cloned(); @@ -282,15 +310,75 @@ pub fn specs() -> Vec { "Filter", [], None, - "partial Filter: {text?, rating?, ratingOp?: atLeast|exactly|atMost, flag?: pick|reject|none|null, label?, kind?, merged?: hdr|panorama|hdrPanorama|any, edited?, date?, keyword?, person?, camera?}", + "partial Filter: {text?, rating?, ratingOp?: atLeast|exactly|atMost, flag?: pick|reject|none|null, label?, kind?, merged?: hdr|panorama|hdrPanorama|any, edited?, date?, libraryFolder?: a path from library.folders, keyword?, person?, camera?}", always, |s, p| { let mut v = serde_json::to_value(&s.filter).unwrap_or_default(); lightcraft_develop::presets::deep_merge(&mut v, p); - s.filter = serde_json::from_value(v).map_err(|e| bad("library.filter", e.to_string()))?; + let f: Filter = serde_json::from_value(v).map_err(|e| bad("library.filter", e.to_string()))?; + let mut f = f; + // a blank folder is no folder: no hidden "filters active" state + f.library_folder = f.library_folder.filter(|d| !d.trim().is_empty()); + if f.library_folder.is_some() && s.source == LibrarySource::LibraryFolder { + return Err(bad("library.filter", "a folder is already shown (library.source): show another source first")); + } + s.filter = f; Ok(json!({"count": s.visible().len()})) } ), + cmd!( + query "library.folders", + "Library Folders", + [], + None, + "{} → [{name, path, count, own, volume, selectable, children}] the disks and the folders the library's photos were imported from, with photo counts (subfolders included in `count`); show one with library.source {kind: libraryFolder, path} (rows with selectable false only open)", + always, + |s, _| Ok(serde_json::to_value(s.catalog.folder_tree()).unwrap_or_default()) + ), + cmd!( + "library.removeFolder", + "Remove Folder from Library", + [], + None, + "{path, disk?: bool} — move the library photos imported from this folder (and the folders inside it) to Recently Deleted; one undo step, no file is touched. A whole disk or share (`/Volumes/nas`, `C:\\`, `\\\\srv\\share`) goes only with `disk: true`, the startup disk never → {removed}", + always, + |s, p| { + const C: &str = "library.removeFolder"; + let path = str_param(p, "path").filter(|d| !d.trim().is_empty()).ok_or_else(|| bad(C, "missing `path`"))?; + if lightcraft_catalog::query::folder_key(path).is_empty() { + return Err(bad(C, format!("{path}: not a folder"))); + } + if lightcraft_catalog::folders::is_startup_disk(path) { + return Err(bad(C, "choose a folder, not the whole startup disk")); + } + if lightcraft_catalog::folders::is_disk_root(path) && !bool_or(p, "disk", false) { + return Err(bad(C, format!("{path} is a whole disk or share: pass `disk: true` to remove everything on it"))); + } + let f = Filter { library_folder: Some(path.to_string()), ..Default::default() }; + let ids = s.catalog.query(&f, &Sort::default()); + if ids.is_empty() { + return Err(bad(C, format!("{path}: no photo in the library was imported from it"))); + } + 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 })?; + if s.filter.library_folder.as_deref().is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)) { + s.filter.library_folder = None; + } + // the folder being shown is gone: back to everything, as for a deleted album + if s.library_folder.as_deref().is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)) { + s.library_folder = None; + if s.source == LibrarySource::LibraryFolder { + s.source = LibrarySource::All; + } + } + let vis = s.visible_cloned(); + s.selection = vis.first().map(|f| Selection::single(*f)).unwrap_or_default(); + Ok(json!({"removed": ids.len()})) + } + ), cmd!("library.clearFilter", "Clear Filters", ["View"], None, "{}", always, |s, _| { s.filter = Default::default(); Ok(json!({"count": s.visible().len()})) @@ -356,36 +444,50 @@ pub fn specs() -> Vec { } ), // ---- selection - cmd!("library.select", "Select Photos", [], None, "{ids: [id], active?: id, mode?: replace|add|toggle|range}", always, |s, p| { - let ids = ids_param(p).unwrap_or_default(); - let mode = str_param(p, "mode").unwrap_or("replace"); - s.end_interaction()?; - match mode { - "replace" => { - s.selection = - Selection { ids: ids.clone(), active: p.get("active").and_then(Value::as_u64).map(PhotoId).or(ids.first().copied()) }; + cmd!( + "library.select", + "Select Photos", + [], + None, + "{ids: [id], active?: id, mode?: replace|add|toggle|range} — every id must be a photo of the library", + always, + |s, p| { + let ids = ids_param(p).unwrap_or_default(); + let active = p.get("active").and_then(Value::as_u64).map(PhotoId); + let mode = str_param(p, "mode").unwrap_or("replace"); + // An id that is not in the library is an error, and the selection stays as it was: + // selecting it would otherwise report success and leave the session with no usable + // active photo (#182). + if let Some(id) = ids.iter().chain(active.as_ref()).find(|id| s.catalog.photo(**id).is_none()) { + return Err(bad("library.select", format!("no such photo {}", id.0))); } - "add" => { - for id in ids { - if !s.selection.contains(id) { - s.selection.ids.push(id); + s.end_interaction()?; + match mode { + "replace" => { + s.selection = Selection { ids: ids.clone(), active: active.or(ids.first().copied()) }; + } + "add" => { + for id in ids { + if !s.selection.contains(id) { + s.selection.ids.push(id); + } + s.selection.active = Some(id); } - s.selection.active = Some(id); } - } - "toggle" => ids.into_iter().for_each(|id| s.selection.toggle(id)), - "range" => { - let vis = s.visible_cloned(); - if let Some(id) = ids.first() { - s.selection.extend_to(*id, &vis); + "toggle" => ids.into_iter().for_each(|id| s.selection.toggle(id)), + "range" => { + let vis = s.visible_cloned(); + if let Some(id) = ids.first() { + s.selection.extend_to(*id, &vis); + } } + other => return Err(bad("library.select", format!("unknown mode `{other}`"))), } - other => return Err(bad("library.select", format!("unknown mode `{other}`"))), + s.active_mask = None; + s.active_spot = None; + Ok(json!({"selected": s.selection.ids.len()})) } - s.active_mask = None; - s.active_spot = None; - Ok(json!({"selected": s.selection.ids.len()})) - }), + ), cmd!("library.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{}", always, |s, _| { let vis = s.visible_cloned(); s.selection = Selection { active: s.selection.active.filter(|a| vis.contains(a)).or(vis.first().copied()), ids: vis }; @@ -560,10 +662,28 @@ pub fn specs() -> Vec { id, deleted: false }))), + cmd!( + "library.emptyRecentlyDeleted", + "Empty Recently Deleted", + ["Photo"], + None, + "{} → {deleted} — removes every photo in Recently Deleted from the library (files on disk stay)", + always, + |s, _| { + let ids: Vec = s.catalog.photos().filter(|p| p.deleted).map(|p| p.id).collect(); + if ids.is_empty() { + return Ok(json!({"deleted": 0})); + } + let op = s.catalog.delete_photos_permanently_ops(&ids); + s.commit("Empty Recently Deleted", op)?; + s.selection = Selection::default(); + Ok(json!({"deleted": ids.len()})) + } + ), cmd!("photo.deletePermanently", "Delete Permanently", ["Photo"], None, "{ids?}", has_selection, |s, p| { let t = s.targets(p); - let ops = t.iter().map(|id| s.catalog.delete_permanently_ops(*id)).collect::>(); - s.commit("Delete Permanently", Op::Batch { ops })?; + let op = s.catalog.delete_photos_permanently_ops(&t); + s.commit("Delete Permanently", op)?; s.selection = Selection::default(); Ok(json!({"deleted": t.len()})) }), @@ -725,8 +845,16 @@ pub fn specs() -> Vec { let rules = if bool_or(p, "fromView", false) { view_rules(s) } else { - let base = if bool_or(p, "replace", false) { Default::default() } else { cur }; - merge_rules(&base, p.get("rules").unwrap_or(&Value::Null), "album.setRules")? + let replace = bool_or(p, "replace", false); + let folder = cur.library_folder.clone(); + let base = if replace { Default::default() } else { cur }; + let mut r = merge_rules(&base, p.get("rules").unwrap_or(&Value::Null), "album.setRules")?; + // the rules dialog has no folder field: replacing its rules keeps the folder + // unless the call says (`libraryFolder: null`) to drop it + if replace && p.get("rules").and_then(|r| r.get("libraryFolder")).is_none() { + r.library_folder = folder; + } + r }; s.commit("Edit Smart Album", Op::SetAlbumRules { id, rules: Box::new(rules) })?; Ok(json!({"count": s.catalog.album_count(id)})) @@ -934,6 +1062,25 @@ pub fn specs() -> Vec { ok() }), // ---- import + cmd!(query "library.inspectLightroom", "Inspect Lightroom Catalog", [], None, + "{path: .lrcat} → {photos, collections, missing, warnings}; read-only, no Lightroom required", + 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::>(),"warnings":data.warnings})) + }), + cmd!( + "library.importLightroom", + "Import Lightroom Catalog", + [], + None, + "{path: .lrcat, updateExisting?: false}; direct import of paths, ratings, flags, metadata, keywords, collections, virtual copies & mapped edits. Archives source settings/history first. Existing edited photos are preserved unless updateExisting=true; source catalog is read-only.", + always, + |s, p| { + let path = str_param(p, "path").ok_or_else(|| bad("library.importLightroom", "missing path"))?; + crate::lightroom_catalog::import(s, std::path::Path::new(path), bool_or(p, "updateExisting", false)) + } + ), cmd!( query "library.importPreview", "Review Import", @@ -956,7 +1103,7 @@ pub fn specs() -> Vec { "Import Photos", ["File"], Some("Cmd+Shift+I"), - "{paths: [file or folder (recursive)], mode?: add|copy|move (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/; move = as copy, then each original and its XMP sidecars are removed from the source — only after the copy is verified (a hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate and unchecked files keep their sources; a taken name gets -1, -2…; undo removes the photos from the library but leaves the files at the destination), destination?: folder for copies / moves, organize?: date (YYYY/YYYY-MM-DD) | month (YYYY/YYYY-MM) | flat | a folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → 2026/20260114 (the template's `/` make the folders, each level expanded with the rename tokens and made a safe folder name: never outside the destination; must be relative, no `..`; a level with missing metadata is `unknown`) — dated by capture time, else the import time, rename?: file-name template for copies, original extension added (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each; blank = keep names), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG; copy only), local?: bool (browsing: the photos stay out of the library, like library.browse; not with move), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..]} → {imported, duplicates, failed, moved?: [{from, to, sidecars?}], kept?: [{path, reason}] (move: sources left in place and why), album?}", + "{paths: [file or folder (recursive)], mode?: add|copy|move (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/; move = as copy, then each original and its XMP sidecars are removed from the source — only after the copy is verified (a hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate and unchecked files keep their sources; a taken name gets -1, -2…; undo removes the photos from the library but leaves the files at the destination), destination?: folder for copies / moves, organize?: date (YYYY/YYYY-MM-DD) | month (YYYY/YYYY-MM) | flat | a folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → 2026/20260114 (the template's `/` make the folders, each level expanded with the rename tokens and made a safe folder name: never outside the destination; must be relative, no `..`; a level with missing metadata is `unknown`) — dated by capture time, else the import time, rename?: file-name template for copies, original extension added (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each; blank = keep names), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG; copy only), local?: bool (browsing: the photos stay out of the library, like library.browse; not with move), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..], onDeleted?: skip|restore|fresh (a file that is in Recently Deleted: skip = leave it there and say so, restore = bring the photo back with its edits, fresh = delete the trashed record and import the file as new)} → {imported, duplicates: [{path, existing, reason, existingDeleted?}], restored?: [photoId], failed, moved?: [{from, to, sidecars?}], kept?: [{path, reason}] (move: sources left in place and why), album?}", always, |s, p| { let req = import_params(s, p)?; @@ -967,9 +1114,12 @@ pub fn specs() -> Vec { let report = crate::import::import_with(s, &req.paths, &req.opts)?; let report = import_batch_done(s, report, req.album, req.album_name.as_deref())?; // one undo step for the whole import - let imported: Vec = report["imported"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default(); + let ids_of = |k: &str| -> Vec { report[k].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default() }; + let (imported, restored) = (ids_of("imported"), ids_of("restored")); let n = s.undo.len().saturating_sub(undo0); - s.merge_undo(n, &format!("Add {} Photo{}", imported.len(), if imported.len() == 1 { "" } else { "s" })); + let (verb, count) = if imported.is_empty() && !restored.is_empty() { ("Restore", restored.len()) } else { ("Add", imported.len()) }; + s.merge_undo(n, &format!("{verb} {count} Photo{}", if count == 1 { "" } else { "s" })); + let imported = if imported.is_empty() { restored } else { imported }; if let Some(f) = imported.first() { s.selection = Selection::single(PhotoId(*f)); } @@ -1051,6 +1201,16 @@ fn merge_rules(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Res Ok(f) } +/// Whether `path` lies above a disk: some of its `ids` photos are on a disk (not the startup +/// disk) that the folder does not lie in. Such a folder (`/Volumes`, `/mnt`…) is not a folder of +/// one disk but a way to reach several. +fn covers_other_disks(s: &Session, path: &str, ids: &[PhotoId]) -> bool { + ids.iter() + .filter_map(|id| s.catalog.photo(*id)) + .filter_map(|p| lightcraft_catalog::folders::volume_of(p)) + .any(|v| v != "/" && !lightcraft_catalog::query::folder_within(path, &v)) +} + /// The current view (source + filter) as smart-album rules. Viewing a smart album starts from /// its rules with the filter bar's settings on top. fn view_rules(s: &Session) -> lightcraft_catalog::Filter { @@ -1077,6 +1237,9 @@ fn view_rules(s: &Session) -> lightcraft_catalog::Filter { None => { let mut f = s.source.to_filter(&s.filter, &s.catalog); f.deleted = false; + if s.source == LibrarySource::LibraryFolder { + f.library_folder = s.library_folder.clone(); + } f } } diff --git a/crates/engine/src/cmd/lut_profiles.rs b/crates/engine/src/cmd/lut_profiles.rs index 5e3febd97..8eb9574e8 100644 --- a/crates/engine/src/cmd/lut_profiles.rs +++ b/crates/engine/src/cmd/lut_profiles.rs @@ -41,22 +41,11 @@ fn cube_files(paths: &[String]) -> Vec<(String, String, Option)> { for p in paths { let path = Path::new(p); if path.is_dir() { - let mut stack = vec![path.to_path_buf()]; - while let Some(d) = stack.pop() { - let Ok(rd) = std::fs::read_dir(&d) else { continue }; - for e in rd.flatten() { - let ep = e.path(); - if ep.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')) { - continue; - } - if ep.is_dir() { - stack.push(ep); - } else if ep.extension().is_some_and(|x| x.eq_ignore_ascii_case("cube")) - && let Ok(t) = std::fs::read_to_string(&ep) - { - let group = ep.parent().and_then(|d| d.file_name()).map(|n| n.to_string_lossy().to_string()); - out.push((ep.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(), t, group)); - } + let cube = |f: &Path| f.extension().is_some_and(|x| x.eq_ignore_ascii_case("cube")); + for ep in crate::walk::files_in(path, None, crate::walk::Limits::default(), cube).files { + if let Ok(t) = std::fs::read_to_string(&ep) { + let group = ep.parent().and_then(|d| d.file_name()).map(|n| n.to_string_lossy().to_string()); + out.push((ep.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(), t, group)); } } } else if path.extension().is_some_and(|x| x.eq_ignore_ascii_case("zip")) { diff --git a/crates/engine/src/cmd/query.rs b/crates/engine/src/cmd/query.rs index e7ffde143..824039e46 100644 --- a/crates/engine/src/cmd/query.rs +++ b/crates/engine/src/cmd/query.rs @@ -5,7 +5,7 @@ use lightcraft_develop::{CONTROLS, controls}; use serde_json::{Value, json}; use super::{CommandSpec, always, bad, cmd, has_active}; -use crate::Session; +use crate::{LibrarySource, Session}; pub fn photo_summary(p: &Photo) -> Value { json!({ @@ -48,8 +48,13 @@ fn album_json(a: &Album, all: &[Album], cat: &lightcraft_catalog::Catalog) -> Va v } +/// The photo a query is about: `id` when given (which must be in the library), else the active one. fn photo_arg(s: &Session, p: &Value, c: &str) -> crate::Result { - p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad(c, "no photo")) + match p.get("id").and_then(Value::as_u64).map(PhotoId) { + Some(id) if s.catalog.photo(id).is_some() => Ok(id), + Some(id) => Err(bad(c, format!("no such photo {}", id.0))), + None => s.active().ok_or_else(|| bad(c, "no photo")), + } } pub fn specs() -> Vec { @@ -81,10 +86,14 @@ pub fn specs() -> Vec { })) }), cmd!(query "library.state", "Library State", [], None, "{}", always, |s, _| { - let label = s.source.label(&s.catalog); + let label = match (s.source, s.library_folder.as_deref()) { + (LibrarySource::LibraryFolder, Some(path)) => lightcraft_catalog::folders::folder_label(path), + _ => s.source.label(&s.catalog), + }; let n = s.visible().len(); Ok(json!({ "source": s.source, + "libraryFolder": s.library_folder.as_ref().filter(|_| s.source == LibrarySource::LibraryFolder), "sourceLabel": label, "filter": s.filter, "sort": s.sort, diff --git a/crates/engine/src/crs.rs b/crates/engine/src/crs.rs index dce623d02..0b0c83b94 100644 --- a/crates/engine/src/crs.rs +++ b/crates/engine/src/crs.rs @@ -44,6 +44,17 @@ const NON_ADJUSTMENT: &[&str] = &[ "crs:AlreadyApplied", "crs:RawFileName", "crs:HasCrop", + // preset/library bookkeeping and tool options that don't change the rendering + "crs:Cluster", + "crs:SupportsAmount2", + "crs:RequiresRGBTables", + "crs:SortName", + "crs:Description", + "crs:CropConstrainToWarp", + "crs:OverrideLookVignette", + // the file's as-shot white, recorded next to the edit (ours comes from the file itself) + "crs:AsShotTemperature", + "crs:AsShotTint", ]; /// True if the packet carries any `crs:` adjustment (not just bookkeeping fields). @@ -233,11 +244,14 @@ pub fn to_partial(props: &Props, raw: Option) -> Value { put(o, "curve.master", c); } } - for (crs, ch) in - [("ToneCurvePV2012", "master"), ("ToneCurvePV2012Red", "red"), ("ToneCurvePV2012Green", "green"), ("ToneCurvePV2012Blue", "blue")] - { - if let Some(c) = curve(props, &format!("crs:{crs}")) { - put(o, &format!("curve.{ch}"), c); + // Lightroom Classic reads the red / green / blue curves only together with the master 2012 + // curve: a channel curve without `ToneCurvePV2012` leaves the photo unchanged + if let Some(c) = curve(props, "crs:ToneCurvePV2012") { + put(o, "curve.master", c); + for (crs, ch) in [("ToneCurvePV2012Red", "red"), ("ToneCurvePV2012Green", "green"), ("ToneCurvePV2012Blue", "blue")] { + if let Some(c) = curve(props, &format!("crs:{crs}")) { + put(o, &format!("curve.{ch}"), c); + } } } @@ -287,9 +301,12 @@ pub fn to_partial(props: &Props, raw: Option) -> Value { n(o, "GrainSize", "grain.size"); n(o, "GrainFrequency", "grain.roughness"); - // ---- Optics (manual corrections; lens profiles are ours, only the switch carries over) - if let Some(b) = boolean(props, "crs:LensProfileEnable") { - put(o, "optics.lens_profile", json!(b)); + // ---- Optics (manual corrections; lens profiles are ours, only the switch carries over). + // Our profile corrections are the file's own (DNG-embedded) ones, which Lightroom applies + // whatever its "Enable Profile Corrections" box says [inferred]: so only an enabling switch + // carries over, and `LensProfileEnable=0` (the default in most presets) leaves ours as it is. + if boolean(props, "crs:LensProfileEnable") == Some(true) { + put(o, "optics.lens_profile", json!(true)); } if let Some(b) = boolean(props, "crs:AutoLateralCA") { put(o, "optics.remove_ca", json!(b)); @@ -373,8 +390,19 @@ pub fn to_partial_report(props: &Props, values: Option<&crate::crs_masks::Values mask_skips = skipped.into_iter().map(|k| format!("Mask: {k}")).collect(); } } - // fields that only switch a panel on/off or name things: not adjustments by themselves - let quiet = |k: &str| k.starts_with("Enable") || k.starts_with("ToneCurveName") || k == "AutoTone" || k == "AutoGrayscaleMix"; + // fields that only switch a panel on/off or name things: not adjustments by themselves; an + // HDR edit mode that is off, and Point Color slots that are all empty (-1) + let value_is = |k: &str, f: &dyn Fn(&[String]) -> bool| props.get(&format!("crs:{k}")).is_some_and(|v| f(v)); + let off = |v: &[String]| v.iter().all(|s| s.trim() == "0"); + let empty_points = |v: &[String]| v.iter().all(|s| s.split(',').all(|n| n.trim().parse::().is_ok_and(|x| x == -1.0) || n.trim().is_empty())); + let quiet = |k: &str| { + k.starts_with("Enable") + || k.starts_with("ToneCurveName") + || k == "AutoTone" + || k == "AutoGrayscaleMix" + || (k == "HDREditMode" && value_is(k, &off)) + || (k == "PointColors" && value_is(k, &empty_points)) + }; let mut unmapped: Vec = props .keys() .filter(|k| k.starts_with("crs:")) @@ -539,4 +567,63 @@ mod tests { assert!(!p.builtin); assert!(preset_from_xmp("", "f").is_none()); } + + /// A preset packet (written for this test) with the bookkeeping fields Lightroom presets carry. + fn preset_packet(extra_attrs: &str, extra_elems: &str) -> String { + format!( + r#" + + + + {extra_elems} + "# + ) + } + + #[test] + fn channel_curves_need_the_master_curve() { + // Lightroom ignores a red / green / blue curve without `ToneCurvePV2012` + let packet = |master: &str| { + format!( + r#" + {master} + 0, 064, 40255, 255 + "# + ) + }; + let alone = to_partial(&props(&packet("")), Some(true)); + assert!(alone.pointer("/curve/green").is_none(), "{alone}"); + let master = "0, 0255, 255"; + let with = to_partial(&props(&packet(master)), Some(true)); + assert!(with.pointer("/curve/green").is_some() && with.pointer("/curve/master").is_some(), "{with}"); + } + + fn unmapped(x: &str) -> Vec { + to_partial_report(&props(x), None, None, 1.5).1 + } + + #[test] + fn preset_bookkeeping_is_not_reported_but_real_gaps_are() { + let empty_points = "-1.000000, -1.000000, -1.000000, -1.000000"; + assert_eq!(unmapped(&preset_packet(r#"crs:HDREditMode="0""#, empty_points)), Vec::::new()); + // an HDR edit, a used Point Color slot and an unknown adjustment still are + let used_points = "0.5, 0.2, 0.1, 10, 0, 0, 0, 0"; + let got = unmapped(&preset_packet(r#"crs:HDREditMode="1" crs:FutureSlider="12""#, used_points)); + assert_eq!(got, ["FutureSlider", "HDREditMode", "PointColors"]); + } + + #[test] + fn disabled_lens_profile_switch_keeps_embedded_corrections() { + // Lightroom applies a file's built-in lens corrections regardless of its profile switch, + // and ours are only those: a preset's `LensProfileEnable=0` must not turn them off + let p = props(&preset_packet("", "")); + let mut d = DevelopSettings::default(); + d.optics.lens_profile = true; + assert!(apply_partial(&d, &to_partial(&p, None), 1.0).optics.lens_profile); + let on = props(&preset_packet("", "").replace(r#"crs:LensProfileEnable="0""#, r#"crs:LensProfileEnable="1""#)); + assert!(apply_partial(&DevelopSettings::default(), &to_partial(&on, None), 1.0).optics.lens_profile); + } } diff --git a/crates/engine/src/devices.rs b/crates/engine/src/devices.rs index a934ee44a..399d03fde 100644 --- a/crates/engine/src/devices.rs +++ b/crates/engine/src/devices.rs @@ -191,7 +191,7 @@ pub fn devices_in(parents: &[std::path::PathBuf], extra: Vec /// The volume's `DCIM` folder: the usual spellings are checked directly, so a volume's root is /// only listed (other spellings on a case-sensitive volume) off Windows, whose drives aren't. -fn dcim_in(root: &std::path::Path) -> Option { +pub fn dcim_in(root: &std::path::Path) -> Option { if let Some(p) = ["DCIM", "dcim"].iter().map(|n| root.join(n)).find(|p| p.is_dir()) { return Some(p); } diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 6affb8b4d..33366285e 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -258,6 +258,43 @@ fn watermark_fonts(craft: &'static [crate::fonts::CraftFont]) -> Vec, + data: &harfrust::ShaperData, + text: &str, + vertical: bool, + px: f32, + left: f32, + top: f32, +) -> Option> { + use ab_glyph::{Font, ScaleFont}; + let face = harfrust::FontRef::new(font.font_data()).ok()?; + let mut buf = harfrust::UnicodeBuffer::new(); + buf.push_str(text); + buf.set_direction(if vertical { harfrust::Direction::TopToBottom } else { harfrust::Direction::LeftToRight }); + buf.guess_segment_properties(); + let shaped = data.shaper(&face).build().shape(buf, harfrust::ShapeOptions::new()); + if shaped.glyph_infos().is_empty() { + return None; + } + let sf = font.as_scaled(px); + let (sx, sy) = (sf.h_scale_factor(), sf.v_scale_factor()); + let advance_x = shaped.glyph_positions().iter().map(|p| p.x_advance as f32 * sx).sum::(); + let advance_y = shaped.glyph_positions().iter().map(|p| -(p.y_advance as f32) * sy).sum::(); + let (mut x, mut y) = if vertical { (left + px / 2.0, top + (px - advance_y) / 2.0) } else { (left + (px - advance_x) / 2.0, top + sf.ascent()) }; + let mut glyphs = Vec::new(); + for (info, pos) in shaped.glyph_infos().iter().zip(shaped.glyph_positions()) { + let gid = u16::try_from(info.glyph_id).ok().filter(|g| *g != 0)?; + let pen = ab_glyph::point(x + pos.x_offset as f32 * sx, y - pos.y_offset as f32 * sy); + glyphs.push(ab_glyph::GlyphId(gid).with_scale_and_position(px, pen)); + x += pos.x_advance as f32 * sx; + y -= pos.y_advance as f32 * sy; + } + Some(glyphs) +} + /// Draw `wm` onto `img` (straight alpha blending of the encoded values). pub fn draw_watermark(img: &mut Rgba8, wm: &Watermark) { let width = img.width; @@ -312,6 +349,11 @@ fn watermark_coverage( } 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); let mut glyphs = Vec::new(); @@ -321,8 +363,13 @@ fn watermark_coverage( let mut prev = None; let columns = text.split('\n').count(); let mut column = 0usize; - for original in text.chars() { - if original == '\n' { + use unicode_segmentation::UnicodeSegmentation; + // Horizontal watermarks retain scalar layout and kerning; only vertical cells use graphemes. + let cells: Box> = + if wm.vertical { Box::new(text.graphemes(true)) } else { Box::new(text.char_indices().filter_map(|(i, c)| text.get(i..i + c.len_utf8()))) }; + for cell in cells { + let Some(original) = cell.chars().next() else { continue }; + if cell == "\n" || (wm.vertical && cell == "\r\n") { if wm.vertical { column = column.saturating_add(1); tw = tw.max(y); @@ -335,6 +382,24 @@ fn watermark_coverage( 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 { '、' => '︑', '。' => '︒', @@ -562,12 +627,264 @@ impl Default for ExportOptions { } } +/// The `app.export` params that are not export options: which photos, where to, a preset to start +/// from, and the desktop app's background flag. +pub const TARGET_PARAMS: &[&str] = &["id", "ids", "path", "dir", "preset", "background"]; + +/// The export option params [`ExportOptions::from_json`] reads (and [`ExportOptions::to_json`] writes). +pub const OPTION_PARAMS: &[&str] = &[ + "format", + "quality", + "resize", + "longEdge", + "shortEdge", + "width", + "height", + "megapixels", + "percent", + "dontEnlarge", + "ppi", + "limitKb", + "sharpen", + "sharpenAmount", + "naming", + "startNumber", + "subfolder", + "conflict", + "tiffCompression", + "dngCompression", + "metadata", + "removeLocation", + "watermark", + "colorSpace", + "bitDepth", +]; + +/// The keys of a `watermark` object ([`Watermark`], camelCase). +pub const WATERMARK_PARAMS: &[&str] = &["text", "vertical", "size", "opacity", "anchor", "inset", "color", "shadow", "image", "imageWidth"]; + +/// Text height as a fraction of the short edge: the range the renderer draws without clamping. +pub const WATERMARK_SIZE_RANGE: (f32, f32) = (0.005, 0.5); +/// Margin as a fraction of the short edge. +pub const WATERMARK_INSET_RANGE: (f32, f32) = (0.0, 0.4); +/// A graphic's width as a fraction of the photo's width. +pub const WATERMARK_IMAGE_WIDTH_RANGE: (f32, f32) = (0.01, 1.0); + +/// Levenshtein distance, for "did you mean" on a misspelled key (both inputs are short). +fn edit_distance(a: &str, b: &str) -> usize { + let (a, b): (Vec, Vec) = (a.chars().collect(), b.chars().collect()); + let mut prev: Vec = (0..=b.len()).collect(); + for (i, ca) in a.iter().enumerate() { + let mut cur = vec![i + 1; b.len() + 1]; + for (j, cb) in b.iter().enumerate() { + let sub = prev.get(j).copied().unwrap_or(0) + usize::from(ca != cb); + let del = prev.get(j + 1).copied().unwrap_or(0) + 1; + let ins = cur.get(j).copied().unwrap_or(0) + 1; + if let Some(c) = cur.get_mut(j + 1) { + *c = sub.min(del).min(ins); + } + } + prev = cur; + } + prev.last().copied().unwrap_or(0) +} + +/// `unknown parameter \`k\``, naming the closest known key when one is near. +fn unknown_key(what: &str, k: &str, known: &[&str]) -> String { + let near = known.iter().map(|n| (edit_distance(&k.to_ascii_lowercase(), &n.to_ascii_lowercase()), *n)).filter(|(d, _)| *d <= 2).min(); + match near { + Some((_, n)) => format!("unknown {what} `{k}` (did you mean `{n}`?)"), + None => format!("unknown {what} `{k}` (one of {})", known.join(", ")), + } +} + +/// A JSON integer (`92`; never `92.5` or `"92"`), as [`ExportOptions::from_json`] reads them. +fn whole(v: &serde_json::Value) -> Option { + v.as_i64() +} + impl ExportOptions { + /// Stored export settings (a saved preset, `prefs.json ▸ lastExport`) with the keys an older or + /// newer version may have written and this one doesn't know dropped, also inside `watermark`. + /// [`ExportOptions::validate`] is strict about what a caller types; settings saved on disk are + /// not the caller's typo, and refusing them would make a preset or Export with Previous fail + /// for good after an update. + pub fn known_keys_only(p: &serde_json::Value) -> serde_json::Value { + let mut p = p.clone(); + if let Some(o) = p.as_object_mut() { + o.retain(|k, _| OPTION_PARAMS.contains(&k.as_str()) || TARGET_PARAMS.contains(&k.as_str())); + if let Some(w) = o.get_mut("watermark").and_then(serde_json::Value::as_object_mut) { + w.retain(|k, _| WATERMARK_PARAMS.contains(&k.as_str())); + } + } + p + } + + /// Strict [`ExportOptions::from_json`] for a command that is about to export (issue #181): an + /// unknown parameter or a value that cannot be read as what it is for is an error, never a + /// silent default. `app.export` targets (`id`, `ids`, `path`, `dir`, `preset`, `background`) + /// are accepted and left to the caller. + pub fn from_params(p: &serde_json::Value) -> crate::Result { + Self::validate("app.export", p)?; + Ok(Self::from_json(p)) + } + + /// Check `app.export` params for `cmd` (the command named in the error): every key must be an + /// export option ([`OPTION_PARAMS`]) or a target ([`TARGET_PARAMS`]), every value must be the + /// kind of thing its key takes, and a `watermark` object must hold only [`WATERMARK_PARAMS`] + /// with its sizes in range (issue #183). `null` means "not given", as a dropped key does. + pub fn validate(cmd: &str, p: &serde_json::Value) -> crate::Result<()> { + use serde_json::Value; + let bad = |msg: String| crate::cmd::bad(cmd, msg); + let Some(obj) = p.as_object() else { return Err(bad("params must be an object".into())) }; + let known: Vec<&str> = OPTION_PARAMS.iter().chain(TARGET_PARAMS).copied().collect(); + let int = |k: &str, v: &Value, lo: i64, hi: i64| -> crate::Result { + whole(v).filter(|i| (lo..=hi).contains(i)).ok_or_else(|| bad(format!("`{k}` must be an integer {lo}..{hi}"))) + }; + let num = |k: &str, v: &Value, lo: f64, hi: f64| -> crate::Result { + v.as_f64().filter(|f| f.is_finite() && (lo..=hi).contains(f)).ok_or_else(|| bad(format!("`{k}` must be a number {lo}..{hi}"))) + }; + fn string<'a>(cmd: &str, k: &str, v: &'a Value) -> crate::Result<&'a str> { + v.as_str().ok_or_else(|| crate::cmd::bad(cmd, format!("`{k}` must be a string"))) + } + let boolean = |k: &str, v: &Value| -> crate::Result { v.as_bool().ok_or_else(|| bad(format!("`{k}` must be true or false"))) }; + let one_of = |k: &str, v: &Value, options: &[&str]| -> crate::Result<()> { + 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("|")))) } + }; + for (k, v) in obj { + if v.is_null() { + continue; + } + match k.as_str() { + "format" => { + if ExportFormat::parse(string(cmd, k, v)?).is_none() { + return Err(bad(format!("`{k}` must be one of jpeg|png|tiff|webp|avif|dng|original"))); + } + } + "quality" => { + int(k, v, 1, 100)?; + } + "ppi" => { + int(k, v, 1, u16::MAX as i64)?; + } + "limitKb" => { + int(k, v, 0, u32::MAX as i64)?; + } + "startNumber" => { + int(k, v, 0, 999_999)?; + } + "bitDepth" => { + if !matches!(whole(v), Some(8 | 10 | 16 | 32)) { + return Err(bad(format!("`{k}` must be 8, 10, 16 or 32"))); + } + } + "longEdge" | "shortEdge" | "width" | "height" | "megapixels" | "percent" => { + num(k, v, 0.0, 1.0e9)?; + } + "resize" => { + let Some(r) = v.as_object() else { return Err(bad(format!("`{k}` must be an object {{mode, value, height?, dontEnlarge?}}"))) }; + if let Some(x) = r.keys().find(|x| !matches!(x.as_str(), "mode" | "value" | "height" | "dontEnlarge")) { + return Err(bad(unknown_key("resize key", x, &["mode", "value", "height", "dontEnlarge"]))); + } + serde_json::from_value::(v.clone()).map_err(|e| bad(format!("`{k}`: {e}")))?; + } + "dontEnlarge" | "removeLocation" | "background" => { + boolean(k, v)?; + } + "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"])?, + "conflict" => one_of(k, v, &["unique", "overwrite", "skip"])?, + "tiffCompression" => one_of(k, v, &["none", "lzw", "zip", "deflate"])?, + "dngCompression" => { + serde_json::from_value::(v.clone()) + .map_err(|_| bad(format!("`{k}` must be one of lossless|deflate|uncompressed")))?; + } + "metadata" => one_of(k, v, &["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"))); + } + } + "watermark" => Self::validate_watermark(cmd, v)?, + "id" => { + int(k, v, 0, i64::MAX)?; + } + "ids" => { + let ok = v.as_array().is_some_and(|a| a.iter().all(|x| x.is_u64())); + if !ok { + return Err(bad(format!("`{k}` must be an array of photo ids"))); + } + } + _ => return Err(bad(unknown_key("parameter", k, &known))), + } + } + Ok(()) + } + + /// A `watermark` value: text, or an object of [`WATERMARK_PARAMS`] (issue #183). + 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 { + Value::String(_) => return Ok(()), + Value::Object(o) => o, + _ => { + return Err(bad( + "`watermark` must be text or an object {text, vertical, size, opacity, anchor, inset, color, shadow, image, imageWidth}".into(), + )); + } + }; + let frac = |k: &str, v: &Value, (lo, hi): (f32, f32), unit: &str| -> crate::Result<()> { + match v.as_f64() { + Some(f) if f.is_finite() && (f64::from(lo)..=f64::from(hi)).contains(&f) => Ok(()), + _ => Err(bad(format!("`watermark.{k}` must be a number {lo}..{hi} ({unit})"))), + } + }; + for (k, v) in obj { + if v.is_null() { + continue; + } + match k.as_str() { + "text" | "image" => { + v.as_str().ok_or_else(|| bad(format!("`watermark.{k}` must be a string")))?; + } + "vertical" | "shadow" => { + v.as_bool().ok_or_else(|| bad(format!("`watermark.{k}` must be true or false")))?; + } + "size" => frac(k, v, WATERMARK_SIZE_RANGE, "fraction of the short edge")?, + "opacity" => frac(k, v, (0.0, 1.0), "0 = invisible, 1 = solid")?, + "inset" => frac(k, v, WATERMARK_INSET_RANGE, "fraction of the short edge")?, + "imageWidth" => frac(k, v, WATERMARK_IMAGE_WIDTH_RANGE, "fraction of the photo's width")?, + "anchor" => { + serde_json::from_value::(v.clone()).map_err(|_| { + bad("`watermark.anchor` must be one of topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight".into()) + })?; + } + "color" => { + let ok = v.as_array().is_some_and(|a| a.len() == 3 && a.iter().all(|c| matches!(whole(c), Some(0..=255)))); + if !ok { + return Err(bad("`watermark.color` must be [r, g, b] with 0..255 each (sRGB)".into())); + } + } + _ => return Err(bad(unknown_key("watermark key", k, WATERMARK_PARAMS))), + } + } + serde_json::from_value::(v.clone()).map(|_| ()).map_err(|e| bad(format!("`watermark`: {e}"))) + } + /// Read options from command params (`format`, `quality`, `limitKb`, `sharpen`, `sharpenAmount`, /// `naming`, `metadata`, `removeLocation`, `watermark`, `colorSpace`, `bitDepth`, `ppi`) and the /// size: one of `longEdge`, `shortEdge`, `width`, `height` (both = fit inside W × H), /// `megapixels`, `percent` (absent or 0 = full size), or a [`Resize`] object as `resize`; plus /// `dontEnlarge` (default true). + /// + /// Lenient: anything unknown or unreadable falls back to the default, which suits stored + /// prefs and dialog prefill. A command that is about to export uses + /// [`ExportOptions::from_params`], which validates first. pub fn from_json(p: &serde_json::Value) -> Self { use serde_json::Value; let d = Self::default(); @@ -1392,6 +1709,99 @@ mod tests { } } + #[test] + 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"); + return; + }; + let font = ab_glyph::FontRef::try_from_slice(entry.bytes).unwrap(); + // BIZ UDMincho has no Unicode presentation-form characters: the old fallback drew 、。. + assert_eq!(font.glyph_id('︑').0, 0); + assert_eq!(font.glyph_id('︒').0, 0); + let data = harfrust::ShaperData::new(&harfrust::FontRef::new(entry.bytes).unwrap()); + for ch in ['、', '。'] { + let glyph = watermark_cell_glyphs(&font, &data, &ch.to_string(), true, 100.0, 0.0, 0.0).unwrap().remove(0); + assert_ne!(glyph.id, font.glyph_id(ch), "the font's vertical alternate for {ch}"); + let rect = font.outline_glyph(glyph).unwrap().px_bounds(); + assert!(rect.min.x > 50.0 && rect.max.y < 50.0, "{ch} in the upper right: {rect:?}"); + } + let craft = Box::leak( + vec![crate::fonts::CraftFont { family: entry.family, style: entry.style, scripts: entry.scripts, bytes: entry.bytes }].into_boxed_slice(), + ); + let wm = Watermark { + text: "、。".into(), + vertical: true, + size: 0.1, + anchor: Anchor::TopLeft, + inset: 0.0, + shadow: false, + opacity: 1.0, + ..Default::default() + }; + let mut pixels = Vec::new(); + watermark_coverage(400, 300, &wm, craft, |x, y, k, _| { + if k > 0.5 { + pixels.push((x, y)); + } + }); + assert!(!pixels.is_empty()); + for (x, y) in pixels { + assert!(x > 15 && y % 30 < 15, "actual export coverage at the upper right of the 30 px cell: {x}, {y}"); + } + } + + #[test] + fn vertical_watermarks_keep_combining_marks_in_one_cell() { + let coverage = |text: &str, craft| { + let wm = Watermark { + text: text.into(), + vertical: true, + size: 0.1, + anchor: Anchor::Center, + inset: 0.0, + shadow: false, + opacity: 1.0, + ..Default::default() + }; + let mut pixels = vec![0.0f32; 400 * 300]; + watermark_coverage(400, 300, &wm, craft, |x, y, k, _| pixels[y * 400 + x] = k); + assert!(pixels.iter().any(|&k| k > 0.0)); + pixels + }; + let compare = |a: &str, b: &str, craft| { + let (a_pixels, b_pixels) = (coverage(a, craft), coverage(b, craft)); + let difference = a_pixels.iter().zip(&b_pixels).position(|(a, b)| a != b); + assert!(difference.is_none(), "{a:?} vs {b:?}: first differing pixel {difference:?}"); + }; + for (composed, decomposed) in [("éA", "e\u{301}A"), ("Å\nA", "A\u{30a}\nA"), ("A\nB", "A\r\nB"), ("日A", "日\u{e0100}A")] { + compare(composed, decomposed, &[]); + } + if crate::fonts::CRAFT_FONTS.iter().any(|f| f.family == "BIZ UDMincho") { + for (composed, decomposed) in [("が日", "か\u{3099}日"), ("ぱ\n日", "は\u{309a}\n日")] { + compare(composed, decomposed, crate::fonts::CRAFT_FONTS); + } + } else { + eprintln!("skipped Japanese comparisons: built without BIZ UDMincho from craft-fonts"); + } + } + + #[test] + fn upright_watermark_cell_retains_multiple_glyphs() { + use ab_glyph::{Font, ScaleFont}; + let font = ab_glyph::FontRef::try_from_slice(WATERMARK_FONT).unwrap(); + let data = harfrust::ShaperData::new(&harfrust::FontRef::new(WATERMARK_FONT).unwrap()); + // There is no precomposed A with a combining long solidus overlay. + let glyphs = watermark_cell_glyphs(&font, &data, "A\u{338}", false, 30.0, 0.0, 0.0).unwrap(); + assert_eq!(glyphs.len(), 2, "retain the base and the separately positioned combining glyph"); + let baseline = font.as_scaled(30.0).ascent(); + for glyph in glyphs { + assert!((glyph.position.y - baseline).abs() < 1.0, "no extra vertical cell for the mark"); + assert!(font.outline_glyph(glyph).is_some(), "both glyphs have ink"); + } + } + #[test] fn japanese_watermarks_support_vertical_columns() { use ab_glyph::Font; @@ -1720,4 +2130,99 @@ mod tests { Err(e) => assert!(e.contains("not available"), "{e}"), } } + + /// Issue #181: a command about to export refuses what `from_json` would silently default — + /// unknown keys (naming the nearest known one) and values of the wrong kind or range. + #[test] + fn export_params_are_validated() { + use serde_json::json; + let err = |p: serde_json::Value| ExportOptions::from_params(&p).err().map(|e| e.to_string()).unwrap_or_default(); + let e = err(json!({"longEdgee": 400})); + assert!(e.starts_with("invalid parameters for `app.export`: unknown parameter `longEdgee` (did you mean `longEdge`?)"), "{e}"); + assert!(err(json!({"qualty": 5})).contains("did you mean `quality`")); + assert!(err(json!({"longEdge": "banana"})).contains("`longEdge` must be a number")); + assert!(err(json!({"quality": 0})).contains("`quality` must be an integer 1..100")); + assert!(err(json!({"quality": "5"})).contains("`quality` must be an integer")); + assert!(err(json!({"quality": 92.5})).contains("`quality` must be an integer")); + assert!(err(json!({"sharpen": "lots"})).contains("`sharpen` must be one of none|screen|matte|glossy")); + assert!(err(json!({"format": "bmp"})).contains("`format` must be one of")); + assert!(err(json!({"bitDepth": 12})).contains("`bitDepth` must be 8, 10, 16 or 32")); + assert!(err(json!({"colorSpace": "cmyk"})).contains("`colorSpace` must be one of")); + assert!(err(json!({"dontEnlarge": "no"})).contains("`dontEnlarge` must be true or false")); + assert!(err(json!({"resize": {"mode": "longEdge", "valeu": 10}})).contains("did you mean `value`")); + assert!(err(json!({"resize": {"mode": "diagonal", "value": 10}})).contains("`resize`:")); + assert!(err(json!({"ids": [1, "x"]})).contains("`ids` must be an array of photo ids")); + assert!(err(json!("jpeg")).contains("params must be an object")); + assert!( + ExportOptions::validate("export.savePreset", &json!({"qualty": 5})) + .unwrap_err() + .to_string() + .starts_with("invalid parameters for `export.savePreset`") + ); + // everything the presets, the dialog, Export with Previous and `render --opt` send passes + for p in builtin_presets() { + ExportOptions::from_params(&p.params).unwrap_or_else(|e| panic!("{}: {e}", p.name)); + } + let full = ExportOptions { + resize: Some(Resize::default()), + limit_kb: Some(500), + watermark: Some(Watermark { text: "©".into(), ..Default::default() }), + bit_depth: Some(16), + ..Default::default() + }; + let mut j = full.to_json(); + for (k, v) in [ + ("dir", json!("/tmp/out")), + ("background", json!(true)), + ("preset", json!("x")), + ("ids", json!([1, 2])), + ("id", json!(1)), + ("path", json!("a.jpg")), + ] { + j[k] = v; + } + assert_eq!(ExportOptions::from_params(&j).unwrap(), full); + assert_eq!(ExportOptions::from_params(&ExportOptions::default().to_json()).unwrap(), ExportOptions::default()); + // null is "not given", as a dropped key is + ExportOptions::from_params(&json!({"quality": 92, "longEdge": null, "watermark": null})).unwrap(); + } + + /// 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] + fn watermark_params_are_validated() { + use serde_json::json; + let err = |w: serde_json::Value| ExportOptions::from_params(&json!({"watermark": w})).err().map(|e| e.to_string()).unwrap_or_default(); + assert!( + err(json!(5)) + .contains("`watermark` must be text or an object {text, vertical, size, opacity, anchor, inset, color, shadow, image, imageWidth}") + ); + assert!( + err(json!({"text": "x", "bogus": 1})) + .contains("unknown watermark key `bogus` (one of text, vertical, size, opacity, anchor, inset, color, shadow, image, imageWidth)") + ); + assert!(err(json!({"text": "x", "sizee": 0.1})).contains("did you mean `size`")); + for size in [json!(3), json!(0.5001), json!(0), json!("big")] { + let e = err(json!({"text": "x", "size": size})); + assert!(e.contains("`watermark.size` must be a number 0.005..0.5 (fraction of the short edge)"), "{size}: {e}"); + } + assert!(err(json!({"text": "x", "opacity": 1.5})).contains("`watermark.opacity` must be a number 0..1")); + assert!(err(json!({"text": "x", "inset": -0.1})).contains("`watermark.inset` must be a number 0..0.4")); + assert!(err(json!({"image": "logo.png", "imageWidth": 2})).contains("`watermark.imageWidth` must be a number 0.01..1")); + assert!(err(json!({"text": "x", "anchor": "middle"})).contains("`watermark.anchor` must be one of")); + assert!(err(json!({"text": "x", "color": [255, 255]})).contains("`watermark.color` must be [r, g, b]")); + assert!(err(json!({"text": "x", "color": [255, 255, 256]})).contains("`watermark.color`")); + assert!(err(json!({"text": "x", "vertical": "yes"})).contains("`watermark.vertical` must be true or false")); + assert!(err(json!({"text": 7})).contains("`watermark.text` must be a string")); + // the documented shapes pass and read as before + let o = ExportOptions::from_params(&json!({"watermark": "© me"})).unwrap(); + assert_eq!(o.watermark.map(|w| w.text).as_deref(), Some("© me")); + let w = json!({"text": "日本語", "vertical": true, "size": 0.1, "opacity": 0.5, "anchor": "topLeft", "inset": 0.05, "color": [0, 0, 0], "shadow": false, "image": "", "imageWidth": 0.3}); + let wm = ExportOptions::from_params(&json!({"watermark": w})).unwrap().watermark.unwrap(); + assert!((wm.size - 0.1).abs() < 1e-6 && wm.vertical && wm.anchor == Anchor::TopLeft && wm.color == [0, 0, 0] && !wm.shadow); + assert_eq!(ExportOptions::from_params(&json!({"watermark": {"text": "x", "size": 0.5}})).unwrap().watermark.map(|w| w.size), Some(0.5)); + // the Export dialog's sliders (1–15 % size, 5–100 % opacity, 2–100 % width) stay inside the accepted ranges + assert!(WATERMARK_SIZE_RANGE.0 <= 0.01 && WATERMARK_SIZE_RANGE.1 >= 0.15); + assert!(WATERMARK_IMAGE_WIDTH_RANGE.0 <= 0.02 && WATERMARK_IMAGE_WIDTH_RANGE.1 >= 1.0); + } } diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index 62a1c467f..c620be928 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -43,8 +43,9 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option) { (meta, m.capture_time.as_ref().map(|d| d.to_iso())) } -/// Lens corrections embedded in a DNG's `OpcodeList3` (`WarpRectilinear`, `FixVignetteRadial`), re-expressed for -/// the default-cropped, EXIF-oriented image. These are the only "profile" corrections LightCraft applies. +/// Lens corrections embedded in a raw's `OpcodeList3` (`WarpRectilinear`, `FixVignetteRadial`: a DNG's own, or the +/// raw reader's equivalent of the camera's correction, e.g. Panasonic / Leica RW2 distortion), re-expressed for the +/// default-cropped, EXIF-oriented image. These are the only "profile" corrections LightCraft applies. pub fn embedded_lens(raw: &lightcraft_raw::RawInfo) -> Option { use lightcraft_develop::{EmbeddedLens, EmbeddedVignette, EmbeddedWarp}; use lightcraft_geom::Point; @@ -87,24 +88,22 @@ pub fn embedded_lens(raw: &lightcraft_raw::RawInfo) -> Option bool { - matches!(op, lightcraft_raw::Opcode::WarpRectilinear { .. } | lightcraft_raw::Opcode::FixVignetteRadial { .. }) -} - fn ext_upper(name: &str) -> String { std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_uppercase()).unwrap_or_default() } -/// Probe a file's bytes: kind, dimensions (oriented), metadata. Raws are described from their -/// headers ([`lightcraft_raw::probe_info`]): no pixel data is decompressed. +/// Probe a file's bytes: kind, dimensions (oriented), metadata. No pixel data is decompressed: +/// raws are described from their headers ([`lightcraft_raw::probe_info`]), other images too +/// ([`lightcraft_codecs::read_header`], which refuses truncated files but can't see damage inside +/// compressed data that is all there: such a file imports and shows as unreadable when rendered). pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result { let content_hash = Some(lightcraft_preview::hash_bytes(bytes).to_string()); let m = lightcraft_meta::extract(bytes); let (meta, captured) = meta_of(&m); if lightcraft_raw::probe(bytes).is_some() { - let raw = match lightcraft_raw::probe_info(bytes) { + let raw = match lightcraft_raw::probe_info(bytes).map_err(|e| preview_reason(bytes, e)) { Ok(r) => r, - Err(lightcraft_raw::RawError::Unsupported(why)) => { + Err(Ok(why)) => { // a raw variant we can't decode yet: describe it from its embedded preview let (w, h) = embedded_preview_size(bytes).ok_or(format!("unsupported raw ({why}) without an embedded preview"))?; return Ok(ProbeInfo { @@ -122,7 +121,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result { ..Default::default() }); } - Err(e) => return Err(e.to_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 { @@ -155,9 +154,10 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result { if !fmt.can_decode() { return Err(format!("{fmt:?} files are not supported yet")); } - let d = lightcraft_codecs::decode(bytes, lightcraft_codecs::DecodeOptions::fit(64, 64)).map_err(|e| e.to_string())?; - let o = Orientation::from_exif(d.orientation); - let (mut w, mut h) = (d.source_width, d.source_height); + // headers only (issue #367: decoding the pixels was nearly all of an import's CPU time) + let header = lightcraft_codecs::read_header(bytes).map_err(|e| e.to_string())?; + let o = Orientation::from_exif(header.orientation); + let (mut w, mut h) = (header.width, header.height); if o.swaps_axes() { std::mem::swap(&mut w, &mut h); } @@ -182,6 +182,21 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result { }) } +/// Why a raw that failed to decode should show its embedded preview instead (`Ok`), or the error +/// to report (`Err`). Variants we can't decode yet always fall back. So does any CR3 error: the CRX +/// decoder is verified on few bodies, and every CR3 opened from its embedded JPEG before it existed. +/// So does any other failure of a recognised raw (damaged or oversized raw data, unreadable TIFF +/// structure): its preview may still be intact. Only a file that is not a raw reports an error. +fn preview_reason(bytes: &[u8], e: lightcraft_raw::RawError) -> Result { + use lightcraft_raw::RawError; + match e { + RawError::Unsupported(why) => Ok(why), + e if lightcraft_raw::probe(bytes) == Some(lightcraft_raw::RawFormat::Cr3) => Ok(format!("CR3 {e}")), + RawError::NotRaw => Err(RawError::NotRaw.to_string()), + e => Ok(e.to_string()), + } +} + /// Sensor clip level (normalised) for highlight reconstruction. const HIGHLIGHT_CLIP: f32 = 0.99; @@ -216,21 +231,22 @@ pub fn load_vec(bytes: Vec, max_edge: usize) -> Result<(Rgb32f, SourceInfo), fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result<(Rgb32f, SourceInfo), String> { if lightcraft_raw::probe(&bytes).is_some() { - let mut raw = match lightcraft_raw::decode(&bytes) { + let mut raw = match lightcraft_raw::decode(&bytes).map_err(|e| preview_reason(&bytes, e)) { Ok(r) => r, - Err(lightcraft_raw::RawError::Unsupported(why)) => { + Err(Ok(why)) => { // show the camera's embedded JPEG (rendered, not raw) until the variant is supported return load_embedded_preview(&bytes, max_edge).ok_or(format!("unsupported raw ({why}) without an embedded preview")); } - Err(e) => return Err(e.to_string()), + Err(Err(e)) => return Err(e), }; + // Embedded lens corrections are applied by the pipeline ("Enable Profile Corrections"), not baked in + // (removed before the camera look's binned sensor proxy too, which needs an empty `OpcodeList3`). + let lens = embedded_lens(&raw.info()); + raw.opcodes.list3.retain(|op| !op.is_lens_correction()); let xy = lightcraft_raw::color::as_shot_white_xy(&raw); let t = lightcraft_raw::color::camera_transform(&raw, xy); let camera_look = crate::camera_preview::fit_preview(&raw, &bytes, &t); drop(bytes); - // Embedded lens corrections are applied by the pipeline ("Enable Profile Corrections"), not baked in. - let lens = embedded_lens(&raw.info()); - raw.opcodes.list3.retain(|op| !is_lens_opcode(op)); // Previews and thumbnails bin the mosaic straight to (about) the size they need; only // larger levels (exports, 1:1) demosaic the whole sensor. let t0 = web_time::Instant::now(); @@ -294,7 +310,7 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result< let (temp, tint) = if relative { (6500.0, 0.0) } else { (temp.round(), tint.round()) }; return Ok((img, SourceInfo { raw: true, as_shot_temp: temp, as_shot_tint: tint, lens, relative_wb: relative, camera_tone })); } - let d = lightcraft_codecs::decode(&bytes, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32)).map_err(|e| e.to_string())?; + let d = lightcraft_codecs::decode(&bytes, fit_box(max_edge)).map_err(|e| e.to_string())?; drop(bytes); let img = d.to_working(); let img = if img.width.max(img.height) > max_edge { fit(&img, max_edge, max_edge, Filter::Mitchell) } else { img }; @@ -320,9 +336,38 @@ fn preview_orientation(raw_bytes: &[u8], jpeg_orientation: u16) -> Orientation { lightcraft_meta::extract(raw_bytes).orientation.unwrap_or(Orientation::Normal) } +/// What stands in for a raw file we can't decode: its largest embedded JPEG, else, for a TIFF-based +/// raw, the file's own first image when the TIFF reader can show it (a reduced RGB copy: the only +/// preview some containers carry). Returns image bytes for [`lightcraft_codecs::decode`]. +fn stand_in_image(bytes: &[u8]) -> Option> { + if let Some(jpeg) = lightcraft_raw::embedded_preview(bytes) { + return Some(std::borrow::Cow::Owned(jpeg)); + } + let undecodable = lightcraft_raw::probe(bytes).is_some_and(|f| !f.is_supported()); + (undecodable && lightcraft_codecs::sniff(bytes).is_some_and(|f| f == lightcraft_codecs::Format::Tiff)) + .then_some(std::borrow::Cow::Borrowed(bytes)) +} + +/// Shared colour interpretation for quick previews, unsupported RAW fallback and camera-look fitting. +pub(crate) fn decode_raw_preview(bytes: &[u8], opts: lightcraft_codecs::DecodeOptions) -> Option { + let jpeg = stand_in_image(bytes)?; + // 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(_)); + match lightcraft_raw::embedded_preview_color_space(bytes).filter(|_| is_jpeg) { + Some(space) => { + let space = match space { + lightcraft_raw::PreviewColorSpace::Srgb => lightcraft_codecs::NamedSpace::Srgb, + lightcraft_raw::PreviewColorSpace::AdobeRgb => lightcraft_codecs::NamedSpace::AdobeRgb, + }; + lightcraft_codecs::decode_jpeg_with_fallback(&jpeg, opts, space).ok() + } + None => lightcraft_codecs::decode(&jpeg, opts).ok(), + } +} + /// Oriented size of the embedded preview of a raw file. fn embedded_preview_size(bytes: &[u8]) -> Option<(u32, u32)> { - let jpeg = lightcraft_raw::embedded_preview(bytes)?; + let jpeg = stand_in_image(bytes)?; let d = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions::fit(64, 64)).ok()?; let (mut w, mut h) = (d.source_width, d.source_height); if preview_orientation(bytes, d.orientation).swaps_axes() { @@ -333,8 +378,7 @@ fn embedded_preview_size(bytes: &[u8]) -> Option<(u32, u32)> { /// The embedded preview of a raw file as a working-space image no larger than `max_edge`, oriented. pub fn load_embedded_preview(bytes: &[u8], max_edge: usize) -> Option<(Rgb32f, SourceInfo)> { - let jpeg = lightcraft_raw::embedded_preview(bytes)?; - let d = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32)).ok()?; + let d = decode_raw_preview(bytes, fit_box(max_edge))?; let img = d.to_working(); let img = if img.width.max(img.height) > max_edge { fit(&img, max_edge, max_edge, Filter::Mitchell) } else { img }; Some((img.oriented(preview_orientation(bytes, d.orientation)), SourceInfo::default())) @@ -343,8 +387,7 @@ pub fn load_embedded_preview(bytes: &[u8], max_edge: usize) -> Option<(Rgb32f, S /// The embedded preview of a raw file for display (sRGB, oriented, no larger than `max_edge`): the /// loupe and grid show it until the raw itself has been developed ([`crate::media::QuickJob`]). pub fn embedded_preview_srgb(bytes: &[u8], max_edge: usize) -> Option { - let jpeg = lightcraft_raw::embedded_preview(bytes)?; - let mut d = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32)).ok()?; + let mut d = decode_raw_preview(bytes, fit_box(max_edge))?; if d.image.width.max(d.image.height) > max_edge { d.image = fit(&d.image, max_edge, max_edge, Filter::Box); d.alpha = None; @@ -353,6 +396,13 @@ pub fn embedded_preview_srgb(bytes: &[u8], max_edge: usize) -> Option lightcraft_codecs::DecodeOptions { + let e = u32::try_from(max_edge).unwrap_or(u32::MAX); + lightcraft_codecs::DecodeOptions::fit(e, e) +} + /// Filesystem-backed embedded-preview hook (native). pub fn fs_preview_loader() -> PreviewLoader { Arc::new(|path: &str, max_edge: usize| embedded_preview_srgb(&std::fs::read(path).ok()?, max_edge)) @@ -402,6 +452,162 @@ mod tests { use super::*; use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg}; + /// Issue #367: probes read headers, not pixels, and report what the decode-based probe did: + /// oriented dimensions (EXIF orientation 6 swaps them), metadata, the error for a truncated + /// file or one that isn't an image. + #[test] + fn image_probe_reads_headers_like_the_decode_did() { + let (w, h) = (300u32, 200u32); + let rgb: Vec = (0..w * h * 3).map(|i| (i * 7 % 251) as u8).collect(); + let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb)); + for o in [None, Some(1), Some(6), Some(3), Some(8)] { + let exif = o.map(lightcraft_codecs::exif::minimal_exif); + let meta = EncodeMeta { exif: exif.as_deref(), ..Default::default() }; + let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &meta).unwrap(); + let png = lightcraft_codecs::encode_png(&img, &meta).unwrap(); + for (name, bytes) in [("a.jpg", &jpeg), ("a.png", &png)] { + let p = probe_bytes(name, bytes).unwrap(); + // what the probe computed from a decode before + let d = lightcraft_codecs::decode(bytes, lightcraft_codecs::DecodeOptions::fit(64, 64)).unwrap(); + let swap = Orientation::from_exif(d.orientation).swaps_axes(); + let want = if swap { (d.source_height, d.source_width) } else { (d.source_width, d.source_height) }; + assert_eq!((p.width, p.height), want, "{name} {o:?}"); + assert_eq!((p.width, p.height), if matches!(o, Some(6 | 8)) { (h, w) } else { (w, h) }, "{name} {o:?}"); + assert_eq!(p.kind, MediaKind::Image); + assert_eq!(p.format, if name == "a.jpg" { "JPEG" } else { "PNG" }); + assert_eq!(p.file_size, bytes.len() as u64); + assert!(p.content_hash.is_some()); + assert!(probe_bytes(name, &bytes[..bytes.len() / 2]).is_err(), "{name}: truncated file accepted"); + } + } + assert!(probe_bytes("x.jpg", b"not an image").is_err()); + } + + /// Luminance (from linear RGB) minus its 9 × 9 local mean: tone differences between a render and a camera JPEG + /// mostly cancel, edges remain. + fn detail(w: usize, h: usize, lum: impl Fn(usize, usize) -> f32) -> Vec { + let l: Vec = (0..w * h).map(|i| lum(i % w, i / w)).collect(); + let mut out = vec![0.0; w * h]; + for y in 0..h { + for x in 0..w { + let (x0, x1, y0, y1) = (x.saturating_sub(4), (x + 5).min(w), y.saturating_sub(4), (y + 5).min(h)); + let m = (y0..y1).flat_map(|yy| (x0..x1).map(move |xx| (xx, yy))).map(|(xx, yy)| l[yy * w + xx]).sum::(); + out[y * w + x] = l[y * w + x] - m / ((x1 - x0) * (y1 - y0)) as f32; + } + } + out + } + + /// The integer shift (within ±`r` px) of the `p × p` patch of `a` at `(x, y)` that best matches `b`, with its + /// normalised cross-correlation, or `None` when the patch has too little detail to tell. + fn patch_shift(a: &[f32], b: &[f32], w: usize, (x, y): (usize, usize), p: usize, r: i64) -> Option<((i64, i64), f32)> { + let pa: Vec = (0..p * p).map(|i| a[(y + i / p) * w + x + i % p]).collect(); + let na = pa.iter().map(|v| v * v).sum::().sqrt(); + if na < 1e-3 * p as f32 { + return None; + } + let mut best = (f32::MIN, (0, 0)); + for dy in -r..=r { + for dx in -r..=r { + let (bx, by) = (x as i64 + dx, y as i64 + dy); + let (mut dot, mut nb) = (0.0f32, 0.0f32); + for i in 0..p { + for j in 0..p { + let v = b[(by as usize + i) * w + bx as usize + j]; + dot += pa[i * p + j] * v; + nb += v * v; + } + } + let c = dot / (na * nb.sqrt()).max(1e-12); + if c > best.0 { + best = (c, (dx, dy)); + } + } + } + Some((best.1, best.0)) + } + + /// Issue #256: public Panasonic / Leica raws (skipped without the corpus) are corrected for distortion the way the + /// camera corrected its own JPEG (tag 0x0119): at the corners of the frame, the render with "Enable Profile + /// Corrections" lines up with the embedded JPEG within 2 px at 640 px, while the uncorrected render of the wide + /// lenses is far off. A file shot with the camera's correction off (DMC-GH1) carries no correction. + #[test] + fn corpus_rw2_distortion_matches_the_camera_jpeg() { + 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(gh1) = std::fs::read(dir.join("rw2-panasonic-gh1.rw2")) else { + eprintln!("skip: {} absent", dir.display()); + return; + }; + assert_eq!(probe_bytes("gh1.rw2", &gh1).unwrap().embedded_lens, None, "correction off: nothing to apply"); + assert_eq!(load_bytes(&gh1, 320).unwrap().1.lens, None); + + // (file, how far off (px) the uncorrected corners are at the least, if they match within ±R at all) + let files = [ + ("rw2-panasonic-fz1000m2-4x3.rw2", 8), // 9.1 mm, ~13 % at the corners + ("rwl-leica-dlux7.rwl", 8), // 10.9 mm, ~13 % + ("rw2-panasonic-gx80.rw2", 8), // 14 mm, ~8 % + ("rw2-panasonic-gh6.rw2", 3), // 20 mm, ~1.6 % + ("rw2-panasonic-s9.rw2", 3), // 45 mm, 3 % zoom after the correction + ("rw2-panasonic-g9.rw2", 0), // 40 mm, ~0.5 % + ]; + const EDGE: usize = 640; + const P: usize = 64; + const R: i64 = 12; + for (name, off_at_least) in files { + let bytes = std::fs::read(dir.join(name)).unwrap(); + let probed = probe_bytes(name, &bytes).unwrap(); + assert!(probed.embedded_lens.is_some_and(|l| l.warp.is_some()), "{name}: no distortion correction read"); + let (src, info) = load_bytes(&bytes, 2 * EDGE).unwrap(); + assert_eq!(info.lens, probed.embedded_lens, "{name}: probe and decode disagree"); + // Convert to DNG keeps it: the same correction, as the DNG's own `WarpRectilinear` + let dng = lightcraft_raw::write_dng(&lightcraft_raw::decode(&bytes).unwrap(), &Default::default()).unwrap(); + assert_eq!(probe_bytes("x.dng", &dng).unwrap().embedded_lens, probed.embedded_lens, "{name}: lost in the DNG"); + let mut s = lightcraft_develop::DevelopSettings::default(); + let render = |s: &lightcraft_develop::DevelopSettings| { + lightcraft_pipeline::render(&src, &info, s, &lightcraft_pipeline::RenderRequest::fit(EDGE, EDGE)).image + }; + let before = render(&s); + s.optics.lens_profile = true; + let after = render(&s); + let (w, h) = (after.width, after.height); + assert_eq!((before.width, before.height), (w, h)); + // the camera JPEG shows the whole sensor; a render in an in-camera aspect ratio is its centre + let (cam, _) = load_embedded_preview(&bytes, 4 * EDGE).unwrap(); + let (cw, ch) = (cam.width as f64, cam.height as f64); + let (sw, sh) = if cw / ch > w as f64 / h as f64 { (ch * w as f64 / h as f64, ch) } else { (cw, cw * h as f64 / w as f64) }; + let cam = cam.crop(((cw - sw) / 2.0).round() as usize, ((ch - sh) / 2.0).round() as usize, sw.round() as usize, sh.round() as usize); + let cam = lightcraft_raster::resample::resize(&cam, w, h, Filter::Mitchell); + let lin = |v: u8| (v as f32 / 255.0).powf(2.2); + let lum8 = |im: &lightcraft_raster::Rgba8, x: usize, y: usize| { + let p = im.data[y * im.width + x]; + 0.3 * lin(p[0]) + 0.6 * lin(p[1]) + 0.1 * lin(p[2]) + }; + let reference = detail(w, h, |x, y| { + let p = cam.data[y * w + x]; + 0.3 * p[0] + 0.6 * p[1] + 0.1 * p[2] + }); + let (db, da) = (detail(w, h, |x, y| lum8(&before, x, y)), detail(w, h, |x, y| lum8(&after, x, y))); + let m = R as usize + 4; + let corners = [(m, m), (w - m - P, m), (m, h - m - P), (w - m - P, h - m - P)]; + let mut measured = 0; + for c in corners { + let Some(((dx, dy), ca)) = patch_shift(&da, &reference, w, c, P, R) else { continue }; + measured += 1; + assert!(dx.abs() <= 2 && dy.abs() <= 2 && ca >= 0.5, "{name}: corrected corner {c:?} off by ({dx}, {dy}) px, ncc {ca}"); + if off_at_least > 0 + && let Some(((bx, by), cb)) = patch_shift(&db, &reference, w, c, P, R) + { + let off = bx.abs().max(by.abs()) >= off_at_least || cb < 0.3; + assert!(off, "{name}: uncorrected corner {c:?} already within ({bx}, {by}) px, ncc {cb}"); + } + } + assert!(measured >= 2, "{name}: only {measured} corners with detail"); + } + } + /// A CR3-shaped file (not decodable yet) whose only content is a `PRVW` preview box. fn cr3_with_preview(w: u32, h: u32) -> Vec { let px: Vec = (0..w * h).flat_map(|i| [(i % 251) as u8, 128, 200]).collect(); @@ -417,6 +623,43 @@ mod tests { b } + /// [`cr3_with_preview`] plus a full-size CRX raw track whose `CMP1` coding header is garbage: + /// the container parses, the raw data doesn't (`RawError::Corrupt`, not `Unsupported`). + fn cr3_with_corrupt_raw(w: u32, h: u32) -> Vec { + let bx = |kind: &[u8; 4], body: &[u8]| [&((body.len() + 8) as u32).to_be_bytes()[..], kind, body].concat(); + let mut craw = vec![0u8; 82]; + craw[24..26].copy_from_slice(&(w as u16).to_be_bytes()); + craw[26..28].copy_from_slice(&(h as u16).to_be_bytes()); + craw.extend(bx(b"CMP1", &[0xff; 4])); + let mut stsd = vec![0, 0, 0, 0, 0, 0, 0, 1]; + stsd.extend(bx(b"CRAW", &craw)); + let stsz = [0u32, 16, 1].map(u32::to_be_bytes).concat(); + let co64 = [&[0u8, 0, 0, 0, 0, 0, 0, 1][..], &0u64.to_be_bytes()].concat(); + let stbl = [bx(b"stsd", &stsd), bx(b"stsz", &stsz), bx(b"co64", &co64)].concat(); + let trak = bx(b"trak", &bx(b"mdia", &bx(b"minf", &bx(b"stbl", &stbl)))); + let preview = cr3_with_preview(w, h); + [&preview[..24], &bx(b"moov", &trak), &preview[24..]].concat() + } + + /// A CR3 whose raw data fails to decode, for any reason, still opens from its embedded JPEG: + /// CR3s did before the CRX decoder, which is verified on few bodies (PR #279 review). + #[test] + fn corrupt_cr3_falls_back_to_embedded_preview() { + let b = cr3_with_corrupt_raw(48, 32); + let err = lightcraft_raw::decode(&b).expect_err("the fixture's raw data is corrupt"); + assert!(!matches!(err, lightcraft_raw::RawError::Unsupported(_)), "{err}"); + let p = probe_bytes("x.cr3", &b).unwrap(); + assert_eq!((p.width, p.height, p.kind, p.format.as_str()), (48, 32, MediaKind::Raw, "CR3")); + assert!(p.preview_only.is_some_and(|why| why.contains("CR3"))); + let (img, src) = load_bytes(&b, 24).unwrap(); + assert_eq!((img.width, img.height), (24, 16)); + assert!(!src.raw); + // other formats still report a corrupt file as an error + let mut dng = crate::tests_xmp::synthetic_dng_with(None, Default::default()); + dng.truncate(dng.len() / 2); + assert!(load_bytes(&dng, 24).is_err()); + } + /// Issue #138: a DNG's own profile look (hue/saturation map, look table, tone curve) is /// applied when it's developed — Lightroom-converted DNGs rendered flat and muted without it. #[test] @@ -520,6 +763,84 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// A raw whose TIFF shell holds only a small RGB image (IFD0) next to a private block, as Exif + /// declares a picture 25x its size. + fn tiff_shell(w: u32, h: u32, shell: bool) -> Vec { + use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![h])); + ifd0.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8])); + ifd0.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + ifd0.set(t::PHOTOMETRIC, Value::Short(vec![2])); + ifd0.set(t::COMPRESSION, Value::Short(vec![1])); + let px: Vec = (0..w * h).flat_map(|i| [(i % 251) as u8, 128, 200]).collect(); + ifd0.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![px] }); + ifd0.set_child( + t::EXIF_IFD, + IfdBuilder::new().with(t::PIXEL_X_DIMENSION, Value::Long(vec![w * 25])).with(t::PIXEL_Y_DIMENSION, Value::Long(vec![h * 25])), + ); + let mut b = TiffWriter::default().write(&[ifd0]).unwrap(); + if shell { + b.extend(std::iter::repeat_n(0x5au8, (w * h * 625 / 8) as usize + 100)); + } + b + } + + /// A TIFF raw whose only image is a small thumbnail used to open as a plain image of that size + /// ("a 150 MP file renders 296x220"), with nothing saying so. Now it is recognised as a raw we can't + /// decode: preview only, with the reason, still showing that image. + #[test] + fn thumbnail_only_tiff_raw_is_preview_only_not_a_plain_image() { + let b = tiff_shell(24, 16, true); + let p = probe_bytes("IMG_0001.IIQ", &b).unwrap(); + assert_eq!((p.kind, p.format.as_str(), p.width, p.height), (MediaKind::Raw, "IIQ", 24, 16)); + let why = p.preview_only.expect("marked preview only"); + assert!(why.contains("600x400") && why.contains("24x16"), "{why}"); + let (img, src) = load_bytes(&b, 12).unwrap(); + assert_eq!((img.width, img.height, src.raw), (12, 8, false)); + assert!(embedded_preview_srgb(&b, 12).is_some()); + // the same pixels without the private block are a small ordinary image + let plain = probe_bytes("small.tif", &tiff_shell(24, 16, false)).unwrap(); + assert_eq!((plain.kind, plain.preview_only, plain.width, plain.height), (MediaKind::Image, None, 24, 16)); + assert!(lightcraft_raw::probe(&tiff_shell(24, 16, false)).is_none()); + } + + /// Recognised raw containers we don't decode (Minolta MRW here: the preview's first byte is + /// overwritten in the file) import as preview only with the JPEG's size, instead of failing. + #[test] + fn undecodable_container_with_a_preview_imports_as_preview_only() { + let px: Vec = (0..40 * 30).flat_map(|i| [(i % 251) as u8, 128, 200]).collect(); + let mut jpeg = encode_jpeg(&EncodeImage::new(40, 30, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap(); + jpeg[0] = 0x02; + let mut f = b"\0MRM\0\x01\0\0".to_vec(); + f.extend(std::iter::repeat_n(0x11u8, 300)); + f.extend_from_slice(&jpeg); + f.extend(std::iter::repeat_n(0x22u8, 300)); + let p = probe_bytes("A.MRW", &f).unwrap(); + assert_eq!((p.kind, p.format.as_str(), p.width, p.height), (MediaKind::Raw, "MRW", 40, 30)); + assert!(p.preview_only.is_some_and(|w| w.contains("Mrw"))); + let (img, src) = load_bytes(&f, 20).unwrap(); + assert_eq!((img.width, img.height, src.raw), (20, 15, false)); + // without a JPEG: a clear error + let e = probe_bytes("A.MRW", b"\0MRM\0\x01\0\0 nothing here").unwrap_err(); + assert!(e.contains("without an embedded preview"), "{e}"); + } + + /// Any failure of a recognised raw (not only an unsupported variant) can fall back to its preview; + /// CR3 keeps its own wording. + #[test] + fn decode_failures_of_recognised_raws_may_fall_back_to_the_preview() { + use lightcraft_raw::RawError; + let cr3 = b"\0\0\0\x18ftypcrx \0\0\0\x01"; + assert_eq!(preview_reason(b"x", RawError::Unsupported("x".into())), Ok("x".to_string())); + assert!(preview_reason(b"x", RawError::Corrupt("bad strip".into())).is_ok_and(|w| w.contains("bad strip"))); + assert!(preview_reason(b"x", RawError::Limit("too big")).is_ok()); + assert!(preview_reason(b"x", RawError::Tiff(lightcraft_tiff::TiffError::MissingTag(256))).is_ok()); + assert!(preview_reason(b"x", RawError::NotRaw).is_err()); + assert!(preview_reason(cr3, RawError::Corrupt("bad".into())).is_ok_and(|w| w.starts_with("CR3 "))); + } + #[test] fn quick_jobs_show_embedded_previews_then_cached_renders() { use crate::media::QuickSource; diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 4895eee73..282d93b79 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -33,7 +33,8 @@ use crate::media::ProbeInfo; /// File extensions LightCraft imports (lower case). pub const EXTENSIONS: &[&str] = &[ "jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "psd", "jxl", - "gif", "bmp", "heic", "avif", + "gif", "bmp", "heic", "avif", // containers LightCraft cannot decode but imports as preview only (their embedded JPEG) + "iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf", ]; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -48,9 +49,34 @@ pub enum ImportMode { Move, } +/// What an import does with a file the library already has in Recently Deleted. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum OnDeleted { + /// Leave it there; the report says so ([`Duplicate::existing_deleted`]). + #[default] + Skip, + /// Bring the trashed photo back, with its edits ([`ImportReport::restored`]). + Restore, + /// Delete the trashed record permanently and import the file as a new photo. + Fresh, +} + +impl OnDeleted { + pub fn parse(s: &str) -> Option { + match s { + "skip" => Some(Self::Skip), + "restore" => Some(Self::Restore), + "fresh" => Some(Self::Fresh), + _ => None, + } + } +} + /// What an import does besides adding the photos. #[derive(Clone, Debug, Default)] pub struct ImportOptions { + /// A file that is in Recently Deleted: skip (default), restore, or import afresh. + pub on_deleted: OnDeleted, pub mode: ImportMode, /// Applied to every imported photo (one History entry). pub preset: Option, @@ -147,12 +173,18 @@ pub struct Duplicate { pub existing: Option, /// `"path"` (already imported from there) or `"content"` (same bytes elsewhere). pub reason: &'static str, + /// That photo is in Recently Deleted (see [`OnDeleted`]). + #[serde(rename = "existingDeleted", skip_serializing_if = "std::ops::Not::not")] + pub existing_deleted: bool, } #[derive(Clone, Debug, Default, Serialize)] pub struct ImportReport { pub imported: Vec, pub duplicates: Vec, + /// [`OnDeleted::Restore`]: photos brought back from Recently Deleted instead of imported. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub restored: Vec, /// Move: the originals that were moved (and removed from the source). #[serde(skip_serializing_if = "Vec::is_empty")] pub moved: Vec, @@ -286,32 +318,96 @@ pub fn is_supported(path: &Path) -> bool { } /// Expand files and folders (recursively) into supported files. `skip` (e.g. the library folder) -/// is never descended into. +/// is never descended into. Each folder's walk is bounded ([`crate::walk::Limits::default`]); a +/// walk that stops at a bound is logged. pub fn expand(paths: &[String], skip: Option<&Path>) -> Vec { - fn walk(p: &Path, skip: Option<&Path>, out: &mut Vec, top: bool) { - let hidden = p.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')); - if (hidden && !top) || skip.is_some_and(|s| p == s) { - return; + expand_within(paths, skip, crate::walk::Limits::default()).0 +} + +/// [`expand`] with the walk bounded by `limits` → (files, whether a walk stopped at a bound). +pub fn expand_within(paths: &[String], skip: Option<&Path>, limits: crate::walk::Limits) -> (Vec, bool) { + let mut out = Vec::new(); + let mut truncated = false; + for p in paths { + let p = Path::new(p); + if skip.is_some_and(|s| p == s) { + continue; } if p.is_dir() { - let Ok(rd) = std::fs::read_dir(p) else { return }; - let mut v: Vec = rd.flatten().map(|e| e.path()).collect(); - v.sort(); - for c in v { - walk(&c, skip, out, false); + let w = crate::walk::files_in(p, skip, limits, is_supported); + if w.truncated { + log::warn!( + "import: stopped looking in {} after {} entries (limits: {} entries, {} folder levels)", + p.display(), + w.entries, + limits.max_entries, + limits.max_depth + ); } - } else if top || is_supported(p) { + truncated |= w.truncated; + out.extend(w.files.into_iter().map(|f| f.to_string_lossy().to_string())); + } else { // explicitly named files are attempted even with an unknown extension (sniffed) out.push(p.to_string_lossy().to_string()); } } - let mut out = Vec::new(); - for p in paths { - walk(Path::new(p), skip, &mut out, true); - } let mut seen = std::collections::HashSet::new(); out.retain(|p| seen.insert(p.clone())); - out + (out, truncated) +} + +/// What a path given on the command line at launch becomes ([`launch_path`]). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum LaunchPath { + /// Import this (absolute) file or folder, in the background. + Import(String), + /// Not imported: `path` (absolute) is a whole drive or the whole home folder, which is never + /// walked without being chosen in the app (issue #374). + Refused { path: String, why: String }, +} + +/// Resolve a launch argument for importing (issue #374). An empty argument is ignored (`None`); +/// a relative one (`.`, `..`, `photos`; `/` or `\` on Windows, the current drive's root) is +/// resolved against `cwd` — the working folder a launcher picked, often a drive root or +/// `C:\Windows\System32` — and refused when that leaves no folder (`cwd` unknown). A drive +/// or file-system root is replaced by its camera folder (`DCIM`, from `dcim`: a memory card) or +/// refused, and so is the home folder `home`: either would walk most of the disk. +pub fn launch_path(arg: &str, cwd: Option<&Path>, home: Option<&Path>, dcim: impl Fn(&Path) -> Option) -> Option { + if arg.trim().is_empty() { + return None; + } + let given = Path::new(arg); + let joined = if given.is_absolute() { + given.to_path_buf() + } else { + match cwd { + Some(c) => c.join(given), + None => return Some(LaunchPath::Refused { path: arg.to_string(), why: "a relative path, and the working folder is unknown".into() }), + } + }; + // `.` and `..` resolved by name (no file-system access) + let mut abs = PathBuf::new(); + for c in joined.components() { + match c { + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + abs.pop(); + } + other => abs.push(other.as_os_str()), + } + } + let shown = abs.to_string_lossy().to_string(); + if abs.parent().is_none() { + return Some(match dcim(&abs) { + Some(d) => LaunchPath::Import(d.to_string_lossy().to_string()), + None => LaunchPath::Refused { path: shown, why: "a whole drive".into() }, + }); + } + let key = lightcraft_catalog::query::folder_key; + if home.is_some_and(|h| key(&h.to_string_lossy()) == key(&shown)) { + return Some(LaunchPath::Refused { path: shown, why: "the whole home folder".into() }); + } + Some(LaunchPath::Import(shown)) } /// Probe files (headers + content hash) as an import would. @@ -334,7 +430,7 @@ fn probe_all(probe: Option<&crate::media::FileProbe>, paths: &[String], progress }; #[cfg(not(target_arch = "wasm32"))] { - let n = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(1, 8).min(paths.len().max(1)); + let n = workers(paths.len()); if n > 1 { let mut results: Vec>> = vec![None; paths.len()]; let next = std::sync::atomic::AtomicUsize::new(0); @@ -373,18 +469,16 @@ fn probe_all(probe: Option<&crate::media::FileProbe>, paths: &[String], progress .collect() } -/// Copy `src` into `root`/`folders` as `name` (default: its own name), made unique with -1, -2…; -/// returns the new path. The copy is verified ([`crate::import_move::copy_new`]: a new file, -/// synced and checked against `probe_hash`, the content hash the probe computed — or compared -/// byte for byte with the source when there is none); a bad one is removed and the photo -/// reported as failed. -fn copy_into(root: &Path, src: &str, folders: &[String], name: Option<&str>, probe_hash: Option<&str>) -> Result { - let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f)); +/// Copy `src` into `dir` as `name` (default: its own name), made unique with -1, -2…; returns the +/// new path. The copy is verified ([`crate::import_move::copy_new`]: a new file, synced and +/// checked against `probe_hash`, the content hash the probe computed — or compared byte for byte +/// with the source when there is none); a bad one is removed and the photo reported as failed. +fn copy_into(dir: &Path, src: &str, name: Option<&str>, probe_hash: Option<&str>) -> Result { let name = name .map(str::to_string) .unwrap_or_else(|| Path::new(src).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "photo".into())); let expect = probe_hash.and_then(lightcraft_preview::Hash128::parse); - crate::import_move::copy_new(Path::new(src), &dir, &name, expect).map(|p| p.to_string_lossy().to_string()) + crate::import_move::copy_new(Path::new(src), dir, &name, expect).map(|p| p.to_string_lossy().to_string()) } /// What a [`scan`] needs from the session, so it can run on another thread (a folder on a network @@ -610,6 +704,50 @@ impl Prepared { pub fn is_empty(&self) -> bool { self.items.is_empty() } + + /// Recheck Add-only entries against the owner catalog immediately before commit. + /// Preparation may have run while another import added the same path or content. + pub fn revalidate_add(&mut self, s: &Session) { + let mut paths = HashMap::new(); + let mut hashes = HashMap::new(); + for photo in s.catalog.photos() { + if let Source::File { path } = &photo.source { + paths.insert(normalize_import_path(path), photo.id); + } + if let Some(hash) = &photo.content_hash { + hashes.insert(hash.clone(), photo.id); + } + } + let mut items = Vec::with_capacity(self.items.len()); + for item in std::mem::take(&mut self.items) { + let PreparedItem::Ready(ready) = item else { + items.push(item); + continue; + }; + let ReadyFile { path, stored, info, sidecar, placed } = *ready; + let existing_path = paths.get(&normalize_import_path(&stored)).copied().or_else(|| paths.get(&normalize_import_path(&path)).copied()); + let existing_hash = info.content_hash.as_ref().and_then(|hash| hashes.get(hash).copied()); + if let Some(existing) = existing_path.or(existing_hash) { + if let Some(placed) = &placed { + crate::import_move::rollback(placed); + } + items.push(PreparedItem::Duplicate { + path, + existing: Some(existing), + reason: if existing_path.is_some() { "path" } else { "content" }, + hash: info.content_hash, + }); + } else { + items.push(PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed }))); + } + } + self.items = items; + } +} + +fn normalize_import_path(path: &str) -> String { + let path = path.replace('\\', "/"); + if cfg!(windows) { path.to_lowercase() } else { path } } impl ImportJob { @@ -638,6 +776,10 @@ impl ImportJob { let mut by_path = HashMap::new(); let mut by_hash = HashMap::new(); for p in s.catalog.photos() { + // a trashed photo is replaced by a fresh import, so it doesn't count as a duplicate + if p.deleted && opts.on_deleted == OnDeleted::Fresh { + continue; + } if let Source::File { path } = &p.source { by_path.insert(path.clone(), (p.id, p.local)); } @@ -664,8 +806,9 @@ impl ImportJob { } /// The file-system half of importing `paths`: expand folders, skip known paths, probe, skip - /// duplicates by content, read sidecars, copy or place (Move) the files. Nothing in the - /// catalog changes. Stops before the next file once `cancel` is set. + /// duplicates by content, read sidecars, copy or place (Move) the files — several at once, + /// with the names and outcomes of one after another. Nothing in the catalog changes. Stops + /// before the next file once `cancel` is set. pub fn prepare(&mut self, paths: &[String], cancel: &std::sync::atomic::AtomicBool) -> Prepared { let files = self.expand(paths); self.prepare_files(files, cancel) @@ -715,24 +858,39 @@ impl ImportJob { let probed: Vec> = cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect(); crate::memory::release(); + // Files are planned one by one, in order (duplicates, sidecars, names, folders); their + // copies or moves — the slow part, reading and writing every byte and verifying it — are + // collected and run on several threads (issue #367), then their outcomes take their places. + let mut plan = Plan::default(); for (path, info) in todo.into_iter().zip(probed) { if cancel.load(Relaxed) { break; } - self.done.fetch_add(1, Relaxed); let mut info = match info { Ok(i) => i, Err(e) => { log::warn!("import {path}: {e}"); - out.items.push(PreparedItem::Failed(path, e)); + self.done.fetch_add(1, Relaxed); + plan.slots.push(Slot::Item(PreparedItem::Failed(path, e))); continue; } }; + // a copy of a file whose transfer is still pending: a duplicate only if that one succeeds + if !opts.local + && let Some(h) = &info.content_hash + && plan.transfers.iter().any(|t| t.info.content_hash.as_ref() == Some(h)) + { + self.run_plan(&mut plan, &mut out, cancel); + if cancel.load(Relaxed) { + break; + } + } if let Some(h) = &info.content_hash && !opts.local && let Some(existing) = self.by_hash.get(h) { - out.items.push(PreparedItem::Duplicate { path, existing: *existing, reason: "content", hash: Some(h.clone()) }); + self.done.fetch_add(1, Relaxed); + plan.slots.push(Slot::Item(PreparedItem::Duplicate { path, existing: *existing, reason: "content", hash: Some(h.clone()) })); continue; } // the XMP sidecar (or a raw's embedded XMP), read before copying: its capture time files @@ -751,7 +909,6 @@ impl ImportJob { if info.captured.is_none() { info.captured = sidecar.as_ref().and_then(|sc| sc.captured.clone()); } - let mut placed = None; let stored = match (mode, &self.copy_root) { (ImportMode::Copy | ImportMode::Move, Some(root)) if !crate::import_move::inside(Path::new(&path), root) @@ -768,37 +925,35 @@ impl ImportJob { n }); let folders = opts.organize.folders(&q); - if mode == ImportMode::Move && !crate::import_move::is_symlink(Path::new(&path)) { - let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f)); - let name = name.unwrap_or(own); - match crate::import_move::place(Path::new(&path), &dir, &name) { - Ok(pl) => { - let dst = pl.dst.to_string_lossy().to_string(); - placed = Some(pl); - dst - } - Err(e) => { - out.items.push(PreparedItem::Failed(path, e)); - continue; - } + let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f)); + // the name this file gets depends on what a pending transfer of a like-named file + // into the same folder ends up with: that one goes first + let as_named = name.as_deref().unwrap_or(if own.is_empty() { "photo" } else { own.as_str() }); + let family = (dir.to_string_lossy().to_lowercase(), name_family(as_named)); + if plan.transfers.iter().any(|t| t.family == family) { + self.run_plan(&mut plan, &mut out, cancel); + if cancel.load(Relaxed) { + break; } + } + let how = if mode == ImportMode::Move && !crate::import_move::is_symlink(Path::new(&path)) { + How::Place { name: name.unwrap_or(own) } } else { if mode == ImportMode::Move { let reason = "a symbolic link: the file it points to was copied, the link and its target stay"; - out.items.push(PreparedItem::Kept(Kept { path: path.clone(), reason: reason.into() })); + plan.slots.push(Slot::Item(PreparedItem::Kept(Kept { path: path.clone(), reason: reason.into() }))); } - match copy_into(root, &path, &folders, name.as_deref(), info.content_hash.as_deref()) { - Ok(p) => p, - Err(e) => { - out.items.push(PreparedItem::Failed(path, e)); - continue; - } - } - } + How::Copy { name } + }; + // Copy as DNG: the copied raw becomes a DNG (the copy is ours to replace) + let dng = opts.convert_dng && mode == ImportMode::Copy && raw && !info.format.eq_ignore_ascii_case("DNG"); + plan.slots.push(Slot::Transfer); + plan.transfers.push(Transfer { path, info, sidecar, dir, how, dng, family }); + continue; } (ImportMode::Move, _) => { let reason = "already inside the destination or the library: added where it is"; - out.items.push(PreparedItem::Kept(Kept { path: path.clone(), reason: reason.into() })); + plan.slots.push(Slot::Item(PreparedItem::Kept(Kept { path: path.clone(), reason: reason.into() }))); path.clone() } _ => path.clone(), @@ -806,27 +961,199 @@ impl ImportJob { if let Some(h) = &info.content_hash { self.by_hash.insert(h.clone(), None); } - // Copy as DNG: the copied raw becomes a DNG (the copy is ours to replace) - let mut stored = stored; - if opts.convert_dng - && mode == ImportMode::Copy - && stored != path - && info.kind == MediaKind::Raw - && !info.format.eq_ignore_ascii_case("DNG") - { - match crate::cmd::convert::write_dng_with(self.file_bytes.as_ref(), &stored, String::new()) { - Ok(dng) => { - let _ = std::fs::remove_file(&stored); - stored = dng; - info.format = "DNG".into(); - } - Err(e) => log::warn!("import {path}: copy as DNG: {e}"), - } - } - out.items.push(PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed }))); + self.done.fetch_add(1, Relaxed); + plan.slots.push(Slot::Item(PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed: None })))); } + self.run_plan(&mut plan, &mut out, cancel); out } + + /// Run the transfers `plan` collected (on several threads) and append its items to `out` in + /// file order. Transfers not started because `cancel` was set leave no item. + fn run_plan(&mut self, plan: &mut Plan, out: &mut Prepared, cancel: &std::sync::atomic::AtomicBool) { + let mut done = run_transfers(std::mem::take(&mut plan.transfers), self.file_bytes.as_ref(), cancel, &self.done).into_iter(); + for slot in std::mem::take(&mut plan.slots) { + let item = match slot { + Slot::Item(it) => it, + Slot::Transfer => match done.next().flatten() { + Some(it) => it, + None => continue, + }, + }; + if let PreparedItem::Ready(r) = &item + && let Some(h) = &r.info.content_hash + { + self.by_hash.insert(h.clone(), None); + } + out.items.push(item); + } + } +} + +/// A batch's items in file order while its transfers are pending ([`ImportJob::run_plan`]). +#[derive(Default)] +struct Plan { + slots: Vec, + transfers: Vec, +} + +enum Slot { + Item(PreparedItem), + /// The outcome of the next of [`Plan::transfers`]. + Transfer, +} + +/// A file to copy or move into `dir`, planned in order and run on any thread ([`run_transfer`]). +struct Transfer { + path: String, + info: ProbeInfo, + sidecar: Option, + dir: PathBuf, + how: How, + /// Copy as DNG. + dng: bool, + /// The folder (lowercase) and [`name_family`]: transfers of one family can take each other's + /// names, so they never run at the same time. + family: (String, String), +} + +enum How { + /// A verified copy, under `name` (default: the file's own). + Copy { name: Option }, + /// Move: placed (linked or copied) without touching the source. + Place { name: String }, +} + +/// The names a file called `name` could end up with, as one key: the transfers number a taken name +/// `IMG-1.JPG`, `IMG-2.JPG`…, and Copy as DNG names its DNG `IMG.dng`, `IMG-2.dng`…, so `IMG.JPG`, +/// `IMG-1.jpg` and `img.dng` are all `img` (case-insensitive: so are some file systems). +fn name_family(name: &str) -> String { + let mut stem = match name.rsplit_once('.') { + Some((s, _)) if !s.is_empty() => s, + _ => name, + }; + while let Some((head, tail)) = stem.rsplit_once('-') + && !head.is_empty() + && !tail.is_empty() + && tail.bytes().all(|b| b.is_ascii_digit()) + { + stem = head; + } + stem.to_lowercase() +} + +/// Threads for `jobs` file reads (probes, copies) at once. +#[cfg(not(target_arch = "wasm32"))] +fn workers(jobs: usize) -> usize { + std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(1, 8).min(jobs.max(1)) +} + +/// Files per batch for a caller that prepares an import in batches ([`ImportJob::prepare_files`]): +/// enough to keep every probe and copy thread busy (a batch waits for its slowest file), few +/// enough that each batch soon joins the catalog. +pub fn batch_size() -> usize { + #[cfg(not(target_arch = "wasm32"))] + { + workers(usize::MAX).saturating_mul(2).clamp(8, 16) + } + #[cfg(target_arch = "wasm32")] + { + 8 + } +} + +/// Run `transfers` (several at once where threads exist); their outcomes in order, `None` for one +/// not started because `cancel` was set. `done` counts each one finished. +fn run_transfers( + transfers: Vec, + file_bytes: Option<&crate::merge::ByteReader>, + cancel: &std::sync::atomic::AtomicBool, + done: &std::sync::atomic::AtomicUsize, +) -> Vec> { + use std::sync::atomic::Ordering::Relaxed; + let one = |t: Transfer| { + if cancel.load(Relaxed) { + return None; + } + let path = t.path.clone(); + // last-resort guard: a panic is this file's failure, not the import's + let item = crate::guard::catch("import", || run_transfer(t, file_bytes)).unwrap_or_else(|e| PreparedItem::Failed(path, e)); + done.fetch_add(1, Relaxed); + Some(item) + }; + #[cfg(not(target_arch = "wasm32"))] + { + let n = workers(transfers.len()); + if n > 1 { + let mut results: Vec> = std::iter::repeat_with(|| None).take(transfers.len()).collect(); + let queue = std::sync::Mutex::new(transfers.into_iter().enumerate()); + let out = std::sync::Mutex::new(&mut results); + // (the tests' injected faults hold for the workers too) + #[cfg(test)] + let fault = crate::import_move::injected_fault(); + let work = &|| { + #[cfg(test)] + crate::import_move::inject(fault); + loop { + let next = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner).next(); + let Some((i, t)) = next else { break }; + let r = one(t); + if let Some(slot) = out.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_mut(i) { + *slot = r; + } + } + }; + std::thread::scope(|sc| { + let handles: Vec<_> = + (0..n).filter_map(|i| std::thread::Builder::new().name(format!("lc-import-copy-{i}")).spawn_scoped(sc, work).ok()).collect(); + if handles.is_empty() { + // no thread to be had: copy here, one after another + work(); + } + for h in handles { + if h.join().is_err() { + log::error!("import: a copy worker stopped unexpectedly"); + } + } + }); + return results; + } + } + transfers.into_iter().map(one).collect() +} + +/// Copy or move one planned file (and Copy as DNG); its item. +fn run_transfer(t: Transfer, file_bytes: Option<&crate::merge::ByteReader>) -> PreparedItem { + let Transfer { path, mut info, sidecar, dir, how, dng, .. } = t; + let mut placed = None; + let mut stored = match how { + How::Place { name } => match crate::import_move::place(Path::new(&path), &dir, &name) { + Ok(pl) => { + let dst = pl.dst.to_string_lossy().to_string(); + placed = Some(pl); + dst + } + Err(e) => return PreparedItem::Failed(path, e), + }, + How::Copy { name } => match copy_into(&dir, &path, name.as_deref(), info.content_hash.as_deref()) { + Ok(p) => p, + Err(e) => return PreparedItem::Failed(path, e), + }, + }; + if dng { + // decoding the raw twice and encoding it takes several times the file's size in memory + let weight = usize::try_from(info.file_size).unwrap_or(usize::MAX).saturating_mul(6); + let _permit = crate::memory::work_gate().acquire(weight); + match crate::cmd::convert::write_dng_with(file_bytes, &stored, String::new()) { + Ok(dng) => { + let _ = std::fs::remove_file(&stored); + stored = dng; + info.format = "DNG".into(); + } + Err(e) => log::warn!("import {path}: copy as DNG: {e}"), + } + } + PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed })) } /// The catalog half of an import: add the photos [`ImportJob::prepare`] readied (one undoable op; @@ -840,6 +1167,20 @@ pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepare let mut placed: Vec = Vec::new(); // photos added by this batch, by content (a duplicate of a file earlier in the import names it) let mut new_hash: HashMap = HashMap::new(); + // Fresh: trashed photos a new import replaces (by content or by file), each removed once + let (mut trash_hash, mut trash_path): (HashMap, HashMap) = Default::default(); + if opts.on_deleted == OnDeleted::Fresh { + for p in s.catalog.photos().filter(|p| p.deleted) { + if let Some(h) = &p.content_hash { + trash_hash.insert(h.clone(), p.id); + } + if let Source::File { path } = &p.source { + trash_path.insert(path.clone(), p.id); + } + } + } + let mut purge: Vec = Vec::new(); + let mut purged: std::collections::HashSet = Default::default(); for it in prepared.items { match it { PreparedItem::Promote(id) => { @@ -853,7 +1194,16 @@ pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepare let h = hash?; new_hash.get(&h).copied().or_else(|| s.catalog.photos().find(|p| p.content_hash.as_deref() == Some(h.as_str())).map(|p| p.id)) }); - report.duplicates.push(Duplicate { path, existing: existing.map(|i| i.0), reason }); + let trashed = existing.filter(|id| s.catalog.photo(*id).is_some_and(|p| p.deleted)); + match trashed { + Some(id) if opts.on_deleted == OnDeleted::Restore => { + if !report.restored.contains(&id.0) { + ops.push(Op::SetDeleted { id, deleted: false }); + report.restored.push(id.0); + } + } + _ => report.duplicates.push(Duplicate { path, existing: existing.map(|i| i.0), reason, existing_deleted: trashed.is_some() }), + } } PreparedItem::Failed(path, e) => report.failed.push((path, e)), PreparedItem::Kept(k) => report.kept.push(k), @@ -864,6 +1214,11 @@ pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepare if let Some(h) = &info.content_hash { new_hash.insert(h.clone(), id); } + for old in info.content_hash.as_ref().and_then(|h| trash_hash.get(h)).into_iter().chain(trash_path.get(&stored)) { + if purged.insert(*old) { + purge.push(*old); + } + } let sidecar = sidecar.map(|sc| sc.resolve_label(&s.catalog)).filter(|sc| *sc != crate::sidecar::SidecarData::default()); // a copy is catalogued under its new name let name = Path::new(&stored).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.clone()); @@ -907,9 +1262,18 @@ pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepare } } } + // (built against the catalog as it is: one op list for all of them, ahead of the additions) + if !purge.is_empty() { + ops.insert(0, s.catalog.delete_photos_permanently_ops(&purge)); + } let log0 = s.pending_log.len(); + let (verb, n) = if report.imported.is_empty() && !report.restored.is_empty() { + ("Restore", report.restored.len()) + } else { + ("Add", report.imported.len().max(1)) + }; if !ops.is_empty() - && let Err(e) = s.commit(&format!("Add {} Photo{}", ops.len(), if ops.len() == 1 { "" } else { "s" }), Op::Batch { ops }) + && let Err(e) = s.commit(&format!("{verb} {n} Photo{}", if n == 1 { "" } else { "s" }), Op::Batch { ops }) { // nothing was catalogued: take the moves back (their sources were never touched) placed.iter().for_each(crate::import_move::rollback); @@ -1021,3 +1385,31 @@ impl Session { self } } + +#[cfg(test)] +mod prepared_tests { + use super::*; + + #[test] + fn revalidate_add_only_converts_matching_ready_item() { + let mut s = Session::new(); + let existing_path = std::env::temp_dir().join("lightcraft-revalidate-existing.jpg"); + let other_path = std::env::temp_dir().join("lightcraft-revalidate-other.jpg"); + let existing = Photo::new(PhotoId(1), Source::File { path: existing_path.to_string_lossy().into() }, "existing.jpg", "JPG", 1, 1, "now"); + s.commit("existing", Op::AddPhoto { photo: Box::new(existing) }).unwrap(); + let ready = |path: &Path| ReadyFile { + path: path.to_string_lossy().into(), + stored: path.to_string_lossy().into(), + info: ProbeInfo { format: "JPG".into(), ..ProbeInfo::default() }, + sidecar: None, + placed: None, + }; + let mut prepared = Prepared { + scanned: 2, + items: vec![PreparedItem::Ready(Box::new(ready(&existing_path))), PreparedItem::Ready(Box::new(ready(&other_path)))], + }; + prepared.revalidate_add(&s); + assert!(matches!(prepared.items.first(), Some(PreparedItem::Duplicate { reason: "path", .. }))); + assert!(matches!(prepared.items.get(1), Some(PreparedItem::Ready(_)))); + } +} diff --git a/crates/engine/src/import_move.rs b/crates/engine/src/import_move.rs index 3465eaa7f..db4421369 100644 --- a/crates/engine/src/import_move.rs +++ b/crates/engine/src/import_move.rs @@ -56,6 +56,9 @@ pub(crate) enum Fault { Corrupt, /// Removing the source fails (as on a read-only card). FailRemove, + /// Each copy takes a little longer and notes the thread it ran on ([`copy_threads`]), to see + /// an import's copies run side by side. + Slow, } #[cfg(test)] @@ -69,6 +72,21 @@ pub(crate) fn inject(f: Fault) { FAULT.with(|c| c.set(f)); } +/// The calling thread's injected fault (tests only), for the workers it starts to inherit. +#[cfg(test)] +pub(crate) fn injected_fault() -> Fault { + FAULT.with(|c| c.get()) +} + +/// The threads copies made under [`Fault::Slow`] ran on (tests only). +#[cfg(test)] +static COPY_THREADS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + +#[cfg(test)] +pub(crate) fn copy_threads() -> Vec { + COPY_THREADS.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone() +} + fn injected(f: Fault) -> bool { #[cfg(test)] { @@ -284,6 +302,11 @@ pub(crate) fn copy_verified(src: &Path, dst: &Path, expect: Option, } +/// The one photo an undo step changes, when it changes exactly one through its edit, rating, flag, +/// label, metadata or versions (`depth` bounds nested batches). +fn single_photo(op: &Op, depth: usize) -> Option { + match op { + Op::SetDevelop { id, .. } + | Op::SetRating { id, .. } + | Op::SetFlag { id, .. } + | Op::SetLabel { id, .. } + | Op::SetMeta { id, .. } + | Op::SetVersions { id, .. } + | Op::SetHistory { id, .. } + | Op::PushHistory { id, .. } => Some(*id), + Op::Batch { ops } if depth < 8 => { + let mut ids = ops.iter().map(|o| single_photo(o, depth + 1)); + let first = ids.next()??; + ids.all(|id| id == Some(first)).then_some(first) + } + _ => None, + } +} + /// A folder renamed or moved on disk as part of an undo step. #[derive(Clone, Debug, PartialEq, Eq)] pub struct FolderMove { @@ -134,6 +161,9 @@ pub struct Session { pub clipboard: Option, /// The folder on disk the [`LibrarySource::Folder`] view browses. pub browse: Option, + /// The folder the [`LibrarySource::LibraryFolder`] view shows: the library's photos imported + /// from it and from the folders inside it. + pub library_folder: Option, /// Copied metadata (`photo.copyMetadata`): photo.setMeta params. pub meta_clipboard: Option, /// The photo that was active before the current one (Paste Settings from Previous). @@ -161,6 +191,8 @@ pub struct Session { pub active_spot: Option, /// The persistent library this session writes to (`None` = in-memory only). pub library: Option, + /// Identity of current library opening; refreshed only after a successful open. + pub(crate) library_identity: Arc<()>, /// XMP sidecar preferences (persisted with the library). pub xmp: sidecar::XmpPrefs, /// Parameters of the last export (`app.export` params, minus targets), persisted in prefs.json. @@ -234,6 +266,7 @@ impl Session { clipboard: None, meta_clipboard: None, browse: None, + library_folder: None, previous_active: None, copy_groups: lightcraft_develop::SettingsGroup::default_copy(), presets: presets::builtin(), @@ -248,6 +281,7 @@ impl Session { segmenter: segment::Segmenter::default(), active_spot: None, library: None, + library_identity: Arc::new(()), xmp: sidecar::XmpPrefs::default(), last_export: None, export_presets: Vec::new(), @@ -423,6 +457,7 @@ impl Session { return Err(err); } }; + self.show_undone(&e.op); self.pending_log.push(e.op); self.redo.push(UndoEntry { label: e.label.clone(), op: redo, folder: e.folder.as_ref().map(FolderMove::reversed) }); Ok(e.label) @@ -437,11 +472,30 @@ impl Session { return Err(err); } }; + self.show_undone(&e.op); self.pending_log.push(e.op); self.undo.push(UndoEntry { label: e.label.clone(), op: undo, folder: e.folder.as_ref().map(FolderMove::reversed) }); Ok(e.label) } + /// After an undo or redo that changed one photo (its edit, rating, flag, label, metadata or + /// versions), that photo becomes the active one, as in Lightroom Classic, so the change is on + /// screen (issue #293). It joins the selection if it isn't in it; nothing changes for steps + /// that touch several photos, albums or files, or for a photo not in the current view. + fn show_undone(&mut self, op: &Op) { + let Some(id) = single_photo(op, 0) else { return }; + if self.selection.active == Some(id) || !self.visible().contains(&id) { + return; + } + if !self.selection.contains(id) { + self.selection = Selection { ids: vec![id], active: Some(id) }; + } else { + self.selection.active = Some(id); + } + self.active_mask = None; + self.active_spot = None; + } + /// Apply an undo/redo op, first renaming the step's folder and moving the files its renames /// imply (all or nothing). Files that can't be moved back after a failure are reported and the /// library follows them (a logged change outside the undo history), so none goes missing; the @@ -590,7 +644,8 @@ impl Session { /// Photos shown in the grid/filmstrip for the current source, filter and sort. pub fn visible(&mut self) -> &[PhotoId] { - let mut key = (self.catalog.revision, format!("{:?}|{:?}|{:?}|{:?}", self.source, self.filter, self.sort, self.browse)); + let mut key = + (self.catalog.revision, format!("{:?}|{:?}|{:?}|{:?}|{:?}", self.source, self.filter, self.sort, self.browse, self.library_folder)); 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())); @@ -605,7 +660,18 @@ impl Session { f.folder = Some(b.path); f.subfolders = b.subfolders; } + 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); + 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 + self.source = LibrarySource::All; + self.library_folder = None; + return self.visible(); + } if matches!(self.source, LibrarySource::Album(_)) && self.sort.key == lightcraft_catalog::SortKey::CaptureDate && let LibrarySource::Album(a) = self.source @@ -642,13 +708,20 @@ impl Session { &self.visible } + /// Whether the library still holds a photo imported from the folder a `LibraryFolder` source + /// shows. + fn folder_holds_photos(&self) -> bool { + let f = Filter { library_folder: self.library_folder.clone(), ..Default::default() }; + self.library_folder.is_some() && !self.catalog.query(&f, &Sort::default()).is_empty() + } + /// Photos in the current source (folder, album, …) before the filter bar and search narrow /// them; `None` where that is not a separate number (Missing Photos). pub fn source_total(&mut self) -> Option { if self.source == LibrarySource::Missing { return None; } - let key = (self.catalog.revision, format!("{:?}|{:?}", self.source, self.browse)); + 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); if self.source == LibrarySource::Folder { @@ -656,6 +729,9 @@ impl Session { f.folder = Some(b.path); f.subfolders = b.subfolders; } + 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(); self.total = Some((key, n)); } @@ -709,6 +785,8 @@ mod tests_color; #[cfg(test)] mod tests_export; #[cfg(test)] +mod tests_folders; +#[cfg(test)] mod tests_forget_local; #[cfg(test)] mod tests_import; diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 6a63e378e..c1b91c4cd 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -102,6 +102,8 @@ struct PresetsFile { struct ViewFile { source: LibrarySource, browse: Option, + /// The folder a `libraryFolder` source shows. + library_folder: Option, // No filter: a library opens unfiltered. A date, keyword or person left over from the last session // would silently hide photos, with only a small badge to say so. sort: lightcraft_catalog::Sort, @@ -309,25 +311,12 @@ impl Session { self.pending_log.clear(); self.selection = Selection::default(); self.source = LibrarySource::All; + self.library_folder = None; if report.created && seed_demo { crate::demo::load(self); journal.snapshot(&self.catalog)?; } let mut settings = SettingsLoad::default(); - // presets - if let Some(f) = settings.read::(files.as_mut(), "presets.json") { - for p in &mut self.presets { - p.favorite = p.builtin && f.favorites.contains(&p.id) || (!p.builtin && p.favorite); - } - for u in f.user { - if !self.presets.iter().any(|p| p.id == u.id) { - self.presets.push(u); - } - } - let known = |id: &String| crate::presets::profile(id).is_some(); - self.profile_favorites = f.profile_favorites.into_iter().filter(known).collect(); - self.profile_recent = f.profile_recent.into_iter().filter(known).take(crate::presets::RECENT_PROFILES).collect(); - } // preferences let prefs = settings.read::(files.as_mut(), "prefs.json").unwrap_or_default(); self.xmp = prefs.xmp; @@ -349,10 +338,34 @@ impl Session { if let Some(d) = &self.smart_previews_dir { self.media.smart_dir = Some(d.clone()); } + // presets + if let Some(f) = settings.read::(files.as_mut(), "presets.json") { + for p in &mut self.presets { + p.favorite = p.builtin && f.favorites.contains(&p.id) || (!p.builtin && p.favorite); + } + for u in f.user { + if !self.presets.iter().any(|p| p.id == u.id) { + self.presets.push(u); + } + } + let known = |id: &String| crate::presets::profile(id).is_some() || self.lut_profiles.iter().any(|p| &p.id == id); + self.profile_favorites = f.profile_favorites.into_iter().filter(known).collect(); + self.profile_recent = f.profile_recent.into_iter().filter(known).take(crate::presets::RECENT_PROFILES).collect(); + } // view state if let Some(v) = settings.read::(files.as_mut(), "view.json") { self.source = v.source; self.browse = v.browse; + self.library_folder = v.library_folder.filter(|f| !f.trim().is_empty()); + if self.source == LibrarySource::LibraryFolder { + // a folder that is gone (or none): everything, not an empty grid + let f = lightcraft_catalog::Filter { library_folder: self.library_folder.clone(), ..Default::default() }; + let any = self.library_folder.is_some() && !self.catalog.query(&f, &lightcraft_catalog::Sort::default()).is_empty(); + if !any { + self.source = LibrarySource::All; + self.library_folder = None; + } + } self.sort = v.sort; self.selection = v.selection; self.selection.ids.retain(|id| self.catalog.photo(*id).is_some()); @@ -382,7 +395,7 @@ impl Session { forgot_local: None, presets_written, view_written, - settings_warnings: settings.warnings, + settings_warnings: unlocked_warning(lock.as_ref()).into_iter().chain(settings.warnings).collect(), warnings_reported: 0, blocked: settings.blocked, lock: lock.take(), @@ -399,6 +412,7 @@ impl Session { lib.forgot_local = Some(plan); } } + self.library_identity = std::sync::Arc::new(()); Ok(()) } @@ -491,16 +505,27 @@ impl Session { lib.retry_at = None; lib.last_error = None; } - match (persisted, snapshot) { + let result = match (persisted, snapshot) { (_, Ok(())) => Ok(()), // neither the log nor the snapshot took the queued ops (Err(EngineError::NotSaved(e)), Err(s)) if lib.journal.seq() == before => Err(EngineError::NotSaved(format!("{e}; snapshot: {s}"))), (_, Err(s)) => Err(s.into()), + }; + if result.is_ok() { + self.library_identity = std::sync::Arc::new(()); } + result } fn view_json(&self) -> Vec { - let view = ViewFile { source: self.source, browse: self.browse.clone(), sort: self.sort, selection: self.selection.clone() }; + let view = ViewFile { + source: self.source, + browse: self.browse.clone(), + // only while it is shown: a leftover would be a stale choice nobody made + library_folder: self.library_folder.clone().filter(|_| self.source == LibrarySource::LibraryFolder), + sort: self.sort, + selection: self.selection.clone(), + }; serde_json::to_vec_pretty(&view).unwrap_or_default() } @@ -550,7 +575,8 @@ impl Session { lib.files.write_atomic("prefs.json", &v).map_err(|e| EngineError::Other(format!("prefs: {e}"))) } - /// Settings-file warnings of the open library not handed out yet (the UI shows each once). + /// Warnings about the open library not handed out yet: its settings files, and a lock that + /// couldn't be taken (the UI shows each once; the CLI and MCP print them on stderr). pub fn take_library_warnings(&mut self) -> Vec { let Some(lib) = self.library.as_mut() else { return vec![] }; let new = lib.settings_warnings[lib.warnings_reported..].to_vec(); @@ -605,3 +631,15 @@ impl Session { Ok(()) } } + +/// The warning for a library opened without its one-program-at-a-time lock (issue #171): its +/// file system can't lock `catalog.lock` (some network shares). It opens anyway, as documented — +/// refusing would lock the user out — but the user must learn that a second program could open it. +fn unlocked_warning(lock: Option<&LibraryLock>) -> Option { + lock.filter(|l| !l.held()).map(|_| { + "This library could not be locked (its catalog.lock file can't be locked where it is stored, e.g. on some network \ + shares), so it is open without protection against a second program: use it in one LightCraft app or command at \ + a time, or changes made in one of them can be lost." + .to_string() + }) +} diff --git a/crates/engine/src/lightroom_archive.rs b/crates/engine/src/lightroom_archive.rs new file mode 100644 index 000000000..0dceb421d --- /dev/null +++ b/crates/engine/src/lightroom_archive.rs @@ -0,0 +1,685 @@ +//! Bounded, deduplicated persistence for Lightroom import source data. +//! +//! Archives use a deterministic JSON representation wrapped in zlib. The JSON +//! writer streams through a bounded compressor, so an import cannot first build +//! an unbounded archive buffer. Blob columns are omitted by the catalog reader; +//! decoded XMP, +//! settings, history, snapshots, identities, paths, collections & membership +//! remain available for recovery. + +use std::collections::BTreeMap; +use std::fs::{self, File}; +use std::io::{self, Read, Write}; +use std::path::{Path, PathBuf}; + +use miniz_oxide::deflate::{ + core::{CompressorOxide, create_comp_flags_from_zip_params}, + stream::deflate, +}; +use miniz_oxide::{MZFlush, MZStatus}; +use serde::ser::{Serialize, SerializeMap, SerializeSeq, SerializeStruct, Serializer}; +use serde_json::Value; + +use crate::lightroom_catalog::{CatalogImport, CatalogPhoto}; + +/// Maximum size of one complete archive on disk, including format header. +pub const MAX_ARCHIVE_BYTES: u64 = 32 * 1024 * 1024; +/// Maximum total size retained in managed archives. +pub const MAX_TOTAL_ARCHIVE_BYTES: u64 = 128 * 1024 * 1024; +/// Maximum number of managed archives retained. +pub const MAX_MANAGED_ARCHIVES: usize = 8; + +const FORMAT_VERSION: u8 = 1; +const HEADER: &[u8] = b"LC-LRARCH\0\x01Z"; +const NAME_PREFIX: &str = "lightroom-import-"; +const NAME_SUFFIX: &str = ".lca"; +const MAX_SCAN: usize = 1024; +const IO_BUFFER: usize = 16 * 1024; + +/// Store compact source data below `directory` and return its managed archive. +/// +/// The operation is safe to call before catalog mutation. It rejects a +/// symlink directory, writes only `lightroom-import-.lca`, compares the +/// complete bounded byte stream with recent managed archives, and retains at +/// most [`MAX_MANAGED_ARCHIVES`] and [`MAX_TOTAL_ARCHIVE_BYTES`]. Legacy +/// `lightroom-import-*.json` files are deliberately ignored and preserved for +/// an explicit caller warning. +pub fn store(directory: &Path, data: &CatalogImport) -> Result { + prepare_directory(directory)?; + let mut managed = managed_files(directory)?; + managed.sort_by_key(|entry| entry.index); + + let temporary = temporary_path(directory)?; + write_archive(&temporary, data).inspect_err(|_| { + let _ = remove_managed_file(&temporary); + })?; + + let recent = managed.iter().rev().take(MAX_MANAGED_ARCHIVES); + for entry in recent { + let same = match same_bounded(&temporary, &entry.path) { + Ok(same) => same, + Err(error) => { + let _ = remove_managed_file(&temporary); + return Err(error.to_string()); + } + }; + if same { + remove_managed_file(&temporary).map_err(|e| e.to_string())?; + let matched = entry.path.clone(); + let mut all = managed.clone(); + prune_existing(directory, &mut all, Some(&matched))?; + return Ok(matched); + } + } + + let index = managed.last().map_or(0, |entry| entry.index.saturating_add(1)); + if managed.last().is_some_and(|entry| entry.index == u64::MAX) { + let _ = remove_managed_file(&temporary); + return Err("managed Lightroom archive index is exhausted".into()); + } + let destination = match next_destination(directory, index) { + Ok(path) => path, + Err(error) => { + let _ = remove_managed_file(&temporary); + return Err(error); + } + }; + let size = match fs::metadata(&temporary) { + Ok(meta) => meta.len(), + Err(error) => { + let _ = remove_managed_file(&temporary); + return Err(error.to_string()); + } + }; + let new_entry = Managed { index, path: destination.clone(), size }; + let mut after = managed; + after.push(new_entry); + after.sort_by_key(|entry| entry.index); + let retirees = match retirement_set(&after) { + Ok(retirees) => retirees, + Err(error) => { + let _ = remove_managed_file(&temporary); + return Err(error); + } + }; + let quarantined = match quarantine(&retirees) { + Ok(quarantined) => quarantined, + Err(error) => { + let _ = remove_managed_file(&temporary); + return Err(error); + } + }; + + if let Err(error) = publish_new(&temporary, &destination) { + rollback_quarantine(&quarantined); + let _ = remove_managed_file(&temporary); + return Err(error.to_string()); + } + if let Err(error) = remove_managed_file(&temporary) { + return Err(format!("archive published but temporary cleanup failed: {error}")); + } + for (old, held) in quarantined { + if let Err(error) = remove_managed_file(&held) { + return Err(format!("archive published but could not retire {}: {error}", old.display())); + } + } + Ok(destination) +} + +/// Store source data when possible. An archive that reaches its bounded size is +/// omitted with a warning so catalog import can still complete. +pub fn store_best_effort(directory: &Path, data: &CatalogImport) -> Result, String> { + match store(directory, data) { + Ok(path) => Ok(Some(path)), + Err(error) if error.contains("Lightroom archive exceeds 32 MiB") => Ok(None), + Err(error) => Err(error), + } +} + +#[derive(Clone, Debug)] +struct Managed { + index: u64, + path: PathBuf, + size: u64, +} + +fn prepare_directory(directory: &Path) -> Result<(), String> { + match fs::symlink_metadata(directory) { + Ok(meta) => { + if meta.file_type().is_symlink() || !meta.is_dir() { + return Err(format!("archive directory is not a real directory: {}", directory.display())); + } + } + Err(error) if error.kind() == io::ErrorKind::NotFound => { + fs::create_dir_all(directory).map_err(|e| e.to_string())?; + let meta = fs::symlink_metadata(directory).map_err(|e| e.to_string())?; + if meta.file_type().is_symlink() || !meta.is_dir() { + return Err(format!("archive directory is not a real directory: {}", directory.display())); + } + } + Err(error) => return Err(error.to_string()), + } + Ok(()) +} + +fn managed_files(directory: &Path) -> Result, String> { + let mut files = Vec::new(); + for (seen, result) in fs::read_dir(directory).map_err(|e| e.to_string())?.enumerate() { + if seen >= MAX_SCAN { + return Err(format!("too many entries in archive directory (limit {MAX_SCAN})")); + } + let entry = result.map_err(|e| e.to_string())?; + let Some(index) = parse_managed_name(&entry.file_name()) else { continue }; + let meta = fs::symlink_metadata(entry.path()).map_err(|e| e.to_string())?; + if meta.file_type().is_symlink() || !meta.is_file() { + continue; + } + files.push(Managed { index, path: entry.path(), size: meta.len() }); + } + Ok(files) +} + +fn parse_managed_name(name: &std::ffi::OsStr) -> Option { + let name = name.to_str()?; + let digits = name.strip_prefix(NAME_PREFIX)?.strip_suffix(NAME_SUFFIX)?; + if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + digits.parse().ok() +} + +fn temporary_path(directory: &Path) -> Result { + let pid = std::process::id(); + for slot in 0..MAX_SCAN { + let path = directory.join(format!(".lightroom-import-{pid}-{slot}.tmp")); + match fs::symlink_metadata(&path) { + Ok(_) => continue, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(path), + Err(error) => return Err(error.to_string()), + } + } + Err("no safe temporary archive name available".into()) +} + +fn next_destination(directory: &Path, mut index: u64) -> Result { + for _ in 0..MAX_SCAN { + let path = directory.join(format!("{NAME_PREFIX}{index}{NAME_SUFFIX}")); + match fs::symlink_metadata(&path) { + Ok(_) => index = index.checked_add(1).ok_or("managed archive index is exhausted")?, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(path), + Err(error) => return Err(error.to_string()), + } + } + Err("no safe managed archive name available".into()) +} + +fn write_archive(path: &Path, data: &CatalogImport) -> Result<(), String> { + lightcraft_catalog::safe_file::write_atomic_with(path, &mut |raw| { + let mut bounded = BoundedWriter { inner: raw, total: 0 }; + bounded.write_all(HEADER)?; + let mut compressor = ZlibWriter::new(&mut bounded); + serde_json::to_writer(&mut compressor, &ArchiveRef(data)).map_err(json_io)?; + compressor.finish()?; + Ok(()) + }) + .map(|_| ()) + .map_err(|error| format!("could not write Lightroom archive: {error}")) +} + +fn json_io(error: serde_json::Error) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, error.to_string()) +} + +struct BoundedWriter<'a> { + inner: &'a mut dyn Write, + total: u64, +} + +impl Write for BoundedWriter<'_> { + fn write(&mut self, bytes: &[u8]) -> io::Result { + let next = self.total.checked_add(bytes.len() as u64).ok_or_else(limit_error)?; + if next > MAX_ARCHIVE_BYTES { + return Err(limit_error()); + } + self.inner.write_all(bytes)?; + self.total = next; + Ok(bytes.len()) + } + + fn flush(&mut self) -> io::Result<()> { + self.inner.flush() + } +} + +fn limit_error() -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, "Lightroom archive exceeds 32 MiB") +} + +struct ZlibWriter<'a, 'b> { + sink: &'a mut BoundedWriter<'b>, + compressor: CompressorOxide, + plain: u64, +} + +impl<'a, 'b> ZlibWriter<'a, 'b> { + fn new(sink: &'a mut BoundedWriter<'b>) -> ZlibWriter<'a, 'b> { + let flags = create_comp_flags_from_zip_params(6, 15, 0); + ZlibWriter { sink, compressor: CompressorOxide::new(flags), plain: 0 } + } + + fn run(&mut self, input: &[u8], flush: MZFlush) -> io::Result<()> { + let mut rest = input; + loop { + let mut output = [0u8; IO_BUFFER]; + let result = deflate(&mut self.compressor, rest, &mut output, flush); + self.sink.write_all(&output[..result.bytes_written])?; + match result.status { + Ok(MZStatus::StreamEnd) => return Ok(()), + Ok(MZStatus::Ok) => { + let consumed = result.bytes_consumed; + if consumed > rest.len() { + return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid compressor progress")); + } + rest = &rest[consumed..]; + if rest.is_empty() && flush == MZFlush::None { + return Ok(()); + } + if consumed == 0 && result.bytes_written == 0 { + return Err(io::Error::new(io::ErrorKind::InvalidData, "compressor made no progress")); + } + } + Ok(status) => { + return Err(io::Error::new(io::ErrorKind::InvalidData, format!("unsupported compressor status: {status:?}"))); + } + Err(error) => return Err(io::Error::new(io::ErrorKind::InvalidData, format!("compression failed: {error:?}"))), + } + } + } + + fn finish(&mut self) -> io::Result<()> { + self.run(&[], MZFlush::Finish) + } +} + +impl Write for ZlibWriter<'_, '_> { + fn write(&mut self, bytes: &[u8]) -> io::Result { + if bytes.is_empty() { + return Ok(0); + } + let next = self.plain.checked_add(bytes.len() as u64).ok_or_else(limit_error)?; + if next > MAX_ARCHIVE_BYTES { + return Err(limit_error()); + } + self.run(bytes, MZFlush::None)?; + self.plain = next; + Ok(bytes.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +fn same_bounded(left: &Path, right: &Path) -> io::Result { + let left_meta = fs::symlink_metadata(left)?; + let right_meta = fs::symlink_metadata(right)?; + if left_meta.file_type().is_symlink() || right_meta.file_type().is_symlink() || !left_meta.is_file() || !right_meta.is_file() { + return Ok(false); + } + let left_len = left_meta.len(); + let right_len = right_meta.len(); + if left_len != right_len || left_len > MAX_ARCHIVE_BYTES { + return Ok(false); + } + let mut left = File::open(left)?; + let mut right = File::open(right)?; + let mut left_buf = [0u8; IO_BUFFER]; + let mut right_buf = [0u8; IO_BUFFER]; + loop { + let left_n = left.read(&mut left_buf)?; + let right_n = right.read(&mut right_buf)?; + if left_n != right_n { + return Ok(false); + } + if left_n == 0 { + return Ok(true); + } + if left_buf[..left_n] != right_buf[..right_n] { + return Ok(false); + } + } +} + +fn retirement_set(entries: &[Managed]) -> Result, String> { + retirement_set_protected(entries, None) +} + +fn retirement_set_protected(entries: &[Managed], protected: Option<&Path>) -> Result, String> { + let mut ordered = entries.to_vec(); + ordered.sort_by_key(|entry| entry.index); + let mut total = ordered.iter().try_fold(0u64, |sum, entry| sum.checked_add(entry.size)).ok_or("archive size overflow")?; + let mut retirees = Vec::new(); + while ordered.len() > MAX_MANAGED_ARCHIVES || total > MAX_TOTAL_ARCHIVE_BYTES { + let Some(position) = ordered.iter().position(|entry| protected.is_none_or(|path| entry.path != path)) else { + return Err("cannot satisfy archive retention limit".into()); + }; + let oldest = ordered.remove(position); + total = total.saturating_sub(oldest.size); + retirees.push(oldest); + } + Ok(retirees) +} + +fn prune_existing(directory: &Path, entries: &mut [Managed], protected: Option<&Path>) -> Result<(), String> { + let retirees = retirement_set_protected(entries, protected)?; + let quarantined = quarantine(&retirees)?; + for (old, held) in quarantined { + if let Err(error) = remove_managed_file(&held) { + return Err(format!("could not retire {} from {}: {error}", old.display(), directory.display())); + } + } + Ok(()) +} + +fn quarantine(entries: &[Managed]) -> Result, String> { + let mut moved = Vec::new(); + let pid = std::process::id(); + for (slot, entry) in entries.iter().enumerate() { + let held = entry.path.with_file_name(format!(".lightroom-retire-{pid}-{slot}.tmp")); + match fs::symlink_metadata(&held) { + Ok(_) => { + rollback_quarantine(&moved); + return Err(format!("retention temporary path already exists: {}", held.display())); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => { + rollback_quarantine(&moved); + return Err(error.to_string()); + } + } + let meta = fs::symlink_metadata(&entry.path).map_err(|error| { + rollback_quarantine(&moved); + error.to_string() + })?; + if meta.file_type().is_symlink() || !meta.is_file() { + rollback_quarantine(&moved); + return Err(format!("managed archive changed into non-file: {}", entry.path.display())); + } + if let Err(error) = fs::rename(&entry.path, &held) { + rollback_quarantine(&moved); + return Err(error.to_string()); + } + moved.push((entry.path.clone(), held)); + } + Ok(moved) +} + +fn rollback_quarantine(moved: &[(PathBuf, PathBuf)]) { + for (original, held) in moved.iter().rev() { + let _ = fs::rename(held, original); + } +} + +fn publish_new(temporary: &Path, destination: &Path) -> io::Result<()> { + match fs::hard_link(temporary, destination) { + Ok(()) => Ok(()), + Err(error) => Err(io::Error::new(error.kind(), format!("cannot atomically publish archive: {error}"))), + } +} + +fn remove_managed_file(path: &Path) -> io::Result<()> { + let meta = fs::symlink_metadata(path)?; + if meta.file_type().is_symlink() || !meta.is_file() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "refusing to remove non-regular archive path")); + } + fs::remove_file(path) +} + +struct ArchiveRef<'a>(&'a CatalogImport); + +impl Serialize for ArchiveRef<'_> { + fn serialize(&self, serializer: S) -> Result { + let mut out = serializer.serialize_struct("CatalogImportArchive", 7)?; + out.serialize_field("version", &FORMAT_VERSION)?; + out.serialize_field("source", &self.0.source)?; + out.serialize_field("photos", &PhotoSeq(&self.0.photos))?; + out.serialize_field("collections", &RowSeq(&self.0.collections))?; + out.serialize_field("members", &RowSeq(&self.0.members))?; + out.serialize_field("collection_content", &RowSeq(&self.0.collection_content))?; + out.serialize_field("warnings", &self.0.warnings)?; + out.end() + } +} + +struct PhotoSeq<'a>(&'a [CatalogPhoto]); +impl Serialize for PhotoSeq<'_> { + fn serialize(&self, serializer: S) -> Result { + let mut out = serializer.serialize_seq(Some(self.0.len()))?; + for photo in self.0 { + out.serialize_element(&PhotoRef(photo))?; + } + out.end() + } +} + +struct PhotoRef<'a>(&'a CatalogPhoto); +impl Serialize for PhotoRef<'_> { + fn serialize(&self, serializer: S) -> Result { + let p = self.0; + let mut out = serializer.serialize_struct("CatalogPhotoArchive", 8)?; + out.serialize_field("source_id", &p.source_id)?; + out.serialize_field("uuid", &p.uuid)?; + out.serialize_field("path", &p.path)?; + out.serialize_field("image", &RowRef(&p.image))?; + out.serialize_field("settings", &p.settings)?; + out.serialize_field("xmp", &p.xmp)?; + out.serialize_field("keywords", &p.keywords)?; + out.serialize_field("history", &RowSeq(&p.history))?; + out.serialize_field("snapshots", &RowSeq(&p.snapshots))?; + out.end() + } +} + +struct RowSeq<'a>(&'a [BTreeMap]); +impl Serialize for RowSeq<'_> { + fn serialize(&self, serializer: S) -> Result { + let mut out = serializer.serialize_seq(Some(self.0.len()))?; + for row in self.0 { + out.serialize_element(&RowRef(row))?; + } + out.end() + } +} + +struct RowRef<'a>(&'a BTreeMap); +impl Serialize for RowRef<'_> { + fn serialize(&self, serializer: S) -> Result { + let mut out = serializer.serialize_map(None)?; + for (key, value) in self.0 { + out.serialize_entry(key, value)?; + } + out.end() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::lightroom_catalog::{CatalogImport, CatalogPhoto}; + use std::sync::atomic::{AtomicU64, Ordering}; + + static NEXT: AtomicU64 = AtomicU64::new(0); + + fn tempdir() -> PathBuf { + let path = std::env::temp_dir().join(format!("lightcraft-lr-archive-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); + fs::create_dir_all(&path).unwrap(); + path + } + + fn data(source: String) -> CatalogImport { + CatalogImport { + source, + photos: Vec::new(), + collections: Vec::new(), + members: Vec::new(), + collection_content: Vec::new(), + warnings: Vec::new(), + } + } + + fn rich_data() -> CatalogImport { + let photo = CatalogPhoto { + source_id: 7, + uuid: "uuid-7".into(), + path: "/photos/one.raw".into(), + image: BTreeMap::from([("rating".into(), serde_json::json!(5)), ("opaque_blob".into(), serde_json::json!([1, 2, 3, 4]))]), + settings: "s = { Exposure2012 = 0.75 }".into(), + xmp: "decoded".into(), + 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"))])], + }; + CatalogImport { + source: "source.lrcat".into(), + photos: vec![photo], + collections: vec![BTreeMap::from([("name".into(), serde_json::json!("Keepers"))])], + members: vec![BTreeMap::from([("image".into(), serde_json::json!(7))])], + collection_content: vec![BTreeMap::from([("rule".into(), serde_json::json!("rating > 3"))])], + warnings: vec!["one warning".into()], + } + } + + fn managed_count(path: &Path) -> usize { + fs::read_dir(path).unwrap().filter_map(Result::ok).filter(|entry| parse_managed_name(&entry.file_name()).is_some()).count() + } + + #[test] + fn deduplicates_identical_deterministic_archives() { + let path = tempdir(); + let input = data("catalog.lrcat".into()); + let first = store(&path, &input).unwrap(); + let second = store(&path, &input).unwrap(); + assert_eq!(first, second); + assert_eq!(managed_count(&path), 1); + fs::remove_dir_all(path).unwrap(); + } + + #[test] + fn dedup_returns_archive_that_retention_keeps() { + let path = tempdir(); + let input = data("catalog.lrcat".into()); + let first = store(&path, &input).unwrap(); + for index in 1..MAX_MANAGED_ARCHIVES { + let filler = path.join(format!("lightroom-import-{index}.lca")); + File::create(filler).unwrap().set_len(19 * 1024 * 1024).unwrap(); + } + let reused = store(&path, &input).unwrap(); + assert_eq!(reused, first); + assert!(reused.is_file()); + assert!(managed_count(&path) <= MAX_MANAGED_ARCHIVES); + fs::remove_dir_all(path).unwrap(); + } + + #[test] + fn caps_managed_archive_count_and_preserves_unrelated_files() { + let path = tempdir(); + let human = path.join("notes.txt"); + let legacy = path.join("lightroom-import-0.json"); + fs::write(&human, b"keep").unwrap(); + fs::write(&legacy, b"legacy").unwrap(); + for index in 0..(MAX_MANAGED_ARCHIVES + 3) { + let input = data(format!("catalog-{index}.lrcat")); + store(&path, &input).unwrap(); + } + assert!(managed_count(&path) <= MAX_MANAGED_ARCHIVES); + assert_eq!(fs::read(&human).unwrap(), b"keep"); + assert_eq!(fs::read(&legacy).unwrap(), b"legacy"); + fs::remove_dir_all(path).unwrap(); + } + + #[test] + fn rejects_oversize_before_creating_archive() { + let path = tempdir(); + let input = data("x".repeat((MAX_ARCHIVE_BYTES as usize) + 1)); + assert!(store(&path, &input).is_err()); + assert_eq!(managed_count(&path), 0); + fs::remove_dir_all(path).unwrap(); + } + + #[test] + fn large_catalog_history_is_best_effort_and_does_not_abort_import() { + let path = tempdir(); + let mut input = data("catalog.lrcat".into()); + input.photos = (0..5_000) + .map(|index| CatalogPhoto { + source_id: index, + uuid: format!("photo-{index}"), + path: format!("/photos/{index}.raw"), + image: BTreeMap::new(), + settings: String::new(), + xmp: String::new(), + keywords: Vec::new(), + history: vec![BTreeMap::from([("text".into(), serde_json::json!(format!("history-{index}-{}", "x".repeat(8 * 1024))))])], + snapshots: Vec::new(), + }) + .collect(); + assert_eq!(store_best_effort(&path, &input).unwrap(), None); + assert_eq!(managed_count(&path), 0); + fs::remove_dir_all(path).unwrap(); + } + + #[test] + fn archive_roundtrips_zlib_payload_and_preserves_numeric_arrays() { + let path = tempdir(); + let archive = store(&path, &rich_data()).unwrap(); + let bytes = fs::read(archive).unwrap(); + assert!(bytes.starts_with(HEADER)); + let json = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(&bytes[HEADER.len()..], MAX_ARCHIVE_BYTES as usize).unwrap(); + let decoded: Value = serde_json::from_slice(&json).unwrap(); + assert_eq!(decoded["version"], serde_json::json!(FORMAT_VERSION)); + assert_eq!(decoded["source"], serde_json::json!("source.lrcat")); + assert_eq!(decoded["photos"].as_array().unwrap().len(), 1); + assert_eq!(decoded["photos"][0]["source_id"], serde_json::json!(7)); + assert_eq!(decoded["photos"][0]["uuid"], serde_json::json!("uuid-7")); + assert_eq!(decoded["photos"][0]["path"], serde_json::json!("/photos/one.raw")); + assert_eq!(decoded["photos"][0]["settings"], serde_json::json!("s = { Exposure2012 = 0.75 }")); + assert_eq!(decoded["photos"][0]["xmp"], serde_json::json!("decoded")); + assert_eq!(decoded["photos"][0]["keywords"], serde_json::json!(["Trips|Goa"])); + assert_eq!(decoded["photos"][0]["image"]["rating"], serde_json::json!(5)); + assert_eq!(decoded["photos"][0]["image"]["opaque_blob"], serde_json::json!([1, 2, 3, 4])); + assert_eq!(decoded["photos"][0]["history"].as_array().unwrap().len(), 1); + assert_eq!(decoded["photos"][0]["history"][0]["curve"], serde_json::json!([0, 128, 255])); + assert_eq!(decoded["photos"][0]["snapshots"].as_array().unwrap().len(), 1); + assert_eq!(decoded["collections"].as_array().unwrap().len(), 1); + assert_eq!(decoded["collections"][0]["name"], serde_json::json!("Keepers")); + assert_eq!(decoded["members"].as_array().unwrap().len(), 1); + assert_eq!(decoded["members"][0]["image"], serde_json::json!(7)); + assert_eq!(decoded["collection_content"].as_array().unwrap().len(), 1); + assert_eq!(decoded["warnings"], serde_json::json!(["one warning"])); + fs::remove_dir_all(path).unwrap(); + } + + #[test] + fn retention_enforces_aggregate_byte_cap_with_small_synthetic_entries() { + let mut entries: Vec<_> = (0..MAX_MANAGED_ARCHIVES as u64) + .map(|index| Managed { index, path: PathBuf::from(format!("lightroom-import-{index}.lca")), size: 20 * 1024 * 1024 }) + .collect(); + entries.push(Managed { index: 8, path: PathBuf::from("lightroom-import-8.lca"), size: 20 * 1024 * 1024 }); + let retirees = retirement_set(&entries).unwrap(); + assert_eq!(retirees.len(), 3); + assert_eq!(retirees.iter().map(|entry| entry.index).collect::>(), vec![0, 1, 2]); + } + + #[test] + fn retention_failure_preserves_old_archive() { + let path = tempdir(); + let old = path.join("lightroom-import-0.lca"); + let held = path.join(format!(".lightroom-retire-{}-0.tmp", std::process::id())); + fs::write(&old, b"old archive").unwrap(); + fs::write(&held, b"occupied").unwrap(); + let entry = Managed { index: 0, path: old.clone(), size: 11 }; + assert!(quarantine(&[entry]).is_err()); + assert_eq!(fs::read(&old).unwrap(), b"old archive"); + fs::remove_dir_all(path).unwrap(); + } +} diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs new file mode 100644 index 000000000..3c510fded --- /dev/null +++ b/crates/engine/src/lightroom_catalog.rs @@ -0,0 +1,1090 @@ +//! Direct, read-only Lightroom Classic catalog import. SQLite pages are read in Rust; +//! 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::{Path, PathBuf}; +use std::sync::{ + Arc, + atomic::{AtomicBool, AtomicUsize, Ordering}, +}; + +use crate::lightroom_sqlite::{Database, LiveTable, Value as SqlValue}; +use lightcraft_catalog::{Album, Flag, Op, Photo, PhotoId, Source}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +type Row = BTreeMap; +const COMMAND: &str = "library.importLightroom"; +const MAX_FILE: u64 = 1 << 30; + +fn error(why: impl Into) -> crate::EngineError { + crate::EngineError::BadParams { cmd: COMMAND.into(), msg: why.into() } +} + +fn text<'a>(r: &'a Row, k: &str) -> &'a str { + r.get(k).and_then(Value::as_str).unwrap_or("") +} +fn number(r: &Row, k: &str) -> i64 { + r.get(k).and_then(Value::as_f64).filter(|n| n.is_finite()).unwrap_or(0.0) as i64 +} + +pub(crate) fn number_for_job(r: &Row, k: &str) -> i64 { + number(r, k) +} +fn key(path: &str) -> String { + let p = path.replace('\\', "/"); + if cfg!(windows) { p.to_lowercase() } else { p } +} +fn source_path(p: &Photo) -> Option<&str> { + match &p.source { + Source::File { path } => Some(path), + _ => None, + } +} + +struct Reader { + db: Database, + tables: Vec, +} +impl Reader { + fn table(&self, name: &str, required: bool) -> Result, String> { + let Some(t) = self.tables.iter().find(|t| t.name == name) else { + return if required { Err(format!("unsupported Lightroom catalog: missing {name}")) } else { Ok(Vec::new()) }; + }; + let columns = t.column_names.as_ref().ok_or_else(|| format!("cannot read {name} columns"))?; + let rows = self.db.read_table(t.rootpage, columns.len()).map_err(|e| format!("{name}: {e:?}"))?; + if rows.len() > 1_000_000 { + return Err(format!("{name}: more than one million records")); + } + Ok(rows + .into_iter() + .map(|r| { + columns + .iter() + .cloned() + .zip(r.values.into_iter().map(|v| match v { + SqlValue::Null => Some(Value::Null), + SqlValue::Integer(n) => Some(json!(n)), + SqlValue::Real(n) => Some(json!(n)), + SqlValue::Text(s) => Some(json!(s)), + // Opaque Lightroom payloads never become JSON byte arrays. + SqlValue::Blob(_) => None, + })) + .filter_map(|(column, value)| value.map(|value| (column, value))) + .collect() + }) + .collect()) + } + + fn optional_table(&self, name: &str, warnings: &mut Vec) -> Result, String> { + match self.table(name, false) { + Ok(rows) => Ok(rows), + Err(error) => { + warnings.push(format!("skipping optional Lightroom table {name}: {error}")); + Ok(Vec::new()) + } + } + } + + fn xmp_table(&self) -> Result<(Vec, Vec), String> { + let Some(t) = self.tables.iter().find(|t| t.name == "Adobe_AdditionalMetadata") else { return Ok((Vec::new(), Vec::new())) }; + let columns = t.column_names.as_ref().ok_or_else(|| "cannot read Adobe_AdditionalMetadata columns".to_string())?; + let rows = self.db.read_table(t.rootpage, columns.len()).map_err(|e| format!("Adobe_AdditionalMetadata: {e:?}"))?; + if rows.len() > 1_000_000 { + return Err("Adobe_AdditionalMetadata: more than one million records".into()); + } + let mut warnings = Vec::new(); + let mut out = Vec::with_capacity(rows.len()); + for row in rows { + let image = row + .values + .iter() + .zip(columns) + .find_map(|(value, column)| { + (column == "image").then_some(match value { + SqlValue::Integer(n) => *n, + _ => 0, + }) + }) + .unwrap_or(0); + let mut mapped = Row::new(); + for (column, value) in columns.iter().zip(row.values) { + let value = match value { + SqlValue::Null => Some(Value::Null), + SqlValue::Integer(n) => Some(json!(n)), + SqlValue::Real(n) => Some(json!(n)), + SqlValue::Text(s) => Some(json!(s)), + SqlValue::Blob(bytes) if column == "xmp" => match decode_xmp_bytes(&bytes) { + Ok(packet) => Some(Value::String(packet)), + Err(error) => { + warnings.push(format!("image {image}: {error}")); + None + } + }, + SqlValue::Blob(_) => None, + }; + if let Some(value) = value { + mapped.insert(column.clone(), value); + } + } + out.push(mapped); + } + Ok((out, warnings)) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CatalogPhoto { + pub source_id: i64, + pub uuid: String, + pub path: String, + pub image: Row, + pub settings: String, + pub xmp: String, + pub keywords: Vec, + pub history: Vec, + pub snapshots: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CatalogImport { + pub source: String, + pub photos: Vec, + pub collections: Vec, + pub members: Vec, + pub collection_content: Vec, + pub warnings: Vec, +} + +#[derive(Clone, Default, Serialize, Deserialize)] +pub(crate) struct ImportIndex { + pub(crate) photos: BTreeMap, + pub(crate) collections: BTreeMap, +} + +pub(crate) fn load_index(path: Option<&Path>) -> crate::Result { + match path { + Some(path) => { + let file = match std::fs::File::open(path) { + Ok(file) => file, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(ImportIndex::default()), + Err(e) => return Err(error(e.to_string())), + }; + if file.metadata().map_err(|e| error(e.to_string()))?.len() > 16 * 1024 * 1024 { + return Err(error("Lightroom import index exceeds 16 MiB")); + } + let mut bytes = Vec::new(); + use std::io::Read; + file.take(16 * 1024 * 1024 + 1).read_to_end(&mut bytes).map_err(|e| error(e.to_string()))?; + if bytes.len() > 16 * 1024 * 1024 { + return Err(error("Lightroom import index exceeds 16 MiB")); + } + serde_json::from_slice(&bytes).map_err(|e| error(format!("invalid Lightroom import index: {e}"))) + } + None => Ok(ImportIndex::default()), + } +} + +pub(crate) struct AppliedImport { + pub(crate) report: Value, + pub(crate) index: ImportIndex, +} + +fn identity(data: &CatalogImport, photo: &CatalogPhoto) -> String { + if photo.uuid.is_empty() { format!("{}:{}", key(&data.source), photo.source_id) } else { photo.uuid.clone() } +} + +fn validate(data: &CatalogImport) -> Result<(), String> { + let photos: HashMap<_, _> = data.photos.iter().map(|p| (p.source_id, p)).collect(); + if photos.len() != data.photos.len() { + return Err("duplicate source photo ids".into()); + } + for photo in &data.photos { + let master = number(&photo.image, "masterImage"); + if master != 0 + && (master == photo.source_id + || !photos.contains_key(&master) + || photos.get(&master).is_some_and(|p| number(&p.image, "masterImage") != 0)) + { + return Err(format!("invalid virtual copy master for {}", photo.source_id)); + } + } + let collections: HashMap<_, _> = data.collections.iter().map(|r| (number(r, "id_local"), r)).collect(); + for id in collections.keys() { + let mut seen = HashSet::new(); + let mut at = *id; + while let Some(r) = collections.get(&at) { + if seen.len() >= 64 || !seen.insert(at) { + return Err("cyclic or overly deep collection hierarchy".into()); + } + at = number(r, "parent"); + if at == 0 { + break; + } + } + } + Ok(()) +} + +pub(crate) fn validate_for_job(data: &CatalogImport) -> Result<(), String> { + validate(data) +} + +fn bytes(path: &Path) -> Result, String> { + use std::io::Read; + let f = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?; + if f.metadata().map_err(|e| e.to_string())?.len() > MAX_FILE { + return Err("catalog or WAL exceeds 1 GiB import limit".into()); + } + let mut out = Vec::new(); + 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()); + } + Ok(out) +} + +fn xmp(v: Option<&Value>) -> Result { + match v { + Some(Value::String(s)) => Ok(s.clone()), + Some(Value::Array(a)) if !a.is_empty() => { + let b: Vec = a.iter().filter_map(|n| n.as_u64().and_then(|n| u8::try_from(n).ok())).collect(); + let size = b.get(..4).and_then(|s| <[u8; 4]>::try_from(s).ok()).map(u32::from_be_bytes).ok_or("truncated XMP header")?; + if size > 16 << 20 { + return Err("XMP packet exceeds 16 MiB".into()); + } + let compressed = b.get(4..).ok_or("truncated XMP")?; + let decoded = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(compressed, 16 << 20).map_err(|_| "invalid compressed XMP")?; + if decoded.len() != size as usize { + return Err("XMP packet length mismatch".into()); + } + String::from_utf8(decoded).map_err(|e| e.to_string()) + } + _ => Ok(String::new()), + } +} + +fn decode_xmp_bytes(bytes: &[u8]) -> Result { + let size = bytes.get(..4).and_then(|s| <[u8; 4]>::try_from(s).ok()).map(u32::from_be_bytes).ok_or("truncated XMP header")?; + if size > 16 << 20 { + return Err("XMP packet exceeds 16 MiB".into()); + } + let decoded = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(bytes.get(4..).ok_or("truncated XMP")?, 16 << 20) + .map_err(|_| "invalid compressed XMP")?; + if decoded.len() != size as usize { + return Err("XMP packet length mismatch".into()); + } + String::from_utf8(decoded).map_err(|e| e.to_string()) +} + +fn be_word(bytes: &[u8], at: usize) -> Result { + let word = bytes.get(at..at + 4).and_then(|s| <[u8; 4]>::try_from(s).ok()).ok_or("truncated WAL")?; + Ok(u32::from_be_bytes(word)) +} + +fn wal_checksum(bytes: &[u8], big: bool, mut sum: (u32, u32)) -> (u32, u32) { + for pair in bytes.as_chunks::<8>().0 { + let first = [pair[0], pair[1], pair[2], pair[3]]; + let second = [pair[4], pair[5], pair[6], pair[7]]; + let (first, second) = + if big { (u32::from_be_bytes(first), u32::from_be_bytes(second)) } else { (u32::from_le_bytes(first), u32::from_le_bytes(second)) }; + sum.0 = sum.0.wrapping_add(first).wrapping_add(sum.1); + sum.1 = sum.1.wrapping_add(second).wrapping_add(sum.0); + } + sum +} + +// SQLite file format §4: validate the live checksum chain in constant space, then overlay +// only its last committed prefix. The forensic timeline materializes EVERY historical +// database snapshot; it must never be used to validate a large, actively used catalog. +fn committed_wal_len(main: &[u8], wal: &[u8]) -> Result { + if wal.is_empty() { + return Ok(0); + } + let header = wal.get(..32).ok_or("truncated WAL header")?; + let magic = be_word(header, 0)?; + if !matches!(magic, 0x377f0682 | 0x377f0683) || be_word(header, 4)? != 3_007_000 { + return Err("invalid WAL header".into()); + } + let size = main.get(16..18).and_then(|s| <[u8; 2]>::try_from(s).ok()).map(u16::from_be_bytes).ok_or("truncated SQLite header")?; + let size = if size == 1 { 65536 } else { u32::from(size) }; + if !(512..=65536).contains(&size) || !size.is_power_of_two() || be_word(header, 8)? != size { + return Err("WAL page size mismatch".into()); + } + let big = magic == 0x377f0683; + let mut sum = wal_checksum(&header[..24], big, (0, 0)); + if sum != (be_word(header, 24)?, be_word(header, 28)?) { + return Err("invalid WAL header checksum".into()); + } + let salt = (be_word(header, 16)?, be_word(header, 20)?); + let mut committed = 0; + let stride = size as usize + 24; + for (i, frame) in wal[32..].chunks_exact(stride).enumerate() { + if be_word(frame, 0)? == 0 || salt != (be_word(frame, 8)?, be_word(frame, 12)?) { + break; + } + sum = wal_checksum(&frame[..8], big, sum); + sum = wal_checksum(&frame[24..], big, sum); + if sum != (be_word(frame, 16)?, be_word(frame, 20)?) { + break; + } + if be_word(frame, 4)? != 0 { + committed = 32 + (i + 1) * stride; + } + } + Ok(committed) +} + +/// Inspect `.lrcat` plus committed WAL pages without modifying either source file. +pub fn read(path: &Path) -> Result { + let cancel = AtomicBool::new(false); + let total = AtomicUsize::new(0); + let done = AtomicUsize::new(0); + read_with_progress(path, &cancel, &total, &done) +} + +pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize, done: &AtomicUsize) -> Result { + // 13 catalog tables plus one validation stage; ImportJob adds one unit per original path. + total.store(14, Ordering::Relaxed); + done.store(0, Ordering::Relaxed); + ensure_read_active(cancel)?; + let before = std::fs::metadata(path).map_err(|e| e.to_string())?; + let wal_path = PathBuf::from(format!("{}-wal", path.display())); + let wal_before = std::fs::metadata(&wal_path).ok().map(|m| (m.len(), m.modified().ok())); + let main = bytes(path)?; + let wal = match std::fs::metadata(&wal_path) { + Ok(_) => bytes(&wal_path)?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(), + Err(e) => return Err(e.to_string()), + }; + let wal_len = committed_wal_len(&main, &wal)?; + ensure_read_active(cancel)?; + let db = Database::open_with_wal(main, &wal[..wal_len]).map_err(|e| format!("invalid SQLite catalog: {e:?}"))?; + let after = std::fs::metadata(path).map_err(|e| e.to_string())?; + let wal_after = std::fs::metadata(&wal_path).ok().map(|m| (m.len(), m.modified().ok())); + if before.len() != after.len() || before.modified().ok() != after.modified().ok() || wal_before != wal_after { + return Err("catalog changed during reading; close Lightroom and retry".into()); + } + let reader = Reader { tables: db.live_tables()?, db }; + let mut warnings = Vec::new(); + let table = |name: &str, required: bool| -> Result, String> { + ensure_read_active(cancel)?; + let rows = reader.table(name, required)?; + done.fetch_add(1, Ordering::Relaxed); + Ok(rows) + }; + let optional_table = |name: &str, warnings: &mut Vec| -> Result, String> { + ensure_read_active(cancel)?; + let rows = reader.optional_table(name, warnings)?; + done.fetch_add(1, Ordering::Relaxed); + Ok(rows) + }; + let roots: HashMap<_, _> = table("AgLibraryRootFolder", true)?.into_iter().map(|r| (number(&r, "id_local"), r)).collect(); + let folders: HashMap<_, _> = table("AgLibraryFolder", true)?.into_iter().map(|r| (number(&r, "id_local"), r)).collect(); + let files: HashMap<_, _> = table("AgLibraryFile", true)?.into_iter().map(|r| (number(&r, "id_local"), r)).collect(); + let develops: HashMap<_, _> = optional_table("Adobe_imageDevelopSettings", &mut warnings)? + .into_iter() + .map(|r| (number(&r, "image"), text(&r, "text").to_string())) + .collect(); + ensure_read_active(cancel)?; + let (metadata_rows, metadata_warnings) = match reader.xmp_table() { + Ok(result) => result, + Err(error) => { + warnings.push(format!("skipping optional Lightroom table Adobe_AdditionalMetadata: {error}")); + (Vec::new(), Vec::new()) + } + }; + warnings.extend(metadata_warnings); + done.fetch_add(1, Ordering::Relaxed); + let metadata: HashMap<_, _> = metadata_rows.into_iter().map(|r| (number(&r, "image"), r)).collect(); + let keyword_rows: HashMap<_, _> = optional_table("AgLibraryKeyword", &mut warnings)?.into_iter().map(|r| (number(&r, "id_local"), r)).collect(); + let mut keywords: HashMap> = HashMap::new(); + for r in optional_table("AgLibraryKeywordImage", &mut warnings)? { + ensure_read_active(cancel)?; + let mut parts = Vec::new(); + let mut at = number(&r, "tag"); + let mut seen = HashSet::new(); + while at != 0 { + if parts.len() >= 64 || !seen.insert(at) { + return Err("cyclic or overly deep keyword hierarchy".into()); + } + let Some(k) = keyword_rows.get(&at) else { break }; + let name = text(k, "name"); + if !name.is_empty() { + parts.push(name.to_string()); + } + at = number(k, "parent"); + } + parts.reverse(); + if !parts.is_empty() { + keywords.entry(number(&r, "image")).or_default().push(parts.join("|")); + } + } + let group = |rows: Vec| { + let mut out: HashMap> = HashMap::new(); + for r in rows { + out.entry(number(&r, "image")).or_default().push(r); + } + out + }; + let mut history = group(optional_table("Adobe_libraryImageDevelopHistoryStep", &mut warnings)?); + let mut snapshots = group(optional_table("Adobe_libraryImageDevelopSnapshot", &mut warnings)?); + let mut out = CatalogImport { + source: path.to_string_lossy().into(), + photos: Vec::new(), + collections: optional_table("AgLibraryCollection", &mut warnings)?, + members: optional_table("AgLibraryCollectionImage", &mut warnings)?, + collection_content: optional_table("AgLibraryCollectionContent", &mut warnings)?, + warnings, + }; + for image in table("Adobe_images", true)? { + ensure_read_active(cancel)?; + let id = number(&image, "id_local"); + 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 = text(root, "absolutePath"); + 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 packet = match xmp(metadata.get(&id).and_then(|r| r.get("xmp"))) { + Ok(s) => s, + Err(e) => { + out.warnings.push(format!("image {id}: {e}")); + String::new() + } + }; + out.photos.push(CatalogPhoto { + source_id: id, + uuid: text(&image, "id_global").into(), + 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(), + }); + } + ensure_read_active(cancel)?; + Ok(out) +} + +fn ensure_read_active(cancel: &AtomicBool) -> Result<(), String> { + if cancel.load(Ordering::Relaxed) { Err("Lightroom import cancelled".into()) } else { Ok(()) } +} + +fn mapped_settings(text: &str, raw: bool, aspect: f64) -> Result<(Value, Vec), String> { + let mut root = crate::preset_import::parse_lua(text)?; + let deferred = strip_lua_sentinels(&mut root); + let (props, values) = crate::preset_import::lua_settings_props(&root)?; + let (partial, mut unknown) = crate::crs::to_partial_report(&props, Some(&values), Some(raw), aspect); + if deferred > 0 { + unknown.push("Deferred Lightroom adjustments (-999999)".into()); + } + Ok((partial, unknown)) +} + +fn strip_lua_sentinels(value: &mut crate::preset_import::Lua) -> usize { + use crate::preset_import::Lua; + match value { + Lua::Num(n) if *n == -999999.0 => { + *value = Lua::Nil; + 1 + } + Lua::Table(array, fields) => { + let mut removed = array.iter_mut().map(strip_lua_sentinels).sum::(); + fields.retain_mut(|(_, value)| { + removed += strip_lua_sentinels(value); + !matches!(value, Lua::Nil) + }); + removed + } + _ => 0, + } +} + +fn strip_xmp_sentinels(value: &mut Value) { + match value { + Value::Object(fields) => fields.retain(|_, value| { + if value.as_f64() == Some(-999999.0) { + return false; + } + strip_xmp_sentinels(value); + true + }), + Value::Array(values) => { + if values.iter().any(|v| v.as_f64() == Some(-999999.0)) { + values.clear(); + } else { + for v in values { + strip_xmp_sentinels(v); + } + } + } + _ => {} + } +} + +/// Import into the current library. Existing edited records are preserved unless explicitly +/// requested; missing originals remain catalogued for later relinking. The source catalog is read-only. +pub fn import(s: &mut crate::Session, path: &Path, update_existing: bool) -> crate::Result { + let mut job = crate::lightroom_job::LightroomJob::new(s, path.to_path_buf(), update_existing)?; + let cancel = AtomicBool::new(false); + let prepared = job.prepare(&cancel)?; + let completion = crate::lightroom_job::commit_prepared(s, prepared)?; + if s.library.as_ref().is_some_and(|library| library.on_disk) { + s.persist()?; + } + completion.finalization.finish()?; + Ok(completion.report) +} + +#[cfg(test)] +fn apply(s: &mut crate::Session, data: CatalogImport, update_existing: bool) -> crate::Result { + let archive_dir = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.join("Interop")); + let index_path = archive_dir.as_ref().map(|p| p.join("lightroom-index.json")); + let mut index = load_index(index_path.as_deref())?; + let archive_path = archive_dir.as_ref().map(|dir| crate::lightroom_archive::store(dir, &data).map_err(error)).transpose()?; + let undo0 = s.undo.len(); + let existing: HashMap<_, _> = + s.catalog.photos().filter(|p| p.copy_of.is_none()).filter_map(|p| source_path(p).map(|path| (key(path), p.id))).collect(); + 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()) + .map(|p| p.path.clone()) + .collect(); + let missing_sources: HashSet = + data.photos.iter().filter(|p| number(&p.image, "masterImage") == 0).filter(|p| !Path::new(&p.path).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 })?; + if let Some(path) = index_path { + s.persist()?; + let bytes = serde_json::to_vec(&applied.index).map_err(|e| error(e.to_string()))?; + lightcraft_catalog::safe_file::write_atomic(&path, &bytes).map_err(|e| error(e.to_string()))?; + } + Ok(applied.report) +} + +/// Apply prepared normal-file results and Lightroom metadata in one owner-thread operation. +/// This function does no filesystem work; callers finish its returned index separately. +pub(crate) struct ApplyPrepared<'a> { + pub(crate) update_existing: bool, + pub(crate) files: crate::import::ImportReport, + pub(crate) index: &'a mut ImportIndex, + pub(crate) archive_path: Option, + pub(crate) missing_sources: &'a HashSet, + pub(crate) undo0: usize, +} + +pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, context: ApplyPrepared<'_>) -> crate::Result { + let ApplyPrepared { update_existing, files, index, archive_path, missing_sources, undo0 } = context; + 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(); + let mut by_path: HashMap<_, _> = + s.catalog.photos().filter(|p| p.copy_of.is_none()).filter_map(|p| source_path(p).map(|path| (key(path), p.id))).collect(); + let mut ids = HashMap::new(); + let mut ops = Vec::new(); + let now = (s.clock)(); + let mut missing = Vec::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 mut planned: HashMap = HashMap::new(); + for src in photos { + let master = number(&src.image, "masterImage"); + let indexed = index + .photos + .get(&identity(&data, src)) + .copied() + .map(PhotoId) + .filter(|id| s.catalog.photo(*id).is_some_and(|p| source_path(p).is_some_and(|path| key(path) == key(&src.path)))); + let existing_id = indexed.or_else(|| if master == 0 { by_path.get(&key(&src.path)).copied() } else { None }); + let mut p = if let Some(id) = existing_id.and_then(|id| s.catalog.photo(id)) { + (**id).clone() + } else if master != 0 { + let master_id = ids.get(&master).copied().ok_or_else(|| error(format!("virtual copy {}: missing master", src.source_id)))?; + let mut p = planned + .get(&master_id) + .cloned() + .or_else(|| s.catalog.photo(master_id).map(|p| (**p).clone())) + .ok_or_else(|| error("virtual copy master is unavailable"))?; + p.id = s.catalog.alloc_photo_id(); + p.copy_of = Some(master_id); + p.copy_name = Some(text(&src.image, "copyName").to_string()); + p + } else if let Some(duplicate) = duplicates.get(&key(&src.path)).and_then(|id| s.catalog.photo(*id)) { + // Catalogs can contain distinct originals with identical bytes and independent edits. + let mut p = (**duplicate).clone(); + p.id = s.catalog.alloc_photo_id(); + p.source = Source::File { path: src.path.clone() }; + p.file_name = Path::new(&src.path).file_name().map(|n| n.to_string_lossy().into()).unwrap_or_else(|| src.path.clone()); + p + } 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() }, + &name, + text(&src.image, "fileFormat"), + number(&src.image, "fileWidth").max(0) as u32, + number(&src.image, "fileHeight").max(0) as u32, + &now, + ); + if ["RAW", "DNG", "ARW", "CR2", "CR3", "NEF", "NRW", "RAF", "ORF", "RW2", "PEF"].contains(&p.format.to_uppercase().as_str()) { + p.kind = lightcraft_catalog::MediaKind::Raw; + } + p + }; + ids.insert(src.source_id, p.id); + index.photos.insert(identity(&data, src), p.id.0); + let keep_develop = existing_id.is_some() && p.edited.is_some() && !update_existing; + let previous_develop = (p.develop.clone(), p.edited.clone()); + if keep_develop { + preserved += 1; + } + if !src.xmp.is_empty() { + match crate::sidecar::parse_sidecar(&src.xmp, p.kind == lightcraft_catalog::MediaKind::Raw) { + Ok(mut sc) => { + if let Some(crate::sidecar::DevelopPatch::Partial(partial)) = &mut sc.develop { + strip_xmp_sentinels(partial); + } + crate::sidecar::merge_into(&mut p, &sc, &now); + } + Err(e) => warnings.push(format!("image {} XMP: {e}", src.source_id)), + } + } + p.rating = number(&src.image, "rating").clamp(0, 5) as u8; + p.flag = match number(&src.image, "pick") { + 1 => Flag::Pick, + -1 => Flag::Reject, + _ => Flag::None, + }; + p.label = s.catalog.label_from_name(text(&src.image, "colorLabels")); + if !text(&src.image, "captureTime").is_empty() { + p.captured = Some(text(&src.image, "captureTime").into()); + } + if !src.keywords.is_empty() { + p.meta.keywords = src.keywords.clone(); + } + if !src.settings.is_empty() && !keep_develop { + match mapped_settings(&src.settings, p.kind == lightcraft_catalog::MediaKind::Raw, p.width.max(1) as f64 / p.height.max(1) as f64) { + Ok((partial, unknown)) => { + p.develop = Arc::new(lightcraft_develop::apply_partial(&p.develop, &partial, 1.0)); + p.edited = Some(now.clone()); + if !unknown.is_empty() { + unmapped.insert(src.source_id, unknown); + } + } + Err(e) => warnings.push(format!("image {} settings: {e}", src.source_id)), + } + } + if keep_develop { + (p.develop, p.edited) = previous_develop; + } + if s.catalog.photo(p.id).is_some() { + ops.extend([ + Op::SetRating { id: p.id, rating: p.rating }, + Op::SetFlag { id: p.id, flag: p.flag }, + Op::SetLabel { id: p.id, label: p.label }, + Op::SetMeta { id: p.id, meta: Box::new(p.meta.clone()) }, + Op::SetCaptured { id: p.id, captured: p.captured.clone() }, + ]); + 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()) }); + } + by_path.entry(key(&src.path)).or_insert(p.id); + planned.insert(p.id, p); + } + let mut albums = HashMap::new(); + for collection in &data.collections { + if number(collection, "systemOnly") != 0 { + continue; + } + let collection_key = format!("{}:{}", key(&data.source), number(collection, "id_local")); + let id = index + .collections + .get(&collection_key) + .copied() + .map(lightcraft_catalog::AlbumId) + .filter(|id| s.catalog.album(*id).is_some()) + .unwrap_or_else(|| s.catalog.alloc_album_id()); + index.collections.insert(collection_key, id.0); + albums.insert(number(collection, "id_local"), id); + } + let parents: HashMap<_, _> = data.collections.iter().map(|r| (number(r, "id_local"), number(r, "parent"))).collect(); + let mut grouped_members: HashMap> = HashMap::new(); + for member in &data.members { + grouped_members.entry(number(member, "collection")).or_default().push(member); + } + for members in grouped_members.values_mut() { + members.sort_by(|a, b| text(a, "positionInCollection").cmp(text(b, "positionInCollection"))); + } + let mut ordered: Vec<_> = data.collections.iter().collect(); + ordered.sort_by_key(|r| { + let mut depth = 0; + let mut at = number(r, "parent"); + while at != 0 && depth < 64 { + depth += 1; + at = parents.get(&at).copied().unwrap_or(0); + } + depth + }); + for collection in ordered { + let Some(id) = albums.get(&number(collection, "id_local")).copied() else { continue }; + let mut album = Album::new(id, text(collection, "name")); + album.parent = albums.get(&number(collection, "parent")).copied(); + album.folder = text(collection, "creationId").contains("group"); + if text(collection, "creationId").contains("smart") { + warnings.push(format!("{}: smart rules preserved in archive; imported current membership", album.name)); + } + if !album.folder { + album.photos = grouped_members + .get(&number(collection, "id_local")) + .into_iter() + .flatten() + .filter_map(|r| ids.get(&number(r, "image")).copied()) + .collect(); + } + if s.catalog.album(id).is_some() { + // Reimport preserves the user's album name/hierarchy, merges only new membership. + if let Some(old) = s.catalog.album(id).filter(|a| !a.folder && !a.is_smart()) { + let mut photos = old.photos.clone(); + let mut seen: HashSet<_> = photos.iter().copied().collect(); + for photo in album.photos { + if seen.insert(photo) { + photos.push(photo); + } + } + ops.push(Op::SetAlbumPhotos { id, photos }); + } + } else { + ops.push(Op::AddAlbum { album }); + } + } + if !ops.is_empty() { + s.commit("Import Lightroom Catalog", Op::Batch { ops })?; + } + 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}), + index: index.clone(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + fn row(v: Value) -> Row { + serde_json::from_value(v).unwrap() + } + fn sample() -> CatalogImport { + let mut photo = CatalogPhoto { + source_id: 1, + uuid: "test-master".into(), + path: "/missing/test.ARW".into(), + image: row(json!({"id_local":1,"rating":5,"pick":1,"fileFormat":"ARW","fileWidth":20,"fileHeight":10})), + settings: "s = { Exposure2012 = 1.25, Contrast2012 = 17, CameraProfile = \"Example\" }".into(), + xmp: String::new(), + keywords: vec!["Travel|Goa".into()], + history: vec![row(json!({"text":"s = { Exposure2012 = 0.5 }"}))], + snapshots: Vec::new(), + }; + let mut copy = photo.clone(); + copy.source_id = 2; + copy.uuid = "test-copy".into(); + copy.image = row(json!({"id_local":2,"masterImage":1,"copyName":"Mono","rating":2,"pick":-1})); + copy.settings = "s = { ConvertToGrayscale = true, Exposure2012 = -0.5 }".into(); + photo.image.insert("captureTime".into(), json!("2024-01-16T12:34:56")); + CatalogImport { + source: "test.lrcat".into(), + photos: vec![copy, photo], + collections: vec![ + row(json!({"id_local":9,"name":"Travel","creationId":"collection.group"})), + row(json!({"id_local":10,"parent":9,"name":"Keepers","creationId":"collection"})), + ], + members: vec![ + row(json!({"collection":10,"image":2,"positionInCollection":"a"})), + row(json!({"collection":10,"image":1,"positionInCollection":"b"})), + ], + collection_content: Vec::new(), + warnings: Vec::new(), + } + } + #[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["missing"].as_array().unwrap().len(), 1); + let master = s.catalog.photo(PhotoId(r["mapping"]["1"].as_u64().unwrap())).unwrap(); + assert_eq!(master.rating, 5); + assert_eq!(master.flag, Flag::Pick); + assert_eq!(master.meta.keywords, vec!["Travel|Goa"]); + assert_eq!(master.develop.light.exposure, 1.25); + let copy = s.catalog.photos().find(|p| p.copy_of.is_some()).unwrap(); + assert_eq!(copy.copy_of, Some(master.id)); + assert_eq!(copy.rating, 2); + assert_eq!(copy.flag, Flag::Reject); + assert_eq!(copy.develop.light.exposure, -0.5); + let album = s.catalog.albums().find(|a| a.name == "Keepers").unwrap(); + assert_eq!(album.photos, vec![copy.id, master.id]); + assert!(album.parent.is_some()); + assert!(r["unmapped"]["1"].as_array().unwrap().iter().any(|v| v.as_str() == Some("CameraProfile"))); + assert_eq!(s.undo.len(), 1); + s.undo_step().unwrap(); + assert!(s.catalog.is_empty()); + assert!(s.catalog.albums().next().is_none()); + assert!(data.photos[1].settings.contains("CameraProfile")); // unmapped source stays intact + } + #[test] + fn preserves_existing_edits_and_rejects_invalid_structure_before_mutation() { + let mut s = crate::Session::new(); + let data = sample(); + apply(&mut s, data.clone(), false).unwrap(); + let id = s.catalog.photos().find(|p| p.copy_of.is_none()).unwrap().id; + s.set_develop(id, lightcraft_develop::DevelopSettings::default(), "Personal edit").unwrap(); + let r = apply(&mut s, data.clone(), false).unwrap(); + assert!(r["preservedExistingEdits"].as_u64().unwrap() > 0); + assert_eq!(s.catalog.photo(id).unwrap().develop.light.exposure, 0.0); + let before = s.catalog.to_snapshot(); + let mut cyclic = data; + cyclic.collections[0].insert("parent".into(), json!(10)); + assert!(apply(&mut s, cyclic, false).is_err()); + assert_eq!(s.catalog.to_snapshot(), before); + assert!(mapped_settings(&format!("{}0{}", "{".repeat(1000), "}".repeat(1000)), true, 1.0).is_err()); + } + #[test] + fn compressed_xmp_is_bounded_and_settings_are_data_only() { + let packet = b"example"; + let mut encoded = (packet.len() as u32).to_be_bytes().to_vec(); + encoded.extend(miniz_oxide::deflate::compress_to_vec_zlib(packet, 6)); + assert_eq!(xmp(Some(&json!(encoded))).unwrap(), String::from_utf8_lossy(packet)); + encoded[0] = 0xff; + assert!(xmp(Some(&json!(encoded))).is_err()); + let (partial, _) = mapped_settings("s = { Exposure2012 = 0.75, ToneCurvePV2012 = {0,0,128,150,255,255} }", true, 1.5).unwrap(); + assert_eq!(partial["light"]["exposure"], 0.75); + } + + #[test] + fn deferred_auto_tone_sentinels_never_become_real_slider_values() { + let mut data = sample(); + data.photos.truncate(1); + data.photos[0].image.remove("masterImage"); + data.photos[0].settings = + "s = { AutoTone = true, Exposure2012 = -999999, Contrast2012 = -999999, Shadows2012 = 65, Temperature = 6150 }".into(); + data.photos[0].xmp = r#""#.into(); + let mut s = crate::Session::new(); + let report = apply(&mut s, data, false).unwrap(); + let p = s.catalog.photos().next().unwrap(); + assert_eq!(p.develop.light.exposure, 0.0); + assert_eq!(p.develop.light.contrast, 0.0); + assert_eq!(p.develop.light.shadows, 65.0); + assert_eq!(p.develop.light.blacks, -22.0); + assert!(report["unmapped"]["2"].as_array().unwrap().iter().any(|v| v.as_str().is_some_and(|v| v.contains("Deferred")))); + let (partial, _) = mapped_settings("s = { Exposure2012 = -5, Contrast2012 = -100 }", true, 1.0).unwrap(); + assert_eq!(partial["light"]["exposure"], -5.0); + assert_eq!(partial["light"]["contrast"], -100.0); + } + + #[test] + fn wal_checksums_cover_commits_without_materializing_historical_databases() { + let mut main = vec![0; 512]; + main[16..18].copy_from_slice(&512u16.to_be_bytes()); + assert_eq!(wal_checksum(&[1, 0, 0, 0, 2, 0, 0, 0], false, (0, 0)), (1, 3)); + for big in [false, true] { + let mut wal = vec![0; 32]; + wal[0..4].copy_from_slice(&(if big { 0x377f0683u32 } else { 0x377f0682u32 }).to_be_bytes()); + wal[4..8].copy_from_slice(&3_007_000u32.to_be_bytes()); + wal[8..12].copy_from_slice(&512u32.to_be_bytes()); + wal[16..20].copy_from_slice(&123u32.to_be_bytes()); + let mut sum = wal_checksum(&wal[..24], big, (0, 0)); + wal[24..28].copy_from_slice(&sum.0.to_be_bytes()); + wal[28..32].copy_from_slice(&sum.1.to_be_bytes()); + for _ in 0..128 { + let mut frame = vec![0; 536]; + frame[0..4].copy_from_slice(&1u32.to_be_bytes()); + frame[4..8].copy_from_slice(&1u32.to_be_bytes()); + frame[8..12].copy_from_slice(&123u32.to_be_bytes()); + sum = wal_checksum(&frame[..8], big, sum); + sum = wal_checksum(&frame[24..], big, sum); + frame[16..20].copy_from_slice(&sum.0.to_be_bytes()); + frame[20..24].copy_from_slice(&sum.1.to_be_bytes()); + wal.extend(frame); + } + assert_eq!(committed_wal_len(&main, &wal).unwrap(), wal.len()); + let end = wal.len(); + wal[end - 1] ^= 1; + assert_eq!(committed_wal_len(&main, &wal).unwrap(), end - 536); + wal[24] ^= 1; + assert!(committed_wal_len(&main, &wal).is_err()); + main[17] = 0; + assert!(committed_wal_len(&main, &wal).is_err()); + main[16..18].copy_from_slice(&512u16.to_be_bytes()); + } + } + + #[test] + fn persistent_reimport_keeps_copy_and_collection_ids_and_capture_time() { + let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-reimport-{}", std::process::id())); + let mut s = crate::Session::new(); + s.open_library(&dir, false).unwrap(); + let mut data = sample(); + data.collections.reverse(); // Parent must be committed before its child regardless of row order. + let first = apply(&mut s, data.clone(), false).unwrap(); + s.close_library().unwrap(); + s.open_library(&dir, false).unwrap(); + let id = PhotoId(first["mapping"]["1"].as_u64().unwrap()); + 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!(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")); + s.close_library().unwrap(); + std::fs::remove_dir_all(dir).unwrap(); + } + + // Construct a small SQLite fixture from public file-format records, never private media. + fn varint(mut n: u64) -> Vec { + let mut out = vec![(n & 127) as u8]; + n >>= 7; + while n > 0 { + out.push((n & 127) as u8 | 128); + n >>= 7; + } + out.reverse(); + out + } + fn record(values: &[SqlValue]) -> Vec { + let mut header = Vec::new(); + let mut body = Vec::new(); + for value in values { + let serial = match value { + SqlValue::Null => 0, + SqlValue::Integer(n) => { + body.extend(n.to_be_bytes()); + 6 + } + SqlValue::Text(s) => { + body.extend(s.as_bytes()); + 13 + 2 * s.len() as u64 + } + _ => unreachable!(), + }; + header.extend(varint(serial)); + } + let mut out = varint(1 + header.len() as u64); + out.extend(header); + out.extend(body); + out + } + fn leaf(page: &mut [u8], offset: usize, rows: &[Vec]) { + page[offset] = 13; + page[offset + 3..offset + 5].copy_from_slice(&(rows.len() as u16).to_be_bytes()); + let mut end = page.len(); + for (i, values) in rows.iter().enumerate() { + let payload = record(values); + let mut cell = varint(payload.len() as u64); + cell.extend(varint(i as u64 + 1)); + cell.extend(payload); + end -= cell.len(); + page[end..end + cell.len()].copy_from_slice(&cell); + page[offset + 8 + i * 2..offset + 10 + i * 2].copy_from_slice(&(end as u16).to_be_bytes()); + } + page[offset + 5..offset + 7].copy_from_slice(&(end as u16).to_be_bytes()); + } + #[test] + fn reads_native_sqlite_tables_without_lightroom_or_external_processes() { + use SqlValue::{Integer as I, Null as N, Text as T}; + let tables = [ + ("AgLibraryRootFolder", "id_local INTEGER PRIMARY KEY, absolutePath", vec![N, T("/catalog/".into())]), + ("AgLibraryFolder", "id_local INTEGER PRIMARY KEY, rootFolder, pathFromRoot", vec![N, I(1), T("Photos/".into())]), + ("AgLibraryFile", "id_local INTEGER PRIMARY KEY, folder, idx_filename", vec![N, I(1), T("test.ARW".into())]), + ( + "Adobe_images", + "id_local INTEGER PRIMARY KEY, id_global, rootFile, fileFormat, fileWidth, fileHeight, rating, pick", + vec![N, T("fixture-image".into()), I(1), T("ARW".into()), I(20), I(10), I(5), I(1)], + ), + ]; + let mut bytes = vec![0u8; 5 * 2048]; + bytes[..16].copy_from_slice(b"SQLite format 3\0"); + bytes[16..18].copy_from_slice(&2048u16.to_be_bytes()); + bytes[18] = 1; + bytes[19] = 1; + bytes[21] = 64; + bytes[22] = 32; + bytes[23] = 32; + bytes[28..32].copy_from_slice(&5u32.to_be_bytes()); + bytes[44..48].copy_from_slice(&4u32.to_be_bytes()); + bytes[56..60].copy_from_slice(&1u32.to_be_bytes()); + let mut schema = Vec::new(); + for (i, (name, columns, row)) in tables.iter().enumerate() { + schema.push(vec![ + T("table".into()), + T((*name).into()), + T((*name).into()), + I(i as i64 + 2), + T(format!("CREATE TABLE {name} ({columns})")), + ]); + leaf(&mut bytes[(i + 1) * 2048..(i + 2) * 2048], 0, std::slice::from_ref(row)); + } + leaf(&mut bytes[..2048], 100, &schema); + let path = std::env::temp_dir().join(format!("lightcraft-native-catalog-{}.lrcat", std::process::id())); + std::fs::write(&path, &bytes).unwrap(); + let catalog = read(&path).unwrap(); + assert_eq!(catalog.photos.len(), 1); + assert_eq!(catalog.photos[0].uuid, "fixture-image"); + assert_eq!(number(&catalog.photos[0].image, "rating"), 5); + assert!(catalog.photos[0].path.ends_with("test.ARW")); + std::fs::write(&path, b"bad catalog").unwrap(); + assert!(read(&path).is_err()); + std::fs::remove_file(path).unwrap(); + } + + #[test] + fn oversized_optional_table_warns_and_is_skipped() { + let mut main = vec![0u8; 2 * 512]; + main[..16].copy_from_slice(b"SQLite format 3\0"); + main[16..18].copy_from_slice(&512u16.to_be_bytes()); + main[18] = 1; + main[19] = 1; + main[21] = 64; + main[22] = 32; + main[23] = 32; + main[28..32].copy_from_slice(&2u32.to_be_bytes()); + main[44..48].copy_from_slice(&4u32.to_be_bytes()); + main[56..60].copy_from_slice(&1u32.to_be_bytes()); + main[100] = 0x0d; + let page = &mut main[512..]; + page[0] = 0x0d; + page[3..5].copy_from_slice(&1u16.to_be_bytes()); + let mut cell = varint((64 * 1024 * 1024 + 1) as u64); + cell.extend(varint(1)); + let start = 500 - cell.len(); + page[start..start + cell.len()].copy_from_slice(&cell); + page[8..10].copy_from_slice(&u16::try_from(start).unwrap_or(0).to_be_bytes()); + page[5..7].copy_from_slice(&u16::try_from(start).unwrap_or(0).to_be_bytes()); + let db = Database::open_with_wal(main, &[]).unwrap(); + let reader = + Reader { db, tables: vec![LiveTable { name: "optional_history".into(), rootpage: 2, column_names: Some(vec!["payload".into()]) }] }; + let mut warnings = Vec::new(); + let rows = reader.optional_table("optional_history", &mut warnings).unwrap(); + assert!(rows.is_empty()); + assert!(warnings.iter().any(|warning| warning.contains("optional_history"))); + } +} diff --git a/crates/engine/src/lightroom_job.rs b/crates/engine/src/lightroom_job.rs new file mode 100644 index 000000000..fd939341d --- /dev/null +++ b/crates/engine/src/lightroom_job.rs @@ -0,0 +1,450 @@ +//! Detached Lightroom catalog preparation and owner-thread commit. +//! +//! `LightroomJob::new` takes only an in-memory session snapshot. `prepare` owns all catalog +//! reading, probing and archive staging, while `commit_prepared` only applies catalog operations +//! to the session that created the job. Archive index bytes are returned for a worker to finish +//! after the owner has accepted the catalog operation. + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::sync::{ + Arc, + atomic::{AtomicBool, AtomicUsize, Ordering}, +}; + +use serde_json::Value; + +use crate::import::{ImportJob, ImportMode, ImportOptions, Prepared}; +use crate::lightroom_catalog::{self, CatalogImport, ImportIndex}; + +const COMMAND: &str = "library.importLightroom"; +const MAX_INDEX_BYTES: usize = 16 * 1024 * 1024; + +fn error(message: impl Into) -> crate::EngineError { + crate::EngineError::BadParams { cmd: COMMAND.into(), msg: message.into() } +} + +fn stage_archive(archive_dir: Option<&Path>, data: &mut CatalogImport) -> crate::Result> { + let archive_path = archive_dir.map(|dir| crate::lightroom_archive::store_best_effort(dir, data).map_err(error)).transpose()?.flatten(); + if archive_dir.is_some() && archive_path.is_none() { + data.warnings.push("Lightroom source archive exceeded 32 MiB; archive skipped".into()); + } + Ok(archive_path) +} + +/// Memory identity of the library which owns a prepared completion. +/// +/// Each successful open/close refreshes session identity, including reopening the same path. +/// The owner check deliberately does not compare catalog revisions: ordinary edits while preparation +/// runs must survive and are merged by the owner commit. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LightroomLibraryToken { + library_dir: Option, + identity: Arc<()>, +} + +impl LightroomLibraryToken { + fn capture(s: &crate::Session) -> Self { + Self { + library_dir: s.library.as_ref().filter(|library| library.on_disk).map(|library| library.dir.clone()), + identity: Arc::clone(&s.library_identity), + } + } + + /// Whether `s` still refers to the library which started preparation. + pub fn matches(&self, s: &crate::Session) -> bool { + Arc::ptr_eq(&self.identity, &s.library_identity) + && self.library_dir == s.library.as_ref().filter(|library| library.on_disk).map(|library| library.dir.clone()) + } + + pub fn library_dir(&self) -> Option<&Path> { + self.library_dir.as_deref() + } +} + +/// Filesystem-free result of catalog preparation. +pub struct PreparedLightroom { + pub(crate) data: CatalogImport, + pub(crate) normal: Prepared, + pub(crate) index: ImportIndex, + pub(crate) archive_path: Option, + pub(crate) token: LightroomLibraryToken, + pub(crate) missing_sources: HashSet, + pub(crate) update_existing: bool, + pub(crate) now: String, + cancelled: bool, +} + +impl PreparedLightroom { + pub fn source(&self) -> &str { + &self.data.source + } + + pub fn photo_count(&self) -> usize { + self.data.photos.len() + } + + pub fn token(&self) -> &LightroomLibraryToken { + &self.token + } + + pub fn is_cancelled(&self) -> bool { + self.cancelled + } + + /// Roll back any normal-import filesystem placement held by this prepared value. + pub fn rollback(&self) { + self.normal.rollback(); + } +} + +/// Index write returned by owner commit. `finish` is intended for a worker after the owner has +/// returned, and performs the only archive-index filesystem operation in this pipeline. +pub struct LightroomArchiveFinalization { + path: Option, + bytes: Option>, +} + +impl LightroomArchiveFinalization { + pub fn finish(self) -> crate::Result<()> { + let (Some(path), Some(bytes)) = (self.path, self.bytes) else { return Ok(()) }; + if bytes.len() > MAX_INDEX_BYTES { + return Err(error("Lightroom import index exceeds 16 MiB")); + } + lightcraft_catalog::safe_file::write_atomic(&path, &bytes).map_err(|e| error(e.to_string())) + } + + pub fn path(&self) -> Option<&Path> { + self.path.as_deref() + } +} + +/// Owner-thread catalog result. The report is ready for the UI immediately; index finalization +/// can remain on the worker while its task stays alive. +pub struct LightroomCompletion { + pub report: Value, + pub finalization: LightroomArchiveFinalization, +} + +/// Alias used by hosts that call the result a commit. +pub type LightroomCommit = LightroomCompletion; + +/// A Lightroom import detached from the owner thread. +pub struct LightroomJob { + path: PathBuf, + update_existing: bool, + import: ImportJob, + token: LightroomLibraryToken, + archive_dir: Option, +} + +impl LightroomJob { + /// Snapshot session state only. This constructor performs no filesystem work. + pub fn new(s: &mut crate::Session, path: PathBuf, update_existing: bool) -> crate::Result { + let opts = ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() }; + let import = ImportJob::new(s, opts)?; + let archive_dir = s.library.as_ref().filter(|library| library.on_disk).map(|library| library.dir.join("Interop")); + Ok(Self { path, update_existing, import, token: LightroomLibraryToken::capture(s), archive_dir }) + } + + pub fn total(&self) -> usize { + self.import.total.load(Ordering::Relaxed) + } + + pub fn done(&self) -> usize { + self.import.done.load(Ordering::Relaxed) + } + + pub fn progress(&self) -> (usize, usize) { + (self.done(), self.total()) + } + + pub fn token(&self) -> &LightroomLibraryToken { + &self.token + } + + pub fn total_atomic(&self) -> std::sync::Arc { + std::sync::Arc::clone(&self.import.total) + } + + pub fn done_atomic(&self) -> std::sync::Arc { + std::sync::Arc::clone(&self.import.done) + } + + /// Finish an owner commit's deferred archive-index write on a worker. + pub fn finish_archive(finalization: LightroomArchiveFinalization) -> crate::Result<()> { + finalization.finish() + } + + /// Read/probe/stage all source data off the owner thread. + pub fn prepare(&mut self, cancel: &AtomicBool) -> crate::Result { + if cancel.load(Ordering::Relaxed) { + return Err(error("Lightroom import cancelled")); + } + self.import.total.store(1, Ordering::Relaxed); + self.import.done.store(0, Ordering::Relaxed); + let mut data = lightroom_catalog::read_with_progress(&self.path, cancel, &self.import.total, &self.import.done).map_err(error)?; + lightroom_catalog::validate_for_job(&data).map_err(error)?; + if cancel.load(Ordering::Relaxed) { + return Err(error("Lightroom import cancelled")); + } + self.import.done.fetch_add(1, Ordering::Relaxed); + let paths: Vec = data + .photos + .iter() + .filter(|photo| lightroom_catalog::number_for_job(&photo.image, "masterImage") == 0) + .map(|photo| photo.path.clone()) + .collect(); + let missing_sources = data + .photos + .iter() + .filter(|photo| lightroom_catalog::number_for_job(&photo.image, "masterImage") == 0) + .filter(|photo| !Path::new(&photo.path).is_file()) + .map(|photo| normalize(&photo.path)) + .collect(); + let normal = self.import.prepare(&paths, cancel); + if cancel.load(Ordering::Relaxed) { + normal.rollback(); + return Err(error("Lightroom import cancelled")); + } + let archive_path = stage_archive(self.archive_dir.as_deref(), &mut data)?; + let index_path = self.archive_dir.as_ref().map(|dir| dir.join("lightroom-index.json")); + let index = lightroom_catalog::load_index(index_path.as_deref())?; + Ok(PreparedLightroom { + data, + normal, + index, + archive_path, + token: self.token.clone(), + missing_sources, + update_existing: self.update_existing, + now: self.import.now().to_string(), + cancelled: cancel.load(Ordering::Relaxed), + }) + } +} + +/// Apply prepared data on the owner thread. No source, archive or index filesystem work occurs. +pub fn commit_prepared(s: &mut crate::Session, mut prepared: PreparedLightroom) -> crate::Result { + if prepared.cancelled { + prepared.normal.rollback(); + return Err(error("Lightroom import cancelled")); + } + if !prepared.token.matches(s) { + prepared.normal.rollback(); + return Err(error("Lightroom import belongs to a different open library")); + } + // A command-scoped Lightroom import must never rewrite source XMP sidecars. Direct depth-0 + // callers do not set this transient flag, so they cannot affect a later command. + if s.depth > 0 { + s.skip_auto_write = true; + } + + let undo0 = s.undo.len(); + prepared.normal.revalidate_add(s); + let options = ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() }; + let now = prepared.now.clone(); + let files = crate::import::commit_prepared(s, &options, &now, prepared.normal)?; + let applied = lightroom_catalog::apply_prepared( + s, + prepared.data, + lightroom_catalog::ApplyPrepared { + update_existing: prepared.update_existing, + files, + index: &mut prepared.index, + archive_path: prepared.archive_path, + missing_sources: &prepared.missing_sources, + undo0, + }, + )?; + let path = prepared.token.library_dir().map(|dir| dir.join("Interop/lightroom-index.json")); + let bytes = if path.is_some() { Some(serde_json::to_vec(&applied.index).map_err(|e| error(e.to_string()))?) } else { None }; + Ok(LightroomCompletion { report: applied.report, finalization: LightroomArchiveFinalization { path, bytes } }) +} + +fn normalize(path: &str) -> String { + let path = path.replace('\\', "/"); + if cfg!(windows) { path.to_lowercase() } else { path } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn empty_data() -> CatalogImport { + CatalogImport { + source: "fixture.lrcat".into(), + photos: Vec::new(), + collections: Vec::new(), + members: Vec::new(), + collection_content: Vec::new(), + warnings: Vec::new(), + } + } + + fn cancelled_prepared(s: &crate::Session) -> PreparedLightroom { + PreparedLightroom { + data: empty_data(), + normal: Prepared::default(), + index: ImportIndex::default(), + archive_path: None, + token: LightroomLibraryToken::capture(s), + missing_sources: HashSet::new(), + update_existing: false, + now: "2026-01-01T00:00:00".into(), + cancelled: true, + } + } + + fn prepared_for(s: &crate::Session, path: &str, rating: i64) -> PreparedLightroom { + let mut image = std::collections::BTreeMap::new(); + image.insert("id_local".into(), serde_json::json!(1)); + image.insert("rating".into(), serde_json::json!(rating)); + image.insert("pick".into(), serde_json::json!(0)); + image.insert("fileFormat".into(), serde_json::json!("JPG")); + image.insert("fileWidth".into(), serde_json::json!(1)); + image.insert("fileHeight".into(), serde_json::json!(1)); + PreparedLightroom { + data: CatalogImport { + source: "fixture.lrcat".into(), + photos: vec![lightroom_catalog::CatalogPhoto { + source_id: 1, + uuid: "fixture-photo".into(), + path: path.into(), + image, + settings: String::new(), + xmp: String::new(), + keywords: Vec::new(), + history: Vec::new(), + snapshots: Vec::new(), + }], + collections: Vec::new(), + members: Vec::new(), + collection_content: Vec::new(), + warnings: Vec::new(), + }, + normal: Prepared::default(), + index: ImportIndex::default(), + archive_path: None, + token: LightroomLibraryToken::capture(s), + missing_sources: [normalize(path)].into_iter().collect(), + update_existing: false, + now: "2026-01-01T00:00:00".into(), + cancelled: false, + } + } + + #[test] + fn cancellation_before_commit_leaves_owner_catalog_unchanged() { + let mut session = crate::Session::new(); + let before = session.catalog.to_snapshot(); + let prepared = cancelled_prepared(&session); + assert!(commit_prepared(&mut session, prepared).is_err()); + assert_eq!(session.catalog.to_snapshot(), before); + assert!(session.undo.is_empty()); + } + + #[test] + fn library_identity_rejects_late_completion_after_reopen() { + let mut session = crate::Session::new(); + let token = LightroomLibraryToken::capture(&session); + assert!(token.matches(&session)); + session.library_identity = Arc::new(()); + assert!(!token.matches(&session)); + } + + #[test] + fn prepared_reimport_preserves_current_edit_and_uses_one_undo_per_import() { + let mut session = crate::Session::new(); + let path = "/missing/lightroom-job-edit.jpg"; + let prepared = prepared_for(&session, path, 4); + let first = commit_prepared(&mut session, prepared).unwrap(); + assert_eq!(first.report["mapping"]["1"].as_u64(), Some(1)); + assert_eq!(session.undo.len(), 1); + let id = lightcraft_catalog::PhotoId(1); + let mut personal = lightcraft_develop::DevelopSettings::default(); + personal.light.exposure = 1.5; + session.set_develop(id, personal, "Personal").unwrap(); + let before = session.catalog.photo(id).map(|photo| photo.develop.clone()); + let prepared = prepared_for(&session, path, 5); + commit_prepared(&mut session, prepared).unwrap(); + assert_eq!(session.catalog.photo(id).map(|photo| photo.develop.clone()), before); + assert_eq!(session.catalog.photo(id).map(|photo| photo.rating), Some(5)); + assert_eq!(session.undo.len(), 3); + } + + #[test] + fn oversized_history_archive_keeps_prepared_import_successful_with_warning() { + let archive_dir = std::env::temp_dir().join(format!("lightcraft-lr-prepared-scale-{}", std::process::id())); + let mut data = empty_data(); + data.photos = (0..5_000) + .map(|source_id| { + let image = std::collections::BTreeMap::from([ + ("id_local".into(), serde_json::json!(source_id)), + ("fileFormat".into(), serde_json::json!("JPG")), + ("fileWidth".into(), serde_json::json!(1)), + ("fileHeight".into(), serde_json::json!(1)), + ]); + lightroom_catalog::CatalogPhoto { + source_id, + uuid: format!("scale-{source_id}"), + path: format!("/missing/scale-{source_id}.jpg"), + image, + settings: String::new(), + xmp: String::new(), + keywords: Vec::new(), + history: vec![std::collections::BTreeMap::from([( + "text".into(), + serde_json::json!(format!("history-{source_id}-{}", "x".repeat(8 * 1024))), + )])], + snapshots: Vec::new(), + } + }) + .collect(); + let archive_path = stage_archive(Some(&archive_dir), &mut data).unwrap(); + assert!(archive_path.is_none()); + let mut session = crate::Session::new(); + let token = LightroomLibraryToken::capture(&session); + let prepared = PreparedLightroom { + data, + normal: Prepared::default(), + index: ImportIndex::default(), + archive_path, + token, + missing_sources: HashSet::new(), + update_existing: false, + now: "2026-01-01T00:00:00".into(), + cancelled: false, + }; + let completion = commit_prepared(&mut session, prepared).unwrap(); + assert_eq!(completion.report["photos"], 5_000); + assert!( + completion.report["warnings"] + .as_array() + .is_some_and(|warnings| warnings.iter().any(|warning| warning == "Lightroom source archive exceeded 32 MiB; archive skipped")) + ); + assert_eq!(session.catalog.len(), 5_000); + std::fs::remove_dir_all(archive_dir).unwrap(); + } + + #[test] + fn execute_fn_import_does_not_rewrite_source_sidecar() { + let dir = std::env::temp_dir().join(format!("lightcraft-lr-job-sidecar-{}", std::process::id())); + let path = dir.join("source.jpg"); + let sidecar = dir.join("source.xmp"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(&path, b"fixture").unwrap(); + std::fs::write(&sidecar, b"original sidecar").unwrap(); + let original = std::fs::read(&sidecar).unwrap(); + let mut session = crate::Session::new(); + session.xmp.auto_write = true; + let result = session.execute_fn("library.importLightroom", |s| { + let prepared = prepared_for(s, &path.to_string_lossy(), 5); + let completion = commit_prepared(s, prepared)?; + Ok(completion.report) + }); + assert!(result.is_ok()); + assert_eq!(std::fs::read(&sidecar).unwrap(), original); + std::fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/crates/engine/src/lightroom_sqlite.rs b/crates/engine/src/lightroom_sqlite.rs new file mode 100644 index 000000000..900fe4a26 --- /dev/null +++ b/crates/engine/src/lightroom_sqlite.rs @@ -0,0 +1,1148 @@ +//! Small, read-only SQLite reader used by Lightroom catalog import. +//! +//! This is a clean-room implementation based on SQLite's public file-format +//! description: (sections 1.6, 1.7, +//! 2.1, 2.2, 2.3, 2.4 and 2.5). It is deliberately a page reader, rather +//! than a SQL engine. It never writes its input and has no SQLite runtime +//! dependency. + +use std::collections::{HashMap, HashSet}; + +const MAX_PAGE_ID: u32 = 16_777_216; +const MAX_COLUMNS: usize = 16_384; +const MAX_ROWS: usize = 1_000_000; +const MAX_CELLS: usize = 4_000_000; +const MAX_PAYLOAD: usize = 64 * 1024 * 1024; +const MAX_TABLE_PAYLOAD: usize = 256 * 1024 * 1024; +const MAX_OVERFLOW_PAGES: usize = 1_048_576; +const MAX_BTREE_DEPTH: usize = 64; + +/// Values supported by SQLite table records. +#[derive(Clone, Debug, PartialEq)] +pub enum Value { + Null, + Integer(i64), + Real(f64), + Text(String), + Blob(Vec), +} + +/// A row returned by [`Database::read_table`]. +#[derive(Clone, Debug, PartialEq)] +pub struct Record { + pub values: Vec, +} + +/// A table discovered in `sqlite_schema`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LiveTable { + pub name: String, + pub rootpage: u32, + pub column_names: Option>, +} + +/// Read-only SQLite database image, with a committed WAL overlay if supplied. +pub struct Database { + main: Vec, + overlay: HashMap>, + page_size: usize, + reserved: usize, + encoding: TextEncoding, + page_count: u32, + tables: Vec, + rowid_aliases: HashMap, +} + +#[derive(Clone, Copy)] +enum TextEncoding { + Utf8, + Utf16Le, + Utf16Be, +} + +struct WalkState { + seen: HashSet, + out: Vec, + payload_total: usize, +} + +struct WalOverlay { + page_size: usize, + frame_count: usize, + pages: HashMap>, + page_count: Option, +} + +struct SchemaInfo { + tables: Vec, + aliases: HashMap, +} + +impl Database { + /// Open a main database image and an already checksum-validated WAL prefix. + /// + /// The WAL argument must include its 32-byte header and only complete + /// frames through a commit. Frames are overlaid by page number, with the + /// final commit's database-size field defining the visible page count. + pub fn open_with_wal(main: Vec, committed_wal: &[u8]) -> Result { + let main_header = main.get(..100).ok_or("truncated SQLite header")?; + if main_header.get(..16) != Some(b"SQLite format 3\0") { + return Err("invalid SQLite header signature".into()); + } + let page_size = sqlite_page_size(be_u16(main_header, 16)?)?; + validate_header_fields(main_header, page_size)?; + if main.len() < page_size || !main.len().is_multiple_of(page_size) { + return Err("SQLite image is not a whole number of pages".into()); + } + let main_pages = u32::try_from(main.len() / page_size).map_err(|_| "SQLite image is too large")?; + if main_pages == 0 || main_pages > MAX_PAGE_ID { + return Err("invalid SQLite image page count".into()); + } + + let mut overlay = HashMap::new(); + let mut wal_page_count = None; + if !committed_wal.is_empty() { + let parsed = parse_wal(committed_wal, page_size)?; + if parsed.page_size != page_size { + return Err("WAL page size mismatch".into()); + } + if parsed.frame_count > MAX_CELLS { + return Err("WAL contains too many frames".into()); + } + overlay = parsed.pages; + wal_page_count = parsed.page_count; + } + let effective_header = overlay.get(&1).map(Vec::as_slice).unwrap_or(main_header); + if effective_header.get(..16) != Some(b"SQLite format 3\0") { + return Err("invalid SQLite header page in WAL".into()); + } + let effective_size = sqlite_page_size(be_u16(effective_header, 16)?)?; + if effective_size != page_size { + return Err("WAL page 1 changes SQLite page size".into()); + } + let reserved = validate_header_fields(effective_header, page_size)?; + let header_pages = be_u32(effective_header, 28)?; + let header_valid = be_u32(effective_header, 24)? != 0 && be_u32(effective_header, 24)? == be_u32(effective_header, 92)?; + if header_valid && header_pages > main_pages && wal_page_count.is_none() { + return Err("SQLite header page count exceeds image".into()); + } + let mut page_count = if header_valid && header_pages != 0 { header_pages } else { main_pages }; + if let Some(count) = wal_page_count { + page_count = count; + } + if page_count == 0 || page_count > MAX_PAGE_ID { + return Err("invalid visible SQLite page count".into()); + } + let encoding = match be_u32(effective_header, 56)? { + 0 | 1 => TextEncoding::Utf8, + 2 => TextEncoding::Utf16Le, + 3 => TextEncoding::Utf16Be, + _ => return Err("unsupported SQLite text encoding".into()), + }; + + let mut db = Self { main, overlay, page_size, reserved, encoding, page_count, tables: Vec::new(), rowid_aliases: HashMap::new() }; + let schema = db.parse_schema()?; + db.tables = schema.tables; + db.rowid_aliases = schema.aliases; + Ok(db) + } + + /// Return tables from `sqlite_schema` in schema order. + pub fn live_tables(&self) -> Result, String> { + Ok(self.tables.clone()) + } + + /// Read a table B-tree, decoding up to `ncols` values per record. + pub fn read_table(&self, rootpage: u32, ncols: usize) -> Result, String> { + if !(1..=MAX_PAGE_ID).contains(&rootpage) || rootpage > self.page_count { + return Err("table root page is outside database".into()); + } + if ncols > MAX_COLUMNS { + return Err("table has too many columns".into()); + } + let alias = self.rowid_aliases.get(&rootpage).copied(); + let mut state = WalkState { seen: HashSet::new(), out: Vec::new(), payload_total: 0 }; + self.visit_table(rootpage, ncols, alias, 0, &mut state)?; + Ok(state.out) + } + + fn page(&self, page_id: u32) -> Result<&[u8], String> { + if page_id == 0 || page_id > self.page_count { + return Err(format!("page {page_id} is outside database")); + } + if let Some(page) = self.overlay.get(&page_id) { + return Ok(page); + } + let id = usize::try_from(page_id).map_err(|_| "page number does not fit host")?; + let start = id.checked_sub(1).and_then(|n| n.checked_mul(self.page_size)).ok_or("page offset overflow")?; + let end = start.checked_add(self.page_size).ok_or("page offset overflow")?; + self.main.get(start..end).ok_or_else(|| format!("page {page_id} is truncated")) + } + + fn visit_table(&self, page_id: u32, ncols: usize, alias: Option, depth: usize, state: &mut WalkState) -> Result<(), String> { + if depth > MAX_BTREE_DEPTH { + return Err("SQLite B-tree is too deep".into()); + } + if !state.seen.insert(page_id) { + return Err("SQLite B-tree contains a cycle or shared page".into()); + } + let page = self.page(page_id)?; + let usable = self.page_size.checked_sub(self.reserved).ok_or("invalid usable page size")?; + let base = if page_id == 1 { 100 } else { 0 }; + let content = page.get(..usable).ok_or("truncated usable SQLite page")?; + let kind = *content.get(base).ok_or("truncated B-tree header")?; + let cells = usize::from(be_u16_at(content, base + 3)?); + if cells > MAX_CELLS || state.out.len().saturating_add(cells) > MAX_ROWS.saturating_add(MAX_CELLS) { + return Err("SQLite B-tree contains too many cells".into()); + } + match kind { + 0x0d => { + let ptr_start = base.checked_add(8).ok_or("B-tree header overflow")?; + let ptr_bytes = cells.checked_mul(2).ok_or("B-tree pointer-array overflow")?; + let ptr_end = ptr_start.checked_add(ptr_bytes).ok_or("B-tree pointer-array overflow")?; + if ptr_end > usable { + return Err("truncated B-tree pointer array".into()); + } + for i in 0..cells { + let p = ptr_start.checked_add(i.checked_mul(2).ok_or("cell offset overflow")?).ok_or("cell offset overflow")?; + let cell = usize::from(be_u16_at(content, p)?); + if cell < ptr_end || cell >= usable { + return Err("invalid B-tree cell pointer".into()); + } + let mut at = cell; + let payload_len = usize_from_u64(read_varint(content, &mut at)?, "payload length")?; + if payload_len > MAX_PAYLOAD { + return Err("SQLite record payload is too large".into()); + } + let rowid = i64::from_be_bytes(read_varint(content, &mut at)?.to_be_bytes()); + state.payload_total = state.payload_total.checked_add(payload_len).ok_or("SQLite table payload total overflow")?; + if state.payload_total > MAX_TABLE_PAYLOAD { + return Err("SQLite table payload exceeds bounded import size".into()); + } + let payload = self.payload(page, at, payload_len)?; + let mut values = decode_record(&payload, ncols, self.encoding)?; + if let Some(index) = alias { + if index >= values.len() { + return Err("INTEGER PRIMARY KEY column is missing from record".into()); + } + if let Some(value @ Value::Null) = values.get_mut(index) { + *value = Value::Integer(rowid); + } + } + if state.out.len() >= MAX_ROWS { + return Err("SQLite table contains more than one million records".into()); + } + state.out.push(Record { values }); + } + Ok(()) + } + 0x05 => { + let ptr_start = base.checked_add(12).ok_or("B-tree header overflow")?; + let ptr_bytes = cells.checked_mul(2).ok_or("B-tree pointer-array overflow")?; + let ptr_end = ptr_start.checked_add(ptr_bytes).ok_or("B-tree pointer-array overflow")?; + if ptr_end > usable { + return Err("truncated interior B-tree pointer array".into()); + } + for i in 0..cells { + let p = ptr_start.checked_add(i.checked_mul(2).ok_or("cell offset overflow")?).ok_or("cell offset overflow")?; + let cell = usize::from(be_u16_at(content, p)?); + if cell < ptr_end || cell >= usable { + return Err("invalid interior B-tree cell pointer".into()); + } + let child = be_u32_at(content, cell)?; + if child == 0 { + return Err("interior B-tree cell has null child".into()); + } + let mut key_at = cell.checked_add(4).ok_or("interior cell overflow")?; + let _ = read_varint(content, &mut key_at)?; + self.visit_table(child, ncols, alias, depth + 1, state)?; + } + let right = be_u32_at(content, base + 8)?; + if right == 0 { + return Err("interior B-tree has null right child".into()); + } + self.visit_table(right, ncols, alias, depth + 1, state) + } + 0x02 | 0x0a => Err("SQLite index B-trees are unsupported".into()), + other => Err(format!("unsupported SQLite B-tree page type 0x{other:02x}")), + } + } + + fn payload(&self, page: &[u8], cell_at: usize, payload_len: usize) -> Result, String> { + let usable = self.page_size.checked_sub(self.reserved).ok_or("invalid usable page size")?; + if usable < 8 { + return Err("SQLite usable page size is too small".into()); + } + let max_local = usable.checked_sub(35).ok_or("SQLite usable page size is too small")?; + let min_local = usable + .checked_sub(12) + .ok_or("SQLite usable page size is too small")? + .saturating_mul(32) + .checked_div(255) + .ok_or("SQLite local payload calculation failed")? + .saturating_sub(23); + let local = if payload_len <= max_local { + payload_len + } else { + let span = usable.checked_sub(4).ok_or("SQLite usable page size is too small")?; + let candidate = min_local.saturating_add(payload_len.checked_sub(min_local).ok_or("SQLite payload length is too small")? % span); + if candidate > max_local { min_local } else { candidate } + }; + let local_end = cell_at.checked_add(local).ok_or("SQLite cell payload overflow")?; + if local_end > usable { + return Err("truncated SQLite cell payload".into()); + } + let overflow_bytes = self + .page_count + .saturating_sub(1) + .try_into() + .unwrap_or(usize::MAX) + .min(MAX_OVERFLOW_PAGES) + .checked_mul(usable.checked_sub(4).ok_or("SQLite usable page size is too small")?) + .ok_or("SQLite overflow capacity overflow")?; + let capacity = local.checked_add(overflow_bytes).ok_or("SQLite payload capacity overflow")?.min(payload_len); + let mut out = Vec::with_capacity(capacity); + out.extend_from_slice(page.get(cell_at..local_end).ok_or("truncated SQLite cell payload")?); + let mut remaining = payload_len.checked_sub(local).ok_or("SQLite payload length is too small")?; + if remaining == 0 { + return Ok(out); + } + let overflow_at = local_end; + match overflow_at.checked_add(4) { + Some(end) if end <= usable => {} + _ => return Err("SQLite overflow pointer is in reserved page bytes".into()), + } + let first = be_u32_at(page, overflow_at)?; + let mut next = first; + let mut seen = HashSet::new(); + while remaining > 0 { + if next == 0 || !seen.insert(next) || seen.len() > MAX_OVERFLOW_PAGES { + return Err("invalid or cyclic SQLite overflow chain".into()); + } + let overflow = self.page(next)?; + let take = remaining.min(usable.checked_sub(4).ok_or("SQLite usable page size is too small")?); + let end = 4usize.checked_add(take).ok_or("overflow payload offset")?; + out.extend_from_slice(overflow.get(4..end).ok_or("truncated SQLite overflow page")?); + remaining -= take; + if remaining > 0 { + next = be_u32_at(overflow, 0)?; + } + } + Ok(out) + } + + fn parse_schema(&self) -> Result { + let mut tables = Vec::new(); + let mut aliases = HashMap::new(); + let mut state = WalkState { seen: HashSet::new(), out: Vec::new(), payload_total: 0 }; + self.visit_table(1, 5, None, 0, &mut state)?; + for record in state.out { + if record.values.len() < 5 { + return Err("sqlite_schema row has too few columns".into()); + } + let kind = match record.values.first() { + Some(Value::Text(s)) => s.as_str(), + _ => return Err("sqlite_schema row has invalid type".into()), + }; + let name = match record.values.get(1) { + Some(Value::Text(s)) => s.clone(), + _ => return Err("sqlite_schema row has invalid name".into()), + }; + if kind.eq_ignore_ascii_case("virtual") { + continue; + } + if !kind.eq_ignore_ascii_case("table") { + continue; + } + let rootpage = match record.values.get(3) { + Some(Value::Integer(n)) if *n > 0 => u32::try_from(*n).map_err(|_| "sqlite_schema root page is invalid")?, + _ => continue, + }; + let sql = match record.values.get(4) { + Some(Value::Text(s)) => s, + _ => continue, + }; + let Ok((column_names, alias)) = parse_create_table(sql) else { continue }; + if column_names.len() > MAX_COLUMNS { + continue; + } + if tables.iter().any(|table: &LiveTable| table.rootpage == rootpage) { + continue; + } + if let Some(index) = alias { + aliases.insert(rootpage, index); + } + tables.push(LiveTable { name, rootpage, column_names: Some(column_names) }); + } + Ok(SchemaInfo { tables, aliases }) + } +} + +fn validate_header_fields(header: &[u8], page_size: usize) -> Result { + let write_version = *header.get(18).ok_or("truncated SQLite header")?; + let read_version = *header.get(19).ok_or("truncated SQLite header")?; + if !matches!(write_version, 1 | 2) || !matches!(read_version, 1 | 2) { + return Err("unsupported SQLite file format version".into()); + } + if *header.get(21).ok_or("truncated SQLite header")? != 64 + || *header.get(22).ok_or("truncated SQLite header")? != 32 + || *header.get(23).ok_or("truncated SQLite header")? != 32 + { + return Err("invalid SQLite payload fractions".into()); + } + let reserved = usize::from(*header.get(20).ok_or("truncated SQLite header")?); + let usable = page_size.checked_sub(reserved).ok_or("invalid SQLite reserved-byte count")?; + if usable < 480 { + return Err("SQLite usable page size is below 480 bytes".into()); + } + let schema_format = be_u32(header, 44)?; + if !(1..=4).contains(&schema_format) { + return Err("unsupported SQLite schema format".into()); + } + Ok(reserved) +} + +fn parse_wal(wal: &[u8], page_size: usize) -> Result { + let header = wal.get(..32).ok_or("truncated WAL header")?; + let magic = be_u32(header, 0)?; + if magic != 0x377f0682 && magic != 0x377f0683 { + return Err("invalid WAL magic".into()); + } + if be_u32(header, 4)? != 3_007_000 || be_u32(header, 8)? != u32::try_from(page_size).map_err(|_| "page size overflow")? { + return Err("invalid WAL page size or format version".into()); + } + let stride = page_size.checked_add(24).ok_or("WAL frame size overflow")?; + let body = wal.get(32..).ok_or("truncated WAL body")?; + if body.len() % stride != 0 { + return Err("WAL ends with a partial frame".into()); + } + let salt = (be_u32(header, 16)?, be_u32(header, 20)?); + let frame_count = body.len() / stride; + if frame_count > MAX_CELLS { + return Err("WAL has too many frames".into()); + } + let mut overlay = HashMap::new(); + let mut page_count = None; + for frame_no in 0..frame_count { + let start = frame_no.checked_mul(stride).ok_or("WAL frame offset overflow")?; + let end = start.checked_add(stride).ok_or("WAL frame offset overflow")?; + let frame = body.get(start..end).ok_or("truncated WAL frame")?; + let page_id = be_u32(frame, 0)?; + if page_id == 0 || page_id > MAX_PAGE_ID || salt != (be_u32(frame, 8)?, be_u32(frame, 12)?) { + return Err("invalid WAL frame page or salt".into()); + } + let commit = be_u32(frame, 4)?; + if commit != 0 { + if commit > MAX_PAGE_ID { + return Err("WAL commit database size is too large".into()); + } + page_count = Some(commit); + } + overlay.insert(page_id, frame.get(24..).ok_or("truncated WAL page")?.to_vec()); + } + Ok(WalOverlay { page_size, frame_count, pages: overlay, page_count }) +} + +fn decode_record(payload: &[u8], ncols: usize, encoding: TextEncoding) -> Result, String> { + let mut at = 0; + let header_len = usize_from_u64(read_varint(payload, &mut at)?, "record header length")?; + if header_len < at || header_len > payload.len() { + return Err("invalid SQLite record header length".into()); + } + let mut serials = Vec::new(); + while at < header_len { + if serials.len() >= ncols { + return Err("SQLite record has more columns than table schema".into()); + } + serials.push(read_varint(payload, &mut at)?); + } + if at != header_len { + return Err("SQLite record header is malformed".into()); + } + let mut body = header_len; + let mut values = Vec::with_capacity(ncols); + for serial in serials { + let (value, size) = serial_value(payload, &mut body, serial, encoding)?; + values.push(value); + if values.len() > MAX_COLUMNS { + return Err("SQLite record has too many columns".into()); + } + let _ = size; + } + if body > payload.len() { + return Err("SQLite record body is truncated".into()); + } + values.resize(ncols, Value::Null); + Ok(values) +} + +fn serial_value(payload: &[u8], body: &mut usize, serial: u64, encoding: TextEncoding) -> Result<(Value, usize), String> { + let (kind, len) = match serial { + 0 => (0, 0), + 1 => (1, 1), + 2 => (2, 2), + 3 => (3, 3), + 4 => (4, 4), + 5 => (5, 6), + 6 => (6, 8), + 7 => (7, 8), + 8 => (8, 0), + 9 => (9, 0), + 10 | 11 => return Err("reserved SQLite serial type".into()), + n if n >= 12 => { + let len = usize_from_u64((n - if n % 2 == 0 { 12 } else { 13 }) / 2, "SQLite field length")?; + (if n % 2 == 0 { 12 } else { 13 }, len) + } + _ => return Err("invalid SQLite serial type".into()), + }; + let end = body.checked_add(len).ok_or("SQLite record body offset overflow")?; + let bytes = payload.get(*body..end).ok_or("SQLite record body is truncated")?; + let value = match kind { + 0 => Value::Null, + 1 => Value::Integer(i64::from(i8::from_be_bytes([*bytes.first().ok_or("truncated integer")?]))), + 2 => Value::Integer(i64::from(i16::from_be_bytes(bytes.try_into().map_err(|_| "truncated integer")?))), + 3 => { + let first = *bytes.first().ok_or("truncated integer")?; + let second = *bytes.get(1).ok_or("truncated integer")?; + let third = *bytes.get(2).ok_or("truncated integer")?; + let n = (u32::from(first) << 16) | (u32::from(second) << 8) | u32::from(third); + let signed = if n & 0x80_0000 != 0 { n | 0xff00_0000 } else { n }; + Value::Integer(i64::from(i32::from_be_bytes(signed.to_be_bytes()))) + } + 4 => Value::Integer(i64::from(i32::from_be_bytes(bytes.try_into().map_err(|_| "truncated integer")?))), + 5 => { + let n = bytes.iter().fold(0u64, |acc, b| (acc << 8) | u64::from(*b)); + let signed = if n & (1 << 47) != 0 { n | 0xffff_0000_0000_0000 } else { n }; + Value::Integer(i64::from_be_bytes(signed.to_be_bytes())) + } + 6 => Value::Integer(i64::from_be_bytes(bytes.try_into().map_err(|_| "truncated integer")?)), + 7 => { + let n = f64::from_be_bytes(bytes.try_into().map_err(|_| "truncated real")?); + if !n.is_finite() { + return Err("non-finite SQLite real value".into()); + } + Value::Real(n) + } + 8 => Value::Integer(0), + 9 => Value::Integer(1), + 12 => Value::Blob(bytes.to_vec()), + 13 => Value::Text(decode_text(bytes, encoding)?), + _ => return Err("invalid SQLite field type".into()), + }; + *body = end; + Ok((value, len)) +} + +fn decode_text(bytes: &[u8], encoding: TextEncoding) -> Result { + match encoding { + TextEncoding::Utf8 => String::from_utf8(bytes.to_vec()).map_err(|_| "invalid UTF-8 SQLite text".into()), + TextEncoding::Utf16Le | TextEncoding::Utf16Be => { + if !bytes.len().is_multiple_of(2) { + return Err("odd-length UTF-16 SQLite text".into()); + } + let units: Result, String> = bytes + .as_chunks::<2>() + .0 + .iter() + .map(|pair| { + let first = *pair.first().ok_or("truncated UTF-16 SQLite text")?; + let second = *pair.get(1).ok_or("truncated UTF-16 SQLite text")?; + Ok(match encoding { + TextEncoding::Utf16Le => u16::from_le_bytes([first, second]), + TextEncoding::Utf16Be => u16::from_be_bytes([first, second]), + TextEncoding::Utf8 => return Err("invalid UTF-16 encoding state".into()), + }) + }) + .collect(); + String::from_utf16(&units?).map_err(|_| "invalid UTF-16 SQLite text".into()) + } + } +} + +fn parse_create_table(sql: &str) -> Result<(Vec, Option), String> { + let tokens = tokenize_sql(sql)?; + if !tokens.first().is_some_and(|t| t.word_is("CREATE")) { + return Err("malformed CREATE TABLE SQL".into()); + } + let mut kind_at = 1usize; + if tokens.get(kind_at).is_some_and(|t| t.word_is("TEMPORARY") || t.word_is("TEMP")) { + kind_at += 1; + } + if tokens.get(kind_at).is_some_and(|t| t.word_is("VIRTUAL")) { + return Err("virtual tables are unsupported".into()); + } + if !tokens.get(kind_at).is_some_and(|t| t.word_is("TABLE")) { + return Err("malformed CREATE TABLE SQL".into()); + } + let open = tokens.iter().position(|t| t.is_symbol(b'(')).ok_or("CREATE TABLE has no column list")?; + let mut depth = 0usize; + let mut close = None; + for (i, token) in tokens.iter().enumerate().skip(open) { + if token.is_symbol(b'(') { + depth += 1; + } else if token.is_symbol(b')') { + depth = depth.checked_sub(1).ok_or("unbalanced CREATE TABLE parentheses")?; + if depth == 0 { + close = Some(i); + break; + } + } + } + let close = close.ok_or("unterminated CREATE TABLE column list")?; + if tokens + .get(close + 1..) + .ok_or("invalid CREATE TABLE suffix")? + .windows(2) + .any(|w| w.first().is_some_and(|t| t.word_is("WITHOUT")) && w.get(1).is_some_and(|t| t.word_is("ROWID"))) + { + return Err("WITHOUT ROWID tables are unsupported".into()); + } + let body = tokens.get(open + 1..close).ok_or("invalid CREATE TABLE column list")?; + let mut columns = Vec::new(); + let mut alias = None; + let mut integer_columns = HashSet::new(); + let mut table_primary = None; + let mut start = 0usize; + let mut nested = 0usize; + for i in 0..=body.len() { + let comma = i == body.len() || (nested == 0 && body.get(i).is_some_and(|t| t.is_symbol(b','))); + if comma { + let part = body.get(start..i).ok_or("invalid CREATE TABLE column range")?; + if part.is_empty() { + return Err("empty CREATE TABLE column definition".into()); + } + if is_table_constraint(part) { + if table_primary.is_none() { + table_primary = table_primary_column(part); + } + } else { + let name = part.first().and_then(Token::identifier).ok_or("column name is not an identifier")?; + if columns.iter().any(|old| old == name) { + return Err("duplicate SQLite column name".into()); + } + let index = columns.len(); + if exact_integer_decl(part) { + integer_columns.insert(name.to_ascii_lowercase()); + } + if integer_primary_key_alias(part) { + alias = Some(index); + } + columns.push(name.to_string()); + } + start = i + 1; + } else if body.get(i).is_some_and(|token| token.is_symbol(b'(')) { + nested += 1; + } else if body.get(i).is_some_and(|token| token.is_symbol(b')')) { + nested = nested.checked_sub(1).ok_or("unbalanced CREATE TABLE column definition")?; + } + } + if columns.is_empty() { + return Err("CREATE TABLE has no columns".into()); + } + if alias.is_none() + && let Some(primary) = table_primary + { + let key = primary.to_ascii_lowercase(); + if integer_columns.contains(&key) { + alias = columns.iter().position(|column| column.eq_ignore_ascii_case(&primary)); + } + } + Ok((columns, alias)) +} + +fn is_table_constraint(part: &[Token]) -> bool { + part.first().is_some_and(|token| ["CONSTRAINT", "PRIMARY", "UNIQUE", "CHECK", "FOREIGN"].iter().any(|word| token.word_is(word))) +} + +fn exact_integer_decl(part: &[Token]) -> bool { + if !part.get(1).is_some_and(|token| token.word_is("INTEGER")) { + return false; + } + match part.get(2) { + None => true, + Some(token) if token.is_symbol(b'(') => false, + Some(token) => ["PRIMARY", "NOT", "NULL", "UNIQUE", "CHECK", "DEFAULT", "COLLATE", "REFERENCES", "CONSTRAINT", "GENERATED"] + .iter() + .any(|word| token.word_is(word)), + } +} + +fn table_primary_column(part: &[Token]) -> Option { + let primary = part.iter().position(|token| token.word_is("PRIMARY"))?; + if !part.get(primary + 1).is_some_and(|token| token.word_is("KEY")) { + return None; + } + let open = primary + 2; + if !part.get(open).is_some_and(|token| token.is_symbol(b'(')) { + return None; + } + let close = (open + 1..part.len()).find(|index| part.get(*index).is_some_and(|token| token.is_symbol(b')')))?; + let terms = part.get(open + 1..close)?; + if terms.len() == 1 || (terms.len() == 2 && terms.get(1).is_some_and(|token| token.word_is("ASC") || token.word_is("DESC"))) { + return terms.first().and_then(Token::identifier).map(str::to_owned); + } + None +} + +fn integer_primary_key_alias(part: &[Token]) -> bool { + if !exact_integer_decl(part) { + return false; + } + let mut primary = None; + let mut depth = 0usize; + for i in 2..part.len() { + if part.get(i).is_some_and(|token| token.is_symbol(b'(')) { + depth += 1; + } else if part.get(i).is_some_and(|token| token.is_symbol(b')')) { + depth = depth.saturating_sub(1); + } else if depth == 0 && part.get(i).is_some_and(|token| token.word_is("PRIMARY")) && part.get(i + 1).is_some_and(|t| t.word_is("KEY")) { + primary = Some(i); + break; + } + } + let Some(i) = primary else { return false }; + !part.get(i + 2).is_some_and(|token| token.word_is("DESC")) && !part.get(i + 2).is_some_and(|token| token.is_symbol(b'(')) +} + +#[derive(Clone, Debug)] +enum Token { + Word(String), + Quoted(String), + StringLiteral, + Symbol(u8), +} + +impl Token { + fn is_symbol(&self, symbol: u8) -> bool { + matches!(self, Token::Symbol(value) if *value == symbol) + } + + fn word_is(&self, wanted: &str) -> bool { + matches!(self, Token::Word(word) if word.eq_ignore_ascii_case(wanted)) + } + + fn identifier(&self) -> Option<&str> { + match self { + Token::Word(word) | Token::Quoted(word) => Some(word), + _ => None, + } + } +} + +fn tokenize_sql(sql: &str) -> Result, String> { + let bytes = sql.as_bytes(); + let mut out = Vec::new(); + let mut at = 0usize; + while at < bytes.len() { + if bytes.get(at).is_some_and(u8::is_ascii_whitespace) { + at += 1; + } else if bytes.get(at) == Some(&b'-') && bytes.get(at + 1) == Some(&b'-') { + at += 2; + while at < bytes.len() && bytes.get(at) != Some(&b'\n') { + at += 1; + } + } else if bytes.get(at) == Some(&b'/') && bytes.get(at + 1) == Some(&b'*') { + at += 2; + let mut closed = false; + while at + 1 < bytes.len() { + if bytes.get(at) == Some(&b'*') && bytes.get(at + 1) == Some(&b'/') { + at += 2; + closed = true; + break; + } + at += 1; + } + if !closed { + return Err("unterminated SQL comment".into()); + } + } else if matches!(bytes.get(at), Some(b'(' | b')' | b',' | b'.' | b';')) { + out.push(Token::Symbol(*bytes.get(at).ok_or("truncated SQL punctuation")?)); + at += 1; + } else if matches!(bytes.get(at), Some(b'"' | b'`' | b'[')) { + let quote = *bytes.get(at).ok_or("truncated SQL quote")?; + let end_quote = if quote == b'[' { b']' } else { quote }; + at += 1; + let mut value = Vec::new(); + let mut closed = false; + while at < bytes.len() { + if bytes.get(at) == Some(&end_quote) { + if bytes.get(at + 1) == Some(&end_quote) { + value.push(end_quote); + at += 2; + } else { + at += 1; + closed = true; + break; + } + } else { + value.push(*bytes.get(at).ok_or("truncated SQL identifier")?); + at += 1; + } + } + if !closed { + return Err("unterminated quoted SQL identifier".into()); + } + out.push(Token::Quoted(String::from_utf8(value).map_err(|_| "invalid SQL identifier encoding")?)); + } else if bytes.get(at) == Some(&b'\'') { + at += 1; + let mut closed = false; + while at < bytes.len() { + if bytes.get(at) == Some(&b'\'') { + if bytes.get(at + 1) == Some(&b'\'') { + at += 2; + } else { + at += 1; + closed = true; + break; + } + } else { + at += 1; + } + } + if !closed { + return Err("unterminated SQL string".into()); + } + out.push(Token::StringLiteral); + } else { + let start = at; + while at < bytes.len() + && !bytes.get(at).is_some_and(u8::is_ascii_whitespace) + && !matches!(bytes.get(at), Some(b'(' | b')' | b',' | b'.' | b';' | b'\'' | b'"' | b'`' | b'[')) + { + at += 1; + } + if start == at { + return Err("unsupported SQL punctuation".into()); + } + out.push(Token::Word(sql.get(start..at).ok_or("invalid SQL token boundary")?.to_string())); + } + } + Ok(out) +} + +fn sqlite_page_size(value: u16) -> Result { + let size = if value == 1 { 65_536 } else { usize::from(value) }; + if !(512..=65_536).contains(&size) || !size.is_power_of_two() { + return Err("invalid SQLite page size".into()); + } + Ok(size) +} + +fn read_varint(bytes: &[u8], at: &mut usize) -> Result { + let mut value = 0u64; + for i in 0..9 { + let byte = *bytes.get(*at).ok_or("truncated SQLite varint")?; + *at = (*at).checked_add(1).ok_or("SQLite varint offset overflow")?; + if i == 8 { + value = (value << 8) | u64::from(byte); + return Ok(value); + } + value = (value << 7) | u64::from(byte & 0x7f); + if byte & 0x80 == 0 { + return Ok(value); + } + } + Err("invalid SQLite varint".into()) +} + +fn usize_from_u64(value: u64, label: &str) -> Result { + usize::try_from(value).map_err(|_| format!("{label} exceeds host size")) +} + +fn be_u16(bytes: &[u8], at: usize) -> Result { + let end = at.checked_add(2).ok_or("SQLite integer offset overflow")?; + bytes.get(at..end).and_then(|s| <[u8; 2]>::try_from(s).ok()).map(u16::from_be_bytes).ok_or("truncated SQLite integer".into()) +} + +fn be_u16_at(bytes: &[u8], at: usize) -> Result { + be_u16(bytes, at) +} + +fn be_u32(bytes: &[u8], at: usize) -> Result { + let end = at.checked_add(4).ok_or("SQLite integer offset overflow")?; + bytes.get(at..end).and_then(|s| <[u8; 4]>::try_from(s).ok()).map(u32::from_be_bytes).ok_or("truncated SQLite integer".into()) +} + +fn be_u32_at(bytes: &[u8], at: usize) -> Result { + be_u32(bytes, at) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn varint(mut value: u64) -> Vec { + let mut out = vec![u8::try_from(value & 0x7f).unwrap_or(0)]; + value >>= 7; + while value != 0 { + out.push(u8::try_from(value & 0x7f).unwrap_or(0) | 0x80); + value >>= 7; + } + out.reverse(); + out + } + + fn leaf(page: &mut [u8], base: usize, rowid: u64, payload: &[u8]) { + page[base] = 0x0d; + page[base + 3..base + 5].copy_from_slice(&1u16.to_be_bytes()); + let mut cell = varint(payload.len() as u64); + cell.extend(varint(rowid)); + cell.extend(payload); + let start = page.len() - cell.len(); + page[start..].copy_from_slice(&cell); + page[base + 8..base + 10].copy_from_slice(&u16::try_from(start).unwrap_or(0).to_be_bytes()); + page[base + 5..base + 7].copy_from_slice(&u16::try_from(start).unwrap_or(0).to_be_bytes()); + } + + fn leaf_rows(page: &mut [u8], base: usize, rows: &[(u64, &[u8])]) { + page[base] = 0x0d; + page[base + 3..base + 5].copy_from_slice(&u16::try_from(rows.len()).unwrap_or(0).to_be_bytes()); + let mut end = page.len(); + for (index, (rowid, payload)) in rows.iter().enumerate() { + let mut cell = varint(payload.len() as u64); + cell.extend(varint(*rowid)); + cell.extend(*payload); + end -= cell.len(); + page[end..end + cell.len()].copy_from_slice(&cell); + let pointer = base + 8 + index * 2; + page[pointer..pointer + 2].copy_from_slice(&u16::try_from(end).unwrap_or(0).to_be_bytes()); + } + page[base + 5..base + 7].copy_from_slice(&u16::try_from(end).unwrap_or(0).to_be_bytes()); + } + + fn schema_record(name: &str, root: u8, sql: &str) -> Vec { + let mut header = vec![23]; + let text_serial = |text: &str| 13 + 2 * text.len() as u64; + header.extend(varint(text_serial(name))); + header.extend(varint(text_serial(name))); + header.push(1); + header.extend(varint(text_serial(sql))); + let mut body = b"table".to_vec(); + body.extend(name.as_bytes()); + body.extend(name.as_bytes()); + body.push(root); + body.extend(sql.as_bytes()); + let mut record = varint((1 + header.len()) as u64); + record.extend(header); + record.extend(body); + record + } + + #[test] + fn schema_names_and_integer_primary_key_alias_are_decoded() { + let sql = r#"CREATE TABLE "t" ("id_local" INTEGER PRIMARY KEY, "odd,name" TEXT, PRIMARY KEY ("id_local"))"#; + let (names, alias) = parse_create_table(sql).unwrap(); + assert_eq!(names, vec!["id_local", "odd,name"]); + assert_eq!(alias, Some(0)); + let (_, no_alias) = parse_create_table("CREATE TABLE t (id INTEGER PRIMARY KEY DESC, value TEXT)").unwrap(); + assert_eq!(no_alias, None); + let (table_names, table_alias) = parse_create_table("CREATE TABLE t (id INTEGER, value TEXT, PRIMARY KEY(id))").unwrap(); + assert_eq!(table_names, vec!["id", "value"]); + assert_eq!(table_alias, Some(0)); + } + + #[test] + fn unsupported_unrelated_schema_entries_are_skipped() { + let mut main = vec![0u8; 2 * 512]; + main[..16].copy_from_slice(b"SQLite format 3\0"); + main[16..18].copy_from_slice(&512u16.to_be_bytes()); + main[18] = 1; + main[19] = 1; + main[21] = 64; + main[22] = 32; + main[23] = 32; + main[28..32].copy_from_slice(&2u32.to_be_bytes()); + main[44..48].copy_from_slice(&4u32.to_be_bytes()); + main[56..60].copy_from_slice(&1u32.to_be_bytes()); + let virtual_sql = "CREATE VIRTUAL TABLE unrelated USING fts5(text)"; + let supported_sql = "CREATE TABLE supported (id INTEGER PRIMARY KEY)"; + let virtual_record = schema_record("unrelated", 2, virtual_sql); + let supported_record = schema_record("supported", 2, supported_sql); + leaf_rows(&mut main[..512], 100, &[(1, &virtual_record), (2, &supported_record)]); + let db = Database::open_with_wal(main, &[]).unwrap(); + let tables = db.live_tables().unwrap(); + assert_eq!(tables.len(), 1); + assert_eq!(tables[0].name, "supported"); + } + + #[test] + fn synthetic_btree_substitutes_rowid_and_reads_schema() { + let mut main = vec![0u8; 2 * 512]; + main[..16].copy_from_slice(b"SQLite format 3\0"); + main[16..18].copy_from_slice(&512u16.to_be_bytes()); + main[18] = 1; + main[19] = 1; + main[21] = 64; + main[22] = 32; + main[23] = 32; + main[28..32].copy_from_slice(&2u32.to_be_bytes()); + main[44..48].copy_from_slice(&4u32.to_be_bytes()); + main[56..60].copy_from_slice(&1u32.to_be_bytes()); + let sql = "CREATE TABLE \"t\" (\"id_local\" INTEGER PRIMARY KEY, \"name\" TEXT)"; + // rootpage is an integer field, so replace fourth text value with a compact record. + let mut header = vec![23, 15, 15, 1]; + header.extend(varint((13 + 2 * sql.len()) as u64)); + let mut body = b"tablett".to_vec(); + body.push(2); + body.extend(sql.as_bytes()); + let mut record = varint((1 + header.len()) as u64); + record.append(&mut header); + record.extend(body); + let schema = record; + leaf(&mut main[512..], 0, 1, &[3, 0, 15, b'x']); + leaf(&mut main[..512], 100, 1, &schema); + let db = Database::open_with_wal(main, &[]).unwrap(); + let tables = db.live_tables().unwrap(); + assert!(tables.iter().any(|table| table.name == "t")); + let rows = db.read_table(2, 2).unwrap(); + assert_eq!(rows.first().and_then(|row| row.values.first()), Some(&Value::Integer(1))); + } + + #[test] + fn interior_table_pointer_array_uses_cell_offsets() { + let mut main = vec![0u8; 4 * 512]; + let mut interior = vec![0u8; 512]; + interior[0] = 0x05; + interior[3..5].copy_from_slice(&1u16.to_be_bytes()); + interior[8..12].copy_from_slice(&4u32.to_be_bytes()); + interior[12..14].copy_from_slice(&500u16.to_be_bytes()); + interior[500..504].copy_from_slice(&3u32.to_be_bytes()); + interior[504] = 1; + main[512..1024].copy_from_slice(&interior); + leaf(&mut main[1024..1536], 0, 1, &[2, 0]); + leaf(&mut main[1536..], 0, 2, &[2, 0]); + let db = Database { + main, + overlay: HashMap::new(), + page_size: 512, + reserved: 0, + encoding: TextEncoding::Utf8, + page_count: 4, + tables: Vec::new(), + rowid_aliases: HashMap::new(), + }; + assert_eq!(db.read_table(2, 1).unwrap().len(), 2); + } + + #[test] + fn negative_rowid_varint_is_signed() { + let mut main = vec![0u8; 512]; + let page = &mut main[..]; + page[100] = 0x0d; + page[103..105].copy_from_slice(&1u16.to_be_bytes()); + let start = 500usize; + page[start] = 2; + for offset in 1..10 { + if let Some(byte) = page.get_mut(start + offset) { + *byte = 0xff; + } + } + page[start + 10..start + 12].copy_from_slice(&[2, 0]); + page[108..110].copy_from_slice(&u16::try_from(start).unwrap_or(0).to_be_bytes()); + page[105..107].copy_from_slice(&u16::try_from(start).unwrap_or(0).to_be_bytes()); + let mut aliases = HashMap::new(); + aliases.insert(1, 0); + let db = Database { + main, + overlay: HashMap::new(), + page_size: 512, + reserved: 0, + encoding: TextEncoding::Utf8, + page_count: 1, + tables: Vec::new(), + rowid_aliases: aliases, + }; + let rows = db.read_table(1, 1).unwrap(); + assert_eq!(rows.first().and_then(|row| row.values.first()), Some(&Value::Integer(-1))); + } + + #[test] + fn reserved_page_trailer_cannot_supply_cells() { + let mut main = vec![0u8; 512]; + main[100] = 0x0d; + main[103..105].copy_from_slice(&1u16.to_be_bytes()); + main[108..110].copy_from_slice(&490u16.to_be_bytes()); + let db = Database { + main, + overlay: HashMap::new(), + page_size: 512, + reserved: 32, + encoding: TextEncoding::Utf8, + page_count: 1, + tables: Vec::new(), + rowid_aliases: HashMap::new(), + }; + assert!(db.read_table(1, 1).is_err()); + } + + #[test] + fn utf16_and_overflow_payloads_are_supported() { + let mut payload = vec![2, 17, b'A', 0]; + assert_eq!(decode_record(&payload, 1, TextEncoding::Utf16Le).unwrap(), vec![Value::Text("A".into())]); + payload.clear(); + payload.extend([2, 17, 0, b'A']); + assert_eq!(decode_record(&payload, 1, TextEncoding::Utf16Be).unwrap(), vec![Value::Text("A".into())]); + + let mut main = vec![0u8; 2 * 512]; + let db = Database { + main: std::mem::take(&mut main), + overlay: HashMap::new(), + page_size: 512, + reserved: 0, + encoding: TextEncoding::Utf8, + page_count: 2, + tables: Vec::new(), + rowid_aliases: HashMap::new(), + }; + let mut cell_page = vec![b'x'; 512]; + // For a 512-byte table leaf & 600-byte payload, SQLite stores 92 + // payload bytes locally, followed by the overflow page pointer. + cell_page[92..96].copy_from_slice(&2u32.to_be_bytes()); + let inline_page = vec![b'i'; 512]; + assert_eq!(db.payload(&inline_page, 0, 110).unwrap(), vec![b'i'; 110]); + let mut overflow_page = vec![b'y'; 512]; + overflow_page[..4].copy_from_slice(&0u32.to_be_bytes()); + let mut db = db; + db.main[512..].copy_from_slice(&overflow_page); + let mut with_overflow = db.overlay; + with_overflow.insert(2, overflow_page); + db.overlay = with_overflow; + let bytes = db.payload(&cell_page, 0, 600).unwrap(); + assert_eq!(bytes.len(), 600); + assert_eq!(&bytes[..92], &[b'x'; 92]); + assert_eq!(&bytes[92..], &[b'y'; 508]); + } + + #[test] + fn malformed_varint_and_wal_cycle_fail_cleanly() { + let mut at = 0; + assert!(read_varint(&[0x80; 8], &mut at).is_err()); + let mut wal = vec![0u8; 32 + 24 + 512]; + wal[..4].copy_from_slice(&0x377f0682u32.to_be_bytes()); + wal[4..8].copy_from_slice(&3_007_000u32.to_be_bytes()); + wal[8..12].copy_from_slice(&512u32.to_be_bytes()); + wal[16..20].copy_from_slice(&7u32.to_be_bytes()); + wal[20..24].copy_from_slice(&8u32.to_be_bytes()); + wal[32..36].copy_from_slice(&1u32.to_be_bytes()); + wal[36..40].copy_from_slice(&1u32.to_be_bytes()); + wal[40..44].copy_from_slice(&7u32.to_be_bytes()); + wal[44..48].copy_from_slice(&8u32.to_be_bytes()); + let parsed = parse_wal(&wal, 512).unwrap(); + assert_eq!(parsed.frame_count, 1); + assert_eq!(parsed.page_count, Some(1)); + assert_eq!(parsed.pages.get(&1).map(Vec::len), Some(512)); + let mut page = vec![0u8; 512]; + page[16..18].copy_from_slice(&512u16.to_be_bytes()); + let db = Database { + main: vec![0; 512], + overlay: HashMap::new(), + page_size: 512, + reserved: 0, + encoding: TextEncoding::Utf8, + page_count: 1, + tables: Vec::new(), + rowid_aliases: HashMap::new(), + }; + assert!(db.payload(&page, 0, 600).is_err()); + } +} diff --git a/crates/engine/src/logging.rs b/crates/engine/src/logging.rs new file mode 100644 index 000000000..380bf61c9 --- /dev/null +++ b/crates/engine/src/logging.rs @@ -0,0 +1,118 @@ +//! `log` records on stderr for the native desktop app and CLI (#168). The web build installs +//! eframe's `WebLogger` instead. +//! +//! Warnings and errors are shown by default. `LIGHTCRAFT_LOG` or `RUST_LOG` picks another level +//! (`off`, `error`, `warn`, `info`, `debug`, `trace`); `LIGHTCRAFT_LOG` wins when both are set. +//! `RUST_LOG` also accepts per-target directives (`lightcraft=debug,wgpu=warn`): a bare level +//! applies to everything, and the most verbose `lightcraft…=level` directive sets LightCraft's. +//! Below warnings only LightCraft's own records are shown, so `info` and `debug` aren't drowned +//! out by the GPU and windowing libraries. + +use log::{Level, LevelFilter, Metadata, Record}; + +/// Records shown at a level, written to stderr as `: LEVEL target: message`. +pub struct StderrLog { + prefix: &'static str, + level: LevelFilter, +} + +impl StderrLog { + pub fn new(prefix: &'static str, level: LevelFilter) -> Self { + Self { prefix, level } + } +} + +impl log::Log for StderrLog { + fn enabled(&self, m: &Metadata) -> bool { + m.level() <= self.level && (m.level() <= Level::Warn || m.target().starts_with("lightcraft")) + } + fn log(&self, r: &Record) { + if self.enabled(r.metadata()) { + eprintln!("{}: {} {}: {}", self.prefix, r.level(), r.target(), r.args()); + } + } + fn flush(&self) {} +} + +fn parse_level(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "off" | "none" => Some(LevelFilter::Off), + "error" => Some(LevelFilter::Error), + "warn" | "warning" => Some(LevelFilter::Warn), + "info" => Some(LevelFilter::Info), + "debug" => Some(LevelFilter::Debug), + "trace" => Some(LevelFilter::Trace), + _ => None, + } +} + +/// The level that `LIGHTCRAFT_LOG` / `RUST_LOG` ask for; warnings when neither names one. +pub fn level_from(lightcraft_log: Option<&str>, rust_log: Option<&str>) -> LevelFilter { + if let Some(l) = lightcraft_log.and_then(parse_level) { + return l; + } + let Some(rust_log) = rust_log else { return LevelFilter::Warn }; + let mut bare = None; + let mut ours: Option = None; + for directive in rust_log.split(',') { + match directive.split_once('=') { + None => bare = parse_level(directive).or(bare), + Some((target, level)) if target.trim().starts_with("lightcraft") => { + if let Some(l) = parse_level(level) { + ours = Some(ours.map_or(l, |o| o.max(l))); + } + } + Some(_) => {} + } + } + ours.or(bare).unwrap_or(LevelFilter::Warn) +} + +/// Install the stderr logger at the level the environment asks for. Does nothing when a logger is +/// already installed. +pub fn install(prefix: &'static str) { + let level = level_from(std::env::var("LIGHTCRAFT_LOG").ok().as_deref(), std::env::var("RUST_LOG").ok().as_deref()); + static LOGGER: std::sync::OnceLock = std::sync::OnceLock::new(); + if log::set_logger(LOGGER.get_or_init(|| StderrLog::new(prefix, level))).is_ok() { + log::set_max_level(level); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use log::Log; + + #[test] + fn warnings_by_default_and_either_variable_picks_the_level() { + assert_eq!(level_from(None, None), LevelFilter::Warn); + assert_eq!(level_from(None, Some("")), LevelFilter::Warn); + assert_eq!(level_from(None, Some("warn")), LevelFilter::Warn); + assert_eq!(level_from(None, Some("TRACE")), LevelFilter::Trace); + assert_eq!(level_from(None, Some("off")), LevelFilter::Off); + assert_eq!(level_from(Some("info"), None), LevelFilter::Info); + assert_eq!(level_from(Some("error"), Some("debug")), LevelFilter::Error, "LIGHTCRAFT_LOG wins"); + assert_eq!(level_from(Some("nonsense"), Some("debug")), LevelFilter::Debug, "an unknown LIGHTCRAFT_LOG is ignored"); + } + + #[test] + fn rust_log_directives_set_lightcraft_s_level() { + assert_eq!(level_from(None, Some("lightcraft=debug,wgpu=warn")), LevelFilter::Debug); + assert_eq!(level_from(None, Some("wgpu_core=trace")), LevelFilter::Warn, "other crates' directives don't apply"); + assert_eq!(level_from(None, Some("error,lightcraft_gpu=info")), LevelFilter::Info); + assert_eq!(level_from(None, Some("lightcraft_gpu=info,lightcraft_engine=trace")), LevelFilter::Trace); + assert_eq!(level_from(None, Some("info,naga=off")), LevelFilter::Info); + } + + #[test] + fn only_lightcraft_records_below_warnings() { + let log = StderrLog::new("test", LevelFilter::Debug); + let meta = |level, target| Metadata::builder().level(level).target(target).build(); + assert!(log.enabled(&meta(Level::Warn, "wgpu_core::device"))); + assert!(log.enabled(&meta(Level::Debug, "lightcraft_gpu::ctx"))); + assert!(!log.enabled(&meta(Level::Info, "wgpu_core::device"))); + assert!(!log.enabled(&meta(Level::Trace, "lightcraft_gpu::ctx"))); + let quiet = StderrLog::new("test", LevelFilter::Off); + assert!(!quiet.enabled(&meta(Level::Error, "lightcraft_engine"))); + } +} diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 76a9bf1a2..1ed89e473 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 = 11; +pub const RENDER_CACHE_VERSION: u64 = 15; /// 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]; @@ -104,6 +104,20 @@ impl SourceLevel { } } +/// Pixels along the source's long edge that an output `out_long` pixels long needs: a crop shows +/// only part of the photo, so its pixels come from a source that many times larger (the source +/// level is chosen from this, or a tight crop shown at its own pixels would be a stretched preview). +fn source_edge_needed(p: &Photo, settings: &DevelopSettings, apply_crop: bool, out_long: usize) -> usize { + let (w, h) = (f64::from(p.width.max(1)), f64::from(p.height.max(1))); + let r = settings.crop.geometry.rect; + let shown = if apply_crop { (r.width() * w).max(r.height() * h) } else { w.max(h) }; + if !shown.is_finite() || shown < 1.0 { + return out_long; + } + let needed = out_long as f64 * w.max(h) / shown; + if needed.is_finite() { needed.min(usize::MAX as f64 / 2.0) as usize } else { out_long } +} + /// Decodes a file into a linear Rec.2020 image no larger than `max_edge` (set by the app). pub type FileLoader = Arc Result<(Rgb32f, SourceInfo), String> + Send + Sync>; @@ -389,6 +403,15 @@ impl MediaCache { (self.thumbs.len(), self.thumbs.cost()) } + /// Whether the decoded source of `id` at `level` is held. + pub fn has_source(&self, id: PhotoId, level: SourceLevel) -> bool { + match level { + SourceLevel::Thumb => self.thumbs.contains(&id), + SourceLevel::Preview => self.previews.iter().any(|e| e.0 == id), + SourceLevel::Full => self.full.as_ref().is_some_and(|e| e.0 == id), + } + } + /// Decoded sources held: (thumbnail level, preview level, full size). pub fn usage(&self) -> (crate::memory::Usage, crate::memory::Usage, crate::memory::Usage) { use crate::memory::Usage; @@ -718,9 +741,13 @@ impl crate::Session { thumb_bucket: Option, ) -> Option { let p = self.catalog.photo(id)?.clone(); - let level = SourceLevel::for_size(max_w.max(max_h)); - let source = self.media.source_ref(&p, level); let settings = if before { Arc::new(self.before_settings(&p)) } else { p.develop.clone() }; + let level = SourceLevel::for_size(if thumb_bucket.is_some() { + max_w.max(max_h) // (grid thumbnails of tight crops stay cheap) + } else { + source_edge_needed(&p, &settings, apply_crop, max_w.max(max_h)) + }); + let source = self.media.source_ref(&p, level); let request = RenderRequest { apply_crop, ..RenderRequest::fit(max_w, max_h) }; // the photo id is part of the key: two photos with the same settings and size must not // share a result (a view slot showing photo A would otherwise look current for photo B) @@ -881,6 +908,69 @@ impl crate::Session { Some(job) } + /// A render of one window of the loupe's frame, for a view zoomed past what one whole-frame + /// render can hold: the frame is `full_w × full_h` (the size the loupe would draw it at) and + /// the result is `window`'s pixels of it, at that scale, from the source level that size needs. + /// Nothing is cached here (a window is only worth keeping while it is on screen). `None`: no + /// such photo, or the window reads (spots, Auto Mask strokes) more than one render can hold. + pub fn region_job( + &mut self, + id: PhotoId, + full_w: usize, + full_h: usize, + window: lightcraft_pipeline::PixelWindow, + apply_crop: bool, + ) -> Option { + self.region_job_of(id, full_w, full_h, window, apply_crop, false) + } + + /// [`Self::region_job`] of the photo without its edits (its crop kept): the Before side. + pub fn region_job_before( + &mut self, + id: PhotoId, + full_w: usize, + full_h: usize, + window: lightcraft_pipeline::PixelWindow, + apply_crop: bool, + ) -> Option { + self.region_job_of(id, full_w, full_h, window, apply_crop, true) + } + + fn region_job_of( + &mut self, + id: PhotoId, + full_w: usize, + full_h: usize, + window: lightcraft_pipeline::PixelWindow, + apply_crop: bool, + before: bool, + ) -> Option { + let mut job = self.render_job(id, full_w, full_h, before, apply_crop)?; + // what the window's spots and Auto Mask strokes read must fit in one render: else the + // caller keeps the whole-frame render (a window alone would come out wrong) + 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, + &job.settings, + apply_crop, + p.embedded_lens.as_ref(), + ); + let ppl = frame.px_per_long(full_w); + lightcraft_pipeline::spots::window_for_reads_checked(&job.settings, &frame, full_w, full_h, ppl, window.clamped(full_w, full_h))?; + job.request.window = Some(window); + job.key = Hasher128::new() + .u64(job.key) + .str("window") + .u64(window.x as u64) + .u64(window.y as u64) + .u64(window.w as u64) + .u64(window.h as u64) + .finish() + .0 as u64; + Some(job) + } + /// The embedded preview of an unedited raw (path, loader), when the app installed a loader. fn embedded_of(&self, p: &Photo) -> Option<(String, PreviewLoader)> { match (&p.source, &self.media.preview_loader) { @@ -1218,6 +1308,110 @@ mod tests { assert_ne!(a.key, b.key); } + // Issue #323: a window of a 1:1 view is its own job: the window's pixels, at the zoom scale, + // from the original, with a key of its own + #[test] + fn region_jobs_render_a_window_of_the_zoomed_frame() { + use lightcraft_pipeline::PixelWindow; + let mut s = crate::Session::with_demo(); + let p = s.catalog.photos().next().unwrap().clone(); + let (w, h) = (p.width as usize, p.height as usize); + assert!(w.max(h) > 2560, "demo photos are camera-sized"); + let win = PixelWindow { x: w / 3, y: h / 3, w: 300, h: 200 }; + let job = s.region_job(p.id, w, h, win, true).unwrap(); + assert_eq!(job.level, SourceLevel::Full, "a 1:1 window reads the original"); + let img = job.run().rendered.unwrap().image; + assert_eq!((img.width, img.height), (300, 200)); + // other windows, and the whole-frame job, never share a key + let other = s.region_job(p.id, w, h, PixelWindow { x: win.x + 64, ..win }, true).unwrap(); + let whole = s.render_job(p.id, w, h, false, true).unwrap(); + let again = s.region_job(p.id, w, h, win, true).unwrap(); + assert_ne!(other.key, again.key); + assert_ne!(whole.key, again.key); + assert_eq!(s.region_job(p.id, w, h, win, true).unwrap().key, again.key, "the same window keeps its key"); + // a window never writes the photo's view preview or the thumbnail cache + assert!(again.view_cache.is_none() && again.cache.is_none()); + } + + // Issue #323: a tight crop shown at its own pixels needs the original, not the 2560 px preview + #[test] + fn a_tight_crop_reads_the_source_level_its_pixels_need() { + let mut s = crate::Session::with_demo(); + let p = s.catalog.photos().next().unwrap().clone(); + let long = p.width.max(p.height) as usize; + assert!(long > 2560); + let id = p.id; + let mut d = (*s.develop_of(id).unwrap()).clone(); + // the uncropped photo at 1600 px: the preview is plenty + assert_eq!(s.render_job(id, 1600, 1600, false, true).unwrap().level, SourceLevel::Preview); + // a crop to 30 % of each side shown 1600 px long needs 1600 / 0.3 px of the source + d.crop.geometry.rect = lightcraft_geom::Rect { x0: 0.2, y0: 0.2, x1: 0.5, y1: 0.5 }; + s.set_develop(id, d, "Crop").unwrap(); + assert_eq!(s.render_job(id, 1600, 1600, false, true).unwrap().level, SourceLevel::Full); + // …but with the crop tool open (the whole photo shown) it does not + assert_eq!(s.render_job(id, 1600, 1600, false, false).unwrap().level, SourceLevel::Preview); + // and a crop shown small enough still reads the preview + assert_eq!(s.render_job(id, 600, 600, false, true).unwrap().level, SourceLevel::Preview); + } + + // a window whose spot reads from further away than a render can hold is refused + #[test] + fn a_region_job_is_refused_when_a_spot_reads_beyond_what_fits() { + use lightcraft_pipeline::PixelWindow; + let mut s = crate::Session::with_demo(); + let id = s.active().unwrap(); + let p = s.catalog.photo(id).unwrap().clone(); + let (w, h) = (p.width as usize * 8, p.height as usize * 8); + let mut d = (*s.develop_of(id).unwrap()).clone(); + d.spots.push(lightcraft_develop::Spot { + points: vec![lightcraft_geom::Point::new(0.2, 0.5)], + size: 0.01, + source_offset: Some(lightcraft_geom::Point::new(0.6, 0.0)), + ..Default::default() + }); + s.set_develop(id, d, "Spot").unwrap(); + let win = PixelWindow { x: (w as f64 * 0.2) as usize - 100, y: h / 2 - 100, w: 400, h: 300 }; + assert!(s.region_job(id, w, h, win, true).is_none()); + // a window elsewhere is fine + assert!(s.region_job(id, w, h, PixelWindow { x: 5000, y: 5000, w: 400, h: 300 }, true).is_some()); + } + + // Issue #323: the Before side of a Before/After view at 1:1 is a window too, of the photo + // without its edits (the crop is kept), with a key of its own + #[test] + fn a_before_window_shows_the_unedited_look() { + use lightcraft_pipeline::PixelWindow; + let mut s = crate::Session::with_demo(); + let id = s.active().unwrap(); + let p = s.catalog.photo(id).unwrap().clone(); + let (w, h) = (p.width as usize, p.height as usize); + let mut d = (*s.develop_of(id).unwrap()).clone(); + d.light.exposure = 2.0; + s.set_develop(id, d, "Exposure").unwrap(); + let win = PixelWindow { x: w / 3, y: h / 3, w: 200, h: 150 }; + let after = s.region_job(id, w, h, win, true).unwrap(); + let before = s.region_job_before(id, w, h, win, true).unwrap(); + assert_ne!(after.key, before.key); + let (a, b) = (after.run().rendered.unwrap().image, before.run().rendered.unwrap().image); + assert_eq!((a.width, a.height), (b.width, b.height)); + let mean = |i: &Rgba8| i.data.iter().map(|p| p[1] as f64).sum::() / i.data.len() as f64; + assert!(mean(&a) > mean(&b) + 10.0, "two stops brighter after: {} vs {}", mean(&a), mean(&b)); + } + + #[test] + fn has_source_follows_what_the_session_accepted() { + let mut s = crate::Session::with_demo(); + let id = s.active().unwrap(); + let p = s.catalog.photo(id).unwrap().clone(); + let long = p.width.max(p.height) as usize; + assert!(!s.media.has_source(id, SourceLevel::Full)); + let r = s.render_job(id, long, long, false, true).unwrap().run(); + s.accept(&r); + assert!(s.media.has_source(id, SourceLevel::Full)); + assert!(!s.media.has_source(id, SourceLevel::Preview)); + assert!(!s.media.has_source(PhotoId(id.0 + 1), SourceLevel::Full)); + } + #[test] fn variant_jobs_have_their_own_keys_and_hit_the_cache() { let mut s = crate::Session::with_demo(); diff --git a/crates/engine/src/preset_import.rs b/crates/engine/src/preset_import.rs index 3f9498cc9..040d46c3b 100644 --- a/crates/engine/src/preset_import.rs +++ b/crates/engine/src/preset_import.rs @@ -100,6 +100,7 @@ impl Lua { struct LuaParser<'a> { s: &'a [u8], i: usize, + depth: usize, } impl LuaParser<'_> { @@ -210,6 +211,15 @@ impl LuaParser<'_> { std::str::from_utf8(&self.s[start..self.i]).ok().and_then(|t| t.parse().ok()).map_or_else(|| self.err("bad number"), Ok) } fn value(&mut self) -> Result { + if self.depth >= 64 { + return self.err("Lua settings exceed nesting limit"); + } + self.depth += 1; + let result = self.value_inner(); + self.depth -= 1; + result + } + fn value_inner(&mut self) -> Result { self.skip_ws(); let Some(&c) = self.s.get(self.i) else { return self.err("unexpected end") }; match c { @@ -315,7 +325,10 @@ impl LuaParser<'_> { /// Parse a Lua table literal, optionally preceded by `name =` or `return`. pub fn parse_lua(text: &str) -> Result { - let mut p = LuaParser { s: text.as_bytes(), i: 0 }; + if text.len() > 16 << 20 { + return Err("Lua settings exceed 16 MiB".into()); + } + let mut p = LuaParser { s: text.as_bytes(), i: 0, depth: 0 }; p.skip_ws(); let save = p.i; match p.ident().as_deref() { @@ -347,6 +360,12 @@ pub fn lrtemplate_props(text: &str) -> Result<(Option, Props, crate::crs let root = parse_lua(text)?; let title = root.get("title").or_else(|| root.get("internalName")).and_then(Lua::str).map(delocalize).filter(|t| !t.trim().is_empty()); let settings = root.get("value").and_then(|v| v.get("settings")).ok_or("no develop settings in this template")?; + let (props, values) = lua_settings_props(settings)?; + Ok((title, props, values)) +} + +/// Data-only Lua settings shared by preset and native catalog import. Never executes Lua. +pub(crate) fn lua_settings_props(settings: &Lua) -> Result<(Props, crate::crs_masks::Values), String> { let Lua::Table(_, fields) = settings else { return Err("no develop settings in this template".into()) }; let mut props = Props::new(); let mut values = crate::crs_masks::Values::new(); @@ -374,7 +393,7 @@ pub fn lrtemplate_props(text: &str) -> Result<(Option, Props, crate::crs }; props.insert(key, vals); } - Ok((title, props, values)) + Ok((props, values)) } // ------------------------------------------------------------------------------- XMP in files diff --git a/crates/engine/src/presets.rs b/crates/engine/src/presets.rs index 57a82ac52..8c36aa742 100644 --- a/crates/engine/src/presets.rs +++ b/crates/engine/src/presets.rs @@ -83,28 +83,22 @@ pub fn parse_preset_file(name: &str, bytes: &[u8]) -> Result, String Ok(out) } -/// Expand files/folders (recursively) into preset files (`.lcpreset`, `.xmp`). +/// Expand files/folders (recursively, bounded: see [`crate::walk`]) into preset files +/// (`.lcpreset`, `.xmp`). pub fn expand_preset_paths(paths: &[String]) -> Vec { - fn walk(p: &Path, out: &mut Vec, top: bool) { - if p.is_dir() { - let Ok(rd) = std::fs::read_dir(p) else { return }; - let mut v: Vec<_> = rd.flatten().map(|e| e.path()).collect(); - v.sort(); - for c in v { - if !c.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')) { - walk(&c, out, false); - } - } - } else { - let ext = p.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); - if top || ext == LCPRESET_EXT || ["xmp", "lrtemplate", "zip", "lmp", "mplumpack"].contains(&ext.as_str()) { - out.push(p.to_string_lossy().to_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()) + }; let mut out = Vec::new(); for p in paths { - walk(Path::new(p), &mut out, true); + let path = Path::new(p); + if path.is_dir() { + let w = crate::walk::files_in(path, None, crate::walk::Limits::default(), preset_file); + out.extend(w.files.into_iter().map(|f| f.to_string_lossy().to_string())); + } else { + out.push(p.clone()); + } } out } @@ -446,6 +440,14 @@ pub fn profile(id: &str) -> Option<&'static ProfileInfo> { pub const RECENT_PROFILES: usize = 5; impl Session { + /// A profile by id, looking up built-in profiles first and imported LUT profiles second. + pub fn profile_info(&self, id: &str) -> Option<(&str, &str)> { + if let Some(p) = profile(id) { + return Some((p.name, p.group)); + } + self.lut_profiles.iter().find(|p| p.id == id).map(|p| (p.name.as_str(), p.group.as_str())) + } + /// Remember `id` as the most recently applied profile. pub fn note_profile_used(&mut self, id: &str) { self.profile_recent.retain(|p| p != id); diff --git a/crates/engine/src/segment/mod.rs b/crates/engine/src/segment/mod.rs index ce9278782..c21873f55 100644 --- a/crates/engine/src/segment/mod.rs +++ b/crates/engine/src/segment/mod.rs @@ -273,7 +273,7 @@ impl Segmenter { if mirrors.is_empty() { return Err(lightcraft_segment::fetch::no_mirrors_message()); } - self.download.start(lightcraft_segment::fetch::SAM3_FILES, mirrors, dir, lightcraft_segment::fetch::Options::default()) + self.download.start(lightcraft_segment::fetch::SAM3_FILES, mirrors, dir, lightcraft_segment::fetch::options()) } #[cfg(not(feature = "sam"))] { diff --git a/crates/engine/src/segment/worker.rs b/crates/engine/src/segment/worker.rs index cb16ab6bd..5769fd2ae 100644 --- a/crates/engine/src/segment/worker.rs +++ b/crates/engine/src/segment/worker.rs @@ -214,8 +214,11 @@ fn run(rx: &mpsc::Receiver, shared: &Shared, idle: Duration) { } let Some(mut job) = queue.pop_front() else { continue }; let is_detail = matches!(job.kind, Kind::Detail { .. }); - let _done = Done(if is_detail { &shared.detail } else { &shared.pending }); + // counts the job as pending until just before its reply goes out: a caller woken by the + // reply must not still see the worker busy with it + let done = Done(if is_detail { &shared.detail } else { &shared.pending }); if queue.iter().any(|later| supersedes(later, &job)) { + drop(done); let _ = job.reply.send(Outcome { tag: job.tag, result: Ok(None), superseded: true }); continue; } @@ -233,6 +236,7 @@ fn run(rx: &mpsc::Receiver, shared: &Shared, idle: Duration) { state.cache = None; } shared.loaded.store(state.model.is_some() || state.remote.is_some(), Ordering::SeqCst); + drop(done); let _ = job.reply.send(Outcome { tag: job.tag, result, superseded: false }); } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d005d5082..978e8067b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -14,24 +14,27 @@ fn active_dev(s: &Session) -> lightcraft_develop::DevelopSettings { fn changing_one_wb_control_resolves_as_shot_without_stale_tint() { use lightcraft_catalog::{Photo, PhotoId, Source}; use lightcraft_develop::{DevelopSettings, WbMode}; - let mut s = demo(); - let id = PhotoId(100); - let mut p = Photo::new(id, Source::File { path: "synthetic.arw".into() }, "synthetic.arw", "ARW", 16, 16, ""); - // A catalog made by the old generic-matrix Kelvin inference. - p.as_shot_wb = Some((6829.0, -127.0)); - p.develop = std::sync::Arc::new(DevelopSettings::for_raw(6829.0, -127.0)); - s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap(); - s.execute("library.select", &json!({"ids": [100], "active": 100})).unwrap(); - s.execute("develop.set", &json!({"control": "wb.temp", "value": 8000})).unwrap(); - let d = active_dev(&s); - assert_eq!(d.wb.mode, WbMode::Custom); - assert_eq!(d.wb.temp, 8000.0); - assert_eq!(d.wb.tint, 0.0, "untouched tint comes from the current as-shot reference"); - s.execute("develop.wb", &json!({"mode": "asShot"})).unwrap(); - s.execute("develop.set", &json!({"control": "wb.tint", "value": 10})).unwrap(); - assert_eq!(active_dev(&s).wb.temp, 6500.0); - s.execute("develop.reset", &json!({})).unwrap(); - assert_eq!((active_dev(&s).wb.temp, active_dev(&s).wb.tint), (6500.0, 0.0)); + for format in ["ARW", "NEF", "NRW", "RAF", "CR2", "PEF"] { + let mut s = demo(); + let id = PhotoId(100); + let name = format!("synthetic.{format}"); + let mut p = Photo::new(id, Source::File { path: name.clone() }, &name, format, 16, 16, ""); + // A catalog made by the old generic-matrix Kelvin inference. + p.as_shot_wb = Some((6829.0, -127.0)); + p.develop = std::sync::Arc::new(DevelopSettings::for_raw(6829.0, -127.0)); + s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap(); + s.execute("library.select", &json!({"ids": [100], "active": 100})).unwrap(); + s.execute("develop.set", &json!({"control": "wb.temp", "value": 8000})).unwrap(); + let d = active_dev(&s); + assert_eq!(d.wb.mode, WbMode::Custom); + assert_eq!(d.wb.temp, 8000.0); + assert_eq!(d.wb.tint, 0.0, "{format}: untouched tint comes from the current as-shot reference"); + s.execute("develop.wb", &json!({"mode": "asShot"})).unwrap(); + s.execute("develop.set", &json!({"control": "wb.tint", "value": 10})).unwrap(); + assert_eq!(active_dev(&s).wb.temp, 6500.0, "{format}"); + s.execute("develop.reset", &json!({})).unwrap(); + assert_eq!((active_dev(&s).wb.temp, active_dev(&s).wb.tint), (6500.0, 0.0), "{format}"); + } } #[test] @@ -382,6 +385,61 @@ fn crop_aspect_lock_current_and_toggle() { assert!(active_dev(&s).crop.aspect.is_none(), "toggle unlocks"); } +// Feature: dragging crop handles (issue 295) +#[test] +fn crop_drag_stops_at_the_image_edge_and_keeps_the_anchor() { + let mut s = demo(); + s.execute("crop.set", &json!({"rect": [0.2, 0.2, 0.8, 0.8]})).unwrap(); + s.execute("crop.drag", &json!({"handle": "bottomRight", "from": [0.8, 0.8], "to": [1.4, 0.7]})).unwrap(); + let r = active_dev(&s).crop.geometry.rect; + assert!((r.x0 - 0.2).abs() < 1e-6 && (r.y0 - 0.2).abs() < 1e-6, "anchor moved: {r:?}"); + assert!((r.x1 - 1.0).abs() < 1e-6 && (r.y1 - 0.7).abs() < 1e-6, "{r:?}"); +} + +#[test] +fn crop_drag_keeps_a_locked_ratio_on_every_handle() { + let mut s = demo(); + s.execute("crop.aspect", &json!({"aspect": "16x9"})).unwrap(); + let d = active_dev(&s); + assert!(d.crop.aspect.is_some()); + let want = d.crop.geometry.rect.aspect(); + let r = d.crop.geometry.rect; + for (handle, to) in [("left", [r.x0 + 0.1, 0.5]), ("top", [0.5, r.y0 + 0.05]), ("bottomRight", [1.3, 1.3]), ("right", [0.9, 0.5])] { + s.execute("crop.drag", &json!({"handle": handle, "from": [0.5, 0.5], "to": to, "start": [r.x0, r.y0, r.x1, r.y1]})).unwrap(); + let g = active_dev(&s).crop.geometry.rect; + assert!((g.aspect() - want).abs() < 1e-6, "{handle}: {} vs {want}", g.aspect()); + } +} + +#[test] +fn crop_drag_rejects_unknown_handles_and_bad_points() { + let mut s = demo(); + assert!(s.execute("crop.drag", &json!({"handle": "sideways", "from": [0.5, 0.5], "to": [0.6, 0.6]})).is_err()); + assert!(s.execute("crop.drag", &json!({"handle": "move", "from": [0.5], "to": [0.6, 0.6]})).is_err()); + assert!(s.execute("crop.drag", &json!({"handle": "move", "to": [0.6, 0.6]})).is_err()); +} + +#[test] +fn crop_aspect_rejects_degenerate_custom_ratios() { + let mut s = demo(); + let before = active_dev(&s).crop; + for bad in [json!([0.004, 3]), json!([3, 0]), json!([-1, 2]), json!([1000, 0.01]), json!(["a", 2]), json!([1, 2, 3])] { + assert!(s.execute("crop.aspect", &json!({"aspect": bad.clone()})).is_err(), "{bad}"); + } + assert!(s.execute("crop.aspect", &json!({"aspect": "0x5"})).is_err()); + let after = active_dev(&s).crop; + assert_eq!(before.geometry, after.geometry, "a rejected ratio leaves the crop alone"); + s.execute("crop.aspect", &json!({"aspect": [2.5, 1]})).unwrap(); + assert!(!active_dev(&s).crop.geometry.rect.is_empty()); +} + +#[test] +fn crop_drag_rejects_non_numeric_points() { + let mut s = demo(); + assert!(s.execute("crop.drag", &json!({"handle": "move", "from": ["a", 0.5, 0.5], "to": [0.6, 0.6]})).is_err()); + assert!(s.execute("crop.drag", &json!({"handle": "move", "from": [0.5, 0.5], "to": [0.6, 0.6], "start": [0.1, "x", 0.5, 0.5]})).is_err()); +} + #[test] fn paste_from_previous_and_copy_paste_metadata() { let mut s = demo(); @@ -817,3 +875,79 @@ fn face_job_follows_rotate_right() { let (pw, ph) = ((r.x1 - r.x0) * h, (r.y1 - r.y0) * w); assert!((pw - ph).abs() < 1e-6 * w.max(h), "square in rotated pixels: {pw} × {ph}"); } + +/// Selecting an id that is not in the library is an error, and the selection stays as it was +/// (#182: `select_photos {"ids": [9999]}` answered `selected: 1` and the session lost its photo). +#[test] +fn selecting_an_unknown_photo_is_an_error_and_keeps_the_selection() { + let mut s = demo(); + let vis = s.visible_cloned(); + s.execute("library.select", &json!({"ids": [vis[0].0]})).unwrap(); + let before = s.selection.clone(); + for p in [ + json!({"ids": [9999]}), + json!({"ids": [9999], "active": 9999}), + json!({"ids": [vis[1].0], "active": 9999}), + json!({"ids": [vis[1].0, 9999]}), + json!({"ids": [9999], "mode": "add"}), + json!({"ids": [9999], "mode": "toggle"}), + json!({"ids": [9999], "mode": "range"}), + ] { + let e = s.execute("library.select", &p).unwrap_err().to_string(); + assert!(e.contains("no such photo 9999"), "{p}: {e}"); + assert_eq!(s.selection, before, "{p}"); + } + assert_eq!(s.active(), Some(vis[0])); + assert!(s.execute("develop.get", &json!({})).is_ok()); + // queries about an explicit id check it too + for c in ["develop.get", "photo.inspect", "photo.allMetadata", "history.list"] { + let e = s.execute(c, &json!({"id": 9999})).unwrap_err().to_string(); + assert!(e.contains("no such photo 9999"), "{c}: {e}"); + assert!(s.execute(c, &json!({"id": vis[1].0})).is_ok(), "{c}"); + } + // an empty selection is still allowed + assert_eq!(s.execute("library.select", &json!({"ids": []})).unwrap()["selected"], 0); +} + +/// A preset saved by another version may hold keys this one doesn't know: exporting with it keeps +/// working (the stale keys are dropped), while a typo the caller makes is still refused (#181). +#[test] +fn saved_presets_with_unknown_keys_still_export() { + use crate::export::{ExportOptions, ExportPreset}; + let mut s = demo(); + s.export_presets.push(ExportPreset { + name: "Old".into(), + params: json!({"format": "png", "retiredOption": 3, "watermark": {"text": "©", "retiredKey": true}}), + }); + let p = s.export_params(&json!({"preset": "old", "quality": 80})).unwrap(); + let o = ExportOptions::from_params(&p).unwrap(); + assert_eq!(o.format, crate::export::ExportFormat::Png); + assert_eq!(o.watermark.map(|w| w.text), Some("©".to_string())); + assert!(ExportOptions::from_params(&s.export_params(&json!({"preset": "old", "qualty": 80})).unwrap()).is_err()); +} + +/// Undo and redo bring the photo they change on screen (issue #293): after editing one photo and +/// moving on, Undo makes it the active photo again; a step that changes several photos leaves the +/// selection alone. +#[test] +fn undo_and_redo_show_the_photo_they_change() { + let mut s = demo(); + let vis = s.visible_cloned(); + let (a, b) = (vis[0], vis[1]); + s.execute("library.select", &json!({"ids": [a.0]})).unwrap(); + s.execute("develop.set", &json!({"control": "light.exposure", "value": 1.0})).unwrap(); + s.execute("library.select", &json!({"ids": [b.0]})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.active(), Some(a), "undo shows the photo it changed"); + assert_eq!(s.selection.ids, vec![a]); + s.execute("library.select", &json!({"ids": [b.0]})).unwrap(); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.active(), Some(a), "so does redo"); + // rating both selected photos is one step over two photos: undoing it leaves the selection + s.execute("library.select", &json!({"ids": [a.0, b.0], "active": b.0})).unwrap(); + s.execute("photo.rate", &json!({"rating": 3})).unwrap(); + s.execute("library.select", &json!({"ids": [a.0, b.0], "active": a.0})).unwrap(); + let before = s.selection.clone(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.selection, before, "a step over several photos keeps the selection"); +} diff --git a/crates/engine/src/tests_folders.rs b/crates/engine/src/tests_folders.rs new file mode 100644 index 000000000..a94921373 --- /dev/null +++ b/crates/engine/src/tests_folders.rs @@ -0,0 +1,465 @@ +//! Exploring the library by folder: `library.folders` lists where the imported photos live and +//! `library.filter {libraryFolder}` shows one folder's photos (see `lightcraft_catalog::folders`); +//! `library.removeFolder` takes a folder's photos out of the library. +//! +//! * Given photos imported from two folders, an agent can list them with counts, then choose one +//! and see exactly its photos; the filter shows as a chip that clears just that choice. +//! * A path that is not a library folder shows nothing rather than everything. +//! * Removing a folder from the library moves its photos to Recently Deleted (one undo step) and +//! leaves every file where it is. +//! * Renaming or moving a folder on disk keeps the chosen folder chosen. + +use lightcraft_catalog::{Op, Photo, Source}; +use serde_json::json; + +use crate::{LibrarySource, Session, filter_chips}; + +fn source_folder(s: &mut Session, path: &str) -> serde_json::Value { + s.execute("library.source", &json!({"kind": "libraryFolder", "path": path})).unwrap() +} + +fn add(s: &mut Session, path: &str) { + let id = s.catalog.alloc_photo_id(); + let p = Photo::new(id, Source::File { path: path.into() }, "x.jpg", "JPEG", 60, 40, "2026-01-01T10:00:00"); + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); +} + +/// 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-folders-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + Scratch(dir) + } + fn path(&self, rel: &str) -> String { + self.0.join(rel).to_string_lossy().to_string() + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn session() -> Session { + let mut s = Session::new(); + for path in ["/pics/trip/a.jpg", "/pics/trip/b.jpg", "/pics/home/c.jpg"] { + add(&mut s, path); + } + s +} + +#[test] +fn the_folders_command_lists_where_the_photos_were_imported_from() { + let mut s = session(); + let r = s.execute("library.folders", &json!({})).unwrap(); + assert_eq!((r[0]["name"].as_str(), r[0]["volume"].as_bool(), r[0]["count"].as_u64()), (Some("/"), Some(true), Some(3))); + let pics = &r[0]["children"][0]; + assert_eq!(pics["name"], "pics"); + let kids: Vec<(&str, u64)> = + pics["children"].as_array().unwrap().iter().map(|c| (c["name"].as_str().unwrap(), c["count"].as_u64().unwrap())).collect(); + assert_eq!(kids, [("home", 1), ("trip", 2)]); +} + +#[test] +fn choosing_a_folder_shows_its_photos_and_a_chip_that_clears_it() { + let mut s = session(); + assert_eq!(s.visible().len(), 3); + let r = s.execute("library.filter", &json!({"libraryFolder": "/pics/trip"})).unwrap(); + assert_eq!(r["count"], 2); + let chips = filter_chips(&s.filter, &s.catalog); + assert_eq!(chips.len(), 1); + assert_eq!(chips[0].label, "Folder: pics/trip", "two names, so two folders called trip are told apart"); + s.execute("library.filter", &chips[0].clear).unwrap(); + assert_eq!(s.visible().len(), 3, "clearing the chip shows everything again"); +} + +#[test] +fn a_path_that_is_not_a_library_folder_shows_nothing() { + let mut s = session(); + let r = s.execute("library.filter", &json!({"libraryFolder": "/elsewhere"})).unwrap(); + assert_eq!(r["count"], 0); +} + +#[test] +fn removing_a_folder_moves_its_photos_to_recently_deleted_and_undo_brings_them_back() { + let mut s = session(); + let r = s.execute("library.removeFolder", &json!({"path": "/pics//trip/"})).unwrap(); + assert_eq!(r["removed"], 2); + assert_eq!(s.visible().len(), 1, "only /pics/home is left"); + let tree = s.execute("library.folders", &json!({})).unwrap(); + assert_eq!(tree[0]["count"], 1); + s.source = LibrarySource::RecentlyDeleted; + assert_eq!(s.visible().len(), 2, "they wait in Recently Deleted"); + s.source = LibrarySource::All; + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.visible().len(), 3, "one undo step"); +} + +#[test] +fn removing_a_folder_that_holds_nothing_or_a_whole_disk_is_refused() { + let mut s = session(); + assert!(s.execute("library.removeFolder", &json!({"path": "/elsewhere"})).is_err()); + assert!(s.execute("library.removeFolder", &json!({"path": "/"})).is_err(), "choose a folder, not everything"); + assert!(s.execute("library.removeFolder", &json!({"path": " "})).is_err()); + assert!(s.execute("library.removeFolder", &json!({})).is_err()); + assert_eq!(s.visible().len(), 3, "nothing changed"); +} + +#[test] +fn removing_a_folder_leaves_browsed_and_already_deleted_photos_alone() { + let mut s = session(); + for (path, local, deleted) in [("/pics/trip/browsed.jpg", true, false), ("/pics/trip/gone.jpg", false, true)] { + let id = s.catalog.alloc_photo_id(); + let mut p = Photo::new(id, Source::File { path: path.into() }, "x.jpg", "JPEG", 60, 40, "2026-01-01T10:00:00"); + (p.local, p.deleted) = (local, deleted); + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + } + let r = s.execute("library.removeFolder", &json!({"path": "/pics/trip"})).unwrap(); + assert_eq!(r["removed"], 2, "only the two library photos of the folder"); + let state: Vec<(bool, bool)> = s.catalog.photos().filter(|p| p.local || p.deleted).map(|p| (p.local, p.deleted)).collect(); + assert!(state.contains(&(true, false)) && state.contains(&(false, true)), "browsed stays browsed, the old deletion stays: {state:?}"); +} + +#[test] +fn removing_the_folder_that_is_chosen_clears_the_choice_but_a_parent_stays() { + let mut s = session(); + s.execute("library.filter", &json!({"libraryFolder": "/pics/trip"})).unwrap(); + s.execute("library.removeFolder", &json!({"path": "/pics/trip"})).unwrap(); + assert_eq!(s.filter.library_folder, None, "no empty grid under a chip for a folder that is gone"); + let mut s = session(); + s.execute("library.filter", &json!({"libraryFolder": "/pics"})).unwrap(); + s.execute("library.removeFolder", &json!({"path": "/pics/trip"})).unwrap(); + assert_eq!(s.filter.library_folder.as_deref(), Some("/pics")); +} + +#[test] +fn a_path_that_names_no_folder_removes_nothing() { + let mut s = session(); + for path in [".", "./", "a/..", "../..", "x/../.."] { + assert!(s.execute("library.removeFolder", &json!({"path": path})).is_err(), "{path:?}"); + } + assert_eq!(s.visible().len(), 3); +} + +#[test] +fn a_whole_disk_goes_only_when_asked_for_by_name_and_never_the_startup_disk() { + let mut s = Session::new(); + for p in ["/Volumes/nas/a/1.jpg", "/Volumes/nas/2.jpg", "/Users/me/3.jpg", r"C:\x\4.jpg", r"\\srv\share\5.jpg"] { + add(&mut s, p); + } + for path in ["/Volumes/nas", "C:", "C:\\", r"\\?\C:\", "C:\\..", r"\\srv\share"] { + assert!(s.execute("library.removeFolder", &json!({"path": path})).is_err(), "{path:?} is a whole disk"); + } + for path in ["/", "//"] { + assert!(s.execute("library.removeFolder", &json!({"path": path, "disk": true})).is_err(), "{path:?}: never the startup disk"); + } + assert_eq!(s.visible().len(), 5, "nothing changed"); + let r = s.execute("library.removeFolder", &json!({"path": "/Volumes/nas", "disk": true})).unwrap(); + assert_eq!(r["removed"], 2, "everything on the nas, nothing elsewhere"); + assert_eq!(s.visible().len(), 3); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.visible().len(), 5); +} + +#[test] +fn a_folder_that_spans_several_disks_is_removed_only_when_asked_for_by_name() { + let mut s = Session::new(); + for p in ["/Volumes/nas/a/1.jpg", "/Volumes/tokyo/2.jpg", "/Volumes/3.jpg"] { + add(&mut s, p); + } + assert!(s.execute("library.removeFolder", &json!({"path": "/Volumes"})).is_err(), "it would take two disks' photos at once"); + assert_eq!(s.visible().len(), 3); + let r = s.execute("library.removeFolder", &json!({"path": "/Volumes", "disk": true})).unwrap(); + assert_eq!(r["removed"], 3); +} + +#[test] +fn the_folder_filter_takes_only_a_text_path() { + let mut s = session(); + assert!(s.execute("library.filter", &json!({"libraryFolder": 5})).is_err()); + assert_eq!(s.filter.library_folder, None, "refused, nothing changed"); + s.execute("library.filter", &json!({"libraryFolder": " "})).unwrap(); + assert_eq!(s.visible().len(), 3, "a blank path is no choice"); + s.execute("library.filter", &json!({"libraryFolder": "."})).unwrap(); + assert_eq!(s.visible().len(), 0, "a path that names no folder shows nothing, not everything"); +} + +/// Folders are compared by identity: a path read back may be spelled with the platform's separator. +fn same_folder(actual: Option<&str>, expected: &str, why: &str) { + let key = lightcraft_catalog::query::folder_key; + assert_eq!(actual.map(key), Some(key(expected)), "{why}: {actual:?} vs {expected}"); +} + +#[test] +fn a_renamed_folder_stays_the_chosen_one_and_undo_follows_it_back() { + let dir = Scratch::new("rename"); + std::fs::create_dir_all(dir.0.join("trip/day1")).unwrap(); + let (trip, renamed) = (dir.path("trip"), dir.path("holiday")); + let mut s = Session::new(); + add(&mut s, &format!("{trip}/a.jpg")); + add(&mut s, &format!("{trip}/day1/b.jpg")); + s.execute("library.filter", &json!({"libraryFolder": trip})).unwrap(); + assert_eq!(s.visible().len(), 2); + s.execute("folder.rename", &json!({"path": trip, "name": "holiday"})).unwrap(); + same_folder(s.filter.library_folder.as_deref(), &renamed, "the choice follows the folder"); + assert_eq!(s.visible().len(), 2, "and still shows its photos"); + s.execute("edit.undo", &json!({})).unwrap(); + same_folder(s.filter.library_folder.as_deref(), &trip, "the choice is the same folder"); + assert_eq!(s.visible().len(), 2); +} + +#[test] +fn a_moved_folder_keeps_a_chosen_subfolder_chosen() { + let dir = Scratch::new("move"); + std::fs::create_dir_all(dir.0.join("trip/day1")).unwrap(); + std::fs::create_dir_all(dir.0.join("archive")).unwrap(); + let (day1, moved) = (dir.path("trip/day1"), dir.path("archive/trip/day1")); + let mut s = Session::new(); + add(&mut s, &format!("{day1}/b.jpg")); + s.execute("library.filter", &json!({"libraryFolder": day1})).unwrap(); + s.execute("folder.move", &json!({"path": dir.path("trip"), "into": dir.path("archive")})).unwrap(); + same_folder(s.filter.library_folder.as_deref(), &moved, "the subfolder follows its parent"); + assert_eq!(s.visible().len(), 1); + s.execute("edit.undo", &json!({})).unwrap(); + same_folder(s.filter.library_folder.as_deref(), &day1, "the choice is the same folder"); +} + +#[test] +fn following_a_folder_reads_both_spellings_the_same_way() { + // the choice was typed with a `..` in it; the folder that moved was not + let mut s = Session::new(); + s.filter.library_folder = Some("/a/x/../b/sub".into()); + crate::cmd::browse::follow_folder(&mut s, "/a/b", "/a/c"); + same_folder(s.filter.library_folder.as_deref(), "/a/c/sub", "a subfolder stays a subfolder, never widens to its parent"); + s.filter.library_folder = Some("/elsewhere/b".into()); + crate::cmd::browse::follow_folder(&mut s, "/a/b", "/a/c"); + assert_eq!(s.filter.library_folder.as_deref(), Some("/elsewhere/b"), "other folders are left alone"); +} + +#[test] +fn following_a_folder_keeps_the_case_of_the_subfolder_names() { + // Windows compares folders in lower case; the name the user sees must not change with it + let mut s = Session::new(); + s.filter.library_folder = Some("/A/Trip/Day1".into()); + crate::cmd::browse::follow_folder(&mut s, "/A/Trip", "/B"); + assert_eq!(s.filter.library_folder.as_deref(), Some(std::path::Path::new("/B").join("Day1").to_str().unwrap())); +} + +#[test] +fn choosing_a_folder_replaces_the_source_the_way_an_album_does() { + let mut s = session(); + s.execute("library.source", &json!({"kind": "picks"})).unwrap(); + assert_eq!(s.visible().len(), 0, "no picks"); + let r = source_folder(&mut s, "/pics/trip"); + assert_eq!(r["count"], 2, "the folder's photos, whatever was shown before"); + assert_eq!((s.source, s.library_folder.as_deref()), (LibrarySource::LibraryFolder, Some("/pics/trip"))); + assert!(filter_chips(&s.filter, &s.catalog).is_empty(), "a source is not a filter chip"); + let state = s.execute("library.state", &json!({})).unwrap(); + assert_eq!((state["sourceLabel"].as_str(), state["libraryFolder"].as_str()), (Some("pics/trip"), Some("/pics/trip")), "agents see which folder"); + // the filter bar narrows it further, as it does an album + s.execute("library.filter", &json!({"rating": 1})).unwrap(); + assert_eq!(s.visible().len(), 0); + s.execute("library.clearFilter", &json!({})).unwrap(); + assert_eq!(s.visible().len(), 2); + s.execute("library.source", &json!({"kind": "all"})).unwrap(); + assert_eq!(s.visible().len(), 3, "back to everything"); +} + +#[test] +fn the_folder_source_takes_a_library_folder_path_only() { + let mut s = session(); + for params in [ + json!({"kind": "libraryFolder"}), + json!({"kind": "libraryFolder", "path": 5}), + json!({"kind": "libraryFolder", "path": " "}), + json!({"kind": "libraryFolder", "path": "."}), + ] { + assert!(s.execute("library.source", ¶ms).is_err(), "{params}"); + } + assert_eq!(s.source, LibrarySource::All, "refused, nothing changed"); + assert!(s.execute("library.source", &json!({"kind": "libraryFolder", "path": "/elsewhere"})).is_err(), "no photo was imported from it"); + assert_eq!(s.source, LibrarySource::All); +} + +#[test] +fn removing_the_folder_being_shown_returns_to_all_photos() { + let mut s = session(); + source_folder(&mut s, "/pics/trip"); + s.execute("library.removeFolder", &json!({"path": "/pics/trip"})).unwrap(); + assert_eq!((s.source, s.library_folder.as_deref()), (LibrarySource::All, None)); + assert_eq!(s.visible().len(), 1); + // removing another folder leaves the source alone + let mut s = session(); + source_folder(&mut s, "/pics/home"); + s.execute("library.removeFolder", &json!({"path": "/pics/trip"})).unwrap(); + assert_eq!(s.source, LibrarySource::LibraryFolder); + assert_eq!(s.visible().len(), 1); +} + +#[test] +fn a_renamed_folder_stays_the_one_shown_and_undo_follows_it_back() { + let dir = Scratch::new("source-rename"); + std::fs::create_dir_all(dir.0.join("trip/day1")).unwrap(); + let (trip, day1, renamed) = (dir.path("trip"), dir.path("trip/day1"), dir.path("holiday")); + let mut s = Session::new(); + add(&mut s, &format!("{trip}/a.jpg")); + add(&mut s, &format!("{day1}/b.jpg")); + source_folder(&mut s, &day1); + s.execute("folder.rename", &json!({"path": trip, "name": "holiday"})).unwrap(); + same_folder(s.library_folder.as_deref(), &format!("{renamed}/day1"), "the subfolder follows its parent"); + assert_eq!(s.visible().len(), 1); + s.execute("edit.undo", &json!({})).unwrap(); + same_folder(s.library_folder.as_deref(), &day1, "the choice is the same folder"); + assert_eq!(s.visible().len(), 1); +} + +#[test] +fn the_shown_folder_follows_reading_both_spellings_the_same_way() { + let mut s = Session::new(); + s.library_folder = Some("/a/x/../b/sub".into()); + crate::cmd::browse::follow_folder(&mut s, "/a/b", "/a/c"); + same_folder(s.library_folder.as_deref(), "/a/c/sub", "the shown folder reads both spellings alike"); +} + +#[test] +fn switching_from_one_folder_to_another_changes_what_is_shown() { + let mut s = session(); + assert_eq!(source_folder(&mut s, "/pics/trip")["count"], 2); + assert_eq!(s.source_total(), Some(2)); + assert_eq!(source_folder(&mut s, "/pics/home")["count"], 1, "same source, another folder"); + assert_eq!(s.visible().len(), 1); + assert_eq!(s.source_total(), Some(1)); +} + +#[test] +fn a_smart_album_made_from_a_folder_view_keeps_the_folder() { + let mut s = session(); + source_folder(&mut s, "/pics/trip"); + assert_eq!(s.visible().len(), 2); + let r = s.execute("album.createSmart", &json!({"name": "Trip"})).unwrap(); + assert_eq!(r["count"], 2, "the album matches the folder's photos, not the whole library: {r}"); +} + +#[test] +fn a_folder_source_and_a_folder_filter_never_disagree() { + let mut s = session(); + s.execute("library.filter", &json!({"libraryFolder": "/pics/home"})).unwrap(); + source_folder(&mut s, "/pics/trip"); + assert_eq!(s.filter.library_folder, None, "choosing a folder drops a folder filter left over"); + assert!(s.execute("library.filter", &json!({"libraryFolder": "/pics/home"})).is_err(), "one folder at a time"); + assert_eq!(s.visible().len(), 2); + s.execute("library.filter", &json!({"libraryFolder": null, "rating": 0})).unwrap(); + s.execute("library.source", &json!({"kind": "all"})).unwrap(); + assert!(s.execute("library.filter", &json!({"libraryFolder": "/pics/home"})).is_ok(), "fine while no folder is the source"); +} + +#[test] +fn a_folder_source_with_no_folder_falls_back_to_all_photos() { + let mut s = session(); + s.source = LibrarySource::LibraryFolder; + s.library_folder = None; + assert_eq!(s.visible().len(), 3, "everything, not an empty grid under no name"); + assert_eq!((s.source, s.library_folder.as_deref()), (LibrarySource::All, None)); + assert_eq!(s.source_total(), Some(3)); +} + +#[test] +fn only_a_folder_that_shows_exactly_its_own_photos_can_be_the_source() { + let mut s = Session::new(); + for p in ["/Volumes/nas/a/1.jpg", "/Users/me/2.jpg", "/Volumes/3.jpg"] { + add(&mut s, p); + } + for path in ["/", "//", "/elsewhere", "/Volumes"] { + assert!(s.execute("library.source", &json!({"kind": "libraryFolder", "path": path})).is_err(), "{path:?}"); + } + assert_eq!(s.source, LibrarySource::All, "refused, nothing changed"); + assert_eq!(source_folder(&mut s, "/Volumes/nas")["count"], 1, "a disk is a fine source"); + assert_eq!(source_folder(&mut s, "/Users/me")["count"], 1); +} + +#[test] +fn removing_a_folder_above_a_disk_needs_asking_by_name_even_with_one_disk() { + let mut s = Session::new(); + for p in ["/Volumes/nas/a/1.jpg", "/Users/me/2.jpg"] { + add(&mut s, p); + } + assert!(s.execute("library.removeFolder", &json!({"path": "/Volumes"})).is_err(), "it would take the whole nas"); + assert_eq!(s.visible().len(), 2); + assert_eq!(s.execute("library.removeFolder", &json!({"path": "/Volumes/nas/a"})).unwrap()["removed"], 1, "a folder on the disk is just a folder"); + assert_eq!(s.visible().len(), 1); +} + +#[test] +fn a_folder_name_with_spaces_at_its_edge_is_never_mistaken_for_its_neighbour() { + let mut s = Session::new(); + for p in ["/p/shoot /a.jpg", "/p/shoot/b.jpg", "/p/other/c.jpg"] { + add(&mut s, p); + } + assert_eq!(source_folder(&mut s, "/p/shoot ")["count"], 1); + let shown: Vec = s.visible().to_vec().iter().filter_map(|id| s.catalog.photo(*id)).map(|p| p.file_name.clone()).collect(); + assert_eq!(s.library_folder.as_deref(), Some("/p/shoot "), "kept as the tree gave it"); + assert_eq!(shown.len(), 1); + let before: Vec<_> = s.catalog.photos().filter(|p| p.deleted).map(|p| p.id).collect(); + assert!(before.is_empty()); + let r = s.execute("library.removeFolder", &json!({"path": "/p/shoot "})).unwrap(); + assert_eq!(r["removed"], 1); + let gone: Vec = s + .catalog + .photos() + .filter(|p| p.deleted) + .map(|p| match &p.source { + Source::File { path } => path.clone(), + Source::Demo { .. } => String::new(), + }) + .collect(); + assert_eq!(gone, ["/p/shoot /a.jpg"], "the folder named, not its neighbour"); +} + +#[test] +fn editing_a_smart_album_made_from_a_folder_view_keeps_the_folder() { + let mut s = session(); + source_folder(&mut s, "/pics/trip"); + let r = s.execute("album.createSmart", &json!({"name": "Trip"})).unwrap(); + let id = r["id"].as_u64().unwrap(); + // the rules dialog saves what it shows (no folder field) with `replace` + let r = s.execute("album.setRules", &json!({"id": id, "replace": true, "rules": {"ruleSet": {"rules": []}}})).unwrap(); + assert_eq!(r["count"], 2, "the folder is not something the dialog can show, so it is kept: {r}"); + let r = s.execute("album.setRules", &json!({"id": id, "replace": true, "rules": {"libraryFolder": null}})).unwrap(); + assert_eq!(r["count"], 3, "and can be dropped on purpose"); +} + +#[test] +fn a_shown_folder_that_loses_its_last_photo_gives_way_to_all_photos() { + let mut s = session(); + source_folder(&mut s, "/pics/home"); + s.execute("photo.delete", &json!({})).unwrap(); + assert_eq!(s.visible().len(), 2, "back to everything that is left"); + assert_eq!((s.source, s.library_folder.as_deref()), (LibrarySource::All, None)); + // a folder whose photos are only hidden by the filter bar stays + let mut s = session(); + source_folder(&mut s, "/pics/trip"); + s.execute("library.filter", &json!({"rating": 5})).unwrap(); + assert_eq!(s.visible().len(), 0); + assert_eq!(s.source, LibrarySource::LibraryFolder, "the filters hide them, the folder is still there"); +} + +#[test] +fn the_state_names_a_folder_only_while_it_is_shown() { + let mut s = session(); + source_folder(&mut s, "/pics/trip"); + assert_eq!(s.execute("library.state", &json!({})).unwrap()["libraryFolder"], "/pics/trip"); + s.execute("library.source", &json!({"kind": "all"})).unwrap(); + assert!(s.execute("library.state", &json!({})).unwrap()["libraryFolder"].is_null(), "not a stale leftover"); +} + +#[test] +fn a_blank_folder_filter_is_no_filter() { + let mut s = session(); + s.execute("library.filter", &json!({"libraryFolder": " "})).unwrap(); + assert_eq!(s.filter, lightcraft_catalog::Filter::default(), "no hidden 'filters active' state"); +} diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index af106765e..4a4cad539 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -545,12 +545,17 @@ fn cube_luts_become_profiles() { ); s.execute("develop.profile", &json!({"id": id, "amount": 0})).unwrap(); assert_eq!(s.render_now(photo, 48, 32).unwrap().image.data, base.data, "Amount 0 = the photo as it was"); + assert_eq!(s.profile_info(&id), Some(("Warm Test", "Film Looks"))); + s.execute("profile.favorite", &json!({"id": id})).unwrap(); + assert!(s.profile_favorites.contains(&id)); // the library keeps it drop(s); lightcraft_pipeline::lut::unregister(&id); let mut s = Session::new().with_fs(); s.open_library(&lib, false).unwrap(); assert!(s.lut_profiles.iter().any(|p| p.id == id)); + assert!(s.profile_favorites.contains(&id), "imported LUT favorite survives reopen"); + assert_eq!(s.profile_info(&id), Some(("Warm Test", "Film Looks"))); assert!(lightcraft_pipeline::lut::get(&id).is_some(), "registered again on open"); s.execute("profile.deleteImported", &json!({"id": id})).unwrap(); assert!(lightcraft_pipeline::lut::get(&id).is_none()); @@ -858,3 +863,245 @@ fn missing_photos_skip_local_browse_records() { s.execute("library.source", &json!({"kind": "missing"})).unwrap(); assert_eq!(s.visible_cloned().len(), 4, "the view lists what the count counts"); } + +/// Import a file that is in Recently Deleted (issue #298): by default it is skipped but the +/// report says it is in the trash; `onDeleted: restore` brings it back with its edits; +/// `onDeleted: fresh` replaces the trashed record with a new one. +fn trashed_photo(tag: &str) -> (Session, std::path::PathBuf, u64) { + let src = temp_dir(tag); + write_png(&src.join("a.png"), 1); + let mut s = Session::new().with_fs(); + let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + let id = r["imported"][0].as_u64().unwrap(); + s.execute("photo.rate", &json!({"ids": [id], "rating": 4})).unwrap(); + s.execute("photo.delete", &json!({"ids": [id]})).unwrap(); + (s, src, id) +} + +#[test] +fn duplicate_in_recently_deleted_is_reported_as_such() { + let (mut s, src, id) = trashed_photo("trash-report"); + let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + assert_eq!(ids(&r, "imported"), 0, "{r}"); + assert_eq!(r["duplicates"][0]["existing"], id, "{r}"); + assert_eq!(r["duplicates"][0]["existingDeleted"], true, "{r}"); + assert!(s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().deleted, "default: left in the trash"); + let _ = std::fs::remove_dir_all(&src); +} + +#[test] +fn import_can_restore_a_duplicate_from_recently_deleted() { + let (mut s, src, id) = trashed_photo("trash-restore"); + let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "onDeleted": "restore"})).unwrap(); + assert_eq!(r["restored"], json!([id]), "{r}"); + assert_eq!(ids(&r, "duplicates"), 0, "{r}"); + let p = s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap(); + assert!(!p.deleted && p.rating == 4, "restored with its edits"); + let _ = std::fs::remove_dir_all(&src); +} + +#[test] +fn import_can_replace_a_duplicate_from_recently_deleted() { + let (mut s, src, id) = trashed_photo("trash-fresh"); + let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "onDeleted": "fresh"})).unwrap(); + assert_eq!(ids(&r, "imported"), 1, "{r}"); + let new = r["imported"][0].as_u64().unwrap(); + assert_ne!(new, id); + assert!(s.catalog.photo(lightcraft_catalog::PhotoId(id)).is_none(), "the trashed record is gone"); + let p = s.catalog.photo(lightcraft_catalog::PhotoId(new)).unwrap(); + assert!(!p.deleted && p.rating == 0, "a new photo without the old edits"); + assert_eq!(s.catalog.len(), 1); + let _ = std::fs::remove_dir_all(&src); +} + +#[test] +fn unknown_on_deleted_is_an_error() { + let mut s = Session::new().with_fs(); + let e = s.execute("library.import", &json!({"paths": [], "onDeleted": "bogus"})).unwrap_err().to_string(); + assert!(e.contains("onDeleted"), "{e}"); +} + +#[test] +fn empty_recently_deleted_removes_only_trashed_photos() { + let (mut s, src, id) = trashed_photo("trash-empty"); + write_png(&src.join("b.png"), 2); + let r = s.execute("library.import", &json!({"paths": [src.join("b.png").to_string_lossy()]})).unwrap(); + let keep = r["imported"][0].as_u64().unwrap(); + let r = s.execute("library.emptyRecentlyDeleted", &json!({})).unwrap(); + assert_eq!(r["deleted"], 1, "{r}"); + assert!(s.catalog.photo(lightcraft_catalog::PhotoId(id)).is_none()); + assert!(s.catalog.photo(lightcraft_catalog::PhotoId(keep)).is_some()); + // nothing to empty is not an error + assert_eq!(s.execute("library.emptyRecentlyDeleted", &json!({})).unwrap()["deleted"], 0); + s.execute("edit.undo", &json!({})).unwrap(); + let _ = std::fs::remove_dir_all(&src); +} + +/// Two trashed photos that share an album and a two-photo stack: emptying (or permanently +/// deleting) them together must not fail or leave dangling references. +fn two_trashed_in_album_and_stack(tag: &str) -> (Session, std::path::PathBuf, [u64; 2], u64) { + let src = temp_dir(tag); + write_png(&src.join("a.png"), 1); + write_png(&src.join("b.png"), 2); + let mut s = Session::new().with_fs(); + let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + let ids = [r["imported"][0].as_u64().unwrap(), r["imported"][1].as_u64().unwrap()]; + let al = s.execute("album.create", &json!({"name": "Trip"})).unwrap()["id"].as_u64().unwrap(); + s.execute("album.addPhotos", &json!({"id": al, "ids": ids})).unwrap(); + s.execute("library.select", &json!({"ids": ids})).unwrap(); + s.execute("stack.group", &json!({"ids": ids})).unwrap(); + s.execute("photo.delete", &json!({"ids": ids})).unwrap(); + (s, src, ids, al) +} + +#[test] +fn emptying_trashed_photos_of_one_album_and_stack_leaves_nothing_dangling() { + let (mut s, src, ids, al) = two_trashed_in_album_and_stack("trash-empty-refs"); + let r = s.execute("library.emptyRecentlyDeleted", &json!({})).unwrap(); + assert_eq!(r["deleted"], 2, "{r}"); + assert_eq!(s.catalog.len(), 0); + assert!(s.catalog.album(lightcraft_catalog::AlbumId(al)).unwrap().photos.is_empty(), "album still lists a removed photo"); + assert!(ids.iter().all(|i| s.catalog.stack_of(lightcraft_catalog::PhotoId(*i)).is_none())); + 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.photos().all(|p| p.deleted), "back in Recently Deleted"); + let _ = std::fs::remove_dir_all(&src); +} + +#[test] +fn deleting_several_photos_permanently_leaves_nothing_dangling() { + let (mut s, src, ids, al) = two_trashed_in_album_and_stack("trash-perm-refs"); + s.execute("library.select", &json!({"ids": ids})).unwrap(); + s.execute("photo.deletePermanently", &json!({"ids": ids})).unwrap(); + assert!(s.catalog.album(lightcraft_catalog::AlbumId(al)).unwrap().photos.is_empty()); + let _ = std::fs::remove_dir_all(&src); +} + +#[test] +fn fresh_import_of_two_trashed_photos_in_one_album_and_stack_works() { + let (mut s, src, ids, al) = two_trashed_in_album_and_stack("trash-fresh-refs"); + let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "onDeleted": "fresh"})).unwrap(); + assert_eq!(ids_len(&r), 2, "{r}"); + assert!(s.catalog.album(lightcraft_catalog::AlbumId(al)).unwrap().photos.is_empty()); + assert!(ids.iter().all(|i| s.catalog.photo(lightcraft_catalog::PhotoId(*i)).is_none())); + let _ = std::fs::remove_dir_all(&src); +} + +fn ids_len(r: &Value) -> usize { + ids(r, "imported") +} + +#[test] +fn restore_import_applies_the_album_to_restored_photos() { + let (mut s, src, ids, _) = trashed_photo_ids("trash-restore-album"); + let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "onDeleted": "restore", "albumName": "Back"})).unwrap(); + assert_eq!(r["restored"], json!(ids), "{r}"); + let album = r["album"].as_u64().expect("album created for the restored photos"); + assert_eq!(s.catalog.album(lightcraft_catalog::AlbumId(album)).unwrap().photos.len(), 1); + let _ = std::fs::remove_dir_all(&src); +} + +fn trashed_photo_ids(tag: &str) -> (Session, std::path::PathBuf, [u64; 1], u64) { + let (s, src, id) = trashed_photo(tag); + (s, src, [id], 0) +} + +/// Issue #374: launch arguments never turn into a walk of a whole drive or home folder. An empty +/// argument is ignored; `.`, `..` and (on Windows) `/` are resolved against the working folder a +/// launcher picked, which is often a drive root; a root is replaced by its memory card's DCIM +/// folder or refused, and so is the home folder. +#[test] +fn launch_paths_never_become_a_drive_root_or_the_home_folder() { + use crate::import::{LaunchPath, launch_path}; + let root = if cfg!(windows) { Path::new("C:\\") } else { Path::new("/") }; + let home = if cfg!(windows) { Path::new("C:\\Users\\me") } else { Path::new("/home/me") }; + let none = |_: &Path| None; + let refused = |r: Option| matches!(r, Some(LaunchPath::Refused { .. })); + for empty in ["", " "] { + assert_eq!(launch_path(empty, Some(root), Some(home), none), None, "{empty:?}"); + } + // the working folder is the drive root: `.` (and `..`) name the whole drive + for arg in [".", "..", "./", ".\\..", "/"] { + let arg = if cfg!(windows) { arg.to_string() } else { arg.replace('\\', "/") }; + assert!(refused(launch_path(&arg, Some(root), Some(home), none)), "{arg:?}"); + } + assert!(refused(launch_path("..", Some(&home.join("Pictures")), Some(home), none)), "up to the home folder"); + assert!(refused(launch_path(&home.to_string_lossy(), None, Some(home), none))); + assert!(refused(launch_path(".", None, Some(home), none)), "relative, no working folder"); + // a memory card's root: its camera folder + let dcim = |r: &Path| Some(r.join("DCIM")); + assert_eq!(launch_path(".", Some(root), Some(home), dcim), Some(LaunchPath::Import(root.join("DCIM").to_string_lossy().to_string()))); + // ordinary folders and files resolve to absolute paths + let pics = home.join("Pictures"); + assert_eq!(launch_path("Pictures", Some(home), Some(home), none), Some(LaunchPath::Import(pics.to_string_lossy().to_string()))); + assert_eq!( + launch_path(&pics.join("a.jpg").to_string_lossy(), None, Some(home), none), + Some(LaunchPath::Import(pics.join("a.jpg").to_string_lossy().to_string())) + ); +} + +/// Issue #374: a folder import is bounded — a tree deeper or larger than the limits is not walked +/// to the end (it stops and says so), and what it found is still returned. +#[test] +fn expand_stops_at_the_walk_limits() { + let d = temp_dir("bounded"); + let mut deep = d.clone(); + for i in 0..8 { + deep = deep.join(format!("d{i}")); + std::fs::create_dir_all(&deep).unwrap(); + std::fs::write(deep.join(format!("p{i}.jpg")), b"x").unwrap(); + } + let top = [d.to_string_lossy().to_string()]; + let (all, cut) = crate::import::expand_within(&top, None, crate::walk::Limits::default()); + assert_eq!((all.len(), cut), (8, false)); + let (some, cut) = crate::import::expand_within(&top, None, crate::walk::Limits { max_depth: 3, max_entries: 1000 }); + assert_eq!((some.len(), cut), (2, true), "{some:?}"); + let (few, cut) = crate::import::expand_within(&top, None, crate::walk::Limits { max_depth: 32, max_entries: 5 }); + assert!(cut && few.len() < 8, "{few:?}"); + let _ = std::fs::remove_dir_all(&d); +} + +/// Raw containers LightCraft cannot decode but shows by their embedded JPEG are picked up from a folder +/// (the extensions are in `import::EXTENSIONS`; the files differ so none is a duplicate) and import as preview only. +#[test] +fn folder_import_picks_up_undecodable_raw_containers_as_preview_only() { + use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg}; + let src = temp_dir("preview-only-exts"); + let px: Vec = (0..40 * 30).flat_map(|i| [(i % 251) as u8, 128, 200]).collect(); + let jpeg = encode_jpeg(&EncodeImage::new(40, 30, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap(); + // CRW / MRW / X3F: their magic, then the JPEG. IIQ / KDC / MOS / ERF: a TIFF whose IFD0 only points at the JPEG. + let tiff = { + let off = 8 + 2 + 24 + 4; + let mut f = b"II*\0\x08\0\0\0\x02\0".to_vec(); + for (tag, v) in [(513u16, off as u32), (514, jpeg.len() as u32)] { + f.extend_from_slice(&tag.to_le_bytes()); + f.extend_from_slice(&[4, 0, 1, 0, 0, 0]); + f.extend_from_slice(&v.to_le_bytes()); + } + f.extend_from_slice(&[0, 0, 0, 0]); + f.extend_from_slice(&jpeg); + f + }; + let with_magic = |magic: &[u8]| [magic, &[0x11u8; 64][..], &jpeg].concat(); + let files: [(&str, Vec); 7] = [ + ("a.CRW", with_magic(b"II\x1a\0\0\0HEAPCCDR")), + ("b.mrw", with_magic(b"\0MRM\0\x01\0\0")), + ("c.x3f", with_magic(b"FOVb\x02\0\x02\0")), + ("d.iiq", [&tiff[..], b"d"].concat()), + ("e.kdc", [&tiff[..], b"e"].concat()), + ("f.mos", [&tiff[..], b"f"].concat()), + ("g.erf", [&tiff[..], b"g"].concat()), + ]; + for (name, bytes) in &files { + std::fs::write(src.join(name), bytes).unwrap(); + } + let mut s = Session::new().with_fs(); + let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + assert_eq!(r["scanned"], 7, "{r}"); + assert_eq!(ids(&r, "imported"), 7, "{r}"); + assert_eq!(ids(&r, "failed"), 0, "{r}"); + assert!(s.catalog.photos().all(|p| p.preview_only.is_some() && p.kind == lightcraft_catalog::MediaKind::Raw && (p.width, p.height) == (40, 30))); + let _ = std::fs::remove_dir_all(&src); +} diff --git a/crates/engine/src/tests_import_move.rs b/crates/engine/src/tests_import_move.rs index 76ea0a3d6..6a19a5e5a 100644 --- a/crates/engine/src/tests_import_move.rs +++ b/crates/engine/src/tests_import_move.rs @@ -433,3 +433,70 @@ fn write_policy_timing() { println!("copy {n} × {} MB: byte compare with the source {bytewise:?}, probe hash {hashed:?}", size >> 20); let _ = std::fs::remove_dir_all(&base); } + +/// Issue #367: Import → Copy wrote and verified one file after another on one thread. The copies +/// now run side by side, with the names, order, duplicates and reports a one-by-one copy gives: +/// like-named files take -1, -2… in file order (also against a file already there and a literal +/// `IMG_1-1`), and a file with the content of one copied earlier in the batch is its duplicate +/// (also while that copy is still under way). +#[test] +fn copies_run_side_by_side_with_the_same_outcome() { + let base = temp_dir("parallel-copy"); + let (card, dest) = (base.join("card"), base.join("out")); + write_png(&card.join("a/IMG_1.png"), 1); + write_png(&card.join("a/IMG_2.png"), 2); + write_png(&card.join("b/IMG_1.png"), 3); + write_png(&card.join("b/IMG_1-1.png"), 4); + write_png(&card.join("c/IMG_1.png"), 5); + for i in 0..8u8 { + write_png(&card.join(format!("d/p{i}.png")), 10 + i); + } + // the content of a file whose copy is still under way + std::fs::create_dir_all(card.join("e")).unwrap(); + std::fs::copy(card.join("d/p3.png"), card.join("e/dup.png")).unwrap(); + std::fs::create_dir_all(&dest).unwrap(); + std::fs::write(dest.join("IMG_2.png"), b"someone else's file").unwrap(); + + let mut s = session(); + inject(Fault::Slow); + let r = s + .execute( + "library.import", + &json!({"paths": [card.to_string_lossy()], "mode": "copy", "destination": dest.to_string_lossy(), "organize": "flat"}), + ) + .unwrap(); + inject(Fault::None); + let threads: std::collections::HashSet<_> = crate::import_move::copy_threads().into_iter().collect(); + if std::thread::available_parallelism().map_or(1, |n| n.get()) > 1 { + assert!(threads.len() > 1, "copies ran on {} thread(s)", threads.len()); + } + + assert_eq!(len(&r, "imported"), 13, "{r}"); + assert_eq!(len(&r, "failed"), 0, "{r}"); + assert_eq!(len(&r, "duplicates"), 1, "{r}"); + assert_eq!(r["duplicates"][0]["reason"], "content"); + assert!(r["duplicates"][0]["path"].as_str().unwrap().ends_with("dup.png"), "{r}"); + // the order of a one-by-one copy (files in name order), each verified + let expect = [ + ("a/IMG_1.png", "IMG_1.png"), + ("a/IMG_2.png", "IMG_2-1.png"), + ("b/IMG_1-1.png", "IMG_1-1.png"), + ("b/IMG_1.png", "IMG_1-2.png"), + ("c/IMG_1.png", "IMG_1-3.png"), + ]; + for (src, dst) in expect { + assert_eq!(std::fs::read(dest.join(dst)).unwrap(), std::fs::read(card.join(src)).unwrap(), "{src} → {dst}"); + } + for i in 0..8 { + assert_eq!(std::fs::read(dest.join(format!("p{i}.png"))).unwrap(), std::fs::read(card.join(format!("d/p{i}.png"))).unwrap()); + } + assert_eq!(std::fs::read(dest.join("IMG_2.png")).unwrap(), b"someone else's file"); + assert_eq!(files_under(&dest).len(), 14, "{:?}", files_under(&dest)); + // the duplicate names the photo copied from the same content (d/p3); ids follow the file order + let ids: Vec = r["imported"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + assert!(ids.windows(2).all(|w| w[0] < w[1]), "{ids:?}"); + assert_eq!(r["duplicates"][0]["existing"], ids[8], "{r}"); + let first = s.catalog.photo(lightcraft_catalog::PhotoId(ids[0])).unwrap(); + assert_eq!(first.file_name, "IMG_1.png"); + let _ = std::fs::remove_dir_all(&base); +} diff --git a/crates/engine/src/tests_library.rs b/crates/engine/src/tests_library.rs index 45a56b2e0..f7b89225e 100644 --- a/crates/engine/src/tests_library.rs +++ b/crates/engine/src/tests_library.rs @@ -212,6 +212,39 @@ fn profile_favorites_and_recent_survive_reopen() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn the_shown_library_folder_survives_reopen() { + let dir = temp_dir("libfolder"); + let mut s = open(&dir, true); + let id = s.catalog.alloc_photo_id(); + let p = lightcraft_catalog::Photo::new( + id, + lightcraft_catalog::Source::File { path: "/pics/trip/a.jpg".into() }, + "a.jpg", + "JPEG", + 60, + 40, + "2026-01-01T10:00:00", + ); + s.commit("Add", lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap(); + s.execute("library.source", &json!({"kind": "libraryFolder", "path": "/pics/trip"})).unwrap(); + s.save_view(); + drop(s); + let s = open(&dir, false); + assert_eq!((s.source, s.library_folder.as_deref()), (crate::LibrarySource::LibraryFolder, Some("/pics/trip"))); + drop(s); + // a folder that holds none of the library's photos any more opens on everything too + std::fs::write(dir.join("view.json"), br#"{"source": {"kind": "libraryFolder"}, "library_folder": "/no/such/folder"}"#).unwrap(); + let s = open(&dir, false); + assert_eq!((s.source, s.library_folder.clone()), (crate::LibrarySource::All, None)); + drop(s); + // a view file that names the source but no folder opens on everything, never an empty grid + std::fs::write(dir.join("view.json"), br#"{"source": {"kind": "libraryFolder"}}"#).unwrap(); + let s = open(&dir, false); + assert_eq!((s.source, s.library_folder), (crate::LibrarySource::All, None)); + let _ = std::fs::remove_dir_all(&dir); +} + /// Smart albums with a rule set: all / any / none, nested groups, validation, live updates. #[test] fn smart_album_rule_sets() { diff --git a/crates/engine/src/tests_settings_files.rs b/crates/engine/src/tests_settings_files.rs index ddc81f20e..f9f24294d 100644 --- a/crates/engine/src/tests_settings_files.rs +++ b/crates/engine/src/tests_settings_files.rs @@ -95,3 +95,28 @@ fn unreadable_settings_are_not_overwritten() { assert_eq!(files.files.get("presets.json").unwrap(), presets); assert_eq!(files.files.get("prefs.json").unwrap(), prefs); } + +/// Issue #171: a library whose `catalog.lock` can't be locked (stood in for here by a directory +/// of that name, which takes the same path as a file system without locks) still opens, and the +/// user is told it is unprotected — in `library.info` and once through the library warnings the +/// app's notices and the CLI show. A normally locked library says nothing. +#[test] +fn an_unlockable_library_opens_with_a_warning() { + let lib = temp_dir("unlocked"); + std::fs::create_dir_all(lib.join("catalog.lock")).unwrap(); + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let info = s.execute("library.info", &json!({})).unwrap(); + let listed = info["settingsWarnings"].as_array().cloned().unwrap_or_default(); + assert!(listed.iter().any(|w| w.as_str().is_some_and(|w| w.contains("without protection against a second program"))), "{info}"); + let warnings = s.take_library_warnings(); + assert_eq!(warnings.len(), 1, "{warnings:?}"); + drop(s); + // the same library with a lockable lock file: no warning + std::fs::remove_dir_all(lib.join("catalog.lock")).unwrap(); + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + assert!(s.take_library_warnings().is_empty()); + drop(s); + let _ = std::fs::remove_dir_all(&lib); +} diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index 757adfe94..16c9f8a0c 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -19,6 +19,9 @@ pub enum LibrarySource { Folder, /// Photos whose original file can't be found (`library.missing`). Missing, + /// A folder the library's photos were imported from ([`crate::Session::library_folder`]), + /// and the folders inside it (`library.folders`). + LibraryFolder, } /// The folder a [`LibrarySource::Folder`] view shows. @@ -49,7 +52,7 @@ impl LibrarySource { LibrarySource::RecentlyDeleted => f.deleted = true, LibrarySource::Picks => f.flag = Some(lightcraft_catalog::Flag::Pick), // the folder itself is filled in by the session (it holds the path) - LibrarySource::Folder | LibrarySource::Missing => {} + LibrarySource::Folder | LibrarySource::Missing | LibrarySource::LibraryFolder => {} } f } @@ -62,6 +65,7 @@ impl LibrarySource { LibrarySource::RecentlyDeleted => "Recently Deleted".into(), LibrarySource::Picks => "Picks".into(), LibrarySource::Folder => "Folder".into(), + LibrarySource::LibraryFolder => "Folder".into(), LibrarySource::Missing => "Missing Photos".into(), } } @@ -74,7 +78,27 @@ pub struct Selection { pub active: Option, } +/// How a photo relates to the selection: what every view draws (grid cell, filmstrip cell…). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SelectionState { + NotSelected, + /// Selected, but not the photo the loupe and panels act on. + Selected, + /// The most-selected photo. + Active, +} + impl Selection { + /// What views draw for `id`: active, selected or neither (one answer for grid, filmstrip, …). + pub fn state_of(&self, id: PhotoId) -> SelectionState { + if self.active == Some(id) { + SelectionState::Active + } else if self.contains(id) { + SelectionState::Selected + } else { + SelectionState::NotSelected + } + } pub fn single(id: PhotoId) -> Selection { Selection { ids: vec![id], active: Some(id) } } @@ -173,6 +197,11 @@ pub fn filter_chips(f: &Filter, cat: &Catalog) -> Vec { if let Some(d) = &f.date { add(format!("Date: {}", date_label(d)), json!({"date": Null})); } + if let Some(d) = f.library_folder.as_deref().filter(|d| !d.trim().is_empty()) { + // the last two names, so two folders called "Pictures" are told apart + let name = lightcraft_catalog::folders::folder_label(d); + add(format!("Folder: {name}"), json!({"libraryFolder": Null})); + } if let Some(d) = &f.imported { add(format!("Imported: {}", date_label(d)), json!({"imported": Null})); } @@ -203,3 +232,18 @@ fn date_label(d: &str) -> String { _ => d.to_string(), } } + +#[cfg(test)] +mod selection_state_tests { + use super::*; + use lightcraft_catalog::PhotoId; + + #[test] + fn select_all_marks_the_others_selected_and_one_active() { + let ids: Vec = (1..=3).map(PhotoId).collect(); + let s = Selection { ids: ids.clone(), active: Some(ids[0]) }; + assert_eq!(s.state_of(ids[0]), SelectionState::Active); + assert_eq!(s.state_of(ids[1]), SelectionState::Selected); + assert_eq!(s.state_of(PhotoId(9)), SelectionState::NotSelected); + } +} diff --git a/crates/engine/src/walk.rs b/crates/engine/src/walk.rs new file mode 100644 index 000000000..479c877cf --- /dev/null +++ b/crates/engine/src/walk.rs @@ -0,0 +1,183 @@ +//! Bounded folder walks (issue #374): every recursive listing of the disk — import, Local's +//! Include Subfolders, Find Missing Photos, preset and LUT folders, MCP — goes through +//! [`files_in`], which stops at a depth and a number of directory entries instead of walking a +//! whole drive, follows a symbolic link or junction to a folder at most once (no loops), and +//! decides what is a folder from the directory listing itself (no extra open per entry, which +//! is what makes a large walk slow on Windows). +//! +//! It never chooses where to walk: callers pass a folder the user named. Nothing in the app walks +//! at startup or on the UI thread. + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +/// How far a walk may go. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Limits { + /// Folder levels below the starting folder that are listed (its own entries are level 1). + pub max_depth: usize, + /// Directory entries looked at in all (files and folders, hidden ones included). + pub max_entries: usize, +} + +impl Default for Limits { + fn default() -> Self { + Limits { max_depth: MAX_DEPTH, max_entries: MAX_ENTRIES } + } +} + +/// Folder levels a walk descends at most; photo trees are a handful of levels deep. +pub const MAX_DEPTH: usize = 32; +/// Directory entries a walk looks at at most: far more than any photo folder holds, far fewer +/// than a whole system drive. +pub const MAX_ENTRIES: usize = 1_000_000; + +/// What a walk found. +#[derive(Debug, Default, PartialEq)] +pub struct Walked { + /// The files `keep` accepted, in path order, folder by folder (depth first). + pub files: Vec, + /// The walk stopped at a [`Limits`] bound: there may be more files. + pub truncated: bool, + /// Directory entries looked at. + pub entries: usize, +} + +fn hidden(p: &Path) -> bool { + p.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')) +} + +/// 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 { + let mut out = Walked::default(); + // canonical folders reached through a link, so a link loop is walked at most once + let mut linked: HashSet = HashSet::new(); + if let Ok(c) = std::fs::canonicalize(root) { + linked.insert(c); + } + // (path, depth of the folder it is in, is a folder); popped in path order + let mut stack: Vec<(PathBuf, usize, bool)> = vec![(root.to_path_buf(), 0, true)]; + while let Some((path, depth, is_dir)) = stack.pop() { + if !is_dir { + if keep(&path) { + out.files.push(path); + } + continue; + } + if skip.is_some_and(|s| path == s) { + continue; + } + if depth >= limits.max_depth { + out.truncated = true; + continue; + } + let Ok(rd) = std::fs::read_dir(&path) else { continue }; + let mut children: Vec<(PathBuf, bool)> = Vec::new(); + for e in rd.flatten() { + if out.entries >= limits.max_entries { + out.truncated = true; + break; + } + out.entries = out.entries.saturating_add(1); + let p = e.path(); + if hidden(&p) { + continue; + } + let Ok(ft) = e.file_type() 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) { + Ok(m) if m.is_dir() => { + let Ok(c) = std::fs::canonicalize(&p) else { continue }; + if !linked.insert(c) { + continue; + } + true + } + _ => false, + } + } else { + ft.is_dir() + }; + children.push((p, dir)); + } + children.sort(); + let below = depth.saturating_add(1); + stack.extend(children.into_iter().rev().map(|(p, d)| (p, below, d))); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tmp(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("lc-walk-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn files_come_in_path_order_without_hidden_ones_or_the_skipped_folder() { + let d = tmp("order"); + for f in ["b/2.jpg", "a/1.jpg", "a/x/0.jpg", "c.jpg", ".hidden/h.jpg", "a/.h.jpg", "lib/l.jpg", "a/n.txt"] { + let p = d.join(f); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, b"x").unwrap(); + } + let lib = d.join("lib"); + let w = files_in(&d, Some(&lib), Limits::default(), |p| p.extension().is_some_and(|e| e == "jpg")); + let rel: Vec = w.files.iter().map(|p| p.strip_prefix(&d).unwrap().to_string_lossy().replace('\\', "/")).collect(); + assert_eq!(rel, ["a/1.jpg", "a/x/0.jpg", "b/2.jpg", "c.jpg"]); + assert!(!w.truncated); + let _ = std::fs::remove_dir_all(&d); + } + + /// Issue #374: a walk never runs on without end — a tree deeper than the limit, or with more + /// entries than the limit, stops and says so. + #[test] + fn deep_and_wide_trees_stop_at_the_limits() { + let d = tmp("bounds"); + let mut deep = d.join("deep"); + for i in 0..12 { + deep = deep.join(format!("l{i}")); + } + std::fs::create_dir_all(&deep).unwrap(); + std::fs::write(deep.join("bottom.jpg"), b"x").unwrap(); + let wide = d.join("wide"); + std::fs::create_dir_all(&wide).unwrap(); + for i in 0..50 { + std::fs::write(wide.join(format!("{i:02}.jpg")), b"x").unwrap(); + } + let all = |_: &Path| true; + let w = files_in(&d.join("deep"), None, Limits { max_depth: 5, max_entries: 1000 }, all); + assert!(w.files.is_empty() && w.truncated, "{w:?}"); + let w = files_in(&d.join("deep"), None, Limits { max_depth: 13, max_entries: 1000 }, all); + assert_eq!((w.files.len(), w.truncated), (1, false), "{w:?}"); + let w = files_in(&wide, None, Limits { max_depth: 5, max_entries: 20 }, all); + assert_eq!((w.files.len(), w.entries, w.truncated), (20, 20, true)); + let w = files_in(&wide, None, Limits::default(), all); + assert_eq!((w.files.len(), w.truncated), (50, false)); + let _ = std::fs::remove_dir_all(&d); + } + + /// A link back up the tree (a loop) is followed at most once: the walk ends. + #[cfg(unix)] + #[test] + fn link_loops_end() { + let d = tmp("loop"); + std::fs::create_dir_all(d.join("a/b")).unwrap(); + std::fs::write(d.join("a/b/p.jpg"), b"x").unwrap(); + std::os::unix::fs::symlink(&d, d.join("a/b/up")).unwrap(); + std::os::unix::fs::symlink(d.join("a"), d.join("alias")).unwrap(); + let w = files_in(&d, None, Limits::default(), |_| true); + assert!(!w.truncated, "{w:?}"); + assert!(w.files.len() <= 2, "{w:?}"); + assert!(w.files.iter().any(|p| p.ends_with("a/b/p.jpg")), "{w:?}"); + let _ = std::fs::remove_dir_all(&d); + } +} diff --git a/crates/fetch/README.md b/crates/fetch/README.md new file mode 100644 index 000000000..60ac1d329 --- /dev/null +++ b/crates/fetch/README.md @@ -0,0 +1,7 @@ +# lightcraft-fetch + +Native model downloads in pure Rust (L0, Apache-2.0): HTTP/1.1 over std sockets and rustls with the RustCrypto provider, without ring, aws-lc, C or assembly build dependencies. The wasm32 crate is empty. + +Call `download` with bounded file specifications and ordered mirrors, or `download_url` for an exact catalog URL (including its query). Each file has a local filename independent of the URL, a size cap and optional pinned size/SHA-256. Transfers resume pinned `.part` files, verify before renaming, report progress and check cancellation during reads. Run on a worker thread. No proxy support; DNS resolution uses the platform resolver. Tokens are off by default and never forwarded to a different redirect host. + +Tests use local synthetic HTTP servers for redirects, resume, truncation, cancellation, stalls, hashes, size limits and hostile specifications; they make no internet requests. diff --git a/crates/fetch/src/http.rs b/crates/fetch/src/http.rs index 332f01477..e900850ac 100644 --- a/crates/fetch/src/http.rs +++ b/crates/fetch/src/http.rs @@ -60,7 +60,10 @@ pub struct Url { impl Url { pub fn parse(s: &str) -> Result { - let bad = |why: &str| HttpError::BadUrl(format!("{s}: {why}")); + let bad = |why: &str| HttpError::BadUrl(why.to_string()); + if s.len() > 16 * 1024 { + return Err(bad("URL exceeds 16 KiB")); + } let s = s.trim(); if s.chars().any(|c| c.is_control() || c == ' ') { return Err(bad("contains spaces or control characters")); @@ -509,6 +512,12 @@ pub fn get(url: &Url, headers: &[(&str, String)], limits: &Limits) -> Result { + pub name: &'a str, /// Exact size in bytes, when pinned (then the download resumes and is checked against it). pub size: Option, /// SHA-256 (lower-case hex), when pinned. - pub sha256: Option<&'static str>, + pub sha256: Option<&'a str>, /// Largest size accepted when `size` is not pinned. pub max: u64, } @@ -79,11 +79,14 @@ pub struct Options { pub stall_timeout: Duration, /// Tries per mirror and file (each resumes where the last one stopped). pub attempts: u32, + /// The environment variable holding a bearer token, sent to each configured mirror's own host (only over https, + /// never to a host a redirect leads to), so list only mirrors that may see it. `None`: nothing is ever sent, so one model's token cannot leak to another's server. + pub token_env: Option<&'static str>, } impl Default for Options { fn default() -> Self { - Options { connect_timeout: Duration::from_secs(15), stall_timeout: Duration::from_secs(30), attempts: 3 } + Options { connect_timeout: Duration::from_secs(15), stall_timeout: Duration::from_secs(30), attempts: 3, token_env: None } } } @@ -103,6 +106,8 @@ pub struct Progress { pub enum DownloadError { /// No mirror is configured. NoMirrors, + /// Invalid filename, size, hash or aggregate bound supplied by the caller. + Invalid(String), Cancelled, /// A local file problem (folder not writable, disk full…). Disk(String), @@ -116,6 +121,7 @@ pub enum DownloadError { impl std::fmt::Display for DownloadError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { + DownloadError::Invalid(e) => write!(f, "invalid download: {e}"), DownloadError::NoMirrors => write!(f, "no download location is configured for this model in this build"), DownloadError::Cancelled => write!(f, "the download was cancelled"), DownloadError::Disk(e) => write!(f, "{e}"), @@ -132,16 +138,45 @@ enum Fail { Disk(String), } -/// Download `files` into `dir` from `mirrors` (in order; see [`mirrors`]). Files already there -/// and verified are kept. `progress` is called as bytes arrive (often: throttle in it). +/// Fetch a single file from its exact URL, preserving any query string. The local filename +/// remains `file.name`; it never comes from redirects or response headers. +pub fn download_url( + file: &FileSpec<'_>, + url: &str, + dir: &Path, + opts: &Options, + cancel: &AtomicBool, + progress: &mut dyn FnMut(&Progress), +) -> Result<(), DownloadError> { + download_source(std::slice::from_ref(file), &[url.to_string()], dir, opts, cancel, progress, true) +} + +/// Download `files` into `dir` from `mirrors` (in order; see [`mirrors`]). Verified files +/// are kept. `progress` is called as bytes arrive (often: throttle in it). pub fn download( - files: &[FileSpec], + files: &[FileSpec<'_>], mirrors: &[String], dir: &Path, opts: &Options, cancel: &AtomicBool, progress: &mut dyn FnMut(&Progress), ) -> Result<(), DownloadError> { + download_source(files, mirrors, dir, opts, cancel, progress, false) +} + +fn download_source( + files: &[FileSpec<'_>], + mirrors: &[String], + dir: &Path, + opts: &Options, + cancel: &AtomicBool, + progress: &mut dyn FnMut(&Progress), + exact_urls: bool, +) -> Result<(), DownloadError> { + let total = validate_files(files)?; + if mirrors.len() > MAX_MIRRORS { + return Err(DownloadError::Invalid("too many mirrors".into())); + } std::fs::create_dir_all(dir).map_err(|e| DownloadError::Disk(format!("can't create {}: {e}", dir.display())))?; // what's left to fetch let mut todo = Vec::new(); @@ -156,14 +191,13 @@ pub fn download( if mirrors.is_empty() { return Err(DownloadError::NoMirrors); } - let total: u64 = files.iter().map(|f| f.size.unwrap_or(0)).sum(); let mut before: u64 = files.iter().filter(|f| !todo.contains(f)).filter_map(|f| f.size).sum(); for f in todo { let mut errors = Vec::new(); let mut ok = false; 'mirrors: for m in mirrors { - let url = format!("{m}/{}", f.name); - for _ in 0..opts.attempts.max(1) { + let url = if exact_urls { m.clone() } else { format!("{m}/{}", f.name) }; + for _ in 0..opts.attempts.clamp(1, 16) { if cancel.load(Ordering::Relaxed) { return Err(DownloadError::Cancelled); } @@ -184,11 +218,11 @@ pub fn download( Err(Fail::Cancelled) => return Err(DownloadError::Cancelled), Err(Fail::Disk(e)) => return Err(DownloadError::Disk(e)), Err(Fail::Retry(e)) => { - log::warn!("download of {url}: {e} (retrying)"); + log::warn!("download of {}: {e} (retrying)", short(&url)); errors.push(format!("{}: {e}", short(m))); } Err(Fail::NextMirror(e)) => { - log::warn!("download of {url}: {e}"); + log::warn!("download of {}: {e}", short(&url)); errors.push(format!("{}: {e}", short(m))); continue 'mirrors; } @@ -203,13 +237,56 @@ pub fn download( Ok(()) } +/// Validate before touching disk, including names supplied by a user model catalog. +fn validate_files(files: &[FileSpec<'_>]) -> Result { + if files.len() > 256 { + return Err(DownloadError::Invalid("too many files".into())); + } + let mut seen = std::collections::HashSet::new(); + let mut total = 0u64; + for f in files { + let stem = f.name.split('.').next().unwrap_or_default().to_ascii_uppercase(); + let reserved = matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL") + || stem + .strip_prefix("COM") + .or_else(|| stem.strip_prefix("LPT")) + .is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")); + if reserved + || f.name.ends_with(['.', ' ']) + || f.name.ends_with(".part") + || f.name.is_empty() + || f.name.len() > 255 + || f.name.starts_with('.') + || f.name.contains(['/', '\\', ':']) + || f.name.chars().any(char::is_control) + || !seen.insert(f.name) + { + return Err(DownloadError::Invalid("a filename must be a unique, ordinary filename".into())); + } + if f.max == 0 + || f.size.is_some_and(|n| n == 0 || n > f.max) + || f.sha256.is_some_and(|h| h.len() != 64 || !h.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))) + { + return Err(DownloadError::Invalid("invalid file size or SHA-256".into())); + } + total = total.checked_add(f.size.unwrap_or(0)).ok_or_else(|| DownloadError::Invalid("total size overflow".into()))?; + } + Ok(total) +} + +/// The bearer token to send to `url`: only a non-empty one, only over https, and only to the host the download began +/// at (`origin`), never to the host a redirect leads to. +fn bearer<'a>(token: Option<&'a str>, url: &Url, origin: &str) -> Option<&'a str> { + token.map(str::trim).filter(|t| !t.is_empty() && url.tls && url.host == origin) +} + /// A mirror for messages (no query string: it may hold a token). fn short(m: &str) -> String { Url::parse(m).map(|u| u.display()).unwrap_or_else(|_| "mirror".into()) } /// Whether `path` is already the right file (exact size and hash when pinned). -fn verified(f: &FileSpec, path: &Path) -> Result { +fn verified(f: &FileSpec<'_>, path: &Path) -> Result { let Ok(meta) = std::fs::metadata(path) else { return Ok(false) }; if !meta.is_file() || meta.len() == 0 { return Ok(false); @@ -243,7 +320,7 @@ fn part_path(dir: &Path, name: &str) -> PathBuf { /// Fetch one file from one mirror into `.part` (resuming it when its size is pinned), /// check it and move it into place. -fn fetch_file(f: &FileSpec, url: &str, dir: &Path, opts: &Options, cancel: &AtomicBool, report: &mut dyn FnMut(u64, u64)) -> Result<(), Fail> { +fn fetch_file(f: &FileSpec<'_>, url: &str, dir: &Path, opts: &Options, cancel: &AtomicBool, report: &mut dyn FnMut(u64, u64)) -> Result<(), Fail> { 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())); @@ -268,12 +345,8 @@ fn fetch_file(f: &FileSpec, url: &str, dir: &Path, opts: &Options, cancel: &Atom headers.push(("Range", format!("bytes={have}-"))); } // a token for the configured host only (never sent on to a redirect's host) - if let Ok(t) = std::env::var("LIGHTCRAFT_SAM3_TOKEN") - && !t.trim().is_empty() - && url.tls - && url.host == origin - { - headers.push(("Authorization", format!("Bearer {}", t.trim()))); + if let Some(t) = bearer(opts.token_env.and_then(|name| std::env::var(name).ok()).as_deref(), &url, &origin) { + headers.push(("Authorization", format!("Bearer {t}"))); } let r = http::get(&url, &headers, &limits).map_err(http_fail)?; match r.status { diff --git a/crates/fetch/src/tests.rs b/crates/fetch/src/tests.rs index 57c168a2d..4a828c6d6 100644 --- a/crates/fetch/src/tests.rs +++ b/crates/fetch/src/tests.rs @@ -22,7 +22,7 @@ fn leak(s: String) -> &'static str { Box::leak(s.into_boxed_str()) } -fn pinned() -> FileSpec { +fn pinned() -> FileSpec<'static> { let p = payload(); FileSpec { name: "model.bin", size: Some(p.len() as u64), sha256: Some(leak(sha_hex(&p))), max: p.len() as u64 } } @@ -137,10 +137,10 @@ fn tmp(name: &str) -> PathBuf { } fn quick() -> Options { - Options { connect_timeout: Duration::from_secs(2), stall_timeout: Duration::from_millis(600), attempts: 2 } + Options { connect_timeout: Duration::from_secs(2), stall_timeout: Duration::from_millis(600), attempts: 2, token_env: None } } -fn run(files: &[FileSpec], mirrors: &[String], dir: &Path, cancel: &AtomicBool) -> (Result<(), DownloadError>, Vec) { +fn run(files: &[FileSpec<'_>], mirrors: &[String], dir: &Path, cancel: &AtomicBool) -> (Result<(), DownloadError>, Vec) { let mut seen = Vec::new(); let r = download(files, mirrors, dir, &quick(), cancel, &mut |p| seen.push(p.clone())); (r, seen) @@ -314,3 +314,75 @@ fn content_ranges() { assert_eq!(parse_content_range("bytes 9-1/300"), None); assert_eq!(parse_content_range("items 1-2/3"), None); } + +/// A bearer token goes only to https, only to the host the download began at, and only when there is one. +#[test] +fn a_token_is_sent_only_over_https_to_the_first_host() { + let u = |s: &str| Url::parse(s).unwrap(); + let first = u("https://huggingface.co/x/model.bin"); + assert_eq!(bearer(Some(" hf_abc \n"), &first, "huggingface.co"), Some("hf_abc")); + // the storage host a redirect leads to, plain http, no token, an empty one + assert_eq!(bearer(Some("hf_abc"), &u("https://cdn-lfs.example/x"), "huggingface.co"), None); + assert_eq!(bearer(Some("hf_abc"), &u("http://huggingface.co/x"), "huggingface.co"), None); + assert_eq!(bearer(None, &first, "huggingface.co"), None); + assert_eq!(bearer(Some(" "), &first, "huggingface.co"), None); + // a caller that names no variable sends nothing, whatever the environment holds + assert!(Options::default().token_env.is_none()); +} + +#[test] +fn exact_url_keeps_query_and_owned_catalog_values() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}/download?file=weights&sig=secret", listener.local_addr().unwrap()); + let worker = std::thread::spawn(move || { + let (mut socket, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(socket.try_clone().unwrap()); + let mut request = String::new(); + reader.read_line(&mut request).unwrap(); + assert_eq!(request.trim(), "GET /download?file=weights&sig=secret HTTP/1.1"); + loop { + let mut line = String::new(); + if reader.read_line(&mut line).unwrap() == 0 || line.trim().is_empty() { + break; + } + } + socket.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 3\r\nConnection: close\r\n\r\nabc").unwrap(); + }); + let name = String::from("catalog.onnx"); + let hash = sha_hex(b"abc"); + let spec = FileSpec { name: &name, size: Some(3), sha256: Some(&hash), max: 3 }; + let dir = tmp("exact"); + let mut progress = Vec::new(); + download_url(&spec, &url, &dir, &quick(), &AtomicBool::new(false), &mut |p| progress.push(p.clone())).unwrap(); + worker.join().unwrap(); + assert_eq!(std::fs::read(dir.join(name)).unwrap(), b"abc"); + assert!(progress.iter().all(|p| !p.mirror.contains("secret"))); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn hostile_file_specs_are_rejected_before_touching_disk() { + let dir = tmp("invalid"); + let base = FileSpec { name: "valid.onnx", size: Some(1), sha256: None, max: 1 }; + let cancel = AtomicBool::new(false); + for name in ["", ".hidden", "../escape", "a/b", "a\\b", "C:escape", "bad\nname", "trailing.", "CON.onnx", "a.part"] { + let spec = FileSpec { name, ..base.clone() }; + assert!(matches!(run(&[spec], &[], &dir, &cancel).0, Err(DownloadError::Invalid(_))), "{name}"); + assert!(!dir.exists()); + } + for spec in [ + FileSpec { max: 0, ..base.clone() }, + FileSpec { size: Some(0), ..base.clone() }, + FileSpec { size: Some(2), ..base.clone() }, + FileSpec { sha256: Some("not a SHA"), ..base.clone() }, + ] { + assert!(matches!(run(&[spec], &[], &dir, &cancel).0, Err(DownloadError::Invalid(_)))); + assert!(!dir.exists()); + } + let huge = FileSpec { size: Some(u64::MAX), max: u64::MAX, ..base.clone() }; + let other = FileSpec { name: "other.onnx", ..huge.clone() }; + for specs in [vec![huge, other], vec![base.clone(); 257], vec![base.clone(), base]] { + assert!(matches!(run(&specs, &[], &dir, &cancel).0, Err(DownloadError::Invalid(_)))); + assert!(!dir.exists()); + } +} diff --git a/crates/geom/src/crop_drag.rs b/crates/geom/src/crop_drag.rs new file mode 100644 index 000000000..f600de6d3 --- /dev/null +++ b/crates/geom/src/crop_drag.rs @@ -0,0 +1,483 @@ +//! Interactive crop editing: what dragging a crop handle does. +//! +//! The crop rectangle lives in the straightened (rotated) frame and must stay inside the rotated +//! image. A drag is a *domain operation* on the crop, not a UI detail: the handle being dragged +//! decides which edges are anchored, an optional aspect lock decides the shape, and the image +//! bounds decide how far the drag may go. [`drag_crop`] answers all three in one place so the app, +//! CLI, control channel and MCP agree. + +use crate::{CropGeometry, Point, Rect}; + +/// Smallest crop side, as a fraction of the image's shorter side. +pub const MIN_CROP_FRACTION: f64 = 0.02; + +/// Most elongated locked crop shape (width:height or its inverse) the drag supports. +pub const MAX_RATIO: f64 = 20.0; + +/// A grab point on the crop frame. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CropHandle { + TopLeft, + TopRight, + BottomRight, + BottomLeft, + Top, + Right, + Bottom, + Left, + /// Drag the whole frame. + Move, +} + +impl CropHandle { + /// The overlay's numbering: 0..3 corners (clockwise from top-left), 4..7 edges (top, right, + /// bottom, left), 8 = move. + pub fn from_index(i: u8) -> Option { + Some(match i { + 0 => CropHandle::TopLeft, + 1 => CropHandle::TopRight, + 2 => CropHandle::BottomRight, + 3 => CropHandle::BottomLeft, + 4 => CropHandle::Top, + 5 => CropHandle::Right, + 6 => CropHandle::Bottom, + 7 => CropHandle::Left, + 8 => CropHandle::Move, + _ => return None, + }) + } + + /// Parse a command-parameter name (`"topLeft"`, `"top"`, `"move"`, …). + pub fn from_name(n: &str) -> Option { + Some(match n { + "topLeft" => CropHandle::TopLeft, + "topRight" => CropHandle::TopRight, + "bottomRight" => CropHandle::BottomRight, + "bottomLeft" => CropHandle::BottomLeft, + "top" => CropHandle::Top, + "right" => CropHandle::Right, + "bottom" => CropHandle::Bottom, + "left" => CropHandle::Left, + "move" => CropHandle::Move, + _ => return None, + }) + } +} + +/// Drag `handle` of `start` from `grab` to `pointer` (both normalized, in the straightened frame) +/// on a `w × h` image. `aspect` is the locked width/height ratio in pixels, if any. +/// +/// Guarantees (for any input, including hostile numbers): +/// - the result lies inside the rotated image; +/// - the edges opposite the handle stay where they were (a move keeps the size); +/// - with `aspect`, the frame keeps that shape on every handle (when `start` had it); +/// - the drag stops at the image edge instead of pushing the frame around, and slides along it. +pub fn drag_crop(start: CropGeometry, handle: CropHandle, grab: Point, pointer: Point, w: f64, h: f64, aspect: Option) -> CropGeometry { + let sane = |v: f64| v.is_finite(); + let r0 = start.rect; + if ![w, h, grab.x, grab.y, pointer.x, pointer.y, start.angle, r0.x0, r0.y0, r0.x1, r0.y1].iter().copied().all(sane) + || w <= 0.0 + || h <= 0.0 + || r0.is_empty() + || aspect.is_some_and(|a| !(a.is_finite() && a > 0.0)) + { + return start; + } + // Start from a valid frame, then work in pixels of the straightened frame. + let mut base = start.constrained(w, h); + if base.angle == 0.0 { + // absorb the 1e-6 tolerance of `is_within_image` so the exact test below accepts the start + let r = base.rect; + base.rect = Rect::new(r.x0.clamp(0.0, 1.0), r.y0.clamp(0.0, 1.0), r.x1.clamp(0.0, 1.0), r.y1.clamp(0.0, 1.0)); + } + let sr = base.rect_px(w, h); + // Moving further than twice the image never matters; clamping keeps the searches well-conditioned. + let (dx, dy) = (((pointer.x - grab.x) * w).clamp(-2.0 * w, 2.0 * w), ((pointer.y - grab.y) * h).clamp(-2.0 * h, 2.0 * h)); + // Ratios beyond 20:1 make even the minimum frame larger than the image; treat them as 20:1. + let aspect = aspect.map(|a| a.clamp(1.0 / MAX_RATIO, MAX_RATIO)); + // Unrotated, the frame must be exactly inside the image (`is_within_image` tolerates 1e-6, which + // would leak out as a sliver past the edge and, after any clamp, a shifted ratio). + let feasible = |r: Rect| { + if base.angle == 0.0 { + r.x0 >= 0.0 && r.y0 >= 0.0 && r.x1 <= w && r.y1 <= h + } else { + CropGeometry { rect: r.scale(1.0 / w, 1.0 / h), angle: base.angle }.is_within_image(w, h) + } + }; + let min_side = MIN_CROP_FRACTION * w.min(h); + + let out = match (handle, aspect) { + (CropHandle::Move, _) => { + let slide = |cur: Rect, ax: f64, ay: f64| { + let t = bisect(0.0, 1.0, |t| feasible(cur.translate(crate::Vec2::new(ax * t, ay * t)))); + cur.translate(crate::Vec2::new(ax * t, ay * t)) + }; + let cur = slide(sr, dx, 0.0); + slide(cur, 0.0, dy) + } + (hd, None) => { + let (mx0, mx1, my0, my1) = sides(hd); + let grow = |cur: Rect, tx: f64, ty: f64| { + // `tx`/`ty`: how far the moving edges would go; interpolate towards it + let target = Rect::new( + if mx0 { (cur.x0 + tx).min(cur.x1 - min_side) } else { cur.x0 }, + if my0 { (cur.y0 + ty).min(cur.y1 - min_side) } else { cur.y0 }, + if mx1 { (cur.x1 + tx).max(cur.x0 + min_side) } else { cur.x1 }, + if my1 { (cur.y1 + ty).max(cur.y0 + min_side) } else { cur.y1 }, + ); + let at = |t: f64| lerp_rect(cur, target, t); + at(bisect(0.0, 1.0, |t| feasible(at(t)))) + }; + let cur = grow(sr, dx, 0.0); + grow(cur, 0.0, dy) + } + (hd, Some(a)) => { + let (mx0, _, my0, _) = sides(hd); + let (min_w, min_h) = (min_side.max(min_side * a), min_side.max(min_side / a)); + let c = sr.center(); + // Anchor: the opposite corner / edge midpoint. `build(n, o)` is the frame of width `n` that + // keeps it, shifted by `o` along the anchored edge (edge handles only). + let (target_w, build): (f64, Box Rect>) = match hd { + CropHandle::Top | CropHandle::Bottom => { + let (ay, sy, edge) = if my0 { (sr.y1, -1.0, sr.y0) } else { (sr.y0, 1.0, sr.y1) }; + let nh = (sy * (edge + dy - ay)).max(min_h); + (nh * a, Box::new(move |nw, o| vband(c.x + o, ay, sy, nw, a))) + } + CropHandle::Left | CropHandle::Right => { + let (ax, sx, edge) = if mx0 { (sr.x1, -1.0, sr.x0) } else { (sr.x0, 1.0, sr.x1) }; + let nw = (sx * (edge + dx - ax)).max(min_w); + (nw, Box::new(move |nw, o| hband(ax, sx, c.y + o, nw, a))) + } + _ => { + let (ax, sx, ex) = if mx0 { (sr.x1, -1.0, sr.x0) } else { (sr.x0, 1.0, sr.x1) }; + let (ay, sy, ey) = if my0 { (sr.y1, -1.0, sr.y0) } else { (sr.y0, 1.0, sr.y1) }; + let (wx, wy) = (sx * (ex + dx - ax), sy * (ey + dy - ay)); + let nw = ((wx + wy * a) / 2.0).max(min_w); + (nw, Box::new(move |nw, _| corner_rect(ax, sx, ay, sy, nw, a))) + } + }; + let target_w = target_w.min(2.0 * w.max(h * a)); + let slides = matches!(hd, CropHandle::Top | CropHandle::Bottom | CropHandle::Left | CropHandle::Right); + let extent = if matches!(hd, CropHandle::Top | CropHandle::Bottom) { w } else { h }; + // Frame of width `n`, centred on the anchored edge if it fits, else slid the least distance. + let place = |n: f64| -> Option { + let at = |o: f64| build(n, o); + if feasible(at(0.0)) { + return Some(at(0.0)); + } + if !slides { + return None; + } + const STEPS: u32 = 128; + let step = extent / STEPS as f64; + for k in 1..=STEPS { + for sign in [1.0, -1.0] { + let hi = sign * k as f64 * step; + if feasible(at(hi)) { + // closest feasible offset: bisect between the last infeasible and this one + let (mut bad, mut good) = (sign * (k - 1) as f64 * step, hi); + for _ in 0..40 { + let mid = (bad + good) / 2.0; + if feasible(at(mid)) { + good = mid; + } else { + bad = mid; + } + } + return Some(at(good)); + } + } + } + None + }; + if place(min_w).is_some() { + let n = bisect_range(min_w, target_w.max(min_w), |n| place(n).is_some()); + place(n).unwrap_or(sr) + } else { + sr + } + } + }; + let rect = out.scale(1.0 / w, 1.0 / h); + if [rect.x0, rect.y0, rect.x1, rect.y1].iter().all(|v| v.is_finite()) { CropGeometry { rect, angle: base.angle } } else { start } +} + +/// Which sides a handle moves: (left, right, top, bottom). +fn sides(h: CropHandle) -> (bool, bool, bool, bool) { + use CropHandle::*; + ( + matches!(h, TopLeft | BottomLeft | Left), + matches!(h, TopRight | BottomRight | Right), + matches!(h, TopLeft | TopRight | Top), + matches!(h, BottomLeft | BottomRight | Bottom), + ) +} + +fn lerp_rect(a: Rect, b: Rect, t: f64) -> Rect { + let l = |p: f64, q: f64| p + (q - p) * t; + Rect::new(l(a.x0, b.x0), l(a.y0, b.y0), l(a.x1, b.x1), l(a.y1, b.y1)) +} + +/// Frame of width `nw` and ratio `a` extending from corner (`ax`, `ay`) in directions `sx`, `sy`. +fn corner_rect(ax: f64, sx: f64, ay: f64, sy: f64, nw: f64, a: f64) -> Rect { + let nh = nw / a; + Rect::from_points(Point::new(ax, ay), Point::new(ax + sx * nw, ay + sy * nh)) +} + +/// Frame whose top/bottom edge (at `ay`, growing in direction `sy`) is anchored, centred on `cx`. +fn vband(cx: f64, ay: f64, sy: f64, nw: f64, a: f64) -> Rect { + let nh = nw / a; + Rect::from_points(Point::new(cx - nw / 2.0, ay), Point::new(cx + nw / 2.0, ay + sy * nh)) +} + +/// Frame whose left/right edge (at `ax`, growing in direction `sx`) is anchored, centred on `cy`. +fn hband(ax: f64, sx: f64, cy: f64, nw: f64, a: f64) -> Rect { + let nh = nw / a; + Rect::from_points(Point::new(ax, cy - nh / 2.0), Point::new(ax + sx * nw, cy + nh / 2.0)) +} + +/// Largest `t` in `[lo, hi]` for which `ok` holds, given that it holds at `lo` and the set of good +/// `t` is an interval (the feasible region is convex). +fn bisect_range(lo: f64, hi: f64, ok: impl Fn(f64) -> bool) -> f64 { + if ok(hi) { + return hi; + } + let (mut good, mut bad) = (lo, hi); + for _ in 0..48 { + let mid = (good + bad) / 2.0; + if ok(mid) { + good = mid; + } else { + bad = mid; + } + } + good +} + +fn bisect(lo: f64, hi: f64, ok: impl Fn(f64) -> bool) -> f64 { + bisect_range(lo, hi, ok) +} + +#[cfg(test)] +mod tests { + use super::*; + use CropHandle::*; + + const W: f64 = 600.0; + const H: f64 = 400.0; + const EPS: f64 = 1e-6; + + fn geo(x0: f64, y0: f64, x1: f64, y1: f64) -> CropGeometry { + CropGeometry { rect: Rect::new(x0, y0, x1, y1), angle: 0.0 } + } + fn p(x: f64, y: f64) -> Point { + Point::new(x, y) + } + fn drag(g: CropGeometry, hd: CropHandle, from: Point, to: Point, aspect: Option) -> CropGeometry { + drag_crop(g, hd, from, to, W, H, aspect) + } + fn px_aspect(g: &CropGeometry) -> f64 { + g.rect.width() * W / (g.rect.height() * H) + } + fn near(a: f64, b: f64) -> bool { + (a - b).abs() < EPS + } + + // Feature: dragging a crop handle + // Scenario: a free corner drag inside the image + #[test] + fn given_free_crop_when_corner_dragged_inside_then_corner_follows_and_opposite_corner_stays() { + let g = drag(geo(0.2, 0.2, 0.8, 0.8), BottomRight, p(0.8, 0.8), p(0.9, 0.7), None); + assert!(near(g.rect.x0, 0.2) && near(g.rect.y0, 0.2)); + assert!(near(g.rect.x1, 0.9) && near(g.rect.y1, 0.7)); + } + + // Scenario: dragging past the right edge (the weird behaviour of issue 295) + #[test] + fn given_corner_dragged_past_image_edge_then_it_stops_at_the_edge_and_anchor_stays() { + let g = drag(geo(0.2, 0.2, 0.8, 0.8), BottomRight, p(0.8, 0.8), p(1.3, 0.7), None); + assert!(near(g.rect.x0, 0.2) && near(g.rect.y0, 0.2), "anchor moved: {:?}", g.rect); + assert!(near(g.rect.x1, 1.0), "stops at the edge: {:?}", g.rect); + assert!(near(g.rect.y1, 0.7), "other axis still follows the pointer: {:?}", g.rect); + } + + #[test] + fn given_locked_aspect_when_corner_dragged_past_edge_then_ratio_and_anchor_hold() { + let a = 1.5; + let g = drag(geo(0.2, 0.2, 0.8, 0.2 + 0.6 * W / H / a), BottomRight, p(0.8, 0.6), p(1.4, 0.9), Some(a)); + assert!(near(g.rect.x0, 0.2) && near(g.rect.y0, 0.2), "{:?}", g.rect); + assert!(near(px_aspect(&g), a), "{}", px_aspect(&g)); + assert!(g.rect.x1 <= 1.0 + EPS && g.rect.y1 <= 1.0 + EPS); + assert!(g.rect.x1 > 0.95 || g.rect.y1 > 0.95, "grows until it touches an edge: {:?}", g.rect); + } + + #[test] + fn given_locked_aspect_when_edge_dragged_then_ratio_holds_and_frame_grows_about_its_midline() { + let a = 2.0; + let start = geo(0.3, 0.3, 0.7, 0.3 + 0.4 * W / H / a); + let g = drag(start, Bottom, p(0.5, start.rect.y1), p(0.5, start.rect.y1 + 0.1), Some(a)); + assert!(near(g.rect.y0, start.rect.y0), "opposite edge stays"); + assert!(near(g.rect.center().x, start.rect.center().x), "grows symmetrically sideways"); + assert!(near(px_aspect(&g), a)); + assert!(g.rect.height() > start.rect.height()); + } + + #[test] + fn given_free_crop_when_edge_dragged_then_only_that_edge_moves() { + let g = drag(geo(0.2, 0.2, 0.8, 0.8), Left, p(0.2, 0.5), p(0.1, 0.9), None); + assert!(near(g.rect.x0, 0.1) && near(g.rect.y0, 0.2) && near(g.rect.x1, 0.8) && near(g.rect.y1, 0.8)); + } + + #[test] + fn given_move_past_edge_then_size_is_kept_and_frame_slides_along_the_edge() { + let start = geo(0.5, 0.25, 0.9, 0.65); + let g = drag(start, Move, p(0.7, 0.45), p(1.3, 0.55), None); + assert!(near(g.rect.width(), 0.4) && near(g.rect.height(), 0.4)); + assert!(near(g.rect.x1, 1.0), "flush with the right edge: {:?}", g.rect); + assert!(near(g.rect.y0, 0.35), "still follows the pointer vertically: {:?}", g.rect); + } + + #[test] + fn given_edge_dragged_across_the_opposite_edge_then_crop_keeps_a_minimum_size() { + let g = drag(geo(0.2, 0.2, 0.8, 0.8), Right, p(0.8, 0.5), p(-0.5, 0.5), None); + assert!(g.rect.width() * W >= MIN_CROP_FRACTION * H - EPS && g.rect.x1 > g.rect.x0); + assert!(near(g.rect.x0, 0.2)); + } + + #[test] + fn given_straightened_image_when_dragged_outside_then_crop_stays_inside_the_rotated_image() { + let start = crate::crop_fit_angle(W, H, 12.0, Some(1.5)); + let c = start.rect.center(); + for hd in [TopLeft, TopRight, BottomRight, BottomLeft, Top, Right, Bottom, Left, Move] { + for aspect in [None, Some(1.5)] { + let g = drag(start, hd, c, p(3.0, -3.0), aspect); + assert!(g.is_within_image(W, H), "{hd:?} {aspect:?}: {:?}", g.rect); + assert_eq!(g.angle, 12.0); + } + } + } + + #[test] + fn given_hostile_numbers_then_the_start_crop_is_returned_not_a_panic() { + let s = geo(0.2, 0.2, 0.8, 0.8); + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + assert_eq!(drag(s, BottomRight, p(0.8, 0.8), p(bad, 0.5), None), s); + } + assert_eq!(drag_crop(s, BottomRight, p(0.8, 0.8), p(0.9, 0.9), 0.0, 400.0, None), s); + assert_eq!(drag(s, BottomRight, p(0.8, 0.8), p(0.9, 0.9), Some(f64::NAN)), s); + assert_eq!(drag(s, BottomRight, p(0.8, 0.8), p(0.9, 0.9), Some(-1.0)), s); + } + + // Scenario: a locked edge drag when the frame already touches the image edge (review finding) + #[test] + fn given_locked_frame_flush_with_the_top_when_right_edge_dragged_out_then_it_grows_sliding_down() { + let a = 1.5; + let start = geo(0.2, 0.0, 0.5, 0.3 * W / H * 0.0 + 0.3 * W / (a * H)); + let g = drag(start, Right, p(0.5, 0.2), p(0.9, 0.2), Some(a)); + assert!(near(g.rect.x0, 0.2), "anchor edge stays: {:?}", g.rect); + assert!(g.rect.width() > start.rect.width() + 0.05, "it must grow: {:?}", g.rect); + assert!(near(px_aspect(&g), a)); + assert!(g.is_within_image(W, H)); + } + + #[test] + fn given_full_fit_straightened_crop_when_locked_edge_dragged_then_it_still_respects_the_image() { + let start = crate::crop_fit_angle(W, H, 20.0, Some(1.5)); + let g = drag(start, Right, p(0.9, 0.5), p(1.5, 0.5), Some(1.5)); + assert!(g.is_within_image(W, H) && near(px_aspect(&g), 1.5)); + } + + // Scenario: extreme or hostile magnitudes + #[test] + fn given_an_extreme_locked_ratio_then_the_frame_can_still_be_moved() { + let start = geo(0.1, 0.4, 0.9, 0.4 + 0.8 * W / (60.0 * H)); + let g = drag(start, Move, p(0.5, 0.4), p(0.5, 0.7), Some(60.0)); + assert!(g.rect.y0 > 0.5, "moved: {:?}", g.rect); + } + + #[test] + fn given_a_huge_finite_pointer_then_a_locked_corner_still_grows_to_the_edge() { + let a = 1.5; + let start = geo(0.2, 0.2, 0.5, 0.2 + 0.3 * W / (a * H)); + let g = drag(start, BottomRight, p(0.5, 0.4), p(1e300, 1e300), Some(a)); + assert!(g.rect.x1 > 0.95 || g.rect.y1 > 0.95, "{:?}", g.rect); + let g = drag(start, BottomRight, p(0.5, 0.4), p(1e300, 1e300), None); + assert!(near(g.rect.x1, 1.0) && near(g.rect.y1, 1.0), "{:?}", g.rect); + } + + #[test] + fn given_an_unrotated_image_then_results_never_leave_the_unit_square() { + let g = drag(geo(0.2, 0.2, 0.8, 0.8), BottomRight, p(0.8, 0.8), p(5.0, 5.0), None); + assert!(g.rect.x1 <= 1.0 && g.rect.y1 <= 1.0 && g.rect.x0 >= 0.0 && g.rect.y0 >= 0.0, "{:?}", g.rect); + } + + #[test] + fn handle_numbering_matches_the_overlay() { + assert_eq!(CropHandle::from_index(0), Some(TopLeft)); + assert_eq!(CropHandle::from_index(6), Some(Bottom)); + assert_eq!(CropHandle::from_index(8), Some(Move)); + assert_eq!(CropHandle::from_index(9), None); + assert_eq!(CropHandle::from_name("bottomLeft"), Some(BottomLeft)); + assert_eq!(CropHandle::from_name("nope"), None); + } + + mod props { + use super::*; + use proptest::prelude::*; + + fn any_handle() -> impl Strategy { + (0u8..9).prop_map(|i| CropHandle::from_index(i).unwrap_or(Move)) + } + + proptest! { + /// Whatever the pointer does, the crop stays valid; the anchor and the ratio hold. + #[test] + fn drag_is_always_inside_anchored_and_shaped( + angle in -45.0f64..45.0, + ratio in prop::option::of(0.3f64..4.0), + hd in any_handle(), + tx in -4.0f64..5.0, ty in -4.0f64..5.0, + shrink in 0.2f64..1.0, + ) { + let base = crate::crop_fit_angle(W, H, angle, ratio); + // a smaller start crop so there is room to move + let c = base.rect.center(); + let start = CropGeometry { rect: Rect::from_center(c, base.rect.width() * shrink, base.rect.height() * shrink), angle }; + prop_assume!(start.is_within_image(W, H)); + let g = drag_crop(start, hd, c, p(tx, ty), W, H, ratio); + prop_assert!(g.is_within_image(W, H), "{:?} -> {:?}", start.rect, g.rect); + prop_assert!(g.rect.width() > 0.0 && g.rect.height() > 0.0); + let min = MIN_CROP_FRACTION * W.min(H) - 1e-6; + prop_assert!(g.rect.width() * W >= min.min(start.rect.width() * W) && g.rect.height() * H >= min.min(start.rect.height() * H), "min size {:?}", g.rect); + if let Some(a) = ratio { + prop_assert!((px_aspect(&g) - a).abs() < 1e-6 * a.max(1.0), "ratio {a}: {}", px_aspect(&g)); + } + // anchors: edges opposite the handle do not move + let (s, r) = (start.rect, g.rect); + let keep_l = matches!(hd, TopRight | BottomRight | Right); + let keep_r = matches!(hd, TopLeft | BottomLeft | Left); + let keep_t = matches!(hd, BottomLeft | BottomRight | Bottom); + let keep_b = matches!(hd, TopLeft | TopRight | Top); + { + if keep_l { prop_assert!((r.x0 - s.x0).abs() < 1e-6, "x0"); } + if keep_r { prop_assert!((r.x1 - s.x1).abs() < 1e-6, "x1"); } + if keep_t { prop_assert!((r.y0 - s.y0).abs() < 1e-6, "y0"); } + if keep_b { prop_assert!((r.y1 - s.y1).abs() < 1e-6, "y1"); } + } + if hd == Move { + prop_assert!((r.width() - s.width()).abs() < 1e-9 && (r.height() - s.height()).abs() < 1e-9); + } + } + + #[test] + fn drag_never_panics_on_arbitrary_numbers( + x0 in -2.0f64..3.0, y0 in -2.0f64..3.0, x1 in -2.0f64..3.0, y1 in -2.0f64..3.0, + angle in -90.0f64..90.0, tx in -1e9f64..1e9, ty in -1e9f64..1e9, + ratio in prop::option::of(-1.0f64..10.0), hd in any_handle(), + ) { + let g = drag_crop(CropGeometry { rect: Rect::new(x0, y0, x1, y1), angle }, hd, p(0.5, 0.5), p(tx, ty), W, H, ratio); + prop_assert!(g.rect.x0.is_finite() && g.rect.y1.is_finite()); + } + } + } +} diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 6de121b00..1d81279d9 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -6,10 +6,12 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod crop; +mod crop_drag; mod homography; mod real; pub use crop::{CropGeometry, crop_fit_angle, max_inscribed_scale}; +pub use crop_drag::{CropHandle, MAX_RATIO, MIN_CROP_FRACTION, drag_crop}; pub use homography::Homography; pub use real::{Interval, Real}; diff --git a/crates/gpu/src/backend.rs b/crates/gpu/src/backend.rs index 5c50ca045..fcdea55fa 100644 --- a/crates/gpu/src/backend.rs +++ b/crates/gpu/src/backend.rs @@ -155,8 +155,16 @@ pub(crate) fn with_init_marker(backends: Backends, f: impl FnOnce() -> T) -> /// If the init marker `path` is present — the last process died while creating the GPU device — /// remove it and return what it recorded. pub fn take_init_marker(path: &std::path::Path) -> Option { - let text = std::fs::read_to_string(path).ok()?; + let text = read_init_marker(path)?; let _ = std::fs::remove_file(path); + Some(text) +} + +/// What the init marker `path` recorded, if it is present — without removing it. For sessions +/// that must leave the disk as they found it (`--memory`, issues #164 and #169): GPU rendering +/// still starts off, and the marker stays for the next ordinary launch to report and clear. +pub fn read_init_marker(path: &std::path::Path) -> Option { + let text = std::fs::read_to_string(path).ok()?; Some(text.trim().to_string()) } @@ -225,10 +233,13 @@ mod tests { set_init_marker(None); 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 + // a marker left behind by a crashed process is reported once; reading it leaves it in place std::fs::write(&m, "GPU device creation started (backends DX12)").unwrap(); + assert!(read_init_marker(&m).unwrap().contains("DX12")); + assert!(m.exists(), "read_init_marker leaves the marker for the next launch"); assert!(take_init_marker(&m).unwrap().contains("DX12")); assert_eq!(take_init_marker(&m), None); + assert_eq!(read_init_marker(&m), None); let _ = std::fs::remove_dir_all(&dir); } } diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 70d6246a7..312d42635 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -270,12 +270,39 @@ pub fn set_pool_limit(bytes: u64) { pub fn trim_pool(keep: u64) { #[cfg(not(target_arch = "wasm32"))] if let Some(g) = existing_device() { + if keep == 0 { + render::release_shared_source(); + } g.trim(keep); } #[cfg(target_arch = "wasm32")] let _ = keep; } +/// Device bytes of the one big source (a zoom window's original) shared by every view of the photo. +pub fn shared_source_bytes() -> usize { + #[cfg(not(target_arch = "wasm32"))] + { + render::shared_source_bytes() + } + #[cfg(target_arch = "wasm32")] + { + 0 + } +} + +/// Big sources uploaded to the shared copy so far (a diagnostic: one big photo is uploaded once, not once per window). +pub fn source_uploads() -> u64 { + #[cfg(not(target_arch = "wasm32"))] + { + render::source_uploads() + } + #[cfg(target_arch = "wasm32")] + { + 0 + } +} + /// Device bytes held by a view's GPU stages (kept with its [`StageCache`]); 0 when it has none. pub fn stage_bytes(stages: &StageCache) -> usize { #[cfg(not(target_arch = "wasm32"))] @@ -294,6 +321,9 @@ pub fn stage_bytes(stages: &StageCache) -> usize { pub fn render(src: &Arc, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, stages: Option<&StageCache>) -> Option { #[cfg(not(target_arch = "wasm32"))] { + // 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 (and soft proofs) render on the CPU if !enabled() || req.depth != lightcraft_pipeline::OutputDepth::U8 || req.proof.is_some() { return None; diff --git a/crates/gpu/src/params.rs b/crates/gpu/src/params.rs index 112e89090..7f9405492 100644 --- a/crates/gpu/src/params.rs +++ b/crates/gpu/src/params.rs @@ -57,6 +57,8 @@ const FIELDS: &[(&str, usize)] = &[ ("VIG_POWER", 1), ("VIG_HL", 1), ("VIG_STYLE", 1), + // the window's offset in the whole output and the whole output's size (pixels) + ("VIG_VIEW", 4), ("GRAIN", 1), ("GRAIN_AMT", 1), ("GRAIN_SC", 1), @@ -211,6 +213,7 @@ pub fn finish_block(fp: &FinishParams, masks: &[[f32; MASK_TERMS]], present: &Pr p.f("VIG_ASPECT_MIX", v.aspect_mix); p.f("VIG_POWER", v.power); p.f("VIG_HL", v.highlights); + p.fs("VIG_VIEW", &fp.view); p.u( "VIG_STYLE", match v.style { diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index d6a838b08..60ffd8065 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -414,6 +414,48 @@ fn warp_params( /// ~20 % faster than 8 at 6000 × 4000 (fewer interval evaluations, few more edge pixels). const COVER_ROWS: usize = 16; +/// The largest source (bytes on the device) a view's stages keep uploaded between renders. +const RETAIN_SOURCE_BYTES: usize = 96 << 20; + +/// Big sources uploaded to the shared copy so far (diagnostics and tests). +static SOURCE_UPLOADS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +/// The one big source (the original a zoom window is cut from) kept on the device for all the +/// views of the photo: the Before and After windows and every pan share it, instead of each +/// view's stages holding (or re-uploading) 288 MB of a 24 MP photo. It does not count as a view's +/// stages; it is released with the buffer pool when the app idles. +static SHARED_SOURCE: std::sync::Mutex, Arc)>> = std::sync::Mutex::new(None); + +fn shared_source(src: &Arc, upload: impl FnOnce() -> Buf) -> Arc { + let mut g = SHARED_SOURCE.lock().unwrap_or_else(|e| e.into_inner()); + if let Some((weak, buf)) = &*g + && weak.upgrade().is_some_and(|s| Arc::ptr_eq(&s, src)) + { + return buf.clone(); + } + // the previous photo's copy goes (on a render thread it is retired with the render's other + // buffers, so for a moment both exist) + *g = None; + SOURCE_UPLOADS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let buf = Arc::new(upload()); + *g = Some((Arc::downgrade(src), buf.clone())); + buf +} + +/// Free the shared big source (see [`shared_source`]). +pub(crate) fn release_shared_source() { + *SHARED_SOURCE.lock().unwrap_or_else(|e| e.into_inner()) = None; +} + +/// Device bytes of the shared big source, 0 when there is none. +pub(crate) fn shared_source_bytes() -> usize { + SHARED_SOURCE.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map_or(0, |(_, b)| b.len * 4) +} + +pub(crate) fn source_uploads() -> u64 { + SOURCE_UPLOADS.load(std::sync::atomic::Ordering::Relaxed) +} + /// The reference framing decision ([`Warp::covers`](lightcraft_pipeline::optics::Warp::covers)) for every /// output pixel, one bit per pixel, rows padded to 32-bit words. Blocks of 32 × [`COVER_ROWS`] pixels whose /// interval bounds place them clearly inside or outside the image are filled at once @@ -552,8 +594,11 @@ pub fn render( Some(e) => e.sampled.clone(), None if gpu.fits(src.data.len() * 3) => { let upload = || gpu.upload(rgb_words(src)); + // (a source too big for a view's stages to keep is the one shared copy: a window is + // cut from the photo's own pixels, and what its stages keep is its own sampled pixels) let src_buf = match stages { - Some(c) => c.source(src, upload), + Some(c) if src.data.len() * 12 <= RETAIN_SOURCE_BYTES => c.source(src, upload), + Some(_) => shared_source(src, upload), None => Arc::new(upload()), }; sample(&mut cx, src, src_buf, &plan) @@ -579,7 +624,11 @@ pub fn render( Some(p) if p.key == plan.lin_key => p, _ => Planes { key: plan.lin_key, ..Default::default() }, }; - let prep = prepare(&mut cx, &lin, &plan, req, &mut planes); + let mut prep = prepare(&mut cx, &lin, &plan, req, &mut planes); + // a window dehazes with the whole frame's airlight + if let Some(a) = plan.fixed_air { + prep.air = a; + } lap("planes", &mut t, &mut cx); if let Some(c) = stages { c.put(Entry { src: src.clone(), geo: plan.geo, sampled, lin: Some((plan.lin_key, lin.clone())), planes }); @@ -650,7 +699,10 @@ pub fn render( fail(FailKind::Fatal, format!("the GPU returned an incomplete image ({unwritten} of {n} pixels unwritten)")); return None; } - let histogram = Histogram::of_srgb8(&image); + let histogram = match plan.keep { + Some(k) => Histogram::of_srgb8(&image.crop(k.x, k.y, k.w, k.h)), + None => Histogram::of_srgb8(&image), + }; lap("histogram", &mut t, &mut cx); // An entirely black result (upstream work that did not run would give that too): redo it on // the CPU, which costs time only for the rare photo that really is black. @@ -669,6 +721,10 @@ pub fn render( } }); lightcraft_pipeline::visualize::apply(&mut image, req.overlay, &plan, overlay_mask.as_ref()); + // spots grew the window the render worked on: cut it back to the request + if let Some(k) = plan.keep { + image = image.crop(k.x, k.y, k.w, k.h); + } Some(Rendered { image, histogram, deep: None }) } @@ -713,7 +769,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, w.max(h)); + let (lum, col) = local::nr_params(&plan.settings, plan.src_long, plan.frame.output_long(w, h)); let mut img = img; if let Some(nr) = lum { let l = cx.gpu.buffer(n); @@ -905,7 +961,7 @@ fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Ho Some(2) } MaskShape::ColorRange { samples, refine } => { - let tol = 0.04 + 0.16 * (*refine as f32 / 100.0); + let tol = lightcraft_pipeline::masks::color_range_tolerance(*refine); p.extend([tol.to_bits(), ev.exp2().to_bits(), samples.len() as u32]); aux.extend(samples.iter().flat_map(|s| s.map(|v| v as f32))); Some(3) diff --git a/crates/gpu/src/wgsl/finish.wgsl b/crates/gpu/src/wgsl/finish.wgsl index edd7114c1..d51308ee0 100644 --- a/crates/gpu/src/wgsl/finish.wgsl +++ b/crates/gpu/src/wgsl/finish.wgsl @@ -432,14 +432,15 @@ fn main(@builtin(global_invocation_id) gid: vec3) { // --- vignette (display linear, post-crop) if (pu(F_VIG) != 0u) { - let fw = f32(w); - let fh = f32(h); + // the vignette belongs to the whole output frame, not to the window being drawn + let fw = pf(F_VIG_VIEW + 2u); + let fh = pf(F_VIG_VIEW + 3u); let aspect = fw / fh; let amount = pf(F_VIG_AMOUNT); let mixa = pf(F_VIG_ASPECT_MIX); let power = pf(F_VIG_POWER); - let u = (f32(x) + 0.5) / fw * 2.0 - 1.0; - let vv = (f32(y) + 0.5) / fh * 2.0 - 1.0; + let u = (pf(F_VIG_VIEW) + f32(x) + 0.5) / fw * 2.0 - 1.0; + let vv = (pf(F_VIG_VIEW + 1u) + f32(y) + 0.5) / fh * 2.0 - 1.0; let sx = 1.0 + (aspect - 1.0) * mixa; let sy = 1.0 + (1.0 / aspect - 1.0) * mixa; let ax = abs(u * max(sx, 1.0) / max(sx, sy)); diff --git a/crates/gpu/tests/equivalence.rs b/crates/gpu/tests/equivalence.rs index c16b46cb4..cb6e7c024 100644 --- a/crates/gpu/tests/equivalence.rs +++ b/crates/gpu/tests/equivalence.rs @@ -639,3 +639,123 @@ fn output_spaces_match() { } } } + +#[test] +fn tint_directions_match_cpu_for_raw_and_rendered_sources() { + if !gpu() { + return; + } + let src = Arc::new(Rgb32f::filled(64, 64, [0.18; 3])); + for info in [ + SourceInfo::default(), + SourceInfo { raw: true, relative_wb: true, ..Default::default() }, + SourceInfo { raw: true, as_shot_temp: 4200.0, as_shot_tint: 15.0, ..Default::default() }, + ] { + for delta in [-50.0, 50.0] { + let mut s = DevelopSettings::default(); + s.wb.mode = WbMode::Custom; + s.wb.temp = info.as_shot_temp; + s.wb.tint = info.as_shot_tint + delta; + check("tint direction", &src, &info, &s, &RenderRequest::fit(64, 64)); + } + } +} + +/// Issue #323: windows of a zoomed frame. The GPU renders the window like the CPU does: the +/// vignette and airlight belong to the whole frame, spots grow the window they work on. +#[test] +fn windows_match() { + if !gpu() { + return; + } + use lightcraft_pipeline::PixelWindow; + let src = scene(1, 960, 640); + let info = SourceInfo { raw: true, ..Default::default() }; + let win = PixelWindow { x: 1200, y: 800, w: 640, h: 480 }; + let req = RenderRequest { window: Some(win), ..RenderRequest::fit(3000, 2000) }; + let mut cases = cases(); + cases.push(("vignette at the corner", |s| { + s.vignette.amount = -80.0; + s.vignette.midpoint = 10.0; + })); + cases.push(("spot reading from outside", |s| { + s.spots.push(Spot { + points: vec![Point::new(0.45, 0.45)], + size: 0.03, + feather: 30.0, + opacity: 100.0, + source_offset: Some(Point::new(0.15, 0.0)), + ..Default::default() + }); + })); + for (name, edit) in cases { + let mut s = DevelopSettings::default(); + edit(&mut s); + let name = format!("window: {name}"); + check(&name, &src, &info, &s, &req); + } + // the corner window (where the vignette is strong) + let mut s = DevelopSettings::default(); + s.vignette.amount = -80.0; + s.vignette.midpoint = 10.0; + let corner = RenderRequest { window: Some(PixelWindow { x: 2360, y: 1520, w: 640, h: 480 }), ..RenderRequest::fit(3000, 2000) }; + check("window: vignette corner", &src, &info, &s, &corner); +} + +/// Issue #323: a window of a 24 MP photo is cut from a source of 288 MB on the device. Keeping that +/// upload with the view's stages counted it against the stage budget on every tick of a drag and +/// got the stages cleared, and the upload and resampling redone: the window's stages are its own +/// pixels, not the photo's. +// (these two share the process's one big source: a test lock keeps them from replacing each other's) +static BIG_SOURCE_TESTS: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +#[test] +fn a_window_does_not_keep_the_whole_source_on_the_device() { + let _lock = BIG_SOURCE_TESTS.lock().unwrap_or_else(|e| e.into_inner()); + if !gpu() { + return; + } + use lightcraft_pipeline::PixelWindow; + let src = scene(1, 6000, 4000); + let info = SourceInfo { raw: true, ..Default::default() }; + let stages = StageCache::default(); + let s = DevelopSettings::default(); + let req = RenderRequest { window: Some(PixelWindow { x: 2000, y: 1500, w: 640, h: 480 }), ..RenderRequest::fit(6000, 4000) }; + let _ = lightcraft_gpu::render(&src, &info, &s, &req, Some(&stages)).expect("gpu render"); + let held = lightcraft_gpu::stage_bytes(&stages); + assert!(held < 100 << 20, "the view's GPU stages hold {} MiB for a 640×480 window", held >> 20); + // …and the next tick of a drag reuses what is there + let mut s2 = s.clone(); + s2.light.exposure = 0.5; + let again = lightcraft_gpu::render(&src, &info, &s2, &req, Some(&stages)).expect("gpu render"); + assert_eq!((again.image.width, again.image.height), (640, 480)); + assert!(lightcraft_gpu::stage_bytes(&stages) < 100 << 20); +} + +/// …but the 288 MB is uploaded once for every window of every view of the photo, not once per +/// window: panning at 1:1, and the Before and After windows, share one device copy of it, which +/// goes when the app idles (`trim_pool(0)`). +#[test] +fn windows_of_a_big_photo_share_one_uploaded_source() { + let _lock = BIG_SOURCE_TESTS.lock().unwrap_or_else(|e| e.into_inner()); + if !gpu() { + return; + } + use lightcraft_pipeline::PixelWindow; + let src = scene(1, 6000, 4000); + let info = SourceInfo { raw: true, ..Default::default() }; + let s = DevelopSettings::default(); + let (after, before) = (StageCache::default(), StageCache::default()); + let at = |x: usize| RenderRequest { window: Some(PixelWindow { x, y: 1500, w: 640, h: 480 }), ..RenderRequest::fit(6000, 4000) }; + let start = lightcraft_gpu::source_uploads(); + for (cache, x) in [(&after, 1000), (&after, 1256), (&after, 1512), (&before, 1512), (&before, 1768)] { + lightcraft_gpu::render(&src, &info, &s, &at(x), Some(cache)).expect("gpu render"); + } + assert_eq!(lightcraft_gpu::source_uploads() - start, 1, "one upload for five windows of two views"); + assert!(lightcraft_gpu::shared_source_bytes() >= 280_000_000, "{}", lightcraft_gpu::shared_source_bytes()); + assert!(lightcraft_gpu::stage_bytes(&after) < 100 << 20, "and it is not in the views' stages"); + lightcraft_gpu::trim_pool(0); + assert_eq!(lightcraft_gpu::shared_source_bytes(), 0, "an idle app gives it back"); + lightcraft_gpu::render(&src, &info, &s, &at(1000), Some(&after)).expect("gpu render"); + assert_eq!(lightcraft_gpu::source_uploads() - start, 2); +} diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 162ccce40..bdebec60a 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -13,7 +13,8 @@ use crate::backend::Backend; /// File extensions recognised as photos when expanding folders. pub const PHOTO_EXTENSIONS: &[&str] = &[ "jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "psd", "jxl", "gif", - "bmp", "avif", + "bmp", "avif", // containers LightCraft cannot decode but imports as preview only (their embedded JPEG) + "iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf", ]; /// Headless backend: a [`Session`] with filesystem hooks. @@ -61,7 +62,7 @@ impl Headless { fn export(&mut self, p: &Value) -> Result { use lightcraft_engine::export::{Destination, ExportFormat, ExportOptions, Resize, export_batch}; let p = &self.session.export_params(p)?; - let mut opts = ExportOptions::from_json(p); + let mut opts = ExportOptions::from_params(p).map_err(|e| e.to_string())?; if !ExportOptions::has_size_param(p) { opts.resize = Some(Resize::long_edge(3000)); } @@ -107,7 +108,7 @@ impl Backend for Headless { "engine.commands" => { let mut v: Vec = self.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect(); v.push(json!({"id": "app.export", "label": "Export Now", "menu": [], "shortcut": null, - "params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.dng) | dir?, ids?, format?: jpeg|png|tiff|webp|avif|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), dontEnlarge?, ppi?, quality?, limitKb?, colorSpace?, bitDepth?, sharpen?, metadata?, watermark?, naming?}", + "params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.dng) | dir?, ids?, preset?, format?: jpeg|png|tiff|webp|avif|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), dontEnlarge?, ppi?, quality?: 1..100, limitKb?, colorSpace?: srgb|displayP3|adobeRgb|proPhoto|rec2020, bitDepth?: 8|10|16|32, sharpen?: none|screen|matte|glossy, sharpenAmount?: low|standard|high, metadata?: all|allExceptCamera|copyright|none, removeLocation?, naming?, startNumber?, subfolder?, conflict?: unique|overwrite|skip, tiffCompression?: none|lzw|zip, dngCompression?: lossless|deflate|uncompressed, watermark?: text | {text?, vertical? (upright columns, right to left), size? (text height, 0.005..0.5 of the short edge; default 0.035), opacity? (0..1; 0.7), anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, inset? (margin, 0..0.4 of the short edge; 0.025), color? [r,g,b] sRGB, shadow?, image? (graphic drawn instead of the text), imageWidth? (0.01..1 of the photo's width; 0.2)}} — an unknown parameter, an out-of-range watermark size or a value of the wrong kind is an error, not a default", "enabled": self.session.active().is_some()})); Ok(Value::Array(v)) } @@ -139,31 +140,20 @@ impl Backend for Headless { } } -/// Expand folders (recursively, sorted) into photo files and make paths absolute. +/// Expand folders (recursively, sorted; bounded, see `lightcraft_engine::walk`) into photo files +/// and make paths absolute. pub fn expand_paths(paths: &[String]) -> Vec { - fn walk(p: &Path, out: &mut Vec) { - if p.is_dir() { - if let Ok(rd) = std::fs::read_dir(p) { - let mut v: Vec<_> = rd.flatten().map(|e| e.path()).collect(); - v.sort(); - for c in v { - if c.file_name().is_some_and(|n| !n.to_string_lossy().starts_with('.')) { - walk(&c, out); - } - } - } - } else if p.extension().is_some_and(|e| PHOTO_EXTENSIONS.contains(&e.to_string_lossy().to_lowercase().as_str())) { - out.push(std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf()).to_string_lossy().to_string()); - } - } + let absolute = |p: &Path| std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf()).to_string_lossy().to_string(); + let photo = |p: &Path| p.extension().is_some_and(|e| PHOTO_EXTENSIONS.contains(&e.to_string_lossy().to_lowercase().as_str())); let mut out = Vec::new(); for p in paths { let path = Path::new(p); if path.is_dir() { - walk(path, &mut out); + let w = lightcraft_engine::walk::files_in(path, None, lightcraft_engine::walk::Limits::default(), photo); + out.extend(w.files.iter().map(|f| absolute(f))); } else { // Explicit files are kept even with unknown extensions (the probe decides). - out.push(std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf()).to_string_lossy().to_string()); + out.push(absolute(path)); } } out diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index c613bf373..ee17e1ad7 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -120,6 +120,25 @@ fn path_writes_never_replace_an_original() { let _ = std::fs::remove_dir_all(&base); } +/// Issue #181: a misspelled or unreadable `app.export` param is an error through the headless +/// backend (what `lightcraft-cli run` and the MCP `export` tool use), not a silent default. +#[test] +fn export_refuses_unknown_params_and_bad_values() { + let mut b = Headless::demo(); + let out = "/nonexistent-lc-test/x.jpg"; + let e = b.call("app.export", json!({"path": out, "longEdgee": 400})).unwrap_err(); + assert!(e.contains("unknown parameter `longEdgee` (did you mean `longEdge`?)"), "{e}"); + let e = b.call("app.export", json!({"path": out, "longEdge": "banana"})).unwrap_err(); + assert!(e.contains("`longEdge` must be a number"), "{e}"); + let e = b.call("app.export", json!({"path": out, "watermark": {"text": "x", "size": 3}})).unwrap_err(); + assert!(e.contains("`watermark.size` must be a number 0.005..0.5"), "{e}"); + // `export` tool and preset expansion go through the same check + let r = call_tool(&mut b, "export", &json!({"path": out, "quality": 101})); + assert!(r.is_error, "{r:?}"); + let e = b.session.execute("export.savePreset", &json!({"name": "Typo", "params": {"qualty": 5}})).unwrap_err().to_string(); + assert!(e.contains("did you mean `quality`"), "{e}"); +} + /// The `import` helper moves: renamed into the folder template, the source removed. #[test] fn import_tool_moves_with_a_folder_template() { @@ -205,3 +224,24 @@ fn resources() { } assert_eq!(rpc(&mut s, 99, "resources/read", json!({"uri": "lightcraft://nope"}))["error"]["code"], -32002); } + +/// `select_photos` with an id that is not in the library is a tool error, and the photo that was +/// active stays active (#182). +#[test] +fn select_photos_rejects_unknown_ids_and_keeps_the_active_photo() { + let mut b = Headless::demo(); + let first = b.session.visible_cloned()[0]; + let r = call_tool(&mut b, "select_photos", &json!({"ids": [first.0]})); + assert!(!r.is_error, "{r:?}"); + let r = call_tool(&mut b, "select_photos", &json!({"ids": [9999]})); + assert!(r.is_error, "{r:?}"); + assert!(r.content[0]["text"].as_str().unwrap().contains("no such photo 9999"), "{r:?}"); + assert_eq!(b.session.active(), Some(first)); + let r = call_tool(&mut b, "get_develop", &json!({})); + assert!(!r.is_error, "{r:?}"); + let r = call_tool(&mut b, "get_develop", &json!({"id": 9999})); + assert!(r.is_error, "{r:?}"); + let r = call_tool(&mut b, "set_develop", &json!({"id": 9999, "values": {"light.exposure": 1.0}})); + assert!(r.is_error, "{r:?}"); + assert_eq!(b.session.active(), Some(first)); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index c5419425f..1b41d86ab 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -102,6 +102,7 @@ pub fn helper_tools(has_ui: bool) -> Vec { "mode": {"type": "string", "enum": ["add", "copy", "move"], "description": "add (default) = reference the files in place; copy = copy them into `destination` (default: the library's Originals/); move = as copy, then each original and its XMP sidecars are removed from the source once the copy is verified and catalogued (duplicates and failures keep their sources; `kept` lists sources left in place and why; undo leaves the moved files at the destination)"}, "destination": {"type": "string", "description": "Copy or move: destination folder"}, "organize": {"type": "string", "description": "Copy or move: folders inside the destination — date (YYYY/YYYY-MM-DD, default), month (YYYY/YYYY-MM), flat, or a folder template such as {date:%Y}/{date:%Y%m%d} (→ 2026/20260114; the template's / make the levels, tokens as for rename; relative, no ..). Dated by capture time, else the import time"}, + "onDeleted": {"type": "string", "enum": ["skip", "restore", "fresh"], "description": "A file that is in Recently Deleted: skip (default) = leave it there (the duplicate is reported with existingDeleted); restore = bring the photo back with its edits (`restored` lists it); fresh = delete the trashed record and import the file as a new photo"}, "rename": {"type": "string", "description": "Copy or move: file-name template, e.g. {date:%Y%m%d_%H%M%S}_{seq:3} (run_command photo.renameTokens lists the tags); the extension is kept"} }), &["paths"], @@ -205,7 +206,7 @@ pub fn helper_tools(has_ui: bool) -> Vec { "removeLocation": {"type": "boolean"}, "colorSpace": {"type": "string", "enum": ["srgb", "displayP3", "adobeRgb", "proPhoto", "rec2020"], "description": "Output colour space (default sRGB; AVIF is always sRGB). adobeRgb = Adobe RGB (1998) compatible; the embedded ICC profile is generated from the published primaries"}, "bitDepth": {"type": "integer", "enum": [8, 10, 16, 32], "description": "Bits per channel: PNG 8|16 (default 8), TIFF 8|16|32 (default 16; 32 = linear float with a linear profile), AVIF 8|10; JPEG/WebP are 8-bit"}, - "watermark": {"description": "Text, or {text, vertical (boolean; defaults to false), size (fraction of short edge), opacity, anchor (topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight), inset, color [r,g,b], shadow}"} + "watermark": {"description": "Text, or {text, vertical (boolean; defaults to false: upright columns right to left), size (text height as a fraction of the short edge, 0.005..0.5; default 0.035), opacity (0..1; default 0.7), anchor (topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight), inset (margin as a fraction of the short edge, 0..0.4; default 0.025), color [r,g,b] (sRGB 0..255), shadow (boolean), image (path of a graphic drawn instead of the text), imageWidth (fraction of the photo's width, 0.01..1; default 0.2)}. Unknown keys and out-of-range sizes are errors"} }), &[], ), @@ -475,7 +476,7 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult { return ToolResult::error(format!("no photos found in {paths:?}")); } let mut p = json!({"paths": files}); - for k in ["album", "mode", "destination", "organize", "rename"] { + for k in ["album", "mode", "destination", "organize", "rename", "onDeleted"] { if let Some(v) = args.get(k).filter(|v| !v.is_null()) { p[k] = v.clone(); } diff --git a/crates/merge/src/frame.rs b/crates/merge/src/frame.rs index df016b5d4..360c7424e 100644 --- a/crates/merge/src/frame.rs +++ b/crates/merge/src/frame.rs @@ -111,9 +111,7 @@ pub fn load_frame(bytes: &[u8], max_edge: Option, orient: bool) -> Result if lightcraft_raw::probe(bytes).is_some() { let mut raw = lightcraft_raw::decode(bytes).map_err(|e| MergeError::Decode(e.to_string()))?; // geometric lens corrections stay with the develop settings ("profile corrections") - raw.opcodes - .list3 - .retain(|op| !matches!(op, lightcraft_raw::Opcode::WarpRectilinear { .. } | lightcraft_raw::Opcode::FixVignetteRadial { .. })); + raw.opcodes.list3.retain(|op| !op.is_lens_correction()); 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()))?; diff --git a/crates/meta/src/cr3.rs b/crates/meta/src/cr3.rs index 18d33ef7f..5fa6437bc 100644 --- a/crates/meta/src/cr3.rs +++ b/crates/meta/src/cr3.rs @@ -17,7 +17,13 @@ pub enum Cr3TrackKind { /// `CRAW` with a `JPEG` child: the full-size JPEG. Jpeg, /// `CRAW` raw image: dimensions and the `CMP1` coding header (byte range in the file). - Raw { width: u16, height: u16, cmp1: Option<(usize, usize)> }, + Raw { + width: u16, + height: u16, + cmp1: Option<(usize, usize)>, + /// `CDI1/IAD1` image-area descriptor (byte range in the file). + iad1: Option<(usize, usize)>, + }, /// Anything else (`CTMD` timed metadata, unknown entries). Other([u8; 4]), } @@ -30,6 +36,134 @@ pub struct Cr3Track { pub data: Option<(usize, usize)>, } +/// Canon's `CMP1` image coding descriptor, separate from the encoded track sample. +/// +/// Parsed from the payload (after the eight-byte box header). Geometry is the full sensor mosaic, +/// including optically masked borders. Unsupported encoding modes are retained for the decoder to +/// report explicitly, rather than confused with the full-size embedded JPEG. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Cr3Compression { + pub version: u16, + pub width: u32, + pub height: u32, + pub tile_width: u32, + pub tile_height: u32, + pub bit_depth: u8, + pub planes: u8, + /// 0 = RGGB, 1 = GRBG, 2 = GBRG, 3 = BGGR. + pub cfa_pattern: u8, + pub encoding: u8, + pub levels: u8, + pub tile_flags: u8, + /// Number of bytes at the start of the sample before its entropy-coded data. + pub header_size: u32, + pub median_bit_depth: Option, +} + +impl Cr3Compression { + /// Read the public `CMP1` field layout. Truncated or impossible descriptors return `None`. + pub fn parse(payload: &[u8]) -> Option { + // The first word is reserved in the public notes; real files use FF00, although older + // descriptions guessed FFFF. It is not a reliable format signature. + let header_len = usize::from(be16(payload, 2)?).checked_add(4)?; + if header_len < 52 || header_len > payload.len() { + return None; + } + let packed = *payload.get(25)?; + let coding = *payload.get(26)?; + let mut out = Self { + version: be16(payload, 4)?, + width: be32(payload, 8)?, + height: be32(payload, 12)?, + tile_width: be32(payload, 16)?, + tile_height: be32(payload, 20)?, + bit_depth: *payload.get(24)?, + planes: packed >> 4, + cfa_pattern: packed & 15, + encoding: coding >> 4, + levels: coding & 15, + tile_flags: *payload.get(27)?, + header_size: be32(payload, 28)?, + median_bit_depth: None, + }; + if out.width == 0 + || out.height == 0 + || out.tile_width == 0 + || out.tile_height == 0 + || out.tile_width > out.width + || out.tile_height > out.height + || !(1..=16).contains(&out.bit_depth) + || !(1..=4).contains(&out.planes) + || (out.planes > 1 && out.cfa_pattern > 3) + || out.header_size == 0 + { + return None; + } + if payload.get(32).is_some_and(|flags| flags & 0x80 != 0) { + // Extended CMP1: a four-byte length, flags, reserved bytes and six words. The optional + // median precision is used by type-3 burst encodings, not ordinary Bayer raw/craw. + let extension_len = usize::try_from(be32(payload, 52)?).ok()?; + let extension_end = 52usize.checked_add(extension_len)?; + if extension_len < 32 || extension_end > payload.len() { + return None; + } + if payload.get(56).is_some_and(|flags| flags & 0x40 != 0) { + let precision = *payload.get(84).filter(|_| extension_end > 84)?; + if !(1..=16).contains(&precision) { + return None; + } + out.median_bit_depth = Some(precision); + } + } + Some(out) + } +} + +/// `IAD1` geometry. Coordinates are inclusive sensor offsets: `[left, top, right, bottom]`. +/// A full raw descriptor distinguishes valid sensor pixels from the recommended final crop. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Cr3ImageArea { + pub width: u16, + pub height: u16, + pub crop: [u16; 4], + pub active: Option<[u16; 4]>, + pub masked_left: [u16; 4], + pub masked_top: Option<[u16; 4]>, +} + +impl Cr3ImageArea { + pub fn parse(payload: &[u8]) -> Option { + let bounds = |at: usize| Some([be16(payload, at)?, be16(payload, at + 2)?, be16(payload, at + 4)?, be16(payload, at + 6)?]); + let width = be16(payload, 4)?; + let height = be16(payload, 6)?; + if width == 0 || height == 0 { + return None; + } + let full = be16(payload, 10)? == 2; + Some(Self { + width, + height, + crop: bounds(16)?, + masked_left: bounds(24)?, + masked_top: if full { Some(bounds(32)?) } else { None }, + active: if full { Some(bounds(40)?) } else { None }, + }) + } +} + +impl Cr3Track { + /// Decode this track's image coding descriptor without reading its pixel data. + pub fn compression(&self, bytes: &[u8]) -> Option { + let Cr3TrackKind::Raw { cmp1: Some((at, len)), .. } = self.kind else { return None }; + Cr3Compression::parse(bytes.get(at..at.checked_add(len)?)?) + } + + pub fn image_area(&self, bytes: &[u8]) -> Option { + let Cr3TrackKind::Raw { iad1: Some((at, len)), .. } = self.kind else { return None }; + Cr3ImageArea::parse(bytes.get(at..at.checked_add(len)?)?) + } +} + /// The parts of a CR3 file LightCraft reads. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Cr3<'a> { @@ -40,6 +174,57 @@ pub struct Cr3<'a> { pub tracks: Vec, } +/// One independent TIFF block from Canon timed metadata. Its offsets remain relative to this block. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Cr3TimedExif<'a> { + /// CTMD record type (7, 8 or 9). + pub record_type: u16, + /// Exif IFD (`0x8769`) or Canon maker note (`0x927c`). + pub tag: u32, + pub data: &'a [u8], +} + +impl Cr3<'_> { + /// Read TIFF blocks in the first samples of CTMD tracks. As-shot white balance and sensor + /// levels often live here rather than in the static `CMT3` maker note. Both record layers use + /// little-endian byte lengths; TIFF blocks retain their own byte order and offset origin. + pub fn timed_exif<'a>(&self, bytes: &'a [u8]) -> Vec> { + let mut out = Vec::new(); + let mut budget = MAX_BOXES; + for track in &self.tracks { + if track.kind != Cr3TrackKind::Other(*b"CTMD") { + continue; + } + let Some(sample) = track.data.and_then(|(at, len)| at.checked_add(len).and_then(|end| bytes.get(at..end))) else { continue }; + let mut at = 0usize; + while budget > 0 { + budget -= 1; + let Some(len) = le32(sample, at).and_then(|v| usize::try_from(v).ok()) else { break }; + let Some(end) = at.checked_add(len).filter(|&end| len >= 12 && end <= sample.len()) else { break }; + let Some(record) = sample.get(at..end) else { break }; + let Some(kind) = le16(record, 4) else { break }; + if matches!(kind, 7..=9) { + let mut item_at = 12usize; + while budget > 0 { + budget -= 1; + let Some(item_len) = le32(record, item_at).and_then(|v| usize::try_from(v).ok()) else { break }; + let Some(item_end) = item_at.checked_add(item_len).filter(|&end| item_len >= 8 && end <= record.len()) else { break }; + let Some(item) = record.get(item_at..item_end) else { break }; + if let Some(tag @ (0x8769 | 0x927c)) = le32(item, 4) + && let Some(data) = item.get(8..) + { + out.push(Cr3TimedExif { record_type: kind, tag, data }); + } + item_at = item_end; + } + } + at = end; + } + } + out + } +} + const CANON_UUID: [u8; 16] = [0x85, 0xc0, 0xb6, 0x87, 0x82, 0x0f, 0x11, 0xe0, 0x81, 0x11, 0xf4, 0xce, 0x46, 0x2b, 0x6a, 0x48]; const XMP_UUID: [u8; 16] = [0xbe, 0x7a, 0xcf, 0xcb, 0x97, 0xa9, 0x42, 0xe8, 0x9c, 0x71, 0x99, 0x94, 0x91, 0xe3, 0xaf, 0xac]; const MAX_DEPTH: usize = 12; @@ -158,19 +343,21 @@ fn trak(bytes: &[u8], t: &BoxRef, budget: &mut usize) -> Option { let stbl = stbl?; let (mut kind, mut size, mut offset) = (None, None, None); for b in boxes(bytes, stbl.body, stbl.end, budget) { + let body = bytes.get(b.body..b.end)?; match &b.kind { // full box: version/flags, entry count, entries - b"stsd" => kind = boxes(bytes, b.body + 8, b.end, budget).first().map(|e| sample_entry(bytes, e, budget)), + b"stsd" if be32(body, 4)? > 0 => kind = boxes(bytes, b.body + 8, b.end, budget).first().map(|e| sample_entry(bytes, e, budget)), // version/flags, sample_size (0 = per-sample table), count, sizes b"stsz" => { - size = match be32(bytes, b.body + 4)? { - 0 if be32(bytes, b.body + 8)? > 0 => be32(bytes, b.body + 12), + size = match be32(body, 4)? { + 0 if be32(body, 8)? > 0 => be32(body, 12), 0 => None, - n => Some(n), + n if be32(body, 8)? > 0 => Some(n), + _ => None, } } - b"co64" if be32(bytes, b.body + 4)? > 0 => offset = be64(bytes, b.body + 8).and_then(|v| usize::try_from(v).ok()), - b"stco" if be32(bytes, b.body + 4)? > 0 => offset = be32(bytes, b.body + 8).map(|v| v as usize), + b"co64" if be32(body, 4)? > 0 => offset = be64(body, 8).and_then(|v| usize::try_from(v).ok()), + b"stco" if be32(body, 4)? > 0 => offset = be32(body, 8).map(|v| v as usize), _ => {} } } @@ -185,18 +372,37 @@ fn sample_entry(bytes: &[u8], e: &BoxRef, budget: &mut usize) -> Cr3TrackKind { if &e.kind != b"CRAW" { return Cr3TrackKind::Other(e.kind); } - let (Some(width), Some(height)) = (be16(bytes, e.body + 24), be16(bytes, e.body + 26)) else { + let Some(body) = bytes.get(e.body..e.end) else { return Cr3TrackKind::Other(e.kind) }; + let (Some(width), Some(height)) = (be16(body, 24), be16(body, 26)) else { return Cr3TrackKind::Other(e.kind); }; - let mut cmp1 = None; + let (mut cmp1, mut iad1, mut rendered) = (None, None, None); for c in boxes(bytes, e.body.saturating_add(CRAW_CHILDREN_AT), e.end, budget) { match &c.kind { - b"JPEG" => return Cr3TrackKind::Jpeg, + b"JPEG" => rendered = Some(Cr3TrackKind::Jpeg), + // HDR-capable cameras also put an HEVC preview in a CRAW entry, + // often with the same displayed dimensions as the main raw track. + b"HEVC" => rendered = Some(Cr3TrackKind::Other(c.kind)), b"CMP1" => cmp1 = Some((c.body, c.end - c.body)), + b"CDI1" => { + for area in boxes(bytes, c.body.saturating_add(4), c.end, budget) { + if &area.kind == b"IAD1" { + iad1 = Some((area.body, area.end - area.body)); + } + } + } _ => {} } } - Cr3TrackKind::Raw { width, height, cmp1 } + // A present, malformed CMP1 must remain a raw descriptor so its corruption + // is reported. Only an explicitly identified rendered codec can establish + // that a CRAW entry without CMP1 is a preview rather than a broken raw. + if cmp1.is_none() + && let Some(kind) = rendered + { + return kind; + } + Cr3TrackKind::Raw { width, height, cmp1, iad1 } } fn be16(b: &[u8], at: usize) -> Option { @@ -212,6 +418,16 @@ fn be64(b: &[u8], at: usize) -> Option { Some(u64::from_be_bytes(a)) } +fn le16(b: &[u8], at: usize) -> Option { + let s = b.get(at..at.checked_add(2)?)?; + Some(u16::from_le_bytes([s[0], s[1]])) +} + +fn le32(b: &[u8], at: usize) -> Option { + let s = b.get(at..at.checked_add(4)?)?; + Some(u32::from_le_bytes([s[0], s[1], s[2], s[3]])) +} + /// Merge `CMT1` (IFD0), `CMT2` (Exif) and `CMT4` (GPS) into one TIFF stream, so the Exif readers see a CR3 /// like any TIFF-based raw. The maker note (`CMT3`) is left out: its offsets are relative to its own block. pub fn merged_exif(c: &Cr3<'_>) -> Option> { @@ -308,12 +524,50 @@ mod tests { assert_eq!(c.xmp, Some(b"".as_slice())); assert_eq!(c.tracks.len(), 2); assert_eq!(c.tracks[0], Cr3Track { kind: Cr3TrackKind::Jpeg, data: Some((4096, 16)) }); - let Cr3TrackKind::Raw { width, height, cmp1: Some((at, len)) } = c.tracks[1].kind else { panic!("{:?}", c.tracks[1]) }; + let Cr3TrackKind::Raw { width, height, cmp1: Some((at, len)), .. } = c.tracks[1].kind else { panic!("{:?}", c.tracks[1]) }; assert_eq!((width, height, len), (60, 40, 4)); assert_eq!(&f[at..at + 2], &[0xff, 0x10]); assert_eq!(c.tracks[1].data, Some((4112, 32))); } + #[test] + fn hevc_preview_is_distinct_from_an_equal_size_raw_track() { + let preview = trak(craw(6000, 4000, &bx(b"HEVC", &[0; 4])), 4096, 16); + let reduced = trak(craw(1624, 1080, &bx(b"CMP1", &compression_payload(0))), 4112, 16); + let sensor = trak(craw(6000, 4000, &bx(b"CMP1", &compression_payload(0))), 4128, 16); + let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom"); + file.extend(bx(b"moov", &[preview, reduced, sensor].concat())); + file.resize(4144, 0); + let container = parse_cr3(&file).unwrap(); + assert_eq!(container.tracks[0].kind, Cr3TrackKind::Other(*b"HEVC")); + assert!(container.tracks[0].compression(&file).is_none()); + assert!(matches!(container.tracks[2].kind, Cr3TrackKind::Raw { width: 6000, height: 4000, cmp1: Some(_), .. })); + assert_eq!(container.tracks[2].compression(&file).unwrap().width, 6888); + } + + #[test] + fn rendered_child_does_not_hide_a_malformed_raw_descriptor() { + for codec in [b"JPEG", b"HEVC"] { + for first in [true, false] { + let rendered = bx(codec, &[0; 4]); + let corrupt = bx(b"CMP1", &[0xff, 0]); + let children = if first { [rendered, corrupt] } else { [corrupt, rendered] }.concat(); + let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom"); + file.extend(bx(b"moov", &trak(craw(6000, 4000, &children), 4096, 16))); + file.resize(4112, 0); + let container = parse_cr3(&file).unwrap(); + assert!(matches!(container.tracks[0].kind, Cr3TrackKind::Raw { cmp1: Some(_), .. })); + assert!(container.tracks[0].compression(&file).is_none()); + } + } + // Absence alone is not evidence of a preview: retain a broken raw so + // main-track selection cannot quietly fall back to a reduced image. + let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom"); + file.extend(bx(b"moov", &trak(craw(6000, 4000, &bx(b"free", &[])), 4096, 16))); + file.resize(4112, 0); + assert!(matches!(parse_cr3(&file).unwrap().tracks[0].kind, Cr3TrackKind::Raw { cmp1: None, .. })); + } + #[test] fn merged_exif_reads_like_a_tiff_raw() { let f = sample(); @@ -322,6 +576,145 @@ mod tests { assert_eq!(m.iso, Some(400)); } + fn compression_payload(levels: u8) -> Vec { + let mut b = vec![0u8; 52]; + b[0..2].copy_from_slice(&0xff00u16.to_be_bytes()); + b[2..4].copy_from_slice(&48u16.to_be_bytes()); + b[4..6].copy_from_slice(&0x100u16.to_be_bytes()); + for (at, value) in [(8, 6888u32), (12, 4546), (16, 3444), (20, 4546), (28, 216)] { + b[at..at + 4].copy_from_slice(&value.to_be_bytes()); + } + b[24] = 14; + b[25] = 0x40; + b[26] = levels; + b + } + + #[test] + fn compression_descriptor_retains_sensor_geometry_and_cfa() { + let b = compression_payload(0); + let h = Cr3Compression::parse(&b).unwrap(); + assert_eq!((h.width, h.height, h.tile_width, h.tile_height), (6888, 4546, 3444, 4546)); + assert_eq!((h.version, h.bit_depth, h.planes, h.cfa_pattern, h.encoding, h.levels, h.header_size), (0x100, 14, 4, 0, 0, 0, 216)); + let mut craw = compression_payload(3); + craw[25] = 0x41; + craw[27] = 0x80; + let h = Cr3Compression::parse(&craw).unwrap(); + assert_eq!((h.cfa_pattern, h.levels, h.tile_flags), (1, 3, 0x80)); + for len in 0..b.len() { + assert!(Cr3Compression::parse(&b[..len]).is_none()); + } + for (at, value) in [(24, 0), (24, 17), (25, 0), (25, 0x44)] { + let mut bad = b.clone(); + bad[at] = value; + assert!(Cr3Compression::parse(&bad).is_none()); + } + } + + #[test] + fn extended_descriptor_checks_optional_median_precision() { + let mut b = compression_payload(0x30); + b[32] = 0x80; + b.extend_from_slice(&33u32.to_be_bytes()); + b.resize(85, 0); + b[56] = 0xc0; + b[84] = 14; + assert_eq!(Cr3Compression::parse(&b).unwrap().median_bit_depth, Some(14)); + assert!(Cr3Compression::parse(&b[..84]).is_none()); + b[84] = 0; + assert!(Cr3Compression::parse(&b).is_none()); + } + + #[test] + fn raw_track_reads_distinct_active_area_and_default_crop() { + let mut area = vec![0u8; 48]; + for (at, value) in [ + (4, 6888u16), + (6, 4546), + (10, 2), + (16, 156), + (18, 158), + (20, 6875), + (22, 4537), + (24, 0), + (26, 0), + (28, 143), + (30, 4545), + (32, 144), + (34, 0), + (36, 6887), + (38, 45), + (40, 144), + (42, 46), + (44, 6887), + (46, 4545), + ] { + area[at..at + 2].copy_from_slice(&value.to_be_bytes()); + } + let cmp1 = bx(b"CMP1", &compression_payload(0)); + let iad1 = full(b"CDI1", &bx(b"IAD1", &area)); + let track = trak(craw(6888, 4546, &[cmp1, iad1].concat()), 4096, 32); + let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom"); + file.extend(bx(b"moov", &track)); + file.resize(4128, 0); + let c = parse_cr3(&file).unwrap(); + assert_eq!(c.tracks[0].compression(&file).unwrap().width, 6888); + let geometry = c.tracks[0].image_area(&file).unwrap(); + assert_eq!(geometry.crop, [156, 158, 6875, 4537]); + assert_eq!(geometry.active, Some([144, 46, 6887, 4545])); + assert_eq!(geometry.masked_left, [0, 0, 143, 4545]); + for len in 0..area.len() { + assert!(Cr3ImageArea::parse(&area[..len]).is_none()); + } + } + + #[test] + fn truncated_sample_table_does_not_read_the_next_box() { + let stbl = [ + full(b"stsd", &[1u32.to_be_bytes().to_vec(), craw(16, 16, &bx(b"CMP1", &compression_payload(0)))].concat()), + bx(b"stsz", &[]), + full(b"co64", &[1u32.to_be_bytes().to_vec(), 256u64.to_be_bytes().to_vec()].concat()), + ] + .concat(); + let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom"); + file.extend(bx(b"moov", &bx(b"trak", &bx(b"mdia", &bx(b"minf", &bx(b"stbl", &stbl)))))); + file.resize(512, 0); + assert!(parse_cr3(&file).unwrap().tracks.is_empty()); + } + + #[test] + fn timed_metadata_exposes_the_dynamic_maker_note() { + use lightcraft_tiff::{Value, tags as t}; + let exif = tiff_with(t::ISO_SPEED, Value::Short(vec![800])); + let maker = tiff_with(0x4001, Value::Short(vec![48, 100, 200])); + let item = |tag: u32, data: &[u8]| { + let mut b = ((data.len() + 8) as u32).to_le_bytes().to_vec(); + b.extend_from_slice(&tag.to_le_bytes()); + b.extend_from_slice(data); + b + }; + let items = [item(0x8769, &exif), item(0x927c, &maker)].concat(); + let mut sample = vec![0u8; 12]; + sample[0..4].copy_from_slice(&((12 + items.len()) as u32).to_le_bytes()); + sample[4..6].copy_from_slice(&8u16.to_le_bytes()); + sample.extend(items); + let container = Cr3 { tracks: vec![Cr3Track { kind: Cr3TrackKind::Other(*b"CTMD"), data: Some((0, sample.len())) }], ..Default::default() }; + let parts = container.timed_exif(&sample); + assert_eq!(parts.len(), 2); + assert_eq!((parts[0].record_type, parts[0].tag, parts[0].data), (8, 0x8769, exif.as_slice())); + assert_eq!((parts[1].record_type, parts[1].tag, parts[1].data), (8, 0x927c, maker.as_slice())); + for len in 0..sample.len() { + assert!(container.timed_exif(&sample[..len]).is_empty()); + } + for at in [0, 12] { + let mut corrupt = sample.clone(); + corrupt[at..at + 4].copy_from_slice(&u32::MAX.to_le_bytes()); + assert!(container.timed_exif(&corrupt).is_empty()); + corrupt[at..at + 4].copy_from_slice(&0u32.to_le_bytes()); + assert!(container.timed_exif(&corrupt).is_empty()); + } + } + #[test] fn hostile_input_never_panics() { let f = sample(); diff --git a/crates/meta/tests/robust.rs b/crates/meta/tests/robust.rs index 6386e7009..cd60e00a9 100644 --- a/crates/meta/tests/robust.rs +++ b/crates/meta/tests/robust.rs @@ -5,8 +5,8 @@ proptest! { #![proptest_config(ProptestConfig { cases: 1500, .. ProptestConfig::default() })] #[test] - fn random_containers_never_panic(mut data in proptest::collection::vec(any::(), 0..600), kind in 0usize..5) { - let heads: [&[u8]; 5] = [b"\xff\xd8\xff\xe1", b"\x89PNG\r\n\x1a\n", b"RIFF\0\0\0\0WEBP", b"II*\0", b"MM\0*"]; + fn random_containers_never_panic(mut data in proptest::collection::vec(any::(), 0..600), kind in 0usize..6) { + let heads: [&[u8]; 6] = [b"\xff\xd8\xff\xe1", b"\x89PNG\r\n\x1a\n", b"RIFF\0\0\0\0WEBP", b"II*\0", b"MM\0*", b"\0\0\0\x18ftypcrx "]; let h = heads[kind]; if data.len() >= h.len() { data[..h.len()].copy_from_slice(h); diff --git a/crates/pipeline/src/auto.rs b/crates/pipeline/src/auto.rs index c34d33110..45100a1ea 100644 --- a/crates/pipeline/src/auto.rs +++ b/crates/pipeline/src/auto.rs @@ -191,3 +191,28 @@ mod bw_tests { assert_eq!(auto_bw_mix(&grey, &SourceInfo::default(), &DevelopSettings::default()), [0.0; 8]); } } + +#[cfg(test)] +mod tint_tests { + use super::*; + + #[test] + fn auto_wb_and_picker_correct_green_with_positive_tint() { + // The picker delegates a sampled patch to this same auto_wb implementation. + for (rgb, sign) in [([0.18, 0.24, 0.18], 1.0), ([0.24, 0.18, 0.24], -1.0)] { + let img = Rgb32f::filled(16, 16, rgb); + let info = SourceInfo { raw: true, relative_wb: true, ..Default::default() }; + let (temp, tint) = auto_wb(&img, &info); + assert!(tint * sign > 0.0, "{rgb:?}: temp {temp}, tint {tint}"); + let mut s = DevelopSettings::default(); + s.wb.mode = lightcraft_develop::WbMode::Custom; + s.wb.temp = temp; + s.wb.tint = tint; + let mut corrected = img; + crate::local::white_balance(&mut corrected, &info, &s); + let p = corrected.get(0, 0); + let spread = p[0].max(p[1]).max(p[2]) - p[0].min(p[1]).min(p[2]); + assert!(spread < 0.003, "{rgb:?} -> {p:?}"); + } + } +} diff --git a/crates/pipeline/src/finish.rs b/crates/pipeline/src/finish.rs index f352004e0..6b422ee9a 100644 --- a/crates/pipeline/src/finish.rs +++ b/crates/pipeline/src/finish.rs @@ -190,6 +190,8 @@ pub struct FinishParams { pub w: usize, pub h: usize, pub px_per_long: f64, + /// The window's offset and the whole output's size (pixels): `(0, 0, w, h)` unless windowed. + pub view: [f32; 4], } impl FinishParams { @@ -255,6 +257,7 @@ impl FinishParams { w, h, px_per_long, + view: frame.view.map_or([0.0, 0.0, w as f32, h as f32], |v| [v.x as f32, v.y as f32, v.full_w as f32, v.full_h as f32]), } } } @@ -344,7 +347,9 @@ pub(crate) fn finish_with( let terms: Vec<[f32; MASK_TERMS]> = p.masks.iter().map(|m| mask_terms(&m.adjust)).collect(); let out_to_norm = fp.out_to_norm; let long = fp.ow.max(fp.oh); - let aspect = w as f32 / h as f32; + let [vx, vy, vw, vh] = fp.view; + // the vignette belongs to the whole output frame, not to the window being drawn + let aspect = vw / vh; let srgb = srgb_lut(); let mut out = vec![T::default(); w * h]; @@ -502,8 +507,8 @@ pub(crate) fn finish_with( // --- vignette (display linear, post-crop) if let Some(v) = vig { - let u = (x as f32 + 0.5) / w as f32 * 2.0 - 1.0; - let vv = (y as f32 + 0.5) / h as f32 * 2.0 - 1.0; + let u = (vx + x as f32 + 0.5) / vw * 2.0 - 1.0; + let vv = (vy + y as f32 + 0.5) / vh * 2.0 - 1.0; let sx = 1.0 + (aspect - 1.0) * v.aspect_mix; let sy = 1.0 + (1.0 / aspect - 1.0) * v.aspect_mix; let (ax, ay) = ((u * sx.max(1.0) / sx.max(sy)).abs(), (vv * sy.max(1.0) / sx.max(sy)).abs()); diff --git a/crates/pipeline/src/geometry.rs b/crates/pipeline/src/geometry.rs index af45526b3..c99bde452 100644 --- a/crates/pipeline/src/geometry.rs +++ b/crates/pipeline/src/geometry.rs @@ -31,6 +31,18 @@ pub struct Frame { pub flip_v: bool, /// Lens corrections / perspective (None = identity). pub warp: Option, + /// Set when this frame draws only a window of a larger output (see [`Frame::window`]). + pub view: Option, +} + +/// Where a windowed [`Frame`]'s pixels sit in the full output: its offset and the full output's +/// size, for effects defined by the whole frame (post-crop vignette). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ViewWindow { + pub x: f64, + pub y: f64, + pub full_w: f64, + pub full_h: f64, } impl Frame { @@ -62,6 +74,7 @@ impl Frame { flip_h: apply_crop && s.crop.flip_h, flip_v: apply_crop && s.crop.flip_v, warp: (!warp.is_identity()).then_some(warp), + view: None, }; if apply_crop && s.geometry.constrain_crop { f.constrain_to_image(); @@ -97,6 +110,30 @@ impl Frame { self.crop.rect = Rect::from_center(r.center(), r.width() * t, r.height() * t); } + /// Long edge, in pixels, of the whole output this frame draws (its own size unless it is a + /// window of a larger one): what size-relative effects (noise reduction, edge refinement) + /// must scale with, so a window matches the same pixels of the whole render. + pub fn output_long(&self, w: usize, h: usize) -> usize { + self.view.map_or(w.max(h), |v| v.full_w.max(v.full_h) as usize) + } + + /// This frame narrowed to `win` of its `full_w × full_h` output: the same mapping, but the + /// output is only that window (the crop rectangle shrinks to it; flips are as drawn). + pub fn window(&self, full_w: usize, full_h: usize, win: crate::PixelWindow) -> Frame { + let (fw, fh) = (full_w.max(1) as f64, full_h.max(1) as f64); + let (x0, x1) = (win.x as f64 / fw, (win.x + win.w) as f64 / fw); + let (y0, y1) = (win.y as f64 / fh, (win.y + win.h) as f64 / fh); + // flips happen on the output before it maps onto the crop: undo them for the range + let (x0, x1) = if self.flip_h { (1.0 - x1, 1.0 - x0) } else { (x0, x1) }; + let (y0, y1) = if self.flip_v { (1.0 - y1, 1.0 - y0) } else { (y0, y1) }; + let r = self.crop.rect; + let (rw, rh) = (r.width(), r.height()); + let mut f = self.clone(); + f.crop.rect = Rect { x0: r.x0 + x0 * rw, y0: r.y0 + y0 * rh, x1: r.x0 + x1 * rw, y1: r.y0 + y1 * rh }; + f.view = Some(ViewWindow { x: win.x as f64, y: win.y as f64, full_w: fw, full_h: fh }); + f + } + /// Add automatically estimated lateral CA (`[α_R, α_B]`, see [`crate::optics::estimate_lateral_ca`]). pub fn add_lateral_ca(&mut self, ca: [f64; 2]) { if ca == [0.0; 2] { diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 15fbd7f33..057fb42fa 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -99,6 +99,31 @@ pub struct RenderRequest { pub depth: OutputDepth, /// Soft proofing (CPU only; see [`Proof`]). pub proof: Option, + /// Render only this window of the output that `max_w × max_h` describes (a zoomed view): + /// the result is that window's pixels, as in the whole render. See [`PixelWindow`]. + pub window: Option, +} + +/// A window of the (virtual) full output, in its pixels: what a zoomed view needs, rendered +/// without materializing the whole frame. The output frame is the one `max_w × max_h` fits, after +/// crop, straighten and flips, as drawn. Out-of-range windows are clamped into it. Spatial stages +/// (clarity, dehaze, sharpening…) read their neighbours, so ask for a margin around what is kept. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PixelWindow { + pub x: usize, + pub y: usize, + pub w: usize, + pub h: usize, +} + +impl PixelWindow { + /// This window inside a `full_w × full_h` frame: at least one pixel, wholly inside. + pub fn clamped(self, full_w: usize, full_h: usize) -> PixelWindow { + let (fw, fh) = (full_w.max(1), full_h.max(1)); + let x = self.x.min(fw - 1); + let y = self.y.min(fh - 1); + PixelWindow { x, y, w: self.w.clamp(1, fw - x), h: self.h.clamp(1, fh - y) } + } } impl RenderRequest { @@ -112,6 +137,7 @@ impl RenderRequest { space: OutputSpace::Srgb, depth: OutputDepth::U8, proof: None, + window: None, } } } @@ -300,8 +326,17 @@ pub struct Plan<'a> { pub lin_key: u64, /// Red eye / pet eye corrections with their detected pupils, in output pixels. pub eyes: Vec, + /// The whole frame's dehaze airlight, for a windowed render (which can't see the whole frame). + pub fixed_air: Option, + /// A windowed render works on a larger window when spots reach into it (see + /// [`spots::window_for_reads`]); this is the requested window inside the rendered one, which + /// the result is cut to. + pub keep: Option, } +/// Long edge of the small render a windowed render estimates the whole frame's airlight from. +const AIRLIGHT_PROXY_EDGE: usize = 384; + /// Resolve `s` against `src` for `req` (see [`Plan`]). pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &RenderRequest) -> Plan<'a> { let mut settings: Cow<'a, DevelopSettings> = match profiles::effective(s) { @@ -309,10 +344,39 @@ pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &R Cow::Owned(o) => Cow::Owned(upright::resolve(src, info, &o).into_owned()), }; visualize::adjust_settings(req.overlay, &mut settings); + // a window must not choose a spot's source from the pixels it happens to hold: fix them + // from a fixed-size render of the whole frame first + if req.window.is_some() && settings.spots.iter().any(|sp| sp.source_offset.is_none()) { + let picked: Vec> = + settings.spots.iter().map(|sp| sp.source_offset.or_else(|| spots::pick_source(src, info, &settings, sp, None))).collect(); + for (sp, o) in settings.to_mut().spots.iter_mut().zip(picked) { + sp.source_offset = o; + } + } let s = &*settings; let frame = frame_for(src, info, s, req.apply_crop); - let (w, h) = frame.fit(req.max_w, req.max_h); - let px_per_long = frame.px_per_long(w); + let (full_w, full_h) = frame.fit(req.max_w, req.max_h); + // sizes and scale are those of the whole output; a window only narrows what is drawn + let px_per_long = frame.px_per_long(full_w); + let mut fixed_air = None; + let mut keep = None; + let (frame, w, h) = match req.window { + Some(win) => { + let win = win.clamped(full_w, full_h); + if s.section_enabled("effects") && (s.effects.dehaze != 0.0 || s.masks.iter().any(|m| m.adjust.dehaze != 0.0)) { + let k = (AIRLIGHT_PROXY_EDGE as f64 / full_w.max(full_h) as f64).min(1.0); + let (pw, ph) = (((full_w as f64 * k).round() as usize).max(1), ((full_h as f64 * k).round() as usize).max(1)); + fixed_air = Some(local::frame_airlight(src, info, s, &frame, pw, ph)); + } + // (spots grow the rendered window; only 8-bit renders are cut back to the request) + let work = if req.depth == OutputDepth::U8 { spots::window_for_reads(s, &frame, full_w, full_h, px_per_long, win) } else { win }; + if work != win { + keep = Some(PixelWindow { x: win.x - work.x, y: win.y - work.y, ..win }); + } + (frame.window(full_w, full_h, work), work.w, work.h) + } + None => (frame, full_w, full_h), + }; let src_long = src.width.max(src.height); let geo = hash_of((format!("{frame:?}"), w, h)); let (wb_t, wb_tint) = local::effective_wb(info, s); @@ -328,7 +392,7 @@ pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &R [d.nr_luminance, d.nr_detail, d.nr_color, d.nr_color_detail, d.nr_color_smoothness].map(f64::to_bits), src_long, )); - Plan { settings, frame, w, h, px_per_long, src_long, geo, lin_key, eyes } + Plan { settings, frame, w, h, px_per_long, src_long, geo, lin_key, eyes, fixed_air, keep } } /// Whether the scene-linear stage needs work only the CPU does (defringe, spot removal). @@ -365,6 +429,9 @@ enum Src<'a> { } fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, cache: Option<&StageCache>) -> Rendered { + // sections switched off with their eye render as if at their defaults (issue #316) + let effective = s.effective(); + let s: &DevelopSettings = &effective; // `Instant::now()` panics on wasm32-unknown-unknown: only read the clock when profiling. let lap = |what: &str, t: &mut Option| { if let Some(t) = t { @@ -398,7 +465,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, w.max(h)); + local::denoise(&mut img, s, src_long, frame.output_long(w, h)); Arc::new(img) } }; @@ -407,7 +474,10 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende Some(p) if p.key == lin_key => p, _ => local::Planes { key: lin_key, ..Default::default() }, }; - let prep = local::prepare(lin.clone(), s, frame, px_per_long, req.quality, &mut planes); + let mut prep = local::prepare(lin.clone(), s, frame, px_per_long, req.quality, &mut planes); + if let Some(a) = plan.fixed_air { + prep.air = a; + } lap("prepare", &mut t); if let Some((a, c)) = shared { c.put(CacheEntry { src: a.clone(), geo, sampled, lin: Some((lin_key, lin)), planes }); @@ -421,11 +491,19 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende } let image = finish::finish(&prep, s, frame, info, req.space, req.proof); lap("finish", &mut t); - let histogram = Histogram::of_srgb8(&image); + let cut = |i: &Rgba8| match plan.keep { + Some(k) => i.crop(k.x, k.y, k.w, k.h), + None => i.clone(), + }; + let histogram = match plan.keep { + Some(_) => Histogram::of_srgb8(&cut(&image)), + None => Histogram::of_srgb8(&image), + }; lap("histogram", &mut t); let mut image = image; let mask = overlay_alpha(req.overlay, &plan, &prep); visualize::apply(&mut image, req.overlay, &plan, mask.as_ref()); + let image = if plan.keep.is_some() { cut(&image) } else { image }; Rendered { image, histogram, deep: None } } @@ -507,3 +585,5 @@ mod tests; mod tests_geometry; #[cfg(test)] mod tests_local; +#[cfg(test)] +mod tests_window; diff --git a/crates/pipeline/src/local.rs b/crates/pipeline/src/local.rs index cedb7cd1a..61ec84820 100644 --- a/crates/pipeline/src/local.rs +++ b/crates/pipeline/src/local.rs @@ -328,6 +328,15 @@ pub(crate) fn prepare(img: Arc, s: &DevelopSettings, frame: &Frame, px_p Prepared { img, log_l, base, clarity_blur, texture_blur, dark, chroma_blur, air, masks, px_per_long } } +/// The airlight of the whole output frame, estimated on a small render of it (`proxy_w` px wide): +/// a windowed render must dehaze with the frame's airlight, not that of the pixels it holds. +pub(crate) fn frame_airlight(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, frame: &Frame, proxy_w: usize, proxy_h: usize) -> f32 { + let mut img = frame.sample(src, proxy_w, proxy_h); + white_balance(&mut img, info, s); + let sigma = (0.02 * frame.px_per_long(proxy_w)).max(1.0) as f32; + airlight(&gaussian(&img.map(dark_of), sigma)) +} + /// The airlight is estimated from every `AIRLIGHT_STEP`-th value of the dark channel. pub const AIRLIGHT_STEP: usize = 7; diff --git a/crates/pipeline/src/masks.rs b/crates/pipeline/src/masks.rs index a71667bc0..aae261c5a 100644 --- a/crates/pipeline/src/masks.rs +++ b/crates/pipeline/src/masks.rs @@ -60,7 +60,7 @@ pub fn evaluate_one(m: &Mask, frame: &Frame, w: usize, h: usize, img: &Rgb32f, l // Refine Edges: the mask's edges follow the photo's (window up to ~4 % of the long edge, // the width of a soft brush edge or gradient) let k = (m.refine / 100.0).clamp(0.0, 1.0) as f32; - let sigma = (0.04 * w.max(h) as f32 * k).max(1.0); + let sigma = (0.04 * frame.output_long(w, h) as f32 * k).max(1.0); let refined = guided_cross(log_l, &alpha, sigma, 0.02); for (a, r) in alpha.data.iter_mut().zip(&refined.data) { *a += (r - *a) * k.sqrt(); @@ -127,7 +127,7 @@ pub fn shape_alpha(shape: &MaskShape, frame: &Frame, w: usize, h: usize, img: &R } } MaskShape::ColorRange { samples, refine } => { - let tol = 0.04 + 0.16 * (*refine as f32 / 100.0); + let tol = color_range_tolerance(*refine); let samples: Vec<[f32; 3]> = samples.iter().map(|s| [s[0] as f32, s[1] as f32, s[2] as f32]).collect(); for (v, c) in out.data.iter_mut().zip(&img.data) { let lab = lightcraft_color::perceptual::oklab_from_2020(tonemap_for_select(c.map(|v| v * gain))); @@ -257,6 +257,21 @@ pub(crate) fn tonemap_for_select(c: [f32; 3]) -> [f32; 3] { c.map(|v| v / (1.0 + v)) } +/// Colour range: the OkLab distance (a, b at full weight, L at a quarter) at which a pixel leaves +/// the selection for a Refine amount of 0..100. A pixel is fully selected up to half of it and +/// fades out over the other half (CPU here, `mask.wgsl` kind 3; both take this value). +/// +/// Issue #157: `0.04 + 0.16·refine` selected the whole photo. Through [`tonemap_for_select`] a +/// slightly teal mid-grey (sRGB 120,150,155) and a neutral light grey (200,198,195) are only ~0.063 +/// apart — L 0.60 vs 0.72, a/b ≤ 0.02 — so at the default Refine 50 (full selection below 0.06) +/// the grey patch was 99 % selected, and at Refine 85 / 100 still most of it. A JND is ~0.01 on +/// this scale, so Refine 50 now holds a surface up to ~2 JND from its sample and drops everything +/// beyond ~4.5 (full to 0.0225, gone at 0.045); Refine 85 is gone at 0.066, past that grey patch; +/// Refine 100 reaches 0.075 and Refine 0 is the sample's own shade (full to 0.0075, gone at 0.015). +pub fn color_range_tolerance(refine: f64) -> f32 { + 0.015 + 0.06 * (refine / 100.0).clamp(0.0, 1.0) as f32 +} + /// A brush stroke resolved to output pixels: dab centres (spacing r/4 along the path), radius, /// hard-core radius, flow and density (0..1), and whether it erases. pub struct BrushDabs { @@ -321,11 +336,13 @@ pub fn auto_similarity(l: f32, ch: [f32; 3], rl: f32, rch: [f32; 3]) -> f32 { 1.0 - smooth(0.5, 1.0, (dl * dl + dc * dc).sqrt()) } -/// The output pixel a dab at `d` samples its reference colour from. +/// The output pixel a dab at `d` samples its reference colour from. The position is taken at f32 +/// precision (like the GPU kernel): a dab on a pixel boundary must not fall on either side of it +/// by an f64 rounding error, which depends on where the window of a zoomed view starts. #[inline] pub fn dab_pixel(d: Point, w: usize, h: usize) -> usize { - let x = (d.x.floor().max(0.0) as usize).min(w - 1); - let y = (d.y.floor().max(0.0) as usize).min(h - 1); + let x = ((d.x as f32).floor().max(0.0) as usize).min(w - 1); + let y = ((d.y as f32).floor().max(0.0) as usize).min(h - 1); y * w + x } @@ -598,4 +615,42 @@ mod tests { let (plain, sharp) = (step(&soft), step(&refined)); assert!(sharp > plain * 1.5 && sharp > 0.1, "the edge in the mask follows the photo's: {plain} → {sharp}"); } + + /// Issue #157: a colour-range sample of one flat patch must not select a clearly different + /// patch. The two patches are the ones from the report (sRGB 120,150,155 and 200,198,195), + /// sampled the way `mask.sampleColor` does (3×3 mean of the selection-space OkLab). + #[test] + fn color_range_keeps_to_the_sampled_colour() { + use lightcraft_color::{REC2020, SRGB, transfer::decode_srgb8}; + let (w, h) = (120usize, 40usize); + let m = SRGB.to_space(&REC2020); + let patch = |rgb: [u8; 3]| m.apply_f32(rgb.map(decode_srgb8)); + let (left, right) = (patch([120, 150, 155]), patch([200, 198, 195])); + let img = Rgb32f::from_fn(w, h, |x, _| if x < 60 { left } else { right }); + let log_l = img.map(crate::local::log_lum); + let f = Frame::new(w, h, &Default::default(), true); + let mut sample = [0f64; 3]; + for y in 19..=21 { + for x in 29..=31 { + let lab = lightcraft_color::perceptual::oklab_from_2020(tonemap_for_select(img.data[y * w + x])); + for k in 0..3 { + sample[k] += lab[k] as f64 / 9.0; + } + } + } + let alpha = |refine| { + let shape = MaskShape::ColorRange { samples: vec![sample], refine }; + let a = shape_alpha(&shape, &f, w, h, &img, &log_l, 0.0); + (a.get(30, 20), a.get(90, 20)) + }; + for refine in [0.0, 50.0, 85.0] { + let (l, r) = alpha(refine); + assert!(l > 0.95, "refine {refine}: sampled patch {l}"); + assert!(r < 0.05, "refine {refine}: other patch {r}"); + } + // Refine 100 reaches furthest (the grey is ~0.063 away, the fade ends at 0.075), but the + // other patch stays mostly out + let (l, r) = alpha(100.0); + assert!(l > 0.95 && r < 0.3, "refine 100: {l} vs {r}"); + } } diff --git a/crates/pipeline/src/optics.rs b/crates/pipeline/src/optics.rs index ad789fc87..f99a3cd80 100644 --- a/crates/pipeline/src/optics.rs +++ b/crates/pipeline/src/optics.rs @@ -11,7 +11,8 @@ //! dimensionless scales), so a 400 px preview and a full-size export are warped identically. //! //! Also here: automatic lateral chromatic aberration estimation ([`estimate_lateral_ca`]) and [`defringe`]. -//! Profile corrections use only lens data embedded in DNG files (`WarpRectilinear` / `FixVignetteRadial`). +//! Profile corrections use only lens data embedded in the files: DNG `WarpRectilinear` / `FixVignetteRadial`, and the +//! raw readers' equivalent of a camera's own correction (Panasonic / Leica RW2 distortion). use std::sync::Mutex; diff --git a/crates/pipeline/src/spots.rs b/crates/pipeline/src/spots.rs index f9edff72c..c50bcf799 100644 --- a/crates/pipeline/src/spots.rs +++ b/crates/pipeline/src/spots.rs @@ -106,6 +106,135 @@ pub fn pick_source( Some(Point::new(n1.x - n0.x, n1.y - n0.y)) } +/// The most a window grows to take in the spots that touch it. +const MAX_SPOT_SPAN: usize = if cfg!(target_arch = "wasm32") { 4096 } else { 8192 }; +/// Strokes with more dabs than this are not examined dab by dab (the render would be too slow anyway). +const MAX_DABS_CONSIDERED: usize = 200_000; + +/// `win` (a window of the `full_w × full_h` output of `frame`) grown to hold, for every spot (or +/// Auto Mask stroke) that reaches into it, the whole spot and the patch it reads from: [`apply`] works on the pixels it +/// is given, so a spot cut by the window's edge, or whose source lies outside it, would come out +/// different from the same spot in the whole render. Spots with an automatic source (`None`) +/// search up to 4.2 radii around themselves. Growth that would pass [`MAX_SPOT_SPAN`] pixels is not +/// done (the window comes back as asked; see [`window_for_reads_checked`] to know). +pub fn window_for_reads( + s: &lightcraft_develop::DevelopSettings, + frame: &Frame, + full_w: usize, + full_h: usize, + ppl: f64, + win: crate::PixelWindow, +) -> crate::PixelWindow { + window_for_reads_checked(s, frame, full_w, full_h, ppl, win).unwrap_or(win) +} + +/// [`window_for_reads`], or `None` when holding everything the window reads would take more than +/// [`MAX_SPOT_SPAN`] pixels along an axis: a caller that can show something else (the loupe's +/// whole-frame render) must, because the window alone would come out wrong. +pub fn window_for_reads_checked( + s: &lightcraft_develop::DevelopSettings, + frame: &Frame, + full_w: usize, + full_h: usize, + ppl: f64, + win: crate::PixelWindow, +) -> Option { + let spots = &s.spots; + let autos: Vec<&lightcraft_develop::BrushStroke> = s + .masks + .iter() + .filter(|m| m.visible) + .flat_map(|m| &m.components) + .filter_map(|c| match &c.shape { + lightcraft_develop::MaskShape::Brush { strokes } => Some(strokes), + _ => None, + }) + .flatten() + .filter(|st| st.auto_mask) + .collect(); + if spots.is_empty() && autos.is_empty() { + return Some(win); + } + let to_out = frame.norm_to_out(full_w, full_h); + let origin = to_out.apply(Point::new(0.0, 0.0)); + // (target box, source box) of each spot, in output pixels: [x0, y0, x1, y1] + let boxes: Vec<([f64; 4], [f64; 4])> = spots + .iter() + .filter_map(|spot| { + let r = (spot.size * ppl).max(1.0); + let pts: Vec<_> = spot.points.iter().map(|p| to_out.apply(*p)).collect(); + let first = pts.first()?; + let (mut x0, mut y0, mut x1, mut y1) = (first.x, first.y, first.x, first.y); + for p in &pts { + (x0, y0, x1, y1) = (x0.min(p.x), y0.min(p.y), x1.max(p.x), y1.max(p.y)); + } + // the box `apply` works in: the stroke plus a radius, plus a radius of padding + let t = [x0 - 2.0 * r, y0 - 2.0 * r, x1 + 2.0 * r, y1 + 2.0 * r]; + let s = match spot.source_offset { + Some(o) => { + let b = to_out.apply(o); + let (dx, dy) = (b.x - origin.x, b.y - origin.y); + [t[0] + dx, t[1] + dy, t[2] + dx, t[3] + dy] + } + None => [t[0] - 4.2 * r, t[1] - 4.2 * r, t[2] + 4.2 * r, t[3] + 4.2 * r], + }; + Some((t, s)).filter(|(t, s)| t.iter().chain(s).all(|v| v.is_finite())) + }) + .collect(); + // Auto Mask strokes weigh each dab by its similarity to the pixel under the dab's centre and + // refine the stroke over a couple of radii: each dab matters to pixels within 2.5 radii + let mut dab_boxes: Vec<[f64; 4]> = Vec::new(); + for st in autos { + let dabs = crate::masks::brush_dabs(st, frame, full_w, full_h); + let r = dabs.r; + if dabs.dabs.len() > MAX_DABS_CONSIDERED { + continue; + } + for d in &dabs.dabs { + let b = [d.x - 2.5 * r, d.y - 2.5 * r, d.x + 2.5 * r, d.y + 2.5 * r]; + if b.iter().all(|v| v.is_finite()) { + dab_boxes.push(b); + } + } + } + let (fw, fh) = (full_w as f64, full_h as f64); + let mut r = [win.x as f64, win.y as f64, (win.x + win.w) as f64, (win.y + win.h) as f64]; + let touches = |r: &[f64; 4], b: &[f64; 4]| b[0] < r[2] && b[2] > r[0] && b[1] < r[3] && b[3] > r[1]; + // a dab reaching the requested window needs its surroundings, once: the pixels that brings in + // are only read, not drawn on (unlike spots, whose patches other spots may read) + let asked = r; + for b in dab_boxes.iter().filter(|b| touches(&asked, b)) { + r = [r[0].min(b[0]), r[1].min(b[1]), r[2].max(b[2]), r[3].max(b[3])]; + } + r = [r[0].max(0.0), r[1].max(0.0), r[2].min(fw), r[3].min(fh)]; + if r[2] - r[0] > MAX_SPOT_SPAN as f64 || r[3] - r[1] > MAX_SPOT_SPAN as f64 { + return None; + } + for _ in 0..=boxes.len() { + let mut grown = r; + for (t, s) in &boxes { + if touches(&r, t) { + for b in [t, s] { + grown = [grown[0].min(b[0]), grown[1].min(b[1]), grown[2].max(b[2]), grown[3].max(b[3])]; + } + } + } + let grown = [grown[0].max(0.0), grown[1].max(0.0), grown[2].min(fw), grown[3].min(fh)]; + if grown == r { + break; + } + if grown[2] - grown[0] > MAX_SPOT_SPAN as f64 || grown[3] - grown[1] > MAX_SPOT_SPAN as f64 { + return None; + } + r = grown; + } + let (x, y) = (r[0].floor() as usize, r[1].floor() as usize); + Some( + crate::PixelWindow { x, y, w: (r[2].ceil() as usize).saturating_sub(x).max(1), h: (r[3].ceil() as usize).saturating_sub(y).max(1) } + .clamped(full_w, full_h), + ) +} + /// Apply all spots to `img` (output resolution). `ppl` = output pixels per long-edge unit. pub fn apply(img: &mut Rgb32f, spots: &[Spot], frame: &Frame, ppl: f64) { if spots.is_empty() { @@ -248,4 +377,95 @@ mod tests { assert!(img.get(25, 50)[0] > 0.8); assert!(img.get(5, 50)[0] < 0.2); } + + fn with_spots(spots: Vec) -> DevelopSettings { + DevelopSettings { spots, ..Default::default() } + } + + fn spot_at(x: f64, y: f64, size: f64, offset: Option) -> Spot { + Spot { points: vec![Point::new(x, y)], size, source_offset: offset, ..Default::default() } + } + + #[test] + fn a_window_grows_to_hold_the_spots_that_touch_it_and_their_sources() { + use crate::PixelWindow; + let frame = Frame::new(4000, 3000, &DevelopSettings::default(), true); + let ppl = frame.px_per_long(4000); + let win = PixelWindow { x: 1000, y: 1000, w: 400, h: 300 }; + // none: the window is left alone + assert_eq!(window_for_reads(&with_spots(vec![]), &frame, 4000, 3000, ppl, win), win); + // a spot far away is no business of this window + let far = spot_at(0.9, 0.9, 0.01, Some(Point::new(0.02, 0.0))); + assert_eq!(window_for_reads(&with_spots(vec![far]), &frame, 4000, 3000, ppl, win), win); + // a spot inside it, reading from 300 px to the right, widens it to the source + let near = spot_at(0.30, 0.38, 0.01, Some(Point::new(0.075, 0.0))); + let g = window_for_reads(&with_spots(vec![near]), &frame, 4000, 3000, ppl, win); + assert!(g.x <= win.x && g.x + g.w >= 1200 + 300 + 80 && g.y <= win.y && g.y + g.h >= win.y + win.h, "{g:?}"); + // a spot that only reaches in with its edge counts too + let edge = spot_at(0.24, 0.38, 0.01, Some(Point::new(-0.05, 0.0))); + assert!(window_for_reads(&with_spots(vec![edge]), &frame, 4000, 3000, ppl, win).x < win.x); + } + + #[test] + fn spot_windows_are_bounded_and_survive_hostile_spots() { + use crate::PixelWindow; + let frame = Frame::new(40_000, 30_000, &DevelopSettings::default(), true); + let ppl = frame.px_per_long(40_000); + let win = PixelWindow { x: 1000, y: 1000, w: 400, h: 300 }; + // a spot whose source is a long way off would need an enormous window: not granted + let wide = spot_at(0.03, 0.04, 0.01, Some(Point::new(0.6, 0.0))); + let g = window_for_reads(&with_spots(vec![wide]), &frame, 40_000, 30_000, ppl, win); + assert!(g.w <= MAX_SPOT_SPAN && g.h <= MAX_SPOT_SPAN, "{g:?}"); + for bad in [f64::NAN, f64::INFINITY, 1e300, -1e300] { + let s = spot_at(bad, bad, bad, Some(Point::new(bad, bad))); + let g = window_for_reads(&with_spots(vec![s]), &frame, 40_000, 30_000, ppl, win); + assert_eq!(g, win); + } + let empty = Spot { points: vec![], ..Default::default() }; + assert_eq!(window_for_reads(&with_spots(vec![empty]), &frame, 40_000, 30_000, ppl, win), win); + } + + // Given an Auto Mask stroke reaching into the window, the window grows to hold all of it + #[test] + fn a_window_grows_to_hold_an_auto_mask_stroke() { + use crate::PixelWindow; + use lightcraft_develop::{BrushStroke, Mask, MaskComponent, MaskOp, MaskShape}; + let frame = Frame::new(4000, 3000, &DevelopSettings::default(), true); + let ppl = frame.px_per_long(4000); + let win = PixelWindow { x: 1000, y: 1000, w: 400, h: 300 }; + let stroke = + |auto| BrushStroke { points: vec![Point::new(0.30, 0.38), Point::new(0.5, 0.38)], size: 0.01, auto_mask: auto, ..Default::default() }; + let mask = |auto| Mask { + components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes: vec![stroke(auto)] } }], + ..Default::default() + }; + let plain = DevelopSettings { masks: vec![mask(false)], ..Default::default() }; + assert_eq!(window_for_reads(&plain, &frame, 4000, 3000, ppl, win), win, "a plain stroke reads nothing around it"); + let auto = DevelopSettings { masks: vec![mask(true)], ..Default::default() }; + let g = window_for_reads(&auto, &frame, 4000, 3000, ppl, win); + // the dabs near the window, not the whole stroke (it runs on to x = 2000) + assert!(g.x <= 1000 && g.x + g.w > 1400 && g.x + g.w < 1700, "{g:?}"); + // a window at the far end of a long stroke does not grow back along all of it + let far = window_for_reads(&auto, &frame, 4000, 3000, ppl, PixelWindow { x: 1900, ..win }); + assert!(far.x > 1500, "{far:?}"); + // a hidden mask reads nothing + let hidden = DevelopSettings { masks: vec![Mask { visible: false, ..auto.masks[0].clone() }], ..Default::default() }; + assert_eq!(window_for_reads(&hidden, &frame, 4000, 3000, ppl, win), win); + } + + // Given a window that would have to grow past the cap, there is no answer (never a wrong one) + #[test] + fn a_window_that_cannot_hold_what_it_reads_is_refused() { + use crate::PixelWindow; + let frame = Frame::new(40_000, 30_000, &DevelopSettings::default(), true); + let ppl = frame.px_per_long(40_000); + let win = PixelWindow { x: 1000, y: 1000, w: 400, h: 300 }; + let far = with_spots(vec![spot_at(0.03, 0.04, 0.01, Some(Point::new(0.6, 0.0)))]); + assert_eq!(window_for_reads_checked(&far, &frame, 40_000, 30_000, ppl, win), None); + let near = with_spots(vec![spot_at(0.03, 0.04, 0.01, Some(Point::new(0.02, 0.0)))]); + assert!(window_for_reads_checked(&near, &frame, 40_000, 30_000, ppl, win).is_some()); + // a spot that doesn't touch the window is no business of it, however far it reads + let elsewhere = with_spots(vec![spot_at(0.9, 0.9, 0.01, Some(Point::new(-0.8, 0.0)))]); + assert_eq!(window_for_reads_checked(&elsewhere, &frame, 40_000, 30_000, ppl, win), Some(win)); + } } diff --git a/crates/pipeline/src/tests.rs b/crates/pipeline/src/tests.rs index 61e0de7c1..697a0f971 100644 --- a/crates/pipeline/src/tests.rs +++ b/crates/pipeline/src/tests.rs @@ -237,6 +237,35 @@ fn exposure_after_spatial_filters_equals_exposing_the_source() { assert!(d <= 2, "max difference {d}"); } +/// 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] +fn sections_switched_off_render_as_defaults() { + let src = Rgb32f::from_fn(32, 24, |x, y| [0.05 + x as f32 / 40.0, 0.1 + y as f32 / 30.0, 0.3]); + let info = SourceInfo::default(); + let req = RenderRequest::fit(32, 24); + let plain = render(&src, &info, &DevelopSettings::default(), &req).image; + let mut s = DevelopSettings::default(); + s.light.exposure = 1.5; + s.light.contrast = 40.0; + s.color.saturation = -60.0; + s.color.vibrance = 30.0; + s.detail.sharpen_amount = 120.0; + let edited = render(&src, &info, &s, &req).image; + assert_ne!(edited.data, plain.data); + for section in ["light", "color", "detail"] { + s.set_section_enabled(section, false); + } + assert_eq!(render(&src, &info, &s, &req).image.data, plain.data, "every edited section off = the unedited photo"); + // one section back on brings back only its own adjustments + s.set_section_enabled("light", true); + let mut light_only = DevelopSettings::default(); + light_only.light.exposure = 1.5; + light_only.light.contrast = 40.0; + assert_eq!(render(&src, &info, &s, &req).image.data, render(&src, &info, &light_only, &req).image.data); + assert!(matches!(DevelopSettings::default().effective(), std::borrow::Cow::Borrowed(_)), "nothing is copied when every section is on"); +} + #[test] fn calibration_shifts_colours_but_keeps_greys() { let info = SourceInfo { raw: true, ..Default::default() }; @@ -330,3 +359,28 @@ fn soft_proof_maps_into_the_proof_gamut_and_flags_what_does_not_fit() { assert!(blue(&pro) > 0); assert!(red(&pro) < n, "ProPhoto holds more than sRGB"); } + +#[test] +fn tint_negative_is_green_and_positive_is_magenta() { + use lightcraft_develop::WbMode; + let src = Rgb32f::filled(16, 16, [0.18; 3]); + // Rendered images, uncalibrated RAW and calibrated RAW with a nonzero As Shot tint. + for info in [ + SourceInfo::default(), + SourceInfo { raw: true, relative_wb: true, ..Default::default() }, + SourceInfo { raw: true, as_shot_temp: 4200.0, as_shot_tint: 15.0, ..Default::default() }, + ] { + let mut s = DevelopSettings::default(); + let neutral = render(&src, &info, &s, &RenderRequest::fit(16, 16)).image.get(8, 8); + assert!((neutral[0] as i16 - neutral[1] as i16).abs() <= 1); + assert!((neutral[2] as i16 - neutral[1] as i16).abs() <= 1); + s.wb.mode = WbMode::Custom; + s.wb.temp = info.as_shot_temp; + for delta in [-50.0, 50.0] { + s.wb.tint = info.as_shot_tint + delta; + let p = render(&src, &info, &s, &RenderRequest::fit(16, 16)).image.get(8, 8); + let magenta = (p[0] as f64 + p[2] as f64) / 2.0 - p[1] as f64; + assert!(magenta * delta > 100.0, "delta {delta} must follow the green/magenta track, got {p:?}"); + } + } +} diff --git a/crates/pipeline/src/tests_window.rs b/crates/pipeline/src/tests_window.rs new file mode 100644 index 000000000..0ed5e81e1 --- /dev/null +++ b/crates/pipeline/src/tests_window.rs @@ -0,0 +1,343 @@ +//! Region rendering (issue #323): a window of the virtual full-size output is rendered on its own +//! and must match the same pixels of the full render, so zoomed views can be sharp at any zoom +//! without materializing the whole frame. + +use lightcraft_develop::{DevelopSettings, controls}; +use lightcraft_geom::{Orientation, Rect}; +use lightcraft_raster::{Rgb32f, Rgba8}; + +use crate::{PixelWindow, RenderRequest, SourceInfo, render}; + +const W: usize = 960; +const H: usize = 640; +/// Context rendered around the pixels a caller keeps (spatial filters read their neighbours). +const HALO: usize = 64; + +fn scene() -> Rgb32f { + lightcraft_scenes::demo_library()[0].render(480, 320) +} + +fn full(s: &DevelopSettings) -> Rgba8 { + render(&scene(), &SourceInfo::default(), s, &RenderRequest::fit(W, H)).image +} + +fn window(s: &DevelopSettings, win: PixelWindow) -> Rgba8 { + let req = RenderRequest { window: Some(win), ..RenderRequest::fit(W, H) }; + render(&scene(), &SourceInfo::default(), s, &req).image +} + +/// Largest per-channel difference and mean absolute difference between `win` (rendered for +/// `at`, kept `HALO` px in from its edges) and the same pixels of `full`. +fn compare(full: &Rgba8, win: &Rgba8, at: PixelWindow) -> (u8, f64) { + compare_inset(full, win, at, HALO) +} + +/// [`compare`] keeping `inset` px in from the window's edges. +fn compare_inset(full: &Rgba8, win: &Rgba8, at: PixelWindow, inset: usize) -> (u8, f64) { + let (mut max, mut sum, mut n) = (0u8, 0f64, 0f64); + for y in inset..at.h - inset { + for x in inset..at.w - inset { + let (a, b) = (full.get(at.x + x, at.y + y), win.get(x, y)); + for k in 0..3 { + let d = a[k].abs_diff(b[k]); + max = max.max(d); + sum += d as f64; + n += 1.0; + } + } + } + (max, sum / n) +} + +fn check(name: &str, s: &DevelopSettings, win: PixelWindow, max_ok: u8, mean_ok: f64) { + let (f, w) = (full(s), window(s, win)); + assert_eq!((w.width, w.height), (win.w, win.h), "{name}: the window's own size"); + let (max, mean) = compare(&f, &w, win); + assert!(max <= max_ok && mean <= mean_ok, "{name}: max {max} (≤ {max_ok}), mean {mean:.3} (≤ {mean_ok})"); +} + +const MID: PixelWindow = PixelWindow { x: 300, y: 180, w: 400, h: 300 }; + +// Given the window is the whole output, then it is the full render, byte for byte +#[test] +fn the_whole_frame_as_a_window_is_the_full_render() { + let s = DevelopSettings::default(); + let f = full(&s); + let w = window(&s, PixelWindow { x: 0, y: 0, w: f.width, h: f.height }); + assert_eq!(f.data, w.data); +} + +// Given default settings, a window in the middle matches the full render +#[test] +fn a_window_matches_the_full_render() { + check("default", &DevelopSettings::default(), MID, 1, 0.1); +} + +// Given a window at each corner, it matches too (edges of the frame are not special) +#[test] +fn windows_at_the_corners_match() { + let s = DevelopSettings::default(); + for (x, y) in [(0, 0), (W - 400, 0), (0, H - 300), (W - 400, H - 300)] { + check("corner", &s, PixelWindow { x, y, w: 400, h: 300 }, 1, 0.1); + } +} + +// Given crop, straighten and flip, the window still lands on the same pixels +#[test] +fn a_cropped_straightened_flipped_view_matches() { + let mut s = DevelopSettings::default(); + s.crop.geometry.rect = Rect { x0: 0.1, y0: 0.15, x1: 0.85, y1: 0.9 }; + s.crop.geometry.angle = 4.0; + s.crop.flip_h = true; + check("crop+angle+flipH", &s, MID, 3, 0.3); + s.crop.flip_h = false; + s.crop.flip_v = true; + check("crop+angle+flipV", &s, MID, 3, 0.3); +} + +// Given a rotated photo, the same +#[test] +fn rotated_and_mirrored_orientations_match() { + for o in [Orientation::Rotate90, Orientation::Rotate270, Orientation::FlipH, Orientation::Transpose] { + let s = DevelopSettings { orientation: o, ..Default::default() }; + let f = full(&s); + let win = PixelWindow { x: f.width / 4, y: f.height / 4, w: f.width / 2, h: f.height / 2 }; + check(&format!("{o:?}"), &s, win, 3, 0.3); + } +} + +// Given a post-crop vignette, the window shows the vignette of the whole frame, not its own +#[test] +fn the_vignette_belongs_to_the_frame_not_the_window() { + let mut s = DevelopSettings::default(); + controls::set(&mut s, "vignette.amount", -90.0); + controls::set(&mut s, "vignette.midpoint", 10.0); + // the vignette is really there: the corner is much darker than without it + let (with, without) = (full(&s), full(&DevelopSettings::default())); + let corner = |i: &Rgba8| i.get(W - 20, H - 20)[1] as i32; + assert!(corner(&without) - corner(&with) > 30, "{} vs {}", corner(&without), corner(&with)); + // …and a window over that corner shows it, at the same place + check("vignette", &s, PixelWindow { x: W - 400, y: H - 300, w: 400, h: 300 }, 2, 0.2); + check("vignette middle", &s, MID, 2, 0.2); +} + +// Given spatial adjustments, the window agrees away from its halo +#[test] +fn spatial_adjustments_agree_inside_the_halo() { + for (id, v) in + [("effects.clarity", 70.0), ("effects.dehaze", 40.0), ("detail.sharpenAmount", 80.0), ("light.shadows", 60.0), ("effects.texture", 60.0)] + { + let mut s = DevelopSettings::default(); + controls::set(&mut s, id, v); + check(id, &s, MID, 6, 0.8); + } +} + +// Given a huge virtual frame (48000 px: an 800 % zoom), only the window is rendered +#[test] +fn a_window_of_a_huge_frame_is_cheap() { + let req = RenderRequest { window: Some(PixelWindow { x: 20_000, y: 12_000, w: 256, h: 192 }), ..RenderRequest::fit(48_000, 32_000) }; + let r = render(&scene(), &SourceInfo::default(), &DevelopSettings::default(), &req); + assert_eq!((r.image.width, r.image.height), (256, 192)); +} + +// Hostile windows (from UI state) are clamped into the frame, never a panic or a huge buffer +#[test] +fn windows_outside_or_degenerate_are_clamped() { + let s = DevelopSettings::default(); + for win in [ + PixelWindow { x: W + 500, y: 5, w: 100, h: 100 }, + PixelWindow { x: 0, y: 0, w: 0, h: 0 }, + PixelWindow { x: usize::MAX, y: usize::MAX, w: usize::MAX, h: usize::MAX }, + PixelWindow { x: 900, y: 600, w: 5000, h: 5000 }, + ] { + let r = window(&s, win); + assert!(r.width >= 1 && r.height >= 1 && r.width <= W && r.height <= H, "{win:?}: {}×{}", r.width, r.height); + } +} + +/// A textured, noisy source: what noise reduction and edge refinement act on. +fn noisy_scene() -> Rgb32f { + let base = scene(); + Rgb32f::from_fn(base.width, base.height, |x, y| { + let mut v = (x as u32).wrapping_mul(0x8da6_b343) ^ (y as u32).wrapping_mul(0xd816_3841); + v ^= v >> 13; + v = v.wrapping_mul(0x5bd1_e995); + v ^= v >> 15; + let n = 1.0 + 0.25 * ((v & 0xffff) as f32 / 32768.0 - 1.0); + base.data[y * base.width + x].map(|c| c * n) + }) +} + +fn check_noisy(name: &str, s: &DevelopSettings, win: PixelWindow, max_ok: u8, mean_ok: f64) { + let src = noisy_scene(); + let f = render(&src, &SourceInfo::default(), s, &RenderRequest::fit(W, H)).image; + let w = render(&src, &SourceInfo::default(), s, &RenderRequest { window: Some(win), ..RenderRequest::fit(W, H) }).image; + let (max, mean) = compare(&f, &w, win); + assert!(max <= max_ok && mean <= mean_ok, "{name}: max {max} (≤ {max_ok}), mean {mean:.3} (≤ {mean_ok})"); +} + +// Given noise reduction, its strength follows the whole frame's size, not the window's +#[test] +fn noise_reduction_does_not_depend_on_the_window() { + for (id, v) in [("detail.nrLuminance", 80.0), ("detail.nrColor", 80.0)] { + let mut s = DevelopSettings::default(); + controls::set(&mut s, id, v); + check_noisy(id, &s, MID, 3, 0.15); + } +} + +// Given a mask with Refine Edges, the refinement width follows the whole frame's size +#[test] +fn mask_edge_refinement_does_not_depend_on_the_window() { + use lightcraft_develop::{LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape}; + use lightcraft_geom::Point; + let shape = MaskShape::Linear { start: Point::new(0.45, 0.5), end: Point::new(0.55, 0.5) }; + let mut s = DevelopSettings::default(); + s.masks.push(Mask { + id: 1, + components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }], + adjust: LocalAdjustments { exposure: 1.5, ..Default::default() }, + refine: 100.0, + ..Default::default() + }); + check_noisy("refine", &s, MID, 3, 0.2); +} + +// ---- 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 +/// filters and the dark channel have something to be wrong about. +fn blocks() -> Rgb32f { + Rgb32f::from_fn(600, 400, |x, y| { + let cell = |size: usize, k: usize| ((x / size * 7 + y / size * 13 + k) % 5) as f32 / 4.0; + let l = 0.02 + 0.5 * cell(97, 1) + 0.25 * cell(31, 2) + 0.1 * cell(11, 3); + [l * 1.1, l, l * 0.8] + }) +} + +const BIG_W: usize = 7680; +const BIG_H: usize = 5120; + +/// A window of a 7680 × 5120 frame against the same pixels of the whole render. The wide stages +/// (dehaze, clarity, highlights/shadows base) have XX/// can't contain them, so they come from the whole frame at reduced size. +fn check_big(name: &str, s: &DevelopSettings, max_ok: u8, mean_ok: f64) { + let src = blocks(); + let f = render(&src, &SourceInfo::default(), s, &RenderRequest::fit(BIG_W, BIG_H)).image; + let win = PixelWindow { x: 3000, y: 1800, w: 512, h: 384 }; + let w = render(&src, &SourceInfo::default(), s, &RenderRequest { window: Some(win), ..RenderRequest::fit(BIG_W, BIG_H) }).image; + let (max, mean) = compare(&f, &w, win); + assert!(max <= max_ok && mean <= mean_ok, "{name}: max {max} (≤ {max_ok}), mean {mean:.3} (≤ {mean_ok})"); +} + +#[test] +fn dehaze_at_depth_matches_the_whole_render() { + let mut s = DevelopSettings::default(); + controls::set(&mut s, "effects.dehaze", 60.0); + check_big("dehaze", &s, 6, 0.6); +} + +#[test] +fn clarity_at_depth_matches_the_whole_render() { + let mut s = DevelopSettings::default(); + controls::set(&mut s, "effects.clarity", 80.0); + check_big("clarity", &s, 6, 0.6); +} + +#[test] +fn highlights_and_shadows_at_depth_match_the_whole_render() { + let mut s = DevelopSettings::default(); + controls::set(&mut s, "light.shadows", 80.0); + controls::set(&mut s, "light.highlights", -80.0); + check_big("shadows+highlights", &s, 6, 0.6); +} + +// ---- Spot removal reads outside the spot ------------------------------------------------------- + +/// Like [`check`], over every pixel of the window (a spot at its edge must be right too). +fn check_whole(name: &str, s: &DevelopSettings, win: PixelWindow, max_ok: u8, mean_ok: f64) { + let (f, w) = (full(s), window(s, win)); + let (max, mean) = compare_inset(&f, &w, win, 0); + assert_eq!((w.width, w.height), (win.w, win.h), "{name}: the window's own size"); + assert!(max <= max_ok && mean <= mean_ok, "{name}: max {max} (≤ {max_ok}), mean {mean:.3} (≤ {mean_ok})"); +} + +fn with_spot(mode: lightcraft_develop::SpotMode, at: (f64, f64), offset: Option<(f64, f64)>) -> DevelopSettings { + use lightcraft_develop::Spot; + use lightcraft_geom::Point; + let mut s = DevelopSettings::default(); + s.spots.push(Spot { + mode, + points: vec![Point::new(at.0, at.1)], + size: 0.04, + feather: 30.0, + opacity: 100.0, + source_offset: offset.map(|(x, y)| Point::new(x, y)), + }); + s +} + +// Given a spot whose source patch lies outside the window, the window still shows it healed +#[test] +fn a_spot_with_its_source_outside_the_window_matches_the_whole_render() { + use lightcraft_develop::SpotMode; + // the target is at x ≈ 0.36 of the frame; its source is 0.12 of the width to the right + for mode in [SpotMode::Clone, SpotMode::Heal] { + let s = with_spot(mode, (0.40, 0.5), Some((0.12, 0.05))); + // a window that holds the target (x ≈ 384) but not the source (x ≈ 499..) + check_whole(&format!("{mode:?}"), &s, PixelWindow { x: 250, y: 200, w: 200, h: 200 }, 1, 0.1); + } +} + +// Given a spot just outside the window whose feathered edge reaches in, the window shows it +#[test] +fn a_spot_overlapping_the_window_edge_matches_the_whole_render() { + let s = with_spot(lightcraft_develop::SpotMode::Heal, (0.30, 0.5), Some((-0.1, 0.0))); + check_whole("edge", &s, PixelWindow { x: 300, y: 200, w: 240, h: 240 }, 1, 0.1); +} + +// Given a spot with an automatic source, two windows over the same spot choose the same source +#[test] +fn an_automatic_spot_source_does_not_depend_on_the_window() { + let s = with_spot(lightcraft_develop::SpotMode::Heal, (0.5, 0.5), None); + // windows so tight that the candidate sources can't all be inside them + let (a, b) = (PixelWindow { x: 430, y: 280, w: 110, h: 90 }, PixelWindow { x: 450, y: 290, w: 110, h: 90 }); + let (ia, ib) = (window(&s, a), window(&s, b)); + let (mut max, mut n) = (0u8, 0); + for y in 0..80 { + for x in 0..90 { + let (p, q) = (ia.get(x + 20, y + 10), ib.get(x, y)); + for k in 0..3 { + max = max.max(p[k].abs_diff(q[k])); + n += 1; + } + } + } + assert!(n > 0 && max <= 2, "overlapping windows disagree by up to {max}"); +} + +// Given Auto Mask strokes that run in and out of the window, the window shows the whole render's mask +#[test] +fn auto_mask_strokes_match_the_whole_render() { + use lightcraft_develop::{BrushStroke, LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape}; + use lightcraft_geom::Point; + let st = |pts: &[(f64, f64)], auto_mask, erase| BrushStroke { + points: pts.iter().map(|p| Point::new(p.0, p.1)).collect(), + size: 0.06, + feather: 40.0, + flow: 80.0, + auto_mask, + erase, + ..Default::default() + }; + let strokes = vec![st(&[(0.2, 0.55), (0.5, 0.6), (0.8, 0.5)], true, false), st(&[(0.5, 0.58)], true, true)]; + let s = DevelopSettings { + masks: vec![Mask { + components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes } }], + adjust: LocalAdjustments { exposure: 1.0, saturation: -50.0, ..Default::default() }, + ..Default::default() + }], + ..Default::default() + }; + check_noisy("auto mask", &s, PixelWindow { x: 400, y: 300, w: 300, h: 250 }, 2, 0.1); +} diff --git a/crates/raw/Cargo.toml b/crates/raw/Cargo.toml index ec7c7f7d1..a40fcc43b 100644 --- a/crates/raw/Cargo.toml +++ b/crates/raw/Cargo.toml @@ -24,3 +24,5 @@ zune-core = { workspace = true } [dev-dependencies] jpeg-encoder = { workspace = true } proptest = { workspace = true } +# Pinned CC0 corpus identities; not a product dependency. +sha2 = { version = "0.10", default-features = false, features = ["std"] } diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index f67649de8..d63e52544 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -12,11 +12,11 @@ //! look tables and tone curve. //! //! Formats: DNG (uncompressed, lossless JPEG, lossy JPEG (Smart Previews), Deflate incl. floating point, tiled/stripped, CFA and LinearRaw), -//! Canon CR2, Nikon NEF/NRW (uncompressed, Huffman lossless / lossy compressed), Sony ARW (uncompressed, ARW2, lossless), Fujifilm RAF (uncompressed Bayer -//! and X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format: compressed 4 and 6, the prefix-coded strips of 8, +//! Canon CR2 / CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), Nikon NEF/NRW (uncompressed, Huffman lossless / lossy compressed), Sony ARW (uncompressed, ARW2, lossless), Fujifilm RAF (uncompressed Bayer +//! and X-Trans, lossless and lossy compressed), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format: compressed 4 and 6, the prefix-coded strips of 8, //! packed 2/5/7, the 16-bit words of the oldest bodies), Pentax PEF (uncompressed, Huffman), Olympus ORF (uncompressed). -//! [`embedded_preview`] covers all of them plus CR3. Variants we can't decode yet (Nikon "lossy after split" NEF, -//! compressed ORF/RAF, CR3) return [`RawError::Unsupported`]; each vendor module documents its sources +//! [`embedded_preview`] covers these containers' JPEG previews. Variants we can't decode yet (Nikon "lossy after split" NEF, +//! compressed ORF, CR3 unverified marker families / C-RAW configurations) return [`RawError::Unsupported`]; each vendor module documents its sources //! (public specifications, tag-name documentation, black-box analysis of CC0 samples) and gaps. Non-DNG files carry no //! colour matrix: [`color`] falls back to a documented neutral model. The decoders never panic on malformed input. #![forbid(unsafe_code)] @@ -43,7 +43,7 @@ pub use lightcraft_geom::Orientation; pub use lightcraft_meta::Metadata; pub use lightcraft_raster::Rgb32f; pub use opcodes::{Opcode, OpcodeLists}; -pub use preview::embedded_preview; +pub use preview::{PreviewColorSpace, embedded_preview, embedded_preview_color_space}; use lightcraft_color::Xy; use lightcraft_tiff::{Tiff, TiffError}; @@ -83,6 +83,12 @@ pub enum RawFormat { Rw2, Pef, Srw, + /// Canon CRW (CIFF heap), the bodies before the CR2 era. + Crw, + /// Minolta MRW (`\0MRM` block container). + Mrw, + /// Sigma / Foveon X3F (`FOVb`). + X3f, /// Another TIFF-based raw (3FR, IIQ, ERF, KDC, DCR, MOS, …). OtherTiff, } @@ -92,7 +98,15 @@ impl RawFormat { pub fn is_supported(self) -> bool { matches!( self, - RawFormat::Dng | RawFormat::Cr2 | RawFormat::Nef | RawFormat::Nrw | RawFormat::Arw | RawFormat::Raf | RawFormat::Rw2 | RawFormat::Pef + RawFormat::Dng + | RawFormat::Cr2 + | RawFormat::Cr3 + | RawFormat::Nef + | RawFormat::Nrw + | RawFormat::Arw + | RawFormat::Raf + | RawFormat::Rw2 + | RawFormat::Pef ) } } @@ -111,6 +125,15 @@ pub fn probe(bytes: &[u8]) -> Option { if bytes.starts_with(b"IIU\0") { return Some(RawFormat::Rw2); } + if bytes.starts_with(b"\0MRM") { + return Some(RawFormat::Mrw); + } + if bytes.starts_with(b"FOVb") { + return Some(RawFormat::X3f); + } + if bytes.len() >= 14 && bytes.starts_with(b"II") && &bytes[6..14] == b"HEAPCCDR" { + return Some(RawFormat::Crw); + } if bytes.len() >= 10 && bytes.starts_with(b"II*\0") && &bytes[8..10] == b"CR" { return Some(RawFormat::Cr2); } @@ -122,10 +145,7 @@ pub fn probe(bytes: &[u8]) -> Option { return Some(RawFormat::Dng); } let make = t.find(lightcraft_tiff::tags::MAKE).and_then(|e| e.value.as_str()).unwrap_or_default().to_ascii_uppercase(); - let has_cfa = t.all_ifds().iter().any(|i| { - i.u16(lightcraft_tiff::tags::PHOTOMETRIC) == Some(lightcraft_tiff::tags::photometric::CFA) - || i.u16(lightcraft_tiff::tags::COMPRESSION).is_some_and(|c| c == 34713 || c == 32767 || c == 32769 || c == 32770) - }); + let has_cfa = has_raw_ifd(&t); if make.starts_with("CANON") && t.ifds.len() >= 4 && t.ifds[3].u16(lightcraft_tiff::tags::COMPRESSION) == Some(6) { return Some(RawFormat::Cr2); } @@ -141,12 +161,103 @@ pub fn probe(bytes: &[u8]) -> Option { if make.starts_with("SAMSUNG") { return Some(RawFormat::Srw); } - if has_cfa { + if has_cfa || thumbnail_shell(&t, bytes.len()).is_some() || is_preview_container(ifd0) { return Some(RawFormat::OtherTiff); } None } +/// Whether some IFD is marked as raw: CFA photometric, or a raw-only compression value (99 is not a +/// registered TIFF compression; Leaf MOS files use it for their tiled 16-bit lossless-JPEG raw). +fn has_raw_ifd(t: &Tiff) -> bool { + t.all_ifds().iter().any(|i| { + i.u16(lightcraft_tiff::tags::PHOTOMETRIC) == Some(lightcraft_tiff::tags::photometric::CFA) + || i.u16(lightcraft_tiff::tags::COMPRESSION).is_some_and(|c| c == 34713 || c == 32767 || c == 32769 || c == 32770 || c == 99) + }) +} + +/// IFD0 that is not an image at all: no `ImageWidth` / `ImageLength`, but a JPEG preview pointer or +/// `SubIFDs` (the layout of Kodak KDC files, whose pictures sit in private tags). A plain TIFF reader +/// rejects such a file as malformed even though it carries a preview. +fn is_preview_container(ifd0: &lightcraft_tiff::Ifd) -> bool { + use lightcraft_tiff::tags::{IMAGE_LENGTH, IMAGE_WIDTH, JPEG_INTERCHANGE_FORMAT, SUB_IFDS}; + !ifd0.contains(IMAGE_WIDTH) && !ifd0.contains(IMAGE_LENGTH) && (ifd0.contains(JPEG_INTERCHANGE_FORMAT) || ifd0.contains(SUB_IFDS)) +} + +/// A TIFF whose first image is only a reduced copy of a picture stored somewhere else. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct ThumbnailShell { + /// Size of the first image (IFD0). + pub thumb: (u32, u32), + /// Size of the picture it stands in for. + pub full: (u32, u32), +} + +/// Some raw files are a TIFF "shell" around a private block: IFD0 holds a small RGB preview and nothing +/// in the TIFF structure marks the real image as raw (no CFA photometric, no raw compression). The +/// structure still gives them away, with a generic rule that names no maker or model: the file +/// declares a picture at least 4x larger than IFD0 in both directions, and either +/// - a `SubIFDs` child of IFD0 is that bigger image (an 8-bit grey mosaic in one such file), or +/// - the Exif `PixelXDimension` / `PixelYDimension` say so *and* the file holds enough bytes outside +/// every IFD's strips and tiles to carry that many pixels at one bit each (a downsized copy that kept +/// its old Exif size has no such block). +/// +/// A multi-page TIFF is not caught (its pages are the IFD chain, not `SubIFDs`), nor a pyramid whose +/// IFD0 is the full image. +fn thumbnail_shell(t: &Tiff, len: usize) -> Option { + use lightcraft_tiff::tags::{IMAGE_LENGTH, IMAGE_WIDTH, PIXEL_X_DIMENSION, PIXEL_Y_DIMENSION}; + let ifd0 = t.ifds.first()?; + let (w0, h0) = (ifd0.u32(IMAGE_WIDTH)?, ifd0.u32(IMAGE_LENGTH)?); + if w0 == 0 || h0 == 0 { + return None; + } + let much_bigger = |(w, h): (u32, u32)| u64::from(w) >= 4 * u64::from(w0) && u64::from(h) >= 4 * u64::from(h0); + let shell = |full| Some(ThumbnailShell { thumb: (w0, h0), full }); + let mut best: Option<(u32, u32)> = None; + for s in &ifd0.sub_ifds { + if s.image().is_ok() + && let (Some(w), Some(h)) = (s.u32(IMAGE_WIDTH), s.u32(IMAGE_LENGTH)) + && much_bigger((w, h)) + && best.is_none_or(|(bw, bh)| u64::from(w) * u64::from(h) > u64::from(bw) * u64::from(bh)) + { + best = Some((w, h)); + } + } + if let Some(full) = best { + return shell(full); + } + let exif = t.exif()?; + let full = (exif.u32(PIXEL_X_DIMENSION)?, exif.u32(PIXEL_Y_DIMENSION)?); + if !much_bigger(full) { + return None; + } + // bytes covered by image data (strips and tiles shared between IFDs, e.g. IFD0 and its thumbnail, count once) + let mut seen = std::collections::BTreeMap::new(); + for ifd in t.all_ifds() { + if let Ok(info) = ifd.image() { + for c in info.chunks(len as u64) { + seen.insert(c.offset, c.len); + } + } + } + let covered = seen.values().fold(0u64, |a, &l| a.saturating_add(l)); + let spare = (len as u64).saturating_sub(covered); + (spare.saturating_mul(8) >= u64::from(full.0) * u64::from(full.1)).then_some(())?; + shell(full) +} + +/// Why [`decode`] gives up on a file [`probe`] called [`RawFormat::OtherTiff`]. +fn other_tiff_reason(bytes: &[u8]) -> String { + let t = Tiff::parse_with(bytes, &lightcraft_tiff::ParseOptions { max_ifds: 256, ..Default::default() }).ok(); + match t.as_ref().filter(|t| !has_raw_ifd(t)).and_then(|t| thumbnail_shell(t, bytes.len())) { + Some(s) => format!( + "{}x{} raw image in a private block, not decoded yet (the file's first image is a {}x{} reduced copy)", + s.full.0, s.full.1, s.thumb.0, s.thumb.1 + ), + None => "OtherTiff files are not decoded yet".to_string(), + } +} + /// Decode a raw file. pub fn decode(bytes: &[u8]) -> Result { decode_with(bytes, Mode::Full) @@ -159,9 +270,9 @@ pub fn decode(bytes: &[u8]) -> Result { /// measure them from the samples). /// /// A few uncompressed vendor formats derive part of this from the samples themselves (Nikon -/// NEF: optically masked trailing columns; Olympus ORF: the CFA phase and bit depth; Pentax PEF -/// without crop tags: dark borders); for those the samples are read (unpacked, nothing to -/// decompress) and dropped. +/// NEF: optically masked trailing columns; Olympus ORF: the bit depth of 16-bit files, and the CFA +/// phase of files without an Exif `CFAPattern`; Pentax PEF without crop tags: dark borders); for +/// those the samples are read (unpacked, nothing to decompress) and dropped. pub fn probe_info(bytes: &[u8]) -> Result { decode_with(bytes, Mode::Header).map(RawImage::into_info) } @@ -180,12 +291,14 @@ fn decode_with(bytes: &[u8], mode: Mode) -> Result { match probe(bytes).ok_or(RawError::NotRaw)? { RawFormat::Dng => dng::decode(bytes, mode), RawFormat::Cr2 => vendor::cr2::decode(bytes, mode), + RawFormat::Cr3 => vendor::cr3::decode(bytes, mode), RawFormat::Nef | RawFormat::Nrw => vendor::nef::decode(bytes), RawFormat::Arw => vendor::arw::decode(bytes, mode), RawFormat::Raf => vendor::raf::decode(bytes, mode), RawFormat::Rw2 => vendor::rw2::decode(bytes, mode), RawFormat::Pef => vendor::pef::decode(bytes, mode), - RawFormat::Orf => vendor::orf::decode(bytes), + RawFormat::Orf => vendor::orf::decode(bytes, mode), + RawFormat::OtherTiff => Err(RawError::Unsupported(other_tiff_reason(bytes))), other => Err(RawError::Unsupported(format!("{other:?} files are not decoded yet"))), } } @@ -614,6 +727,136 @@ mod tests { assert_eq!(BlackLevel::uniform(7.0).at(9, 9, 0, 3), 7.0); } + // --- TIFF shells around a private raw block (thumbnail-only IFD0) --- + + use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; + + /// An uncompressed 8-bit RGB image IFD. + fn rgb_ifd(w: u32, h: u32) -> IfdBuilder { + let mut ifd = IfdBuilder::new(); + 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![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: h, strips: vec![vec![90u8; (w * h * 3) as usize]] }); + ifd + } + + fn exif_size(w: u32, h: u32) -> IfdBuilder { + IfdBuilder::new().with(t::PIXEL_X_DIMENSION, Value::Long(vec![w])).with(t::PIXEL_Y_DIMENSION, Value::Long(vec![h])) + } + + fn write(chain: &[IfdBuilder]) -> Vec { + TiffWriter::default().write(chain).unwrap() + } + + const SHELL_REASON: &str = "raw image in a private block"; + + /// A bigger image in a `SubIFDs` child of a small IFD0 (the layout of an 8-bit-mosaic TIFF raw). + #[test] + fn thumbnail_with_a_bigger_sub_ifd_is_an_undecodable_raw() { + let mut ifd0 = rgb_ifd(16, 12); + ifd0.add_sub_ifd(rgb_ifd(64, 48)); + let bytes = write(&[ifd0]); + assert_eq!(probe(&bytes), Some(RawFormat::OtherTiff)); + let Err(RawError::Unsupported(why)) = decode(&bytes) else { panic!("expected Unsupported") }; + assert!(why.contains("64x48") && why.contains("16x12") && why.contains(SHELL_REASON), "{why}"); + // a sub-IFD that is not much bigger (a pyramid level) does not count + let mut ifd0 = rgb_ifd(16, 12); + ifd0.add_sub_ifd(rgb_ifd(32, 24)); + assert_eq!(probe(&write(&[ifd0])), None); + } + + /// Exif says the picture is far bigger than IFD0 and the file holds a block that could carry it. + #[test] + fn thumbnail_with_a_private_block_behind_the_exif_size_is_an_undecodable_raw() { + let mut ifd0 = rgb_ifd(16, 12); + ifd0.set_child(t::EXIF_IFD, exif_size(400, 300)); + let mut bytes = write(&[ifd0.clone()]); + // no private block: a downsized copy that kept its old Exif size is an ordinary image + assert_eq!(probe(&bytes), None); + // 400 x 300 pixels at one bit each is 15000 bytes: 14000 is not enough, 16000 is + bytes.extend(std::iter::repeat_n(7u8, 14_000)); + assert_eq!(probe(&bytes), None); + bytes.extend(std::iter::repeat_n(7u8, 2_000)); + assert_eq!(probe(&bytes), Some(RawFormat::OtherTiff)); + let Err(RawError::Unsupported(why)) = decode(&bytes) else { panic!("expected Unsupported") }; + assert!(why.contains("400x300") && why.contains("16x12") && why.contains(SHELL_REASON), "{why}"); + // truncating it never panics + for n in (0..bytes.len()).step_by(37) { + let _ = probe(&bytes[..n]); + let _ = decode(&bytes[..n]); + } + } + + /// Ordinary TIFFs stay images: the full picture first, equal Exif size, or later pages that are bigger. + #[test] + fn ordinary_tiffs_are_not_shells() { + let padded = |chain: &[IfdBuilder]| { + let mut b = write(chain); + b.extend(std::iter::repeat_n(0u8, 100_000)); + b + }; + let mut full = rgb_ifd(400, 300); + full.set_child(t::EXIF_IFD, exif_size(400, 300)); + assert_eq!(probe(&padded(&[full])), None); + // the Exif size is much bigger than IFD0, with spare bytes, but IFD0 is not tiny next to it + let mut near = rgb_ifd(120, 90); + near.set_child(t::EXIF_IFD, exif_size(400, 300)); + assert_eq!(probe(&padded(&[near])), None); + // a multi-page TIFF whose first page is small + assert_eq!(probe(&padded(&[rgb_ifd(16, 12), rgb_ifd(400, 300)])), None); + // no dimensions at all + assert_eq!(probe(&padded(&[IfdBuilder::new().with(t::MAKE, Value::Ascii("X".into()))])), None); + } + + // --- containers that are recognised but not decoded, with a preview --- + + #[test] + fn private_containers_are_recognised_by_their_magic() { + assert_eq!(probe(b"II\x1a\0\0\0HEAPCCDR\0\0"), Some(RawFormat::Crw)); + assert_eq!(probe(b"\0MRM\0\x01\0\0"), Some(RawFormat::Mrw)); + assert_eq!(probe(b"FOVb\x02\0\x02\0"), Some(RawFormat::X3f)); + for f in [RawFormat::Crw, RawFormat::Mrw, RawFormat::X3f] { + assert!(!f.is_supported()); + } + assert!(matches!(decode(b"\0MRM\0\x01\0\0"), Err(RawError::Unsupported(w)) if w.contains("Mrw"))); + // too short for the CIFF signature + assert_eq!(probe(b"II\x1a\0\0\0HEAP"), None); + } + + /// An IFD0 with a JPEG pointer but no `ImageWidth` / `ImageLength` (the Kodak KDC layout) is a raw + /// container with a preview; a TIFF reader would call it malformed. + #[test] + fn ifd0_without_dimensions_but_with_a_jpeg_is_a_preview_container() { + let jpeg: Vec = [&[0xffu8, 0xd8, 0xff, 0xc0, 0, 0x0b, 8, 0, 1, 0, 1, 1, 1, 0x11, 0][..], &[0x55; 40], &[0xff, 0xd9]].concat(); + let off = 8 + 2 + 24 + 4; + let mut f = b"II*\0\x08\0\0\0\x02\0".to_vec(); + for (tag, v) in [(513u16, off as u32), (514, jpeg.len() as u32)] { + f.extend_from_slice(&tag.to_le_bytes()); + f.extend_from_slice(&[4, 0, 1, 0, 0, 0]); + f.extend_from_slice(&v.to_le_bytes()); + } + f.extend_from_slice(&[0, 0, 0, 0]); + f.extend_from_slice(&jpeg); + assert_eq!(probe(&f), Some(RawFormat::OtherTiff)); + assert!(matches!(decode(&f), Err(RawError::Unsupported(_)))); + assert_eq!(embedded_preview(&f), Some(jpeg)); + // no pointer and no sub-IFDs: just a broken TIFF + let g = write(&[IfdBuilder::new().with(t::MAKE, Value::Ascii("KODAK".into()))]); + assert_eq!(probe(&g), None); + } + + /// Compression 99 (not a registered TIFF value; Leaf MOS tiles) marks a raw even without a CFA tag. + #[test] + fn private_compression_99_is_a_raw() { + let mut ifd = rgb_ifd(16, 12); + ifd.set(t::COMPRESSION, Value::Short(vec![99])); + assert_eq!(probe(&write(&[ifd])), Some(RawFormat::OtherTiff)); + } + #[test] fn probe_rejects_junk() { assert_eq!(probe(b""), None); diff --git a/crates/raw/src/opcodes.rs b/crates/raw/src/opcodes.rs index f63eaf17e..bca397748 100644 --- a/crates/raw/src/opcodes.rs +++ b/crates/raw/src/opcodes.rs @@ -98,6 +98,13 @@ impl Opcode { pub fn is_applied(&self) -> bool { !matches!(self, Opcode::Unknown { .. } | Opcode::TrimBounds { .. } | Opcode::WarpFisheye { .. }) } + + /// Whether this is a lens correction (`WarpRectilinear`, `FixVignetteRadial`): from a DNG, or the equivalent of + /// a camera's own correction recorded in its raw (Panasonic RW2). Developers that apply these through the + /// "profile corrections" of the develop settings remove them before demosaicing. + pub fn is_lens_correction(&self) -> bool { + matches!(self, Opcode::WarpRectilinear { .. } | Opcode::FixVignetteRadial { .. }) + } } /// The three opcode lists of a DNG raw IFD. diff --git a/crates/raw/src/preview.rs b/crates/raw/src/preview.rs index ded35d294..6ec326ea6 100644 --- a/crates/raw/src/preview.rs +++ b/crates/raw/src/preview.rs @@ -5,6 +5,30 @@ use lightcraft_tiff::image::chunk_bytes; use lightcraft_tiff::tags as t; use lightcraft_tiff::{Ifd, Tiff, makernote}; +/// Colour space of an embedded JPEG when the enclosing raw supplies it instead of the JPEG. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PreviewColorSpace { + Srgb, + AdobeRgb, +} + +/// Nikon maker-note ColorSpace (0x001e: 1 = sRGB, 2 = Adobe RGB), per Nikon tag documentation. +/// This is a fallback only: a JPEG's own ICC/EXIF colour declaration takes precedence. +pub fn embedded_preview_color_space(bytes: &[u8]) -> Option { + let tiff = Tiff::parse(bytes).ok()?; + let make = tiff.ifds.first()?.string(t::MAKE)?; + if !make.to_ascii_uppercase().starts_with("NIKON") { + return None; + } + let e = tiff.exif()?.get(t::MAKER_NOTE)?; + let mn = makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, &make)?; + match mn.ifd.u16(0x001e)? { + 1 => Some(PreviewColorSpace::Srgb), + 2 => Some(PreviewColorSpace::AdobeRgb), + _ => None, + } +} + /// Whether `b` looks like a displayable (DCT) JPEG: SOI, and the first SOF marker is not lossless. fn is_dct_jpeg(b: &[u8]) -> bool { if b.len() < 4 || b[0] != 0xff || b[1] != 0xd8 { @@ -67,9 +91,13 @@ pub fn embedded_preview(bytes: &[u8]) -> Option> { let j = crate::vendor::raf::header(bytes).ok()?.jpeg?; return is_dct_jpeg(j).then(|| trim_eoi(j).to_vec()); } - if crate::probe(bytes) == Some(crate::RawFormat::Cr3) { + let format = crate::probe(bytes); + if format == Some(crate::RawFormat::Cr3) { return cr3_preview(bytes).map(|j| trim_eoi(j).to_vec()); } + if matches!(format, Some(crate::RawFormat::Crw | crate::RawFormat::Mrw | crate::RawFormat::X3f)) { + return scan_for_jpeg(bytes); + } let tiff = Tiff::parse(bytes).ok()?; let mut found: Vec<&[u8]> = Vec::new(); for ifd in tiff.all_ifds() { @@ -93,7 +121,112 @@ pub fn embedded_preview(bytes: &[u8]) -> Option> { if let Some(p) = crate::vendor::orf::preview(bytes) { found.push(p); } - found.into_iter().filter(|s| is_dct_jpeg(s)).max_by_key(|s| s.len()).map(|s| trim_eoi(s).to_vec()) + let best = found.into_iter().filter(|s| is_dct_jpeg(s)).max_by_key(|s| s.len()).map(|s| 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))) +} + +/// The end of the DCT JPEG whose SOI is at `soi` (exclusive index after its EOI) and its component count, +/// walking the marker segments and the entropy-coded data. `None` when it is not a complete, displayable +/// (baseline or progressive) JPEG, so a stray `D8 FF` in other data is rejected. `*reach` is left at +/// the furthest byte looked at (what the walk cost; see [`scan_for_jpeg`]). +fn jpeg_extent(b: &[u8], soi: usize, reach: &mut usize) -> Option<(usize, u8)> { + let mut i = soi + 2; + let r = walk_jpeg(b, &mut i); + *reach = i; + r +} + +fn walk_jpeg(b: &[u8], i: &mut usize) -> Option<(usize, u8)> { + let mut components = None; + for _ in 0..4096 { + if *b.get(*i)? != 0xff { + return None; + } + while *b.get(*i)? == 0xff { + *i += 1; + } + let m = *b.get(*i)?; + *i += 1; + match m { + 0xd9 => return components.map(|c| (*i, c)), + 0x01 | 0xd0..=0xd8 => continue, + _ => {} + } + let len = usize::from(u16::from_be_bytes([*b.get(*i)?, *b.get(*i + 1)?])); + if len < 2 { + return None; + } + match m { + 0xc0..=0xc2 => { + // precision, height, width, component count + let h = u16::from_be_bytes([*b.get(*i + 3)?, *b.get(*i + 4)?]); + let w = u16::from_be_bytes([*b.get(*i + 5)?, *b.get(*i + 6)?]); + if h == 0 || w == 0 || components.is_some() { + return None; + } + components = Some(*b.get(*i + 7)?); + } + 0xc3 | 0xc5..=0xc7 | 0xc9..=0xcb | 0xcd..=0xcf => return None, // lossless, arithmetic, hierarchical + _ => {} + } + *i += len; + if m == 0xda { + // entropy-coded data up to the next marker that is not a stuffed 0xff or a restart + loop { + *i += b.get(*i..)?.iter().position(|&x| x == 0xff)?; + match *b.get(*i + 1)? { + 0x00 | 0xd0..=0xd7 => *i += 2, + 0xff => *i += 1, + _ => break, + } + } + } + } + None +} + +/// Last resort for the containers whose structure is not walked (Canon CRW, Minolta MRW, Sigma X3F, TIFF raws +/// with private blocks such as Leaf MOS or Epson ERF): the largest complete colour DCT JPEG stored as a plain +/// byte run in the file. A one-component JPEG is skipped (a Canon PowerShot CRW stores its sensor mosaic as one). +/// +/// Evidence: in the Minolta and Epson files the first byte of the stored preview is not `ff` (it is `00`, `02` or +/// `ee`) while the rest is a regular JPEG, so a run is found by its `d8 ff` and starts one byte earlier, +/// with that byte restored to `ff`. +/// +/// A crafted file can hold millions of JPEG-like starts whose walks each run to its end: the walks +/// share a budget of eight times the file's size, so the scan stays linear (a real file's candidates +/// fail within a few bytes, or are the preview itself). +fn scan_for_jpeg(data: &[u8]) -> Option> { + let mut best: Option<(usize, usize)> = None; + let mut at = 1; + let mut budget = data.len().saturating_mul(8).max(1 << 20); + while let Some(off) = data.get(at..).and_then(|s| s.iter().position(|&b| b == 0xd8)) { + let d8 = at + off; + at = d8 + 1; + if data.get(d8 + 1) != Some(&0xff) || !matches!(data.get(d8 + 2), Some(0xc0..=0xc4 | 0xdb | 0xe0..=0xef | 0xfe)) { + continue; + } + let start = d8 - 1; // `at` starts at 1, so d8 >= 1 + let mut reach = start; + let found = jpeg_extent(data, start, &mut reach); + budget = budget.saturating_sub(reach.saturating_sub(start)); + if budget == 0 { + break; + } + if let Some((end, 3)) = found { + if best.is_none_or(|(s, e)| end - start > e - s) { + best = Some((start, end)); + } + at = end; + } + } + let (start, end) = best?; + let mut jpeg = data.get(start..end)?.to_vec(); + if let Some(first) = jpeg.first_mut() { + *first = 0xff; + } + Some(jpeg) } /// Canon CR3: the full-size JPEG track (see [`lightcraft_meta::cr3`]), else the `PRVW` / `THMB` boxes. @@ -218,4 +351,66 @@ mod tests { file.truncate(at as usize + 100); assert_eq!(embedded_preview(&file).unwrap(), small); } + + /// A structurally complete DCT JPEG (no real image data): `nc` components, `n` entropy bytes. + fn scan_jpeg(nc: u8, n: usize) -> Vec { + let mut j = vec![0xff, 0xd8, 0xff, 0xdb, 0x00, 0x43, 0x00]; + j.extend(std::iter::repeat_n(8u8, 64)); + j.extend_from_slice(&[0xff, 0xc0, 0x00, 8 + 3 * nc, 8, 0, 16, 0, 24, nc]); + for c in 0..nc { + j.extend_from_slice(&[c + 1, 0x11, 0]); + } + j.extend_from_slice(&[0xff, 0xda, 0x00, 6 + 2 * nc, nc]); + for c in 0..nc { + j.extend_from_slice(&[c + 1, 0]); + } + j.extend_from_slice(&[0, 63, 0]); + j.extend(std::iter::repeat_n(0x55u8, n)); + j.extend_from_slice(&[0xff, 0x00, 0x12, 0xff, 0xd0, 0x34, 0xff, 0xd9]); + j + } + + /// A crafted container full of JPEG-like starts (each walk running on through the ones after it) + /// is scanned in linear time: the walks share a budget (without it this 40 MB file takes about a minute). + #[test] + fn a_scan_through_many_fake_jpegs_stays_linear() { + let mut unit = vec![0u8, 0xd8, 0xff, 0xdb, 0x00, 0x02, 0xff, 0xda, 0x00, 0x02]; + unit.extend(std::iter::repeat_n(0x55u8, 190)); + let mut f = b"\0MRM\0\x01\0\0".to_vec(); + for _ in 0..200_000 { + f.extend_from_slice(&unit); + } + let t = std::time::Instant::now(); + assert_eq!(scan_for_jpeg(&f), None); + assert!(t.elapsed() < std::time::Duration::from_secs(10), "{:?}", t.elapsed()); + } + + /// Containers whose structure we do not walk give up their largest colour JPEG, found by scanning. + #[test] + fn scanned_containers_give_the_largest_colour_jpeg() { + let (small, big, mosaic) = (scan_jpeg(3, 100), scan_jpeg(3, 900), scan_jpeg(1, 5000)); + let heads: [&[u8]; 3] = [b"II\x1a\0\0\0HEAPCCDR", b"\0MRM\0\x01\0\0", b"FOVb\x02\0\x02\0"]; + for head in heads { + let mut f = head.to_vec(); + for part in [&small, &mosaic, &big] { + f.extend_from_slice(&[0xff, 0xd8, 0xff, 0x00, 0x00]); // a stray SOI-like run: not a JPEG + f.extend_from_slice(part); + f.extend_from_slice(&[0u8; 7]); + } + assert!(crate::probe(&f).is_some_and(|p| !p.is_supported()), "{head:?}"); + assert_eq!(embedded_preview(&f).as_deref(), Some(&big[..]), "{head:?}"); + for n in (0..f.len()).step_by(11) { + let _ = embedded_preview(&f[..n]); + } + } + // a preview whose first byte was overwritten (Minolta and Epson files store 00, 02 or ee there) is restored + let mut f = b"\0MRM\0\x01\0\0".to_vec(); + f.push(0x02); + f.extend_from_slice(&big[1..]); + assert_eq!(embedded_preview(&f).as_deref(), Some(&big[..])); + // only a one-component JPEG, or none: nothing + let mut f = b"FOVb\x02\0\x02\0".to_vec(); + f.extend_from_slice(&mosaic); + assert_eq!(embedded_preview(&f), None); + } } diff --git a/crates/raw/src/vendor/cr2.rs b/crates/raw/src/vendor/cr2.rs index d29e45f4b..d6529ece1 100644 --- a/crates/raw/src/vendor/cr2.rs +++ b/crates/raw/src/vendor/cr2.rs @@ -15,14 +15,15 @@ //! - `SensorInfo` (maker note `0x00e0`): sensor width/height and the left/top/right/bottom borders of the image //! area; the masked columns left of it give the black level. //! - `ColorBalance` (maker note `0x4001`): as-shot `RGGB` levels at a model-dependent offset; we probe the known -//! offsets and accept the first plausible quadruple. +//! offsets and accept the first plausible quadruple. The array is 16-bit words; some models store it as UNDEFINED +//! bytes (ColorData versions -3 and -4), which are paired up in the maker note's byte order first. //! - sRAW / mRAW (YCbCr, subsampled) are not supported yet. use super::{black_from_columns, white_from_data}; use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result, ljpeg}; use lightcraft_geom::Orientation; use lightcraft_tiff::image::chunk_bytes; -use lightcraft_tiff::{Ifd, Tiff, makernote, tags as t}; +use lightcraft_tiff::{ByteOrder, Ifd, Tiff, Value, makernote, tags as t}; const CR2_SLICE: u16 = 0xc640; const SRAW_TYPE: u16 = 0xc6c5; @@ -46,6 +47,17 @@ fn cfa_from_tag(v: Option) -> Option { Some(Cfa::bayer_static(name)) } +/// The 16-bit words of a maker-note array. Canon writes `ColorData` as SHORT in most models, but as UNDEFINED +/// bytes (count = byte length, e.g. 5120) in the PowerShot / EOS M models with ColorData versions -3 and -4; +/// those bytes are the same 16-bit words in the maker note's byte order, so they are paired up, not read one by +/// one. Other types are returned value by value. +fn words(v: &Value, order: ByteOrder) -> Vec { + match v { + Value::Byte(b) | Value::Undefined(b) => b.as_chunks::<2>().0.iter().map(|c| u64::from(order.u16(*c))).collect(), + _ => v.to_u64_vec(), + } +} + /// As-shot WB multipliers (R, G, B; G = 1) from the ColorBalance array. fn wb_from_color_balance(v: &[u64]) -> Option<[f32; 3]> { for off in [63usize, 25, 24, 34, 71, 85, 105, 69, 77] { @@ -134,7 +146,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { active = Rect::new(l, tp, r - l + 1, b - tp + 1); } } - let wb = mn.as_ref().and_then(|m| m.ifd.u64s(COLOR_BALANCE)).and_then(|v| wb_from_color_balance(&v)); + let wb = mn.as_ref().and_then(|m| m.ifd.value(COLOR_BALANCE).map(|v| words(v, m.order))).and_then(|v| wb_from_color_balance(&v)); let cfa = cfa_from_tag(raw.u64(CR2_CFA_PATTERN)).unwrap_or_else(|| Cfa::bayer_static("RGGB")); let black = if active.x >= 8 { black_from_columns(&data, width, 2..active.x - 2, active.y..active.y + active.height, active) @@ -175,6 +187,11 @@ pub(crate) mod tests { /// Build a synthetic CR2: IFD0..IFD2 placeholders + IFD3 holding a sliced 2-component LJ92 frame. pub(crate) fn synthetic_cr2(w: usize, h: usize, slices: Option<[u16; 3]>) -> (Vec, Vec) { + synthetic_cr2_note(w, h, slices, None) + } + + /// Like [`synthetic_cr2`], with `note` as the Exif maker note (bytes as stored). + fn synthetic_cr2_note(w: usize, h: usize, slices: Option<[u16; 3]>, note: Option>) -> (Vec, Vec) { let img: Vec = (0..w * h).map(|i| 1024 + ((i * 7919) % 12000) as u16).collect(); let widths: Vec = match slices { Some([n, sw, last]) => { @@ -200,6 +217,9 @@ pub(crate) mod tests { // maker note: SensorInfo + ColorBalance, plain IFD with offsets relative to the TIFF header let mut exif = IfdBuilder::new(); exif.set(t::ISO_SPEED, Value::Short(vec![400])); + if let Some(n) = note { + exif.set(t::MAKER_NOTE, Value::Undefined(n)); + } ifd0.set_child(t::EXIF_IFD, exif); let mut ifd3 = IfdBuilder::new(); ifd3.set(t::COMPRESSION, Value::Short(vec![6])); @@ -251,4 +271,62 @@ pub(crate) mod tests { assert!((wb[0] - 2000.0 / 1025.0).abs() < 1e-4 && (wb[2] - 1500.0 / 1025.0).abs() < 1e-4); assert!(wb_from_color_balance(&[0; 10]).is_none()); } + + #[test] + fn wb_from_undefined_bytes() { + // hand-coded: ColorData of an S110-like file, UNDEFINED bytes, little-endian words; word 71.. = 1988 745 745 1641 + let mut b = vec![0u8; 2 * 120]; + b[0..2].copy_from_slice(&[0xfd, 0xff]); // version -3 + b[142..150].copy_from_slice(&[0xc4, 0x07, 0xe9, 0x02, 0xe9, 0x02, 0x69, 0x06]); + let le = words(&Value::Undefined(b.clone()), ByteOrder::Little); + assert_eq!(&le[71..75], &[1988, 745, 745, 1641]); + let wb = wb_from_color_balance(&le).unwrap(); + assert!((wb[0] - 1988.0 / 745.0).abs() < 1e-5 && wb[1] == 1.0 && (wb[2] - 1641.0 / 745.0).abs() < 1e-5); + // read as single bytes (the old behaviour) nothing is plausible + assert!(wb_from_color_balance(&b.iter().map(|&x| u64::from(x)).collect::>()).is_none()); + // big-endian note: same words, bytes swapped + let mut be = b.clone(); + be.as_chunks_mut::<2>().0.iter_mut().for_each(|c| c.swap(0, 1)); + assert_eq!(words(&Value::Undefined(be), ByteOrder::Big), le); + // SHORT values pass through, an odd trailing byte is ignored + assert_eq!(words(&Value::Short(vec![1, 2, 3]), ByteOrder::Little), vec![1, 2, 3]); + assert_eq!(words(&Value::Undefined(vec![1, 0, 2]), ByteOrder::Little), vec![1]); + } + + /// A maker note holding only `ColorData` (`0x4001`) as `ty` (3 = SHORT, 7 = UNDEFINED) with `n` values, a plain + /// IFD whose value offset is relative to the TIFF header (patched once the note's position is known). + fn note_with_color_data(ty: u16, data: &[u8]) -> Vec { + let count = if ty == 3 { data.len() / 2 } else { data.len() } as u32; + let mut note = Vec::new(); + note.extend_from_slice(&1u16.to_le_bytes()); + note.extend_from_slice(&COLOR_BALANCE.to_le_bytes()); + note.extend_from_slice(&ty.to_le_bytes()); + note.extend_from_slice(&count.to_le_bytes()); + note.extend_from_slice(&0u32.to_le_bytes()); // value offset, patched below + note.extend_from_slice(&0u32.to_le_bytes()); // next IFD + note.extend_from_slice(data); + note + } + + fn decode_with_color_data(ty: u16, data: &[u8]) -> RawImage { + let (mut bytes, _) = synthetic_cr2_note(64, 10, None, Some(note_with_color_data(ty, data))); + let tiff = Tiff::parse(&bytes).unwrap(); + let e = tiff.exif().unwrap().get(t::MAKER_NOTE).unwrap(); + let off = e.offset as usize; + bytes[off + 10..off + 14].copy_from_slice(&(e.offset as u32 + 18).to_le_bytes()); + crate::decode(&bytes).unwrap() + } + + /// `ColorData` stored as UNDEFINED bytes (versions -3 / -4: PowerShot, EOS M) gives the as-shot WB from word 71, + /// exactly as the same words stored as SHORT do. + #[test] + fn color_data_stored_as_bytes() { + let mut bytes = vec![0u8; 2 * 120]; + bytes[0..2].copy_from_slice(&[0xfd, 0xff]); // version -3 + bytes[142..150].copy_from_slice(&[0xc4, 0x07, 0xe9, 0x02, 0xe9, 0x02, 0x69, 0x06]); // 1988 745 745 1641 + for ty in [7, 3] { + let wb = decode_with_color_data(ty, &bytes).wb_multipliers.unwrap_or_else(|| panic!("type {ty}: no WB")); + assert!((wb[0] - 1988.0 / 745.0).abs() < 1e-5 && wb[1] == 1.0 && (wb[2] - 1641.0 / 745.0).abs() < 1e-5, "type {ty}: {wb:?}"); + } + } } diff --git a/crates/raw/src/vendor/cr3.rs b/crates/raw/src/vendor/cr3.rs new file mode 100644 index 000000000..c51f8e302 --- /dev/null +++ b/crates/raw/src/vendor/cr3.rs @@ -0,0 +1,470 @@ +//! Canon CR3 container, camera metadata and sensor geometry. +//! +//! Sources: Laurent Clévy's public CR3 format description (`CMP1`, `CDI1/IAD1`, `CMT3`), +//! , and ExifTool's published Canon +//! tag-name tables (`SensorInfo`, `ColorData9`…`ColorData12`, `LensModel`), +//! . No third-party decoder source was consulted. +//! +//! `IAD1` stores the valid sensor area separately from the recommended image crop. Both use inclusive +//! sensor offsets. The CFA is anchored at the full sensor's origin; cropping shifts its phase later. +//! Unknown camera metadata versions are ignored rather than assigned a guessed white balance. + +use super::{black_from_columns, crx, white_from_data}; +use crate::{BlackLevel, Cfa, ColorData, MAX_SAMPLES, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result}; +use lightcraft_meta::cr3::{Cr3ImageArea, Cr3Track, Cr3TrackKind, parse_cr3}; +use lightcraft_tiff::{Ifd, Tiff}; + +const SENSOR_INFO: u16 = 0x00e0; +const COLOR_DATA: u16 = 0x4001; +const LENS_MODEL: u16 = 0x0095; + +/// Pick the first largest raw track. Equal-sized later tracks can be the Dual Pixel delta, which +/// must never replace the main sensor image. An invalid full-size descriptor is reported, not +/// silently replaced with the reduced raw preview. +fn main_track(tracks: &[Cr3Track]) -> Option<&Cr3Track> { + let mut best = None; + let mut best_area = 0u64; + for track in tracks { + if let Cr3TrackKind::Raw { width, height, .. } = track.kind { + let area = u64::from(width) * u64::from(height); + if area > best_area { + best = Some(track); + best_area = area; + } + } + } + best +} + +fn cfa(pattern: u8) -> Option { + let name = match pattern { + 0 => "RGGB", + 1 => "GRBG", + 2 => "GBRG", + 3 => "BGGR", + _ => return None, + }; + Some(Cfa::bayer_static(name)) +} + +/// Inclusive offsets, clipped to the sample dimensions (some Canon crop-mode files record right +/// and bottom borders a few pixels beyond the encoded image). +fn inclusive_rect(bounds: [u16; 4], width: usize, height: usize) -> Option { + let [left, top, right, bottom] = bounds.map(usize::from); + if left >= width || top >= height || right < left || bottom < top { + return None; + } + let end_x = right.checked_add(1)?.min(width); + let end_y = bottom.checked_add(1)?.min(height); + Some(Rect::new(left, top, end_x.checked_sub(left)?, end_y.checked_sub(top)?)) +} + +fn relative_crop(crop: Rect, active: Rect) -> Option { + let x = crop.x.checked_sub(active.x)?; + let y = crop.y.checked_sub(active.y)?; + if crop.width < 2 || crop.height < 2 || x.checked_add(crop.width)? > active.width || y.checked_add(crop.height)? > active.height { + return None; + } + Some(Rect::new(x, y, crop.width, crop.height)) +} + +fn sensor_crop(maker: Option<&Ifd>, width: usize, height: usize) -> Option { + let data = maker?.u64s(SENSOR_INFO)?; + let bounds = data.get(5..9)?; + inclusive_rect( + [ + u16::try_from(*bounds.first()?).ok()?, + u16::try_from(*bounds.get(1)?).ok()?, + u16::try_from(*bounds.get(2)?).ok()?, + u16::try_from(*bounds.get(3)?).ok()?, + ], + width, + height, + ) +} + +fn geometry(area: Option<&Cr3ImageArea>, maker: Option<&Ifd>, width: usize, height: usize) -> (Rect, Rect) { + let area = area.filter(|a| usize::from(a.width) == width && usize::from(a.height) == height); + let sensor = sensor_crop(maker, width, height); + let active = area.and_then(|a| a.active).and_then(|b| inclusive_rect(b, width, height)).or(sensor).unwrap_or(Rect::new(0, 0, width, height)); + let crop = area.and_then(|a| inclusive_rect(a.crop, width, height)).or(sensor).and_then(|c| relative_crop(c, active)).unwrap_or(Rect::new( + 0, + 0, + active.width, + active.height, + )); + (active, crop) +} + +#[derive(Default)] +struct CameraLevels { + wb: Option<[f32; 3]>, + black: Option<[f32; 4]>, + white: Option, +} + +/// Canon's versioned word arrays. Offsets are word indices from the published tag tables, not +/// byte offsets. The four as-shot WB values are R, G, G, B, independent of the CFA layout. +fn camera_levels(maker: Option<&Ifd>, bits: u8) -> CameraLevels { + let Some(values) = maker.and_then(|m| m.u64s(COLOR_DATA)) else { return CameraLevels::default() }; + let offsets = match values.first().copied() { + Some(16..=19) => (71, 329, 797), + Some(32 | 33) => (85, 343, 811), + Some(34 | 48) => (105, 363, 641), + Some(64 | 65) => (105, 383, 661), + _ => return CameraLevels::default(), + }; + let full = ((1u32 << u32::from(bits.clamp(1, 16))) - 1) as f32; + let quadruple = |at: usize| -> Option<[f32; 4]> { + let v = values.get(at..at + 4)?; + Some([*v.first()? as f32, *v.get(1)? as f32, *v.get(2)? as f32, *v.get(3)? as f32]) + }; + let wb = quadruple(offsets.0).and_then(|[r, g1, g2, b]| { + let green = (g1 + g2) * 0.5; + if !(64.0..=32767.0).contains(&green) || (g1 - g2).abs() > green * 0.1 { + return None; + } + let red = r / green; + let blue = b / green; + ((0.1..8.0).contains(&red) && (0.1..8.0).contains(&blue)).then_some([red, 1.0, blue]) + }); + let black = quadruple(offsets.1).filter(|v| v.iter().any(|&b| b > 0.0) && v.iter().all(|&b| b >= 0.0 && b < full * 0.5)); + let max_black = black.map(|v| v.into_iter().fold(0.0f32, f32::max)).unwrap_or(0.0); + let white = values.get(offsets.2).map(|&v| v as f32).filter(|&v| v > max_black && v >= full * 0.5 && v <= full); + CameraLevels { wb, black, white } +} + +fn black_at_active(levels: [f32; 4], pattern: u8, active: Rect) -> BlackLevel { + // Convert Canon's channel order to full-sensor site order, then shift to the active origin. + let sites = match pattern { + 1 => [1, 0, 3, 2], + 2 => [2, 3, 0, 1], + 3 => [3, 2, 1, 0], + _ => [0, 1, 2, 3], + }; + let values = (0..4) + .map(|i| { + let x = (active.x + i % 2) % 2; + let y = (active.y + i / 2) % 2; + levels.get(*sites.get(y * 2 + x).unwrap_or(&0)).copied().unwrap_or(0.0) + }) + .collect(); + BlackLevel { repeat_rows: 2, repeat_cols: 2, values, delta_h: vec![], delta_v: vec![] } +} + +pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { + let container = parse_cr3(bytes).ok_or(RawError::NotRaw)?; + let track = main_track(&container.tracks).ok_or_else(|| RawError::Unsupported("CR3 without a Bayer raw track".into()))?; + let coding = track.compression(bytes).ok_or_else(|| RawError::Corrupt("CR3 raw track has an invalid CMP1 descriptor".into()))?; + if coding.planes != 4 || coding.encoding != 0 { + return Err(RawError::Unsupported(format!("Canon CR3 encoding {} with {} planes", coding.encoding, coding.planes))); + } + let (width, height) = (coding.width as usize, coding.height as usize); + let samples = width.checked_mul(height).filter(|&n| n > 0 && n <= MAX_SAMPLES).ok_or(RawError::Limit("CR3 sensor dimensions"))?; + let cfa = cfa(coding.cfa_pattern).ok_or_else(|| RawError::Unsupported(format!("Canon CR3 CFA {}", coding.cfa_pattern)))?; + let (offset, len) = track.data.ok_or_else(|| RawError::Corrupt("CR3 raw sample outside file".into()))?; + let sample = + offset.checked_add(len).and_then(|end| bytes.get(offset..end)).ok_or_else(|| RawError::Corrupt("CR3 raw sample outside file".into()))?; + if coding.header_size as usize > sample.len() { + return Err(RawError::Corrupt("CR3 coding header outside sample".into())); + } + crx::validate(sample, &coding)?; + let data = match mode { + Mode::Full => { + let data = crx::decode(sample, &coding)?; + if data.len() != samples { + return Err(RawError::Corrupt("CR3 decoded sample count does not match CMP1".into())); + } + data + } + Mode::Header => vec![], + }; + let static_maker = container.cmt.get(2).and_then(|b| *b).and_then(|b| Tiff::parse(b).ok()); + let maker = static_maker.as_ref().and_then(|m| m.ifds.first()); + // Retain only the first useful timed maker note: repeated CTMD records must not accumulate + // independent copies of every bounded TIFF value in memory. + let timed_maker = container + .timed_exif(bytes) + .into_iter() + .filter(|b| b.tag == 0x927c) + .filter_map(|b| Tiff::parse(b.data).ok()) + .find(|t| t.ifds.first().is_some_and(|m| m.contains(COLOR_DATA))); + let color_maker = timed_maker.as_ref().and_then(|t| t.ifds.first()).or(maker); + let area = track.image_area(bytes); + let (active, crop) = geometry(area.as_ref(), maker, width, height); + let levels = camera_levels(color_maker, coding.bit_depth); + let black = match levels.black { + Some(levels) => black_at_active(levels, coding.cfa_pattern, active), + None if active.x >= 8 => black_from_columns(&data, width, 2..active.x - 2, active.y..active.y + active.height, active), + None => BlackLevel::uniform(0.0), + }; + let white = levels.white.unwrap_or_else(|| white_from_data(&data, u32::from(coding.bit_depth))); + let mut metadata = lightcraft_meta::extract(bytes); + if metadata.lens_model.is_none() { + metadata.lens_model = maker.and_then(|m| m.string(LENS_MODEL)).map(|s| s.trim().to_owned()).filter(|s| !s.is_empty()); + } + metadata.width = Some(crop.width as u32); + metadata.height = Some(crop.height as u32); + let image = RawImage { + format: RawFormat::Cr3, + width, + height, + cpp: 1, + data: RawData::U16(data), + cfa: Some(cfa), + bits: u32::from(coding.bit_depth), + black, + white: vec![white], + active_area: active, + crop, + orientation: metadata.orientation.unwrap_or_default(), + color: ColorData::default(), + wb_multipliers: levels.wb, + linearized: false, + opcodes: OpcodeLists::default(), + metadata, + }; + image.validate_for(mode)?; + Ok(image) +} + +#[cfg(test)] +mod tests { + use super::*; + use lightcraft_tiff::{IfdBuilder, Value}; + + fn maker(version: u16, wb_at: usize, black_at: usize, white_at: usize) -> Ifd { + let mut words = vec![0u16; white_at + 1]; + words[0] = version; + words[wb_at..wb_at + 4].copy_from_slice(&[2048, 1024, 1024, 1536]); + words[black_at..black_at + 4].copy_from_slice(&[100, 101, 102, 103]); + words[white_at] = 15000; + let b = IfdBuilder::new().with(COLOR_DATA, Value::Short(words)); + let bytes = lightcraft_tiff::TiffWriter::new(lightcraft_tiff::ByteOrder::Little, false).write(&[b]).unwrap(); + Tiff::parse(&bytes).unwrap().ifds.remove(0) + } + + fn box_bytes(kind: &[u8; 4], body: &[u8]) -> Vec { + let mut b = ((body.len() + 8) as u32).to_be_bytes().to_vec(); + b.extend_from_slice(kind); + b.extend_from_slice(body); + b + } + + fn track(entry: &[u8], offset: u64, size: u32) -> Vec { + let mut stsd = vec![0u8; 4]; + stsd.extend(1u32.to_be_bytes()); + stsd.extend(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)))) + } + + fn synthetic_header() -> Vec { + use lightcraft_tiff::{ByteOrder, TiffWriter, tags as t}; + let tiff = |b: IfdBuilder| TiffWriter::new(ByteOrder::Little, false).write(&[b]).unwrap(); + let cmt1 = tiff( + IfdBuilder::new() + .with(t::MAKE, Value::Ascii("Canon".into())) + .with(t::MODEL, Value::Ascii("EOS Test".into())) + .with(t::ORIENTATION, Value::Short(vec![6])), + ); + let cmt2 = tiff(IfdBuilder::new().with(t::ISO_SPEED, Value::Short(vec![800]))); + let cmt3 = tiff( + IfdBuilder::new() + .with(SENSOR_INFO, Value::Short(vec![34, 64, 48, 1, 1, 6, 4, 61, 45])) + .with(LENS_MODEL, Value::Ascii("RF-S18-45mm F4.5-6.3 IS STM".into())), + ); + let mut canon = vec![0x85, 0xc0, 0xb6, 0x87, 0x82, 0x0f, 0x11, 0xe0, 0x81, 0x11, 0xf4, 0xce, 0x46, 0x2b, 0x6a, 0x48]; + canon.extend([box_bytes(b"CMT1", &cmt1), box_bytes(b"CMT2", &cmt2), box_bytes(b"CMT3", &cmt3)].concat()); + 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()); + for (at, value) in [(8, 64u32), (12, 48), (16, 32), (20, 48), (28, 216)] { + coding[at..at + 4].copy_from_slice(&value.to_be_bytes()); + } + coding[24] = 14; + coding[25] = 0x40; + let mut area = vec![0u8; 48]; + for (at, value) in [ + (4, 64u16), + (6, 48), + (10, 2), + (16, 6), + (18, 4), + (20, 61), + (22, 45), + (28, 3), + (30, 47), + (32, 4), + (36, 63), + (38, 1), + (40, 4), + (42, 2), + (44, 63), + (46, 47), + ] { + area[at..at + 2].copy_from_slice(&value.to_be_bytes()); + } + let mut craw = vec![0u8; 82]; + craw[24..26].copy_from_slice(&64u16.to_be_bytes()); + craw[26..28].copy_from_slice(&48u16.to_be_bytes()); + craw.extend(box_bytes(b"CMP1", &coding)); + craw.extend(box_bytes(b"CDI1", &[vec![0u8; 4], box_bytes(b"IAD1", &area)].concat())); + let levels = maker(48, 105, 363, 641).value(COLOR_DATA).unwrap().clone(); + let dynamic_maker = tiff(IfdBuilder::new().with(COLOR_DATA, levels)); + let mut item = ((dynamic_maker.len() + 8) as u32).to_le_bytes().to_vec(); + item.extend(0x927cu32.to_le_bytes()); + item.extend(dynamic_maker); + let mut timed = vec![0u8; 12]; + timed[0..4].copy_from_slice(&((12 + item.len()) as u32).to_le_bytes()); + timed[4..6].copy_from_slice(&8u16.to_le_bytes()); + timed.extend(item); + // Two tiles, each with four one-byte lossless plane streams. Header + // probing checks the complete marker layout without decoding entropy. + let mut sample = Vec::new(); + let marker = |out: &mut Vec, 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()); + }; + for tile in 0..2 { + marker(&mut sample, 0xff01, 4, tile << 16); + for plane in 0..4 { + marker(&mut sample, 0xff02, 1, (plane << 28) | 0x0800_0000); + marker(&mut sample, 0xff03, 1, 0x0020_0000); + } + } + assert_eq!(sample.len(), 216); + sample.extend([0x1e; 8]); + let moov = + [box_bytes(b"uuid", &canon), track(&box_bytes(b"CRAW", &craw), 4096, 224), track(&box_bytes(b"CTMD", &[]), 4320, timed.len() as u32)] + .concat(); + let mut file = box_bytes(b"ftyp", b"crx \0\0\0\x01crx isom"); + file.extend(box_bytes(b"moov", &moov)); + assert!(file.len() < 4096); + file.resize(4096, 0); + file.extend(sample); + file.extend(timed); + file + } + + #[test] + fn header_reads_timed_white_balance_and_distinct_geometry() { + let bytes = synthetic_header(); + let image = decode(&bytes, Mode::Header).unwrap(); + assert_eq!((image.width, image.height, image.bits), (64, 48, 14)); + assert_eq!(image.active_area, Rect::new(4, 2, 60, 46)); + assert_eq!(image.crop, Rect::new(2, 2, 56, 42)); + assert_eq!(image.wb_multipliers, Some([2.0, 1.0, 1.5])); + assert_eq!(image.black.values, vec![100.0, 101.0, 102.0, 103.0]); + assert_eq!(image.white, vec![15000.0]); + assert_eq!(image.metadata.iso, Some(800)); + assert_eq!(image.metadata.lens_model.as_deref(), Some("RF-S18-45mm F4.5-6.3 IS STM")); + assert_eq!((image.metadata.width, image.metadata.height), (Some(56), Some(42))); + assert_eq!(image.orientation, crate::Orientation::Rotate90); + assert!(image.data.is_empty()); + assert!(decode(&bytes[..4200], Mode::Header).is_err()); + } + + fn prepend_tracks(bytes: &[u8], tracks: &[u8]) -> Vec { + let ftyp_end = u32::from_be_bytes(bytes[..4].try_into().unwrap()) as usize; + let moov_len = u32::from_be_bytes(bytes[ftyp_end..ftyp_end + 4].try_into().unwrap()) as usize; + assert_eq!(&bytes[ftyp_end + 4..ftyp_end + 8], b"moov"); + let moov = [tracks, &bytes[ftyp_end + 8..ftyp_end + moov_len]].concat(); + let mut out = [bytes[..ftyp_end].to_vec(), box_bytes(b"moov", &moov)].concat(); + assert!(out.len() < 4096); + out.resize(4096, 0); + out.extend_from_slice(&bytes[4096..]); + out + } + + #[test] + fn hevc_preview_with_equal_dimensions_does_not_replace_the_sensor_track() { + let bytes = synthetic_header(); + let mut preview = vec![0u8; 82]; + preview[24..26].copy_from_slice(&64u16.to_be_bytes()); + preview[26..28].copy_from_slice(&48u16.to_be_bytes()); + preview.extend(box_bytes(b"HEVC", &[0; 4])); + let bytes = prepend_tracks(&bytes, &track(&box_bytes(b"CRAW", &preview), 4096, 224)); + let container = parse_cr3(&bytes).unwrap(); + assert_eq!(container.tracks[0].kind, Cr3TrackKind::Other(*b"HEVC")); + let image = decode(&bytes, Mode::Header).unwrap(); + assert_eq!((image.width, image.height, image.bits), (64, 48, 14)); + assert_eq!(image.active_area, Rect::new(4, 2, 60, 46)); + } + + #[test] + fn corrupt_main_descriptor_is_not_replaced_by_the_reduced_raw() { + let bytes = synthetic_header(); + let container = parse_cr3(&bytes).unwrap(); + let Cr3TrackKind::Raw { cmp1: Some((at, len)), .. } = main_track(&container.tracks).unwrap().kind else { panic!("raw fixture") }; + let mut reduced = vec![0u8; 82]; + reduced[24..26].copy_from_slice(&32u16.to_be_bytes()); + reduced[26..28].copy_from_slice(&24u16.to_be_bytes()); + reduced.extend(box_bytes(b"CMP1", &bytes[at..at + len])); + let bytes = prepend_tracks(&bytes, &track(&box_bytes(b"CRAW", &reduced), 4096, 224)); + let container = parse_cr3(&bytes).unwrap(); + let Cr3TrackKind::Raw { cmp1: Some((at, len)), .. } = main_track(&container.tracks).unwrap().kind else { panic!("main raw fixture") }; + let mut malformed = bytes.clone(); + malformed[at..at + len].fill(0); + assert!(matches!(decode(&malformed, Mode::Header), Err(RawError::Corrupt(_)))); + let mut missing = bytes; + missing[at - 4..at].copy_from_slice(b"free"); + assert!(matches!(decode(&missing, Mode::Header), Err(RawError::Corrupt(_)))); + } + + #[test] + fn color_data_uses_versioned_as_shot_offsets() { + for (version, wb_at, black_at, white_at) in + [(16, 71, 329, 797), (19, 71, 329, 797), (33, 85, 343, 811), (48, 105, 363, 641), (64, 105, 383, 661)] + { + let m = maker(version, wb_at, black_at, white_at); + let levels = camera_levels(Some(&m), 14); + assert_eq!(levels.wb, Some([2.0, 1.0, 1.5])); + assert_eq!(levels.black, Some([100.0, 101.0, 102.0, 103.0])); + assert_eq!(levels.white, Some(15000.0)); + } + assert!(camera_levels(Some(&maker(255, 71, 329, 797)), 14).wb.is_none()); + assert!(camera_levels(None, 14).black.is_none()); + } + + #[test] + fn crop_is_relative_to_the_valid_sensor_area() { + let area = Cr3ImageArea { + width: 6888, + height: 4546, + crop: [156, 158, 6875, 4537], + active: Some([144, 46, 6887, 4545]), + masked_left: [0, 0, 143, 4545], + masked_top: None, + }; + assert_eq!(geometry(Some(&area), None, 6888, 4546), (Rect::new(144, 46, 6744, 4500), Rect::new(12, 112, 6720, 4380))); + assert_eq!(inclusive_rect([152, 46, 4353, 2851], 4352, 2850), Some(Rect::new(152, 46, 4200, 2804))); + assert_eq!(inclusive_rect([100, 100, 50, 50], 200, 200), None); + assert_eq!(relative_crop(Rect::new(0, 0, 100, 100), Rect::new(4, 4, 92, 92)), None); + } + + #[test] + fn first_full_size_track_wins_over_dual_pixel_delta() { + let raw = |w, h, offset| Cr3Track { kind: Cr3TrackKind::Raw { width: w, height: h, cmp1: None, iad1: None }, data: Some((offset, 100)) }; + let tracks = [raw(1624, 1080, 1), raw(6888, 4546, 2), raw(6888, 4546, 3)]; + assert_eq!(main_track(&tracks).unwrap().data, Some((2, 100))); + } + + #[test] + fn cfa_and_black_follow_the_full_sensor_origin() { + for (code, name) in [(0, "RGGB"), (1, "GRBG"), (2, "GBRG"), (3, "BGGR")] { + assert_eq!(cfa(code).unwrap().name(), name); + } + let b = black_at_active([100.0, 101.0, 102.0, 103.0], 0, Rect::new(3, 1, 32, 20)); + assert_eq!(b.values, vec![103.0, 102.0, 101.0, 100.0]); + } +} diff --git a/crates/raw/src/vendor/crx.rs b/crates/raw/src/vendor/crx.rs new file mode 100644 index 000000000..dfdc73c56 --- /dev/null +++ b/crates/raw/src/vendor/crx.rs @@ -0,0 +1,1088 @@ +//! Canon CRX sensor sample decompression. +//! +//! Clean-room sources: Laurent Clévy's public prose CR3 format description +//! (CMP1 and marker layouts), +//! Canon patent US20160323602A1 (MED prediction, signed mapping, adaptive Rice coding), +//! Canon patent JP2017192077A (zero-run flag and MELCODE of run length minus one), and +//! ITU-T T.87 (MELCODE run index table). No third-party decoder source was consulted. +//! CRX-specific boundaries and adaptive parameters were established by comparing +//! CC0 raw.pixls.us files against an external decoder used only as a black-box oracle. +//! +//! Current support: version 0x100 lossless Bayer RAW and horizontal-tile C-RAW, +//! plus version 0x200 single-tile 14-bit C-RAW with adaptive QP quantization. +//! Both ff01/ff02/ff03 and ff11/ff12/ff13 marker families are supported. +//! Six full M50/R100/R8 sensor fixtures match an external decoder sample for sample. +//! Unverified coding variants return explicit unsupported errors. + +use super::crx_wavelet::{self, Band as WaveletBand, TileEdges}; +use crate::{MAX_SAMPLES, RawError, Result}; +use lightcraft_meta::cr3::Cr3Compression; +use rayon::prelude::*; +use std::ops::Range; + +const MAX_TILES: usize = 1024; +const MAX_HEADER_BYTES: usize = 1 << 20; +const MAX_CRX_SAMPLES: usize = 200_000_000; +const MAX_CRX_EDGE: u32 = 65_536; +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 { + RawError::Corrupt(format!("Canon CRX: {reason}")) +} + +#[derive(Debug)] +struct Band { + data: Range, + padding: usize, + quant: Quantization, + partial: bool, +} + +#[derive(Debug)] +enum Quantization { + Uniform(u8), + Adaptive { base: u32, gain: u32 }, +} + +struct QpMap { + width: usize, + height: usize, + data: Vec, +} + +#[derive(Debug)] +struct Plane { + partial: bool, + rounded_bits: u8, + bands: Vec, +} + +#[derive(Debug)] +struct Tile { + x: usize, + y: usize, + width: usize, + height: usize, + planes: Vec, + qp: Option>, +} + +/// Decode to full sensor-order CFA samples, including masked borders. +pub(crate) fn decode(sample: &[u8], config: &Cr3Compression) -> Result> { + supports(config)?; + let width = usize::try_from(config.width).map_err(|_| RawError::Limit("CRX width"))?; + let count = image_size(config)?; + let tiles = layout(sample, config)?; + validate_planes(&tiles, config)?; + // Allocated once the first plane decodes: a tiny crafted file can claim a huge image. + let mut output = Vec::new(); + let midpoint = 1i32 << (config.bit_depth - 1); + let maximum = (1i32 << config.bit_depth) - 1; + for tile in tiles { + let qp = tile_qp(sample, &tile)?; + // Each plane is its own entropy stream: decode a tile's planes in parallel. + let planes: Vec>> = tile + .planes + .par_iter() + .enumerate() + .map(|(p, plane)| { + if config.levels == 0 { + let band = plane.bands.first().ok_or_else(|| corrupt("missing lossless band"))?; + lossless_plane(band_bytes(sample, band)?, tile.width / 2, tile.height / 2) + } else { + let edges = TileEdges { left: tile.x > 0, right: tile.x + tile.width < width, top: false, bottom: false }; + wavelet_plane(sample, plane, tile.width / 2, tile.height / 2, edges, qp.as_ref()) + } + .map_err(|error| RawError::Corrupt(format!("Canon CRX tile ({}, {}), plane {p}: {error}", tile.x, tile.y))) + }) + .collect(); + for (p, decoded) in planes.into_iter().enumerate() { + let decoded = decoded?; + if output.is_empty() { + output.try_reserve_exact(count).map_err(|_| RawError::Limit("CRX sample allocation"))?; + output.resize(count, 0); + } + for (i, &value) in decoded.iter().enumerate() { + let sensor_value = sensor_sample(value, midpoint, maximum, config.levels != 0)?; + let (x, y) = (tile.x + 2 * (i % (tile.width / 2)) + p % 2, tile.y + 2 * (i / (tile.width / 2)) + p / 2); + let slot = y + .checked_mul(width) + .and_then(|n| n.checked_add(x)) + .and_then(|n| output.get_mut(n)) + .ok_or_else(|| corrupt("tile writes outside image"))?; + *slot = sensor_value; + } + } + } + Ok(output) +} + +/// A decoded plane value as a sensor sample of the CMP1 bit depth. +/// +/// Lossless planes reproduce the sensor exactly, so a value outside the bit depth is corrupt data. +/// Wavelet (C-RAW) planes are quantized: next to clipped highlights the reconstruction overshoots +/// the sensor's range by up to a few quantization steps (measured on 17 CC0 files: at most 36 codes +/// above 16383 at 14 bits, never below 0), while lossless files of the same bodies clip at exactly +/// 16383. Those samples are clamped to the range the sensor can record. +fn sensor_sample(value: i32, midpoint: i32, maximum: i32, quantized: bool) -> Result { + let sample = value.checked_add(midpoint).ok_or_else(|| corrupt("decoded sample outside its bit depth"))?; + let sample = if quantized { sample.clamp(0, maximum) } else { sample }; + u16::try_from(sample).ok().filter(|_| sample <= maximum).ok_or_else(|| corrupt("decoded sample outside its bit depth")) +} + +/// Header-only probing must reject the same unsupported coding variants as full decoding. +pub(crate) fn validate(sample: &[u8], config: &Cr3Compression) -> Result<()> { + supports(config)?; + image_size(config)?; + let tiles = layout(sample, config)?; + validate_planes(&tiles, config)?; + for tile in &tiles { + tile_qp(sample, tile)?; + } + Ok(()) +} + +fn image_size(config: &Cr3Compression) -> Result { + if config.width > MAX_CRX_EDGE || config.height > MAX_CRX_EDGE { + return Err(RawError::Limit("CRX image edge")); + } + (config.width as usize) + .checked_mul(config.height as usize) + .filter(|&n| n > 0 && n <= MAX_SAMPLES.min(MAX_CRX_SAMPLES)) + .ok_or(RawError::Limit("CRX image size")) +} + +fn supports(config: &Cr3Compression) -> Result<()> { + if ![0x100, 0x200].contains(&config.version) + || config.encoding != 0 + || config.planes != 4 + || config.cfa_pattern > 3 + || ![0, 3].contains(&config.levels) + { + return Err(RawError::Unsupported(format!( + "Canon CRX version {:#x}, encoding {}, {} planes, {} wavelet levels", + config.version, config.encoding, config.planes, config.levels + ))); + } + if !(8..=16).contains(&config.bit_depth) { + return Err(RawError::Unsupported(format!("Canon CRX {}-bit samples", config.bit_depth))); + } + if config.levels == 3 && config.tile_height != config.height { + return Err(RawError::Unsupported("Canon CRX C-RAW with vertical tiles".into())); + } + if config.version == 0x200 && (config.levels != 3 || config.bit_depth != 14 || config.tile_width != config.width) { + return Err(RawError::Unsupported("Canon CRX version 0x200 requires a single-tile 14-bit Bayer C-RAW image".into())); + } + if config.version == 0x200 && !config.height.div_ceil(4).is_multiple_of(2) { + return Err(RawError::Unsupported("Canon CRX adaptive QP map with an odd height".into())); + } + if config.median_bit_depth.is_some() { + return Err(RawError::Unsupported("Canon CRX extended median-bit-depth header".into())); + } + Ok(()) +} + +fn validate_planes(tiles: &[Tile], config: &Cr3Compression) -> Result<()> { + for tile in tiles { + for plane in &tile.planes { + if !plane.partial || plane.rounded_bits != 0 || plane.bands.len() != 1 + usize::from(config.levels) * 3 { + return Err(RawError::Unsupported("Canon CRX plane coding flags".into())); + } + for (index, band) in plane.bands.iter().enumerate() { + if band.partial { + return Err(RawError::Unsupported("Canon CRX partial subband coding".into())); + } + if config.levels != 0 { + match band.quant { + Quantization::Uniform(value) => { + quant_step(value)?; + } + Quantization::Adaptive { gain, .. } => { + if index < 4 && gain != 0 { + return Err(RawError::Unsupported("Canon CRX adaptive quantization subband parameters".into())); + } + } + } + } + } + } + } + Ok(()) +} + +fn layout(sample: &[u8], config: &Cr3Compression) -> Result> { + let (width, height, tw, th) = (config.width as usize, config.height as usize, config.tile_width as usize, config.tile_height as usize); + if width == 0 + || height == 0 + || tw == 0 + || th == 0 + || tw > width + || th > height + || !width.is_multiple_of(2) + || !height.is_multiple_of(2) + || !tw.is_multiple_of(2) + || !th.is_multiple_of(2) + { + return Err(corrupt("invalid Bayer tile dimensions")); + } + let columns = width.div_ceil(tw); + let rows = height.div_ceil(th); + let tile_count = columns.checked_mul(rows).filter(|&n| n <= MAX_TILES).ok_or(RawError::Limit("CRX tile count"))?; + let header_size = config.header_size as usize; + if header_size > MAX_HEADER_BYTES { + return Err(RawError::Limit("CRX header size")); + } + let header = sample.get(..header_size).ok_or_else(|| corrupt("truncated tile headers"))?; + let mut reader = Header { src: header, at: 0 }; + let mut data_at = header_size; + let mut tiles = Vec::with_capacity(tile_count); + for index in 0..tile_count { + let modern = config.version == 0x200; + let body = reader.marker(if modern { 0xff11 } else { 0xff01 }, if modern { 16 } else { 8 })?; + let tile_size = usize::try_from(be32(body, 0)?).map_err(|_| RawError::Limit("CRX tile size"))?; + if usize::from(be16(body, 4)?) != index { + return Err(corrupt("tile index out of sequence")); + } + let tile_end = data_at.checked_add(tile_size).filter(|&end| end <= sample.len()).ok_or_else(|| corrupt("tile data outside sample"))?; + let qp = if modern { + let length = be32(body, 8)? as usize; + let padding = usize::from(be16(body, 12)?); + if be16(body, 6)? != 0x4000 || be16(body, 14)? != 0 || length == 0 || padding > 7 { + return Err(RawError::Unsupported("Canon CRX adaptive quantization tile header".into())); + } + let end = data_at.checked_add(length).filter(|&end| end <= tile_end).ok_or_else(|| corrupt("QP data outside tile"))?; + let data = data_at..end; + data_at = end.checked_add(padding).filter(|&end| end <= tile_end).ok_or_else(|| corrupt("QP padding outside tile"))?; + Some(data) + } else { + None + }; + let mut planes = Vec::with_capacity(usize::from(config.planes)); + for p in 0..config.planes { + let body = reader.marker(if modern { 0xff12 } else { 0xff02 }, 8)?; + let size = be32(body, 0)? as usize; + let flags = be32(body, 4)?; + if flags >> 28 != u32::from(p) { + return Err(corrupt("plane index out of sequence")); + } + let plane_end = data_at.checked_add(size).filter(|&end| end <= tile_end).ok_or_else(|| corrupt("plane size exceeds tile"))?; + let mut bands = Vec::with_capacity(usize::from(config.levels) * 3 + 1); + for b in 0..u32::from(config.levels) * 3 + 1 { + let body = reader.marker(if modern { 0xff13 } else { 0xff03 }, if modern { 16 } else { 8 })?; + let band_size = be32(body, 0)? as usize; + let band_flags = be32(body, 4)?; + if band_flags >> 28 != b { + return Err(corrupt("band index out of sequence")); + } + let band_end = data_at.checked_add(band_size).filter(|&end| end <= plane_end).ok_or_else(|| corrupt("band size exceeds plane"))?; + let padding = if modern { usize::from(be16(body, 12)?) } else { (band_flags & 0x7ffff) as usize }; + if padding > band_size || (modern && padding > 7) { + return Err(corrupt("invalid band padding")); + } + bands.push(Band { + data: data_at..band_end, + padding, + quant: if modern { + if be16(body, 14)? != 0 { + return Err(RawError::Unsupported("Canon CRX adaptive subband reserved field".into())); + } + Quantization::Adaptive { base: be32(body, 8)?, gain: band_flags & 0x07ff_ffff } + } else { + Quantization::Uniform(((band_flags >> 19) & 0xff) as u8) + }, + partial: band_flags & 0x0800_0000 != 0, + }); + data_at = band_end; + } + if data_at != plane_end { + return Err(corrupt("band sizes do not sum to plane size")); + } + planes.push(Plane { partial: flags & 0x0800_0000 != 0, rounded_bits: ((flags >> 25) & 3) as u8, bands }); + } + if data_at != tile_end { + return Err(corrupt("plane sizes do not sum to tile size")); + } + let (x, y) = ((index % columns) * tw, (index / columns) * th); + tiles.push(Tile { x, y, width: tw.min(width - x), height: th.min(height - y), planes, qp }); + } + if header.get(reader.at..).is_none_or(|rest| rest.iter().any(|&byte| byte != 0)) { + return Err(corrupt("unexpected bytes after tile headers")); + } + if data_at != sample.len() { + return Err(corrupt("tile sizes do not cover the sample")); + } + Ok(tiles) +} + +fn band_bytes<'a>(sample: &'a [u8], band: &Band) -> Result<&'a [u8]> { + let end = + band.data.end.checked_sub(band.padding).filter(|&end| end >= band.data.start).ok_or_else(|| corrupt("band padding exceeds its size"))?; + sample.get(band.data.start..end).ok_or_else(|| corrupt("band data outside sample")) +} + +fn tile_qp(sample: &[u8], tile: &Tile) -> Result> { + tile.qp + .as_ref() + .map(|range| { + let bytes = sample.get(range.clone()).ok_or_else(|| corrupt("QP map outside sample"))?; + qp_map(bytes, tile.width.div_ceil(16), tile.height.div_ceil(4)) + }) + .transpose() +} + +fn quant_step(value: u8) -> Result { + // Measured independently with an external decoder after modifying only the + // quantValue field: all forty values 4..43 follow this integer sequence. + if !(4..=43).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"))?; + Ok((scale << (value / 6)) / 64) +} + +fn split_dimension(length: usize, before: bool, after: bool) -> (usize, usize) { + (length.div_ceil(2) + usize::from(after && length.is_multiple_of(2)), length / 2 + usize::from(before) + usize::from(after)) +} + +fn wavelet_plane(sample: &[u8], plane: &Plane, width: usize, height: usize, edges: TileEdges, qp: Option<&QpMap>) -> Result> { + let mut dimensions = vec![(width, height)]; + let mut high_dimensions = Vec::new(); + for _ in 0..3 { + let &(w, h) = dimensions.last().ok_or_else(|| corrupt("missing wavelet dimensions"))?; + let (lw, hw) = split_dimension(w, edges.left, edges.right); + let (lh, hh) = split_dimension(h, edges.top, edges.bottom); + dimensions.push((lw, lh)); + high_dimensions.push([(hw, lh), (lw, hh), (hw, hh)]); + } + let mut shapes = vec![*dimensions.last().ok_or_else(|| corrupt("missing LL dimensions"))?]; + for high in high_dimensions.iter().rev() { + shapes.extend_from_slice(high); + } + let mut bands = Vec::with_capacity(10); + for (i, (&(w, h), band)) in shapes.iter().zip(&plane.bands).enumerate() { + let bytes = band_bytes(sample, band)?; + let mut data = if i == 0 { + lossless_plane(bytes, w, h)? + } else { + // Some CRX high-frequency bands retain a redundant right column + // beyond synthesis support (LH2 in the M50/R100 fixtures). Infer + // that small extension only from a complete, correctly aligned + // entropy stream, then crop each row to the synthesis support. + let mut decoded = None; + for extra in 0..=2 { + let coded_width = w.checked_add(extra).ok_or(RawError::Limit("CRX band width"))?; + if let Ok(values) = high_frequency_band(bytes, coded_width, h) { + if extra == 0 { + decoded = Some(values); + } else { + let mut cropped = Vec::new(); + cropped.try_reserve_exact(w * h).map_err(|_| RawError::Limit("CRX cropped band allocation"))?; + for row in values.chunks_exact(coded_width) { + cropped.extend_from_slice(row.get(..w).ok_or_else(|| corrupt("band crop outside row"))?); + } + decoded = Some(cropped); + } + break; + } + } + decoded.ok_or_else(|| RawError::Corrupt(format!("Canon CRX band {i}: unsupported geometry or corrupt entropy")))? + }; + for (position, value) in data.iter_mut().enumerate() { + let step = match band.quant { + Quantization::Uniform(value) => quant_step(value)?, + Quantization::Adaptive { base, gain } => { + adaptive_step(qp.ok_or_else(|| corrupt("missing QP map"))?, i, position % w, position / w, base, gain)? + } + }; + *value = value.checked_mul(step).ok_or_else(|| corrupt("dequantized coefficient overflow"))?; + } + bands.push(WaveletBand { width: w, height: h, data }); + } + crx_wavelet::reconstruct(&bands, width, height, 3, edges) +} + +fn adaptive_step(map: &QpMap, band: usize, x: usize, y: usize, base: u32, gain: u32) -> Result { + let step = if gain == 0 { + u64::from(base) + } else { + let (row, column) = if (4..=6).contains(&band) { + (y * 2, x / 2) + } else if (7..=9).contains(&band) { + (y, x / 4) + } else { + return Err(RawError::Unsupported("Canon CRX adaptive low-frequency quantization".into())); + }; + if row >= map.height || column >= map.width { + return Err(corrupt("QP coordinate outside map")); + } + let index = row.checked_mul(map.width).and_then(|n| n.checked_add(column)).ok_or(RawError::Limit("CRX QP map index"))?; + let first = *map.data.get(index).ok_or_else(|| corrupt("QP coordinate outside map"))?; + let value = if band < 7 { + let second_row = row + 1; + let second = *map.data.get(second_row * map.width + column).ok_or_else(|| corrupt("QP average outside map"))?; + (first + second) / 2 + } else { + first + }; + // 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 + }; + i32::try_from(step.max(1)).map_err(|_| corrupt("adaptive quantization step outside precision")) +} + +struct Header<'a> { + src: &'a [u8], + at: usize, +} + +impl<'a> Header<'a> { + fn marker(&mut self, wanted: u16, wanted_size: usize) -> Result<&'a [u8]> { + let marker = be16(self.src, self.at)?; + if marker == wanted + 0x10 { + return Err(RawError::Unsupported("Canon CRX ff11/ff12/ff13 marker family".into())); + } + if marker != wanted { + return Err(corrupt("unexpected tile/plane/band marker")); + } + let size = usize::from(be16(self.src, self.at + 2)?); + if size != wanted_size { + return Err(RawError::Unsupported(format!("Canon CRX marker {marker:#x} header length {size}"))); + } + let start = self.at.checked_add(4).ok_or(RawError::Limit("CRX header offset"))?; + let end = start.checked_add(size).ok_or(RawError::Limit("CRX header offset"))?; + let body = self.src.get(start..end).ok_or_else(|| corrupt("truncated marker header"))?; + self.at = end; + Ok(body) + } +} + +fn be16(src: &[u8], at: usize) -> Result { + let end = at.checked_add(2).ok_or(RawError::Limit("CRX header offset"))?; + let bytes = src.get(at..end).and_then(|s| <[u8; 2]>::try_from(s).ok()).ok_or_else(|| corrupt("truncated header field"))?; + Ok(u16::from_be_bytes(bytes)) +} + +fn be32(src: &[u8], at: usize) -> Result { + let end = at.checked_add(4).ok_or(RawError::Limit("CRX header offset"))?; + let bytes = src.get(at..end).and_then(|s| <[u8; 4]>::try_from(s).ok()).ok_or_else(|| corrupt("truncated header field"))?; + Ok(u32::from_be_bytes(bytes)) +} + +struct Bits<'a> { + src: &'a [u8], + at: usize, +} + +impl Bits<'_> { + /// The next 32 bits, MSB first, when at least that many remain before the end of the stream. + fn peek32(&self) -> Option { + let bytes = self.src.get(self.at / 8..self.at / 8 + 5)?; + let word = u64::from_be_bytes([0, 0, 0, bytes[0], bytes[1], bytes[2], bytes[3], bytes[4]]); + Some((word >> (8 - self.at % 8)) as u32) + } + + fn take(&mut self, count: u32) -> Result { + if count > 24 { + return Err(corrupt("invalid Rice parameter")); + } + if count == 0 { + return Ok(0); + } + if let Some(window) = self.peek32() { + self.at += count as usize; + return Ok(window >> (32 - count)); + } + // Near the end of the stream: bit by bit, so truncation is reported exactly. + let mut value = 0; + for _ in 0..count { + let byte = self.src.get(self.at / 8).ok_or_else(|| corrupt("truncated entropy stream"))?; + value = (value << 1) | u32::from((byte >> (7 - self.at % 8)) & 1); + self.at += 1; + } + Ok(value) + } + + fn rice(&mut self, k: u32) -> Result { + // Common case: the unary prefix and its terminating 1 lie within the next 32 bits. + if let Some(window) = self.peek32().filter(|&w| w != 0) { + let zeros = window.leading_zeros(); + self.at += zeros as usize + 1; + return if k <= 20 { Ok((zeros << k) | self.take(k)?) } else { Err(corrupt("Rice parameter exceeds sample precision")) }; + } + let mut zeros = 0; + while self.take(1)? == 0 { + zeros += 1; + if zeros > 41 { + return Err(corrupt("Rice escape prefix exceeds 41 bits")); + } + } + if zeros == 41 { + self.take(21) + } else if k <= 20 { + Ok((zeros << k) | self.take(k)?) + } else { + Err(corrupt("Rice parameter exceeds sample precision")) + } + } + + fn qp_rice(&mut self, k: u32) -> Result { + let mut zeros = 0; + while zeros < 16 && self.take(1)? == 0 { + zeros += 1; + } + if zeros == 16 { + // QP escape has no unary terminating 1 before the 16-bit literal. + self.take(16) + } else if k <= 20 { + Ok((zeros << k) | self.take(k)?) + } else { + Err(corrupt("QP Rice parameter exceeds precision")) + } + } + + fn finish(&self) -> Result<()> { + let bits = self.src.len().checked_mul(8).ok_or(RawError::Limit("CRX entropy length"))?; + if bits.saturating_sub(self.at) > 7 { + return Err(corrupt("unconsumed entropy data")); + } + if let Some(&last) = self.src.last() { + let remaining = (bits - self.at) as u32; + if u32::from(last) & ((1 << remaining) - 1) != 0 { + return Err(corrupt("nonzero entropy alignment bits")); + } + } + Ok(()) + } +} + +fn signed(symbol: u32) -> i32 { + (symbol >> 1) as i32 ^ -((symbol & 1) as i32) +} + +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) +} + +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 + } +} + +fn run(bits: &mut Bits<'_>, index: &mut usize, remaining: usize) -> Result { + let mut length = 1usize; + while length < remaining { + let exponent = u32::from(*RUN_EXPONENT.get(*index).ok_or_else(|| corrupt("run index outside table"))?); + if bits.take(1)? == 0 { + length += bits.take(exponent)? as usize; + *index = index.saturating_sub(1); + if length > remaining { + return Err(corrupt("run exceeds row")); + } + break; + } + length += 1usize << exponent; + if length <= remaining { + *index = (*index + 1).min(RUN_EXPONENT.len() - 1); + } + } + Ok(length.min(remaining)) +} + +fn lossless_plane(src: &[u8], width: usize, height: usize) -> Result> { + let count = width + .checked_mul(height) + .filter(|&n| width > 0 && n > 0 && n <= MAX_SAMPLES.min(MAX_CRX_SAMPLES) / 4) + .ok_or(RawError::Limit("CRX plane size"))?; + // Grows per decoded row: a truncated stream fails before the whole plane is reserved. + let mut output = Vec::new(); + let mut previous = Vec::new(); + previous.try_reserve_exact(width).map_err(|_| RawError::Limit("CRX row allocation"))?; + previous.resize(width, 0i32); + let mut current = Vec::new(); + current.try_reserve_exact(width).map_err(|_| RawError::Limit("CRX row allocation"))?; + current.resize(width, 0i32); + let (mut k, mut run_index) = (0u32, 0usize); + let mut bits = Bits { src, at: 0 }; + for y in 0..height { + let mut x = 0; + let mut left = previous.first().copied().ok_or_else(|| corrupt("empty plane row"))?; + while x < width { + let mut interrupted = false; + let above = *previous.get(x).ok_or_else(|| corrupt("above sample outside row"))?; + let right = previous.get(x + 1).copied().unwrap_or(above); + if x + 1 < width && left == above && left == right && bits.take(1)? != 0 { + let length = run(&mut bits, &mut run_index, width - x)?; + let end = x.checked_add(length).ok_or(RawError::Limit("CRX run length"))?; + current.get_mut(x..end).ok_or_else(|| corrupt("run outside row"))?.fill(left); + x = end; + if x == width { + continue; + } + // A run interruption always uses regular prediction; no second run flag. + interrupted = true; + } + let above = *previous.get(x).ok_or_else(|| corrupt("above sample outside row"))?; + let corner = if x == 0 { above } else { *previous.get(x - 1).ok_or_else(|| corrupt("corner sample outside row"))? }; + let right = previous.get(x + 1).copied().unwrap_or(above); + let symbol = bits.rice(k)?; + let prediction = if interrupted { above } else { median(left, above, corner) }; + let value = prediction + .checked_add(signed(symbol)) + .filter(|v| (-1_048_576..=1_048_575).contains(v)) + .ok_or_else(|| RawError::Corrupt(format!("Canon CRX prediction outside precision at row {y}, column {x}, k {k}, symbol {symbol}")))?; + *current.get_mut(x).ok_or_else(|| corrupt("decoded sample outside row"))? = value; + left = value; + let estimate = if y > 0 && x + 1 < width { + symbol.checked_add(2 * right.abs_diff(above)).ok_or_else(|| corrupt("Rice estimate overflow"))? / 2 + } else { + symbol + }; + k = update_rice(k, estimate); + x += 1; + } + output.try_reserve(width).map_err(|_| RawError::Limit("CRX plane allocation"))?; + output.extend_from_slice(¤t); + std::mem::swap(&mut current, &mut previous); + } + bits.finish()?; + debug_assert_eq!(output.len(), count); + Ok(output) +} + +fn high_frequency_band(src: &[u8], width: usize, height: usize) -> Result> { + let count = width + .checked_mul(height) + .filter(|&n| width > 0 && n > 0 && n <= MAX_SAMPLES.min(MAX_CRX_SAMPLES) / 4) + .ok_or(RawError::Limit("CRX high-frequency band size"))?; + // Grows per decoded row, as in `lossless_plane`. + let mut output = Vec::new(); + let row = || -> Result> { + let mut values = Vec::new(); + values.try_reserve_exact(width).map_err(|_| RawError::Limit("CRX row allocation"))?; + values.resize(width, 0); + Ok(values) + }; + let (mut previous, mut current) = (row()?, row()?); + let (mut previous_k, mut current_k) = (row()?, row()?); + let (mut k, mut run_index) = (0u32, 0usize); + let mut bits = Bits { src, at: 0 }; + for y in 0..height { + let (mut x, mut left) = (0, 0); + while x < width { + let above = *previous.get(x).ok_or_else(|| corrupt("above coefficient outside row"))?; + 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 bits.take(1)? != 0 { + let length = run(&mut bits, &mut run_index, width - x)?; + let end = x.checked_add(length).ok_or(RawError::Limit("CRX run length"))?; + current.get_mut(x..end).ok_or_else(|| corrupt("coefficient run outside row"))?.fill(0); + // The upper-row context for a zero run is zero, although the + // current Rice parameter remains unchanged across the run. + current_k.get_mut(x..end).ok_or_else(|| corrupt("Rice context run outside row"))?.fill(0); + left = 0; + x = end; + if x == width { + continue; + } + } + nonzero = true; + } + let coded = bits.rice(k)?; + // A zero-run flag 0 or its interruption proves that the coefficient + // is nonzero. That context omits zero from the signed mapping. + let symbol = coded.checked_add(u32::from(nonzero)).ok_or_else(|| corrupt("coefficient symbol overflow"))?; + let value = signed(symbol); + if !(-1_048_576..=1_048_575).contains(&value) { + return Err(corrupt("high-frequency coefficient outside precision")); + } + *current.get_mut(x).ok_or_else(|| corrupt("coefficient outside row"))? = value; + left = value; + k = update_rice(k, coded); + if y > 0 && x + 1 < width { + let upper_right_k = *previous_k.get(x + 1).ok_or_else(|| corrupt("Rice context outside row"))? as u32; + if upper_right_k > k + 1 { + k += 1; + } + } + *current_k.get_mut(x).ok_or_else(|| corrupt("Rice context outside row"))? = k as i32; + x += 1; + } + output.try_reserve(width).map_err(|_| RawError::Limit("CRX coefficient allocation"))?; + output.extend_from_slice(¤t); + std::mem::swap(&mut current, &mut previous); + std::mem::swap(&mut current_k, &mut previous_k); + } + bits.finish()?; + debug_assert_eq!(output.len(), count); + Ok(output) +} + +fn qp_map(src: &[u8], width: usize, height: usize) -> Result { + if !height.is_multiple_of(2) { + return Err(RawError::Unsupported("Canon CRX adaptive QP map with an odd height".into())); + } + let count = width.checked_mul(height).filter(|&n| width > 0 && n > 0 && n <= MAX_CRX_SAMPLES / 32).ok_or(RawError::Limit("CRX QP map size"))?; + let mut data = Vec::new(); + data.try_reserve_exact(count).map_err(|_| RawError::Limit("CRX QP allocation"))?; + let row = || -> Result> { + let mut values = Vec::new(); + values.try_reserve_exact(width).map_err(|_| RawError::Limit("CRX QP row allocation"))?; + values.resize(width, 0); + Ok(values) + }; + let (mut previous, mut current) = (row()?, row()?); + let mut bits = Bits { src, at: 0 }; + let mut k = 0; + for y in 0..height { + let mut left = *previous.first().ok_or_else(|| corrupt("empty QP row"))?; + for x in 0..width { + let above = *previous.get(x).ok_or_else(|| corrupt("above QP outside row"))?; + let corner = if x == 0 { above } else { *previous.get(x - 1).ok_or_else(|| corrupt("corner QP outside row"))? }; + let right = previous.get(x + 1).copied().unwrap_or(above); + let symbol = bits.qp_rice(k)?; + let value = median(left, above, corner).checked_add(signed(symbol)).ok_or_else(|| corrupt("QP prediction overflow"))?; + if !(131..=167).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; + left = value; + let estimate = if y > 0 && x + 1 < width { + symbol.checked_add(2 * right.abs_diff(above)).ok_or_else(|| corrupt("QP Rice estimate overflow"))? / 2 + } else { + symbol + }; + k = update_rice(k, estimate); + } + data.extend_from_slice(¤t); + std::mem::swap(&mut current, &mut previous); + } + bits.finish()?; + Ok(QpMap { width, height, data }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn coding() -> Cr3Compression { + Cr3Compression { + version: 0x100, + width: 4, + height: 4, + tile_width: 4, + tile_height: 4, + bit_depth: 14, + planes: 4, + cfa_pattern: 0, + encoding: 0, + levels: 0, + tile_flags: 0, + header_size: 108, + median_bit_depth: None, + } + } + + fn marker(out: &mut Vec, 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()); + } + + fn flat_sample() -> Vec { + let mut out = Vec::new(); + marker(&mut out, 0xff01, 4, 0); + for p in 0..4 { + marker(&mut out, 0xff02, 1, (p << 28) | 0x0800_0000); + marker(&mut out, 0xff03, 1, 0x0020_0000); + } + // Four 2×2 planes at coefficient 1. Row 0: zero-run flag 0, Rice(2,k0)=001, + // Rice(0,k0)=1. Row 1: nonzero-run flag 1, MELCODE(RL−1=1)=1. Zero alignment. + out.extend([0x1e; 4]); + out + } + + #[test] + fn sensor_mosaic_from_four_lossless_planes() { + let result = decode(&flat_sample(), &coding()).unwrap(); + assert_eq!(result, vec![8193; 16]); + } + + #[test] + fn med_signed_mapping_and_adaptation() { + assert_eq!([signed(0), signed(1), signed(2), signed(3)], [0, -1, 1, -2]); + assert_eq!(median(-7, -8, -9), -7); + assert_eq!(median(-7, -8, -6), -8); + assert_eq!(median(10, 20, 14), 16); + assert_eq!(update_rice(0, 12_289), 2); + assert_eq!(update_rice(2, 58), 4); + assert_eq!(update_rice(4, 7), 3); + assert_eq!(update_rice(4, 8), 4); + } + + #[test] + fn interrupted_run_predicts_from_above() { + // First row [5, 5, 8, 9]. The next row runs three 5s, so interruption at + // column 3 must predict the above sample 9, then add −2 to obtain 7. + assert_eq!(lossless_plane(&[0x00, 0x18, 0x2d, 0xdc], 4, 2).unwrap(), [5, 5, 8, 9, 5, 5, 5, 7]); + } + + #[test] + fn partial_final_run_segment_keeps_its_index() { + let mut bits = Bits { src: &[0xc0], at: 0 }; + let mut index = 11; + assert_eq!(run(&mut bits, &mut index, 6).unwrap(), 6); + assert_eq!(index, 12); + assert_eq!(bits.at, 2); + } + + #[test] + fn every_truncated_header_is_an_error() { + let sample = flat_sample(); + for n in 0..sample.len() { + assert!(decode(&sample[..n], &coding()).is_err(), "prefix {n}"); + } + } + + #[test] + fn inconsistent_parent_sizes_and_indices_are_errors() { + let original = flat_sample(); + for (at, mask) in [(7, 1), (9, 1), (19, 1), (20, 0x10), (31, 1), (32, 0x10)] { + let mut broken = original.clone(); + broken[at] ^= mask; + assert!(decode(&broken, &coding()).is_err(), "offset {at}"); + } + } + + #[test] + fn entropy_truncation_and_invalid_alignment_are_errors() { + assert!(lossless_plane(&[], 2, 2).is_err()); + assert!(lossless_plane(&[0x1f], 2, 2).is_err()); + assert!(lossless_plane(&[0x1e, 0], 2, 2).is_err()); + assert!(lossless_plane(&[0; 8], 2, 2).is_err()); + } + + #[test] + fn high_frequency_zero_runs_and_nonzero_mapping() { + assert_eq!(high_frequency_band(&[0xf0], 2, 2).unwrap(), [0, 0, 0, 0]); + assert_eq!(high_frequency_band(&[0x34, 0x80], 2, 2).unwrap(), [1, 0, -1, 1]); + assert!(high_frequency_band(&[0x34], 2, 2).is_err()); + assert!(high_frequency_band(&[0xf1], 2, 2).is_err()); + } + + #[test] + fn unsupported_wavelet_variant_is_explicit() { + let mut config = coding(); + config.levels = 2; + assert!(matches!(decode(&flat_sample(), &config), Err(RawError::Unsupported(_)))); + assert!(matches!(validate(&flat_sample(), &config), Err(RawError::Unsupported(_)))); + } + + #[test] + fn three_level_craw_reconstructs_sensor_samples() { + let mut config = coding(); + config.width = 16; + config.height = 16; + config.tile_width = 16; + config.tile_height = 16; + config.header_size = 540; + config.levels = 3; + let mut sample = Vec::new(); + marker(&mut sample, 0xff01, 52, 0); + for plane in 0..4 { + marker(&mut sample, 0xff02, 13, (plane << 28) | 0x0800_0000); + for band in 0..10 { + marker(&mut sample, 0xff03, if band >= 7 { 2 } else { 1 }, (band << 28) | 0x0020_0000); + } + } + for _ in 0..4 { + sample.extend([0x80; 4]); // LL3 and the three 1×1 HF3 bands. + sample.extend([0xf0; 3]); // Three 2×2 all-zero HF2 bands. + for _ in 0..3 { + sample.extend([0xff, 0xf8]); // Three 4×4 all-zero HF1 bands. + } + } + validate(&sample, &config).unwrap(); + assert_eq!(decode(&sample, &config).unwrap(), vec![8192; 256]); + for end in 0..sample.len() { + assert!(decode(&sample[..end], &config).is_err()); + } + } + + #[test] + fn craw_overshoot_at_clipped_highlights_is_clamped_to_the_bit_depth() { + // As `three_level_craw_reconstructs_sensor_samples`, but each LL3 coefficient is +8200 + // (Rice escape: 41 zeros, 1, 21-bit literal 16400 = signed +8200). Flat synthesis gives + // 8192 + 8200 = 16392, 9 codes above the 14-bit maximum, as in clipped C-RAW highlights. + let mut config = coding(); + config.width = 16; + config.height = 16; + config.tile_width = 16; + config.tile_height = 16; + config.header_size = 540; + config.levels = 3; + let mut sample = Vec::new(); + marker(&mut sample, 0xff01, 80, 0); + for plane in 0..4 { + marker(&mut sample, 0xff02, 20, (plane << 28) | 0x0800_0000); + for band in 0..10 { + marker(&mut sample, 0xff03, [8, 1, 1, 1, 1, 1, 1, 2, 2, 2][band as usize], (band << 28) | 0x0020_0000); + } + } + for _ in 0..4 { + sample.extend([0, 0, 0, 0, 0, 0x40, 0x80, 0x20]); // LL3 = +8200. + sample.extend([0x80; 3]); // The three 1×1 HF3 bands. + sample.extend([0xf0; 3]); // Three 2×2 all-zero HF2 bands. + for _ in 0..3 { + sample.extend([0xff, 0xf8]); // Three 4×4 all-zero HF1 bands. + } + } + validate(&sample, &config).unwrap(); + assert_eq!(decode(&sample, &config).unwrap(), vec![16383; 256]); + } + + #[test] + fn only_quantized_planes_clamp_out_of_range_samples() { + assert_eq!(sensor_sample(8200, 8192, 16383, true).unwrap(), 16383); + assert_eq!(sensor_sample(-8300, 8192, 16383, true).unwrap(), 0); + assert_eq!(sensor_sample(8191, 8192, 16383, false).unwrap(), 16383); + assert_eq!(sensor_sample(-8192, 8192, 16383, false).unwrap(), 0); + // Lossless planes reproduce the sensor exactly: outside the bit depth is corrupt data. + assert!(matches!(sensor_sample(8192, 8192, 16383, false), Err(RawError::Corrupt(_)))); + assert!(matches!(sensor_sample(-8193, 8192, 16383, false), Err(RawError::Corrupt(_)))); + assert!(matches!(sensor_sample(i32::MAX, 8192, 16383, true), Err(RawError::Corrupt(_)))); + } + + #[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)] { + assert_eq!(quant_step(quant).unwrap(), expected); + } + assert!(matches!(quant_step(3), Err(RawError::Unsupported(_)))); + assert!(matches!(quant_step(44), Err(RawError::Unsupported(_)))); + for (width, height) in [(20_000, 20_000), (65_538, 2), (2, 65_538)] { + let mut config = coding(); + config.width = width; + config.height = height; + assert!(matches!(decode(&[], &config), Err(RawError::Limit(_)))); + assert!(matches!(validate(&[], &config), Err(RawError::Limit(_)))); + } + } + + #[test] + fn qp_escape_and_average_precede_the_quantization_table() { + let mut bits = Bits { src: &[0, 0, 1, 0x2c], at: 0 }; + assert_eq!(bits.qp_rice(0).unwrap(), 300); + assert_eq!(bits.at, 32); + let map = qp_map(&[0, 0, 1, 0x2c, 0x94], 1, 4).unwrap(); + assert_eq!(map.data, [150; 4]); + assert!(qp_map(&[0, 0, 1, 0x2c, 0x95], 1, 4).is_err()); + let map = QpMap { width: 1, height: 2, data: vec![150, 153] }; + // floor((150+153)/2)=151; integer step 14, gain4/8 gives7. Averaging + // the nonlinear steps, or rounding QP upward, incorrectly gives8. + assert_eq!(adaptive_step(&map, 4, 0, 0, 0, 4).unwrap(), 7); + assert_eq!(adaptive_step(&map, 7, 0, 0, 1, 4).unwrap(), 7); + assert_eq!(adaptive_step(&map, 7, 0, 1, 0, 16).unwrap(), 36); + let low = QpMap { width: 1, height: 2, data: vec![131; 2] }; + assert_eq!(adaptive_step(&low, 4, 0, 0, 1, 1).unwrap(), 1); + assert_eq!(adaptive_step(&low, 4, 0, 0, 0, 1).unwrap(), 1); + assert_eq!(adaptive_step(&low, 0, 0, 0, 0, 0).unwrap(), 1); + assert!(matches!(qp_map(&[0, 0, 1, 4, 0x94], 1, 4), Err(RawError::Unsupported(_)))); + } + + #[test] + fn adaptive_v200_craw_reconstructs_sensor_samples() { + let mut config = coding(); + config.version = 0x200; + config.width = 16; + config.height = 16; + config.tile_width = 16; + config.tile_height = 16; + config.header_size = 872; + config.levels = 3; + let mut sample = Vec::new(); + sample.extend(0xff11u16.to_be_bytes()); + sample.extend(16u16.to_be_bytes()); + sample.extend(60u32.to_be_bytes()); + sample.extend(0x4000u32.to_be_bytes()); + sample.extend(5u32.to_be_bytes()); + sample.extend(3u16.to_be_bytes()); + sample.extend(0u16.to_be_bytes()); + let gains = [0, 0, 0, 0, 4, 4, 8, 8, 8, 16]; + for plane in 0..4 { + marker(&mut sample, 0xff12, 13, (plane << 28) | 0x0800_0000); + for (band, &gain) in gains.iter().enumerate() { + sample.extend(0xff13u16.to_be_bytes()); + sample.extend(16u16.to_be_bytes()); + sample.extend(if band >= 7 { 2u32 } else { 1 }.to_be_bytes()); + sample.extend(((band as u32) << 28 | gain).to_be_bytes()); + sample.extend(u32::from(band < 4).to_be_bytes()); + sample.extend([0u8; 4]); + } + } + sample.extend([0u8; 4]); + sample.extend([0, 0, 1, 0x2c, 0x94, 0, 0, 0]); + for _ in 0..4 { + sample.extend([0x80; 4]); + sample.extend([0xf0; 3]); + for _ in 0..3 { + sample.extend([0xff, 0xf8]); + } + } + validate(&sample, &config).unwrap(); + assert_eq!(decode(&sample, &config).unwrap(), vec![8192; 256]); + for end in 0..sample.len() { + assert!(decode(&sample[..end], &config).is_err()); + } + } + + #[test] + fn optional_cc0_samples_match_external_oracle() { + let Some(directory) = std::env::var_os("LIGHTCRAFT_CR3_CORPUS") else { return }; + for (name, expected) in [ + ("cr3-canon-r100-raw.cr3", 0xcbe5299ab9c52630u64), + ("cr3-canon-m50-raw.cr3", 0x62261f0ba81cfcd2u64), + ("cr3-canon-r100-craw.cr3", 0x341c706b37c38bbfu64), + ("cr3-canon-m50-craw.cr3", 0x9aacbfe66f505e1bu64), + ("cr3-canon-r8-raw.cr3", 0x63c1c6d8d1eb2312u64), + ("cr3-canon-r8-craw.cr3", 0xf836a663a795a375u64), + ] { + let path = std::path::Path::new(&directory).join(name); + let bytes = std::fs::read(path).unwrap(); + let container = lightcraft_meta::cr3::parse_cr3(&bytes).unwrap(); + let mut candidates = container + .tracks + .iter() + .filter_map(|track| { + let lightcraft_meta::cr3::Cr3TrackKind::Raw { cmp1: Some((at, length)), .. } = track.kind else { return None }; + let coding = Cr3Compression::parse(bytes.get(at..at + length)?)?; + Some((u64::from(coding.width) * u64::from(coding.height), coding, track.data?)) + }) + .collect::>(); + candidates.sort_by_key(|entry| entry.0); + let (_, coding, (at, length)) = candidates.last().unwrap(); + let sensor = decode(&bytes[*at..at + length], coding).unwrap(); + let hash = sensor + .iter() + .flat_map(|value| value.to_le_bytes()) + .fold(0xcbf29ce484222325u64, |hash, byte| (hash ^ u64::from(byte)).wrapping_mul(0x100000001b3)); + assert_eq!(hash, expected, "{name}"); + } + } +} diff --git a/crates/raw/src/vendor/crx_wavelet.rs b/crates/raw/src/vendor/crx_wavelet.rs new file mode 100644 index 000000000..ac5e51d1d --- /dev/null +++ b/crates/raw/src/vendor/crx_wavelet.rs @@ -0,0 +1,338 @@ +//! Reversible integer Le Gall 5/3 synthesis for Canon CRX subbands. +//! +//! Independent implementation of the lifting equations in ITU-T T.800, Annex F; +//! the equations are also printed in US20030031370A1, paragraph 0047. +//! Each tile level has even local origin. Internal boundaries retain the +//! neighbouring low/high coefficients; only outer image edges are symmetric. + +use crate::{RawError, Result}; + +#[derive(Clone, Debug)] +pub(crate) struct Band { + pub width: usize, + pub height: usize, + pub data: Vec, +} + +fn corrupt() -> RawError { + RawError::Corrupt("invalid CRX wavelet subband geometry".into()) +} + +fn checked_sample(v: i64) -> Result { + i32::try_from(v).map_err(|_| RawError::Corrupt("CRX wavelet coefficient overflow".into())) +} + +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct TileEdges { + pub left: bool, + pub right: bool, + pub top: bool, + pub bottom: bool, +} + +fn sizes(length: usize, before: bool, after: bool) -> (usize, usize) { + (length.div_ceil(2) + usize::from(after && length.is_multiple_of(2)), length / 2 + usize::from(before) + usize::from(after)) +} + +fn inverse_line(low: &[i32], high: &[i32], out: &mut [i32], before: bool, after: bool) -> Result<()> { + let (low_count, high_count) = sizes(out.len(), before, after); + if out.is_empty() || low.len() != low_count || high.len() != high_count { + return Err(corrupt()); + } + if high.is_empty() { + let value = low.first().copied().ok_or_else(corrupt)?; + *out.first_mut().ok_or_else(corrupt)? = value; + return Ok(()); + } + // The high-pass storage starts at -1 when there is a preceding tile. + // Only absent external neighbours are reflected at the image boundary. + let high_at = |index: usize| -> Result { high.get(index.min(high.len().saturating_sub(1))).copied().ok_or_else(corrupt) }; + let even = |i: usize| -> Result { + let left = high_at((i + usize::from(before)).saturating_sub(1))?; + let right = high_at(i + usize::from(before))?; + let update = (i64::from(left) + i64::from(right) + 2).div_euclid(4); + checked_sample(i64::from(*low.get(i).ok_or_else(corrupt)?) - update) + }; + for i in 0..out.len().div_ceil(2) { + *out.get_mut(i * 2).ok_or_else(corrupt)? = even(i)?; + } + for i in 0..out.len() / 2 { + let left = out.get(i * 2).copied().ok_or_else(corrupt)?; + let right = match out.get(i * 2 + 2).copied() { + Some(value) => value, + None if after => even(i + 1)?, + None => left, + }; + let prediction = (i64::from(left) + i64::from(right)).div_euclid(2); + let value = high_at(i + usize::from(before))?; + *out.get_mut(i * 2 + 1).ok_or_else(corrupt)? = checked_sample(i64::from(value) + prediction)?; + } + Ok(()) +} + +fn valid_band(band: &Band, width: usize, height: usize) -> bool { + band.width == width && band.height == height && width.checked_mul(height) == Some(band.data.len()) +} + +/// A zeroed buffer, or a limit error instead of an abort when the allocation fails. +fn zeroed(count: usize) -> Result> { + let mut v = Vec::new(); + v.try_reserve_exact(count).map_err(|_| RawError::Limit("CRX wavelet allocation"))?; + v.resize(count, 0); + Ok(v) +} + +/// One inverse level. `HL` is horizontally high-pass, `LH` vertically high-pass. +pub(crate) fn inverse_level(ll: &Band, hl: &Band, lh: &Band, hh: &Band, width: usize, height: usize, edges: TileEdges) -> Result { + let count = width.checked_mul(height).filter(|n| *n > 0).ok_or_else(corrupt)?; + if count > crate::MAX_SAMPLES { + return Err(RawError::Limit("CRX wavelet image size")); + } + let (lw, hw) = sizes(width, edges.left, edges.right); + let (lhgt, hhgt) = sizes(height, edges.top, edges.bottom); + if !valid_band(ll, lw, lhgt) || !valid_band(hl, hw, lhgt) || !valid_band(lh, lw, hhgt) || !valid_band(hh, hw, hhgt) { + return Err(corrupt()); + } + let intermediate_count = width.checked_mul(lhgt + hhgt).filter(|n| *n <= crate::MAX_SAMPLES).ok_or(RawError::Limit("CRX wavelet image size"))?; + let mut temporary = zeroed(intermediate_count)?; + // CRX analysis is vertical then horizontal. Integer rounding means that + // synthesis must reverse that order, including for quantized subbands. + for y in 0..lhgt + hhgt { + let (lo, hi, by) = if y < lhgt { (ll, hl, y) } else { (lh, hh, y - lhgt) }; + inverse_line( + lo.data.get(by * lw..(by + 1) * lw).ok_or_else(corrupt)?, + hi.data.get(by * hw..(by + 1) * hw).ok_or_else(corrupt)?, + temporary.get_mut(y * width..(y + 1) * width).ok_or_else(corrupt)?, + edges.left, + edges.right, + )?; + } + let mut data = zeroed(count)?; + let mut low = zeroed(lhgt)?; + let mut high = zeroed(hhgt)?; + let mut column = zeroed(height)?; + for x in 0..width { + for (y, value) in low.iter_mut().enumerate() { + *value = temporary.get(y * width + x).copied().ok_or_else(corrupt)?; + } + for (y, value) in high.iter_mut().enumerate() { + *value = temporary.get((y + lhgt) * width + x).copied().ok_or_else(corrupt)?; + } + inverse_line(&low, &high, &mut column, edges.top, edges.bottom)?; + for (y, &value) in column.iter().enumerate() { + *data.get_mut(y * width + x).ok_or_else(corrupt)? = value; + } + } + Ok(Band { width, height, data }) +} + +/// Subbands in CRX order: LLn, HLn, LHn, HHn, ... HL1, LH1, HH1. +pub(crate) fn reconstruct(bands: &[Band], width: usize, height: usize, levels: u8, edges: TileEdges) -> Result> { + if levels > 3 || bands.len() != 1 + usize::from(levels) * 3 || width == 0 || height == 0 { + return Err(corrupt()); + } + let count = width.checked_mul(height).ok_or_else(corrupt)?; + if count > crate::MAX_SAMPLES { + return Err(RawError::Limit("CRX wavelet image size")); + } + let mut dimensions = vec![(width, height)]; + for _ in 0..levels { + let &(w, h) = dimensions.last().ok_or_else(corrupt)?; + dimensions.push((sizes(w, edges.left, edges.right).0, sizes(h, edges.top, edges.bottom).0)); + } + let &(low_width, low_height) = dimensions.last().ok_or_else(corrupt)?; + if !bands.first().is_some_and(|b| valid_band(b, low_width, low_height)) { + return Err(corrupt()); + } + let mut ll = bands.first().cloned().ok_or_else(corrupt)?; + for step in 0..usize::from(levels) { + let &(w, h) = dimensions.get(usize::from(levels) - step - 1).ok_or_else(corrupt)?; + let index = 1 + step * 3; + ll = inverse_level( + &ll, + bands.get(index).ok_or_else(corrupt)?, + bands.get(index + 1).ok_or_else(corrupt)?, + bands.get(index + 2).ok_or_else(corrupt)?, + w, + h, + edges, + )?; + } + if !valid_band(&ll, width, height) { + return Err(corrupt()); + } + Ok(ll.data) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn integer_lifting_preserves_signed_rounding_and_symmetric_ends() { + let mut out = [0; 9]; + inverse_line(&[6, 11, 8, 6, 3], &[2, 2, -1, -2], &mut out, false, false).unwrap(); + assert_eq!(out, [5, 9, 10, 11, 8, 6, 7, 3, 4]); + let mut out = [0; 2]; + inverse_line(&[25], &[20], &mut out, false, false).unwrap(); + assert_eq!(out, [15, 35]); + let mut out = [0]; + inverse_line(&[-27], &[], &mut out, false, false).unwrap(); + assert_eq!(out, [-27]); + } + + #[test] + fn two_dimensional_subband_order() { + let band = |value| Band { width: 1, height: 1, data: vec![value] }; + let out = reconstruct(&[band(25), band(10), band(20), band(0)], 2, 2, 1, TileEdges::default()).unwrap(); + assert_eq!(out, [10, 20, 30, 40]); + } + + #[test] + fn invalid_geometry_and_overflow_are_errors() { + let mut out = [0; 2]; + assert!(inverse_line(&[], &[0], &mut out, false, false).is_err()); + assert!(inverse_line(&[i32::MAX], &[i32::MIN], &mut out, false, false).is_err()); + let b = Band { width: 1, height: 1, data: vec![0] }; + assert!(inverse_level(&b, &b, &b, &b, usize::MAX, 2, TileEdges::default()).is_err()); + assert!(reconstruct(&[b], 2, 2, 0, TileEdges::default()).is_err()); + } + + // Independent forward lifting in tests checks all odd/even boundary + // combinations; hand-computed vectors above also establish sign/order. + fn forward_line(input: &[i32]) -> (Vec, Vec) { + let high: Vec<_> = (0..input.len() / 2) + .map(|i| input[i * 2 + 1] - (input[i * 2] + input.get(i * 2 + 2).copied().unwrap_or(input[i * 2])).div_euclid(2)) + .collect(); + let low = input + .iter() + .step_by(2) + .enumerate() + .map(|(i, &v)| { + if high.is_empty() { + v + } else { + v + (high[i.saturating_sub(1)] + high.get(i).copied().unwrap_or(high[high.len() - 1]) + 2).div_euclid(4) + } + }) + .collect(); + (low, high) + } + + #[test] + fn every_boundary_parity_round_trips_signed_coefficients() { + for width in 1..=17usize { + for height in 1..=17usize { + let input: Vec = (0..width * height).map(|i| ((i * 193 + i * i * 17) % 1001) as i32 - 500).collect(); + let (lw, hw, lhgt, hhgt) = (width.div_ceil(2), width / 2, height.div_ceil(2), height / 2); + let mut vertical = vec![0; width * height]; + for x in 0..width { + let column: Vec<_> = (0..height).map(|y| input[y * width + x]).collect(); + let (lo, hi) = forward_line(&column); + for (y, value) in lo.into_iter().chain(hi).enumerate() { + vertical[y * width + x] = value; + } + } + let mut bands = [ + Band { width: lw, height: lhgt, data: vec![0; lw * lhgt] }, + Band { width: hw, height: lhgt, data: vec![0; hw * lhgt] }, + Band { width: lw, height: hhgt, data: vec![0; lw * hhgt] }, + Band { width: hw, height: hhgt, data: vec![0; hw * hhgt] }, + ]; + for y in 0..height { + let (lo, hi) = forward_line(&vertical[y * width..(y + 1) * width]); + let (b, by) = if y < lhgt { (0, y) } else { (2, y - lhgt) }; + bands[b].data[by * lw..(by + 1) * lw].copy_from_slice(&lo); + bands[b + 1].data[by * hw..(by + 1) * hw].copy_from_slice(&hi); + } + assert_eq!(reconstruct(&bands, width, height, 1, TileEdges::default()).unwrap(), input, "{width}x{height}"); + } + } + } + // Independently generated from ITU-T T.800 integer 5/3 forward equations. + // Original40x9 samples: ((x*23+y*37+x*y*7)%101)-50. + // Tile width20 creates the same level3phase4 mismatch as R1001572. + fn left_halo_fixture() -> Vec { + vec![ + Band { width: 4, height: 2, data: vec![1, 1, -4, 1, 20, -2, -1, 2] }, + Band { width: 4, height: 2, data: vec![35, 21, -2, 19, -24, -10, -7, -2] }, + Band { width: 4, height: 1, data: vec![31, -16, -19, -7] }, + Band { width: 4, height: 1, data: vec![22, 9, 20, 18] }, + Band { width: 6, height: 3, data: vec![24, -34, -55, -21, 2, 7, 25, 5, -3, 14, 31, 14, 24, 37, 55, 19, -42, -41] }, + Band { width: 6, height: 2, data: vec![9, -21, 20, -4, 31, -6, -32, -3, 7, 31, 14, -11] }, + Band { width: 6, height: 2, data: vec![3, 62, 40, 50, 50, 51, -3, -62, -38, -45, 50, 35] }, + Band { + width: 11, + height: 5, + data: vec![ + 13, -38, -12, 13, -50, -13, 13, 39, 25, -38, 38, -44, 6, 0, -44, -50, 57, 0, -6, 38, 19, -13, 26, 26, 39, -37, -62, -37, -37, + -25, -25, -44, -44, 6, -44, -12, 57, 39, -44, 13, 32, -18, 0, 57, -38, 13, 13, -13, 51, 13, -12, -38, 13, 13, -13, + ], + }, + Band { + width: 11, + height: 4, + data: vec![ + 13, 39, 0, -25, 6, -6, 26, 0, -43, 20, -25, -50, 26, 7, -19, 0, 13, 32, -6, -25, 38, 32, 1, 26, -44, 19, 0, -12, -6, 45, -25, -6, + 32, -37, 39, -12, 26, -6, 6, -25, 0, 39, -12, 26, + ], + }, + Band { + width: 11, + height: 4, + data: vec![ + 26, -75, 76, 25, 0, -25, -75, 76, -50, -75, -25, 0, -101, -75, 0, 0, 51, 76, 101, 0, -51, -25, -101, 0, 25, 51, -50, 0, -25, 0, + 101, 76, 51, -75, 26, -75, -25, 0, 25, 76, -75, 26, -75, -25, + ], + }, + ] + } + fn right_halo_fixture() -> Vec { + vec![ + Band { width: 3, height: 2, data: vec![-12, 8, -5, 0, 4, -2] }, + Band { width: 3, height: 2, data: vec![-10, -5, -15, 8, 6, 5] }, + Band { width: 3, height: 1, data: vec![-10, -14, -10] }, + Band { width: 3, height: 1, data: vec![-17, -14, -25] }, + Band { width: 6, height: 3, data: vec![2, 7, -4, 7, 26, 38, 31, 14, 6, -12, -2, -22, -42, -41, -47, -7, 10, 44] }, + Band { width: 5, height: 2, data: vec![-6, 15, 31, -7, 12, -11, 6, 9, 5, -5] }, + Band { width: 6, height: 2, data: vec![50, 51, -46, -36, -58, -61, 50, 35, 69, 18, -36, -21] }, + Band { + width: 11, + height: 5, + data: vec![ + -38, 38, -13, 13, -38, 39, -25, -38, 63, 13, 49, 19, -13, -57, 70, 25, -6, -12, 32, 0, -44, -51, -44, -44, -50, -37, -37, -25, 1, + -31, -44, -50, -50, 0, 57, -37, -44, 13, 32, -12, 6, 57, -37, -36, 13, -13, 77, 13, -12, -38, -25, -25, -13, 77, -22, + ], + }, + Band { + width: 10, + height: 4, + data: vec![ + -25, 39, 0, -37, 25, -18, 45, -12, -37, 19, 32, -6, 13, 32, -6, 1, -25, 45, -6, 0, 32, 13, 0, -6, 45, -25, -6, 32, 13, 0, 26, 7, + 19, -25, 0, 45, -50, 32, 7, 13, + ], + }, + Band { + width: 11, + height: 4, + data: vec![ + -75, -25, -25, 25, 25, 76, 51, -75, 25, 25, 51, -51, -25, 0, 51, 76, 101, 101, 0, -25, 0, 0, 76, 51, 0, 0, -25, 0, 101, 76, 51, + 0, 0, -75, -25, 51, 25, 76, -75, 51, -50, -25, 51, 0, + ], + }, + ] + } + + #[test] + fn three_levels_keep_internal_tile_halos_and_local_phase() { + for (tile, bands, edges) in [ + (0, left_halo_fixture(), TileEdges { right: true, ..Default::default() }), + (1, right_halo_fixture(), TileEdges { left: true, ..Default::default() }), + ] { + let expected: Vec = + (0..9).flat_map(|y| (tile * 20..tile * 20 + 20).map(move |x| ((x * 23 + y * 37 + x * y * 7) % 101) - 50)).collect(); + assert_eq!(reconstruct(&bands, 20, 9, 3, edges).unwrap(), expected); + assert!(reconstruct(&bands, 20, 9, 3, TileEdges::default()).is_err()); + } + } +} diff --git a/crates/raw/src/vendor/mod.rs b/crates/raw/src/vendor/mod.rs index 2de155ef6..fbbc18eb4 100644 --- a/crates/raw/src/vendor/mod.rs +++ b/crates/raw/src/vendor/mod.rs @@ -2,11 +2,15 @@ pub mod arw; pub mod cr2; +pub mod cr3; +pub mod crx; +pub(crate) mod crx_wavelet; pub mod nef; pub mod nefc; pub mod orf; pub mod pef; pub mod raf; +mod rafc; pub mod rw2; use crate::{BlackLevel, Rect}; diff --git a/crates/raw/src/vendor/nef.rs b/crates/raw/src/vendor/nef.rs index bf386bb4b..8d1898e0f 100644 --- a/crates/raw/src/vendor/nef.rs +++ b/crates/raw/src/vendor/nef.rs @@ -17,6 +17,19 @@ use lightcraft_tiff::{ByteOrder, Ifd, Tiff, makernote}; const WB_RB_LEVELS: u16 = 0x000c; const BLACK_LEVEL: u16 = 0x003d; const LINEARIZATION_TABLE: u16 = 0x0096; +const CROP_AREA: u16 = 0x0045; + +/// Nikon CropArea is `[left, top, width, height]` in sensor pixels (maker-note tag documentation). +/// Keep the active area/CFA origin unchanged: the default crop is applied after demosaicing. +fn default_crop(mn: Option<&makernote::MakerNote>, w: usize, h: usize) -> Rect { + let fallback = Rect::new(0, 0, w, h); + let Some(values) = mn.and_then(|m| m.ifd.u64s(CROP_AREA)) else { return fallback }; + let [x, y, width, height] = values.as_slice() else { return fallback }; + if *width == 0 || *height == 0 || x.checked_add(*width).is_none_or(|v| v > w as u64) || y.checked_add(*height).is_none_or(|v| v > h as u64) { + return fallback; + } + Rect::new(*x as usize, *y as usize, *width as usize, *height as usize) +} fn raw_ifd(tiff: &Tiff) -> Option<&Ifd> { tiff.all_ifds() @@ -80,9 +93,10 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { }; let white = white_from_data(samples, bits); let active_w = trailing_masked_columns(samples, w, h, white); + let crop = default_crop(mn.as_ref(), active_w, h); let mut metadata = lightcraft_meta::from_tiff(&tiff); - metadata.width = Some(active_w as u32); - metadata.height = Some(h as u32); + metadata.width = Some(crop.width as u32); + metadata.height = Some(crop.height as u32); let img = RawImage { format: RawFormat::Nef, width: w, @@ -94,7 +108,7 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { black, white: vec![white], active_area: Rect::new(0, 0, active_w, h), - crop: Rect::new(0, 0, active_w, h), + crop, orientation: Orientation::from_exif(ifd0.u16(t::ORIENTATION).unwrap_or(1)), color: ColorData::default(), wb_multipliers: wb, @@ -181,6 +195,42 @@ mod tests { TiffWriter::new(ByteOrder::Big, false).write(&[ifd0]).unwrap() } + #[test] + fn preview_color_space_is_read_from_the_nikon_note() { + for (value, expected) in [(1, Some(crate::PreviewColorSpace::Srgb)), (2, Some(crate::PreviewColorSpace::AdobeRgb)), (3, None)] { + let mut mn = IfdBuilder::new(); + mn.set(0x001e, Value::Short(vec![value])); + let bytes = nef_with_note(1, 14, vec![vec![0; 128]], 8, 8, 8, Some(mn)); + assert_eq!(crate::embedded_preview_color_space(&bytes), expected); + } + assert_eq!(crate::embedded_preview_color_space(&nef(1, 14, vec![vec![0; 128]], 8, 8, 8)), None); + assert_eq!(crate::embedded_preview_color_space(b"not a TIFF"), None); + } + + #[test] + fn nikon_crop_area_preserves_sensor_origin_and_rejects_invalid_rectangles() { + let (w, h) = (32, 24); + let words: Vec = (0..w * h).flat_map(|i| (100 + i as u16).to_be_bytes()).collect(); + for (values, expected) in [ + (vec![2, 4, 28, 16], Rect::new(2, 4, 28, 16)), + (vec![31, 4, 28, 16], Rect::new(0, 0, 32, 24)), + (vec![2, 4, 0, 16], Rect::new(0, 0, 32, 24)), + (vec![u32::MAX, 4, 28, 16], Rect::new(0, 0, 32, 24)), + (vec![2, 4, 28], Rect::new(0, 0, 32, 24)), + ] { + let mut mn = IfdBuilder::new(); + mn.set(CROP_AREA, Value::Long(values)); + let bytes = nef_with_note(1, 14, vec![words.clone()], w, h, h, Some(mn)); + let raw = crate::decode(&bytes).unwrap(); + assert_eq!(raw.crop, expected); + assert_eq!(raw.active_area, Rect::new(0, 0, 32, 24)); + assert_eq!(raw.metadata.width, Some(expected.width as u32)); + assert_eq!(raw.metadata.height, Some(expected.height as u32)); + let developed = raw.develop(crate::Method::Bilinear).unwrap(); + assert_eq!((developed.width, developed.height), (expected.width, expected.height)); + } + } + #[test] fn black_level_tag_is_in_14_bit_units() { let (w, h) = (16usize, 4usize); diff --git a/crates/raw/src/vendor/nefc.rs b/crates/raw/src/vendor/nefc.rs index c0260d95f..8ac9e73cd 100644 --- a/crates/raw/src/vendor/nefc.rs +++ b/crates/raw/src/vendor/nefc.rs @@ -30,7 +30,13 @@ //! first two pixels of the previous row of the same parity, starting at the seeds of maker note `0x0096`. //! - Lossless (`0x0096` version `0x46`): the decoded values are the samples. Lossy (version `0x44`): they index a //! curve. Type 1 (`0x44 0x10`) stores the full curve; type 2 (`0x44 0x20`, `0x44 0x40`) stores 257 points, -//! one every `2^bits / 256` codes, linearly interpolated. +//! one every `2^bits / 256` codes (`0x20`) or `2^bits / 1024` codes (`0x40`), linearly interpolated. The span +//! was established from the files: in every `0x20` file the first interval of the 257 points rises by +//! `2^bits / 256` (16 at 12 bit, 64 at 14 bit) and in every `0x40` file by `2^bits / 1024` (4 and 16), i.e. +//! the curve is the identity in the dark part, one code per sample. Read with a point every `2^bits / 256` +//! codes instead, the 12 `0x40` files that are not split decoded to values that never exceeded 255 (12 bit) or +//! 1023 (14 bit) and whose darkest 0.01 % sat at a quarter of the black level of maker note `0x003d` (63 against +//! 252 in 12-bit units); with the first-interval step they sit at that black level. //! - Values in the maker note are in the maker note's byte order (newer bodies write little-endian notes). //! //! Not supported (returned as [`RawError::Unsupported`], the embedded preview is used instead): @@ -127,8 +133,10 @@ pub(crate) fn parse_table(t: &[u8], order: ByteOrder, bits: u32) -> Result range || !range.is_multiple_of(n - 1) { return Err(RawError::Unsupported(format!("NEF: {n}-point lossy curve for {bits}-bit data"))); } @@ -355,6 +363,60 @@ mod tests { } } + /// Maker note `0x0096` as bytes, written out by hand: version `0x44 `, four seeds, 257 points (little-endian + /// as newer bodies write it) of a curve whose first 256 points rise by `step` and whose last is the white + /// level, and a zero split value at offset 562. + fn hand_table(v1: u8, seed: u16, step: u16, white: u16) -> Vec { + let mut t = vec![0x44, v1]; + for _ in 0..4 { + t.extend_from_slice(&seed.to_le_bytes()); + } + t.extend_from_slice(&257u16.to_le_bytes()); + for k in 0..256u16 { + t.extend_from_slice(&(k * step).to_le_bytes()); + } + t.extend_from_slice(&white.to_le_bytes()); + t.resize(624, 0); + t + } + + /// Known answers for the `0x44 0x40` curve, from bytes and bit strings written by hand (nothing here uses the + /// decoder's span rule or the test encoder): the first interval of the points rises by `2^bits / 1024`, so + /// the curve is the identity for one code per sample and spans `2^bits / 4` codes. The `0x20` curve of the + /// same sizes keeps its span of all `2^bits` codes. + #[test] + fn lossy_type2_version_0x40_spans_a_quarter_of_the_range() { + // 12 bit: points 0, 4, 8 … 1020, 4095 + let t = parse_table(&hand_table(0x40, 252, 4, 4095), ByteOrder::Little, 12).unwrap(); + let Encoding::Lossy(curve) = &t.encoding else { panic!() }; + assert_eq!(curve.len(), 1025); + assert_eq!(curve[0..6], [0, 1, 2, 3, 4, 5]); + assert_eq!((curve[252], curve[255], curve[1020]), (252, 255, 1020)); + assert_eq!((curve[1021], curve[1022], curve[1023], curve[1024]), (1788, 2557, 3326, 4095)); + // the pixels p0 = 252 + 4, p1 = 252, p2 = p0 - 1, p3 = p1, coded as 011 100 | 11110 | 1110 0 | 11110 + // (LOSSY_12: category 3 = 011, 0 = 11110, 1 = 1110; extra bits 100 = +4, 0 = -1) + let out = decode(&[0x73, 0xdc, 0xf0], 4, 1, 12, &t).unwrap(); + assert_eq!(out, [256, 252, 255, 252]); + // 14 bit: points 0, 16, 32 … 4080, 16383 + let t = parse_table(&hand_table(0x40, 1008, 16, 16383), ByteOrder::Little, 14).unwrap(); + let Encoding::Lossy(curve) = &t.encoding else { panic!() }; + assert_eq!(curve.len(), 4097); + assert_eq!(curve[0..6], [0, 1, 2, 3, 4, 5]); + assert_eq!((curve[1008], curve[4080], curve[4096]), (1008, 4080, 16383)); + // the same pixels coded as 1100 100 | 111110 | 11110 0 | 111110 + // (LOSSY_14: category 3 = 1100, 0 = 111110, 1 = 11110) + let out = decode(&[0xc9, 0xf7, 0x9f, 0x00], 4, 1, 14, &t).unwrap(); + assert_eq!(out, [1012, 1008, 1011, 1008]); + // version 0x20 with the same points is a curve over all 2^bits codes: the points are 16 codes apart + let t = parse_table(&hand_table(0x20, 252, 16, 4095), ByteOrder::Little, 12).unwrap(); + let Encoding::Lossy(curve) = &t.encoding else { panic!() }; + assert_eq!((curve.len(), curve[16], curve[17], curve[4095]), (4097, 16, 17, 4094)); + // 200 points can't be spaced evenly over the 1024 codes of a 12-bit 0x40 table + let mut odd = hand_table(0x40, 252, 4, 4095); + odd[10..12].copy_from_slice(&200u16.to_le_bytes()); + assert!(parse_table(&odd, ByteOrder::Little, 12).is_err()); + } + #[test] fn lossy_type1_full_curve() { let mut t = vec![0x44, 0x10, 0, 10, 0, 10, 0, 10, 0, 10, 0, 5]; diff --git a/crates/raw/src/vendor/orf.rs b/crates/raw/src/vendor/orf.rs index 52f597d2c..59c5f14c4 100644 --- a/crates/raw/src/vendor/orf.rs +++ b/crates/raw/src/vendor/orf.rs @@ -1,10 +1,10 @@ //! Olympus ORF — uncompressed variants. //! -//! Sources: TIFF 6.0 (the `IIRO`/`MMOR` container is a TIFF with a different magic number), the ExifTool -//! Olympus tag-name documentation (maker-note sub-directories `0x2020` CameraSettings — `0x0101/0x0102` -//! PreviewImageStart/Length — and `0x2040` ImageProcessing — `0x0100` WB_RBLevels, `0x0600` BlackLevel2, -//! `0x0612–0x0615` CropLeft/Top/Width/Height) and our own black-box analysis of CC0 samples from raw.pixls.us -//! (E-1, E-400, XZ-2): +//! Sources: TIFF 6.0 (the `IIRO`/`MMOR` container is a TIFF with a different magic number), Exif 2.3 (`CFAPattern`, +//! tag `0xa302`), the ExifTool Olympus tag-name documentation (maker-note sub-directories `0x2020` CameraSettings — +//! `0x0101/0x0102` PreviewImageStart/Length — and `0x2040` ImageProcessing — `0x0100` WB_RBLevels, `0x0600` +//! BlackLevel2, `0x0612–0x0615` CropLeft/Top/Width/Height) and our own black-box analysis of CC0 samples from +//! raw.pixls.us (E-1, E-400, XZ-2): //! //! - 16 bits per sample: little-endian words; some bodies (E-1, E-400) store 12-bit values in the top bits (the low //! four bits zero in more than 99% of samples), which we shift down. @@ -12,14 +12,16 @@ //! candidate bit orders for the smoothest image). //! - Olympus's compressed ORF (most interchangeable-lens bodies since ~2008) is not decoded: no permissively //! licensed description exists. It reports [`RawError::Unsupported`]; the embedded preview still works. -//! - The files carry no CFA tag. The green diagonal is found from the data (the diagonal whose two samples differ -//! least); bodies with greens on the main diagonal (E-1, E-400) are GRBG, the others (XZ-2) RGGB — verified by -//! colour renders. +//! - The colour-filter layout is the file's Exif `CFAPattern`: GRBG on the E-1 and E-400, RGGB on the XZ-2, where +//! the colours of the decoded mosaic follow the embedded JPEG best with exactly that layout +//! (`corpus_orf_cfa_patterns`). The samples' active areas start at even offsets, so they can't tell whether the +//! pattern is anchored at the sensor origin (assumed) or at the active area. Files without a usable tag fall back +//! to the green diagonal found from the data, which can't tell red from blue (GRBG or RGGB is assumed). use super::white_from_data; use crate::tiffraw::{Packing, read_image}; use crate::unpack::unpack_msb; -use crate::{BlackLevel, Cfa, ColorData, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result}; +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::makernote::MakerNote; @@ -33,6 +35,8 @@ pub(crate) const PREVIEW_LENGTH: u16 = 0x0102; const WB_RB: u16 = 0x0100; const BLACK: u16 = 0x0600; const CROP: [u16; 4] = [0x0612, 0x0613, 0x0614, 0x0615]; +/// Exif `CFAPattern`. +const EXIF_CFA_PATTERN: u16 = 0xa302; /// The Olympus maker note. pub(crate) fn maker_note(bytes: &[u8], tiff: &Tiff) -> Option { @@ -66,23 +70,52 @@ pub(crate) fn unpack_row_le32_msb(src: &[u8], bits: u32, out: &mut [u16]) { unpack_msb(&swapped, bits, out); } -/// GRBG when the greens sit on the main diagonal of the 2×2 cell at the sensor origin, else RGGB. +/// The colour-filter layout from the Exif `CFAPattern` tag (`0xa302`: two 16-bit repeat counts, found in either +/// byte order, then one byte per site: 0 = red, 1 = green, 2 = blue), when it describes a 2×2 Bayer cell. +fn cfa_from_exif(tiff: &Tiff) -> Option { + let &[c0, c1, r0, r1, s0, s1, s2, s3] = tiff.exif()?.bytes(EXIF_CFA_PATTERN)? else { return None }; + let two = |a: u8, b: u8| matches!((a, b), (2, 0) | (0, 2)); + let name = match [s0, s1, s2, s3] { + [0, 1, 1, 2] => "RGGB", + [2, 1, 1, 0] => "BGGR", + [1, 0, 2, 1] => "GRBG", + [1, 2, 0, 1] => "GBRG", + _ => return None, + }; + (two(c0, c1) && two(r0, r1)).then(|| Cfa::bayer_static(name)) +} + +/// The layout found from the samples, for files without the Exif tag: GRBG when the greens sit on the main +/// diagonal of the 2×2 cell at the sensor origin, else RGGB. Over 32×32-pixel blocks of `a` (every fourth in both +/// directions) it compares the block totals of the two sites on each diagonal: the two greens of a block see the +/// same light, red and blue rarely do. Totals rather than single pixels, so that fine texture, which makes +/// neighbouring greens differ, doesn't outweigh a small difference between red and blue. pub(crate) fn cfa_from_data(d: &[u16], w: usize, a: Rect) -> Cfa { - let (x0, y0) = (a.x + ((a.width / 8) & !1), a.y + ((a.height / 8) & !1)); - let (x1, y1) = (a.x + a.width * 7 / 8, a.y + a.height * 7 / 8); + const BLOCK: usize = 32; + let (x0, y0) = (a.x.saturating_add(1) & !1, a.y.saturating_add(1) & !1); + let across = a.x.saturating_add(a.width).min(w).saturating_sub(x0) / BLOCK; + let down = a.y.saturating_add(a.height).saturating_sub(y0) / BLOCK; let (mut main, mut anti) = (0u64, 0u64); - for y in (y0..y1.saturating_sub(1)).step_by(16) { - for x in (x0..x1.saturating_sub(1)).step_by(8) { - let (x, y) = (x & !1, y & !1); - let p = |dx: usize, dy: usize| d[(y + dy) * w + x + dx] as i64; - main += (p(0, 0) - p(1, 1)).unsigned_abs(); - anti += (p(1, 0) - p(0, 1)).unsigned_abs(); + for by in (0..down).step_by(4) { + for bx in (0..across).step_by(4) { + let mut sums = [0i64; 4]; + for y in 0..BLOCK { + let row = (y0 + by * BLOCK + y).checked_mul(w).and_then(|r| r.checked_add(x0 + bx * BLOCK)); + // rows past the end of the samples: nothing more to read + let Some(row) = row.and_then(|start| d.get(start..start.checked_add(BLOCK)?)) else { break }; + for (x, &v) in row.iter().enumerate() { + sums[(y & 1) * 2 + (x & 1)] += v as i64; + } + } + let [s0, s1, s2, s3] = sums; + main += (s0 - s3).unsigned_abs(); + anti += (s1 - s2).unsigned_abs(); } } Cfa::bayer_static(if main < anti { "GRBG" } else { "RGGB" }) } -pub(crate) fn decode(bytes: &[u8]) -> Result { +pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let tiff = Tiff::parse(bytes)?; let ifd0 = &tiff.ifds[0]; let info = ifd0.image()?; @@ -90,6 +123,7 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { let n = w.checked_mul(h).filter(|n| *n > 0 && *n <= crate::MAX_SAMPLES).ok_or(RawError::Limit("image too large"))?; let chunks = info.chunks(bytes.len() as u64); let total: u64 = chunks.iter().map(|c| c.len).sum(); + let stated = cfa_from_exif(&tiff); let (mut data, bits) = if info.compression != 1 { return Err(RawError::Unsupported(format!("ORF compression {}", info.compression))); } else if total >= (n as u64) * 2 { @@ -97,9 +131,16 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { (d, 16) } else if total * 8 >= (n as u64) * 12 && total * 8 < (n as u64) * 13 && chunks.len() == 1 { let src = chunk_bytes(bytes, &chunks[0]).ok_or_else(|| RawError::Corrupt("ORF strip outside file".into()))?; - let stride = src.len() / h; - let mut d = vec![0u16; n]; - d.par_chunks_mut(w).enumerate().for_each(|(y, row)| unpack_row_le32_msb(&src[y * stride..(y + 1) * stride], 12, row)); + // the depth is fixed here, so a header-only probe needs the samples only when the file doesn't state its + // colour-filter layout + let d = if mode == Mode::Full || stated.is_none() { + let stride = src.len() / h; + let mut d = vec![0u16; n]; + d.par_chunks_mut(w).enumerate().for_each(|(y, row)| unpack_row_le32_msb(&src[y * stride..(y + 1) * stride], 12, row)); + d + } else { + Vec::new() + }; (RawData::U16(d), 12) } else { return Err(RawError::Unsupported("Olympus compressed ORF".into())); @@ -128,7 +169,7 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { 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), _ => Rect::new(0, 0, w, h), }; - let cfa = cfa_from_data(samples, w, active); + let cfa = stated.unwrap_or_else(|| cfa_from_data(samples, w, active)); let black = match ip.as_ref().and_then(|i| i.f64s(BLACK)).as_deref() { Some(v @ [_, _, _, _]) => { let a = cfa.shifted(active.x, active.y); @@ -165,7 +206,7 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { opcodes: OpcodeLists::default(), metadata, }; - img.validate()?; + img.validate_for(mode)?; Ok(img) } @@ -185,6 +226,11 @@ mod tests { use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter}; fn orf(w: u32, h: u32, bits: u16, strip: Vec) -> Vec { + orf_with(w, h, bits, strip, None) + } + + /// A minimal ORF with one strip in IFD0 and, optionally, an Exif `CFAPattern`. + fn orf_with(w: u32, h: u32, bits: u16, strip: Vec, cfa_pattern: Option<&[u8]>) -> Vec { let mut ifd = IfdBuilder::new(); ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w])); ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h])); @@ -193,12 +239,120 @@ mod tests { ifd.set(t::PHOTOMETRIC, Value::Short(vec![1])); ifd.set(t::MAKE, Value::Ascii("OLYMPUS IMAGING CORP.".into())); ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![strip] }); + if let Some(p) = cfa_pattern { + let mut exif = IfdBuilder::new(); + exif.set(EXIF_CFA_PATTERN, Value::Undefined(p.to_vec())); + ifd.set_child(t::EXIF_IFD, exif); + } let mut b = TiffWriter::new(ByteOrder::Little, false).write(&[ifd]).unwrap(); b[2] = b'R'; b[3] = b'O'; b } + /// A 12-bit mosaic stored as 16-bit words: `site(cx, cy)` gives the four values of the 2×2 cell at (cx, cy). + fn mosaic(w: usize, h: usize, site: impl Fn(usize, usize) -> [u16; 4]) -> Vec { + (0..w * h).flat_map(|i| site(i % w / 2, i / w / 2)[((i / w) & 1) * 2 + ((i % w) & 1)].to_le_bytes()).collect() + } + + /// Smooth scene texture shared by the four sites of a cell. + fn shade(cx: usize, cy: usize) -> u16 { + ((cx * 7 + cy * 13) % 23) as u16 + } + + const BGGR: &[u8] = &[2, 0, 2, 0, 2, 1, 1, 0]; + const GRBG: &[u8] = &[2, 0, 2, 0, 1, 0, 2, 1]; + // repeat counts in the other byte order + const RGGB: &[u8] = &[0, 2, 0, 2, 0, 1, 1, 2]; + const GBRG: &[u8] = &[0, 2, 0, 2, 1, 2, 0, 1]; + + fn layout(bytes: &[u8]) -> String { + let r = crate::decode(bytes).unwrap(); + assert_eq!(crate::probe_info(bytes).unwrap(), r.info()); + r.cfa.unwrap().name() + } + + #[test] + fn exif_cfa_pattern_states_the_layout() { + let (w, h) = (160usize, 144usize); + // samples whose green diagonal alone would say RGGB + let px = mosaic(w, h, |cx, cy| [600, 1000, 1000, 300].map(|v| v + shade(cx, cy))); + for (tag, name) in [(BGGR, "BGGR"), (GRBG, "GRBG"), (RGGB, "RGGB"), (GBRG, "GBRG")] { + assert_eq!(layout(&orf_with(w as u32, h as u32, 16, px.clone(), Some(tag))), name); + } + // not a 2×2 Bayer cell (four greens, greens side by side, a fourth colour), other repeat counts, wrong + // lengths: the samples decide + let unusable: [&[u8]; 8] = [ + &[2, 0, 2, 0, 1, 1, 1, 1], + &[2, 0, 2, 0, 1, 1, 0, 2], + &[2, 0, 2, 0, 0, 1, 1, 3], + &[3, 0, 3, 0, 0, 1, 1, 2], + &[2, 2, 2, 0, 0, 1, 1, 2], + &[2, 0, 2, 0, 1, 0], + &[2, 0, 2, 0, 2, 1, 1, 0, 0], + &[], + ]; + for (site, name) in [([600, 1000, 1000, 300], "RGGB"), ([1000, 600, 300, 1000], "GRBG")] { + let px = mosaic(w, h, |cx, cy| site.map(|v| v + shade(cx, cy))); + assert_eq!(layout(&orf(w as u32, h as u32, 16, px.clone())), name); + for tag in unusable { + assert_eq!(layout(&orf_with(w as u32, h as u32, 16, px.clone(), Some(tag))), name, "{tag:?}"); + } + } + } + + #[test] + fn packed_12_bit_probe_agrees_with_decode() { + let (w, h) = (16usize, 4usize); + let strip: Vec = (0..w * h * 3 / 2).map(|i| (i * 37 % 251) as u8).collect(); + for tag in [Some(BGGR), None] { + let bytes = orf_with(w as u32, h as u32, 12, strip.clone(), tag); + let r = crate::decode(&bytes).unwrap(); + assert_eq!((r.bits, r.data.len()), (12, w * h)); + assert_eq!(r.cfa.as_ref().unwrap().name(), if tag.is_some() { "BGGR" } else { "RGGB" }); + assert_eq!(crate::probe_info(&bytes).unwrap(), r.info()); + } + } + + #[test] + fn data_fallback_looks_past_fine_texture() { + let (w, h) = (160usize, 144usize); + // greens on the main diagonal, red and blue close; a checkerboard of detail makes the two greens of a cell + // differ by far more than red differs from blue + let detail = |cx: usize, cy: usize| if (cx + cy) & 1 == 0 { 150 } else { -150i32 }; + let site = |cx: usize, cy: usize| { + let (g, d) = (1000 + shade(cx, cy) as i32, detail(cx, cy)); + [(g + d) as u16, 500, 520, (g - d) as u16] + }; + assert_eq!(layout(&orf(w as u32, h as u32, 16, mosaic(w, h, site))), "GRBG"); + // the same scene with the greens on the other diagonal + let px = mosaic(w, h, |cx, cy| { + let [g0, r, b, g1] = site(cx, cy); + [r, g0, g1, b] + }); + assert_eq!(layout(&orf(w as u32, h as u32, 16, px)), "RGGB"); + } + + #[test] + fn data_fallback_survives_any_area() { + let d = vec![100u16; 64 * 64]; + let big = usize::MAX; + for a in [ + Rect::new(0, 0, 64, 64), + Rect::new(0, 0, 0, 0), + Rect::new(0, 0, 31, 31), + Rect::new(63, 63, 1, 1), + Rect::new(0, 0, 4096, 4096), + Rect::new(big, big, big, big), + Rect::new(0, big - 40, 64, 40), + ] { + for w in [64, 0, 1, big] { + assert_eq!(cfa_from_data(&d, w, a).name(), "RGGB", "{a:?} width {w}"); + assert_eq!(cfa_from_data(&[], w, a).name(), "RGGB", "{a:?} width {w}"); + } + } + } + #[test] fn word16_shifted_and_packed12() { let (w, h) = (16usize, 4usize); diff --git a/crates/raw/src/vendor/raf.rs b/crates/raw/src/vendor/raf.rs index 2505db137..9d9501215 100644 --- a/crates/raw/src/vendor/raf.rs +++ b/crates/raw/src/vendor/raf.rs @@ -1,4 +1,4 @@ -//! Fujifilm RAF — uncompressed Bayer and X-Trans. +//! Fujifilm RAF — uncompressed, lossless compressed and lossy compressed Bayer and X-Trans. //! //! Sources: the ExifTool FujiFilm tag-name documentation (RAF header record tags `0x0100` RawImageFullSize, //! `0x0110` RawImageCropTopLeft, `0x0111` RawImageCroppedSize, `0x0131` XTransLayout; raw-IFD tags `0xf001` @@ -17,7 +17,7 @@ //! is anchored at raw pixel (0, 0) — verified on X-Trans I and III samples by minimising the difference //! between horizontally / vertically adjacent "green" samples over all 72 orientations/phases, and by colour //! renders. Bayer bodies (X-A series) use RGGB at (0, 0), verified by colour renders. -//! - Fujifilm's compressed RAF variants are reported as unsupported (their embedded preview still works). +//! - Striped predictive compression is decoded by `rafc`; see `docs/raf-compression.md`. use super::white_from_data; use crate::unpack::{unpack_lsb, unpack_msb}; @@ -118,35 +118,56 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let h = header(bytes)?; let raw = h.raw.ok_or_else(|| RawError::Corrupt("RAF without raw data".into()))?; let ifd = raw_ifd(raw)?; - let get = |t: u16| ifd.u64(t).map(|v| v as usize); - let (w, hgt) = (get(0xf001).unwrap_or(0), get(0xf002).unwrap_or(0)); - let bits = get(0xf003).unwrap_or(0) as u32; - let (off, len) = (get(0xf007).unwrap_or(0), get(0xf008).unwrap_or(0)); + let get = |t: u16| ifd.u64(t).map(|v| usize::try_from(v).map_err(|_| RawError::Limit("RAF IFD value too large"))).transpose(); + let (w, hgt) = (get(0xf001)?.unwrap_or(0), get(0xf002)?.unwrap_or(0)); + let bits = ifd.u64(0xf003).and_then(|v| u32::try_from(v).ok()).unwrap_or(0); + let (off, len) = (get(0xf007)?.unwrap_or(0), get(0xf008)?.unwrap_or(0)); if w == 0 || hgt == 0 || !(8..=16).contains(&bits) { return Err(RawError::Corrupt(format!("RAF raw IFD: {w}x{hgt}, {bits} bits"))); } let n = w.checked_mul(hgt).filter(|n| *n <= crate::MAX_SAMPLES).ok_or(RawError::Limit("image too large"))?; - // samples of any depth may be stored in 16-bit words (e.g. 14-bit X-T20); otherwise packed at `bits` - let store = if len as u64 >= (n as u64) * 2 { 16 } else { bits }; - let packed_row = if store == 16 { w * 2 } else { (w * store as usize).div_ceil(8) }; - let stride = match get(0xf005).unwrap_or(0) { - 0 => packed_row, - words => (words * 4).max(packed_row), + let src = raw + .get(off..off.checked_add(len).ok_or_else(|| RawError::Corrupt("RAF strip size overflow".into()))?) + .ok_or_else(|| RawError::Corrupt("RAF strip outside file".into()))?; + let cfa = cfa(&h); + let compression = be32(bytes, 108).unwrap_or(0); + let compressed = match compression { + 0 => false, + 2 | 3 => true, + _ => return Err(RawError::Unsupported(format!("Fujifilm RAF compression mode {compression}"))), }; - if (len as u64) < (stride as u64) * (hgt as u64 - 1) + packed_row as u64 { - return Err(RawError::Unsupported(format!("Fujifilm compressed RAF ({len} bytes for {w}x{hgt} {bits}-bit)"))); - } - let src = raw.get(off..off.saturating_add(len).min(raw.len())).ok_or_else(|| RawError::Corrupt("RAF strip outside file".into()))?; let mut data = Vec::new(); - if mode == Mode::Full { - data = vec![0u16; n]; - data.par_chunks_mut(w).enumerate().for_each(|(y, row)| { - let s = src.get(y * stride..(y * stride + packed_row).min(src.len())).unwrap_or(&[]); - unpack_row(s, store, row); - }); + if compressed { + if cfa.width == 6 && cfa != Cfa::xtrans() { + return Err(RawError::Unsupported("Fujifilm compressed RAF with an unknown X-Trans layout".into())); + } + let flag = if compression == 2 { 1 } else { 0 }; + if src.get(2) != Some(&flag) { + return Err(RawError::Corrupt("RAF container and stream compression modes disagree".into())); + } + data = super::rafc::decode(src, w, hgt, bits, cfa.width == 6, mode)?; + } else { + // Samples may use 16-bit words (e.g. 14-bit X-T20); otherwise they are packed at `bits`. + let store = if len as u64 >= (n as u64) * 2 { 16 } else { bits }; + let packed_row = w.checked_mul(store as usize).map(|n| n.div_ceil(8)).ok_or(RawError::Limit("RAF row too large"))?; + let stride = match get(0xf005)?.unwrap_or(0) { + 0 => packed_row, + words => words.checked_mul(4).ok_or(RawError::Limit("RAF stride too large"))?.max(packed_row), + }; + let required = stride.checked_mul(hgt - 1).and_then(|n| n.checked_add(packed_row)).ok_or(RawError::Limit("RAF strip too large"))?; + if len < required { + return Err(RawError::Unsupported(format!("Fujifilm RAF packing ({len} bytes for {w}x{hgt} {bits}-bit)"))); + } + if mode == Mode::Full { + data = vec![0u16; n]; + data.par_chunks_mut(w).enumerate().for_each(|(y, row)| { + // `required <= src.len()` proves these row offsets fit, including the final row. + let s = src.get(y * stride..y * stride + packed_row).unwrap_or(&[]); + unpack_row(s, store, row); + }); + } } - let cfa = cfa(&h); let active = match (pair(&h, CROP_TOP_LEFT), pair(&h, CROPPED_SIZE)) { (Some((top, left)), Some((ch, cw))) if ch > 0 && cw > 0 => Rect::new(left, top, cw, ch).clipped(w, hgt), _ => Rect::new(0, 0, w, hgt), @@ -194,7 +215,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { } #[cfg(test)] -mod tests { +pub(super) mod tests { use super::*; /// Build a minimal RAF: header, records, raw block (LE TIFF whose IFD0 points to a 0xf000 raw IFD). @@ -247,6 +268,10 @@ mod tests { dir.extend_from_slice(&recs); let mut out = b"FUJIFILMCCD-RAW 0201FF000000TEST".to_vec(); out.resize(108, 0); + if strip.starts_with(b"IS") { + let compression = if strip.get(2) == Some(&0) { 3u32 } else { 2u32 }; + out.extend_from_slice(&compression.to_be_bytes()); + } let jpeg_off = 160u32; let dir_off = jpeg_off + jpeg.len() as u32; let raw_off = dir_off + dir.len() as u32; @@ -310,8 +335,24 @@ mod tests { } let bytes = raf(w, h, 16, pack(&[0; 96], 16), None, b""); assert_eq!(crate::decode(&bytes).unwrap().cfa.unwrap().name(), "RGGB"); + let mut px = vec![0; 96]; + px[0] = 0x5349; + let mut bytes = raf(w, h, 16, pack(&px, 16), None, b""); + bytes[108..112].copy_from_slice(&0u32.to_be_bytes()); + assert_eq!(crate::decode(&bytes).unwrap().data, RawData::U16(px)); // too little data for the size: compressed let bytes = raf(w, h, 14, vec![0; 20], None, b""); assert!(matches!(crate::decode(&bytes), Err(RawError::Unsupported(_)))); } + + #[test] + fn overflowing_row_stride_is_an_error() { + let mut bytes = raf(16, 6, 16, vec![0; 192], None, &[]); + let off = be32(&bytes, 100).unwrap(); + // Replace WB with a LONG8 stride, pointing to the existing out-of-line WB storage. + bytes[off + 100..off + 104].copy_from_slice(&[0x05, 0xf0, 16, 0]); + bytes[off + 104..off + 108].copy_from_slice(&1u32.to_le_bytes()); + bytes[off + 116..off + 124].copy_from_slice(&u64::MAX.to_le_bytes()); + assert!(matches!(crate::decode(&bytes), Err(RawError::Limit(_)))); + } } diff --git a/crates/raw/src/vendor/rafc.rs b/crates/raw/src/vendor/rafc.rs new file mode 100644 index 000000000..f3b925b51 --- /dev/null +++ b/crates/raw/src/vendor/rafc.rs @@ -0,0 +1,505 @@ +//! Fujifilm's striped predictive RAW compression. +//! +//! Clean-room implementation: Fabian's prose description of lossless RAF compression +//! (https://capnfabs.net/posts/fuji-raf-compression-algorithm/) supplied the broad structure; +//! headers, colour-vector placement, predictors, contexts and adaptation were recovered +//! from sample bytes and black-box comparisons of decoded sensor arrays. No other RAW +//! decoder's source was consulted. See docs/raf-compression.md for verification details. + +use crate::{Mode, RawError, Result}; +use rayon::prelude::*; + +const BLOCK: usize = 768; +const XTRANS: [[usize; 6]; 6] = + [[1, 1, 0, 1, 1, 2], [1, 1, 2, 1, 1, 0], [2, 0, 1, 0, 2, 1], [1, 1, 2, 1, 1, 0], [1, 1, 0, 1, 1, 2], [0, 2, 1, 2, 0, 1]]; +const MASKS: [[u8; 2]; 6] = [[10, 15], [15, 10], [14, 10], [15, 11], [11, 15], [10, 14]]; + +fn corrupt(why: &str) -> RawError { + RawError::Corrupt(format!("Fujifilm compressed RAF: {why}")) +} + +fn be16(src: &[u8], at: usize) -> Result { + let s = src.get(at..at.saturating_add(2)).ok_or_else(|| corrupt("truncated header"))?; + let [a, b] = s else { return Err(corrupt("truncated header")) }; + Ok(u16::from_be_bytes([*a, *b]) as usize) +} + +struct Stripe<'a> { + bytes: &'a [u8], + quantizers: Option<&'a [u8]>, + width: usize, +} + +pub(super) fn decode(src: &[u8], width: usize, height: usize, bits: u32, xtrans: bool, mode: Mode) -> Result> { + let h = src.get(..16).ok_or_else(|| corrupt("truncated header"))?; + if !h.starts_with(b"IS") { + return Err(corrupt("missing compression signature")); + } + let flag = h.get(2).copied().ok_or_else(|| corrupt("truncated header"))?; + if flag > 1 { + return Err(corrupt("invalid compression flag")); + } + let sensor = h.get(3).copied().ok_or_else(|| corrupt("truncated header"))?; + if sensor != if xtrans { 16 } else { 0 } { + return Err(corrupt("compression sensor type disagrees with CFA")); + } + if !matches!(bits, 12 | 14 | 16) { + return Err(RawError::Unsupported(format!("Fujifilm compressed RAF at {bits} bits"))); + } + let count = h.get(13).copied().ok_or_else(|| corrupt("truncated header"))? as usize; + if h.get(4).copied() != Some(bits as u8) + || be16(h, 5)? != height + || be16(h, 9)? != width + || count == 0 + || count != width.div_ceil(BLOCK) + || be16(h, 7)? != count * BLOCK + || be16(h, 11)? != BLOCK + || height == 0 + || !height.is_multiple_of(6) + || be16(h, 14)? != height / 6 + { + return Err(corrupt("inconsistent compression dimensions")); + } + let n = width.checked_mul(height).filter(|&n| n <= crate::MAX_SAMPLES).ok_or(RawError::Limit("image too large"))?; + let table_end = 16 + count * 4; + let mut offset = table_end.div_ceil(16) * 16; + let quantizer_start = offset; + let quantizer_stride = (height / 6).div_ceil(16) * 16; + if flag == 0 { + offset += quantizer_stride * count; + if src.get(quantizer_start..offset).is_none() { + return Err(corrupt("truncated quantizer tables")); + } + } + let mut stripes = Vec::with_capacity(count); + for i in 0..count { + let entry = src.get(16 + i * 4..20 + i * 4).ok_or_else(|| corrupt("truncated stripe size table"))?; + let [a, b, c, d] = entry else { return Err(corrupt("truncated stripe size table")) }; + let size = u32::from_be_bytes([*a, *b, *c, *d]) as usize; + let end = offset.checked_add(size).ok_or_else(|| corrupt("stripe size overflow"))?; + let bytes = src.get(offset..end).filter(|s| !s.is_empty()).ok_or_else(|| corrupt("stripe outside file"))?; + let quantizers = if flag == 0 { + Some( + src.get(quantizer_start + i * quantizer_stride..quantizer_start + i * quantizer_stride + height / 6) + .ok_or_else(|| corrupt("truncated quantizer table"))?, + ) + } else { + None + }; + stripes.push(Stripe { bytes, quantizers, width: (width - i * BLOCK).min(BLOCK) }); + offset = end; + } + if mode == Mode::Header { + return Ok(Vec::new()); + } + let decoded: Vec> = stripes.par_iter().map(|s| decode_stripe(s, height, bits, xtrans)).collect::>()?; + let mut output = vec![0; n]; + for (i, (stripe, data)) in stripes.iter().zip(&decoded).enumerate() { + for (row, samples) in output.chunks_exact_mut(width).zip(data.chunks_exact(stripe.width)) { + row.get_mut(i * BLOCK..i * BLOCK + stripe.width).ok_or_else(|| corrupt("invalid stripe placement"))?.copy_from_slice(samples); + } + } + Ok(output) +} + +struct Bits<'a> { + src: &'a [u8], + pos: usize, + acc: u64, + available: u32, +} + +impl<'a> Bits<'a> { + fn new(src: &'a [u8]) -> Self { + Self { src, pos: 0, acc: 0, available: 0 } + } + + #[inline] + fn get(&mut self, n: u32) -> Result { + while self.available < n { + let b = self.src.get(self.pos).copied().ok_or_else(|| corrupt("truncated entropy stream"))?; + self.pos += 1; + self.acc = (self.acc << 8) | u64::from(b); + self.available += 8; + } + self.available -= n; + Ok(((self.acc >> self.available) & ((1u64 << n) - 1)) as u32) + } + + fn residual(&mut self, k: u32, bits: u32, range: u32, limit: u32) -> Result { + let mut zeros = 0; + while self.get(1)? == 0 { + zeros += 1; + if zeros > limit { + return Err(corrupt("invalid residual escape")); + } + } + let code = if zeros == limit { self.get(bits)? + 1 } else { (zeros << k) | self.get(k)? }; + if code > range { + return Err(corrupt(&format!( + "residual {code} outside sample range {range} (k={k}, prefix={zeros}, bits={bits}, position={})", + self.pos * 8 - self.available as usize + ))); + } + Ok(if code & 1 == 0 { (code / 2) as i32 } else { -((code / 2) as i32) - 1 }) + } +} + +#[derive(Clone, Copy)] +struct State { + sum: u32, + count: u32, +} + +impl State { + fn k(self) -> u32 { + let mut k = 0; + while self.count << k < self.sum { + k += 1; + } + k + } + + fn update(&mut self, delta: i32) { + self.sum += delta.unsigned_abs(); + if self.count == 64 { + self.sum >>= 1; + self.count >>= 1; + } + self.count += 1; + } +} + +#[inline] +fn quant(v: i32, near: u32) -> i32 { + let t = [18 + 3 * near, 67 + 5 * near, 276 + 7 * near]; + let a = v.unsigned_abs(); + let bucket = if a <= near { + 0 + } else if a < t[0] { + 1 + } else if a < t[1] { + 2 + } else if a < t[2] { + 3 + } else { + 4 + }; + if v < 0 { -bucket } else { bucket } +} + +#[inline] +fn even_prediction(nw: i32, n: i32, ne: i32, nn: i32) -> i32 { + let (a, b, c) = ((nw - n).abs(), (ne - n).abs(), (nn - n).abs()); + let pair = if a > b && a > c { + ne + nn + } else if b > a && b > c { + nw + nn + } else { + nw + ne + }; + (pair + 2 * n) >> 2 +} + +#[inline] +fn sample(rows: &[Vec], y: usize, x: usize) -> Result { + rows.get(y).and_then(|r| r.get(x)).copied().ok_or_else(|| corrupt("invalid predictor position")) +} + +fn decode_stripe(stripe: &Stripe<'_>, height: usize, bits: u32, xtrans: bool) -> Result> { + let size = if xtrans { BLOCK * 2 / 3 } else { BLOCK / 2 }; + let mut previous = vec![vec![vec![0; size + 2]; 2]; 3]; + let max = (1u32 << bits) - 1; + let range = |near: u32| (max + 2 * near) / (2 * near + 1) + 1; + let seed = |near: u32| State { sum: ((range(near) + 32) / 64).max(2), count: 1 }; + let mut states = [[[seed(0); 41]; 2]; 3]; + // Smooth regions retain finer precision and independent state across block quantizer changes. + let mut fine = [[[[seed(0); 5]; 2]; 3], [[[seed(1); 5]; 2]; 3], [[[seed(2); 5]; 2]; 3]]; + let mut last_near = None; + let mut reader = Bits::new(stripe.bytes); + let n = height.checked_mul(stripe.width).filter(|&n| n <= crate::MAX_SAMPLES).ok_or(RawError::Limit("image too large"))?; + let mut output = Vec::with_capacity(n); + for group in 0..height / 6 { + let near = match stripe.quantizers { + Some(q) => u32::from(*q.get(group).ok_or_else(|| corrupt("truncated quantizer table"))?), + None => 0, + }; + if last_near != Some(near) { + states = [[[seed(near); 41]; 2]; 3]; + last_near = Some(near); + } + let mut rows = previous; + for pair in 0..6 { + let colours = if pair % 2 == 0 { [0, 1] } else { [1, 2] }; + for &colour in &colours { + let lines = rows.get_mut(colour).ok_or_else(|| corrupt("invalid colour vector"))?; + let mut row = vec![0; size + 2]; + let prev = lines.len() - 1; + let left = sample(lines, prev, 1)?; + let right = sample(lines, prev, size)?; + if let Some(v) = row.first_mut() { + *v = left; + } + if let Some(v) = row.last_mut() { + *v = right; + } + lines.push(row); + } + for even in (0..size + 8).step_by(2) { + for (odd, position) in [(0, Some(even)), (1, even.checked_sub(7))] { + let Some(pos) = position.filter(|&x| x < size) else { continue }; + let x = pos + 1; + for (which, &colour) in colours.iter().enumerate() { + let lines = rows.get_mut(colour).ok_or_else(|| corrupt("invalid colour vector"))?; + let y = lines.len() - 1; + let (nw, north, ne, nn) = + (sample(lines, y - 1, x - 1)?, sample(lines, y - 1, x)?, sample(lines, y - 1, x + 1)?, sample(lines, y - 2, x)?); + let west = sample(lines, y, x - 1)?; + let east = sample(lines, y, x + 1)?; + let (predict, d1, d2) = if odd == 0 { + (even_prediction(nw, north, ne, nn), north - nn, nw - north) + } else { + let p = if (nw.min(ne)..=nw.max(ne)).contains(&north) { (west + east) >> 1 } else { (west + east + 2 * north) >> 2 }; + (p, north - nw, nw - west) + }; + let real = !xtrans || MASKS.get(pair).and_then(|m| m.get(which)).is_some_and(|m| (m >> (pos % 4)) & 1 != 0); + let value = if real { + let contrast = d1.unsigned_abs() + d2.unsigned_abs(); + let precision = match contrast { + 0..=5 => 0, + 6 => near.min(1), + 7 => near.min(2), + _ => near, + }; + let (context, state) = if precision < near { + let sign = |v: i32| if v.unsigned_abs() <= precision { 0 } else { v.signum() }; + let context = 3 * sign(d1) + sign(d2); + let state = fine + .get_mut(precision as usize) + .and_then(|f| f.get_mut(pair % 3)) + .and_then(|s| s.get_mut(odd)) + .and_then(|s| s.get_mut(context.unsigned_abs() as usize)) + .ok_or_else(|| corrupt("invalid fine coding context"))?; + (context, state) + } else { + let context = 9 * quant(d1, near) + quant(d2, near); + let state = states + .get_mut(pair % 3) + .and_then(|s| s.get_mut(odd)) + .and_then(|s| s.get_mut(context.unsigned_abs() as usize)) + .ok_or_else(|| corrupt("invalid coding context"))?; + (context, state) + }; + let coded_range = range(precision); + let coded_bits = 32 - (coded_range - 1).leading_zeros(); + let mut delta = reader.residual(state.k(), coded_bits, coded_range, 4 * bits - coded_bits - 1)?; + state.update(delta); + if context < 0 { + delta = -delta; + } + let step = (2 * precision + 1) as i32; + let mut value = predict + delta * step; + if value < -(precision as i32) { + value += coded_range as i32 * step; + } else if value > (max + precision) as i32 { + value -= coded_range as i32 * step; + } + value.clamp(0, max as i32) + } else { + predict + }; + *lines.get_mut(y).and_then(|r| r.get_mut(x)).ok_or_else(|| corrupt("invalid predictor position"))? = value; + } + } + } + } + for y in 0..6 { + for x in 0..stripe.width { + let colour = if xtrans { + XTRANS.get(y).and_then(|r| r.get(x % 6)).copied().ok_or_else(|| corrupt("invalid CFA position"))? + } else { + match (y % 2, x % 2) { + (0, 0) => 0, + (1, 1) => 2, + _ => 1, + } + }; + let row = 2 + if colour == 1 { y } else { y / 2 }; + let col = 1 + if xtrans { 2 * (x / 3) + (x % 3).min(1) } else { x / 2 }; + output.push(sample(rows.get(colour).ok_or_else(|| corrupt("invalid colour vector"))?, row, col)? as u16); + } + } + previous = rows.into_iter().map(|mut r| r.split_off(r.len() - 2)).collect(); + } + Ok(output) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Cfa; + use proptest::prelude::*; + + /// Procedural zero residuals: no camera media or encoder dependency. + fn zero_stream(bits: u32, xtrans: bool, quantizers: Option<&[u8]>) -> Vec { + let groups = quantizers.map_or(3, <[u8]>::len); + let mut main = [[(1u32 << (bits - 6), 1u32); 2]; 3]; + let mut fine = main; + let mut previous = None; + let mut stream = Vec::new(); + for group in 0..groups { + let q = quantizers.map_or(0, |q| q[group]); + if previous != Some(q) { + main = [[(1u32 << (bits - 6), 1u32); 2]; 3]; + previous = Some(q); + } + for pair in 0..6 { + let width = if xtrans { 512 } else { 384 }; + for even in (0usize..width + 8).step_by(2) { + for (parity, column) in [(0, Some(even)), (1, even.checked_sub(7))] { + let Some(column) = column.filter(|&x| x < width) else { continue }; + for colour in 0..2 { + if xtrans && (MASKS[pair][colour] >> (column % 4)) & 1 == 0 { + continue; + } + let (sum, count) = &mut if q == 0 { &mut main } else { &mut fine }[pair % 3][parity]; + let k = 32 - (sum.saturating_sub(1) / *count).leading_zeros(); + stream.push(true); + stream.extend(std::iter::repeat_n(false, k as usize)); + if *count == 64 { + *sum /= 2; + *count /= 2; + } + *count += 1; + } + } + } + } + } + stream.chunks(8).map(|chunk| chunk.iter().enumerate().fold(0, |b, (i, bit)| b | (u8::from(*bit) << (7 - i)))).collect() + } + + fn compressed(bits: u32, xtrans: bool, quantizers: Option<&[u8]>) -> Vec { + let groups = quantizers.map_or(3, <[u8]>::len); + let stream = zero_stream(bits, xtrans, quantizers); + // Two stripes; the second is mostly padding, which must never enter the output. + let mut out = b"IS".to_vec(); + out.extend_from_slice(&[u8::from(quantizers.is_none()), if xtrans { 16 } else { 0 }, bits as u8]); + for v in [groups as u16 * 6, 1536, 780, 768] { + out.extend_from_slice(&v.to_be_bytes()); + } + out.push(2); + out.extend_from_slice(&(groups as u16).to_be_bytes()); + for _ in 0..2 { + out.extend_from_slice(&(stream.len() as u32).to_be_bytes()); + } + out.resize(32, 0); + if let Some(q) = quantizers { + for _ in 0..2 { + out.extend_from_slice(q); + out.resize(out.len() + q.len().div_ceil(16) * 16 - q.len(), 0); + } + } + out.extend_from_slice(&stream); + out.extend_from_slice(&stream); + out + } + + #[test] + fn lossless_and_lossy_bayer_and_xtrans_stripes() { + for bits in [12, 14, 16] { + for xtrans in [false, true] { + for q in [None, Some([2, 0, 5].as_slice())] { + let src = compressed(bits, xtrans, q); + let out = decode(&src, 780, 18, bits, xtrans, Mode::Full).unwrap(); + assert_eq!(out, vec![0; 780 * 18]); + assert!(decode(&src, 780, 18, bits, xtrans, Mode::Header).unwrap().is_empty()); + let layout = xtrans.then(|| { + let mut l = [0; 36]; + for (to, from) in l.iter_mut().zip(XTRANS.iter().flatten().rev()) { + *to = *from as u8; + } + l + }); + let raf = super::super::raf::tests::raf(780, 18, bits, src, layout, &[]); + let image = crate::decode(&raf).unwrap(); + assert_eq!(crate::probe_info(&raf).unwrap(), image.info()); + assert_eq!(image.data, crate::RawData::U16(out)); + } + } + } + } + + #[test] + fn malformed_headers_and_truncated_entropy_are_errors() { + let valid = compressed(14, true, Some(&[1, 2, 0])); + for len in (0..96).chain([valid.len() / 2, valid.len() - 1]) { + assert!(decode(&valid[..len], 780, 18, 14, true, Mode::Full).is_err(), "length {len}"); + } + for at in [2, 3, 4, 5, 7, 9, 11, 13, 14, 16] { + let mut src = valid.clone(); + src[at] ^= 0xff; + assert!(decode(&src, 780, 18, 14, true, Mode::Full).is_err(), "header byte {at}"); + } + assert!(decode(&valid, usize::MAX, usize::MAX, 14, true, Mode::Full).is_err()); + let mut reader = Bits::new(&[0; 64]); + assert!(reader.residual(0, 14, 16384, 41).is_err()); + } + + #[test] + fn unsupported_container_modes_and_cfa_are_not_silently_decoded() { + let layout = std::array::from_fn(|i| Cfa::xtrans().pattern[35 - i]); + let src = compressed(14, true, None); + let raf = super::super::raf::tests::raf(780, 18, 14, src, Some(layout), &[]); + let mut mismatched = raf.clone(); + mismatched[108..112].copy_from_slice(&3u32.to_be_bytes()); + assert!(crate::decode(&mismatched).is_err(), "declared lossy mode disagrees with lossless stream"); + let bad_layout = layout.map(|c| { + if c == 0 { + 2 + } else if c == 2 { + 0 + } else { + c + } + }); + let raf = super::super::raf::tests::raf(780, 18, 14, compressed(14, true, None), Some(bad_layout), &[]); + assert!(matches!(crate::decode(&raf), Err(RawError::Unsupported(_))), "compression uses a fixed X-Trans arrangement"); + let mut raf = super::super::raf::tests::raf(780, 18, 16, vec![0; 780 * 18 * 2], None, &[]); + for mode in [1u32, 4, u32::MAX] { + raf[108..112].copy_from_slice(&mode.to_be_bytes()); + assert!(matches!(crate::decode(&raf), Err(RawError::Unsupported(_))), "unknown mode {mode}"); + assert!(matches!(crate::probe_info(&raf), Err(RawError::Unsupported(_))), "header-only mode {mode}"); + } + } + + #[test] + fn residual_sign_wrap_and_escape() { + // Ordinary codes 0, 1, 2, 3 with k=2; then the escape for -8192. + let mut stream = vec![true, false, false, true, false, true, true, true, false, true, true, true]; + stream.extend(std::iter::repeat_n(false, 41)); + stream.push(true); + let escaped = 16382u32; + stream.extend((0..14).rev().map(|i| (escaped >> i) & 1 != 0)); + let bytes: Vec = stream.chunks(8).map(|chunk| chunk.iter().enumerate().fold(0, |b, (i, bit)| b | (u8::from(*bit) << (7 - i)))).collect(); + let mut reader = Bits::new(&bytes); + for delta in [0, -1, 1, -2, -8192] { + assert_eq!(reader.residual(2, 14, 16384, 41).unwrap(), delta); + } + assert_eq!(even_prediction(1112, 858, 1104, 1152), 983); + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + #[test] + fn malformed_streams_never_panic(data in prop::collection::vec(any::(), 0..2048), quantizer in any::(), bits in prop::sample::select(vec![12,14,16]), xtrans in any::()) { + let mut src = compressed(bits, xtrans, Some(&[quantizer])); + let start = 64; + src.truncate(start); + for i in [16, 20] { src[i..i+4].copy_from_slice(&(data.len() as u32).to_be_bytes()); } + src.extend_from_slice(&data); + src.extend_from_slice(&data); + let _ = decode(&src, 780, 6, bits, xtrans, Mode::Full); + } + } +} diff --git a/crates/raw/src/vendor/rw2.rs b/crates/raw/src/vendor/rw2.rs index 10ade6272..7fe6d0e42 100644 --- a/crates/raw/src/vendor/rw2.rs +++ b/crates/raw/src/vendor/rw2.rs @@ -43,7 +43,9 @@ //! reads as repeats of one code only in this order; MSB-first reading gives code frequencies of exactly //! 2^−length, the signature of random bits]. Tag `0x0040` lists (length, code) for 17 prefix codes, the difference //! categories 0–16 of ITU-T T.81 (category `c`, then `c` additional bits, a leading 0 meaning negative). Pixels -//! are coded two rows at a time, cell by 2×2 cell (top-left, top-right, bottom-left, bottom-right); each is +//! are coded two rows at a time, cell by 2×2 cell, column by column (top-left, bottom-left, top-right, +//! bottom-right) [in row order the two greens trade sites: an edge's even and odd rows of green then sit ~2 px +//! apart with red and blue between them]; each is //! predicted from the same site of the previous cell, the first cell of a row pair from the first cell of the //! previous pair, 0 at the top [the strips decode to the camera JPEG's image without seams]. The streams end a //! few padding rows before their stated length. Tags `0x0039`–`0x003f`, `0x0041` and `0x0043` carry the same @@ -67,12 +69,38 @@ //! - Active area: the sensor borders. Default crop: `0x002f`–`0x0032`, the in-camera aspect ratio (e.g. 4:3 or 1:1 //! on a 3:2 sensor; the embedded JPEG always shows the whole sensor). Some bodies write zeros there: ignored. //! +//! Lens distortion (tag `0x0119`, ExifTool name DistortionInfo; ExifTool's names for its words were the only outside +//! input, their meaning below is our own measurement). The tag holds 16 little-endian 16-bit words. The camera's +//! own correction, which its embedded JPEG shows, is applied as an `OpcodeList3` `WarpRectilinear` (the DNG form +//! LightCraft already applies under "Enable Profile Corrections"): +//! +//! - Word 7, low 4 bits: 1 = correction on [0 on the DMC-GH1, whose JPEG matches the uncorrected render]. +//! - Word 12: the normalisation radius in pixels, the half diagonal of the active area [2870 for 4592 × 3448 +//! (2871.2), 3240 for 5184 × 3888, 3611 for 5776 × 4336, 3288 for the FZ1000 II's whole 5472 × 3648 sensor +//! even in its 4:3 mode]. +//! - Words 8, 4 and 11 (ExifTool: DistortionParam08, 04, 11), divided by 32768, are `k2`, `k4`, `k6` of the +//! correction: a sensor point at radius ρ (in units of word 12, about the centre of the active area) appears in +//! the corrected image at ρ·(1 + k2 ρ² + k4 ρ⁴ + k6 ρ⁶). +//! - Word 5 (ExifTool: DistortionScale), divided by 32768, plus 1, is the zoom `z` the camera crops by afterwards: +//! output radius = corrected radius / z [z < 1 when the corrected corners would otherwise be empty]. +//! +//! [These rules were chosen among alternative word orders, powers, directions and readings of the zoom by fitting +//! radial curves between our uncorrected renders and the embedded JPEGs of 38 DMC-GX85 photos (12–32 mm and 25 mm +//! lenses). With no free parameter the words then predict the curves of 35 held-out photos from 12 other bodies and +//! 9 other lenses (9.1–105 mm: two compacts, Panasonic, Leica, Olympus, Sigma and Samyang lenses) within 0.12 % of +//! the half diagonal, against up to 13 % uncorrected; corrected renders of all 79 photos line up with the camera +//! JPEG within 0.75 px at 1440 px at every corner measured.] Words 2, 3, 6, 9, 10 +//! and 13 are not used (word 13 is always word 9 + 500; word 10 is fixed per body); words 0, 1, 14 and 15 change +//! from shot to shot. The correction is radial, so it is fitted by the DNG form's odd polynomial `u·(kr0 + kr1 u² + +//! kr2 u⁴ + kr3 u⁶)` over the output (within 0.4 px on a 20 MP sensor); implausible values (a radius far from the +//! active area's, a zoom beyond ±25 %, a correction that folds over) leave the photo uncorrected. +//! //! Not supported ([`RawError::Unsupported`], the embedded preview is shown instead): compression 34826, raw formats //! 1 and 3, and bit depths other than the ones above (no samples of any of them). use super::pef::{Bits, Huffman, diff}; use super::white_from_data; -use crate::{BlackLevel, Cfa, ColorData, MAX_SAMPLES, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result}; +use crate::{BlackLevel, Cfa, ColorData, MAX_SAMPLES, Mode, Opcode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result}; use lightcraft_geom::Orientation; use lightcraft_tiff::{ByteOrder, Ifd, Tiff, Value, tags as t}; use rayon::prelude::*; @@ -113,6 +141,14 @@ const FORMAT8_FIXED: [(u16, &[u16]); 9] = [ (0x0041, &[17, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), (0x0043, &[1]), ]; +/// The camera's lens distortion correction (ExifTool name: DistortionInfo); see the module docs. +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). const MAX_STRIPS: usize = 64; @@ -401,7 +437,7 @@ fn decode_strip(s: &Strip, codes: &Huffman, mut rows: Vec<&mut [u16]>) -> Result for (k, v) in cell.iter_mut().enumerate() { let d = diff(&mut bits, codes).ok_or_else(|| RawError::Corrupt(format!("RW2 format 8: invalid code in row {}", 2 * pair)))?; *v = v.saturating_add(d); - if let Some(px) = rows.get_mut(2 * pair + k / 2).and_then(|r| r.get_mut(2 * cx + k % 2)) { + if let Some(px) = rows.get_mut(2 * pair + k % 2).and_then(|r| r.get_mut(2 * cx + k / 2)) { *px = (*v).clamp(0, u16::MAX as i32) as u16; } } @@ -586,13 +622,121 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { color: ColorData::default(), wb_multipliers: wb, linearized: false, - opcodes: OpcodeLists::default(), + opcodes: OpcodeLists { list3: distortion(ifd0, active).into_iter().collect(), ..Default::default() }, metadata, }; img.validate_for(mode)?; Ok(img) } +/// The camera's distortion correction (tag `0x0119`, see the module docs) as the equivalent DNG `WarpRectilinear` +/// opcode for `OpcodeList3` (relative to the active area), so it reaches the same "profile corrections" as a DNG's +/// embedded warp. `None` when the camera recorded the correction as off, when it is the identity, or when the +/// values are implausible (the photo is then shown uncorrected rather than wrongly warped). +fn distortion(ifd: &Ifd, active: Rect) -> Option { + let b = ifd.bytes(DISTORTION_INFO)?; + let word = |i: usize| -> Option<[u8; 2]> { b.get(2 * i..2 * i + 2)?.try_into().ok() }; + let signed = |i: usize| word(i).map(|w| i16::from_le_bytes(w) as f64); + if u16::from_le_bytes(word(7)?) & 0x000f != 1 { + return None; + } + let (k2, k4, k6) = (signed(8)? / DISTORTION_ONE, signed(4)? / DISTORTION_ONE, signed(11)? / DISTORTION_ONE); + let zoom = 1.0 + signed(5)? / DISTORTION_ONE; + let radius = u16::from_le_bytes(word(12)?) as f64; + if k2 == 0.0 && k4 == 0.0 && k6 == 0.0 && zoom == 1.0 { + return None; + } + // DNG's normalisation: the distance from the centre to the farthest corner, in pixel-index units + let half_diag = (active.width as f64 - 1.0).hypot(active.height as f64 - 1.0) / 2.0; + if !(half_diag >= 1.0 && (0.5 * half_diag..=2.0 * half_diag).contains(&radius) && (0.75..=1.25).contains(&zoom)) { + return None; + } + // the camera's correction: a source point at radius ρ (in units of `radius`) moves to ρ·g(ρ) + let corrected = |r: f64| { + 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::(); + 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; + } + 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::*; @@ -853,6 +997,76 @@ mod tests { } } + /// A tag-0x0119 block: 16 little-endian words, the given ones set. + fn distortion_block(words: &[(usize, i16)]) -> Value { + let mut w = [0i16; 16]; + for &(i, v) in words { + w[i] = v; + } + Value::Undefined(w.iter().flat_map(|v| v.to_le_bytes()).collect()) + } + + fn distorted(info: Option) -> Vec { + let (w, h) = (90usize, 60usize); + let px: Vec = (0..w * h).map(|i| (i % 4000) as u16 + 1).collect(); + let mut tags = vec![short(CFA_PATTERN, 1), short(BITS, 12), short(RAW_FORMAT, 5)]; + tags.extend(info.map(|v| (DISTORTION_INFO, v))); + let bytes = file(w as u16, h as u16, &tags, &rotate(pack(&px, 12)), &|off| vec![(RAW_OFFSET, Value::Long(vec![off]))]); + let r = crate::decode(&bytes).unwrap(); + assert_eq!(crate::probe_info(&bytes).unwrap().opcodes, r.opcodes, "headers-only probe reads the same correction"); + r.opcodes.list3 + } + + /// Known answer: correction k2 = 4096/32768 = 1/8 (word 8), zoom 1 − 1024/32768 (word 5), radius 51 px (word 12) + /// on an 86 × 58 active area. A sensor point at ρ appears at ρ + ρ³/8, so the output corner (corrected radius + /// t = half diagonal · zoom / 51) shows the root of ρ³ + 8ρ − 8t = 0, here by Cardano's formula. + #[test] + fn distortion_correction_becomes_a_rectilinear_warp() { + let on = |more: &[(usize, i16)]| { + let mut w = vec![(7, 0x0001), (8, 4096), (5, -1024), (12, 51)]; + w.extend_from_slice(more); + distortion_block(&w) + }; + let ops = distorted(Some(on(&[]))); + let [Opcode::WarpRectilinear { planes, center }] = ops.as_slice() else { panic!("{ops:?}") }; + assert_eq!((planes.len(), *center), (1, [0.5, 0.5])); + let k = planes[0]; + assert_eq!((k[4], k[5]), (0.0, 0.0), "radial only"); + let half_diag = 85f64.hypot(57.0) / 2.0; + for u in [0.1, 0.25, 0.5, 0.75, 1.0] { + let t = u * half_diag * (1.0 - 1024.0 / 32768.0) / 51.0; + let (p, q) = (8.0, -8.0 * t); + let d = (q * q / 4.0 + p * p * p / 27.0).sqrt(); + let rho = (-q / 2.0 + d).cbrt() + (-q / 2.0 - d).cbrt(); + let want = rho * 51.0 / half_diag; + let got = u * (k[0] + k[1] * u * u + k[2] * u.powi(4) + k[3] * u.powi(6)); + // the 4-term odd polynomial approximates the exact inverse (5e-5 of the half diagonal ≈ 0.2 px at 20 MP) + assert!((got - want).abs() < 5e-5, "u {u}: source radius {got}, want {want}"); + } + + // no correction: no block, recorded as off, or the identity + assert!(distorted(None).is_empty()); + assert!(distorted(Some(distortion_block(&[(7, 0x0000), (8, 4096), (12, 51)]))).is_empty(), "off"); + assert!(distorted(Some(distortion_block(&[(7, 0x7f00u16 as i16), (8, 4096), (12, 51)]))).is_empty(), "off, other bits set"); + assert!(distorted(Some(distortion_block(&[(7, 0x0001), (12, 51)]))).is_empty(), "identity"); + // hostile values are ignored, never a panic or a wild warp + for (what, block) in [ + ("truncated", Value::Undefined(vec![1, 0, 2, 0, 3])), + ("radius 0", on(&[(12, 0)])), + ("radius far beyond the sensor", on(&[(12, -1)])), + ("zoom 0", on(&[(5, -32768)])), + ("folds over", on(&[(8, -32768)])), + ("folds over at high order", on(&[(11, -32768), (4, -32768)])), + ("runs away", on(&[(8, 32767), (4, 32767), (11, 32767)])), + ] { + let ops = distorted(Some(block)); + assert!(ops.iter().all(|op| !matches!(op, Opcode::WarpRectilinear { .. })) || what == "runs away", "{what}: {ops:?}"); + if let [Opcode::WarpRectilinear { planes, .. }] = ops.as_slice() { + assert!(planes[0].iter().all(|c| c.is_finite()), "{what}"); + } + } + } + #[test] fn packed_and_word_formats() { let (w, h) = (90usize, 200usize); // > one chunk of data @@ -962,7 +1176,7 @@ mod tests { let mut cell = first; for cx in 0..w / 2 { for (k, p) in cell.iter_mut().enumerate() { - let v = at(2 * cx + k % 2, 2 * pair + k / 2); + let v = at(2 * cx + k / 2, 2 * pair + k % 2); let d = v - *p; let c = 32 - d.unsigned_abs().leading_zeros(); let (len, code) = table[c as usize]; @@ -1026,6 +1240,26 @@ mod tests { } } + #[test] + fn format8_cell_is_coded_column_by_column() { + // One 2×2 cell coded by hand (not with `encode_strip`, which shares the decoder's site order): the values + // 1, 2, 3, 4 in stream order. Row order would put the two greens of an RGGB cell on each other's sites. + let mut bits: Vec = vec![]; + let mut put = |n: u32, v: u32| bits.extend((0..n).rev().map(|i| v >> i & 1 == 1)); + for v in [1u32, 2, 3, 4] { + let c = 32 - v.leading_zeros(); + let (len, code) = TABLE14[c as usize]; + put(len, code); + put(c, v); + } + let n = bits.len() as u64; + let mut data: Vec = bits.chunks(8).map(|b| b.iter().enumerate().fold(0u8, |a, (i, &x)| a | (x as u8) << i)).collect(); + data.extend([0; 8]); + let (mut top, mut bottom) = ([0u16; 2], [0u16; 2]); + decode_strip(&Strip { data: &data, width: 2, height: 2, bits: n }, &code_table(&TABLE14).unwrap(), vec![&mut top, &mut bottom]).unwrap(); + assert_eq!((top, bottom), ([1, 3], [2, 4]), "top-left, bottom-left, top-right, bottom-right"); + } + fn replace(tags: &mut Vec<(u16, Value)>, tag: u16, v: Value) { tags.retain(|(t, _)| *t != tag); tags.push((tag, v)); diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index e4761eaef..b3c0ade9e 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -1,7 +1,7 @@ //! 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 (DNG: optional), and either decode to a valid image or report +//! 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 //! `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`). @@ -15,10 +15,9 @@ fn corpus_root() -> PathBuf { /// Variants known not to decode yet (see the crate docs): matched against the lower-case file name. const KNOWN_UNSUPPORTED: &[&str] = &[ - "cr3-", // CR3 / CRX (M11.1) - "raf-fuji-xt20-compressed", // Fujifilm compressed RAF - "orf-olympus-em", // Olympus compressed ORF - "sraw", // Canon sRAW / mRAW + "cr3-", // Unverified CRX coding variants; exact supported cases live in cr3_corpus.rs. + "orf-olympus-em", // Olympus compressed ORF + "sraw", // Canon sRAW / mRAW ]; #[test] @@ -42,11 +41,14 @@ fn corpus_raw_decodes() { 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 + // except the Panasonic `.RAW` files of 2005–2007 and Canon's explicit HEVC preview tracks. if let Some(p) = &preview { assert!(p.starts_with(&[0xff, 0xd8]) && p.ends_with(&[0xff, 0xd9]), "{name}: preview is not a JPEG"); } else { - assert!(fmt == RawFormat::Dng || name.starts_with("raw-panasonic-"), "{name}: no embedded preview"); + 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"); } let preview_kb = preview.as_ref().map_or(0, |p| p.len() / 1024); let t1 = Instant::now(); @@ -86,7 +88,7 @@ fn corpus_raw_decodes() { Err(e) => panic!("{name}: {e}"), } } - eprintln!("corpus/raw: {ok} decoded, {unsupported} known-unsupported (preview only)"); + eprintln!("corpus/raw: {ok} decoded, {unsupported} known-unsupported (embedded JPEG when available)"); } /// Green-channel means of 32×32 blocks. @@ -166,6 +168,34 @@ fn corpus_cr2_cfa_patterns() { eprintln!("CR2 CFA layouts checked on {seen} files"); } +/// The as-shot white balance of Canon CR2s whose ColorData is stored as SHORT words stays readable (the PowerShot / +/// EOS M files that store it as UNDEFINED bytes are covered by the unit test in `vendor/cr2.rs`; none is in this corpus). +/// Daylight-ish CR2s have R/G above 1 and B/G between 1 and 2.5 (observed 1.4-2.7 on these nine bodies). +#[test] +fn corpus_cr2_as_shot_white_balance() { + let dir = corpus_root().join("raw"); + let mut seen = 0; + for name in [ + "cr2-canon-40d.cr2", + "cr2-canon-550d.cr2", + "cr2-canon-5d2.cr2", + "cr2-canon-5d3.cr2", + "cr2-canon-5dsr.cr2", + "cr2-canon-6d.cr2", + "cr2-canon-7d.cr2", + "cr2-canon-80d.cr2", + ] { + let Ok(bytes) = std::fs::read(dir.join(name)) else { + eprintln!("skip: {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")); + assert!(wb[1] == 1.0 && wb[0] > 1.0 && wb[2] > 0.5 && wb[0] < 4.0 && wb[2] < 4.0, "{name}: WB {wb:?}"); + seen += 1; + } + eprintln!("CR2 as-shot WB checked on {seen} files"); +} + /// raw.pixls.us has the same D5100 scene as 14-bit lossless compressed and uncompressed NEF: the Huffman decode must /// match the uncompressed image (up to the small differences between two exposures). #[test] @@ -427,3 +457,115 @@ fn corpus_panasonic_encodings() { } eprintln!("Panasonic encodings checked on {seen} files"); } + +/// Olympus ORF (`crates/raw/src/vendor/orf.rs`): the colour-filter layout comes from the file's Exif `CFAPattern`. +/// With it, the decoded mosaic's colours follow the camera's own JPEG better than with any other layout. +#[test] +fn corpus_orf_cfa_patterns() { + let dir = corpus_root().join("raw"); + let cases = [("orf-olympus-e1.orf", "GRBG"), ("orf-olympus-e400.orf", "GRBG"), ("orf-olympus-xz2.orf", "RGGB")]; + let mut seen = 0; + for (name, cfa) in cases { + let Ok(bytes) = std::fs::read(dir.join(name)) else { + eprintln!("skip: {name} absent"); + continue; + }; + let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(img.cfa.as_ref().map(|c| c.name()).as_deref(), Some(cfa), "{name}: CFA layout"); + assert_eq!(green_on_main_diagonal(&img), cfa.starts_with('G'), "{name}: mosaic statistics disagree with {cfa}"); + let jpeg = embedded_preview(&bytes).unwrap_or_else(|| panic!("{name}: no embedded preview")); + let (gw, gh) = (24, 18); + let (sites, jpeg) = (raw_sites(&img, gw, gh), jpeg_cells(&jpeg, gw, gh)); + let agree = |l: &str| chroma_agreement(&sites, &jpeg, &lightcraft_raw::Cfa::bayer(l).unwrap().pattern); + let best_other = ["RGGB", "GRBG", "GBRG", "BGGR"].into_iter().filter(|l| *l != cfa).map(agree).fold(f64::MIN, f64::max); + let own = agree(cfa); + let a = img.active_area; + eprintln!("{name:32} {cfa}, active area at ({}, {}): colour agreement {own:.3} (others ≤ {best_other:.3})", a.x, a.y); + assert!(own > 0.5 && own > best_other, "{name}: colours follow the JPEG better with another layout ({own} vs {best_other})"); + seen += 1; + } + eprintln!("ORF CFA layouts checked on {seen} files"); +} + +/// Reference sensor sums and position-weighted sums from black-box decoding, independent +/// of this implementation. These cover complete images, padding, stripe boundaries and +/// lossy block quantizer changes; the source files are CC0, fetched by `xtask corpus`. +#[test] +fn corpus_fujifilm_compressed_samples() { + use sha2::{Digest, Sha256}; + let checksums = include_str!("../../../docs/raf-corpus.sha256"); + let cases: &[(&str, usize, usize, u64, u64)] = &[ + ("raf-fuji-gfx100-3773.raf", 11808, 8754, 483668733090, 6453395759968151080), + ("raf-fuji-gfx100-3775.raf", 11808, 8754, 120434414850, 6144091676930008551), + ("raf-fuji-gfx100rf-8091.raf", 11808, 8754, 509005112862, 6771145666297463071), + ("raf-fuji-gfx100s-4495.raf", 11808, 8754, 941987648006, 4811558780180901389), + ("raf-fuji-gfx100s-4503.raf", 11808, 8754, 939580201129, 4821879272388485929), + ("raf-fuji-gfx50s-1435.raf", 9216, 6210, 79545293042, 2565169091480702981), + ("raf-fuji-xe5-8509.raf", 7872, 5196, 76435253154, 1299804440011842144), + ("raf-fuji-xh2-6001.raf", 7872, 5196, 83480264024, 1799924693528487099), + ("raf-fuji-xh2-6002.raf", 7872, 5196, 72476569659, 1547275125734054244), + ("raf-fuji-xm5-7748.raf", 6336, 4182, 93243605328, 986712869515471030), + ("raf-fuji-xt2-865.raf", 6048, 4038, 44212684244, 541983062567959797), + ("raf-fuji-xt20-compressed.raf", 6048, 4038, 72690241711, 882852288583469317), + ("raf-fuji-xt4-3914.raf", 6384, 4182, 45943146703, 599112804573868025), + ("raf-fuji-xt4-3918.raf", 6384, 4182, 46401490361, 605690093061448074), + ("raf-fuji-xt5-6122.raf", 7872, 5196, 109559716516, 1653376659872066392), + ("raf-fuji-xt5-6123.raf", 7872, 5196, 109489917369, 1673725237502600190), + ("raf-fuji-xt50-7807.raf", 7872, 5196, 76453904143, 1262190863834170778), + ]; + let dir = corpus_root().join("raw"); + let mut seen = 0; + for &(name, width, height, sum, weighted) in cases { + let path = format!("corpus/raw/{name}"); + let checksum = checksums.lines().filter_map(|s| s.split_once(" ")).find(|(_, p)| *p == path).unwrap().0; + assert_eq!(checksum.len(), 64, "{name}: missing published SHA-256"); + let Ok(bytes) = std::fs::read(dir.join(name)) else { continue }; + let actual_sha: String = Sha256::digest(&bytes).iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!(actual_sha, checksum, "{name}: corpus file differs from its pinned identity"); + let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!((img.width, img.height), (width, height), "{name}"); + let lightcraft_raw::RawData::U16(data) = img.data else { panic!("{name}: float data") }; + let actual = data + .iter() + .enumerate() + .fold((0u64, 0u64), |(s, w), (i, &v)| (s.wrapping_add(u64::from(v)), w.wrapping_add((i as u64 + 1).wrapping_mul(u64::from(v))))); + assert_eq!(actual, (sum, weighted), "{name}: sensor samples differ from reference"); + seen += 1; + } + eprintln!("verified {seen} Fujifilm compressed sensor arrays"); +} + +/// The container rule for TIFF "shells" (a small IFD0 next to a private raw block) must not touch real +/// raws: every corpus file keeps the format its maker implies, and none is described as a private block. +#[test] +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()); + return; + }; + let mut checked = 0; + for p in rd.flatten().map(|e| e.path()).filter(|p| p.is_file()) { + let name = p.file_name().unwrap().to_string_lossy().to_lowercase(); + let expected = match name.split(['-', '.']).next().unwrap_or_default() { + "arw" => RawFormat::Arw, + "cr2" => RawFormat::Cr2, + "cr3" => RawFormat::Cr3, + "dng" => RawFormat::Dng, + "nef" => RawFormat::Nef, + "nrw" => RawFormat::Nef, // probed as Nef when the file has several IFDs + "orf" => RawFormat::Orf, + "pef" => RawFormat::Pef, + "raf" => RawFormat::Raf, + "rw2" | "rwl" | "raw" => RawFormat::Rw2, + _ => continue, + }; + let bytes = std::fs::read(&p).unwrap(); + assert_eq!(probe(&bytes), Some(expected), "{name}"); + if let Err(RawError::Unsupported(why)) = probe_info(&bytes) { + assert!(!why.contains("private block"), "{name}: {why}"); + } + checked += 1; + } + eprintln!("{checked} corpus raws keep their container"); +} diff --git a/crates/raw/tests/cr3_corpus.rs b/crates/raw/tests/cr3_corpus.rs new file mode 100644 index 000000000..6e2705762 --- /dev/null +++ b/crates/raw/tests/cr3_corpus.rs @@ -0,0 +1,78 @@ +//! Exact sensor-pixel regressions, not embedded-JPEG comparisons. +//! +//! Public-domain inputs from raw.pixls.us, kept in the gitignored corpus/raw. +//! Reference pixels were independently obtained by running the DNGLab v0.8.0 +//! release executable as a black-box oracle (analyze --raw-pixel). Its source +//! and camera matrices were not used. Canonical FNV-1a hashes below are over +//! decoded u16 samples in row-major, little-endian order, including masked borders. +//! macOS oracle archive SHA256: +//! ee70805cb60f18d5ed62548c4e595f6cee0a33407e15358eb1766a46afc1c16e. + +use lightcraft_raw::{RawData, RawFormat, decode, 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")) +} + +fn check(name: &str, hash: u64, sensor: (usize, usize)) { + let path = corpus().join("raw").join(name); + let Ok(bytes) = std::fs::read(&path) else { + eprintln!("skip: {} absent", path.display()); + return; + }; + let raw = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(raw.format, RawFormat::Cr3); + raw.validate().unwrap(); + assert_eq!((raw.width, raw.height, raw.cpp), (sensor.0, sensor.1, 1)); + assert_eq!(raw.info().developed_size(), (6000, 4000)); + assert_eq!(raw.info(), probe_info(&bytes).unwrap()); + 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 independent reference"); + assert!(raw.wb_multipliers.is_some(), "{name}: as-shot white balance missing"); + assert!(raw.white_at(0) > raw.black.mean(), "{name}: invalid black/white levels"); +} + +#[test] +fn canon_m50_lossless_sensor_is_exact() { + // raw.pixls.us/getfile.php/4657; SHA256 + // 1ba9ad6b51b315b88820eb0fe5fd7f52bc136ba37b1a5ca76825ebfe25b18058 + check("cr3-canon-m50-raw.cr3", 0x6226_1f0b_a81c_fcd2, (6288, 4056)); +} + +#[test] +fn canon_r100_lossless_sensor_is_exact() { + // raw.pixls.us/getfile.php/7896; SHA256 + // 6d83217d58a5e6d2dabcf470430e91a16676b60531c025b81fa7453fc74b0521 + check("cr3-canon-r100-raw.cr3", 0xcbe5_299a_b9c5_2630, (6288, 4056)); +} + +#[test] +fn canon_m50_craw_sensor_is_exact() { + // raw.pixls.us/getfile.php/2663; SHA256 + // 15384b775867ec4c42b11882837f1e368cedc0561832ffab271221e6bb80be4c + check("cr3-canon-m50-craw.cr3", 0x9aac_bfe6_6f50_5e1b, (6288, 4056)); +} + +#[test] +fn canon_r100_craw_sensor_is_exact() { + // raw.pixls.us/getfile.php/7897; SHA256 + // 0b66842b2fe00329ebc05fe8ab9357ddbbe1a0a2d2e69a2893102de7fdf5c942 + check("cr3-canon-r100-craw.cr3", 0x341c_706b_37c3_8bbf, (6288, 4056)); +} + +#[test] +fn canon_r8_lossless_sensor_is_exact() { + // raw.pixls.us/getfile.php/6585; SHA256 + // 7d5c6dbb11ff7e6ee58715d90a20f5d801e8b103f4711ff6ea329ceafcef254c + check("cr3-canon-r8-raw.cr3", 0x63c1_c6d8_d1eb_2312, (6188, 4120)); +} + +#[test] +fn canon_r8_craw_sensor_is_exact() { + // raw.pixls.us/getfile.php/6587; SHA256 + // df33cf394573645ce03dea1b2e9f0b5cc2b7734e9e3391ee7114151414cf2812 + check("cr3-canon-r8-craw.cr3", 0xf836_a663_a795_a375, (6188, 4120)); +} diff --git a/crates/segment/src/fetch/mod.rs b/crates/segment/src/fetch/mod.rs index 55c083ccf..35f2d2eb5 100644 --- a/crates/segment/src/fetch/mod.rs +++ b/crates/segment/src/fetch/mod.rs @@ -51,6 +51,14 @@ pub fn mirrors(env: Option<&str>, file: Option<&Path>) -> Vec { lightcraft_fetch::mirrors(env, file, DEFAULT_MIRRORS) } +/// Download settings for SAM 3: the defaults, plus [`TOKEN_ENV`] for a gated mirror. +pub fn options() -> Options { + Options { token_env: Some(TOKEN_ENV), ..Options::default() } +} + +/// Environment variable with a bearer token for the SAM 3 mirror's own host (a gated Hugging Face repository). +pub const TOKEN_ENV: &str = "LIGHTCRAFT_SAM3_TOKEN"; + /// What to tell the user when there is no mirror for the SAM 3 model. pub fn no_mirrors_message() -> String { format!( diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json new file mode 100644 index 000000000..af10202f8 --- /dev/null +++ b/crates/ui-egui/locales/de-formats.json @@ -0,0 +1,191 @@ +{ + " ({unsure} with several look-alike files: use Locate)": " ({unsure} mit mehreren ähnlichen Dateien: „Fehlende Datei suchen“ verwenden)", + " ({} in Recently Deleted)": " ({} in „Zuletzt gelöscht“)", + " · {:.0}% clipped": " · {:.0}% beschnitten", + " · {failed} couldn't be rendered": " · nicht gerenderte Fotos: {failed}", + " · {skipped} skipped (file exists)": " · {skipped} übersprungen (Datei vorhanden)", + " · {} duplicate{} skipped": " · {} übersprungen (Duplikate){:.0}", + " · {} failed: {}": " · {} fehlgeschlagen: {}", + " · {} moved": " · {} verschoben", + " · {} not readable": " · {} nicht lesbar", + " · {} original{} left at the source": " · Originale an der Quelle verblieben: {}{:.0}", + " · {} restored from Recently Deleted": " · {} aus „Zuletzt gelöscht“ wiederhergestellt", + " — not carried over: {}{}": " — nicht übernommen: {}{}", + "+{} more": "+{} weitere", + ", {failed} file{} not readable": ", nicht lesbare Dateien: {failed}{:.0}", + "; {kept} already had a location": "; bereits verortet: {kept}", + "; {outside} outside its time range": "; {outside} außerhalb des Zeitbereichs", + "@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: {} PRs, {} Commits, +{} / −{} Zeilen (Δ {}), +{} / −{} Binärdateien, {} – {}", + "Add {local_n} to My Photos": "{local_n} zu „Meine Fotos“ hinzufügen", + "Add {} Photo{}": "{} Foto{} hinzufügen", + "Add {} Photo{}, restore {}": "{} Foto{} hinzufügen, {} wiederherstellen", + "Add “{}” to Favorites": "„{}“ zu Favoriten hinzufügen", + "Added {n} photo{} to My Photos": "{n} Foto{} zu „Meine Fotos“ hinzugefügt", + "Added {n} photo{} to “{}”": "{n} Foto{0} zu „{1}“ hinzugefügt", + "Added {what} to {name}": "{what} zu {name} hinzugefügt", + "Adding photos… {} of {}": "Fotos werden hinzugefügt… {} von {}", + "All {n} selected photos move by the same amount.": "Alle {n} ausgewählten Fotos werden um denselben Betrag verschoben.", + "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Bereits in der Bibliothek unter „Zuletzt gelöscht“: {} Foto{} ausgewählt · Rechtsklick → Wiederherstellen, oder endgültig löschen, um neu zu importieren", + "Already in the library: {} photo{} selected in All Photos": "Bereits in der Bibliothek: {} Foto{} in „Alle Fotos“ ausgewählt", + "Auto ({})": "Automatisch ({})", + "Auto Import: added {} photo{}": "Automatischer Import: {} Foto{} hinzugefügt", + "Auto Sync: edits apply to {n} photos": "Automatische Synchronisierung: Bearbeitungen gelten für {n} Fotos", + "Automatic ({} MB)": "Automatisch ({} MB)", + "Build 1:1 Previews ({scope})": "1:1-Vorschauen erstellen ({scope})", + "Build Smart Previews ({scope})": "Smart-Vorschauen erstellen ({scope})", + "Build Standard-Sized Previews ({scope})": "Standardvorschauen erstellen ({scope})", + "Building previews for {} photos…": "Vorschauen für {} Fotos werden erstellt…", + "Click {}:1": "Klick {}:1", + "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}", + "Decoder: {reason}": "Decoder: {reason}", + "Delete \"{label}\"": "„{label}“ löschen", + "Delete \"{label}\" (and the mask)": "„{label}“ löschen (und die Maske)", + "Delete {n} Photos": "{n} Fotos löschen", + "Delete “{current}”": "„{current}“ löschen", + "Discard Smart Previews ({scope})": "Smart-Vorschauen verwerfen ({scope})", + "Done ({what}): {done} photo{plural}": "Fertig ({what}): {done} Foto{plural}", + "Duplicate \"{label}\"": "„{label}“ duplizieren", + "Example: {example}": "Beispiel: {example}", + "Exported {n} photo{}": "{n} Foto{} exportiert", + "Exported {ok} of {total} photo{}": "{ok} von {total} Foto{} exportiert", + "Exported {} point curve preset{}": "Exportierte Punktkurvenvorgaben: {}{:.0}", + "Exported {} preset{}": "Exportierte Vorgaben: {}{:.0}", + "Exporting {} of {total}": "{} von {total} wird exportiert", + "Filter bar — {active} active filter{}": "Filterleiste — aktive Filter: {active}{:.0}", + "Focus {:.0}": "Schärfe {:.0}", + "Folder “{}” (and its subfolders)": "Ordner „{}“ (und seine Unterordner)", + "Found {n} missing photo{}; {left} still missing{unsure}": "Gefundene fehlende Fotos: {n}{:.0}; weiterhin fehlend: {left}{unsure}", + "Healed {n} dust spot{}": "Reparierte Staubflecken: {n}{:.0}", + "Import cancelled · {} photo{} added": "Import abgebrochen · {} Foto{} hinzugefügt", + "Import failed: {e}": "Import fehlgeschlagen: {e}", + "Imported {n} point curve preset{}": "Importierte Punktkurvenvorgaben: {n}{:.0}", + "Imported {n} preset{}": "Importierte Vorgaben: {n}{:.0}", + "Imported {n} preset{}, {f} file{} not readable": "Importierte Vorgaben: {n}{0:.0}, nicht lesbare Dateien: {f}{1:.0}", + "Imported {profiles} profile{} (Profile browser ▸ their groups)": "Importierte Profile: {profiles}{:.0} (Profilbrowser → jeweilige Gruppen)", + "Imported {} photo{}": "{} Foto{} importiert", + "Insert at the cursor (also written {})": "An Cursorposition einfügen (auch als {} geschrieben)", + "Label Set: {name}": "Farbmarkierungssatz: {name}", + "LightCraft could not open its window: {e}": "LightCraft konnte sein Fenster nicht öffnen: {e}", + "LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft konnte sein Fenster nicht öffnen: {e}\n\nEinzelheiten stehen in der Protokolldatei: {path}", + "Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Lightroom-Katalog geprüft: {photos} Fotos, {collections} Sammlungen, {missing} fehlen", + "Lightroom import complete: {imported} photos added": "Lightroom-Import abgeschlossen: {imported} Fotos hinzugefügt", + "Lightroom import complete: {imported} photos added, {failed} failed": "Lightroom-Import abgeschlossen: {imported} Fotos hinzugefügt, {failed} fehlgeschlagen", + "Lightroom import complete: {imported} photos added; index warning: {warning}": "Lightroom-Import abgeschlossen: {imported} Fotos hinzugefügt; Index-Warnung: {warning}", + "Lightroom import failed: {e}{warning}": "Lightroom-Import fehlgeschlagen: {e}{warning}", + "Lightroom inspection failed: {e}{warning}": "Lightroom-Prüfung fehlgeschlagen: {e}{warning}", + "MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT ODER Apache-2.0. Schriften: {} (OFL). Symbole: eigene Gestaltung.", + "Matches: {}": "Treffer: {}", + "Merging… {stage} {:.0}%": "Wird zusammengefügt… {stage} {:.0}%", + "Move {what} to Recently Deleted?": "{what} nach „Zuletzt gelöscht“ verschieben?", + "Named ({named_n})": "Benannt ({named_n})", + "Painting “{k}”: click photos to add or remove it · Esc stops": "„{k}“ auftragen: Fotos anklicken, um es hinzuzufügen oder zu entfernen · Esc beendet", + "Paste into {n} photo{} — only settings that were copied are pasted": "In {n} Foto{} einfügen — nur kopierte Einstellungen werden eingefügt", + "Preset: {name}": "Vorgabe: {name}", + "Preview only — editing the camera's embedded JPEG ({})": "Nur Vorschau — das eingebettete Kamera-JPEG wird bearbeitet ({})", + "Preview only — {}": "Nur Vorschau — {}", + "Previews ready for {done} photo{plural}": "Vorschauen für {done} Foto{plural} fertig", + "Profile: {}": "Profil: {}", + "Proof Copy ({})": "Softproof-Kopie ({})", + "Proof Preview · {}": "Softproof-Vorschau · {}", + "Quick Develop · {n} photos": "Schnellentwicklung · {n} Fotos", + "Quick Develop: {name}": "Schnellentwicklung: {name}", + "Rated {}": "Bewertet mit {}", + "Reading Lightroom catalog… {done} of {total}": "Lightroom-Katalog wird gelesen … {done} von {total}", + "Reading photos… {done} of {total}": "Fotos werden gelesen… {done} von {total}", + "Reading photos… {} of {}": "Fotos werden gelesen… {} von {}", + "Redo {}": "Wiederholen: {}", + "Remove “{name}” and its {count} photo{} from the library?": "„{name}“ mit {count} Foto{} aus der Bibliothek entfernen?", + "Remove “{}” from Favorites": "„{}“ aus Favoriten entfernen", + "Removed from {}": "Entfernt von {}", + "Removed {what} from {name}": "{what} aus {name} entfernt", + "Rename {n} Photos…": "{n} Fotos umbenennen…", + "Rename “{name}”": "„{name}“ umbenennen", + "Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "Benennt „{from}“ bei {n} Foto{} um (auch untergeordnete Stichwörter). Verwende | für Ebenen, z. B. Reise|Italien. Bei vorhandenem Namen werden beide zusammengeführt.", + "Rendering on the CPU: {why}": "Rendering auf der CPU: {why}", + "Replace {} with:": "{} ersetzen durch:", + "Reset {name}": "{name} zurücksetzen", + "Reset {} Channel": "Kanal {} zurücksetzen", + "Restore {}": "{} wiederherstellen", + "Restore {} Photo{}": "{} Foto{} wiederherstellen", + "Saved export preset “{}”": "Exportvorgabe „{}“ gespeichert", + "Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Bewertet {scope} nach Schärfe und Belichtung und findet ähnliche Aufnahmen im Abstand weniger Sekunden (Serien).", + "Show {hidden} hidden location{}": "Ausgeblendete Orte anzeigen: {hidden}{:.0}", + "Showing {n} of {}": "{n} von {} werden angezeigt", + "Smart album: {}": "Smart-Album: {}", + "Tagged {n} photo{} from the tracklog": "{n} Foto{} anhand des GPS-Tracks verortet", + "The SAM 3 download failed: {e}": "SAM-3-Download fehlgeschlagen: {e}", + "Undo {}": "Rückgängig: {}", + "Unknown tag{s} {} stay{} as typed (see Tags)": "Unbekannte Platzhalter{s:.0} {} bleiben unverändert{:.0} (siehe „Platzhalter“)", + "Updated “{name}”": "„{name}“ aktualisiert", + "Version {} — a clean-room, pure-Rust photo library and raw developer.": "Version {} — eine unabhängig entwickelte Fotobibliothek und RAW-Entwicklung in reinem Rust.", + "Version: {}": "Version: {}", + "Virtual copy “{name}” of {of}": "Virtuelle Kopie „{name}“ von {of}", + "Working on {what} for {} photos…": "{what} für {} Fotos wird verarbeitet…", + "none (CPU rendering): {why}": "keine (CPU-Rendering): {why}", + "the {n} selected photos": "die {n} ausgewählten Fotos", + "the {} photos in view": "die {} Fotos in der Ansicht", + "these {count} photos": "diese {count} Fotos", + "{count} smart preview{} · {:.1} MB": "Smart-Vorschauen: {count}{0:.0} · {1:.1} MB", + "{f} folders (and their subfolders)": "{f} Ordner (und ihre Unterordner)", + "{label} Grading": "Farbtonung: {label}", + "{label} {d:+} on every selected photo": "{label} {d:+} für jedes ausgewählte Foto", + "{label} {index} of {n}": "{label} {index} von {n}", + "{label}, {n} photos": "{label}, Fotos: {n}", + "{matching} of {t} photos": "Fotos: {matching} von {t}", + "{matching} photos": "Fotos: {matching}", + "{mode} spot {} of {}": "{mode}: Stelle {} von {}", + "{month}": "{month:02}", + "{month} {year}": "{month:02}/{year}", + "{msg} ({failed} failed)": "{msg} ({failed} fehlgeschlagen)", + "{name} Label": "Farbmarkierung {name}", + "{name} Off": "{name} aus", + "{name} On": "{name} ein", + "{name} opened for editing; it is stacked with the original": "{name} zum Bearbeiten geöffnet; mit dem Original gestapelt", + "{name} — double-click to reset it": "{name} — zum Zurücksetzen doppelklicken", + "{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "Nicht auf den Datenträger geschriebene Änderungen: {n}{0:.0}: {e}\nWenn du jetzt beendest, gehen diese Änderungen verloren.{1:.0}", + "{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "Ungespeicherte Änderungen: {n}{0:.0} im Arbeitsspeicher, aber nicht auf dem Datenträger: {e}\nLightCraft versucht es automatisch erneut; beim Beenden gingen diese Änderungen verloren.{1:.0}", + "{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "Änderungen nur im Arbeitsspeicher gespeichert: {n}{:.0}: {e} — LightCraft versucht es erneut", + "{n} found · {sel} selected": "{n} gefunden · {sel} ausgewählt", + "{n} photos": "Fotos: {n}", + "{n} photos in {} are hidden by the filters above": "Ausgeblendete Fotos in {}: {n} (durch die obigen Filter)", + "{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", + "{pid} (missing)": "{pid} (fehlt)", + "{sel_n} selected · {counted}": "{sel_n} ausgewählt · {counted}", + "{stage}… {:.0}%": "{stage}… {:.0}%", + "{verb} {n} Photo{}": "{verb}: {n} Foto{}", + "{weekday}, {day}": "{weekday}, {day}.", + "{weekday}, {day} {month} {year}": "{weekday}, {day}.{month:02}.{year}", + "{what} merge added": "{what}-Zusammenfügung hinzugefügt", + "{what} merge failed: {e}": "{what}-Zusammenfügung fehlgeschlagen: {e}", + "{year}": "{year}", + "{{date:…}} directives: {}": "{{date:…}}-Formatangaben: {}", + "{} and higher": "{} und höher", + "{} contributors · {} commits": "{} Mitwirkende · {} Commits", + "{} failed": "{} fehlgeschlagen", + "{} files": "{} Dateien", + "{} files and {f} folder{}": "{} Dateien und {f} Ordner{:.0}", + "{} item{}": "Elemente: {}{:.0}", + "{} of 4 guides — drag along lines that should be vertical or horizontal.": "{} von 4 Hilfslinien — entlang von Linien ziehen, die senkrecht oder waagerecht sein sollen.", + "{} photos": "Fotos: {}", + "{} photos · exposures {} EV": "{} Fotos · Belichtungen {} EV", + "{} photos · {} · {:.0}° × {:.0}°": "{} Fotos · {} · {:.0}° × {:.0}°", + "{} photo{} not readable": "{} Foto{} nicht lesbar", + "{} sample{}": "Farbproben: {}{:.0}", + "{} sec": "{} s", + "{} spot(s) on this photo": "{} Retuschierstelle(n) in diesem Foto", + "{} — show their photos": "{} — Fotos dieser Person anzeigen", + "· {dups} already in the library": "· {dups} bereits in der Bibliothek", + "· {n} photo{}": "· {n} Foto{}", + "… and {more} more": "… und {more} weitere", + "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 kann diese RAW-Variante noch nicht dekodieren ({}). Du bearbeitest die eingebettete JPEG-Vorschau der Kamera. Sie enthält bereits deren Bildstil (z. B. Monochrom) und Weißabgleich.", + "Filtered: {label}": "Gefiltert: {label}", + "{n} similar photo{} · View ▸ Clear Filters to see all": "Ähnliche Fotos: {n}{:.0} · Ansicht → Filter löschen zeigt alle Fotos", + "Moved; {} photo(s) relinked": "Verschoben; {} Fotos neu verknüpft", + "{} 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: {}" +} diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json new file mode 100644 index 000000000..2a4721864 --- /dev/null +++ b/crates/ui-egui/locales/de.json @@ -0,0 +1,1801 @@ +{ + " (library default)": " (Bibliotheksstandard)", + " (inverted)": " (umgekehrt)", + " · best of its burst": " · bestes Foto der Serie", + " · in a burst": " · Teil einer Serie", + "\"saved in memory but not written to disk\", the top bar shows a warning, and LightCraft keeps retrying until the": "„im Arbeitsspeicher gespeichert, aber nicht auf den Datenträger geschrieben“, die obere Leiste zeigt eine Warnung, und LightCraft versucht es erneut,", + "#106): a DNG is checked against the raw data, written to a temp file, synced and read back before it gets its": "(Issue #106). Ein DNG wird mit den RAW-Daten verglichen, temporär geschrieben, synchronisiert und zurückgelesen, bevor es seinen", + "% of all commits": "% aller Commits", + "(`gpuReason`, `gpuFallback`), Help ▸ System Info and Settings ▸ Performance say why the GPU isn't": "mit `gpuReason` und `gpuFallback`, Hilfe → Systeminformationen und Einstellungen → Leistung erklären, warum die GPU", + "(e.g. D750, D780, D850, D7500, Z 50) no longer render nearly black or with crushed shadows: their black level was": "etwa D750, D780, D850, D7500 und Z 50, erscheinen nicht mehr fast schwarz oder mit zugelaufenen Tiefen: Ihr Schwarzwert wurde", + "(issue #94): an HTTP request from a web page can no longer carry a command in its body. Lines are capped at": "(Issue #94). Eine HTTP-Anfrage einer Webseite kann so keinen Befehl mehr im Anfrageinhalt transportieren. Zeilen sind auf", + "+ Group": "+ Gruppe", + "+ Rule": "+ Regel", + "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", + "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.", + "4 MiB and connections at 16.": "4 MiB und gleichzeitige Verbindungen auf 16 begrenzt.", + "630 drivers): on Windows the window and GPU rendering use DirectX 12 only and never load the Vulkan driver unless": "Fenster und GPU-Rendering verwenden unter Windows standardmäßig ausschließlich DirectX 12; der Vulkan-Treiber wird nur auf ausdrückliche Anforderung geladen.", + "= exactly": "= genau", + "A blank template keeps the original names.": "Eine leere Vorlage behält die Originalnamen bei.", + "A change that can't be saved to disk (full or unplugged drive) is no longer silent: the command reports": "Kann eine Änderung wegen eines vollen oder getrennten Laufwerks nicht gespeichert werden, bleibt das nicht mehr unbemerkt: Der Befehl meldet", + "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 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", + "AI masks with SAM 3 (Object and Describe in the Masking panel): click an object to select it (⌥-click leaves a": "KI-Masken mit SAM 3 über Objekt und Beschreiben im Maskenbereich: Ein Objekt zum Auswählen anklicken; ⌥-Klick lässt einen Teil", + "About": "Über", + "About LightCraft": "Über LightCraft", + "Active": "Aktiv", + "Activity": "Aktivität", + "Actual size": "Originalgröße", + "Add": "Hinzufügen", + "Add Catchlight": "Lichtreflex hinzufügen", + "Add Folder to Local": "Ordner zu „Lokal“ hinzufügen", + "Add Mask": "Maske hinzufügen", + "Add Object Click": "Objektklick hinzufügen", + "Add Point Color Sample": "Punktfarbenprobe hinzufügen", + "Add Red Eye Correction": "Rote-Augen-Korrektur hinzufügen", + "Add Remove Spot": "Retuschierstelle hinzufügen", + "Add Selected Photos": "Ausgewählte Fotos hinzufügen", + "Add a rule (⌥-click: a group)": "Regel hinzufügen (⌥-Klick: eine Gruppe)", + "Add in place": "Am Speicherort hinzufügen", + "Add in place: the library references the files where they are; nothing is copied or moved.": "Am Speicherort hinzufügen: Die Bibliothek verweist auf die Dateien an ihrem aktuellen Speicherort; nichts wird kopiert oder verschoben.", + "Add keyword": "Stichwort hinzufügen", + "Add number": "Nummer hinzufügen", + "Add to": "Hinzufügen zu", + "Add to Album": "Zu Album hinzufügen", + "Add to Favorites": "Zu Favoriten hinzufügen", + "Add to Local": "Zu „Lokal“ hinzufügen", + "Add to My Photos": "Zu „Meine Fotos“ hinzufügen", + "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", + "Add/Subtract/Intersect Mask": "Maske hinzufügen/subtrahieren/schneiden", + "Added to photos you import that don't already have it.": "Wird importierten Fotos hinzugefügt, die diese Angabe noch nicht enthalten.", + "Adding": "Wird hinzugefügt", + "Adding Lightroom photos…": "Lightroom-Fotos werden hinzugefügt …", + "Advanced search…": "Erweiterte Suche…", + "After": "Nachher", + "After 30 days": "Nach 30 Tagen", + "After 7 days": "Nach 7 Tagen", + "After 90 days": "Nach 90 Tagen", + "After a year": "Nach einem Jahr", + "Album": "Album", + "Albums": "Alben", + "All": "Alle", + "All Metadata": "Alle Metadaten", + "All Metadata…": "Alle Metadaten…", + "All Photos": "Alle Fotos", + "All rights reserved": "Alle Rechte vorbehalten", + "All the photos imported from this disk are included.": "Alle von diesem Datenträger importierten Fotos sind eingeschlossen.", + "All types": "Alle Typen", + "Alt Text": "Alternativtext", + "Always": "Immer", + "Amount": "Stärke", + "Analyzing the photo for AI masks…": "Foto wird für KI-Masken analysiert…", + "Any Searchable Text": "Beliebiger durchsuchbarer Text", + "Aperture": "Blende", + "Application": "Anwendung", + "Apply": "Anwenden", + "Apply Color Label Set": "Farbmarkierungssatz anwenden", + "Apply Filter Preset": "Filtervorgabe anwenden", + "Apply Metadata Preset": "Metadatenvorgabe anwenden", + "Apply Point Curve Preset": "Punktkurvenvorgabe anwenden", + "Apply Preset": "Vorgabe anwenden", + "Apply Settings JSON": "Einstellungen aus JSON anwenden", + "Approved": "Freigegeben", + "Aqua": "Aquamarin", + "Aqua Hue": "Aquamarin – Farbton", + "Aqua Luminance": "Aquamarin – Luminanz", + "Aqua Saturation": "Aquamarin – Sättigung", + "ArtCraft Website": "ArtCraft-Website", + "ArtCraft — more creative apps": "ArtCraft — weitere Kreativ-Apps", + "As Shot": "Wie Aufnahme", + "As shot": "Wie Aufnahme", + "Ascending": "Aufsteigend", + "Aspect": "Seitenverhältnis", + "Aspect Ratio": "Seitenverhältnis", + "Assisted Culling": "Unterstützte Fotoauswahl", + "Assisted Culling…": "Unterstützte Fotoauswahl…", + "Auto": "Auto", + "Auto Advance": "Automatisch weiterschalten", + "Auto Advance Off": "Automatisches Weiterschalten aus", + "Auto Advance On": "Automatisches Weiterschalten ein", + "Auto Advance after rating or flagging": "Nach Bewertung oder Markierung automatisch weiterschalten", + "Auto Advance: move to the next photo after rating or flagging": "Automatisch weiterschalten: Nach Bewertung oder Markierung zum nächsten Foto wechseln", + "Auto Align": "Automatisch ausrichten", + "Auto B&W Mix": "Automatische Schwarzweißmischung", + "Auto Crop": "Automatisch zuschneiden", + "Auto Import": "Automatischer Import", + "Auto Import Now": "Jetzt automatisch importieren", + "Auto Import Settings": "Einstellungen für automatischen Import", + "Auto Mask": "Automatisch maskieren", + "Auto Select": "Automatisch auswählen", + "Auto Settings": "Automatische Einstellungen", + "Auto Straighten": "Automatisch gerade ausrichten", + "Auto Sync": "Automatisch synchronisieren", + "Auto Sync: edits apply to every selected photo. Auto B&W mix. Automatic versions.": "Automatische Synchronisierung auf alle ausgewählten Fotos, automatische Schwarzweißmischung und automatische Versionen.", + "Auto settings applied": "Automatische Einstellungen angewendet", + "Auto-Stack by Capture Time": "Automatisch nach Aufnahmezeit stapeln", + "Auto-Stack by Capture Time…": "Automatisch nach Aufnahmezeit stapeln…", + "Auto-Tag Photos from Tracklog": "Fotos anhand eines GPS-Tracks automatisch verorten", + "Auto-Tag from Tracklog": "Anhand eines GPS-Tracks automatisch verorten", + "Auto-Tag from Tracklog: GPS locations for your photos from a GPX track log, matched by capture time.": "Automatisches Verorten anhand eines GPS-Tracks: GPS-Orte für deine Fotos aus einem GPX-Track, abgeglichen nach Aufnahmezeit.", + "Auto-Tag from Tracklog…": "Anhand eines GPS-Tracks automatisch verorten…", + "Automatic": "Automatisch", + "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.": "Automatisch rendert die Detailansicht in der angezeigten Größe, bis zu den eigenen Pixeln des Fotos. Eine Größe wählen, um sie zu begrenzen: kleiner ist schneller.", + "Automatically Write Changes into XMP": "Änderungen automatisch in XMP schreiben", + "Automatically write changes into XMP sidecars": "Änderungen automatisch in XMP-Begleitdateien schreiben", + "Autumn Gold": "Herbstgold", + "B&W": "S/W", + "B&W Mix": "Schwarzweißmischung", + "B&W Mixer": "Schwarzweißmischer", + "Back": "Zurück", + "Back Up Library…": "Bibliothek sichern…", + "Back to Edit": "Zurück zur Bearbeitung", + "Back to Edit (E)": "Zurück zur Bearbeitung (E)", + "Back to Grid": "Zurück zum Raster", + "Background": "Hintergrund", + "Balance": "Balance", + "Basic": "Grundeinstellungen", + "Before": "Vorher", + "Before/After (Y)": "Vorher/Nachher (Y)", + "Before/After Settings": "Vorher/Nachher-Einstellungen", + "Before/After Split": "Vorher/Nachher geteilt", + "Before/After Split Top/Bottom": "Vorher/Nachher geteilt – oben/unten", + "Before/After Top/Bottom": "Vorher/Nachher – oben/unten", + "Begin Interaction": "Interaktion beginnen", + "Best of Similar Shots": "Beste ähnlicher Aufnahmen", + "Binary assets added": "Hinzugefügte Binärdateien", + "Binary assets removed": "Entfernte Binärdateien", + "Bit depth": "Bittiefe", + "Black & White": "Schwarzweiß", + "Blacks": "Schwarz", + "Bleach Bypass": "Bleach-Bypass", + "Bleached": "Ausgebleicht", + "Blending": "Überblendung", + "Blue": "Blau", + "Blue Hour Boost": "Blaue Stunde verstärken", + "Blue Hue": "Blau – Farbton", + "Blue Luminance": "Blau – Luminanz", + "Blue Primary": "Primärfarbe Blau", + "Blue Saturation": "Blau – Sättigung", + "Blue channel": "Blaukanal", + "Blue label": "Blaue Markierung", + "Blue/Yellow Fringe": "Blaue/gelbe Farbsäume", + "Blurry photos below the score are flagged as rejects.": "Unscharfe Fotos unterhalb des Grenzwerts werden als abgelehnt markiert.", + "Boundary Warp": "Randverkrümmung", + "Bright & Airy": "Hell und luftig", + "Bring All to Front": "Alle nach vorne bringen", + "Browse Folder": "Ordner durchsuchen", + "Browse Folder…": "Ordner durchsuchen…", + "Browse Profiles": "Profile durchsuchen", + "Browser version (experimental), keeping a library safe (issue #107): File ▸ Back Up Library… downloads the catalog": "Browserversion (experimentell), Schutz der Bibliothek (Issue #107): Datei → Bibliothek sichern… lädt Katalog", + "Browse…": "Durchsuchen…", + "Brush": "Pinsel", + "Brush Settings": "Pinseleinstellungen", + "Brush Stroke": "Pinselstrich", + "Build Previews": "Vorschauen erstellen", + "Build Smart Previews": "Smart-Vorschauen erstellen", + "By Date": "Nach Datum", + "By day (YYYY/YYYY-MM-DD)": "Nach Tag (JJJJ/JJJJ-MM-TT)", + "By month (YYYY/YYYY-MM)": "Nach Monat (JJJJ/JJJJ-MM)", + "CPU threads": "CPU-Threads", + "Calibration": "Kalibrierung", + "Camera": "Kamera", + "Camera make and model": "Kamerahersteller und -modell", + "Cameras and Cards": "Kameras und Karten", + "Cancel": "Abbrechen", + "Cancel AI Mask Model Download": "Download des KI-Maskenmodells abbrechen", + "Cancel Download": "Download abbrechen", + "Cancel Interaction": "Interaktion abbrechen", + "Cancel Preview Build": "Vorschauerstellung abbrechen", + "Candidate": "Kandidat", + "Canon CR2 photos from the EOS 7D, 50D, 60D, 550D, 600D, 1200D, 1300D, 5D Mark II and 1D Mark IV (and other": "Canon-CR2-Fotos der EOS 7D, 50D, 60D, 550D, 600D, 1200D, 1300D, 5D Mark II und 1D Mark IV sowie anderer", + "Canon CR3 files show their full-size embedded JPEG (e.g. 6960 × 4640 on an EOS R6 Mark III) instead of the": "Canon-CR3-Dateien zeigen ihr eingebettetes JPEG in voller Größe, etwa 6960 × 4640 auf einer EOS R6 Mark III, statt der", + "Caption": "Bildunterschrift", + "Capture Date": "Aufnahmedatum", + "Capture date and time in your own format (directives below)": "Aufnahmedatum und -zeit im eigenen Format (Formatangaben unten)", + "Capture date as YYYYMMDD": "Aufnahmedatum als JJJJMMTT", + "Captured": "Aufgenommen", + "Catalog Statistics": "Katalogstatistik", + "Catchlight": "Lichtreflex", + "Change": "Ändern", + "Change Component Mode": "Komponentenmodus ändern", + "Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "Unzulässige Zeichen in Dateinamen (/ \\ : * ? \" < > |) werden zu -; ein vorhandener Name erhält -1, -2….", + "Check All": "Alle auswählen", + "Check Output Path": "Ausgabepfad prüfen", + "Check the files of this type": "Dateien dieses Typs auswählen", + "Checking names…": "Namen werden geprüft…", + "Choose Another Library…": "Andere Bibliothek auswählen…", + "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 photo in the grid or filmstrip": "Wähle ein Foto im Raster oder Filmstreifen", + "Choose…": "Auswählen…", + "Cinematic": "Filmisch", + "City": "Stadt", + "Clarity": "Klarheit", + "Clean Studio": "Sauberes Studio", + "Clear": "Löschen", + "Clear Cache": "Cache leeren", + "Clear Filters": "Filter löschen", + "Clear Guides": "Hilfslinien löschen", + "Clear History": "Verlauf löschen", + "Clear Preview Cache": "Vorschau-Cache leeren", + "Clear Quick Collection": "Schnellsammlung leeren", + "Clear Reference": "Referenz entfernen", + "Clear all": "Alles löschen", + "Clear form": "Formular leeren", + "Clear rating": "Bewertung entfernen", + "Clear rating and advance": "Bewertung entfernen und weiter", + "Cleared History": "Verlauf gelöscht", + "Click Zoom Ratio": "Zoomfaktor beim Klicken", + "Click a shortcut, then press the new keys (Esc cancels).": "Auf ein Tastenkürzel klicken, dann die neuen Tasten drücken (Esc bricht ab).", + "Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "Klicke auf einen Platzhalter, um ihn einzufügen. Die Beispiele gelten für IMG_0042.CR3, aufgenommen am 14. Januar 2026 um 05:58:48.", + "Click photos in the grid to toggle the keyword; Esc stops": "Klicke auf Fotos im Raster, um das Stichwort umzuschalten; Esc beendet den Vorgang", + "Click the object to select it · ⌥-click leaves a part out": "Objekt zum Auswählen anklicken · ⌥-Klick lässt einen Teil aus", + "Click the photo to pick a colour · ⇧-click adds more": "Klicke auf das Foto, um eine Farbe auszuwählen · ⇧-Klick fügt weitere hinzu", + "Click the photo to pick a colour; ⇧-click adds more (up to 5)": "Klicke auf das Foto, um eine Farbe auszuwählen; ⇧-Klick fügt weitere hinzu (bis zu 5)", + "Click to clear these filters": "Klicken, um diese Filter zu löschen", + "Click to restore · double-click to rename": "Zum Wiederherstellen klicken · zum Umbenennen doppelklicken", + "Clone": "Kopierstempel", + "Close": "Schließen", + "Close Panel": "Bedienfeld schließen", + "Close Window": "Fenster schließen", + "Close all": "Alle schließen", + "Close file": "Datei schließen", + "Cloudy": "Bewölkt", + "Cmd+Shift+R, keeps the photo and clears its edits.)": "mit ⌘⇧R die Bearbeitungen zurück und behält das Foto.", + "Collapse": "Einklappen", + "Collapse All Stacks": "Alle Stapel einklappen", + "Collapse Stack": "Stapel einklappen", + "Color": "Farbe", + "Color Grading": "Farbtonung", + "Color Label": "Farbmarkierung", + "Color Label Names": "Namen der Farbmarkierungen", + "Color Label Sets": "Farbmarkierungssätze", + "Color Mixer": "Farbmischer", + "Color Noise Reduction": "Farbrauschreduzierung", + "Color Priority": "Farbpriorität", + "Color Range": "Farbbereich", + "Color label cleared": "Farbmarkierung entfernt", + "Color space": "Farbraum", + "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.", + "Colour-range masks: click the photo to sample. Luminance ranges: range bar, smoothness, luminance map.": "Farbbereichsmasken durch Anklicken einer Farbprobe; Luminanzbereiche mit Bereichsleiste, Glättung und Luminanzkarte.", + "Combine files…": "Dateien zusammenführen…", + "Command Journal": "Befehlsprotokoll", + "Comments panel": "Kommentarbereich", + "Commits": "Commits", + "Company": "Unternehmen", + "Compare": "Vergleichen", + "Compare (Shift+C)": "Vergleichen (Umschalt+C)", + "Compare Before and After": "Vorher und Nachher vergleichen", + "Compare needs at least two photos": "Zum Vergleichen sind mindestens zwei Fotos erforderlich", + "Component options": "Komponentenoptionen", + "Compressed Nikon NEFs (lossless and lossy compressed, 12- and 14-bit — e.g. D3200, D5100, D7000, D750, D850, Z 50)": "Komprimierte Nikon-NEFs, verlustfrei oder verlustbehaftet mit 12 oder 14 Bit, etwa D3200, D5100, D7000, D750, D850 und Z 50,", + "Compression": "Komprimierung", + "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", + "Constrain Crop": "Zuschnitt beschränken", + "Continue": "Fortfahren", + "Continue Without Saving": "Ohne Speichern fortfahren", + "Contrast": "Kontrast", + "Contributors": "Mitwirkende", + "Convert to DNG": "In DNG konvertieren", + "Convert to DNG, Copy as DNG, Photo Merge and smart previews no longer write straight to the final file (issue": "DNG-Konvertierung, Kopieren als DNG, Fotozusammenfügung und Smart-Vorschauen schreiben nicht mehr direkt in die endgültige Datei", + "Cool Fade": "Kühle Verblassung", + "Cool Morning": "Kühler Morgen", + "Copy": "Kopieren", + "Copy After's Settings to Before": "Nachher-Einstellungen auf Vorher übertragen", + "Copy Before to After": "Vorher auf Nachher übertragen", + "Copy Before's Settings to After": "Vorher-Einstellungen auf Nachher übertragen", + "Copy Edit Settings": "Bearbeitungseinstellungen kopieren", + "Copy History Step Settings to Before": "Einstellungen des Verlaufsschritts auf Vorher übertragen", + "Copy Metadata": "Metadaten kopieren", + "Copy as DNG": "Als DNG kopieren", + "Copy into the library (else use the files where they are)": "In die Bibliothek kopieren (sonst Dateien am Speicherort verwenden)", + "Copy pages": "Seiten kopieren", + "Copy the files (into the library's Originals/, or a folder you choose)": "Dateien kopieren (in Originals/ der Bibliothek oder einen gewählten Ordner)", + "Copy to": "Kopieren nach", + "Copy to Clipboard": "In Zwischenablage kopieren", + "Copy: the files are copied to the folder below and the library uses the copies; the originals are left as they are.": "Kopieren: Die Dateien werden in den folgenden Ordner kopiert und die Bibliothek verwendet die Kopien; die Originale bleiben unverändert.", + "Copyright": "Urheberrecht", + "Copyright Info URL": "URL mit Urheberrechtsinformationen", + "Copyright Status": "Urheberrechtsstatus", + "Copyright status, rights usage terms and copyright info URL in Info, metadata presets and exports.": "Urheberrechtsstatus, Nutzungsbedingungen und URL mit Urheberrechtsinformationen in Infobereich, Metadatenvorgaben und Exporten.", + "Country": "Land", + "Create Album": "Album erstellen", + "Create Album…": "Album erstellen…", + "Create Folder": "Ordner erstellen", + "Create Folder…": "Ordner erstellen…", + "Create New Mask": "Neue Maske erstellen", + "Create PDF from clipboard": "PDF aus Zwischenablage erstellen", + "Create PDF from file…": "PDF aus Datei erstellen…", + "Create PDF from images…": "PDF aus Bildern erstellen…", + "Create Preset": "Vorgabe erstellen", + "Create Preset…": "Vorgabe erstellen…", + "Create Proof Copy": "Softproof-Kopie erstellen", + "Create Smart Album": "Smart-Album erstellen", + "Create Smart Album from Filter…": "Smart-Album aus Filter erstellen…", + "Create Smart Album…": "Smart-Album erstellen…", + "Create Stack": "Stapel erstellen", + "Create Version": "Version erstellen", + "Create Version from Step": "Version aus Schritt erstellen", + "Create Virtual Copy": "Virtuelle Kopie erstellen", + "Creator": "Urheber", + "Creator metadata": "Urheber aus Metadaten", + "Crisp Landscape": "Knackige Landschaft", + "Crisp Vista": "Klare Aussicht", + "Crop": "Zuschneiden", + "Crop & Rotate": "Zuschneiden und drehen", + "Crop & Rotate (C)": "Zuschneiden und drehen (C)", + "Crop Aspect": "Zuschnitt-Seitenverhältnis", + "Crop, Rotate, Geometry": "Zuschnitt, Drehung, Geometrie", + "Cross Process": "Crossentwicklung", + "Culling": "Fotoauswahl", + "Curve": "Kurve", + "Custom": "Benutzerdefiniert", + "Custom template…": "Benutzerdefinierte Vorlage…", + "Custom…": "Benutzerdefiniert…", + "Cut": "Ausschneiden", + "Cut pages": "Seiten ausschneiden", + "Cycle Crop Overlay": "Zuschnittüberlagerung wechseln", + "Cycle Crop Overlay Orientation": "Ausrichtung der Zuschnittüberlagerung wechseln", + "Cycle Info Overlay": "Infoüberlagerung wechseln", + "Cycle Mask Overlay Color": "Farbe der Maskenüberlagerung wechseln", + "Cycle Mask Overlay Mode": "Modus der Maskenüberlagerung wechseln", + "Cylindrical": "Zylindrisch", + "Dark & Moody": "Dunkel und stimmungsvoll", + "Darken": "Abdunkeln", + "Darks": "Dunkle Mitteltöne", + "Date": "Datum", + "Date Groups": "Datumsgruppen", + "Day": "Tag", + "Daylight": "Tageslicht", + "Decrease Brush Feather": "Weiche Pinselkante verringern", + "Decrease Brush Size": "Pinselgröße verringern", + "Deep Sky": "Tiefer Himmel", + "Defringe": "Farbsäume entfernen", + "Deghost Amount": "Geisterbildreduzierung", + "Dehaze": "Dunst entfernen", + "Delete": "Löschen", + "Delete (⌫)": "Löschen (⌫)", + "Delete Album": "Album löschen", + "Delete All Masks": "Alle Masken löschen", + "Delete Color Label Set": "Farbmarkierungssatz löschen", + "Delete Component": "Komponente löschen", + "Delete Export Preset": "Exportvorgabe löschen", + "Delete Filter Preset": "Filtervorgabe löschen", + "Delete Imported Profile": "Importiertes Profil löschen", + "Delete Keyword": "Stichwort löschen", + "Delete Keyword Set": "Stichwortsatz löschen", + "Delete Mask": "Maske löschen", + "Delete Metadata Preset": "Metadatenvorgabe löschen", + "Delete Permanently": "Endgültig löschen", + "Delete Photo": "Foto löschen", + "Delete Photos": "Fotos löschen", + "Delete Point Color": "Punktfarbe löschen", + "Delete Point Color Sample": "Punktfarbenprobe löschen", + "Delete Point Curve Preset": "Punktkurvenvorgabe löschen", + "Delete Preset": "Vorgabe löschen", + "Delete Red Eye": "Rote-Augen-Korrektur löschen", + "Delete Red Eye Correction": "Rote-Augen-Korrektur löschen", + "Delete Spot": "Retuschierstelle löschen", + "Delete Version": "Version löschen", + "Delete all": "Alle löschen", + "Delete all spots": "Alle Retuschierstellen löschen", + "Delete mask": "Maske löschen", + "Delete them": "Löschen", + "Density": "Dichte", + "Depth Range": "Tiefenbereich", + "Descending": "Absteigend", + "Describe": "Beschreiben", + "Describe what to keep": "Beschreibe, was bleiben soll", + "Describe what to leave out": "Beschreibe, was ausgelassen werden soll", + "Describe what to select": "Beschreibe, was ausgewählt werden soll", + "Describe…": "Beschreiben…", + "Deselect": "Auswahl aufheben", + "Deselect All": "Gesamte Auswahl aufheben", + "Desert Heat": "Wüstenhitze", + "Desert Warmth": "Wüstenwärme", + "Desktop": "Schreibtisch", + "Detail": "Detail", + "Detail (D)": "Detail (D)", + "Details": "Details", + "Diagonal": "Diagonal", + "Discard Preview Cache": "Vorschau-Cache verwerfen", + "Display P3": "Display P3", + "Display name": "Anzeigename", + "Display theme": "Oberflächendesign", + "Distortion": "Verzeichnung", + "Document properties…": "Dokumenteigenschaften…", + "Don't enlarge": "Nicht vergrößern", + "Done": "Fertig", + "Download": "Herunterladen", + "Download AI Mask Model": "KI-Maskenmodell herunterladen", + "Download the SAM 3 Model?": "SAM-3-Modell herunterladen?", + "Downloads": "Downloads", + "Drag along the horizon; double-click for Auto": "Entlang des Horizonts ziehen; Doppelklick für Automatik", + "Drag over an eye on the photo; the pupil inside is found automatically.": "Ziehe über ein Auge im Foto; die Pupille darin wird automatisch gefunden.", + "Draw Guides": "Hilfslinien zeichnen", + "Duplicate": "Duplizieren", + "Duplicate Component": "Komponente duplizieren", + "Duplicate Mask": "Maske duplizieren", + "Duplicate and Invert Mask": "Maske duplizieren und umkehren", + "Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").": "Jeder / beginnt eine Ordnerebene; Platzhalter werden pro Foto ausgefüllt (eine Ebene mit fehlenden Metadaten heißt „unknown“).", + "Edge": "Rand", + "Edge setting, and get a sharper zoomed-in pass in the background. The model runs inside LightCraft in pure Rust": "besitzen eine Randeinstellung und werden beim Hineinzoomen im Hintergrund genauer berechnet. Das Modell läuft in LightCraft in reinem Rust", + "Edit": "Bearbeiten", + "Edit (E)": "Bearbeiten (E)", + "Edit Capture Time": "Aufnahmezeit bearbeiten", + "Edit Capture Time…": "Aufnahmezeit bearbeiten…", + "Edit Color Label Names": "Namen der Farbmarkierungen bearbeiten", + "Edit Color Label Names…": "Namen der Farbmarkierungen bearbeiten…", + "Edit Copy for External Editor": "Kopie für externen Editor bearbeiten", + "Edit Date": "Bearbeitungsdatum", + "Edit Info": "Informationen bearbeiten", + "Edit Label Names": "Namen der Farbmarkierungen bearbeiten", + "Edit Label Names…": "Markierungsnamen bearbeiten…", + "Edit Mask": "Maske bearbeiten", + "Edit Mask Component": "Maskenkomponente bearbeiten", + "Edit Sections": "Bearbeitungsbereiche", + "Edit Smart Album": "Smart-Album bearbeiten", + "Edit Smart Album…": "Smart-Album bearbeiten…", + "Edit Spot": "Retuschierstelle bearbeiten", + "Edit in External Editor": "In externem Editor bearbeiten", + "Edit in External Editor (⇧⌘E): a 16-bit TIFF copy, stacked, refreshed when you come back.": "In externem Editor bearbeiten mit ⇧⌘E: Eine 16-Bit-TIFF-Kopie wird gestapelt und bei der Rückkehr aktualisiert.", + "Edit settings copied": "Bearbeitungseinstellungen kopiert", + "Edited": "Bearbeitet", + "Edited or not": "Bearbeitet oder nicht", + "Editing": "Bearbeitung", + "Effect": "Effekt", + "Effects": "Effekte", + "Empty Recently Deleted": "„Zuletzt gelöscht“ leeren", + "Enable Lens Corrections": "Objektivkorrekturen aktivieren", + "Enable Profile Corrections": "Profilkorrekturen aktivieren", + "End Interaction": "Interaktion beenden", + "English": "English", + "Enter Full Screen": "Vollbildmodus starten", + "Enter a date as YYYY-MM-DD HH:MM:SS": "Datum als JJJJ-MM-TT HH:MM:SS eingeben", + "Erase": "Radieren", + "Every EXIF, GPS and XMP field in the file": "Alle EXIF-, GPS- und XMP-Felder in der Datei", + "Existing previews": "Vorhandene Vorschauen", + "Exit": "Beenden", + "Expand": "Ausklappen", + "Expand All Stacks": "Alle Stapel ausklappen", + "Expand Stack": "Stapel ausklappen", + "Expand/Collapse Stack": "Stapel aus-/einklappen", + "Export": "Exportieren", + "Export Album with Preset": "Album mit Vorgabe exportieren", + "Export Album…": "Album exportieren…", + "Export As…": "Exportieren als…", + "Export Group…": "Gruppe exportieren…", + "Export Now": "Jetzt exportieren", + "Export Point Curve Presets": "Punktkurvenvorgaben exportieren", + "Export Point Curve Presets…": "Punktkurvenvorgaben exportieren…", + "Export Presets": "Vorgaben exportieren", + "Export Presets…": "Vorgaben exportieren…", + "Export User Presets…": "Benutzervorgaben exportieren…", + "Export file names use the Rename Photos tokens ({title}, {seq:2}, {date:%Y-%m-%d}…), plus new {num}, {folder}, {lens}, {iso}, {rating}, {creator}.": "Exportdateinamen verwenden die Platzhalter der Fotoumbenennung ({title}, {seq:2}, {date:%Y-%m-%d}…) sowie {num}, {folder}, {lens}, {iso}, {rating}, {creator}.", + "Export stopped unexpectedly": "Export unerwartet angehalten", + "Export with Preset": "Mit Vorgabe exportieren", + "Export with Previous": "Mit vorherigen Einstellungen exportieren", + "Exported files are written to a temp file and then renamed into place, so a full disk or an unplugged drive": "Exportdateien werden zunächst temporär geschrieben und anschließend umbenannt. Ein voller Datenträger oder getrenntes Laufwerk", + "Exporting": "Wird exportiert", + "Exports and renders never write over a photo's original (issue #93): exporting into the photo's own folder with": "Export und Rendering überschreiben niemals das Originalfoto (Issue #93). Ein Export in dessen Ordner mit gleichem Namen", + "Exports are never black because of the GPU (issue #78): a GPU render that runs out of device": "Exporte werden wegen GPU-Problemen nicht mehr schwarz (Issue #78): Ein GPU-Rendering mit erschöpftem Gerätespeicher,", + "Export…": "Exportieren…", + "Exposure": "Belichtung", + "Exposure (shutter · aperture · ISO)": "Belichtung (Verschlusszeit · Blende · ISO)", + "Extended Description": "Ausführliche Beschreibung", + "External Editor": "Externer Editor", + "Face Boxes": "Gesichtsrahmen", + "Face boxes": "Gesichtsrahmen", + "Face names written to XMP by Lightroom and other apps show up here.": "Gesichtsnamen, die Lightroom und andere Apps in XMP schreiben, erscheinen hier.", + "Faded Matte": "Verblasst und matt", + "Faded Slide": "Verblasstes Dia", + "Faded Urban": "Verblasste Stadt", + "Faster exports and card imports, with the same protection for what can't be recreated (issue #134): exports,": "Schnellere Exporte und Kartenimporte mit unverändertem Schutz für nicht wiederherstellbare Daten (Issue #134): Exporte,", + "Favorite Preset": "Vorgabe als Favorit markieren", + "Favorite Profile": "Profil als Favorit markieren", + "Favorites": "Favoriten", + "Feather": "Weiche Kante", + "File": "Datei", + "File & date": "Datei und Datum", + "File Format": "Dateiformat", + "File Name": "Dateiname", + "File Path": "Dateipfad", + "File Size": "Dateigröße", + "File Type": "Dateityp", + "File name": "Dateiname", + "File types:": "Dateitypen:", + "Filename": "Dateiname", + "Fill": "Ausfüllen", + "Fill Edges": "Ränder füllen", + "Film": "Film", + "Filmstrip": "Filmstreifen", + "Filmstrip (/)": "Filmstreifen (/)", + "Filter": "Filter", + "Filter Bar": "Filterleiste", + "Filter Presets": "Filtervorgaben", + "Filter bar": "Filterleiste", + "Filter fields": "Filterfelder", + "Find Dust Spots": "Staubflecken finden", + "Find Missing Photos": "Fehlende Fotos suchen", + "Find Missing Photos…": "Fehlende Fotos suchen…", + "Find Similar Photos": "Ähnliche Fotos suchen", + "Find tools and commands…": "Werkzeuge und Befehle suchen…", + "Find…": "Suchen…", + "First": "Erster", + "First commit": "Erster Commit", + "Fit": "Einpassen", + "Fit (F)": "Einpassen (F)", + "Fit on Screen": "An Bildschirm anpassen", + "Fit to width": "An Breite anpassen", + "Fit visible": "Sichtbaren Bereich einpassen", + "Five stars": "Fünf Sterne", + "Fixed keys": "Feste Tasten", + "Flag": "Markierung", + "Flag as Pick": "Als ausgewählt markieren", + "Flag as Reject": "Als abgelehnt markieren", + "Flagged as Pick": "Als ausgewählt markiert", + "Flagged as Reject": "Als abgelehnt markiert", + "Flash": "Blitz", + "Flip H": "H spiegeln", + "Flip Horizontal": "Horizontal spiegeln", + "Flip Overlay (⇧O)": "Überlagerung spiegeln (⇧O)", + "Flip V": "V spiegeln", + "Flip Vertical": "Vertikal spiegeln", + "Flow": "Fluss", + "Fluorescent": "Leuchtstofflicht", + "Focal Length": "Brennweite", + "Focal length": "Brennweite", + "Focus (assisted culling)": "Schärfe (unterstützte Fotoauswahl)", + "Folder": "Ordner", + "Folder name (renamed on disk; its photos follow)": "Ordnername (wird auf dem Datenträger umbenannt; die Fotos folgen)", + "Folders": "Ordner", + "Folders and {date} use the capture time; a photo without one uses today's date.": "Ordner und {date} verwenden die Aufnahmezeit; bei Fotos ohne Aufnahmezeit wird das heutige Datum verwendet.", + "Food": "Essen", + "Forget Unchanged Local Photos": "Unveränderte lokale Fotos vergessen", + "Forget unchanged photos": "Unveränderte Fotos vergessen", + "Form fields panel": "Formularfeldbereich", + "Format": "Format", + "Forward": "Vorwärts", + "Frame time": "Bilddauer", + "Frame update": "Bildaktualisierung", + "Free": "Frei", + "Fresh & Bright": "Frisch und hell", + "Friday": "Freitag", + "Full": "Vollständig", + "Full Screen Preview": "Vollbildvorschau", + "Full screen mode": "Vollbildmodus", + "Full size": "Volle Größe", + "GPS Track Log": "GPS-Track", + "GPU Rendering": "GPU-Rendering", + "GPU rendering": "GPU-Rendering", + "General": "Allgemein", + "Generated demo photo": "Generiertes Demofoto", + "Geometry": "Geometrie", + "Get Develop Settings": "Entwicklungseinstellungen abrufen", + "Global Hue": "Globaler Farbton", + "Global Luminance": "Globale Luminanz", + "Global Saturation": "Globale Sättigung", + "Glossy": "Glänzend", + "Go to History Step": "Zu Verlaufsschritt wechseln", + "Golden": "Goldfarben", + "Golden Glow": "Goldener Glanz", + "Golden Hour": "Goldene Stunde", + "Grab bag": "Namensliste", + "Grain": "Körnung", + "Graphic": "Grafisch", + "Green": "Grün", + "Green Amount": "Grün – Stärke", + "Green Hue": "Grün – Farbton", + "Green Hue High": "Grün – oberer Farbton", + "Green Hue Low": "Grün – unterer Farbton", + "Green Luminance": "Grün – Luminanz", + "Green Primary": "Primärfarbe Grün", + "Green Saturation": "Grün – Sättigung", + "Green channel": "Grünkanal", + "Green label": "Grüne Markierung", + "Grid": "Raster", + "Grid Info": "Rasterinformationen", + "Gritty Street": "Raue Straße", + "Group": "Gruppe", + "Group by Date: Automatic": "Nach Datum gruppieren: automatisch", + "Group by Day": "Nach Tag gruppieren", + "Group by Month": "Nach Monat gruppieren", + "Group by Year": "Nach Jahr gruppieren", + "Group by date": "Nach Datum gruppieren", + "Group into Stack": "Zu Stapel gruppieren", + "Group name": "Gruppenname", + "Guided": "Mit Hilfslinien", + "Guided Upright": "Perspektivkorrektur mit Hilfslinien", + "Guided Upright Guides": "Hilfslinien der Perspektivkorrektur", + "HDR Merge": "Zu HDR zusammenfügen", + "HDR Merge Preview": "Vorschau der HDR-Zusammenfügung", + "HDR Panorama Merge": "Zu HDR-Panorama zusammenfügen", + "HDR Panorama Merge Preview": "Vorschau des HDR-Panoramas", + "HDR Panorama with Last Settings": "HDR-Panorama mit letzten Einstellungen", + "HDR Panoramas": "HDR-Panoramen", + "HDR Panorama…": "HDR-Panorama…", + "HDR panorama": "HDR-Panorama", + "HDR panoramas": "HDR-Panoramen", + "HDR with Last Settings": "HDR mit letzten Einstellungen", + "HDR…": "HDR…", + "Has Edits": "Enthält Bearbeitungen", + "Has GPS": "Enthält GPS", + "Heal": "Reparieren", + "Heal the small, soft dark spots sensor dust leaves on smooth areas": "Kleine, weiche dunkle Flecken reparieren, die Sensorstaub auf gleichmäßigen Flächen hinterlässt", + "Height": "Höhe", + "Height (px)": "Höhe (px)", + "Help": "Hilfe", + "Hide LightCraft": "LightCraft ausblenden", + "Hide Mask": "Maske ausblenden", + "Hide Others": "Andere ausblenden", + "Hides this shortcut only; the folder and its photos stay as they are": "Blendet nur diese Verknüpfung aus; der Ordner und seine Fotos bleiben unverändert", + "High": "Hoch", + "High Contrast B&W": "Kontrastreiches Schwarzweiß", + "Highlight": "Hervorheben", + "Highlights": "Lichter", + "Highlights Hue": "Lichter – Farbton", + "Highlights Luminance": "Lichter – Luminanz", + "Highlights Saturation": "Lichter – Sättigung", + "Highlights split": "Lichtergrenze", + "Histogram": "Histogramm", + "History": "Verlauf", + "History & Activity (Y)": "Verlauf und Aktivität (Y)", + "Home": "Persönlicher Ordner", + "Horizontal": "Horizontal", + "Horizontal text": "Horizontaler Text", + "Hours": "Stunden", + "Hue": "Farbton", + "Hue Range": "Farbtonbereich", + "ISO Speed": "ISO-Empfindlichkeit", + "ISO speed": "ISO-Empfindlichkeit", + "If file exists": "Bei vorhandener Datei", + "If your library can't be opened at launch (open in another program, unreadable, on a drive that isn't connected,": "Kann deine Bibliothek beim Start nicht geöffnet werden, etwa weil sie anderweitig geöffnet, unlesbar, auf einem getrennten Laufwerk oder von einer", + "Image": "Bild", + "Import": "Importieren", + "Import Date": "Importdatum", + "Import Lightroom Catalog": "Lightroom-Katalog importieren", + "Import Lightroom Catalog…": "Lightroom-Katalog importieren …", + "Import Photos": "Fotos importieren", + "Import Photos…": "Fotos importieren…", + "Import Point Curve Presets": "Punktkurvenvorgaben importieren", + "Import Point Curve Presets…": "Punktkurvenvorgaben importieren…", + "Import Presets": "Vorgaben importieren", + "Import Presets…": "Vorgaben importieren…", + "Import Profiles": "Profile importieren", + "Import Profiles & Presets…": "Profile und Vorgaben importieren…", + "Import as new": "Neu importieren", + "Import from Device": "Von Gerät importieren", + "Import from Folder…": "Aus Ordner importieren…", + "Import photos with File → Import Photos… (Cmd+Shift+I), or drop them here": "Importiere Fotos über Datei → Fotos importieren… (⌘⇧I) oder lege sie hier ab", + "Import presets from other editors: XMP presets, classic `.lrtemplate` files, \"DNG presets\" from mobile apps and `.zip`": "Vorgaben aus anderen Editoren importieren: XMP-Vorgaben, klassische `.lrtemplate`-Dateien, „DNG-Vorgaben“ aus mobilen Apps und `.zip`-Pakete.", + "Import ▸ Copy verifies every copy, like Move (issue #96): each file is written as a new file, synced to disk and": "Import → Kopieren prüft jede Kopie wie beim Verschieben (Issue #96): Jede Datei wird neu geschrieben, auf den Datenträger synchronisiert", + "Import ▸ Move: photos go into the destination (with the same folders and renaming as Copy, e.g.": "Import → Verschieben: Fotos gehen mit derselben Ordnerstruktur und Umbenennung wie beim Kopieren in den Zielordner, etwa", + "Import: copy to any folder, by day / by month / one folder, rename on import, metadata preset, Copy as DNG.": "Import: In beliebige Ordner kopieren, nach Tag oder Monat oder in einen Ordner, beim Import umbenennen, Metadatenvorgabe und Kopieren als DNG.", + "Imported": "Importiert", + "Imported Photos": "Importierte Fotos", + "Importing": "Wird importiert", + "Importing Lightroom Catalog": "Lightroom-Katalog wird importiert", + "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).": "Beim Import wird die Quelle nach Fotos durchsucht; der Ordner wird nicht zu „Lokal“ hinzugefügt (verwende Lokal → Ordner durchsuchen…, um ohne Import in einem Ordner zu arbeiten).", + "In Photo Grid and Square Grid: Flag as Pick and advance": "In Fotoraster und quadratischem Raster: als ausgewählt markieren und weiter", + "In Photo Grid and Square Grid: expand or collapse the stack": "In Fotoraster und quadratischem Raster: Stapel auf- oder zuklappen", + "In Recently Deleted": "In „Zuletzt gelöscht“", + "In library": "In Bibliothek", + "In memory — nothing is saved": "Im Arbeitsspeicher — nichts wird gespeichert", + "In use": "In Verwendung", + "In-memory session: these settings last until LightCraft quits.": "Sitzung im Arbeitsspeicher: Diese Einstellungen gelten bis zum Beenden von LightCraft.", + "Include subfolders": "Unterordner einbeziehen", + "Increase Brush Feather": "Weiche Pinselkante vergrößern", + "Increase Brush Size": "Pinselgröße vergrößern", + "Info": "Informationen", + "Info (I)": "Informationen (I)", + "Info Overlay Off": "Infoüberlagerung aus", + "Info Overlay: Exposure": "Infoüberlagerung: Belichtung", + "Info Overlay: File": "Infoüberlagerung: Datei", + "Info overlay": "Infoüberlagerung", + "Insert at the cursor": "An Cursorposition einfügen", + "Inspect Lightroom Catalog": "Lightroom-Katalog prüfen", + "Inspect Photo": "Foto untersuchen", + "Inspecting Lightroom Catalog": "Lightroom-Katalog wird geprüft", + "Instant ’70s": "Sofortbild der 70er", + "Interface": "Oberfläche", + "Interface language": "Sprache der Oberfläche", + "Intersect": "Schnittmenge", + "Intersect with": "Schnittmenge mit", + "Into one folder": "In einen Ordner", + "Invert": "Umkehren", + "Invert Component": "Komponente umkehren", + "Invert Mask": "Maske umkehren", + "JPEG (Large)": "JPEG (groß)", + "JPEG (Small)": "JPEG (klein)", + "JPEG with the same name (`IMG_0001.CR3` + `IMG_0001.JPG`) no longer share one sidecar: the raw keeps `IMG_0001.xmp`,": "JPEG mit gleichem Namen (`IMG_0001.CR3` + `IMG_0001.JPG`) keine Begleitdatei mehr: RAW verwendet `IMG_0001.xmp`,", + "Join the ArtCraft Discord": "ArtCraft auf Discord beitreten", + "Join the ArtCraft Discord…": "ArtCraft auf Discord beitreten…", + "Join the ArtCraft community on Discord": "Der ArtCraft-Community auf Discord beitreten", + "Keep in Local": "In „Lokal“ behalten", + "Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.": "Wähle ein Laufwerk mit genügend Platz: Smart-Vorschauen benötigen etwa 1 MB pro Foto. Der Miniaturbild-Cache bleibt in der Bibliothek.", + "Keyboard Shortcuts": "Tastenkürzel", + "Keyboard shortcuts": "Tastenkürzel", + "Keyword": "Stichwort", + "Keyword Painter": "Stichwortpinsel", + "Keyword Set": "Stichwortsatz", + "Keyword Sets": "Stichwortsätze", + "Keyword Suggestions": "Stichwortvorschläge", + "Keyword to paint": "Stichwort zum Auftragen", + "Keywords": "Stichwörter", + "Keywords (K)": "Stichwörter (K)", + "Keywords you add appear here; ⌥1–⌥9 apply them.": "Hinzugefügte Stichwörter erscheinen hier; ⌥1–⌥9 wendet sie an.", + "Label": "Farbmarkierung", + "Landscape": "Landschaft", + "Language": "Sprache", + "Last": "Letzter", + "Last GPU fallback": "Letzter GPU-Fallback", + "Last commit": "Letzter Commit", + "Last loupe render": "Letztes Rendering der Lupenansicht", + "Last view": "Letzte Ansicht", + "Lateral Chromatic Aberration": "Laterale chromatische Aberration", + "Layer": "Ebene", + "Layers": "Ebenen", + "Leave them": "Beibehalten", + "Lens": "Objektiv", + "Lens Blur": "Objektivunschärfe", + "Lens Corrections": "Objektivkorrekturen", + "Level": "Waagerecht", + "Library": "Bibliothek", + "Library Info": "Bibliotheksinformationen", + "Library Originals": "Originale der Bibliothek", + "Library Preferences": "Bibliothekseinstellungen", + "Library State": "Bibliotheksstatus", + "Library saved": "Bibliothek gespeichert", + "Licence:": "Lizenz:", + "Light": "Licht", + "LightCraft 0.2.0 and older refuse it (\"unsupported format … v2\").": "lehnen LightCraft 0.2.0 und ältere Versionen sie ab („unsupported format … v2“).", + "LightCraft Default": "LightCraft-Standard", + "LightCraft Help": "LightCraft-Hilfe", + "LightCraft Preset": "LightCraft-Vorgabe", + "LightCraft Presets": "LightCraft-Vorgaben", + "LightCraft Website": "LightCraft-Website", + "LightCraft could not start": "LightCraft konnte nicht starten", + "LightCraft couldn't find where to keep your library.": "LightCraft konnte keinen Speicherort für deine Bibliothek finden.", + "LightCraft couldn't open the library at": "LightCraft konnte die Bibliothek nicht öffnen unter", + "LightCraft no longer crashes at launch on Windows PCs whose Vulkan driver is broken (issue #136, e.g. some Intel UHD": "LightCraft stürzt beim Start auf Windows-PCs mit defektem Vulkan-Treiber nicht mehr ab (Issue #136, etwa bestimmte Intel-UHD-630-Treiber).", + "LightCraft on GitHub": "LightCraft auf GitHub", + "LightCraft website": "LightCraft-Website", + "LightCraft — Second Window": "LightCraft — zweites Fenster", + "Lightroom Classic Catalog": "Lightroom Classic-Katalog", + "Lightroom import cancelled": "Lightroom-Import abgebrochen", + "Lightroom inspection cancelled": "Lightroom-Prüfung abgebrochen", + "Lights": "Helle Mitteltöne", + "Limit file size (KB, 0 = off)": "Dateigröße begrenzen (KB, 0 = aus)", + "Line delta": "Zeilendifferenz", + "Linear": "Linear", + "Linear Gradient": "Linearer Verlauf", + "Lines +/−": "Zeilen +/−", + "Lines added": "Hinzugefügte Zeilen", + "Lines deleted": "Gelöschte Zeilen", + "List Albums": "Alben auflisten", + "List Develop Controls": "Entwicklungsregler auflisten", + "List History": "Verlauf auflisten", + "List Presets": "Vorgaben auflisten", + "List Profiles": "Profile auflisten", + "List this folder in Local from now on": "Diesen Ordner ab jetzt unter „Lokal“ auflisten", + "Local": "Lokal", + "Local folders": "Lokale Ordner", + "Local library — no cloud account needed": "Lokale Bibliothek — kein Cloud-Konto erforderlich", + "Locate Missing File…": "Fehlende Datei suchen…", + "Locate Photo": "Foto suchen", + "Location": "Ort", + "Lock": "Sperren", + "Lock Crop Aspect": "Zuschnitt-Seitenverhältnis sperren", + "Locked": "Gesperrt", + "Long Edge": "Lange Kante", + "Looking for photos…": "Fotos werden gesucht…", + "Lossless": "Verlustfrei", + "Lossy DNG files and Smart Previews open as raw photos.": "Verlustbehaftete DNG-Dateien und Smart-Vorschauen werden als RAW-Fotos geöffnet.", + "Low": "Niedrig", + "Luminance": "Luminanz", + "Luminance Range": "Luminanzbereich", + "Luminar looks: `.lmp` files and `.mplumpack` collections import as presets (grouped by collection); the sliders": "Luminar-Looks: `.lmp`-Dateien und `.mplumpack`-Sammlungen werden als Vorgaben importiert und nach Sammlung gruppiert.", + "Lush Greens": "Sattes Grün", + "Magenta": "Magenta", + "Magenta Hue": "Magenta – Farbton", + "Magenta Luminance": "Magenta – Luminanz", + "Magenta Saturation": "Magenta – Sättigung", + "Make Candidate the Select": "Kandidat als Auswahl verwenden", + "Manual": "Manuell", + "Marquee zoom": "Zoom per Auswahlrahmen", + "Mask Adjustment": "Maskenkorrektur", + "Masking": "Maskieren", + "Masking (M)": "Maskieren (M)", + "Masks": "Masken", + "Match": "Erfülle", + "Match Total Exposures": "Gesamtbelichtungen abgleichen", + "Matte": "Matt", + "Matte Soft": "Sanft matt", + "Med": "Mittel", + "Medium Contrast": "Mittlerer Kontrast", + "Megapixels": "Megapixel", + "Memory Budget": "Speicherbudget", + "Memory Usage": "Speichernutzung", + "Memory budget": "Speicherbudget", + "Memory for caches": "Speicher für Caches", + "Menu": "Menü", + "Merge": "Zusammenfügen", + "Merge Keywords": "Stichwörter zusammenführen", + "Merge into…": "Zusammenführen mit…", + "Merged": "Zusammengefügt", + "Merged PRs": "Zusammengeführte PRs", + "Merging…": "Wird zusammengefügt…", + "Meta's terms, not LightCraft's. Downloading it means accepting them.": "Es gelten die Bedingungen von Meta, nicht von LightCraft. Mit dem Download akzeptierst du sie.", + "Metadata": "Metadaten", + "Metadata Preset": "Metadatenvorgabe", + "Metadata Presets": "Metadatenvorgaben", + "Metadata preset": "Metadatenvorgabe", + "Midpoint": "Mittelpunkt", + "Midtones": "Mitteltöne", + "Midtones Hue": "Mitteltöne – Farbton", + "Midtones Luminance": "Mitteltöne – Luminanz", + "Midtones Saturation": "Mitteltöne – Sättigung", + "Midtones split": "Mitteltontrennung", + "Minimize": "Minimieren", + "Missing Photos": "Fehlende Fotos", + "Missing metadata ({camera}, {title}…) leaves an empty gap; a name that comes out empty keeps the original name.": "Fehlende Metadaten ({camera}, {title}…) hinterlassen eine Lücke; bei einem leeren Ergebnis bleibt der Originalname erhalten.", + "Misty Morning": "Nebliger Morgen", + "Mode": "Modus", + "Model": "Modell", + "Models": "Modelle", + "Modified Date": "Änderungsdatum", + "Monday": "Montag", + "Mono Red Filter": "Monochrom mit Rotfilter", + "Mono Rich": "Kräftig monochrom", + "Mono Soft": "Sanft monochrom", + "Monochrome": "Monochrom", + "Month": "Monat", + "Moody": "Stimmungsvoll", + "Moody Portrait": "Stimmungsvolles Porträt", + "More": "Mehr", + "More preset options": "Weitere Vorgabenoptionen", + "Move": "Verschieben", + "Move Album": "Album verschieben", + "Move Down": "Nach unten verschieben", + "Move Down in Stack": "Im Stapel nach unten verschieben", + "Move Folder": "Ordner verschieben", + "Move Folder To…": "Ordner verschieben nach…", + "Move Mask": "Maske verschieben", + "Move Preset to Group": "Vorgabe in Gruppe verschieben", + "Move Preset to New Group": "Vorgabe in neue Gruppe verschieben", + "Move Up": "Nach oben verschieben", + "Move Up in Stack": "Im Stapel nach oben verschieben", + "Move in Stack": "Im Stapel verschieben", + "Move the files (into the library's Originals/, or a folder you choose), removing them from the source": "Dateien verschieben (in Originals/ der Bibliothek oder einen gewählten Ordner) und aus der Quelle entfernen", + "Move them": "Verschieben", + "Move to": "Verschieben nach", + "Move to Group": "In Gruppe verschieben", + "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.": "Verschieben: Die Dateien werden in den folgenden Ordner verschoben; jedes Original (und seine XMP-Begleitdatei) wird erst nach Überprüfung der Kopie aus der Quelle entfernt. Duplikate und fehlgeschlagene Dateien bleiben an ihrem Speicherort.", + "Moves every photo imported from this disk to Recently Deleted; no file is touched": "Verschiebt alle von diesem Datenträger importierten Fotos nach „Zuletzt gelöscht“; keine Datei wird verändert", + "Moves the photos imported from this folder to Recently Deleted; no file is touched": "Verschiebt die aus diesem Ordner importierten Fotos nach „Zuletzt gelöscht“; keine Datei wird verändert", + "Muted": "Gedämpft", + "Muted Film": "Gedämpfter Film", + "My Photos": "Meine Fotos", + "My Photos Panel": "Bereich „Meine Fotos“", + "Name": "Name", + "Name (A–Z)": "Name (A–Z)", + "Name of the folder the original is in": "Name des Ordners mit dem Original", + "Named": "Benannt", + "Named People": "Benannte Personen", + "Names appear in the label menu, the filter bar and the Info panel, and are written to XMP. Empty = the colour's name.": "Die Namen erscheinen im Farbmarkierungsmenü, in der Filterleiste und im Infobereich und werden in XMP geschrieben. Leer = Name der Farbe.", + "Navigator": "Navigator", + "Needs Work": "Überarbeitung nötig", + "Neon Dusk": "Neondämmerung", + "Neon Night": "Neonnacht", + "Nested group": "Verschachtelte Gruppe", + "Neutral": "Neutral", + "Never": "Nie", + "New": "Neu", + "New Album": "Neues Album", + "New Album…": "Neues Album…", + "New Folder…": "Neuer Ordner…", + "New Group…": "Neue Gruppe…", + "New Smart Album": "Neues Smart-Album", + "New Smart Album from Filter…": "Neues Smart-Album aus Filter…", + "New Smart Album…": "Neues Smart-Album…", + "New album": "Neues Album", + "New album…": "Neues Album…", + "New blank PDF": "Neues leeres PDF", + "New name": "Neuer Name", + "New time": "Neue Zeit", + "New…": "Neu…", + "Next Photo": "Nächstes Foto", + "Night Blue": "Nachtblau", + "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-NEFs beginnen mit einem Farb- und Tonwertbild, das wie bei Sony-ARWs an das Kamera-JPEG angepasst ist, statt mit einem gedämpften,", + "Nikon splits into two differently compressed halves still use the preview for now.)": "die Nikon in zwei unterschiedlich komprimierte Hälften teilt, verwenden vorerst weiterhin die Vorschau.", + "No": "Nein", + "No Camera or Card Found": "Keine Kamera oder Karte gefunden", + "No Date Groups": "Keine Datumsgruppen", + "No Stars": "Keine Sterne", + "No Tool": "Kein Werkzeug", + "No automatic versions.": "Keine automatischen Versionen.", + "No camera": "Keine Kamera", + "No contributor data was built into this copy.": "Diese Version enthält keine Daten zu Mitwirkenden.", + "No date": "Kein Datum", + "No dust spots found": "Keine Staubflecken gefunden", + "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 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", + "No metadata found": "Keine Metadaten gefunden", + "No model credits were built into this copy.": "Diese Version enthält keine Modellangaben.", + "No named people in these photos": "Keine benannten Personen in diesen Fotos", + "No named people yet": "Noch keine benannten Personen", + "No new presets": "Keine neuen Vorgaben", + "No photo selected": "Kein Foto ausgewählt", + "No photos": "Keine Fotos", + "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", + "No photos match the filters (View → Clear Filters)": "Keine Fotos entsprechen den Filtern (Ansicht → Filter löschen)", + "No raw photos yet: cameras appear here once their photos are in the library.": "Noch keine RAW-Fotos: Kameras erscheinen hier, sobald ihre Fotos in der Bibliothek sind.", + "No reference photo": "Kein Referenzfoto", + "No usable GPU found: rendering on the CPU": "Keine geeignete GPU gefunden: Rendering auf der CPU", + "No versions yet. Create one to keep this look.": "Noch keine Versionen. Erstelle eine, um diesen Look zu bewahren.", + "Noise": "Rauschen", + "Noise Reduction": "Rauschreduzierung", + "Non-raw photos": "Andere Fotos als RAW", + "None": "Keine", + "Not Now": "Nicht jetzt", + "Nothing to compare": "Nichts zum Vergleichen", + "Nothing to survey": "Nichts für die Übersicht", + "Nudge Develop Value": "Entwicklungswert schrittweise ändern", + "OK": "OK", + "Object": "Objekt", + "Object Mask": "Objektmaske", + "Object and Describe masks use SAM 3, Meta's segmentation model. It isn't part of LightCraft, and everything else works without it.": "Objekt- und Beschreibungsmasken verwenden SAM 3, das Segmentierungsmodell von Meta. Es ist kein Bestandteil von LightCraft; alle anderen Funktionen arbeiten auch ohne dieses Modell.", + "October 2026": "Oktober 2026", + "Off": "Aus", + "Offset X": "Versatz X", + "Offset Y": "Versatz Y", + "Opacity": "Deckkraft", + "Opacity (%)": "Deckkraft (%)", + "Open Library": "Bibliothek öffnen", + "Open Library…": "Bibliothek öffnen…", + "Open in": "Öffnen in", + "Open in Detail": "In Detailansicht öffnen", + "Open the place in OpenStreetMap": "Ort in OpenStreetMap öffnen", + "Open…": "Öffnen…", + "Optics": "Optik", + "Optimize Library": "Bibliothek optimieren", + "Optional name": "Optionaler Name", + "Options": "Optionen", + "Orange": "Orange", + "Orange Hue": "Orange – Farbton", + "Orange Luminance": "Orange – Luminanz", + "Orange Saturation": "Orange – Sättigung", + "Orientation": "Ausrichtung", + "Original": "Original", + "Original + Settings": "Original und Einstellungen", + "Original extension without the dot": "Originalerweiterung ohne Punkt", + "Original file name without its extension": "Originaldateiname ohne Erweiterung", + "Original files": "Originaldateien", + "Original offline · editing the smart preview": "Original offline · Smart-Vorschau wird bearbeitet", + "Original offline · no smart preview": "Original offline · keine Smart-Vorschau", + "Originals": "Originale", + "Other images (JPEG, PNG, TIFF, HEIC…)": "Andere Bilder (JPEG, PNG, TIFF, HEIC…)", + "Overlay": "Überlagerung", + "Overwrite": "Überschreiben", + "Pages": "Seiten", + "Paint": "Auftragen", + "Paint only areas like the one under the brush": "Nur Bereiche wie den unter dem Pinsel bearbeiten", + "Paint over a distraction on the photo to remove it.": "Übermale ein störendes Element im Foto, um es zu entfernen.", + "Paint overlay": "Überlagerung auftragen", + "Panasonic and Leica raws (RW2, RWL and the older RAW files, DMC-LX1 to DC-S1R II) develop from the raw data in": "RAW-Dateien von Panasonic und Leica, RW2, RWL und ältere RAW-Dateien von DMC-LX1 bis DC-S1R II, werden in", + "Panorama": "Panorama", + "Panorama Merge": "Zu Panorama zusammenfügen", + "Panorama Merge Preview": "Panoramavorschau", + "Panorama with Last Settings": "Panorama mit letzten Einstellungen", + "Panoramas": "Panoramen", + "Panorama…": "Panorama…", + "Parametric curve": "Parametrische Kurve", + "Paste": "Einfügen", + "Paste Edit Settings": "Bearbeitungseinstellungen einfügen", + "Paste Metadata": "Metadaten einfügen", + "Paste Selected Settings": "Ausgewählte Einstellungen einfügen", + "Paste Selected Settings…": "Ausgewählte Einstellungen einfügen…", + "Paste Settings": "Einstellungen einfügen", + "Paste Settings from Previous": "Einstellungen vom vorherigen Foto einfügen", + "Paste from Previous": "Vom vorherigen Foto einfügen", + "Paste pages": "Seiten einfügen", + "Pastel Haze": "Pastelldunst", + "People": "Personen", + "Percent": "Prozent", + "Percentage": "Prozentsatz", + "Performance": "Leistung", + "Person": "Person", + "Perspective": "Perspektive", + "Pet Eye": "Tieraugen", + "Pet Eye Catchlight": "Lichtreflex für Tieraugen", + "Photo": "Foto", + "Photo Grid": "Fotoraster", + "Photo Grid (G)": "Fotoraster (G)", + "Photo Merge": "Fotos zusammenfügen", + "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 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.", + "Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.": "Fotos, die in diesem Ordner ankommen (Tethering, Scanner, Synchronisierungs-App), werden hinzugefügt, sobald sie vollständig sind.", + "Photos); importing (also by drag and drop), Find Missing Photos, Build / Discard Smart Previews, the Rename preview,": "Auch Import einschließlich Drag-and-drop, Suche fehlender Fotos, Erstellen und Verwerfen von Smart-Vorschauen, Umbenennungsvorschau,", + "Pick": "Auswahl", + "Pick Flag": "Auswahlmarkierung", + "Pick a colour on the photo with the eyedropper.": "Wähle mit der Pipette eine Farbe im Foto.", + "Pick the sharpest photo of each burst": "Schärfstes Foto jeder Serie auswählen", + "Picked": "Ausgewählt", + "Picks": "Ausgewählte Fotos", + "Pictures": "Bilder", + "Pixels": "Pixel", + "Point Color": "Punktfarbe", + "Point Curve": "Punktkurve", + "Point Curve Presets": "Punktkurvenvorgaben", + "Point curve": "Punktkurve", + "Portrait": "Porträt", + "Position": "Position", + "Preferences": "Einstellungen", + "Preferences…": "Einstellungen…", + "Prepare AI Masks": "KI-Masken vorbereiten", + "Preset": "Vorgabe", + "Preset name": "Name der Vorgabe", + "Presets": "Vorgaben", + "Presets & Profiles": "Vorgaben und Profile", + "Presets (Shift+P)": "Vorgaben (Umschalt+P)", + "Presets and profiles": "Vorgaben und Profile", + "Presets ▾": "Vorgaben ⌄", + "Press keys…": "Tasten drücken …", + "Preview": "Vorschau", + "Preview Build Progress": "Fortschritt der Vorschauerstellung", + "Preview only": "Nur Vorschau", + "Preview size": "Vorschaugröße", + "Previews": "Vorschauen", + "Previous Photo": "Vorheriges Foto", + "Print": "Drucken", + "Print…": "Drucken…", + "Profile": "Profil", + "Profile Browser": "Profilbrowser", + "Profile Distortion": "Profil-Verzeichnung", + "Profile Menu": "Profilmenü", + "Profile Vignetting": "Profil-Vignettierung", + "Profile:": "Profil:", + "Profiles": "Profile", + "Projection": "Projektion", + "Proof copy created": "Softproof-Kopie erstellt", + "Properties": "Eigenschaften", + "Public domain": "Gemeinfrei", + "Pupil Size": "Pupillengröße", + "Purple": "Violett", + "Purple Amount": "Violett – Stärke", + "Purple Hue": "Violett – Farbton", + "Purple Hue High": "Violett – oberer Farbton", + "Purple Hue Low": "Violett – unterer Farbton", + "Purple Luminance": "Violett – Luminanz", + "Purple Saturation": "Violett – Sättigung", + "Put the locations you removed from Local back (no files change)": "Aus „Lokal“ entfernte Orte wiederherstellen (keine Dateien werden geändert)", + "Quality": "Qualität", + "Query Photos": "Fotos abfragen", + "Quick Collection": "Schnellsammlung", + "Quick Collection and target album (B in the grid), keyword sets (⌥1–⌥9), colour-label sets.": "Schnellsammlung und Zielalbum mit B im Raster, Stichwortsätze mit ⌥1–⌥9 und Farbmarkierungssätze.", + "Quick Develop": "Schnellentwicklung", + "Quiet Green": "Ruhiges Grün", + "Quit": "Beenden", + "Quit Anyway": "Trotzdem beenden", + "Quit LightCraft": "LightCraft beenden", + "Quit with unsaved changes?": "Mit ungespeicherten Änderungen beenden?", + "RAW decoding": "RAW-Decodierung", + "Radial": "Radial", + "Radial Gradient": "Radialer Verlauf", + "Radius": "Radius", + "Random": "Zufällig", + "Range": "Bereich", + "Rate ★": "Mit ★ bewerten", + "Rate ★ and advance": "Mit ★ bewerten und weiter", + "Rate ★★": "Mit ★★ bewerten", + "Rate ★★ and advance": "Mit ★★ bewerten und weiter", + "Rate ★★★": "Mit ★★★ bewerten", + "Rate ★★★ and advance": "Mit ★★★ bewerten und weiter", + "Rate ★★★★": "Mit ★★★★ bewerten", + "Rate ★★★★ and advance": "Mit ★★★★ bewerten und weiter", + "Rate ★★★★★": "Mit ★★★★★ bewerten", + "Rate ★★★★★ and advance": "Mit ★★★★★ bewerten und weiter", + "Rating": "Bewertung", + "Rating cleared": "Bewertung entfernt", + "Ratings & flags": "Bewertungen und Markierungen", + "Raw": "RAW", + "Raw default": "RAW-Standard", + "Raw defaults": "RAW-Standardeinstellungen", + "Raw files": "RAW-Dateien", + "Raw photos": "RAW-Fotos", + "Raw photos as DNG, with the edits embedded. Size, color and output options don't apply.": "RAW-Fotos als DNG mit eingebetteten Bearbeitungen. Größen-, Farb- und Ausgabeoptionen gelten nicht.", + "Read Metadata from File": "Metadaten aus Datei lesen", + "Read mode": "Lesemodus", + "Read the SAM License": "SAM-Lizenz lesen", + "Reading": "Wird gelesen", + "Reading Lightroom catalog…": "Lightroom-Katalog wird gelesen …", + "Reading folder…": "Ordner wird gelesen…", + "Real name": "Echter Name", + "Recent": "Zuletzt verwendet", + "Recent Keywords": "Letzte Stichwörter", + "Recently Added": "Zuletzt hinzugefügt", + "Recently Deleted": "Zuletzt gelöscht", + "Red": "Rot", + "Red Eye": "Rote Augen", + "Red Hue": "Rot – Farbton", + "Red Luminance": "Rot – Luminanz", + "Red Primary": "Primärfarbe Rot", + "Red Saturation": "Rot – Sättigung", + "Red channel": "Rotkanal", + "Red label": "Rote Markierung", + "Red/Cyan Fringe": "Rote/cyanfarbene Farbsäume", + "Redo": "Wiederholen", + "Reference": "Referenz", + "Reference View": "Referenzansicht", + "Reference the files where they are": "Auf Dateien am aktuellen Speicherort verweisen", + "Refine": "Verfeinern", + "Refine AI Mask Detail": "KI-Maskendetails verfeinern", + "Refine Edges": "Ränder verfeinern", + "Refine Mask Edges": "Maskenränder verfeinern", + "Refine Saturation": "Sättigung verfeinern", + "Refining the mask's detail…": "Maskendetails werden verfeinert…", + "Refresh Source": "Quelle aktualisieren", + "Refresh Source (/)": "Quelle aktualisieren (/)", + "Reject": "Ablehnen", + "Reject below focus": "Unter Schärfegrenzwert ablehnen", + "Rejected": "Abgelehnt", + "Rejects": "Abgelehnte Fotos", + "Reliability": "Zuverlässigkeit", + "Relink Photo": "Foto neu verknüpfen", + "Reload Changed Files": "Geänderte Dateien neu laden", + "Reload from Disk": "Vom Datenträger neu laden", + "Remove": "Entfernen", + "Remove (H)": "Entfernen (H)", + "Remove CA": "Chromatische Aberration entfernen", + "Remove Chromatic Aberration": "Chromatische Aberration entfernen", + "Remove Disk from Library": "Datenträger aus Bibliothek entfernen", + "Remove Disk from Library…": "Datenträger aus Bibliothek entfernen…", + "Remove Face Box": "Gesichtsrahmen entfernen", + "Remove Folder from Library": "Ordner aus Bibliothek entfernen", + "Remove a filter above, or choose Clear all": "Entferne oben einen Filter oder wähle „Alles löschen“", + "Remove from Album": "Aus Album entfernen", + "Remove from Favorites": "Aus Favoriten entfernen", + "Remove from Library…": "Aus Bibliothek entfernen…", + "Remove from Local": "Aus „Lokal“ entfernen", + "Remove from Photo": "Von Foto entfernen", + "Remove from Selected Photos": "Von ausgewählten Fotos entfernen", + "Remove from Stack": "Aus Stapel entfernen", + "Remove from Target Album": "Aus Zielalbum entfernen", + "Remove from survey": "Aus Übersicht entfernen", + "Remove location info": "Ortsinformationen entfernen", + "Remove shortcut": "Tastenkürzel entfernen", + "Remove this face box (undo with Edit ▸ Undo)": "Diesen Gesichtsrahmen entfernen (rückgängig über Bearbeiten → Rückgängig)", + "Remove this filter": "Diesen Filter entfernen", + "Remove this group": "Diese Gruppe entfernen", + "Remove this rule": "Diese Regel entfernen", + "Rename": "Umbenennen", + "Rename Album": "Album umbenennen", + "Rename Component": "Komponente umbenennen", + "Rename Folder": "Ordner umbenennen", + "Rename Folder…": "Ordner umbenennen…", + "Rename Folder… and Move Folder To… (Local) can be undone and redone (issue #97): the folder moves back on disk with": "Ordner umbenennen… und Ordner verschieben nach… unter „Lokal“ lassen sich rückgängig machen und wiederholen (Issue #97). Der Ordner wird auf dem Datenträger", + "Rename Keyword": "Stichwort umbenennen", + "Rename Keyword…": "Stichwort umbenennen…", + "Rename Mask": "Maske umbenennen", + "Rename Photos": "Fotos umbenennen", + "Rename Photos never overwrites another photo when only the letter case changes (issue #95): on case-sensitive": "Beim Umbenennen von Fotos wird kein anderes Foto überschrieben, wenn sich nur die Groß-/Kleinschreibung ändert (Issue #95). Auf Laufwerken,", + "Rename Photos reports files it could not move back after a failure (issue #105), e.g. when a network share drops": "Beim Umbenennen meldet LightCraft Dateien, die nach einem Fehler nicht zurückverschoben werden konnten (Issue #105), etwa bei einer getrennten Netzwerkfreigabe", + "Rename Photos…": "Fotos umbenennen…", + "Rename Preset": "Vorgabe umbenennen", + "Rename Preview": "Umbenennungsvorschau", + "Rename Template Tags": "Platzhalter der Umbenennungsvorlage", + "Rename Version": "Version umbenennen", + "Rename…": "Umbenennen…", + "Rendering": "Wird gerendert", + "Rendering…": "Wird gerendert…", + "Reorder Masks": "Masken umordnen", + "Reset": "Zurücksetzen", + "Reset All": "Alle zurücksetzen", + "Reset All Curves": "Alle Kurven zurücksetzen", + "Reset All Keyboard Shortcuts": "Alle Tastenkürzel zurücksetzen", + "Reset Before to Import State": "Vorher auf Importzustand zurücksetzen", + "Reset Control": "Regler zurücksetzen", + "Reset Crop": "Zuschnitt zurücksetzen", + "Reset Edits": "Bearbeitungen zurücksetzen", + "Reset Parametric Curve": "Parametrische Kurve zurücksetzen", + "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)", + "Reshuffle": "Neu mischen", + "Resize Face Box": "Gesichtsrahmengröße ändern", + "Resize to": "Größe ändern auf", + "Resolution (ppi)": "Auflösung (ppi)", + "Restore": "Wiederherstellen", + "Restore (no shortcut)": "Wiederherstellen (kein Tastenkürzel)", + "Restore Library from Backup…": "Bibliothek aus Sicherung wiederherstellen…", + "Restore Version": "Version wiederherstellen", + "Restore keeps their edits; Import as new deletes the old record first.": "Wiederherstellen behält ihre Bearbeitungen; „Neu importieren“ löscht zuerst den alten Eintrag.", + "Restore them": "Wiederherstellen", + "Retouch": "Retuschieren", + "Reverse the order": "Reihenfolge umkehren", + "Revert": "Zurück zum letzten Stand", + "Review": "Überprüfung", + "Review Import": "Import überprüfen", + "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "Rechtsklick auf ein Foto → Als Referenzfoto festlegen, dann Ansicht → Referenzansicht (Umschalt+R)", + "Right-click a preset to delete it": "Rechtsklick auf eine Vorgabe, um sie zu löschen", + "Rights Usage Terms": "Nutzungsbedingungen", + "Rotate": "Drehen", + "Rotate Crop": "Zuschnitt drehen", + "Rotate Crop Aspect": "Zuschnitt-Seitenverhältnis drehen", + "Rotate Left": "Nach links drehen", + "Rotate Right": "Nach rechts drehen", + "Roughness": "Unregelmäßigkeit", + "Roundness": "Rundheit", + "Rules": "Regeln", + "SAM 3 download stopped: it resumes where it left off next time.": "SAM-3-Download angehalten: Beim nächsten Mal wird er an derselben Stelle fortgesetzt.", + "Same as raw default": "Wie RAW-Standard", + "Sample Mask Color": "Maskenfarbe aufnehmen", + "Sample a colour on the photo": "Eine Farbe im Foto aufnehmen", + "Saturation": "Sättigung", + "Saturation Range": "Sättigungsbereich", + "Saturday": "Samstag", + "Save": "Speichern", + "Save As…": "Speichern unter…", + "Save Color Label Set": "Farbmarkierungssatz speichern", + "Save Current Filter as Preset…": "Aktuellen Filter als Vorgabe speichern…", + "Save Current Keywords as Set…": "Aktuelle Stichwörter als Satz speichern…", + "Save Export Preset": "Exportvorgabe speichern", + "Save Filter Preset": "Filtervorgabe speichern", + "Save Keyword Set": "Stichwortsatz speichern", + "Save Metadata Preset": "Metadatenvorgabe speichern", + "Save Metadata Preset…": "Metadatenvorgabe speichern…", + "Save Metadata to File": "Metadaten in Datei speichern", + "Save Point Curve Preset": "Punktkurvenvorgabe speichern", + "Save Point Curve…": "Punktkurve speichern…", + "Save as Preset": "Als Vorgabe speichern", + "Save as Set…": "Als Satz speichern…", + "Save as Smart Album…": "Als Smart-Album speichern…", + "Save as set": "Als Satz speichern", + "Save as…": "Speichern unter…", + "Save preset": "Vorgabe speichern", + "Saving Lightroom import index…": "Lightroom-Importindex wird gespeichert …", + "Saving metadata to an XMP sidecar another application wrote no longer replaces it (issue #92): LightCraft merges its": "Beim Speichern von Metadaten in einer XMP-Begleitdatei einer anderen Anwendung wird diese nicht mehr ersetzt (Issue #92): LightCraft führt seine", + "Scale": "Skalierung", + "Scan failed": "Durchsuchen fehlgeschlagen", + "Scanning": "Wird durchsucht", + "Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "Die Werte bleiben an den Fotos gespeichert: Filtere danach oder erstelle Smart-Alben mit Schärfe und „Beste ähnlicher Aufnahmen“.", + "Screen": "Bildschirm", + "Scroll menu down": "Menü nach unten scrollen", + "Scroll menu up": "Menü nach oben scrollen", + "Search": "Suche", + "Search Photos": "Fotos suchen", + "Search commands or keys": "Befehle oder Tasten suchen", + "Search…": "Suchen…", + "Seasons": "Jahreszeiten", + "Second Window": "Zweites Fenster", + "Select": "Auswählen", + "Select All": "Alles auswählen", + "Select Luminance Range": "Luminanzbereich auswählen", + "Select Mask": "Maske auswählen", + "Select Photos": "Fotos auswählen", + "Select Spot": "Retuschierstelle auswählen", + "Select a photo to edit": "Wähle ein Foto zum Bearbeiten", + "Select by": "Auswählen nach", + "Select by Flag, Rating or Label": "Nach Markierung, Bewertung oder Farbmarkierung auswählen", + "Select photos, then choose View → Survey (N)": "Wähle Fotos aus, dann Ansicht → Übersicht (N)", + "Select two photos, then choose View → Compare (Shift+C)": "Wähle zwei Fotos aus, dann Ansicht → Vergleichen (Umschalt+C)", + "Selected": "Ausgewählt", + "Selecting…": "Wird ausgewählt…", + "Selenium Tone": "Selentonung", + "Send Feedback…": "Feedback senden…", + "Sepia Tone": "Sepiatonung", + "Sequence number zero-padded to N digits (1–9)": "Laufende Nummer, mit Nullen auf N Stellen aufgefüllt (1–9)", + "Sequence number, counting from the start number": "Laufende Nummer, beginnend mit der Startnummer", + "Services": "Dienste", + "Set": "Festlegen", + "Set Album Cover": "Albumcover festlegen", + "Set Before": "Vorher festlegen", + "Set Color Label": "Farbmarkierung festlegen", + "Set Crop": "Zuschnitt festlegen", + "Set Develop Values": "Entwicklungswerte festlegen", + "Set Flag": "Markierung festlegen", + "Set Image Zoom and Pan": "Bildzoom und -ausschnitt festlegen", + "Set Keyboard Shortcut": "Tastenkürzel festlegen", + "Set Mask Adjustments": "Maskenkorrekturen festlegen", + "Set Point Curve": "Punktkurve festlegen", + "Set Profile": "Profil festlegen", + "Set Rating": "Bewertung festlegen", + "Set Top of Stack": "Oberstes Foto im Stapel festlegen", + "Set as Album Cover": "Als Albumcover festlegen", + "Set as Before": "Als Vorher festlegen", + "Set as Reference Photo": "Als Referenzfoto festlegen", + "Set as Target Album": "Als Zielalbum festlegen", + "Set as Target Album (B adds to it)": "Als Zielalbum festlegen (B fügt Fotos hinzu)", + "Set as Top of Stack": "Als oberstes Foto im Stapel festlegen", + "Set date & time": "Datum und Uhrzeit festlegen", + "Set name": "Satzname", + "Set the mix from the photo's colours": "Mischung anhand der Farben des Fotos festlegen", + "Settings": "Einstellungen", + "Settings file problem": "Problem mit Einstellungsdatei", + "Settings files are never quietly reset (issue #103): a damaged `prefs.json`, `presets.json` or `view.json` is kept": "Einstellungsdateien werden nie still zurückgesetzt (Issue #103): Beschädigte `prefs.json`, `presets.json` oder `view.json` bleiben", + "Settings new raw photos start from. Changing them doesn't touch photos already in the library.": "Einstellungen für neue RAW-Fotos. Änderungen wirken sich nicht auf Fotos aus, die bereits in der Bibliothek sind.", + "Settings pasted": "Einstellungen eingefügt", + "Settings to include": "Einzubeziehende Einstellungen", + "Settings…": "Einstellungen…", + "Shade": "Schatten", + "Shadow": "Schatten", + "Shadows": "Tiefen", + "Shadows Hue": "Tiefen – Farbton", + "Shadows Luminance": "Tiefen – Luminanz", + "Shadows Saturation": "Tiefen – Sättigung", + "Shadows Tint": "Tiefen – Tonung", + "Shadows split": "Tiefengrenze", + "Sharpen": "Schärfen", + "Sharpening": "Schärfen", + "Sharpness": "Schärfe", + "Shift": "Verschieben", + "Shift by": "Verschieben um", + "Short Edge": "Kurze Kante", + "Show": "Anzeigen", + "Show All": "Alle einblenden", + "Show Clipping": "Beschnitt anzeigen", + "Show Deghost Overlay": "Geisterbildüberlagerung anzeigen", + "Show Hidden Local Locations": "Ausgeblendete lokale Orte anzeigen", + "Show Luminance Map": "Luminanzkarte anzeigen", + "Show Mask": "Maske anzeigen", + "Show Mask Overlay": "Maskenüberlagerung anzeigen", + "Show Mask Pins": "Maskenpins anzeigen", + "Show Original": "Original anzeigen", + "Show Photo Counts": "Fotoanzahl anzeigen", + "Show Photos with Keyword": "Fotos mit Stichwort anzeigen", + "Show Pins": "Pins anzeigen", + "Show Source": "Quelle anzeigen", + "Show Thumbnails": "Miniaturbilder anzeigen", + "Show commands without a shortcut": "Befehle ohne Tastenkürzel anzeigen", + "Show destination gamut warning": "Zielfarbraumwarnung anzeigen", + "Show display gamut warning": "Bildschirmfarbraumwarnung anzeigen", + "Show file names": "Dateinamen anzeigen", + "Show in Explorer": "Im Explorer anzeigen", + "Show in File Manager": "Im Dateimanager anzeigen", + "Show in Finder": "Im Finder anzeigen", + "Show on Map": "Auf Karte anzeigen", + "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 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", + "Shutter speed": "Verschlusszeit", + "Side panels": "Seitliche Bedienfelder", + "Sidecar names": "Namen der Begleitdateien", + "Size": "Größe", + "Size (% of short edge)": "Größe (% der kurzen Kante)", + "Size limit": "Größenbegrenzung", + "Skip": "Überspringen", + "Sky": "Himmel", + "Slideshow": "Diashow", + "Slideshow paused": "Diashow pausiert", + "Slideshow resumed": "Diashow fortgesetzt", + "Slideshow · Space pauses · Esc ends": "Diashow · Leertaste pausiert · Esc beendet", + "Slideshow, second window, All Metadata, System Info.": "Diashow, zweites Fenster, alle Metadaten und Systeminformationen.", + "Smaller, faster catalogs: photos you only looked at in Local (never added, rated or edited) are forgotten once": "Kleinere und schnellere Kataloge: Fotos, die nur unter „Lokal“ angesehen, aber nie hinzugefügt, bewertet oder bearbeitet wurden, werden vergessen,", + "Smart Album": "Smart-Album", + "Smart Album Rule Fields": "Regelfelder für Smart-Alben", + "Smart Preview Status": "Status der Smart-Vorschauen", + "Smart Previews Location": "Speicherort der Smart-Vorschauen", + "Smart albums with a rule editor: match all / any / none, nested groups, 26 fields.": "Smart-Alben mit Regel-Editor: alle, beliebige oder keine Regeln, verschachtelte Gruppen und 26 Felder.", + "Smart preview available": "Smart-Vorschau verfügbar", + "Smart previews": "Smart-Vorschauen", + "Smart previews folder changed": "Ordner für Smart-Vorschauen geändert", + "Smoothness": "Glättung", + "Soft B&W": "Sanftes Schwarzweiß", + "Soft Pastel": "Sanftes Pastell", + "Soft Proofing": "Softproof", + "Soft Skin": "Weiche Haut", + "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", + "Sort by": "Sortieren nach", + "Source": "Quelle", + "Source code on GitHub": "Quellcode auf GitHub", + "Spherical": "Sphärisch", + "Spiral": "Spirale", + "Split Stack": "Stapel teilen", + "Spring Fresh": "Frühlingsfrische", + "Square Grid": "Quadratisches Raster", + "Square Grid (G toggles)": "Quadratisches Raster (G schaltet um)", + "Square Grid: show file names and formats": "Quadratisches Raster: Dateinamen und Formate anzeigen", + "Stack": "Stapel", + "Stack photos taken within this time of each other:": "Fotos innerhalb dieses Zeitabstands stapeln:", + "Stacks": "Stapel", + "Standard": "Standard", + "Star rating (0–5)": "Sternebewertung (0–5)", + "Start at": "Beginnen bei", + "Start number": "Startnummer", + "Starting": "Wird gestartet", + "Startup": "Beim Start", + "State / Province": "Bundesland / Provinz", + "Stop": "Anhalten", + "Stop Building Previews": "Vorschauerstellung anhalten", + "Stop Using as Target Album": "Nicht mehr als Zielalbum verwenden", + "Stopping…": "Wird angehalten…", + "Straighten": "Gerade ausrichten", + "Straighten Tool": "Gerade-ausrichten-Werkzeug", + "Strong Contrast": "Starker Kontrast", + "Style": "Stil", + "Subfolder": "Unterordner", + "Subfolder name": "Unterordnername", + "Subject": "Motiv", + "Subtract": "Subtrahieren", + "Subtract from this mask": "Von dieser Maske subtrahieren", + "Suggestions": "Vorschläge", + "Summer Haze": "Sommerdunst", + "Sunday": "Sonntag", + "Survey": "Übersicht", + "Survey (N)": "Übersicht (N)", + "Swap": "Tauschen", + "Swap Aspect (X)": "Seitenverhältnis tauschen (X)", + "Swap Before and After": "Vorher und Nachher tauschen", + "Swap Before and After Settings": "Vorher- und Nachher-Einstellungen tauschen", + "Swap Compare Photos": "Vergleichsfotos tauschen", + "Switch light / dark theme": "Helles/dunkles Design wechseln", + "Sync Settings": "Einstellungen synchronisieren", + "System": "System", + "System Info": "Systeminformationen", + "System Info…": "Systeminformationen…", + "System default": "Systemstandard", + "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": "und TZ/ZS-Serien, Leica D-Lux, V-Lux und C-Lux sowie GH6, GH7, G9 II, S5 II und S9. Ihr Farb- und Tonwertbild wird", + "Table": "Tabelle", + "Tags": "Platzhalter", + "Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "Platzhalter: Siehe „Platzhalter“ neben der Vorlage. Dateien werden auf dem Datenträger umbenannt (mit ihren XMP-Begleitdateien); vorhandene Namen erhalten -1, -2…", + "Take a snapshot": "Schnappschuss erstellen", + "Targeted Adjustment": "Gezielte Anpassung", + "Targeted adjustment: drag up/down on the photo to adjust the colours there": "Gezielte Anpassung: Im Foto nach oben/unten ziehen, um die dortigen Farben anzupassen", + "Targeted adjustment: drag up/down on the photo to adjust the curve there": "Gezielte Anpassung: Im Foto nach oben/unten ziehen, um die Kurve dort anzupassen", + "Teal & Amber": "Petrol und Bernstein", + "Teal & Orange": "Petrol und Orange", + "Temp": "Temp.", + "Temperature": "Temperatur", + "Template": "Vorlage", + "Temporary session — your library isn't open, and nothing you do here is saved.": "Vorübergehende Sitzung — deine Bibliothek ist nicht geöffnet; hier vorgenommene Änderungen werden nicht gespeichert.", + "Text": "Text", + "Text direction": "Textrichtung", + "Texture": "Struktur", + "That includes the photos in the folders inside it.": "Das schließt die Fotos in den enthaltenen Unterordnern ein.", + "That key can't be used as a shortcut": "Diese Taste kann nicht als Tastenkürzel verwendet werden", + "The SAM 3 model is installed: Object and Describe masks are ready.": "Das SAM-3-Modell ist installiert: Objekt- und Beschreibungsmaske sind verfügbar.", + "The catalog has a format version (issue #102). Opening a library from an older LightCraft upgrades it; a library": "Der Katalog hat eine Formatversion (Issue #102). Bibliotheken älterer LightCraft-Versionen werden beim Öffnen aktualisiert; eine Bibliothek", + "The control port (`--control`) closes a connection as soon as it receives anything that isn't a JSON request": "Der Steuerport (`--control`) schließt eine Verbindung, sobald er etwas anderes als eine JSON-Anfrage erhält", + "The download didn't work:": "Der Download ist fehlgeschlagen:", + "The number at the end of the original name (IMG_0042 → 0042)": "Nummer am Ende des Originalnamens (IMG_0042 → 0042)", + "The original extension is always added; {ext} only puts it inside the name as well.": "Die Originalerweiterung wird immer angehängt; {ext} fügt sie zusätzlich innerhalb des Namens ein.", + "The original files, unchanged, each with an XMP sidecar holding its edits.": "Unveränderte Originaldateien, jeweils mit einer XMP-Begleitdatei für die Bearbeitungen.", + "The photo has these settings": "Das Foto enthält diese Einstellungen", + "The thumbnail cache only ever counts and deletes its own files (issue #98): a library opened on a folder that": "Der Miniaturbild-Cache zählt und löscht ausschließlich eigene Dateien (Issue #98). Wird eine Bibliothek in einem Ordner mit", + "Theme": "Design", + "They can be restored from Recently Deleted until it is emptied.": "Sie können aus „Zuletzt gelöscht“ wiederhergestellt werden, bis dieser Bereich geleert wird.", + "They move to Recently Deleted and can be restored; no file on disk is touched.": "Sie werden nach „Zuletzt gelöscht“ verschoben und können wiederhergestellt werden; keine Datei auf dem Datenträger wird verändert.", + "Thirds": "Drittel", + "This Computer": "Dieser Computer", + "This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "Dieser Ordner ist nicht verfügbar (Laufwerk getrennt?). Smart-Vorschauen werden erst wieder erstellt oder verwendet, wenn er verfügbar ist oder du einen anderen Ordner auswählst.", + "This photo can't be opened": "Dieses Foto kann nicht geöffnet werden", + "Threshold": "Schwellenwert", + "Thumbnail cache": "Miniaturbild-Cache", + "Thursday": "Donnerstag", + "Time zone": "Zeitzone", + "Tint": "Tonung", + "Title": "Titel", + "Title metadata": "Titel aus Metadaten", + "Toggle Keyword from Set": "Stichwort aus Satz umschalten", + "Toggle Mask": "Maske umschalten", + "Toggle Section": "Bereich umschalten", + "Toggle Zoom": "Zoom umschalten", + "Tone Curve": "Gradationskurve", + "Tools": "Werkzeuge", + "Top Level": "Oberste Ebene", + "Transfer": "Übertragen", + "Treatment": "Behandlung", + "Triangle": "Dreieck", + "Try Again": "Erneut versuchen", + "Try Saving Again": "Speichern erneut versuchen", + "Tuesday": "Dienstag", + "Tungsten": "Glühlampenlicht", + "Turn Off": "Ausschalten", + "Turn on Include subfolders, or pick another folder under Local": "Aktiviere „Unterordner einbeziehen“ oder wähle unter „Lokal“ einen anderen Ordner", + "Type": "Typ", + "Uncheck All": "Alle abwählen", + "Uncheck the files of this type": "Auswahl der Dateien dieses Typs aufheben", + "Undo": "Rückgängig", + "Unedited": "Unbearbeitet", + "Unedited raws": "Unbearbeitete RAW-Fotos", + "Unflag": "Markierung entfernen", + "Unflagged": "Unmarkiert", + "Ungroup Stack": "Stapel auflösen", + "Unknown Date": "Unbekanntes Datum", + "Unknown tags stay as typed — check the preview for a {typo}.": "Unbekannte Platzhalter bleiben unverändert — prüfe die Vorschau auf einen {typo}.", + "Unrated": "Unbewertet", + "Unreadable": "Nicht lesbar", + "Update": "Aktualisieren", + "Update Mask Shape": "Maskenform aktualisieren", + "Update Preset with Current Settings": "Vorgabe mit aktuellen Einstellungen aktualisieren", + "Update Rules from Current Filter": "Regeln aus aktuellem Filter aktualisieren", + "Update Version": "Version aktualisieren", + "Update Version with Current Settings": "Version mit aktuellen Einstellungen aktualisieren", + "Update with Current Settings": "Mit aktuellen Einstellungen aktualisieren", + "Updated the edits saved in the external editor": "Im externen Editor gespeicherte Bearbeitungen aktualisiert", + "Upright": "Perspektivkorrektur", + "Urban": "Städtisch", + "Use Keyword Set": "Stichwortsatz verwenden", + "Use Library Folder": "Bibliotheksordner verwenden", + "Use camera-specific defaults": "Kameraspezifische Standardeinstellungen verwenden", + "Use the GPU for rendering": "GPU zum Rendern verwenden", + "User Presets": "Benutzervorgaben", + "Username": "Benutzername", + "Value": "Wert", + "Variance": "Varianz", + "Version": "Version", + "Version name": "Versionsname", + "Versions": "Versionen", + "Versions (Shift+V)": "Versionen (Umschalt+V)", + "Vertical": "Vertikal", + "Vertical text": "Vertikaler Text", + "Vibrance": "Dynamik", + "Videos": "Videos", + "View": "Ansicht", + "Viewing and sharing": "Ansicht und Weitergabe", + "Vignette": "Vignette", + "Vignetting": "Vignettierung", + "Vintage": "Vintage", + "Virtual Copy": "Virtuelle Kopie", + "Visible": "Sichtbar", + "Visualize Spots": "Flecken sichtbar machen", + "Visualize Spots (A)": "Flecken sichtbar machen (A)", + "Visualize range": "Bereich sichtbar machen", + "Vivid": "Lebendig", + "Vivid Pop": "Lebendige Farben", + "Warm Film": "Warmer Film", + "Warm Glow": "Warmer Glanz", + "Warm Print": "Warmer Abzug", + "Warm Table": "Warmer Tisch", + "Watched folder": "Überwachter Ordner", + "Watched-folder auto import; Convert to DNG; Duplicate; Build Standard / 1:1 / Smart Previews.": "Automatischer Import aus überwachten Ordnern, DNG-Konvertierung, Duplizieren und Erstellen von Standard-, 1:1- und Smart-Vorschauen.", + "Watermark": "Wasserzeichen", + "Wednesday": "Mittwoch", + "What's New": "Neuigkeiten", + "When rated or hovered": "Bei Bewertung oder Mauszeigerkontakt", + "White Balance": "Weißabgleich", + "White Balance Selector": "Weißabgleich-Pipette", + "White Balance Selector (W)": "Weißabgleich-Pipette (W)", + "Whites": "Weiß", + "Width": "Breite", + "Width (% of photo)": "Breite (% des Fotos)", + "Width (px)": "Breite (px)", + "Width × Height": "Breite × Höhe", + "Will be replaced": "Wird ersetzt", + "Will be restored": "Wird wiederhergestellt", + "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", + "XMP Sidecar Preferences": "Einstellungen für XMP-Begleitdateien", + "XMP sidecars": "XMP-Begleitdateien", + "Year": "Jahr", + "Yellow": "Gelb", + "Yellow Hue": "Gelb – Farbton", + "Yellow Luminance": "Gelb – Luminanz", + "Yellow Saturation": "Gelb – Sättigung", + "Yellow label": "Gelbe Markierung", + "Yes": "Ja", + "You can close this: the download continues, and resumes if interrupted.": "Du kannst dieses Fenster schließen: Der Download läuft weiter und wird nach Unterbrechungen fortgesetzt.", + "Your Name": "Dein Name", + "Your library couldn't be opened": "Deine Bibliothek konnte nicht geöffnet werden", + "Zoom": "Zoom", + "Zoom 100%": "Zoom 100 %", + "Zoom In": "Vergrößern", + "Zoom Out": "Verkleinern", + "Zoom to Fit": "Zum Einpassen zoomen", + "Zoom to page level": "Auf Seitenebene zoomen", + "`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`, zusammen mit ihren XMP-Begleitdateien. Jedes Original verlässt die Karte erst nach", + "`img_1-1.JPG`; on case-insensitive volumes the case change still goes through.": "`img_1-1.JPG`. Auf Laufwerken ohne diese Unterscheidung wird die Schreibweise weiterhin geändert.", + "a full drive) no longer counts as built and Build Smart Previews replaces it.": "oder vollen Laufwerk gilt nicht mehr als fertig und wird beim Erstellen der Smart-Vorschauen ersetzt.", + "a literal %": "ein wörtliches %", + "a removed photo": "einem entfernten Foto", + "afterwards (issue #101): the partial write is cut off before LightCraft retries, so the next launch replays every": "(Issue #101). Vor einem erneuten Versuch wird das Schreibfragment entfernt; beim nächsten Start werden alle Änderungen wiederhergestellt.", + "again. Change the period (or turn it off) in Settings → Performance.": "wieder. Der Zeitraum lässt sich unter Einstellungen → Leistung ändern oder deaktivieren.", + "against the library's originals no longer scans the whole library when nothing is at that path.": "gegen Originale durchsucht nicht mehr die gesamte Bibliothek, wenn an diesem Pfad nichts liegt.", + "all": "alle", + "already has a `thumbs/` folder of other pictures no longer loses them when the cache is trimmed or cleared.": "bereits vorhandenen fremden Bildern unter `thumbs/` geöffnet, bleiben diese beim Leeren oder Verkleinern des Caches erhalten.", + "and": "und", + "and checks the file before using it. Masks keep their selection, so they render and export without the model.": "Vor Verwendung werden die Dateien geprüft. Masken behalten ihre Auswahl und lassen sich ohne das Modell rendern und exportieren.", + "and every imported photo as one zip, and File ▸ Restore Library from Backup… brings it back (the current library": "und alle importierten Fotos als ZIP herunter. Datei → Bibliothek aus Sicherung wiederherstellen… stellt sie wieder her; die aktuelle Bibliothek", + "and never freezes the window. It is optional and not part of LightCraft (Meta's SAM License, about 3.4 GB): the": "und blockiert das Fenster nie. Es ist optional und kein Bestandteil von LightCraft, unter Metas SAM-Lizenz mit etwa 3,4 GB.", + "any": "mindestens eine", + "are empty": "sind leer", + "aren't empty": "sind nicht leer", + "as `.corrupt-