diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml
index 0b528ae1..c63fdeef 100644
--- a/.github/workflows/freebsd.yml
+++ b/.github/workflows/freebsd.yml
@@ -26,15 +26,15 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 300
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Optional font build input, so the Japanese font tests run (they skip without it).
- - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 8dcdacd5153e64560d109541a47d806f26f048c0
path: craft-fonts
- name: Build and test in a FreeBSD VM
- uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
+ uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
with:
release: "14.3"
usesh: true
diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml
index 0e2388a6..46ce3c90 100644
--- a/.github/workflows/packaging-lint.yml
+++ b/.github/workflows/packaging-lint.yml
@@ -23,7 +23,7 @@ jobs:
env:
ACTIONLINT_VERSION: 1.7.12
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install tools
run: |
sudo apt-get update
@@ -51,6 +51,11 @@ jobs:
if grep -oE ' ' 'Value="InstallDirDlg"' ' ' \
+ 'Key="Software\LightCraft" Name="InstallFolder"' 'Value="LaunchLightcraft"'; do
+ grep -qF "$want" packaging/windows/lightcraft.wxs || { echo "::error::lightcraft.wxs lost: $want"; exit 1; }
+ done
desktop-file-validate packaging/linux/ai.storyteller.lightcraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.lightcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.lightcraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.lightcraft.metainfo.xml"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index b9c3a1ea..c81bf050 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -44,7 +44,7 @@ jobs:
date: ${{ steps.v.outputs.date }}
prerelease: ${{ steps.v.outputs.prerelease }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- id: v
name: Resolve version
run: |
@@ -72,17 +72,17 @@ jobs:
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
- - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
path: craft-fonts
- - uses: dtolnay/rust-toolchain@stable
+ - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: aarch64-apple-darwin, x86_64-apple-darwin
- - uses: Swatinem/rust-cache@v2
+ - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-macos-universal
- name: Apply version override
@@ -102,7 +102,7 @@ jobs:
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: packaging/macos/package.sh --arch universal
- - uses: actions/upload-artifact@v4
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-macos
path: dist/release/*
@@ -130,23 +130,24 @@ jobs:
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
- - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
path: craft-fonts
- - uses: dtolnay/rust-toolchain@stable
+ - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: ${{ matrix.target }}
- - uses: Swatinem/rust-cache@v2
+ - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-windows-${{ matrix.arch }}
- name: Apply version override
if: inputs.version != ''
shell: bash
run: cargo xtask version set "$LIGHTCRAFT_VERSION"
+ # package.ps1 adds the matching WixToolset.UI/Util.wixext (5.0.2) to the global extension cache.
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2
@@ -164,7 +165,7 @@ jobs:
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
- - uses: actions/upload-artifact@v4
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-windows-${{ matrix.arch }}
path: dist/release/*
@@ -187,9 +188,9 @@ jobs:
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
NFPM_VERSION: 2.47.0
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
- - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
@@ -203,8 +204,8 @@ jobs:
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
- - uses: dtolnay/rust-toolchain@stable
- - uses: Swatinem/rust-cache@v2
+ - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
+ - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-linux-${{ matrix.arch }}
- name: Apply version override
@@ -228,7 +229,7 @@ jobs:
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
test -s "$appimage.zsync"
done
- - uses: actions/upload-artifact@v4
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/release/*
@@ -250,8 +251,8 @@ jobs:
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- - uses: actions/checkout@v4
- - uses: actions/download-artifact@v4
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/linux
@@ -265,7 +266,7 @@ jobs:
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
- name: Build the Flatpak bundle
run: packaging/linux/flatpak-bundle.sh "dist/linux/lightcraft-$LIGHTCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
- - uses: actions/upload-artifact@v4
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-flatpak-${{ matrix.arch }}
path: dist/release/*.flatpak
@@ -282,16 +283,16 @@ jobs:
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
- - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
path: craft-fonts
- name: Build and package in a FreeBSD VM (tar.gz)
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
- uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
+ uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
with:
release: "14.3"
usesh: true
@@ -316,7 +317,7 @@ jobs:
bash packaging/freebsd/package.sh
tar -tzf dist/release/lightcraft-*-freebsd-x86_64.tar.gz \
| grep -E '/bin/lightcraft$|/bin/lightcraft-cli$|/share/applications/ai\.storyteller\.lightcraft\.desktop$|/share/doc/lightcraft/LICENSE-MIT$'
- - uses: actions/upload-artifact@v4
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-freebsd-x86_64
path: dist/release/*
@@ -331,17 +332,17 @@ jobs:
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
WASM_BINDGEN_VERSION: 0.2.129
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
- - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
path: craft-fonts
- - uses: dtolnay/rust-toolchain@stable
+ - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: wasm32-unknown-unknown
- - uses: Swatinem/rust-cache@v2
+ - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-web
- name: Install wasm-bindgen CLI
@@ -359,7 +360,7 @@ jobs:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/web/package.sh
- - uses: actions/upload-artifact@v4
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-web
path: dist/release/*
@@ -378,7 +379,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
steps:
- - uses: actions/download-artifact@v4
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: release-*
path: artifacts
diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml
index d6067b3f..1cccd858 100644
--- a/.github/workflows/windows-arm64.yml
+++ b/.github/workflows/windows-arm64.yml
@@ -36,11 +36,11 @@ jobs:
runs-on: windows-latest
timeout-minutes: 90
steps:
- - uses: actions/checkout@v4
- - uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: aarch64-pc-windows-msvc
- - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
+ - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: windows-arm64-package
- name: Install WiX
@@ -51,7 +51,7 @@ jobs:
env:
DIST: ${{ github.workspace }}\dist\arm64
run: ./packaging/windows/package.ps1 -Arch arm64
- - uses: actions/upload-artifact@v4
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: lightcraft-windows-arm64
path: dist/arm64/*
@@ -64,7 +64,7 @@ jobs:
runs-on: windows-11-arm
timeout-minutes: 30
steps:
- - uses: actions/download-artifact@v4
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: lightcraft-windows-arm64
path: dist
diff --git a/CLAUDE.md b/CLAUDE.md
index 994920d3..57745896 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -103,6 +103,9 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
the running app). See `docs/mcp.md`. Quick non-UI checks: `lightcraft-cli render in.jpg -o out.jpg --set light.exposure=1`.
- Export goes through `lightcraft_engine::export` (one encoder for app, CLI, MCP and web); UI-only commands live in
`crates/ui-egui/src/menus.rs`.
+- Text boxes use `crates/ui-egui/src/text_field.rs` (`TextField`), not `egui::TextEdit`: it gives every field the
+ Cut / Copy / Paste / Select All menu, Esc restoring the text, and reports how an edit ended. Give each a widget id
+ that is unique on screen. Older fields still on `TextEdit` move over as they are touched.
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`.
- Parallel agents: separate git worktrees and `CARGO_TARGET_DIR=target/agent-`; each agent uses its **own control port** (pick one in 18000–19999, never the default 7980) and its own scratch subfolder (`//`) — never `rm -rf` shared paths; delete your target dir when done (disk is shared); keep every `Cargo.toml` valid at all times (the `crates/*` glob means one broken manifest breaks everyone).
- Test corpora: `cargo xtask corpus --download` into `corpus/` (gitignored, CC0 only). Never commit media.
diff --git a/Cargo.lock b/Cargo.lock
index 54f556b2..e8037a6f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2998,6 +2998,7 @@ version = "0.5.0"
dependencies = [
"crc32fast",
"lightcraft-develop",
+ "lightcraft-geom",
"lightcraft-meta",
"log",
"proptest",
@@ -3026,6 +3027,7 @@ dependencies = [
name = "lightcraft-codecs"
version = "0.5.0"
dependencies = [
+ "avif-serialize",
"bytemuck",
"image",
"image-webp",
@@ -3039,6 +3041,7 @@ dependencies = [
"moxcms 0.9.1",
"png",
"proptest",
+ "rav1e",
"ravif",
"rayon",
"serde",
@@ -3118,6 +3121,7 @@ dependencies = [
"lightcraft-fetch",
"lightcraft-geom",
"lightcraft-gpu",
+ "lightcraft-heif",
"lightcraft-merge",
"lightcraft-meta",
"lightcraft-pipeline",
@@ -3181,6 +3185,7 @@ dependencies = [
"lightcraft-geom",
"lightcraft-pipeline",
"lightcraft-raster",
+ "lightcraft-raw",
"lightcraft-scenes",
"log",
"pollster 1.0.1",
@@ -3192,6 +3197,7 @@ name = "lightcraft-heif"
version = "0.5.0"
dependencies = [
"heic-rs",
+ "rayon",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index e85ef3f8..58e5383b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -68,6 +68,9 @@ moxcms = { version = "0.9", default-features = false, features = ["lut"] }
jxl-oxide = { version = "0.12", default-features = false }
zune-jpegxl = { version = "0.5", default-features = false, features = ["std"] } # MIT/Apache-2.0/Zlib lossless JXL encoder (test fixtures only)
ravif = { version = "0.13", default-features = false, features = ["threading"] } # no "asm": it needs nasm (non-Rust)
+# HDR AVIF (PQ): ravif fixes the sRGB transfer curve, so HDR files drive its rav1e + avif-serialize directly
+rav1e = { version = "0.8.1", default-features = false, features = ["threading"] }
+avif-serialize = "0.8.6"
# gpu (crates/gpu; native backends only, no GL: everything stays pure Rust)
# SAM 3 inference (lightcraft-segment). 0.9.2: later releases pull `tokenizers` with the
diff --git a/README.md b/README.md
index bca63f8a..217092fb 100644
--- a/README.md
+++ b/README.md
@@ -249,6 +249,20 @@ lightcraft --control 7980 ~/Pictures/trip
path=out.jpg longEdge=2048` runs any chain of commands (headless, on a saved library, or against the running app)
and prints one JSON result per command; `lightcraft-cli render in.dng -o out.jpg --set light.exposure=0.7 --preset …`.
- **Undo for everything**, including agent actions: a slider drag (or a scripted burst of updates) is one undo step.
+- **Contact sheets (native app / CLI):** select photos, then **File → Contact Sheet PDF…**. Choose A4 or Letter, landscape,
+ rows/columns and filename captions; save a paginated PDF and print it from your PDF viewer. Exports use the
+ current edits, fit each photo without further cropping, and render at 150 dpi in sRGB. The background export
+ supports cancellation and protects originals and XMP sidecars. From the CLI:
+
+ ```sh
+ lightcraft-cli run --import ~/Pictures/shoot library.selectAll export.contactSheet path=Contact.pdf paper=a4 columns=3 rows=4 captions=true
+ ```
+
+ `export.contactSheet` also accepts explicit `ids`, `landscape=true`, and `paper=letter`. It replaces an existing
+ output only after the entire PDF succeeds. Limits: 1,000 photos, 100 pages, 256 MiB; captions are rasterized
+ with the same font coverage as watermarks (CJK requires craft-fonts), with long names shortened in the middle.
+ This is a contact-sheet workflow,
+ not a printer-driver or custom picture-package layout editor.
- **Every widget is addressable** (`ui.widgets`) and clickable by name, so agents operate the real UI, not a
side door.
- The screenshots in this README were produced end to end by the [`docs/showcase/`](docs/showcase/) scripts.
@@ -269,7 +283,8 @@ lightcraft --control 7980 ~/Pictures/trip
- **GPU-accelerated, CPU-exact.** The whole develop pipeline runs as wgpu compute kernels (Metal / Vulkan / DX12),
checked against the CPU pipeline to within 1/255. On a 24 MP raw (Apple M4 Pro): a slider update re-renders in
~4 ms, a cold 2.5 MP loupe in ~30 ms, and a full-size export in ~0.3 s including a parallel JPEG encode.
- Without a GPU the same pipeline runs on all CPU cores, redoing only the stages a slider affects.
+ Without a GPU the same pipeline runs on all CPU cores, redoing only the stages a slider affects. Batch exports
+ render several photos side by side, so one's decode and encode overlap another's render.
- **Instant culling.** Opening a raw shows its embedded camera preview or cached render within ~0.1 s while the
full render follows (~0.2–0.5 s for 24 MP). The next and previous photos are prepared in the background, so stepping
through a shoot takes ~50 ms per photo.
@@ -289,10 +304,10 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
- **As a day-to-day Lightroom replacement we're nearer 60–70%.** It's great for JPEG/DNG and most Nikon / Sony /
older-Canon raws on one machine.
- **The biggest gaps:**
- - **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCE-7M4, X-H2S and X-T4 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix;
+ - **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCE-7CR, ILCE-7M4, X-H2S and X-T4 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix;
- **compressed Olympus raws and unsupported CR3 variants:** these use embedded JPEG previews when present. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md);
- **AI masks and denoise:** subject and sky selection are classical heuristics;
- - **HDR, video and the Classic Print / Book / Map modules.**
+ - **on-screen HDR display, video and the Classic Print / Book / Map modules.**
- **What's next:** see [where we're going](ROADMAP.md#where-were-going).
| Area | Status |
@@ -308,7 +323,8 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
| RAW: DNG, CR2, CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), ARW, NEF (uncompressed + lossless/lossy compressed), Fujifilm RAF (uncompressed + lossless/lossy compressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed), Samsung SRW (uncompressed); embedded previews for every format incl. CR3 | 🟡 · CR3, compressed ORF and compressed SRW decode ⬜ |
| Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise, Super Resolution ⬜ |
| Remove / Heal / Clone spots (auto source), Visualize Spots, Red Eye and Pet Eye (auto pupil detection, catchlight) | ✅ · content-aware fill, spot pin editing 🚧 |
-| Export: JPEG / PNG / TIFF / WebP / AVIF / DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | ✅ · HDR export ⬜ |
+| Export: JPEG / PNG / TIFF / WebP / AVIF / DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | ✅ |
+| HDR: HDR editing with a headroom limit, SDR rendition, Visualize HDR; export as ISO 21496-1 gain map JPEG, PQ AVIF or 32-bit float TIFF | ✅ · HDR display ⬜ |
| Library persistence (crash-safe op log + snapshots, background compaction, failed saves reported), disk thumbnail cache | ✅ |
| Import: Add in place / Copy / Move, rename and folder templates, devices, duplicate detection, watched folders; Local folder browsing | ✅ |
| MCP server (headless or live app, persistent libraries), CLI, control channel | ✅ |
@@ -316,7 +332,7 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
| Optics (distortion, vignetting, auto + manual CA, defringe, lens corrections embedded in DNG files and Panasonic / Leica RW2 / RWL distortion data), Geometry (transforms, Constrain Crop), Upright (Auto/Level/Vertical/Full/Guided) | ✅ · camera lens profiles (our own) ⬜ |
| Photo Merge: HDR (auto-align, deghost), Panorama (spherical/cylindrical/perspective, boundary warp, auto crop), HDR Panorama → DNG | ✅ |
| GPU pipeline (wgpu compute, CPU-exact within 1/255), CPU fallback on device limits / errors | ✅ · WebGPU in the browser 🚧 |
-| AI: segmentation masks, AI denoise, super resolution, faces; HDR editing; video | ⬜ (see [roadmap](ROADMAP.md#where-were-going)) |
+| AI: segmentation masks, AI denoise, super resolution, faces; video | ⬜ (see [roadmap](ROADMAP.md#where-were-going)) |
| Web build (same UI in the browser via WASM): persistent library in OPFS/IndexedDB, Web Worker rendering, export downloads | ✅ · WebGPU, Safari/Firefox testing 🚧 |
✅ works today · 🚧 in progress · ⬜ not started
@@ -354,7 +370,7 @@ CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft
Without it LightCraft builds and runs the same, but Chinese and Japanese text have no glyphs. Fonts are never committed to this
repo; see [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md).
-**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Deutsch) or **Settings → General →
+**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français) or **Settings → General →
Language**; the choice applies immediately and persists. See [docs/localization.md](docs/localization.md).
**Logs:** the desktop app writes its log to standard error and to `logs/lightcraft.log` in its settings folder
@@ -444,7 +460,9 @@ Questions, ideas or a bug you'd like to talk through first? Bring them to [Disco
| arm64 (Snapdragon and other ARM PCs) | `lightcraft--windows-arm64.msi` | `lightcraft--windows-arm64-portable.zip` |
| x86 (32-bit) | `lightcraft--windows-x86.msi` | `lightcraft--windows-x86-portable.zip` |
-Installers and executables are code-signed.
+Installers and executables are code-signed. The installer asks where to install (`C:\Program Files\LightCraft` by
+default; upgrades stay in the folder you chose) and ends on a page confirming LightCraft was installed, with an
+option to start it. For unattended installs: `msiexec /i lightcraft--windows-x64.msi /qn INSTALLFOLDER="D:\Apps\LightCraft\"`.
### macOS
diff --git a/ROADMAP.md b/ROADMAP.md
index 32f6a89b..dad5a432 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -23,13 +23,13 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
| Dimension | Estimate | What's true today | Biggest gaps |
|---|---:|---|---|
| **Feature checklist** | 79.7% | P0 core and P1 nearly complete: import (Add / Copy / Move, templates, devices), library, grid/loupe/compare/survey, every Edit slider, curves, colour grading, masking tools, crop/Upright, heal/clone, presets/profiles, versions/history, sync, export, menus, shortcuts | P0: full-resolution zoom detail; P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim |
-| **RAW coverage** (formats people shoot) | ~55% | DNG (all kinds), CR2, CR3 lossless Bayer and version 0x100/0x200 C-RAW (M50/R100/R8 pixel-exact corpus), ARW, NEF (uncompressed + Huffman lossless/lossy), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans), uncompressed ORF, uncompressed Samsung SRW (NX5 to NX1100, EX1, WB2000), RW2 / RWL / Panasonic RAW (every raw format, checked on 178 files from 118 Panasonic and Leica bodies), PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini), Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Per-model verification is thin (~55 corpus files vs >1,000 models) |
-| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF, RW2, RAF, CR3, CR2 and PEF have guarded camera-JPEG colour estimates, with bundled ILCE-7M4, X-H2S and X-T4 profiles (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
+| **RAW coverage** (formats people shoot) | ~55% | DNG (all kinds), CR2, CR3 lossless Bayer and version 0x100/0x200 C-RAW (M50/R100/R8 pixel-exact corpus), ARW, NEF (uncompressed + Huffman lossless/lossy), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans), uncompressed ORF, uncompressed Samsung SRW (NX5 to NX1100, EX1, WB2000), RW2 / RWL / Panasonic RAW (every raw format, checked on 178 files from 118 Panasonic and Leica bodies), PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini), Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Nikon High Efficiency NEF (HE / HE★: Z 8, Z 9, Z 6III, Z f; JPEG XS-based, preview only, #193). Per-model verification is thin (~55 corpus files vs >1,000 models) |
+| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF, RW2, RAF, CR3, CR2 and PEF have guarded camera-JPEG colour estimates, with bundled ILCE-7CR, ILCE-7M4, X-H2S and X-T4 profiles (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
| **AI & computational** | ~15–20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics, or the photo's own segmentation mattes when it ships them (DNG semantic masks, e.g. iPhone ProRAW's sky matte); non-destructive Bayer RAW denoise with opt-in weights on pure-Rust CPU/GPU; **faces (MVP, [docs/faces.md](docs/faces.md))**: a pure-Rust detector (a 232 KB opt-in download), opt-in recognition models (one-click download or add a file; they run on the shared pure-Rust CPU interpreter; pinned SFace/AuraFace embeddings agree with an independent reference runtime on 40 public crops; one-thread medians 34–36 / 233–252 ms (AuraFace remains expensive)), a background scan that finds and embeds faces, name suggestions, a person's page with look-alike faces to confirm, an Unnamed faces section (look-alikes together, select a group and name it at once) | Real segmentation masks (subject, sky, people, objects, landscape, depth), denoise model policy / linear RGB / quality verification, super resolution, lens blur, generative remove, natural-language search; for faces: clusters with separators between groups, small faces in large photos (the detector looks at 640 px), thresholds calibrated on live faces, writing face regions back to XMP. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) |
| **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering |
-| **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic) | **Map view, Book, Print, Slideshow module, Web, publish services**: ~40 tracker rows ⬜ |
-| **HDR & video** | 0% | | HDR edit/display/export; video play/trim/edit/export |
-| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; English, Simplified/Traditional Chinese, Japanese, Brazilian Portuguese and German UI (see docs/localization.md); remaining technical errors; Traditional Chinese uses the Simplified Chinese font until craft-fonts has a TC face; accessibility partial; headless UI tests time out under machine load |
+| **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic), paginated contact-sheet PDF export (A4/Letter, layout/captions, current edits, 150 dpi sRGB) | **Map view, Book, full Print module, Slideshow module, Web, publish services**: contact sheets work, custom print packages and printer colour management remain missing |
+| **HDR & video** | ~25% | HDR editing (headroom limit, SDR rendition, Visualize HDR); gain map JPEG, PQ AVIF and float TIFF export | On-screen HDR display, HLG, HDR settings from crs XMP; video play/trim/edit/export |
+| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; English, Simplified/Traditional Chinese, Japanese, Brazilian Portuguese, Spanish, German, Russian and Ukrainian UI (see docs/localization.md); remaining technical errors; Traditional Chinese uses the Simplified Chinese font until craft-fonts has a TC face; accessibility partial; headless UI tests time out under machine load |
### By kind of user
@@ -48,8 +48,8 @@ Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/par
1. **Camera colour calibration of our own** (LR-PROF-CAMERACOLOR, P0): fit each camera to its own embedded JPEG, use
matrices the files carry themselves, then chart shots. Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2,
- Pentax PEF and Samsung SRW have the file-local fit (matrix + tone curve from their own JPEG; CR2 and PEF since issue #310, not yet
- checked on the corpus); then ORF, then validate fidelity.
+ Pentax PEF, Samsung SRW and Olympus ORF have the file-local fit (matrix + tone curve from their own JPEG; CR2 and PEF
+ since issue #310, not yet checked on the corpus); 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.
@@ -62,13 +62,18 @@ Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/par
segmentation masks and denoise. Bayer denoise inference is implemented in pure Rust with an opt-in GPL model; release policy and independently licensed weights remain open. Unblocks M12 and Enhance. Face detection and recognition run on our own checked Rust engine. Object / Describe masks now
run Meta's SAM 3 in pure Rust (optional, user-consented download under the non-OSI SAM License; CDN mirrors still
to be set up, docs/ai-masks.md); Subject / Sky / People, Raw Details and Super Resolution are still open.
-7. **Then:** HDR (Q), the Classic output modules (Print first, then Map view, Book, Slideshow), video (R), localisation
+7. **Then:** HDR (Q), the Classic output modules (expand contact-sheet PDF export into full Print, then Map view, Book, Slideshow), video (R), localisation
and accessibility.
Already closed in the week of 2026-10-03: compressed NEF (#10), Move import (#29), per-library smart-preview folder,
Local roots / cleanup, grid and catalog performance at 85k photos (#35, #37), failed-save reporting, GPU export
hardening (#78), copyright metadata (#51), GPX geotagging (#60), import tag help and folder templates (#31, #32).
+Library folders now have records of their own in the catalog (`Catalog.folder_records`, keyed by folder path; colour
+labels first). Photos still store full paths, so moving a whole disk or folder relinks each photo. If that becomes a
+problem (an "Update Folder Location" for a renamed or replaced drive, journal growth on large moves), the next step is
+the Classic model: photos point to a folder id (root + relative path), and the records keyed by path become those rows.
+
Image navigation now supports native pinch zoom and two-finger pan in Detail, Compare and Reference views.
The whole-image preview still has a configurable size cap (2,560 px default), so 100% on larger photos does not
yet guarantee original-pixel detail; visible-region rendering remains a gap (LR-VIEW-ZOOM).
@@ -89,13 +94,13 @@ yet guarantee original-pixel detail; visible-region rendering remains a gap (LR-
| M7 | Detail | sharpening + masking preview, luminance/colour NR, Denoise, Raw Details, Super Resolution | 6–10 | 🚧 (sharpening, luminance/colour NR ✅; AI Denoise 🟡 (Bayer, optional weights; policy/fidelity open); Raw Details, Super Resolution ⬜) |
| M8 | Heal / Remove | content-aware remove (PatchMatch), heal, clone, brush spots, visualize spots, red/pet eye | 6–10 | 🚧 (heal, clone, auto source, visualize spots, red/pet eye ✅; PatchMatch remove ⬜) |
| M9 | Presets, profiles, versions, sync | preset browser + amount, create/import presets, profile browser, versions, history, copy/paste/sync settings | 5–8 | ✅ |
-| M10 | Export & share | export dialog (JPEG/PNG/TIFF/DNG/AVIF/JXL/original), sizing, sharpening, metadata, watermark, naming, batch jobs, XMP sidecars, HDR export | 6–10 | 🚧 (all formats incl. DNG/original, sizing, presets, background jobs ✅; JXL encode, HDR export ⬜) |
+| M10 | Export & share | export dialog (JPEG/PNG/TIFF/DNG/AVIF/JXL/original), sizing, sharpening, metadata, watermark, naming, batch jobs, XMP sidecars, HDR export | 6–10 | 🚧 (all formats incl. DNG/original, sizing, presets, background jobs, HDR gain map JPEG / PQ AVIF ✅; JXL encode ⬜) |
| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (CR3 lossless CRX and C-RAW, RAF uncompressed and lossless/lossy compressed, RW2 (every raw format), PEF, ORF uncompressed ✅; **camera colour calibration** 🚧 (guarded ARW, NEF, RW2 and RAF preview fitting; measured database still missing), CR3, compressed ORF ⬜) |
| M12 | AI & smart features | subject/sky/background/people/object masks, semantic search, faces/People (permissively licensed models, pure-Rust inference) | 20–40 | ⬜ |
| M13 | Merge | HDR merge (deghost), panorama (projections, boundary warp, fill edges), HDR panorama | 10–15 | ✅ |
| M14 | Video | import/playback/trim via FilmCraft crates, global edits + presets on video, video export | 6–10 | ⬜ |
| M15 | Classic modules | Map, Book, Slideshow, Print, Web; smart collections, stacks, virtual copies, publish services, tethering | 25–40 | 🚧 (smart albums, stacks, virtual copies, compare/survey ✅; Map/Book/Slideshow/Print/Web ⬜) |
-| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; English/Chinese (Simplified, Traditional)/Japanese/Brazilian Portuguese/German localisation 🟡; accessibility and further locales ⬜) |
+| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; English/Chinese (Simplified, Traditional)/Japanese/Brazilian Portuguese/Spanish/German/Russian/Ukrainian localisation 🟡; accessibility and further locales ⬜) |
## Parity estimate (feature count updated 2026-10-08; effort estimate from 2026-10-02)
@@ -164,7 +169,8 @@ The milestone estimates in the table above were made before work started and are
published alongside GPL code); decoder source is never read. Still open: freedom-to-operate review for local
Laplacian filters, PatchMatch and HEVC (HEIC).
- **Look parity** with Adobe's default rendering is tuned by eye today; the planned fidelity suite (LR-BEHAV-RENDER-FIDELITY)
- turns it into measured comparisons against local-only Lightroom references.
+ turns it into measured comparisons against local-only Lightroom references. Retuning changes existing edits, so it
+ ships as a new process version (`docs/process-versions.md`): photos keep their look until they are updated.
## Raw format coverage and known gaps
@@ -175,20 +181,16 @@ CR3 lossless Bayer and version 0x100/0x200 C-RAW sensor decoding is verified pix
Decoded (CC0 corpus from raw.pixls.us, `cargo xtask corpus --download`, `crates/raw/tests/corpus.rs`): DNG (uncompressed,
LJ92, lossy JPEG / Smart Previews, Deflate, float, linear, DNG 1.7 JPEG XL tiles: lossless ones bit-exact on synthetic files,
-lossy ones unclipped in the declared linear primaries, checked on one real file), CR2, ARW (uncompressed, ARW2, LJ92; as-shot white balance and black level of pre-2017 bodies from the enciphered
-maker-note `Tag2010` and the encrypted `SR2SubIFD`, both recovered by black-box analysis, `crates/raw/src/vendor/arw.rs`), NEF/NRW uncompressed and Huffman-compressed (lossless, lossy type 1/2, 12/14-bit), RAF uncompressed and lossless/lossy compressed (Bayer and
+lossy ones unclipped in the declared linear primaries, checked on one real file), CR2, ARW (uncompressed, ARW2, LJ92; as-shot white balance and black level of pre-2017 bodies from the encrypted
+`SR2SubIFD`, decrypted with a keystream recovered by black-box analysis, `crates/raw/src/vendor/sr2.rs`, `crates/raw/src/vendor/arw.rs`), NEF/NRW uncompressed and Huffman-compressed (lossless, lossy type 1/2, 12/14-bit), RAF uncompressed and lossless/lossy compressed (Bayer and
X-Trans, 14/16-bit real-file verification), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed formats 4 and 6, the prefix-coded strips of format 8,
packed formats 2, 5 and 7, the 16-bit words of the 2005–2007 bodies; all recovered by black-box analysis of 178 CC0 files from
118 bodies, `crates/raw/src/vendor/rw2.rs`), PEF (uncompressed and Huffman), ORF uncompressed (16-bit and 12-bit packed). Supported containers yield their embedded JPEG preview when present (including CR3). The engine uses it for RAW variants it cannot decode yet; HEVC-only CR3 previews are not decoded.
Not decoded yet — preview only (no permissively licensed description; black-box analysis incomplete):
-- **Nikon "lossy after split" NEF** (non-zero split row in maker note `0x0096`, e.g. some D3400/D5000/D5200/D5500/D5600
- files): rows above the split decode with the regular lossy table; from the split row on a different code is used
- that our black-box analysis has not recovered yet (none of the four regular tables fits, also not with byte
- alignment or reset predictors at the split row). The other Nikon Huffman variants are decoded (`crates/raw/src/vendor/nefc.rs` documents the analysis).
- **Olympus compressed ORF**, **Canon CR3 unverified CRX variants** (M11.1), **Canon sRAW/mRAW**.
-**Camera colour matrices:** ARW, NEF, RW2, RAF, CR3, CR2, PEF and Samsung SRW files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
+**Camera colour matrices:** ARW, NEF, RW2, RAF, CR3, CR2, PEF, Samsung SRW and Olympus ORF files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. 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.
@@ -220,13 +222,16 @@ database is read-only; LightCraft owns subsequent edits. See `docs/lightroom-cat
16-bit words), black levels, in-camera aspect crops and mapped-out defects established on 178 CC0 files from 118
bodies; RW2 gets the file-local camera look (accepted on 140 of 174 files with a preview).
- 2026-10-08: independent Rust CR3 lossless and version 0x100/0x200 C-RAW decoding, six exact full-sensor regressions on M50/R100/R8; any CR3 decode error falls back to the embedded JPEG; guarded CR3 camera-JPEG colour fitting with independent framing validation (`docs/cr3.md`). Unverified CRX variants and broader model verification remain.
+- 2026-10-09: Sony ILCE-7CR camera colour: bundled profile from 125 photos. On 26 withheld photos mean ΔE vs the camera JPEG 4.07 → 3.29; all 131 photos accepted, where 8 fell back to the neutral matrix without it. Measured calibration remains open.
## Chinese and Japanese interfaces, and text watermarks
English, Simplified Chinese, Traditional Chinese (Taiwan) and Japanese interface languages are persisted in UI state, and the
language table (`crates/ui-egui/src/i18n.rs`) drives the menus, settings and fonts, so another
language is a table entry plus two catalogs (docs/localization.md). Core menus and panels are
-translated; untranslated text retains English. CJK glyphs (Chinese: Noto Sans CJK SC; Japanese UI:
+translated; untranslated text retains English. Ukrainian covers all existing catalog keys, command/control/rule labels
+and format messages, with count-neutral labels and numeric dates; technical errors from lower layers remain English.
+CJK glyphs (Chinese: Noto Sans CJK SC; Japanese UI:
BIZ UDPGothic; watermarks: BIZ UDMincho) come from storytold/craft-fonts, embedded by builds made
with the optional `CRAFT_FONTS_DIR` input (all releases), so no system fonts are needed. Text watermarks now accept
`vertical: true` in export JSON/presets and expose an orientation selector. Japanese
diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs
index 7c9d255e..592a534a 100644
--- a/apps/lightcraft-cli/src/main.rs
+++ b/apps/lightcraft-cli/src/main.rs
@@ -391,7 +391,7 @@ fn merge(args: &[String]) -> Result<(), String> {
}
i += 1;
}
- let mut s = Session::new().with_fs().with_default_denoise_models();
+ let mut s = Session::new().with_fs().with_default_denoise_models().with_system_clock();
let paths = expand_paths(&files);
let r = s.execute("library.import", &json!({"paths": paths})).map_err(|e| e.to_string())?;
let mut ids: Vec = r["imported"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
@@ -652,7 +652,7 @@ fn render(args: &[String]) -> Result<(), String> {
}
let input = input.ok_or("render: missing input file")?;
let output = output.ok_or("render: missing -o OUTPUT")?;
- let mut s = Session::new().with_fs().with_default_denoise_models();
+ let mut s = Session::new().with_fs().with_default_denoise_models().with_system_clock();
let abs = expand_paths(std::slice::from_ref(&input));
let r = s.execute("library.import", &json!({"paths": abs})).map_err(|e| e.to_string())?;
let id = r["imported"][0].as_u64().ok_or_else(|| format!("{input}: not a readable photo"))?;
@@ -732,12 +732,12 @@ fn snapshot(args: &[String]) -> Result<(), String> {
let t0 = Instant::now();
let mut session = match &library {
Some(dir) => {
- let mut s = Session::new().with_fs().with_default_denoise_models().with_default_face_models();
+ let mut s = Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models();
s.open_library(dir, false).map_err(|e| library_error(dir, e))?;
s
}
- None if files.is_empty() => Session::with_demo().with_fs().with_default_denoise_models().with_default_face_models(),
- None => Session::new().with_fs().with_default_denoise_models().with_default_face_models(),
+ None if files.is_empty() => Session::with_demo().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models(),
+ None => Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models(),
};
// Apply compute policy before imports or the first scripted query can discover an adapter.
session.execute("app.gpu", &json!({"enabled": false})).map_err(|e| e.to_string())?;
diff --git a/apps/lightcraft-cli/tests/cli.rs b/apps/lightcraft-cli/tests/cli.rs
index 2009e818..ecda1102 100644
--- a/apps/lightcraft-cli/tests/cli.rs
+++ b/apps/lightcraft-cli/tests/cli.rs
@@ -7,6 +7,46 @@ use serde_json::{Value, json};
const BIN: &str = env!("CARGO_BIN_EXE_lightcraft-cli");
+#[test]
+fn contact_sheet_exports_pdf_and_protects_originals() {
+ let input = tmp("contact-sheet-input.png");
+ gradient_png(&input);
+ let out = tmp("contact-sheet.pdf");
+ let (ok, lines, err) = run_cli(
+ &[
+ "--import",
+ input.to_str().unwrap(),
+ "export.contactSheet",
+ &format!("path={}", out.display()),
+ "paper=letter",
+ "landscape=true",
+ "columns=2",
+ "rows=3",
+ ],
+ None,
+ );
+ assert!(ok, "{err}: {lines:?}");
+ assert_eq!(lines[0]["result"]["pages"], 1);
+ assert_eq!(lines[0]["result"]["photos"], 1);
+ let bytes = std::fs::read(&out).unwrap();
+ assert!(bytes.starts_with(b"%PDF-1.4"));
+ assert!(String::from_utf8_lossy(&bytes).contains("/MediaBox [0 0 792.00 612.00]"));
+ let before = std::fs::read(&input).unwrap();
+ for target in [input.clone(), input.with_extension("xmp")] {
+ if target != input {
+ std::fs::write(&target, b"existing sidecar").unwrap();
+ }
+ let (ok, _, _) = run_cli(&["--import", input.to_str().unwrap(), "export.contactSheet", &format!("path={}", target.display())], None);
+ assert!(!ok, "overwrote protected file");
+ }
+ assert_eq!(std::fs::read(&input).unwrap(), before);
+ assert_eq!(std::fs::read(input.with_extension("xmp")).unwrap(), b"existing sidecar");
+ let prior = std::fs::read(&out).unwrap();
+ let (ok, _, _) = run_cli(&["--import", input.to_str().unwrap(), "export.contactSheet", &format!("path={}", out.display()), "columns=0"], None);
+ assert!(!ok);
+ assert_eq!(std::fs::read(&out).unwrap(), prior, "failed export changed prior output");
+}
+
fn tmp(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("lightcraft-cli-test-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
@@ -236,11 +276,8 @@ fn snapshot_script_failure_exits_non_zero() {
#[test]
fn snapshot_ui_zoom_keeps_requested_pixel_dimensions() {
let script = tmp("snap-ui-zoom.jsonl");
- std::fs::write(
- &script,
- format!("{}\n{}\n", json!({"method": "ui.key", "params": {"key": "Plus", "cmd": true}}), json!({"method": "ui.inspect"})),
- )
- .unwrap();
+ std::fs::write(&script, format!("{}\n{}\n", json!({"method": "ui.zoomFactor", "params": {"factor": 1.1}}), json!({"method": "ui.inspect"})))
+ .unwrap();
for (size, scale, expected) in [("400x240", "1", (400, 240)), ("345x200", "0.9", (311, 180))] {
let out = tmp(&format!("snap-ui-zoom-{scale}.png"));
let o = Command::new(BIN)
diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs
index 9960fdd5..592d05cd 100644
--- a/apps/lightcraft-web/src/web.rs
+++ b/apps/lightcraft-web/src/web.rs
@@ -169,6 +169,7 @@ fn services(originals: Originals, backend: Option, files: Files, frozen
let restore_backend = backend.clone();
Services {
picker: None,
+ pick_display_profile: None,
backup_library: Some(Box::new(move |session: &mut Session| {
let Some(b) = backup_backend.clone() else { return Err("nothing is stored in this browser session (?store=memory)".into()) };
// the photos' own names for the originals in the zip
@@ -213,6 +214,8 @@ fn services(originals: Originals, backend: Option, files: Files, frozen
// Face models need a folder to live in; the web has none.
pick_model_file: None,
save_preset_file: None,
+ pick_keyword_list: None,
+ save_keyword_list: None,
pick_curve_preset_files: None,
save_curve_preset_file: None,
write: Some(Box::new(download)),
diff --git a/apps/lightcraft-web/src/wire.rs b/apps/lightcraft-web/src/wire.rs
index b08ae150..4796740e 100644
--- a/apps/lightcraft-web/src/wire.rs
+++ b/apps/lightcraft-web/src/wire.rs
@@ -113,6 +113,7 @@ impl WireJob {
depth: lightcraft_engine::pipeline::OutputDepth::U8,
proof: None,
window: self.window.map(|[x, y, w, h]| lightcraft_engine::pipeline::PixelWindow { x, y, w, h }),
+ display: None,
}
}
diff --git a/apps/lightcraft-web/src/workers.rs b/apps/lightcraft-web/src/workers.rs
index 52a32328..f321bdb9 100644
--- a/apps/lightcraft-web/src/workers.rs
+++ b/apps/lightcraft-web/src/workers.rs
@@ -293,6 +293,7 @@ impl Workers {
rendered: Err(msg),
loaded: None,
quick: None,
+ display: None,
};
g.done.push((b.slot, r, 0.0));
}
@@ -379,6 +380,7 @@ impl Workers {
rendered,
loaded: None,
quick: None,
+ display: None,
},
ms,
));
@@ -431,6 +433,7 @@ impl RenderOffload for Workers {
rendered: Ok(Rendered { image, histogram, deep: None }),
loaded: None,
quick: None,
+ display: None,
};
g.done.push((slot, r, 0.0));
return None;
diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs
index 832b158b..6aaa1762 100644
--- a/apps/lightcraft/src/main.rs
+++ b/apps/lightcraft/src/main.rs
@@ -133,6 +133,23 @@ fn gpu_crash_notice(what: &str) -> String {
)
}
+/// Where the system keeps monitor profiles, for the Choose Profile dialog to start in: the user's
+/// own profiles first (Linux: colord / DisplayCAL; macOS: ColorSync), then the system's.
+fn display_profile_dir() -> Option {
+ let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).filter(|h| !h.is_empty()).map(std::path::PathBuf::from);
+ let user = |rel: &str| home.as_ref().map(|h| h.join(rel));
+ let candidates: Vec> = if cfg!(target_os = "macos") {
+ vec![user("Library/ColorSync/Profiles"), Some("/Library/ColorSync/Profiles".into())]
+ } else if cfg!(windows) {
+ let root = std::env::var_os("SystemRoot").map(std::path::PathBuf::from).unwrap_or_else(|| "C:\\Windows".into());
+ vec![Some(root.join("System32").join("spool").join("drivers").join("color"))]
+ } else {
+ let data = std::env::var_os("XDG_DATA_HOME").filter(|d| !d.is_empty()).map(std::path::PathBuf::from).or_else(|| user(".local/share"));
+ vec![data.map(|d| d.join("icc")), user(".color/icc"), Some("/usr/share/color/icc".into())]
+ };
+ candidates.into_iter().flatten().find(|d| d.is_dir())
+}
+
fn config_dir() -> Option {
lightcraft_engine::config::config_dir()
}
@@ -464,6 +481,15 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
.map(|p| vec![p.to_string_lossy().to_string()])
.unwrap_or_default()
})),
+ pick_display_profile: Some(Box::new(|| {
+ let mut d = rfd::FileDialog::new()
+ .set_title(lightcraft_ui_egui::i18n::tr("Choose Monitor Profile"))
+ .add_filter_nocase(lightcraft_ui_egui::i18n::tr("ICC Profiles"), &["icc", "icm"]);
+ if let Some(dir) = display_profile_dir() {
+ d = d.set_directory(dir);
+ }
+ d.pick_file().map(|p| vec![p.to_string_lossy().to_string()]).unwrap_or_default()
+ })),
save_preset_file: Some(Box::new(|name: &str| {
rfd::FileDialog::new()
.set_title(lightcraft_ui_egui::i18n::tr("Export Presets"))
@@ -472,6 +498,22 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
.save_file()
.map(|p| p.to_string_lossy().to_string())
})),
+ pick_keyword_list: Some(Box::new(|| {
+ rfd::FileDialog::new()
+ .set_title(lightcraft_ui_egui::i18n::tr("Import Keywords"))
+ .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Keyword Lists"), &["txt", "utf8"])
+ .pick_file()
+ .map(|p| vec![p.to_string_lossy().to_string()])
+ .unwrap_or_default()
+ })),
+ save_keyword_list: Some(Box::new(|name: &str| {
+ rfd::FileDialog::new()
+ .set_title(lightcraft_ui_egui::i18n::tr("Export Keywords"))
+ .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Keyword Lists"), &["txt"])
+ .set_file_name(name)
+ .save_file()
+ .map(|p| p.to_string_lossy().to_string())
+ })),
pick_curve_preset_files: Some(Box::new(|| {
rfd::FileDialog::new()
.set_title(lightcraft_ui_egui::i18n::tr("Import Point Curve Presets"))
diff --git a/assets/ATTRIBUTION.md b/assets/ATTRIBUTION.md
index 52074466..0fc883bd 100644
--- a/assets/ATTRIBUTION.md
+++ b/assets/ATTRIBUTION.md
@@ -12,7 +12,9 @@ covers a whole directory), or if a licence file referenced in the Licence column
| `assets/app-icon/` (`lightcraft.svg` and the PNG/ICNS/ICO/SVG files derived from it) | LightCraft app icon (lynx) | LightCraft project owner (drawn in ArtCraft) | original work (vectorised from the owner's drawing; see `assets/app-icon/README.md`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | 2026-10-02 | vectorised to SVG; rendered to PNG/ICNS/ICO by `packaging/icons.sh` |
| `assets/camera-profiles/*.json` | Camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | ILCE-7M4: Harald Hoyer | original work: fitted by `lightcraft-cli calibrate` to the contributor's own raw photos and their in-camera JPEGs (597 ILCE-7M4 photos, 2026); aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-07 | none |
| `packaging/macos/dmg/` (`background.svg`, the `background.tiff` rendered from it by `generate.py`, and the `dmg-layout.DS_Store` layout file) | LightCraft macOS DMG window background | @XusBadia | original work for this repository, derived from the LightCraft app icon (`assets/app-icon/lightcraft.svg`, referenced via ``, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0 | 2026-10-08 | none (original); used for the Finder window of the macOS DMG, `packaging/macos/dmg/generate.py` renders it to `background.tiff` |
+| `assets/camera-profiles/ILCE-7CR.json` | Sony ILCE-7CR camera colour profile (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by @AlexChambers | original work: fitted by `lightcraft-cli calibrate` to 125 usable ILCE-7CR ARWs (of 131, September 2025 to September 2026, Standard creative style) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-09 | none |
| `assets/camera-profiles/X-H2S.json`, `assets/camera-profiles/X-T4.json` | Fujifilm camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by Sebastian Pfluegelmeier | original work: fitted by `lightcraft-cli calibrate` to 201 X-H2S and 545 X-T4 RAFs and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-08 | none |
+| `assets/camera-tone/sony-dro-off.json` | Sony global tone curve with the Dynamic Range Optimizer off (32 knots), built into LightCraft for ARWs shot with DRO on | LightCraft contributors; source photographs by raw.pixls.us contributors | original work: tone curve quantile-matched over the pooled pixel pairs of 12 CC0 raw.pixls.us ARWs (DRO off, Standard creative style; ILCE-3500, -5000, -6600, -7CR, -7M3 ×3, -7S, -9M2, ILCA-77M2, SLT-A57, NEX-F3) and their embedded camera JPEGs; the file lists each source URL and SHA-256 and how the curve was derived (see also `docs/camera-preview-colour.md`); aggregate tone statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 (sources CC0) | 2026-10-09 | none |
| `crates/scenes/` (generated images) | Procedural demo photographs | LightCraft contributors | original work (generated at runtime, no source imagery) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
| `crates/ui-egui/src/icons.rs` | UI icons drawn as vector paths in code | LightCraft contributors | original work | MIT OR Apache-2.0 | 2026-09-30 | n/a |
| `docs/images/*-tetons.jpg`, `docs/images/grid-pd.jpg` (screenshots containing it) | "The Tetons and the Snake River" | Ansel Adams (1942), U.S. National Archives | https://commons.wikimedia.org/wiki/File:Adams_The_Tetons_and_the_Snake_River.jpg | Public domain (U.S. federal government work) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
@@ -22,6 +24,7 @@ covers a whole directory), or if a licence file referenced in the Licence column
| `docs/images/*.jpg` (UI) | LightCraft application screenshots | LightCraft contributors | original work (captured with `docs/showcase/`) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
| `docs/brand/` (artcraft-logo and artcraft-mark, SVG and PNG) | ArtCraft wordmark and mark | ArtCraft Team | original work (https://getartcraft.com/) | ArtCraft trademark, see `docs/brand/LICENSE-brand.txt` (not open source) | 2026-10-01 | none |
| craft-fonts: optional build input, not files in this repository | BIZ UDPGothic (Regular, Bold), BIZ UDMincho Regular, Shippori Mincho Regular, Noto Sans CJK SC Regular | Morisawa Inc. / The BIZ UDGothic and BIZ UDMincho Project Authors; The Shippori Mincho Project Authors; Google and Adobe (Noto Sans CJK, maintainer-approved exception; see AGENTS.md) (per craft-fonts' attribution) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | SIL Open Font License 1.1 (each font's OFL.txt in craft-fonts; release packages ship it as OFL-.txt) | 2026-10-06 | none; native builds made with `CRAFT_FONTS_DIR` embed the manifest fonts (all official releases); the web build embeds BIZ UDPGothic Regular and ships Noto Sans CJK SC Regular as a separate font file |
+| `crates/ui-egui/locales/uk.json`, `crates/ui-egui/locales/uk-formats.json` | Ukrainian interface catalogs | @dmatviichuk with Codex; PhotoCraft, FilmCraft and VectorCraft translation contributors for reused labels | original translations from English source; 337 initial English-key matches reviewed from [PhotoCraft 69c41f3](https://github.com/storytold/photocraft/blob/69c41f3523e147eb215da8c7b4c29b18b35d1c63/crates/ui-egui/src/i18n/uk.tsv), [FilmCraft 4854c27](https://github.com/storytold/filmcraft/blob/4854c277536fe8118257362304b6a7d82d8e704b/crates/ui-egui/src/i18n/uk.tsv), [VectorCraft 4b44265](https://github.com/storytold/vectorcraft/blob/4b44265965fdd6ffedfd6182a892c1a6c360d344/crates/ui-egui/src/i18n/uk.tsv) | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-09 | adapted to LightCraft photo-development context, completed catalogs, reviewed spelling and placeholders |
Notes
- Fonts authored or published by Adobe (Source Sans/Serif/Code, Source Han, …) are not used, even though some are
diff --git a/assets/camera-profiles/ILCE-7CR.json b/assets/camera-profiles/ILCE-7CR.json
new file mode 100644
index 00000000..3534f05c
--- /dev/null
+++ b/assets/camera-profiles/ILCE-7CR.json
@@ -0,0 +1,1819 @@
+{
+ "version": 1,
+ "model": "ILCE-7CR",
+ "files": 125,
+ "samples": 448230,
+ "matrix": [
+ [1.1094755401970307, -0.1814643173262276, 0.07642328704815071],
+ [-0.0353042898479492, 1.1462523170955077, -0.11812642489032932],
+ [-0.08133079077168937, -0.8682697273084319, 2.0120289809143364]
+ ],
+ "hue_sat": {
+ "hue_divisions": 72,
+ "sat_divisions": 5,
+ "val_divisions": 5,
+ "data": [
+ [0.0, 1.0, 1.0],
+ [1.6383686, 1.0720526, 1.0],
+ [-3.1825573, 1.0282102, 1.0],
+ [-3.0505095, 1.0160933, 1.0],
+ [-0.6544773, 1.0011821, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.7573091, 1.0635676, 1.0],
+ [-3.6906753, 1.0049208, 1.0],
+ [-3.9162223, 0.98691005, 1.0],
+ [-1.7661046, 0.9845228, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.3244245, 1.0702444, 1.0],
+ [-3.7783418, 1.0267969, 1.0],
+ [-4.208394, 0.98504055, 1.0],
+ [-3.8247044, 0.95117354, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.7408551, 1.0626208, 1.0],
+ [-4.5483084, 1.0503119, 1.0],
+ [-5.022255, 1.0133994, 1.0],
+ [-4.9868593, 0.9531773, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.1140733, 1.0510494, 1.0],
+ [-4.3416905, 1.0382092, 1.0],
+ [-5.6782136, 1.0137324, 1.0],
+ [-6.408746, 0.95611, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.8071455, 1.0453869, 1.0],
+ [-4.1650367, 1.0307838, 1.0],
+ [-7.1355047, 1.0019747, 1.0],
+ [-8.950974, 0.94559556, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.9337237, 1.0438595, 1.0],
+ [-4.7190723, 1.0019213, 1.0],
+ [-8.449162, 0.98546976, 1.0],
+ [-9.80414, 0.9475938, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.8035413, 1.0596792, 1.0],
+ [-4.483865, 0.99361634, 1.0],
+ [-9.145701, 0.95965594, 1.0],
+ [-12.007559, 0.9046417, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.1054618, 1.0610611, 1.0],
+ [-3.750633, 1.0037059, 1.0],
+ [-8.3648205, 0.9508676, 1.0],
+ [-12.104278, 0.89305633, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.3433983, 1.0385724, 1.0],
+ [-1.8320823, 0.96856654, 1.0],
+ [-6.4716563, 0.89340544, 1.0],
+ [-13.182256, 0.79202217, 1.0],
+ [0.0, 1.0, 1.0],
+ [4.1155825, 1.0546852, 1.0],
+ [0.2504126, 0.95593035, 1.0],
+ [-4.421105, 0.86601037, 1.0],
+ [-11.81684, 0.76859766, 1.0],
+ [0.0, 1.0, 1.0],
+ [5.193353, 1.100501, 1.0],
+ [2.245616, 0.9877438, 1.0],
+ [-1.3484466, 0.89190966, 1.0],
+ [-5.656248, 0.79824746, 1.0],
+ [0.0, 1.0, 1.0],
+ [5.539407, 1.1472836, 1.0],
+ [3.5321336, 1.0246028, 1.0],
+ [1.6943812, 0.92413193, 1.0],
+ [0.06802155, 0.8097732, 1.0],
+ [0.0, 1.0, 1.0],
+ [5.217841, 1.1450701, 1.0],
+ [4.3687263, 1.0359106, 1.0],
+ [3.8731997, 0.92895746, 1.0],
+ [3.6518888, 0.81207585, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.9854875, 1.1393564, 1.0],
+ [4.353656, 1.0462686, 1.0],
+ [4.8320484, 0.9382486, 1.0],
+ [5.2603836, 0.83231425, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.4905012, 1.1162536, 1.0],
+ [4.399138, 1.0209066, 1.0],
+ [5.125341, 0.93585205, 1.0],
+ [5.7167406, 0.8500491, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.456529, 1.0933266, 1.0],
+ [4.4567842, 1.0046545, 1.0],
+ [5.3395023, 0.9265063, 1.0],
+ [6.239501, 0.8413839, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.3244715, 1.0775917, 1.0],
+ [4.413473, 1.0032461, 1.0],
+ [5.2259183, 0.93235445, 1.0],
+ [5.996756, 0.8481907, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.7977114, 1.0930682, 1.0],
+ [3.9809735, 1.0265673, 1.0],
+ [4.826016, 0.964025, 1.0],
+ [5.9678993, 0.8728676, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.3974957, 1.1096336, 1.0],
+ [3.507613, 1.0540297, 1.0],
+ [4.679865, 0.94909227, 1.0],
+ [6.176524, 0.80889976, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.6225548, 1.1412165, 1.0],
+ [3.2221508, 1.0394865, 1.0],
+ [4.731801, 0.89790475, 1.0],
+ [5.0452776, 0.8134008, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.92599183, 1.1604693, 1.0],
+ [3.414574, 1.0124736, 1.0],
+ [3.7358258, 0.9165049, 1.0],
+ [2.2723522, 0.91374624, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.45341417, 1.168921, 1.0],
+ [1.5084614, 1.0431032, 1.0],
+ [0.8854018, 0.9520788, 1.0],
+ [-1.05283, 0.94668686, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.3174335, 1.1757046, 1.0],
+ [0.5896504, 1.061128, 1.0],
+ [-0.31401426, 0.9736818, 1.0],
+ [-1.8383517, 0.95333457, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.1522639, 1.1637881, 1.0],
+ [-1.4493614, 1.0722672, 1.0],
+ [-1.4632736, 1.0168622, 1.0],
+ [-0.29180732, 0.99931026, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.7037742, 1.1205176, 1.0],
+ [-2.8786352, 1.0513667, 1.0],
+ [-1.286718, 1.0099841, 1.0],
+ [-0.06583492, 0.99989015, 1.0],
+ [0.0, 1.0, 1.0],
+ [-4.2293205, 1.1057495, 1.0],
+ [-5.281706, 1.0493338, 1.0],
+ [-2.4175398, 1.0138595, 1.0],
+ [-0.047981504, 0.99992627, 1.0],
+ [0.0, 1.0, 1.0],
+ [-5.8813257, 1.1112839, 1.0],
+ [-7.088055, 1.0551285, 1.0],
+ [-3.9832814, 1.0086114, 1.0],
+ [-0.17585999, 0.9988311, 1.0],
+ [0.0, 1.0, 1.0],
+ [-6.761259, 1.1026558, 1.0],
+ [-8.000364, 1.0663072, 1.0],
+ [-4.1537356, 1.0163689, 1.0],
+ [-0.13010694, 0.99951726, 1.0],
+ [0.0, 1.0, 1.0],
+ [-8.351095, 1.0976008, 1.0],
+ [-8.466059, 1.0682116, 1.0],
+ [-3.5471215, 1.0211279, 1.0],
+ [-0.0509097, 1.0001352, 1.0],
+ [0.0, 1.0, 1.0],
+ [-9.057853, 1.1055348, 1.0],
+ [-8.974651, 1.0689155, 1.0],
+ [-3.2166526, 1.0227399, 1.0],
+ [-0.032699484, 1.0002922, 1.0],
+ [0.0, 1.0, 1.0],
+ [-9.427814, 1.1105726, 1.0],
+ [-9.507676, 1.0691713, 1.0],
+ [-3.295734, 1.0189606, 1.0],
+ [-0.034528155, 1.0002044, 1.0],
+ [0.0, 1.0, 1.0],
+ [-9.442637, 1.1099377, 1.0],
+ [-10.499484, 1.0757492, 1.0],
+ [-4.3815494, 1.0181382, 1.0],
+ [-0.07561345, 1.0000867, 1.0],
+ [0.0, 1.0, 1.0],
+ [-9.983269, 1.114439, 1.0],
+ [-10.951814, 1.0815339, 1.0],
+ [-4.2156606, 1.0171853, 1.0],
+ [-0.08563397, 0.99997413, 1.0],
+ [0.0, 1.0, 1.0],
+ [-10.670809, 1.1166714, 1.0],
+ [-11.719119, 1.0770913, 1.0],
+ [-4.6209064, 1.0083113, 1.0],
+ [-0.12722205, 0.9996538, 1.0],
+ [0.0, 1.0, 1.0],
+ [-12.437358, 1.1277313, 1.0],
+ [-12.732603, 1.0851367, 1.0],
+ [-4.236061, 1.0089667, 1.0],
+ [-0.113364056, 0.9994685, 1.0],
+ [0.0, 1.0, 1.0],
+ [-12.538394, 1.1138527, 1.0],
+ [-11.907458, 1.0645828, 1.0],
+ [-4.889425, 0.9901681, 1.0],
+ [-0.30379632, 0.99794984, 1.0],
+ [0.0, 1.0, 1.0],
+ [-12.718707, 1.0802225, 1.0],
+ [-12.242265, 1.0252401, 1.0],
+ [-6.184896, 0.96578616, 1.0],
+ [-0.6155264, 0.99502194, 1.0],
+ [0.0, 1.0, 1.0],
+ [-14.020359, 1.0553012, 1.0],
+ [-13.098407, 1.0011256, 1.0],
+ [-7.327469, 0.9492975, 1.0],
+ [-0.9094673, 0.9916255, 1.0],
+ [0.0, 1.0, 1.0],
+ [-15.101152, 1.0212511, 1.0],
+ [-12.824179, 0.96607465, 1.0],
+ [-10.019363, 0.9155735, 1.0],
+ [-1.7772533, 0.9820373, 1.0],
+ [0.0, 1.0, 1.0],
+ [-14.286782, 0.9806954, 1.0],
+ [-9.569648, 0.94380945, 1.0],
+ [-9.857059, 0.9003953, 1.0],
+ [-2.1718888, 0.9759639, 1.0],
+ [0.0, 1.0, 1.0],
+ [-13.73097, 0.94591624, 1.0],
+ [-11.556911, 0.921234, 1.0],
+ [-12.93851, 0.8641401, 1.0],
+ [-3.652711, 0.9552426, 1.0],
+ [0.0, 1.0, 1.0],
+ [-16.801582, 0.8864382, 1.0],
+ [-11.193753, 0.9149052, 1.0],
+ [-9.738617, 0.8872918, 1.0],
+ [-2.5534081, 0.9664013, 1.0],
+ [0.0, 1.0, 1.0],
+ [-17.101755, 0.84524596, 1.0],
+ [-9.365246, 0.91499794, 1.0],
+ [-4.452413, 0.9442718, 1.0],
+ [-0.7637209, 0.989576, 1.0],
+ [0.0, 1.0, 1.0],
+ [-18.14948, 0.8175313, 1.0],
+ [-11.684059, 0.8936882, 1.0],
+ [-3.2155685, 0.9607375, 1.0],
+ [-0.26556945, 0.9955721, 1.0],
+ [0.0, 1.0, 1.0],
+ [-16.426699, 0.8100732, 1.0],
+ [-9.782718, 0.9018959, 1.0],
+ [-4.339177, 0.96653545, 1.0],
+ [-0.41509473, 0.99594533, 1.0],
+ [0.0, 1.0, 1.0],
+ [-10.315517, 0.84992623, 1.0],
+ [-5.747398, 0.9413663, 1.0],
+ [-3.1746607, 0.982243, 1.0],
+ [-0.5927395, 0.994371, 1.0],
+ [0.0, 1.0, 1.0],
+ [-6.9678645, 0.8809627, 1.0],
+ [-3.7169914, 0.9728603, 1.0],
+ [-0.7432635, 1.0332607, 1.0],
+ [0.729794, 1.0225081, 1.0],
+ [0.0, 1.0, 1.0],
+ [-5.2035117, 0.84641093, 1.0],
+ [-1.5942934, 0.9789916, 1.0],
+ [1.2482002, 1.0807693, 1.0],
+ [1.347859, 1.064622, 1.0],
+ [0.0, 1.0, 1.0],
+ [-3.1986942, 0.7543623, 1.0],
+ [0.8410356, 0.938187, 1.0],
+ [2.4551218, 1.095957, 1.0],
+ [2.1389015, 1.0985862, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.88384134, 0.7095005, 1.0],
+ [1.9973824, 0.93200374, 1.0],
+ [2.549452, 1.0998962, 1.0],
+ [2.047063, 1.0879719, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.7609332, 0.6956258, 1.0],
+ [2.31692, 0.9102155, 1.0],
+ [2.647937, 1.0628512, 1.0],
+ [3.2615342, 1.036636, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.2741787, 0.69057876, 1.0],
+ [3.5142605, 0.88028973, 1.0],
+ [4.4796543, 1.0268018, 1.0],
+ [4.20299, 1.024853, 1.0],
+ [0.0, 1.0, 1.0],
+ [5.380194, 0.69558483, 1.0],
+ [5.080697, 0.882952, 1.0],
+ [6.105996, 1.0175306, 1.0],
+ [5.514054, 1.0149113, 1.0],
+ [0.0, 1.0, 1.0],
+ [9.913718, 0.69718885, 1.0],
+ [6.5240126, 0.8923008, 1.0],
+ [8.154414, 1.0078559, 1.0],
+ [6.6946135, 1.0048004, 1.0],
+ [0.0, 1.0, 1.0],
+ [13.040619, 0.6963414, 1.0],
+ [8.325149, 0.912086, 1.0],
+ [9.543625, 1.0013618, 1.0],
+ [6.4392266, 0.99676377, 1.0],
+ [0.0, 1.0, 1.0],
+ [14.4534025, 0.727793, 1.0],
+ [8.82926, 0.949796, 1.0],
+ [6.9925814, 1.0022112, 1.0],
+ [2.9664295, 0.99772435, 1.0],
+ [0.0, 1.0, 1.0],
+ [15.132328, 0.78525466, 1.0],
+ [10.728416, 0.9771031, 1.0],
+ [7.899672, 0.9900357, 1.0],
+ [2.4584854, 0.9965681, 1.0],
+ [0.0, 1.0, 1.0],
+ [16.370947, 0.8516936, 1.0],
+ [14.804218, 1.0220509, 1.0],
+ [7.60627, 1.0105268, 1.0],
+ [0.47612935, 0.9996986, 1.0],
+ [0.0, 1.0, 1.0],
+ [15.766772, 0.93907374, 1.0],
+ [16.010454, 1.051158, 1.0],
+ [7.589318, 1.0160706, 1.0],
+ [0.24546027, 1.000115, 1.0],
+ [0.0, 1.0, 1.0],
+ [14.457747, 0.9682619, 1.0],
+ [13.106996, 1.0823271, 1.0],
+ [5.0127563, 1.0306457, 1.0],
+ [0.11494827, 1.0005567, 1.0],
+ [0.0, 1.0, 1.0],
+ [13.896352, 0.95646006, 1.0],
+ [10.509385, 1.0747057, 1.0],
+ [4.687772, 1.0336343, 1.0],
+ [0.18767904, 1.0010031, 1.0],
+ [0.0, 1.0, 1.0],
+ [12.871006, 0.94392693, 1.0],
+ [8.716992, 1.0580047, 1.0],
+ [4.032118, 1.0331796, 1.0],
+ [0.19235706, 1.0013452, 1.0],
+ [0.0, 1.0, 1.0],
+ [12.761354, 0.94344586, 1.0],
+ [4.8192053, 1.0303724, 1.0],
+ [-0.55819005, 1.0258765, 1.0],
+ [-0.18075792, 1.0020537, 1.0],
+ [0.0, 1.0, 1.0],
+ [11.323022, 0.95529723, 1.0],
+ [3.8540874, 1.0221503, 1.0],
+ [-1.5989528, 1.0256935, 1.0],
+ [-0.31334943, 1.0026582, 1.0],
+ [0.0, 1.0, 1.0],
+ [9.946704, 0.988154, 1.0],
+ [2.8923109, 1.0258386, 1.0],
+ [-1.5042264, 1.0218962, 1.0],
+ [-0.26236275, 1.0021061, 1.0],
+ [0.0, 1.0, 1.0],
+ [9.115504, 0.9980661, 1.0],
+ [1.3947439, 1.0351801, 1.0],
+ [-1.7974052, 1.0161659, 1.0],
+ [-0.24226402, 1.0010152, 1.0],
+ [0.0, 1.0, 1.0],
+ [7.536506, 1.0020467, 1.0],
+ [0.87553304, 1.0329614, 1.0],
+ [0.18591607, 1.0122479, 1.0],
+ [0.054326985, 1.0008035, 1.0],
+ [0.0, 1.0, 1.0],
+ [6.8057537, 1.0455284, 1.0],
+ [1.8502842, 1.0506709, 1.0],
+ [0.7893407, 1.0182511, 1.0],
+ [0.09190431, 1.0011369, 1.0],
+ [0.0, 1.0, 1.0],
+ [6.130396, 1.0462483, 1.0],
+ [0.7220753, 1.0326569, 1.0],
+ [-0.40912512, 1.0052713, 1.0],
+ [0.03908602, 1.0007522, 1.0],
+ [0.0, 1.0, 1.0],
+ [6.2550654, 1.05479, 1.0],
+ [1.3151088, 1.0173345, 1.0],
+ [0.43053803, 1.0046135, 1.0],
+ [0.23496991, 1.0016526, 1.0],
+ [0.0, 1.0, 1.0],
+ [4.200835, 1.041949, 1.0],
+ [-0.12719084, 0.97952026, 1.0],
+ [0.40004775, 0.9858662, 1.0],
+ [0.089090385, 1.0008289, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.1867458, 1.063474, 1.0],
+ [-3.518081, 1.0572506, 1.0],
+ [-3.6311622, 1.0408844, 1.0],
+ [-1.250096, 1.0127242, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.11487997, 1.055631, 1.0],
+ [-3.8922422, 1.0360129, 1.0],
+ [-4.149058, 0.99983233, 1.0],
+ [-2.9244108, 0.9689904, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.1612215, 1.0692844, 1.0],
+ [-3.87592, 1.0401592, 1.0],
+ [-4.3422823, 0.97830665, 1.0],
+ [-3.9827328, 0.9330193, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.6057762, 1.0657002, 1.0],
+ [-4.05366, 1.0526804, 1.0],
+ [-4.907047, 0.98893243, 1.0],
+ [-4.8015065, 0.93376106, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.9405156, 1.0538071, 1.0],
+ [-3.854631, 1.0372756, 1.0],
+ [-5.677292, 1.008557, 1.0],
+ [-6.4605684, 0.95010316, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.84950966, 1.0556635, 1.0],
+ [-3.8397493, 1.0464863, 1.0],
+ [-6.724636, 0.9974292, 1.0],
+ [-9.358037, 0.9205249, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.36474, 1.0466326, 1.0],
+ [-4.358288, 1.0096835, 1.0],
+ [-8.078475, 0.96894723, 1.0],
+ [-10.280892, 0.91327274, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.1897643, 1.0460767, 1.0],
+ [-4.8276143, 0.9861743, 1.0],
+ [-8.967868, 0.9454565, 1.0],
+ [-11.58099, 0.89431435, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.8428742, 1.0415783, 1.0],
+ [-4.2447996, 0.9901131, 1.0],
+ [-8.640936, 0.9449217, 1.0],
+ [-11.831331, 0.8849682, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.8116823, 1.0284086, 1.0],
+ [-2.5288608, 0.97493637, 1.0],
+ [-6.911547, 0.9111893, 1.0],
+ [-13.192846, 0.79674995, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.2551363, 1.0582069, 1.0],
+ [-0.61379474, 0.9854093, 1.0],
+ [-5.207653, 0.885589, 1.0],
+ [-12.072603, 0.7643964, 1.0],
+ [0.0, 1.0, 1.0],
+ [4.0988145, 1.1001511, 1.0],
+ [1.3202463, 1.014535, 1.0],
+ [-2.0446327, 0.91062075, 1.0],
+ [-6.382716, 0.7884341, 1.0],
+ [0.0, 1.0, 1.0],
+ [4.772627, 1.1340997, 1.0],
+ [2.6516492, 1.0298927, 1.0],
+ [1.0751725, 0.93674093, 1.0],
+ [-0.6240697, 0.80116963, 1.0],
+ [0.0, 1.0, 1.0],
+ [4.6609416, 1.1409346, 1.0],
+ [3.6919625, 1.0409919, 1.0],
+ [3.1592674, 0.94452727, 1.0],
+ [2.8074346, 0.81181794, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.88111, 1.1356293, 1.0],
+ [3.934658, 1.0424132, 1.0],
+ [4.262655, 0.94446814, 1.0],
+ [4.659253, 0.8268726, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.2726362, 1.1170003, 1.0],
+ [3.9152527, 1.0194097, 1.0],
+ [4.7911615, 0.9256793, 1.0],
+ [5.6844645, 0.8277047, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.968466, 1.084938, 1.0],
+ [3.864402, 1.0008085, 1.0],
+ [4.9878488, 0.9206543, 1.0],
+ [6.272722, 0.8304063, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.805873, 1.0721347, 1.0],
+ [3.7892249, 1.0025132, 1.0],
+ [4.7126594, 0.937998, 1.0],
+ [5.7331266, 0.8633941, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.439447, 1.0980592, 1.0],
+ [3.5134554, 1.0268866, 1.0],
+ [4.373185, 0.973018, 1.0],
+ [5.2375226, 0.9141984, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.122798, 1.1300637, 1.0],
+ [3.0972943, 1.0678729, 1.0],
+ [4.0227356, 0.99388194, 1.0],
+ [4.4452977, 0.8434974, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.4539274, 1.1699649, 1.0],
+ [2.7342727, 1.0873699, 1.0],
+ [4.0375504, 0.9513469, 1.0],
+ [2.912934, 0.7801174, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.2035671, 1.2061948, 1.0],
+ [2.204418, 1.0808513, 1.0],
+ [3.2734146, 0.95607066, 1.0],
+ [3.3829432, 0.8604104, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.069349, 1.2016822, 1.0],
+ [0.78204316, 1.0802776, 1.0],
+ [0.8939021, 0.9876918, 1.0],
+ [0.9314171, 0.9479382, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.2317412, 1.2042382, 1.0],
+ [-0.8626396, 1.0817081, 1.0],
+ [-1.1587913, 1.0106043, 1.0],
+ [-0.7471611, 0.9841788, 1.0],
+ [0.0, 1.0, 1.0],
+ [-3.6874542, 1.1916536, 1.0],
+ [-2.8666534, 1.0680176, 1.0],
+ [-3.0523398, 1.0200123, 1.0],
+ [-0.8098775, 1.0000143, 1.0],
+ [0.0, 1.0, 1.0],
+ [-4.6867037, 1.1576309, 1.0],
+ [-4.1102524, 1.0329399, 1.0],
+ [-3.0036724, 0.9886833, 1.0],
+ [-0.33008483, 0.99551946, 1.0],
+ [0.0, 1.0, 1.0],
+ [-6.2138457, 1.1177778, 1.0],
+ [-5.5909715, 1.0219685, 1.0],
+ [-3.883835, 0.9935609, 1.0],
+ [-0.19700186, 0.9980648, 1.0],
+ [0.0, 1.0, 1.0],
+ [-7.703151, 1.118376, 1.0],
+ [-7.2529035, 1.0356544, 1.0],
+ [-5.5993958, 1.0061052, 1.0],
+ [-0.36084718, 0.99833083, 1.0],
+ [0.0, 1.0, 1.0],
+ [-9.133752, 1.1199366, 1.0],
+ [-9.163867, 1.0599968, 1.0],
+ [-6.685689, 1.0225968, 1.0],
+ [-0.3279509, 0.9992778, 1.0],
+ [0.0, 1.0, 1.0],
+ [-10.304704, 1.1167951, 1.0],
+ [-9.944268, 1.0687343, 1.0],
+ [-6.581675, 1.0340862, 1.0],
+ [-0.1731145, 1.0005047, 1.0],
+ [0.0, 1.0, 1.0],
+ [-11.287525, 1.12426, 1.0],
+ [-10.652732, 1.0709195, 1.0],
+ [-6.425803, 1.0336635, 1.0],
+ [-0.1326214, 1.0005356, 1.0],
+ [0.0, 1.0, 1.0],
+ [-11.926306, 1.1344961, 1.0],
+ [-11.767215, 1.0815933, 1.0],
+ [-6.9073353, 1.033758, 1.0],
+ [-0.15162425, 1.0003935, 1.0],
+ [0.0, 1.0, 1.0],
+ [-12.356211, 1.1320031, 1.0],
+ [-12.448733, 1.087039, 1.0],
+ [-7.868196, 1.0303723, 1.0],
+ [-0.21782555, 1.0001445, 1.0],
+ [0.0, 1.0, 1.0],
+ [-12.909969, 1.1390688, 1.0],
+ [-12.794445, 1.0962518, 1.0],
+ [-7.4221215, 1.0277627, 1.0],
+ [-0.24051952, 0.9994796, 1.0],
+ [0.0, 1.0, 1.0],
+ [-14.318492, 1.1441628, 1.0],
+ [-14.100717, 1.0943929, 1.0],
+ [-8.575853, 1.009897, 1.0],
+ [-0.41431823, 0.9982393, 1.0],
+ [0.0, 1.0, 1.0],
+ [-15.74911, 1.1528009, 1.0],
+ [-15.211963, 1.1008726, 1.0],
+ [-8.026304, 0.99996704, 1.0],
+ [-0.47660625, 0.9950099, 1.0],
+ [0.0, 1.0, 1.0],
+ [-16.241463, 1.1295997, 1.0],
+ [-15.810595, 1.066377, 1.0],
+ [-10.653159, 0.9577683, 1.0],
+ [-1.2817223, 0.9887495, 1.0],
+ [0.0, 1.0, 1.0],
+ [-17.143677, 1.0890604, 1.0],
+ [-17.320019, 1.0180693, 1.0],
+ [-14.736028, 0.90419865, 1.0],
+ [-3.1021247, 0.97202545, 1.0],
+ [0.0, 1.0, 1.0],
+ [-18.748793, 1.0463697, 1.0],
+ [-18.46616, 0.98106045, 1.0],
+ [-18.009438, 0.86111045, 1.0],
+ [-5.893209, 0.94255584, 1.0],
+ [0.0, 1.0, 1.0],
+ [-19.686289, 0.996309, 1.0],
+ [-19.95499, 0.901589, 1.0],
+ [-23.705408, 0.78339463, 1.0],
+ [-13.599181, 0.862805, 1.0],
+ [0.0, 1.0, 1.0],
+ [-19.272419, 0.9451124, 1.0],
+ [-18.105583, 0.8663253, 1.0],
+ [-23.313438, 0.7696128, 1.0],
+ [-14.894453, 0.842119, 1.0],
+ [0.0, 1.0, 1.0],
+ [-18.445044, 0.90566933, 1.0],
+ [-17.009562, 0.85194093, 1.0],
+ [-23.163944, 0.7514455, 1.0],
+ [-18.454296, 0.7895318, 1.0],
+ [0.0, 1.0, 1.0],
+ [-19.975847, 0.8582838, 1.0],
+ [-15.432948, 0.85963035, 1.0],
+ [-21.027409, 0.75946647, 1.0],
+ [-18.194445, 0.78112334, 1.0],
+ [0.0, 1.0, 1.0],
+ [-18.885874, 0.83879834, 1.0],
+ [-14.115562, 0.8839908, 1.0],
+ [-12.019917, 0.8720784, 1.0],
+ [-6.3177505, 0.91974896, 1.0],
+ [0.0, 1.0, 1.0],
+ [-17.701115, 0.8398532, 1.0],
+ [-14.244609, 0.89724517, 1.0],
+ [-9.12698, 0.9179684, 1.0],
+ [-1.9648052, 0.9748977, 1.0],
+ [0.0, 1.0, 1.0],
+ [-16.505676, 0.80830556, 1.0],
+ [-10.482741, 0.91814256, 1.0],
+ [-6.0856237, 0.97157246, 1.0],
+ [-1.7848674, 0.98539186, 1.0],
+ [0.0, 1.0, 1.0],
+ [-11.111074, 0.84318155, 1.0],
+ [-6.120855, 0.96219766, 1.0],
+ [-3.149391, 1.02109, 1.0],
+ [-0.97741336, 1.0116717, 1.0],
+ [0.0, 1.0, 1.0],
+ [-7.5704823, 0.8942756, 1.0],
+ [-3.2543333, 1.0210055, 1.0],
+ [-0.5860294, 1.0834074, 1.0],
+ [0.870136, 1.0914137, 1.0],
+ [0.0, 1.0, 1.0],
+ [-6.0229716, 0.8704741, 1.0],
+ [-1.4675226, 1.0396374, 1.0],
+ [0.57677406, 1.1029227, 1.0],
+ [1.422931, 1.1059595, 1.0],
+ [0.0, 1.0, 1.0],
+ [-3.5032904, 0.7701063, 1.0],
+ [0.7988452, 0.982442, 1.0],
+ [2.1533048, 1.102169, 1.0],
+ [2.2210178, 1.1071599, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.029193172, 0.72968274, 1.0],
+ [2.5515428, 0.9443575, 1.0],
+ [2.4624462, 1.1000748, 1.0],
+ [2.2837563, 1.1011144, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.7426748, 0.71043026, 1.0],
+ [3.054676, 0.9081306, 1.0],
+ [2.6141508, 1.0664026, 1.0],
+ [2.980883, 1.0584391, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.6768913, 0.6937622, 1.0],
+ [4.527743, 0.8812177, 1.0],
+ [4.8806653, 1.0332351, 1.0],
+ [4.967902, 1.0312556, 1.0],
+ [0.0, 1.0, 1.0],
+ [7.1945524, 0.68730164, 1.0],
+ [6.950681, 0.8867778, 1.0],
+ [7.3334575, 1.0227884, 1.0],
+ [7.0962567, 1.0217508, 1.0],
+ [0.0, 1.0, 1.0],
+ [11.6981535, 0.68517774, 1.0],
+ [9.424991, 0.89795345, 1.0],
+ [10.109896, 1.0150306, 1.0],
+ [9.631388, 1.0133693, 1.0],
+ [0.0, 1.0, 1.0],
+ [15.01595, 0.69489264, 1.0],
+ [10.674274, 0.93846947, 1.0],
+ [12.570046, 0.999178, 1.0],
+ [11.800817, 0.993752, 1.0],
+ [0.0, 1.0, 1.0],
+ [16.40186, 0.72178566, 1.0],
+ [13.150471, 0.9621032, 1.0],
+ [13.788712, 0.9881621, 1.0],
+ [12.145564, 0.9821678, 1.0],
+ [0.0, 1.0, 1.0],
+ [16.426535, 0.7873739, 1.0],
+ [15.013656, 0.98788464, 1.0],
+ [15.114616, 0.9857073, 1.0],
+ [10.671278, 0.97703195, 1.0],
+ [0.0, 1.0, 1.0],
+ [18.044664, 0.8680412, 1.0],
+ [18.143904, 1.0268604, 1.0],
+ [16.295511, 1.0147856, 1.0],
+ [5.056517, 0.9941207, 1.0],
+ [0.0, 1.0, 1.0],
+ [18.028666, 0.95773226, 1.0],
+ [18.875088, 1.0640182, 1.0],
+ [16.150946, 1.0385625, 1.0],
+ [3.1871507, 1.0035607, 1.0],
+ [0.0, 1.0, 1.0],
+ [16.514034, 1.0013099, 1.0],
+ [17.314789, 1.0905116, 1.0],
+ [14.560985, 1.0545624, 1.0],
+ [1.7983161, 1.0041636, 1.0],
+ [0.0, 1.0, 1.0],
+ [15.797172, 1.0078827, 1.0],
+ [14.955377, 1.0977542, 1.0],
+ [12.220257, 1.0560337, 1.0],
+ [1.3466339, 1.0037528, 1.0],
+ [0.0, 1.0, 1.0],
+ [14.565454, 0.98415726, 1.0],
+ [11.735246, 1.0906302, 1.0],
+ [8.986611, 1.0521289, 1.0],
+ [0.96998435, 1.0029405, 1.0],
+ [0.0, 1.0, 1.0],
+ [13.707696, 0.9740702, 1.0],
+ [9.109479, 1.0700653, 1.0],
+ [6.174766, 1.0449634, 1.0],
+ [0.61549, 1.0034261, 1.0],
+ [0.0, 1.0, 1.0],
+ [13.172035, 0.965594, 1.0],
+ [6.792434, 1.0554295, 1.0],
+ [2.0799077, 1.0371919, 1.0],
+ [-0.03451228, 1.002662, 1.0],
+ [0.0, 1.0, 1.0],
+ [12.510916, 0.9951767, 1.0],
+ [4.6987195, 1.0665245, 1.0],
+ [0.08861357, 1.0415522, 1.0],
+ [-0.20895422, 1.0027298, 1.0],
+ [0.0, 1.0, 1.0],
+ [10.848053, 0.9867255, 1.0],
+ [2.4717958, 1.0654173, 1.0],
+ [-1.2781724, 1.0430613, 1.0],
+ [-0.29540727, 1.0029501, 1.0],
+ [0.0, 1.0, 1.0],
+ [8.924775, 1.0128679, 1.0],
+ [1.6049622, 1.0838153, 1.0],
+ [0.04660962, 1.0468647, 1.0],
+ [0.06334232, 1.0034819, 1.0],
+ [0.0, 1.0, 1.0],
+ [7.795764, 1.0535507, 1.0],
+ [1.7419995, 1.088198, 1.0],
+ [-0.15147558, 1.0503129, 1.0],
+ [-0.0022487787, 1.0045427, 1.0],
+ [0.0, 1.0, 1.0],
+ [7.2572355, 1.0579109, 1.0],
+ [1.0977098, 1.0733509, 1.0],
+ [-1.2772944, 1.044092, 1.0],
+ [-0.13525482, 1.0073868, 1.0],
+ [0.0, 1.0, 1.0],
+ [6.364052, 1.0702623, 1.0],
+ [0.38560438, 1.0597174, 1.0],
+ [-0.880569, 1.0338639, 1.0],
+ [0.45450735, 1.0093911, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.635617, 1.0665764, 1.0],
+ [-1.8541563, 1.0492157, 1.0],
+ [-2.224277, 1.0312802, 1.0],
+ [0.02060782, 1.0094924, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.9177897, 0.931073, 1.0],
+ [-3.830959, 0.9463443, 1.0],
+ [-4.178485, 0.96985245, 1.0],
+ [-2.8674722, 0.9778141, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.63912636, 0.92612, 1.0],
+ [-3.5380905, 0.9278758, 1.0],
+ [-4.304839, 0.9169423, 1.0],
+ [-3.5802007, 0.9137134, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.754941, 0.92468625, 1.0],
+ [-2.9381945, 0.9232937, 1.0],
+ [-3.6206696, 0.90653074, 1.0],
+ [-3.333006, 0.8945919, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.4640048, 0.9297938, 1.0],
+ [-1.9298265, 0.9331787, 1.0],
+ [-3.5713015, 0.9139552, 1.0],
+ [-3.4944124, 0.8915449, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.2694256, 0.93911076, 1.0],
+ [-1.7281839, 0.9344968, 1.0],
+ [-4.068964, 0.9337624, 1.0],
+ [-3.7026005, 0.88743496, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.443677, 0.9488072, 1.0],
+ [-2.1556156, 0.9397697, 1.0],
+ [-5.040308, 0.9141083, 1.0],
+ [-7.3046207, 0.8774791, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.2289524, 0.9390356, 1.0],
+ [-2.256875, 0.87258524, 1.0],
+ [-5.24316, 0.86723936, 1.0],
+ [-8.016062, 0.860503, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.379857, 0.9152337, 1.0],
+ [-3.06269, 0.88222355, 1.0],
+ [-5.274808, 0.8565716, 1.0],
+ [-6.253486, 0.83439857, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.9447069, 0.8909187, 1.0],
+ [-2.1291292, 0.8753727, 1.0],
+ [-4.2053227, 0.8382535, 1.0],
+ [-4.9932523, 0.804721, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.2031088, 0.883391, 1.0],
+ [-0.5427132, 0.8514603, 1.0],
+ [-2.1573637, 0.8162667, 1.0],
+ [-5.739983, 0.78099483, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.0463758, 0.8902918, 1.0],
+ [0.4049588, 0.8463498, 1.0],
+ [-1.3030909, 0.8070003, 1.0],
+ [-5.3864274, 0.73747945, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.5551093, 0.93288326, 1.0],
+ [1.5804863, 0.88684475, 1.0],
+ [-0.0010043167, 0.8088468, 1.0],
+ [-2.0880547, 0.7102155, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.7656722, 0.9366638, 1.0],
+ [2.0004451, 0.89587283, 1.0],
+ [0.78522146, 0.8225704, 1.0],
+ [-0.7193246, 0.7130292, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.447219, 0.95061296, 1.0],
+ [2.4834387, 0.91706544, 1.0],
+ [1.561155, 0.851608, 1.0],
+ [0.53629506, 0.7400539, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.7838557, 0.9778509, 1.0],
+ [2.667703, 0.94507015, 1.0],
+ [2.5998428, 0.87076056, 1.0],
+ [2.1060734, 0.76656604, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.9395797, 0.98501194, 1.0],
+ [2.645288, 0.9442791, 1.0],
+ [3.7864566, 0.86861855, 1.0],
+ [4.5985446, 0.77926284, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.8749794, 0.9834793, 1.0],
+ [2.6313577, 0.9403016, 1.0],
+ [3.6338875, 0.8868182, 1.0],
+ [4.784866, 0.8023069, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.8682514, 0.97530806, 1.0],
+ [2.5901785, 0.9404073, 1.0],
+ [3.0052283, 0.905837, 1.0],
+ [2.0069096, 0.83484143, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.4727961, 1.0080808, 1.0],
+ [2.3305118, 0.9540467, 1.0],
+ [2.282054, 0.91564345, 1.0],
+ [-7.6290994, 0.69604313, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.0637859, 1.0516135, 1.0],
+ [1.7671573, 0.9879856, 1.0],
+ [-5.2584343, 0.73675436, 1.0],
+ [-16.342901, 0.5216483, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.61982626, 1.0828284, 1.0],
+ [1.5899999, 0.95572406, 1.0],
+ [-5.914253, 0.5874747, 1.0],
+ [-8.813023, 0.507078, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.5224679, 1.0920671, 1.0],
+ [2.2305388, 0.914912, 1.0],
+ [8.181367, 0.6633539, 1.0],
+ [11.847236, 0.5297007, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.8650954, 1.0546236, 1.0],
+ [2.5084958, 0.8949213, 1.0],
+ [8.434784, 0.7660728, 1.0],
+ [19.517859, 0.6189292, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.33615965, 0.9680921, 1.0],
+ [1.9523638, 0.8709517, 1.0],
+ [3.7503355, 0.8401423, 1.0],
+ [5.316759, 0.87355274, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.4475873, 0.99367666, 1.0],
+ [0.3394638, 0.9137543, 1.0],
+ [1.7758911, 0.8927394, 1.0],
+ [1.1283293, 0.9674458, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.4284496, 1.0324663, 1.0],
+ [-1.4436898, 0.9559075, 1.0],
+ [-0.31089765, 0.9398312, 1.0],
+ [0.15274782, 0.99124306, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.9135323, 0.9894693, 1.0],
+ [-1.0511459, 0.9277451, 1.0],
+ [0.45192885, 0.9280664, 1.0],
+ [0.32532325, 0.98979396, 1.0],
+ [0.0, 1.0, 1.0],
+ [-3.6640732, 0.9995134, 1.0],
+ [-1.4445381, 0.94103074, 1.0],
+ [-0.45791662, 0.9589122, 1.0],
+ [0.080993034, 0.9953546, 1.0],
+ [0.0, 1.0, 1.0],
+ [-3.9926722, 1.0097752, 1.0],
+ [-2.3710601, 0.9654012, 1.0],
+ [-0.9738827, 0.97923654, 1.0],
+ [-0.07195478, 0.99595565, 1.0],
+ [0.0, 1.0, 1.0],
+ [-4.377553, 0.9997677, 1.0],
+ [-2.9565587, 0.951771, 1.0],
+ [-1.253855, 0.97048193, 1.0],
+ [-0.08838272, 0.99680644, 1.0],
+ [0.0, 1.0, 1.0],
+ [-4.8839297, 0.9710823, 1.0],
+ [-2.0442789, 0.89748603, 1.0],
+ [-0.6975334, 0.93988526, 1.0],
+ [0.013153156, 0.9972744, 1.0],
+ [0.0, 1.0, 1.0],
+ [-5.2213855, 0.95285964, 1.0],
+ [-2.4940815, 0.8997929, 1.0],
+ [-0.48827216, 0.956785, 1.0],
+ [0.22012724, 0.9936011, 1.0],
+ [0.0, 1.0, 1.0],
+ [-6.361059, 0.9966289, 1.0],
+ [-4.30683, 0.97904444, 1.0],
+ [-0.99236, 0.99435294, 1.0],
+ [0.009188855, 0.99846935, 1.0],
+ [0.0, 1.0, 1.0],
+ [-8.2860985, 1.0472525, 1.0],
+ [-4.832397, 1.016574, 1.0],
+ [-1.5982567, 0.96859825, 1.0],
+ [-0.58968186, 0.9639687, 1.0],
+ [0.0, 1.0, 1.0],
+ [-8.645831, 1.0387725, 1.0],
+ [-4.62883, 0.99550915, 1.0],
+ [-2.1827693, 0.89403373, 1.0],
+ [-0.32529926, 0.9052658, 1.0],
+ [0.0, 1.0, 1.0],
+ [-8.275511, 1.0268661, 1.0],
+ [-6.3909574, 0.9760469, 1.0],
+ [-7.4211764, 0.8973423, 1.0],
+ [-2.6890829, 0.9495063, 1.0],
+ [0.0, 1.0, 1.0],
+ [-9.703486, 1.0061108, 1.0],
+ [-7.5075417, 0.95615023, 1.0],
+ [-7.846866, 0.88695526, 1.0],
+ [-3.1512766, 0.9450091, 1.0],
+ [0.0, 1.0, 1.0],
+ [-11.724224, 0.97924507, 1.0],
+ [-7.2378116, 0.9570293, 1.0],
+ [-6.0934024, 0.91340625, 1.0],
+ [-1.4091715, 0.97521883, 1.0],
+ [0.0, 1.0, 1.0],
+ [-15.354833, 0.944077, 1.0],
+ [-11.498621, 0.93437237, 1.0],
+ [-10.131048, 0.8965729, 1.0],
+ [-2.5142438, 0.97270924, 1.0],
+ [0.0, 1.0, 1.0],
+ [-16.9717, 0.904382, 1.0],
+ [-15.424872, 0.87748027, 1.0],
+ [-19.262257, 0.8070707, 1.0],
+ [-8.481445, 0.91010606, 1.0],
+ [0.0, 1.0, 1.0],
+ [-16.93574, 0.86007816, 1.0],
+ [-14.099674, 0.8571838, 1.0],
+ [-18.867304, 0.7990837, 1.0],
+ [-9.067885, 0.89900285, 1.0],
+ [0.0, 1.0, 1.0],
+ [-17.21487, 0.795665, 1.0],
+ [-14.66645, 0.8218075, 1.0],
+ [-20.941486, 0.75604767, 1.0],
+ [-14.4985485, 0.8239577, 1.0],
+ [0.0, 1.0, 1.0],
+ [-19.005753, 0.758779, 1.0],
+ [-16.680302, 0.7971698, 1.0],
+ [-23.688643, 0.7191442, 1.0],
+ [-21.129248, 0.7400342, 1.0],
+ [0.0, 1.0, 1.0],
+ [-18.484707, 0.78127193, 1.0],
+ [-17.147287, 0.8205015, 1.0],
+ [-16.7867, 0.8153531, 1.0],
+ [-6.6663785, 0.8562561, 1.0],
+ [0.0, 1.0, 1.0],
+ [-17.904772, 0.7992509, 1.0],
+ [-16.290035, 0.8404375, 1.0],
+ [-14.898, 0.84211147, 1.0],
+ [-2.2072968, 0.9246411, 1.0],
+ [0.0, 1.0, 1.0],
+ [-17.892328, 0.7252675, 1.0],
+ [-12.422257, 0.84525913, 1.0],
+ [-7.915848, 0.90744674, 1.0],
+ [-2.4332101, 0.9394951, 1.0],
+ [0.0, 1.0, 1.0],
+ [-12.256403, 0.7670256, 1.0],
+ [-7.3728104, 0.89708215, 1.0],
+ [-4.5105395, 0.97277874, 1.0],
+ [-1.5832016, 0.9920798, 1.0],
+ [0.0, 1.0, 1.0],
+ [-8.016388, 0.830646, 1.0],
+ [-4.5538907, 0.95883, 1.0],
+ [-2.5789335, 1.0234452, 1.0],
+ [-1.105107, 1.0530177, 1.0],
+ [0.0, 1.0, 1.0],
+ [-7.20433, 0.7782197, 1.0],
+ [-3.208458, 0.96606797, 1.0],
+ [-1.2935421, 1.0479633, 1.0],
+ [-0.0024548925, 1.0702269, 1.0],
+ [0.0, 1.0, 1.0],
+ [-4.574243, 0.6783128, 1.0],
+ [-0.29323265, 0.85936415, 1.0],
+ [1.1714814, 1.0271277, 1.0],
+ [1.6805246, 1.0721738, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.44121024, 0.69732547, 1.0],
+ [2.5033805, 0.8090156, 1.0],
+ [2.4892168, 1.0115892, 1.0],
+ [2.4337752, 1.0545831, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.7189965, 0.7012635, 1.0],
+ [3.8152018, 0.79624814, 1.0],
+ [4.6650043, 1.0100449, 1.0],
+ [4.9573836, 1.0277257, 1.0],
+ [0.0, 1.0, 1.0],
+ [4.6361694, 0.6836669, 1.0],
+ [5.552024, 0.82980967, 1.0],
+ [7.0284324, 1.0110646, 1.0],
+ [7.0502753, 1.0152555, 1.0],
+ [0.0, 1.0, 1.0],
+ [7.356563, 0.66799754, 1.0],
+ [7.49933, 0.86984295, 1.0],
+ [8.87279, 1.0068405, 1.0],
+ [8.66629, 1.0076414, 1.0],
+ [0.0, 1.0, 1.0],
+ [10.443153, 0.6744798, 1.0],
+ [8.668258, 0.89632285, 1.0],
+ [10.57484, 0.9984911, 1.0],
+ [10.112442, 1.0011668, 1.0],
+ [0.0, 1.0, 1.0],
+ [12.3462305, 0.69375634, 1.0],
+ [8.875547, 0.9268088, 1.0],
+ [11.964839, 0.98318386, 1.0],
+ [11.041635, 0.98537666, 1.0],
+ [0.0, 1.0, 1.0],
+ [13.036472, 0.71343744, 1.0],
+ [11.375755, 0.9281431, 1.0],
+ [13.321662, 0.9727517, 1.0],
+ [11.939063, 0.9759464, 1.0],
+ [0.0, 1.0, 1.0],
+ [12.724641, 0.77864206, 1.0],
+ [12.92866, 0.9616247, 1.0],
+ [13.789892, 0.96263355, 1.0],
+ [10.826783, 0.966194, 1.0],
+ [0.0, 1.0, 1.0],
+ [14.19844, 0.8416513, 1.0],
+ [15.266627, 0.9758373, 1.0],
+ [14.160454, 0.96900314, 1.0],
+ [6.107243, 0.977027, 1.0],
+ [0.0, 1.0, 1.0],
+ [12.747447, 0.88937104, 1.0],
+ [15.23662, 0.99281013, 1.0],
+ [13.870695, 0.9845561, 1.0],
+ [3.245, 0.992735, 1.0],
+ [0.0, 1.0, 1.0],
+ [11.464138, 0.9069218, 1.0],
+ [13.882505, 0.99412805, 1.0],
+ [12.3678875, 0.984619, 1.0],
+ [2.4400496, 0.9936844, 1.0],
+ [0.0, 1.0, 1.0],
+ [10.054893, 0.9309123, 1.0],
+ [11.051042, 0.9889742, 1.0],
+ [9.503872, 0.9689598, 1.0],
+ [2.0153925, 0.98796266, 1.0],
+ [0.0, 1.0, 1.0],
+ [9.463208, 0.9101172, 1.0],
+ [7.3157954, 0.9752043, 1.0],
+ [6.0373473, 0.95434564, 1.0],
+ [1.4550027, 0.9813375, 1.0],
+ [0.0, 1.0, 1.0],
+ [7.941972, 0.91271096, 1.0],
+ [4.060063, 0.9565896, 1.0],
+ [2.8974419, 0.94200397, 1.0],
+ [0.63167155, 0.97762823, 1.0],
+ [0.0, 1.0, 1.0],
+ [7.7601333, 0.8894366, 1.0],
+ [1.375002, 0.936908, 1.0],
+ [-1.5859069, 0.9271758, 1.0],
+ [-0.71164757, 0.9791451, 1.0],
+ [0.0, 1.0, 1.0],
+ [8.1289625, 0.8806327, 1.0],
+ [-1.5395085, 0.93572843, 1.0],
+ [-5.483463, 0.9259951, 1.0],
+ [-1.7303535, 0.97720516, 1.0],
+ [0.0, 1.0, 1.0],
+ [8.060848, 0.89185596, 1.0],
+ [-2.4483445, 0.95255435, 1.0],
+ [-7.1547413, 0.9442576, 1.0],
+ [-2.1877682, 0.98242164, 1.0],
+ [0.0, 1.0, 1.0],
+ [6.4304748, 0.86830884, 1.0],
+ [-1.4413718, 0.9585318, 1.0],
+ [-4.279423, 0.9782264, 1.0],
+ [-0.973853, 0.9942283, 1.0],
+ [0.0, 1.0, 1.0],
+ [6.0025797, 0.86645776, 1.0],
+ [-1.0643132, 0.94587845, 1.0],
+ [-2.816222, 0.9981094, 1.0],
+ [-0.6120733, 0.999216, 1.0],
+ [0.0, 1.0, 1.0],
+ [5.2618656, 0.87934244, 1.0],
+ [-1.9260371, 0.92144936, 1.0],
+ [-3.44394, 0.9980872, 1.0],
+ [-0.8797513, 1.0006056, 1.0],
+ [0.0, 1.0, 1.0],
+ [4.48014, 0.9191667, 1.0],
+ [-2.3073015, 0.9339955, 1.0],
+ [-2.6852305, 0.9934067, 1.0],
+ [-0.5358713, 1.0003943, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.1199076, 0.94316065, 1.0],
+ [-3.2203076, 0.9512757, 1.0],
+ [-3.2261748, 0.98376966, 1.0],
+ [-1.225657, 0.99541444, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.5250582, 0.87108445, 1.0],
+ [-2.2838533, 0.8486961, 1.0],
+ [-3.0364053, 0.91204727, 1.0],
+ [-2.5156116, 0.9678634, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.9791424, 0.90573484, 1.0],
+ [-1.1473076, 0.85067075, 1.0],
+ [-4.143441, 0.83307755, 1.0],
+ [-3.6135044, 0.8626736, 1.0],
+ [0.0, 1.0, 1.0],
+ [4.1917787, 0.8886335, 1.0],
+ [1.4209502, 0.84427315, 1.0],
+ [-1.902567, 0.8220766, 1.0],
+ [-1.2590855, 0.84276557, 1.0],
+ [0.0, 1.0, 1.0],
+ [6.5577917, 0.8582681, 1.0],
+ [4.414367, 0.8102154, 1.0],
+ [1.2046701, 0.8105167, 1.0],
+ [1.181831, 0.82471985, 1.0],
+ [0.0, 1.0, 1.0],
+ [5.644376, 0.800012, 1.0],
+ [4.4705477, 0.738722, 1.0],
+ [4.9720116, 0.78066784, 1.0],
+ [6.1397104, 0.79611427, 1.0],
+ [0.0, 1.0, 1.0],
+ [5.2449017, 0.72134954, 1.0],
+ [3.139061, 0.68135095, 1.0],
+ [3.8384428, 0.7519106, 1.0],
+ [6.912432, 0.7883944, 1.0],
+ [0.0, 1.0, 1.0],
+ [4.7535887, 0.67236185, 1.0],
+ [1.6767513, 0.6455759, 1.0],
+ [1.2658948, 0.7090765, 1.0],
+ [4.059322, 0.7603392, 1.0],
+ [0.0, 1.0, 1.0],
+ [4.5784483, 0.6933753, 1.0],
+ [0.27928707, 0.699313, 1.0],
+ [2.2699614, 0.6997288, 1.0],
+ [6.3981876, 0.672012, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.7372062, 0.6813957, 1.0],
+ [1.5830798, 0.67802733, 1.0],
+ [3.873308, 0.6486227, 1.0],
+ [5.3511515, 0.6483506, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.6191099, 0.6723053, 1.0],
+ [2.7363768, 0.64387995, 1.0],
+ [3.5159998, 0.6142523, 1.0],
+ [2.8553054, 0.64838594, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.7020638, 0.6383956, 1.0],
+ [2.3508477, 0.6238232, 1.0],
+ [2.9867542, 0.61862445, 1.0],
+ [2.3013074, 0.64164305, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.380534, 0.6982154, 1.0],
+ [1.8927245, 0.6699597, 1.0],
+ [2.130032, 0.6536506, 1.0],
+ [1.440734, 0.64402896, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.307678, 0.74071306, 1.0],
+ [2.2724662, 0.70253104, 1.0],
+ [1.6237959, 0.6603189, 1.0],
+ [-0.29978958, 0.64183867, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.4868233, 0.7586426, 1.0],
+ [1.4523857, 0.7366422, 1.0],
+ [-0.15140325, 0.68974066, 1.0],
+ [-1.4498984, 0.65608776, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.5952954, 0.81705433, 1.0],
+ [0.3610198, 0.76384574, 1.0],
+ [-1.1764607, 0.7085227, 1.0],
+ [-2.0014498, 0.66375816, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.009735043, 0.8393661, 1.0],
+ [-0.52455544, 0.74124515, 1.0],
+ [-0.48256284, 0.7240061, 1.0],
+ [-4.231129, 0.6864818, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.5011253, 0.85113287, 1.0],
+ [-1.526793, 0.741856, 1.0],
+ [-3.389466, 0.69727397, 1.0],
+ [-10.565374, 0.63681203, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.5787122, 0.8358186, 1.0],
+ [-1.0496063, 0.7413957, 1.0],
+ [-6.023933, 0.6728621, 1.0],
+ [-16.243042, 0.57179403, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.5238857, 0.81381685, 1.0],
+ [-3.973887, 0.73654264, 1.0],
+ [-13.029781, 0.5978037, 1.0],
+ [-18.08632, 0.5250716, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.3169994, 0.8272504, 1.0],
+ [-8.047612, 0.7040424, 1.0],
+ [-16.19256, 0.52720886, 1.0],
+ [-17.492706, 0.50534374, 1.0],
+ [0.0, 1.0, 1.0],
+ [-3.432385, 0.8549761, 1.0],
+ [-4.998627, 0.67764604, 1.0],
+ [-8.85871, 0.51909405, 1.0],
+ [-9.629815, 0.5025227, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.8054225, 0.83563244, 1.0],
+ [2.1752064, 0.66142064, 1.0],
+ [12.088789, 0.54614425, 1.0],
+ [12.621284, 0.51645017, 1.0],
+ [0.0, 1.0, 1.0],
+ [4.066309, 0.7654777, 1.0],
+ [9.837004, 0.6236221, 1.0],
+ [17.423119, 0.5883316, 1.0],
+ [21.496513, 0.5862999, 1.0],
+ [0.0, 1.0, 1.0],
+ [6.741904, 0.7127471, 1.0],
+ [9.628795, 0.64409775, 1.0],
+ [10.995573, 0.6744512, 1.0],
+ [6.354313, 0.84476554, 1.0],
+ [0.0, 1.0, 1.0],
+ [4.6841927, 0.7843752, 1.0],
+ [8.19482, 0.7160338, 1.0],
+ [6.0010133, 0.7742455, 1.0],
+ [0.18970187, 0.94752014, 1.0],
+ [0.0, 1.0, 1.0],
+ [7.842979, 0.8000173, 1.0],
+ [11.690232, 0.739449, 1.0],
+ [5.110884, 0.80710995, 1.0],
+ [-1.2012933, 0.922968, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.3574314, 0.84719974, 1.0],
+ [5.8742213, 0.8123406, 1.0],
+ [2.516008, 0.8360613, 1.0],
+ [-0.37090135, 0.9494042, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.4409087, 0.846134, 1.0],
+ [-0.13644595, 0.8567972, 1.0],
+ [-3.2113655, 0.839664, 1.0],
+ [-1.5690522, 0.91481286, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.6356204, 0.8613304, 1.0],
+ [-1.9126496, 0.8373374, 1.0],
+ [-10.372357, 0.78524256, 1.0],
+ [-6.5644794, 0.844308, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.35381076, 0.93528235, 1.0],
+ [-2.7392008, 0.89980006, 1.0],
+ [-9.026086, 0.7895136, 1.0],
+ [-4.9918127, 0.83979625, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.22767301, 0.91764885, 1.0],
+ [-0.28452978, 0.84251344, 1.0],
+ [-1.9016887, 0.84161645, 1.0],
+ [0.3422665, 0.93315935, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.123091474, 0.89455956, 1.0],
+ [-2.1685836, 0.84581566, 1.0],
+ [1.3732817, 0.84082806, 1.0],
+ [2.6831613, 0.92868334, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.86503315, 0.88304895, 1.0],
+ [-1.5601242, 0.91238695, 1.0],
+ [0.37940353, 0.93464494, 1.0],
+ [1.0456579, 0.96896976, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.7394576, 0.89377844, 1.0],
+ [-2.3233414, 0.9390823, 1.0],
+ [-5.721086, 0.8329246, 1.0],
+ [-3.1057966, 0.82862717, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.6411211, 0.92481196, 1.0],
+ [-2.3975968, 0.85534614, 1.0],
+ [-5.8770084, 0.68478525, 1.0],
+ [-1.5260915, 0.6935012, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.2765417, 0.9208617, 1.0],
+ [-12.06742, 0.81020206, 1.0],
+ [-20.405844, 0.7374704, 1.0],
+ [-12.597571, 0.79101664, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.897616, 0.9211402, 1.0],
+ [-14.421744, 0.7903851, 1.0],
+ [-19.214266, 0.71761405, 1.0],
+ [-13.390762, 0.7809222, 1.0],
+ [0.0, 1.0, 1.0],
+ [-3.158559, 0.93960077, 1.0],
+ [-11.273947, 0.7577517, 1.0],
+ [-13.330117, 0.7017324, 1.0],
+ [-5.7236233, 0.8588474, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.592437, 0.9224862, 1.0],
+ [-1.9768095, 0.79358673, 1.0],
+ [-2.52034, 0.80400187, 1.0],
+ [-0.77087885, 0.96653724, 1.0],
+ [0.0, 1.0, 1.0],
+ [-4.4444714, 0.9292429, 1.0],
+ [1.6647115, 0.8260513, 1.0],
+ [1.4503638, 0.8003794, 1.0],
+ [-0.4073486, 0.9618924, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.7638762, 0.8422526, 1.0],
+ [-1.7696239, 0.8175861, 1.0],
+ [-2.5969453, 0.82940525, 1.0],
+ [-1.4243466, 0.9425862, 1.0],
+ [0.0, 1.0, 1.0],
+ [-5.8228145, 0.77634525, 1.0],
+ [-8.170251, 0.7666279, 1.0],
+ [-7.5123396, 0.81542164, 1.0],
+ [-1.8198384, 0.95125085, 1.0],
+ [0.0, 1.0, 1.0],
+ [-9.360387, 0.7797565, 1.0],
+ [-11.051248, 0.7752701, 1.0],
+ [-9.444043, 0.77841234, 1.0],
+ [-0.21361573, 0.87572235, 1.0],
+ [0.0, 1.0, 1.0],
+ [-13.2849245, 0.7415836, 1.0],
+ [-13.399887, 0.7924007, 1.0],
+ [4.650317, 0.81708753, 1.0],
+ [18.099548, 0.862965, 1.0],
+ [0.0, 1.0, 1.0],
+ [-14.249046, 0.7239625, 1.0],
+ [-12.644726, 0.787061, 1.0],
+ [1.3048109, 0.795617, 1.0],
+ [9.834041, 0.8405552, 1.0],
+ [0.0, 1.0, 1.0],
+ [-17.444523, 0.5949457, 1.0],
+ [-13.476753, 0.6840091, 1.0],
+ [-12.6381235, 0.6644669, 1.0],
+ [-9.62859, 0.6923138, 1.0],
+ [0.0, 1.0, 1.0],
+ [-14.0186615, 0.6051366, 1.0],
+ [-10.975076, 0.6890023, 1.0],
+ [-10.500401, 0.6949134, 1.0],
+ [-8.894962, 0.72246975, 1.0],
+ [0.0, 1.0, 1.0],
+ [-10.543245, 0.67233354, 1.0],
+ [-8.299539, 0.78261155, 1.0],
+ [-7.1005125, 0.845384, 1.0],
+ [-4.664283, 0.8898768, 1.0],
+ [0.0, 1.0, 1.0],
+ [-9.807613, 0.6154132, 1.0],
+ [-7.3155074, 0.76350015, 1.0],
+ [-5.8409595, 0.86448425, 1.0],
+ [-3.3455367, 0.93252707, 1.0],
+ [0.0, 1.0, 1.0],
+ [-7.5137596, 0.5462595, 1.0],
+ [-3.004627, 0.658936, 1.0],
+ [-1.8417222, 0.8381658, 1.0],
+ [-1.8682895, 0.9441728, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.9677281, 0.56995666, 1.0],
+ [0.90389174, 0.6623684, 1.0],
+ [1.5478073, 0.8449315, 1.0],
+ [0.2826305, 0.8891036, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.82919896, 0.63599986, 1.0],
+ [2.8135967, 0.7249443, 1.0],
+ [7.3101892, 0.9042873, 1.0],
+ [7.7040653, 0.9522446, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.789316, 0.6838728, 1.0],
+ [4.7111635, 0.81004953, 1.0],
+ [9.877694, 0.9593944, 1.0],
+ [10.302842, 0.9809844, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.4230366, 0.7425048, 1.0],
+ [3.738975, 0.84671533, 1.0],
+ [10.307028, 0.958077, 1.0],
+ [10.699147, 0.9737339, 1.0],
+ [0.0, 1.0, 1.0],
+ [4.8469787, 0.7797298, 1.0],
+ [4.6221204, 0.8404099, 1.0],
+ [9.099866, 0.9094019, 1.0],
+ [8.047509, 0.95190895, 1.0],
+ [0.0, 1.0, 1.0],
+ [5.217233, 0.7993761, 1.0],
+ [1.3621497, 0.8585704, 1.0],
+ [4.016475, 0.86902857, 1.0],
+ [3.1859565, 0.94888914, 1.0],
+ [0.0, 1.0, 1.0],
+ [4.6767554, 0.8303384, 1.0],
+ [2.1546807, 0.8991955, 1.0],
+ [3.7274258, 0.9081848, 1.0],
+ [1.5931357, 0.9754997, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.6186461, 0.8725928, 1.0],
+ [-0.23522311, 0.9146301, 1.0],
+ [0.9835199, 0.91176814, 1.0],
+ [0.58563864, 0.9759792, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.1956127, 0.8745205, 1.0],
+ [-0.72214097, 0.8934399, 1.0],
+ [0.99975485, 0.9016286, 1.0],
+ [0.34649053, 0.98511404, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.01588, 0.8797899, 1.0],
+ [-0.11028997, 0.90068436, 1.0],
+ [2.4051347, 0.8929491, 1.0],
+ [0.31044072, 0.98858964, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.801372, 0.9000487, 1.0],
+ [3.2723014, 0.94898623, 1.0],
+ [3.251487, 0.95616883, 1.0],
+ [0.26165524, 0.9953507, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.3384478, 0.94916874, 1.0],
+ [-0.76461834, 0.9320178, 1.0],
+ [-1.0340531, 0.937888, 1.0],
+ [-0.2052262, 0.9910225, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.5522646, 0.8218283, 1.0],
+ [-7.093138, 0.7325952, 1.0],
+ [-3.7177348, 0.77458364, 1.0],
+ [-0.5455225, 0.9524443, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.41480815, 0.85181725, 1.0],
+ [-0.36262518, 0.7285184, 1.0],
+ [1.2038608, 0.76166785, 1.0],
+ [0.34981248, 0.9373231, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.0183747, 0.8615714, 1.0],
+ [6.522213, 0.7130658, 1.0],
+ [4.7299647, 0.7329898, 1.0],
+ [0.69027966, 0.9252049, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.8928224, 0.86741346, 1.0],
+ [-0.22437978, 0.85102594, 1.0],
+ [-4.3790607, 0.8801903, 1.0],
+ [-1.6888652, 0.97201353, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.9431077, 0.87125516, 1.0],
+ [-6.9137535, 0.94617844, 1.0],
+ [-11.575183, 0.9801642, 1.0],
+ [-2.7485921, 0.99310756, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.68844545, 0.8476669, 1.0],
+ [-6.3806562, 0.9206668, 1.0],
+ [-11.188042, 0.98616713, 1.0],
+ [-1.8384333, 0.9988374, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.11813285, 0.83367205, 1.0],
+ [-5.729458, 0.87351876, 1.0],
+ [-9.243516, 0.94089884, 1.0],
+ [-1.174662, 0.994138, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.088543095, 0.81548285, 1.0],
+ [-2.1764681, 0.8262967, 1.0],
+ [-2.4391656, 0.9571753, 1.0],
+ [-0.27592131, 0.99790347, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.30223542, 0.8490914, 1.0],
+ [-2.565041, 0.84209675, 1.0],
+ [-0.73276377, 0.9614384, 1.0],
+ [-0.06476155, 0.9990294, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.5150307, 0.87056994, 1.0],
+ [-2.4532168, 0.83294433, 1.0],
+ [-1.4075577, 0.94370353, 1.0],
+ [-0.6351769, 0.9955674, 1.0],
+ [0.0, 1.0, 1.0],
+ [6.843285, 1.0033246, 1.0],
+ [7.4428577, 0.95056987, 1.0],
+ [2.108944, 0.94717664, 1.0],
+ [-0.25893068, 0.99069285, 1.0],
+ [0.0, 1.0, 1.0],
+ [9.999977, 1.0822781, 1.0],
+ [12.696831, 1.0251509, 1.0],
+ [5.93438, 0.93318224, 1.0],
+ [0.086959586, 0.9213895, 1.0],
+ [0.0, 1.0, 1.0],
+ [11.269931, 1.041934, 1.0],
+ [13.652761, 1.0116991, 1.0],
+ [7.9889326, 0.8788855, 1.0],
+ [6.5503616, 0.8362481, 1.0],
+ [0.0, 1.0, 1.0],
+ [9.251398, 0.93022597, 1.0],
+ [9.773671, 0.9091432, 1.0],
+ [11.005983, 0.7908927, 1.0],
+ [14.940665, 0.7742994, 1.0],
+ [0.0, 1.0, 1.0],
+ [6.4155545, 0.85385036, 1.0],
+ [8.46417, 0.829654, 1.0],
+ [12.680234, 0.7829167, 1.0],
+ [16.267979, 0.77082276, 1.0],
+ [0.0, 1.0, 1.0],
+ [4.912974, 0.8363341, 1.0],
+ [7.421986, 0.7468915, 1.0],
+ [13.157313, 0.70293534, 1.0],
+ [15.670254, 0.72895324, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.2732983, 0.83324426, 1.0],
+ [4.6879416, 0.76169664, 1.0],
+ [10.194166, 0.70281017, 1.0],
+ [15.137923, 0.6564709, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.691554, 0.8958847, 1.0],
+ [2.522616, 0.835281, 1.0],
+ [9.138479, 0.64440495, 1.0],
+ [13.552704, 0.5891457, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.0758106, 0.8807258, 1.0],
+ [3.3481083, 0.76597595, 1.0],
+ [9.720517, 0.59901714, 1.0],
+ [11.450588, 0.57016766, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.6788739, 0.8362954, 1.0],
+ [4.2696314, 0.6845474, 1.0],
+ [6.650018, 0.60105395, 1.0],
+ [7.2880855, 0.60911906, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.7132614, 0.8527012, 1.0],
+ [4.419341, 0.72993284, 1.0],
+ [5.6017246, 0.65223414, 1.0],
+ [5.9043407, 0.6311042, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.8517584, 0.93619037, 1.0],
+ [3.993357, 0.8524601, 1.0],
+ [4.7696304, 0.73141557, 1.0],
+ [5.134474, 0.6413797, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.8878054, 0.9270773, 1.0],
+ [3.787974, 0.889603, 1.0],
+ [2.4917884, 0.7853351, 1.0],
+ [-1.3098156, 0.6682702, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.0253179, 0.92877746, 1.0],
+ [4.033826, 0.9008868, 1.0],
+ [2.3986337, 0.83885485, 1.0],
+ [-2.687886, 0.695036, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.32751077, 0.94140244, 1.0],
+ [2.0182226, 0.85748637, 1.0],
+ [-1.1619195, 0.7632388, 1.0],
+ [-5.5223203, 0.6635563, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.050555307, 0.9604787, 1.0],
+ [0.5055935, 0.83031297, 1.0],
+ [-1.6055543, 0.70797473, 1.0],
+ [-8.740934, 0.6385445, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.79567736, 0.96682066, 1.0],
+ [-3.8943377, 0.8166324, 1.0],
+ [-5.3518414, 0.68081903, 1.0],
+ [-8.843345, 0.6458594, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.6558982, 0.9616084, 1.0],
+ [-5.2786994, 0.80397224, 1.0],
+ [-10.992683, 0.63992244, 1.0],
+ [-16.177847, 0.58813363, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.75773, 0.9437587, 1.0],
+ [-6.6879635, 0.7789285, 1.0],
+ [-14.333078, 0.6058587, 1.0],
+ [-18.033953, 0.5445878, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.043338794, 0.9552455, 1.0],
+ [-5.321281, 0.83083135, 1.0],
+ [-16.969313, 0.5537831, 1.0],
+ [-19.017706, 0.51461524, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.0175552, 0.9202445, 1.0],
+ [-2.6896808, 0.7869496, 1.0],
+ [-9.4466, 0.5490753, 1.0],
+ [-10.706868, 0.5131165, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.088289, 0.8988069, 1.0],
+ [-0.30899394, 0.75239635, 1.0],
+ [9.381413, 0.59920007, 1.0],
+ [12.207738, 0.5500292, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.4638748, 0.87054735, 1.0],
+ [5.293201, 0.7064153, 1.0],
+ [13.263786, 0.6700958, 1.0],
+ [17.00946, 0.66093135, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.8831717, 0.8199807, 1.0],
+ [4.7780185, 0.6955879, 1.0],
+ [1.744097, 0.76860195, 1.0],
+ [1.6281474, 0.8714117, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.23940727, 0.86877394, 1.0],
+ [-3.3042376, 0.77865523, 1.0],
+ [-12.2173395, 0.7857939, 1.0],
+ [-8.226082, 0.87283903, 1.0],
+ [0.0, 1.0, 1.0],
+ [4.590711, 0.8670613, 1.0],
+ [6.093016, 0.75086886, 1.0],
+ [-8.317774, 0.7136495, 1.0],
+ [-12.525191, 0.7473165, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.1493275, 0.8251384, 1.0],
+ [1.9776939, 0.76206553, 1.0],
+ [-6.1686516, 0.72631574, 1.0],
+ [-6.670901, 0.8287188, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.16302122, 0.8325588, 1.0],
+ [-2.9226806, 0.84611636, 1.0],
+ [-4.327261, 0.8001389, 1.0],
+ [-1.8962665, 0.88277936, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.857538, 0.87345517, 1.0],
+ [-4.3408766, 0.82445586, 1.0],
+ [-10.225279, 0.7459497, 1.0],
+ [-7.025876, 0.81165254, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.5294373, 0.88929296, 1.0],
+ [-2.200273, 0.8323351, 1.0],
+ [-10.405199, 0.74956995, 1.0],
+ [-6.132038, 0.81040746, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.16174325, 0.96300906, 1.0],
+ [-2.8326685, 0.88233006, 1.0],
+ [-1.4370927, 0.8078291, 1.0],
+ [2.6871767, 0.8748735, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.3610977, 0.96746254, 1.0],
+ [-3.2795222, 0.9109032, 1.0],
+ [-0.09399727, 0.89467824, 1.0],
+ [1.9920977, 0.94535923, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.7663058, 0.9140197, 1.0],
+ [-1.8021601, 0.91958934, 1.0],
+ [0.29760635, 0.93971634, 1.0],
+ [0.9959274, 0.97391975, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.160797, 0.8958013, 1.0],
+ [1.4534999, 0.89726895, 1.0],
+ [0.68010294, 0.89640915, 1.0],
+ [-0.5888536, 0.93606305, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.0493987, 0.968603, 1.0],
+ [0.9824815, 0.89990664, 1.0],
+ [-0.49817523, 0.7474924, 1.0],
+ [-1.0627013, 0.79141307, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.48714, 0.9750619, 1.0],
+ [-2.067653, 0.8603522, 1.0],
+ [-8.571636, 0.7305363, 1.0],
+ [-4.7118134, 0.8108301, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.725708, 0.98409593, 1.0],
+ [-7.151362, 0.8263027, 1.0],
+ [-12.0670395, 0.7186623, 1.0],
+ [-6.794398, 0.8430157, 1.0],
+ [0.0, 1.0, 1.0],
+ [-3.816568, 0.9682442, 1.0],
+ [-7.979974, 0.7548534, 1.0],
+ [-12.051381, 0.6975676, 1.0],
+ [-6.639557, 0.8519053, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.947905, 0.92271626, 1.0],
+ [-11.209933, 0.7695706, 1.0],
+ [-13.407172, 0.7412468, 1.0],
+ [-4.071555, 0.91521406, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.0821028, 0.9144603, 1.0],
+ [-4.187682, 0.8065792, 1.0],
+ [-5.4081926, 0.75121087, 1.0],
+ [-2.8812873, 0.896328, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.7102738, 0.89479977, 1.0],
+ [1.0865877, 0.75759023, 1.0],
+ [-2.4494224, 0.68042827, 1.0],
+ [-4.5266833, 0.78840774, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.7601203, 0.91100687, 1.0],
+ [-0.37138972, 0.77027845, 1.0],
+ [-1.2509688, 0.72588146, 1.0],
+ [-1.5607324, 0.8591345, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.8733535, 0.9064345, 1.0],
+ [-3.315706, 0.7831079, 1.0],
+ [1.3590022, 0.72675896, 1.0],
+ [6.3603826, 0.8151842, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.1287688, 0.9338128, 1.0],
+ [1.2533982, 0.73052305, 1.0],
+ [13.717384, 0.74975586, 1.0],
+ [18.239985, 0.8096522, 1.0],
+ [0.0, 1.0, 1.0],
+ [-3.185362, 0.8746891, 1.0],
+ [-5.5458236, 0.6902374, 1.0],
+ [2.3753786, 0.7056687, 1.0],
+ [7.2279525, 0.7721612, 1.0],
+ [0.0, 1.0, 1.0],
+ [-5.603719, 0.7863287, 1.0],
+ [-11.014495, 0.64638335, 1.0],
+ [-13.238409, 0.6131685, 1.0],
+ [-11.532672, 0.64018786, 1.0],
+ [0.0, 1.0, 1.0],
+ [-9.553351, 0.64817256, 1.0],
+ [-10.655705, 0.5973265, 1.0],
+ [-11.155985, 0.6075254, 1.0],
+ [-8.812423, 0.6737688, 1.0],
+ [0.0, 1.0, 1.0],
+ [-12.175202, 0.59095675, 1.0],
+ [-12.133568, 0.60512704, 1.0],
+ [-9.002674, 0.67072225, 1.0],
+ [-3.0383387, 0.8035592, 1.0],
+ [0.0, 1.0, 1.0],
+ [-12.228456, 0.5612792, 1.0],
+ [-11.996711, 0.59280115, 1.0],
+ [-9.705961, 0.687603, 1.0],
+ [-7.773533, 0.80342376, 1.0],
+ [0.0, 1.0, 1.0],
+ [-8.663709, 0.5645525, 1.0],
+ [-7.764659, 0.6173788, 1.0],
+ [-10.524891, 0.75197405, 1.0],
+ [-11.214434, 0.77335423, 1.0],
+ [0.0, 1.0, 1.0],
+ [-5.396718, 0.67858976, 1.0],
+ [-7.135087, 0.71634585, 1.0],
+ [-2.9309926, 0.74045223, 1.0],
+ [-4.4241138, 0.7625005, 1.0],
+ [0.0, 1.0, 1.0],
+ [-7.4634604, 0.93015254, 1.0],
+ [-11.637723, 0.8254694, 1.0],
+ [1.3877314, 0.75356996, 1.0],
+ [7.4537945, 0.7911049, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.0797722, 0.8257694, 1.0],
+ [-9.952251, 0.7275845, 1.0],
+ [-1.22222, 0.76648885, 1.0],
+ [8.443583, 0.86626667, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.719869, 0.81726277, 1.0],
+ [-8.175672, 0.6951251, 1.0],
+ [-5.122706, 0.7475115, 1.0],
+ [2.8121452, 0.87993133, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.5990696, 0.8328561, 1.0],
+ [-0.61761487, 0.68436635, 1.0],
+ [-1.5171839, 0.73146296, 1.0],
+ [-1.249486, 0.9136568, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.1546803, 0.9241708, 1.0],
+ [-4.1920066, 0.75697076, 1.0],
+ [-4.5107627, 0.73179376, 1.0],
+ [-2.238509, 0.89131826, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.956483, 0.96588635, 1.0],
+ [-1.0265821, 0.84155124, 1.0],
+ [-2.66922, 0.8079714, 1.0],
+ [-2.6284955, 0.95289963, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.347914, 0.93753785, 1.0],
+ [-1.3445268, 0.83233905, 1.0],
+ [-4.9731174, 0.79405123, 1.0],
+ [-4.1485014, 0.9415318, 1.0],
+ [0.0, 1.0, 1.0],
+ [-5.317987, 0.923656, 1.0],
+ [-4.806357, 0.8041632, 1.0],
+ [-4.5212965, 0.7689882, 1.0],
+ [-1.453726, 0.9560485, 1.0],
+ [0.0, 1.0, 1.0],
+ [-9.224498, 0.98964065, 1.0],
+ [-10.262257, 0.8849552, 1.0],
+ [-2.7684932, 0.7562575, 1.0],
+ [-0.23699033, 0.9539201, 1.0],
+ [0.0, 1.0, 1.0],
+ [-3.002255, 0.97370094, 1.0],
+ [-2.716253, 0.914175, 1.0],
+ [1.5104983, 0.89325345, 1.0],
+ [0.14295425, 0.99063605, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.36685634, 0.9282719, 1.0],
+ [-8.506473, 0.8523475, 1.0],
+ [-12.094891, 0.90356064, 1.0],
+ [-2.1393285, 0.9895608, 1.0],
+ [0.0, 1.0, 1.0],
+ [-9.882091, 0.7290178, 1.0],
+ [-9.057711, 0.6043994, 1.0],
+ [-4.1615314, 0.62489474, 1.0],
+ [-1.1871183, 0.8577925, 1.0],
+ [0.0, 1.0, 1.0],
+ [-4.1569786, 0.7451061, 1.0],
+ [0.43200502, 0.61813676, 1.0],
+ [5.076925, 0.62860876, 1.0],
+ [2.997295, 0.8324412, 1.0],
+ [0.0, 1.0, 1.0],
+ [4.9129972, 0.81619036, 1.0],
+ [7.9838896, 0.63521093, 1.0],
+ [8.996219, 0.6237475, 1.0],
+ [4.1178374, 0.81866705, 1.0],
+ [0.0, 1.0, 1.0],
+ [4.801817, 0.8912142, 1.0],
+ [8.943227, 0.74254304, 1.0],
+ [5.654569, 0.76142734, 1.0],
+ [0.74153537, 0.9477238, 1.0],
+ [0.0, 1.0, 1.0],
+ [4.679993, 0.9053932, 1.0],
+ [0.72251046, 0.8655048, 1.0],
+ [-8.141989, 0.94272244, 1.0],
+ [-2.3421266, 1.0002514, 1.0],
+ [0.0, 1.0, 1.0],
+ [7.750666, 0.8927274, 1.0],
+ [1.6069077, 0.8618249, 1.0],
+ [-12.324378, 0.9456767, 1.0],
+ [-3.3410037, 0.99778306, 1.0],
+ [0.0, 1.0, 1.0],
+ [4.629134, 0.8679321, 1.0],
+ [-5.62241, 0.8793257, 1.0],
+ [-17.850567, 0.9430222, 1.0],
+ [-4.631688, 0.9899626, 1.0],
+ [0.0, 1.0, 1.0],
+ [6.1155505, 0.7920305, 1.0],
+ [6.8210797, 0.7819359, 1.0],
+ [-3.9575536, 0.92559654, 1.0],
+ [-0.8186751, 0.9944719, 1.0],
+ [0.0, 1.0, 1.0],
+ [6.673559, 0.9143873, 1.0],
+ [8.677403, 0.9072902, 1.0],
+ [3.8159382, 0.9608776, 1.0],
+ [0.21488708, 0.9980647, 1.0],
+ [0.0, 1.0, 1.0],
+ [9.044622, 0.9573329, 1.0],
+ [11.07611, 0.8985059, 1.0],
+ [3.8399148, 0.9358206, 1.0],
+ [0.10755205, 0.997731, 1.0]
+ ],
+ "srgb_value": true
+ }
+}
diff --git a/assets/camera-tone/sony-dro-off.json b/assets/camera-tone/sony-dro-off.json
new file mode 100644
index 00000000..46397044
--- /dev/null
+++ b/assets/camera-tone/sony-dro-off.json
@@ -0,0 +1,52 @@
+{
+ "description": "Sony's global tone curve with the Dynamic Range Optimizer (DRO) off, Standard creative style: knots of scene luminance (white-balanced camera RGB with baseline exposure, through the colour model) to display-linear luminance. The camera look of an ARW shot with DRO on is lowered to it wherever it is darker (crates/engine/src/camera_preview.rs, docs/camera-preview-colour.md, issue #244).",
+ "derivation": "Each source raw was paired with its own embedded camera JPEG at the 96-pixel proxy of the per-photo fit (camera_preview::collect_pairs). The pairs of all 12 files were pooled as one model, as lightcraft-cli calibrate pools a camera profile: a colour matrix and hue/saturation table were fitted to the pool, and the tone curve was quantile-matched (camera_preview::fit_tone) between the pooled scene luminances through that colour model and the JPEG luminances. Knots rounded to 5 decimals. Aggregate statistics only, no image content.",
+ "sources": [
+ { "body": "ILCA-77M2", "url": "https://raw.pixls.us/data/Sony/ILCA-77M2/_DSC0244.ARW", "sha256": "f41ceff680b675460cc1fe258a24d8c8c01c52a300933f3ab6401d9a20b60dcc", "licence": "CC0" },
+ { "body": "ILCE-3500", "url": "https://raw.pixls.us/data/Sony/ILCE-3500/DSC06923.ARW", "sha256": "04c4fe04425c3e6fff7c8af0f811923c35e750300373357bd54342150c5505d8", "licence": "CC0" },
+ { "body": "ILCE-5000", "url": "https://raw.pixls.us/data/Sony/ILCE-5000/_DSC0793.ARW", "sha256": "a919c8535eed68577f1745fb9674ccf333962628ccdec6d48a6f635208467f6c", "licence": "CC0" },
+ { "body": "ILCE-6600", "url": "https://raw.pixls.us/data/Sony/ILCE-6600/_DSC0268.ARW", "sha256": "041d71d70148ca77164f19d061e3582adb34399ea5a4baba32960f2956fb0362", "licence": "CC0" },
+ { "body": "ILCE-7CR", "url": "https://raw.pixls.us/data/Sony/ILCE-7CR/DSC00798.ARW", "sha256": "f5096e8fbccf0842c8a57763cabd2836608f019eb7fd54f7b22260c2f5bc4da5", "licence": "CC0" },
+ { "body": "ILCE-7M3", "url": "https://raw.pixls.us/data/Sony/ILCE-7M3/UNCOMPRESSED_14bit.ARW", "sha256": "ece80551abf64949dbe826985a80f1d9b265401ee4ea6b989e1829549616fdcd", "licence": "CC0" },
+ { "body": "ILCE-7M3", "url": "https://raw.pixls.us/data/Sony/ILCE-7M3/_DSC0009.ARW", "sha256": "250784580ea527442c09004417bb0eead484f2bf3ee8f9121a776ac65bb50d0f", "licence": "CC0" },
+ { "body": "ILCE-7M3", "url": "https://raw.pixls.us/data/Sony/ILCE-7M3/_DSC0010.ARW", "sha256": "3ae2ff75cfa7240d0b4458f662afd28665c5fd1e83d230ac73f5ad69d3df8592", "licence": "CC0" },
+ { "body": "ILCE-7S", "url": "https://raw.pixls.us/data/Sony/ILCE-7S/DSC04125.ARW", "sha256": "7cc338a0abc8fdad32d61006f1f8f412297b93e040c7efba572eb2bd8f8e8be2", "licence": "CC0" },
+ { "body": "ILCE-9M2", "url": "https://raw.pixls.us/data/Sony/ILCE-9M2/SONY_A9II_(ILCE-9M2)_-_compressed_(3:2)_14bit.ARW", "sha256": "161c2a9da2b5f1e50be6117a0b4da0ce249660b5d7de13568d301c4034716b97", "licence": "CC0" },
+ { "body": "NEX-F3", "url": "https://raw.pixls.us/data/Sony/NEX-F3/_DSC4017.ARW", "sha256": "cfdc60d118b5ce2dce10d459dcb98ad63c6db3e609999932ca8964d86daf836b", "licence": "CC0" },
+ { "body": "SLT-A57", "url": "https://raw.pixls.us/data/Sony/SLT-A57/a57-06125.arw", "sha256": "0e9064f369a3947f892c1d7b1971f75d3879f5673134932d5cc7a67bf4263ba4", "licence": "CC0" }
+ ],
+ "knots": [
+ [0.01655, 0.01799],
+ [0.01970, 0.02376],
+ [0.02212, 0.03008],
+ [0.02469, 0.03634],
+ [0.02737, 0.04320],
+ [0.03049, 0.05044],
+ [0.03425, 0.05812],
+ [0.03824, 0.06667],
+ [0.04232, 0.07817],
+ [0.04658, 0.09059],
+ [0.05095, 0.10268],
+ [0.05550, 0.11731],
+ [0.05962, 0.13173],
+ [0.06409, 0.14529],
+ [0.06843, 0.15939],
+ [0.07314, 0.17363],
+ [0.07800, 0.18897],
+ [0.08298, 0.20502],
+ [0.08909, 0.22159],
+ [0.09693, 0.23964],
+ [0.10540, 0.26082],
+ [0.11579, 0.28882],
+ [0.12763, 0.31986],
+ [0.14079, 0.35198],
+ [0.15786, 0.39293],
+ [0.17949, 0.44101],
+ [0.20330, 0.49407],
+ [0.23466, 0.54244],
+ [0.27207, 0.60078],
+ [0.31673, 0.66533],
+ [0.38682, 0.74143],
+ [0.52622, 0.80307]
+ ]
+}
diff --git a/crates/catalog/Cargo.toml b/crates/catalog/Cargo.toml
index c70c2831..604fb039 100644
--- a/crates/catalog/Cargo.toml
+++ b/crates/catalog/Cargo.toml
@@ -19,4 +19,5 @@ log = { workspace = true }
web-time = { workspace = true }
[dev-dependencies]
+lightcraft-geom = { workspace = true }
proptest = { workspace = true }
diff --git a/crates/catalog/src/dates.rs b/crates/catalog/src/dates.rs
index 06e8e0f1..d180d013 100644
--- a/crates/catalog/src/dates.rs
+++ b/crates/catalog/src/dates.rs
@@ -40,11 +40,17 @@ pub fn weekday(iso: &str) -> Option<&'static str> {
}
/// Shift an ISO time by `secs`, keeping anything after the seconds (fractions, zone) as it was.
-/// A date without a time gets one. `None` if it doesn't parse.
+/// A date without a time gets one. `None` if it doesn't parse, or if the result leaves years
+/// 0000–9999 (an ISO date here has four year digits, so it couldn't be read back); `secs` may be
+/// anything, it never overflows.
pub fn shift_iso(iso: &str, secs: i64) -> Option {
- let t = iso_seconds(iso)?;
+ let t = iso_seconds(iso)?.checked_add(secs)?;
+ let (first, last) = (iso_seconds("0000-01-01T00:00:00")?, iso_seconds("9999-12-31T23:59:59")?);
+ if !(first..=last).contains(&t) {
+ return None;
+ }
let tail = iso.trim().get(19..).unwrap_or("");
- Some(format!("{}{tail}", civil(t + secs)))
+ Some(format!("{}{tail}", civil(t)))
}
/// Normalize user input to `YYYY-MM-DDTHH:MM:SS` (accepts a space instead of `T`, missing
@@ -204,6 +210,14 @@ mod tests {
assert_eq!(shift_iso("2026-12-31T23:30:00", 3600).as_deref(), Some("2027-01-01T00:30:00"));
assert_eq!(shift_iso("2026-03-01T00:00:00.25+02:00", -1).as_deref(), Some("2026-02-28T23:59:59.25+02:00"));
assert_eq!(shift_iso("nope", 5), None);
+ // hostile shifts never overflow, and a result outside years 0000–9999 (which no ISO date
+ // here can read back) is refused rather than stored
+ for secs in [i64::MAX, i64::MIN, i64::MAX / 2, -(i64::MAX / 2)] {
+ assert_eq!(shift_iso("2026-01-01T00:00:00", secs), None, "{secs}");
+ }
+ assert_eq!(shift_iso("9999-12-31T23:59:59", 1), None);
+ assert_eq!(shift_iso("0000-01-01T00:00:00", -1), None);
+ assert_eq!(shift_iso("9999-12-31T23:59:58", 1).as_deref(), Some("9999-12-31T23:59:59"));
assert_eq!(normalize_iso("2026-04-01 10:05").as_deref(), Some("2026-04-01T10:05:00"));
assert_eq!(normalize_iso("2026-02-30"), None);
}
diff --git a/crates/catalog/src/folders.rs b/crates/catalog/src/folders.rs
index 61a8f8c4..aab3b867 100644
--- a/crates/catalog/src/folders.rs
+++ b/crates/catalog/src/folders.rs
@@ -23,15 +23,21 @@
//! (`/Volumes/Macintosh HD/Users/me` and `/Users/me`, `/media/me/usb` and `/run/media/me/usb`) is
//! two rows. Each row is still consistent: choosing it shows exactly the photos it counts.
//!
+//! A folder is otherwise only where photos are, but the library can also keep a
+//! [`FolderRecord`] about it (its colour label), stored under the folder's identity
+//! ([`crate::query::folder_key`]). A record follows its folder when the folder is renamed or moved
+//! here ([`Catalog::folder_records_follow`]); one about a folder that no longer holds a photo is
+//! kept (undo can bring the photos back) and shows again when the folder does.
+//!
//! This is the library's own view of its photos; browsing any folder on disk without importing
//! is Local's job (see [`crate::local`]).
use std::collections::{BTreeMap, HashMap};
-use serde::Serialize;
+use serde::{Deserialize, Serialize};
-use crate::query::{folder_key, folder_within, split_path};
-use crate::{Catalog, Source};
+use crate::query::{folder_key, folder_rest, folder_within, key_within, split_path};
+use crate::{Catalog, ColorLabel, Op, Source};
/// Folder levels kept per photo. No real path comes near it; a deeper one is counted at its
/// ancestor this many levels down, which bounds the tree's depth (and so every walk of it)
@@ -58,9 +64,35 @@ pub struct FolderNode {
/// so for the startup disk and for folders that hold other disks (`/Volumes`, `/mnt`…): their
/// path covers those disks' photos too, so choosing them is not offered.
pub selectable: bool,
+ /// The folder's colour label (see [`FolderRecord`]).
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub label: Option,
pub children: Vec,
}
+/// What the library keeps about one of its folders beyond the photos in it. A record with
+/// nothing in it is not kept.
+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct FolderRecord {
+ /// A colour label, as a photo has one.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub label: Option,
+}
+
+impl FolderRecord {
+ /// Whether the record says nothing (and so is not kept).
+ pub fn is_empty(&self) -> bool {
+ self.label.is_none()
+ }
+}
+
+/// The identity a folder's record is kept under: its [`folder_key`]; `None` for a path that
+/// names no folder (empty, relative, `.`).
+pub(crate) fn record_key(path: &str) -> Option {
+ let key = folder_key(path);
+ (split_path(path, true).absolute && !key.is_empty()).then_some(key)
+}
+
/// A folder while the tree is being built (children by identity key).
struct Level {
name: String,
@@ -151,6 +183,53 @@ pub fn folder_label(path: &str) -> String {
}
impl Catalog {
+ /// What the library keeps about folder `path`, however it is spelled.
+ pub fn folder_record(&self, path: &str) -> Option<&FolderRecord> {
+ record_key(path).and_then(|k| self.folder_records.get(&k))
+ }
+
+ /// The colour label of folder `path`, however it is spelled.
+ pub fn folder_color_label(&self, path: &str) -> Option {
+ self.folder_record(path).and_then(|r| r.label)
+ }
+
+ /// The op that gives folder `path` colour label `label` (`None`: takes it off), keeping the
+ /// rest of its record. Applying it checks the path.
+ pub fn folder_label_op(&self, path: &str, label: Option) -> Op {
+ let mut record = self.folder_record(path).cloned().unwrap_or_default();
+ record.label = label;
+ Op::SetFolderRecord { folder: path.to_string(), record: Some(record) }
+ }
+
+ /// The ops that carry the records of folder `from` and the folders inside it over to `to`
+ /// (the same place below it), for a folder renamed or moved on disk: commit them with the
+ /// photos' relinks so undo puts both back. Records already at or below `to` are left from a
+ /// folder that is no longer there and are dropped (undo restores them). Empty when there is
+ /// nothing to carry or drop.
+ pub fn folder_records_follow(&self, from: &str, to: &str) -> Vec {
+ let (Some(root), Some(dest)) = (record_key(from), record_key(to)) else { return Vec::new() };
+ if root == dest {
+ return Vec::new();
+ }
+ let moved: Vec<(String, FolderRecord)> = self
+ .folder_records
+ .iter()
+ .filter(|(k, _)| key_within(k, &root))
+ .filter_map(|(k, r)| {
+ let rest = folder_rest(k, &root)?;
+ let base = dest.trim_end_matches('/');
+ let path = if rest.is_empty() { dest.clone() } else { format!("{base}/{}", rest.join("/")) };
+ Some((folder_key(&path), r.clone()))
+ })
+ .collect();
+ // what was known at the old place goes, and so does what is left at the new one (also
+ // when the moved folder carries nothing: the folder now there is not the one it was about)
+ let stale = self.folder_records.keys().filter(|k| key_within(k, &root) || key_within(k, &dest));
+ let mut ops: Vec = stale.map(|k| Op::SetFolderRecord { folder: k.clone(), record: None }).collect();
+ ops.extend(moved.into_iter().map(|(k, r)| Op::SetFolderRecord { folder: k, record: Some(r) }));
+ ops
+ }
+
/// The library's volumes with their folders and photo counts: volumes by name
/// (case-insensitive), each folder with its subfolders the same way.
pub fn folder_tree(&self) -> Vec {
@@ -196,7 +275,7 @@ impl Catalog {
l.own += n;
}
}
- let mut tree: Vec = roots.iter().filter_map(|k| build(k, &levels)).collect();
+ let mut tree: Vec = roots.iter().filter_map(|k| build(k, &levels, &self.folder_records)).collect();
sort(&mut tree);
// a folder that holds another disk's mount covers that disk's photos too
let mounts: Vec = tree.iter().filter(|v| v.path != "/").map(|v| v.path.clone()).collect();
@@ -206,12 +285,21 @@ impl Catalog {
}
/// The node for `key` with everything below it (at most [`MAX_DEPTH`] deep).
-fn build(key: &str, levels: &HashMap) -> Option {
+fn build(key: &str, levels: &HashMap, records: &BTreeMap) -> Option {
let l = levels.get(key)?;
- let mut children: Vec = l.children.iter().filter_map(|k| build(k, levels)).collect();
+ let mut children: Vec = l.children.iter().filter_map(|k| build(k, levels, records)).collect();
sort(&mut children);
let count = l.own + children.iter().map(|c| c.count).sum::();
- Some(FolderNode { name: l.name.clone(), path: l.path.clone(), count, own: l.own, volume: l.volume, selectable: true, children })
+ Some(FolderNode {
+ name: l.name.clone(),
+ path: l.path.clone(),
+ count,
+ own: l.own,
+ volume: l.volume,
+ selectable: true,
+ label: records.get(key).and_then(|r| r.label),
+ children,
+ })
}
fn mark_selectable(nodes: &mut [FolderNode], mounts: &[String]) {
diff --git a/crates/catalog/src/journal.rs b/crates/catalog/src/journal.rs
index 5973975d..f52d08db 100644
--- a/crates/catalog/src/journal.rs
+++ b/crates/catalog/src/journal.rs
@@ -46,6 +46,9 @@
//! | 1 | up to v0.2.0 | |
//! | 2 | after v0.2.0 | `Op::SetBrowsed`, `Catalog.browsed`, `Photo.local_baseline` |
//! | 3 | albums by hand | `Op::SetAlbumOrder`, `Album.order` |
+//! | 4 | Reload reads lens data | `Op::SetEmbeddedLens` |
+//! | 5 | the keyword list | `Op::SetKeyword`, `Catalog.keyword_list` |
+//! | 6 | folder labels | `Op::SetFolderRecord`, `Catalog.folder_records` |
//!
//! Rules:
//! - **Bump [`VERSION`]** (and add a row above) in the change that adds an [`Op`] variant or a
@@ -70,7 +73,7 @@ pub const LOG: &str = "catalog.log";
const FORMAT: &str = "lightcraft-catalog";
/// The catalog format this build writes (and the newest it reads). See the module docs →
/// *Format versions*; bump it whenever an [`Op`] variant or a serialized field is added.
-pub const VERSION: u32 = 3;
+pub const VERSION: u32 = 6;
/// When [`Journal::wants_snapshot`] says it's time to compact the log.
#[derive(Clone, Copy, Debug)]
diff --git a/crates/catalog/src/keywords.rs b/crates/catalog/src/keywords.rs
index dde02015..92a53195 100644
--- a/crates/catalog/src/keywords.rs
+++ b/crates/catalog/src/keywords.rs
@@ -5,8 +5,14 @@
//! changes — so they are a single undo step and replay from the op log like any other edit.
//! Keyword names compare case-insensitively; renaming a keyword renames its children too
//! (`travel|italy` → `trips|italy`).
+//!
+//! The **keyword list** holds keywords on their own ([`Op::SetKeyword`]): created before any
+//! photo has them, or given attributes ([`KeywordInfo`]: synonyms, export options, person). The
+//! tree is the keyword list together with the keywords photos carry.
-use serde::Serialize;
+use std::collections::BTreeMap;
+
+use serde::{Deserialize, Serialize};
use crate::{Catalog, CatalogError, Meta, Op, Result};
@@ -18,16 +24,135 @@ pub fn clean(k: &str) -> String {
k.split(SEP).map(str::trim).filter(|s| !s.is_empty()).collect::>().join("|")
}
+/// The same name, whatever the case of any of its letters (not only ASCII ones).
+pub fn same(a: &str, b: &str) -> bool {
+ a == b || a.to_lowercase() == b.to_lowercase()
+}
+
/// `k` is `parent` or one of its descendants (`parent|…`), ignoring case.
pub fn is_under(k: &str, parent: &str) -> bool {
let (k, p) = (k.to_lowercase(), parent.to_lowercase());
k == p || k.strip_prefix(&p).is_some_and(|rest| rest.starts_with(SEP))
}
-/// Replace the `from` prefix of `k` (which [`is_under`] `from`) with `to`.
-fn reparent(k: &str, from: &str, to: &str) -> String {
- let rest = &k[from.len().min(k.len())..];
- if to.is_empty() { rest.trim_start_matches(SEP).to_string() } else { format!("{to}{rest}") }
+/// Replace the `from` prefix of `k` (which [`is_under`] `from`) with `to`. By levels, not bytes:
+/// `from` may be written in another case, which can change a letter's length (ẞ / ß).
+pub fn reparent(k: &str, from: &str, to: &str) -> String {
+ let skip = from.split(SEP).filter(|s| !s.trim().is_empty()).count();
+ let rest: Vec<&str> = k.split(SEP).filter(|s| !s.trim().is_empty()).skip(skip).collect();
+ std::iter::once(to).filter(|t| !t.is_empty()).chain(rest).collect::>().join("|")
+}
+
+/// A file's keywords as a photo carries them: the paths of `lr:hierarchicalSubject`, then the
+/// names of `dc:subject` that aren't levels of those paths (Lightroom Classic writes each keyword's
+/// name, its parents' and synonyms flat besides its path). Each once, cleaned.
+pub fn from_file(flat: &[String], hierarchical: &[String]) -> Vec {
+ let mut out: Vec = hierarchical.iter().map(|k| clean(k)).collect();
+ out.extend(flat.iter().map(|k| clean(k)).filter(|k| !hierarchical.iter().any(|h| h.split(SEP).any(|level| same(level.trim(), k)))));
+ dedupe(&mut out);
+ out
+}
+
+/// The deepest a keyword list file may nest keywords: a file is input, so its nesting is bounded.
+pub const MAX_LEVELS: usize = 64;
+
+/// A keyword list as written by [`Catalog::keyword_list_text`]: the file's text, the keywords in
+/// it (paths), and what the format can't hold and so is left out (a keyword, with those inside it,
+/// as its path; a synonym as `path {synonym}`).
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub struct KeywordListFile {
+ pub text: String,
+ pub written: Vec,
+ pub unwritable: Vec,
+}
+
+/// Read a keyword list file (Lightroom Classic's format): a keyword a line, those inside it
+/// indented one tab more, a synonym in braces on a line of its own under its keyword, a keyword
+/// left out of export in square brackets. Gives each keyword's path and attributes, in the order of
+/// the file; a keyword twice is one. A line it can't read is an error that says which.
+pub fn parse_keyword_list(text: &str) -> Result> {
+ let bad = |n: usize, why: &str| CatalogError::Invalid(format!("line {n}: {why}"));
+ let text = text.strip_prefix('\u{feff}').unwrap_or(text);
+ // Windows, Unix and classic Mac line ends alike
+ let text = text.replace("\r\n", "\n").replace('\r', "\n");
+ let mut out: Vec<(String, KeywordInfo)> = Vec::new();
+ // where each keyword is in `out`, by lower-case path (a search of `out` per line was quadratic)
+ let mut index: std::collections::HashMap = std::collections::HashMap::new();
+ // the keyword at each level above the line: its path and where it is in `out`
+ let mut above: Vec<(String, usize)> = Vec::new();
+ for (i, line) in text.split('\n').enumerate() {
+ let n = i + 1;
+ if line.trim().is_empty() {
+ continue;
+ }
+ let depth = line.chars().take_while(|c| *c == '\t').count();
+ let rest = line.trim_start_matches('\t');
+ // levels are tabs: spaces would flatten the hierarchy without a word
+ if rest.starts_with(' ') {
+ return Err(bad(n, "keywords are indented with tabs, one per level, not spaces"));
+ }
+ if rest.chars().any(char::is_control) {
+ return Err(bad(n, "a control character in a keyword"));
+ }
+ let body = rest.trim();
+ if let Some(synonym) = body.strip_prefix('{').and_then(|b| b.strip_suffix('}')) {
+ let Some(at) = depth.checked_sub(1).and_then(|d| above.get(d)).map(|(_, at)| *at) else {
+ return Err(bad(n, "a synonym (in braces) goes one tab under its keyword"));
+ };
+ let synonym = synonym.trim();
+ if synonym.contains(SEP) {
+ return Err(bad(n, "“|” separates a keyword's levels: a synonym can't hold one"));
+ }
+ if let Some((_, info)) = out.get_mut(at)
+ && !synonym.is_empty()
+ && !info.synonyms.iter().any(|s| same(s, synonym))
+ {
+ info.synonyms.push(synonym.to_string());
+ }
+ continue;
+ }
+ if depth > above.len() {
+ return Err(bad(n, "indented more than one tab below the keyword above it"));
+ }
+ if depth >= MAX_LEVELS {
+ return Err(bad(n, &format!("keywords nest {MAX_LEVELS} levels deep at most")));
+ }
+ let (name, left_out) = match body.strip_prefix('[').and_then(|b| b.strip_suffix(']')) {
+ Some(name) => (name.trim(), true),
+ None => (body, false),
+ };
+ if name.is_empty() {
+ return Err(bad(n, "a keyword needs a name"));
+ }
+ if name.contains(SEP) {
+ return Err(bad(n, "“|” separates a keyword's levels: a name can't hold one"));
+ }
+ above.truncate(depth);
+ let path = match above.last() {
+ Some((parent, _)) => format!("{parent}{SEP}{name}"),
+ None => name.to_string(),
+ };
+ let at = match index.get(&path.to_lowercase()) {
+ Some(at) => *at,
+ None => {
+ out.push((path.clone(), KeywordInfo::default()));
+ index.insert(path.to_lowercase(), out.len() - 1);
+ out.len() - 1
+ }
+ };
+ if left_out && let Some((_, info)) = out.get_mut(at) {
+ info.include_on_export = false;
+ }
+ // as first spelled: the keywords inside a repeat take that spelling
+ let spelled = out.get(at).map_or(path, |(p, _)| p.clone());
+ above.push((spelled, at));
+ }
+ Ok(out)
+}
+
+/// The keyword of `from` that `k` is under, the closest one when several are (`a|b` before `a`).
+pub fn closest<'a>(k: &str, from: &'a [String]) -> Option<&'a String> {
+ from.iter().filter(|f| is_under(k, f)).max_by_key(|f| f.split(SEP).count())
}
/// Keep the first of case-insensitively equal keywords.
@@ -36,6 +161,66 @@ fn dedupe(v: &mut Vec) {
v.retain(|k| !k.is_empty() && seen.insert(k.to_lowercase()));
}
+/// What the keyword list knows about a keyword besides the photos that carry it: Lightroom
+/// Classic's keyword tag options. A keyword is listed when it was created on its own or given
+/// attributes; one that only photos carry has the defaults.
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct KeywordInfo {
+ /// Other words for it, exported with it (when `export_synonyms`).
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub synonyms: Vec,
+ /// Written into exported files at all.
+ #[serde(default = "yes")]
+ pub include_on_export: bool,
+ /// The keywords containing it are exported with it (`travel|italy` also gives `travel`).
+ #[serde(default = "yes")]
+ pub export_containing: bool,
+ #[serde(default = "yes")]
+ pub export_synonyms: bool,
+ /// The keyword names a person.
+ #[serde(default)]
+ pub person: bool,
+ /// New keywords go inside this one (Put New Keywords Inside This Keyword): one keyword at
+ /// most, see [`Catalog::default_keyword_parent`].
+ #[serde(default, skip_serializing_if = "std::ops::Not::not")]
+ pub new_keywords_inside: bool,
+}
+
+fn yes() -> bool {
+ true
+}
+
+impl Default for KeywordInfo {
+ fn default() -> Self {
+ KeywordInfo {
+ synonyms: Vec::new(),
+ include_on_export: true,
+ export_containing: true,
+ export_synonyms: true,
+ person: false,
+ new_keywords_inside: false,
+ }
+ }
+}
+
+/// A keyword in the library's keyword list: its path as written and its attributes.
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ListedKeyword {
+ pub path: String,
+ pub info: KeywordInfo,
+}
+
+/// A photo's keywords as an exported file carries them.
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub struct ExportKeywords {
+ /// Names, flat (`dc:subject`): each keyword's own, the keywords containing it and synonyms, as
+ /// their options say.
+ pub flat: Vec,
+ /// Full paths (`lr:hierarchicalSubject`).
+ pub hierarchical: Vec,
+}
+
/// One node of the keyword tree.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct KeywordNode {
@@ -63,9 +248,12 @@ impl Catalog {
}
let mut levels: HashMap = HashMap::new();
let mut this_photo: HashSet = HashSet::new();
- for p in self.photos().filter(|p| p.in_library()) {
+ // the keyword list first (no photos counted): a listed keyword is there without photos
+ let listed: Vec = self.listed_keywords().map(|k| k.path.clone()).collect();
+ let photos = self.photos().filter(|p| p.in_library()).map(|p| (true, p.meta.keywords.as_slice()));
+ for (counts, keywords) in std::iter::once((false, listed.as_slice())).chain(photos) {
this_photo.clear();
- for k in &p.meta.keywords {
+ for k in keywords {
let mut path = String::new();
let mut lower = String::new();
let mut parent: Option = None;
@@ -82,7 +270,7 @@ impl Catalog {
count: 0,
parent: parent.clone(),
});
- if this_photo.insert(lower.clone()) {
+ if counts && this_photo.insert(lower.clone()) {
l.count += 1;
}
parent = Some(lower.clone());
@@ -118,52 +306,434 @@ impl Catalog {
.collect()
}
- /// Rename `from` (and the keywords below it) to `to` on every photo — one batch. Renaming onto
- /// an existing keyword merges the two.
+ /// `SetKeyword` ops turning the keyword list into what `f` makes of it (lower-case path →
+ /// listing): what it no longer has is taken off first, then what is new or changed is set.
+ fn list_ops(&self, f: impl FnOnce(&mut BTreeMap)) -> Vec {
+ let before = &self.keyword_list;
+ let mut after = before.clone();
+ f(&mut after);
+ let gone = before.iter().filter(|(k, _)| !after.contains_key(*k)).map(|(_, l)| Op::SetKeyword { path: l.path.clone(), info: None });
+ let set = after
+ .iter()
+ .filter(|(k, l)| before.get(*k) != Some(*l))
+ .map(|(_, l)| Op::SetKeyword { path: l.path.clone(), info: Some(l.info.clone()) });
+ gone.chain(set).collect()
+ }
+
+ /// The keyword list once the keywords under each of `from` move to `to`: a listing landing on
+ /// one already there merges into it (that one's attributes stay, the synonyms gather).
+ fn list_move_ops(&self, from: &[String], to: &str) -> Vec {
+ self.list_ops(|list| {
+ // every moving listing leaves first, then each lands: one landing where another is
+ // about to leave isn't merged into it
+ let moving: Vec = list.iter().filter(|(_, l)| closest(&l.path, from).is_some()).map(|(k, _)| k.clone()).collect();
+ let left: Vec = moving.iter().filter_map(|key| list.remove(key)).collect();
+ for l in left {
+ let Some(f) = closest(&l.path, from) else { continue };
+ let path = reparent(&l.path, f, to);
+ match list.get_mut(&path.to_lowercase()) {
+ Some(there) => {
+ there.info.new_keywords_inside |= l.info.new_keywords_inside;
+ for s in l.info.synonyms {
+ if !there.info.synonyms.iter().any(|x| same(x, &s)) {
+ there.info.synonyms.push(s);
+ }
+ }
+ }
+ None => {
+ list.insert(path.to_lowercase(), ListedKeyword { path, info: l.info });
+ }
+ }
+ }
+ })
+ }
+
+ /// Rename `from` (and the keywords below it) to `to` on every photo and in the keyword list —
+ /// one batch. Renaming onto an existing keyword merges the two.
pub fn rename_keyword_ops(&self, from: &str, to: &str) -> Result {
let (from, to) = (clean(from), clean(to));
if from.is_empty() || to.is_empty() {
return Err(CatalogError::Invalid("keyword names can't be empty".into()));
}
- if is_under(&to, &from) && !to.eq_ignore_ascii_case(&from) {
+ if is_under(&to, &from) && !same(&to, &from) {
return Err(CatalogError::Invalid("can't move a keyword below itself".into()));
}
- let ops = self.keyword_ops(|kws| kws.iter().map(|k| if is_under(k, &from) { reparent(k, &from, &to) } else { k.clone() }).collect());
+ let mut ops = self.keyword_ops(|kws| kws.iter().map(|k| if is_under(k, &from) { reparent(k, &from, &to) } else { k.clone() }).collect());
+ ops.extend(self.list_move_ops(std::slice::from_ref(&from), &to));
Ok(Op::Batch { ops })
}
- /// Remove `keyword` and the keywords below it from every photo.
+ /// Remove `keyword` and the keywords below it from every photo and from the keyword list.
pub fn delete_keyword_ops(&self, keyword: &str) -> Result {
let k = clean(keyword);
if k.is_empty() {
return Err(CatalogError::Invalid("empty keyword".into()));
}
- Ok(Op::Batch { ops: self.keyword_ops(|kws| kws.iter().filter(|x| !is_under(x, &k)).cloned().collect()) })
+ let mut ops = self.keyword_ops(|kws| kws.iter().filter(|x| !is_under(x, &k)).cloned().collect());
+ ops.extend(self.list_ops(|list| list.retain(|_, l| !is_under(&l.path, &k))));
+ Ok(Op::Batch { ops })
}
/// Merge several keywords (with their children) into `into`.
pub fn merge_keywords_ops(&self, from: &[String], into: &str) -> Result {
let into = clean(into);
- let from: Vec = from.iter().map(|f| clean(f)).filter(|f| !f.is_empty() && !f.eq_ignore_ascii_case(&into)).collect();
+ let from: Vec = from.iter().map(|f| clean(f)).filter(|f| !f.is_empty() && !same(f, &into)).collect();
if into.is_empty() || from.is_empty() {
return Err(CatalogError::Invalid("merge needs keywords and a target".into()));
}
if from.iter().any(|f| is_under(&into, f)) {
return Err(CatalogError::Invalid("can't merge a keyword into one below it".into()));
}
- let ops = self.keyword_ops(|kws| {
- kws.iter().map(|k| from.iter().find(|f| is_under(k, f)).map(|f| reparent(k, f, &into)).unwrap_or_else(|| k.clone())).collect()
- });
+ let mut ops =
+ self.keyword_ops(|kws| kws.iter().map(|k| closest(k, &from).map(|f| reparent(k, f, &into)).unwrap_or_else(|| k.clone())).collect());
+ ops.extend(self.list_move_ops(&from, &into));
Ok(Op::Batch { ops })
}
+ /// What exported files carry for a photo with these keywords (Lightroom Classic's keyword tag
+ /// options, [`KeywordInfo`]): a keyword left out of export goes nowhere; otherwise its name
+ /// and full path, the keywords containing it that are exported themselves (unless "export
+ /// containing keywords" is off) and synonyms (unless "export synonyms" is off). Each name once,
+ /// whatever its case.
+ pub fn export_keywords(&self, keywords: &[String]) -> ExportKeywords {
+ let defaults = KeywordInfo::default();
+ let info = |path: &str| self.keyword_info(path).unwrap_or(&defaults);
+ let mut out = ExportKeywords::default();
+ let push = |v: &mut Vec, s: &str| {
+ if !s.is_empty() && !v.iter().any(|x| same(x, s)) {
+ v.push(s.to_string());
+ }
+ };
+ for k in keywords {
+ let path = clean(k);
+ let me = info(&path);
+ if path.is_empty() || !me.include_on_export {
+ continue;
+ }
+ let levels: Vec<&str> = path.split(SEP).collect();
+ let mut names = |at: usize, i: &KeywordInfo| {
+ if let Some(name) = levels.get(at) {
+ push(&mut out.flat, name);
+ }
+ if i.export_synonyms {
+ for s in &i.synonyms {
+ push(&mut out.flat, s.trim());
+ }
+ }
+ };
+ names(levels.len().saturating_sub(1), me);
+ if me.export_containing {
+ for at in (0..levels.len().saturating_sub(1)).rev() {
+ let parent = levels.get(..=at).map(|l| l.join("|")).unwrap_or_default();
+ let pi = info(&parent);
+ if pi.include_on_export {
+ names(at, pi);
+ }
+ }
+ }
+ push(&mut out.hierarchical, &path);
+ }
+ out
+ }
+
+ /// The keyword list as a file (Lightroom Classic's format, [`parse_keyword_list`]): every
+ /// keyword of the tree, those inside one indented a tab more, synonyms in braces under their
+ /// keyword, a keyword left out of export in square brackets; by name. What the format can't hold
+ /// is left out and named, so the file always reads back.
+ pub fn keyword_list_text(&self) -> KeywordListFile {
+ // a name the list would read as something else, or not at all
+ fn unwritable(name: &str) -> bool {
+ name.trim().is_empty()
+ || name != name.trim()
+ || name.chars().any(char::is_control)
+ // a byte-order mark: on the first line the reader takes it for the file's own
+ || name.starts_with('\u{feff}')
+ || (name.starts_with('[') && name.ends_with(']'))
+ || (name.starts_with('{') && name.ends_with('}'))
+ }
+ fn write(c: &Catalog, nodes: &[KeywordNode], depth: usize, file: &mut KeywordListFile) {
+ let tabs = "\t".repeat(depth);
+ for n in nodes {
+ // the keywords inside go with it: under its parent they'd be other keywords
+ if depth >= MAX_LEVELS || unwritable(&n.name) {
+ file.unwritable.push(n.path.clone());
+ continue;
+ }
+ let info = c.keyword_info(&n.path).cloned().unwrap_or_default();
+ if info.include_on_export {
+ file.text.push_str(&format!("{tabs}{}\n", n.name));
+ } else {
+ file.text.push_str(&format!("{tabs}[{}]\n", n.name));
+ }
+ file.written.push(n.path.clone());
+ // the reader trims synonyms and takes one per spelling: an empty one, or another's
+ // in a different case, is no loss; a line break or a “|” is
+ let mut synonyms: Vec<&str> = Vec::new();
+ for s in info.synonyms.iter().map(|s| s.trim()) {
+ if s.is_empty() || synonyms.iter().any(|x| same(x, s)) {
+ continue;
+ }
+ if s.chars().any(char::is_control) || s.contains(SEP) {
+ file.unwritable.push(format!("{} {{{s}}}", n.path));
+ } else {
+ synonyms.push(s);
+ file.text.push_str(&format!("{tabs}\t{{{s}}}\n"));
+ }
+ }
+ write(c, &n.children, depth + 1, file);
+ }
+ }
+ let mut file = KeywordListFile::default();
+ write(self, &self.keyword_tree(), 0, &mut file);
+ file
+ }
+
+ /// Import a keyword list (read with [`parse_keyword_list`]) — one batch: the keywords the
+ /// library doesn't have are added with the list's attributes, in the library's spelling of the
+ /// levels it has; those it has keep their attributes and gain the list's synonyms. Also says how
+ /// many keywords were added and how many gained synonyms.
+ pub fn import_keywords_ops(&self, entries: &[(String, KeywordInfo)]) -> (Op, usize, usize) {
+ // every keyword the library has, by lower-case path, as it spells it: learnt once from the
+ // tree (looking each line up among all the photos' keywords took minutes for big lists)
+ let mut known: std::collections::HashMap = std::collections::HashMap::new();
+ fn learn(nodes: &[KeywordNode], known: &mut std::collections::HashMap) {
+ for n in nodes {
+ known.insert(n.path.to_lowercase(), n.path.clone());
+ learn(&n.children, known);
+ }
+ }
+ learn(&self.keyword_tree(), &mut known);
+ // a path in the library's spelling of each level it has
+ let spell = |path: &str, known: &std::collections::HashMap| -> String {
+ let levels: Vec<&str> = path.split(SEP).collect();
+ let mut out = String::new();
+ for (i, level) in levels.iter().enumerate() {
+ if !out.is_empty() {
+ out.push(SEP);
+ }
+ let prefix = levels.get(..=i).map(|l| l.join("|").to_lowercase()).unwrap_or_default();
+ match known.get(&prefix).and_then(|p| p.rsplit(SEP).next()) {
+ Some(name) => out.push_str(name),
+ None => out.push_str(level),
+ }
+ }
+ out
+ };
+ let (mut added, mut updated) = (0, 0);
+ let ops = self.list_ops(|list| {
+ for (path, info) in entries {
+ let path = spell(&clean(path), &known);
+ let key = path.to_lowercase();
+ if path.is_empty() {
+ continue;
+ }
+ if !known.contains_key(&key) {
+ list.insert(key.clone(), ListedKeyword { path: path.clone(), info: KeywordInfo { new_keywords_inside: false, ..info.clone() } });
+ // it and its parents are the library's now: what follows spells them so
+ let levels: Vec<&str> = path.split(SEP).collect();
+ for i in 0..levels.len() {
+ let prefix = levels.get(..=i).map(|l| l.join("|")).unwrap_or_default();
+ known.entry(prefix.to_lowercase()).or_insert(prefix);
+ }
+ added += 1;
+ continue;
+ }
+ // one the library has: the list's synonyms join its own (listed only if it gains some)
+ let mut info_now = list.get(&key).map(|l| l.info.clone()).unwrap_or_default();
+ let before = info_now.synonyms.len();
+ for s in &info.synonyms {
+ if !info_now.synonyms.iter().any(|x| same(x, s)) {
+ info_now.synonyms.push(s.clone());
+ }
+ }
+ if info_now.synonyms.len() > before {
+ let path = list.get(&key).map_or(path, |l| l.path.clone());
+ list.insert(key, ListedKeyword { path, info: info_now });
+ updated += 1;
+ }
+ }
+ });
+ (Op::Batch { ops }, added, updated)
+ }
+
+ /// The keyword is in the tree: listed or on a library photo (not one in Recently Deleted or
+ /// only browsed), itself or one below it.
+ pub fn has_keyword(&self, path: &str) -> bool {
+ self.keyword_path(path).is_some()
+ }
+
+ /// Where a keyword typed for a photo goes: inside the default parent when it is a new name (Put
+ /// New Keywords Inside This Keyword); a keyword the library has, or a path typed whole, as
+ /// typed (cleaned).
+ pub fn typed_keyword(&self, typed: &str) -> String {
+ let k = clean(typed);
+ match self.default_keyword_parent() {
+ Some(parent) if !k.is_empty() && !k.contains(SEP) && !self.has_keyword(&k) => format!("{parent}{SEP}{k}"),
+ _ => k,
+ }
+ }
+
+ /// `path` with each level the library has spelled as the library spells it (`TRAVEL|Rome` next
+ /// to `travel|italy` is `travel|Rome`).
+ fn spelled(&self, path: &str) -> String {
+ let levels: Vec<&str> = path.split(SEP).collect();
+ let mut out = String::new();
+ for (i, level) in levels.iter().enumerate() {
+ let prefix = levels.get(..=i).map(|l| l.join("|")).unwrap_or_default();
+ let name = self.keyword_path(&prefix).and_then(|p| p.rsplit(SEP).next().map(str::to_string)).unwrap_or_else(|| level.to_string());
+ if !out.is_empty() {
+ out.push(SEP);
+ }
+ out.push_str(&name);
+ }
+ out
+ }
+
+ /// The keyword new keywords go inside (Put New Keywords Inside This Keyword), if any.
+ pub fn default_keyword_parent(&self) -> Option {
+ self.keyword_list.values().find(|l| l.info.new_keywords_inside).map(|l| l.path.clone())
+ }
+
+ /// Make `keyword` the one new keywords go inside (`None`: none) — one batch. It is listed if it
+ /// wasn't, keeping the attributes it has.
+ pub fn set_default_parent_ops(&self, keyword: Option<&str>) -> Result {
+ let path = match keyword {
+ Some(k) => Some(self.keyword_path(k).ok_or_else(|| CatalogError::Invalid(format!("no keyword “{}”", clean(k))))?),
+ None => None,
+ };
+ Ok(Op::Batch {
+ ops: self.list_ops(|list| {
+ for l in list.values_mut() {
+ l.info.new_keywords_inside = false;
+ }
+ if let Some(path) = path {
+ let l = list.entry(path.to_lowercase()).or_insert_with(|| ListedKeyword { path, info: KeywordInfo::default() });
+ l.info.new_keywords_inside = true;
+ }
+ }),
+ })
+ }
+
+ /// The keyword as written in the library (whatever the case of `path`): its listing, or the
+ /// levels a photo's keyword spells it with. `None` when there is no such keyword.
+ pub fn keyword_path(&self, path: &str) -> Option {
+ let k = clean(path);
+ if k.is_empty() {
+ return None;
+ }
+ if let Some(l) = self.keyword_list.get(&k.to_lowercase()) {
+ return Some(l.path.clone());
+ }
+ // the levels of a listed keyword below it, or of a photo's
+ let levels = k.split(SEP).count();
+ let listed = self.keyword_list.values().map(|l| &l.path);
+ listed
+ .chain(self.photos().filter(|p| p.in_library()).flat_map(|p| p.meta.keywords.iter()))
+ .find(|x| is_under(x, &k))
+ .map(|x| x.split(SEP).map(str::trim).filter(|s| !s.is_empty()).take(levels).collect::>().join("|"))
+ }
+
+ /// Create a keyword with these attributes, and give it to `photos` — one batch. A keyword that
+ /// exists already is refused.
+ pub fn create_keyword_ops(&self, path: &str, info: KeywordInfo, photos: &[crate::PhotoId]) -> Result {
+ let path = clean(path);
+ if path.is_empty() {
+ return Err(CatalogError::Invalid("keyword names can't be empty".into()));
+ }
+ if self.has_keyword(&path) {
+ return Err(CatalogError::KeywordExists(path));
+ }
+ let path = self.spelled(&path);
+ let mut ops = vec![Op::SetKeyword { path: path.clone(), info: Some(info) }];
+ for id in photos {
+ let p = self.photo(*id).ok_or(CatalogError::NoPhoto(*id))?;
+ let mut meta = p.meta.clone();
+ meta.keywords.push(path.clone());
+ dedupe(&mut meta.keywords);
+ ops.push(Op::SetMeta { id: *id, meta: Box::new(meta) });
+ }
+ Ok(Op::Batch { ops })
+ }
+
+ /// Move `keyword` (with the keywords below it) inside `parent`, or to the top level (`None`).
+ /// It keeps its name. Onto a keyword that is there already the two merge, which only happens
+ /// with `merge`; otherwise [`CatalogError::KeywordExists`].
+ pub fn move_keyword_ops(&self, keyword: &str, parent: Option<&str>, merge: bool) -> Result {
+ let Some(from) = self.keyword_path(keyword) else {
+ return Err(CatalogError::Invalid(format!("no keyword “{}”", clean(keyword))));
+ };
+ let leaf = from.rsplit(SEP).next().unwrap_or(&from);
+ let to = match parent.map(clean).filter(|p| !p.is_empty()) {
+ Some(p) if is_under(&p, &from) => return Err(CatalogError::Invalid("can't move a keyword inside itself".into())),
+ Some(p) => format!("{}{SEP}{leaf}", self.keyword_path(&p).unwrap_or(p)),
+ None => leaf.to_string(),
+ };
+ if same(&to, &from) {
+ return Ok(Op::Batch { ops: vec![] });
+ }
+ if !merge && self.has_keyword(&to) {
+ return Err(CatalogError::KeywordExists(to));
+ }
+ self.rename_keyword_ops(&from, &to)
+ }
+
+ /// Rename `keyword`'s last level to `name` (it stays where it is) and set its attributes — one
+ /// batch. A name that is taken is refused ([`CatalogError::KeywordExists`]): merging is its own
+ /// action.
+ pub fn edit_keyword_ops(&self, keyword: &str, name: &str, info: KeywordInfo) -> Result {
+ let Some(from) = self.keyword_path(keyword) else {
+ return Err(CatalogError::Invalid(format!("no keyword “{}”", clean(keyword))));
+ };
+ let name = name.trim();
+ if name.is_empty() || name.contains(SEP) {
+ return Err(CatalogError::Invalid("a keyword's name is one level, without “|”".into()));
+ }
+ let to = match from.rsplit_once(SEP) {
+ Some((parent, _)) => format!("{parent}{SEP}{name}"),
+ None => name.to_string(),
+ };
+ let mut ops = Vec::new();
+ if to != from {
+ if !same(&to, &from) && self.has_keyword(&to) {
+ return Err(CatalogError::KeywordExists(to));
+ }
+ if let Op::Batch { ops: renamed } = self.rename_keyword_ops(&from, &to)? {
+ ops = renamed;
+ }
+ }
+ // set the attributes only when they change something: an unlisted keyword with the default
+ // ones stays unlisted
+ let now = self.keyword_info(&from).cloned();
+ let changed = match &now {
+ Some(now) => *now != info || to != from,
+ None => info != KeywordInfo::default(),
+ };
+ if changed {
+ ops.push(Op::SetKeyword { path: to, info: Some(info) });
+ }
+ Ok(Op::Batch { ops })
+ }
+
+ /// Take off the keyword list the keywords no library photo has, nor any keyword below them — one
+ /// batch.
+ pub fn purge_unused_keywords_ops(&self) -> Op {
+ let used: Vec<&String> = self.photos().filter(|p| p.in_library()).flat_map(|p| p.meta.keywords.iter()).collect();
+ Op::Batch { ops: self.list_ops(|list| list.retain(|_, l| used.iter().any(|k| is_under(k, &l.path)))) }
+ }
+
/// Keyword suggestions for a photo that has `current` keywords: with a typed `prefix`, the
/// library's keywords containing it (those starting with it first); otherwise keywords that
/// appear together with `current` on other photos, then the most used ones. Most frequent
/// first, at most `n`, never one of `current`.
pub fn keyword_suggestions(&self, current: &[String], prefix: &str, n: usize) -> Vec {
- let all = self.keywords();
- let has = |k: &str| current.iter().any(|c| c.eq_ignore_ascii_case(k));
+ // the photos' keywords with their counts, and those only the list has (none)
+ let mut all = self.keywords();
+ for l in self.keyword_list.values() {
+ if !all.iter().any(|(k, _)| same(k, &l.path)) {
+ all.push((l.path.clone(), 0));
+ }
+ }
+ let has = |k: &str| current.iter().any(|c| same(c, k));
let q = prefix.trim().to_lowercase();
let mut scored: Vec<(i64, String)> = if !q.is_empty() {
all.into_iter()
@@ -263,6 +833,600 @@ mod tests {
assert_eq!(clean(" a | |b "), "a|b");
}
+ fn tree_paths(nodes: &[KeywordNode]) -> Vec<(String, usize)> {
+ let mut out = Vec::new();
+ for n in nodes {
+ out.push((n.path.clone(), n.count));
+ out.extend(tree_paths(&n.children));
+ }
+ out
+ }
+
+ /// A keyword can be listed before any photo has it (Lightroom Classic's Create Keyword Tag):
+ /// it is in the tree with no photos, its parents too, and undo takes it away.
+ #[test]
+ fn a_keyword_listed_with_no_photos_is_in_the_tree() {
+ let (mut c, _) = lib(&[&["travel|France"]]);
+ let undo = c.apply(Op::SetKeyword { path: " travel | Italy ".into(), info: Some(KeywordInfo::default()) }).unwrap();
+ assert_eq!(tree_paths(&c.keyword_tree()), [("travel".to_string(), 1), ("travel|France".to_string(), 1), ("travel|Italy".to_string(), 0)]);
+ assert_eq!(c.keyword_info("TRAVEL|italy"), Some(&KeywordInfo::default()), "found whatever the case");
+ c.apply(undo).unwrap();
+ assert_eq!(tree_paths(&c.keyword_tree()), [("travel".to_string(), 1), ("travel|France".to_string(), 1)]);
+ assert_eq!(c.keyword_info("travel|Italy"), None);
+ }
+
+ /// A listed keyword needs a name, and its attributes start as Lightroom Classic's do: exported,
+ /// with the keywords containing it and its synonyms; not a person.
+ #[test]
+ fn a_listed_keyword_needs_a_name_and_starts_exported() {
+ let mut c = Catalog::new();
+ assert!(c.apply(Op::SetKeyword { path: " | ".into(), info: Some(KeywordInfo::default()) }).is_err());
+ let info = KeywordInfo::default();
+ assert!(info.include_on_export && info.export_containing && info.export_synonyms && !info.person && info.synonyms.is_empty());
+ }
+
+ /// Changing a listed keyword's attributes is undone to the attributes it had, under the name as
+ /// it was written.
+ #[test]
+ fn changing_a_listed_keyword_is_undone_to_what_it_was() {
+ let mut c = Catalog::new();
+ c.apply(Op::SetKeyword { path: "Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap();
+ let person = KeywordInfo { person: true, synonyms: vec!["Marriage".into()], ..KeywordInfo::default() };
+ let undo = c.apply(Op::SetKeyword { path: "weddings".into(), info: Some(person.clone()) }).unwrap();
+ assert_eq!(c.keyword_info("Weddings"), Some(&person));
+ assert_eq!(undo, Op::SetKeyword { path: "Weddings".into(), info: Some(KeywordInfo::default()) });
+ c.apply(undo).unwrap();
+ assert_eq!(c.keyword_info("weddings"), Some(&KeywordInfo::default()));
+ }
+
+ fn listed(c: &Catalog) -> Vec<(String, KeywordInfo)> {
+ let mut v: Vec<(String, KeywordInfo)> = c.listed_keywords().map(|k| (k.path.clone(), k.info.clone())).collect();
+ v.sort_by_key(|(p, _)| p.to_lowercase());
+ v
+ }
+
+ fn with_synonyms(words: &[&str]) -> KeywordInfo {
+ KeywordInfo { synonyms: words.iter().map(|w| w.to_string()).collect(), ..KeywordInfo::default() }
+ }
+
+ /// Renaming (or moving) a keyword takes its attributes and its listed children along, photos
+ /// or not, in the same undo step.
+ #[test]
+ fn renaming_a_keyword_carries_its_listing_along() {
+ let (mut c, ids) = lib(&[&["travel|italy"]]);
+ c.apply(Op::SetKeyword { path: "travel".into(), info: Some(with_synonyms(&["trip"])) }).unwrap();
+ c.apply(Op::SetKeyword { path: "travel|Spain".into(), info: Some(KeywordInfo::default()) }).unwrap();
+ let before = c.to_snapshot();
+ let op = c.rename_keyword_ops("Travel", "Places|Europe").unwrap();
+ let undo = c.apply(op).unwrap();
+ assert_eq!(kws(&c, ids[0]), ["Places|Europe|italy"]);
+ assert_eq!(
+ listed(&c),
+ [("Places|Europe".to_string(), with_synonyms(&["trip"])), ("Places|Europe|Spain".to_string(), KeywordInfo::default())]
+ );
+ c.apply(undo).unwrap();
+ assert_eq!(c.to_snapshot(), before);
+ }
+
+ /// A keyword only the list holds (no photos) can be renamed too.
+ #[test]
+ fn a_keyword_without_photos_can_be_renamed() {
+ let mut c = Catalog::new();
+ c.apply(Op::SetKeyword { path: "weddings".into(), info: Some(KeywordInfo::default()) }).unwrap();
+ let op = c.rename_keyword_ops("weddings", "Events|Weddings").unwrap();
+ c.apply(op).unwrap();
+ assert_eq!(listed(&c), [("Events|Weddings".to_string(), KeywordInfo::default())]);
+ }
+
+ /// Merging keeps the target's attributes and adds the merged keyword's synonyms to them; a target
+ /// not listed yet takes the merged keyword's attributes.
+ #[test]
+ fn merging_keeps_the_targets_attributes_and_gathers_synonyms() {
+ let (mut c, _) = lib(&[&["holiday"], &["travel"]]);
+ let person = KeywordInfo { person: true, ..with_synonyms(&["trip"]) };
+ c.apply(Op::SetKeyword { path: "travel".into(), info: Some(person.clone()) }).unwrap();
+ c.apply(Op::SetKeyword {
+ path: "holiday".into(),
+ info: Some(KeywordInfo { include_on_export: false, ..with_synonyms(&["vacation", "Trip"]) }),
+ })
+ .unwrap();
+ let op = c.merge_keywords_ops(&["holiday".into()], "travel").unwrap();
+ c.apply(op).unwrap();
+ assert_eq!(listed(&c), [("travel".to_string(), KeywordInfo { person: true, ..with_synonyms(&["trip", "vacation"]) })]);
+ // onto a keyword that isn't listed: it takes the merged one's attributes
+ let mut c = Catalog::new();
+ c.apply(Op::SetKeyword { path: "holiday".into(), info: Some(with_synonyms(&["vacation"])) }).unwrap();
+ let op = c.merge_keywords_ops(&["holiday".into()], "Travel").unwrap();
+ c.apply(op).unwrap();
+ assert_eq!(listed(&c), [("Travel".to_string(), with_synonyms(&["vacation"]))]);
+ }
+
+ /// Deleting a keyword takes it, and the keywords below it, off the list as well as off photos.
+ #[test]
+ fn deleting_a_keyword_takes_it_off_the_list() {
+ let (mut c, _) = lib(&[&["travel|italy"], &["beach"]]);
+ for path in ["travel", "travel|spain", "beach"] {
+ c.apply(Op::SetKeyword { path: path.into(), info: Some(KeywordInfo::default()) }).unwrap();
+ }
+ let before = c.to_snapshot();
+ let op = c.delete_keyword_ops("travel").unwrap();
+ let undo = c.apply(op).unwrap();
+ assert_eq!(listed(&c), [("beach".to_string(), KeywordInfo::default())]);
+ assert_eq!(tree_paths(&c.keyword_tree()), [("beach".to_string(), 1)]);
+ c.apply(undo).unwrap();
+ assert_eq!(c.to_snapshot(), before);
+ }
+
+ /// Creating a keyword lists it with its attributes, and can tag photos with it in the same undo
+ /// step. A keyword that exists already (listed, or on a photo) isn't created again.
+ #[test]
+ fn creating_a_keyword_lists_it_and_can_tag_photos() {
+ let (mut c, ids) = lib(&[&["beach"], &[]]);
+ let before = c.to_snapshot();
+ let op = c.create_keyword_ops("Events | Weddings", with_synonyms(&["marriage"]), &[ids[1]]).unwrap();
+ let undo = c.apply(op).unwrap();
+ assert_eq!(listed(&c), [("Events|Weddings".to_string(), with_synonyms(&["marriage"]))]);
+ assert_eq!(kws(&c, ids[1]), ["Events|Weddings"]);
+ c.apply(undo).unwrap();
+ assert_eq!(c.to_snapshot(), before);
+ for exists in ["BEACH", "events|weddings"] {
+ if exists == "events|weddings" {
+ let op = c.create_keyword_ops("Events|Weddings", KeywordInfo::default(), &[]).unwrap();
+ c.apply(op).unwrap();
+ }
+ assert!(c.create_keyword_ops(exists, KeywordInfo::default(), &[]).is_err(), "{exists}");
+ }
+ assert!(c.create_keyword_ops(" | ", KeywordInfo::default(), &[]).is_err());
+ }
+
+ /// Moving a keyword nests it inside another (or takes it to the top level), with its children,
+ /// on photos and in the list. It can't go inside itself or one of its children, and moving it
+ /// where it already is changes nothing.
+ #[test]
+ fn moving_a_keyword_nests_it_inside_another() {
+ let (mut c, ids) = lib(&[&["Italy|Rome"], &["Europe"]]);
+ // typed in any case, the keywords keep the spelling the library has
+ let op = c.move_keyword_ops("italy", Some("EUROPE"), false).unwrap();
+ c.apply(op).unwrap();
+ assert_eq!(kws(&c, ids[0]), ["Europe|Italy|Rome"]);
+ let op = c.move_keyword_ops("europe|italy", None, false).unwrap();
+ c.apply(op).unwrap();
+ assert_eq!(kws(&c, ids[0]), ["Italy|Rome"], "back at the top level");
+ assert!(c.move_keyword_ops("italy", Some("italy|rome"), false).is_err(), "inside its own child");
+ assert!(c.move_keyword_ops("italy", Some("Italy"), false).is_err(), "inside itself");
+ assert_eq!(c.move_keyword_ops("italy|rome", Some("italy"), false).unwrap(), Op::Batch { ops: vec![] }, "already there");
+ assert!(c.move_keyword_ops("lisbon", Some("europe"), false).is_err(), "no such keyword");
+ }
+
+ /// Moving onto a name that is taken (Europe already has a Rome) merges the two, so the move
+ /// asks first: refused unless merging is allowed.
+ #[test]
+ fn moving_onto_a_taken_name_merges_only_when_allowed() {
+ let (mut c, ids) = lib(&[&["rome"], &["europe|rome"]]);
+ let err = c.move_keyword_ops("rome", Some("europe"), false).unwrap_err();
+ assert!(matches!(err, CatalogError::KeywordExists(ref k) if k == "europe|rome"), "{err:?}");
+ let op = c.move_keyword_ops("rome", Some("europe"), true).unwrap();
+ c.apply(op).unwrap();
+ assert_eq!((kws(&c, ids[0]), kws(&c, ids[1])), (vec!["europe|rome".to_string()], vec!["europe|rome".to_string()]));
+ }
+
+ /// Editing a keyword renames it (its last level: it stays where it is) and sets its attributes,
+ /// in one undo step. A name that is taken is refused: merging is its own action.
+ #[test]
+ fn editing_a_keyword_renames_it_and_sets_its_attributes() {
+ let (mut c, ids) = lib(&[&["travel|italy"], &["travel|spain"]]);
+ let before = c.to_snapshot();
+ let person = KeywordInfo { person: true, ..KeywordInfo::default() };
+ let op = c.edit_keyword_ops("travel|italy", "Italia", person.clone()).unwrap();
+ let undo = c.apply(op).unwrap();
+ assert_eq!(kws(&c, ids[0]), ["travel|Italia"]);
+ assert_eq!(c.keyword_info("travel|italia"), Some(&person));
+ c.apply(undo).unwrap();
+ assert_eq!(c.to_snapshot(), before);
+ // attributes alone
+ let op = c.edit_keyword_ops("travel", "travel", with_synonyms(&["trip"])).unwrap();
+ c.apply(op).unwrap();
+ assert_eq!(listed(&c), [("travel".to_string(), with_synonyms(&["trip"]))]);
+ assert!(matches!(c.edit_keyword_ops("travel|italy", "Spain", KeywordInfo::default()), Err(CatalogError::KeywordExists(_))));
+ assert!(c.edit_keyword_ops("travel|italy", "a|b", KeywordInfo::default()).is_err(), "a name, not a path");
+ assert!(c.edit_keyword_ops("travel|italy", " ", KeywordInfo::default()).is_err());
+ }
+
+ /// Purging takes off the list the keywords no photo has (nor any keyword below them), in one
+ /// undo step; keywords photos carry stay.
+ #[test]
+ fn purging_takes_unused_keywords_off_the_list() {
+ let (mut c, _) = lib(&[&["travel|italy"]]);
+ for path in ["travel", "travel|italy", "travel|spain", "weddings"] {
+ c.apply(Op::SetKeyword { path: path.into(), info: Some(KeywordInfo::default()) }).unwrap();
+ }
+ let op = c.purge_unused_keywords_ops();
+ c.apply(op).unwrap();
+ assert_eq!(listed(&c).iter().map(|(p, _)| p.as_str()).collect::>(), ["travel", "travel|italy"]);
+ assert_eq!(tree_paths(&c.keyword_tree()), [("travel".to_string(), 1), ("travel|italy".to_string(), 1)]);
+ }
+
+ /// Renaming matches names whatever their case, also where a letter's case changes its length
+ /// (ẞ is three bytes, ß two): the levels below are kept whole, never cut mid-letter.
+ #[test]
+ fn renaming_keeps_the_levels_below_whatever_the_case() {
+ let (mut c, ids) = lib(&[&["straße|nord"], &["ßa|ü"]]);
+ let op = c.rename_keyword_ops("STRAẞE", "Road").unwrap();
+ c.apply(op).unwrap();
+ assert_eq!(kws(&c, ids[0]), ["Road|nord"]);
+ let op = c.rename_keyword_ops("ẞA", "Weg").unwrap();
+ c.apply(op).unwrap();
+ assert_eq!(kws(&c, ids[1]), ["Weg|ü"]);
+ }
+
+ fn exported(c: &Catalog, keywords: &[&str]) -> (Vec, Vec) {
+ let e = c.export_keywords(&keywords.iter().map(|k| k.to_string()).collect::>());
+ (e.flat, e.hierarchical)
+ }
+
+ /// By default a keyword is exported as its name together with the keywords containing it (flat,
+ /// `dc:subject`), and as its full path (`lr:hierarchicalSubject`); each name once.
+ #[test]
+ fn a_keyword_exports_its_name_its_parents_and_its_path() {
+ let c = Catalog::new();
+ let (flat, paths) = exported(&c, &["travel|Italy|Rome", "Travel|Spain", "beach"]);
+ assert_eq!(flat, ["Rome", "Italy", "travel", "Spain", "beach"]);
+ assert_eq!(paths, ["travel|Italy|Rome", "Travel|Spain", "beach"]);
+ }
+
+ /// A keyword not included on export is left out, path and all; a parent left out (a keyword that
+ /// only organizes others) isn't exported as a containing keyword, but its children are.
+ #[test]
+ fn keywords_left_out_of_export_stay_out() {
+ let mut c = Catalog::new();
+ let out = KeywordInfo { include_on_export: false, ..KeywordInfo::default() };
+ c.apply(Op::SetKeyword { path: "Places".into(), info: Some(out.clone()) }).unwrap();
+ c.apply(Op::SetKeyword { path: "draft".into(), info: Some(out) }).unwrap();
+ let (flat, paths) = exported(&c, &["Places|Lisbon", "draft"]);
+ assert_eq!(flat, ["Lisbon"]);
+ assert_eq!(paths, ["Places|Lisbon"]);
+ }
+
+ /// Without "export containing keywords" only the keyword's own name goes out flat; synonyms go
+ /// out with it unless "export synonyms" is off.
+ #[test]
+ fn containing_keywords_and_synonyms_follow_their_options() {
+ let mut c = Catalog::new();
+ let alone = KeywordInfo { export_containing: false, ..with_synonyms(&["Lisboa"]) };
+ c.apply(Op::SetKeyword { path: "Places|Lisbon".into(), info: Some(alone) }).unwrap();
+ let quiet = KeywordInfo { export_synonyms: false, ..with_synonyms(&["marriage"]) };
+ c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(quiet) }).unwrap();
+ let (flat, _) = exported(&c, &["Places|Lisbon", "Events|Weddings"]);
+ assert_eq!(flat, ["Lisbon", "Lisboa", "Weddings", "Events"]);
+ }
+
+ /// Names match whatever the case of any letter, not only ASCII ones: changing only the case of
+ /// "Ärzte" is a rename, not a clash with itself, and moving it where it is changes nothing.
+ #[test]
+ fn names_match_whatever_the_case_of_any_letter() {
+ let (mut c, ids) = lib(&[&["Ärzte|Wien"]]);
+ let op = c.edit_keyword_ops("ärzte", "ÄRZTE", KeywordInfo::default()).unwrap();
+ c.apply(op).unwrap();
+ assert_eq!(kws(&c, ids[0]), ["ÄRZTE|Wien"]);
+ assert_eq!(c.move_keyword_ops("ärzte|wien", Some("ärzte"), false).unwrap(), Op::Batch { ops: vec![] });
+ let op = c.rename_keyword_ops("ärzte", "Ärzte").unwrap();
+ c.apply(op).unwrap();
+ assert_eq!(kws(&c, ids[0]), ["Ärzte|Wien"]);
+ assert!(c.create_keyword_ops("ärzte", KeywordInfo::default(), &[]).is_err(), "exists");
+ }
+
+ /// A keyword that is only the parent of a listed one ("Events" of "Events|Weddings") is a
+ /// keyword like any other: it is in the tree, so it can be found, moved and renamed.
+ #[test]
+ fn the_parent_of_a_listed_keyword_is_a_keyword() {
+ let mut c = Catalog::new();
+ c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap();
+ assert!(c.has_keyword("events"));
+ assert_eq!(c.keyword_path("EVENTS").as_deref(), Some("Events"));
+ let op = c.edit_keyword_ops("events", "Occasions", KeywordInfo::default()).unwrap();
+ c.apply(op).unwrap();
+ assert_eq!(tree_paths(&c.keyword_tree()), [("Occasions".to_string(), 0), ("Occasions|Weddings".to_string(), 0)]);
+ }
+
+ /// Photos in Recently Deleted don't count: a keyword only they have isn't in the tree, so it
+ /// can be created again and is purged as unused, and its spelling there isn't the library's.
+ #[test]
+ fn deleted_photos_dont_hold_keywords() {
+ let (mut c, ids) = lib(&[&["Weddings"], &["travel"]]);
+ c.apply(Op::SetDeleted { id: ids[0], deleted: true }).unwrap();
+ assert!(!c.has_keyword("weddings"));
+ assert!(c.create_keyword_ops("weddings", KeywordInfo::default(), &[]).is_ok());
+ c.apply(Op::SetKeyword { path: "Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap();
+ let op = c.purge_unused_keywords_ops();
+ c.apply(op).unwrap();
+ assert!(listed(&c).is_empty(), "only a deleted photo has it: unused");
+ }
+
+ /// Merging several keywords moves each keyword under them by the closest one, whatever the
+ /// order they are given in, and every listing lands where its keyword does: one landing where
+ /// another is about to leave isn't merged into it.
+ #[test]
+ fn merging_overlapping_keywords_keeps_every_listing() {
+ let (mut c, ids) = lib(&[&["a|b|c"], &["a|c"]]);
+ let person = KeywordInfo { person: true, ..KeywordInfo::default() };
+ c.apply(Op::SetKeyword { path: "a|b|c".into(), info: Some(person.clone()) }).unwrap();
+ c.apply(Op::SetKeyword { path: "a|c".into(), info: Some(with_synonyms(&["sea"])) }).unwrap();
+ let op = c.merge_keywords_ops(&["a|b".into(), "a|c".into()], "a").unwrap();
+ c.apply(op).unwrap();
+ assert_eq!((kws(&c, ids[0]), kws(&c, ids[1])), (vec!["a|c".to_string()], vec!["a".to_string()]));
+ assert_eq!(listed(&c), [("a".to_string(), with_synonyms(&["sea"])), ("a|c".to_string(), person)]);
+ // the order of the keywords merged doesn't matter
+ let merged = |from: [&str; 2]| {
+ let (mut c, ids) = lib(&[&["a|b|c", "a|d"]]);
+ let op = c.merge_keywords_ops(&from.map(String::from), "x").unwrap();
+ c.apply(op).unwrap();
+ kws(&c, ids[0])
+ };
+ assert_eq!(merged(["a", "a|b"]), merged(["a|b", "a"]));
+ assert_eq!(merged(["a", "a|b"]), ["x|c", "x|d"]);
+ }
+
+ /// One keyword at most is where new keywords go (Put New Keywords Inside This Keyword). Making
+ /// another one so is one undo step; the mark moves with its keyword (also into a merge) and
+ /// goes with it.
+ #[test]
+ fn one_keyword_is_where_new_keywords_go() {
+ let (mut c, _) = lib(&[&["Events|Weddings"], &["Travel"]]);
+ let op = c.set_default_parent_ops(Some("events")).unwrap();
+ let undo = c.apply(op).unwrap();
+ assert_eq!(c.default_keyword_parent().as_deref(), Some("Events"));
+ let op = c.set_default_parent_ops(Some("travel")).unwrap();
+ c.apply(op).unwrap();
+ assert_eq!(c.default_keyword_parent().as_deref(), Some("Travel"), "only one");
+ assert!(c.set_default_parent_ops(Some("lisbon")).is_err());
+ let op = c.rename_keyword_ops("travel", "Trips").unwrap();
+ c.apply(op).unwrap();
+ assert_eq!(c.default_keyword_parent().as_deref(), Some("Trips"));
+ let op = c.merge_keywords_ops(&["trips".into()], "Events").unwrap();
+ c.apply(op).unwrap();
+ assert_eq!(c.default_keyword_parent().as_deref(), Some("Events"), "into the merge");
+ let op = c.delete_keyword_ops("events").unwrap();
+ c.apply(op).unwrap();
+ assert_eq!(c.default_keyword_parent(), None);
+ let op = c.set_default_parent_ops(None).unwrap();
+ assert_eq!(op, Op::Batch { ops: vec![] }, "none to clear");
+ c.apply(undo).unwrap();
+ assert_eq!(c.default_keyword_parent(), None, "undo of the first: none was");
+ }
+
+ /// A file's keywords as a photo carries them: the paths of `lr:hierarchicalSubject`, and the
+ /// names of `dc:subject` that aren't levels of those paths (an exported `travel|Italy` comes
+ /// back as one keyword, not three). Without paths, the names as they are.
+ #[test]
+ fn a_files_keywords_keep_their_hierarchy() {
+ let v = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::>();
+ assert_eq!(from_file(&v(&["Rome", "Italy", "travel", "beach"]), &v(&["travel|Italy|Rome"])), ["travel|Italy|Rome", "beach"]);
+ assert_eq!(from_file(&v(&["ROME"]), &v(&["travel|Rome", " travel | rome "])), ["travel|Rome"], "each once, cleaned");
+ assert_eq!(from_file(&v(&["beach", " sea "]), &[]), ["beach", "sea"]);
+ assert_eq!(from_file(&v(&["a|b"]), &[]), ["a|b"], "LightCraft's own sidecars write paths flat");
+ }
+
+ /// A new keyword inside one the library has takes the library's spelling of it: creating
+ /// "TRAVEL|rome" next to "travel|italy" doesn't respell "travel".
+ #[test]
+ fn a_new_keyword_keeps_the_spelling_of_its_parents() {
+ let (mut c, _) = lib(&[&["travel|italy"]]);
+ let op = c.create_keyword_ops("TRAVEL|Rome", KeywordInfo::default(), &[]).unwrap();
+ c.apply(op).unwrap();
+ assert_eq!(listed(&c).iter().map(|(p, _)| p.as_str()).collect::>(), ["travel|Rome"]);
+ assert_eq!(tree_paths(&c.keyword_tree())[0].0, "travel");
+ }
+
+ /// An edit that changes nothing is nothing: no listing, no undo step.
+ #[test]
+ fn an_edit_that_changes_nothing_is_nothing() {
+ let (mut c, _) = lib(&[&["beach"]]);
+ assert_eq!(c.edit_keyword_ops("beach", "beach", KeywordInfo::default()).unwrap(), Op::Batch { ops: vec![] });
+ c.apply(Op::SetKeyword { path: "beach".into(), info: Some(with_synonyms(&["shore"])) }).unwrap();
+ assert_eq!(c.edit_keyword_ops("BEACH", "beach", with_synonyms(&["shore"])).unwrap(), Op::Batch { ops: vec![] });
+ // renamed with the default attributes: the photos change, nothing is listed
+ let (mut c, _) = lib(&[&["beach"]]);
+ let op = c.edit_keyword_ops("beach", "Shore", KeywordInfo::default()).unwrap();
+ c.apply(op).unwrap();
+ assert!(listed(&c).is_empty());
+ }
+
+ /// Typing suggests keywords no photo has yet too (created in the Keyword List).
+ #[test]
+ fn suggestions_include_keywords_without_photos() {
+ let (mut c, _) = lib(&[&["beach"]]);
+ c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap();
+ assert_eq!(c.keyword_suggestions(&[], "wed", 5), ["Events|Weddings"]);
+ assert!(!c.keyword_suggestions(&["events|weddings".into()], "wed", 5).contains(&"Events|Weddings".to_string()), "not one it has");
+ }
+
+ /// A new name typed for a photo goes inside the default parent; a keyword the library has, and
+ /// a path typed whole, don't.
+ #[test]
+ fn typed_new_keywords_go_inside_the_default_parent() {
+ let (mut c, _) = lib(&[&["beach"], &["Events"]]);
+ assert_eq!(c.typed_keyword(" Weddings "), "Weddings", "no default parent");
+ let op = c.set_default_parent_ops(Some("events")).unwrap();
+ c.apply(op).unwrap();
+ assert_eq!(c.typed_keyword("Weddings"), "Events|Weddings");
+ assert_eq!(c.typed_keyword("BEACH"), "BEACH", "the library has it");
+ assert_eq!(c.typed_keyword("Places|Lisbon"), "Places|Lisbon");
+ }
+
+ /// The keyword list as a text file, as Lightroom Classic writes one: a keyword a line, the
+ /// keywords inside it indented one tab more, a synonym in braces on a line of its own under its
+ /// keyword, a keyword left out of export in square brackets; by name.
+ #[test]
+ fn the_keyword_list_is_written_as_tab_indented_text() {
+ let (mut c, _) = lib(&[&["Places|Lisbon", "beach"]]);
+ c.apply(Op::SetKeyword { path: "Places".into(), info: Some(KeywordInfo { include_on_export: false, ..KeywordInfo::default() }) }).unwrap();
+ c.apply(Op::SetKeyword { path: "Places|Lisbon".into(), info: Some(with_synonyms(&["Lisboa"])) }).unwrap();
+ c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap();
+ assert_eq!(c.keyword_list_text().text, "beach\nEvents\n\tWeddings\n[Places]\n\tLisbon\n\t\t{Lisboa}\n");
+ }
+
+ /// Reading a list gives each keyword's path and attributes: what was written comes back, and a
+ /// file from elsewhere (Windows line ends, a byte-order mark, blank lines) reads too.
+ #[test]
+ fn a_keyword_list_reads_back() {
+ let read = parse_keyword_list("\u{feff}beach\r\n\r\nEvents\r\n\tWeddings\r\n\t\t{marriage}\r\n[Places]\r\n\tLisbon\r\n").unwrap();
+ let out = KeywordInfo { include_on_export: false, ..KeywordInfo::default() };
+ assert_eq!(
+ read,
+ [
+ ("beach".to_string(), KeywordInfo::default()),
+ ("Events".to_string(), KeywordInfo::default()),
+ ("Events|Weddings".to_string(), with_synonyms(&["marriage"])),
+ ("Places".to_string(), out),
+ ("Places|Lisbon".to_string(), KeywordInfo::default()),
+ ]
+ );
+ }
+
+ /// Photo Supreme's Formatted Vocabulary File is the same format: plain text with Windows line
+ /// ends and no byte-order mark, a top-level category in square brackets (an organizing keyword,
+ /// left out of export), keywords a tab further in at each level.
+ #[test]
+ fn a_photo_supreme_vocabulary_file_reads() {
+ let file = "[Places]\r\n\tPortugal\r\n\t\tLisbon\r\n\t\t\tAlfama\r\n\t\tPorto\r\n\tSpain\r\n\t\tSeville\r\n";
+ let read = parse_keyword_list(file).unwrap();
+ let paths: Vec<&str> = read.iter().map(|(p, _)| p.as_str()).collect();
+ assert_eq!(
+ paths,
+ ["Places", "Portugal", "Portugal|Lisbon", "Portugal|Lisbon|Alfama", "Portugal|Porto", "Spain", "Spain|Seville"]
+ .map(|p| if p == "Places" { p.to_string() } else { format!("Places|{p}") })
+ );
+ assert!(!read[0].1.include_on_export, "the category organizes: it isn't exported");
+ assert!(read[1..].iter().all(|(_, i)| i.include_on_export));
+ }
+
+ /// A list that can't be read says which line and why, and nothing of it is taken.
+ #[test]
+ fn a_bad_keyword_list_says_which_line() {
+ let err = |text: &str| parse_keyword_list(text).unwrap_err().to_string();
+ assert!(err("Events\n\t\tWeddings\n").contains("line 2"), "two levels down at once");
+ assert!(err("{marriage}\n").contains("line 1"), "a synonym of nothing");
+ assert!(err("Events\nTravel|Lisbon\n").contains("line 2"), "“|” separates levels here");
+ let deep: String = (0..70).map(|i| format!("{}k{i}\n", "\t".repeat(i))).collect();
+ assert!(err(&deep).contains("line 65"), "a file is input: 64 levels at most");
+ }
+
+ /// Files from elsewhere read as they were meant or say why not: classic Mac line ends (a lone
+ /// carriage return) are lines; indenting with spaces, which would flatten the hierarchy, is
+ /// refused at its line; so are control characters in a name and "|" in a synonym.
+ #[test]
+ fn a_keyword_list_from_elsewhere_reads_or_says_why_not() {
+ let paths = |text: &str| parse_keyword_list(text).unwrap().into_iter().map(|(p, _)| p).collect::>();
+ assert_eq!(paths("Events\r\tWeddings\rPlaces\r"), ["Events", "Events|Weddings", "Places"]);
+ let err = |text: &str| parse_keyword_list(text).unwrap_err().to_string();
+ assert!(err("Events\n Weddings\n").contains("line 2"), "space indentation");
+ assert!(err("Events\n\t Weddings\n").contains("line 2"), "a space after the tabs");
+ assert!(err("Events\nx\u{0}y\n").contains("line 2"), "a control character");
+ assert!(err("Events\n\t{sea|shore}\n").contains("line 2"), "“|” in a synonym");
+ }
+
+ /// A keyword repeated in another case is one keyword, and the keywords inside either take the
+ /// first spelling.
+ #[test]
+ fn a_repeated_keyword_keeps_its_first_spelling() {
+ let read = parse_keyword_list("Events\n\tWeddings\nevents\n\tBirthdays\n").unwrap();
+ assert_eq!(read.iter().map(|(p, _)| p.as_str()).collect::>(), ["Events", "Events|Weddings", "Events|Birthdays"]);
+ }
+
+ /// What the list can't hold is left out and named, so the file always reads back: a name the
+ /// format would read as something else (in brackets or braces), one with a line break, a
+ /// synonym with one, a keyword nested deeper than a list goes; and the keywords inside one left
+ /// out go with it.
+ #[test]
+ fn an_exported_keyword_list_always_reads_back() {
+ let deep = (0..=MAX_LEVELS).map(|i| format!("level {i}")).collect::>().join("|");
+ let (mut c, _) = lib(&[&["Travel|[draft]", "{todo}|Lisbon", "Weddings", "Line\nbreak", deep.as_str()]]);
+ c.apply(Op::SetKeyword { path: "Weddings".into(), info: Some(with_synonyms(&["marriage", "two\nlines"])) }).unwrap();
+ let KeywordListFile { text, unwritable, .. } = c.keyword_list_text();
+ let read = parse_keyword_list(&text).unwrap_or_else(|e| panic!("{e}\n{text}"));
+ assert!(read.iter().any(|(p, _)| p == "Travel"));
+ assert!(read.iter().all(|(p, _)| !p.contains("draft") && !p.contains("todo") && !p.contains("Line")), "{text}");
+ let weddings = read.iter().find(|(p, _)| p == "Weddings").map(|(_, i)| i.synonyms.clone());
+ assert_eq!(weddings, Some(vec!["marriage".to_string()]));
+ let deepest = read.iter().map(|(p, _)| p.split(SEP).count()).max();
+ assert_eq!(deepest, Some(MAX_LEVELS));
+ assert!(unwritable.contains(&"Travel|[draft]".to_string()), "{unwritable:?}");
+ assert!(unwritable.contains(&"{todo}".to_string()), "{unwritable:?}");
+ assert!(unwritable.contains(&"Line\nbreak".to_string()), "{unwritable:?}");
+ assert!(unwritable.contains(&"Weddings {two\nlines}".to_string()), "{unwritable:?}");
+ assert!(unwritable.iter().any(|u| u.ends_with(&format!("level {MAX_LEVELS}"))), "{unwritable:?}");
+ }
+
+ /// The list leaves out only what it must, and names only what's lost: a name starting with a
+ /// byte-order mark would lose it, but a synonym in braces reads back, and a synonym that's empty
+ /// or another's in a different case is no loss. It also says which keywords it wrote.
+ #[test]
+ fn an_exported_keyword_list_names_only_what_it_loses() {
+ let (mut c, _) = lib(&[&["\u{feff}Albums", "{draft", "Weddings"]]);
+ c.apply(Op::SetKeyword { path: "Weddings".into(), info: Some(with_synonyms(&["Marriage", "marriage", " ", "{vows}"])) }).unwrap();
+ let out = c.keyword_list_text();
+ let read = parse_keyword_list(&out.text).unwrap_or_else(|e| panic!("{e}\n{}", out.text));
+ assert_eq!(out.unwritable, ["\u{feff}Albums"]);
+ assert_eq!(out.written, ["Weddings", "{draft"]);
+ assert_eq!(read.iter().map(|(p, _)| p.as_str()).collect::>(), ["Weddings", "{draft"]);
+ let weddings = read.iter().find(|(p, _)| p == "Weddings").map(|(_, i)| i.synonyms.clone());
+ assert_eq!(weddings, Some(vec!["Marriage".to_string(), "{vows}".to_string()]));
+ }
+
+ /// A big list reads and imports at once, against a big library too: 40,000 keywords used to take
+ /// half a minute to read, and checking 5,000 new ones against 20,000 photos over a minute, with
+ /// the app frozen.
+ #[test]
+ fn a_big_keyword_list_imports_quickly() {
+ let mut text = String::new();
+ for g in 0..400 {
+ text.push_str(&format!("group {g}\n"));
+ for k in 0..99 {
+ text.push_str(&format!("\tkeyword {g}-{k}\n"));
+ }
+ }
+ let t0 = std::time::Instant::now();
+ let read = parse_keyword_list(&text).unwrap();
+ assert_eq!(read.len(), 40_000);
+ let mut c = Catalog::new();
+ let mut ops = Vec::new();
+ for i in 0..20_000u32 {
+ let id = c.alloc_photo_id();
+ let mut p = Photo::new(id, Source::Demo { scene: 1 }, "a.jpg", "JPEG", 3, 2, "2026-01-01");
+ p.meta.keywords = (0..5).map(|k| format!("library {}|tag {}", k, (i + k) % 3000)).collect();
+ ops.push(Op::AddPhoto { photo: Box::new(p) });
+ }
+ c.apply(Op::Batch { ops }).unwrap();
+ let (op, added, _) = c.import_keywords_ops(&read[..5_000]);
+ c.apply(op).unwrap();
+ assert_eq!(added, 5_000);
+ let took = t0.elapsed();
+ assert!(took < std::time::Duration::from_secs(10), "{took:?}");
+ }
+
+ /// Importing a list adds the keywords the library doesn't have, with their attributes, and gives
+ /// those it has the list's synonyms (their own attributes stay), in one undo step.
+ #[test]
+ fn importing_a_keyword_list_adds_what_is_missing() {
+ let (mut c, _) = lib(&[&["Events|Weddings"]]);
+ c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(KeywordInfo { person: true, ..with_synonyms(&["marriage"]) }) }).unwrap();
+ let before = c.to_snapshot();
+ let read = parse_keyword_list("events\n\tweddings\n\t\t{nuptials}\n\tBirthdays\n[Drafts]\n").unwrap();
+ let (op, added, updated) = c.import_keywords_ops(&read);
+ assert_eq!((added, updated), (2, 1));
+ let undo = c.apply(op).unwrap();
+ assert_eq!(c.keyword_info("Events|Weddings"), Some(&KeywordInfo { person: true, ..with_synonyms(&["marriage", "nuptials"]) }));
+ assert!(c.has_keyword("Events|Birthdays"));
+ assert_eq!(
+ listed(&c).iter().map(|(p, _)| p.as_str()).collect::>(),
+ ["Drafts", "Events|Birthdays", "Events|Weddings"],
+ "in the library's spelling"
+ );
+ assert!(!c.keyword_info("Drafts").unwrap().include_on_export);
+ c.apply(undo).unwrap();
+ assert_eq!(c.to_snapshot(), before);
+ }
+
#[test]
fn suggestions_rank_co_occurrence_and_prefixes() {
let (c, _) = lib(&[&["dog", "park"], &["dog", "park", "ball"], &["dog", "beach"], &["cat"], &["cat"], &["cat"], &["parade"], &["eagle"]]);
diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs
index 884ec658..ed615084 100644
--- a/crates/catalog/src/lib.rs
+++ b/crates/catalog/src/lib.rs
@@ -30,7 +30,7 @@ use std::collections::BTreeMap;
use std::sync::Arc;
pub use dates::{DateRun, GroupBy};
-pub use folders::FolderNode;
+pub use folders::{FolderNode, FolderRecord};
pub use journal::{Journal, LoadReport, PersistStats, SnapshotPolicy, SnapshotTiming};
pub use keywords::KeywordNode;
use lightcraft_develop::DevelopSettings;
@@ -52,6 +52,9 @@ pub enum CatalogError {
NoStack(StackId),
#[error("invalid: {0}")]
Invalid(String),
+ /// A keyword of that name is there already: moving or renaming onto it would merge the two.
+ #[error("there is a keyword “{0}” already")]
+ KeywordExists(String),
#[error("corrupt catalog data: {0}")]
Corrupt(String),
#[error("catalog storage: {0}")]
@@ -206,6 +209,11 @@ pub enum Op {
#[serde(default, skip_serializing_if = "Option::is_none")]
preview_only: Option,
},
+ /// The lens data a photo's file carries (what Reload finds when it was read after import).
+ SetEmbeddedLens {
+ id: PhotoId,
+ lens: Option>,
+ },
/// The name shown for a colour label (`None` = its colour's name).
SetLabelName {
label: ColorLabel,
@@ -217,6 +225,18 @@ pub enum Op {
folder: String,
at: Option,
},
+ /// List a keyword in the library's keyword list with these attributes, or take it off the list
+ /// (`None`; photos that carry it keep it). Format version 4.
+ SetKeyword {
+ path: String,
+ info: Option,
+ },
+ /// What the library keeps about one of its folders (see [`folders::FolderRecord`]), under
+ /// its identity ([`query::folder_key`]); `None` or an empty record = keep nothing.
+ SetFolderRecord {
+ folder: String,
+ record: Option,
+ },
/// Several ops as one step (undo applies the inverses in reverse).
Batch {
ops: Vec,
@@ -239,6 +259,12 @@ pub struct Catalog {
/// When each Local folder was last browsed (folder path → ISO 8601), see [`local`].
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
browsed: BTreeMap,
+ /// Keywords listed on their own or given attributes, by lower-case path (see [`keywords`]).
+ #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
+ keyword_list: BTreeMap,
+ /// What the library keeps about its folders (folder identity → record), see [`folders`].
+ #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
+ folder_records: BTreeMap,
/// Increments on every applied op.
#[serde(skip)]
pub revision: u64,
@@ -324,12 +350,57 @@ impl Catalog {
/// no smart album).
pub fn album_contains(&self, id: AlbumId, p: &Photo) -> bool {
match self.albums.get(&id) {
- Some(Album { smart: Some(rules), .. }) => !p.deleted && rules.matches(p, self),
+ // guarded: a smart album testing smart albums can't loop or recurse without end
+ Some(Album { smart: Some(rules), .. }) => !p.deleted && rules::smart_album_holds(id, p.id, || rules.matches(p, self)),
Some(a) => a.photos.contains(&p.id),
None => false,
}
}
+ /// Whether smart album `from` tests `to`, directly or through the smart albums it tests (the
+ /// rules of `from` would then change when those of `to` do). Loops in saved rules end the
+ /// search, they don't repeat it.
+ pub fn album_reaches(&self, from: AlbumId, to: AlbumId) -> bool {
+ let mut seen: std::collections::HashSet = std::collections::HashSet::new();
+ let mut next = vec![from];
+ while let Some(a) = next.pop() {
+ if !seen.insert(a) {
+ continue;
+ }
+ let tested = self.albums.get(&a).and_then(|al| al.smart.as_deref()).map(Filter::albums_tested).unwrap_or_default();
+ if tested.contains(&to) {
+ return true;
+ }
+ next.extend(tested);
+ }
+ false
+ }
+
+ /// What is wrong with a saved smart album's rules now (`RuleSet::check`, after
+ /// `RuleSet::upgrade`): typically a rule testing an album that has since been deleted. Empty
+ /// for a sound smart album, a plain album or no album.
+ pub fn smart_album_problems(&self, id: AlbumId) -> Vec {
+ let Some(filter) = self.albums.get(&id).and_then(|a| a.smart.as_deref()) else { return Vec::new() };
+ let mut out: Vec = self.album_filter_problem(filter, Some(id)).into_iter().collect();
+ if let Some(mut rules) = filter.rule_set.clone() {
+ rules.upgrade();
+ out.extend(rules.check_for(self, Some(id)));
+ }
+ out
+ }
+
+ /// A loop through `filter`'s own album field (not its rules): smart album `owner` filtered to
+ /// itself, or to an album that leads back to it.
+ pub fn album_filter_problem(&self, filter: &Filter, owner: Option) -> Option {
+ let (a, owner) = (filter.album?, owner?);
+ (a == owner || self.album_reaches(a, owner)).then(|| rules::Problem {
+ path: Vec::new(),
+ field: Some("album".into()),
+ issue: rules::Issue::AlbumLoop,
+ message: format!("its album filter (album {}) would make this album include itself", a.0),
+ })
+ }
+
/// The photos of an album: the stored list, or a smart album's current matches (id order).
pub fn album_photos(&self, id: AlbumId) -> Vec {
match self.albums.get(&id) {
@@ -366,6 +437,18 @@ impl Catalog {
pub fn label_name(&self, l: ColorLabel) -> String {
self.label_names.get(&l).cloned().unwrap_or_else(|| format!("{l:?}"))
}
+ /// A listed keyword's attributes (any case), `None` when it isn't listed.
+ pub fn keyword_info(&self, path: &str) -> Option<&keywords::KeywordInfo> {
+ self.keyword_list.get(&keywords::clean(path).to_lowercase()).map(|k| &k.info)
+ }
+ /// How the keyword list spells a listed keyword (any case), `None` when it isn't listed.
+ pub fn listed_path(&self, path: &str) -> Option<&str> {
+ self.keyword_list.get(&keywords::clean(path).to_lowercase()).map(|k| k.path.as_str())
+ }
+ /// The keyword list: keywords listed on their own or given attributes, by path.
+ pub fn listed_keywords(&self) -> impl Iterator- {
+ self.keyword_list.values()
+ }
/// The custom name of a colour label, if any.
pub fn custom_label_name(&self, l: ColorLabel) -> Option<&str> {
self.label_names.get(&l).map(String::as_str)
@@ -373,7 +456,9 @@ impl Catalog {
/// The label a name stands for: a custom name first, then a colour's own name (any case).
pub fn label_from_name(&self, name: &str) -> Option
{
let name = name.trim();
- self.label_names.iter().find(|(_, n)| n.trim().eq_ignore_ascii_case(name)).map(|(l, _)| *l).or_else(|| ColorLabel::parse(name))
+ // any case, accents included (Été / été), as rules compare names
+ let name_lower = name.to_lowercase();
+ self.label_names.iter().find(|(_, n)| n.trim().to_lowercase() == name_lower).map(|(l, _)| *l).or_else(|| ColorLabel::parse(name))
}
// ---- writes
@@ -596,6 +681,10 @@ impl Catalog {
preview_only: std::mem::replace(&mut p.preview_only, preview_only),
}
}
+ Op::SetEmbeddedLens { id, lens } => {
+ let p = self.photo_mut(id)?;
+ Op::SetEmbeddedLens { id, lens: std::mem::replace(&mut p.embedded_lens, lens.map(|l| *l)).map(Box::new) }
+ }
Op::SetFile { id, file_name, source } => {
if file_name.trim().is_empty() {
return Err(CatalogError::Invalid("empty file name".into()));
@@ -612,6 +701,21 @@ impl Catalog {
};
Op::SetLabelName { label, name: old }
}
+ Op::SetKeyword { path, info } => {
+ let path = keywords::clean(&path);
+ if path.is_empty() {
+ return Err(CatalogError::Invalid("keyword names can't be empty".into()));
+ }
+ let key = path.to_lowercase();
+ let old = match info {
+ Some(info) => self.keyword_list.insert(key, keywords::ListedKeyword { path: path.clone(), info }),
+ None => self.keyword_list.remove(&key),
+ };
+ match old {
+ Some(old) => Op::SetKeyword { path: old.path, info: Some(old.info) },
+ None => Op::SetKeyword { path, info: None },
+ }
+ }
Op::SetBrowsed { folder, at } => {
let folder = crate::query::folder_key(&folder);
let old = match at {
@@ -620,6 +724,14 @@ impl Catalog {
};
Op::SetBrowsed { folder, at: old }
}
+ Op::SetFolderRecord { folder, record } => {
+ let key = folders::record_key(&folder).ok_or_else(|| CatalogError::Invalid(format!("not a folder: {folder}")))?;
+ let old = match record.filter(|r| !r.is_empty()) {
+ Some(r) => self.folder_records.insert(key.clone(), r),
+ None => self.folder_records.remove(&key),
+ };
+ Op::SetFolderRecord { folder: key, record: old }
+ }
Op::Batch { ops } => {
let mut inverses = Vec::with_capacity(ops.len());
for op in ops {
@@ -721,6 +833,8 @@ mod tests_album_order;
#[cfg(test)]
mod tests_background;
#[cfg(test)]
+mod tests_folder_records;
+#[cfg(test)]
mod tests_folders;
#[cfg(test)]
mod tests_format_version;
diff --git a/crates/catalog/src/local.rs b/crates/catalog/src/local.rs
index 4291f45f..e81d4880 100644
--- a/crates/catalog/src/local.rs
+++ b/crates/catalog/src/local.rs
@@ -283,7 +283,8 @@ impl Op {
| Op::SetAnalysis { id, .. }
| Op::SetFile { id, .. }
| Op::Relink { id, .. }
- | Op::SetContent { id, .. } => out(*id),
+ | Op::SetContent { id, .. }
+ | Op::SetEmbeddedLens { id, .. } => out(*id),
Op::AddAlbum { album } => {
album.photos.iter().for_each(|p| out(*p));
album.cover.into_iter().for_each(&mut *out);
@@ -299,7 +300,9 @@ impl Op {
| Op::SetAlbumRules { .. }
| Op::RemoveStack { .. }
| Op::SetLabelName { .. }
- | Op::SetBrowsed { .. } => {}
+ | Op::SetKeyword { .. }
+ | Op::SetBrowsed { .. }
+ | Op::SetFolderRecord { .. } => {}
}
}
}
diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs
index b2a1a696..199edac1 100644
--- a/crates/catalog/src/model.rs
+++ b/crates/catalog/src/model.rs
@@ -152,6 +152,17 @@ impl CopyrightStatus {
}
}
+/// A shutter speed as cameras write it (`1/250`, `0.5`, `2"`, `30s`) in seconds; `None` for
+/// anything that isn't a positive, finite time.
+pub fn parse_shutter_seconds(s: &str) -> Option {
+ let s = s.trim().trim_end_matches(['s', '"']).trim();
+ match s.split_once('/') {
+ Some((n, d)) => Some(n.trim().parse::().ok()? / d.trim().parse::().ok()?),
+ None => s.parse().ok(),
+ }
+ .filter(|v: &f64| v.is_finite() && *v > 0.0)
+}
+
/// Descriptive + capture metadata.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
@@ -296,6 +307,14 @@ pub struct Analysis {
pub best: bool,
}
+/// Raw formats whose readers have vendor white-balance multipliers but no measured camera
+/// illuminant: their white balance is developed relative to the as-shot look (6500 K / 0 means
+/// as shot), as for rendered photographs. `format` is the file's extension or Lightroom's
+/// `fileFormat` name, in any case. See [`Photo::relative_wb`].
+pub fn relative_wb_format(format: &str) -> bool {
+ ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF", "SRW", "ORF"].iter().any(|f| format.eq_ignore_ascii_case(f))
+}
+
impl Photo {
pub fn new(id: PhotoId, source: Source, file_name: &str, format: &str, width: u32, height: u32, imported: &str) -> Photo {
Photo {
@@ -336,24 +355,27 @@ impl Photo {
pub fn develops_raw(&self) -> bool {
self.kind == MediaKind::Raw && self.preview_only.is_none()
}
- /// The current ARW, NEF, RW2, RAF, CR3, CR2, PEF and SRW readers have vendor WB multipliers but no measured camera
+ /// The current ARW, NEF, RW2, RAF, CR3, CR2, PEF, SRW and ORF readers have vendor WB multipliers but no measured camera
/// illuminant. Use adjustments relative to the camera's as-shot look, as for rendered
/// photographs (the engine's `camera_preview::file_local_look` covers the same formats; RWL and
/// RAW are Leica's and the oldest Panasonic bodies' names for RW2 files).
pub fn relative_wb(&self) -> bool {
- self.develops_raw()
- && ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF", "SRW"].iter().any(|f| self.format.eq_ignore_ascii_case(f))
+ self.develops_raw() && relative_wb_format(&self.format)
}
/// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's
- /// default preset applied on top of them ([`Photo::import_look`]).
+ /// default preset applied on top of them ([`Photo::import_look`]). Like the camera defaults,
+ /// in the photo's own rendering process.
pub fn import_defaults(&self) -> DevelopSettings {
match &self.import_look {
- Some(l) => (**l).clone(),
+ Some(l) => DevelopSettings { process: self.develop.process, ..(**l).clone() },
None => self.camera_defaults(),
}
}
/// The built-in defaults for this photo, before any user default preset: raws start from
- /// their as-shot white balance; embedded lens corrections on when the file has them.
+ /// their as-shot white balance; embedded lens corrections on when the file has them. They
+ /// carry the photo's own rendering process ([`lightcraft_develop::ProcessVersion`]): which
+ /// process a photo renders with is not an edit, so comparisons with its defaults (edited?
+ /// still as imported?) don't change when a newer process exists. A new photo has the latest.
pub fn camera_defaults(&self) -> DevelopSettings {
let wb = if self.relative_wb() { Some((6500.0, 0.0)) } else { self.as_shot_wb };
let mut d = match wb {
@@ -363,8 +385,49 @@ impl Photo {
if self.embedded_lens.is_some() {
d.optics.lens_profile = true;
}
+ d.process = self.develop.process;
d
}
+ /// The file name's extension without the dot, as written (`CR2` for `IMG_0042.CR2`); empty
+ /// when the name has none (`README`, a dot file such as `.hidden`).
+ pub fn extension(&self) -> &str {
+ self.file_name.rsplit_once('.').filter(|(stem, _)| !stem.is_empty()).map_or("", |(_, ext)| ext)
+ }
+ /// Width and height in pixels as the photo is shown: turned by its orientation and cut by its
+ /// crop. Straightening and Constrain Crop shrink the crop without changing its shape, so the
+ /// aspect is exact and the edges can read a little larger than an export.
+ pub fn shown_size(&self) -> (f64, f64) {
+ let (w, h) = (self.width as f64, self.height as f64);
+ let (w, h) = if self.develop.orientation.swaps_axes() { (h, w) } else { (w, h) };
+ let r = self.develop.crop.geometry.rect;
+ // a crop read from a file or an agent is hostile: no NaN, nothing outside the frame
+ let part = |len: f64| if len.is_finite() { len.clamp(0.0, 1.0) } else { 1.0 };
+ (w * part(r.width()), h * part(r.height()))
+ }
+ /// Cropped or straightened.
+ pub fn is_cropped(&self) -> bool {
+ !self.develop.crop.geometry.is_identity()
+ }
+ /// How many keywords the photo has: a hierarchical keyword (`travel|italy|rome`) is one, and
+ /// the same keyword in different case or with stray spaces counts once; blank ones don't count.
+ pub fn keyword_count(&self) -> usize {
+ let mut seen: Vec = self.meta.keywords.iter().map(|k| k.trim().to_lowercase()).filter(|k| !k.is_empty()).collect();
+ seen.sort_unstable();
+ seen.dedup();
+ seen.len()
+ }
+ /// The people in the photo: the names on its face regions, trimmed, each once (names that
+ /// differ only in case are one person, as first seen). Pets and unnamed faces are not people.
+ pub fn people(&self) -> Vec<&str> {
+ let mut out: Vec<&str> = Vec::new();
+ for r in self.meta.regions.iter().filter(|r| r.kind == lightcraft_meta::RegionKind::Face) {
+ let Some(name) = r.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) else { continue };
+ if !out.iter().any(|o| o.to_lowercase() == name.to_lowercase()) {
+ out.push(name);
+ }
+ }
+ out
+ }
/// In the library: not deleted and not only browsed (Local).
pub fn in_library(&self) -> bool {
!self.deleted && !self.local
@@ -439,6 +502,32 @@ mod edited_tests {
assert!(!p.is_edited(), "a full reset is unedited too");
}
+ #[test]
+ fn the_process_a_photo_renders_with_is_not_an_edit() {
+ use lightcraft_develop::ProcessVersion;
+ // a photo on another process than the latest (a V1 photo once a later process is the
+ // latest, or a photo saved by a newer LightCraft)
+ let other = ProcessVersion(ProcessVersion::LATEST.0 + 1);
+ let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.dng", "DNG", 10, 10, "2026-10-01T00:00:00");
+ p.kind = MediaKind::Raw;
+ p.as_shot_wb = Some((5200.0, 4.0));
+ assert_eq!(p.camera_defaults().process, ProcessVersion::LATEST, "a new photo gets the latest");
+ p.develop = Arc::new(DevelopSettings { process: other, ..p.import_defaults() });
+ assert_eq!((p.camera_defaults().process, p.import_defaults().process), (other, other));
+ assert!(!p.is_edited(), "still as imported");
+ // with a default preset's look as well
+ let mut look = DevelopSettings::for_raw(5200.0, 4.0);
+ look.light.exposure = 0.3;
+ p.import_look = Some(Arc::new(look.clone()));
+ p.develop = Arc::new(DevelopSettings { process: other, ..look });
+ assert_eq!(p.import_defaults().process, other);
+ assert!(!p.is_edited());
+ let mut d = (*p.develop).clone();
+ d.light.exposure = 1.0;
+ p.develop = Arc::new(d);
+ assert!(p.is_edited(), "an edit still is one");
+ }
+
#[test]
fn supported_raws_use_relative_white_balance() {
for (name, format, relative) in [
@@ -451,6 +540,9 @@ mod edited_tests {
("a.raf", "rAf", true),
("a.cr3", "cr3", true),
("a.srw", "SRW", true),
+ ("a.cr2", "CR2", true),
+ ("a.pef", "pef", true),
+ ("a.orf", "ORF", true),
("a.dng", "DNG", false),
] {
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, name, format, 10, 10, "2026-10-01T00:00:00");
diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs
index c2e27cbd..8b694a02 100644
--- a/crates/catalog/src/query.rs
+++ b/crates/catalog/src/query.rs
@@ -154,12 +154,21 @@ fn token_matches(p: &Photo, tok: &str) -> bool {
/// Whether `p` has a named face region called `name` (case-insensitive).
fn has_person(p: &Photo, name: &str) -> bool {
let name = name.trim().to_lowercase();
- p.meta.regions.iter().any(|r| r.kind == lightcraft_meta::RegionKind::Face && r.name.as_deref().is_some_and(|n| n.to_lowercase() == name))
+ p.people().iter().any(|n| n.to_lowercase() == name)
}
impl Filter {
/// Human-readable summary of the active rules (smart album tooltips, `album.list`).
pub fn describe(&self) -> String {
+ self.describe_in(None)
+ }
+
+ /// [`Filter::describe`] naming the albums its rules test (`RuleSet::describe_with`).
+ pub fn describe_with(&self, cat: &crate::Catalog) -> String {
+ self.describe_in(Some(cat))
+ }
+
+ fn describe_in(&self, cat: Option<&crate::Catalog>) -> String {
let mut v: Vec = Vec::new();
if self.rating > 0 {
let op = match self.rating_op {
@@ -207,11 +216,18 @@ impl Filter {
v.push(format!("“{}”", self.text.trim()));
}
if let Some(rs) = self.rule_set.as_ref().filter(|r| !r.rules.is_empty()) {
- v.push(rs.describe());
+ v.push(cat.map_or_else(|| rs.describe(), |c| rs.describe_with(c)));
}
if v.is_empty() { "all photos".into() } else { v.join(", ") }
}
+ /// The albums this filter tests: its album field and its rules' Album rules.
+ pub fn albums_tested(&self) -> Vec {
+ let mut out: Vec = self.album.into_iter().collect();
+ out.extend(self.rule_set.as_ref().map(crate::RuleSet::albums_tested).unwrap_or_default());
+ out
+ }
+
/// Whether matches depend on the clock (only "in the last…" rules do).
pub fn depends_on_now(&self) -> bool {
self.rule_set.as_ref().is_some_and(crate::RuleSet::depends_on_now)
diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs
index fab8566c..be1fd867 100644
--- a/crates/catalog/src/rules.rs
+++ b/crates/catalog/src/rules.rs
@@ -43,49 +43,494 @@ pub enum Rule {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
Text,
- /// Keywords: text ops over each keyword (any keyword matches; `isEmpty` = none).
+ /// A list of names (keywords, people): text ops over each name (any name matches; `isEmpty` =
+ /// none).
Keywords,
Number,
Date,
- /// One of a fixed set (`choices`).
- Choice(&'static [&'static str]),
+ /// An album or a smart album (not a folder), by id: in it or not.
+ Album,
+ /// One of a fixed set: (id, label). Rules store the id; people see the label.
+ Choice(&'static [(&'static str, &'static str)]),
Bool,
}
-/// The fields rules can test: (id, label, kind).
+/// The fields rules can test: (id, label, kind), in field-menu order ([`TOP_LEVEL_FIELDS`], then
+/// each of [`FIELD_GROUPS`]).
pub const FIELDS: &[(&str, &str, Kind)] = &[
("rating", "Rating", Kind::Number),
- ("flag", "Pick Flag", Kind::Choice(&["pick", "reject", "none"])),
- ("label", "Color Label", Kind::Choice(&["red", "yellow", "green", "blue", "purple", "none"])),
- ("kind", "File Type", Kind::Choice(&["image", "raw", "video"])),
- ("edited", "Has Edits", Kind::Bool),
- ("keywords", "Keywords", Kind::Keywords),
+ ("flag", "Pick Flag", Kind::Choice(&[("pick", "Picked"), ("reject", "Rejected"), ("none", "Unflagged")])),
+ (
+ "label",
+ "Color Label",
+ Kind::Choice(&[("red", "Red"), ("yellow", "Yellow"), ("green", "Green"), ("blue", "Blue"), ("purple", "Purple"), ("none", "No Label")]),
+ ),
("text", "Any Searchable Text", Kind::Text),
+ // Source
+ ("album", "Album", Kind::Album),
+ ("virtualCopy", "Virtual Copy", Kind::Bool),
+ ("copyName", "Copy Name", Kind::Text),
+ ("stacked", "In a Stack", Kind::Bool),
+ // File
("fileName", "Filename", Kind::Text),
+ ("extension", "File Extension", Kind::Text),
("filePath", "File Path", Kind::Text),
+ ("kind", "File Type", Kind::Choice(&[("image", "Image"), ("raw", "Raw"), ("video", "Video")])),
("format", "File Format", Kind::Text),
- ("title", "Title", Kind::Text),
- ("caption", "Caption", Kind::Text),
- ("camera", "Camera", Kind::Text),
- ("lens", "Lens", Kind::Text),
- ("location", "Location", Kind::Text),
- ("creator", "Creator", Kind::Text),
- ("copyright", "Copyright", Kind::Text),
- ("copyrightStatus", "Copyright Status", Kind::Choice(&["copyrighted", "publicDomain", "unknown"])),
+ ("duration", "Video Duration", Kind::Number),
+ // Date
("captureDate", "Capture Date", Kind::Date),
("importDate", "Import Date", Kind::Date),
("editDate", "Edit Date", Kind::Date),
- ("iso", "ISO Speed", Kind::Number),
- ("aperture", "Aperture", Kind::Number),
+ // Keywords & People
+ ("keywords", "Keywords", Kind::Keywords),
+ ("keywordCount", "Keyword Count", Kind::Number),
+ ("person", "People", Kind::Keywords),
+ ("personCount", "People Count", Kind::Number),
+ // Description
+ ("title", "Title", Kind::Text),
+ ("caption", "Caption", Kind::Text),
+ ("altText", "Alt Text", Kind::Text),
+ ("creator", "Creator", Kind::Text),
+ ("copyright", "Copyright", Kind::Text),
+ (
+ "copyrightStatus",
+ "Copyright Status",
+ Kind::Choice(&[("copyrighted", "Copyrighted"), ("publicDomain", "Public Domain"), ("unknown", "Unknown")]),
+ ),
+ // Camera Info
+ ("camera", "Camera", Kind::Text),
+ ("lens", "Lens", Kind::Text),
("focalLength", "Focal Length", Kind::Number),
- ("megapixels", "Megapixels", Kind::Number),
+ ("aperture", "Aperture", Kind::Number),
+ ("shutterSpeed", "Shutter Speed", Kind::Number),
+ ("iso", "ISO Speed", Kind::Number),
+ // Location
+ ("location", "Location", Kind::Text),
+ ("city", "City", Kind::Text),
+ ("state", "State / Province", Kind::Text),
+ ("country", "Country", Kind::Text),
("hasGps", "Has GPS", Kind::Bool),
- ("virtualCopy", "Virtual Copy", Kind::Bool),
- ("album", "Album", Kind::Number),
+ // Size
+ ("longEdge", "Long Edge", Kind::Number),
+ ("shortEdge", "Short Edge", Kind::Number),
+ ("aspect", "Aspect Ratio", Kind::Choice(&[("landscape", "Landscape (wide)"), ("portrait", "Portrait (tall)"), ("square", "Square")])),
+ ("megapixels", "Megapixels", Kind::Number),
+ // Develop
+ ("edited", "Has Edits", Kind::Bool),
+ ("cropped", "Cropped", Kind::Bool),
+ ("treatment", "Treatment", Kind::Choice(&[("color", "In Color"), ("monochrome", "Black & White")])),
+ // Assisted Culling
("sharpness", "Focus (assisted culling)", Kind::Number),
("bestOfGroup", "Best of Similar Shots", Kind::Bool),
];
+/// The fields the field menu shows at its top level, before the groups.
+pub const TOP_LEVEL_FIELDS: &[&str] = &["rating", "flag", "label", "text"];
+
+/// The field menu's submenus: (label, fields), in order. Every field of [`FIELDS`] is either here
+/// once or in [`TOP_LEVEL_FIELDS`].
+pub const FIELD_GROUPS: &[(&str, &[&str])] = &[
+ ("Source", &["album", "virtualCopy", "copyName", "stacked"]),
+ ("File", &["fileName", "extension", "filePath", "kind", "format", "duration"]),
+ ("Date", &["captureDate", "importDate", "editDate"]),
+ ("Keywords & People", &["keywords", "keywordCount", "person", "personCount"]),
+ ("Description", &["title", "caption", "altText", "creator", "copyright", "copyrightStatus"]),
+ ("Camera Info", &["camera", "lens", "focalLength", "aperture", "shutterSpeed", "iso"]),
+ ("Location", &["location", "city", "state", "country", "hasGps"]),
+ ("Size", &["longEdge", "shortEdge", "aspect", "megapixels"]),
+ ("Develop", &["edited", "cropped", "treatment"]),
+ ("Assisted Culling", &["sharpness", "bestOfGroup"]),
+];
+
+/// The field-menu group `field` sits in; `None` for a top-level or unknown field.
+pub fn field_group(field: &str) -> Option<&'static str> {
+ FIELD_GROUPS.iter().find(|g| g.1.contains(&field)).map(|g| g.0)
+}
+
+/// A rule that can't mean anything ([`RuleSet::check`]): where it is, what is wrong in English
+/// (for agents and logs), and the field and kind of issue, so the editor can say it in any language.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Problem {
+ /// Positions from the top rule list down: `[1, 0]` is the first rule of the second rule's group.
+ pub path: Vec,
+ /// The rule's field; `None` for a group.
+ pub field: Option,
+ pub issue: Issue,
+ pub message: String,
+}
+
+impl std::fmt::Display for Problem {
+ /// `rule 2.1: no rating 0–5 is 9` (counting from 1, as people do).
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ let at: Vec = self.path.iter().map(|i| (i.saturating_add(1)).to_string()).collect();
+ // a problem with the filter around the rules (its album field) has no rule to point at
+ if at.is_empty() { write!(f, "{}", self.message) } else { write!(f, "rule {}: {}", at.join("."), self.message) }
+ }
+}
+
+/// What kind of thing is wrong with a rule ([`Problem`]); [`Issue::text`] says it after the
+/// field's name ("Title: needs something to look for").
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Issue {
+ UnknownField,
+ NoSuchOperator,
+ NotYesNo,
+ NeedsNumber,
+ NeedsShutterSpeed,
+ NeedsDate,
+ DatesReversed,
+ NeedsTwoValues,
+ NeedsCount,
+ UnknownUnit,
+ NotAChoice,
+ NeedsTextOrEmpty,
+ NeedsTextOrNotEmpty,
+ /// Keywords and People: their operators say "are empty".
+ NeedsNamesOrEmpty,
+ NeedsNamesOrNotEmpty,
+ NeedsText,
+ NoRatingMatches,
+ ChooseAlbum,
+ NoSuchAlbum,
+ AlbumLoop,
+ FolderAlbum,
+ EmptyGroup,
+}
+
+impl Issue {
+ pub const ALL: [Issue; 22] = [
+ Issue::UnknownField,
+ Issue::NoSuchOperator,
+ Issue::NotYesNo,
+ Issue::NeedsNumber,
+ Issue::NeedsShutterSpeed,
+ Issue::NeedsDate,
+ Issue::DatesReversed,
+ Issue::NeedsTwoValues,
+ Issue::NeedsCount,
+ Issue::UnknownUnit,
+ Issue::NotAChoice,
+ Issue::NeedsTextOrEmpty,
+ Issue::NeedsTextOrNotEmpty,
+ Issue::NeedsNamesOrEmpty,
+ Issue::NeedsNamesOrNotEmpty,
+ Issue::NeedsText,
+ Issue::NoRatingMatches,
+ Issue::ChooseAlbum,
+ Issue::NoSuchAlbum,
+ Issue::AlbumLoop,
+ Issue::FolderAlbum,
+ Issue::EmptyGroup,
+ ];
+
+ /// The issue in a few words, to follow the field's name; a message for translation catalogs.
+ pub fn text(self) -> &'static str {
+ match self {
+ Issue::UnknownField => "isn't a field rules know",
+ Issue::NoSuchOperator => "can't be tested that way",
+ Issue::NotYesNo => "is yes or no",
+ Issue::NeedsNumber => "needs a number",
+ Issue::NeedsShutterSpeed => "needs a time like 1/250 or 2",
+ Issue::NeedsDate => "needs a date like 2026, 2026-08 or 2026-08-14",
+ Issue::DatesReversed => "has the first date after the second",
+ Issue::NeedsTwoValues => "needs two values",
+ Issue::NeedsCount => "needs a number more than 0",
+ Issue::UnknownUnit => "needs hours, days, weeks, months or years",
+ Issue::NotAChoice => "isn't one of the choices",
+ Issue::NeedsTextOrEmpty => "needs something to look for (or use “is empty”)",
+ Issue::NeedsTextOrNotEmpty => "needs something to look for (or use “isn't empty”)",
+ Issue::NeedsNamesOrEmpty => "needs something to look for (or use “are empty”)",
+ Issue::NeedsNamesOrNotEmpty => "needs something to look for (or use “aren't empty”)",
+ Issue::NeedsText => "needs text",
+ Issue::NoRatingMatches => "can't match any rating from 0 to 5",
+ Issue::ChooseAlbum => "needs an album",
+ Issue::NoSuchAlbum => "names an album that no longer exists",
+ Issue::AlbumLoop => "would make this album include itself through other smart albums",
+ Issue::FolderAlbum => "can't test a folder; choose an album in it",
+ Issue::EmptyGroup => "This group is empty: add a rule or remove it.",
+ }
+ }
+}
+
+/// An issue with its English message, or nothing wrong.
+type Found = Option<(Issue, String)>;
+
+/// What is wrong with one rule, if anything ([`RuleSet::check`]).
+fn field_problem(field: &str, op: &str, value: &Value, cat: &Catalog, owner: Option) -> Found {
+ let Some(kind) = field_kind(field) else { return Some((Issue::UnknownField, format!("unknown field `{field}`"))) };
+ if !ops_for(kind).iter().any(|o| o.0 == op) {
+ return Some((Issue::NoSuchOperator, format!("`{field}` has no operator `{op}`")));
+ }
+ if matches!(op, "isEmpty" | "isNotEmpty") {
+ return None;
+ }
+ match kind {
+ Kind::Bool => bool_value(value).is_none().then(|| (Issue::NotYesNo, format!("`{field}` is yes or no, not {value}"))),
+ Kind::Album => album_problem(value, cat, owner),
+ Kind::Number => {
+ let each = match value {
+ Value::Array(a) if op == "between" && a.len() == 2 => a.iter().find_map(|v| number_problem(field, v)),
+ _ if op == "between" => Some((Issue::NeedsTwoValues, format!("`{field}` between needs two values, not {value}"))),
+ v => number_problem(field, v),
+ };
+ // a rating rule no rating could meet ("is 9", "≥ 7"); "< 6" is fine, if broad
+ each.or_else(|| {
+ let label = ops_for(kind).iter().find(|o| o.0 == op).map_or(op, |o| o.1);
+ (field == "rating" && !(0..=5).any(|r| num_op(op, Some(f64::from(r)), value)))
+ .then(|| (Issue::NoRatingMatches, format!("no rating 0–5 {label} {value}")))
+ })
+ }
+ Kind::Date => date_problem(field, op, value),
+ Kind::Choice(choices) => choice_problem(field, value, choices, cat),
+ Kind::Text | Kind::Keywords => match value {
+ Value::String(s) if !s.trim().is_empty() => None,
+ Value::String(_) | Value::Null => {
+ // the hint names the operator as the menu does: Keywords and People say "are empty"
+ let not = matches!(op, "isNot" | "notContains");
+ let (issue, instead) = match (kind, not) {
+ (Kind::Keywords, false) => (Issue::NeedsNamesOrEmpty, "are empty"),
+ (Kind::Keywords, true) => (Issue::NeedsNamesOrNotEmpty, "aren't empty"),
+ (_, false) => (Issue::NeedsTextOrEmpty, "is empty"),
+ (_, true) => (Issue::NeedsTextOrNotEmpty, "isn't empty"),
+ };
+ Some((issue, format!("`{field}` needs something to look for (or use “{instead}”)")))
+ }
+ Value::Number(_) => None,
+ other => Some((Issue::NeedsText, format!("`{field}` needs text, not {other}"))),
+ },
+ }
+}
+
+fn number_problem(field: &str, v: &Value) -> Found {
+ if field == "shutterSpeed" {
+ return shutter_value(v).is_null().then(|| (Issue::NeedsShutterSpeed, format!("`shutterSpeed` needs a time like 1/250 or 2, not {v}")));
+ }
+ number(v).filter(|n| n.is_finite()).is_none().then(|| (Issue::NeedsNumber, format!("`{field}` needs a number, not {v}")))
+}
+
+/// The album an Album rule names: its id as a whole number, written as a number (`3`, `3.0`) or a
+/// numeric string (`"3"`). The check, the matcher and the editor all read it this way.
+pub fn album_rule_id(value: &Value) -> Option {
+ number(value).filter(|n| n.is_finite() && *n >= 0.0 && n.fract() == 0.0).map(|n| crate::AlbumId(n as u64))
+}
+
+/// An Album rule's value: an album that exists and holds photos (a plain or a smart album, not a
+/// folder of albums), and, for the rules of `owner`, not one that leads back to it (a smart album
+/// testing itself, directly or through others).
+fn album_problem(v: &Value, cat: &Catalog, owner: Option) -> Found {
+ if v.is_null() {
+ return Some((Issue::ChooseAlbum, "choose an album".into()));
+ }
+ match album_rule_id(v).and_then(|id| cat.album(id)) {
+ None => Some((Issue::NoSuchAlbum, format!("no album {v}"))),
+ Some(a) if owner.is_some_and(|o| a.id == o || cat.album_reaches(a.id, o)) => {
+ Some((Issue::AlbumLoop, format!("album {v} would make this album include itself")))
+ }
+ Some(a) if a.folder => Some((Issue::FolderAlbum, format!("album {v} is a folder; choose an album in it"))),
+ Some(_) => None,
+ }
+}
+
+/// A rule date in its one reading: `2026`, `2026-08`, `2026-08-14`, then optionally `T10`,
+/// `T10:00` or `T10:00:00` (a space works as the `T`), and after whole seconds a fraction and a
+/// time zone as imported capture times carry them (`.250`, `+02:00`, `Z`); a real date and time
+/// (no month 13, no 30 February, no 25 o'clock). Anything else is [`Issue::NeedsDate`]. The check
+/// and the matcher both read dates this way.
+pub fn rule_date(s: &str) -> Result {
+ const PATTERN: &str = "dddd-dd-ddTdd:dd:dd";
+ let s: String = s.trim().char_indices().map(|(i, c)| if i == 10 && c == ' ' { 'T' } else { c }).collect();
+ let shaped = |t: &str| {
+ matches!(t.len(), 4 | 7 | 10 | 13 | 16 | 19)
+ && t.chars().zip(PATTERN.chars()).all(|(c, p)| if p == 'd' { c.is_ascii_digit() } else { c == p })
+ };
+ // after whole seconds, a fraction and a zone as imports write them (.250, +02:00 or Z)
+ let tail_ok = |rest: &str| {
+ let after_fraction = match rest.strip_prefix('.') {
+ Some(f) => {
+ let digits = f.bytes().take_while(u8::is_ascii_digit).count();
+ if !(1..=9).contains(&digits) {
+ return false;
+ }
+ f.get(digits..).unwrap_or("")
+ }
+ None => rest,
+ };
+ let zone = after_fraction.as_bytes();
+ match zone {
+ [] | [b'Z' | b'z'] => true,
+ [b'+' | b'-', h1, h2, b':', m1, m2] => [h1, h2, m1, m2].iter().all(|c| c.is_ascii_digit()),
+ _ => false,
+ }
+ };
+ if !shaped(&s) && !(s.get(..19).is_some_and(shaped) && s.get(19..).is_some_and(tail_ok)) {
+ return Err(Issue::NeedsDate);
+ }
+ let part = |a: usize, b: usize| s.get(a..b).and_then(|t| t.parse::().ok());
+ let time_ok = part(11, 13).is_none_or(|h| h < 24) && part(14, 16).is_none_or(|m| m < 60) && part(17, 19).is_none_or(|x| x < 60);
+ let day = match s.len().min(19) {
+ 4 => format!("{s}-01-01"),
+ 7 => format!("{s}-01"),
+ _ => s.get(..10).unwrap_or("").to_string(),
+ };
+ if time_ok && crate::dates::normalize_iso(&day).is_some() { Ok(s) } else { Err(Issue::NeedsDate) }
+}
+
+fn date_problem(field: &str, op: &str, value: &Value) -> Found {
+ let date = |v: &Value| match v.as_str().map(rule_date) {
+ Some(Ok(_)) => None,
+ _ => Some((Issue::NeedsDate, format!("`{field}` needs a date like 2026, 2026-08 or 2026-08-14, not {v}"))),
+ };
+ match op {
+ "inLast" | "notInLast" => {
+ let (n, unit) = match value {
+ Value::Object(o) => (o.get("n").and_then(number), o.get("unit").cloned().unwrap_or(Value::Null)),
+ v => (number(v), Value::Null),
+ };
+ if !n.is_some_and(|n| n.is_finite() && n > 0.0) {
+ return Some((Issue::NeedsCount, format!("`{field}` needs a number of hours, days, weeks, months or years more than 0")));
+ }
+ unit_secs(&unit).is_none().then(|| (Issue::UnknownUnit, format!("`{field}`: unknown unit {unit} (hours, days, weeks, months or years)")))
+ }
+ "between" => match value {
+ Value::Array(a) if a.len() == 2 => a.iter().find_map(date).or_else(|| {
+ let bound = |i: usize| a.get(i).and_then(Value::as_str).and_then(|d| rule_date(d).ok()).unwrap_or_default();
+ let (from, to) = (bound(0), bound(1));
+ (from > to && !from.starts_with(&to)).then(|| (Issue::DatesReversed, format!("`{field}`: the first date is after the second")))
+ }),
+ _ => Some((Issue::NeedsTwoValues, format!("`{field}` between needs two dates, not {value}"))),
+ },
+ _ => date(value),
+ }
+}
+
+fn choice_problem(field: &str, value: &Value, choices: &[(&str, &str)], cat: &Catalog) -> Found {
+ let known = value.as_str().is_some_and(|s| {
+ choices.iter().any(|c| c.0.eq_ignore_ascii_case(s.trim()))
+ || match field {
+ "flag" => matches!(s.trim().to_lowercase().as_str(), "picked" | "rejected"),
+ // a colour's name or the custom name given to it
+ "label" => cat.label_from_name(s).is_some(),
+ "copyrightStatus" => crate::CopyrightStatus::parse(s).is_some(),
+ _ => false,
+ }
+ });
+ let ids: Vec<&str> = choices.iter().map(|c| c.0).collect();
+ (!known).then(|| (Issue::NotAChoice, format!("`{field}` is one of {}, not {value}", ids.join(", "))))
+}
+
+thread_local! {
+ /// The smart albums whose rules are being evaluated on this thread, outermost first.
+ static EVALUATING: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) };
+}
+
+thread_local! {
+ /// While one outermost question is answered (is this photo in album X?): what each smart album
+ /// it leads to answered for each photo, so an album tested many times along the way (X tests Y
+ /// twice, Y tests Z twice…) is worked out once, not 2^depth times. Cleared when it ends.
+ static ANSWERS: std::cell::RefCell> =
+ std::cell::RefCell::new(std::collections::HashMap::new());
+}
+
+/// Whether `photo` is in smart album `id`, by `matches` (its rules), remembered for the rest of the
+/// outermost question and guarded by [`evaluating`] (a loop or a chain too deep holds nothing).
+pub(crate) fn smart_album_holds(id: crate::AlbumId, photo: crate::PhotoId, matches: impl FnOnce() -> bool) -> bool {
+ if let Some(known) = ANSWERS.with_borrow(|a| a.get(&(id, photo)).copied()) {
+ return known;
+ }
+ // the outermost question forgets the answers when it ends, however it ends
+ struct Forget(bool);
+ impl Drop for Forget {
+ fn drop(&mut self) {
+ if self.0 {
+ ANSWERS.with_borrow_mut(std::collections::HashMap::clear);
+ }
+ }
+ }
+ let _forget = Forget(EVALUATING.with_borrow(Vec::is_empty));
+ let answer = evaluating(id, matches);
+ if let Some(a) = answer {
+ ANSWERS.with_borrow_mut(|known| known.insert((id, photo), a));
+ }
+ answer.unwrap_or(false)
+}
+
+/// How deep smart albums may test smart albums before the innermost counts as holding nothing.
+const MAX_ALBUM_DEPTH: usize = 32;
+
+/// Run `f` as the evaluation of smart album `id`'s rules: `None` (so it holds nothing) when `id`
+/// is already being evaluated (a loop the check would refuse, saved anyway) or the chain is
+/// deeper than [`MAX_ALBUM_DEPTH`]. Loops cost one pass round them, never a stack overflow.
+pub(crate) fn evaluating(id: crate::AlbumId, f: impl FnOnce() -> T) -> Option {
+ let entered = EVALUATING.with_borrow_mut(|stack| {
+ let ok = !stack.contains(&id) && stack.len() < MAX_ALBUM_DEPTH;
+ if ok {
+ stack.push(id);
+ }
+ ok
+ });
+ if !entered {
+ return None;
+ }
+ // leaves the stack as it was, however `f` ends
+ struct Leave;
+ impl Drop for Leave {
+ fn drop(&mut self) {
+ EVALUATING.with_borrow_mut(|stack| {
+ stack.pop();
+ });
+ }
+ }
+ let _leave = Leave;
+ Some(f())
+}
+
+/// How a summary names an Album rule's album: “Name”, or `#9` when there is no such album.
+pub fn album_name(value: &Value, cat: Option<&Catalog>) -> String {
+ let id = album_rule_id(value);
+ match id.and_then(|id| cat.and_then(|c| c.album(id))) {
+ Some(a) => format!("“{}”", a.name),
+ None => id.map_or_else(|| value.to_string(), |id| format!("#{}", id.0)),
+ }
+}
+
+/// What a yes/no rule's value means: `true`, `"yes"`, `1` (or `1.0`) or no value is yes; `false`,
+/// `"no"`, `0` is no (strings in any case); `None` for anything else, which matches nothing.
+pub fn bool_value(value: &Value) -> Option {
+ match value {
+ Value::Bool(b) => Some(*b),
+ Value::Null => Some(true),
+ Value::Number(n) => match n.as_f64() {
+ Some(1.0) => Some(true),
+ Some(0.0) => Some(false),
+ _ => None,
+ },
+ Value::String(s) => match s.trim().to_lowercase().as_str() {
+ "true" | "yes" | "1" => Some(true),
+ "false" | "no" | "0" => Some(false),
+ _ => None,
+ },
+ _ => None,
+ }
+}
+
+/// The display label of a yes/no value.
+pub fn bool_label(b: bool) -> &'static str {
+ if b { "Yes" } else { "No" }
+}
+
+/// The display label of choice `id` of `field` ("Public Domain" for `publicDomain`); `None` when
+/// `field` isn't a choice field or has no such choice.
+pub fn choice_label(field: &str, id: &str) -> Option<&'static str> {
+ let Some(Kind::Choice(choices)) = field_kind(field) else { return None };
+ choices.iter().find(|c| c.0 == id).map(|c| c.1)
+}
+
+/// The display label of `field`; `None` for an unknown field.
+pub fn field_label(field: &str) -> Option<&'static str> {
+ FIELDS.iter().find(|f| f.0 == field).map(|f| f.1)
+}
+
/// The operators for a field kind: (id, label).
pub fn ops_for(kind: Kind) -> &'static [(&'static str, &'static str)] {
match kind {
@@ -119,7 +564,7 @@ pub fn ops_for(kind: Kind) -> &'static [(&'static str, &'static str)] {
("notInLast", "isn't in the last"),
("isEmpty", "is unknown"),
],
- Kind::Choice(_) => &[("is", "is"), ("isNot", "isn't")],
+ Kind::Choice(_) | Kind::Album => &[("is", "is"), ("isNot", "isn't")],
Kind::Bool => &[("is", "is")],
}
}
@@ -176,6 +621,18 @@ fn text_op(op: &str, have: &str, want: &str) -> bool {
}
}
+/// A [`Kind::Keywords`] op over a list of names: any name matches (none for `notContains`); `is`
+/// also matches one level of a hierarchical keyword (`italy` in `travel|italy|rome`).
+fn names_op>(op: &str, names: &[S], want: &str) -> bool {
+ let names = names.iter().map(AsRef::as_ref);
+ match op {
+ "isEmpty" => names.clone().all(|n| n.trim().is_empty()),
+ "isNotEmpty" => names.clone().any(|n| !n.trim().is_empty()),
+ "notContains" => !names.clone().any(|n| text_op("contains", n, want)),
+ _ => names.clone().any(|n| text_op(op, n, want) || (op == "is" && n.to_lowercase().split('|').any(|part| part.trim() == want))),
+ }
+}
+
fn number(v: &Value) -> Option {
match v {
Value::Number(n) => n.as_f64(),
@@ -204,21 +661,70 @@ fn num_op(op: &str, have: Option, value: &Value) -> bool {
}
}
-/// `value` for in-the-last rules: `{n, unit: days|weeks|months|years}` or a number of days.
+/// `landscape`, `portrait` or `square` (edges within 1 %) for a `w × h` frame; `None` when it has no area.
+fn aspect((w, h): (f64, f64)) -> Option<&'static str> {
+ if !(w > 0.0 && h > 0.0) {
+ return None;
+ }
+ Some(if (w - h).abs() <= 0.01 * w.max(h) {
+ "square"
+ } else if w > h {
+ "landscape"
+ } else {
+ "portrait"
+ })
+}
+
+/// A shutter-speed rule's value in seconds: camera notation (`1/250`) or a number; an operand
+/// that isn't a time becomes `null`, which matches nothing.
+fn shutter_value(value: &Value) -> Value {
+ let secs = |v: &Value| match v {
+ Value::String(s) => crate::parse_shutter_seconds(s).map_or(Value::Null, Value::from),
+ other => number(other).filter(|n| n.is_finite() && *n > 0.0).map_or(Value::Null, Value::from),
+ };
+ match value {
+ Value::Array(a) => Value::Array(a.iter().map(secs).collect()),
+ v => secs(v),
+ }
+}
+
+/// Seconds in one "in the last…" unit: hours, days, weeks, months or years, in any case, singular
+/// or plural; none means days. `None` for anything else, which the check refuses and the matcher
+/// matches nothing with.
+fn unit_secs(unit: &Value) -> Option {
+ let day = 86_400.0;
+ let name = match unit {
+ Value::Null => return Some(day),
+ Value::String(s) => s.trim().to_lowercase(),
+ _ => return None,
+ };
+ match name.strip_suffix('s').unwrap_or(&name) {
+ "hour" => Some(3600.0),
+ "day" => Some(day),
+ "week" => Some(7.0 * day),
+ "month" => Some(30.4375 * day),
+ "year" => Some(365.25 * day),
+ _ => None,
+ }
+}
+
+/// The longest "in the last…" span: 10,000 years, beyond any capture date ("ever").
+const MAX_LAST_SECS: f64 = 10_000.0 * 365.25 * 86_400.0;
+
+/// `value` for in-the-last rules: `{n, unit: days|weeks|months|years}` or a number of days, in
+/// seconds. `None` for a zero, negative or unreadable `n`; a longer span than [`MAX_LAST_SECS`]
+/// is that (so the arithmetic on it can't overflow).
fn last_secs(value: &Value) -> Option {
let (n, unit) = match value {
- Value::Object(o) => (o.get("n").and_then(number)?, o.get("unit").and_then(Value::as_str).unwrap_or("days")),
- v => (number(v)?, "days"),
+ Value::Object(o) => (o.get("n").and_then(number)?, o.get("unit").unwrap_or(&Value::Null)),
+ v => (number(v)?, &Value::Null),
};
- let day = 86_400.0;
- let per = match unit {
- "hours" => 3600.0,
- "weeks" => 7.0 * day,
- "months" => 30.4375 * day,
- "years" => 365.25 * day,
- _ => day,
- };
- Some((n * per) as i64)
+ let per = unit_secs(unit)?;
+ let secs = n * per;
+ if secs.is_nan() || secs <= 0.0 {
+ return None;
+ }
+ Some(secs.min(MAX_LAST_SECS) as i64)
}
fn date_op(op: &str, have: Option<&str>, value: &Value) -> bool {
@@ -226,7 +732,8 @@ fn date_op(op: &str, have: Option<&str>, value: &Value) -> bool {
return have.is_none_or(str::is_empty);
}
let Some(h) = have.filter(|h| !h.is_empty()) else { return op == "notInLast" };
- let s = |v: &Value| v.as_str().map(str::trim).unwrap_or("").to_string();
+ // the rule's date as the check reads it (a space as the "T"); one it can't read as written
+ let s = |v: &Value| v.as_str().map(|d| rule_date(d).unwrap_or_else(|_| d.trim().to_string())).unwrap_or_default();
match op {
// a prefix: 2026, 2026-04, 2026-04-12
"is" => {
@@ -250,7 +757,8 @@ fn date_op(op: &str, have: Option<&str>, value: &Value) -> bool {
},
"inLast" | "notInLast" => {
let Some(secs) = last_secs(value) else { return false };
- let from = crate::dates::shift_iso(&now(), -secs).unwrap_or_default();
+ // a span reaching before year 0 is "ever": every dated photo is in it
+ let Some(from) = crate::dates::shift_iso(&now(), -secs) else { return op == "inLast" };
(h >= from.as_str()) == (op == "inLast")
}
_ => false,
@@ -266,6 +774,7 @@ impl Rule {
let m = &p.meta;
let want = lower(value);
let text = |have: &str| text_op(op, have, &want);
+ let yes = |have: bool| bool_value(value).is_some_and(|b| have == b);
match field {
"rating" => num_op(op, Some(p.rating as f64), value),
"flag" => {
@@ -279,15 +788,20 @@ impl Rule {
(l == want || named) == (op == "is")
}
"kind" => (format!("{:?}", p.kind).to_lowercase() == want) == (op == "is"),
- "edited" => p.is_edited() == value.as_bool().unwrap_or(true),
- "hasGps" => m.gps.is_some() == value.as_bool().unwrap_or(true),
- "virtualCopy" => p.copy_of.is_some() == value.as_bool().unwrap_or(true),
- "keywords" => match op {
- "isEmpty" => m.keywords.is_empty(),
- "isNotEmpty" => !m.keywords.is_empty(),
- "notContains" => !m.keywords.iter().any(|k| text_op("contains", k, &want)),
- _ => m.keywords.iter().any(|k| text_op(op, k, &want) || (op == "is" && k.to_lowercase().split('|').any(|part| part == want))),
- },
+ "edited" => yes(p.is_edited()),
+ "hasGps" => yes(m.gps.is_some()),
+ "virtualCopy" => yes(p.copy_of.is_some()),
+ "copyName" => text(p.copy_name.as_deref().unwrap_or("")),
+ "stacked" => yes(cat.stack_of(p.id).is_some()),
+ "cropped" => yes(p.is_cropped()),
+ "treatment" => {
+ let t = if p.develop.treatment == lightcraft_develop::Treatment::Bw { "monochrome" } else { "color" };
+ (t == want) == (op == "is")
+ }
+ "keywords" => names_op(op, &m.keywords, &want),
+ "keywordCount" => num_op(op, Some(p.keyword_count() as f64), value),
+ "person" => names_op(op, &p.people(), &want),
+ "personCount" => num_op(op, Some(p.people().len() as f64), value),
"text" => {
let all = [
p.file_name.as_str(),
@@ -297,14 +811,20 @@ impl Rule {
&m.lens,
&m.location,
&m.city,
+ &m.state,
&m.country,
+ &m.alt_text,
&p.format,
&m.keywords.join(" "),
+ &p.people().join(" "),
+ p.copy_name.as_deref().unwrap_or(""),
]
.join(" ");
text(&all)
}
"fileName" => text(&p.file_name),
+ "extension" => text_op(op, p.extension(), want.trim_start_matches('.')),
+ "duration" => num_op(op, p.duration.filter(|d| d.is_finite()), value),
"filePath" => {
// `/` and `\` both separate, whatever platform the catalog came from; demo photos have no path
let path = match &p.source {
@@ -322,6 +842,10 @@ impl Rule {
"format" => text(&p.format),
"title" => text(&m.title),
"caption" => text(&m.caption),
+ "altText" => text(&m.alt_text),
+ "city" => text(&m.city),
+ "state" => text(&m.state),
+ "country" => text(&m.country),
"camera" => text(&m.camera),
"lens" => text(&m.lens),
"location" => text(&[m.location.as_str(), &m.city, &m.state, &m.country].join(" ")),
@@ -334,15 +858,25 @@ impl Rule {
"captureDate" => date_op(op, p.captured.as_deref(), value),
"importDate" => date_op(op, Some(&p.imported), value),
"editDate" => date_op(op, p.edited.as_deref(), value),
+ "shutterSpeed" => num_op(op, crate::parse_shutter_seconds(&m.shutter), &shutter_value(value)),
"iso" => num_op(op, m.iso.map(|v| v as f64), value),
"aperture" => num_op(op, m.aperture.map(|v| v as f64), value),
"focalLength" => num_op(op, m.focal_mm.map(|v| v as f64), value),
- "megapixels" => num_op(op, Some(p.width as f64 * p.height as f64 / 1e6), value),
+ "longEdge" | "shortEdge" => {
+ let (w, h) = p.shown_size();
+ num_op(op, Some(if field == "longEdge" { w.max(h) } else { w.min(h) }.round()), value)
+ }
+ "aspect" => (aspect(p.shown_size()) == Some(want.as_str())) == (op == "is"),
+ "megapixels" => {
+ let (w, h) = p.shown_size();
+ num_op(op, Some(w * h / 1e6), value)
+ }
"sharpness" => num_op(op, p.analysis.map(|a| a.sharpness as f64), value),
- "bestOfGroup" => p.analysis.is_some_and(|a| a.best || a.group.is_none()) == value.as_bool().unwrap_or(true),
+ "bestOfGroup" => yes(p.analysis.is_some_and(|a| a.best || a.group.is_none())),
"album" => {
- let id = number(value).map(|v| crate::AlbumId(v as u64));
- id.is_some_and(|a| cat.album(a).is_some_and(|al| !al.is_smart()) && cat.album_contains(a, p)) == (op == "is")
+ let id = album_rule_id(value);
+ // a smart album's photos are its matches; one being evaluated (a loop) holds none
+ id.is_some_and(|a| cat.album_contains(a, p)) == (op == "is")
}
_ => false,
}
@@ -370,24 +904,78 @@ impl RuleSet {
})
}
- /// Unknown fields or operators (for command validation), as readable messages.
- pub fn problems(&self) -> Vec {
+ /// Bring rules saved by older versions up to date, keeping what they meant: an Album rule with
+ /// an operator from when Album was a number field (≥, between…) always matched as "isn't", and
+ /// now says so. Commands and the editor apply it before checking.
+ pub fn upgrade(&mut self) {
+ for rule in &mut self.rules {
+ match rule {
+ Rule::Group { group } => group.upgrade(),
+ Rule::Field { field, op, .. } if field == "album" && op != "is" && op != "isNot" => *op = "isNot".into(),
+ Rule::Field { .. } => {}
+ }
+ }
+ }
+
+ /// The rules that can't mean anything, in order: an unknown field, an operator the field
+ /// doesn't have, a value that isn't one of the field's (a number, a date, a choice, yes or
+ /// no, an album that exists and isn't a folder…), text with nothing to look for, an empty group. Commands refuse a rule
+ /// set with problems; the editor shows them. `cat` knows the albums and colour-label names.
+ pub fn check(&self, cat: &Catalog) -> Vec {
+ self.check_for(cat, None)
+ }
+
+ /// [`RuleSet::check`] for the rules of smart album `owner` (`None`: a new album or a filter),
+ /// which also refuses an Album rule that would make `owner` include itself.
+ pub fn check_for(&self, cat: &Catalog, owner: Option) -> Vec {
let mut out = Vec::new();
- for r in &self.rules {
- match r {
- Rule::Group { group } => out.extend(group.problems()),
- Rule::Field { field, op, .. } => match field_kind(field) {
- None => out.push(format!("unknown field `{field}`")),
- Some(k) if !ops_for(k).iter().any(|o| o.0 == op) => out.push(format!("`{field}` has no operator `{op}`")),
- _ => {}
- },
+ self.check_into(cat, owner, &mut Vec::new(), &mut out);
+ out
+ }
+
+ /// The albums the Album rules test, groups included, in order.
+ pub fn albums_tested(&self) -> Vec {
+ let mut out = Vec::new();
+ for rule in &self.rules {
+ match rule {
+ Rule::Group { group } => out.extend(group.albums_tested()),
+ Rule::Field { field, value, .. } if field == "album" => out.extend(album_rule_id(value)),
+ Rule::Field { .. } => {}
}
}
out
}
+ fn check_into(&self, cat: &Catalog, owner: Option, path: &mut Vec, out: &mut Vec) {
+ for (i, rule) in self.rules.iter().enumerate() {
+ path.push(i);
+ match rule {
+ Rule::Group { group } if group.rules.is_empty() => {
+ out.push(Problem { path: path.clone(), field: None, issue: Issue::EmptyGroup, message: "an empty group".into() })
+ }
+ Rule::Group { group } => group.check_into(cat, owner, path, out),
+ Rule::Field { field, op, value } => {
+ if let Some((issue, message)) = field_problem(field, op, value, cat, owner) {
+ out.push(Problem { path: path.clone(), field: Some(field.clone()), issue, message });
+ }
+ }
+ }
+ path.pop();
+ }
+ }
+
/// A short readable summary ("rating is ≥ 3 and keywords contains travel").
pub fn describe(&self) -> String {
+ self.describe_in(None)
+ }
+
+ /// [`RuleSet::describe`] naming the albums Album rules test ("album isn't “Excluded
+ /// Photos”"); one that is gone shows as `#9`.
+ pub fn describe_with(&self, cat: &Catalog) -> String {
+ self.describe_in(Some(cat))
+ }
+
+ fn describe_in(&self, cat: Option<&Catalog>) -> String {
let join = match self.mode {
Match::All => " and ",
Match::Any => " or ",
@@ -397,12 +985,16 @@ impl RuleSet {
.rules
.iter()
.map(|r| match r {
- Rule::Group { group } => format!("({})", group.describe()),
+ Rule::Group { group } => format!("({})", group.describe_in(cat)),
Rule::Field { field, op, value } => {
- let label = FIELDS.iter().find(|f| f.0 == field).map_or(field.as_str(), |f| f.1).to_lowercase();
+ let label = field_label(field).unwrap_or(field).to_lowercase();
let op = field_kind(field).and_then(|k| ops_for(k).iter().find(|o| o.0 == op)).map_or(op.as_str(), |o| o.1);
let v = match value {
- Value::String(s) => s.clone(),
+ _ if field == "album" && cat.is_some() => album_name(value, cat),
+ _ if field_kind(field) == Some(Kind::Bool) => {
+ bool_value(value).map_or_else(|| value.to_string(), |b| bool_label(b).to_lowercase())
+ }
+ Value::String(s) => choice_label(field, s).map_or_else(|| s.clone(), str::to_lowercase),
Value::Null => String::new(),
Value::Object(o) => format!(
"{} {}",
@@ -462,14 +1054,14 @@ mod tests {
yes(json!({"rules": [{"field": "fileName", "op": "startsWith", "value": "img_"}, {"field": "fileName", "op": "endsWith", "value": ".cr2"}]}));
// file path: the whole string (spaces included), either separator, case-insensitive
let mut fp = photo(3);
- fp.source = Source::File { path: "D:\\Photos\\Aliah Ira Polanco-Grylls\\2026\\IMG_0042.CR2".into() };
+ fp.source = Source::File { path: "D:\\Photos\\Lake Como Wedding-Day\\2026\\IMG_0042.CR2".into() };
let fpm = |v: serde_json::Value| rs(v).matches(&fp, &cat);
- assert!(fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Aliah Ira Polanco-Grylls/"}]})));
- assert!(!fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Ira Aliah/"}]})));
- assert!(!fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "Polanco Grylls"}]})));
+ assert!(fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Lake Como Wedding-Day/"}]})));
+ assert!(!fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Como Lake/"}]})));
+ assert!(!fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "Wedding Day"}]})));
assert!(fpm(json!({"rules": [{"field": "filePath", "op": "notContains", "value": "/Other/"}]})));
assert!(fpm(json!({"rules": [{"field": "filePath", "op": "startsWith", "value": "d:/photos/"}]})));
- no(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Aliah/"}]})); // demo photo has no path
+ no(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Lake/"}]})); // demo photo has no path
yes(json!({"rules": [{"field": "filePath", "op": "isEmpty"}]}));
yes(json!({"rules": [{"field": "camera", "op": "contains", "value": "x2"}, {"field": "title", "op": "isEmpty"}]}));
yes(json!({"rules": [{"field": "iso", "op": "between", "value": [800, 3200]}, {"field": "aperture", "op": "lte", "value": "f/4"}]}));
@@ -504,7 +1096,8 @@ mod tests {
#[test]
fn problems_and_description() {
let r = rs(json!({"rules": [{"field": "rating", "op": "contains", "value": 1}, {"group": {"rules": [{"field": "nope", "op": "is"}]}}]}));
- assert_eq!(r.problems(), vec!["`rating` has no operator `contains`".to_string(), "unknown field `nope`".to_string()]);
+ let messages: Vec = r.check(&Catalog::new()).into_iter().map(|p| p.message).collect();
+ assert_eq!(messages, vec!["`rating` has no operator `contains`".to_string(), "unknown field `nope`".to_string()]);
let r = rs(
json!({"match": "any", "rules": [{"field": "rating", "op": "gte", "value": 3}, {"field": "captureDate", "op": "inLast", "value": {"n": 2, "unit": "weeks"}}]}),
);
@@ -514,4 +1107,561 @@ mod tests {
assert!(!ops_for(*k).is_empty(), "{f}");
}
}
+
+ /// Keyword Count: a photo with 2 keywords is in "≥ 2", "≤ 2" and "is 2"; a hierarchical path
+ /// (travel|italy|rome) is one keyword, and the same keyword twice in different case is one.
+ #[test]
+ fn keyword_count_rules() {
+ let cat = Catalog::new();
+ let mut p = photo(1); // travel|italy|rome, food
+ let m = |p: &Photo, op: &str, value: serde_json::Value| {
+ rs(json!({"rules": [{"field": "keywordCount", "op": op, "value": value}]})).matches(p, &cat)
+ };
+ assert!(m(&p, "gte", json!(2)) && m(&p, "lte", json!(2)) && m(&p, "is", json!(2)));
+ assert!(!m(&p, "gte", json!(3)) && !m(&p, "lt", json!(2)) && m(&p, "between", json!([1, 2])));
+ p.meta.keywords.push("Food".into());
+ assert!(m(&p, "is", json!(2)), "Food and food are one keyword");
+ p.meta.keywords.clear();
+ assert!(m(&p, "is", json!(0)) && m(&p, "lte", json!(2)));
+ assert_eq!(field_group("keywordCount"), Some("Keywords & People"));
+ }
+
+ /// People: the names on face regions. "People contains ana" finds Ana Lima; pets and unnamed
+ /// faces are not people; People Count counts each person once.
+ #[test]
+ fn people_rules() {
+ use lightcraft_meta::{Rect, Region, RegionKind};
+ let cat = Catalog::new();
+ let region = |name: Option<&str>, kind: RegionKind| Region {
+ rect: Rect { x0: 0.4, y0: 0.4, x1: 0.6, y1: 0.6 },
+ kind,
+ name: name.map(str::to_string),
+ description: None,
+ };
+ let mut p = photo(1);
+ let m = |p: &Photo, field: &str, op: &str, value: serde_json::Value| {
+ rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat)
+ };
+ assert!(m(&p, "person", "isEmpty", json!(null)) && m(&p, "personCount", "is", json!(0)));
+ p.meta.regions = vec![
+ region(Some("Ana Lima"), RegionKind::Face),
+ region(Some(" ana lima "), RegionKind::Face),
+ region(Some("Rex"), RegionKind::Pet),
+ region(None, RegionKind::Face),
+ region(Some("Bo"), RegionKind::Face),
+ ];
+ assert!(m(&p, "person", "contains", json!("ana")));
+ assert!(m(&p, "person", "is", json!("ANA LIMA")));
+ assert!(!m(&p, "person", "is", json!("rex")), "a pet isn't a person");
+ assert!(m(&p, "person", "notContains", json!("rex")));
+ assert!(m(&p, "person", "isNotEmpty", json!(null)));
+ assert!(m(&p, "personCount", "is", json!(2)), "Ana Lima once, Bo; not the pet or the unnamed face");
+ assert_eq!(field_group("person"), Some("Keywords & People"));
+ }
+
+ /// Shutter Speed compares exposure times in seconds, written as the camera shows them: a
+ /// photo at 1/250 is in "≤ 1/60" (faster) and not in "≥ 1/60"; "between 1/1000 and 1/125".
+ #[test]
+ fn shutter_speed_rules() {
+ let cat = Catalog::new();
+ let mut p = photo(1);
+ let m = |p: &Photo, op: &str, value: serde_json::Value| {
+ rs(json!({"rules": [{"field": "shutterSpeed", "op": op, "value": value}]})).matches(p, &cat)
+ };
+ assert!(!m(&p, "gte", json!(0)), "no shutter speed recorded");
+ p.meta.shutter = "1/250".into();
+ assert!(m(&p, "is", json!("1/250")) && m(&p, "lte", json!("1/60")) && !m(&p, "gte", json!("1/60")));
+ assert!(m(&p, "between", json!(["1/1000", "1/125"])) && m(&p, "lt", json!(0.01)));
+ assert!(!m(&p, "is", json!("1/0")) && !m(&p, "is", json!("fast")), "a value that isn't a time matches nothing");
+ p.meta.shutter = "2\"".into();
+ assert!(m(&p, "gte", json!("1")) && m(&p, "is", json!(2)));
+ assert_eq!(field_group("shutterSpeed"), Some("Camera Info"));
+ }
+
+ #[test]
+ fn shutter_seconds_parses_camera_notation() {
+ for (s, want) in [("1/250", Some(0.004)), (" 1/250 s", Some(0.004)), ("0.5", Some(0.5)), ("2\"", Some(2.0)), ("30s", Some(30.0))] {
+ assert_eq!(crate::parse_shutter_seconds(s), want, "{s}");
+ }
+ for s in ["", "1/0", "0", "-1/250", "fast", "inf", "NaN", "1/x"] {
+ assert_eq!(crate::parse_shutter_seconds(s), None, "{s}");
+ }
+ }
+
+ /// File Extension ignores case and a leading dot; Copy Name, Alt Text, City, State /
+ /// Province and Country are text fields of their own (Location still matches all of them).
+ #[test]
+ fn file_source_description_and_location_rules() {
+ let cat = Catalog::new();
+ let mut p = photo(1); // IMG_0042.CR2
+ let m = |p: &Photo, field: &str, op: &str, value: serde_json::Value| {
+ rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat)
+ };
+ assert!(m(&p, "extension", "is", json!("cr2")) && m(&p, "extension", "is", json!(".CR2")));
+ assert!(!m(&p, "extension", "is", json!("jpg")));
+ p.file_name = "README".into();
+ assert!(m(&p, "extension", "isEmpty", json!(null)), "no dot, no extension");
+ p.file_name = "archive.tar.gz".into();
+ assert!(m(&p, "extension", "is", json!("gz")));
+ p.file_name = ".hidden".into();
+ assert!(m(&p, "extension", "isEmpty", json!(null)), "a dot file has no extension");
+ assert!(m(&p, "copyName", "isEmpty", json!(null)));
+ p.copy_name = Some("Black and white".into());
+ assert!(m(&p, "copyName", "contains", json!("black")));
+ p.meta.alt_text = "A red kite over a hill".into();
+ assert!(m(&p, "altText", "contains", json!("kite")));
+ p.meta.city = "Porto".into();
+ p.meta.state = "Porto District".into();
+ p.meta.country = "Portugal".into();
+ assert!(m(&p, "city", "is", json!("porto")) && m(&p, "state", "startsWith", json!("porto")) && m(&p, "country", "is", json!("portugal")));
+ assert!(!m(&p, "city", "is", json!("portugal")), "City tests only the city");
+ assert!(m(&p, "location", "contains", json!("portugal")), "Location still covers the country");
+ }
+
+ /// In a Stack, Video Duration (seconds; photos have none), Cropped and Treatment.
+ #[test]
+ fn source_file_and_develop_rules() {
+ use crate::{Op, Stack, StackId};
+ let mut cat = Catalog::new();
+ let a = photo(1);
+ let b = photo(2);
+ let c = photo(3);
+ for p in [&a, &b, &c] {
+ cat.apply(Op::AddPhoto { photo: Box::new(p.clone()) }).unwrap();
+ }
+ cat.apply(Op::AddStack { stack: Stack { id: StackId(1), photos: vec![a.id, b.id], collapsed: false } }).unwrap();
+ let m = |p: &Photo, field: &str, op: &str, value: serde_json::Value| {
+ rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat)
+ };
+ assert!(m(&a, "stacked", "is", json!(true)) && m(&b, "stacked", "is", json!(true)));
+ assert!(m(&c, "stacked", "is", json!(false)));
+ let mut v = photo(4);
+ assert!(!m(&v, "duration", "gte", json!(0)), "a photo has no duration");
+ v.duration = Some(95.0);
+ assert!(m(&v, "duration", "gt", json!(60)) && m(&v, "duration", "between", json!([90, 120])));
+ let mut d = photo(5);
+ assert!(m(&d, "cropped", "is", json!(false)) && m(&d, "treatment", "is", json!("color")));
+ let mut s = (*d.develop).clone();
+ s.crop.geometry.rect = lightcraft_geom::Rect::new(0.1, 0.0, 0.9, 1.0);
+ s.treatment = lightcraft_develop::Treatment::Bw;
+ d.develop = std::sync::Arc::new(s);
+ assert!(m(&d, "cropped", "is", json!(true)) && m(&d, "treatment", "is", json!("monochrome")));
+ assert!(m(&d, "treatment", "isNot", json!("color")));
+ let mut s = (*d.develop).clone();
+ s.crop = Default::default();
+ s.crop.geometry.angle = 2.0;
+ d.develop = std::sync::Arc::new(s);
+ assert!(m(&d, "cropped", "is", json!(true)), "straightening counts as a crop");
+ }
+
+ /// Size follows the photo as shown: rotated a quarter turn a landscape frame is a portrait, and
+ /// a crop changes its edges and its megapixels. Long / Short Edge are in pixels.
+ #[test]
+ fn size_rules_follow_orientation_and_crop() {
+ let cat = Catalog::new();
+ let mut p = photo(1); // 6000 × 4000
+ let m = |p: &Photo, field: &str, op: &str, value: serde_json::Value| {
+ rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat)
+ };
+ assert!(m(&p, "aspect", "is", json!("landscape")) && m(&p, "longEdge", "is", json!(6000)) && m(&p, "shortEdge", "is", json!(4000)));
+ assert!(m(&p, "megapixels", "is", json!(24)));
+ let mut s = (*p.develop).clone();
+ s.orientation = lightcraft_geom::Orientation::Rotate90;
+ p.develop = std::sync::Arc::new(s.clone());
+ assert!(m(&p, "aspect", "is", json!("portrait")) && m(&p, "longEdge", "is", json!(6000)));
+ // a square crop of the rotated frame: 4000 wide, 4000 of its 6000 tall
+ s.crop.geometry.rect = lightcraft_geom::Rect::new(0.0, 1.0 / 6.0, 1.0, 5.0 / 6.0);
+ p.develop = std::sync::Arc::new(s);
+ assert!(m(&p, "aspect", "is", json!("square")) && m(&p, "longEdge", "is", json!(4000)));
+ assert!(m(&p, "megapixels", "is", json!(16)), "a 4000 × 4000 crop is 16 MP, not the sensor's 24");
+ assert!(m(&p, "megapixels", "lt", json!(20)) && !m(&p, "megapixels", "gte", json!(24)));
+ assert!(m(&p, "aspect", "isNot", json!("portrait")));
+ let empty = Photo::new(PhotoId(9), Source::Demo { scene: 0 }, "x.jpg", "JPEG", 0, 0, "2026-09-20T10:00:00");
+ assert!(
+ !m(&empty, "aspect", "is", json!("landscape")) && !m(&empty, "aspect", "is", json!("square")) && m(&empty, "longEdge", "is", json!(0))
+ );
+ assert!(m(&empty, "megapixels", "is", json!(0)));
+ }
+
+ /// Any Searchable Text also finds the state / province, the alt text, a person on a face and
+ /// a virtual copy's name, as it already finds the city and country.
+ #[test]
+ fn any_searchable_text_covers_new_fields() {
+ let cat = Catalog::new();
+ let mut p = photo(1);
+ let m = |p: &Photo, value: &str| rs(json!({"rules": [{"field": "text", "op": "contains", "value": value}]})).matches(p, &cat);
+ for word in ["oregon", "kite", "ana", "bluish"] {
+ assert!(!m(&p, word), "{word}");
+ }
+ p.meta.state = "Oregon".into();
+ p.meta.alt_text = "A kite".into();
+ p.meta.regions = vec![lightcraft_meta::Region {
+ rect: lightcraft_meta::Rect { x0: 0.4, y0: 0.4, x1: 0.6, y1: 0.6 },
+ kind: lightcraft_meta::RegionKind::Face,
+ name: Some("Ana".into()),
+ description: None,
+ }];
+ p.copy_name = Some("Bluish".into());
+ for word in ["oregon", "kite", "ana", "bluish"] {
+ assert!(m(&p, word), "{word}");
+ }
+ }
+
+ /// Choice values have readable labels: the editor and summaries show "Public Domain" and
+ /// "Black & White", never ids like `publicDomain`; rules still store and accept the ids.
+ #[test]
+ fn choices_have_readable_labels() {
+ assert_eq!(choice_label("copyrightStatus", "publicDomain"), Some("Public Domain"));
+ assert_eq!(choice_label("treatment", "monochrome"), Some("Black & White"));
+ // own labels where a shared word would translate wrongly: "Color" is the colour noun (the
+ // Color panel) and "None" agrees with other nouns in gendered languages
+ assert_eq!(choice_label("treatment", "color"), Some("In Color"));
+ assert_eq!(choice_label("label", "none"), Some("No Label"));
+ assert_eq!(choice_label("flag", "pick"), Some("Picked"));
+ assert_eq!(choice_label("aspect", "landscape"), Some("Landscape (wide)"));
+ assert_eq!(choice_label("flag", "nope"), None);
+ assert_eq!(choice_label("rating", "pick"), None, "not a choice field");
+ for (field, _, kind) in FIELDS {
+ let Kind::Choice(choices) = kind else { continue };
+ for (id, label) in *choices {
+ assert!(!label.is_empty() && label != id, "{field}: {id} has no label of its own");
+ assert!(label.starts_with(|c: char| c.is_uppercase()), "{field}: {label}");
+ }
+ }
+ let r = rs(
+ json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "publicDomain"}, {"field": "flag", "op": "isNot", "value": "reject"}]}),
+ );
+ assert_eq!(r.describe(), "copyright status is public domain and pick flag isn't rejected");
+ let mut p = photo(1);
+ p.meta.copyright_status = crate::CopyrightStatus::PublicDomain;
+ assert!(r.matches(&p, &Catalog::new()), "ids still match");
+ }
+
+ /// Yes/no fields read "Yes" / "No", and a value means the same to matching and to every
+ /// summary: `false`, `"false"`, `"no"` and `0` are no; `true`, `"yes"`, `1` and a missing value
+ /// are yes. Anything else is reported, not silently taken as yes.
+ #[test]
+ fn yes_no_values() {
+ for v in [json!(true), json!("true"), json!("Yes"), json!(1), json!(1.0), json!(null)] {
+ assert_eq!(bool_value(&v), Some(true), "{v}");
+ }
+ for v in [json!(false), json!("false"), json!(" NO "), json!(0), json!(0.0)] {
+ assert_eq!(bool_value(&v), Some(false), "{v}");
+ }
+ for v in [json!("maybe"), json!(2), json!(0.5), json!(-1), json!([true]), json!({"x": 1})] {
+ assert_eq!(bool_value(&v), None, "{v}");
+ }
+ assert_eq!((bool_label(true), bool_label(false)), ("Yes", "No"));
+ let cat = Catalog::new();
+ let p = photo(1); // unedited
+ assert!(rs(json!({"rules": [{"field": "edited", "op": "is", "value": "false"}]})).matches(&p, &cat), "\"false\" means no");
+ assert!(
+ !rs(json!({"rules": [{"field": "edited", "op": "is", "value": "false"}, {"field": "hasGps", "op": "is", "value": "yes"}]}))
+ .matches(&p, &cat)
+ );
+ assert!(!rs(json!({"rules": [{"field": "edited", "op": "is", "value": "maybe"}]})).matches(&p, &cat), "an unreadable value matches nothing");
+ let r = rs(json!({"rules": [{"field": "edited", "op": "is", "value": true}, {"field": "cropped", "op": "is", "value": "false"}]}));
+ assert_eq!(r.describe(), "has edits is yes and cropped is no");
+ let bad = rs(json!({"rules": [{"field": "edited", "op": "is", "value": "maybe"}]}));
+ let messages: Vec = bad.check(&cat).into_iter().map(|p| p.message).collect();
+ assert_eq!(messages, vec!["`edited` is yes or no, not \"maybe\"".to_string()]);
+ }
+
+ /// "In the last N" never overflows, whatever N an agent or a saved file holds: a zero,
+ /// negative or unreadable N matches nothing (either way round); an N beyond 10,000 years means
+ /// "ever", so every dated photo is in it and none is outside it.
+ #[test]
+ fn in_the_last_survives_hostile_counts() {
+ set_now(Some("2026-09-01T00:00:00".into()));
+ let cat = Catalog::new();
+ let p = photo(1); // captured 2026-08-14
+ let m = |op: &str, n: serde_json::Value, unit: &str| {
+ rs(json!({"rules": [{"field": "captureDate", "op": op, "value": {"n": n, "unit": unit}}]})).matches(&p, &cat)
+ };
+ for n in [json!(0), json!(-5), json!(-1e30), json!(-9.3e18), json!("soon")] {
+ for op in ["inLast", "notInLast"] {
+ assert!(!m(op, n.clone(), "years"), "{op} {n}");
+ }
+ }
+ for n in [json!(1e30), json!(9.3e18), json!(u64::MAX), json!(20_000)] {
+ assert!(m("inLast", n.clone(), "years"), "{n} years is ever");
+ assert!(!m("notInLast", n.clone(), "years"), "{n}");
+ }
+ assert!(m("inLast", json!(1e30), "hours") && m("inLast", json!(30), "days"));
+ set_now(None);
+ }
+
+ /// Every rule's value is checked against its field, so a rule that can't mean anything is
+ /// reported instead of quietly matching nothing (or everything). Each problem says which rule
+ /// it is about: "rule 2", or "rule 2.1" inside a group.
+ #[test]
+ fn values_are_checked_against_their_field() {
+ use crate::{Album, AlbumId, Op};
+ let mut cat = Catalog::new();
+ cat.apply(Op::AddAlbum { album: Album::new(AlbumId(1), "Trip") }).unwrap();
+ let smart = Album { smart: Some(Box::default()), ..Album::new(AlbumId(2), "Best") };
+ cat.apply(Op::AddAlbum { album: smart }).unwrap();
+ cat.apply(Op::SetLabelName { label: crate::ColorLabel::Red, name: Some("Client".into()) }).unwrap();
+ let check = |rule: serde_json::Value| rs(json!({"rules": [rule]})).check(&cat);
+ let ok = |rule: serde_json::Value| assert!(check(rule.clone()).is_empty(), "{rule}: {:?}", check(rule.clone()));
+ let bad = |rule: serde_json::Value, want: &str| {
+ let p = check(rule.clone());
+ assert!(p.iter().any(|p| p.message.contains(want)), "{rule}: wanted {want:?}, got {p:?}");
+ };
+ let r = |field: &str, op: &str, value: serde_json::Value| json!({"field": field, "op": op, "value": value});
+ // numbers
+ ok(r("rating", "gte", json!(3)));
+ ok(r("rating", "gte", json!("4")));
+ ok(r("rating", "between", json!([1, 5])));
+ ok(r("aperture", "lte", json!("f/4")));
+ ok(r("focalLength", "is", json!("50mm")));
+ bad(r("rating", "gte", json!("abc")), "needs a number");
+ bad(r("rating", "gte", json!(7)), "no rating 0–5 is ≥ 7");
+ bad(r("iso", "between", json!([100])), "two");
+ bad(r("iso", "between", json!("100")), "two");
+ ok(r("shutterSpeed", "lte", json!("1/60")));
+ bad(r("shutterSpeed", "lte", json!("fast")), "1/250");
+ // dates
+ for d in ["2026", "2026-08", "2026-08-14", "2026-08-14T10:00:00"] {
+ ok(r("captureDate", "is", json!(d)));
+ }
+ ok(r("captureDate", "between", json!(["2026-01", "2026-12"])));
+ for d in [json!("banana"), json!("2026-13"), json!("2026-02-30"), json!(""), json!(2026)] {
+ bad(r("captureDate", "after", d), "needs a date");
+ }
+ bad(r("captureDate", "between", json!(["2026-12", "2026-01"])), "after the second");
+ bad(r("captureDate", "between", json!(["", "2026"])), "needs a date");
+ ok(r("captureDate", "inLast", json!({"n": 2, "unit": "weeks"})));
+ ok(r("captureDate", "inLast", json!(7)));
+ bad(r("captureDate", "inLast", json!({"n": 0, "unit": "days"})), "more than 0");
+ bad(r("captureDate", "notInLast", json!({"n": 3, "unit": "fortnights"})), "fortnights");
+ ok(r("captureDate", "isEmpty", json!(null)));
+ // choices: the ids, the flag's aliases and a colour label's custom name
+ ok(r("kind", "is", json!("raw")));
+ ok(r("flag", "is", json!("picked")));
+ ok(r("label", "is", json!("client")));
+ bad(r("label", "is", json!("pink")), "pink");
+ bad(r("kind", "isNot", json!("photo")), "photo");
+ // text needs something to look for
+ ok(r("title", "contains", json!("kite")));
+ ok(r("title", "isEmpty", json!(null)));
+ bad(r("title", "contains", json!(" ")), "something to look for");
+ bad(r("keywords", "startsWith", json!("")), "something to look for");
+ // albums: one that exists, plain or smart
+ ok(r("album", "is", json!(1)));
+ bad(r("album", "is", json!(999)), "no album 999");
+ ok(r("album", "is", json!(2)));
+ // groups: an empty one is a mistake; problems inside one say where they are
+ let p = rs(json!({"rules": [r("rating", "gte", json!(3)), {"group": {"match": "any", "rules": []}}]})).check(&cat);
+ assert_eq!(p.iter().map(ToString::to_string).collect::>(), vec!["rule 2: an empty group".to_string()]);
+ let p = rs(json!({"rules": [r("rating", "gte", json!(3)), {"group": {"rules": [r("iso", "is", json!(100)), r("rating", "is", json!(9))]}}]}))
+ .check(&cat);
+ assert_eq!(p.len(), 1);
+ assert_eq!(p[0].path, vec![1, 1]);
+ assert_eq!(p[0].to_string(), "rule 2.2: no rating 0–5 is 9");
+ // an empty rule list at the top is "all photos" (or none, for any), not a mistake
+ assert!(rs(json!({"rules": []})).check(&cat).is_empty());
+ }
+
+ /// The check and the matcher agree. Units are any case, singular or plural, and missing means
+ /// days; an unknown unit is refused and matches nothing (it used to count as days, so "week" was
+ /// one day). A rating rule is refused only when no rating 0–5 could meet it. A colour label's
+ /// custom name matches in any case, accents included. Empty text hints at the right operator.
+ #[test]
+ fn check_and_matcher_agree() {
+ use crate::Op;
+ set_now(Some("2026-09-01T00:00:00".into()));
+ let mut cat = Catalog::new();
+ cat.apply(Op::SetLabelName { label: crate::ColorLabel::Red, name: Some("Été".into()) }).unwrap();
+ let p = photo(1); // captured 2026-08-14 (18 days before now), red label, rating 4
+ let rule = |field: &str, op: &str, value: serde_json::Value| rs(json!({"rules": [{"field": field, "op": op, "value": value}]}));
+ let valid = |r: &RuleSet| r.check(&cat).is_empty();
+ for unit in [json!("Days"), json!("DAY"), json!("days"), json!(null)] {
+ let r = rule("captureDate", "inLast", json!({"n": 30, "unit": unit}));
+ assert!(valid(&r) && r.matches(&p, &cat), "{unit}");
+ }
+ let r = rule("captureDate", "inLast", json!({"n": 30}));
+ assert!(valid(&r) && r.matches(&p, &cat), "no unit is days");
+ let week = rule("captureDate", "inLast", json!({"n": 1, "unit": "week"}));
+ assert!(valid(&week) && !week.matches(&p, &cat), "a week, not a day: 18 days ago is outside it");
+ assert!(rule("captureDate", "inLast", json!({"n": 3, "unit": "Weeks"})).matches(&p, &cat));
+ let odd = rule("captureDate", "inLast", json!({"n": 30, "unit": "fortnights"}));
+ assert!(!valid(&odd) && !odd.matches(&p, &cat), "an unknown unit is refused and matches nothing");
+ // rating: only a rule no rating could meet
+ for (op, v) in [("lt", json!(6)), ("gt", json!(-1)), ("isNot", json!(9)), ("between", json!([3, 8]))] {
+ assert!(valid(&rule("rating", op, v.clone())), "{op} {v}");
+ }
+ for (op, v) in [("is", json!(9)), ("gte", json!(7)), ("gt", json!(5)), ("lt", json!(0)), ("between", json!([6, 9]))] {
+ assert!(!valid(&rule("rating", op, v.clone())), "{op} {v}");
+ }
+ // a custom label name, any case, accents too
+ let r = rule("label", "is", json!("été"));
+ assert!(valid(&r) && r.matches(&p, &cat));
+ // "isn't" with nothing points at "isn't empty", the others at "is empty"
+ let hint = |op: &str| rule("title", op, json!("")).check(&cat).first().map(|p| p.message.clone()).unwrap_or_default();
+ assert!(hint("isNot").contains("isn't empty"), "{}", hint("isNot"));
+ assert!(hint("contains").contains("“is empty”"), "{}", hint("contains"));
+ set_now(None);
+ }
+
+ /// An album is in it or not: Album offers "is" and "isn't", never "≥" or "between".
+ #[test]
+ fn album_rules_are_is_or_isnt() {
+ assert_eq!(field_kind("album"), Some(Kind::Album));
+ let ops: Vec<&str> = ops_for(Kind::Album).iter().map(|o| o.0).collect();
+ assert_eq!(ops, ["is", "isNot"]);
+ let r = rs(json!({"rules": [{"field": "album", "op": "gte", "value": 1}]}));
+ assert_eq!(r.check(&Catalog::new()).first().map(|p| p.message.as_str()), Some("`album` has no operator `gte`"));
+ }
+
+ /// An Album rule names its album by id, written as a number or a numeric string, the same way
+ /// for the check, the matcher and the editor. A folder holds albums, not photos, so it can't be
+ /// tested.
+ #[test]
+ fn album_rule_ids() {
+ use crate::{Album, AlbumId, Op};
+ assert_eq!(album_rule_id(&json!(3)), Some(AlbumId(3)));
+ assert_eq!(album_rule_id(&json!("3")), Some(AlbumId(3)));
+ assert_eq!(album_rule_id(&json!(3.0)), Some(AlbumId(3)));
+ for v in [json!(1.5), json!(-1), json!("x"), json!(null)] {
+ assert_eq!(album_rule_id(&v), None, "{v}");
+ }
+ let mut cat = Catalog::new();
+ cat.apply(Op::AddAlbum { album: Album { folder: true, ..Album::new(AlbumId(4), "Trips") } }).unwrap();
+ let p = rs(json!({"rules": [{"field": "album", "op": "is", "value": 4}]})).check(&cat);
+ assert!(p.first().is_some_and(|p| p.message.contains("folder")), "{p:?}");
+ }
+
+ /// Album rules saved before Album offered only "is" / "isn't" (≥, between… from when it was a
+ /// number field) always meant "isn't" to the matcher; upgrading says so, so they check again.
+ #[test]
+ fn old_album_operators_upgrade_to_isnt() {
+ let mut r = rs(json!({"rules": [
+ {"field": "album", "op": "gte", "value": 1},
+ {"field": "album", "op": "is", "value": 1},
+ {"field": "rating", "op": "gte", "value": 3},
+ {"group": {"rules": [{"field": "album", "op": "between", "value": [1, 2]}]}}
+ ]}));
+ r.upgrade();
+ let ops = serde_json::to_value(&r).unwrap().to_string();
+ assert!(ops.contains(r#""field":"album","op":"isNot","value":1"#), "{ops}");
+ assert!(ops.contains(r#""field":"album","op":"is","value":1"#), "{ops}");
+ assert!(ops.contains(r#""field":"rating","op":"gte""#), "other fields keep theirs: {ops}");
+ assert!(ops.contains(r#""field":"album","op":"isNot","value":[1,2]"#), "inside groups too: {ops}");
+ }
+
+ /// Besides its English message (for agents), a problem names its field and the kind of issue,
+ /// so the editor can show "Title: needs something to look for" in any language. Every issue
+ /// has its own short text.
+ #[test]
+ fn problems_name_their_field_and_issue() {
+ let cat = Catalog::new();
+ let check = |rules: serde_json::Value| rs(json!({"rules": rules})).check(&cat);
+ let p = check(json!([{"field": "title", "op": "contains", "value": ""}]));
+ assert_eq!((p[0].field.as_deref(), p[0].issue), (Some("title"), Issue::NeedsTextOrEmpty));
+ let p = check(json!([{"field": "title", "op": "isNot", "value": ""}]));
+ assert_eq!(p[0].issue, Issue::NeedsTextOrNotEmpty);
+ // keywords and people say "are empty", and so does their hint
+ let p = check(json!([{"field": "keywords", "op": "contains", "value": ""}, {"field": "person", "op": "notContains", "value": " "}]));
+ assert_eq!((p[0].issue, p[1].issue), (Issue::NeedsNamesOrEmpty, Issue::NeedsNamesOrNotEmpty));
+ assert!(p[0].issue.text().contains("“are empty”") && p[0].message.contains("“are empty”"));
+ let p = check(json!([{"field": "rating", "op": "is", "value": 9}, {"group": {"rules": []}}]));
+ assert_eq!((p[0].field.as_deref(), p[0].issue), (Some("rating"), Issue::NoRatingMatches));
+ assert_eq!((p[1].field.as_deref(), p[1].issue), (None, Issue::EmptyGroup));
+ let p = check(json!([{"field": "captureDate", "op": "between", "value": ["2026-12", "2026-01"]}]));
+ assert_eq!(p[0].issue, Issue::DatesReversed);
+ let p = check(json!([{"field": "album", "op": "is", "value": null}]));
+ assert_eq!(p[0].issue, Issue::ChooseAlbum);
+ let mut texts: Vec<&str> = Issue::ALL.iter().map(|i| i.text()).collect();
+ assert!(texts.iter().all(|t| !t.is_empty()));
+ texts.sort_unstable();
+ texts.dedup();
+ assert_eq!(texts.len(), Issue::ALL.len(), "each issue reads differently");
+ }
+
+ /// A rule date reads one way for the check and the matcher: a space works as the "T"
+ /// ("2026-10-01 10:00" matches a photo taken then). Imported capture times can carry fractions
+ /// of a second and a time zone (EXIF SubSecTime / OffsetTime: "…T10:00:00.250+02:00"), so a
+ /// rule may too, after the seconds; anywhere else they are no date.
+ #[test]
+ fn rule_dates_have_one_reading() {
+ let cat = Catalog::new();
+ let mut p = photo(1);
+ p.captured = Some("2026-10-01T10:00:00".into());
+ let rule = |op: &str, v: serde_json::Value| rs(json!({"rules": [{"field": "captureDate", "op": op, "value": v}]}));
+ for (op, v) in [
+ ("is", json!("2026-10-01 10:00")),
+ ("is", json!(" 2026-10-01T10 ")),
+ ("between", json!(["2026-10-01 09:00", "2026-10-01 11:00"])),
+ ("after", json!("2026-10-01 09:59")),
+ ] {
+ let r = rule(op, v.clone());
+ assert!(r.check(&cat).is_empty() && r.matches(&p, &cat), "{op} {v}");
+ }
+ // a capture time with a fraction and a zone, as imports write it
+ let mut zoned = photo(2);
+ zoned.captured = Some("2026-08-14T10:00:00.250+02:00".into());
+ for v in ["2026-08-14T10:00:00.250", "2026-08-14T10:00:00.250+02:00", "2026-08-14T10:00:00"] {
+ let r = rule("is", json!(v));
+ assert!(r.check(&cat).is_empty() && r.matches(&zoned, &cat), "{v}");
+ }
+ assert!(rule("is", json!("2026-08-14T10:00:00Z")).check(&cat).is_empty());
+ for v in [
+ "2026-8-14",
+ "2026-08-14T1",
+ "2026-08-14X10",
+ "26-08-14",
+ "2026-08-14T25:00",
+ "2026-08-14Z",
+ "2026-08-14 10:00-05:00",
+ "2026-08-14T10:00:00.",
+ "2026-08-14T10:00:00+2",
+ ] {
+ let p = rule("is", json!(v)).check(&cat);
+ assert_eq!(p.first().map(|p| p.issue), Some(Issue::NeedsDate), "{v}");
+ }
+ assert_eq!(rule_date("2026-10-01 10:00"), Ok("2026-10-01T10:00".to_string()));
+ assert_eq!(rule_date("2026"), Ok("2026".to_string()));
+ }
+
+ /// The field menu shows every rule field once: at the top level or in exactly one group,
+ /// and [`FIELDS`] lists them in menu order.
+ #[test]
+ fn every_field_is_in_the_menu_exactly_once() {
+ let menu: Vec<&str> = TOP_LEVEL_FIELDS.iter().chain(FIELD_GROUPS.iter().flat_map(|g| g.1.iter())).copied().collect();
+ let fields: Vec<&str> = FIELDS.iter().map(|f| f.0).collect();
+ assert_eq!(menu, fields, "the menu and FIELDS list the same fields in the same order");
+ for (label, group) in FIELD_GROUPS {
+ assert!(!group.is_empty(), "group {label} is empty");
+ }
+ let mut labels: Vec<&str> = FIELD_GROUPS.iter().map(|g| g.0).collect();
+ labels.dedup();
+ assert_eq!(labels.len(), FIELD_GROUPS.len(), "group labels are unique");
+ }
+
+ /// Related fields sit together: all the file fields, all the camera (EXIF) fields, all the
+ /// dates, all the keyword fields.
+ #[test]
+ fn related_fields_share_a_group() {
+ let same = |fields: &[&str], group: &str| {
+ for f in fields {
+ assert_eq!(field_group(f), Some(group), "{f}");
+ }
+ };
+ same(&["fileName", "extension", "filePath", "kind", "format", "duration"], "File");
+ same(&["album", "virtualCopy", "copyName", "stacked"], "Source");
+ same(&["longEdge", "shortEdge", "aspect", "megapixels"], "Size");
+ same(&["edited", "cropped", "treatment"], "Develop");
+ same(&["camera", "lens", "focalLength", "aperture", "shutterSpeed", "iso"], "Camera Info");
+ same(&["captureDate", "importDate", "editDate"], "Date");
+ same(&["keywords", "keywordCount", "person", "personCount"], "Keywords & People");
+ same(&["title", "caption", "altText", "creator", "copyright", "copyrightStatus"], "Description");
+ same(&["location", "city", "state", "country", "hasGps"], "Location");
+ for f in ["rating", "flag", "label", "text"] {
+ assert_eq!(field_group(f), None, "{f} stays at the top level");
+ }
+ assert_eq!(field_group("nope"), None);
+ assert_eq!(field_label("filePath"), Some("File Path"));
+ assert_eq!(field_label("nope"), None);
+ }
}
diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs
index 4c2bb33e..20f4b728 100644
--- a/crates/catalog/src/tests.rs
+++ b/crates/catalog/src/tests.rs
@@ -457,6 +457,178 @@ fn random_sort_has_no_date_headers() {
assert!(c.date_runs(&ids, SortKey::Random, GroupBy::Day).is_empty());
}
+/// Reload stores the lens data a file carries; the inverse (through the journal's JSON) takes it back.
+#[test]
+fn set_embedded_lens_is_undoable_and_journaled() {
+ let mut c = Catalog::default();
+ let a = c.alloc_photo_id();
+ c.apply(Op::AddPhoto {
+ photo: Box::new(Photo::new(a, Source::File { path: "/x.rw2".into() }, "x.rw2", "RW2", 4000, 3000, "2026-10-06T00:00:00")),
+ })
+ .unwrap();
+ let lens = lightcraft_develop::EmbeddedLens { warp: Some(Default::default()), vignette: None };
+ let op = Op::SetEmbeddedLens { id: a, lens: Some(Box::new(lens)) };
+ let op: Op = serde_json::from_str(&serde_json::to_string(&op).unwrap()).unwrap();
+ let inv = c.apply(op).unwrap();
+ assert_eq!(c.photo(a).unwrap().embedded_lens, Some(lens));
+ let inv: Op = serde_json::from_str(&serde_json::to_string(&inv).unwrap()).unwrap();
+ c.apply(inv).unwrap();
+ assert_eq!(c.photo(a).unwrap().embedded_lens, None);
+}
+
+/// A saved smart album whose rules no longer check (the album a rule tests was deleted) is found,
+/// with its problems; a good one has none; an old album operator is read as it was meant.
+#[test]
+fn smart_album_problems_flag_stale_rules() {
+ let mut c = Catalog::new();
+ c.apply(Op::AddAlbum { album: Album::new(AlbumId(1), "Trip") }).unwrap();
+ let smart = |id: u64, rules: serde_json::Value| {
+ let f: Filter = serde_json::from_value(serde_json::json!({"ruleSet": {"rules": rules}})).unwrap();
+ Album { smart: Some(Box::new(f)), ..Album::new(AlbumId(id), "Smart") }
+ };
+ c.apply(Op::AddAlbum { album: smart(2, serde_json::json!([{"field": "album", "op": "is", "value": 1}])) }).unwrap();
+ c.apply(Op::AddAlbum { album: smart(3, serde_json::json!([{"field": "album", "op": "gte", "value": 1}])) }).unwrap();
+ assert!(c.smart_album_problems(AlbumId(2)).is_empty());
+ assert!(c.smart_album_problems(AlbumId(3)).is_empty(), "an old operator is upgraded, not a problem");
+ assert!(c.smart_album_problems(AlbumId(1)).is_empty(), "a plain album has no rules");
+ c.apply(Op::RemoveAlbum { id: AlbumId(1) }).unwrap();
+ let p = c.smart_album_problems(AlbumId(2));
+ assert_eq!(p.iter().map(ToString::to_string).collect::>(), vec!["rule 1: no album 1".to_string()]);
+}
+
+/// A smart album can test another smart album: "Keywords contain travel" and "Album isn't Excluded
+/// Photos" (red or rejected) leaves out the excluded photos, and follows that album's rules as they
+/// change.
+#[test]
+fn a_smart_album_can_exclude_another() {
+ let mut c = Catalog::new();
+ let smart = |id: u64, name: &str, rules: serde_json::Value| {
+ let f: Filter = serde_json::from_value(serde_json::json!({"ruleSet": rules})).unwrap();
+ Album { smart: Some(Box::new(f)), ..Album::new(AlbumId(id), name) }
+ };
+ c.apply(Op::AddAlbum {
+ album: smart(
+ 1,
+ "Excluded Photos",
+ serde_json::json!({"match": "any", "rules": [
+ {"field": "label", "op": "is", "value": "red"}, {"field": "flag", "op": "is", "value": "reject"}]}),
+ ),
+ })
+ .unwrap();
+ let travel = |c: &mut Catalog, name: &str, label: Option, flag: Flag| {
+ let id = c.alloc_photo_id();
+ let mut p = Photo::new(id, Source::Demo { scene: 1 }, name, "JPEG", 6000, 4000, "2026-09-30T10:00:00");
+ p.meta.keywords = vec!["travel".into()];
+ p.label = label;
+ p.flag = flag;
+ c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
+ id
+ };
+ let keep = travel(&mut c, "keep.jpg", None, Flag::Pick);
+ let red = travel(&mut c, "red.jpg", Some(ColorLabel::Red), Flag::None);
+ let rejected = travel(&mut c, "rejected.jpg", None, Flag::Reject);
+ let rules: RuleSet = serde_json::from_value(serde_json::json!({"rules": [
+ {"field": "keywords", "op": "contains", "value": "travel"}, {"field": "album", "op": "isNot", "value": 1}]}))
+ .unwrap();
+ assert!(rules.check(&c).is_empty(), "a smart album can be tested: {:?}", rules.check(&c));
+ let matched = |c: &Catalog| c.photos().filter(|p| rules.matches(p, c)).map(|p| p.id).collect::>();
+ assert_eq!(matched(&c), vec![keep]);
+ // "is" works the other way round
+ let inside: RuleSet = serde_json::from_value(serde_json::json!({"rules": [{"field": "album", "op": "is", "value": 1}]})).unwrap();
+ assert_eq!(c.photos().filter(|p| inside.matches(p, &c)).count(), 2);
+ let _ = (red, rejected);
+}
+
+/// An album can't include itself, directly or through other smart albums: the check refuses the
+/// loop for the album being edited, and a loop saved anyway (an older version, a hand-edited file)
+/// neither recurses forever nor crashes: inside it, the album being evaluated counts as holding no
+/// photos, and every album in it is reported.
+#[test]
+fn smart_album_loops_are_refused_and_survived() {
+ let mut c = Catalog::new();
+ let refers = |to: u64| -> Filter {
+ serde_json::from_value(serde_json::json!({"ruleSet": {"rules": [{"field": "album", "op": "isNot", "value": to}]}})).unwrap()
+ };
+ let smart = |id: u64, f: Filter| Album { smart: Some(Box::new(f)), ..Album::new(AlbumId(id), "S") };
+ // A → B → C
+ c.apply(Op::AddAlbum { album: smart(3, Filter::default()) }).unwrap();
+ c.apply(Op::AddAlbum { album: smart(2, refers(3)) }).unwrap();
+ c.apply(Op::AddAlbum { album: smart(1, refers(2)) }).unwrap();
+ let rules = |to: u64| refers(to).rule_set.unwrap();
+ assert!(rules(1).check_for(&c, Some(AlbumId(3))).iter().any(|p| p.issue == crate::rules::Issue::AlbumLoop), "C → A closes A → B → C → A");
+ assert!(rules(3).check_for(&c, Some(AlbumId(3))).iter().any(|p| p.issue == crate::rules::Issue::AlbumLoop), "an album testing itself");
+ assert!(rules(1).check_for(&c, None).is_empty(), "a new album can't be in a loop yet");
+ assert!(rules(3).check_for(&c, Some(AlbumId(1))).is_empty(), "A testing C again is no loop");
+ assert!(!c.album_reaches(AlbumId(3), AlbumId(1)) && c.album_reaches(AlbumId(1), AlbumId(3)));
+ // a loop saved anyway: C → A
+ c.apply(Op::SetAlbumRules { id: AlbumId(3), rules: Box::new(refers(1)) }).unwrap();
+ let id = photo(&mut c, "a.jpg", "2026-09-01T10:00:00");
+ let p = c.photo(id).unwrap().clone();
+ for a in [1, 2, 3] {
+ let _ = c.album_contains(AlbumId(a), &p); // returns, no stack overflow
+ assert!(c.smart_album_problems(AlbumId(a)).iter().any(|p| p.issue == crate::rules::Issue::AlbumLoop), "album {a} is reported");
+ }
+}
+
+/// Summaries name the albums Album rules test ("album isn't “Excluded Photos”"), not their ids;
+/// one that is gone shows as its number.
+#[test]
+fn summaries_name_albums() {
+ let mut c = Catalog::new();
+ c.apply(Op::AddAlbum { album: Album::new(AlbumId(4), "Excluded Photos") }).unwrap();
+ let rules: RuleSet = serde_json::from_value(serde_json::json!({"rules": [
+ {"field": "keywords", "op": "contains", "value": "travel"},
+ {"group": {"match": "any", "rules": [{"field": "album", "op": "isNot", "value": 4}, {"field": "album", "op": "is", "value": 9}]}}]}))
+ .unwrap();
+ assert_eq!(rules.describe_with(&c), "keywords contains travel and (album isn't “Excluded Photos” or album is #9)");
+ let f = Filter { rule_set: Some(rules), ..Default::default() };
+ assert!(f.describe_with(&c).contains("album isn't “Excluded Photos”"));
+}
+
+/// A chain of smart albums each testing the one before twice ("all of: album is X(k-1), album is
+/// X(k-1)") costs a pass per album, not 2^depth: each album's answer for a photo is worked out once
+/// per question. 25 albums deep stays instant.
+#[test]
+fn chains_of_smart_albums_stay_linear() {
+ let mut c = Catalog::new();
+ let smart = |id: u64, f: Filter| Album { smart: Some(Box::new(f)), ..Album::new(AlbumId(id), "X") };
+ c.apply(Op::AddAlbum { album: smart(1, Filter::default()) }).unwrap();
+ for k in 2..=26u64 {
+ let f: Filter = serde_json::from_value(serde_json::json!({"ruleSet": {"rules": [
+ {"field": "album", "op": "is", "value": k - 1}, {"field": "album", "op": "is", "value": k - 1}]}}))
+ .unwrap();
+ c.apply(Op::AddAlbum { album: smart(k, f) }).unwrap();
+ }
+ for i in 0..20 {
+ photo(&mut c, &format!("p{i}.jpg"), "2026-09-01T10:00:00");
+ }
+ let start = std::time::Instant::now();
+ assert_eq!(c.album_count(AlbumId(26)), 20);
+ assert!(start.elapsed() < std::time::Duration::from_secs(2), "took {:?}", start.elapsed());
+}
+
+/// A smart album's own album filter (not only its rules) counts in loops: A filtered to "in A" is
+/// reported, and so is A filtered to B while B's rules test A.
+#[test]
+fn an_album_filter_counts_in_loops() {
+ let mut c = Catalog::new();
+ let smart = |id: u64, f: Filter| Album { smart: Some(Box::new(f)), ..Album::new(AlbumId(id), "S") };
+ c.apply(Op::AddAlbum { album: smart(1, Filter { album: Some(AlbumId(1)), ..Default::default() }) }).unwrap();
+ let loops = |c: &Catalog, id: u64| c.smart_album_problems(AlbumId(id)).iter().any(|p| p.issue == crate::rules::Issue::AlbumLoop);
+ assert!(loops(&c, 1), "filtered to itself");
+ let tests_3: Filter = serde_json::from_value(serde_json::json!({"ruleSet": {"rules": [{"field": "album", "op": "is", "value": 3}]}})).unwrap();
+ c.apply(Op::AddAlbum { album: smart(2, Filter { album: Some(AlbumId(3)), ..Default::default() }) }).unwrap();
+ c.apply(Op::AddAlbum {
+ album: smart(3, serde_json::from_value(serde_json::json!({"ruleSet": {"rules": [{"field": "album", "op": "is", "value": 2}]}})).unwrap()),
+ })
+ .unwrap();
+ assert!(c.album_reaches(AlbumId(3), AlbumId(2)) && c.album_reaches(AlbumId(2), AlbumId(3)));
+ assert!(loops(&c, 2) && loops(&c, 3));
+ let _ = tests_3;
+ let shown: Vec = c.smart_album_problems(AlbumId(1)).iter().map(ToString::to_string).collect();
+ assert!(shown.iter().all(|s| !s.starts_with("rule :")), "{shown:?}");
+}
+
#[test]
fn undated_photos_group_under_unknown_date_and_sort_together() {
let mut c = Catalog::new();
diff --git a/crates/catalog/src/tests_folder_records.rs b/crates/catalog/src/tests_folder_records.rs
new file mode 100644
index 00000000..3ae19c33
--- /dev/null
+++ b/crates/catalog/src/tests_folder_records.rs
@@ -0,0 +1,199 @@
+//! What the library keeps about its folders: a colour label, like a photo's (see
+//! [`crate::folders::FolderRecord`]).
+//!
+//! Scenarios, in the words of someone organising their library by folder:
+//!
+//! * Given a folder of the library, when I give it a colour label, its row in the folder tree
+//! shows that label; the folders around it show none.
+//! * However the folder is spelled, it is one folder with one label.
+//! * When I take the label off, the library keeps nothing about the folder.
+//! * Every change is one undo step: undo puts back the label the folder had before.
+//! * A path that names no folder is refused and nothing changes.
+//! * When the folder is renamed or moved, its label and those of the folders inside it go along;
+//! a folder beside it whose name merely starts the same way keeps its own. Undo brings them
+//! back, also a label that was left at the destination.
+//! * Labels survive closing and reopening the library.
+
+use crate::*;
+
+fn add(c: &mut Catalog, path: &str) {
+ let id = c.alloc_photo_id();
+ let name = path.rsplit('/').next().unwrap_or(path);
+ let p = Photo::new(id, Source::File { path: path.into() }, name, "JPEG", 60, 40, "2026-01-01T10:00:00");
+ c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
+}
+
+fn library() -> Catalog {
+ let mut c = Catalog::new();
+ for p in ["/pics/trip/a.jpg", "/pics/trip/day1/b.jpg", "/pics/trip2/c.jpg", "/pics/home/d.jpg"] {
+ add(&mut c, p);
+ }
+ c
+}
+
+fn label(c: &mut Catalog, path: &str, l: Option) -> Op {
+ let op = c.folder_label_op(path, l);
+ c.apply(op).unwrap()
+}
+
+/// The label of each row of the tree, by path, depth first (rows without one left out).
+fn tree_labels(c: &Catalog) -> Vec<(String, ColorLabel)> {
+ fn walk(n: &FolderNode, out: &mut Vec<(String, ColorLabel)>) {
+ if let Some(l) = n.label {
+ out.push((n.path.clone(), l));
+ }
+ n.children.iter().for_each(|c| walk(c, out));
+ }
+ let mut out = Vec::new();
+ c.folder_tree().iter().for_each(|n| walk(n, &mut out));
+ out
+}
+
+#[test]
+fn a_new_library_knows_nothing_about_its_folders() {
+ let c = library();
+ assert_eq!(c.folder_record("/pics/trip"), None);
+ assert_eq!(c.folder_color_label("/pics/trip"), None);
+ assert!(tree_labels(&c).is_empty());
+}
+
+#[test]
+fn a_labelled_folder_shows_its_label_in_the_tree_and_only_there() {
+ let mut c = library();
+ label(&mut c, "/pics/trip", Some(ColorLabel::Red));
+ assert_eq!(c.folder_color_label("/pics/trip"), Some(ColorLabel::Red));
+ assert_eq!(tree_labels(&c), vec![("/pics/trip".to_string(), ColorLabel::Red)]);
+}
+
+#[test]
+fn however_a_folder_is_spelled_it_has_one_label() {
+ let mut c = library();
+ label(&mut c, "/pics//trip/", Some(ColorLabel::Green));
+ assert_eq!(c.folder_color_label("/pics/./trip"), Some(ColorLabel::Green));
+ label(&mut c, "/pics/home/../trip", Some(ColorLabel::Blue));
+ assert_eq!(c.folder_color_label("/pics/trip"), Some(ColorLabel::Blue));
+ assert_eq!(tree_labels(&c).len(), 1, "one folder, one record");
+}
+
+#[test]
+fn taking_the_label_off_leaves_no_record() {
+ let mut c = library();
+ label(&mut c, "/pics/trip", Some(ColorLabel::Purple));
+ label(&mut c, "/pics/trip", None);
+ assert_eq!(c.folder_record("/pics/trip"), None);
+ assert!(!c.to_snapshot().contains("folder_records"), "nothing empty is saved");
+}
+
+#[test]
+fn an_empty_record_is_no_record() {
+ let mut c = library();
+ c.apply(Op::SetFolderRecord { folder: "/pics/trip".into(), record: Some(FolderRecord::default()) }).unwrap();
+ assert_eq!(c.folder_record("/pics/trip"), None);
+}
+
+#[test]
+fn undo_puts_back_the_label_the_folder_had() {
+ let mut c = library();
+ let undo_first = label(&mut c, "/pics/trip", Some(ColorLabel::Yellow));
+ let undo_second = label(&mut c, "/pics/trip", Some(ColorLabel::Red));
+ c.apply(undo_second).unwrap();
+ assert_eq!(c.folder_color_label("/pics/trip"), Some(ColorLabel::Yellow));
+ c.apply(undo_first).unwrap();
+ assert_eq!(c.folder_record("/pics/trip"), None);
+}
+
+#[test]
+fn a_path_that_names_no_folder_is_refused() {
+ let mut c = library();
+ let before = c.to_snapshot();
+ for bad in ["", "relative/trip", ".", "a/.."] {
+ let op = Op::SetFolderRecord { folder: bad.into(), record: Some(FolderRecord { label: Some(ColorLabel::Red) }) };
+ assert!(c.apply(op).is_err(), "{bad:?} names no folder");
+ }
+ assert_eq!(c.to_snapshot(), before);
+}
+
+#[test]
+fn a_moved_folder_takes_its_labels_and_its_subfolders_labels_along() {
+ let mut c = library();
+ label(&mut c, "/pics/trip", Some(ColorLabel::Red));
+ label(&mut c, "/pics/trip/day1", Some(ColorLabel::Green));
+ label(&mut c, "/pics/trip2", Some(ColorLabel::Blue));
+ let ops = c.folder_records_follow("/pics/trip", "/archive/2026/trip");
+ c.apply(Op::Batch { ops }).unwrap();
+ assert_eq!(c.folder_record("/pics/trip"), None);
+ assert_eq!(c.folder_color_label("/archive/2026/trip"), Some(ColorLabel::Red));
+ assert_eq!(c.folder_color_label("/archive/2026/trip/day1"), Some(ColorLabel::Green));
+ assert_eq!(c.folder_color_label("/pics/trip2"), Some(ColorLabel::Blue), "a neighbour that only starts the same stays");
+}
+
+#[test]
+fn undoing_a_move_brings_back_every_label_also_one_left_at_the_destination() {
+ let mut c = library();
+ label(&mut c, "/pics/trip", Some(ColorLabel::Red));
+ // a folder that once was at the destination left its labels behind
+ label(&mut c, "/pics/holiday", Some(ColorLabel::Purple));
+ label(&mut c, "/pics/holiday/day9", Some(ColorLabel::Yellow));
+ let before = c.to_snapshot();
+ let ops = c.folder_records_follow("/pics/trip", "/pics/holiday");
+ let undo = c.apply(Op::Batch { ops }).unwrap();
+ assert_eq!(c.folder_color_label("/pics/holiday"), Some(ColorLabel::Red), "the moved folder's label wins");
+ assert_eq!(c.folder_record("/pics/holiday/day9"), None, "what was known about the old folder goes");
+ c.apply(undo).unwrap();
+ assert_eq!(c.to_snapshot(), before);
+}
+
+#[test]
+fn a_folder_without_labels_moves_without_ops() {
+ let c = library();
+ assert!(c.folder_records_follow("/pics/trip", "/elsewhere/trip").is_empty());
+}
+
+#[test]
+fn an_unlabelled_folder_moved_onto_a_stale_label_does_not_inherit_it() {
+ let mut c = library();
+ // a folder that was at the destination once (its photos are gone) left its label behind
+ label(&mut c, "/pics/holiday", Some(ColorLabel::Purple));
+ let ops = c.folder_records_follow("/pics/trip", "/pics/holiday");
+ let undo = c.apply(Op::Batch { ops }).unwrap();
+ assert_eq!(c.folder_record("/pics/holiday"), None, "the folder now there is another one");
+ c.apply(undo).unwrap();
+ assert_eq!(c.folder_color_label("/pics/holiday"), Some(ColorLabel::Purple));
+}
+
+#[test]
+fn labels_survive_the_journal_without_a_snapshot() {
+ let m = MemStore::new();
+ let (mut j, mut c, _) = Journal::open(Box::new(m.clone())).unwrap();
+ {
+ let p = "/pics/trip/a.jpg";
+ let id = c.alloc_photo_id();
+ let op = Op::AddPhoto { photo: Box::new(Photo::new(id, Source::File { path: p.into() }, "a.jpg", "JPEG", 6, 4, "2026-01-01")) };
+ c.apply(op.clone()).unwrap();
+ j.append(std::slice::from_ref(&op)).unwrap();
+ }
+ let op = c.folder_label_op("/pics/trip", Some(ColorLabel::Yellow));
+ c.apply(op.clone()).unwrap();
+ j.append(std::slice::from_ref(&op)).unwrap();
+ drop(j);
+ let (_, reopened, r) = Journal::open(Box::new(m)).unwrap();
+ assert!(r.replayed >= 2, "{r:?}");
+ assert_eq!(reopened.folder_color_label("/pics/trip"), Some(ColorLabel::Yellow));
+}
+
+#[test]
+fn labels_survive_closing_and_reopening_the_library() {
+ let mut c = library();
+ label(&mut c, "/pics/home", Some(ColorLabel::Green));
+ let reopened = Catalog::from_snapshot(&c.to_snapshot()).unwrap();
+ assert_eq!(reopened.folder_color_label("/pics/home"), Some(ColorLabel::Green));
+}
+
+#[test]
+fn a_library_saved_before_folder_records_opens_without_any() {
+ let c = library();
+ let old = c.to_snapshot();
+ assert!(!old.contains("folder_records"));
+ let reopened = Catalog::from_snapshot(&old).unwrap();
+ assert_eq!(reopened.folder_record("/pics/trip"), None);
+}
diff --git a/crates/catalog/src/tests_format_version.rs b/crates/catalog/src/tests_format_version.rs
index cb80ce67..ed2bfe7d 100644
--- a/crates/catalog/src/tests_format_version.rs
+++ b/crates/catalog/src/tests_format_version.rs
@@ -47,7 +47,7 @@ fn v1_library_loads_and_is_upgraded() {
assert_eq!((r.replayed, r.upgraded_from), (1, None));
}
-/// A library saved before albums could be ordered by hand opens as it was and is rewritten as v3.
+/// A library saved before albums could be ordered by hand opens as it was and is rewritten in the current format.
#[test]
fn v2_library_loads_and_is_upgraded() {
let (base, log, full) = legacy_parts();
@@ -57,7 +57,21 @@ fn v2_library_loads_and_is_upgraded() {
let (_, c, r) = Journal::open(Box::new(m.clone())).unwrap();
assert_eq!(c.to_snapshot(), full.to_snapshot());
assert_eq!((r.replayed, r.upgraded_from), (1, Some(2)));
- assert_eq!(snapshot_version(&m), 3);
+ assert_eq!(snapshot_version(&m), u64::from(VERSION));
+}
+
+/// A library saved before keywords could be listed on their own opens as it was and is rewritten
+/// in the current format.
+#[test]
+fn v3_library_loads_and_is_upgraded() {
+ let (base, log, full) = legacy_parts();
+ let m = MemStore::new();
+ m.set(SNAPSHOT, format!("{{\"format\":\"lightcraft-catalog\",\"version\":3,\"seq\":2,\"catalog\":{}}}\n", base.to_snapshot()).into_bytes());
+ m.set(LOG, log.into_bytes());
+ let (_, c, r) = Journal::open(Box::new(m.clone())).unwrap();
+ assert_eq!(c.to_snapshot(), full.to_snapshot());
+ assert_eq!((r.replayed, r.upgraded_from), (1, Some(3)));
+ assert_eq!(snapshot_version(&m), u64::from(VERSION));
}
#[test]
@@ -193,8 +207,11 @@ fn op_variants_are_versioned() {
| Op::Batch { .. } => 1,
Op::SetBrowsed { .. } => 2,
Op::SetAlbumOrder { .. } => 3,
+ Op::SetEmbeddedLens { .. } => 4,
+ Op::SetKeyword { .. } => 5,
+ Op::SetFolderRecord { .. } => 6,
}
}
- let newest = since(&Op::SetAlbumOrder { id: AlbumId(0), order: None });
+ let newest = since(&Op::SetFolderRecord { folder: "/".into(), record: None });
assert_eq!(newest, VERSION, "the newest op's version must be the current format version");
}
diff --git a/crates/catalog/src/tests_journal.rs b/crates/catalog/src/tests_journal.rs
index 0594b6d8..8570bd07 100644
--- a/crates/catalog/src/tests_journal.rs
+++ b/crates/catalog/src/tests_journal.rs
@@ -78,6 +78,15 @@ fn op_for(c: &mut Catalog, kind: u8, a: u8, b: u8) -> Op {
13 => Op::Batch { ops: c.remove_from_stacks_ops(&[pid]) },
14 => c.set_top_ops(pid).unwrap_or(Op::Batch { ops: vec![] }),
9 if b.is_multiple_of(5) => Op::SetLabelName { label: ColorLabel::ALL[a as usize % 5], name: (a % 3 != 1).then(|| format!("Label {b}")) },
+ 9 if b % 7 == 5 => Op::SetKeyword {
+ path: format!("travel|place {}", b % 4),
+ info: (!b.is_multiple_of(3)).then(|| crate::keywords::KeywordInfo {
+ person: a.is_multiple_of(2),
+ synonyms: vec![format!("trip {a}")],
+ ..Default::default()
+ }),
+ },
+ 9 if b % 11 == 4 => c.folder_label_op(&format!("/p/f{}", a % 4), ColorLabel::ALL.get(usize::from(b % 6)).copied()),
9 if b % 7 == 3 => Op::SetAlbumOrder { id: aid, order: (!b.is_multiple_of(3)).then_some(u32::from(b % 5)) },
9 if b.is_multiple_of(2) => {
Op::SetFile { id: pid, file_name: format!("Renamed_{b}.jpg"), source: Source::File { path: format!("/p/Renamed_{b}.jpg") } }
@@ -326,3 +335,44 @@ fn failed_streamed_write_keeps_the_old_file() {
assert_eq!(std::fs::read(dir.join(SNAPSHOT)).unwrap(), b"new");
let _ = std::fs::remove_dir_all(&dir);
}
+
+/// A damaged rendering process in a saved photo (snapshot or log) reads as V1 and costs nothing
+/// else: the photo and its edits load (`docs/process-versions.md`).
+#[test]
+fn a_damaged_process_value_loads_as_v1_with_the_edits() {
+ use lightcraft_develop::ProcessVersion;
+ let mut c = Catalog::new();
+ let id = c.alloc_photo_id();
+ let mut p = Photo::new(id, Source::Demo { scene: 1 }, "a.jpg", "JPEG", 10, 10, "2026-10-09T00:00:00");
+ p.develop = Arc::new(DevelopSettings {
+ process: ProcessVersion(7),
+ light: lightcraft_develop::Light { exposure: 0.5, ..Default::default() },
+ ..Default::default()
+ });
+ c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
+ let snapshot = c.to_snapshot();
+ assert!(snapshot.contains(r#""process":7"#));
+ let snapshot = format!(
+ "{{\"format\":\"lightcraft-catalog\",\"version\":{},\"seq\":0,\"catalog\":{}}}\n",
+ crate::journal::VERSION,
+ snapshot.replace(r#""process":7"#, r#""process":-1"#)
+ );
+ let m = MemStore::new();
+ m.set(SNAPSHOT, snapshot.into_bytes());
+ let (_, loaded, _) = open(&m);
+ let d = &loaded.photo(id).unwrap().develop;
+ assert_eq!((d.process, d.light.exposure), (ProcessVersion::V1, 0.5));
+ // and in a (CRC-valid) log record
+ let edit = DevelopSettings {
+ process: ProcessVersion(7),
+ light: lightcraft_develop::Light { exposure: 0.8, ..Default::default() },
+ ..Default::default()
+ };
+ let op = Op::SetDevelop { id, settings: Arc::new(edit), label: "Edit".into(), edited: None };
+ let body = serde_json::to_string(&op).unwrap().replace(r#""process":7"#, r#""process":"seven""#);
+ assert!(body.contains("seven"));
+ m.set(LOG, format!("{{\"seq\":1,\"crc\":{},\"op\":{body}}}\n", crc32fast::hash(body.as_bytes())).into_bytes());
+ let (_, loaded, _) = open(&m);
+ let d = &loaded.photo(id).unwrap().develop;
+ assert_eq!((d.process, d.light.exposure), (ProcessVersion::V1, 0.8));
+}
diff --git a/crates/codecs/Cargo.toml b/crates/codecs/Cargo.toml
index 18d00ac3..4c93a89a 100644
--- a/crates/codecs/Cargo.toml
+++ b/crates/codecs/Cargo.toml
@@ -30,6 +30,8 @@ jxl-oxide = { workspace = true, optional = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
ravif = { workspace = true, optional = true }
+rav1e = { workspace = true, optional = true }
+avif-serialize = { workspace = true, optional = true }
[features]
default = ["parallel", "jxl", "avif"]
@@ -40,7 +42,9 @@ jxl = ["dep:jxl-oxide"]
# PhotoCraft); `cargo xtask ci` clippies and tests it (`--features heif`), releases don't ship it yet.
heif = ["dep:lightcraft-heif"]
## AVIF encoding via ravif/rav1e (pure Rust, BSD). Native targets only; a no-op on wasm32.
-avif = ["dep:ravif"]
+avif = ["dep:ravif", "dep:rav1e", "dep:avif-serialize"]
[dev-dependencies]
proptest = { workspace = true }
+# Lossless synthetic HEIC files for the `heif` feature's tests.
+lightcraft-heif = { workspace = true, features = ["testdata"] }
diff --git a/crates/codecs/README.md b/crates/codecs/README.md
index 18743ad0..e1998c49 100644
--- a/crates/codecs/README.md
+++ b/crates/codecs/README.md
@@ -23,6 +23,12 @@ encode_png(&EncodeImage::new(w, h, 4, Samples::U16(&px)), &meta)?;
encode_tiff(&EncodeImage::new(w, h, 3, Samples::F32(&px)), TiffCompression::Deflate, &meta)?;
encode_webp_lossless(&img, &meta)?;
encode_avif(&img, 70, 6, &meta)?; // native + feature `avif`
+
+// HDR gain map JPEG (ISO 21496-1): SDR + HDR renditions → base + gain map, and back
+let (map, gm) = gainmap::compute(&sdr_linear, &hdr_linear, w, h, luma, &GainMapOptions::default())?;
+gainmap::encode_jpeg(&EncodeImage::new(w, h, 3, Samples::U8(&base8)), &map, &gm, 90, ChromaSubsampling::S444, &meta)?;
+let found = gainmap::read_jpeg(&bytes); // ours, cameras', Lightroom's
+gainmap::apply(&mut base_linear, w, h, &found.gain, &found.meta, 1.0)?;
```
Thumbnail fallback decoding limits the source to 64 million pixels by default.
@@ -46,8 +52,9 @@ CRW, MRW, X3F) are detected so the engine can route them to `lightcraft-raw`; `d
| GIF / BMP | yes (first GIF frame) | — | via `image` |
| PSD / PSB | merged composite: 8/16/32-bit gray, RGB, CMYK, indexed, duotone (as gray); raw/RLE | — | ICC (1039), EXIF (1058), XMP (1060); ZIP-compressed composite, Lab and 1-bit unsupported |
| JPEG XL | yes (jxl-oxide, feature `jxl`, default on) | — | enum colour → rendered straight to linear Rec.2020; ICC → our ICC path; orientation applied by the decoder (reported as 1) |
-| AVIF | **no** | yes (ravif/rav1e, native only, feature `avif`) | 8-bit sRGB, EXIF; no ICC in the muxer |
-| HEIC/HEIF | **no** (sniff only) | — | see gaps |
+| AVIF | **no** | yes (ravif/rav1e, native only, feature `avif`) | 8/10-bit sRGB, EXIF; no ICC in the muxer. HDR: `encode_avif_pq` writes 10-bit BT.2020 PQ (SDR white at 203 cd/m², CICP `nclx` + `clli`) |
+| HEIC/HEIF | yes with feature `heif` (lightcraft-heif: heic-rs + libheif-matching colour; 4:2:0/mono, 8–10-bit, grids, irot/imir/clap, ICC/nclx/VUI, EXIF/XMP, thumbnail item) | — | 4:2:2/4:4:4 (Canon/Sony HIF) unsupported |
+| Gain map JPEG (HDR) | gain map found and decoded (`gainmap::read_jpeg`): ISO 21496-1 metadata, else Adobe `hdrgm` XMP | yes (`gainmap::encode_jpeg`) | CIPA DC-007 MPF index (gain map typed `0x050000`), ISO 21496-1 APP2 on both images, `hdrgm` + Container XMP for Android/Chrome, Apple `HDRGainMap`/`HDRToneMap` XMP; gain maps are never used as thumbnails |
## Colour
@@ -100,9 +107,11 @@ Run `cargo test --release -p lightcraft-codecs --test bench -- --ignored --nocap
## Gaps / limitations
-- **AVIF and HEIC decode**: no permissively licensed, pure-Rust AV1/HEVC decoder exists today
- (dav1d/libheif are C; rav1d-safe and imazen's heic are AGPL). Files are sniffed and rejected with
- `Error::Unsupported`; a raw-style embedded-preview path could be added for HEIC thumbnails.
+- **AVIF decode**: no permissively licensed, pure-Rust AV1 decoder exists today (dav1d is C;
+ rav1d-safe is AGPL). Files are sniffed and rejected with `Error::Unsupported`.
+- **HEIC** needs the `heif` feature (off by default: HEVC patents are the distributor's call);
+ without it, `Error::Unsupported` says the build has no HEIC support. 4:2:2 and 4:4:4 HEIF
+ (Canon/Sony HIF) is refused: heic-rs 0.1.1 loses sync on those bitstreams.
- JPEG: arithmetic coding unsupported (as in both decoders); extended XMP (> 64 KiB) neither read
nor written; EXIF > 64 KiB not written. The encoder uses standard Huffman tables (interleaved
baseline) for maximum compatibility.
diff --git a/crates/codecs/src/display.rs b/crates/codecs/src/display.rs
new file mode 100644
index 00000000..e26e6ab6
--- /dev/null
+++ b/crates/codecs/src/display.rs
@@ -0,0 +1,442 @@
+//! Monitor (display) ICC profiles: showing photos correctly on wide-gamut and calibrated displays.
+//!
+//! The loupe renders straight into the display's own primaries: the develop pipeline targets the
+//! display's linear RGB ([`DisplayProfile::to_rec2020`]) and encodes it with the sRGB curve, as it
+//! does for every 8-bit output. That "proxy" encoding then goes to the display's real device
+//! values with [`DisplayProfile::correct`]: its tone curves for a matrix/TRC profile (a per-channel
+//! table), or its LUTs for a LUT-based profile (a CMS transform). Images made for sRGB (thumbnails,
+//! cached previews, embedded camera JPEGs) go through [`DisplayProfile::from_srgb`] instead.
+//!
+//! All conversions are relative colorimetric: the display's white is the image's white.
+
+use std::sync::Arc;
+
+use crate::icc;
+use crate::space::Trc;
+use lightcraft_color::{D50, D65, Mat3, REC2020, bradford};
+use lightcraft_raster::Rgba8;
+use moxcms::{ColorProfile, DataColorSpace, Layout, RenderingIntent, Transform8BitExecutor, TransformOptions, Xyzd};
+
+/// Largest profile file accepted (monitor profiles are a few KB; LUT profiles up to a few MB).
+pub const MAX_PROFILE_BYTES: usize = 32 << 20;
+
+/// How a display profile describes the display.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum DisplayKind {
+ /// Primaries + tone curves (exact).
+ MatrixTrc,
+ /// LUT-based (A2B/B2A tables): applied through the CMS; the primaries the pipeline targets are
+ /// the profile's matrix tags when it has them, else measured through its tables.
+ Lut,
+}
+
+impl DisplayKind {
+ pub fn label(self) -> &'static str {
+ match self {
+ DisplayKind::MatrixTrc => "matrix",
+ DisplayKind::Lut => "lut",
+ }
+ }
+}
+
+/// The proxy → device step of a display.
+enum Correction {
+ /// The proxy encoding is the display's (sRGB-curve matrix profile): nothing to do.
+ None,
+ /// Per-channel tables (matrix/TRC profile: same primaries, other curves).
+ Curves(Box<[[u8; 256]; 3]>),
+ /// A full CMS transform (LUT-based profile).
+ Cms(Arc),
+}
+
+/// A parsed display profile with its transforms (built once, applied to every preview).
+pub struct DisplayProfile {
+ /// The profile's own description (or "Display profile").
+ pub description: String,
+ /// Identifies the profile (hash of its bytes): part of render keys.
+ pub id: u64,
+ pub kind: DisplayKind,
+ /// Linear display RGB → linear Rec.2020 (D65; display white → (1, 1, 1)).
+ pub to_rec2020: Mat3,
+ correction: Correction,
+ from_srgb: Arc,
+ to_srgb: Arc,
+}
+
+impl std::fmt::Debug for DisplayProfile {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("DisplayProfile")
+ .field("description", &self.description)
+ .field("id", &self.id)
+ .field("kind", &self.kind)
+ .field("to_rec2020", &self.to_rec2020)
+ .finish_non_exhaustive()
+ }
+}
+
+fn opts() -> TransformOptions {
+ TransformOptions { rendering_intent: RenderingIntent::RelativeColorimetric, ..TransformOptions::default() }
+}
+
+fn xyzd(v: [f64; 3]) -> Xyzd {
+ Xyzd { x: v[0], y: v[1], z: v[2] }
+}
+
+/// Linear Rec.2020 → XYZ relative to D50 (the ICC PCS), Bradford-adapted.
+fn rec2020_to_xyz_d50() -> Mat3 {
+ bradford(D65, D50).mul(&REC2020.to_xyz())
+}
+
+/// Scale `m`'s columns so that (1, 1, 1) maps to (1, 1, 1): the display's white is the image's.
+fn white_balanced(m: Mat3) -> Option {
+ let s = m.inverse()?.apply([1.0; 3]);
+ s.iter().all(|v| v.is_finite() && *v > 0.0).then(|| m.mul(&Mat3::diag(s[0], s[1], s[2])))
+}
+
+fn hash(bytes: &[u8]) -> u64 {
+ use std::hash::{Hash, Hasher};
+ let mut h = std::collections::hash_map::DefaultHasher::new();
+ bytes.hash(&mut h);
+ h.finish() | 1
+}
+
+/// Runs a moxcms call that may panic on a hostile profile.
+fn guarded(f: impl FnOnce() -> Option) -> Option {
+ std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).ok().flatten()
+}
+
+impl DisplayProfile {
+ /// Parse a display profile (an RGB ICC profile, matrix/TRC or LUT-based) and build its
+ /// transforms. Errors say what is wrong with the file; nothing here panics.
+ pub fn from_icc(bytes: &[u8]) -> Result {
+ if bytes.len() > MAX_PROFILE_BYTES {
+ return Err(format!("the profile is too large ({} MB)", bytes.len() >> 20));
+ }
+ let p = guarded(|| ColorProfile::new_from_slice(bytes).ok()).ok_or("not a valid ICC profile")?;
+ if p.color_space != DataColorSpace::Rgb {
+ return Err("not an RGB profile (a display profile describes an RGB device)".into());
+ }
+ if !matches!(p.pcs, DataColorSpace::Xyz | DataColorSpace::Lab) {
+ return Err("unsupported profile connection space".into());
+ }
+ let info = icc::parse(bytes).ok_or("not a valid ICC profile")?;
+ let description = info
+ .description
+ .as_deref()
+ .map(|d| d.trim_start_matches('\u{feff}').trim())
+ .filter(|d| !d.is_empty())
+ .unwrap_or("Display profile")
+ .to_string();
+ let has_lut = p.lut_a_to_b_colorimetric.is_some()
+ || p.lut_a_to_b_perceptual.is_some()
+ || p.lut_b_to_a_colorimetric.is_some()
+ || p.lut_b_to_a_perceptual.is_some();
+ let srgb = ColorProfile::new_srgb();
+
+ // the primaries the pipeline renders into
+ let (kind, to_xyz_d50, curves) = match &info.kind {
+ icc::IccKind::MatrixTrc { to_xyz_d50, trc } if !has_lut => (DisplayKind::MatrixTrc, *to_xyz_d50, Some(trc.clone())),
+ icc::IccKind::MatrixTrc { to_xyz_d50, .. } => (DisplayKind::Lut, *to_xyz_d50, None),
+ _ => (DisplayKind::Lut, measured_to_xyz_d50(&p)?, None),
+ };
+ let to_rec2020 =
+ rec2020_to_xyz_d50().inverse().map(|m| m.mul(&to_xyz_d50)).and_then(white_balanced).ok_or("the profile's primaries are degenerate")?;
+ if to_rec2020.0.iter().flatten().any(|v| !v.is_finite()) {
+ return Err("the profile's primaries are degenerate".into());
+ }
+
+ // the proxy: the display's primaries (as the pipeline targets them), sRGB curve
+ let proxy_xyz = rec2020_to_xyz_d50().mul(&to_rec2020).0;
+ let mut proxy = ColorProfile::new_srgb();
+ proxy.cicp = None;
+ proxy.red_colorant = xyzd([proxy_xyz[0][0], proxy_xyz[1][0], proxy_xyz[2][0]]);
+ proxy.green_colorant = xyzd([proxy_xyz[0][1], proxy_xyz[1][1], proxy_xyz[2][1]]);
+ proxy.blue_colorant = xyzd([proxy_xyz[0][2], proxy_xyz[1][2], proxy_xyz[2][2]]);
+
+ let transform = |src: &ColorProfile, dst: &ColorProfile| guarded(|| src.create_transform_8bit(Layout::Rgba, dst, Layout::Rgba, opts()).ok());
+ let correction = match curves {
+ Some(trc) if trc.iter().all(|t| t.approx_eq(&Trc::Srgb, 2e-4)) => Correction::None,
+ Some(trc) => Correction::Curves(Box::new(curve_tables(&trc))),
+ None => Correction::Cms(transform(&proxy, &p).ok_or("the profile's tables can't be applied")?),
+ };
+ let from_srgb = transform(&srgb, &p).ok_or("can't convert sRGB to this profile")?;
+ let to_srgb = transform(&proxy, &srgb).ok_or("can't convert this profile to sRGB")?;
+ Ok(DisplayProfile { description, id: hash(bytes), kind, to_rec2020, correction, from_srgb, to_srgb })
+ }
+
+ /// Does [`correct`](Self::correct) change anything (the display's curves aren't sRGB's)?
+ pub fn needs_correction(&self) -> bool {
+ !matches!(self.correction, Correction::None)
+ }
+
+ /// A render in the display's primaries with the sRGB curve (the pipeline's display target) →
+ /// the display's device values, in place.
+ pub fn correct(&self, img: &mut Rgba8) -> Result<(), String> {
+ match &self.correction {
+ Correction::None => Ok(()),
+ Correction::Curves(t) => {
+ let map = |p: &mut [u8; 4]| *p = [t[0][p[0] as usize], t[1][p[1] as usize], t[2][p[2] as usize], p[3]];
+ #[cfg(feature = "parallel")]
+ {
+ use rayon::prelude::*;
+ img.data.par_chunks_mut(1 << 14).for_each(|c| c.iter_mut().for_each(map));
+ }
+ #[cfg(not(feature = "parallel"))]
+ img.data.iter_mut().for_each(map);
+ Ok(())
+ }
+ Correction::Cms(x) => apply(x, img),
+ }
+ }
+
+ /// An sRGB image → the display's device values, in place.
+ pub fn from_srgb(&self, img: &mut Rgba8) -> Result<(), String> {
+ apply(&self.from_srgb, img)
+ }
+
+ /// A render in the display's primaries with the sRGB curve → sRGB (colours outside sRGB are
+ /// clipped): for histograms and cached previews, which stay sRGB.
+ pub fn to_srgb(&self, img: &Rgba8) -> Result {
+ let mut out = img.clone();
+ apply(&self.to_srgb, &mut out)?;
+ Ok(out)
+ }
+}
+
+/// Device RGB → PCS (XYZ D50) of the pure primaries, measured through the profile's tables
+/// (LUT-only profiles have no colorant tags).
+fn measured_to_xyz_d50(p: &ColorProfile) -> Result {
+ let lin = icc::linear_rec2020_profile();
+ let x = guarded(|| p.create_transform_f32(Layout::Rgb, &lin, Layout::Rgb, opts()).ok()).ok_or("the profile's tables can't be applied")?;
+ let src = [1.0f32, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0];
+ let mut dst = [0f32; 9];
+ guarded(|| x.transform(&src, &mut dst).ok()).ok_or("the profile's tables can't be applied")?;
+ let c = |i: usize| [dst[i] as f64, dst[i + 3] as f64, dst[i + 6] as f64];
+ // columns: each primary in linear Rec.2020
+ let m = Mat3([c(0), c(1), c(2)]);
+ if m.0.iter().flatten().any(|v| !v.is_finite()) || m.determinant().abs() < 1e-6 {
+ return Err("the profile's primaries are degenerate".into());
+ }
+ Ok(rec2020_to_xyz_d50().mul(&m))
+}
+
+/// sRGB-encoded 8-bit value → the display curve's code value, per channel.
+fn curve_tables(trc: &[Trc; 3]) -> [[u8; 256]; 3] {
+ let mut t = [[0u8; 256]; 3];
+ for (k, tab) in t.iter_mut().enumerate() {
+ for (i, v) in tab.iter_mut().enumerate() {
+ let lin = lightcraft_color::transfer::srgb_to_linear(i as f32 / 255.0);
+ *v = (from_linear(&trc[k], lin) * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
+ }
+ }
+ t
+}
+
+/// Inverse of [`Trc::to_linear`] on 0..1 (bisection: works for tables and every parametric form).
+fn from_linear(t: &Trc, y: f32) -> f32 {
+ let y = y.clamp(0.0, 1.0);
+ let (mut lo, mut hi) = (0.0f32, 1.0f32);
+ for _ in 0..24 {
+ let mid = 0.5 * (lo + hi);
+ if t.to_linear(mid) < y {
+ lo = mid;
+ } else {
+ hi = mid;
+ }
+ }
+ 0.5 * (lo + hi)
+}
+
+fn apply(x: &Arc, img: &mut Rgba8) -> Result<(), String> {
+ const CHUNK: usize = 1 << 14; // pixels
+ let src = img.data.clone();
+ let run = |s: &[[u8; 4]], d: &mut [[u8; 4]]| guarded(|| x.transform(s.as_flattened(), d.as_flattened_mut()).ok()).is_some();
+ #[cfg(feature = "parallel")]
+ let ok = {
+ use rayon::prelude::*;
+ src.par_chunks(CHUNK).zip(img.data.par_chunks_mut(CHUNK)).all(|(s, d)| run(s, d))
+ };
+ #[cfg(not(feature = "parallel"))]
+ let ok = src.chunks(CHUNK).zip(img.data.chunks_mut(CHUNK)).all(|(s, d)| run(s, d));
+ if ok { Ok(()) } else { Err("display profile transform failed".into()) }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::space::NamedSpace;
+ use lightcraft_color::{DISPLAY_P3, SRGB};
+
+ fn img(px: &[[u8; 4]]) -> Rgba8 {
+ Rgba8 { width: px.len(), height: 1, data: px.to_vec() }
+ }
+
+ fn close(a: [u8; 4], b: [u8; 4], tol: i32) -> bool {
+ (0..4).all(|i| (a[i] as i32 - b[i] as i32).abs() <= tol)
+ }
+
+ #[test]
+ fn srgb_display_changes_nothing() {
+ let d = DisplayProfile::from_icc(&icc::write_named(NamedSpace::Srgb)).unwrap();
+ assert_eq!(d.kind, DisplayKind::MatrixTrc);
+ assert!(!d.needs_correction());
+ for (i, row) in d.to_rec2020.0.iter().enumerate() {
+ let want = SRGB.to_space(&REC2020).0[i];
+ for k in 0..3 {
+ assert!((row[k] - want[k]).abs() < 2e-3, "{:?}", d.to_rec2020);
+ }
+ }
+ let px = [[0, 0, 0, 255], [255, 255, 255, 255], [200, 30, 90, 128], [17, 140, 251, 7]];
+ let mut a = img(&px);
+ d.correct(&mut a).unwrap();
+ assert_eq!(a.data, px);
+ let mut b = img(&px);
+ d.from_srgb(&mut b).unwrap();
+ for (x, y) in b.data.iter().zip(&px) {
+ assert!(close(*x, *y, 1), "{x:?} {y:?}");
+ }
+ }
+
+ /// On a Display P3 monitor, sRGB red is shown with less red drive than full; P3 green (out of
+ /// sRGB) is reachable in the display's own primaries.
+ #[test]
+ fn p3_display() {
+ let d = DisplayProfile::from_icc(&icc::write_named(NamedSpace::DisplayP3)).unwrap();
+ assert!(!d.needs_correction(), "P3 uses the sRGB curve");
+ let want = DISPLAY_P3.to_space(&REC2020).0;
+ for i in 0..3 {
+ for k in 0..3 {
+ assert!((d.to_rec2020.0[i][k] - want[i][k]).abs() < 2e-3, "{:?}", d.to_rec2020);
+ }
+ }
+ let mut red = img(&[[255, 0, 0, 255], [128, 128, 128, 255]]);
+ d.from_srgb(&mut red).unwrap();
+ assert!(red.data[0][0] < 245 && red.data[0][1] > 40, "{:?}", red.data[0]);
+ assert!(close(red.data[1], [128, 128, 128, 255], 1), "grey stays grey: {:?}", red.data[1]);
+ // the display's own pure green is outside sRGB: it clips there
+ let srgb = d.to_srgb(&img(&[[0, 255, 0, 255]])).unwrap();
+ assert!(srgb.data[0][0] < 10 && srgb.data[0][1] == 255, "{:?}", srgb.data[0]);
+ }
+
+ /// A gamma 2.2 display (Adobe RGB-like): the proxy's sRGB curve goes to the display's curve.
+ #[test]
+ fn gamma_display_uses_its_curve() {
+ let bytes = icc::write_matrix_trc(&lightcraft_color::ADOBE_RGB, &Trc::Gamma(2.2));
+ let d = DisplayProfile::from_icc(&bytes).unwrap();
+ assert!(d.needs_correction());
+ let mut a = img(&[[0, 0, 0, 255], [255, 255, 255, 255], [10, 128, 240, 9]]);
+ d.correct(&mut a).unwrap();
+ assert_eq!(a.data[0], [0, 0, 0, 255]);
+ assert_eq!(a.data[1], [255, 255, 255, 255]);
+ for (k, v) in [10u8, 128, 240].into_iter().enumerate() {
+ let lin = lightcraft_color::transfer::srgb_to_linear(v as f32 / 255.0);
+ let want = (lin.powf(1.0 / 2.2) * 255.0).round() as i32;
+ assert!((a.data[2][k] as i32 - want).abs() <= 1, "{k}: {} vs {want}", a.data[2][k]);
+ }
+ assert_eq!(a.data[2][3], 9, "alpha kept");
+ }
+
+ #[test]
+ fn rejects_bad_profiles() {
+ assert!(DisplayProfile::from_icc(&[]).is_err());
+ assert!(DisplayProfile::from_icc(&[0u8; 300]).is_err());
+ let mut gray = ColorProfile::new_gray_with_gamma(2.2);
+ gray.cicp = None;
+ assert!(DisplayProfile::from_icc(&gray.encode().unwrap()).is_err());
+ // truncated / corrupted real profiles never panic
+ let good = icc::write_named(NamedSpace::DisplayP3);
+ for n in [10, 128, 140, 200, good.len() - 1] {
+ let _ = DisplayProfile::from_icc(&good[..n]);
+ }
+ for i in (0..good.len()).step_by(7) {
+ let mut b = good.clone();
+ b[i] ^= 0xa5;
+ let _ = DisplayProfile::from_icc(&b);
+ }
+ }
+
+ /// A LUT-based display profile with no colorant or TRC tags (its A2B/B2A tables describe a
+ /// Display P3 panel with a gamma 2.2 response): the primaries are measured through the tables
+ /// and every preview goes through the CMS.
+ fn lut_only_p3_gamma22() -> Vec {
+ use moxcms::{LutMultidimensionalType, LutWarehouse, Matrix3d, ProfileClass, ToneReprCurve, Vector3d};
+ let m = crate::space::rgb_to_xyz_d50(&DISPLAY_P3).0;
+ let inv = crate::space::rgb_to_xyz_d50(&DISPLAY_P3).inverse().unwrap().0;
+ let curves = |v: Vec| vec![ToneReprCurve::Parametric(v.clone()), ToneReprCurve::Parametric(v.clone()), ToneReprCurve::Parametric(v)];
+ let ident = || curves(vec![1.0]);
+ // mAB: A → CLUT → M → matrix → B (no CLUT: M curves linearize, the matrix goes to XYZ)
+ let a2b = LutWarehouse::Multidimensional(LutMultidimensionalType {
+ num_input_channels: 3,
+ num_output_channels: 3,
+ grid_points: [0; 16],
+ clut: None,
+ a_curves: vec![],
+ m_curves: curves(vec![2.2]),
+ matrix: Matrix3d { v: m.map(|r| r.map(|v| v * 32768.0 / 65535.0)) },
+ b_curves: ident(),
+ bias: Vector3d::default(),
+ });
+ let b2a = LutWarehouse::Multidimensional(LutMultidimensionalType {
+ num_input_channels: 3,
+ num_output_channels: 3,
+ grid_points: [0; 16],
+ clut: None,
+ a_curves: vec![],
+ m_curves: curves(vec![1.0 / 2.2]),
+ matrix: Matrix3d { v: inv.map(|r| r.map(|v| v * 65535.0 / 32768.0)) },
+ b_curves: ident(),
+ bias: Vector3d::default(),
+ });
+ let mut p = ColorProfile::new_srgb();
+ p.cicp = None;
+ p.profile_class = ProfileClass::DisplayDevice;
+ p.red_colorant = Xyzd::default();
+ p.green_colorant = Xyzd::default();
+ p.blue_colorant = Xyzd::default();
+ p.red_trc = None;
+ p.green_trc = None;
+ p.blue_trc = None;
+ p.lut_a_to_b_colorimetric = Some(a2b.clone());
+ p.lut_a_to_b_perceptual = Some(a2b);
+ p.lut_b_to_a_colorimetric = Some(b2a.clone());
+ p.lut_b_to_a_perceptual = Some(b2a);
+ p.encode().unwrap()
+ }
+
+ #[test]
+ fn lut_profile_goes_through_the_cms() {
+ let bytes = lut_only_p3_gamma22();
+ let parsed = ColorProfile::new_from_slice(&bytes).unwrap();
+ assert!(!parsed.is_matrix_shaper() && parsed.lut_b_to_a_colorimetric.is_some(), "a LUT-only profile");
+ let d = DisplayProfile::from_icc(&bytes).unwrap();
+ assert_eq!(d.kind, DisplayKind::Lut);
+ assert!(d.needs_correction());
+ // the measured primaries are P3's
+ let want = DISPLAY_P3.to_space(&REC2020).0;
+ for i in 0..3 {
+ for k in 0..3 {
+ assert!((d.to_rec2020.0[i][k] - want[i][k]).abs() < 0.02, "{:?} vs {want:?}", d.to_rec2020);
+ }
+ }
+ // the correction is the gamma 2.2 curve; grey stays grey
+ let mut a = img(&[[0, 0, 0, 255], [255, 255, 255, 255], [128, 128, 128, 200]]);
+ d.correct(&mut a).unwrap();
+ assert!(close(a.data[0], [0, 0, 0, 255], 1) && close(a.data[1], [255, 255, 255, 255], 1), "{:?}", a.data);
+ let lin = lightcraft_color::transfer::srgb_to_linear(128.0 / 255.0);
+ let g = (lin.powf(1.0 / 2.2) * 255.0).round() as u8;
+ assert!(close(a.data[2], [g, g, g, 200], 2), "{:?} vs {g}", a.data[2]);
+ // sRGB red → less than full red drive on a P3 panel
+ let mut red = img(&[[255, 0, 0, 255]]);
+ d.from_srgb(&mut red).unwrap();
+ assert!(red.data[0][0] < 245 && red.data[0][1] > 30, "{:?}", red.data[0]);
+ }
+
+ #[test]
+ fn ids_identify_profiles() {
+ let a = DisplayProfile::from_icc(&icc::write_named(NamedSpace::DisplayP3)).unwrap();
+ let b = DisplayProfile::from_icc(&icc::write_named(NamedSpace::AdobeRgb)).unwrap();
+ let a2 = DisplayProfile::from_icc(&icc::write_named(NamedSpace::DisplayP3)).unwrap();
+ assert_ne!(a.id, b.id);
+ assert_eq!(a.id, a2.id);
+ }
+}
diff --git a/crates/codecs/src/encode.rs b/crates/codecs/src/encode.rs
index 59622073..c002abd9 100644
--- a/crates/codecs/src/encode.rs
+++ b/crates/codecs/src/encode.rs
@@ -79,6 +79,46 @@ pub enum ChromaSubsampling {
const XMP_NS: &[u8] = b"http://ns.adobe.com/xap/1.0/\0";
+/// The APP segments [`encode_jpeg`] writes for `meta` (marker byte, payload): JFIF density, EXIF,
+/// XMP and the ICC profile in chunks, in that order.
+pub(crate) fn jpeg_app_segments(meta: &EncodeMeta) -> Result)>> {
+ let mut segs: Vec<(u8, Vec)> = Vec::new();
+ let ppi = meta.ppi.filter(|p| *p > 0).map_or([0u8, 0, 1, 0, 1], |p| {
+ let [hi, lo] = p.to_be_bytes();
+ [1, hi, lo, hi, lo]
+ });
+ segs.push((0xE0, [&b"JFIF\0\x01\x02"[..], &ppi, &[0, 0]].concat()));
+ if let Some(exif) = meta.exif {
+ if exif.len() > 65_527 {
+ return Err(Error::Encode("EXIF larger than one APP1 segment (64 KiB)".into()));
+ }
+ segs.push((0xE1, [&b"Exif\0\0"[..], exif].concat()));
+ }
+ if let Some(xmp) = meta.xmp {
+ segs.push((0xE1, xmp_segment(xmp)?));
+ }
+ if let Some(icc) = meta.icc {
+ // ICC.1 Annex B.4: "ICC_PROFILE\0", 1-based chunk number, chunk count
+ let chunks: Vec<&[u8]> = icc.chunks(65_519).collect();
+ if chunks.len() > 255 {
+ return Err(Error::Encode("ICC profile too large for JPEG".into()));
+ }
+ for (i, c) in chunks.iter().enumerate() {
+ segs.push((0xE2, [&b"ICC_PROFILE\0"[..], &[i as u8 + 1, chunks.len() as u8], c].concat()));
+ }
+ }
+ Ok(segs)
+}
+
+/// An APP1 XMP payload (namespace + packet), refusing packets larger than one segment.
+pub(crate) fn xmp_segment(xmp: &str) -> Result> {
+ let seg = [XMP_NS, xmp.as_bytes()].concat();
+ if seg.len() > 65_533 {
+ return Err(Error::Encode("XMP larger than one APP1 segment (extended XMP not supported)".into()));
+ }
+ Ok(seg)
+}
+
/// Encode baseline JPEG (8-bit; alpha is dropped). `quality` 1..=100.
pub fn encode_jpeg(img: &EncodeImage, quality: u8, subsampling: ChromaSubsampling, meta: &EncodeMeta) -> Result> {
img.validate()?;
@@ -97,35 +137,7 @@ pub fn encode_jpeg(img: &EncodeImage, quality: u8, subsampling: ChromaSubsamplin
};
if subsampling != ChromaSubsampling::S422 {
// Parallel encoder (restart-interval bands on all cores).
- let mut segs: Vec<(u8, Vec)> = Vec::new();
- let ppi = meta.ppi.filter(|p| *p > 0).map_or([0u8, 0, 1, 0, 1], |p| {
- let [hi, lo] = p.to_be_bytes();
- [1, hi, lo, hi, lo]
- });
- segs.push((0xE0, [&b"JFIF\0\x01\x02"[..], &ppi, &[0, 0]].concat()));
- if let Some(exif) = meta.exif {
- if exif.len() > 65_527 {
- return Err(Error::Encode("EXIF larger than one APP1 segment (64 KiB)".into()));
- }
- segs.push((0xE1, [&b"Exif\0\0"[..], exif].concat()));
- }
- if let Some(xmp) = meta.xmp {
- let seg = [XMP_NS, xmp.as_bytes()].concat();
- if seg.len() > 65_533 {
- return Err(Error::Encode("XMP larger than one APP1 segment (extended XMP not supported)".into()));
- }
- segs.push((0xE1, seg));
- }
- if let Some(icc) = meta.icc {
- // ICC.1 Annex B.4: "ICC_PROFILE\0", 1-based chunk number, chunk count
- let chunks: Vec<&[u8]> = icc.chunks(65_519).collect();
- if chunks.len() > 255 {
- return Err(Error::Encode("ICC profile too large for JPEG".into()));
- }
- for (i, c) in chunks.iter().enumerate() {
- segs.push((0xE2, [&b"ICC_PROFILE\0"[..], &[i as u8 + 1, chunks.len() as u8], c].concat()));
- }
- }
+ let segs = jpeg_app_segments(meta)?;
let ch = if img.channels == 2 { 1 } else { img.channels as usize };
// `data` is already gray for 2-channel input (see above)
return Ok(crate::jpeg_par::encode(&data, img.width as usize, img.height as usize, ch, quality, subsampling, &segs));
@@ -351,6 +363,141 @@ pub fn encode_avif(img: &EncodeImage, quality: u8, speed: u8, meta: &EncodeMeta)
}
}
+/// Absolute luminance of SDR white in an HDR file (ITU-R BT.2408 reference white), cd/m².
+pub const HDR_REFERENCE_WHITE_NITS: f32 = 203.0;
+
+/// SMPTE ST 2084 (PQ) inverse EOTF: luminance in cd/m² → signal 0..1.
+pub fn pq_encode(nits: f32) -> f32 {
+ const M1: f32 = 2610.0 / 16384.0;
+ const M2: f32 = 2523.0 / 4096.0 * 128.0;
+ const C1: f32 = 3424.0 / 4096.0;
+ const C2: f32 = 2413.0 / 4096.0 * 32.0;
+ const C3: f32 = 2392.0 / 4096.0 * 32.0;
+ let y = (nits / 10_000.0).clamp(0.0, 1.0).powf(M1);
+ ((C1 + C2 * y) / (1.0 + C3 * y)).powf(M2)
+}
+
+/// SMPTE ST 2084 (PQ) EOTF: signal 0..1 → luminance in cd/m².
+pub fn pq_decode(e: f32) -> f32 {
+ const M1: f32 = 2610.0 / 16384.0;
+ const M2: f32 = 2523.0 / 4096.0 * 128.0;
+ const C1: f32 = 3424.0 / 4096.0;
+ const C2: f32 = 2413.0 / 4096.0 * 32.0;
+ const C3: f32 = 2392.0 / 4096.0 * 32.0;
+ let p = e.clamp(0.0, 1.0).powf(1.0 / M2);
+ ((p - C1).max(0.0) / (C2 - C3 * p)).powf(1.0 / M1) * 10_000.0
+}
+
+/// Encode an HDR AVIF: `rgb` is linear light in **Rec. 2020** primaries with SDR white at 1.0
+/// (`width × height × 3`). Written as 10-bit 4:4:4 BT.2020 non-constant-luminance YCbCr with the
+/// PQ transfer curve, SDR white at [`HDR_REFERENCE_WHITE_NITS`], and a content light level box.
+/// `quality` 1..=100, `speed` 1..=10. Native targets with the `avif` feature only.
+pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed: u8, meta: &EncodeMeta) -> Result> {
+ let (w, h) = (width as usize, height as usize);
+ let n = w.checked_mul(h).ok_or_else(|| Error::Encode("image too large".into()))?;
+ if n == 0 || n.checked_mul(3).is_none_or(|need| rgb.len() < need) {
+ return Err(Error::Encode("HDR AVIF: sample buffer too short".into()));
+ }
+ #[cfg(all(feature = "avif", not(target_arch = "wasm32")))]
+ {
+ use rav1e::prelude::*;
+ const K: [f32; 3] = [0.2627, 0.6780, 0.0593]; // BT.2020 NCL
+ let mut max_nits = 0f32;
+ let mut sum_nits = 0f64;
+ let planes: Vec<[u16; 3]> = rgb
+ .as_chunks::<3>()
+ .0
+ .iter()
+ .take(n)
+ .map(|c| {
+ let nits = c.map(|v| (if v.is_finite() { v.max(0.0) } else { 0.0 }) * HDR_REFERENCE_WHITE_NITS);
+ let y_nits = K[0] * nits[0] + K[1] * nits[1] + K[2] * nits[2];
+ max_nits = max_nits.max(nits[0].max(nits[1]).max(nits[2]));
+ sum_nits += y_nits as f64;
+ let [r, g, b] = nits.map(pq_encode);
+ let y = K[0] * r + K[1] * g + K[2] * b;
+ let cb = (b - y) / (2.0 * (1.0 - K[2])) + 0.5;
+ let cr = (r - y) / (2.0 * (1.0 - K[0])) + 0.5;
+ [y, cb, cr].map(|v| (v * 1023.0).round().clamp(0.0, 1023.0) as u16)
+ })
+ .collect();
+ let q = quality.clamp(1, 100) as f32 / 100.0;
+ // ravif's quality → quantizer curve, so qualities match the SDR AVIF path
+ let x = if q >= 0.82 {
+ (1.0 - q) * 2.6
+ } else if q > 0.25 {
+ 1.0 - 0.125 - q * 0.5
+ } else {
+ 1.0 - q
+ };
+ let quantizer = (x * 255.0).round() as usize;
+ let mut ec = EncoderConfig::with_speed_preset(speed.clamp(1, 10));
+ ec.width = w;
+ ec.height = h;
+ ec.time_base = Rational::new(1, 1);
+ ec.bit_depth = 10;
+ ec.chroma_sampling = ChromaSampling::Cs444;
+ ec.pixel_range = PixelRange::Full;
+ ec.color_description = Some(ColorDescription {
+ color_primaries: ColorPrimaries::BT2020,
+ transfer_characteristics: TransferCharacteristics::SMPTE2084,
+ matrix_coefficients: MatrixCoefficients::BT2020NCL,
+ });
+ let cll = (max_nits.ceil().clamp(1.0, 10_000.0) as u16, ((sum_nits / n as f64).ceil().clamp(1.0, 10_000.0)) as u16);
+ ec.content_light = Some(ContentLight { max_content_light_level: cll.0, max_frame_average_light_level: cll.1 });
+ ec.still_picture = true;
+ ec.quantizer = quantizer;
+ ec.min_quantizer = quantizer.min(255) as u8;
+ ec.speed_settings.multiref = false;
+ ec.speed_settings.rdo_lookahead_frames = 1;
+ ec.speed_settings.scene_detection_mode = SceneDetectionSpeed::None;
+ let e = |e: &dyn std::fmt::Display| Error::Encode(format!("AV1: {e}"));
+ let mut ctx: Context = Config::new().with_encoder_config(ec).new_context().map_err(|x| e(&x))?;
+ let mut frame = ctx.new_frame();
+ {
+ let mut it = planes.iter();
+ let mut fp = frame.planes.iter_mut();
+ let (Some(py), Some(pu), Some(pv)) = (fp.next(), fp.next(), fp.next()) else {
+ return Err(Error::Encode("AV1: frame has no planes".into()));
+ };
+ let (mut sy, mut su, mut sv) = (py.mut_slice(Default::default()), pu.mut_slice(Default::default()), pv.mut_slice(Default::default()));
+ for ((ry, ru), rv) in sy.rows_iter_mut().zip(su.rows_iter_mut()).zip(sv.rows_iter_mut()).take(h) {
+ for ((y, u), v) in ry.iter_mut().zip(ru.iter_mut()).zip(rv.iter_mut()).take(w) {
+ let Some(p) = it.next() else { break };
+ (*y, *u, *v) = (p[0], p[1], p[2]);
+ }
+ }
+ }
+ ctx.send_frame(frame).map_err(|x| e(&x))?;
+ ctx.flush();
+ let mut av1 = Vec::new();
+ loop {
+ match ctx.receive_packet() {
+ Ok(mut p) if p.frame_type == FrameType::KEY => av1.append(&mut p.data),
+ Ok(_) => {}
+ Err(EncoderStatus::Encoded | EncoderStatus::LimitReached) => break,
+ Err(x) => return Err(e(&x)),
+ }
+ }
+ use avif_serialize::constants as c;
+ let mut a = avif_serialize::Aviffy::new();
+ a.set_matrix_coefficients(c::MatrixCoefficients::Bt2020Ncl)
+ .set_transfer_characteristics(c::TransferCharacteristics::Smpte2084)
+ .set_color_primaries(c::ColorPrimaries::Bt2020)
+ .set_full_color_range(true)
+ .set_content_light_level(cll.0, cll.1);
+ if let Some(exif) = meta.exif {
+ a.set_exif(exif.to_vec());
+ }
+ Ok(a.to_vec(&av1, None, width, height, 10))
+ }
+ #[cfg(not(all(feature = "avif", not(target_arch = "wasm32"))))]
+ {
+ let _ = (quality, speed, meta);
+ Err(Error::Encode("AVIF encoding is not available in this build".into()))
+ }
+}
+
/// 16-bit RGB(A) → 10-bit BT.601 full-range YCbCr planes (the matrix `ravif` uses for 8-bit input).
#[cfg(all(feature = "avif", not(target_arch = "wasm32")))]
fn avif_10bit(enc: &ravif::Encoder, img: &EncodeImage, s: &[u16]) -> Result> {
diff --git a/crates/codecs/src/gainmap.rs b/crates/codecs/src/gainmap.rs
new file mode 100644
index 00000000..13c3c0e5
--- /dev/null
+++ b/crates/codecs/src/gainmap.rs
@@ -0,0 +1,834 @@
+//! Gain map HDR images (ISO 21496-1): an SDR base image plus a gain map that a decoder uses to
+//! rebuild the HDR rendition, so the file looks right everywhere and lights up on HDR displays.
+//!
+//! - [`compute`] measures a gain map from an SDR and an HDR rendition of the same picture (linear
+//! light, same primaries). Measure it against the base *as written* (decoded from its 8-bit
+//! codes), so quantisation in the base is handed back by the gain map.
+//! - [`apply`] rebuilds the HDR rendition from a base and its gain map.
+//! - [`encode_jpeg`] writes a gain map JPEG: the base with an MPF index (CIPA DC-007), the ISO
+//! 21496-1 marker and Adobe/Google container XMP, followed by the gain map JPEG carrying the ISO
+//! 21496-1 metadata, the Adobe `hdrgm` XMP (Android, Chrome) and Apple's description of the same
+//! map (what survives an iMessage send).
+//! - [`read_jpeg`] finds and decodes the gain map of a gain map JPEG (ours, cameras, Lightroom),
+//! from its ISO 21496-1 metadata or, failing that, its `hdrgm` XMP.
+//!
+//! All gains are log2. A decoder recovers `log2(hdr + offset_hdr) − log2(sdr + offset_sdr)` as
+//! `min + (max − min) · (code / 255)^(1 / gamma)`, scaled by a weight in 0..1 that depends on how
+//! much headroom the display has between `base_headroom` and `alternate_headroom`.
+
+use crate::encode::{EncodeImage, EncodeMeta, Samples, jpeg_app_segments, xmp_segment};
+use crate::{ChromaSubsampling, Error, Format, Result, jpeg, jpeg_par};
+
+/// The ISO 21496-1 namespace that opens its APP2 payloads (NUL terminated).
+pub const ISO_URN: &[u8] = b"urn:iso:std:iso:ts:21496:-1\0";
+
+/// Fixed denominator for the metadata's rationals: one value per setting, byte-identical output.
+const DENOMINATOR: u32 = 1_000_000;
+
+/// Gain map parameters (ISO 21496-1 / Adobe `hdrgm`). Per-channel arrays repeat the first value
+/// when the map is single-channel.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct GainMapMeta {
+ /// Three gain channels (R, G, B) instead of one luminance gain.
+ pub multichannel: bool,
+ /// The gain applies in the base image's colour space.
+ pub use_base_colour_space: bool,
+ /// log2 headroom at which the base is shown as is (0: an SDR base).
+ pub base_headroom: f32,
+ /// log2 headroom at which the gain map applies in full.
+ pub alternate_headroom: f32,
+ pub min: [f32; 3],
+ pub max: [f32; 3],
+ pub gamma: [f32; 3],
+ pub base_offset: [f32; 3],
+ pub alternate_offset: [f32; 3],
+}
+
+impl Default for GainMapMeta {
+ fn default() -> Self {
+ GainMapMeta {
+ multichannel: false,
+ use_base_colour_space: true,
+ base_headroom: 0.0,
+ alternate_headroom: 1.0,
+ min: [0.0; 3],
+ max: [1.0; 3],
+ gamma: [1.0; 3],
+ base_offset: [1.0 / 64.0; 3],
+ alternate_offset: [1.0 / 64.0; 3],
+ }
+ }
+}
+
+impl GainMapMeta {
+ fn channels(&self) -> usize {
+ if self.multichannel { 3 } else { 1 }
+ }
+
+ /// Usable by [`apply`]: finite, positive gammas, max ≥ min, headrooms in order.
+ pub fn is_valid(&self) -> bool {
+ let fin = |a: &[f32; 3]| a.iter().all(|v| v.is_finite());
+ fin(&self.min)
+ && fin(&self.max)
+ && fin(&self.gamma)
+ && fin(&self.base_offset)
+ && fin(&self.alternate_offset)
+ && self.gamma.iter().all(|g| *g > 0.0)
+ && self.min.iter().zip(&self.max).all(|(a, b)| b >= a)
+ && self.base_headroom.is_finite()
+ && self.alternate_headroom.is_finite()
+ && self.alternate_headroom > self.base_headroom
+ }
+
+ /// The ISO 21496-1 APP2 payload of the gain map image, URN included.
+ pub fn to_iso(&self) -> Vec {
+ let mut p = ISO_URN.to_vec();
+ p.extend_from_slice(&0u16.to_be_bytes()); // minimum_version: readers gate on this
+ p.extend_from_slice(&0u16.to_be_bytes()); // writer_version
+ let mut flags = 0u8;
+ if self.multichannel {
+ flags |= FLAG_MULTICHANNEL;
+ }
+ if self.use_base_colour_space {
+ flags |= FLAG_BASE_COLOUR_SPACE;
+ }
+ p.push(flags);
+ let unsigned = |p: &mut Vec, v: f32| {
+ let n = (v as f64 * DENOMINATOR as f64).round().clamp(0.0, u32::MAX as f64) as u32;
+ p.extend_from_slice(&n.to_be_bytes());
+ p.extend_from_slice(&DENOMINATOR.to_be_bytes());
+ };
+ let signed = |p: &mut Vec, v: f32| {
+ let n = (v as f64 * DENOMINATOR as f64).round().clamp(i32::MIN as f64, i32::MAX as f64) as i32;
+ p.extend_from_slice(&n.to_be_bytes());
+ p.extend_from_slice(&DENOMINATOR.to_be_bytes());
+ };
+ unsigned(&mut p, self.base_headroom.max(0.0));
+ unsigned(&mut p, self.alternate_headroom.max(0.0));
+ for c in 0..self.channels() {
+ signed(&mut p, self.min[c]);
+ signed(&mut p, self.max[c]);
+ unsigned(&mut p, self.gamma[c]);
+ signed(&mut p, self.base_offset[c]);
+ signed(&mut p, self.alternate_offset[c]);
+ }
+ p
+ }
+
+ /// Parse an ISO 21496-1 payload (the bytes after [`ISO_URN`]). `None` for the versions-only
+ /// marker a primary image carries, unknown versions, a base that is the HDR rendition, or
+ /// values no decoder could use.
+ pub fn from_iso(p: &[u8]) -> Option {
+ let mut r = Reader { b: p, at: 0 };
+ let min_version = r.u16()?;
+ let _writer_version = r.u16()?;
+ if min_version != 0 {
+ return None;
+ }
+ let flags = r.u8()?;
+ if flags & FLAG_BACKWARD != 0 {
+ return None;
+ }
+ let multichannel = flags & FLAG_MULTICHANNEL != 0;
+ let common = flags & FLAG_COMMON_DENOMINATOR != 0;
+ let common_d = if common { r.u32()? } else { 0 };
+ let unsigned = |r: &mut Reader| -> Option {
+ let n = r.u32()?;
+ let d = if common { common_d } else { r.u32()? };
+ (d != 0).then(|| (n as f64 / d as f64) as f32)
+ };
+ let signed = |r: &mut Reader| -> Option {
+ let n = r.u32()? as i32;
+ let d = if common { common_d } else { r.u32()? };
+ (d != 0).then(|| (n as f64 / d as f64) as f32)
+ };
+ let mut m = GainMapMeta {
+ multichannel,
+ use_base_colour_space: flags & FLAG_BASE_COLOUR_SPACE != 0,
+ base_headroom: unsigned(&mut r)?,
+ alternate_headroom: unsigned(&mut r)?,
+ ..GainMapMeta::default()
+ };
+ for c in 0..m.channels() {
+ m.min[c] = signed(&mut r)?;
+ m.max[c] = signed(&mut r)?;
+ m.gamma[c] = unsigned(&mut r)?;
+ m.base_offset[c] = signed(&mut r)?;
+ m.alternate_offset[c] = signed(&mut r)?;
+ }
+ if !multichannel {
+ m.fill_from_first();
+ }
+ m.is_valid().then_some(m)
+ }
+
+ fn fill_from_first(&mut self) {
+ for a in [&mut self.min, &mut self.max, &mut self.gamma, &mut self.base_offset, &mut self.alternate_offset] {
+ a[1] = a[0];
+ a[2] = a[0];
+ }
+ }
+
+ /// The gain parameters from Adobe `hdrgm` XMP (the gain map image's packet), for files
+ /// without ISO 21496-1 metadata (the first Ultra HDR files). Attributes or `rdf:Seq` lists.
+ pub fn from_hdrgm_xmp(xmp: &str) -> Option {
+ if !xmp.contains("hdrgm:") {
+ return None;
+ }
+ let base_rendition_hdr = xmp_values(xmp, "BaseRenditionIsHDR").is_some_and(|v| v.first().is_some_and(|s| s.eq_ignore_ascii_case("true")));
+ if base_rendition_hdr {
+ return None;
+ }
+ let get = |name: &str, default: f32| -> Option<([f32; 3], bool)> {
+ let Some(v) = xmp_values(xmp, name) else { return Some(([default; 3], false)) };
+ let f: Vec = v.iter().map(|s| s.trim().parse::().ok()).collect::>()?;
+ match f.as_slice() {
+ [a] => Some(([*a; 3], false)),
+ [a, b, c] => Some(([*a, *b, *c], true)),
+ _ => None,
+ }
+ };
+ let (max, m1) = get("GainMapMax", f32::NAN)?;
+ if max.iter().any(|v| v.is_nan()) {
+ return None; // the one required parameter
+ }
+ let (min, m2) = get("GainMapMin", 0.0)?;
+ let (gamma, m3) = get("Gamma", 1.0)?;
+ let (base_offset, m4) = get("OffsetSDR", 1.0 / 64.0)?;
+ let (alternate_offset, m5) = get("OffsetHDR", 1.0 / 64.0)?;
+ let (cap_min, _) = get("HDRCapacityMin", 0.0)?;
+ let max_gain = max.iter().copied().fold(f32::MIN, f32::max);
+ let (cap_max, _) = get("HDRCapacityMax", max_gain)?;
+ let m = GainMapMeta {
+ multichannel: m1 || m2 || m3 || m4 || m5,
+ use_base_colour_space: true,
+ base_headroom: cap_min[0].max(0.0),
+ alternate_headroom: cap_max[0],
+ min,
+ max,
+ gamma,
+ base_offset,
+ alternate_offset,
+ };
+ m.is_valid().then_some(m)
+ }
+
+ /// The gain map image's XMP: the Adobe `hdrgm` parameters (read by Android and Chrome, which
+ /// ignore the ISO payload) and Apple's description of the same map (what iMessage keeps).
+ pub fn to_gain_map_xmp(&self) -> String {
+ let names = ["GainMapMin", "GainMapMax", "Gamma", "OffsetSDR", "OffsetHDR"];
+ let values = [&self.min, &self.max, &self.gamma, &self.base_offset, &self.alternate_offset];
+ let uniform = |v: &[f32; 3]| !self.multichannel || (v[0] == v[1] && v[1] == v[2]);
+ let mut s = String::from(XMP_OPEN);
+ s.push_str(r#"');
+ // per-channel values are an rdf:Seq of three, not a comma list: readers parse each item
+ for (n, v) in names.iter().zip(values) {
+ if !uniform(v) {
+ s.push_str(&format!(""));
+ for x in v {
+ s.push_str(&format!("{} ", num(*x)));
+ }
+ s.push_str(&format!(" "));
+ }
+ }
+ s.push_str(" ");
+ s.push_str(&self.apple_description());
+ s.push_str(XMP_CLOSE);
+ s
+ }
+
+ /// Apple's own description of the map (`apdi`, `HDRGainMap`, `HDRToneMap` namespaces).
+ fn apple_description(&self) -> String {
+ // 'L008' (8-bit luminance) or '444f'; the stored map must really have that many channels
+ let format = if self.multichannel { 0x3434_3466u32 } else { 0x4C30_3038 };
+ let mut s = String::from(
+ r#""#,
+ );
+ s.push_str("urn:com:apple:photo:2020:aux:hdrgainmap ");
+ s.push_str(&format!("{format} {format} "));
+ s.push_str("131072 ");
+ // linear, not log2: the headroom multiplier itself
+ s.push_str(&format!("{} ", num(self.alternate_headroom.exp2())));
+ s.push_str(&format!("{} ", num(self.alternate_headroom)));
+ s.push_str("");
+ for c in 0..self.channels() {
+ s.push_str(&format!(
+ r#"{} {} {} {} {} "#,
+ num(self.min[c]),
+ num(self.max[c]),
+ num(self.gamma[c]),
+ num(self.base_offset[c]),
+ num(self.alternate_offset[c])
+ ));
+ }
+ s.push_str(" ");
+ s.push_str(&format!("{} ", num(self.base_headroom)));
+ s.push_str("True 1 ");
+ s.push_str(" ");
+ s
+ }
+}
+
+const FLAG_MULTICHANNEL: u8 = 0x80;
+const FLAG_BASE_COLOUR_SPACE: u8 = 0x40;
+const FLAG_COMMON_DENOMINATOR: u8 = 0x08;
+const FLAG_BACKWARD: u8 = 0x04;
+
+const XMP_OPEN: &str = "";
+const XMP_CLOSE: &str = " ";
+
+/// A short decimal (up to 6 significant digits, no exponent for ordinary values).
+fn num(v: f32) -> String {
+ if !v.is_finite() {
+ return "0".into();
+ }
+ let s = format!("{:.6}", v);
+ let s = s.trim_end_matches('0').trim_end_matches('.');
+ if s == "-0" || s.is_empty() { "0".into() } else { s.to_string() }
+}
+
+/// The values of `hdrgm:`: an attribute (`name="v"`) or an element holding an `rdf:Seq`.
+fn xmp_values(xmp: &str, name: &str) -> Option> {
+ let attr = format!("hdrgm:{name}=");
+ if let Some(i) = xmp.find(&attr) {
+ let rest = xmp.get(i + attr.len()..)?;
+ let q = rest.chars().next()?;
+ if q == '"' || q == '\'' {
+ let body = rest.get(1..)?;
+ let end = body.find(q)?;
+ return Some(vec![body.get(..end)?.to_string()]);
+ }
+ }
+ let open = format!("");
+ let close = format!(" ");
+ let i = xmp.find(&open)?;
+ let body = xmp.get(i + open.len()..)?;
+ let body = body.get(..body.find(&close)?)?;
+ let mut out = Vec::new();
+ let mut rest = body;
+ while let Some(a) = rest.find("") {
+ let after = rest.get(a + 8..)?;
+ let b = after.find(" ")?;
+ out.push(after.get(..b)?.to_string());
+ rest = after.get(b..)?;
+ if out.len() > 3 {
+ return None;
+ }
+ }
+ if out.is_empty() {
+ // a plain element value
+ out.push(body.trim().to_string());
+ }
+ Some(out)
+}
+
+struct Reader<'a> {
+ b: &'a [u8],
+ at: usize,
+}
+
+impl Reader<'_> {
+ fn take(&mut self) -> Option<[u8; N]> {
+ let s = self.b.get(self.at..self.at.checked_add(N)?)?;
+ self.at += N;
+ s.try_into().ok()
+ }
+ fn u8(&mut self) -> Option {
+ self.take::<1>().map(|a| a[0])
+ }
+ fn u16(&mut self) -> Option {
+ self.take().map(u16::from_be_bytes)
+ }
+ fn u32(&mut self) -> Option {
+ self.take().map(u32::from_be_bytes)
+ }
+}
+
+/// A gain map image: `channels` (1 or 3) interleaved 8-bit codes.
+#[derive(Clone, Debug, PartialEq)]
+pub struct GainImage {
+ pub width: usize,
+ pub height: usize,
+ pub channels: usize,
+ pub data: Vec,
+}
+
+impl GainImage {
+ /// Bilinear code of channel `c` at gain map coordinates (`x`, `y`) (pixel centres at +0.5).
+ fn sample(&self, x: f32, y: f32, c: usize) -> f32 {
+ let (w, h) = (self.width, self.height);
+ let fx = (x - 0.5).clamp(0.0, (w - 1) as f32);
+ let fy = (y - 0.5).clamp(0.0, (h - 1) as f32);
+ let (x0, y0) = (fx as usize, fy as usize);
+ let (x1, y1) = ((x0 + 1).min(w - 1), (y0 + 1).min(h - 1));
+ let (tx, ty) = (fx - x0 as f32, fy - y0 as f32);
+ let at = |x: usize, y: usize| self.data.get((y * w + x) * self.channels + c).copied().unwrap_or(0) as f32;
+ let a = at(x0, y0) + (at(x1, y0) - at(x0, y0)) * tx;
+ let b = at(x0, y1) + (at(x1, y1) - at(x0, y1)) * tx;
+ a + (b - a) * ty
+ }
+}
+
+/// How [`compute`] builds the map.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct GainMapOptions {
+ /// Downscale factor of the map (1, 2 or 4).
+ pub scale: usize,
+ /// Three gains (R, G, B) instead of one luminance gain. Apple's readers want a single
+ /// channel, so this is off by default.
+ pub multichannel: bool,
+ pub gamma: f32,
+ /// Offsets added to SDR and HDR before the ratio (keep blacks finite and noise quiet).
+ pub offset: f32,
+}
+
+impl Default for GainMapOptions {
+ fn default() -> Self {
+ GainMapOptions { scale: 2, multichannel: false, gamma: 1.0, offset: 1.0 / 64.0 }
+ }
+}
+
+/// Measure the gain map that turns `sdr` into `hdr` (both `width × height` linear RGB in the same
+/// primaries; `luma` = that space's luminance weights).
+pub fn compute(
+ sdr: &[[f32; 3]],
+ hdr: &[[f32; 3]],
+ width: usize,
+ height: usize,
+ luma: [f32; 3],
+ o: &GainMapOptions,
+) -> Result<(GainImage, GainMapMeta)> {
+ let n = width.checked_mul(height).ok_or_else(|| Error::Encode("gain map: image too large".into()))?;
+ if n == 0 || sdr.len() < n || hdr.len() < n {
+ return Err(Error::Encode("gain map: SDR and HDR renditions must match the image size".into()));
+ }
+ if !matches!(o.scale, 1 | 2 | 4) || !(o.gamma > 0.0 && o.gamma.is_finite()) || !(o.offset > 0.0 && o.offset.is_finite()) {
+ return Err(Error::Encode("gain map: scale must be 1, 2 or 4, gamma and offset positive".into()));
+ }
+ let s = o.scale;
+ let (gw, gh) = (width.div_ceil(s), height.div_ceil(s));
+ let ch = if o.multichannel { 3 } else { 1 };
+ let y = |p: &[f32; 3]| luma[0] * p[0] + luma[1] * p[1] + luma[2] * p[2];
+ let ratio = |a: f32, b: f32| ((b.max(0.0) + o.offset) / (a.max(0.0) + o.offset)).log2();
+ // block means of the log gains
+ let mut gains = vec![0f32; gw * gh * ch];
+ let mut lo = [f32::MAX; 3];
+ let mut hi = [f32::MIN; 3];
+ for gy in 0..gh {
+ for gx in 0..gw {
+ let mut acc = [0f32; 3];
+ let mut count = 0f32;
+ for yy in gy * s..((gy + 1) * s).min(height) {
+ for xx in gx * s..((gx + 1) * s).min(width) {
+ let i = yy * width + xx;
+ let (Some(a), Some(b)) = (sdr.get(i), hdr.get(i)) else { continue };
+ if o.multichannel {
+ for c in 0..3 {
+ acc[c] += ratio(a[c], b[c]);
+ }
+ } else {
+ acc[0] += ratio(y(a), y(b));
+ }
+ count += 1.0;
+ }
+ }
+ for c in 0..ch {
+ let g = if count > 0.0 { acc[c] / count } else { 0.0 };
+ let g = if g.is_finite() { g } else { 0.0 };
+ gains[(gy * gw + gx) * ch + c] = g;
+ lo[c] = lo[c].min(g);
+ hi[c] = hi[c].max(g);
+ }
+ }
+ }
+ let mut m = GainMapMeta {
+ multichannel: o.multichannel,
+ gamma: [o.gamma; 3],
+ base_offset: [o.offset; 3],
+ alternate_offset: [o.offset; 3],
+ ..GainMapMeta::default()
+ };
+ for c in 0..ch {
+ // the range always includes 0 (no change) so an SDR-only area encodes exactly
+ m.min[c] = lo[c].min(0.0);
+ m.max[c] = hi[c].max(m.min[c] + 1e-4);
+ }
+ if !o.multichannel {
+ m.fill_from_first();
+ }
+ m.base_headroom = 0.0;
+ m.alternate_headroom = m.max.iter().copied().fold(0.0f32, f32::max).max(1e-3);
+ let data = gains
+ .iter()
+ .enumerate()
+ .map(|(i, g)| {
+ let c = i % ch;
+ let norm = ((g - m.min[c]) / (m.max[c] - m.min[c])).clamp(0.0, 1.0);
+ (norm.powf(o.gamma) * 255.0).round() as u8
+ })
+ .collect();
+ Ok((GainImage { width: gw, height: gh, channels: ch, data }, m))
+}
+
+/// Rebuild the HDR rendition in place: `base` (`width × height` linear RGB) is replaced by the
+/// rendition at `weight` (0 = the base, 1 = the full alternate rendition; a display with headroom
+/// `H` uses `(H − base_headroom) / (alternate_headroom − base_headroom)`, clamped).
+pub fn apply(base: &mut [[f32; 3]], width: usize, height: usize, gain: &GainImage, m: &GainMapMeta, weight: f32) -> Result<()> {
+ let n = width.checked_mul(height).ok_or_else(|| Error::Encode("gain map: image too large".into()))?;
+ if base.len() < n
+ || gain.width == 0
+ || gain.height == 0
+ || !matches!(gain.channels, 1 | 3)
+ || gain.data.len() < gain.width * gain.height * gain.channels
+ {
+ return Err(Error::Encode("gain map: base and gain map sizes don't match".into()));
+ }
+ if !m.is_valid() {
+ return Err(Error::Encode("gain map: unusable metadata".into()));
+ }
+ let weight = if weight.is_finite() { weight.clamp(0.0, 1.0) } else { 1.0 };
+ let (sx, sy) = (gain.width as f32 / width as f32, gain.height as f32 / height as f32);
+ let inv_gamma = m.gamma.map(|g| 1.0 / g);
+ for (i, p) in base.iter_mut().enumerate().take(n) {
+ let (x, y) = ((i % width) as f32 + 0.5, (i / width) as f32 + 0.5);
+ let (gx, gy) = (x * sx, y * sy);
+ let mut g = [0f32; 3];
+ for (c, gc) in g.iter_mut().enumerate() {
+ let src = if gain.channels == 3 { c } else { 0 };
+ let norm = (gain.sample(gx, gy, src) / 255.0).clamp(0.0, 1.0).powf(inv_gamma[c]);
+ *gc = m.min[c] + (m.max[c] - m.min[c]) * norm;
+ }
+ for c in 0..3 {
+ let v = (p[c] + m.base_offset[c]) * (g[c] * weight).exp2() - m.alternate_offset[c];
+ p[c] = v.max(0.0);
+ }
+ }
+ Ok(())
+}
+
+/// The primary image's container XMP: `hdrgm:Version` (marks the file as a gain map image for
+/// Adobe/Google readers) and the Container directory naming the gain map and its length.
+fn primary_description(gain_len: usize) -> String {
+ format!(
+ concat!(
+ r#""#,
+ r#""#,
+ r#" "#,
+ r#" "#,
+ r#" "#
+ ),
+ gain_len
+ )
+}
+
+/// `xmp` with `description` added as another `rdf:Description` (a fresh packet when there is
+/// none, or when it has no `rdf:RDF` to add to).
+fn merge_xmp(xmp: Option<&str>, description: &str) -> String {
+ if let Some(x) = xmp
+ && let Some(i) = x.rfind("")
+ && let (Some(a), Some(b)) = (x.get(..i), x.get(i..))
+ {
+ return format!("{a}{description}{b}");
+ }
+ format!("{XMP_OPEN}{description}{XMP_CLOSE}")
+}
+
+/// Offsets within the MPF payload (from "MPF\0"): the endian field, then the index IFD (3 tags).
+const MPF_ENTRIES_FROM_ENDIAN: usize = 8 + 2 + 3 * 12 + 4;
+
+/// The primary image's MPF payload, entry sizes and offset to be patched once the file exists.
+fn mpf_placeholder() -> Vec {
+ let mut p = b"MPF\0MM".to_vec();
+ p.extend_from_slice(&42u16.to_be_bytes());
+ p.extend_from_slice(&8u32.to_be_bytes()); // first IFD
+ p.extend_from_slice(&3u16.to_be_bytes());
+ let tag = |p: &mut Vec, tag: u16, ty: u16, count: u32, value: [u8; 4]| {
+ p.extend_from_slice(&tag.to_be_bytes());
+ p.extend_from_slice(&ty.to_be_bytes());
+ p.extend_from_slice(&count.to_be_bytes());
+ p.extend_from_slice(&value);
+ };
+ tag(&mut p, 0xB000, 7, 4, *b"0100"); // MPFVersion
+ tag(&mut p, 0xB001, 4, 1, 2u32.to_be_bytes()); // NumberOfImages
+ tag(&mut p, 0xB002, 7, 32, (MPF_ENTRIES_FROM_ENDIAN as u32).to_be_bytes()); // MPEntry
+ p.extend_from_slice(&0u32.to_be_bytes()); // no next IFD
+ // entry 0: baseline primary (0x030000) flagged as the representative image (bit 29)
+ for attr in [0x2003_0000u32, jpeg::MPF_GAIN_MAP] {
+ p.extend_from_slice(&attr.to_be_bytes());
+ p.extend_from_slice(&[0; 12]); // size, offset, two dependent-image entries
+ }
+ p
+}
+
+/// Encode a gain map JPEG: `base` (8-bit SDR, 3 or 4 channels, colour-managed by `meta.icc`) and
+/// its gain map, both at `quality`. `meta.xmp` is kept, with the gain map container added.
+pub fn encode_jpeg(base: &EncodeImage, gain: &GainImage, m: &GainMapMeta, quality: u8, sub: ChromaSubsampling, meta: &EncodeMeta) -> Result> {
+ let Samples::U8(data) = base.samples else {
+ return Err(Error::Encode("gain map JPEG needs an 8-bit base image".into()));
+ };
+ let (w, h) = (base.width as usize, base.height as usize);
+ if !matches!(base.channels, 3 | 4) || data.len() < w * h * base.channels as usize {
+ return Err(Error::Encode("gain map JPEG needs an RGB base image".into()));
+ }
+ if w == 0 || h == 0 || w > u16::MAX as usize || h > u16::MAX as usize {
+ return Err(Error::Encode("JPEG dimensions are limited to 65535".into()));
+ }
+ if !matches!(gain.channels, 1 | 3) || gain.width == 0 || gain.height == 0 || gain.data.len() < gain.width * gain.height * gain.channels {
+ return Err(Error::Encode("gain map image is malformed".into()));
+ }
+ if !m.is_valid() || m.multichannel != (gain.channels == 3) {
+ return Err(Error::Encode("gain map metadata doesn't match the map".into()));
+ }
+ // jpeg_par writes 4:4:4 or 4:2:0
+ let sub = if sub == ChromaSubsampling::S422 { ChromaSubsampling::S420 } else { sub };
+
+ let gain_segs = vec![(0xE1, xmp_segment(&m.to_gain_map_xmp())?), (0xE2, m.to_iso())];
+ let gain_jpeg = jpeg_par::encode(&gain.data, gain.width, gain.height, gain.channels, quality, ChromaSubsampling::S444, &gain_segs);
+
+ let xmp = merge_xmp(meta.xmp, &primary_description(gain_jpeg.len()));
+ let mut segs = jpeg_app_segments(&EncodeMeta { xmp: Some(&xmp), ..*meta })?;
+ let mut iso_marker = ISO_URN.to_vec();
+ iso_marker.extend_from_slice(&[0, 0, 0, 0]); // versions only: "this image has a gain map"
+ segs.push((0xE2, iso_marker));
+ segs.push((0xE2, mpf_placeholder()));
+ let mut primary = jpeg_par::encode(data, w, h, base.channels as usize, quality, sub, &segs);
+
+ // patch the MPF index: sizes, and the gain map's offset from the MPF endian field
+ let markers = jpeg::parse_markers(&primary).ok_or_else(|| Error::Encode("gain map JPEG: primary image unreadable".into()))?;
+ let endian = markers.mpf.as_ref().map(|m| m.0).ok_or_else(|| Error::Encode("gain map JPEG: MPF segment missing".into()))?;
+ let entries = endian + MPF_ENTRIES_FROM_ENDIAN;
+ let (plen, glen) = (primary.len(), gain_jpeg.len());
+ let fields = [(entries + 4, plen), (entries + 8, 0), (entries + 20, glen), (entries + 24, plen - endian)];
+ for (at, v) in fields {
+ let v = u32::try_from(v).map_err(|_| Error::Encode("gain map JPEG larger than 4 GiB".into()))?;
+ let slot = primary.get_mut(at..at + 4).ok_or_else(|| Error::Encode("gain map JPEG: truncated MPF segment".into()))?;
+ slot.copy_from_slice(&v.to_be_bytes());
+ }
+ primary.extend_from_slice(&gain_jpeg);
+ Ok(primary)
+}
+
+/// The gain map of a gain map JPEG.
+#[derive(Clone, Debug, PartialEq)]
+pub struct JpegGainMap {
+ pub gain: GainImage,
+ pub meta: GainMapMeta,
+}
+
+/// Find and decode the gain map of a JPEG (`None` when it has none or it's unusable). The
+/// primary image is decoded as usual ([`crate::decode`]); combine the two with [`apply`].
+pub fn read_jpeg(bytes: &[u8]) -> Option {
+ let m = jpeg::parse_markers(bytes)?;
+ let marked = m.iso_gainmap.is_some() || m.xmp.as_deref().is_some_and(|x| x.contains("hdrgm:Version"));
+ if !marked {
+ return None;
+ }
+ for e in jpeg::mpf_images(&m) {
+ let Some(data) = bytes.get(e.offset..e.offset.saturating_add(e.len)) else { continue };
+ let Some(gm) = jpeg::parse_markers(data) else { continue };
+ let meta = gm.iso_gainmap.as_deref().and_then(GainMapMeta::from_iso).or_else(|| gm.xmp.as_deref().and_then(GainMapMeta::from_hdrgm_xmp));
+ let Some(meta) = meta else { continue };
+ let Ok((w, h, ch, codes)) = jpeg::decode_codes8(data) else { continue };
+ if ch == 3 && !meta.multichannel {
+ // a single-channel map stored as RGB: use its first channel
+ let g: Vec = codes.as_chunks::<3>().0.iter().map(|c| c[0]).collect();
+ return Some(JpegGainMap { gain: GainImage { width: w, height: h, channels: 1, data: g }, meta });
+ }
+ let mut meta = meta;
+ if ch == 1 && meta.multichannel {
+ meta.multichannel = false;
+ meta.fill_from_first();
+ }
+ return Some(JpegGainMap { gain: GainImage { width: w, height: h, channels: ch, data: codes }, meta });
+ }
+ None
+}
+
+/// Whether `bytes` is a gain map JPEG with a usable gain map (cheap: no pixel decode).
+pub fn is_gain_map_jpeg(bytes: &[u8]) -> bool {
+ crate::sniff(bytes) == Some(Format::Jpeg)
+ && jpeg::parse_markers(bytes).is_some_and(|m| {
+ (m.iso_gainmap.is_some() || m.xmp.as_deref().is_some_and(|x| x.contains("hdrgm:Version")))
+ && jpeg::mpf_images(&m)
+ .iter()
+ .any(|e| bytes.get(e.offset..e.offset.saturating_add(e.len)).and_then(jpeg::parse_markers).is_some_and(|g| g.is_gain_map()))
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn meta3() -> GainMapMeta {
+ GainMapMeta {
+ multichannel: true,
+ use_base_colour_space: true,
+ base_headroom: 0.0,
+ alternate_headroom: 2.5,
+ min: [-0.25, -0.5, 0.0],
+ max: [2.5, 2.25, 2.0],
+ gamma: [1.0, 1.25, 0.75],
+ base_offset: [0.015625; 3],
+ alternate_offset: [0.015625, 0.03125, 0.015625],
+ }
+ }
+
+ #[test]
+ fn iso_metadata_round_trips() {
+ for m in [GainMapMeta { alternate_headroom: 3.0, max: [3.0; 3], ..GainMapMeta::default() }, meta3()] {
+ let p = m.to_iso();
+ assert!(p.starts_with(ISO_URN));
+ let back = GainMapMeta::from_iso(&p[ISO_URN.len()..]).unwrap();
+ for (a, b) in [(back.min, m.min), (back.max, m.max), (back.gamma, m.gamma), (back.alternate_offset, m.alternate_offset)] {
+ for c in 0..3 {
+ assert!((a[c] - b[c]).abs() < 1e-6, "{a:?} {b:?}");
+ }
+ }
+ assert_eq!(back.multichannel, m.multichannel);
+ assert!((back.alternate_headroom - m.alternate_headroom).abs() < 1e-6);
+ }
+ }
+
+ #[test]
+ fn iso_common_denominator_and_rejections() {
+ // common-denominator form, single channel: d = 4, headrooms 0 and 2, min 0, max 2, gamma 1, offsets 0
+ let mut p = vec![0, 0, 0, 0, FLAG_COMMON_DENOMINATOR];
+ for v in [4u32, 0, 8, 0, 8, 4, 0, 0] {
+ p.extend_from_slice(&v.to_be_bytes());
+ }
+ let m = GainMapMeta::from_iso(&p).unwrap();
+ assert_eq!((m.alternate_headroom, m.max[2], m.gamma[1]), (2.0, 2.0, 1.0));
+ // versions only (a primary image's marker), unknown version, truncated, backward
+ assert!(GainMapMeta::from_iso(&[0, 0, 0, 0]).is_none());
+ assert!(GainMapMeta::from_iso(&[0, 1, 0, 0, 0]).is_none());
+ assert!(GainMapMeta::from_iso(&p[..p.len() - 1]).is_none());
+ let mut back = p.clone();
+ back[4] |= FLAG_BACKWARD;
+ assert!(GainMapMeta::from_iso(&back).is_none());
+ // a zero denominator is rejected, not divided by
+ let mut z = p.clone();
+ z[5..9].copy_from_slice(&0u32.to_be_bytes());
+ assert!(GainMapMeta::from_iso(&z).is_none());
+ }
+
+ #[test]
+ fn hdrgm_xmp_round_trips_including_per_channel_sequences() {
+ for m in [GainMapMeta { alternate_headroom: 2.0, max: [2.0; 3], ..GainMapMeta::default() }, meta3()] {
+ let x = m.to_gain_map_xmp();
+ assert!(x.contains("apdi:AuxiliaryImageType"));
+ if m.multichannel {
+ assert!(x.contains("2.5 "), "{x}");
+ }
+ let back = GainMapMeta::from_hdrgm_xmp(&x).unwrap();
+ assert_eq!(back.multichannel, m.multichannel);
+ for c in 0..3 {
+ assert!((back.max[c] - m.max[c]).abs() < 1e-5 && (back.gamma[c] - m.gamma[c]).abs() < 1e-5);
+ }
+ assert!((back.alternate_headroom - m.alternate_headroom).abs() < 1e-5);
+ }
+ assert!(GainMapMeta::from_hdrgm_xmp("").is_none(), "no GainMapMax");
+ assert!(GainMapMeta::from_hdrgm_xmp("hdrgm:GainMapMax=\"nope\"").is_none());
+ }
+
+ fn renditions(w: usize, h: usize) -> (Vec<[f32; 3]>, Vec<[f32; 3]>) {
+ // a ramp: SDR rolls off at 1, HDR keeps going to 6
+ let hdr: Vec<[f32; 3]> = (0..w * h)
+ .map(|i| {
+ let t = (i % w) as f32 / (w - 1) as f32;
+ let v = 0.01 + 6.0 * t * t;
+ [v, v * 0.8, v * 0.6]
+ })
+ .collect();
+ // a luminance tone map (one scale per pixel, as LightCraft's renders do), so a single
+ // luminance gain can rebuild it exactly
+ let sdr = hdr
+ .iter()
+ .map(|p| {
+ let y = 0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2];
+ let k = 1.0 / (1.0 + y * 0.5);
+ p.map(|v| v * k)
+ })
+ .collect();
+ (sdr, hdr)
+ }
+
+ #[test]
+ fn compute_then_apply_recovers_the_hdr_rendition() {
+ let (w, h) = (64, 8);
+ let (sdr, hdr) = renditions(w, h);
+ for multichannel in [false, true] {
+ let o = GainMapOptions { scale: 1, multichannel, ..GainMapOptions::default() };
+ let (g, m) = compute(&sdr, &hdr, w, h, [0.2126, 0.7152, 0.0722], &o).unwrap();
+ assert!(m.is_valid() && m.alternate_headroom > 1.0);
+ let mut out = sdr.clone();
+ apply(&mut out, w, h, &g, &m, 1.0).unwrap();
+ for (a, b) in out.iter().zip(&hdr) {
+ for c in 0..3 {
+ // 8-bit codes over the gain range: within ~2 % in linear light
+ assert!((a[c] - b[c]).abs() <= 0.03 * b[c] + 0.01, "{multichannel}: {a:?} vs {b:?}");
+ }
+ }
+ // weight 0 is the base
+ let mut base = sdr.clone();
+ apply(&mut base, w, h, &g, &m, 0.0).unwrap();
+ for (a, b) in base.iter().zip(&sdr) {
+ assert!((a[0] - b[0]).abs() < 1e-5);
+ }
+ }
+ }
+
+ #[test]
+ fn jpeg_round_trip_and_container_structure() {
+ let (w, h) = (96, 64);
+ let (sdr, hdr) = renditions(w, h);
+ let (g, m) = compute(&sdr, &hdr, w, h, [0.2126, 0.7152, 0.0722], &GainMapOptions::default()).unwrap();
+ let base: Vec =
+ sdr.iter().flat_map(|p| p.map(|v| (lightcraft_color::transfer::linear_to_srgb(v.clamp(0.0, 1.0)) * 255.0).round() as u8)).collect();
+ let user_xmp = r#" "#;
+ let meta = EncodeMeta { xmp: Some(user_xmp), ..EncodeMeta::default() };
+ let file = encode_jpeg(&EncodeImage::new(w as u32, h as u32, 3, Samples::U8(&base)), &g, &m, 90, ChromaSubsampling::S444, &meta).unwrap();
+ assert!(is_gain_map_jpeg(&file));
+ // the primary decodes as an ordinary JPEG of the right size, and keeps the user's XMP
+ let d = crate::decode(&file, crate::DecodeOptions::default()).unwrap();
+ assert_eq!((d.width, d.height), (w as u32, h as u32));
+ let x = d.xmp.unwrap();
+ assert!(x.contains("xmlns:dc=") && x.contains("hdrgm:Version") && x.contains("Item:Semantic=\"GainMap\""));
+ // the MPF index points at the gain map, typed as one
+ let pm = jpeg::parse_markers(&file).unwrap();
+ let e = jpeg::mpf_images(&pm);
+ assert_eq!(e.len(), 1);
+ assert_eq!(e[0].kind, jpeg::MPF_GAIN_MAP);
+ assert_eq!(e[0].offset + e[0].len, file.len());
+ // and the gain map reads back
+ let r = read_jpeg(&file).unwrap();
+ assert_eq!((r.gain.width, r.gain.height, r.gain.channels), (48, 32, 1));
+ assert!((r.meta.max[0] - m.max[0]).abs() < 1e-5);
+ // the gain map is never a thumbnail
+ if let Some(t) = jpeg::embedded_thumbnail(&file, 32, 16) {
+ assert_ne!(t.source, crate::ThumbnailSource::MpfPreview);
+ }
+ }
+
+ #[test]
+ fn garbage_never_panics() {
+ assert!(read_jpeg(b"").is_none());
+ assert!(read_jpeg(&[0xFF, 0xD8, 0xFF, 0xE2, 0x00, 0x04, 0, 0]).is_none());
+ assert!(!is_gain_map_jpeg(&[0xFF, 0xD8]));
+ let mut b = vec![0xFF, 0xD8, 0xFF, 0xE2, 0, 40];
+ b.extend_from_slice(ISO_URN);
+ b.extend_from_slice(&[0, 0, 0, 0, 0xFF, 1, 2, 3, 4]);
+ assert!(read_jpeg(&b).is_none());
+ let g = GainImage { width: 2, height: 2, channels: 1, data: vec![0; 3] };
+ assert!(apply(&mut [[0.0; 3]; 4], 2, 2, &g, &GainMapMeta::default(), 1.0).is_err());
+ assert!(compute(&[], &[], 0, 0, [0.3, 0.6, 0.1], &GainMapOptions::default()).is_err());
+ }
+}
diff --git a/crates/codecs/src/heif.rs b/crates/codecs/src/heif.rs
index fe91c0af..702a0d27 100644
--- a/crates/codecs/src/heif.rs
+++ b/crates/codecs/src/heif.rs
@@ -1,16 +1,18 @@
//! HEIF / HEIC (the iPhone and Mac photo format), read-only.
//!
//! Decoding lives in the optional `lightcraft-heif` crate (heic-rs, a pure-Rust HEVC
-//! still-picture decoder), enabled by this crate's `heif` feature — off by default because HEVC
-//! is patent-encumbered and whether a build carries an HEVC decoder is the distributor's call
-//! (the same policy as the sibling PhotoCraft). Without the feature, HEIF files are still
-//! detected and opening one is an [`Error::Unsupported`] error, never a panic.
+//! still-picture decoder, plus a libheif-matching colour conversion), enabled by this crate's
+//! `heif` feature — off by default because HEVC is patent-encumbered and whether a build carries
+//! an HEVC decoder is the distributor's call (the same policy as the sibling PhotoCraft). Without
+//! the feature, HEIF files are still detected and opening one is an [`Error::Unsupported`] error
+//! saying so, never a panic.
//!
//! HEIF records orientation in the container (`irot`/`imir`, plus a `clap` crop), not in EXIF,
//! so the decoder applies them and `orientation` is reported as 1: the EXIF Orientation tag only
//! mirrors what the container says, and applying it as well would turn the photo twice.
-//! The samples come back in the container's own primaries with its ICC profile (if any); like
-//! every decoder here, the colour interpretation happens in [`crate::convert::finish`].
+//! The samples come back as 16-bit RGB in the container's own primaries, with its ICC profile
+//! (iPhones: Display P3) or, without one, its `nclx` / VUI colour description; like every decoder
+//! here, the colour interpretation happens in [`crate::convert::finish`].
#[cfg(feature = "heif")]
use crate::convert::{Buf, Meta, Model, Raw, finish};
@@ -22,26 +24,32 @@ const F: Format = Format::Heif;
#[cfg(not(feature = "heif"))]
pub(crate) const NOT_IN_BUILD: &str = "HEIC/HEIF support isn't included in this build of LightCraft";
-/// The reason a HEIF file that no build can open is refused (this crate decodes HEVC stills
-/// only; the wrapper names the exact construct in its own errors).
-#[cfg(feature = "heif")]
-const NOT_DECODED: &str = "the image is not a decodable HEVC still (image sequence, overlay, alpha-less grid or AVIF-coded item)";
-
#[cfg(not(feature = "heif"))]
pub(crate) fn decode(_bytes: &[u8], _opts: &DecodeOptions) -> Result {
Err(Error::Unsupported(F, NOT_IN_BUILD))
}
+#[cfg(not(feature = "heif"))]
+pub(crate) fn header(_bytes: &[u8]) -> Result<(u32, u32, u16)> {
+ Err(Error::Unsupported(F, NOT_IN_BUILD))
+}
+
#[cfg(feature = "heif")]
fn err(e: lightcraft_heif::Error) -> Error {
match e {
- // This crate's Unsupported takes a fixed reason; the dynamic wording lives in the
- // lightcraft-heif error itself.
- lightcraft_heif::Error::Unsupported(_) => Error::Unsupported(F, NOT_DECODED),
+ lightcraft_heif::Error::Unsupported(why) => Error::Unsupported(F, why),
lightcraft_heif::Error::Limit(m) | lightcraft_heif::Error::Malformed(m) => Error::Malformed(F, m),
}
}
+/// The displayed size (container transforms applied, so orientation 1), from the container
+/// alone: no picture is decoded.
+#[cfg(feature = "heif")]
+pub(crate) fn header(bytes: &[u8]) -> Result<(u32, u32, u16)> {
+ let info = lightcraft_heif::probe(bytes).map_err(err)?;
+ Ok((info.width, info.height, 1))
+}
+
#[cfg(feature = "heif")]
pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result {
// The container alone: the declared size is checked before any pixel is decoded.
@@ -50,16 +58,22 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result {
return Err(Error::TooLarge(info.width as u64, info.height as u64));
}
let options = lightcraft_heif::Options { max_pixels: opts.max_pixels, apply_transforms: true };
- let decoded = lightcraft_heif::decode(bytes, &options).map_err(err)?;
- // The decoder re-reads the container; trust its output's shape, not the probe's.
- let buf = if decoded.sixteen_bit {
- let mut v = Vec::with_capacity(decoded.data.len() / 2);
- for pair in decoded.data.as_chunks::<2>().0 {
- v.push(u16::from_ne_bytes(*pair));
+ // A small decode (a thumbnail or a cataloguing probe) uses the file's own thumbnail image when
+ // it is at least as large as asked: iPhones store one of 320 × 240, and decoding it is a
+ // hundredth of the work of the 12 MP photo.
+ let thumb = match opts.max_size {
+ Some((mw, mh)) if mw > 0 && mh > 0 && mw.max(mh) < info.width.max(info.height) => {
+ lightcraft_heif::decode_thumbnail(bytes, mw.max(mh), &options).ok().flatten().filter(|t| {
+ // Only a thumbnail of the same picture: same orientation and aspect within 1 %.
+ let aspect = |w: u32, h: u32| w as f64 / h.max(1) as f64;
+ (aspect(t.width, t.height) / aspect(info.width, info.height) - 1.0).abs() < 0.01
+ })
}
- Buf::U16(v)
- } else {
- Buf::U8(decoded.data)
+ _ => None,
+ };
+ let mut decoded = match thumb {
+ Some(t) => t,
+ None => lightcraft_heif::decode(bytes, &options).map_err(err)?,
};
let raw = Raw {
width: decoded.width as usize,
@@ -67,11 +81,20 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result {
model: Model::Rgb,
alpha: decoded.has_alpha,
premultiplied: false,
- buf,
- bit_depth: if decoded.sixteen_bit { 16 } else { 8 },
+ buf: Buf::U16(std::mem::take(&mut decoded.data)),
+ bit_depth: decoded.bit_depth,
};
- let meta = Meta { icc: decoded.icc, exif: decoded.exif, xmp: decoded.xmp, orientation: Some(1), ..Default::default() };
- finish(F, raw, meta, (decoded.width, decoded.height), opts)
+ let c = decoded.colour;
+ let meta = Meta {
+ icc: decoded.icc,
+ exif: decoded.exif,
+ xmp: decoded.xmp,
+ orientation: Some(1),
+ // Used when there is no (usable) ICC profile.
+ hint: crate::space::SourceSpace::from_cicp(c.primaries, c.transfer),
+ ..Default::default()
+ };
+ finish(F, raw, meta, (info.width, info.height), opts)
}
#[cfg(test)]
@@ -96,6 +119,8 @@ mod tests {
fn without_the_feature_heif_is_a_clear_unsupported_error() {
let r = crate::decode(HEIC_HEADER, crate::DecodeOptions::default());
assert!(matches!(&r, Err(crate::Error::Unsupported(Format::Heif, why)) if why.contains("isn't included in this build")), "{r:?}");
+ let h = crate::read_header(HEIC_HEADER);
+ assert!(matches!(&h, Err(crate::Error::Unsupported(Format::Heif, why)) if why.contains("isn't included in this build")), "{h:?}");
}
/// Found by PhotoCraft's `decode_heif` fuzz target: a malformed box makes heic-rs 0.1.1 slice
@@ -113,6 +138,7 @@ mod tests {
fn a_heic_rs_panic_is_a_malformed_error() {
let r = crate::decode(&HEIC_RS_BOX_PANIC, crate::DecodeOptions::default());
assert!(matches!(&r, Err(crate::Error::Malformed(Format::Heif, _))), "{r:?}");
+ assert!(crate::read_header(&HEIC_RS_BOX_PANIC).is_err());
}
#[cfg(feature = "heif")]
@@ -120,6 +146,6 @@ mod tests {
fn an_image_sequence_is_unsupported() {
// An ftyp with no meta box: a video track, not a still.
let r = crate::decode(b"\0\0\0\x18ftypmsf1\0\0\0\0msf1hevc", crate::DecodeOptions::default());
- assert!(matches!(&r, Err(crate::Error::Unsupported(Format::Heif, _))), "{r:?}");
+ assert!(matches!(&r, Err(crate::Error::Unsupported(Format::Heif, why)) if why.contains("sequence")), "{r:?}");
}
}
diff --git a/crates/codecs/src/icc.rs b/crates/codecs/src/icc.rs
index ec9e8efe..c3de9edd 100644
--- a/crates/codecs/src/icc.rs
+++ b/crates/codecs/src/icc.rs
@@ -205,7 +205,7 @@ pub fn write_named(n: NamedSpace) -> Vec {
write_matrix_trc(&n.rgb_space(), &n.trc())
}
-fn linear_rec2020_profile() -> ColorProfile {
+pub(crate) fn linear_rec2020_profile() -> ColorProfile {
let mut p = ColorProfile::new_bt2020();
p.cicp = None;
let lin = ToneReprCurve::Parametric(vec![1.0]);
diff --git a/crates/codecs/src/jpeg.rs b/crates/codecs/src/jpeg.rs
index 92409af3..e40cf7e7 100644
--- a/crates/codecs/src/jpeg.rs
+++ b/crates/codecs/src/jpeg.rs
@@ -25,6 +25,17 @@ pub(crate) struct Markers {
pub adobe_transform: Option,
/// Offset of the MPF TIFF header within the file and its bytes.
pub mpf: Option<(usize, Vec)>,
+ /// The ISO 21496-1 APP2 payload after its URN: 4 bytes (versions only) on a primary image
+ /// that has a gain map, the full metadata on the gain map image itself.
+ pub iso_gainmap: Option>,
+}
+
+impl Markers {
+ /// This image is a gain map (ISO 21496-1 metadata or Adobe `hdrgm` gain parameters), not a
+ /// picture: never a preview, never the image to show.
+ pub fn is_gain_map(&self) -> bool {
+ self.iso_gainmap.as_ref().is_some_and(|p| p.len() > 4) || self.xmp.as_deref().is_some_and(|x| x.contains("hdrgm:GainMapMax"))
+ }
}
pub(crate) fn parse_markers(b: &[u8]) -> Option {
@@ -88,6 +99,10 @@ pub(crate) fn parse_markers(b: &[u8]) -> Option {
icc_chunks.push((seg[12], &seg[14..]));
} else if seg.starts_with(b"MPF\0") && m.mpf.is_none() {
m.mpf = Some((start + 4, seg[4..].to_vec()));
+ } else if let Some(p) = seg.strip_prefix(crate::gainmap::ISO_URN)
+ && m.iso_gainmap.is_none()
+ {
+ m.iso_gainmap = Some(p.to_vec());
}
}
0xEE if seg.starts_with(b"Adobe") && seg.len() >= 12 => {
@@ -260,8 +275,11 @@ pub(crate) fn embedded_thumbnail(bytes: &[u8], max_edge: u32, min_edge: u32) ->
if let (Some(ex), Some((o, l))) = (m.exif.as_deref(), summary.thumbnail) {
candidates.push((&ex[o..o + l], ThumbnailSource::ExifThumbnail));
}
- for (o, l) in mpf_images(&m) {
- if let Some(s) = bytes.get(o..o.saturating_add(l)) {
+ for e in mpf_images(&m) {
+ if e.kind == MPF_GAIN_MAP {
+ continue;
+ }
+ if let Some(s) = bytes.get(e.offset..e.offset.saturating_add(e.len)) {
candidates.push((s, ThumbnailSource::MpfPreview));
}
}
@@ -269,6 +287,10 @@ pub(crate) fn embedded_thumbnail(bytes: &[u8], max_edge: u32, min_edge: u32) ->
let mut best: Option<(u32, &[u8], ThumbnailSource)> = None;
for (data, src) in candidates {
let Some(pm) = parse_markers(data) else { continue };
+ // a gain map (also when its MPF entry doesn't say so) is greyscale gain data, not a preview
+ if pm.is_gain_map() {
+ continue;
+ }
let long = pm.width.max(pm.height);
// Previews must have the main image's aspect ratio (within 2%), else they are letterboxed/cropped.
let aspect_ok = m.width > 0
@@ -290,8 +312,21 @@ pub(crate) fn embedded_thumbnail(bytes: &[u8], max_edge: u32, min_edge: u32) ->
Some(Thumbnail { image: d.to_srgb8(), orientation, source, source_width: m.width, source_height: m.height })
}
-/// (file offset, length) of MPF-listed images other than the primary.
-fn mpf_images(m: &Markers) -> Vec<(usize, usize)> {
+/// MP type code of a gain map image (CIPA DC-007 entry attribute, low 24 bits).
+pub(crate) const MPF_GAIN_MAP: u32 = 0x05_0000;
+
+/// An MPF-listed image other than the primary.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) struct MpfEntry {
+ /// File offset and length of the image.
+ pub offset: usize,
+ pub len: usize,
+ /// MP type code (attribute low 24 bits).
+ pub kind: u32,
+}
+
+/// The MPF-listed images other than the primary.
+pub(crate) fn mpf_images(m: &Markers) -> Vec {
let Some((base, data)) = &m.mpf else { return vec![] };
let Some(t) = exif::Tiff::new(data) else { return vec![] };
let Some(ifd) = t.first_ifd() else { return vec![] };
@@ -307,14 +342,34 @@ fn mpf_images(m: &Markers) -> Vec<(usize, usize)> {
let a = [rec[o], rec[o + 1], rec[o + 2], rec[o + 3]];
if data.starts_with(b"II") { u32::from_le_bytes(a) } else { u32::from_be_bytes(a) }
};
- let (size, off) = (rd(4) as usize, rd(8) as usize);
+ let (attr, size, off) = (rd(0), rd(4) as usize, rd(8) as usize);
if size > 0 && off > 0 {
- out.push((base + off, size));
+ out.push(MpfEntry { offset: base.saturating_add(off), len: size, kind: attr & 0x00FF_FFFF });
}
}
out
}
+/// Decode to the file's 8-bit samples as stored (no colour management): 1 channel for greyscale
+/// files, else 3 (RGB). For data images such as gain maps, whose values are codes, not colours.
+pub(crate) fn decode_codes8(bytes: &[u8]) -> Result<(usize, usize, usize, Vec)> {
+ let m = parse_markers(bytes).ok_or_else(|| Error::Malformed(F, "missing SOI".into()))?;
+ if m.components == 0 {
+ return Err(Error::Malformed(F, "no frame header".into()));
+ }
+ check_size(F, m.width as u64, m.height as u64, &DecodeOptions::default())?;
+ if !matches!(m.components, 1 | 3) || m.precision != 8 {
+ return Err(Error::Malformed(F, "expected an 8-bit greyscale or RGB image".into()));
+ }
+ let non_interleaved = matches!(m.sof, 0xC0 | 0xC1) && m.first_scan_components < m.components;
+ let raw = if non_interleaved { decode_jpeg_decoder(bytes, &m, None)? } else { decode_zune(bytes, &m)? };
+ let ch = raw.model.channels();
+ match raw.buf {
+ Buf::U8(v) if v.len() >= raw.width * raw.height * ch => Ok((raw.width, raw.height, ch, v)),
+ _ => Err(Error::Malformed(F, "unexpected sample format".into())),
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs
index 169f40c1..65798718 100644
--- a/crates/codecs/src/lib.rs
+++ b/crates/codecs/src/lib.rs
@@ -17,8 +17,10 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod convert;
+pub mod display;
pub mod encode;
pub mod exif;
+pub mod gainmap;
mod heif;
pub mod icc;
mod jpeg;
@@ -34,7 +36,8 @@ mod tiff_codec;
mod webp;
pub use encode::{
- ChromaSubsampling, EncodeImage, EncodeMeta, Samples, TiffCompression, encode_avif, encode_jpeg, encode_png, encode_tiff, encode_webp_lossless,
+ ChromaSubsampling, EncodeImage, EncodeMeta, HDR_REFERENCE_WHITE_NITS, Samples, TiffCompression, encode_avif, encode_avif_pq, encode_jpeg,
+ encode_png, encode_tiff, encode_webp_lossless,
};
pub use sniff::{Format, sniff};
pub use space::{NamedSpace, SourceSpace, SpaceOrigin, Trc};
@@ -171,9 +174,9 @@ pub struct Header {
}
/// Read the stored dimensions and orientation of any format [`decode`] supports, agreeing with
-/// what a decode reports, without decoding pixels: JPEG, PNG, TIFF, WebP and PSD are read from
-/// their headers (markers, chunks, IFD, resources). GIF, BMP and JPEG XL fall back to a small
-/// decode.
+/// what a decode reports, without decoding pixels: JPEG, PNG, TIFF, WebP, PSD and HEIF are read
+/// from their headers (markers, chunks, IFD, resources, boxes). GIF, BMP and JPEG XL fall back to
+/// a small decode.
///
/// The header readers also refuse what a decode would refuse before reaching the pixels (an
/// unsupported layout, compression or frame type) and files whose image data is cut short
@@ -196,6 +199,7 @@ pub fn read_header_unguarded(bytes: &[u8], format: Format) -> Result {
Format::Tiff => tiff_codec::header(bytes)?,
Format::WebP => webp::header(bytes)?,
Format::Psd => psd::header(bytes)?,
+ Format::Heif => heif::header(bytes)?,
// rare here (or, for JPEG XL, without a header reader yet): a small decode, as before
_ => {
let d = decode_unguarded(bytes, format, &DecodeOptions::fit(64, 64))?;
@@ -217,6 +221,8 @@ pub fn decode_jpeg_with_fallback(bytes: &[u8], opts: DecodeOptions, fallback: Na
#[doc(hidden)]
pub fn decode_unguarded(bytes: &[u8], format: Format, opts: &DecodeOptions) -> Result {
let opts = *opts;
+ // The reason a format can't be decoded lives in one place, `Format::not_decodable`.
+ let unsupported = || Err(Error::Unsupported(format, format.not_decodable().unwrap_or("not decodable in this build")));
match format {
Format::Jpeg => jpeg::decode(bytes, &opts),
Format::Png => png_codec::decode(bytes, &opts),
@@ -227,10 +233,9 @@ pub fn decode_unguarded(bytes: &[u8], format: Format, opts: &DecodeOptions) -> R
#[cfg(feature = "jxl")]
Format::Jxl => jxl::decode(bytes, &opts),
#[cfg(not(feature = "jxl"))]
- Format::Jxl => Err(Error::Unsupported(format, "built without the `jxl` feature")),
+ Format::Jxl => unsupported(),
Format::Heif => heif::decode(bytes, &opts),
- Format::Avif => Err(Error::Unsupported(format, "no pure-Rust, permissively licensed AV1 decoder yet")),
- Format::RawTiffLike | Format::RawOther => Err(Error::Unsupported(format, "camera raw: decode with lightcraft-raw")),
+ Format::Avif | Format::RawTiffLike | Format::RawOther => unsupported(),
}
}
diff --git a/crates/codecs/src/png_codec.rs b/crates/codecs/src/png_codec.rs
index 600f60b0..098f1cfa 100644
--- a/crates/codecs/src/png_codec.rs
+++ b/crates/codecs/src/png_codec.rs
@@ -127,25 +127,10 @@ pub(crate) fn header(b: &[u8]) -> Result<(u32, u32, u16)> {
}
fn container_hint(info: &png::Info) -> Option {
- if let Some(c) = &info.coding_independent_code_points {
- let named = match c.color_primaries {
- 1 => Some(NamedSpace::Srgb),
- 9 => Some(NamedSpace::Rec2020),
- 12 => Some(NamedSpace::DisplayP3),
- _ => None,
- };
- let trc = match c.transfer_function {
- 13 => Some(Trc::Srgb),
- 1 | 6 | 14 | 15 => Some(Trc::Rec709),
- 8 => Some(Trc::Linear),
- 4 => Some(Trc::Gamma(2.2)),
- _ => None,
- };
- if let (Some(n), Some(t)) = (named, trc) {
- let mut s = SourceSpace::named(n, SpaceOrigin::Container);
- s.trc = Some([t.clone(), t.clone(), t]);
- return Some(s);
- }
+ if let Some(s) =
+ info.coding_independent_code_points.as_ref().and_then(|c| SourceSpace::from_cicp(c.color_primaries.into(), c.transfer_function.into()))
+ {
+ return Some(s);
}
if info.srgb.is_some() {
return Some(SourceSpace::named(NamedSpace::Srgb, SpaceOrigin::Container));
diff --git a/crates/codecs/src/sniff.rs b/crates/codecs/src/sniff.rs
index 50fa3ccf..5bc5c402 100644
--- a/crates/codecs/src/sniff.rs
+++ b/crates/codecs/src/sniff.rs
@@ -64,11 +64,20 @@ impl Format {
/// Whether [`crate::decode`] can decode this format in this build.
pub fn can_decode(self) -> bool {
+ self.not_decodable().is_none()
+ }
+
+ /// Why [`crate::decode`] can't decode this format in this build (`None`: it can). The
+ /// [`crate::Error::Unsupported`] a decode returns carries the same reason.
+ pub fn not_decodable(self) -> Option<&'static str> {
match self {
- Format::Jxl => cfg!(feature = "jxl"),
- Format::Heif => cfg!(feature = "heif"),
- Format::Avif | Format::RawTiffLike | Format::RawOther => false,
- _ => true,
+ #[cfg(not(feature = "jxl"))]
+ Format::Jxl => Some("built without the `jxl` feature"),
+ #[cfg(not(feature = "heif"))]
+ Format::Heif => Some(crate::heif::NOT_IN_BUILD),
+ Format::Avif => Some("no pure-Rust, permissively licensed AV1 decoder yet"),
+ Format::RawTiffLike | Format::RawOther => Some("camera raw: decode with lightcraft-raw"),
+ _ => None,
}
}
diff --git a/crates/codecs/src/space.rs b/crates/codecs/src/space.rs
index a945fe72..d8ee0d19 100644
--- a/crates/codecs/src/space.rs
+++ b/crates/codecs/src/space.rs
@@ -255,6 +255,27 @@ impl SourceSpace {
SourceSpace { named: Some(n), to_xyz_d50: n.to_xyz_d50(), trc: Some([t.clone(), t.clone(), t]), origin }
}
+ /// The space named by ITU-T H.273 code points (PNG `cICP`, HEIF `nclx`, HEVC VUI), when
+ /// both the primaries and the transfer are ones this crate models.
+ pub(crate) fn from_cicp(primaries: u16, transfer: u16) -> Option {
+ let named = match primaries {
+ 1 => NamedSpace::Srgb,
+ 9 => NamedSpace::Rec2020,
+ 12 => NamedSpace::DisplayP3,
+ _ => return None,
+ };
+ let trc = match transfer {
+ 13 => Trc::Srgb,
+ 1 | 6 | 14 | 15 => Trc::Rec709,
+ 8 => Trc::Linear,
+ 4 => Trc::Gamma(2.2),
+ _ => return None,
+ };
+ let mut s = SourceSpace::named(named, SpaceOrigin::Container);
+ s.trc = Some([trc.clone(), trc.clone(), trc]);
+ Some(s)
+ }
+
/// Linear source RGB → linear `dst` RGB (Bradford from D50 to `dst`'s white).
pub fn to_space(&self, dst: &RgbSpace) -> Mat3 {
if let Some(n) = self.named {
diff --git a/crates/codecs/tests/fuzz.rs b/crates/codecs/tests/fuzz.rs
index 15b236b0..bfee84df 100644
--- a/crates/codecs/tests/fuzz.rs
+++ b/crates/codecs/tests/fuzz.rs
@@ -36,6 +36,17 @@ fn try_all(bytes: &[u8]) {
let _ = read_header(bytes);
let _ = decode_thumbnail_with(bytes, &ThumbnailOptions { max_pixels: small_opts().max_pixels, ..ThumbnailOptions::new(16) });
let _ = lightcraft_codecs::icc::parse(bytes);
+ let _ = lightcraft_codecs::gainmap::read_jpeg(bytes);
+ let _ = lightcraft_codecs::gainmap::is_gain_map_jpeg(bytes);
+}
+
+fn gain_map_jpeg(meta: &EncodeMeta) -> Vec {
+ use lightcraft_codecs::gainmap::{GainMapOptions, compute, encode_jpeg};
+ let hdr: Vec<[f32; 3]> = (0..24 * 16).map(|i| [i as f32 * 0.02; 3]).collect();
+ let sdr: Vec<[f32; 3]> = hdr.iter().map(|p| p.map(|v| v / (1.0 + v))).collect();
+ let (g, m) = compute(&sdr, &hdr, 24, 16, [0.2126, 0.7152, 0.0722], &GainMapOptions::default()).unwrap();
+ let base: Vec = sdr.iter().flat_map(|p| p.map(|v| (v * 255.0) as u8)).collect();
+ encode_jpeg(&EncodeImage::new(24, 16, 3, Samples::U8(&base)), &g, &m, 80, ChromaSubsampling::S420, meta).unwrap()
}
#[test]
@@ -91,6 +102,7 @@ fn seeds() -> &'static Vec> {
encode_tiff(&e, TiffCompression::Lzw, &meta).unwrap(),
encode_tiff(&EncodeImage::new(24, 16, 3, Samples::F32(&f32s)), TiffCompression::Deflate, &meta).unwrap(),
encode_webp_lossless(&e, &meta).unwrap(),
+ gain_map_jpeg(&meta),
]
})
}
@@ -106,7 +118,7 @@ proptest! {
}
#[test]
- fn mutated_valid_files(which in 0usize..6, flips in proptest::collection::vec((any::(), any::()), 1..12), cut in any::()) {
+ fn mutated_valid_files(which in 0usize..7, flips in proptest::collection::vec((any::(), any::()), 1..12), cut in any::()) {
let mut b = seeds()[which].clone();
for (i, v) in flips {
let i = i.index(b.len());
diff --git a/crates/codecs/tests/heif.rs b/crates/codecs/tests/heif.rs
new file mode 100644
index 00000000..d14f51ac
--- /dev/null
+++ b/crates/codecs/tests/heif.rs
@@ -0,0 +1,98 @@
+//! HEIC/HEIF through the codec API (feature `heif`), on lossless synthetic files from
+//! `lightcraft_heif::testdata`: an iPhone-like grid with a Display P3 profile, rotation, EXIF, a
+//! thumbnail and an HDR gain map; `nclx`-only colour; 10-bit; and hostile input.
+#![cfg(feature = "heif")]
+
+use lightcraft_codecs::*;
+use lightcraft_heif::testdata::{Prop, Spec, build};
+
+fn grey(x: u32, y: u32) -> [u16; 3] {
+ [((x * 7 + y * 13) % 220 + 20) as u16, 128, 128]
+}
+
+/// EXIF whose Orientation (6) only mirrors the container's `irot`: it must not turn the photo again.
+fn exif_orientation_6() -> Vec {
+ let mut t = b"MM\0\x2a\0\0\0\x08\0\x01".to_vec();
+ t.extend_from_slice(&[0x01, 0x12, 0, 3, 0, 0, 0, 1, 0, 6, 0, 0]);
+ t.extend_from_slice(&[0, 0, 0, 0]);
+ t
+}
+
+/// The thumbnail is flat mid grey so a test can tell it from the photo.
+fn iphone_like() -> Vec {
+ build(&Spec {
+ props: vec![Prop::Icc(icc::write_named(NamedSpace::DisplayP3)), Prop::Irot(3)],
+ exif: Some(exif_orientation_6()),
+ thumbnail: Some(&|_, _| [126, 128, 128]),
+ ignored_aux: vec!["urn:com:apple:photo:2020:aux:hdrgainmap"],
+ ..Spec::new(96, 64, &grey)
+ })
+}
+
+#[test]
+fn an_iphone_like_heic_decodes_upright_in_display_p3() {
+ let bytes = iphone_like();
+ assert_eq!(sniff(&bytes), Some(Format::Heif));
+ let d = decode(&bytes, DecodeOptions::default()).unwrap();
+ // irot 3 = 90° clockwise, applied by the decoder; the EXIF Orientation is not applied again.
+ assert_eq!((d.width, d.height, d.source_width, d.source_height, d.orientation), (64, 96, 64, 96, 1));
+ assert_eq!(d.space.named, Some(NamedSpace::DisplayP3));
+ assert_eq!(d.space.origin, SpaceOrigin::IccMatrixTrc);
+ assert_eq!((d.bit_depth, d.has_alpha, d.float), (8, false, false));
+ assert!(d.exif.is_some() && d.icc.is_some());
+ // No nclx and no VUI: limited range, BT.601; neutral chroma stays neutral.
+ let srgb = d.to_srgb8();
+ let px = srgb.data[0];
+ assert!(px[0].abs_diff(px[1]) <= 1 && px[1].abs_diff(px[2]) <= 1, "{px:?}");
+
+ let h = read_header(&bytes).unwrap();
+ assert_eq!((h.format, h.width, h.height, h.orientation), (Format::Heif, 64, 96, 1));
+}
+
+#[test]
+fn a_small_decode_uses_the_embedded_thumbnail() {
+ let bytes = iphone_like();
+ // The 48 × 32 thumbnail, turned like the photo, covers a 32-pixel request.
+ let d = decode(&bytes, DecodeOptions::fit(32, 32)).unwrap();
+ assert!(d.width.max(d.height) <= 32 && d.height > d.width, "{}x{}", d.width, d.height);
+ assert_eq!((d.source_width, d.source_height), (64, 96));
+ assert_eq!(d.space.named, Some(NamedSpace::DisplayP3));
+ let flat = d.to_srgb8();
+ assert!(flat.data.iter().all(|p| *p == flat.data[0]), "the flat thumbnail, not the photo");
+ // A thumbnail too small for the request: the photo is decoded and resized.
+ let d = decode(&bytes, DecodeOptions::fit(60, 60)).unwrap();
+ assert_eq!((d.width.max(d.height), d.source_width), (60, 64));
+ let photo = d.to_srgb8();
+ assert!(photo.data.iter().any(|p| *p != photo.data[0]));
+ let t = decode_thumbnail(&bytes, 32).unwrap();
+ assert_eq!((t.source_width, t.source_height), (64, 96));
+}
+
+#[test]
+fn nclx_colour_and_ten_bit() {
+ // Display P3 primaries, sRGB transfer, BT.709 matrix, full range: the nclx alone names the space.
+ let bytes = build(&Spec { bit_depth: 10, props: vec![Prop::Nclx(12, 13, 1, true)], ..Spec::new(64, 32, &|x, _| [(x * 16) as u16, 512, 512]) });
+ let d = decode(&bytes, DecodeOptions::default()).unwrap();
+ assert_eq!(d.space.named, Some(NamedSpace::DisplayP3));
+ assert_eq!(d.space.origin, SpaceOrigin::Container);
+ assert_eq!(d.bit_depth, 10);
+ // Full-range 10-bit neutral steps of 16 code values: linear values rise monotonically.
+ let row: Vec = (0..64).map(|x| d.image.row(0)[x][1]).collect();
+ assert!(row.windows(2).all(|w| w[1] > w[0]), "{row:?}");
+}
+
+#[test]
+fn hostile_heic_is_an_error_never_a_panic() {
+ let bytes = iphone_like();
+ let full = decode(&bytes, DecodeOptions::default()).unwrap();
+ for cut in (12..bytes.len()).step_by(11) {
+ let b = &bytes[..cut];
+ // Cut inside the photo: an error. Cut in a trailing item (gain map, thumbnail): the photo.
+ if let (Ok(_), Ok(d)) = (read_header(b), decode(b, DecodeOptions::default())) {
+ assert_eq!(d.image.data, full.image.data, "cut at {cut}");
+ }
+ let _ = decode_thumbnail(b, 16);
+ }
+ let r = decode(&bytes, DecodeOptions { max_pixels: 64 * 96 - 1, ..Default::default() });
+ assert!(matches!(r, Err(Error::TooLarge(64, 96))), "{r:?}");
+}
diff --git a/crates/develop/src/controls.rs b/crates/develop/src/controls.rs
index c8a001b7..83b26403 100644
--- a/crates/develop/src/controls.rs
+++ b/crates/develop/src/controls.rs
@@ -140,6 +140,13 @@ controls! {
"light.shadows" => light.shadows, "Shadows", Light, -100, 100, 0, 1, 0, Centered;
"light.whites" => light.whites, "Whites", Light, -100, 100, 0, 1, 0, Centered;
"light.blacks" => light.blacks, "Blacks", Light, -100, 100, 0, 1, 0, Centered;
+ "hdr.maxEv" => hdr.max_ev, "HDR Headroom", Light, 0, 5, 3, 0.1, 1, Plain;
+ "hdr.sdrBrightness" => hdr.sdr_brightness, "SDR Brightness", Light, -100, 100, 0, 1, 0, Centered;
+ "hdr.sdrContrast" => hdr.sdr_contrast, "SDR Contrast", Light, -100, 100, 0, 1, 0, Centered;
+ "hdr.sdrHighlights" => hdr.sdr_highlights, "SDR Highlights", Light, -100, 100, 0, 1, 0, Centered;
+ "hdr.sdrShadows" => hdr.sdr_shadows, "SDR Shadows", Light, -100, 100, 0, 1, 0, Centered;
+ "hdr.sdrWhites" => hdr.sdr_whites, "SDR Whites", Light, -100, 100, 0, 1, 0, Centered;
+ "hdr.sdrClarity" => hdr.sdr_clarity, "SDR Clarity", Light, -100, 100, 0, 1, 0, Centered;
"curve.highlights" => curve.highlights, "Highlights", Curve, -100, 100, 0, 1, 0, Centered;
"curve.lights" => curve.lights, "Lights", Curve, -100, 100, 0, 1, 0, Centered;
"curve.darks" => curve.darks, "Darks", Curve, -100, 100, 0, 1, 0, Centered;
diff --git a/crates/develop/src/lib.rs b/crates/develop/src/lib.rs
index b7c95251..c2edd76c 100644
--- a/crates/develop/src/lib.rs
+++ b/crates/develop/src/lib.rs
@@ -56,11 +56,13 @@ impl DevelopSettings {
h
}
- /// True if nothing differs from a fresh default (ignoring white balance "as shot" values).
+ /// True if nothing differs from a fresh default (ignoring white balance "as shot" values, and
+ /// the rendering process: that is not an edit).
pub fn is_unedited(&self) -> bool {
let mut a = self.clone();
let d = DevelopSettings::default();
a.wb = d.wb;
+ a.process = d.process;
a == d
}
@@ -130,6 +132,9 @@ impl DevelopSettings {
}
}
+#[cfg(test)]
+mod tests_process;
+
#[cfg(test)]
mod tests {
use super::*;
@@ -162,6 +167,27 @@ mod tests {
assert_ne!(back.hash64(), DevelopSettings::default().hash64());
}
+ #[test]
+ fn default_hdr_is_not_serialized_so_existing_hashes_stay() {
+ let s = DevelopSettings::default();
+ assert!(s.to_json().get("hdr").is_none(), "HDR off must not change the settings JSON or hash64");
+ let mut on = s.clone();
+ on.hdr.enabled = true;
+ assert!(on.to_json().get("hdr").is_some());
+ assert_ne!(on.hash64(), s.hash64());
+ assert_eq!(DevelopSettings::from_json(&on.to_json()).unwrap(), on);
+ }
+
+ #[test]
+ fn hdr_peak_is_finite_for_a_non_finite_headroom() {
+ let mut h = Hdr { enabled: true, max_ev: f64::NAN, ..Hdr::default() };
+ assert_eq!(h.peak(), (Hdr::DEFAULT_MAX_EV as f32).exp2());
+ h.max_ev = f64::INFINITY;
+ assert_eq!(h.peak(), (Hdr::DEFAULT_MAX_EV as f32).exp2());
+ h.max_ev = -3.0;
+ assert_eq!(h.peak(), 1.0);
+ }
+
#[test]
fn missing_fields_take_defaults_and_unknown_ignored() {
let s = DevelopSettings::from_json(&json!({"light": {"exposure": 0.5}, "futureThing": 3})).unwrap();
diff --git a/crates/develop/src/presets.rs b/crates/develop/src/presets.rs
index 64bb116c..8bd15060 100644
--- a/crates/develop/src/presets.rs
+++ b/crates/develop/src/presets.rs
@@ -3,6 +3,10 @@
//! A preset is a *partial* settings object (JSON) — only the groups it includes. Applying merges it
//! into the photo's settings; `amount` (0..200 %) interpolates numeric values between the current
//! settings and the preset's. Copy/paste/sync use the same group selection ([`SettingsGroup`]).
+//!
+//! The rendering process ([`crate::ProcessVersion`]) is in no group: copy, paste, sync and presets
+//! made from a photo never carry it, so the photos they change keep their own. A partial that names
+//! `process` itself (Apply Settings JSON) sets it like any other field.
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
@@ -98,7 +102,7 @@ impl SettingsGroup {
SettingsGroup::Profile => &["profile"],
SettingsGroup::Treatment => &["treatment"],
SettingsGroup::WhiteBalance => &["wb"],
- SettingsGroup::Light => &["light"],
+ SettingsGroup::Light => &["light", "hdr"],
SettingsGroup::ToneCurve => &["curve"],
SettingsGroup::Color => &["color"],
SettingsGroup::ColorMixer => &["mixer", "bw_mix", "point_colors"],
@@ -162,7 +166,9 @@ fn scale_patch(base: &Value, patch: &Value, t: f64) -> Value {
}
Value::Object(out)
}
- (Value::Number(b), Value::Number(p)) => {
+ // Integer fields (the grain seed, ids) are discrete like strings: an interpolated seed would
+ // not deserialize as an integer, and the whole preset would silently not apply.
+ (Value::Number(b), Value::Number(p)) if b.is_f64() || p.is_f64() => {
let (b, p) = (b.as_f64().unwrap_or(0.0), p.as_f64().unwrap_or(0.0));
serde_json::Number::from_f64(b + (p - b) * t).map(Value::Number).unwrap_or(Value::Null)
}
@@ -267,6 +273,25 @@ mod tests {
assert_eq!(lots.light.exposure, 5.0);
}
+ /// A preset with the Grain group (its seed is an integer) used to do nothing at any amount
+ /// other than 100 %: the interpolated seed failed to deserialize and the preset was dropped.
+ #[test]
+ fn amount_keeps_integer_fields_whole() {
+ let mut a = DevelopSettings::default();
+ a.light.exposure = 1.0;
+ a.grain.amount = 40.0;
+ a.grain.seed = 12345;
+ let p = Preset::from_settings("p", "P", "User", &a, &SettingsGroup::default_copy());
+ assert!(p.settings.pointer("/grain/seed").is_some_and(Value::is_u64), "the default copy groups include the grain seed");
+ let base = DevelopSettings::default();
+ for (amount, seed) in [(0.3, 0), (0.5, 12345), (1.5, 12345), (2.0, 12345)] {
+ let out = p.apply(&base, amount);
+ assert!((out.light.exposure - amount.min(5.0)).abs() < 1e-9, "{amount}: exposure {}", out.light.exposure);
+ assert!((out.grain.amount - (40.0 * amount).min(100.0)).abs() < 1e-9, "{amount}: grain {}", out.grain.amount);
+ assert_eq!(out.grain.seed, seed, "{amount}: seed");
+ }
+ }
+
#[test]
fn deep_merge_objects() {
let mut a = json!({"x": {"a": 1, "b": 2}, "y": [1]});
diff --git a/crates/develop/src/settings.rs b/crates/develop/src/settings.rs
index 42011287..54577bff 100644
--- a/crates/develop/src/settings.rs
+++ b/crates/develop/src/settings.rs
@@ -1,19 +1,118 @@
//! The develop settings schema. Every field has a neutral default; serde uses `#[serde(default)]` so
-//! older/newer files load (unknown fields are ignored, missing fields take defaults).
+//! older/newer files load (unknown fields are ignored, missing fields take defaults). The one
+//! exception is `process`: missing (or unreadable), it is V1, the rendering those older files were
+//! made with.
use lightcraft_geom::{CropGeometry, Homography, Orientation, Point};
use serde::{Deserialize, Serialize};
pub const SCHEMA_VERSION: u32 = 1;
+/// The rendering process a photo's settings are interpreted by (what Lightroom calls the process
+/// version). A change that would alter how existing settings render ships as a new process, so a
+/// photo keeps its look until someone updates it (`develop.updateProcess`). See
+/// `docs/process-versions.md`.
+///
+/// Stored as a plain number: one written by a newer LightCraft loads, is kept as it is, and
+/// renders with the newest process this build knows ([`ProcessVersion::process`]). Reading is
+/// lenient: a whole number from 0 to 2^32 - 1 (also written as `2.0`) is that process; anything
+/// else (negative, fractional, too large, text, null, a list…) reads as V1, like a missing field,
+/// so a damaged value never costs the rest of the settings.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
+#[serde(transparent)]
+pub struct ProcessVersion(pub u32);
+
+impl<'de> Deserialize<'de> for ProcessVersion {
+ fn deserialize>(d: D) -> Result {
+ #[derive(Deserialize)]
+ #[serde(untagged)]
+ enum Wire {
+ Whole(u64),
+ Number(f64),
+ Other(serde::de::IgnoredAny),
+ }
+ Ok(match Wire::deserialize(d)? {
+ Wire::Whole(n) => u32::try_from(n).map_or_else(|_| ProcessVersion::legacy(), ProcessVersion),
+ Wire::Number(x) if x.fract() == 0.0 && (0.0..=f64::from(u32::MAX)).contains(&x) => ProcessVersion(x as u32),
+ Wire::Number(_) | Wire::Other(_) => ProcessVersion::legacy(),
+ })
+ }
+}
+
+impl ProcessVersion {
+ /// The first process: LightCraft's rendering from before process versions existed. Frozen.
+ pub const V1: ProcessVersion = Process::V1.version();
+ /// The process new photos and Reset get.
+ pub const LATEST: ProcessVersion = Process::LATEST.version();
+
+ /// The process of settings saved without one (catalogs, snapshots, XMP sidecars and browser
+ /// libraries written before process versions existed): V1, whatever [`Self::LATEST`] becomes.
+ pub fn legacy() -> ProcessVersion {
+ ProcessVersion::V1
+ }
+
+ /// Saved settings leave the field out for V1, so files and preview-cache keys of settings made
+ /// before process versions existed stay byte for byte what they were.
+ pub fn is_legacy(&self) -> bool {
+ *self == ProcessVersion::legacy()
+ }
+
+ /// The process this build renders with: the newest one it knows that isn't newer than this
+ /// number (a number from a newer LightCraft renders with [`Process::LATEST`]; one below V1,
+ /// which no LightCraft writes, with V1).
+ pub fn process(self) -> Process {
+ Process::ALL.into_iter().rev().find(|p| p.version() <= self).unwrap_or(Process::V1)
+ }
+
+ /// A process this build implements.
+ pub fn is_known(self) -> bool {
+ Process::ALL.iter().any(|p| p.version() == self)
+ }
+
+ /// Older than [`Self::LATEST`]: Update to Current Process applies.
+ pub fn is_outdated(self) -> bool {
+ self < ProcessVersion::LATEST
+ }
+}
+
+/// A rendering process this build implements. Matches on it are exhaustive, so adding a process
+/// makes the compiler point at every stage whose behaviour depends on it.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub enum Process {
+ /// The rendering of LightCraft before process versions existed (2026-10).
+ V1,
+}
+
+impl Process {
+ /// Every process, oldest first.
+ pub const ALL: [Process; 1] = [Process::V1];
+ pub const LATEST: Process = Process::V1;
+
+ pub const fn version(self) -> ProcessVersion {
+ match self {
+ Process::V1 => ProcessVersion(1),
+ }
+ }
+}
+
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct DevelopSettings {
+ /// Schema version of this structure (not the rendering process, see `process`).
pub version: u32,
+ /// The rendering process ([`ProcessVersion`]): the latest for new settings, V1 for settings
+ /// saved without the field (the field-level default: not the struct's `Default`) or with a
+ /// value that isn't a process number.
+ #[serde(default = "ProcessVersion::legacy", skip_serializing_if = "ProcessVersion::is_legacy")]
+ pub process: ProcessVersion,
pub profile: Profile,
pub treatment: Treatment,
pub wb: WhiteBalance,
pub light: Light,
+ /// HDR editing: highlights above SDR white, and the SDR rendition derived from them. Not written while it is
+ /// the default, so settings without HDR keep their JSON and `hash64` (and every cached preview stays valid).
+ #[serde(skip_serializing_if = "Hdr::is_default")]
+ pub hdr: Hdr,
pub curve: ToneCurve,
pub color: ColorAdj,
pub mixer: Mixer,
@@ -44,10 +143,12 @@ impl Default for DevelopSettings {
fn default() -> Self {
Self {
version: SCHEMA_VERSION,
+ process: ProcessVersion::LATEST,
profile: Profile::default(),
treatment: Treatment::Color,
wb: WhiteBalance::default(),
light: Light::default(),
+ hdr: Hdr::default(),
curve: ToneCurve::default(),
color: ColorAdj::default(),
mixer: Mixer::default(),
@@ -931,3 +1032,78 @@ impl Enhance {
self.denoise_on.unwrap_or(self.denoise > 0.0)
}
}
+
+/// HDR editing. When enabled, an HDR render ([`Hdr::peak`]) keeps highlights above SDR white (1.0)
+/// up to `2^max_ev`; every SDR render (previews on SDR displays, 8/16-bit exports and a gain map's
+/// base image) uses the SDR rendition ([`DevelopSettings::sdr_rendition`]): the same edit with the
+/// `sdr_*` offsets applied.
+#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(default)]
+pub struct Hdr {
+ pub enabled: bool,
+ /// Headroom limit: stops above SDR white the HDR render may reach.
+ pub max_ev: f64,
+ /// SDR rendition offsets (−100..100): brightness (±1 EV), then added to the matching sliders.
+ pub sdr_brightness: f64,
+ pub sdr_contrast: f64,
+ pub sdr_highlights: f64,
+ pub sdr_shadows: f64,
+ pub sdr_whites: f64,
+ pub sdr_clarity: f64,
+}
+
+impl Default for Hdr {
+ fn default() -> Self {
+ Self {
+ enabled: false,
+ max_ev: Hdr::DEFAULT_MAX_EV,
+ sdr_brightness: 0.0,
+ sdr_contrast: 0.0,
+ sdr_highlights: 0.0,
+ sdr_shadows: 0.0,
+ sdr_whites: 0.0,
+ sdr_clarity: 0.0,
+ }
+ }
+}
+
+impl Hdr {
+ pub const DEFAULT_MAX_EV: f64 = 3.0;
+ pub const MAX_EV_LIMIT: f64 = 5.0;
+
+ /// Whether these are the default HDR settings (HDR off, nothing changed).
+ pub fn is_default(&self) -> bool {
+ *self == Hdr::default()
+ }
+
+ /// Peak linear value of an HDR render relative to SDR white (1.0 when HDR is off). A non-finite headroom
+ /// (a damaged file or argument) falls back to the default, so the peak is always finite and at least 1.
+ pub fn peak(&self) -> f32 {
+ if !self.enabled {
+ return 1.0;
+ }
+ let ev = if self.max_ev.is_finite() { self.max_ev } else { Hdr::DEFAULT_MAX_EV };
+ (ev.clamp(0.0, Hdr::MAX_EV_LIMIT) as f32).exp2()
+ }
+}
+
+impl DevelopSettings {
+ /// The settings an SDR render uses: unchanged when HDR is off; otherwise HDR off and the
+ /// SDR rendition offsets applied to exposure, contrast, highlights, shadows, whites, clarity.
+ pub fn sdr_rendition(&self) -> std::borrow::Cow<'_, DevelopSettings> {
+ if !self.hdr.enabled {
+ return std::borrow::Cow::Borrowed(self);
+ }
+ let h = self.hdr;
+ let mut s = self.clone();
+ s.hdr.enabled = false;
+ let add = |v: f64, d: f64| (v + d).clamp(-100.0, 100.0);
+ s.light.exposure = (s.light.exposure + h.sdr_brightness / 100.0).clamp(-5.0, 5.0);
+ s.light.contrast = add(s.light.contrast, h.sdr_contrast);
+ s.light.highlights = add(s.light.highlights, h.sdr_highlights);
+ s.light.shadows = add(s.light.shadows, h.sdr_shadows);
+ s.light.whites = add(s.light.whites, h.sdr_whites);
+ s.effects.clarity = add(s.effects.clarity, h.sdr_clarity);
+ std::borrow::Cow::Owned(s)
+ }
+}
diff --git a/crates/develop/src/tests_process.rs b/crates/develop/src/tests_process.rs
new file mode 100644
index 00000000..f89ebfa1
--- /dev/null
+++ b/crates/develop/src/tests_process.rs
@@ -0,0 +1,162 @@
+//! Process versions: settings saved before they existed stay on V1, new settings get the latest,
+//! and numbers this build doesn't know load without harm.
+
+use serde_json::{Value, json};
+
+use crate::*;
+
+/// Complete settings as LightCraft wrote them before process versions existed (main at 8f924a9:
+/// `serde_json::to_string` of a raw photo with tone, colour, curve, grading, effects and a radial
+/// mask), one top-level key per line. There is no `process` field.
+const LEGACY: &str = r#"
+{
+ "version": 1,
+ "profile": {"id": "lc.color", "amount": 100.0},
+ "treatment": "color",
+ "wb": {"mode": "asShot", "temp": 5200.0, "tint": 4.0},
+ "light": {"exposure": 0.4, "contrast": 30.0, "highlights": -60.0, "shadows": 40.0, "whites": 15.0, "blacks": -20.0},
+ "curve": {"highlights": -10.0, "lights": 0.0, "darks": 10.0, "shadows": 0.0, "split_shadows": 25.0, "split_mid": 50.0, "split_highlights": 75.0, "master": [{"x": 0.0, "y": 0.05}, {"x": 0.5, "y": 0.55}, {"x": 1.0, "y": 0.95}], "red": [], "green": [], "blue": [], "refine_saturation": 100.0},
+ "color": {"vibrance": 20.0, "saturation": -5.0},
+ "mixer": {"red": {"hue": 0.0, "sat": 0.0, "lum": 0.0}, "orange": {"hue": 0.0, "sat": 0.0, "lum": 15.0}, "yellow": {"hue": 0.0, "sat": 0.0, "lum": 0.0}, "green": {"hue": 0.0, "sat": 0.0, "lum": 0.0}, "aqua": {"hue": 0.0, "sat": 0.0, "lum": 0.0}, "blue": {"hue": 0.0, "sat": -30.0, "lum": 0.0}, "purple": {"hue": 0.0, "sat": 0.0, "lum": 0.0}, "magenta": {"hue": 0.0, "sat": 0.0, "lum": 0.0}},
+ "point_colors": [],
+ "bw_mix": {"red": 0.0, "orange": 0.0, "yellow": 0.0, "green": 0.0, "aqua": 0.0, "blue": 0.0, "purple": 0.0, "magenta": 0.0},
+ "grading": {"shadows": {"hue": 220.0, "sat": 20.0, "lum": -5.0}, "midtones": {"hue": 0.0, "sat": 0.0, "lum": 0.0}, "highlights": {"hue": 40.0, "sat": 15.0, "lum": 5.0}, "global": {"hue": 0.0, "sat": 0.0, "lum": 0.0}, "blending": 50.0, "balance": 0.0},
+ "effects": {"texture": 20.0, "clarity": 25.0, "dehaze": 15.0},
+ "vignette": {"amount": -25.0, "midpoint": 50.0, "roundness": 0.0, "feather": 50.0, "highlights": 0.0, "style": "highlightPriority"},
+ "grain": {"amount": 20.0, "size": 25.0, "roughness": 50.0, "seed": 0},
+ "detail": {"sharpen_amount": 40.0, "sharpen_radius": 1.0, "sharpen_detail": 25.0, "sharpen_masking": 0.0, "nr_luminance": 20.0, "nr_detail": 50.0, "nr_contrast": 0.0, "nr_color": 25.0, "nr_color_detail": 50.0, "nr_color_smoothness": 50.0},
+ "optics": {"remove_ca": false, "lens_profile": false, "profile_distortion": 100.0, "profile_vignetting": 100.0, "defringe_purple_amount": 0.0, "defringe_purple_hue_lo": 30.0, "defringe_purple_hue_hi": 70.0, "defringe_green_amount": 0.0, "defringe_green_hue_lo": 40.0, "defringe_green_hue_hi": 60.0, "distortion": 0.0, "vignetting": 0.0, "vignetting_midpoint": 50.0, "ca_red": 0.0, "ca_blue": 0.0},
+ "geometry": {"upright": "off", "guides": [], "upright_transform": null, "vertical": 0.0, "horizontal": 0.0, "rotate": 0.0, "aspect": 0.0, "scale": 100.0, "offset_x": 0.0, "offset_y": 0.0, "constrain_crop": false},
+ "crop": {"geometry": {"rect": {"x0": 0.0, "y0": 0.0, "x1": 1.0, "y1": 1.0}, "angle": 0.0}, "aspect": null, "flip_h": false, "flip_v": false},
+ "orientation": "Normal",
+ "masks": [{"id": 1, "name": "Mask 1", "visible": true, "invert": false, "components": [{"op": "add", "invert": false, "shape": {"kind": "radial", "center": {"x": 0.4, "y": 0.5}, "rx": 0.3, "ry": 0.2, "angle": 15.0, "feather": 60.0, "invert": false}}], "adjust": {"temp": 10.0, "tint": 0.0, "exposure": 0.5, "contrast": 0.0, "highlights": 0.0, "shadows": 0.0, "whites": 0.0, "blacks": 0.0, "texture": 0.0, "clarity": 30.0, "dehaze": 0.0, "hue": 0.0, "saturation": 0.0, "sharpness": 0.0, "noise": 0.0, "moire": 0.0, "defringe": 0.0, "color_hue": 0.0, "color_sat": 0.0, "amount": 100.0}}],
+ "spots": [],
+ "red_eye": [],
+ "lens_blur": {"enabled": false, "amount": 0.0, "focal_distance": 0.0, "focal_range": 0.0},
+ "enhance": {"denoise": 0.0, "raw_details": false, "super_resolution": false},
+ "calibration": {"shadows_tint": 0.0, "red_hue": 0.0, "red_sat": 0.0, "green_hue": 0.0, "green_sat": 0.0, "blue_hue": 0.0, "blue_sat": 0.0},
+ "disabled_sections": []
+}
+"#;
+
+fn legacy() -> Value {
+ serde_json::from_str(LEGACY).unwrap()
+}
+
+#[test]
+fn settings_saved_before_process_versions_load_as_v1() {
+ let v = legacy();
+ assert!(v.get("process").is_none(), "the fixture predates the field");
+ let s = DevelopSettings::from_json(&v).unwrap();
+ // V1 as a number, not `LATEST`: once a newer process is the latest, this is what keeps every
+ // existing catalog, sidecar and preset on the rendering it was made with
+ assert_eq!(s.process, ProcessVersion(1));
+ assert_eq!(s.process, ProcessVersion::legacy());
+ assert_eq!((s.light.exposure, s.light.highlights, s.masks.len(), s.detail.sharpen_amount), (0.4, -60.0, 1, 40.0));
+ // the smallest legacy file too, and settings nested in other records (history, versions)
+ assert_eq!(DevelopSettings::from_json(&json!({})).unwrap().process, ProcessVersion(1));
+ let nested: Vec = serde_json::from_value(json!([{}, {"light": {"exposure": 1.0}}])).unwrap();
+ assert!(nested.iter().all(|s| s.process == ProcessVersion(1)));
+ // saved again, they gain no field: files and preview-cache keys (`hash64`) stay as they were
+ assert!(s.to_json().get("process").is_none());
+}
+
+#[test]
+fn new_settings_get_the_latest_process() {
+ assert_eq!(DevelopSettings::default().process, ProcessVersion::LATEST);
+ assert_eq!(DevelopSettings::for_raw(5200.0, 4.0).process, ProcessVersion::LATEST);
+ assert_eq!(ProcessVersion::LATEST, Process::LATEST.version());
+ assert_eq!(ProcessVersion::V1, ProcessVersion(1));
+ // the processes are listed oldest first, the latest last, each with its own number
+ let versions: Vec = Process::ALL.iter().map(|p| p.version().0).collect();
+ assert!(versions.windows(2).all(|w| w[0] < w[1]), "{versions:?}");
+ assert_eq!(Process::ALL.last(), Some(&Process::LATEST));
+ assert!(Process::ALL.iter().all(|p| p.version().process() == *p && p.version().is_known()));
+}
+
+#[test]
+fn v1_is_left_out_when_saving_and_other_processes_are_kept() {
+ let mut s = DevelopSettings::from_json(&legacy()).unwrap();
+ assert!(s.to_json().get("process").is_none());
+ let before = s.hash64();
+ s.process = ProcessVersion(2);
+ let v = s.to_json();
+ assert_eq!(v["process"], json!(2));
+ assert_eq!(DevelopSettings::from_json(&v).unwrap().process, ProcessVersion(2));
+ assert_ne!(s.hash64(), before, "another process is another preview");
+}
+
+#[test]
+fn unknown_process_numbers_load_and_render_with_the_newest_known() {
+ // written by a newer LightCraft: kept as it is, rendered with the latest process this build has
+ let s = DevelopSettings::from_json(&json!({"process": 7, "light": {"exposure": 0.5}})).unwrap();
+ assert_eq!(s.process, ProcessVersion(7));
+ assert_eq!(s.process.process(), Process::LATEST);
+ assert!(!s.process.is_known());
+ assert!(!s.process.is_outdated(), "never downgraded by Update to Current Process");
+ assert_eq!(s.to_json()["process"], json!(7));
+ assert_eq!(s.merged(&json!({"light": {"exposure": 1.0}})).unwrap().process, ProcessVersion(7));
+ // below V1 (no LightCraft writes it): rendered as V1, and updatable
+ let zero = DevelopSettings::from_json(&json!({"process": 0})).unwrap().process;
+ assert_eq!((zero.process(), zero.is_known(), zero.is_outdated()), (Process::V1, false, true));
+ let max = DevelopSettings::from_json(&json!({"process": u32::MAX})).unwrap().process;
+ assert_eq!(max.process(), Process::LATEST);
+ // a whole number written as a float is that number
+ assert_eq!(DevelopSettings::from_json(&json!({"process": 2.0})).unwrap().process, ProcessVersion(2));
+}
+
+#[test]
+fn a_damaged_process_value_reads_as_v1_and_keeps_the_edits() {
+ let too_big = u64::from(u32::MAX) + 1;
+ for bad in [
+ json!(-1),
+ json!(1.5),
+ json!(-0.5),
+ json!(1e300),
+ json!(too_big),
+ json!(u64::MAX),
+ json!("2"),
+ json!(null),
+ json!(true),
+ json!([2]),
+ json!({"v": 2}),
+ ] {
+ let mut v = legacy();
+ v["process"] = bad.clone();
+ let s = DevelopSettings::from_json(&v).unwrap_or_else(|e| panic!("{bad}: {e}"));
+ assert_eq!(s.process, ProcessVersion::V1, "{bad}");
+ assert_eq!((s.light.exposure, s.masks.len(), s.curve.master.len()), (0.4, 1, 3), "{bad}: the rest of the settings load");
+ // nested in other records too (history, versions, the catalog)
+ let nested: Vec = serde_json::from_value(json!([v])).unwrap();
+ assert_eq!(nested[0].process, ProcessVersion::V1);
+ }
+}
+
+#[test]
+fn the_process_is_not_an_edit_nor_part_of_copied_settings() {
+ let mut s = DevelopSettings { process: ProcessVersion(LATEST_PLUS_ONE), ..Default::default() };
+ assert!(s.is_unedited(), "a photo on another process is not edited for that");
+ s.light.exposure = 1.0;
+ assert!(!s.is_unedited());
+ // resetting a section or a slider leaves the process alone
+ s.reset_section(Section::Light);
+ assert_eq!(s.process, ProcessVersion(LATEST_PLUS_ONE));
+ // copy / paste / sync / presets: no group carries the process, so the target keeps its own
+ assert!(SettingsGroup::ALL.iter().all(|g| !g.keys().contains(&"process")));
+ s.light.exposure = 1.0;
+ let copied = extract_groups(&s, &SettingsGroup::ALL);
+ assert!(copied.get("process").is_none());
+ let target = DevelopSettings::from_json(&legacy()).unwrap();
+ let pasted = apply_partial(&target, &copied, 1.0);
+ assert_eq!((pasted.process, pasted.light.exposure), (ProcessVersion(1), 1.0));
+ // (at another amount, without Grain: its integer seed doesn't survive scaling, a separate issue)
+ let groups: Vec = SettingsGroup::ALL.into_iter().filter(|g| *g != SettingsGroup::Grain).collect();
+ let preset = Preset::from_settings("p", "P", "User", &s, &groups);
+ let half = preset.apply(&target, 0.5);
+ assert_eq!((half.process, half.light.exposure), (ProcessVersion(1), 0.7));
+ // a partial that names a process explicitly (Apply Settings JSON) sets it
+ assert_eq!(target.merged(&json!({"process": LATEST_PLUS_ONE})).unwrap().process, ProcessVersion(LATEST_PLUS_ONE));
+}
+
+/// A process other than the latest: a newer LightCraft's number (no older one than V1 exists).
+const LATEST_PLUS_ONE: u32 = ProcessVersion::LATEST.0 + 1;
diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml
index 9bd9845f..4036a5c9 100644
--- a/crates/engine/Cargo.toml
+++ b/crates/engine/Cargo.toml
@@ -49,5 +49,7 @@ denoise = ["dep:lightcraft-denoise", "lightcraft-denoise/runtime", "lightcraft-g
rawnind-model = ["denoise", "lightcraft-denoise/rawnind-model", "lightcraft-denoise-core/rawnind-model"]
[dev-dependencies]
+# Lossless synthetic HEIC files (the import test; decoded when the codecs' `heif` feature is on).
+lightcraft-heif = { workspace = true, features = ["testdata"] }
libc = { workspace = true }
lightcraft-tiff = { workspace = true }
diff --git a/crates/engine/examples/render_bench.rs b/crates/engine/examples/render_bench.rs
index 55dc2b11..6a473a2d 100644
--- a/crates/engine/examples/render_bench.rs
+++ b/crates/engine/examples/render_bench.rs
@@ -247,18 +247,20 @@ fn main() {
}
}
if run("batch") && args.len() > 1 {
- // full-size JPEG export of all the files given (decode + render + encode)
- use lightcraft_engine::export::export_photo;
+ // full-size JPEG export of all the files given (decode + render + encode), as the Export
+ // dialog runs it: several photos side by side (`export_parallelism`), files discarded
+ use lightcraft_engine::export::{Destination, export_batch};
let mut session = lightcraft_engine::Session::new().with_fs();
let r = session.execute("library.import", &serde_json::json!({"paths": args})).expect("import");
let ids: Vec<_> = r["imported"].as_array().expect("ids").iter().filter_map(|v| v.as_u64()).map(lightcraft_engine::catalog::PhotoId).collect();
- let items: Vec<_> = ids.iter().enumerate().map(|(i, id)| (*id, i + 1)).collect();
let o = ExportOptions::default();
- row(&format!("batch export of {} files (decode+render+JPEG):", items.len()), &mut |g| {
+ let lanes = lightcraft_engine::export::export_parallelism(ids.len());
+ row(&format!("batch export of {} files, {lanes} at a time (decode+render+JPEG):", ids.len()), &mut |g| {
lightcraft_engine::gpu::set_enabled(g);
- for &(id, seq) in &items {
- drop(export_photo(&mut session, id, &o, seq).expect("export"));
- session.media.forget(id); // as in a batch: every original is decoded once
+ let files = export_batch(&mut session, &ids, &o, &Destination::default(), &mut |_, _| Ok(()), &|_| false).expect("export");
+ assert_eq!(files.len(), ids.len());
+ for &id in &ids {
+ session.media.forget(id); // every original is decoded once per run
}
});
lightcraft_engine::gpu::set_enabled(true);
diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs
index dd3d7361..fd7c58f8 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, Fujifilm RAF, Canon CR3) from its own JPEG. Colour and luminance are fitted separately; the JPEG supplies correspondences only,
+//! RW2, Fujifilm RAF, Canon CR2/CR3, Pentax PEF, Samsung SRW, Olympus ORF) from its own JPEG. Colour and luminance are fitted separately; the JPEG supplies correspondences only,
//! never output pixels or a replacement for RAW editing.
//! A global matrix can't follow the camera's hue-dependent rendering (the best matrix rendered a
//! lime shirt olive that the camera kept lime): a hue/saturation table fitted to the residuals
@@ -8,7 +8,7 @@ use lightcraft_color::{D50, D65, Mat3, PROPHOTO, REC2020, bradford, luminance_20
use lightcraft_pipeline::tone::{CameraTone, ToneMap};
use lightcraft_raster::{
Rgb32f,
- resample::{Filter, fit},
+ resample::{Filter, fit, resize},
};
use lightcraft_raw::{RawFormat, RawImage, color::CameraTransform, profile::HsvTable};
@@ -42,6 +42,7 @@ pub(crate) fn file_local_look(format: RawFormat) -> bool {
| RawFormat::Cr2
| RawFormat::Pef
| RawFormat::Srw
+ | RawFormat::Orf
)
}
@@ -49,15 +50,19 @@ pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransf
if !transform.matrix_is_fallback || !file_local_look(raw.format) {
return None;
}
- let (sensor, reference) = proxies(raw, bytes, transform, PROXY)?;
+ let (sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY)?;
// A camera profile pooled from many photos knows colours this photo shows too little of;
- // try its colour first and fit tone/chroma per photo (DRO and picture styles vary).
+ // try its colour first and fit tone/chroma per photo (picture styles vary).
let profile = raw.metadata.model.as_deref().and_then(crate::camera_profiles::get);
let colour = profile.as_ref().and_then(|p| Some((p.matrix().mul(&transform.matrix.inverse()?), p.hue_sat.clone())));
- let look = fit_pairs_with(&sensor, &reference, colour)?;
+ let look = fit_look(&sensor, &reference, &clipped, colour)?;
+ // Sony's Dynamic Range Optimizer (on by default) brightens the camera JPEG's darker regions,
+ // not the raw: keep the colour fitted to the JPEG, take the tone curve without DRO.
+ let dro = lightcraft_raw::embedded_preview_dynamic_range_optimized(bytes) == Some(true);
+ let look = if dro { with_dro_off_tone(look) } else { look };
if lightcraft_pipeline::profiling() {
eprintln!(
- "[profile] {:?} camera look: {:?}, {:?}, hue/sat table {}, camera profile available {}",
+ "[profile] {:?} camera look: {:?}, {:?}, hue/sat table {}, camera profile available {}, DRO tone replaced {dro}",
raw.format,
look.matrix.0,
look.tone,
@@ -68,9 +73,129 @@ pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransf
Some(look)
}
+/// Sensor level (normalised, white = 1) at or above which a raw channel counts as clipped, as in
+/// the render's highlight reconstruction.
+const SENSOR_CLIP: f32 = 0.99;
+
+/// The photo's look, refitted without the proxy pixels whose sensor values are clipped.
+///
+/// A clipped raw pixel no longer records the scene's colour: with one channel held at the clip
+/// level while the others keep rising, a white overcast sky comes out magenta or blue through the
+/// camera matrix, while the camera JPEG shows it white. As correspondences such pixels teach the
+/// colour matrix to pull colours toward grey, and the chroma curve to remove colour at their
+/// display luminance (on a Z 6II forest under a clipped sky the curve cut colourfulness above
+/// display luminance 0.4 to 13–19%, and every one of its samples there was clipped). The render
+/// rebuilds clipped highlights separately (`lightcraft_raw::highlight`), so the look is fitted on
+/// what the sensor measured.
+///
+/// The search on all pixels decides, as before, whether the photo gets a look and which attempt
+/// of [`search_ordered`] gives it; only that attempt is refitted without the clipped pixels, and
+/// when the refit misses the acceptance gates the look on all pixels is kept. So no photo loses
+/// its look and none switches attempt (a refit that just clears the gate on all pixels must not
+/// replace a look fitted away from edges). A photo that had no look gets one when the search
+/// without the clipped pixels finds it, and otherwise the [`fit_partial`] look when that is clearly
+/// closer to the camera JPEG than the neutral fallback.
+fn fit_look(sensor: &Rgb32f, reference: &Rgb32f, clipped: &[bool], colour: Option<(Mat3, Option)>) -> Option {
+ let unclipped = without_clipped(sensor, clipped);
+ let Some((look, attempt)) = search_ordered(sensor, reference, colour.clone(), TONE_FITS) else {
+ return unclipped
+ .as_ref()
+ .and_then(|u| search_ordered(u, reference, colour, TONE_FITS))
+ .map(|(look, _)| look)
+ .or_else(|| fit_partial(unclipped.as_ref().unwrap_or(sensor), reference));
+ };
+ let Some(unclipped) = unclipped else { return Some(look) };
+ let refit = fit_attempt(&unclipped, reference, &colour, attempt);
+ if refit.is_none() && lightcraft_pipeline::profiling() {
+ eprintln!("[profile] camera look: no fit without the clipped pixels on the accepted attempt, keeping the fit on all pixels");
+ }
+ Some(refit.unwrap_or(look))
+}
+
+/// `sensor` with its clipped pixels set to NaN (left out of every training pair), or `None` when
+/// none is clipped.
+fn without_clipped(sensor: &Rgb32f, clipped: &[bool]) -> Option {
+ if clipped.len() != sensor.data.len() || !clipped.iter().any(|c| *c) {
+ return None;
+ }
+ let mut out = sensor.clone();
+ for (p, c) in out.data.iter_mut().zip(clipped) {
+ if *c {
+ *p = [f32::NAN; 3];
+ }
+ }
+ if lightcraft_pipeline::profiling() {
+ eprintln!(
+ "[profile] camera look: {} of {} proxy pixels have a clipped sensor channel",
+ clipped.iter().filter(|c| **c).count(),
+ clipped.len()
+ );
+ }
+ Some(out)
+}
+
+/// 1 where any channel of a camera-RGB pixel is at or above [`SENSOR_CLIP`], else 0.
+fn clip_mask(camera: &Rgb32f) -> Rgb32f {
+ let mut mask = camera.clone();
+ mask.map_in_place(|p| if p.iter().any(|v| *v >= SENSOR_CLIP) { [1.0; 3] } else { [0.0; 3] });
+ mask
+}
+
+/// Sony's global tone curve with the Dynamic Range Optimizer off (Standard creative style), pooled
+/// from 12 CC0 raw.pixls.us ARWs of 10 interchangeable-lens bodies and their camera JPEGs; the file
+/// lists them and how the curve was derived (`assets/ATTRIBUTION.md`). Pooled the same way from 55
+/// private ILCE-7CR photos with DRO off, the curve differs by at most 1.2 L* between scene luminance
+/// 0.01 and 0.5. Issue #244, `docs/camera-preview-colour.md`.
+const SONY_DRO_OFF_TONE: &str = include_str!("../../../assets/camera-tone/sony-dro-off.json");
+
+/// [`SONY_DRO_OFF_TONE`]'s curve (`None` only if the built-in file were invalid; a test checks it).
+fn sony_dro_off_tone() -> Option {
+ #[derive(serde::Deserialize)]
+ struct File {
+ knots: [[f32; 2]; 32],
+ }
+ static TONE: std::sync::OnceLock> = std::sync::OnceLock::new();
+ *TONE.get_or_init(|| serde_json::from_str::(SONY_DRO_OFF_TONE).ok().and_then(|f| CameraTone::new(f.knots)))
+}
+
+/// `look`, fitted to a camera JPEG brightened by Sony's Dynamic Range Optimizer, with its tone
+/// curve lowered to Sony's without DRO ([`SONY_DRO_OFF_TONE`]) wherever that is darker. DRO only
+/// brightens, so the fitted curve bounds the curve without it from above, everywhere: where Sony's
+/// curve is brighter than the photo's own (the 1″ compacts map raw values darker), the photo keeps
+/// its own. Its colour (matrix, hue/saturation table and chroma curve) stays as fitted.
+fn with_dro_off_tone(look: CameraLook) -> CameraLook {
+ let Some(dro_off) = sony_dro_off_tone() else { return look };
+ let own = look.tone.knots();
+ let mut knots = own.map(|[x, y]| [x, y.min(dro_off.apply(x))]);
+ // Past its last knot a curve goes on as a shoulder whose rate comes from its last two knots
+ // (`CameraTone::apply`): lowering the second-last knot more than the last steepens it, and the
+ // extended highlights would end up above the fitted curve. Raise the second-last knot (never
+ // above the fitted curve) until the rate is at most the fitted curve's.
+ let ([a, b], last) = ([own[30], own[31]], knots[31]);
+ let rate = ((b[1] - a[1]) / (b[0] - a[0])).clamp(0.1, 16.0) / (1.0 - b[1]).max(0.01);
+ let floor = last[1] - rate * (1.0 - last[1]).max(0.01) * (b[0] - a[0]);
+ knots[30][1] = knots[30][1].max(floor).min(last[1]);
+ let Some(tone) = CameraTone::new(knots).and_then(|tone| tone.with_chroma(*look.tone.chroma())) else { return look };
+ if !at_most(&tone, &look.tone) {
+ return look;
+ }
+ CameraLook { tone, ..look }
+}
+
+/// Whether curve `a` is nowhere brighter than `b` over the scene luminances the finish stage's tone
+/// table covers ([`ToneMap::camera`]).
+fn at_most(a: &CameraTone, b: &CameraTone) -> bool {
+ use lightcraft_pipeline::tone::{GREY, LUT_MAX_EV, LUT_MIN_EV, LUT_N};
+ (0..LUT_N).all(|i| {
+ let x = GREY * 2f32.powf(LUT_MIN_EV + (LUT_MAX_EV - LUT_MIN_EV) * i as f32 / (LUT_N - 1) as f32);
+ a.apply(x) <= b.apply(x) + 1e-5
+ })
+}
+
/// Same-size proxies of the sensor (white-balanced, baseline exposure, through `transform`'s
-/// matrix: the generic camera ≈ sRGB model) and of the file's embedded camera JPEG (linear Rec.2020).
-fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usize) -> Option<(Rgb32f, Rgb32f)> {
+/// matrix: the generic camera ≈ sRGB model) and of the file's embedded camera JPEG (linear Rec.2020),
+/// and per proxy pixel whether any sensor sample it covers is clipped.
+fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usize) -> Option<(Rgb32f, Rgb32f, Vec)> {
let edge = (2 * size).max(384) as u32;
let decoded = crate::files::decode_raw_preview(bytes, lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: 64_000_000 })?;
let mut reference = decoded.to_working();
@@ -95,23 +220,38 @@ fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usiz
// Fixed, bounded proxy: the selected look cannot depend on thumbnail/export resolution.
let k = (crop.width.max(crop.height).div_ceil(edge as usize).max(2)).div_ceil(2) * 2;
let mut sensor = sensor_proxy(raw, k, edge as usize)?;
+ let mut clipped = clip_mask(&sensor);
let gain = 2f32.powf(transform.baseline_exposure as f32);
let to_working = |p: [f32; 3]| transform.matrix.apply_f32(std::array::from_fn(|i| p[i] * transform.wb[i] * gain));
if raw.format == RawFormat::Cr3 {
// Orient the camera JPEG and the sensor identically before collecting pixel correspondences.
sensor = sensor.into_oriented(raw.orientation);
+ clipped = clipped.into_oriented(raw.orientation);
reference = reference.into_oriented(raw.orientation);
sensor.map_in_place(to_working);
// Canon's JPEG can be cropped differently from the sensor. Estimate only that common
// framing from edge directions, independently of the subsequent colour fit.
- sensor = align_cr3_framing(sensor, &reference);
+ (sensor, clipped) = align_cr3_framing(sensor, clipped, &reference);
}
let mut sensor = fit(&sensor, size, size, Filter::Box);
- let reference = fit(&reference, sensor.width, sensor.height, Filter::Box);
+ let reference = reference_at(&reference, &sensor);
+ // A proxy pixel is clipped when any sample it averages is (outside the CR3 framing the mask is
+ // NaN: those pixels are left out of the pairs anyway). Resized to exactly the proxy's size, like
+ // the reference, so the mask pairs pixel for pixel.
+ let clipped: Vec = resize(&clipped, sensor.width, sensor.height, Filter::Box).data.iter().map(|p| p[0] > 0.0).collect();
if raw.format != RawFormat::Cr3 {
sensor.map_in_place(to_working);
}
- Some((sensor, reference))
+ Some((sensor, reference, clipped))
+}
+
+/// The camera JPEG at exactly the sensor proxy's size, so the two pair pixel for pixel. Fitting it
+/// into the proxy's box instead keeps its own aspect, which can round a pixel short: an ILCE-7RM4's
+/// 1616×1080 preview, decoded at 384×257, fits a 192×128 proxy as 191×128, and `collect_pairs`
+/// then rejected every file `lightcraft-cli calibrate` was given. `proxies` has already checked
+/// that the aspects agree within 2 %.
+fn reference_at(reference: &Rgb32f, sensor: &Rgb32f) -> Rgb32f {
+ resize(reference, sensor.width, sensor.height, Filter::Box)
}
#[derive(Clone, Copy, Debug)]
@@ -238,15 +378,19 @@ fn estimate_cr3_framing(sensor: &Rgb32f, reference: &Rgb32f) -> Option Rgb32f {
- let Some(framing) = estimate_cr3_framing(&sensor, reference) else { return sensor };
- let (w, h) = (sensor.width, sensor.height);
- let mut aligned = Rgb32f::new(w, h);
- for (i, pixel) in aligned.data.iter_mut().enumerate() {
- let (x, y) = framing.source((i % w) as f32 + 0.5, (i / w) as f32 + 0.5, w, h);
- *pixel = if x >= 0.5 && y >= 0.5 && x <= w as f32 - 0.5 && y <= h as f32 - 0.5 { sensor.sample_bilinear(x, y) } else { [f32::NAN; 3] };
- }
- aligned
+/// The sensor proxy and its clip mask (same size), both moved to the camera JPEG's framing.
+fn align_cr3_framing(sensor: Rgb32f, clipped: Rgb32f, reference: &Rgb32f) -> (Rgb32f, Rgb32f) {
+ let Some(framing) = estimate_cr3_framing(&sensor, reference) else { return (sensor, clipped) };
+ let apply = |image: &Rgb32f| {
+ let (w, h) = (image.width, image.height);
+ let mut aligned = Rgb32f::new(w, h);
+ for (i, pixel) in aligned.data.iter_mut().enumerate() {
+ let (x, y) = framing.source((i % w) as f32 + 0.5, (i / w) as f32 + 0.5, w, h);
+ *pixel = if x >= 0.5 && y >= 0.5 && x <= w as f32 - 0.5 && y <= h as f32 - 0.5 { image.sample_bilinear(x, y) } else { [f32::NAN; 3] };
+ }
+ aligned
+ };
+ (apply(&sensor), apply(&clipped))
}
/// Colour training pairs of one raw for a camera profile: white-balanced camera RGB (with the
@@ -257,8 +401,10 @@ pub(crate) fn profile_pairs(raw: &RawImage, bytes: &[u8]) -> Option Option<(Mat3, Optio
}
fn sensor_proxy(raw: &RawImage, k: usize, edge: usize) -> Option {
- Some(match raw.develop_binned(k, 0.99).ok()? {
+ Some(match raw.develop_binned(k, SENSOR_CLIP).ok()? {
Some(sensor) => sensor,
None if raw.cpp == 3 && raw.cfa.is_none() => fit(&raw.develop(lightcraft_raw::Method::Bilinear).ok()?, edge, edge, Filter::Box),
None => return None,
@@ -397,42 +543,66 @@ fn fit_matrix(pairs: &[([f64; 3], [f64; 3])]) -> Option {
}
/// Neighbourhood luminance ratio above which a pixel counts as an edge for the second attempt
-/// of [`fit_pairs_with`]. Camera JPEGs are often lens-corrected (Sony "Distortion Comp.: Auto",
+/// of [`search_ordered`]. Camera JPEGs are often lens-corrected (Sony "Distortion Comp.: Auto",
/// compacts and kit zooms): on a 51 mm ILCE-7RM2 shot of a glass façade the JPEG is up to 15 px
/// of 1440 (about one proxy pixel) off the raw, and the fit failed the gate on the mismatched
/// window frames alone (held-out RMS 0.111; 0.059 for the same shot with distortion correction
/// off). Away from edges: 0.049. Issue #232.
const EDGE_CONTRAST: f32 = 3.0;
-/// The photo's look: its own matrix (with and without a hue/saturation table), or the given
-/// colour model (a camera profile's, in the sensor proxy's space), each completed with a tone
-/// and chroma curve fitted to this photo. When the fit on all pixels fails the acceptance gates,
-/// it is tried once more on the pixels away from edges (same gates): there colour pairs stay
-/// valid when the camera JPEG's geometry differs slightly from the raw's.
+/// The look on all pixels (the search of [`search_ordered`] with [`TONE_FITS`]), as before
+/// clipped pixels were left out.
+#[cfg(test)]
fn fit_pairs_with(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option)>) -> Option {
- fit_pairs_ordered(sensor, reference, colour, &[ToneFit::Quantile, ToneFit::ConditionalMedian])
+ fit_pairs_ordered(sensor, reference, colour, TONE_FITS)
}
-/// [`fit_pairs_with`] with the tone fits to try, in order: the first whose look passes the
-/// acceptance gates is used. Quantile matching comes first because it cannot produce flat steps;
-/// the conditional-median fit follows because on a few photos (compacts, early Micro Four Thirds)
-/// its slightly lower held-out error is what clears the gates, and a photo must not lose its look
-/// to the change of fit. The whole search (all pixels, then away from edges, with and without the
-/// camera profile's colour) runs with one tone fit before the next is tried, so the fallback
-/// accepts exactly what the previous releases accepted.
+/// The look from [`search_ordered`] without its attempt.
+#[cfg(test)]
fn fit_pairs_ordered(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option)>, order: &[ToneFit]) -> Option {
- let has_profile = colour.is_some();
+ search_ordered(sensor, reference, colour, order).map(|(look, _)| look)
+}
+
+/// The tone fits the search tries, in order: the first whose look passes the acceptance gates is
+/// used. Quantile matching comes first because it cannot produce flat steps; the
+/// conditional-median fit follows because on a few photos (compacts, early Micro Four Thirds) its
+/// slightly lower held-out error is what clears the gates, and a photo must not lose its look to
+/// the change of fit.
+const TONE_FITS: &[ToneFit] = &[ToneFit::Quantile, ToneFit::ConditionalMedian];
+
+/// One attempt of the search: tone fit, the camera profile's colour or the photo's own, all pixels
+/// or only those away from edges.
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+struct Attempt {
+ tone: ToneFit,
+ profile: bool,
+ away_from_edges: bool,
+}
+
+/// The photo's look and the attempt that produced it: its own matrix (with and without a
+/// hue/saturation table), or the given colour model (a camera profile's, in the sensor proxy's
+/// space), each completed with a tone and chroma curve fitted to this photo. Per tone fit in
+/// `order`: the camera profile's colour (when there is one), then the photo's own, as a different
+/// picture style can make a camera profile fail the gates; each fitted on all pixels and, when that
+/// fails the gates, once more on the pixels away from edges (same gates): there colour pairs stay
+/// valid when the camera JPEG's geometry differs slightly from the raw's (issue #232). The whole
+/// search runs with one tone fit before the next is tried, so the conditional-median fallback
+/// accepts exactly what the previous releases accepted.
+fn search_ordered(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option)>, order: &[ToneFit]) -> Option<(CameraLook, Attempt)> {
+ let profiles: &[bool] = if colour.is_some() { &[true, false] } else { &[false] };
order.iter().find_map(|&tone| {
- // A different picture style can make a camera profile fail the gates. Preserve the
- // photo's own colour fit before resorting to the neutral fallback.
- fit_candidate(sensor, reference, colour.clone(), tone).or_else(|| has_profile.then(|| fit_candidate(sensor, reference, None, tone)).flatten())
+ profiles.iter().find_map(|&profile| {
+ [false, true].into_iter().find_map(|away_from_edges| {
+ let attempt = Attempt { tone, profile, away_from_edges };
+ fit_attempt(sensor, reference, &colour, attempt).map(|look| (look, attempt))
+ })
+ })
})
}
-/// One candidate colour model, fitted on all pixels and, when that fails the gates, once more on
-/// the pixels away from edges (issue #232).
-fn fit_candidate(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option)>, tone: ToneFit) -> Option {
- fit_pairs_on(sensor, reference, colour.clone(), None, tone).or_else(|| fit_pairs_on(sensor, reference, colour, Some(EDGE_CONTRAST), tone))
+fn fit_attempt(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3, Option)>, attempt: Attempt) -> Option {
+ let colour = if attempt.profile { colour.clone() } else { None };
+ fit_pairs_on(sensor, reference, colour, attempt.away_from_edges.then_some(EDGE_CONTRAST), attempt.tone)
}
fn fit_pairs_on(
@@ -508,6 +678,106 @@ fn fit_pairs_on(
Some(look)
}
+/// Weight of the photo's own colour matrix in a [`fit_partial`] look; the rest is the identity (the
+/// neutral fallback's generic camera ≈ sRGB colour). On the CC0 archive's proxies, with the
+/// matrix at full weight 2 of the 23 colourful photos without a look came out more than 2° further
+/// from the camera's hues than the neutral fallback, at half weight none (median hue error 9.6° with
+/// the generic colour, which the tone and chroma curves make visible, 5.1° at half weight).
+const PARTIAL_MATRIX_WEIGHT: f64 = 0.5;
+
+/// Smallest rank correlation of scene and camera JPEG luminance over a [`fit_partial`] look's pairs:
+/// the JPEG must show the same picture. A look that only matches the brightness distribution of an
+/// unrelated picture can still beat the dark neutral fallback pixel for pixel. On the CC0 archive's
+/// proxies the accepted full looks have 0.55 or more, lens-corrected compacts without one 0.53–0.77,
+/// three old Coolpix files whose raw decodes wrongly 0.07–0.27.
+const PARTIAL_MIN_RANK_CORRELATION: f64 = 0.5;
+
+/// Spearman rank correlation of the scene's and the JPEG's luminance over `pairs`.
+fn rank_correlation(pairs: &[([f64; 3], [f64; 3])]) -> f64 {
+ let ranks = |values: Vec| {
+ let mut order: Vec = (0..values.len()).collect();
+ order.sort_by(|a, b| values[*a].total_cmp(&values[*b]));
+ let mut ranks = vec![0.0; values.len()];
+ for (rank, i) in order.into_iter().enumerate() {
+ ranks[i] = rank as f64;
+ }
+ ranks
+ };
+ let scene = ranks(pairs.iter().map(|(x, _)| luma(*x)).collect());
+ let jpeg = ranks(pairs.iter().map(|(_, y)| luma(*y)).collect());
+ let mean = (pairs.len() as f64 - 1.0) / 2.0;
+ let (covariance, variance) =
+ scene.iter().zip(&jpeg).fold((0.0, 0.0), |(c, v), (a, b)| (c + (a - mean) * (b - mean), v + (a - mean) * (a - mean)));
+ if variance > 0.0 { covariance / variance } else { 0.0 }
+}
+
+/// A partial look for a photo whose full look misses the acceptance gates (lens-corrected JPEGs of
+/// compacts, dull or nearly colourless scenes): the tone curve by quantile matching, which needs
+/// no pixel-exact registration and was the largest part of the neutral fallback's error (rendered
+/// 9–22 L* too dark), the chroma curve (the fallback kept about a third of the camera's
+/// colourfulness), and, when the scene has enough colour to learn one (the full look's condition),
+/// the photo's own colour matrix at [`PARTIAL_MATRIX_WEIGHT`]. No hue/saturation table. Tried on
+/// all pixels and then, like the full look, on the pixels away from edges; used only when it cuts
+/// the held-out squared error to below [`MIN_IMPROVEMENT`] of the neutral fallback's, in linear
+/// and in gamma-encoded display values, and when the JPEG shows the same picture
+/// ([`PARTIAL_MIN_RANK_CORRELATION`]). There is no limit on its own error: it only has to be clearly
+/// closer to the camera JPEG than what the photo would get otherwise.
+fn fit_partial(sensor: &Rgb32f, reference: &Rgb32f) -> Option {
+ [None, Some(EDGE_CONTRAST)].into_iter().find_map(|edge_limit| fit_partial_on(sensor, reference, edge_limit))
+}
+
+fn fit_partial_on(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option) -> Option {
+ // as for a known colour model: enough signal for the tone, but no monochrome reference
+ let (pairs, bright) = collect_pairs(sensor, reference, 0.005, edge_limit)?;
+ let related = rank_correlation(&pairs);
+ if related < PARTIAL_MIN_RANK_CORRELATION {
+ if lightcraft_pipeline::profiling() {
+ eprintln!("[profile] camera look partial: luminance rank correlation {related:.3}, not the same picture");
+ }
+ return None;
+ }
+ let spread = |y: &[f64; 3]| y.iter().copied().fold(f64::NEG_INFINITY, f64::max) - y.iter().copied().fold(f64::INFINITY, f64::min);
+ let colourful = pairs.iter().filter(|(_, y)| spread(y) > 0.05).count() >= pairs.len() / 20;
+ let matrix = match colourful.then(|| fit_matrix(&pairs)).flatten() {
+ Some(own) => Mat3(std::array::from_fn(|r| {
+ std::array::from_fn(|c| Mat3::IDENTITY.0[r][c] + PARTIAL_MATRIX_WEIGHT * (own.0[r][c] - Mat3::IDENTITY.0[r][c]))
+ })),
+ None => Mat3::IDENTITY,
+ };
+ let tone_pairs: Vec<_> =
+ pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(matrix.apply(*x).map(|v| v.max(0.0))), luma(*y))).collect();
+ let mut look = CameraLook { matrix, tone: fit_tone(tone_pairs)?, hue_sat: None };
+ if let Some(tone) = fit_chroma(&bright, &look) {
+ look.tone = tone;
+ }
+ let (tone, neutral) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0));
+ let encoded = |v: f64| v.max(0.0).powf(1.0 / 2.2);
+ let (mut before, mut after, mut before_encoded, mut after_encoded, mut samples) = (0.0, 0.0, 0.0, 0.0, 0);
+ for (x, target) in pairs.iter().step_by(3) {
+ let (fallback, partial) = (displayed(*x, &neutral), displayed(matrix.apply(*x), &tone));
+ for c in 0..3 {
+ before += (fallback[c] - target[c]).powi(2);
+ after += (partial[c] - target[c]).powi(2);
+ before_encoded += (encoded(fallback[c]) - encoded(target[c])).powi(2);
+ after_encoded += (encoded(partial[c]) - encoded(target[c])).powi(2);
+ samples += 1;
+ }
+ }
+ if lightcraft_pipeline::profiling() {
+ eprintln!(
+ "[profile] camera look partial (tone{}{}) holdout RMS {:.5} -> {:.5} ({samples} channels{})",
+ if look.tone.chroma().iter().any(|k| *k != 1.0) { ", chroma" } else { "" },
+ if matrix != Mat3::IDENTITY { ", damped matrix" } else { "" },
+ (before / samples.max(1) as f64).sqrt(),
+ (after / samples.max(1) as f64).sqrt(),
+ if edge_limit.is_some() { ", away from edges" } else { "" }
+ );
+ }
+ let better =
+ after.is_finite() && after_encoded.is_finite() && after < before * MIN_IMPROVEMENT && after_encoded < before_encoded * MIN_IMPROVEMENT;
+ (samples > 0 && better).then_some(look)
+}
+
/// Linear Rec.2020 D65 → linear ProPhoto RGB D50, the space DNG hue/saturation tables work in.
fn to_prophoto() -> Mat3 {
PROPHOTO.from_xyz().mul(&bradford(D65, D50)).mul(&REC2020.to_xyz())
@@ -813,6 +1083,23 @@ fn fit_tone_conditional(mut pairs: Vec<(f64, f64)>) -> Option {
mod tests {
use super::*;
+ #[test]
+ fn camera_jpeg_matches_the_sensor_proxy_size_exactly() {
+ // An ILCE-7RM4 ARW (9504×6336 crop) embeds a 1616×1080 preview; for the camera-profile proxy
+ // it is decoded at 384×257 (`proxies`: twice the proxy edge) next to a 192×128 sensor proxy.
+ let sensor = fit(&Rgb32f::new(9504, 6336), PROFILE_PROXY, PROFILE_PROXY, Filter::Box);
+ assert_eq!((sensor.width, sensor.height), (192, 128));
+ let jpeg = Rgb32f::new(384, 257);
+ // what fitting gave before: a pixel short, so every pair was rejected
+ assert_eq!(fit(&jpeg, sensor.width, sensor.height, Filter::Box).width, 191);
+ let r = reference_at(&jpeg, &sensor);
+ assert_eq!((r.width, r.height), (192, 128));
+ // and at the single-photo proxy size, where fitting happened to land on the same size
+ let sensor = fit(&Rgb32f::new(9504, 6336), PROXY, PROXY, Filter::Box);
+ let r = reference_at(&Rgb32f::new(192, 129), &sensor);
+ assert_eq!((r.width, r.height), (sensor.width, sensor.height));
+ }
+
#[test]
fn linear_arw_gets_a_sensor_proxy_without_demosaicing() {
use lightcraft_raw::{BlackLevel, ColorData, OpcodeLists, Orientation, RawData, RawFormat, Rect};
@@ -1115,6 +1402,7 @@ mod tests {
RawFormat::Cr2,
RawFormat::Pef,
RawFormat::Srw,
+ RawFormat::Orf,
];
assert!(local.into_iter().all(file_local_look));
// DNG carries its own colour model
@@ -1182,6 +1470,40 @@ mod tests {
assert_eq!(info.camera_tone.is_some(), look.is_some());
}
+ /// A public ILCE-7CR sample (skipped without the corpus) takes its colour from the bundled ILCE-7CR profile and
+ /// passes the acceptance gates with it: the fitted look keeps the profile's matrix and table, which the photo's
+ /// own fit (the fallback when a profile is rejected) would not.
+ #[test]
+ fn corpus_a7cr_uses_the_bundled_profile() {
+ let path = std::env::var_os("LIGHTCRAFT_CORPUS")
+ .map(std::path::PathBuf::from)
+ .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
+ .join("raw/arw-sony-a7cr-lossless-l.arw");
+ let bytes = match std::fs::read(&path) {
+ Ok(bytes) => bytes,
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
+ eprintln!("skip: {} absent", path.display());
+ return;
+ }
+ Err(e) => panic!("{}: {e}", path.display()),
+ };
+ let (_, json) = crate::camera_profiles::BUNDLED.iter().find(|(m, _)| *m == "ILCE-7CR").expect("ILCE-7CR is built in");
+ let bundled: crate::camera_profiles::CameraProfile = serde_json::from_str(json).unwrap();
+ let used = crate::camera_profiles::get("ILCE-7CR").expect("ILCE-7CR profile");
+ assert_eq!(*used, bundled, "a local ILCE-7CR profile overrides the built-in one; remove it to run this test");
+ let raw = lightcraft_raw::decode(&bytes).unwrap();
+ assert_eq!(raw.metadata.model.as_deref(), Some("ILCE-7CR"));
+ let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
+ let look = fit_preview(&raw, &bytes, &transform).expect("fit accepted");
+ let expected = bundled.matrix().mul(&transform.matrix.inverse().unwrap());
+ for (row, want) in look.matrix.0.iter().zip(expected.0) {
+ for (got, want) in row.iter().zip(want) {
+ assert!((got - want).abs() < 1e-12, "the look's matrix is not the profile's: {:?} vs {:?}", look.matrix.0, expected.0);
+ }
+ }
+ assert_eq!(look.hue_sat, bundled.hue_sat, "the look's table is not the profile's");
+ }
+
/// A public D7500 NEF (skipped without the corpus): its look is fitted to its own JPEG and white
/// balance is relative to the as-shot look.
#[test]
@@ -1199,6 +1521,30 @@ mod tests {
assert!(info.relative_wb && info.as_shot_temp == 6500.0 && info.as_shot_tint == 0.0);
}
+ /// The public ILCE-7CR samples (61 MP; skipped without the corpus): lossless compressed L and M and compressed
+ /// ARW2 all get a look fitted to their own JPEG, not the neutral fallback.
+ #[test]
+ fn corpus_a7cr_codings_get_a_camera_look() {
+ let dir = std::env::var_os("LIGHTCRAFT_CORPUS")
+ .map(std::path::PathBuf::from)
+ .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
+ .join("raw");
+ for name in ["arw-sony-a7cr-lossless-l.arw", "arw-sony-a7cr-lossless-m.arw", "arw-sony-a7cr-compressed.arw"] {
+ let bytes = match std::fs::read(dir.join(name)) {
+ Ok(bytes) => bytes,
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
+ eprintln!("skip: {name} absent");
+ continue;
+ }
+ Err(e) => panic!("{name}: {e}"),
+ };
+ let (img, info) = crate::files::load_bytes(&bytes, 400).unwrap();
+ assert_eq!((img.width, img.height), (400, 267), "{name}: framed in the default crop");
+ assert!(info.camera_tone.is_some(), "{name}: no camera look fitted");
+ assert!(info.relative_wb && info.as_shot_temp == 6500.0 && info.as_shot_tint == 0.0, "{name}");
+ }
+ }
+
/// A public DC-FZ1000 II RW2 shot at 4:3 on its 3:2 sensor (skipped without the corpus): the default crop is 4:3
/// while the embedded JPEG shows the whole sensor; the look is still fitted, against the matching part of it.
#[test]
@@ -1234,6 +1580,185 @@ mod tests {
assert!(info.camera_tone.is_some(), "no camera look fitted");
}
+ /// A look with tone curve `f` at knots `xs` and a colour model and chroma curve to keep.
+ fn dro_test_look(xs: &[f32; 32], f: &dyn Fn(f32) -> f32) -> CameraLook {
+ CameraLook {
+ matrix: Mat3([[1.6, -0.4, -0.2], [-0.2, 1.4, -0.2], [0.0, -0.3, 1.3]]),
+ tone: CameraTone::new(xs.map(|x| [x, f(x)])).unwrap().with_chroma([1.3, 1.2, 1.1, 1.0, 1.0, 0.9, 0.6, 0.3]).unwrap(),
+ hue_sat: Some(HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[5.0, 1.1, 1.0]; 8], srgb_value: false }),
+ }
+ }
+
+ /// `fixed` (from `fitted`) is `min(fitted, Sony's DRO-off curve)` at every knot but the
+ /// second-last, which may stay higher (at most the fitted curve) to keep the shoulder past the
+ /// last knot below the fitted one; nowhere brighter than `fitted`, colour unchanged.
+ fn assert_lowered(fitted: &CameraLook, fixed: &CameraLook, what: &str) {
+ let dro_off = sony_dro_off_tone().unwrap();
+ assert_eq!((fixed.matrix, &fixed.hue_sat, fixed.tone.chroma()), (fitted.matrix, &fitted.hue_sat, fitted.tone.chroma()), "{what}");
+ for (i, (k, own)) in fixed.tone.knots().iter().zip(fitted.tone.knots()).enumerate() {
+ let lowered = own[1].min(dro_off.apply(own[0]));
+ assert_eq!(k[0], own[0], "{what}: knot {i}");
+ if i == 30 {
+ assert!(k[1] >= lowered && k[1] <= own[1], "{what}: knot {i} {} not in {lowered}..={}", k[1], own[1]);
+ } else {
+ assert!((k[1] - lowered).abs() < 1e-6, "{what}: knot {i} {} vs {lowered}", k[1]);
+ }
+ }
+ assert!(at_most(&fixed.tone, &fitted.tone), "{what}: brighter than the fitted curve somewhere");
+ // the same check spelled out, from deep shadows to far past the last knot (the shoulder)
+ for i in 0..=2400 {
+ let x = 0.001 * 1.005f32.powi(i);
+ assert!(fixed.tone.apply(x) <= fitted.tone.apply(x) + 1e-5, "{what} at {x}: {} > {}", fixed.tone.apply(x), fitted.tone.apply(x));
+ }
+ }
+
+ /// Issue #244: Sony's Dynamic Range Optimizer lifts the camera JPEG's shadows, not the raw's. A
+ /// look fitted to such a JPEG keeps its colour (matrix, hue/saturation table, chroma curve) and
+ /// its curve is lowered to Sony's without DRO wherever that is darker, never raised.
+ #[test]
+ fn a_dro_look_keeps_its_colour_and_is_lowered_to_the_dro_off_tone() {
+ let dro_off = sony_dro_off_tone().expect("the built-in curve is valid");
+ // the curve itself: black stays black, increasing, mid-grey where cameras put it (L* 50–75)
+ assert_eq!(dro_off.apply(0.0), 0.0);
+ let mut previous = 0.0;
+ for i in 1..200 {
+ let y = dro_off.apply(i as f32 * 0.005);
+ assert!(y > previous && y < 1.0, "{i}: {y}");
+ previous = y;
+ }
+ assert!((0.18..0.48).contains(&dro_off.apply(0.18)), "{}", dro_off.apply(0.18));
+ let xs: [f32; 32] = std::array::from_fn(|i| 0.006 * 1.13f32.powi(i as i32));
+ // a JPEG with lifted shadows: the curve comes down to Sony's, colour and chroma curve stay
+ let lifted = dro_test_look(&xs, &|x| dro_off.apply(x).powf(0.8));
+ let fixed = with_dro_off_tone(lifted.clone());
+ assert_lowered(&lifted, &fixed, "lifted");
+ assert!(fixed.tone.apply(0.02) < 0.85 * lifted.tone.apply(0.02));
+ // a camera that maps raw values darker than Sony's curve keeps its own curve exactly
+ let darker = dro_test_look(&xs, &|x| 0.7 * dro_off.apply(x));
+ assert_eq!(with_dro_off_tone(darker.clone()).tone, darker.tone);
+ // crossing curves: the lower of the two at the knots, still a valid increasing curve
+ let crossing = dro_test_look(&xs, &|x| (dro_off.apply(x) * (0.6 + x)).min(0.95));
+ assert_lowered(&crossing, &with_dro_off_tone(crossing.clone()), "crossing");
+ }
+
+ /// Past its last knot a camera curve continues as a shoulder whose rate comes from its last two
+ /// knots. Lowering the second-last knot more than the last steepened that shoulder: the curve
+ /// fitted to the CC0 NEX-5T sample (raw.pixls.us, DRO Auto) went from 0.740 to 0.843 at scene
+ /// luminance 0.63, brighter than before. The lowered curve must stay at or below the fitted one
+ /// through the shoulder, on that curve and on many others.
+ #[test]
+ fn the_lowered_curve_stays_below_the_fitted_one_past_its_last_knot() {
+ let nex5t: [[f32; 2]; 32] = [
+ [0.014948029, 0.017424058],
+ [0.018395446, 0.023981506],
+ [0.021539066, 0.030581286],
+ [0.024555191, 0.0374646],
+ [0.027602654, 0.044408206],
+ [0.030193308, 0.049894042],
+ [0.03261828, 0.055907387],
+ [0.034544908, 0.06156683],
+ [0.03669959, 0.06814718],
+ [0.039161213, 0.075263545],
+ [0.04173663, 0.0830011],
+ [0.044166446, 0.088166036],
+ [0.04682434, 0.095398724],
+ [0.049544845, 0.1027153],
+ [0.05235826, 0.11173598],
+ [0.05497513, 0.119017586],
+ [0.057503678, 0.12616614],
+ [0.059836045, 0.13521175],
+ [0.062677935, 0.14601946],
+ [0.06599234, 0.15573128],
+ [0.069809504, 0.16883339],
+ [0.0755404, 0.1854325],
+ [0.08250754, 0.20684941],
+ [0.089204684, 0.23466235],
+ [0.09650368, 0.25805998],
+ [0.102161564, 0.27849936],
+ [0.107812546, 0.29706743],
+ [0.11410169, 0.31379074],
+ [0.12000221, 0.33262977],
+ [0.12776275, 0.35407156],
+ [0.1387929, 0.38362882],
+ [0.19492775, 0.43935108],
+ ];
+ let fitted = CameraLook { tone: CameraTone::new(nex5t).unwrap(), ..dro_test_look(&std::array::from_fn(|i| 0.01 + i as f32 * 0.01), &|x| x) };
+ let fixed = with_dro_off_tone(fitted.clone());
+ assert_lowered(&fitted, &fixed, "NEX-5T");
+ assert!((fitted.tone.apply(0.63) - 0.7405).abs() < 1e-3 && fixed.tone.apply(0.63) <= fitted.tone.apply(0.63));
+ assert_ne!(fixed.tone, fitted.tone, "the lifted mid-tones still come down");
+ // curves that end at all kinds of scene luminances, slopes and heights relative to Sony's
+ let dro_off = sony_dro_off_tone().unwrap();
+ let mut seed = 0x9e37_79b9_u32;
+ let mut next = move || {
+ seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
+ f32::from((seed >> 16) as u16) / 65536.0
+ };
+ for case in 0..300 {
+ let (start, span) = (0.002 + 0.03 * next(), 1.05 + 0.15 * next());
+ let xs: [f32; 32] = std::array::from_fn(|i| start * span.powi(i as i32));
+ let (lift, gain, top) = (0.5 + 0.6 * next(), 0.6 + 0.8 * next(), next());
+ // brighter or darker than Sony's, with a last knot sometimes lowered less than the one before
+ let f = |x: f32| (gain * dro_off.apply(x).powf(lift) + top * 0.2 * (x / xs[31]).powi(8)).min(0.995);
+ let mut ys = xs.map(f);
+ for i in 1..32 {
+ ys[i] = ys[i].max(ys[i - 1]);
+ }
+ let Some(tone) = CameraTone::new(std::array::from_fn(|i| [xs[i], ys[i]])) else { continue };
+ let fitted = CameraLook { tone, ..dro_test_look(&std::array::from_fn(|i| 0.01 + i as f32 * 0.01), &|x| x) };
+ assert_lowered(&fitted, &with_dro_off_tone(fitted.clone()), &format!("case {case}"));
+ }
+ }
+
+ /// Issue #244 (skipped without the corpus): the public ILCE-7RM2 sample (Standard creative
+ /// style) was shot with DRO Auto, so its curve comes down to Sony's without DRO in the shadows the
+ /// camera lifted; the ILCE-7M4 sample (DRO Auto, Vivid) has deeper shadows than that curve and
+ /// keeps them, only its brighter highlights come down. Both keep the colour fitted to their
+ /// JPEGs. The ILCE-7M3 sample (DRO off) keeps its own fit.
+ #[test]
+ fn corpus_arw_with_dro_is_lowered_to_the_dro_off_tone() {
+ let dir = std::env::var_os("LIGHTCRAFT_CORPUS")
+ .map(std::path::PathBuf::from)
+ .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
+ .join("raw");
+ let samples = [
+ ("arw-sony-a7rm2-12bit-uncompressed.arw", true, [0.01, 0.05, 0.1]),
+ ("arw-sony-a7m4-14bit.arw", true, [0.3, 0.4, 0.5]),
+ ("arw-sony-a7m3-compressed.arw", false, [0.0; 3]),
+ ];
+ for (name, dro, lowered) in samples {
+ let path = dir.join(name);
+ let bytes = match std::fs::read(&path) {
+ Ok(bytes) => bytes,
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
+ eprintln!("skip: {} absent", path.display());
+ continue;
+ }
+ Err(e) => panic!("{}: {e}", path.display()),
+ };
+ assert_eq!(lightcraft_raw::embedded_preview_dynamic_range_optimized(&bytes), Some(dro), "{name}");
+ let mut raw = lightcraft_raw::decode(&bytes).unwrap();
+ raw.opcodes.list3.retain(|op| !op.is_lens_correction());
+ let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
+ let look = fit_preview(&raw, &bytes, &transform).expect("a camera look");
+ // the look fitted to the JPEG, as before this change
+ let (sensor, reference, clipped) = proxies(&raw, &bytes, &transform, PROXY).unwrap();
+ let profile = raw.metadata.model.as_deref().and_then(crate::camera_profiles::get);
+ let colour = profile.as_ref().and_then(|p| Some((p.matrix().mul(&transform.matrix.inverse()?), p.hue_sat.clone())));
+ let fitted = fit_look(&sensor, &reference, &clipped, colour).unwrap();
+ if dro {
+ assert_lowered(&fitted, &look, name);
+ for x in lowered {
+ assert!(look.tone.apply(x) < 0.95 * fitted.tone.apply(x), "{name} at {x}: {} vs {}", look.tone.apply(x), fitted.tone.apply(x));
+ }
+ } else {
+ assert_eq!((look.matrix, &look.hue_sat, look.tone), (fitted.matrix, &fitted.hue_sat, fitted.tone), "{name}");
+ }
+ let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
+ assert_eq!(info.camera_tone, Some(look.tone), "{name}");
+ }
+ }
+
/// 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.
@@ -1374,6 +1899,110 @@ mod tests {
(sensor, reference)
}
+ /// A camera rendering with constant colourfulness (no highlight bleaching) of a colourful scene
+ /// under a bright neutral sky, whose sensor pixels are clipped: one channel held at the clip
+ /// level turns the white sky magenta in the raw while the camera JPEG keeps it white. Returns
+ /// the sensor proxy, the JPEG proxy and the clip mask.
+ fn clipped_sky_scene() -> (Rgb32f, Rgb32f, Vec) {
+ let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
+ let (w, h) = (96usize, 64usize);
+ let mut sensor = Rgb32f::new(w, h);
+ let mut reference = sensor.clone();
+ let mut clipped = vec![false; w * h];
+ let mut seed = 0x2545_f491_u32;
+ let mut next = move || {
+ seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
+ f32::from((seed >> 16) as u16) / 65536.0
+ };
+ let camera = |p: [f32; 3]| {
+ let y = luminance_2020(p);
+ let o = 1.0 - (-2.5 * y).exp();
+ p.map(|v| (v * o / y).clamp(0.0, 0.97))
+ };
+ // the sky: a quarter of the frame, white in the scene and in the JPEG
+ let neutral = known.inverse().unwrap().apply_f32([0.32; 3]);
+ for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
+ let scene = if i < w * h / 4 {
+ clipped[i] = true;
+ let sky = neutral.map(|v| v * (0.8 + 0.4 * next()));
+ *dst = camera(known.apply_f32(sky));
+ // the green sample clips, red and blue (after white balance) go on rising
+ *src = [sky[0] * 1.35, sky[1] * 0.85, sky[2] * 1.4];
+ continue;
+ } else {
+ // colourful foliage, leaves, bark and bright flowers from dark to light
+ let ev = 0.02 + next() * 0.4;
+ [ev * (0.6 + next() * 0.8), ev, ev * (0.5 + next() * 0.8)]
+ };
+ *src = scene;
+ *dst = camera(known.apply_f32(scene));
+ }
+ (sensor, reference, clipped)
+ }
+
+ /// Mean colourfulness (distance from neutral of luminance-normalised RGB) of the unclipped
+ /// pixels rendered with `look`, over that of the camera JPEG, per display-luminance band.
+ fn colourfulness_ratio(look: &CameraLook, sensor: &Rgb32f, reference: &Rgb32f, clipped: &[bool], band: std::ops::Range) -> f64 {
+ let chroma = |p: [f64; 3]| {
+ let y = luma(p);
+ p.iter().map(|v| (v / y - 1.0).powi(2)).sum::