mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 11:17:05 -04:00
M11.4: merge main; spectral matrices only replace the neutral fallback
Merge origin/main into #560 (fit_preview / camera-look refactors of #528, #583, #568, #616). `starting_colour` now tries the per-file camera-JPEG fit (with the camera profile when there is one) first and uses the spectral-sensitivity matrices only when that fit fails, before the neutral fallback, so no photo with an accepted JPEG fit changes. Tests pin the order through an injectable fit; docs, parity, ROADMAP and what's new describe it. RENDER_CACHE_VERSION 37 -> 38. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
9f73f086c9
237 files changed
+35345
-1819
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 '<Icon Id="[^"]*"' packaging/windows/lightcraft.wxs | grep -vE '\.(ico|exe)"$'; then
|
||||
echo "::error::Icon Id must end in .ico or .exe"; exit 1
|
||||
fi
|
||||
# The interactive installer asks where to install and ends on a completion page (issues #18, #399).
|
||||
for want in '<UIRef Id="LightcraftUI" />' 'Value="InstallDirDlg"' '<Property Id="WIXUI_INSTALLDIR" Value="INSTALLFOLDER" />' \
|
||||
'Key="Software\LightCraft" Name="InstallFolder"' 'Value="LaunchLightcraft"'; do
|
||||
grep -qF "$want" packaging/windows/lightcraft.wxs || { echo "::error::lightcraft.wxs lost: $want"; exit 1; }
|
||||
done
|
||||
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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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-<name>`; each agent uses its **own control port** (pick one in 18000–19999, never the default 7980) and its own scratch subfolder (`<scratch>/<agent-name>/`) — 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.
|
||||
|
||||
Generated
+34
-28
@@ -2976,7 +2976,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -2994,10 +2994,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-catalog"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"lightcraft-develop",
|
||||
"lightcraft-geom",
|
||||
"lightcraft-meta",
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -3009,7 +3010,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-cli"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"dhat",
|
||||
"lightcraft-codecs",
|
||||
@@ -3024,8 +3025,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-codecs"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"avif-serialize",
|
||||
"bytemuck",
|
||||
"image",
|
||||
"image-webp",
|
||||
@@ -3039,6 +3041,7 @@ dependencies = [
|
||||
"moxcms 0.9.1",
|
||||
"png",
|
||||
"proptest",
|
||||
"rav1e",
|
||||
"ravif",
|
||||
"rayon",
|
||||
"serde",
|
||||
@@ -3050,7 +3053,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-color"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"lightcraft-geom",
|
||||
"proptest",
|
||||
@@ -3059,7 +3062,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-denoise"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"faer-core",
|
||||
"lightcraft-denoise-core",
|
||||
@@ -3076,7 +3079,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-denoise-core"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"lightcraft-raster",
|
||||
"miniz_oxide 0.8.9",
|
||||
@@ -3091,7 +3094,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-develop"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"lightcraft-color",
|
||||
"lightcraft-geom",
|
||||
@@ -3103,7 +3106,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-engine"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"harfrust",
|
||||
@@ -3118,6 +3121,7 @@ dependencies = [
|
||||
"lightcraft-fetch",
|
||||
"lightcraft-geom",
|
||||
"lightcraft-gpu",
|
||||
"lightcraft-heif",
|
||||
"lightcraft-merge",
|
||||
"lightcraft-meta",
|
||||
"lightcraft-pipeline",
|
||||
@@ -3139,7 +3143,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-faces"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"matrixmultiply",
|
||||
"ndarray",
|
||||
@@ -3153,7 +3157,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-fetch"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"rustls",
|
||||
@@ -3164,7 +3168,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-geom"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -3172,7 +3176,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-gpu"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"lightcraft-color",
|
||||
@@ -3181,6 +3185,7 @@ dependencies = [
|
||||
"lightcraft-geom",
|
||||
"lightcraft-pipeline",
|
||||
"lightcraft-raster",
|
||||
"lightcraft-raw",
|
||||
"lightcraft-scenes",
|
||||
"log",
|
||||
"pollster 1.0.1",
|
||||
@@ -3189,14 +3194,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-heif"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"heic-rs",
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-mcp"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"lightcraft-codecs",
|
||||
"lightcraft-engine",
|
||||
@@ -3207,7 +3213,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-merge"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"lightcraft-codecs",
|
||||
"lightcraft-color",
|
||||
@@ -3223,7 +3229,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-meta"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"lightcraft-geom",
|
||||
"lightcraft-tiff",
|
||||
@@ -3235,7 +3241,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-pipeline"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"lightcraft-color",
|
||||
@@ -3252,7 +3258,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-preview"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"jpeg-encoder",
|
||||
"lightcraft-raster",
|
||||
@@ -3264,7 +3270,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-raster"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"lightcraft-color",
|
||||
@@ -3276,7 +3282,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-raw"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"jpeg-encoder",
|
||||
"jxl-oxide",
|
||||
@@ -3298,7 +3304,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-scenes"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"lightcraft-color",
|
||||
@@ -3309,7 +3315,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-segment"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"candle-core",
|
||||
"candle-nn",
|
||||
@@ -3322,11 +3328,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-sysmem"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-tiff"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -3335,7 +3341,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-ui-egui"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"lightcraft-catalog",
|
||||
@@ -3359,7 +3365,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-web"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"eframe",
|
||||
@@ -6819,7 +6825,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"flate2",
|
||||
|
||||
+4
-1
@@ -3,7 +3,7 @@ resolver = "3"
|
||||
members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
@@ -68,6 +68,9 @@ moxcms = { version = "0.9", default-features = false, features = ["lut"] }
|
||||
jxl-oxide = { version = "0.12", default-features = false }
|
||||
zune-jpegxl = { version = "0.5", default-features = false, features = ["std"] } # MIT/Apache-2.0/Zlib lossless JXL encoder (test fixtures only)
|
||||
ravif = { version = "0.13", default-features = false, features = ["threading"] } # no "asm": it needs nasm (non-Rust)
|
||||
# HDR AVIF (PQ): ravif fixes the sRGB transfer curve, so HDR files drive its rav1e + avif-serialize directly
|
||||
rav1e = { version = "0.8.1", default-features = false, features = ["threading"] }
|
||||
avif-serialize = "0.8.6"
|
||||
|
||||
# gpu (crates/gpu; native backends only, no GL: everything stays pure Rust)
|
||||
# SAM 3 inference (lightcraft-segment). 0.9.2: later releases pull `tokenizers` with the
|
||||
|
||||
@@ -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 🚧 |
|
||||
|
||||
<sub>✅ works today · 🚧 in progress · ⬜ not started</sub>
|
||||
@@ -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-<ver>-windows-arm64.msi` | `lightcraft-<ver>-windows-arm64-portable.zip` |
|
||||
| x86 (32-bit) | `lightcraft-<ver>-windows-x86.msi` | `lightcraft-<ver>-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-<ver>-windows-x64.msi /qn INSTALLFOLDER="D:\Apps\LightCraft\"`.
|
||||
|
||||
### macOS
|
||||
|
||||
|
||||
+25
-20
@@ -23,13 +23,13 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
|
||||
| Dimension | Estimate | What's true today | Biggest gaps |
|
||||
|---|---:|---|---|
|
||||
| **Feature checklist** | 79.7% | P0 core and P1 nearly complete: import (Add / Copy / Move, templates, devices), library, grid/loupe/compare/survey, every Edit slider, curves, colour grading, masking tools, crop/Upright, heal/clone, presets/profiles, versions/history, sync, export, menus, shortcuts | P0: full-resolution zoom detail; P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim |
|
||||
| **RAW coverage** (formats people shoot) | ~55% | DNG (all kinds), CR2, CR3 lossless Bayer and version 0x100/0x200 C-RAW (M50/R100/R8 pixel-exact corpus), ARW, NEF (uncompressed + Huffman lossless/lossy), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans), uncompressed ORF, uncompressed Samsung SRW (NX5 to NX1100, EX1, WB2000), RW2 / RWL / Panasonic RAW (every raw format, checked on 178 files from 118 Panasonic and Leica bodies), PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini), Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Per-model verification is thin (~55 corpus files vs >1,000 models) |
|
||||
| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF, RW2, RAF, CR3, CR2 and PEF have guarded camera-JPEG colour estimates, with bundled ILCE-7M4, X-H2S and X-T4 profiles (docs/camera-preview-colour.md); 52 models start from matrices fitted to measured spectral sensitivities; other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
|
||||
| **RAW coverage** (formats people shoot) | ~55% | DNG (all kinds), CR2, CR3 lossless Bayer and version 0x100/0x200 C-RAW (M50/R100/R8 pixel-exact corpus), ARW, NEF (uncompressed + Huffman lossless/lossy), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans), uncompressed ORF, uncompressed Samsung SRW (NX5 to NX1100, EX1, WB2000), RW2 / RWL / Panasonic RAW (every raw format, checked on 178 files from 118 Panasonic and Leica bodies), PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini), Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Nikon High Efficiency NEF (HE / HE★: Z 8, Z 9, Z 6III, Z f; JPEG XS-based, preview only, #193). Per-model verification is thin (~55 corpus files vs >1,000 models) |
|
||||
| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF, RW2, RAF, CR3, CR2 and PEF have guarded camera-JPEG colour estimates, with bundled ILCE-7CR, ILCE-7M4, X-H2S and X-T4 profiles (docs/camera-preview-colour.md); 52 models whose JPEG fit is rejected start from matrices fitted to measured spectral sensitivities; other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
|
||||
| **AI & computational** | ~15–20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics, or the photo's own segmentation mattes when it ships them (DNG semantic masks, e.g. iPhone ProRAW's sky matte); non-destructive Bayer RAW denoise with opt-in weights on pure-Rust CPU/GPU; **faces (MVP, [docs/faces.md](docs/faces.md))**: a pure-Rust detector (a 232 KB opt-in download), opt-in recognition models (one-click download or add a file; they run on the shared pure-Rust CPU interpreter; pinned SFace/AuraFace embeddings agree with an independent reference runtime on 40 public crops; one-thread medians 34–36 / 233–252 ms (AuraFace remains expensive)), a background scan that finds and embeds faces, name suggestions, a person's page with look-alike faces to confirm, an Unnamed faces section (look-alikes together, select a group and name it at once) | Real segmentation masks (subject, sky, people, objects, landscape, depth), denoise model policy / linear RGB / quality verification, super resolution, lens blur, generative remove, natural-language search; for faces: clusters with separators between groups, small faces in large photos (the detector looks at 640 px), thresholds calibrated on live faces, writing face regions back to XMP. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) |
|
||||
| **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering |
|
||||
| **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic) | **Map view, Book, Print, Slideshow module, Web, publish services**: ~40 tracker rows ⬜ |
|
||||
| **HDR & video** | 0% | | HDR edit/display/export; video play/trim/edit/export |
|
||||
| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; English, Simplified/Traditional Chinese, Japanese, Brazilian Portuguese and German UI (see docs/localization.md); remaining technical errors; Traditional Chinese uses the Simplified Chinese font until craft-fonts has a TC face; accessibility partial; headless UI tests time out under machine load |
|
||||
| **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic), paginated contact-sheet PDF export (A4/Letter, layout/captions, current edits, 150 dpi sRGB) | **Map view, Book, full Print module, Slideshow module, Web, publish services**: contact sheets work, custom print packages and printer colour management remain missing |
|
||||
| **HDR & video** | ~25% | HDR editing (headroom limit, SDR rendition, Visualize HDR); gain map JPEG, PQ AVIF and float TIFF export | On-screen HDR display, HLG, HDR settings from crs XMP; video play/trim/edit/export |
|
||||
| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; English, Simplified/Traditional Chinese, Japanese, Brazilian Portuguese, Spanish, German, Russian and Ukrainian UI (see docs/localization.md); remaining technical errors; Traditional Chinese uses the Simplified Chinese font until craft-fonts has a TC face; accessibility partial; headless UI tests time out under machine load |
|
||||
|
||||
### By kind of user
|
||||
|
||||
@@ -48,9 +48,9 @@ Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/par
|
||||
|
||||
1. **Camera colour calibration of our own** (LR-PROF-CAMERACOLOR, P0): fit each camera to its own embedded JPEG, use
|
||||
matrices the files carry themselves, then chart shots. Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2,
|
||||
Pentax PEF and Samsung SRW have the file-local fit (matrix + tone curve from their own JPEG; CR2 and PEF since issue #310, not yet
|
||||
checked on the corpus); 52 models start from matrices fitted to measured spectral sensitivities (rawtoaces-data);
|
||||
then ORF, then validate fidelity.
|
||||
Pentax PEF, Samsung SRW and Olympus ORF have the file-local fit (matrix + tone curve from their own JPEG; CR2 and PEF
|
||||
since issue #310, not yet checked on the corpus); 52 models whose fit is rejected fall back to matrices fitted to
|
||||
measured spectral sensitivities (rawtoaces-data); then validate fidelity.
|
||||
2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): Remaining **unverified CR3 CRX** variants, compressed ORF, NEF
|
||||
lossy-after-split, sRAW. Decided 2026-10-05: write our own decoders from prose descriptions (never decoder source,
|
||||
no LGPL dependency); compressed NEF (#86) is the template.
|
||||
@@ -63,13 +63,18 @@ Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/par
|
||||
segmentation masks and denoise. Bayer denoise inference is implemented in pure Rust with an opt-in GPL model; release policy and independently licensed weights remain open. Unblocks M12 and Enhance. Face detection and recognition run on our own checked Rust engine. Object / Describe masks now
|
||||
run Meta's SAM 3 in pure Rust (optional, user-consented download under the non-OSI SAM License; CDN mirrors still
|
||||
to be set up, docs/ai-masks.md); Subject / Sky / People, Raw Details and Super Resolution are still open.
|
||||
7. **Then:** HDR (Q), the Classic output modules (Print first, then Map view, Book, Slideshow), video (R), localisation
|
||||
7. **Then:** HDR (Q), the Classic output modules (expand contact-sheet PDF export into full Print, then Map view, Book, Slideshow), video (R), localisation
|
||||
and accessibility.
|
||||
|
||||
Already closed in the week of 2026-10-03: compressed NEF (#10), Move import (#29), per-library smart-preview folder,
|
||||
Local roots / cleanup, grid and catalog performance at 85k photos (#35, #37), failed-save reporting, GPU export
|
||||
hardening (#78), copyright metadata (#51), GPX geotagging (#60), import tag help and folder templates (#31, #32).
|
||||
|
||||
Library folders now have records of their own in the catalog (`Catalog.folder_records`, keyed by folder path; colour
|
||||
labels first). Photos still store full paths, so moving a whole disk or folder relinks each photo. If that becomes a
|
||||
problem (an "Update Folder Location" for a renamed or replaced drive, journal growth on large moves), the next step is
|
||||
the Classic model: photos point to a folder id (root + relative path), and the records keyed by path become those rows.
|
||||
|
||||
Image navigation now supports native pinch zoom and two-finger pan in Detail, Compare and Reference views.
|
||||
The whole-image preview still has a configurable size cap (2,560 px default), so 100% on larger photos does not
|
||||
yet guarantee original-pixel detail; visible-region rendering remains a gap (LR-VIEW-ZOOM).
|
||||
@@ -90,13 +95,13 @@ yet guarantee original-pixel detail; visible-region rendering remains a gap (LR-
|
||||
| M7 | Detail | sharpening + masking preview, luminance/colour NR, Denoise, Raw Details, Super Resolution | 6–10 | 🚧 (sharpening, luminance/colour NR ✅; AI Denoise 🟡 (Bayer, optional weights; policy/fidelity open); Raw Details, Super Resolution ⬜) |
|
||||
| M8 | Heal / Remove | content-aware remove (PatchMatch), heal, clone, brush spots, visualize spots, red/pet eye | 6–10 | 🚧 (heal, clone, auto source, visualize spots, red/pet eye ✅; PatchMatch remove ⬜) |
|
||||
| M9 | Presets, profiles, versions, sync | preset browser + amount, create/import presets, profile browser, versions, history, copy/paste/sync settings | 5–8 | ✅ |
|
||||
| M10 | Export & share | export dialog (JPEG/PNG/TIFF/DNG/AVIF/JXL/original), sizing, sharpening, metadata, watermark, naming, batch jobs, XMP sidecars, HDR export | 6–10 | 🚧 (all formats incl. DNG/original, sizing, presets, background jobs ✅; JXL encode, HDR export ⬜) |
|
||||
| M10 | Export & share | export dialog (JPEG/PNG/TIFF/DNG/AVIF/JXL/original), sizing, sharpening, metadata, watermark, naming, batch jobs, XMP sidecars, HDR export | 6–10 | 🚧 (all formats incl. DNG/original, sizing, presets, background jobs, HDR gain map JPEG / PQ AVIF ✅; JXL encode ⬜) |
|
||||
| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (CR3 lossless CRX and C-RAW, RAF uncompressed and lossless/lossy compressed, RW2 (every raw format), PEF, ORF uncompressed ✅; **camera colour calibration** 🚧 (guarded ARW, NEF, RW2 and RAF preview fitting; measured database still missing), CR3, compressed ORF ⬜) |
|
||||
| M12 | AI & smart features | subject/sky/background/people/object masks, semantic search, faces/People (permissively licensed models, pure-Rust inference) | 20–40 | ⬜ |
|
||||
| M13 | Merge | HDR merge (deghost), panorama (projections, boundary warp, fill edges), HDR panorama | 10–15 | ✅ |
|
||||
| M14 | Video | import/playback/trim via FilmCraft crates, global edits + presets on video, video export | 6–10 | ⬜ |
|
||||
| M15 | Classic modules | Map, Book, Slideshow, Print, Web; smart collections, stacks, virtual copies, publish services, tethering | 25–40 | 🚧 (smart albums, stacks, virtual copies, compare/survey ✅; Map/Book/Slideshow/Print/Web ⬜) |
|
||||
| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; English/Chinese (Simplified, Traditional)/Japanese/Brazilian Portuguese/German localisation 🟡; accessibility and further locales ⬜) |
|
||||
| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; English/Chinese (Simplified, Traditional)/Japanese/Brazilian Portuguese/Spanish/German/Russian/Ukrainian localisation 🟡; accessibility and further locales ⬜) |
|
||||
|
||||
## Parity estimate (feature count updated 2026-10-08; effort estimate from 2026-10-02)
|
||||
|
||||
@@ -165,7 +170,8 @@ The milestone estimates in the table above were made before work started and are
|
||||
published alongside GPL code); decoder source is never read. Still open: freedom-to-operate review for local
|
||||
Laplacian filters, PatchMatch and HEVC (HEIC).
|
||||
- **Look parity** with Adobe's default rendering is tuned by eye today; the planned fidelity suite (LR-BEHAV-RENDER-FIDELITY)
|
||||
turns it into measured comparisons against local-only Lightroom references.
|
||||
turns it into measured comparisons against local-only Lightroom references. Retuning changes existing edits, so it
|
||||
ships as a new process version (`docs/process-versions.md`): photos keep their look until they are updated.
|
||||
|
||||
## Raw format coverage and known gaps
|
||||
|
||||
@@ -176,20 +182,16 @@ CR3 lossless Bayer and version 0x100/0x200 C-RAW sensor decoding is verified pix
|
||||
|
||||
Decoded (CC0 corpus from raw.pixls.us, `cargo xtask corpus --download`, `crates/raw/tests/corpus.rs`): DNG (uncompressed,
|
||||
LJ92, lossy JPEG / Smart Previews, Deflate, float, linear, DNG 1.7 JPEG XL tiles: lossless ones bit-exact on synthetic files,
|
||||
lossy ones unclipped in the declared linear primaries, checked on one real file), CR2, ARW (uncompressed, ARW2, LJ92; as-shot white balance and black level of pre-2017 bodies from the enciphered
|
||||
maker-note `Tag2010` and the encrypted `SR2SubIFD`, both recovered by black-box analysis, `crates/raw/src/vendor/arw.rs`), NEF/NRW uncompressed and Huffman-compressed (lossless, lossy type 1/2, 12/14-bit), RAF uncompressed and lossless/lossy compressed (Bayer and
|
||||
lossy ones unclipped in the declared linear primaries, checked on one real file), CR2, ARW (uncompressed, ARW2, LJ92; as-shot white balance and black level of pre-2017 bodies from the encrypted
|
||||
`SR2SubIFD`, decrypted with a keystream recovered by black-box analysis, `crates/raw/src/vendor/sr2.rs`, `crates/raw/src/vendor/arw.rs`), NEF/NRW uncompressed and Huffman-compressed (lossless, lossy type 1/2, 12/14-bit), RAF uncompressed and lossless/lossy compressed (Bayer and
|
||||
X-Trans, 14/16-bit real-file verification), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed formats 4 and 6, the prefix-coded strips of format 8,
|
||||
packed formats 2, 5 and 7, the 16-bit words of the 2005–2007 bodies; all recovered by black-box analysis of 178 CC0 files from
|
||||
118 bodies, `crates/raw/src/vendor/rw2.rs`), PEF (uncompressed and Huffman), ORF uncompressed (16-bit and 12-bit packed). Supported containers yield their embedded JPEG preview when present (including CR3). The engine uses it for RAW variants it cannot decode yet; HEVC-only CR3 previews are not decoded.
|
||||
|
||||
Not decoded yet — preview only (no permissively licensed description; black-box analysis incomplete):
|
||||
- **Nikon "lossy after split" NEF** (non-zero split row in maker note `0x0096`, e.g. some D3400/D5000/D5200/D5500/D5600
|
||||
files): rows above the split decode with the regular lossy table; from the split row on a different code is used
|
||||
that our black-box analysis has not recovered yet (none of the four regular tables fits, also not with byte
|
||||
alignment or reset predictors at the split row). The other Nikon Huffman variants are decoded (`crates/raw/src/vendor/nefc.rs` documents the analysis).
|
||||
- **Olympus compressed ORF**, **Canon CR3 unverified CRX variants** (M11.1), **Canon sRAW/mRAW**.
|
||||
|
||||
**Camera colour matrices:** ARW, NEF, RW2, RAF, CR3, CR2, PEF and Samsung SRW files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. 52 models without a camera profile start from matrices fitted to their measured spectral sensitivities instead (rawtoaces-data, Apache-2.0; `crates/raw/src/spectral.rs`). Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
|
||||
**Camera colour matrices:** ARW, NEF, RW2, RAF, CR3, CR2, PEF, Samsung SRW and Olympus ORF files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. 52 models whose fit is rejected (or that have no usable JPEG) start from matrices fitted to their measured spectral sensitivities instead (rawtoaces-data, Apache-2.0; `crates/raw/src/spectral.rs`). Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
|
||||
`matrix_is_fallback`) with the file's as-shot white-balance multipliers. Clean sources to evaluate next: manufacturer
|
||||
matrices stored in the files themselves (Olympus ImageProcessing `ColorMatrix`, Pentax equivalents; Panasonic RW2 files carry none) and our
|
||||
own chart-based calibration (M11.4). Adobe matrices are never used.
|
||||
@@ -221,13 +223,16 @@ database is read-only; LightCraft owns subsequent edits. See `docs/lightroom-cat
|
||||
16-bit words), black levels, in-camera aspect crops and mapped-out defects established on 178 CC0 files from 118
|
||||
bodies; RW2 gets the file-local camera look (accepted on 140 of 174 files with a preview).
|
||||
- 2026-10-08: independent Rust CR3 lossless and version 0x100/0x200 C-RAW decoding, six exact full-sensor regressions on M50/R100/R8; any CR3 decode error falls back to the embedded JPEG; guarded CR3 camera-JPEG colour fitting with independent framing validation (`docs/cr3.md`). Unverified CRX variants and broader model verification remain.
|
||||
- 2026-10-09: Sony ILCE-7CR camera colour: bundled profile from 125 photos. On 26 withheld photos mean ΔE vs the camera JPEG 4.07 → 3.29; all 131 photos accepted, where 8 fell back to the neutral matrix without it. Measured calibration remains open.
|
||||
|
||||
## Chinese and Japanese interfaces, and text watermarks
|
||||
|
||||
English, Simplified Chinese, Traditional Chinese (Taiwan) and Japanese interface languages are persisted in UI state, and the
|
||||
language table (`crates/ui-egui/src/i18n.rs`) drives the menus, settings and fonts, so another
|
||||
language is a table entry plus two catalogs (docs/localization.md). Core menus and panels are
|
||||
translated; untranslated text retains English. CJK glyphs (Chinese: Noto Sans CJK SC; Japanese UI:
|
||||
translated; untranslated text retains English. Ukrainian covers all existing catalog keys, command/control/rule labels
|
||||
and format messages, with count-neutral labels and numeric dates; technical errors from lower layers remain English.
|
||||
CJK glyphs (Chinese: Noto Sans CJK SC; Japanese UI:
|
||||
BIZ UDPGothic; watermarks: BIZ UDMincho) come from storytold/craft-fonts, embedded by builds made
|
||||
with the optional `CRAFT_FONTS_DIR` input (all releases), so no system fonts are needed. Text watermarks now accept
|
||||
`vertical: true` in export JSON/presets and expose an orientation selector. Japanese
|
||||
|
||||
@@ -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<u64> = 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())?;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -169,6 +169,7 @@ fn services(originals: Originals, backend: Option<Backend>, 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<Backend>, 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)),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -78,9 +78,17 @@ impl eframe::App for App {
|
||||
if let Err(e) = self.0.session.close_library() {
|
||||
log::error!("saving the library failed: {e}");
|
||||
}
|
||||
// last, with everything saved: no render may be inside the GPU driver when the process ends
|
||||
if !self.0.shutdown(EXIT_WAIT) {
|
||||
log::warn!("quit: background work was still running after {EXIT_WAIT:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How long quitting waits for renders that are running (issue #620). A GPU render takes well under
|
||||
/// a second; anything slower gives way to quitting.
|
||||
const EXIT_WAIT: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
|
||||
/// The window's renderer (egui-wgpu): eframe's defaults, with LightCraft's backend choice — DX12
|
||||
/// alone on Windows unless `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND` say otherwise (issue #136:
|
||||
/// with Vulkan in the set, wgpu loads the Vulkan driver even when it then picks DX12) — and DX12
|
||||
@@ -133,6 +141,23 @@ fn gpu_crash_notice(what: &str) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// Where the system keeps monitor profiles, for the Choose Profile dialog to start in: the user's
|
||||
/// own profiles first (Linux: colord / DisplayCAL; macOS: ColorSync), then the system's.
|
||||
fn display_profile_dir() -> Option<std::path::PathBuf> {
|
||||
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<Option<std::path::PathBuf>> = 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<std::path::PathBuf> {
|
||||
lightcraft_engine::config::config_dir()
|
||||
}
|
||||
@@ -464,6 +489,15 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
|
||||
.map(|p| vec![p.to_string_lossy().to_string()])
|
||||
.unwrap_or_default()
|
||||
})),
|
||||
pick_display_profile: Some(Box::new(|| {
|
||||
let mut d = rfd::FileDialog::new()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Choose Monitor Profile"))
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("ICC Profiles"), &["icc", "icm"]);
|
||||
if let Some(dir) = display_profile_dir() {
|
||||
d = d.set_directory(dir);
|
||||
}
|
||||
d.pick_file().map(|p| vec![p.to_string_lossy().to_string()]).unwrap_or_default()
|
||||
})),
|
||||
save_preset_file: Some(Box::new(|name: &str| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Export Presets"))
|
||||
@@ -472,6 +506,22 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
|
||||
.save_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
pick_keyword_list: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Import Keywords"))
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Keyword Lists"), &["txt", "utf8"])
|
||||
.pick_file()
|
||||
.map(|p| vec![p.to_string_lossy().to_string()])
|
||||
.unwrap_or_default()
|
||||
})),
|
||||
save_keyword_list: Some(Box::new(|name: &str| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Export Keywords"))
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Keyword Lists"), &["txt"])
|
||||
.set_file_name(name)
|
||||
.save_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
pick_curve_preset_files: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Import Point Curve Presets"))
|
||||
|
||||
@@ -12,8 +12,10 @@ covers a whole directory), or if a licence file referenced in the Licence column
|
||||
| `assets/app-icon/` (`lightcraft.svg` and the PNG/ICNS/ICO/SVG files derived from it) | LightCraft app icon (lynx) | LightCraft project owner (drawn in ArtCraft) | original work (vectorised from the owner's drawing; see `assets/app-icon/README.md`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | 2026-10-02 | vectorised to SVG; rendered to PNG/ICNS/ICO by `packaging/icons.sh` |
|
||||
| `assets/camera-profiles/*.json` | Camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | ILCE-7M4: Harald Hoyer | original work: fitted by `lightcraft-cli calibrate` to the contributor's own raw photos and their in-camera JPEGs (597 ILCE-7M4 photos, 2026); aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-07 | none |
|
||||
| `packaging/macos/dmg/` (`background.svg`, the `background.tiff` rendered from it by `generate.py`, and the `dmg-layout.DS_Store` layout file) | LightCraft macOS DMG window background | @XusBadia | original work for this repository, derived from the LightCraft app icon (`assets/app-icon/lightcraft.svg`, referenced via `<image>`, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0 | 2026-10-08 | none (original); used for the Finder window of the macOS DMG, `packaging/macos/dmg/generate.py` renders it to `background.tiff` |
|
||||
| `assets/camera-profiles/ILCE-7CR.json` | Sony ILCE-7CR camera colour profile (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by @AlexChambers | original work: fitted by `lightcraft-cli calibrate` to 125 usable ILCE-7CR ARWs (of 131, September 2025 to September 2026, Standard creative style) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-09 | none |
|
||||
| `assets/camera-profiles/X-H2S.json`, `assets/camera-profiles/X-T4.json` | Fujifilm camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by Sebastian Pfluegelmeier | original work: fitted by `lightcraft-cli calibrate` to 201 X-H2S and 545 X-T4 RAFs and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-08 | none |
|
||||
| `crates/raw/src/spectral_table.rs` | Camera colour matrices (DNG ColorMatrix1/2 and ForwardMatrix1/2 for Standard Illuminant A and D65) for 52 camera models, and the model alias list | Derived by LightCraft contributors from rawtoaces-data (Academy Software Foundation): camera spectral sensitivities measured by Weta Digital (44 cameras), scitech (7) and Jonathon Irons at the National Physical Laboratory (Nikon D5100); training reflectances; CIE 1931 colour-matching functions (CIE, CC-BY-4.0, as shipped in rawtoaces-data) | https://github.com/AcademySoftwareFoundation/rawtoaces-data, tag v1.1.0, commit 5cfa53707b053594b2d0d48e8019fb56e6582d6c | Apache-2.0 (`LICENSE-APACHE`); the colour-matching functions CC-BY-4.0 | 2026-10-08 | generated by `tools/spectral_camera_table.py`: a 3×3 matrix per illuminant fitted to the spectral data (no measurement copied verbatim); aliases copied from `data/aliases.json`; no Adobe or third-party camera matrix |
|
||||
| `assets/camera-tone/sony-dro-off.json` | Sony global tone curve with the Dynamic Range Optimizer off (32 knots), built into LightCraft for ARWs shot with DRO on | LightCraft contributors; source photographs by raw.pixls.us contributors | original work: tone curve quantile-matched over the pooled pixel pairs of 12 CC0 raw.pixls.us ARWs (DRO off, Standard creative style; ILCE-3500, -5000, -6600, -7CR, -7M3 ×3, -7S, -9M2, ILCA-77M2, SLT-A57, NEX-F3) and their embedded camera JPEGs; the file lists each source URL and SHA-256 and how the curve was derived (see also `docs/camera-preview-colour.md`); aggregate tone statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 (sources CC0) | 2026-10-09 | none |
|
||||
| `crates/scenes/` (generated images) | Procedural demo photographs | LightCraft contributors | original work (generated at runtime, no source imagery) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
|
||||
| `crates/ui-egui/src/icons.rs` | UI icons drawn as vector paths in code | LightCraft contributors | original work | MIT OR Apache-2.0 | 2026-09-30 | n/a |
|
||||
| `docs/images/*-tetons.jpg`, `docs/images/grid-pd.jpg` (screenshots containing it) | "The Tetons and the Snake River" | Ansel Adams (1942), U.S. National Archives | https://commons.wikimedia.org/wiki/File:Adams_The_Tetons_and_the_Snake_River.jpg | Public domain (U.S. federal government work) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
|
||||
@@ -23,6 +25,7 @@ covers a whole directory), or if a licence file referenced in the Licence column
|
||||
| `docs/images/*.jpg` (UI) | LightCraft application screenshots | LightCraft contributors | original work (captured with `docs/showcase/`) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
|
||||
| `docs/brand/` (artcraft-logo and artcraft-mark, SVG and PNG) | ArtCraft wordmark and mark | ArtCraft Team | original work (https://getartcraft.com/) | ArtCraft trademark, see `docs/brand/LICENSE-brand.txt` (not open source) | 2026-10-01 | none |
|
||||
| craft-fonts: optional build input, not files in this repository | BIZ UDPGothic (Regular, Bold), BIZ UDMincho Regular, Shippori Mincho Regular, Noto Sans CJK SC Regular | Morisawa Inc. / The BIZ UDGothic and BIZ UDMincho Project Authors; The Shippori Mincho Project Authors; Google and Adobe (Noto Sans CJK, maintainer-approved exception; see AGENTS.md) (per craft-fonts' attribution) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | SIL Open Font License 1.1 (each font's OFL.txt in craft-fonts; release packages ship it as OFL-<family>.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
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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]
|
||||
]
|
||||
}
|
||||
+479
-62
@@ -2,23 +2,23 @@
|
||||
"schema": 1,
|
||||
"repo": "storytold/lightcraft",
|
||||
"branch": "main",
|
||||
"head": "2694d7089fbf4ada62c6738a7c62fecdfd5711fb",
|
||||
"head_date": "2026-10-08T00:53:19Z",
|
||||
"commits": 424,
|
||||
"head": "88ea1a804b35709e1fe436e3792adf0428b97daa",
|
||||
"head_date": "2026-10-09T13:05:04Z",
|
||||
"commits": 729,
|
||||
"unattributed_commits": 0,
|
||||
"contributors": [
|
||||
{
|
||||
"login": "echelon",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 71,
|
||||
"commits": 330,
|
||||
"lines_added": 125623,
|
||||
"lines_deleted": 8643,
|
||||
"prs": 83,
|
||||
"commits": 348,
|
||||
"lines_added": 127920,
|
||||
"lines_deleted": 8745,
|
||||
"binary_added": 38,
|
||||
"binary_deleted": 8,
|
||||
"first_commit": "2026-09-30T14:04:12Z",
|
||||
"last_commit": "2026-10-08T00:32:34Z"
|
||||
"last_commit": "2026-10-09T13:05:04Z"
|
||||
},
|
||||
{
|
||||
"login": "leipsfur",
|
||||
@@ -50,40 +50,40 @@
|
||||
"login": "storybored379",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 7,
|
||||
"commits": 7,
|
||||
"lines_added": 1194,
|
||||
"lines_deleted": 82,
|
||||
"prs": 8,
|
||||
"commits": 9,
|
||||
"lines_added": 1925,
|
||||
"lines_deleted": 119,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-04T00:28:49Z",
|
||||
"last_commit": "2026-10-06T06:51:43Z"
|
||||
"last_commit": "2026-10-09T06:02:21Z"
|
||||
},
|
||||
{
|
||||
"login": "midasdf",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 424,
|
||||
"lines_deleted": 25,
|
||||
"prs": 5,
|
||||
"commits": 5,
|
||||
"lines_added": 755,
|
||||
"lines_deleted": 80,
|
||||
"binary_added": 1,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-05T16:07:26Z",
|
||||
"last_commit": "2026-10-05T16:07:26Z"
|
||||
"last_commit": "2026-10-09T06:04:40Z"
|
||||
},
|
||||
{
|
||||
"login": "voshart",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 3,
|
||||
"commits": 12,
|
||||
"lines_added": 2481,
|
||||
"lines_deleted": 1389,
|
||||
"prs": 8,
|
||||
"commits": 26,
|
||||
"lines_added": 30524,
|
||||
"lines_deleted": 4416,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-05T16:23:27Z",
|
||||
"last_commit": "2026-10-07T16:07:24Z"
|
||||
"last_commit": "2026-10-09T02:31:04Z"
|
||||
},
|
||||
{
|
||||
"login": "arata-1972",
|
||||
@@ -124,6 +124,19 @@
|
||||
"first_commit": "2026-10-06T20:21:14Z",
|
||||
"last_commit": "2026-10-06T20:21:14Z"
|
||||
},
|
||||
{
|
||||
"login": "Exalted5373",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 5,
|
||||
"commits": 11,
|
||||
"lines_added": 1988,
|
||||
"lines_deleted": 132,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-06T21:01:23Z",
|
||||
"last_commit": "2026-10-09T01:33:43Z"
|
||||
},
|
||||
{
|
||||
"login": "treyreynolds",
|
||||
"people_entry": false,
|
||||
@@ -193,7 +206,7 @@
|
||||
"login": "itsmunzir",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"prs": 2,
|
||||
"commits": 1,
|
||||
"lines_added": 46,
|
||||
"lines_deleted": 30,
|
||||
@@ -245,40 +258,40 @@
|
||||
"login": "radialmonster",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 3,
|
||||
"commits": 19,
|
||||
"lines_added": 2630,
|
||||
"lines_deleted": 259,
|
||||
"prs": 26,
|
||||
"commits": 53,
|
||||
"lines_added": 7227,
|
||||
"lines_deleted": 717,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T12:33:51Z",
|
||||
"last_commit": "2026-10-08T00:46:10Z"
|
||||
"last_commit": "2026-10-09T11:31:46Z"
|
||||
},
|
||||
{
|
||||
"login": "bogusyogi",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 434,
|
||||
"lines_deleted": 29,
|
||||
"prs": 2,
|
||||
"commits": 17,
|
||||
"lines_added": 5124,
|
||||
"lines_deleted": 603,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T17:06:00Z",
|
||||
"last_commit": "2026-10-07T17:06:00Z"
|
||||
"last_commit": "2026-10-08T19:26:28Z"
|
||||
},
|
||||
{
|
||||
"login": "nledford",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 4,
|
||||
"commits": 8,
|
||||
"lines_added": 525,
|
||||
"lines_deleted": 60,
|
||||
"prs": 10,
|
||||
"commits": 88,
|
||||
"lines_added": 9299,
|
||||
"lines_deleted": 1024,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T17:33:38Z",
|
||||
"last_commit": "2026-10-07T22:49:00Z"
|
||||
"last_commit": "2026-10-09T00:59:12Z"
|
||||
},
|
||||
{
|
||||
"login": "MusseJusse",
|
||||
@@ -336,14 +349,27 @@
|
||||
"login": "DWAA1660",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 54,
|
||||
"lines_deleted": 12,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 206,
|
||||
"lines_deleted": 42,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T22:08:01Z",
|
||||
"last_commit": "2026-10-07T22:08:01Z"
|
||||
"last_commit": "2026-10-08T17:04:19Z"
|
||||
},
|
||||
{
|
||||
"login": "sebastianpfluegelmeier",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 4,
|
||||
"lines_added": 23063,
|
||||
"lines_deleted": 17968,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T22:14:20Z",
|
||||
"last_commit": "2026-10-08T14:38:56Z"
|
||||
},
|
||||
{
|
||||
"login": "agorevski",
|
||||
@@ -371,6 +397,344 @@
|
||||
"first_commit": "2026-10-08T00:53:19Z",
|
||||
"last_commit": "2026-10-08T00:53:19Z"
|
||||
},
|
||||
{
|
||||
"login": "Hanmo123",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 4,
|
||||
"commits": 6,
|
||||
"lines_added": 922,
|
||||
"lines_deleted": 84,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T02:16:44Z",
|
||||
"last_commit": "2026-10-09T05:35:02Z"
|
||||
},
|
||||
{
|
||||
"login": "wondarkus",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 8,
|
||||
"lines_added": 2776,
|
||||
"lines_deleted": 1387,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T02:56:08Z",
|
||||
"last_commit": "2026-10-08T04:23:30Z"
|
||||
},
|
||||
{
|
||||
"login": "xak47d",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 58,
|
||||
"lines_deleted": 10,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T04:10:21Z",
|
||||
"last_commit": "2026-10-08T04:10:21Z"
|
||||
},
|
||||
{
|
||||
"login": "marceldenzer",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 2864,
|
||||
"lines_deleted": 261,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T05:27:32Z",
|
||||
"last_commit": "2026-10-08T05:27:32Z"
|
||||
},
|
||||
{
|
||||
"login": "bflatastic",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 25,
|
||||
"commits": 2,
|
||||
"lines_added": 108,
|
||||
"lines_deleted": 24,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T06:30:19Z",
|
||||
"last_commit": "2026-10-08T14:00:00Z"
|
||||
},
|
||||
{
|
||||
"login": "wstock",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 8,
|
||||
"commits": 8,
|
||||
"lines_added": 834,
|
||||
"lines_deleted": 67,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T07:45:39Z",
|
||||
"last_commit": "2026-10-08T11:55:57Z"
|
||||
},
|
||||
{
|
||||
"login": "marek-hubar",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 212,
|
||||
"lines_deleted": 29,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T09:01:44Z",
|
||||
"last_commit": "2026-10-08T09:01:44Z"
|
||||
},
|
||||
{
|
||||
"login": "switch87",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 762,
|
||||
"lines_deleted": 34,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T11:00:29Z",
|
||||
"last_commit": "2026-10-08T11:00:29Z"
|
||||
},
|
||||
{
|
||||
"login": "h-a-n-a-s-h-i",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 0,
|
||||
"commits": 2,
|
||||
"lines_added": 50,
|
||||
"lines_deleted": 50,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T12:12:53Z",
|
||||
"last_commit": "2026-10-08T14:01:45Z"
|
||||
},
|
||||
{
|
||||
"login": "Issuenate",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 2,
|
||||
"lines_added": 107,
|
||||
"lines_deleted": 12,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T12:26:27Z",
|
||||
"last_commit": "2026-10-08T12:28:18Z"
|
||||
},
|
||||
{
|
||||
"login": "XusBadia",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 231,
|
||||
"lines_deleted": 1,
|
||||
"binary_added": 2,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T13:12:32Z",
|
||||
"last_commit": "2026-10-08T13:12:32Z"
|
||||
},
|
||||
{
|
||||
"login": "MarekSpodBiedry",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 5,
|
||||
"commits": 6,
|
||||
"lines_added": 336,
|
||||
"lines_deleted": 29,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T13:44:47Z",
|
||||
"last_commit": "2026-10-09T08:45:45Z"
|
||||
},
|
||||
{
|
||||
"login": "peiyan0",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 3,
|
||||
"commits": 3,
|
||||
"lines_added": 215,
|
||||
"lines_deleted": 80,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T15:36:56Z",
|
||||
"last_commit": "2026-10-09T08:09:23Z"
|
||||
},
|
||||
{
|
||||
"login": "hemna",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 459,
|
||||
"lines_deleted": 5,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T16:45:33Z",
|
||||
"last_commit": "2026-10-08T16:45:33Z"
|
||||
},
|
||||
{
|
||||
"login": "joshuaswarren",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 349,
|
||||
"lines_deleted": 18,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T16:47:59Z",
|
||||
"last_commit": "2026-10-08T16:47:59Z"
|
||||
},
|
||||
{
|
||||
"login": "wrgdbtry",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 2733,
|
||||
"lines_deleted": 63,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T19:22:13Z",
|
||||
"last_commit": "2026-10-08T19:22:13Z"
|
||||
},
|
||||
{
|
||||
"login": "parkerdotexe",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 0,
|
||||
"commits": 1,
|
||||
"lines_added": 712,
|
||||
"lines_deleted": 84,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T19:54:50Z",
|
||||
"last_commit": "2026-10-08T19:54:50Z"
|
||||
},
|
||||
{
|
||||
"login": "ragtux",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 3,
|
||||
"commits": 3,
|
||||
"lines_added": 805,
|
||||
"lines_deleted": 50,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T22:50:18Z",
|
||||
"last_commit": "2026-10-08T23:42:19Z"
|
||||
},
|
||||
{
|
||||
"login": "sanyamsingh001",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 431,
|
||||
"lines_deleted": 19,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T03:19:47Z",
|
||||
"last_commit": "2026-10-09T03:19:47Z"
|
||||
},
|
||||
{
|
||||
"login": "onemannoplan",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 1,
|
||||
"lines_deleted": 2,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T04:53:21Z",
|
||||
"last_commit": "2026-10-09T04:53:21Z"
|
||||
},
|
||||
{
|
||||
"login": "Yijie-Si",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 0,
|
||||
"commits": 1,
|
||||
"lines_added": 267,
|
||||
"lines_deleted": 43,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T06:48:00Z",
|
||||
"last_commit": "2026-10-09T06:48:00Z"
|
||||
},
|
||||
{
|
||||
"login": "HarrisXiu",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 69,
|
||||
"lines_deleted": 1,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T09:34:51Z",
|
||||
"last_commit": "2026-10-09T09:34:51Z"
|
||||
},
|
||||
{
|
||||
"login": "rock-meister",
|
||||
"people_entry": true,
|
||||
"github_name": "rock-meister",
|
||||
"prs": 3,
|
||||
"commits": 4,
|
||||
"lines_added": 263,
|
||||
"lines_deleted": 44,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T10:15:52Z",
|
||||
"last_commit": "2026-10-09T11:26:47Z"
|
||||
},
|
||||
{
|
||||
"login": "QuirkyTurtle94",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 153,
|
||||
"lines_deleted": 39,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T11:08:11Z",
|
||||
"last_commit": "2026-10-09T11:08:11Z"
|
||||
},
|
||||
{
|
||||
"login": "BuonBreda",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 0,
|
||||
"lines_added": 0,
|
||||
"lines_deleted": 0,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": null,
|
||||
"last_commit": null
|
||||
},
|
||||
{
|
||||
"login": "cookingwithsimon",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 0,
|
||||
"lines_added": 0,
|
||||
"lines_deleted": 0,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": null,
|
||||
"last_commit": null
|
||||
},
|
||||
{
|
||||
"login": "GuintherKovalski",
|
||||
"people_entry": false,
|
||||
@@ -384,6 +748,32 @@
|
||||
"first_commit": null,
|
||||
"last_commit": null
|
||||
},
|
||||
{
|
||||
"login": "henu043",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 0,
|
||||
"lines_added": 0,
|
||||
"lines_deleted": 0,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": null,
|
||||
"last_commit": null
|
||||
},
|
||||
{
|
||||
"login": "stano-bustor",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 0,
|
||||
"lines_added": 0,
|
||||
"lines_deleted": 0,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": null,
|
||||
"last_commit": null
|
||||
},
|
||||
{
|
||||
"login": "XRTce",
|
||||
"people_entry": false,
|
||||
@@ -404,36 +794,45 @@
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Opus",
|
||||
"version": "5.5",
|
||||
"commits": 364,
|
||||
"lines_added": 131810,
|
||||
"lines_deleted": 9367
|
||||
"commits": 475,
|
||||
"lines_added": 147839,
|
||||
"lines_deleted": 10702
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-sonnet/5.5",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Sonnet",
|
||||
"version": "5.5",
|
||||
"commits": 31,
|
||||
"lines_added": 3941,
|
||||
"lines_deleted": 1543
|
||||
},
|
||||
{
|
||||
"id": "openai/codex",
|
||||
"company": "OpenAI",
|
||||
"model": "Codex",
|
||||
"version": "",
|
||||
"commits": 14,
|
||||
"lines_added": 8341,
|
||||
"lines_deleted": 995
|
||||
"commits": 116,
|
||||
"lines_added": 15071,
|
||||
"lines_deleted": 2624
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude",
|
||||
"version": "",
|
||||
"commits": 2,
|
||||
"lines_added": 1304,
|
||||
"lines_deleted": 192
|
||||
"commits": 65,
|
||||
"lines_added": 8385,
|
||||
"lines_deleted": 952
|
||||
},
|
||||
{
|
||||
"id": "openai/codex",
|
||||
"company": "OpenAI",
|
||||
"model": "Codex",
|
||||
"version": "",
|
||||
"commits": 33,
|
||||
"lines_added": 14418,
|
||||
"lines_deleted": 1647
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-fable/5.1",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Fable",
|
||||
"version": "5.1",
|
||||
"commits": 9,
|
||||
"lines_added": 1548,
|
||||
"lines_deleted": 112
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-sonnet/5",
|
||||
@@ -453,6 +852,15 @@
|
||||
"lines_added": 1633,
|
||||
"lines_deleted": 3
|
||||
},
|
||||
{
|
||||
"id": "deepseek/deepseek-flash/v4",
|
||||
"company": "DeepSeek",
|
||||
"model": "DeepSeek Flash",
|
||||
"version": "V4",
|
||||
"commits": 1,
|
||||
"lines_added": 1439,
|
||||
"lines_deleted": 6
|
||||
},
|
||||
{
|
||||
"id": "deepseek/deepseek/v3.2",
|
||||
"company": "DeepSeek",
|
||||
@@ -461,6 +869,15 @@
|
||||
"commits": 1,
|
||||
"lines_added": 1895,
|
||||
"lines_deleted": 294
|
||||
},
|
||||
{
|
||||
"id": "openai/gpt/6",
|
||||
"company": "OpenAI",
|
||||
"model": "GPT",
|
||||
"version": "6",
|
||||
"commits": 1,
|
||||
"lines_added": 2864,
|
||||
"lines_deleted": 261
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -19,4 +19,5 @@ log = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
lightcraft-geom = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
@@ -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<String> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<ColorLabel>,
|
||||
pub children: Vec<FolderNode>,
|
||||
}
|
||||
|
||||
/// 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<ColorLabel>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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<ColorLabel> {
|
||||
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<ColorLabel>) -> 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<Op> {
|
||||
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<Op> = 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<FolderNode> {
|
||||
@@ -196,7 +275,7 @@ impl Catalog {
|
||||
l.own += n;
|
||||
}
|
||||
}
|
||||
let mut tree: Vec<FolderNode> = roots.iter().filter_map(|k| build(k, &levels)).collect();
|
||||
let mut tree: Vec<FolderNode> = 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<String> = 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<String, Level>) -> Option<FolderNode> {
|
||||
fn build(key: &str, levels: &HashMap<String, Level>, records: &BTreeMap<String, FolderRecord>) -> Option<FolderNode> {
|
||||
let l = levels.get(key)?;
|
||||
let mut children: Vec<FolderNode> = l.children.iter().filter_map(|k| build(k, levels)).collect();
|
||||
let mut children: Vec<FolderNode> = 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::<usize>();
|
||||
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]) {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
+1184
-20
File diff suppressed because it is too large.
Load diff
+117
-3
@@ -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<String>,
|
||||
},
|
||||
/// The lens data a photo's file carries (what Reload finds when it was read after import).
|
||||
SetEmbeddedLens {
|
||||
id: PhotoId,
|
||||
lens: Option<Box<lightcraft_develop::EmbeddedLens>>,
|
||||
},
|
||||
/// 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<String>,
|
||||
},
|
||||
/// 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<keywords::KeywordInfo>,
|
||||
},
|
||||
/// 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<FolderRecord>,
|
||||
},
|
||||
/// Several ops as one step (undo applies the inverses in reverse).
|
||||
Batch {
|
||||
ops: Vec<Op>,
|
||||
@@ -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<String, String>,
|
||||
/// 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<String, keywords::ListedKeyword>,
|
||||
/// What the library keeps about its folders (folder identity → record), see [`folders`].
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
folder_records: BTreeMap<String, FolderRecord>,
|
||||
/// 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<AlbumId> = 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<rules::Problem> {
|
||||
let Some(filter) = self.albums.get(&id).and_then(|a| a.smart.as_deref()) else { return Vec::new() };
|
||||
let mut out: Vec<rules::Problem> = 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<AlbumId>) -> Option<rules::Problem> {
|
||||
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<PhotoId> {
|
||||
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<Item = &keywords::ListedKeyword> {
|
||||
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<ColorLabel> {
|
||||
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;
|
||||
|
||||
@@ -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 { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<f64> {
|
||||
let s = s.trim().trim_end_matches(['s', '"']).trim();
|
||||
match s.split_once('/') {
|
||||
Some((n, d)) => Some(n.trim().parse::<f64>().ok()? / d.trim().parse::<f64>().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<String> = 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");
|
||||
|
||||
@@ -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<String> = 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<crate::AlbumId> {
|
||||
let mut out: Vec<crate::AlbumId> = 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)
|
||||
|
||||
+1219
-69
File diff suppressed because it is too large.
Load diff
@@ -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<_>>(), 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<ColorLabel>, 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::<Vec<_>>();
|
||||
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<String> = 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();
|
||||
|
||||
@@ -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<ColorLabel>) -> 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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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"] }
|
||||
+14
-5
@@ -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.
|
||||
|
||||
@@ -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<Transform8BitExecutor>),
|
||||
}
|
||||
|
||||
/// 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<Transform8BitExecutor>,
|
||||
to_srgb: Arc<Transform8BitExecutor>,
|
||||
}
|
||||
|
||||
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<Mat3> {
|
||||
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<T>(f: impl FnOnce() -> Option<T>) -> Option<T> {
|
||||
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<DisplayProfile, String> {
|
||||
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<Rgba8, String> {
|
||||
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<Mat3, String> {
|
||||
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<Transform8BitExecutor>, 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<u8> {
|
||||
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<f32>| 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);
|
||||
}
|
||||
}
|
||||
+176
-29
@@ -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<Vec<(u8, Vec<u8>)>> {
|
||||
let mut segs: Vec<(u8, Vec<u8>)> = 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<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
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<u8>)> = 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<Vec<u8>> {
|
||||
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<u16> = 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<Vec<u8>> {
|
||||
|
||||
@@ -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<u8> {
|
||||
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<u8>, 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<u8>, 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<GainMapMeta> {
|
||||
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<f32> {
|
||||
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<f32> {
|
||||
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<GainMapMeta> {
|
||||
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<f32> = v.iter().map(|s| s.trim().parse::<f32>().ok()).collect::<Option<_>>()?;
|
||||
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#"<rdf:Description rdf:about="" xmlns:hdrgm="http://ns.adobe.com/hdr-gain-map/1.0/" hdrgm:Version="1.0""#);
|
||||
s.push_str(&format!(r#" hdrgm:HDRCapacityMin="{}""#, num(self.base_headroom)));
|
||||
s.push_str(&format!(r#" hdrgm:HDRCapacityMax="{}""#, num(self.alternate_headroom)));
|
||||
s.push_str(r#" hdrgm:BaseRenditionIsHDR="False""#);
|
||||
for (n, v) in names.iter().zip(values) {
|
||||
if uniform(v) {
|
||||
s.push_str(&format!(r#" hdrgm:{n}="{}""#, num(v[0])));
|
||||
}
|
||||
}
|
||||
s.push('>');
|
||||
// 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!("<hdrgm:{n}><rdf:Seq>"));
|
||||
for x in v {
|
||||
s.push_str(&format!("<rdf:li>{}</rdf:li>", num(*x)));
|
||||
}
|
||||
s.push_str(&format!("</rdf:Seq></hdrgm:{n}>"));
|
||||
}
|
||||
}
|
||||
s.push_str("</rdf:Description>");
|
||||
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#"<rdf:Description rdf:about="" xmlns:apdi="http://ns.apple.com/pixeldatainfo/1.0/" xmlns:HDRGainMap="http://ns.apple.com/HDRGainMap/1.0/" xmlns:HDRToneMap="http://ns.apple.com/HDRToneMap/1.0/">"#,
|
||||
);
|
||||
s.push_str("<apdi:AuxiliaryImageType>urn:com:apple:photo:2020:aux:hdrgainmap</apdi:AuxiliaryImageType>");
|
||||
s.push_str(&format!("<apdi:NativeFormat>{format}</apdi:NativeFormat><apdi:StoredFormat>{format}</apdi:StoredFormat>"));
|
||||
s.push_str("<HDRGainMap:HDRGainMapVersion>131072</HDRGainMap:HDRGainMapVersion>");
|
||||
// linear, not log2: the headroom multiplier itself
|
||||
s.push_str(&format!("<HDRGainMap:HDRGainMapHeadroom>{}</HDRGainMap:HDRGainMapHeadroom>", num(self.alternate_headroom.exp2())));
|
||||
s.push_str(&format!("<HDRToneMap:AlternateHeadroom>{}</HDRToneMap:AlternateHeadroom>", num(self.alternate_headroom)));
|
||||
s.push_str("<HDRToneMap:ChannelMetadata><rdf:Seq>");
|
||||
for c in 0..self.channels() {
|
||||
s.push_str(&format!(
|
||||
r#"<rdf:li rdf:parseType="Resource"><HDRToneMap:GainMapMin>{}</HDRToneMap:GainMapMin><HDRToneMap:GainMapMax>{}</HDRToneMap:GainMapMax><HDRToneMap:Gamma>{}</HDRToneMap:Gamma><HDRToneMap:BaseOffset>{}</HDRToneMap:BaseOffset><HDRToneMap:AlternateOffset>{}</HDRToneMap:AlternateOffset></rdf:li>"#,
|
||||
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("</rdf:Seq></HDRToneMap:ChannelMetadata>");
|
||||
s.push_str(&format!("<HDRToneMap:BaseHeadroom>{}</HDRToneMap:BaseHeadroom>", num(self.base_headroom)));
|
||||
s.push_str("<HDRToneMap:BaseColorIsWorkingColor>True</HDRToneMap:BaseColorIsWorkingColor><HDRToneMap:Version>1</HDRToneMap:Version>");
|
||||
s.push_str("</rdf:Description>");
|
||||
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 = "<?xpacket begin=\"\u{feff}\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?><x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">";
|
||||
const XMP_CLOSE: &str = "</rdf:RDF></x:xmpmeta><?xpacket end=\"w\"?>";
|
||||
|
||||
/// 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:<name>`: an attribute (`name="v"`) or an element holding an `rdf:Seq`.
|
||||
fn xmp_values(xmp: &str, name: &str) -> Option<Vec<String>> {
|
||||
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!("<hdrgm:{name}>");
|
||||
let close = format!("</hdrgm:{name}>");
|
||||
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("<rdf:li>") {
|
||||
let after = rest.get(a + 8..)?;
|
||||
let b = after.find("</rdf:li>")?;
|
||||
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<const N: usize>(&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<u8> {
|
||||
self.take::<1>().map(|a| a[0])
|
||||
}
|
||||
fn u16(&mut self) -> Option<u16> {
|
||||
self.take().map(u16::from_be_bytes)
|
||||
}
|
||||
fn u32(&mut self) -> Option<u32> {
|
||||
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<u8>,
|
||||
}
|
||||
|
||||
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#"<rdf:Description rdf:about="" xmlns:Container="http://ns.google.com/photos/1.0/container/" xmlns:Item="http://ns.google.com/photos/1.0/container/item/" xmlns:hdrgm="http://ns.adobe.com/hdr-gain-map/1.0/" hdrgm:Version="1.0">"#,
|
||||
r#"<Container:Directory><rdf:Seq>"#,
|
||||
r#"<rdf:li rdf:parseType="Resource"><Container:Item Item:Semantic="Primary" Item:Mime="image/jpeg"/></rdf:li>"#,
|
||||
r#"<rdf:li rdf:parseType="Resource"><Container:Item Item:Semantic="GainMap" Item:Mime="image/jpeg" Item:Length="{}"/></rdf:li>"#,
|
||||
r#"</rdf:Seq></Container:Directory></rdf:Description>"#
|
||||
),
|
||||
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("</rdf:RDF>")
|
||||
&& 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<u8> {
|
||||
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<u8>, 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<Vec<u8>> {
|
||||
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<JpegGainMap> {
|
||||
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<u8> = 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("<hdrgm:GainMapMax><rdf:Seq><rdf:li>2.5</rdf:li>"), "{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("<x hdrgm:Version=\"1.0\"/>").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<u8> =
|
||||
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#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/"/></rdf:RDF></x:xmpmeta>"#;
|
||||
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());
|
||||
}
|
||||
}
|
||||
+54
-28
@@ -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<Decoded> {
|
||||
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<Decoded> {
|
||||
// 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<Decoded> {
|
||||
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<Decoded> {
|
||||
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:?}");
|
||||
}
|
||||
}
|
||||
@@ -205,7 +205,7 @@ pub fn write_named(n: NamedSpace) -> Vec<u8> {
|
||||
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]);
|
||||
|
||||
@@ -25,6 +25,17 @@ pub(crate) struct Markers {
|
||||
pub adobe_transform: Option<u8>,
|
||||
/// Offset of the MPF TIFF header within the file and its bytes.
|
||||
pub mpf: Option<(usize, Vec<u8>)>,
|
||||
/// 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<Vec<u8>>,
|
||||
}
|
||||
|
||||
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<Markers> {
|
||||
@@ -88,6 +99,10 @@ pub(crate) fn parse_markers(b: &[u8]) -> Option<Markers> {
|
||||
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<MpfEntry> {
|
||||
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<u8>)> {
|
||||
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::*;
|
||||
|
||||
@@ -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<Header> {
|
||||
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<Decoded> {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -127,25 +127,10 @@ pub(crate) fn header(b: &[u8]) -> Result<(u32, u32, u16)> {
|
||||
}
|
||||
|
||||
fn container_hint(info: &png::Info) -> Option<SourceSpace> {
|
||||
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));
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<SourceSpace> {
|
||||
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 {
|
||||
|
||||
@@ -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<u8> {
|
||||
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<u8> = 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<Vec<u8>> {
|
||||
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::<prop::sample::Index>(), any::<u8>()), 1..12), cut in any::<prop::sample::Index>()) {
|
||||
fn mutated_valid_files(which in 0usize..7, flips in proptest::collection::vec((any::<prop::sample::Index>(), any::<u8>()), 1..12), cut in any::<prop::sample::Index>()) {
|
||||
let mut b = seeds()[which].clone();
|
||||
for (i, v) in flips {
|
||||
let i = i.index(b.len());
|
||||
|
||||
@@ -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<u8> {
|
||||
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<u8> {
|
||||
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<f32> = (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:?}");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]});
|
||||
|
||||
@@ -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: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
#[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)
|
||||
}
|
||||
}
|
||||
@@ -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<DevelopSettings> = 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<u32> = 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<DevelopSettings> = 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> = 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;
|
||||
@@ -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 }
|
||||
@@ -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);
|
||||
|
||||
+867
-122
File diff suppressed because it is too large.
Load diff
@@ -25,6 +25,7 @@ const MAX_FILE: u64 = 4 << 20;
|
||||
/// Profiles built into LightCraft (`assets/camera-profiles/`, see `assets/ATTRIBUTION.md`):
|
||||
/// `(model, JSON)`.
|
||||
pub const BUNDLED: &[(&str, &str)] = &[
|
||||
("ILCE-7CR", include_str!("../../../assets/camera-profiles/ILCE-7CR.json")),
|
||||
("ILCE-7M4", include_str!("../../../assets/camera-profiles/ILCE-7M4.json")),
|
||||
("X-H2S", include_str!("../../../assets/camera-profiles/X-H2S.json")),
|
||||
("X-T4", include_str!("../../../assets/camera-profiles/X-T4.json")),
|
||||
|
||||
@@ -286,7 +286,7 @@ pub(crate) fn move_folder(s: &mut Session, from: &str, to: &str) -> std::result:
|
||||
return Err("the library's own folders can't be moved here".into());
|
||||
}
|
||||
rename_folder_on_disk(from, to)?;
|
||||
let ops: Vec<Op> = s
|
||||
let mut ops: Vec<Op> = s
|
||||
.catalog
|
||||
.photos()
|
||||
.filter_map(|p| match &p.source {
|
||||
@@ -301,6 +301,8 @@ pub(crate) fn move_folder(s: &mut Session, from: &str, to: &str) -> std::result:
|
||||
})
|
||||
.collect();
|
||||
let n = ops.len();
|
||||
// what the library knows about the folder (and those inside it) goes along, in the same step
|
||||
ops.extend(s.catalog.folder_records_follow(from, to));
|
||||
let name = Path::new(to).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let renamed = src.parent() == dst.parent();
|
||||
let label = if renamed { format!("Rename Folder to “{name}”") } else { format!("Move Folder “{name}”") };
|
||||
|
||||
@@ -210,6 +210,25 @@ fn fill_missing_meta(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::P
|
||||
ops
|
||||
}
|
||||
|
||||
/// Ops that give a photo the lens data its file carries now (`info`, a fresh probe), or none when
|
||||
/// it already has them. A catalog from before the lens data was read at import has none, so such a
|
||||
/// photo's correction stayed off. When the photo gains lens data and its develop settings are still
|
||||
/// what import gave it, the lens correction is turned on as import would; an edited photo keeps
|
||||
/// its settings.
|
||||
pub(crate) fn lens_ops(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::Photo, info: &crate::media::ProbeInfo) -> Vec<Op> {
|
||||
if info.embedded_lens == ph.embedded_lens {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut ops = vec![Op::SetEmbeddedLens { id, lens: info.embedded_lens.map(Box::new) }];
|
||||
// (a photo with a default-preset look keeps it: that look is its import state)
|
||||
if ph.embedded_lens.is_none() && info.embedded_lens.is_some() && ph.import_look.is_none() && !ph.is_edited() && !ph.develop.optics.lens_profile {
|
||||
let mut d = (*ph.develop).clone();
|
||||
d.optics.lens_profile = true;
|
||||
ops.push(Op::SetDevelop { id, settings: std::sync::Arc::new(d), label: "Lens Corrections".into(), edited: ph.edited.clone() });
|
||||
}
|
||||
ops
|
||||
}
|
||||
|
||||
/// Re-read photos whose files changed on disk (an external editor saved them): new size,
|
||||
/// dimensions and content hash, cached sources dropped. → {reloaded: [ids]}
|
||||
fn reload(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -229,21 +248,19 @@ fn reload(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// camera fields the catalog lacks (e.g. a raw imported before its format was read) are filled in;
|
||||
// nothing already set is overwritten
|
||||
let meta_ops = fill_missing_meta(id, ph, &info);
|
||||
let Some(op) = content_op(id, ph, info.clone()) else {
|
||||
if !meta_ops.is_empty() {
|
||||
ops.extend(meta_ops);
|
||||
reloaded.push(id);
|
||||
}
|
||||
continue;
|
||||
};
|
||||
ops.push(op);
|
||||
ops.extend(meta_ops);
|
||||
let mut mine: Vec<Op> = content_op(id, ph, info.clone()).into_iter().chain(meta_ops).chain(lens_ops(id, ph, &info)).collect();
|
||||
if !mine.is_empty() {
|
||||
ops.append(&mut mine);
|
||||
reloaded.push(id);
|
||||
}
|
||||
// virtual copies share the file
|
||||
for c in s.catalog.photos().filter(|c| c.copy_of == Some(id)) {
|
||||
ops.extend(content_op(c.id, c, info.clone()));
|
||||
reloaded.push(c.id);
|
||||
let theirs: Vec<Op> = content_op(c.id, c, info.clone()).into_iter().chain(lens_ops(c.id, c, &info)).collect();
|
||||
if !theirs.is_empty() {
|
||||
ops.extend(theirs);
|
||||
reloaded.push(c.id);
|
||||
}
|
||||
}
|
||||
reloaded.push(id);
|
||||
}
|
||||
for id in &reloaded {
|
||||
s.media.forget(*id);
|
||||
@@ -316,7 +333,7 @@ pub fn edit_specs() -> Vec<CommandSpec> {
|
||||
"Reload from Disk",
|
||||
["Photo"],
|
||||
None,
|
||||
"{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now; camera fields the catalog lacks are filled in → {reloaded}",
|
||||
"{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now; camera fields the catalog lacks are filled in, and so is the lens data the file carries (an untouched photo then gets its lens correction turned on as import would; an edited one keeps its settings) → {reloaded}",
|
||||
has_selection,
|
||||
reload
|
||||
),
|
||||
@@ -359,4 +376,102 @@ mod tests {
|
||||
ph.captured = Some("2026-10-04T08:16:11".into());
|
||||
assert!(fill_missing_meta(PhotoId(1), &ph, &info).is_empty());
|
||||
}
|
||||
|
||||
fn lens() -> lightcraft_develop::EmbeddedLens {
|
||||
lightcraft_develop::EmbeddedLens {
|
||||
warp: Some(lightcraft_develop::EmbeddedWarp { planes: [[1.0, -0.1, 0.03, 0.0, 0.0, 0.0]; 3], ..Default::default() }),
|
||||
vignette: None,
|
||||
}
|
||||
}
|
||||
fn old_rw2() -> lightcraft_catalog::Photo {
|
||||
// as an older build catalogued it: no lens data, correction off
|
||||
lightcraft_catalog::Photo::new(
|
||||
lightcraft_catalog::PhotoId(1),
|
||||
Source::File { path: "/x.rw2".into() },
|
||||
"x.rw2",
|
||||
"RW2",
|
||||
4592,
|
||||
3448,
|
||||
"2026-10-06T00:00:00",
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_adds_lens_data_and_turns_the_correction_on_for_an_untouched_photo() {
|
||||
let ph = old_rw2();
|
||||
let info = crate::media::ProbeInfo { embedded_lens: Some(lens()), ..Default::default() };
|
||||
let ops = lens_ops(ph.id, &ph, &info);
|
||||
assert!(matches!(ops.first(), Some(Op::SetEmbeddedLens { lens: Some(_), .. })), "{ops:?}");
|
||||
let Some(Op::SetDevelop { settings, .. }) = ops.get(1) else { panic!("{ops:?}") };
|
||||
assert!(settings.optics.lens_profile);
|
||||
// once applied, the photo is still "unedited" and has what import would have given it
|
||||
let mut c = lightcraft_catalog::Catalog::default();
|
||||
c.apply(Op::AddPhoto { photo: Box::new(ph) }).unwrap();
|
||||
for op in ops {
|
||||
c.apply(op).unwrap();
|
||||
}
|
||||
let after = c.photo(lightcraft_catalog::PhotoId(1)).unwrap();
|
||||
assert_eq!(after.embedded_lens, Some(lens()));
|
||||
assert!(!after.is_edited());
|
||||
assert_eq!(*after.develop, after.camera_defaults());
|
||||
assert!(lens_ops(after.id, after, &info).is_empty(), "nothing more to do the second time");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_adds_lens_data_but_keeps_an_edited_photos_settings() {
|
||||
let mut ph = old_rw2();
|
||||
let mut d = (*ph.develop).clone();
|
||||
d.light.exposure = 0.7;
|
||||
ph.develop = std::sync::Arc::new(d);
|
||||
assert!(ph.is_edited());
|
||||
let info = crate::media::ProbeInfo { embedded_lens: Some(lens()), ..Default::default() };
|
||||
let ops = lens_ops(ph.id, &ph, &info);
|
||||
assert!(matches!(ops.as_slice(), [Op::SetEmbeddedLens { lens: Some(_), .. }]), "{ops:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_leaves_a_photo_whose_user_switched_the_correction_off_alone() {
|
||||
let mut ph = old_rw2();
|
||||
ph.embedded_lens = Some(lens());
|
||||
let mut d = ph.camera_defaults();
|
||||
d.optics.lens_profile = false;
|
||||
ph.develop = std::sync::Arc::new(d);
|
||||
let info = crate::media::ProbeInfo { embedded_lens: Some(lens()), ..Default::default() };
|
||||
assert!(lens_ops(ph.id, &ph, &info).is_empty());
|
||||
}
|
||||
|
||||
/// End to end on a public Panasonic raw (skipped without the corpus): a catalog from before the lens data was read
|
||||
/// gets it on Reload, the render changes to the corrected one, and Undo takes it all back.
|
||||
#[test]
|
||||
fn corpus_reload_corrects_an_rw2_catalogued_without_lens_data() {
|
||||
let root = std::env::var_os("LIGHTCRAFT_CORPUS")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"));
|
||||
let f = root.join("raw/rw2-panasonic-gx80.rw2");
|
||||
if !f.exists() {
|
||||
eprintln!("skip: {} absent", f.display());
|
||||
return;
|
||||
}
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.import", &json!({"paths": [f.to_string_lossy()]})).unwrap();
|
||||
let id = s.active().unwrap();
|
||||
let ph = s.catalog.photo(id).unwrap().clone();
|
||||
assert!(ph.embedded_lens.is_some() && ph.develop.optics.lens_profile, "import reads it");
|
||||
// back to how 0.4.0 catalogued it
|
||||
let mut old = (*ph.develop).clone();
|
||||
old.optics.lens_profile = false;
|
||||
s.catalog.apply(Op::SetEmbeddedLens { id, lens: None }).unwrap();
|
||||
s.catalog.apply(Op::SetDevelop { id, settings: std::sync::Arc::new(old), label: "x".into(), edited: None }).unwrap();
|
||||
s.media.forget(id);
|
||||
let before = s.render_now(id, 160, 120).unwrap().image.data;
|
||||
let r = s.execute("photo.reload", &json!({"ids": [id.0]})).unwrap();
|
||||
assert_eq!(r["reloaded"], json!([id.0]));
|
||||
let now = s.catalog.photo(id).unwrap().clone();
|
||||
assert_eq!(now.embedded_lens, ph.embedded_lens);
|
||||
assert!(now.develop.optics.lens_profile && !now.is_edited());
|
||||
assert_ne!(s.render_now(id, 160, 120).unwrap().image.data, before, "the corrected render differs");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let undone = s.catalog.photo(id).unwrap();
|
||||
assert!(undone.embedded_lens.is_none() && !undone.develop.optics.lens_profile, "one undo step");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Develop commands. All operate on the active photo unless `ids` are given (sync).
|
||||
|
||||
use lightcraft_catalog::{Op, PhotoId, Version};
|
||||
use lightcraft_develop::{DevelopSettings, Preset, Section, SettingsGroup, Treatment, Upright, WbMode, controls};
|
||||
use lightcraft_develop::{DevelopSettings, Preset, ProcessVersion, Section, SettingsGroup, Treatment, Upright, WbMode, controls};
|
||||
use lightcraft_geom::{CropGeometry, CropHandle, Point, Rect, crop_fit_angle, drag_crop};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -74,6 +74,49 @@ fn set_aspect(d: &mut DevelopSettings, w: f64, h: f64, aspect: Option<(u32, u32)
|
||||
d.crop.geometry = crop_fit_angle(w, h, angle, a);
|
||||
}
|
||||
|
||||
/// Photo `id` is on an older rendering process than [`ProcessVersion::LATEST`].
|
||||
fn outdated(s: &Session, id: PhotoId) -> bool {
|
||||
s.catalog.photo(id).is_some_and(|p| p.develop.process.is_outdated())
|
||||
}
|
||||
|
||||
fn already_current() -> String {
|
||||
format!("already on the current process (version {})", ProcessVersion::LATEST.0)
|
||||
}
|
||||
|
||||
/// Update to Current Process applies to the selection (the selected photos, else the active
|
||||
/// photo): one of them is on an older rendering process. Calls that name their photos are
|
||||
/// checked by the command instead (`explicit_targets`).
|
||||
fn has_outdated_process(s: &Session) -> std::result::Result<(), String> {
|
||||
has_selection(s)?;
|
||||
let sel = &s.selection;
|
||||
let targets = if sel.ids.is_empty() { sel.active.as_slice() } else { sel.ids.as_slice() };
|
||||
if targets.iter().any(|id| outdated(s, *id)) { Ok(()) } else { Err(already_current()) }
|
||||
}
|
||||
|
||||
/// The photos a call names (`ids`, or `id`), each of which must be in the library; `None` when it
|
||||
/// names none (the selection applies).
|
||||
fn named_photos(s: &Session, p: &Value, c: &str) -> Result<Option<Vec<PhotoId>>> {
|
||||
if !super::names_photos(p) {
|
||||
return Ok(None);
|
||||
}
|
||||
let given = match p.get("ids").filter(|v| !v.is_null()) {
|
||||
Some(Value::Array(a)) => a.clone(),
|
||||
Some(_) => return Err(bad(c, "`ids` must be a list of photo ids")),
|
||||
None => p.get("id").cloned().into_iter().collect(),
|
||||
};
|
||||
if given.is_empty() {
|
||||
return Err(bad(c, "`ids` names no photos"));
|
||||
}
|
||||
let ids = given
|
||||
.iter()
|
||||
.map(|v| match v.as_u64().map(PhotoId) {
|
||||
Some(id) if s.catalog.photo(id).is_some() => Ok(id),
|
||||
_ => Err(bad(c, format!("no such photo {v}"))),
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Ok(Some(ids))
|
||||
}
|
||||
|
||||
/// The user preset named by `p.id` (built-ins can't be changed).
|
||||
fn user_preset<'a>(s: &'a mut Session, p: &Value, c: &str) -> Result<&'a mut Preset> {
|
||||
let pid = str_param(p, "id").ok_or_else(|| bad(c, "missing id"))?;
|
||||
@@ -203,12 +246,44 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
}
|
||||
};
|
||||
s.develop_op(id, fresh, "Reset")
|
||||
// a fresh start renders with the current process (a section or slider reset doesn't change it)
|
||||
s.develop_op(id, DevelopSettings { process: ProcessVersion::LATEST, ..fresh }, "Reset")
|
||||
})
|
||||
.collect();
|
||||
s.commit("Reset", Op::Batch { ops })?;
|
||||
ok()
|
||||
}),
|
||||
CommandSpec {
|
||||
explicit_targets: true,
|
||||
..cmd!(
|
||||
"develop.updateProcess",
|
||||
"Update to Current Process",
|
||||
["Photo"],
|
||||
None,
|
||||
"{ids?}: moves the target photos (`ids`, else the selection) that are on an older rendering process to the current one, as one undo step (their look can change; the sliders keep their values; see docs/process-versions.md). Enabled while a selected photo is on an older process; named photos are checked themselves (each must exist, one must be on an older process) → {changed, process}",
|
||||
has_outdated_process,
|
||||
|s, p| {
|
||||
const C: &str = "develop.updateProcess";
|
||||
const LABEL: &str = "Update to Current Process";
|
||||
let targets = match named_photos(s, p, C)? {
|
||||
Some(ids) if !ids.iter().any(|id| outdated(s, *id)) => return Err(crate::EngineError::Disabled(C.into(), already_current())),
|
||||
Some(ids) => ids,
|
||||
None => s.targets(p),
|
||||
};
|
||||
let ops: Vec<Op> = targets
|
||||
.into_iter()
|
||||
.filter_map(|id| s.develop_of(id).map(|d| (id, d)))
|
||||
.filter(|(_, d)| d.process.is_outdated())
|
||||
.filter_map(|(id, d)| s.develop_op(id, DevelopSettings { process: ProcessVersion::LATEST, ..(*d).clone() }, LABEL))
|
||||
.collect();
|
||||
let n = ops.len();
|
||||
if n > 0 {
|
||||
s.commit(LABEL, Op::Batch { ops })?;
|
||||
}
|
||||
Ok(json!({"changed": n, "process": ProcessVersion::LATEST}))
|
||||
}
|
||||
)
|
||||
},
|
||||
cmd!(
|
||||
"develop.resetSection",
|
||||
"Reset Section",
|
||||
@@ -572,6 +647,33 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(())
|
||||
})
|
||||
}),
|
||||
// ---- HDR editing
|
||||
cmd!(
|
||||
"develop.hdr",
|
||||
"HDR",
|
||||
[],
|
||||
None,
|
||||
"{enabled?: bool (default: toggle), maxEv?: number 0..5 (headroom limit, stops above SDR white)} — HDR editing: highlights above SDR white for HDR exports and displays; every SDR render uses the SDR rendition (the hdr.sdr* controls)",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let id = active(s, "develop.hdr")?;
|
||||
let cur = s.develop_of(id).unwrap_or_default().hdr;
|
||||
let enabled = bool_or(p, "enabled", !cur.enabled);
|
||||
let max_ev = match p.get("maxEv") {
|
||||
None => None,
|
||||
Some(v) => Some(v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| bad("develop.hdr", "`maxEv` must be a number"))?),
|
||||
};
|
||||
let label = if enabled { "HDR On" } else { "HDR Off" };
|
||||
edit(s, "develop.hdr", label, |d| {
|
||||
d.hdr.enabled = enabled;
|
||||
if let Some(m) = max_ev {
|
||||
d.hdr.max_ev = m.clamp(0.0, lightcraft_develop::Hdr::MAX_EV_LIMIT);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"enabled": enabled, "maxEv": s.develop_of(id).map(|d| d.hdr.max_ev)}))
|
||||
}
|
||||
),
|
||||
// ---- geometry: Upright
|
||||
cmd!(
|
||||
"geometry.upright",
|
||||
|
||||
@@ -80,6 +80,17 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "export.contactSheet", "Export Contact Sheet PDF", [], None,
|
||||
"{path, ids?: photo ids (default selection), paper?: a4|letter, landscape?: bool, columns?: 1..8, rows?: 1..10, captions?: bool} → {path, pages, photos, bytes}; replaces an existing output, never an original",
|
||||
always, |s, p| {
|
||||
let path = p.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).ok_or_else(|| bad("export.contactSheet", "missing path"))?;
|
||||
s.check_write_target(path).map_err(|e| bad("export.contactSheet", e))?;
|
||||
let doc = crate::contact_sheet::prepare(s, p).and_then(|work| work.run(&mut |_, _| true)).map_err(|e| bad("export.contactSheet", e))?;
|
||||
s.check_write_target(path).map_err(|e| bad("export.contactSheet", e))?;
|
||||
crate::export::write_file(path, &doc.bytes).map_err(|e| bad("export.contactSheet", e))?;
|
||||
Ok(json!({"path": path, "pages": doc.pages, "photos": doc.photos, "bytes": doc.bytes.len()}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"export.presets",
|
||||
"Export Presets",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! Library-wide keyword commands: list (tree with counts), suggestions, rename, delete, merge;
|
||||
//! keyword sets (nine keywords a keystroke away: ⌥1–⌥9) and Recent Keywords.
|
||||
|
||||
use lightcraft_catalog::keywords::{clean, is_under};
|
||||
use lightcraft_catalog::keywords::{KeywordInfo, clean, closest, is_under, reparent};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, cmd, str_param};
|
||||
use super::{CommandSpec, always, bad, bool_or, cmd, str_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
fn strs(p: &Value, key: &str) -> Vec<String> {
|
||||
@@ -15,20 +15,54 @@ fn strs(p: &Value, key: &str) -> Vec<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Commit a keyword batch; returns how many photos changed. The keyword filter follows a renamed
|
||||
/// keyword and is cleared when its keyword is deleted.
|
||||
/// Commit a keyword batch; returns `{changed: photos changed, listed: keyword list changes}`. The
|
||||
/// keyword filter follows a renamed keyword and is cleared when its keyword is deleted.
|
||||
fn commit_keywords(s: &mut Session, label: &str, op: lightcraft_catalog::Op, follow: impl Fn(&str) -> Option<String>) -> Result<Value> {
|
||||
let n = match &op {
|
||||
lightcraft_catalog::Op::Batch { ops } => ops.len(),
|
||||
_ => 1,
|
||||
use lightcraft_catalog::Op;
|
||||
let (photos, listed) = match &op {
|
||||
Op::Batch { ops } => {
|
||||
(ops.iter().filter(|o| matches!(o, Op::SetMeta { .. })).count(), ops.iter().filter(|o| matches!(o, Op::SetKeyword { .. })).count())
|
||||
}
|
||||
Op::SetMeta { .. } => (1, 0),
|
||||
_ => (0, 1),
|
||||
};
|
||||
if n > 0 {
|
||||
if photos + listed > 0 {
|
||||
s.commit(label, op)?;
|
||||
}
|
||||
if let Some(k) = s.filter.keyword.clone() {
|
||||
s.filter.keyword = follow(&k);
|
||||
}
|
||||
Ok(json!({"changed": n}))
|
||||
Ok(json!({"changed": photos, "listed": listed}))
|
||||
}
|
||||
|
||||
/// A keyword's attributes from command params over `base`: those not given keep their value.
|
||||
fn info_from(p: &Value, base: KeywordInfo) -> KeywordInfo {
|
||||
KeywordInfo {
|
||||
synonyms: if p.get("synonyms").is_some() {
|
||||
strs(p, "synonyms").into_iter().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
|
||||
} else {
|
||||
base.synonyms
|
||||
},
|
||||
include_on_export: bool_or(p, "includeOnExport", base.include_on_export),
|
||||
export_containing: bool_or(p, "exportContaining", base.export_containing),
|
||||
export_synonyms: bool_or(p, "exportSynonyms", base.export_synonyms),
|
||||
person: bool_or(p, "person", base.person),
|
||||
new_keywords_inside: base.new_keywords_inside,
|
||||
}
|
||||
}
|
||||
|
||||
/// The keyword a command names (`keyword`), as the library writes it.
|
||||
fn named_keyword(s: &Session, p: &Value, command: &str) -> Result<String> {
|
||||
let k = str_param(p, "keyword").ok_or_else(|| bad(command, "missing `keyword`"))?;
|
||||
s.catalog.keyword_path(k).ok_or_else(|| bad(command, format!("no keyword “{}”", clean(k))))
|
||||
}
|
||||
|
||||
/// A catalog refusal as a command error; a taken name says how to merge.
|
||||
fn refused(command: &str, e: lightcraft_catalog::CatalogError, how_to_merge: &str) -> crate::EngineError {
|
||||
match e {
|
||||
lightcraft_catalog::CatalogError::KeywordExists(k) => bad(command, format!("there is a keyword “{k}” already{how_to_merge}")),
|
||||
e => bad(command, e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A named set of up to nine keywords.
|
||||
@@ -38,6 +72,12 @@ pub struct KeywordSet {
|
||||
pub keywords: Vec<String>,
|
||||
}
|
||||
|
||||
/// Two set names (or recent keywords) are the same whatever the case of any of their letters, as
|
||||
/// keywords are.
|
||||
fn same_set(a: &str, b: &str) -> bool {
|
||||
lightcraft_catalog::keywords::same(a.trim(), b.trim())
|
||||
}
|
||||
|
||||
/// The set name meaning "the nine most recently added keywords".
|
||||
pub const RECENT: &str = "Recent Keywords";
|
||||
|
||||
@@ -48,16 +88,54 @@ pub fn note_recent(s: &mut Session, added: &[String]) {
|
||||
if k.is_empty() {
|
||||
continue;
|
||||
}
|
||||
s.recent_keywords.retain(|x| !x.eq_ignore_ascii_case(&k));
|
||||
s.recent_keywords.retain(|x| !same_set(x, &k));
|
||||
s.recent_keywords.insert(0, k);
|
||||
}
|
||||
s.recent_keywords.truncate(9);
|
||||
let _ = s.save_prefs();
|
||||
}
|
||||
|
||||
/// The largest keyword list file read: a file is input (a list of 100,000 keywords is ~2 MB).
|
||||
const MAX_LIST_BYTES: u64 = 16 << 20;
|
||||
|
||||
/// The characters Capture One's keyword importer refuses in a list (`|` can't be in a name here).
|
||||
const CAPTURE_ONE_REFUSES: [char; 4] = [';', ',', '<', '>'];
|
||||
|
||||
/// A keyword list file's text: at most `MAX_LIST_BYTES`, UTF-8 (with or without a byte-order mark).
|
||||
fn read_list(path: &str) -> Result<String> {
|
||||
use std::io::Read;
|
||||
let file = std::fs::File::open(path).map_err(|e| bad("keyword.import", format!("{path}: {e}")))?;
|
||||
let mut bytes = Vec::new();
|
||||
file.take(MAX_LIST_BYTES + 1).read_to_end(&mut bytes).map_err(|e| bad("keyword.import", format!("{path}: {e}")))?;
|
||||
if bytes.len() as u64 > MAX_LIST_BYTES {
|
||||
return Err(bad("keyword.import", format!("{path}: larger than {} MB, not a keyword list", MAX_LIST_BYTES >> 20)));
|
||||
}
|
||||
// Notepad and some exporters save UTF-16: say how to get the file in, not just that it isn't
|
||||
if bytes.starts_with(&[0xff, 0xfe]) || bytes.starts_with(&[0xfe, 0xff]) {
|
||||
return Err(bad("keyword.import", format!("{path}: UTF-16 text — save it as UTF-8 and import it again")));
|
||||
}
|
||||
String::from_utf8(bytes).map_err(|_| bad("keyword.import", format!("{path}: not UTF-8 text")))
|
||||
}
|
||||
|
||||
/// A set's slots as typed: each cleaned in its place (an empty one is an empty slot, so the others
|
||||
/// keep their ⌥ keys), each keyword once whatever its case (a second one leaves its slot empty),
|
||||
/// nine at most, no empty ones at the end.
|
||||
pub fn slots(typed: &[String]) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for k in typed.iter().take(9) {
|
||||
let k = clean(k);
|
||||
let twice = !k.is_empty() && out.iter().any(|x| lightcraft_catalog::keywords::same(x, &k));
|
||||
out.push(if twice { String::new() } else { k });
|
||||
}
|
||||
while out.last().is_some_and(String::is_empty) {
|
||||
out.pop();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The nine keywords ⌥1–⌥9 apply: the current set's, or the recent ones.
|
||||
pub fn current_keywords(s: &Session) -> Vec<String> {
|
||||
let set = s.keyword_set.as_deref().and_then(|n| s.keyword_sets.iter().find(|x| x.name.eq_ignore_ascii_case(n)));
|
||||
let set = s.keyword_set.as_deref().and_then(|n| s.keyword_sets.iter().find(|x| same_set(&x.name, n)));
|
||||
let mut v = set.map_or_else(|| s.recent_keywords.clone(), |x| x.keywords.clone());
|
||||
v.truncate(9);
|
||||
v
|
||||
@@ -75,13 +153,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(query "keyword.sets", "Keyword Sets", [], None, "{} → {sets: [{name, keywords}], current, keywords: the nine ⌥1–⌥9 apply}", always, |s, _| Ok(keyword_sets_json(s))),
|
||||
cmd!("keyword.useSet", "Use Keyword Set", [], None, "{name} (\"Recent Keywords\" = the recently added ones)", always, |s, p| {
|
||||
let name = str_param(p, "name").map(str::trim).unwrap_or(RECENT);
|
||||
s.keyword_set = if name.eq_ignore_ascii_case(RECENT) || name.is_empty() {
|
||||
s.keyword_set = if same_set(name, RECENT) || name.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
s.keyword_sets
|
||||
.iter()
|
||||
.find(|x| x.name.eq_ignore_ascii_case(name))
|
||||
.find(|x| same_set(&x.name, name))
|
||||
.ok_or_else(|| bad("keyword.useSet", format!("no keyword set `{name}`")))?
|
||||
.name
|
||||
.clone(),
|
||||
@@ -95,24 +173,53 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Save Keyword Set",
|
||||
[],
|
||||
None,
|
||||
"{name, keywords?: [up to 9] (default: the current nine)} — replaces a set of that name and makes it current",
|
||||
"{name, keywords?: [up to 9, \"\" = an empty slot] (default: the renamed set's, else the current nine), replace?: the set it renames, new?: refuse a name another set has} — replaces a set of that name (or `replace`, in its place) and makes it current",
|
||||
always,
|
||||
|s, p| {
|
||||
let name = str_param(p, "name")
|
||||
.map(str::trim)
|
||||
.filter(|n| !n.is_empty() && !n.eq_ignore_ascii_case(RECENT))
|
||||
.filter(|n| !n.is_empty() && !same_set(n, RECENT))
|
||||
.ok_or_else(|| bad("keyword.saveSet", "missing or reserved `name`"))?
|
||||
.to_string();
|
||||
let mut keywords: Vec<String> = if p.get("keywords").is_some() {
|
||||
strs(p, "keywords").iter().map(|k| clean(k)).filter(|k| !k.is_empty()).collect()
|
||||
let same_name = |x: &KeywordSet, n: &str| same_set(&x.name, n);
|
||||
// the set it renames, if any (an empty `replace` is none)
|
||||
let renames = match str_param(p, "replace").map(str::trim).filter(|o| !o.is_empty()) {
|
||||
Some(old) => Some(
|
||||
s.keyword_sets
|
||||
.iter()
|
||||
.position(|x| same_name(x, old))
|
||||
.ok_or_else(|| bad("keyword.saveSet", format!("no keyword set `{old}`")))?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
// keywords not given: the renamed set's own, else the current nine
|
||||
let keywords = if p.get("keywords").is_some() {
|
||||
slots(&strs(p, "keywords"))
|
||||
} else {
|
||||
current_keywords(s)
|
||||
match renames.and_then(|at| s.keyword_sets.get(at)) {
|
||||
Some(x) => x.keywords.clone(),
|
||||
None => current_keywords(s),
|
||||
}
|
||||
};
|
||||
keywords.truncate(9);
|
||||
let set = KeywordSet { name: name.clone(), keywords };
|
||||
match s.keyword_sets.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&name)) {
|
||||
Some(x) => *x = set,
|
||||
None => s.keyword_sets.push(set),
|
||||
match renames {
|
||||
// rename: in its place, onto a name no other set has
|
||||
Some(at) => {
|
||||
if s.keyword_sets.iter().enumerate().any(|(i, x)| i != at && same_name(x, &name)) {
|
||||
return Err(bad("keyword.saveSet", format!("there is a keyword set “{name}” already")));
|
||||
}
|
||||
if let Some(x) = s.keyword_sets.get_mut(at) {
|
||||
*x = set;
|
||||
}
|
||||
}
|
||||
// a new set (`new`) never takes another set's name
|
||||
None if bool_or(p, "new", false) && s.keyword_sets.iter().any(|x| same_name(x, &name)) => {
|
||||
return Err(bad("keyword.saveSet", format!("there is a keyword set “{name}” already")));
|
||||
}
|
||||
None => match s.keyword_sets.iter_mut().find(|x| same_name(x, &name)) {
|
||||
Some(x) => *x = set,
|
||||
None => s.keyword_sets.push(set),
|
||||
},
|
||||
}
|
||||
s.keyword_set = Some(name);
|
||||
s.save_prefs()?;
|
||||
@@ -122,11 +229,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("keyword.deleteSet", "Delete Keyword Set", [], None, "{name}", always, |s, p| {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("keyword.deleteSet", "missing `name`"))?;
|
||||
let before = s.keyword_sets.len();
|
||||
s.keyword_sets.retain(|x| !x.name.eq_ignore_ascii_case(name));
|
||||
s.keyword_sets.retain(|x| !same_set(&x.name, name));
|
||||
if s.keyword_sets.len() == before {
|
||||
return Err(bad("keyword.deleteSet", format!("no keyword set `{name}`")));
|
||||
}
|
||||
if s.keyword_set.as_deref().is_some_and(|c| c.eq_ignore_ascii_case(name)) {
|
||||
if s.keyword_set.as_deref().is_some_and(|c| same_set(c, name)) {
|
||||
s.keyword_set = None;
|
||||
}
|
||||
s.save_prefs()?;
|
||||
@@ -145,12 +252,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.and_then(Value::as_u64)
|
||||
.filter(|i| (1..=9).contains(i))
|
||||
.ok_or_else(|| bad("keyword.toggleFromSet", "`index` must be 1..9"))?;
|
||||
let Some(k) = current_keywords(s).get(i as usize - 1).cloned() else {
|
||||
// (an empty slot does nothing)
|
||||
let Some(k) = current_keywords(s).get(i as usize - 1).cloned().filter(|k| !k.is_empty()) else {
|
||||
return Ok(json!({"changed": 0}));
|
||||
};
|
||||
let ids = s.targets(p);
|
||||
let all = !ids.is_empty()
|
||||
&& ids.iter().all(|id| s.catalog.photo(*id).is_some_and(|ph| ph.meta.keywords.iter().any(|x| x.eq_ignore_ascii_case(&k))));
|
||||
&& ids.iter().all(|id| {
|
||||
s.catalog
|
||||
.photo(*id)
|
||||
.is_some_and(|ph| ph.meta.keywords.iter().any(|x| lightcraft_catalog::keywords::same(&clean(x), &clean(&k))))
|
||||
});
|
||||
let ids: Vec<u64> = ids.iter().map(|i| i.0).collect();
|
||||
let key = if all { "removeKeywords" } else { "addKeywords" };
|
||||
// applying from Recent Keywords mustn't reshuffle the numbers under the keys
|
||||
@@ -185,6 +297,184 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!(s.catalog.keyword_suggestions(¤t, str_param(p, "prefix").unwrap_or(""), n)))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
query "keyword.info",
|
||||
"Keyword Info",
|
||||
[],
|
||||
None,
|
||||
"{keyword} → {path, listed, count, synonyms, includeOnExport, exportContaining, exportSynonyms, person}",
|
||||
always,
|
||||
|s, p| {
|
||||
let path = named_keyword(s, p, "keyword.info")?;
|
||||
let info = s.catalog.keyword_info(&path).cloned();
|
||||
let count = s.catalog.photos().filter(|ph| ph.in_library() && ph.meta.keywords.iter().any(|k| is_under(k, &path))).count();
|
||||
let mut v = serde_json::to_value(info.clone().unwrap_or_default()).unwrap_or_default();
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("path".into(), json!(path));
|
||||
o.insert("listed".into(), json!(info.is_some()));
|
||||
o.insert("count".into(), json!(count));
|
||||
o.entry("synonyms").or_insert(json!([]));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"keyword.create",
|
||||
"Create Keyword",
|
||||
[],
|
||||
None,
|
||||
"{name, parent?: keyword | null (default: the default parent; null = the top level), synonyms?, includeOnExport?, exportContaining?, exportSynonyms?, person?, addToSelected?: bool, ids?} → {keyword}",
|
||||
always,
|
||||
|s, p| {
|
||||
let name = str_param(p, "name").map(clean).filter(|n| !n.is_empty()).ok_or_else(|| bad("keyword.create", "missing `name`"))?;
|
||||
let parent = match p.get("parent") {
|
||||
Some(Value::String(k)) => Some(s.catalog.keyword_path(k).unwrap_or_else(|| clean(k))),
|
||||
Some(_) => None,
|
||||
None => s.catalog.default_keyword_parent(),
|
||||
};
|
||||
let path = clean(&match parent {
|
||||
Some(parent) => format!("{parent}|{name}"),
|
||||
None => name,
|
||||
});
|
||||
let photos = if bool_or(p, "addToSelected", false) { s.targets(p) } else { Vec::new() };
|
||||
let op = s
|
||||
.catalog
|
||||
.create_keyword_ops(&path, info_from(p, KeywordInfo::default()), &photos)
|
||||
.map_err(|e| refused("keyword.create", e, ""))?;
|
||||
s.commit("Create Keyword", op)?;
|
||||
if !photos.is_empty() {
|
||||
note_recent(s, std::slice::from_ref(&path));
|
||||
}
|
||||
Ok(json!({"keyword": path}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"keyword.edit",
|
||||
"Edit Keyword",
|
||||
[],
|
||||
None,
|
||||
"{keyword, name?: its new name (one level), synonyms?, includeOnExport?, exportContaining?, exportSynonyms?, person?} — what isn't given keeps its value",
|
||||
always,
|
||||
|s, p| {
|
||||
let from = named_keyword(s, p, "keyword.edit")?;
|
||||
let leaf = from.rsplit('|').next().unwrap_or(&from).to_string();
|
||||
let name = str_param(p, "name").unwrap_or(&leaf).to_string();
|
||||
let info = info_from(p, s.catalog.keyword_info(&from).cloned().unwrap_or_default());
|
||||
let op =
|
||||
s.catalog.edit_keyword_ops(&from, &name, info).map_err(|e| refused("keyword.edit", e, " (to merge them, use keyword.merge)"))?;
|
||||
let to = match from.rsplit_once('|') {
|
||||
Some((parent, _)) => format!("{parent}|{}", name.trim()),
|
||||
None => name.trim().to_string(),
|
||||
};
|
||||
commit_keywords(s, "Edit Keyword", op, |k| Some(if is_under(k, &from) { reparent(k, &from, &to) } else { k.to_string() }))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"keyword.move",
|
||||
"Move Keyword",
|
||||
[],
|
||||
None,
|
||||
"{keyword, parent: keyword | null (the top level), merge?: bool} — with the keywords below it; onto a name that is taken only with `merge: true`",
|
||||
always,
|
||||
|s, p| {
|
||||
let from = named_keyword(s, p, "keyword.move")?;
|
||||
let parent = match p.get("parent") {
|
||||
Some(Value::String(k)) => Some(k.clone()),
|
||||
Some(Value::Null) => None,
|
||||
_ => return Err(bad("keyword.move", "`parent` is a keyword, or null for the top level")),
|
||||
};
|
||||
// where it lands, as the catalog spells it: a parent that doesn't exist yet is made
|
||||
let leaf = from.rsplit('|').next().unwrap_or(&from).to_string();
|
||||
let to = match parent.as_deref().map(|k| s.catalog.keyword_path(k).unwrap_or_else(|| clean(k))).filter(|k| !k.is_empty()) {
|
||||
Some(parent) => format!("{parent}|{leaf}"),
|
||||
None => leaf,
|
||||
};
|
||||
let op = s
|
||||
.catalog
|
||||
.move_keyword_ops(&from, parent.as_deref(), bool_or(p, "merge", false))
|
||||
.map_err(|e| refused("keyword.move", e, ": moving would merge the two (merge: true)"))?;
|
||||
commit_keywords(s, "Move Keyword", op, |k| Some(if is_under(k, &from) { reparent(k, &from, &to) } else { k.to_string() }))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"keyword.purgeUnused",
|
||||
"Purge Unused Keywords",
|
||||
[],
|
||||
None,
|
||||
"{} — takes off the keyword list the keywords no photo has → {purged}",
|
||||
always,
|
||||
|s, _| {
|
||||
let op = s.catalog.purge_unused_keywords_ops();
|
||||
let r = commit_keywords(s, "Purge Unused Keywords", op, |k| Some(k.to_string()))?;
|
||||
Ok(json!({"purged": r["listed"]}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"keyword.setDefaultParent",
|
||||
"Put New Keywords Inside",
|
||||
[],
|
||||
None,
|
||||
"{keyword: keyword | null} — the parent keyword.create puts new keywords inside (null: the top level)",
|
||||
always,
|
||||
|s, p| {
|
||||
let keyword = match p.get("keyword") {
|
||||
Some(Value::Null) | None => None,
|
||||
Some(_) => Some(named_keyword(s, p, "keyword.setDefaultParent")?),
|
||||
};
|
||||
let op = s.catalog.set_default_parent_ops(keyword.as_deref()).map_err(|e| bad("keyword.setDefaultParent", e.to_string()))?;
|
||||
commit_keywords(s, "Put New Keywords Inside", op, |k| Some(k.to_string()))?;
|
||||
Ok(json!({"keyword": s.catalog.default_keyword_parent()}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"keyword.export",
|
||||
"Export Keywords",
|
||||
[],
|
||||
None,
|
||||
"{path?} — the keyword list as a file (Lightroom Classic's format, which Capture One, Photo Supreme and Bridge read) → {keywords, captureOneRefuses: keywords with ; , < > that Capture One's importer refuses, unwritable: keywords left out because the format can't hold them (a name in brackets or braces, a line break, deeper than 64 levels; a synonym as \"path {synonym}\"), text when there is no path}",
|
||||
always,
|
||||
|s, p| {
|
||||
let file = s.catalog.keyword_list_text();
|
||||
// Capture One's importer refuses ; , < > in a keyword's name
|
||||
let refuses: Vec<&str> = file
|
||||
.written
|
||||
.iter()
|
||||
.filter_map(|p| p.rsplit(lightcraft_catalog::keywords::SEP).next())
|
||||
.filter(|n| n.contains(CAPTURE_ONE_REFUSES))
|
||||
.collect();
|
||||
let mut r = json!({"keywords": file.written.len(), "captureOneRefuses": refuses, "unwritable": file.unwritable});
|
||||
match str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()) {
|
||||
Some(path) => {
|
||||
std::fs::write(path, &file.text).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?;
|
||||
r["path"] = json!(path);
|
||||
}
|
||||
None => r["text"] = json!(file.text),
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"keyword.import",
|
||||
"Import Keywords",
|
||||
[],
|
||||
None,
|
||||
"{path | text} — a keyword list (Lightroom Classic's format, also Capture One's and Photo Supreme's Formatted Vocabulary File): adds the keywords the library doesn't have, gives those it has the list's synonyms; one undo step → {added, updated}",
|
||||
always,
|
||||
|s, p| {
|
||||
let text = match (str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()), str_param(p, "text")) {
|
||||
(Some(path), _) => read_list(path)?,
|
||||
(None, Some(text)) if text.len() as u64 > MAX_LIST_BYTES => {
|
||||
return Err(bad("keyword.import", format!("larger than {} MB, not a keyword list", MAX_LIST_BYTES >> 20)));
|
||||
}
|
||||
(None, Some(text)) => text.to_string(),
|
||||
(None, None) => return Err(bad("keyword.import", "give `path` or `text`")),
|
||||
};
|
||||
let entries = lightcraft_catalog::keywords::parse_keyword_list(&text).map_err(|e| bad("keyword.import", e.to_string()))?;
|
||||
let (op, added, updated) = s.catalog.import_keywords_ops(&entries);
|
||||
commit_keywords(s, "Import Keywords", op, |k| Some(k.to_string()))?;
|
||||
Ok(json!({"added": added, "updated": updated}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"keyword.rename",
|
||||
"Rename Keyword",
|
||||
@@ -197,9 +487,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let to = str_param(p, "to").ok_or_else(|| bad("keyword.rename", "missing `to`"))?.to_string();
|
||||
let op = s.catalog.rename_keyword_ops(&from, &to).map_err(|e| bad("keyword.rename", e.to_string()))?;
|
||||
let (f, t) = (clean(&from), clean(&to));
|
||||
commit_keywords(s, "Rename Keyword", op, |k| {
|
||||
Some(if is_under(k, &f) { format!("{t}{}", &k[f.len().min(k.len())..]) } else { k.to_string() })
|
||||
})
|
||||
commit_keywords(s, "Rename Keyword", op, |k| Some(if is_under(k, &f) { reparent(k, &f, &t) } else { k.to_string() }))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
@@ -229,8 +517,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let op = s.catalog.merge_keywords_ops(&from, &into).map_err(|e| bad("keyword.merge", e.to_string()))?;
|
||||
let (from, into) = (from.iter().map(|f| clean(f)).collect::<Vec<_>>(), clean(&into));
|
||||
commit_keywords(s, "Merge Keywords", op, |k| {
|
||||
Some(match from.iter().find(|f| is_under(k, f)) {
|
||||
Some(f) => format!("{into}{}", &k[f.len().min(k.len())..]),
|
||||
Some(match closest(k, &from) {
|
||||
Some(f) => reparent(k, f, &into),
|
||||
None => k.to_string(),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,20 @@ use serde_json::{Value, json};
|
||||
use super::{CommandSpec, always, bad, bool_or, cmd, has_active, has_selection, ok, str_param};
|
||||
use crate::{LibrarySource, Result, Selection, Session};
|
||||
|
||||
fn select_after_deletion(s: &mut Session, before: &[PhotoId]) {
|
||||
let at = before.iter().position(|id| Some(*id) == s.selection.active);
|
||||
let remaining: std::collections::HashSet<_> = s.visible_cloned().into_iter().collect();
|
||||
s.selection.ids.retain(|id| remaining.contains(id));
|
||||
s.selection.active = s.selection.active.filter(|id| remaining.contains(id)).or_else(|| s.selection.ids.first().copied());
|
||||
if s.selection.active.is_none()
|
||||
&& let Some(at) = at
|
||||
{
|
||||
// Keep culling near the deleted photo: next visible survivor, or the previous one at the end.
|
||||
let neighbour = before.iter().skip(at.saturating_add(1)).chain(before.iter().take(at).rev()).find(|id| remaining.contains(id));
|
||||
s.selection = neighbour.map(|id| Selection::single(*id)).unwrap_or_default();
|
||||
}
|
||||
}
|
||||
|
||||
/// The auto-import folder's visible files with their sizes (the file-system half of
|
||||
/// `library.autoImportScan`; the app lists on a worker thread and passes `listing`).
|
||||
pub fn list_auto_import_folder(folder: &str) -> std::result::Result<Vec<(String, u64)>, String> {
|
||||
@@ -146,6 +160,24 @@ fn for_targets(s: &mut Session, p: &Value, label: &str, f: impl Fn(PhotoId) -> O
|
||||
Ok(json!({"changed": n}))
|
||||
}
|
||||
|
||||
/// Adjust each target's own rating; unchanged bounds do not create an undo step.
|
||||
fn change_rating(s: &mut Session, p: &Value, delta: i8, label: &str) -> Result<Value> {
|
||||
let mut ops = Vec::new();
|
||||
for id in s.targets(p) {
|
||||
let old = s.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?.rating;
|
||||
let rating = (i16::from(old) + i16::from(delta)).clamp(0, 5) as u8;
|
||||
if rating != old {
|
||||
ops.push(Op::SetRating { id, rating });
|
||||
}
|
||||
}
|
||||
let n = ops.len();
|
||||
if n > 0 {
|
||||
s.commit(label, Op::Batch { ops })?;
|
||||
}
|
||||
advance_if(s, p);
|
||||
Ok(json!({"changed": n}))
|
||||
}
|
||||
|
||||
fn advance_if(s: &mut Session, p: &Value) {
|
||||
if bool_or(p, "advance", false) {
|
||||
let _ = step(s, 1);
|
||||
@@ -330,6 +362,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
if f.library_folder.is_some() && s.source == LibrarySource::LibraryFolder {
|
||||
return Err(bad("library.filter", "a folder is already shown (library.source): show another source first"));
|
||||
}
|
||||
// checked like a smart album's rules when this call sets them: an unknown field or a value
|
||||
// that can't match is an error, not an empty grid. Rules that stopped checking since
|
||||
// (their album was deleted) don't block other filter changes.
|
||||
if let Some(rules) = f.rule_set.as_mut() {
|
||||
rules.upgrade();
|
||||
}
|
||||
let sets_rules = p.get("ruleSet").is_some_and(|r| !r.is_null());
|
||||
if let Some(problems) = f.rule_set.as_ref().filter(|_| sets_rules).map(|rs| rs.check(&s.catalog)).filter(|p| !p.is_empty()) {
|
||||
return Err(bad("library.filter", problems.iter().map(ToString::to_string).collect::<Vec<_>>().join("; ")));
|
||||
}
|
||||
s.filter = f;
|
||||
Ok(json!({"count": s.visible().len()}))
|
||||
}
|
||||
@@ -339,7 +381,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Library Folders",
|
||||
[],
|
||||
None,
|
||||
"{} → [{name, path, count, own, volume, selectable, children}] the disks and the folders the library's photos were imported from, with photo counts (subfolders included in `count`); show one with library.source {kind: libraryFolder, path} (rows with selectable false only open)",
|
||||
"{} → [{name, path, count, own, volume, selectable, label?, children}] the disks and the folders the library's photos were imported from, with photo counts (subfolders included in `count`); show one with library.source {kind: libraryFolder, path} (rows with selectable false only open)",
|
||||
always,
|
||||
|s, _| Ok(serde_json::to_value(s.catalog.folder_tree()).unwrap_or_default())
|
||||
),
|
||||
@@ -387,6 +429,33 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"removed": ids.len()}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"folder.label",
|
||||
"Set Folder Color Label",
|
||||
[],
|
||||
None,
|
||||
"{path, label: red|yellow|green|blue|purple|none} — give a folder of the library (one its photos were imported from, see library.folders) a colour label, or take it off (also once its photos are gone); one undo step. The label follows the folder when it is renamed or moved with folder.rename / folder.move → {path, label}",
|
||||
always,
|
||||
|s, p| {
|
||||
const C: &str = "folder.label";
|
||||
let path = str_param(p, "path").filter(|d| !d.trim().is_empty()).ok_or_else(|| bad(C, "missing `path`"))?;
|
||||
let label = match str_param(p, "label").ok_or_else(|| bad(C, "missing `label` (red|yellow|green|blue|purple|none)"))? {
|
||||
"none" => None,
|
||||
x => Some(ColorLabel::parse(x).ok_or_else(|| bad(C, format!("unknown label {x:?}")))?),
|
||||
};
|
||||
// taking a label off always works, also once the folder's photos are gone
|
||||
let f = Filter { library_folder: Some(path.to_string()), ..Default::default() };
|
||||
if label.is_some() && s.catalog.query(&f, &Sort::default()).is_empty() {
|
||||
return Err(bad(C, format!("{path}: no photo in the library was imported from it")));
|
||||
}
|
||||
if label.is_none() && s.catalog.folder_record(path).is_none() {
|
||||
return Ok(json!({"path": path, "label": label}));
|
||||
}
|
||||
let op = s.catalog.folder_label_op(path, label);
|
||||
s.commit(if label.is_some() { "Set Folder Color Label" } else { "Remove Folder Color Label" }, op)?;
|
||||
Ok(json!({"path": path, "label": label}))
|
||||
}
|
||||
),
|
||||
cmd!("library.clearFilter", "Clear Filters", ["View"], None, "{}", always, |s, _| {
|
||||
s.filter = Default::default();
|
||||
Ok(json!({"count": s.visible().len()}))
|
||||
@@ -564,6 +633,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
advance_if(s, p);
|
||||
Ok(v)
|
||||
}),
|
||||
cmd!("photo.decreaseRating", "Decrease Rating", [], None, "{ids?, advance?: bool}", has_selection, |s, p| {
|
||||
change_rating(s, p, -1, "Decrease Rating")
|
||||
}),
|
||||
cmd!("photo.increaseRating", "Increase Rating", [], None, "{ids?, advance?: bool}", has_selection, |s, p| {
|
||||
change_rating(s, p, 1, "Increase Rating")
|
||||
}),
|
||||
cmd!("photo.flag", "Set Flag", ["Photo", "Set Flag"], None, "{flag: pick|reject|none, ids?, advance?: bool}", has_selection, |s, p| {
|
||||
let f = str_param(p, "flag").and_then(Flag::parse).ok_or_else(|| bad("photo.flag", "flag must be pick|reject|none"))?;
|
||||
let v = for_targets(s, p, "Set Flag", |id| Some(Op::SetFlag { id, flag: f }))?;
|
||||
@@ -662,18 +737,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
// ---- delete / restore
|
||||
cmd!("photo.delete", "Delete Photo", ["Photo"], Some("Delete"), "{ids?} — moves to Recently Deleted", has_selection, |s, p| {
|
||||
let before = s.visible_cloned();
|
||||
let at = before.iter().position(|id| Some(*id) == s.selection.active);
|
||||
let v = for_targets(s, p, "Delete", |id| Some(Op::SetDeleted { id, deleted: true }))?;
|
||||
let remaining: std::collections::HashSet<_> = s.visible_cloned().into_iter().collect();
|
||||
s.selection.ids.retain(|id| remaining.contains(id));
|
||||
s.selection.active = s.selection.active.filter(|id| remaining.contains(id)).or_else(|| s.selection.ids.first().copied());
|
||||
if s.selection.active.is_none()
|
||||
&& let Some(at) = at
|
||||
{
|
||||
// Keep culling near the deleted photo: next visible survivor, or the previous one at the end.
|
||||
let neighbour = before.iter().skip(at.saturating_add(1)).chain(before.iter().take(at).rev()).find(|id| remaining.contains(id));
|
||||
s.selection = neighbour.map(|id| Selection::single(*id)).unwrap_or_default();
|
||||
}
|
||||
select_after_deletion(s, &before);
|
||||
Ok(v)
|
||||
}),
|
||||
cmd!("photo.restore", "Restore", ["Photo"], None, "{ids?}", has_selection, |s, p| for_targets(s, p, "Restore", |id| Some(Op::SetDeleted {
|
||||
@@ -699,10 +764,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
),
|
||||
cmd!("photo.deletePermanently", "Delete Permanently", ["Photo"], None, "{ids?}", has_selection, |s, p| {
|
||||
let before = s.visible_cloned();
|
||||
let t = s.targets(p);
|
||||
let op = s.catalog.delete_photos_permanently_ops(&t);
|
||||
s.commit("Delete Permanently", op)?;
|
||||
s.selection = Selection::default();
|
||||
select_after_deletion(s, &before);
|
||||
Ok(json!({"deleted": t.len()}))
|
||||
}),
|
||||
// ---- metadata
|
||||
@@ -766,16 +832,26 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
*field = v.to_string();
|
||||
}
|
||||
}
|
||||
if let Some(k) = strs("keywords") {
|
||||
m.keywords = k;
|
||||
}
|
||||
for k in strs("addKeywords").unwrap_or_default() {
|
||||
if !m.keywords.iter().any(|x| x.eq_ignore_ascii_case(&k)) {
|
||||
// keywords are stored cleaned (`a | b ` is `a|b`), each once whatever its case
|
||||
use lightcraft_catalog::keywords::{clean, same};
|
||||
let add = |m: &mut lightcraft_catalog::Meta, k: &str| {
|
||||
let k = clean(k);
|
||||
if !k.is_empty() && !m.keywords.iter().any(|x| same(x, &k)) {
|
||||
m.keywords.push(k);
|
||||
}
|
||||
};
|
||||
if let Some(k) = strs("keywords") {
|
||||
m.keywords.clear();
|
||||
for k in k {
|
||||
add(&mut m, &k);
|
||||
}
|
||||
}
|
||||
for k in strs("addKeywords").unwrap_or_default() {
|
||||
add(&mut m, &k);
|
||||
}
|
||||
for k in strs("removeKeywords").unwrap_or_default() {
|
||||
m.keywords.retain(|x| !x.eq_ignore_ascii_case(&k));
|
||||
let k = clean(&k);
|
||||
m.keywords.retain(|x| !same(&clean(x), &k));
|
||||
}
|
||||
ops.push(Op::SetMeta { id, meta: Box::new(m) });
|
||||
}
|
||||
@@ -819,7 +895,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
return Err(bad("album.createSmart", "empty name"));
|
||||
}
|
||||
let rules = match p.get("rules") {
|
||||
Some(r) => merge_rules(&lightcraft_catalog::Filter::default(), r, "album.createSmart")?,
|
||||
Some(r) => merge_rules(&lightcraft_catalog::Filter::default(), r, "album.createSmart", &s.catalog, None)?,
|
||||
None => view_rules(s),
|
||||
};
|
||||
let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId);
|
||||
@@ -829,8 +905,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"id": id.0, "count": s.catalog.album_count(id)}))
|
||||
}
|
||||
),
|
||||
cmd!(query "album.ruleFields", "Smart Album Rule Fields", [], None, "{} → [{field, label, kind, ops: [{op, label}], choices?}] for ruleSet rules", always, |_, _| {
|
||||
use lightcraft_catalog::rules::{FIELDS, Kind, ops_for};
|
||||
cmd!(query "album.ruleFields", "Smart Album Rule Fields", [], None, "{} → [{field, label, kind (text|keywords|number|date|choice|bool|album: an album id), group (field-menu submenu, null at the top level), ops: [{op, label}], choices? (ids), choiceLabels? ({id: label})}] for ruleSet rules, in menu order", always, |_, _| {
|
||||
use lightcraft_catalog::rules::{FIELDS, Kind, field_group, ops_for};
|
||||
Ok(json!(FIELDS
|
||||
.iter()
|
||||
.map(|(id, label, kind)| {
|
||||
@@ -838,13 +914,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Kind::Text => "text",
|
||||
Kind::Keywords => "keywords",
|
||||
Kind::Number => "number",
|
||||
Kind::Album => "album",
|
||||
Kind::Date => "date",
|
||||
Kind::Choice(_) => "choice",
|
||||
Kind::Bool => "bool",
|
||||
};
|
||||
let mut v = json!({"field": id, "label": label, "kind": k, "ops": ops_for(*kind).iter().map(|(o, l)| json!({"op": o, "label": l})).collect::<Vec<_>>()});
|
||||
let mut v = json!({"field": id, "label": label, "kind": k, "group": field_group(id), "ops": ops_for(*kind).iter().map(|(o, l)| json!({"op": o, "label": l})).collect::<Vec<_>>()});
|
||||
if let Kind::Choice(c) = kind {
|
||||
v["choices"] = json!(c);
|
||||
v["choices"] = json!(c.iter().map(|c| c.0).collect::<Vec<_>>());
|
||||
v["choiceLabels"] = c.iter().map(|(id, label)| (id.to_string(), json!(label))).collect::<serde_json::Map<_, _>>().into();
|
||||
}
|
||||
v
|
||||
})
|
||||
@@ -855,18 +933,34 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Edit Smart Album",
|
||||
[],
|
||||
None,
|
||||
"{id, rules?: partial Filter merged onto the current rules (null clears a field), replace?: bool, fromView?: bool (use the current view)}",
|
||||
"{id, rules?: partial Filter merged onto the current rules (null clears a field), replace?: bool, fromView?: bool (use the current view), name?: also rename it (blank keeps the name) — one undo step, both or neither}",
|
||||
always,
|
||||
|s, p| {
|
||||
let id = album_param(p, "id", "album.setRules")?;
|
||||
let cur = s.catalog.album(id).and_then(|a| a.smart.as_deref().cloned()).ok_or_else(|| bad("album.setRules", "not a smart album"))?;
|
||||
let rules = if bool_or(p, "fromView", false) {
|
||||
view_rules(s)
|
||||
// the view is saved as it is, but never as a loop (showing A, which tests this
|
||||
// album, and saving that view here would make it test itself)
|
||||
let view = view_rules(s);
|
||||
let loops: Vec<String> = view
|
||||
.rule_set
|
||||
.as_ref()
|
||||
.map(|r| r.check_for(&s.catalog, Some(id)))
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.chain(s.catalog.album_filter_problem(&view, Some(id)))
|
||||
.filter(|p| p.issue == lightcraft_catalog::rules::Issue::AlbumLoop)
|
||||
.map(|p| p.to_string())
|
||||
.collect();
|
||||
if !loops.is_empty() {
|
||||
return Err(bad("album.setRules", loops.join("; ")));
|
||||
}
|
||||
view
|
||||
} else {
|
||||
let replace = bool_or(p, "replace", false);
|
||||
let folder = cur.library_folder.clone();
|
||||
let base = if replace { Default::default() } else { cur };
|
||||
let mut r = merge_rules(&base, p.get("rules").unwrap_or(&Value::Null), "album.setRules")?;
|
||||
let mut r = merge_rules(&base, p.get("rules").unwrap_or(&Value::Null), "album.setRules", &s.catalog, Some(id))?;
|
||||
// the rules dialog has no folder field: replacing its rules keeps the folder
|
||||
// unless the call says (`libraryFolder: null`) to drop it
|
||||
if replace && p.get("rules").and_then(|r| r.get("libraryFolder")).is_none() {
|
||||
@@ -874,7 +968,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
r
|
||||
};
|
||||
s.commit("Edit Smart Album", Op::SetAlbumRules { id, rules: Box::new(rules) })?;
|
||||
let set = Op::SetAlbumRules { id, rules: Box::new(rules) };
|
||||
// a new name goes with the rules: one step, applied (and undone) together
|
||||
let rename = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty() && s.catalog.album(id).is_some_and(|a| a.name != *n));
|
||||
match rename {
|
||||
Some(name) => s.commit("Edit Smart Album", Op::Batch { ops: vec![Op::RenameAlbum { id, name: name.to_string() }, set] })?,
|
||||
None => s.commit("Edit Smart Album", set)?,
|
||||
};
|
||||
Ok(json!({"count": s.catalog.album_count(id)}))
|
||||
}
|
||||
),
|
||||
@@ -884,14 +984,45 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s.commit("Rename Album", Op::RenameAlbum { id, name })?;
|
||||
ok()
|
||||
}),
|
||||
cmd!("album.delete", "Delete Album", [], None, "{id}", always, |s, p| {
|
||||
let id = album_param(p, "id", "album.delete")?;
|
||||
s.commit("Delete Album", Op::RemoveAlbum { id })?;
|
||||
if s.source == LibrarySource::Album(id) {
|
||||
s.source = LibrarySource::All;
|
||||
cmd!(
|
||||
"album.delete",
|
||||
"Delete Album",
|
||||
[],
|
||||
None,
|
||||
"{id} — deletes the album or folder and its descendants, keeping photos in the library; one undo step",
|
||||
always,
|
||||
|s, p| {
|
||||
let id = album_param(p, "id", "album.delete")?;
|
||||
let folder = s.catalog.album(id).ok_or_else(|| bad("album.delete", "no such album"))?.folder;
|
||||
let mut children = std::collections::BTreeMap::<AlbumId, Vec<AlbumId>>::new();
|
||||
for album in s.catalog.albums() {
|
||||
if let Some(parent) = album.parent {
|
||||
children.entry(parent).or_default().push(album.id);
|
||||
}
|
||||
}
|
||||
// Iterative traversal: even a malformed cycle is rejected before any change.
|
||||
// Reverse parent-first order removes children first; the batch inverse restores
|
||||
// parents first, including memberships, smart rules, covers and manual ordering.
|
||||
let mut pending = vec![id];
|
||||
let mut order = Vec::new();
|
||||
let mut removed = std::collections::BTreeSet::new();
|
||||
while let Some(next) = pending.pop() {
|
||||
if !removed.insert(next) {
|
||||
return Err(bad("album.delete", "cycle in album folders"));
|
||||
}
|
||||
order.push(next);
|
||||
if let Some(nested) = children.remove(&next) {
|
||||
pending.extend(nested);
|
||||
}
|
||||
}
|
||||
let ops = order.into_iter().rev().map(|id| Op::RemoveAlbum { id }).collect();
|
||||
s.commit(if folder { "Delete Folder" } else { "Delete Album" }, Op::Batch { ops })?;
|
||||
if matches!(s.source, LibrarySource::Album(current) if removed.contains(¤t)) {
|
||||
s.source = LibrarySource::All;
|
||||
}
|
||||
ok()
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
),
|
||||
cmd!("album.move", "Move Album", [], None, "{id, parent?: folderId|null}", always, |s, p| {
|
||||
let id = album_param(p, "id", "album.move")?;
|
||||
let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId);
|
||||
@@ -1283,12 +1414,39 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
|
||||
/// `base` with a partial Filter (JSON) merged on top.
|
||||
fn merge_rules(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Result<lightcraft_catalog::Filter> {
|
||||
/// `patch` merged onto `base`, its rules brought up to date (`RuleSet::upgrade`), unchecked (see
|
||||
/// [`merge_rules`]).
|
||||
fn merge_filter(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Result<lightcraft_catalog::Filter> {
|
||||
let mut v = serde_json::to_value(base).unwrap_or_default();
|
||||
lightcraft_develop::presets::deep_merge(&mut v, patch);
|
||||
let f: lightcraft_catalog::Filter = serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))?;
|
||||
if let Some(problem) = f.rule_set.as_ref().and_then(|r| r.problems().into_iter().next()) {
|
||||
return Err(bad(c, problem));
|
||||
let mut f: lightcraft_catalog::Filter = serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))?;
|
||||
if let Some(rules) = f.rule_set.as_mut() {
|
||||
rules.upgrade();
|
||||
}
|
||||
Ok(f)
|
||||
}
|
||||
|
||||
/// `patch` merged onto `base`, refused when the rule set it sets has problems (`RuleSet::check_for`;
|
||||
/// `owner` is the smart album the rules are for, so testing an album that leads back to it is a
|
||||
/// problem too).
|
||||
fn merge_rules(
|
||||
base: &lightcraft_catalog::Filter,
|
||||
patch: &Value,
|
||||
c: &str,
|
||||
cat: &lightcraft_catalog::Catalog,
|
||||
owner: Option<AlbumId>,
|
||||
) -> Result<lightcraft_catalog::Filter> {
|
||||
let f = merge_filter(base, patch, c)?;
|
||||
// only rules this change sets are checked: one that stopped checking since (its album was
|
||||
// deleted) doesn't block editing the album's other settings
|
||||
let sets_rules = patch.get("ruleSet").is_some_and(|r| !r.is_null());
|
||||
let mut problems = f.rule_set.as_ref().filter(|_| sets_rules).map(|r| r.check_for(cat, owner)).unwrap_or_default();
|
||||
// and a loop through the album field the change sets
|
||||
if patch.get("album").is_some_and(|a| !a.is_null()) {
|
||||
problems.extend(cat.album_filter_problem(&f, owner));
|
||||
}
|
||||
if !problems.is_empty() {
|
||||
return Err(bad(c, problems.iter().map(ToString::to_string).collect::<Vec<_>>().join("; ")));
|
||||
}
|
||||
Ok(f)
|
||||
}
|
||||
@@ -1296,7 +1454,7 @@ fn merge_rules(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Res
|
||||
/// Whether `path` lies above a disk: some of its `ids` photos are on a disk (not the startup
|
||||
/// disk) that the folder does not lie in. Such a folder (`/Volumes`, `/mnt`…) is not a folder of
|
||||
/// one disk but a way to reach several.
|
||||
fn covers_other_disks(s: &Session, path: &str, ids: &[PhotoId]) -> bool {
|
||||
pub(crate) fn covers_other_disks(s: &Session, path: &str, ids: &[PhotoId]) -> bool {
|
||||
ids.iter()
|
||||
.filter_map(|id| s.catalog.photo(*id))
|
||||
.filter_map(|p| lightcraft_catalog::folders::volume_of(p))
|
||||
@@ -1324,7 +1482,9 @@ fn view_rules(s: &Session) -> lightcraft_catalog::Filter {
|
||||
}
|
||||
}
|
||||
}
|
||||
merge_rules(&base, &Value::Object(patch), "").unwrap_or(base)
|
||||
// the view as it is: a rule that no longer checks (its album was deleted) just matches
|
||||
// nothing there, and mustn't cost the filter bar's settings
|
||||
merge_filter(&base, &Value::Object(patch), "").unwrap_or(base)
|
||||
}
|
||||
None => {
|
||||
let mut f = s.source.to_filter(&s.filter, &s.catalog);
|
||||
@@ -1392,3 +1552,7 @@ mod gps_tests {
|
||||
assert_eq!(p("hello"), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "library/tests_relative_ratings.rs"]
|
||||
mod relative_rating_tests;
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Relative ratings dispatch through the real engine, including history and AutoWrite.
|
||||
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Result, Selection, Session};
|
||||
|
||||
fn session() -> Result<Session> {
|
||||
let mut s = Session::new();
|
||||
for (id, rating) in [(1, 0), (2, 2), (3, 5), (4, 1)] {
|
||||
let mut photo = Photo::new(
|
||||
PhotoId(id),
|
||||
Source::File { path: format!("/lightcraft-relative-ratings/{id}.jpg") },
|
||||
&format!("{id}.jpg"),
|
||||
"JPEG",
|
||||
40,
|
||||
30,
|
||||
"2026-10-09",
|
||||
);
|
||||
photo.rating = rating;
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) })?;
|
||||
}
|
||||
s.execute("library.sort", &json!({"key": "fileName", "ascending": true}))?;
|
||||
s.execute("library.select", &json!({"ids": [1, 2, 3], "active": 2}))?;
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
fn ratings(s: &Session) -> Result<Vec<u8>> {
|
||||
(1..=4)
|
||||
.map(|id| s.catalog.photo(PhotoId(id)).map(|p| p.rating).ok_or_else(|| lightcraft_catalog::CatalogError::NoPhoto(PhotoId(id)).into()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_ratings_clamp_each_photo_and_form_one_history_step() {
|
||||
let mut s = session().unwrap();
|
||||
let undo = s.undo.len();
|
||||
assert_eq!(s.execute("photo.increaseRating", &json!({})).unwrap()["changed"], 2);
|
||||
assert_eq!(ratings(&s).unwrap(), [1, 3, 5, 1]);
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
assert_eq!(s.active(), Some(PhotoId(2)));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(ratings(&s).unwrap(), [0, 2, 5, 1]);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(ratings(&s).unwrap(), [1, 3, 5, 1]);
|
||||
assert_eq!(s.execute("photo.decreaseRating", &json!({})).unwrap()["changed"], 3);
|
||||
assert_eq!(ratings(&s).unwrap(), [0, 2, 4, 1]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(ratings(&s).unwrap(), [1, 3, 5, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_active_fallback_explicit_targets_and_errors_preserve_history() {
|
||||
let mut s = session().unwrap();
|
||||
assert_eq!(s.execute("photo.decreaseRating", &json!({"ids": [1]})).unwrap()["changed"], 0);
|
||||
assert_eq!(s.execute("photo.increaseRating", &json!({"ids": [3]})).unwrap()["changed"], 0);
|
||||
assert!(s.undo.is_empty());
|
||||
s.selection = Selection { ids: vec![], active: Some(PhotoId(2)) };
|
||||
assert_eq!(s.execute("photo.increaseRating", &json!({})).unwrap()["changed"], 1);
|
||||
assert_eq!(ratings(&s).unwrap(), [0, 3, 5, 1]);
|
||||
assert_eq!(s.execute("photo.decreaseRating", &json!({"ids": [4]})).unwrap()["changed"], 1);
|
||||
assert_eq!(ratings(&s).unwrap(), [0, 3, 5, 0]);
|
||||
let undo = s.undo.len();
|
||||
assert!(s.execute("photo.increaseRating", &json!({"ids": [1, 999]})).is_err());
|
||||
assert_eq!(ratings(&s).unwrap(), [0, 3, 5, 0]);
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
s.execute("photo.increaseRating", &json!({"advance": true})).unwrap();
|
||||
assert_eq!(s.active(), Some(PhotoId(3)));
|
||||
s.selection = Selection::default();
|
||||
assert!(s.execute("photo.decreaseRating", &json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_ratings_and_history_use_auto_write() {
|
||||
let mut s = session().unwrap();
|
||||
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("lc-relative-ratings-{}-{stamp}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
// Metadata-only fixtures: no import, image decoding or model/service dependency.
|
||||
for id in 1..=4 {
|
||||
s.catalog
|
||||
.apply(Op::SetFile {
|
||||
id: PhotoId(id),
|
||||
file_name: format!("{id}.jpg"),
|
||||
source: Source::File { path: dir.join(format!("{id}.jpg")).to_string_lossy().into_owned() },
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap();
|
||||
s.execute("photo.increaseRating", &json!({})).unwrap();
|
||||
assert!(std::fs::read_to_string(dir.join("1.xmp")).unwrap().contains("<xmp:Rating>1</xmp:Rating>"));
|
||||
assert!(std::fs::read_to_string(dir.join("2.xmp")).unwrap().contains("<xmp:Rating>3</xmp:Rating>"));
|
||||
assert!(!dir.join("3.xmp").exists(), "unchanged upper bound must not write");
|
||||
assert!(!dir.join("4.xmp").exists(), "unselected photo must not write");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(std::fs::read_to_string(dir.join("1.xmp")).unwrap().contains("<xmp:Rating>0</xmp:Rating>"));
|
||||
assert!(std::fs::read_to_string(dir.join("2.xmp")).unwrap().contains("<xmp:Rating>2</xmp:Rating>"));
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert!(std::fs::read_to_string(dir.join("2.xmp")).unwrap().contains("<xmp:Rating>3</xmp:Rating>"));
|
||||
s.execute("photo.decreaseRating", &json!({})).unwrap();
|
||||
assert!(std::fs::read_to_string(dir.join("3.xmp")).unwrap().contains("<xmp:Rating>4</xmp:Rating>"));
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
@@ -189,7 +189,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
// photos without a capture time start from their import time
|
||||
let base = |s: &crate::Session, id| s.catalog.photo(id).map(|p| p.date().to_string()).unwrap_or_default();
|
||||
let mut delta = (f64_or(p, "shift", 0.0) + f64_or(p, "hours", 0.0) * 3600.0).round() as i64;
|
||||
// seconds; a shift beyond 10,000 years is a mistake (and would overflow), not a date
|
||||
const MAX_SHIFT: f64 = 10_000.0 * 365.25 * 86_400.0;
|
||||
let shift = f64_or(p, "shift", 0.0) + f64_or(p, "hours", 0.0) * 3600.0;
|
||||
if shift.is_nan() || shift.abs() > MAX_SHIFT {
|
||||
return Err(bad(c, "the shift is more than 10,000 years"));
|
||||
}
|
||||
let mut delta = shift.round() as i64;
|
||||
let mut each: Option<String> = None;
|
||||
if let Some(t) = str_param(p, "time") {
|
||||
let t = normalize_iso(t).ok_or_else(|| bad(c, format!("`{t}` is not a date (YYYY-MM-DDTHH:MM:SS)")))?;
|
||||
@@ -197,23 +203,38 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
each = Some(t);
|
||||
} else {
|
||||
let anchor = s.active().filter(|a| targets.contains(a)).unwrap_or(targets[0]);
|
||||
let from = iso_seconds(&base(s, anchor)).ok_or_else(|| bad(c, "the photo's date doesn't parse"))?;
|
||||
let anchor_date = base(s, anchor);
|
||||
// An impossible calendar date cannot anchor changes to the other photos.
|
||||
let from = normalize_iso(anchor_date.trim().get(..10).unwrap_or(""))
|
||||
.and_then(|_| iso_seconds(&anchor_date))
|
||||
.ok_or_else(|| bad(c, "the photo's date doesn't parse"))?;
|
||||
delta += iso_seconds(&t).unwrap_or(from) - from;
|
||||
}
|
||||
}
|
||||
let mut ops = Vec::new();
|
||||
let mut out = Vec::new();
|
||||
let mut out_of_range = 0usize;
|
||||
for id in &targets {
|
||||
let new = match &each {
|
||||
Some(t) => shift_iso(t, delta),
|
||||
None => shift_iso(&base(s, *id), delta),
|
||||
let from = match &each {
|
||||
Some(t) => t.clone(),
|
||||
None => base(s, *id),
|
||||
};
|
||||
let Some(new) = shift_iso(&from, delta) else {
|
||||
// a date that reads but would leave years 0000–9999 is refused below; one
|
||||
// that doesn't read is skipped, as before
|
||||
if iso_seconds(&from).is_some() {
|
||||
out_of_range += 1;
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let Some(new) = new else { continue };
|
||||
out.push(json!(new));
|
||||
if s.catalog.photo(*id).and_then(|p| p.captured.as_deref()) != Some(new.as_str()) {
|
||||
ops.push(Op::SetCaptured { id: *id, captured: Some(new) });
|
||||
}
|
||||
}
|
||||
if out_of_range > 0 {
|
||||
return Err(bad(c, format!("the shift would take {out_of_range} photo(s) outside the years 0000–9999; nothing was changed")));
|
||||
}
|
||||
let n = ops.len();
|
||||
if n > 0 {
|
||||
s.commit("Edit Capture Time", Op::Batch { ops })?;
|
||||
|
||||
@@ -255,7 +255,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
};
|
||||
let m = &mut masks[i];
|
||||
if let Some(MaskComponent { name: None, shape: MaskShape::Brush { strokes }, .. }) =
|
||||
if let Some(MaskComponent { shape: MaskShape::Brush { strokes }, .. }) =
|
||||
m.components.iter_mut().rev().find(|c| matches!(c.shape, MaskShape::Brush { .. }))
|
||||
{
|
||||
strokes.push(stroke);
|
||||
|
||||
@@ -51,7 +51,7 @@ pub fn is_missing(cat: &Catalog, avail: &crate::availability::Availability, id:
|
||||
!cfg!(target_arch = "wasm32") && cat.photo(id).and_then(|p| checked_path(p)).is_some_and(|f| avail.is_offline(f))
|
||||
}
|
||||
|
||||
fn relink_op(id: PhotoId, path: &str) -> Op {
|
||||
pub(crate) fn relink_op(id: PhotoId, path: &str) -> Op {
|
||||
let file_name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
|
||||
Op::Relink { id, file_name, source: Source::File { path: path.to_string() }, format: None }
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ mod prefs;
|
||||
mod preset_files;
|
||||
pub mod previews;
|
||||
mod query;
|
||||
mod sync;
|
||||
mod xmp;
|
||||
|
||||
use serde::Serialize;
|
||||
@@ -54,6 +55,10 @@ pub struct CommandSpec {
|
||||
pub run: Run,
|
||||
/// Record in the journal (false for queries).
|
||||
pub journal: bool,
|
||||
/// `enabled` judges the selection (menus, shortcuts); a call that names its photos (`ids` /
|
||||
/// `id`, see [`names_photos`]) skips it, and the command validates those photos itself.
|
||||
/// Off unless a command opts in (`CommandSpec { explicit_targets: true, ..cmd!(…) }`).
|
||||
pub explicit_targets: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
@@ -107,10 +112,10 @@ pub fn has_clipboard(s: &Session) -> std::result::Result<(), String> {
|
||||
|
||||
macro_rules! cmd {
|
||||
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
|
||||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true }
|
||||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, explicit_targets: false }
|
||||
};
|
||||
(query $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
|
||||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: false }
|
||||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: false, explicit_targets: false }
|
||||
};
|
||||
}
|
||||
pub(crate) use cmd;
|
||||
@@ -142,6 +147,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(before::specs());
|
||||
v.extend(browse::specs());
|
||||
v.extend(missing::specs());
|
||||
v.extend(sync::specs());
|
||||
v.extend(metadata::specs());
|
||||
v.extend(filters::specs());
|
||||
v.extend(denoise::specs());
|
||||
@@ -158,6 +164,11 @@ pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
|
||||
|
||||
// ---------- param helpers
|
||||
|
||||
/// The call names its target photos (`ids` or `id`, not null) instead of using the selection.
|
||||
pub(crate) fn names_photos(p: &Value) -> bool {
|
||||
["ids", "id"].iter().any(|k| p.get(k).is_some_and(|v| !v.is_null()))
|
||||
}
|
||||
|
||||
pub(crate) fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||||
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
|
||||
}
|
||||
|
||||
@@ -43,7 +43,9 @@ fn album_json(a: &Album, all: &[Album], cat: &lightcraft_catalog::Catalog) -> Va
|
||||
if let Some(rules) = &a.smart {
|
||||
v["smart"] = json!(true);
|
||||
v["rules"] = serde_json::to_value(rules).unwrap_or_default();
|
||||
v["rulesText"] = json!(rules.describe());
|
||||
v["rulesText"] = json!(rules.describe_with(cat));
|
||||
// rules that no longer check (an album they test was deleted…), as `rule 1: …`
|
||||
v["problems"] = json!(cat.smart_album_problems(a.id).iter().map(ToString::to_string).collect::<Vec<_>>());
|
||||
}
|
||||
v
|
||||
}
|
||||
@@ -149,9 +151,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
Ok(v)
|
||||
}),
|
||||
cmd!(query "develop.get", "Get Develop Settings", [], None, "{id?}", always, |s, p| {
|
||||
cmd!(query "develop.get", "Get Develop Settings", [], None, "{id?} → the photo's develop settings; `process` is the rendering process they are interpreted by (see develop.updateProcess)", always, |s, p| {
|
||||
let id = photo_arg(s, p, "develop.get")?;
|
||||
Ok(s.develop_of(id).map(|d| d.to_json()).unwrap_or(Value::Null))
|
||||
Ok(s.develop_of(id)
|
||||
.map(|d| {
|
||||
let mut v = d.to_json();
|
||||
// saved settings leave V1 out (`ProcessVersion::is_legacy`); here it is always stated
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("process".into(), json!(d.process));
|
||||
}
|
||||
v
|
||||
})
|
||||
.unwrap_or(Value::Null))
|
||||
}),
|
||||
cmd!(query "develop.controls", "List Develop Controls", [], None, "{section?} — every slider with range, default and current value", always, |s, p| {
|
||||
let d = s.active().and_then(|id| s.develop_of(id)).unwrap_or_default();
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//! Synchronize Folder commands (see [`crate::sync`]).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, bool_or, cmd, str_param};
|
||||
use crate::sync::{SyncChoice, scan, synchronize};
|
||||
use crate::{Result, Session};
|
||||
|
||||
fn path(p: &Value, c: &str) -> Result<String> {
|
||||
str_param(p, "path").filter(|d| !d.trim().is_empty()).map(str::to_string).ok_or_else(|| bad(c, "missing `path`"))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
query "folder.scanChanges",
|
||||
"Scan Folder for Changes",
|
||||
[],
|
||||
None,
|
||||
"{path, disk?: bool (a whole disk or share, or a folder holding disks)} — compare a folder of the library (and the folders inside it) with the disk; changes nothing → {path, new: [{path, name, format, …}] files the library doesn't have, duplicates: files whose content it already has, unreadable: [{path, error}], missing: [{id, path}] photos whose file is gone, moved: [{id, from, to}] photos whose file is elsewhere in the folder (same content), metadata: [{id, path}] photos whose XMP sidecar was saved after they came in or were last edited here and says something the library doesn't}",
|
||||
always,
|
||||
|s, p| {
|
||||
const C: &str = "folder.scanChanges";
|
||||
let path = path(p, C)?;
|
||||
let changes = scan(s, &path, bool_or(p, "disk", false)).map_err(|e| bad(C, e.to_string()))?;
|
||||
let v = serde_json::to_value(&changes).unwrap_or_default();
|
||||
s.folder_changes = Some(changes);
|
||||
Ok(v)
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"folder.synchronize",
|
||||
"Synchronize Folder",
|
||||
[],
|
||||
None,
|
||||
"{path, disk?: bool, scanned?: bool, importNew?: true, relinkMoved?: true, removeMissing?: false, readMetadata?: false} — bring a folder of the library up to date with the disk (see folder.scanChanges): import its new files in place, relink photos whose file was renamed or moved within it, move photos whose file is gone to Recently Deleted, read XMP sidecars saved by other apps (the sidecar wins); acts on the last folder.scanChanges of the folder while it is current (the folder's photos unchanged), else scans again — or, with `scanned: true`, refuses; one undo step, no file is touched → {imported, relinked, removed, read, failed: [[path, error]]}",
|
||||
always,
|
||||
sync
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn sync(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "folder.synchronize";
|
||||
let path = path(p, C)?;
|
||||
let d = SyncChoice::default();
|
||||
let choice = SyncChoice {
|
||||
import_new: bool_or(p, "importNew", d.import_new),
|
||||
relink_moved: bool_or(p, "relinkMoved", d.relink_moved),
|
||||
remove_missing: bool_or(p, "removeMissing", d.remove_missing),
|
||||
read_metadata: bool_or(p, "readMetadata", d.read_metadata),
|
||||
};
|
||||
let changes = match s.take_folder_changes(&path) {
|
||||
Some(c) => c,
|
||||
// the person agreed to what a scan showed: never act on something else
|
||||
None if bool_or(p, "scanned", false) => return Err(bad(C, format!("{path} changed since it was scanned: scan again"))),
|
||||
None => scan(s, &path, bool_or(p, "disk", false)).map_err(|e| bad(C, e.to_string()))?,
|
||||
};
|
||||
let r = synchronize(s, changes, choice)?;
|
||||
Ok(json!({"imported": r.imported, "relinked": r.relinked, "removed": r.removed, "read": r.read, "failed": r.failed}))
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
//! Paginated contact sheets, rendered with the normal export pipeline and packaged as PDF.
|
||||
use crate::export::{Anchor, ExportFormat, ExportOptions, Watermark, draw_watermark, encode_image};
|
||||
use crate::{Session, media::RenderJob};
|
||||
use lightcraft_pipeline::{OutputDepth, OutputSpace};
|
||||
use lightcraft_raster::Rgba8;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct Options {
|
||||
pub paper: String,
|
||||
pub landscape: bool,
|
||||
pub columns: usize,
|
||||
pub rows: usize,
|
||||
pub captions: bool,
|
||||
}
|
||||
|
||||
impl Default for Options {
|
||||
fn default() -> Self {
|
||||
Self { paper: "a4".into(), landscape: false, columns: 3, rows: 4, captions: true }
|
||||
}
|
||||
}
|
||||
|
||||
impl Options {
|
||||
pub fn parse(p: &Value) -> Result<Self, String> {
|
||||
let mut settings = p.as_object().cloned().ok_or("params must be an object")?;
|
||||
settings.remove("path");
|
||||
settings.remove("ids");
|
||||
settings.remove("id");
|
||||
let o: Self = serde_json::from_value(Value::Object(settings)).map_err(|e| e.to_string())?;
|
||||
o.layout()?;
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
fn layout(&self) -> Result<Layout, String> {
|
||||
if !(1..=8).contains(&self.columns) || !(1..=10).contains(&self.rows) {
|
||||
return Err("columns must be 1–8 and rows 1–10".into());
|
||||
}
|
||||
let (mut w, mut h) = match self.paper.as_str() {
|
||||
"a4" => (595.28, 841.89),
|
||||
"letter" => (612.0, 792.0),
|
||||
_ => return Err("paper must be a4 or letter".into()),
|
||||
};
|
||||
if self.landscape {
|
||||
std::mem::swap(&mut w, &mut h);
|
||||
}
|
||||
// Fixed 150 dpi, bounded paper sizes and grid dimensions keep allocations predictable.
|
||||
let width = (w * 150.0 / 72.0_f64).round() as usize;
|
||||
let height = (h * 150.0 / 72.0_f64).round() as usize;
|
||||
Ok(Layout { points: (w, h), width, height, cell_w: (width - 120) / self.columns, cell_h: (height - 120) / self.rows })
|
||||
}
|
||||
}
|
||||
|
||||
struct Layout {
|
||||
points: (f64, f64),
|
||||
width: usize,
|
||||
height: usize,
|
||||
cell_w: usize,
|
||||
cell_h: usize,
|
||||
}
|
||||
|
||||
pub struct Prepared {
|
||||
options: Options,
|
||||
items: Vec<(String, RenderJob)>,
|
||||
}
|
||||
|
||||
pub struct Document {
|
||||
pub bytes: Vec<u8>,
|
||||
pub pages: usize,
|
||||
pub photos: usize,
|
||||
}
|
||||
|
||||
pub fn prepare(session: &mut Session, p: &Value) -> Result<Prepared, String> {
|
||||
let options = Options::parse(p)?;
|
||||
if let Some(ids) = p.get("ids") {
|
||||
let a = ids.as_array().ok_or("ids must be an array of photo ids")?;
|
||||
if a.iter().any(|v| v.as_u64().is_none()) {
|
||||
return Err("ids must contain only photo ids".into());
|
||||
}
|
||||
}
|
||||
if p.get("id").is_some_and(|v| v.as_u64().is_none()) {
|
||||
return Err("id must be a photo id".into());
|
||||
}
|
||||
let ids = session.targets(p);
|
||||
if ids.is_empty() || ids.len() > 1000 {
|
||||
return Err("select between 1 and 1000 photos".into());
|
||||
}
|
||||
let l = options.layout()?;
|
||||
if ids.len().div_ceil(options.columns * options.rows) > 100 {
|
||||
return Err("contact sheets are limited to 100 pages; increase the grid size or select fewer photos".into());
|
||||
}
|
||||
let mut items = Vec::with_capacity(ids.len());
|
||||
for id in ids {
|
||||
let name = session.catalog.photo(id).ok_or("no such photo")?.file_name.clone();
|
||||
let job = session.export_job(id, l.cell_w - 16, l.cell_h - 48, OutputSpace::Srgb, OutputDepth::U8)?;
|
||||
items.push((name, job));
|
||||
}
|
||||
Ok(Prepared { options, items })
|
||||
}
|
||||
|
||||
impl Prepared {
|
||||
pub fn len(&self) -> usize {
|
||||
self.items.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.items.is_empty()
|
||||
}
|
||||
|
||||
/// Cancellation produces no partial document; callers write only after this succeeds.
|
||||
pub fn run(self, progress: &mut dyn FnMut(usize, &str) -> bool) -> Result<Document, String> {
|
||||
let l = self.options.layout()?;
|
||||
let capacity = self.options.columns * self.options.rows;
|
||||
let photos = self.items.len();
|
||||
let pages = photos.div_ceil(capacity);
|
||||
let mut pdf = Pdf::new(pages, l.points);
|
||||
let mut page = Rgba8::filled(l.width, l.height, [255; 4]);
|
||||
for (i, (name, job)) in self.items.into_iter().enumerate() {
|
||||
if !progress(i, &name) {
|
||||
return Err("Contact sheet cancelled".into());
|
||||
}
|
||||
let image = job.run().rendered?.image;
|
||||
let slot = i % capacity;
|
||||
let x = 60 + slot % self.options.columns * l.cell_w;
|
||||
let y = 60 + slot / self.options.columns * l.cell_h;
|
||||
// The renderer fits within the requested rectangle and applies crop/orientation.
|
||||
if image.width > l.cell_w - 16 || image.height > l.cell_h - 48 {
|
||||
return Err("render exceeded contact sheet cell".into());
|
||||
}
|
||||
blit(&mut page, &image, x + (l.cell_w - image.width) / 2, y + (l.cell_h - 40 - image.height) / 2)?;
|
||||
if self.options.captions {
|
||||
let mut caption = Rgba8::filled(l.cell_w - 16, 28, [255; 4]);
|
||||
draw_watermark(
|
||||
&mut caption,
|
||||
&Watermark {
|
||||
text: caption_text(&name, l.cell_w - 16),
|
||||
size: 0.5,
|
||||
opacity: 1.0,
|
||||
anchor: Anchor::Center,
|
||||
inset: 0.0,
|
||||
color: [32; 3],
|
||||
shadow: false,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
blit(&mut page, &caption, x + 8, y + l.cell_h - 36)?;
|
||||
}
|
||||
if slot + 1 == capacity || i + 1 == photos {
|
||||
let jpg = encode_image(&page, &ExportOptions { format: ExportFormat::Jpeg, quality: 92, ..Default::default() })?;
|
||||
if pdf.bytes.len().saturating_add(jpg.len()) > 256 * 1024 * 1024 {
|
||||
return Err("contact sheet exceeds 256 MiB; select fewer photos".into());
|
||||
}
|
||||
pdf.page(i / capacity, &jpg, l.width, l.height);
|
||||
page.data.fill([255; 4]);
|
||||
}
|
||||
}
|
||||
if !progress(photos, "PDF") {
|
||||
return Err("Contact sheet cancelled".into());
|
||||
}
|
||||
Ok(Document { bytes: pdf.finish(), pages, photos })
|
||||
}
|
||||
}
|
||||
|
||||
fn caption_text(name: &str, width: usize) -> String {
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
let clean: String = name.chars().map(|c| if c.is_control() { ' ' } else { c }).collect();
|
||||
let cells: Vec<_> = clean.graphemes(true).collect();
|
||||
// A conservative one-em budget at 14 px also accommodates the optional CJK faces.
|
||||
let limit = (width / 14).max(3);
|
||||
if cells.len() <= limit {
|
||||
return clean;
|
||||
}
|
||||
let left = limit / 2;
|
||||
let right = limit - left - 1;
|
||||
format!("{}…{}", cells.iter().take(left).copied().collect::<String>(), cells.iter().skip(cells.len() - right).copied().collect::<String>())
|
||||
}
|
||||
|
||||
fn blit(dst: &mut Rgba8, src: &Rgba8, x: usize, y: usize) -> Result<(), String> {
|
||||
if x.checked_add(src.width).is_none_or(|w| w > dst.width) || y.checked_add(src.height).is_none_or(|h| h > dst.height) {
|
||||
return Err("contact sheet image outside page".into());
|
||||
}
|
||||
for row in 0..src.height {
|
||||
let start = (y + row) * dst.width + x;
|
||||
let a = dst.data.get_mut(start..start + src.width).ok_or("invalid page buffer")?;
|
||||
let b = src.data.get(row * src.width..(row + 1) * src.width).ok_or("invalid render buffer")?;
|
||||
for (dst, src) in a.iter_mut().zip(b) {
|
||||
let alpha = u32::from(src[3]);
|
||||
for channel in 0..3 {
|
||||
dst[channel] = ((u32::from(src[channel]) * alpha + u32::from(dst[channel]) * (255 - alpha) + 127) / 255) as u8;
|
||||
}
|
||||
dst[3] = 255;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Minimal PDF 1.4: one JPEG XObject per page, explicit byte lengths and classic xref offsets.
|
||||
struct Pdf {
|
||||
bytes: Vec<u8>,
|
||||
offsets: Vec<usize>,
|
||||
points: (f64, f64),
|
||||
}
|
||||
|
||||
impl Pdf {
|
||||
fn new(pages: usize, points: (f64, f64)) -> Self {
|
||||
let mut p = Self { bytes: b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n".to_vec(), offsets: vec![0], points };
|
||||
p.object(1, b"<< /Type /Catalog /Pages 2 0 R >>");
|
||||
let kids = (0..pages).map(|i| format!("{} 0 R", 3 + i * 3)).collect::<Vec<_>>().join(" ");
|
||||
p.object(2, format!("<< /Type /Pages /Count {pages} /Kids [{kids}] >>").as_bytes());
|
||||
p
|
||||
}
|
||||
|
||||
fn object(&mut self, id: usize, body: &[u8]) {
|
||||
self.offsets.push(self.bytes.len());
|
||||
self.bytes.extend_from_slice(format!("{id} 0 obj\n").as_bytes());
|
||||
self.bytes.extend_from_slice(body);
|
||||
self.bytes.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
|
||||
fn stream(&mut self, id: usize, dictionary: &str, bytes: &[u8]) {
|
||||
let mut body = format!("<< {dictionary} /Length {} >>\nstream\n", bytes.len()).into_bytes();
|
||||
body.extend_from_slice(bytes);
|
||||
body.extend_from_slice(b"\nendstream");
|
||||
self.object(id, &body);
|
||||
}
|
||||
|
||||
fn page(&mut self, index: usize, jpg: &[u8], width: usize, height: usize) {
|
||||
let id = 3 + index * 3;
|
||||
let (w, h) = self.points;
|
||||
self.object(
|
||||
id,
|
||||
format!(
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {w:.2} {h:.2}] /Resources << /XObject << /Im {} 0 R >> >> /Contents {} 0 R >>",
|
||||
id + 2,
|
||||
id + 1
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
self.stream(id + 1, "", format!("q {w:.2} 0 0 {h:.2} 0 0 cm /Im Do Q").as_bytes());
|
||||
self.stream(
|
||||
id + 2,
|
||||
&format!("/Type /XObject /Subtype /Image /Width {width} /Height {height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode"),
|
||||
jpg,
|
||||
);
|
||||
}
|
||||
|
||||
fn finish(mut self) -> Vec<u8> {
|
||||
let start = self.bytes.len();
|
||||
self.bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", self.offsets.len()).as_bytes());
|
||||
for offset in self.offsets.iter().skip(1) {
|
||||
self.bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
self.bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{start}\n%%EOF\n", self.offsets.len()).as_bytes());
|
||||
self.bytes
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn validates_settings_and_selection() {
|
||||
let mut s = Session::with_demo();
|
||||
for p in [
|
||||
json!({"columns": 0}),
|
||||
json!({"rows": 11}),
|
||||
json!({"paper": "poster"}),
|
||||
json!({"landscape": "yes"}),
|
||||
json!({"typo": 2}),
|
||||
json!({"ids": ["bad"]}),
|
||||
json!({"ids": []}),
|
||||
] {
|
||||
assert!(prepare(&mut s, &p).is_err(), "{p}");
|
||||
}
|
||||
let a = Options::default().layout().unwrap();
|
||||
let b = Options { landscape: true, ..Default::default() }.layout().unwrap();
|
||||
assert_eq!((a.width, a.height), (b.height, b.width));
|
||||
let id = s.catalog.photos().next().unwrap().id.0;
|
||||
assert!(prepare(&mut s, &json!({"ids": [id, u64::MAX]})).is_err());
|
||||
assert!(prepare(&mut s, &json!({"ids": vec![id; 1001]})).is_err());
|
||||
assert!(prepare(&mut s, &json!({"ids": vec![id; 101], "columns": 1, "rows": 1})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparent_pixels_composite_over_white() {
|
||||
let mut dst = Rgba8::filled(3, 1, [255; 4]);
|
||||
let src = Rgba8 { width: 3, height: 1, data: vec![[0, 0, 0, 0], [255, 0, 0, 128], [10, 20, 30, 255]] };
|
||||
blit(&mut dst, &src, 0, 0).unwrap();
|
||||
assert_eq!(dst.data, vec![[255; 4], [255, 127, 127, 255], [10, 20, 30, 255]]);
|
||||
assert!(blit(&mut dst, &src, usize::MAX, 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_captions_are_rasterized() {
|
||||
assert_eq!(caption_text("very-long-éééé-filename.jpg", 140), "very-….jpg");
|
||||
assert_eq!(caption_text("café\n.jpg", 300), "café .jpg");
|
||||
let mut img = Rgba8::filled(360, 28, [255; 4]);
|
||||
draw_watermark(
|
||||
&mut img,
|
||||
&Watermark {
|
||||
text: "Été – Straße café.jpg".into(),
|
||||
size: 0.5,
|
||||
opacity: 1.0,
|
||||
anchor: Anchor::Center,
|
||||
inset: 0.0,
|
||||
color: [32; 3],
|
||||
shadow: false,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(img.data.iter().filter(|p| p[0] < 128).count() > 100);
|
||||
let mut ascii = Rgba8::filled(360, 28, [255; 4]);
|
||||
draw_watermark(
|
||||
&mut ascii,
|
||||
&Watermark {
|
||||
text: "Ete - Strasse cafe.jpg".into(),
|
||||
size: 0.5,
|
||||
opacity: 1.0,
|
||||
anchor: Anchor::Center,
|
||||
inset: 0.0,
|
||||
color: [32; 3],
|
||||
shadow: false,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert_ne!(img, ascii);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pagination_xref_and_cancellation() {
|
||||
let mut s = Session::with_demo();
|
||||
let ids: Vec<_> = s.catalog.photos().take(3).map(|p| p.id.0).collect();
|
||||
let p = json!({"ids": ids, "columns": 1, "rows": 2, "captions": false});
|
||||
let mut seen = Vec::new();
|
||||
let doc = prepare(&mut s, &p)
|
||||
.unwrap()
|
||||
.run(&mut |i, _| {
|
||||
seen.push(i);
|
||||
true
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!((doc.pages, doc.photos), (2, 3));
|
||||
assert_eq!(seen, vec![0, 1, 2, 3]);
|
||||
let text = String::from_utf8_lossy(&doc.bytes);
|
||||
assert!(text.contains("/Count 2 /Kids [3 0 R 6 0 R]"));
|
||||
let xref = text.rsplit("startxref\n").next().unwrap().lines().next().unwrap().parse::<usize>().unwrap();
|
||||
assert!(doc.bytes[xref..].starts_with(b"xref\n0 9\n"));
|
||||
let xref_text = std::str::from_utf8(&doc.bytes[xref..]).unwrap();
|
||||
for (index, line) in xref_text.lines().skip(3).take(8).enumerate() {
|
||||
let offset = line.split_whitespace().next().unwrap().parse::<usize>().unwrap();
|
||||
assert!(doc.bytes[offset..].starts_with(format!("{} 0 obj\n", index + 1).as_bytes()));
|
||||
}
|
||||
assert!(prepare(&mut s, &p).unwrap().run(&mut |_, _| false).is_err());
|
||||
}
|
||||
|
||||
fn first_page(bytes: &[u8]) -> Rgba8 {
|
||||
let start = bytes.windows(2).position(|v| v == [0xff, 0xd8]).unwrap();
|
||||
let end = bytes[start..].windows(2).position(|v| v == [0xff, 0xd9]).unwrap() + start + 2;
|
||||
lightcraft_codecs::decode(&bytes[start..end], Default::default()).unwrap().to_srgb8()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sheet_uses_current_edits_and_captions() {
|
||||
let mut s = Session::with_demo();
|
||||
let p = json!({"columns": 1, "rows": 2, "captions": false});
|
||||
let plain = first_page(&prepare(&mut s, &p).unwrap().run(&mut |_, _| true).unwrap().bytes);
|
||||
s.execute("develop.set", &json!({"control": "light.exposure", "value": 2.0})).unwrap();
|
||||
let bright = first_page(&prepare(&mut s, &p).unwrap().run(&mut |_, _| true).unwrap().bytes);
|
||||
let sum = |img: &Rgba8| img.data.iter().map(|p| u64::from(p[0]) + u64::from(p[1]) + u64::from(p[2])).sum::<u64>();
|
||||
assert!(sum(&bright) > sum(&plain), "sheet ignored develop settings");
|
||||
let captions = first_page(&prepare(&mut s, &json!({"columns": 1, "rows": 2})).unwrap().run(&mut |_, _| true).unwrap().bytes);
|
||||
assert!(sum(&captions) < sum(&bright), "captions absent from PDF pixels");
|
||||
assert_eq!((plain.width, plain.height), (1240, 1754));
|
||||
}
|
||||
}
|
||||
+181
-36
@@ -18,6 +18,43 @@ use serde_json::{Map, Value, json};
|
||||
/// Properties keyed `prefix:name` (as produced by [`lightcraft_meta::parse_xmp`]).
|
||||
pub type Props = BTreeMap<String, Vec<String>>;
|
||||
|
||||
/// What the mapped settings will develop. It decides how a `crs:` white balance is read: a raw
|
||||
/// with a measured illuminant takes `Temperature` as Kelvin; everything developed relative to its
|
||||
/// as-shot look (rendered files, and raws whose readers have no illuminant,
|
||||
/// [`lightcraft_catalog::relative_wb_format`]) needs a shift from the other app's as-shot white
|
||||
/// instead, because on that scale 6500 K / 0 means *as shot* (issue #510).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Target {
|
||||
/// A preset: raw or rendered unknown. Absolute Kelvin first, else the relative scale.
|
||||
Any,
|
||||
/// A rendered photo (or a raw shown from its embedded preview): `Incremental*` fields on our
|
||||
/// relative scale.
|
||||
Rendered,
|
||||
/// A raw developed with an absolute white balance (DNG and other files with a measured
|
||||
/// illuminant): Kelvin as written.
|
||||
RawAbsolute,
|
||||
/// A raw developed relative to its as-shot look ([`lightcraft_catalog::Photo::relative_wb`]).
|
||||
RawRelative,
|
||||
}
|
||||
|
||||
impl Target {
|
||||
/// The target for a file of this media kind and format (its extension or Lightroom's
|
||||
/// `fileFormat` name); `preview_only` for a raw that can't be decoded yet.
|
||||
pub fn for_file(kind: lightcraft_catalog::MediaKind, format: &str, preview_only: bool) -> Target {
|
||||
if kind != lightcraft_catalog::MediaKind::Raw || preview_only {
|
||||
Target::Rendered
|
||||
} else if lightcraft_catalog::relative_wb_format(format) {
|
||||
Target::RawRelative
|
||||
} else {
|
||||
Target::RawAbsolute
|
||||
}
|
||||
}
|
||||
/// The target for a photo in the catalog.
|
||||
pub fn for_photo(p: &lightcraft_catalog::Photo) -> Target {
|
||||
Target::for_file(p.kind, &p.format, p.preview_only.is_some())
|
||||
}
|
||||
}
|
||||
|
||||
/// Band names as they appear in `crs:` field names, in our mixer order.
|
||||
const CRS_BANDS: [&str; 8] = ["Red", "Orange", "Yellow", "Green", "Aqua", "Blue", "Purple", "Magenta"];
|
||||
|
||||
@@ -69,6 +106,8 @@ pub fn has_adjustments(props: &Props) -> bool {
|
||||
thread_local! {
|
||||
/// Keys read by [`to_partial`] while [`to_partial_report`] runs.
|
||||
static READ: std::cell::RefCell<Option<std::collections::BTreeSet<String>>> = const { std::cell::RefCell::new(None) };
|
||||
/// Adjustments [`to_partial`] read but deliberately left out, for the report.
|
||||
static SKIPPED: std::cell::RefCell<Option<Vec<String>>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
fn note(k: &str) {
|
||||
@@ -79,6 +118,14 @@ fn note(k: &str) {
|
||||
});
|
||||
}
|
||||
|
||||
fn skipped(what: &str) {
|
||||
SKIPPED.with(|r| {
|
||||
if let Some(s) = r.borrow_mut().as_mut() {
|
||||
s.push(what.to_string());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn first<'a>(props: &'a Props, k: &str) -> Option<&'a str> {
|
||||
note(k);
|
||||
props.get(k).and_then(|v| v.first()).map(|s| s.trim()).filter(|s| !s.is_empty())
|
||||
@@ -138,10 +185,8 @@ fn curve(props: &Props, k: &str) -> Option<Value> {
|
||||
|
||||
/// Map the `crs:` fields of an XMP packet to a partial develop-settings JSON object.
|
||||
///
|
||||
/// `raw`: the target is a raw file (absolute Kelvin white balance) — `Some(false)` prefers the
|
||||
/// relative `Incremental*` white balance used for rendered files, `None` (presets) takes whichever
|
||||
/// is present, absolute first.
|
||||
pub fn to_partial(props: &Props, raw: Option<bool>) -> Value {
|
||||
/// `target` decides how the white balance is read (see [`Target`]).
|
||||
pub fn to_partial(props: &Props, target: Target) -> Value {
|
||||
let mut out = Value::Object(Map::new());
|
||||
let o = &mut out;
|
||||
let n = |o: &mut Value, crs: &str, path: &str| {
|
||||
@@ -200,10 +245,24 @@ pub fn to_partial(props: &Props, raw: Option<bool>) -> Value {
|
||||
let rel = num(props, "crs:IncrementalTemperature")
|
||||
.or(num(props, "crs:IncrementalTint").map(|_| 0.0))
|
||||
.map(|t| (rel_to_kelvin(t), num(props, "crs:IncrementalTint")));
|
||||
let wb = match raw {
|
||||
Some(true) => abs,
|
||||
Some(false) => rel.or(abs),
|
||||
None => abs.or(rel),
|
||||
// A Kelvin value for something we develop relative to its as-shot look: the other app's
|
||||
// as-shot white (`AsShotTemperature` / `AsShotTint`, written next to the edit) is the
|
||||
// reference, so the edit is the same mired shift from ours (issue #510).
|
||||
let shifted = || {
|
||||
let (t, tint) = abs?;
|
||||
let as_shot = num(props, "crs:AsShotTemperature").filter(|k| *k > 0.0)?;
|
||||
if t <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let r = (1e6 / as_shot - 1e6 / t) / 0.8;
|
||||
let tint = tint.map(|v| v - num(props, "crs:AsShotTint").unwrap_or(0.0));
|
||||
Some((rel_to_kelvin(r), tint))
|
||||
};
|
||||
let wb = match target {
|
||||
Target::RawAbsolute => abs,
|
||||
Target::Any => abs.or(rel),
|
||||
Target::Rendered => rel.or_else(shifted).or(abs),
|
||||
Target::RawRelative => rel.or_else(shifted),
|
||||
};
|
||||
match (mode, wb) {
|
||||
(Some(m), _) if m != "custom" => put(o, "wb.mode", json!(m)),
|
||||
@@ -214,6 +273,13 @@ pub fn to_partial(props: &Props, raw: Option<bool>) -> Value {
|
||||
put(o, "wb.tint", json!(tint));
|
||||
}
|
||||
}
|
||||
// a custom Kelvin with nothing to shift it from: read on the relative scale it would be a
|
||||
// large colour shift (ΔE 22 against Lightroom's export on the photos of issue #510), so
|
||||
// the photo stays As Shot (ΔE 12 there) and the report says so
|
||||
(_, None) if target == Target::RawRelative && abs.is_some() => {
|
||||
put(o, "wb.mode", json!("asShot"));
|
||||
skipped("Temperature, Tint (custom white balance without AsShotTemperature: kept As Shot)");
|
||||
}
|
||||
(Some(m), None) => put(o, "wb.mode", json!(m)),
|
||||
(None, None) => {}
|
||||
}
|
||||
@@ -244,13 +310,19 @@ pub fn to_partial(props: &Props, raw: Option<bool>) -> Value {
|
||||
put(o, "curve.master", c);
|
||||
}
|
||||
}
|
||||
// Lightroom Classic reads the red / green / blue curves only together with the master 2012
|
||||
// curve: a channel curve without `ToneCurvePV2012` leaves the photo unchanged
|
||||
// Lightroom Classic reads the red / green / blue curves only when the packet holds the master
|
||||
// 2012 curve and all three channel curves (as its own packets always do): a packet missing any
|
||||
// of them imports without channel curves (master + red, master + red + green, or red / green /
|
||||
// blue without the master all leave the photo's channels unchanged there)
|
||||
if let Some(c) = curve(props, "crs:ToneCurvePV2012") {
|
||||
put(o, "curve.master", c);
|
||||
for (crs, ch) in [("ToneCurvePV2012Red", "red"), ("ToneCurvePV2012Green", "green"), ("ToneCurvePV2012Blue", "blue")] {
|
||||
if let Some(c) = curve(props, &format!("crs:{crs}")) {
|
||||
put(o, &format!("curve.{ch}"), c);
|
||||
let channels = [("ToneCurvePV2012Red", "red"), ("ToneCurvePV2012Green", "green"), ("ToneCurvePV2012Blue", "blue")]
|
||||
.map(|(crs, ch)| (curve(props, &format!("crs:{crs}")), ch));
|
||||
if channels.iter().all(|(c, _)| c.is_some()) {
|
||||
for (c, ch) in channels {
|
||||
if let Some(c) = c {
|
||||
put(o, &format!("curve.{ch}"), c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -375,11 +447,12 @@ pub fn to_partial(props: &Props, raw: Option<bool>) -> Value {
|
||||
/// With `values` (the packet's structured properties), local corrections become masks too
|
||||
/// ([`crate::crs_masks`]; radial masks fitted to `aspect` = width / height); components that
|
||||
/// can't be carried over are reported as `Mask: <kind>`.
|
||||
pub fn to_partial_report(props: &Props, values: Option<&crate::crs_masks::Values>, raw: Option<bool>, aspect: f64) -> (Value, Vec<String>) {
|
||||
pub fn to_partial_report(props: &Props, values: Option<&crate::crs_masks::Values>, target: Target, aspect: f64) -> (Value, Vec<String>) {
|
||||
READ.with(|r| *r.borrow_mut() = Some(Default::default()));
|
||||
let mut out = to_partial(props, raw);
|
||||
SKIPPED.with(|r| *r.borrow_mut() = Some(Vec::new()));
|
||||
let mut out = to_partial(props, target);
|
||||
let mut read = READ.with(|r| r.borrow_mut().take()).unwrap_or_default();
|
||||
let mut mask_skips = Vec::new();
|
||||
let mut mask_skips = SKIPPED.with(|r| r.borrow_mut().take()).unwrap_or_default();
|
||||
if let Some(values) = values {
|
||||
let (masks, skipped) = crate::crs_masks::masks(values, aspect);
|
||||
if !masks.is_empty() {
|
||||
@@ -387,7 +460,7 @@ pub fn to_partial_report(props: &Props, values: Option<&crate::crs_masks::Values
|
||||
}
|
||||
if values.keys().any(|k| crate::crs_masks::CONTAINERS.contains(&k.as_str())) {
|
||||
read.extend(crate::crs_masks::CONTAINERS.iter().map(|c| c.to_string()));
|
||||
mask_skips = skipped.into_iter().map(|k| format!("Mask: {k}")).collect();
|
||||
mask_skips.extend(skipped.into_iter().map(|k| format!("Mask: {k}")));
|
||||
}
|
||||
}
|
||||
// fields that only switch a panel on/off or name things: not adjustments by themselves; an
|
||||
@@ -421,7 +494,7 @@ pub fn to_partial_report(props: &Props, values: Option<&crate::crs_masks::Values
|
||||
pub fn preset_from_xmp(xmp: &str, fallback_name: &str) -> Option<Preset> {
|
||||
let d = lightcraft_meta::parse_xmp(xmp).ok()?;
|
||||
let props = &d.properties;
|
||||
let settings = to_partial(props, None);
|
||||
let settings = to_partial(props, Target::Any);
|
||||
if settings.as_object().is_none_or(Map::is_empty) {
|
||||
return None;
|
||||
}
|
||||
@@ -474,7 +547,7 @@ mod tests {
|
||||
fn maps_common_fields() {
|
||||
let p = props(SIDECAR);
|
||||
assert!(has_adjustments(&p));
|
||||
let partial = to_partial(&p, Some(true));
|
||||
let partial = to_partial(&p, Target::RawAbsolute);
|
||||
let s = apply_partial(&DevelopSettings::for_raw(5000.0, 0.0), &partial, 1.0);
|
||||
assert_eq!(s.wb.mode, WbMode::Custom);
|
||||
assert_eq!((s.wb.temp, s.wb.tint), (5150.0, 12.0));
|
||||
@@ -516,7 +589,7 @@ mod tests {
|
||||
fn only_present_fields_are_in_the_partial() {
|
||||
let x = r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description
|
||||
xmlns:c="http://ns.adobe.com/camera-raw-settings/1.0/" c:Vibrance="+30" c:GrainAmount="10"/></rdf:RDF>"#;
|
||||
let partial = to_partial(&props(x), None);
|
||||
let partial = to_partial(&props(x), Target::Any);
|
||||
assert_eq!(partial, json!({"color": {"vibrance": 30.0}, "grain": {"amount": 10.0}}));
|
||||
}
|
||||
|
||||
@@ -531,12 +604,12 @@ mod tests {
|
||||
<crs:Temperature>4800</crs:Temperature>
|
||||
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
|
||||
let p = props(x);
|
||||
let rendered = apply_partial(&DevelopSettings::default(), &to_partial(&p, Some(false)), 1.0);
|
||||
let rendered = apply_partial(&DevelopSettings::default(), &to_partial(&p, Target::Rendered), 1.0);
|
||||
assert_eq!(rendered.treatment, lightcraft_develop::Treatment::Bw);
|
||||
assert_eq!(rendered.bw_mix.blue, -35.0);
|
||||
assert!((rendered.wb.temp - rel_to_kelvin(25.0)).abs() < 1e-6 && rendered.wb.temp > 6500.0);
|
||||
assert_eq!(rendered.wb.tint, -6.0);
|
||||
let raw = apply_partial(&DevelopSettings::default(), &to_partial(&p, Some(true)), 1.0);
|
||||
let raw = apply_partial(&DevelopSettings::default(), &to_partial(&p, Target::RawAbsolute), 1.0);
|
||||
assert_eq!(raw.wb.temp, 4800.0);
|
||||
}
|
||||
|
||||
@@ -545,13 +618,72 @@ mod tests {
|
||||
let x = r#"<rdf:Description xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
|
||||
crs:WhiteBalance="Daylight" crs:Temperature="5500" crs:Tint="10" crs:AlreadyApplied="True"/>"#;
|
||||
let p = props(x);
|
||||
assert_eq!(to_partial(&p, Some(true)), json!({"wb": {"mode": "daylight"}}));
|
||||
assert_eq!(to_partial(&p, Target::RawAbsolute), json!({"wb": {"mode": "daylight"}}));
|
||||
assert!(!has_adjustments(&p));
|
||||
let bookkeeping = r#"<rdf:Description xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:Version="1" crs:HasSettings="True"/>"#;
|
||||
assert!(!has_adjustments(&props(bookkeeping)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_kelvin_on_relative_wb_raws_shifts_from_the_as_shot_white() {
|
||||
// Issue #510: a Sony ARW edited in Lightroom to Custom 3578 K / −5 next to an as-shot white
|
||||
// of 3650 K / −2 (written for this test from the numbers in the issue).
|
||||
let x = r#"<rdf:Description xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
|
||||
crs:WhiteBalance="Custom" crs:Temperature="3578" crs:Tint="-5" crs:AsShotTemperature="3650" crs:AsShotTint="-2"/>"#;
|
||||
let p = props(x);
|
||||
let rel = to_partial(&p, Target::RawRelative);
|
||||
// 3650 → 3578 K is +5.5 mired: the same shift from 6500 K lands near 6275 K on our scale
|
||||
let t = rel["wb"]["temp"].as_f64().unwrap();
|
||||
assert!((t - 6275.0).abs() < 5.0, "{t}");
|
||||
assert_eq!(rel["wb"]["tint"], -3.0);
|
||||
assert_eq!(rel["wb"]["mode"], "custom");
|
||||
// a rendered file without `Incremental*` fields shifts the same way; a raw with a measured
|
||||
// illuminant (and a preset) keeps the Kelvin value
|
||||
assert_eq!(to_partial(&p, Target::Rendered)["wb"]["temp"], rel["wb"]["temp"]);
|
||||
assert_eq!(to_partial(&p, Target::RawAbsolute)["wb"]["temp"], 3578.0);
|
||||
assert_eq!(to_partial(&p, Target::Any)["wb"]["temp"], 3578.0);
|
||||
// the as-shot fields are a reference, not an adjustment: nothing to report
|
||||
assert!(to_partial_report(&p, None, Target::RawRelative, 1.5).1.is_empty());
|
||||
// no tint next to the as-shot tint: the custom tint is taken as the shift itself
|
||||
let no_tint = props(&x.replace(r#" crs:AsShotTint="-2""#, ""));
|
||||
assert_eq!(to_partial(&no_tint, Target::RawRelative)["wb"]["tint"], -5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_kelvin_without_an_as_shot_reference_stays_as_shot_and_is_reported() {
|
||||
// The same edit from a Lightroom catalog, which keeps no as-shot white for Custom photos:
|
||||
// 3578 K read on the relative scale would be a large blue shift (issue #510)
|
||||
let x = r#"<rdf:Description xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
|
||||
crs:WhiteBalance="Custom" crs:Temperature="3578" crs:Tint="-5" crs:Exposure2012="+0.3"/>"#;
|
||||
let p = props(x);
|
||||
let (partial, unmapped) = to_partial_report(&p, None, Target::RawRelative, 1.5);
|
||||
assert_eq!(partial["wb"], json!({"mode": "asShot"}));
|
||||
assert_eq!(partial["light"]["exposure"], 0.3);
|
||||
assert_eq!(unmapped.len(), 1, "{unmapped:?}");
|
||||
assert!(unmapped[0].starts_with("Temperature, Tint"), "{unmapped:?}");
|
||||
// a raw with a measured illuminant and a rendered file read it as before, and report nothing
|
||||
assert_eq!(to_partial(&p, Target::RawAbsolute)["wb"]["temp"], 3578.0);
|
||||
assert_eq!(to_partial(&p, Target::Rendered)["wb"]["temp"], 3578.0);
|
||||
assert!(to_partial_report(&p, None, Target::RawAbsolute, 1.5).1.is_empty());
|
||||
// an unusable reference (zero) counts as none
|
||||
let zero = props(&x.replace(r#" crs:Tint="-5""#, r#" crs:Tint="-5" crs:AsShotTemperature="0""#));
|
||||
assert_eq!(to_partial(&zero, Target::RawRelative)["wb"], json!({"mode": "asShot"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_follows_the_file() {
|
||||
use lightcraft_catalog::MediaKind;
|
||||
assert_eq!(Target::for_file(MediaKind::Raw, "ARW", false), Target::RawRelative);
|
||||
assert_eq!(Target::for_file(MediaKind::Raw, "nef", false), Target::RawRelative);
|
||||
// Lightroom's `fileFormat` name for proprietary raws
|
||||
assert_eq!(Target::for_file(MediaKind::Raw, "RAW", false), Target::RawRelative);
|
||||
assert_eq!(Target::for_file(MediaKind::Raw, "DNG", false), Target::RawAbsolute);
|
||||
// a raw shown from its embedded preview develops like a rendered file
|
||||
assert_eq!(Target::for_file(MediaKind::Raw, "ARW", true), Target::Rendered);
|
||||
assert_eq!(Target::for_file(MediaKind::Image, "JPG", false), Target::Rendered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xmp_preset_with_name_and_group() {
|
||||
// Written for this test: a minimal preset-shaped packet with localized name/group.
|
||||
@@ -584,25 +716,38 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_curves_need_the_master_curve() {
|
||||
// Lightroom ignores a red / green / blue curve without `ToneCurvePV2012`
|
||||
let packet = |master: &str| {
|
||||
fn channel_curves_need_the_master_and_all_channel_curves() {
|
||||
// Lightroom applies red / green / blue curves only when the master and all three are present
|
||||
let seq = |name: &str, pts: &str| format!("<crs:{name}><rdf:Seq>{pts}</rdf:Seq></crs:{name}>");
|
||||
let identity = "<rdf:li>0, 0</rdf:li><rdf:li>255, 255</rdf:li>";
|
||||
let bent = "<rdf:li>0, 0</rdf:li><rdf:li>64, 40</rdf:li><rdf:li>255, 255</rdf:li>";
|
||||
let packet = |curves: &[String]| {
|
||||
format!(
|
||||
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:ToneCurveName2012="Custom">{master}
|
||||
<crs:ToneCurvePV2012Green><rdf:Seq><rdf:li>0, 0</rdf:li><rdf:li>64, 40</rdf:li><rdf:li>255, 255</rdf:li></rdf:Seq></crs:ToneCurvePV2012Green>
|
||||
</rdf:Description></rdf:RDF></x:xmpmeta>"#
|
||||
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:ToneCurveName2012="Custom">{}
|
||||
</rdf:Description></rdf:RDF></x:xmpmeta>"#,
|
||||
curves.concat()
|
||||
)
|
||||
};
|
||||
let alone = to_partial(&props(&packet("")), Some(true));
|
||||
let partial = |curves: &[String]| to_partial(&props(&packet(curves)), Target::RawAbsolute);
|
||||
let (master, red, green, blue) = (
|
||||
seq("ToneCurvePV2012", identity),
|
||||
seq("ToneCurvePV2012Red", identity),
|
||||
seq("ToneCurvePV2012Green", bent),
|
||||
seq("ToneCurvePV2012Blue", identity),
|
||||
);
|
||||
// a channel curve alone, or with the master but without its two siblings: ignored
|
||||
let alone = partial(std::slice::from_ref(&green));
|
||||
assert!(alone.pointer("/curve/green").is_none(), "{alone}");
|
||||
let master = "<crs:ToneCurvePV2012><rdf:Seq><rdf:li>0, 0</rdf:li><rdf:li>255, 255</rdf:li></rdf:Seq></crs:ToneCurvePV2012>";
|
||||
let with = to_partial(&props(&packet(master)), Some(true));
|
||||
assert!(with.pointer("/curve/green").is_some() && with.pointer("/curve/master").is_some(), "{with}");
|
||||
let master_and_green = partial(&[master.clone(), green.clone()]);
|
||||
assert!(master_and_green.pointer("/curve/green").is_none() && master_and_green.pointer("/curve/master").is_some(), "{master_and_green}");
|
||||
// all four: read
|
||||
let all = partial(&[master, red, green, blue]);
|
||||
assert!(["/curve/master", "/curve/red", "/curve/green", "/curve/blue"].iter().all(|p| all.pointer(p).is_some()), "{all}");
|
||||
}
|
||||
|
||||
fn unmapped(x: &str) -> Vec<String> {
|
||||
to_partial_report(&props(x), None, None, 1.5).1
|
||||
to_partial_report(&props(x), None, Target::Any, 1.5).1
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -622,8 +767,8 @@ mod tests {
|
||||
let p = props(&preset_packet("", ""));
|
||||
let mut d = DevelopSettings::default();
|
||||
d.optics.lens_profile = true;
|
||||
assert!(apply_partial(&d, &to_partial(&p, None), 1.0).optics.lens_profile);
|
||||
assert!(apply_partial(&d, &to_partial(&p, Target::Any), 1.0).optics.lens_profile);
|
||||
let on = props(&preset_packet("", "").replace(r#"crs:LensProfileEnable="0""#, r#"crs:LensProfileEnable="1""#));
|
||||
assert!(apply_partial(&DevelopSettings::default(), &to_partial(&on, None), 1.0).optics.lens_profile);
|
||||
assert!(apply_partial(&DevelopSettings::default(), &to_partial(&on, Target::Any), 1.0).optics.lens_profile);
|
||||
}
|
||||
}
|
||||
@@ -298,7 +298,7 @@ mod tests {
|
||||
|
||||
fn settings() -> (DevelopSettings, Vec<String>) {
|
||||
let d = lightcraft_meta::parse_xmp(PACKET).unwrap();
|
||||
let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), None, 1.5);
|
||||
let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5);
|
||||
(DevelopSettings::default().merged(&partial).expect("valid settings"), unmapped)
|
||||
}
|
||||
|
||||
|
||||
+463
-17
@@ -101,14 +101,15 @@ pub fn load(s: &mut Session) {
|
||||
/// functions, so a 24 MP demo original takes minutes of CPU time on a small machine (a 4-core CI
|
||||
/// VM). Each scene at each size is therefore generated once per process and shared:
|
||||
///
|
||||
/// - concurrent requests wait for one generation (Before and After at 1:1 both load the
|
||||
/// original; sessions side by side, e.g. parallel tests, open the same photo);
|
||||
/// - one generation per scene and size, which concurrent requests wait for (Before and After at
|
||||
/// 1:1 both load the original; sessions side by side, e.g. parallel tests, open the same photo;
|
||||
/// how it renders: [`generate`]);
|
||||
/// - the most recently used results are kept, up to [`scene_cache_limit`] bytes. They are the same
|
||||
/// allocations the sessions' source caches hold, so they cost extra memory only once no session
|
||||
/// holds them any more.
|
||||
///
|
||||
/// Only demo photos use this; real files are decoded by their loader as before.
|
||||
pub(crate) fn scene_pixels(scene: &lightcraft_scenes::Scene, max_edge: usize) -> Arc<lightcraft_raster::Rgb32f> {
|
||||
pub(crate) fn scene_pixels(scene: &lightcraft_scenes::Scene, max_edge: usize) -> Result<Arc<lightcraft_raster::Rgb32f>, String> {
|
||||
let key = SceneKey { kind: scene.kind, seed: scene.seed, width: scene.width, height: scene.height, max_edge };
|
||||
let slot = {
|
||||
let mut c = scene_cache();
|
||||
@@ -120,18 +121,152 @@ pub(crate) fn scene_pixels(scene: &lightcraft_scenes::Scene, max_edge: usize) ->
|
||||
e.slot.clone()
|
||||
}
|
||||
None => {
|
||||
let slot = Arc::new(std::sync::OnceLock::new());
|
||||
let slot = Arc::new(SceneSlot::default());
|
||||
c.entries.push(SceneEntry { key, slot: slot.clone(), used: tick });
|
||||
slot
|
||||
}
|
||||
}
|
||||
};
|
||||
// (no lock held while generating: other scenes and sizes are served meanwhile)
|
||||
let pixels = slot.get_or_init(|| Arc::new(scene.render_fit(max_edge))).clone();
|
||||
let pixels = slot.get_or_generate(|how| match how {
|
||||
Render::Parallel => scene.render_fit(max_edge),
|
||||
Render::Serial => scene.render_fit_serial(max_edge),
|
||||
});
|
||||
trim_scene_cache(&mut scene_cache(), scene_cache_limit());
|
||||
pixels
|
||||
}
|
||||
|
||||
/// How a generation renders (see [`generate`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Render {
|
||||
/// On rayon threads that do nothing else.
|
||||
Parallel,
|
||||
/// On the calling thread alone, without rayon.
|
||||
Serial,
|
||||
}
|
||||
|
||||
const FAILED: &str = "the demo scene could not be generated";
|
||||
|
||||
/// Render a scene for [`SceneSlot::get_or_generate`], so that it never needs a thread that waits
|
||||
/// for a generation. A rayon worker renders on its own thread alone: rendering in parallel, it
|
||||
/// would run queued jobs while it waits for rows other workers took, and one of them could ask for
|
||||
/// this very scene and wait for the generation further down its own stack (the faces scan's pool
|
||||
/// threads hung that way). That also keeps the work within the caller's pool and its limits (the
|
||||
/// scan's pace, `LIGHTCRAFT_FACE_THREADS`). Any other caller (the render workers, which are plain
|
||||
/// threads) renders in parallel on [`render_pool`], whose threads only ever render, and blocks
|
||||
/// without running other work meanwhile; where that pool can't be started it renders alone too.
|
||||
fn generate(render: impl FnOnce(Render) -> lightcraft_raster::Rgb32f + Send) -> lightcraft_raster::Rgb32f {
|
||||
if rayon::current_thread_index().is_some() {
|
||||
return render(Render::Serial);
|
||||
}
|
||||
match render_pool() {
|
||||
Some(pool) => pool.install(|| render(Render::Parallel)),
|
||||
None => render(Render::Serial),
|
||||
}
|
||||
}
|
||||
|
||||
/// The threads non-rayon callers' generations render on: rayon's default count (`RAYON_NUM_THREADS`,
|
||||
/// else the available parallelism), like the global pool they used before, without touching that
|
||||
/// pool (asking it for its size starts it, which panics where its threads can't start); `None`
|
||||
/// where this pool's threads can't start.
|
||||
fn render_pool() -> Option<&'static rayon::ThreadPool> {
|
||||
static POOL: std::sync::OnceLock<Option<rayon::ThreadPool>> = std::sync::OnceLock::new();
|
||||
POOL.get_or_init(|| rayon::ThreadPoolBuilder::new().thread_name(|i| format!("lc-demo-scene-{i}")).build().ok()).as_ref()
|
||||
}
|
||||
|
||||
/// One scene at one size: generated once, then shared.
|
||||
#[derive(Default)]
|
||||
struct SceneSlot {
|
||||
state: std::sync::Mutex<SlotState>,
|
||||
/// Signalled when a generation ends.
|
||||
ended: std::sync::Condvar,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct SlotState {
|
||||
generation: Generation,
|
||||
/// Generations started so far; the one running or last ended is number `started`.
|
||||
started: u64,
|
||||
/// Callers waiting for the generation running (for the tests).
|
||||
waiting: usize,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
enum Generation {
|
||||
#[default]
|
||||
Empty,
|
||||
Running,
|
||||
Ready(Arc<lightcraft_raster::Rgb32f>),
|
||||
/// The last generation failed (its render panicked); the next request tries again.
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl SlotState {
|
||||
/// Start a generation; its number.
|
||||
fn start(&mut self) -> u64 {
|
||||
self.started += 1;
|
||||
self.generation = Generation::Running;
|
||||
self.started
|
||||
}
|
||||
|
||||
/// What came of generation `n`, once it has ended: its pixels, or an error when it failed
|
||||
/// (even if a retry has started or finished since: callers get the outcome they waited for).
|
||||
fn outcome(&self, n: u64) -> Option<Result<Arc<lightcraft_raster::Rgb32f>, String>> {
|
||||
match &self.generation {
|
||||
Generation::Running if self.started == n => None,
|
||||
// pixels are kept for good once made, so only generation `n` itself can have made them
|
||||
Generation::Ready(p) if self.started == n => Some(Ok(p.clone())),
|
||||
_ => Some(Err(FAILED.into())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SceneSlot {
|
||||
fn lock(&self) -> std::sync::MutexGuard<'_, SlotState> {
|
||||
self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn ready(&self) -> Option<Arc<lightcraft_raster::Rgb32f>> {
|
||||
match &self.lock().generation {
|
||||
Generation::Ready(p) => Some(p.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The pixels `render` makes ([`generate`]), generated once: the first caller renders, with no
|
||||
/// lock held, and later ones wait for its outcome. A render that panics is an error for the
|
||||
/// callers of its generation, and the next request tries again.
|
||||
fn get_or_generate(&self, render: impl FnOnce(Render) -> lightcraft_raster::Rgb32f + Send) -> Result<Arc<lightcraft_raster::Rgb32f>, String> {
|
||||
let mut g = self.lock();
|
||||
let n = match &g.generation {
|
||||
Generation::Ready(p) => return Ok(p.clone()),
|
||||
Generation::Running => {
|
||||
let n = g.started;
|
||||
g.waiting += 1;
|
||||
let outcome = loop {
|
||||
if let Some(o) = g.outcome(n) {
|
||||
break o;
|
||||
}
|
||||
g = self.ended.wait(g).unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
};
|
||||
g.waiting -= 1;
|
||||
return outcome;
|
||||
}
|
||||
Generation::Empty | Generation::Failed => g.start(),
|
||||
};
|
||||
drop(g);
|
||||
let made = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| generate(render)));
|
||||
let mut g = self.lock();
|
||||
g.generation = match made {
|
||||
Ok(pixels) => Generation::Ready(Arc::new(pixels)),
|
||||
Err(_) => Generation::Failed,
|
||||
};
|
||||
let outcome = g.outcome(n).unwrap_or_else(|| Err(FAILED.into()));
|
||||
drop(g);
|
||||
self.ended.notify_all();
|
||||
outcome
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes of generated scenes kept for reuse: half the memory budget (768 MB by default; enough
|
||||
/// for one 24 MP original and the previews around it).
|
||||
fn scene_cache_limit() -> usize {
|
||||
@@ -147,11 +282,9 @@ struct SceneKey {
|
||||
max_edge: usize,
|
||||
}
|
||||
|
||||
type SceneSlot = Arc<std::sync::OnceLock<Arc<lightcraft_raster::Rgb32f>>>;
|
||||
|
||||
struct SceneEntry {
|
||||
key: SceneKey,
|
||||
slot: SceneSlot,
|
||||
slot: Arc<SceneSlot>,
|
||||
used: u64,
|
||||
}
|
||||
|
||||
@@ -167,7 +300,7 @@ fn scene_cache() -> std::sync::MutexGuard<'static, SceneCache> {
|
||||
}
|
||||
|
||||
fn scene_bytes(slot: &SceneSlot) -> usize {
|
||||
slot.get().map_or(0, |img| img.data.len().saturating_mul(std::mem::size_of::<[f32; 3]>()))
|
||||
slot.ready().map_or(0, |img| img.data.len().saturating_mul(std::mem::size_of::<[f32; 3]>()))
|
||||
}
|
||||
|
||||
/// Drop the least recently used generated scenes until the rest fit in `limit` bytes. Scenes still
|
||||
@@ -178,7 +311,7 @@ fn trim_scene_cache(c: &mut SceneCache, limit: usize) {
|
||||
if total <= limit {
|
||||
return;
|
||||
}
|
||||
let Some(i) = c.entries.iter().enumerate().filter(|(_, e)| e.slot.get().is_some()).min_by_key(|(_, e)| e.used).map(|(i, _)| i) else {
|
||||
let Some(i) = c.entries.iter().enumerate().filter(|(_, e)| e.slot.ready().is_some()).min_by_key(|(_, e)| e.used).map(|(i, _)| i) else {
|
||||
return;
|
||||
};
|
||||
c.entries.swap_remove(i);
|
||||
@@ -196,12 +329,12 @@ mod tests {
|
||||
#[test]
|
||||
fn a_scene_is_generated_once_per_size_and_shared() {
|
||||
let s = small_scene();
|
||||
let a = scene_pixels(&s, 48);
|
||||
let b = scene_pixels(&s, 48);
|
||||
let a = scene_pixels(&s, 48).unwrap();
|
||||
let b = scene_pixels(&s, 48).unwrap();
|
||||
assert!(Arc::ptr_eq(&a, &b), "the second request reuses the first generation");
|
||||
assert_eq!(a.width.max(a.height), 48);
|
||||
assert_eq!(a.data, s.render_fit(48).data, "the same pixels as generating it directly");
|
||||
let c = scene_pixels(&s, 40);
|
||||
let c = scene_pixels(&s, 40).unwrap();
|
||||
assert_eq!(c.width.max(c.height), 40, "another size is its own generation");
|
||||
}
|
||||
|
||||
@@ -210,19 +343,332 @@ mod tests {
|
||||
let s = small_scene();
|
||||
let edge = 37; // a size no other test asks for
|
||||
let got: Vec<_> = std::thread::scope(|sc| {
|
||||
let hs: Vec<_> = (0..4).map(|_| sc.spawn(|| scene_pixels(&s, edge))).collect();
|
||||
let hs: Vec<_> = (0..4).map(|_| sc.spawn(|| scene_pixels(&s, edge).unwrap())).collect();
|
||||
hs.into_iter().map(|h| h.join().unwrap()).collect()
|
||||
});
|
||||
assert!(got.windows(2).all(|w| Arc::ptr_eq(&w[0], &w[1])), "every request got the same pixels");
|
||||
}
|
||||
|
||||
/// Run `f` on a thread of its own and wait for it at most `secs`: a deadlock fails the test instead of hanging it.
|
||||
fn within<T: Send + 'static>(secs: u64, f: impl FnOnce() -> T + Send + 'static) -> T {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(f());
|
||||
});
|
||||
rx.recv_timeout(std::time::Duration::from_secs(secs)).expect("deadlocked")
|
||||
}
|
||||
|
||||
/// Wait (at most ten seconds) until `n` callers wait for `slot`'s generation.
|
||||
fn until_waiting(slot: &SceneSlot, n: usize) {
|
||||
let started = std::time::Instant::now();
|
||||
while slot.lock().waiting < n {
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(10), "the callers never got to wait");
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
}
|
||||
}
|
||||
|
||||
fn pixels(n: usize) -> lightcraft_raster::Rgb32f {
|
||||
lightcraft_raster::Rgb32f::new(n, 1)
|
||||
}
|
||||
|
||||
/// Rayon workers asking for a scene while it is generated never deadlock. The render is parallel, and a rayon worker
|
||||
/// waiting for rows another worker took runs queued jobs on top of itself: generating on the caller's thread, the
|
||||
/// faces scan's pool threads ended up waiting on the generation further down their own stack and hung. Requests
|
||||
/// keep arriving on a four-thread pool while the scene is generated, as when several photos are queued; every one
|
||||
/// of them must come back, at least four of them must have overlapped the generation, and the test waits with a
|
||||
/// timeout, so a deadlock fails it instead of hanging.
|
||||
#[test]
|
||||
fn rayon_workers_asking_for_a_scene_being_generated_never_deadlock() {
|
||||
let scene = Arc::new(small_scene());
|
||||
let edge = 601; // a size no other test asks for
|
||||
const ASKED: usize = 24;
|
||||
let (overlap, sizes) = within(120, move || {
|
||||
let pool = rayon::ThreadPoolBuilder::new().num_threads(4).build().unwrap();
|
||||
let issued = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let (done_tx, done_rx) = std::sync::mpsc::channel();
|
||||
for _ in 0..ASKED {
|
||||
let (scene, done, issued) = (scene.clone(), done_tx.clone(), issued.clone());
|
||||
issued.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
pool.spawn(move || {
|
||||
let got = scene_pixels(&scene, edge).unwrap();
|
||||
// how many requests had been made when this one came back
|
||||
let _ = done.send((issued.load(std::sync::atomic::Ordering::SeqCst), got.data.len()));
|
||||
});
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
}
|
||||
let replies: Vec<(usize, usize)> = (0..ASKED).map(|_| done_rx.recv().unwrap()).collect();
|
||||
(replies.iter().map(|r| r.0).min().unwrap(), replies.iter().map(|r| r.1).collect::<Vec<_>>())
|
||||
});
|
||||
assert_eq!(sizes.len(), ASKED, "every request came back");
|
||||
assert!(sizes.iter().all(|n| *n == sizes[0] && *n > 0));
|
||||
assert!(overlap >= 4, "requests overlapped the generation (only {overlap} had been made when the first came back)");
|
||||
}
|
||||
|
||||
/// However many requests (from rayon workers and other threads) arrive while a scene is generated, it is rendered
|
||||
/// once, and never twice at the same time: duplicates would each hold a whole image (288 MB for a 24 MP original).
|
||||
#[test]
|
||||
fn a_scene_is_rendered_once_however_many_ask() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
let (renders, peak) = within(60, || {
|
||||
let slot = Arc::new(SceneSlot::default());
|
||||
let (running, peak, renders) = (Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0)));
|
||||
let render = {
|
||||
let (running, peak, renders) = (running.clone(), peak.clone(), renders.clone());
|
||||
move |how: Render| {
|
||||
let now = running.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
peak.fetch_max(now, Ordering::SeqCst);
|
||||
renders.fetch_add(1, Ordering::SeqCst);
|
||||
// work like the real render's: in parallel, the rendering thread waits for rows other threads took
|
||||
use rayon::prelude::*;
|
||||
let sum: u64 = match how {
|
||||
Render::Parallel => (0..2_000_000u64).into_par_iter().map(|i| i % 7).sum(),
|
||||
Render::Serial => (0..2_000_000u64).map(|i| i % 7).sum(),
|
||||
};
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
running.fetch_sub(1, Ordering::SeqCst);
|
||||
pixels(sum as usize % 5 + 1)
|
||||
}
|
||||
};
|
||||
let pool = rayon::ThreadPoolBuilder::new().num_threads(4).build().unwrap();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
for _ in 0..16 {
|
||||
let (slot, render, tx) = (slot.clone(), render.clone(), tx.clone());
|
||||
pool.spawn(move || {
|
||||
let _ = tx.send(slot.get_or_generate(render).unwrap());
|
||||
});
|
||||
}
|
||||
for _ in 0..4 {
|
||||
let (slot, render, tx) = (slot.clone(), render.clone(), tx.clone());
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(slot.get_or_generate(render).unwrap());
|
||||
});
|
||||
}
|
||||
let got: Vec<_> = (0..20).map(|_| rx.recv().unwrap()).collect();
|
||||
assert!(got.windows(2).all(|w| Arc::ptr_eq(&w[0], &w[1])), "every request got the same pixels");
|
||||
(renders.load(Ordering::SeqCst), peak.load(Ordering::SeqCst))
|
||||
});
|
||||
assert_eq!((renders, peak), (1, 1));
|
||||
}
|
||||
|
||||
/// A rayon worker (the faces scan's) renders a scene on its own thread alone, so the work stays within its pool
|
||||
/// and that pool's limits; other threads render in parallel on the scene pool.
|
||||
#[test]
|
||||
fn a_rayon_worker_renders_alone_on_its_own_thread() {
|
||||
let ((how, caller, renderer), (other_how, other_name)) = within(30, || {
|
||||
let pool = rayon::ThreadPoolBuilder::new().num_threads(2).build().unwrap();
|
||||
let slot = SceneSlot::default();
|
||||
let mut seen = None;
|
||||
pool.install(|| {
|
||||
let caller = std::thread::current().id();
|
||||
slot.get_or_generate(|how| {
|
||||
seen = Some((how, caller, std::thread::current().id()));
|
||||
pixels(1)
|
||||
})
|
||||
.unwrap();
|
||||
});
|
||||
let other = SceneSlot::default();
|
||||
let mut other_seen = None;
|
||||
other
|
||||
.get_or_generate(|how| {
|
||||
other_seen = Some((how, std::thread::current().name().unwrap_or_default().to_string()));
|
||||
pixels(1)
|
||||
})
|
||||
.unwrap();
|
||||
(seen.unwrap(), other_seen.unwrap())
|
||||
});
|
||||
assert_eq!((how, renderer), (Render::Serial, caller));
|
||||
assert_eq!(other_how, Render::Parallel);
|
||||
assert!(other_name.starts_with("lc-demo-scene-"), "{other_name}");
|
||||
}
|
||||
|
||||
/// A caller finding a generation running waits for it instead of rendering its own (one 24 MP original costs
|
||||
/// minutes of CPU time on a small machine).
|
||||
#[test]
|
||||
fn callers_wait_for_the_generation_running() {
|
||||
let (a, b, renders) = within(30, || {
|
||||
let slot = Arc::new(SceneSlot::default());
|
||||
let renders = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let (started_tx, started) = std::sync::mpsc::channel::<()>();
|
||||
let (go, go_rx) = std::sync::mpsc::channel::<()>();
|
||||
let (started_tx, go_rx) = (Arc::new(std::sync::Mutex::new(started_tx)), Arc::new(std::sync::Mutex::new(go_rx)));
|
||||
let ask = |slot: &Arc<SceneSlot>| {
|
||||
let (slot, renders, started_tx, go_rx) = (slot.clone(), renders.clone(), started_tx.clone(), go_rx.clone());
|
||||
std::thread::spawn(move || {
|
||||
slot.get_or_generate(move |_| {
|
||||
renders.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
let _ = started_tx.lock().unwrap().send(());
|
||||
let _ = go_rx.lock().unwrap().recv();
|
||||
pixels(1)
|
||||
})
|
||||
})
|
||||
};
|
||||
let first = ask(&slot);
|
||||
started.recv().unwrap();
|
||||
let second = ask(&slot);
|
||||
until_waiting(&slot, 1);
|
||||
go.send(()).unwrap();
|
||||
let (a, b) = (first.join().unwrap().unwrap(), second.join().unwrap().unwrap());
|
||||
(a, b, renders.load(std::sync::atomic::Ordering::SeqCst))
|
||||
});
|
||||
assert!(Arc::ptr_eq(&a, &b) && a.width == 1, "one generation, shared");
|
||||
assert_eq!(renders, 1);
|
||||
}
|
||||
|
||||
/// A generation that fails (its render panicked) is an error for its callers, never a hang, and the next request
|
||||
/// generates the scene again.
|
||||
#[test]
|
||||
fn a_failed_generation_is_an_error_and_the_next_request_tries_again() {
|
||||
let (first, waiters, again) = within(30, || {
|
||||
let slot = Arc::new(SceneSlot::default());
|
||||
let (started_tx, started) = std::sync::mpsc::channel::<()>();
|
||||
let (go, go_rx) = std::sync::mpsc::channel::<()>();
|
||||
let failing = {
|
||||
let slot = slot.clone();
|
||||
std::thread::spawn(move || {
|
||||
slot.get_or_generate(move |_| {
|
||||
let _ = started_tx.send(());
|
||||
let _ = go_rx.recv();
|
||||
panic!("render failed");
|
||||
})
|
||||
})
|
||||
};
|
||||
started.recv().unwrap();
|
||||
let waiters: Vec<_> = (0..2)
|
||||
.map(|_| {
|
||||
let slot = slot.clone();
|
||||
std::thread::spawn(move || slot.get_or_generate(|_| pixels(2)))
|
||||
})
|
||||
.collect();
|
||||
until_waiting(&slot, 2);
|
||||
drop(go);
|
||||
let first = failing.join().unwrap();
|
||||
let waiters: Vec<_> = waiters.into_iter().map(|w| w.join().unwrap()).collect();
|
||||
(first, waiters, slot.get_or_generate(|_| pixels(3)))
|
||||
});
|
||||
assert!(first.is_err() && waiters.iter().all(Result::is_err), "the callers of the failed generation get its error");
|
||||
assert_eq!(again.unwrap().width, 3);
|
||||
}
|
||||
|
||||
/// Callers get the outcome of the generation they waited for, even when a retry starts (or finishes) before they
|
||||
/// look: a failed generation followed by a retry is still an error for them.
|
||||
#[test]
|
||||
fn a_retry_never_changes_the_outcome_waited_for() {
|
||||
let mut st = SlotState::default();
|
||||
let n = st.start();
|
||||
assert!(st.outcome(n).is_none(), "running");
|
||||
st.generation = Generation::Failed;
|
||||
let m = st.start();
|
||||
assert!(matches!(st.outcome(n), Some(Err(_))));
|
||||
assert!(st.outcome(m).is_none());
|
||||
st.generation = Generation::Ready(Arc::new(pixels(1)));
|
||||
assert!(matches!(st.outcome(n), Some(Err(_))), "even once the retry succeeded");
|
||||
assert!(matches!(st.outcome(m), Some(Ok(_))));
|
||||
// and with threads: a retry racing the waiters of a failed generation never hands them its result
|
||||
let wrong = within(60, || {
|
||||
let mut wrong = 0;
|
||||
for _ in 0..20 {
|
||||
let slot = Arc::new(SceneSlot::default());
|
||||
let (started_tx, started) = std::sync::mpsc::channel::<()>();
|
||||
let (go, go_rx) = std::sync::mpsc::channel::<()>();
|
||||
let failing = {
|
||||
let slot = slot.clone();
|
||||
std::thread::spawn(move || {
|
||||
slot.get_or_generate(move |_| {
|
||||
let _ = started_tx.send(());
|
||||
let _ = go_rx.recv();
|
||||
panic!("render failed");
|
||||
})
|
||||
})
|
||||
};
|
||||
started.recv().unwrap();
|
||||
let waiters: Vec<_> = (0..3)
|
||||
.map(|_| {
|
||||
let slot = slot.clone();
|
||||
std::thread::spawn(move || slot.get_or_generate(|_| pixels(2)))
|
||||
})
|
||||
.collect();
|
||||
until_waiting(&slot, 3);
|
||||
let retry = {
|
||||
let slot = slot.clone();
|
||||
std::thread::spawn(move || {
|
||||
loop {
|
||||
if matches!(slot.lock().generation, Generation::Failed) {
|
||||
return slot.get_or_generate(|_| pixels(4));
|
||||
}
|
||||
std::hint::spin_loop();
|
||||
}
|
||||
})
|
||||
};
|
||||
drop(go);
|
||||
let _ = failing.join().unwrap();
|
||||
wrong += waiters.into_iter().map(|w| w.join().unwrap()).filter(Result::is_ok).count();
|
||||
assert!(retry.join().unwrap().is_ok());
|
||||
}
|
||||
wrong
|
||||
});
|
||||
assert_eq!(wrong, 0, "waiters of a failed generation got a retry's pixels");
|
||||
}
|
||||
|
||||
/// Generating a scene never touches the global rayon pool, so a process whose global pool couldn't start (asking it
|
||||
/// anything then panics) still gets its demo scenes. Run in a process of its own: the global pool is the process's.
|
||||
#[test]
|
||||
fn scenes_are_generated_when_the_global_pool_is_broken() {
|
||||
use std::io::Read;
|
||||
use std::process::Stdio;
|
||||
let mut child = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.args(["--exact", "demo::tests::with_a_broken_global_pool", "--ignored", "--test-threads=1"])
|
||||
.env("LC_DEMO_BROKEN_GLOBAL_POOL", "1")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
// Drain both pipes on threads so a chatty child can't block on a full pipe.
|
||||
let drain = |mut r: Box<dyn Read + Send>| {
|
||||
std::thread::spawn(move || {
|
||||
let mut s = String::new();
|
||||
let _ = r.read_to_string(&mut s);
|
||||
s
|
||||
})
|
||||
};
|
||||
let (stdout, stderr) = (drain(Box::new(child.stdout.take().unwrap())), drain(Box::new(child.stderr.take().unwrap())));
|
||||
// A deadlocked child fails the test instead of hanging CI.
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120);
|
||||
let status = loop {
|
||||
if let Some(status) = child.try_wait().unwrap() {
|
||||
break status;
|
||||
}
|
||||
if std::time::Instant::now() > deadline {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
panic!("the child process did not finish within 120 s");
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
};
|
||||
let (stdout, stderr) = (stdout.join().unwrap(), stderr.join().unwrap());
|
||||
assert!(status.success() && stdout.contains("1 passed"), "{stdout}\n{stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "run in a process of its own by scenes_are_generated_when_the_global_pool_is_broken"]
|
||||
fn with_a_broken_global_pool() {
|
||||
if std::env::var_os("LC_DEMO_BROKEN_GLOBAL_POOL").is_none() {
|
||||
return;
|
||||
}
|
||||
let broken = rayon::ThreadPoolBuilder::new().spawn_handler(|_| Err(std::io::Error::other("no threads"))).build_global();
|
||||
assert!(broken.is_err());
|
||||
assert!(std::panic::catch_unwind(rayon::current_num_threads).is_err(), "the global pool is broken");
|
||||
let s = small_scene();
|
||||
let got = scene_pixels(&s, 33).unwrap();
|
||||
assert_eq!(got.data, s.render_fit_serial(33).data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cache_keeps_the_most_recent_scenes_within_its_limit() {
|
||||
let key = |max_edge| SceneKey { kind: lightcraft_scenes::Kind::Dunes, seed: 1, width: 3, height: 2, max_edge };
|
||||
let entry = |max_edge, used, px: Option<usize>| {
|
||||
let slot: SceneSlot = Arc::new(std::sync::OnceLock::new());
|
||||
let slot = Arc::new(SceneSlot::default());
|
||||
if let Some(n) = px {
|
||||
let _ = slot.set(Arc::new(lightcraft_raster::Rgb32f::new(n, 1)));
|
||||
slot.lock().generation = Generation::Ready(Arc::new(lightcraft_raster::Rgb32f::new(n, 1)));
|
||||
}
|
||||
SceneEntry { key: key(max_edge), slot, used }
|
||||
};
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
//! The monitor profile (`app.displayProfile`): previews shown on a wide-gamut or calibrated
|
||||
//! display through its ICC profile.
|
||||
//!
|
||||
//! The frontend holds the profile in use (Settings ▸ Display, the UI command `app.displayProfile`).
|
||||
//! With a profile, the loupe renders into the display's own primaries
|
||||
//! ([`RenderJob::with_display`](crate::media::RenderJob::with_display)) and everything else made
|
||||
//! for sRGB (thumbnails, cached previews, embedded camera JPEGs) is converted to it
|
||||
//! ([`present`]). Histograms and the preview caches stay sRGB, and exports never use it.
|
||||
|
||||
pub use lightcraft_codecs::display::DisplayKind;
|
||||
use lightcraft_codecs::display::{DisplayProfile, MAX_PROFILE_BYTES};
|
||||
use lightcraft_pipeline::DisplaySpace;
|
||||
use lightcraft_raster::{Histogram, Rgba8};
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::media::RenderResult;
|
||||
|
||||
/// A loaded display profile: its transforms and the pipeline target.
|
||||
#[derive(Debug)]
|
||||
pub struct Display {
|
||||
/// The file it came from.
|
||||
pub path: String,
|
||||
pub profile: DisplayProfile,
|
||||
pub space: DisplaySpace,
|
||||
}
|
||||
|
||||
impl Display {
|
||||
/// Load the ICC profile at `path`.
|
||||
pub fn load(path: &str) -> Result<Display, String> {
|
||||
let path = path.trim();
|
||||
let meta = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
if meta.len() > MAX_PROFILE_BYTES as u64 {
|
||||
return Err(format!("{path}: too large for an ICC profile"));
|
||||
}
|
||||
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
Display::from_bytes(path, &bytes)
|
||||
}
|
||||
|
||||
pub fn from_bytes(path: &str, bytes: &[u8]) -> Result<Display, String> {
|
||||
let profile = DisplayProfile::from_icc(bytes).map_err(|e| format!("{path}: {e}"))?;
|
||||
let space = DisplaySpace::new(profile.to_rec2020, profile.id).ok_or_else(|| format!("{path}: the profile's primaries are degenerate"))?;
|
||||
Ok(Display { path: path.to_string(), profile, space })
|
||||
}
|
||||
|
||||
pub fn id(&self) -> u64 {
|
||||
self.profile.id
|
||||
}
|
||||
|
||||
/// Load the profile at `path` (`""`: none, the display is treated as sRGB).
|
||||
pub fn load_opt(path: &str) -> Result<Option<Arc<Display>>, String> {
|
||||
if path.trim().is_empty() { Ok(None) } else { Display::load(path).map(|d| Some(Arc::new(d))) }
|
||||
}
|
||||
|
||||
/// For `app.displayProfile` and Settings: path, description, kind, primaries (xy).
|
||||
pub fn describe(&self) -> Value {
|
||||
let to_xyz = lightcraft_color::REC2020.to_xyz().mul(&self.profile.to_rec2020).0;
|
||||
let xy = |c: usize| {
|
||||
let (x, y, z) = (to_xyz[0][c], to_xyz[1][c], to_xyz[2][c]);
|
||||
let s = x + y + z;
|
||||
if s.abs() < 1e-12 { json!([0.0, 0.0]) } else { json!([round4(x / s), round4(y / s)]) }
|
||||
};
|
||||
json!({
|
||||
"path": self.path,
|
||||
"description": self.profile.description,
|
||||
"kind": self.profile.kind.label(),
|
||||
"primaries": {"red": xy(0), "green": xy(1), "blue": xy(2)},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn round4(v: f64) -> f64 {
|
||||
(v * 1e4).round() / 1e4
|
||||
}
|
||||
|
||||
/// Make `r` ready for `display`: an sRGB image (thumbnail, cached preview, camera JPEG) is
|
||||
/// converted to the display's values. A render made for the display (see
|
||||
/// [`RenderJob::with_display`](crate::media::RenderJob::with_display)) is already.
|
||||
pub fn present(r: &mut RenderResult, display: &Display) {
|
||||
if r.display.is_some() {
|
||||
return;
|
||||
}
|
||||
if let Ok(rendered) = &mut r.rendered {
|
||||
match display.profile.from_srgb(&mut rendered.image) {
|
||||
Ok(()) => r.display = Some(display.id()),
|
||||
Err(e) => log::warn!("display profile: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A render into `display`'s primaries (sRGB curve) → in place, the display's device values; and
|
||||
/// its sRGB histogram, plus (`full_srgb`: for the cached view preview) its whole sRGB version.
|
||||
/// The histogram samples the same pixels [`Histogram::of_srgb8`] would, so only those are
|
||||
/// converted when the sRGB image itself isn't needed (slider drags).
|
||||
pub(crate) fn finish_render(image: &mut Rgba8, display: &Display, full_srgb: bool) -> (Option<Rgba8>, Option<Histogram>) {
|
||||
let warn = |e: String| log::warn!("display profile: {e}");
|
||||
let (srgb, histogram) = if full_srgb {
|
||||
let srgb = display.profile.to_srgb(image).map_err(warn).ok();
|
||||
let h = srgb.as_ref().map(Histogram::of_srgb8);
|
||||
(srgb, h)
|
||||
} else {
|
||||
let sample = histogram_sample(image);
|
||||
(None, display.profile.to_srgb(&sample).map_err(warn).ok().map(|s| Histogram::of_srgb8(&s)))
|
||||
};
|
||||
if let Err(e) = display.profile.correct(image) {
|
||||
warn(e);
|
||||
}
|
||||
(srgb, histogram)
|
||||
}
|
||||
|
||||
/// The pixels [`Histogram::of_srgb8`] reads (at most ~1 MP, a regular grid), as an image of
|
||||
/// their own (which it then reads whole).
|
||||
fn histogram_sample(img: &Rgba8) -> Rgba8 {
|
||||
let step = ((img.len() as f64 / 1_000_000.0).sqrt().ceil() as usize).max(1);
|
||||
if step == 1 {
|
||||
return img.clone();
|
||||
}
|
||||
let (w, h) = (img.width.div_ceil(step), img.height.div_ceil(step));
|
||||
let mut data = Vec::with_capacity(w * h);
|
||||
for y in (0..img.height).step_by(step) {
|
||||
for x in (0..img.width).step_by(step) {
|
||||
data.push(img.get(x, y));
|
||||
}
|
||||
}
|
||||
Rgba8 { width: w, height: h, data }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn p3() -> Display {
|
||||
Display::from_bytes("p3.icc", &lightcraft_codecs::icc::write_named(lightcraft_codecs::NamedSpace::DisplayP3)).unwrap()
|
||||
}
|
||||
|
||||
fn picture(w: usize, h: usize) -> Rgba8 {
|
||||
Rgba8 { width: w, height: h, data: (0..w * h).map(|i| [(i * 7 % 256) as u8, (i * 13 % 256) as u8, (i * 29 % 256) as u8, 255]).collect() }
|
||||
}
|
||||
|
||||
/// The sampled histogram of a drag render is exactly the histogram of the whole sRGB image.
|
||||
#[test]
|
||||
fn sampled_histogram_matches_the_full_one() {
|
||||
let d = p3();
|
||||
for (w, h) in [(40, 30), (1500, 1100), (2561, 1709)] {
|
||||
let img = picture(w, h);
|
||||
let (mut a, mut b) = (img.clone(), img.clone());
|
||||
let (srgb, full) = finish_render(&mut a, &d, true);
|
||||
let (none, sampled) = finish_render(&mut b, &d, false);
|
||||
assert!(srgb.is_some() && none.is_none());
|
||||
assert_eq!(full.unwrap(), sampled.unwrap(), "{w}x{h}");
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_reports_and_rejects() {
|
||||
let dir = std::env::temp_dir().join(format!("lightcraft-display-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let good = dir.join("p3.icc");
|
||||
std::fs::write(&good, lightcraft_codecs::icc::write_named(lightcraft_codecs::NamedSpace::DisplayP3)).unwrap();
|
||||
let bad = dir.join("bad.icc");
|
||||
std::fs::write(&bad, b"not a profile").unwrap();
|
||||
|
||||
let d = Display::load_opt(&good.to_string_lossy()).unwrap().unwrap();
|
||||
let r = d.describe();
|
||||
assert_eq!(r["kind"], "matrix");
|
||||
assert_eq!(r["description"], "Display P3");
|
||||
let red = r["primaries"]["red"].as_array().unwrap();
|
||||
assert!((red[0].as_f64().unwrap() - 0.68).abs() < 2e-3 && (red[1].as_f64().unwrap() - 0.32).abs() < 2e-3, "{r}");
|
||||
assert!(Display::load_opt(" ").unwrap().is_none());
|
||||
let e = Display::load(&bad.to_string_lossy()).unwrap_err();
|
||||
assert!(e.contains("not a valid ICC profile"), "{e}");
|
||||
assert!(Display::load(&dir.join("missing.icc").to_string_lossy()).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
+486
-72
@@ -600,6 +600,11 @@ pub struct ExportOptions {
|
||||
/// Bits per channel: 8, 16 (PNG, TIFF), 32 (TIFF: float, linear) or 10 (AVIF); `None` = the
|
||||
/// format's default (TIFF 16, everything else 8). See [`ExportOptions::effective_depth`].
|
||||
pub bit_depth: Option<u8>,
|
||||
/// HDR output for photos edited in HDR: JPEG as an ISO 21496-1 gain map JPEG (the SDR
|
||||
/// rendition plus a gain map, so the file looks right everywhere; quality as set, `limit_kb`
|
||||
/// not applied), AVIF as 10-bit Rec. 2020 PQ, 32-bit float TIFF with the highlights above SDR
|
||||
/// white kept. Other formats, and photos without an HDR edit, are SDR.
|
||||
pub hdr: bool,
|
||||
}
|
||||
|
||||
impl Default for ExportOptions {
|
||||
@@ -623,6 +628,7 @@ impl Default for ExportOptions {
|
||||
watermark: None,
|
||||
color_space: OutputSpace::Srgb,
|
||||
bit_depth: None,
|
||||
hdr: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -658,6 +664,7 @@ pub const OPTION_PARAMS: &[&str] = &[
|
||||
"watermark",
|
||||
"colorSpace",
|
||||
"bitDepth",
|
||||
"hdr",
|
||||
];
|
||||
|
||||
/// The keys of a `watermark` object ([`Watermark`], camelCase).
|
||||
@@ -789,7 +796,7 @@ impl ExportOptions {
|
||||
}
|
||||
serde_json::from_value::<Resize>(v.clone()).map_err(|e| bad(format!("`{k}`: {e}")))?;
|
||||
}
|
||||
"dontEnlarge" | "removeLocation" | "background" => {
|
||||
"dontEnlarge" | "removeLocation" | "background" | "hdr" => {
|
||||
boolean(k, v)?;
|
||||
}
|
||||
"naming" | "subfolder" | "path" | "dir" | "preset" => {
|
||||
@@ -922,6 +929,7 @@ impl ExportOptions {
|
||||
.filter(|w: &Watermark| !w.text.trim().is_empty() || !w.image.trim().is_empty()),
|
||||
color_space: s("colorSpace").and_then(OutputSpace::parse).unwrap_or(d.color_space),
|
||||
bit_depth: u("bitDepth").filter(|b| matches!(b, 8 | 10 | 16 | 32)).map(|b| b as u8),
|
||||
hdr: p.get("hdr").and_then(Value::as_bool).unwrap_or(d.hdr),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -990,8 +998,10 @@ impl ExportOptions {
|
||||
(ExportFormat::Png, Some(16 | 32)) => OutputDepth::U16,
|
||||
(ExportFormat::Png, _) => OutputDepth::U8,
|
||||
(ExportFormat::Tiff, Some(8)) => OutputDepth::U8,
|
||||
(ExportFormat::Tiff, Some(32)) if self.hdr => OutputDepth::F32Hdr,
|
||||
(ExportFormat::Tiff, Some(32)) => OutputDepth::F32Linear,
|
||||
(ExportFormat::Tiff, _) => OutputDepth::U16,
|
||||
(ExportFormat::Avif, _) if self.hdr => OutputDepth::F32Hdr,
|
||||
(ExportFormat::Avif, Some(10 | 16 | 32)) => OutputDepth::U16,
|
||||
(ExportFormat::Avif, _) => OutputDepth::U8,
|
||||
}
|
||||
@@ -1007,9 +1017,19 @@ impl ExportOptions {
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour space the file is actually written in (AVIF: sRGB).
|
||||
/// Whether these options write HDR files (for photos edited in HDR): JPEG (gain map), AVIF
|
||||
/// (PQ) or 32-bit float TIFF with [`ExportOptions::hdr`].
|
||||
pub fn hdr_output(&self) -> bool {
|
||||
self.hdr && matches!((self.format, self.bit_depth), (ExportFormat::Jpeg | ExportFormat::Avif, _) | (ExportFormat::Tiff, Some(32)))
|
||||
}
|
||||
|
||||
/// The colour space the file is actually written in (AVIF: sRGB, or Rec. 2020 for HDR).
|
||||
pub fn effective_space(&self) -> OutputSpace {
|
||||
if self.format == ExportFormat::Avif { OutputSpace::Srgb } else { self.color_space }
|
||||
match self.format {
|
||||
ExportFormat::Avif if self.hdr => OutputSpace::Rec2020,
|
||||
ExportFormat::Avif => OutputSpace::Srgb,
|
||||
_ => self.color_space,
|
||||
}
|
||||
}
|
||||
|
||||
/// Output file name for photo `p` at 1-based position `seq` in a batch (the original's
|
||||
@@ -1119,13 +1139,8 @@ pub fn encode_image(img: &Rgba8, o: &ExportOptions) -> Result<Vec<u8>, String> {
|
||||
|
||||
/// Like [`encode_image`], embedding `meta` (already filtered by the policy) as EXIF + XMP.
|
||||
pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metadata>) -> Result<Vec<u8>, String> {
|
||||
let mut img = img.clone();
|
||||
output_sharpen(&mut img, o.sharpen, o.sharpen_amount);
|
||||
let img = finish_8bit(img, o);
|
||||
let space = o.effective_space();
|
||||
if let Some(wm) = &o.watermark {
|
||||
let wm = Watermark { color: srgb8_in(space, wm.color), target: Some(space), ..wm.clone() };
|
||||
draw_watermark(&mut img, &wm);
|
||||
}
|
||||
let profile = icc::write_named(named_space(space));
|
||||
let exif = meta.map(lightcraft_meta::write_exif);
|
||||
let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None));
|
||||
@@ -1168,6 +1183,56 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada
|
||||
r.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Output sharpening and the watermark on an 8-bit render in `o.effective_space()`.
|
||||
fn finish_8bit(img: &Rgba8, o: &ExportOptions) -> Rgba8 {
|
||||
let mut img = img.clone();
|
||||
output_sharpen(&mut img, o.sharpen, o.sharpen_amount);
|
||||
let space = o.effective_space();
|
||||
if let Some(wm) = &o.watermark {
|
||||
let wm = Watermark { color: srgb8_in(space, wm.color), target: Some(space), ..wm.clone() };
|
||||
draw_watermark(&mut img, &wm);
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
/// Output sharpening and the watermark on a high-bit-depth render.
|
||||
fn finish_deep(img: &DeepImage, o: &ExportOptions) -> DeepImage {
|
||||
let mut img = img.clone();
|
||||
output_sharpen_deep(&mut img, o.sharpen, o.sharpen_amount);
|
||||
if let Some(wm) = &o.watermark {
|
||||
let wm = Watermark { color: srgb8_in(img.space, wm.color), target: Some(img.space), ..wm.clone() };
|
||||
draw_watermark_deep(&mut img, &wm);
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
/// Encode an ISO 21496-1 gain map JPEG from the SDR rendition `sdr` (8-bit, in the output space)
|
||||
/// and the HDR rendition `hdr` (float linear, same space and size). The gain map is measured
|
||||
/// against the base as written, so its 8-bit rounding is handed back by the map.
|
||||
pub fn encode_gain_map_jpeg(sdr: &Rgba8, hdr: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) -> Result<Vec<u8>, String> {
|
||||
use lightcraft_codecs::gainmap;
|
||||
let base = finish_8bit(sdr, o);
|
||||
let hdr = finish_deep(hdr, o);
|
||||
let DeepSamples::F32(hv) = &hdr.samples else {
|
||||
return Err("HDR export needs a float HDR render".into());
|
||||
};
|
||||
let (w, h) = (base.width, base.height);
|
||||
if (hdr.width, hdr.height) != (w, h) || hv.len() < w * h * 3 {
|
||||
return Err("HDR and SDR renders differ in size".into());
|
||||
}
|
||||
let space = o.effective_space();
|
||||
let trc = space.trc();
|
||||
let sdr_lin: Vec<[f32; 3]> = base.data.iter().map(|p| [0, 1, 2].map(|c| trc.decode(p[c] as f32 / 255.0))).collect();
|
||||
let hdr_lin: &[[f32; 3]] = hv.as_chunks::<3>().0;
|
||||
let (map, gm) = gainmap::compute(&sdr_lin, hdr_lin, w, h, space.luma(), &gainmap::GainMapOptions::default()).map_err(|e| e.to_string())?;
|
||||
let profile = icc::write_named(named_space(space));
|
||||
let exif = meta.map(lightcraft_meta::write_exif);
|
||||
let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None));
|
||||
let em = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref(), ppi: Some(o.ppi) };
|
||||
let sub = if o.limit_kb.is_some() { ChromaSubsampling::S420 } else { ChromaSubsampling::S444 };
|
||||
gainmap::encode_jpeg(&EncodeImage::rgba8(&base), &map, &gm, o.quality, sub, &em).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// The codecs' name of an output space (for its ICC profile).
|
||||
pub fn named_space(s: OutputSpace) -> NamedSpace {
|
||||
match s {
|
||||
@@ -1204,12 +1269,7 @@ pub fn encode_rendered(r: &lightcraft_pipeline::Rendered, o: &ExportOptions, met
|
||||
|
||||
/// Encode a high-bit-depth image (see [`encode_rendered`]).
|
||||
pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) -> Result<Vec<u8>, String> {
|
||||
let mut img = img.clone();
|
||||
output_sharpen_deep(&mut img, o.sharpen, o.sharpen_amount);
|
||||
if let Some(wm) = &o.watermark {
|
||||
let wm = Watermark { color: srgb8_in(img.space, wm.color), target: Some(img.space), ..wm.clone() };
|
||||
draw_watermark_deep(&mut img, &wm);
|
||||
}
|
||||
let img = finish_deep(img, o);
|
||||
let profile = match img.samples {
|
||||
DeepSamples::F32(_) => icc::write_matrix_trc(&img.space.rgb_space(), &lightcraft_codecs::Trc::Linear),
|
||||
DeepSamples::U16(_) => icc::write_named(named_space(img.space)),
|
||||
@@ -1222,27 +1282,21 @@ pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>)
|
||||
DeepSamples::U16(v) => EncodeImage::new(w, h, 3, Samples::U16(v)),
|
||||
DeepSamples::F32(v) => EncodeImage::new(w, h, 3, Samples::F32(v)),
|
||||
};
|
||||
let r = match o.format {
|
||||
ExportFormat::Png => encode::encode_png(&e, &meta),
|
||||
ExportFormat::Tiff => encode::encode_tiff(&e, o.tiff_compression, &meta),
|
||||
ExportFormat::Avif => encode::encode_avif(&e, o.quality, 8, &meta),
|
||||
f => return Err(format!("{f:?} export is 8-bit only")),
|
||||
let r = match (o.format, &img.samples) {
|
||||
(ExportFormat::Png, _) => encode::encode_png(&e, &meta),
|
||||
(ExportFormat::Tiff, _) => encode::encode_tiff(&e, o.tiff_compression, &meta),
|
||||
(ExportFormat::Avif, DeepSamples::F32(v)) if img.space == OutputSpace::Rec2020 => {
|
||||
lightcraft_codecs::encode_avif_pq(w, h, v, o.quality, 8, &meta)
|
||||
}
|
||||
(ExportFormat::Avif, _) => encode::encode_avif(&e, o.quality, 8, &meta),
|
||||
(f, _) => return Err(format!("{f:?} export is 8-bit only")),
|
||||
};
|
||||
r.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Parse a shutter speed such as `1/250`, `0.5` or `2"` into seconds.
|
||||
fn parse_shutter(s: &str) -> Option<f64> {
|
||||
let s = s.trim().trim_end_matches(['s', '"']).trim();
|
||||
match s.split_once('/') {
|
||||
Some((n, d)) => Some(n.trim().parse::<f64>().ok()? / d.trim().parse::<f64>().ok()?),
|
||||
None => s.parse().ok(),
|
||||
}
|
||||
.filter(|v: &f64| v.is_finite() && *v > 0.0)
|
||||
}
|
||||
|
||||
/// The metadata to embed for `photo` under `o.metadata` / `o.remove_location`. `None` = embed nothing.
|
||||
pub fn export_metadata(photo: &lightcraft_catalog::Photo, o: &ExportOptions) -> Option<Metadata> {
|
||||
/// Keywords follow the catalog's keyword list (`Catalog::export_keywords`).
|
||||
pub fn export_metadata(photo: &lightcraft_catalog::Photo, catalog: &lightcraft_catalog::Catalog, o: &ExportOptions) -> Option<Metadata> {
|
||||
let m = &photo.meta;
|
||||
let text = |s: &str| (!s.trim().is_empty()).then(|| s.to_string());
|
||||
// copyright info (also under "copyright only"): notice, creator, status, usage terms, info URL
|
||||
@@ -1270,7 +1324,9 @@ pub fn export_metadata(photo: &lightcraft_catalog::Photo, o: &ExportOptions) ->
|
||||
out.state = text(&m.state);
|
||||
out.country = text(&m.country);
|
||||
}
|
||||
out.keywords = m.keywords.clone();
|
||||
let keywords = catalog.export_keywords(&m.keywords);
|
||||
out.keywords = keywords.flat;
|
||||
out.hierarchical_keywords = keywords.hierarchical;
|
||||
out.capture_time = photo.captured.as_deref().and_then(DateTime::parse_iso);
|
||||
out.rating = (photo.rating > 0).then_some(photo.rating as i8);
|
||||
// Pixels are exported upright: orientation is baked in.
|
||||
@@ -1283,7 +1339,7 @@ pub fn export_metadata(photo: &lightcraft_catalog::Photo, o: &ExportOptions) ->
|
||||
out.lens_model = text(&m.lens);
|
||||
out.focal_length = m.focal_mm.map(f64::from);
|
||||
out.f_number = m.aperture.map(f64::from);
|
||||
out.exposure_time = parse_shutter(&m.shutter);
|
||||
out.exposure_time = lightcraft_catalog::parse_shutter_seconds(&m.shutter);
|
||||
out.iso = m.iso;
|
||||
}
|
||||
Some(out)
|
||||
@@ -1324,10 +1380,15 @@ pub struct PreparedExport {
|
||||
work: Work,
|
||||
/// The library's originals, which [`run_batch`] never writes over (shared by a batch).
|
||||
guard: std::sync::Arc<crate::originals::OriginalGuard>,
|
||||
/// Estimated working memory of [`Self::run`] (bytes), held from [`crate::memory::export_gate`]
|
||||
/// while it runs beside other photos of a batch.
|
||||
weight: usize,
|
||||
}
|
||||
|
||||
struct RenderWork {
|
||||
job: crate::media::RenderJob,
|
||||
/// The HDR rendition of a gain map JPEG (`job` renders its SDR base).
|
||||
hdr_job: Option<crate::media::RenderJob>,
|
||||
meta: Option<Metadata>,
|
||||
opts: ExportOptions,
|
||||
}
|
||||
@@ -1373,25 +1434,52 @@ fn prepare_guarded(
|
||||
) -> Result<PreparedExport, String> {
|
||||
let p = session.catalog.photo(id).ok_or("no such photo")?;
|
||||
let file_name = o.file_name_for(p, seq);
|
||||
let work = if o.format.is_rendered() {
|
||||
// HDR output only for photos edited in HDR; the rest of the batch exports as usual
|
||||
let sdr_opts;
|
||||
let o = if o.hdr && !p.develop.hdr.enabled {
|
||||
sdr_opts = ExportOptions { hdr: false, ..o.clone() };
|
||||
&sdr_opts
|
||||
} else {
|
||||
o
|
||||
};
|
||||
let (full_px, long) = ((p.width as usize).saturating_mul(p.height as usize), p.width.max(p.height) as usize);
|
||||
let (work, weight) = if o.format.is_rendered() {
|
||||
let (w, h) = output_size(p, o);
|
||||
let meta = export_metadata(p, o);
|
||||
let meta = export_metadata(p, &session.catalog, o);
|
||||
let gain_map = o.hdr_output() && o.format == ExportFormat::Jpeg;
|
||||
let job = session.export_job(id, w, h, o.effective_space(), o.effective_depth())?;
|
||||
Work::Render(Box::new(RenderWork { job, meta, opts: o.clone() }))
|
||||
let weight = render_weight(full_px, long, job.level, w.saturating_mul(h));
|
||||
let hdr_job = if gain_map { Some(session.export_job(id, w, h, o.effective_space(), OutputDepth::F32Hdr)?) } else { None };
|
||||
(Work::Render(Box::new(RenderWork { job, hdr_job, meta, opts: o.clone() })), weight)
|
||||
} else {
|
||||
let lightcraft_catalog::Source::File { path } = &p.source else {
|
||||
return Err(format!("{} is a generated demo photo: it has no original file to export", p.file_name));
|
||||
};
|
||||
Work::File {
|
||||
let dng = (o.format == ExportFormat::Dng).then_some(o.dng_compression);
|
||||
// the file's bytes; a DNG also decodes the raw data (16-bit samples) and writes it again
|
||||
let weight = (p.file_size as usize).saturating_add(if dng.is_some() { full_px.saturating_mul(6) } else { 0 });
|
||||
let work = Work::File {
|
||||
path: path.clone(),
|
||||
read: session.media.file_bytes.clone(),
|
||||
packet: crate::sidecar::sidecar_packet(p, &session.catalog),
|
||||
dng: (o.format == ExportFormat::Dng).then_some(o.dng_compression),
|
||||
dng,
|
||||
label: p.file_name.clone(),
|
||||
size: (p.width as usize, p.height as usize),
|
||||
}
|
||||
};
|
||||
(work, weight)
|
||||
};
|
||||
Ok(PreparedExport { photo: id, file_name, work, guard })
|
||||
Ok(PreparedExport { photo: id, file_name, work, guard, weight })
|
||||
}
|
||||
|
||||
/// Working memory of rendering a photo of `full_px` pixels (long edge `long`) from source level
|
||||
/// `level` into `out_px` output pixels: the decoded source (linear RGB f32, 12 B/px) plus the
|
||||
/// pipeline's planes and the encoded output (~36 B per output pixel). Measured: a 24 MP export
|
||||
/// peaks at ~1.2 GB, one at 2048 px from the 2560 px preview at ~150 MB.
|
||||
fn render_weight(full_px: usize, long: usize, level: crate::media::SourceLevel, out_px: usize) -> usize {
|
||||
let edge = level.max_edge().min(long.max(1));
|
||||
let scale = edge as f64 / long.max(1) as f64;
|
||||
let src_px = (full_px as f64 * scale * scale).min(usize::MAX as f64 / 64.0) as usize;
|
||||
src_px.saturating_mul(12).saturating_add(out_px.saturating_mul(36))
|
||||
}
|
||||
|
||||
impl PreparedExport {
|
||||
@@ -1399,9 +1487,16 @@ impl PreparedExport {
|
||||
let file_name = self.file_name;
|
||||
match self.work {
|
||||
Work::Render(w) => {
|
||||
let RenderWork { job, meta, opts } = *w;
|
||||
let RenderWork { job, hdr_job, meta, opts } = *w;
|
||||
let r = job.run().rendered?;
|
||||
let bytes = encode_rendered(&r, &opts, meta.as_ref())?;
|
||||
let bytes = match hdr_job {
|
||||
Some(hj) => {
|
||||
let hr = hj.run().rendered?;
|
||||
let hdr = hr.deep.ok_or("the HDR render has no float samples")?;
|
||||
encode_gain_map_jpeg(&r.image, &hdr, &opts, meta.as_ref())?
|
||||
}
|
||||
None => encode_rendered(&r, &opts, meta.as_ref())?,
|
||||
};
|
||||
Ok(Exported { file_name, bytes, width: r.image.width, height: r.image.height, sidecars: Vec::new() })
|
||||
}
|
||||
Work::File { path, read, packet, dng, label, size } => {
|
||||
@@ -1501,6 +1596,11 @@ fn sidecar_path(main: &str, ext: &str) -> String {
|
||||
/// policy. `progress(done, next file)` is called before each photo; returning false cancels the
|
||||
/// rest. Returns one JSON object per photo: `{path, width, height, bytes, sidecars}`,
|
||||
/// `{skipped: path}` or (unless `stop_on_error`) `{photo, file, error}`.
|
||||
///
|
||||
/// Several photos render side by side ([`export_parallelism`], within the memory of
|
||||
/// [`crate::memory::export_gate`]), so one photo's decode, encode or GPU wait overlaps another's
|
||||
/// render (issue #496). Paths, writes, results and `progress` still follow the batch order, and the
|
||||
/// files are the same as one at a time.
|
||||
pub fn run_batch(
|
||||
items: Vec<PreparedExport>,
|
||||
o: &ExportOptions,
|
||||
@@ -1510,31 +1610,226 @@ pub fn run_batch(
|
||||
stop_on_error: bool,
|
||||
progress: &mut dyn FnMut(usize, &str) -> bool,
|
||||
) -> Result<Vec<serde_json::Value>, String> {
|
||||
use serde_json::json;
|
||||
let join = |a: &str, b: &str| if a.is_empty() { b.to_string() } else { format!("{}/{b}", a.trim_end_matches('/')) };
|
||||
let dir = if o.subfolder.is_empty() { to.dir.clone() } else { join(&to.dir, &o.subfolder) };
|
||||
let single = items.len() == 1;
|
||||
let mut taken = std::collections::HashSet::new();
|
||||
let mut out = Vec::new();
|
||||
for (i, item) in items.into_iter().enumerate() {
|
||||
if !progress(i, &item.file_name) {
|
||||
break;
|
||||
let lanes = export_parallelism(items.len());
|
||||
run_batch_with(items, &mut Placer::new(o, to, write, exists, stop_on_error), progress, lanes)
|
||||
}
|
||||
|
||||
/// How many photos of a batch of `n` render side by side. Each render already spreads its rows
|
||||
/// over every core; the others fill the cores while it decodes, encodes or waits for the GPU.
|
||||
/// One on wasm32 (no threads).
|
||||
pub fn export_parallelism(n: usize) -> usize {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return 1;
|
||||
}
|
||||
let cores = std::thread::available_parallelism().map_or(1, |c| c.get());
|
||||
(cores / 4).clamp(1, 4).min(n.max(1))
|
||||
}
|
||||
|
||||
/// [`run_batch`] with `lanes` photos in flight (the calling thread is one of them).
|
||||
fn run_batch_with(
|
||||
items: Vec<PreparedExport>,
|
||||
placer: &mut Placer<'_>,
|
||||
progress: &mut dyn FnMut(usize, &str) -> bool,
|
||||
lanes: usize,
|
||||
) -> Result<Vec<serde_json::Value>, String> {
|
||||
placer.single = items.len() == 1;
|
||||
if lanes <= 1 || items.len() <= 1 {
|
||||
for (i, item) in items.into_iter().enumerate() {
|
||||
if !progress(i, &item.file_name) {
|
||||
break;
|
||||
}
|
||||
let (photo, name, guard) = (item.photo, item.file_name.clone(), item.guard.clone());
|
||||
placer.place(photo, name, &guard, run_gated(item))?;
|
||||
}
|
||||
let (photo, name, guard) = (item.photo, item.file_name.clone(), item.guard.clone());
|
||||
let e = match item.run() {
|
||||
Ok(e) => e,
|
||||
Err(err) if stop_on_error => return Err(err),
|
||||
Err(err) => {
|
||||
out.push(json!({"photo": photo.0, "file": name, "error": err}));
|
||||
return Ok(std::mem::take(&mut placer.out));
|
||||
}
|
||||
let heads: Vec<_> = items.iter().map(|i| (i.photo, i.file_name.clone(), i.guard.clone())).collect();
|
||||
let queue = Queue {
|
||||
lane: std::sync::Mutex::new(Lane { items: items.into_iter().map(Some).collect(), next: 0, placed: 0, stop: false, done: Default::default() }),
|
||||
cv: std::sync::Condvar::new(),
|
||||
ahead: lanes,
|
||||
};
|
||||
std::thread::scope(|sc| {
|
||||
for k in 1..lanes {
|
||||
let q = &queue;
|
||||
// a lane that can't start leaves its photos to the others (and to this thread)
|
||||
if std::thread::Builder::new().name(format!("export-{k}")).spawn_scoped(sc, move || q.work()).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let placed = || {
|
||||
for (i, (photo, name, guard)) in heads.into_iter().enumerate() {
|
||||
if !progress(i, &name) {
|
||||
break;
|
||||
}
|
||||
let e = queue.result(i);
|
||||
placer.place(photo, name, &guard, e)?;
|
||||
}
|
||||
Ok::<_, String>(())
|
||||
};
|
||||
let r = placed();
|
||||
queue.stop();
|
||||
r
|
||||
})?;
|
||||
Ok(std::mem::take(&mut placer.out))
|
||||
}
|
||||
|
||||
/// Run one photo's export holding its working memory from [`crate::memory::export_gate`]; a panic
|
||||
/// becomes that photo's error, not the batch's end.
|
||||
fn run_gated(item: PreparedExport) -> Result<Exported, String> {
|
||||
let _held = crate::memory::export_gate().acquire(item.weight);
|
||||
let what = format!("exporting {}", item.file_name);
|
||||
crate::guard::catch(&what, || item.run()).and_then(|r| r)
|
||||
}
|
||||
|
||||
/// The photos of a batch shared by its lanes: each lane takes the next one, at most
|
||||
/// [`Queue::ahead`] past the last placed. That bounds the finished files waiting in memory for
|
||||
/// their turn too (their working memory is given back to the gate as soon as they are encoded).
|
||||
struct Queue {
|
||||
lane: std::sync::Mutex<Lane>,
|
||||
cv: std::sync::Condvar,
|
||||
ahead: usize,
|
||||
}
|
||||
|
||||
struct Lane {
|
||||
items: Vec<Option<PreparedExport>>,
|
||||
/// The next photo to start.
|
||||
next: usize,
|
||||
/// Photos handed to the placer so far.
|
||||
placed: usize,
|
||||
stop: bool,
|
||||
done: std::collections::HashMap<usize, Result<Exported, String>>,
|
||||
}
|
||||
|
||||
impl Lane {
|
||||
fn take_next(&mut self) -> Option<(usize, PreparedExport)> {
|
||||
let i = self.next;
|
||||
let item = self.items.get_mut(i)?.take()?;
|
||||
self.next += 1;
|
||||
Some((i, item))
|
||||
}
|
||||
}
|
||||
|
||||
impl Queue {
|
||||
fn lock(&self) -> std::sync::MutexGuard<'_, Lane> {
|
||||
self.lane.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn wait<'a>(&self, g: std::sync::MutexGuard<'a, Lane>) -> std::sync::MutexGuard<'a, Lane> {
|
||||
self.cv.wait(g).unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn finish(&self, i: usize, r: Result<Exported, String>) {
|
||||
self.lock().done.insert(i, r);
|
||||
self.cv.notify_all();
|
||||
}
|
||||
|
||||
/// A lane thread: run photos until none is left or the batch stops.
|
||||
fn work(&self) {
|
||||
loop {
|
||||
let mut l = self.lock();
|
||||
let (i, item) = loop {
|
||||
if l.stop || l.next >= l.items.len() {
|
||||
return;
|
||||
}
|
||||
if l.next < l.placed.saturating_add(self.ahead) {
|
||||
match l.take_next() {
|
||||
Some(t) => break t,
|
||||
None => return,
|
||||
}
|
||||
}
|
||||
l = self.wait(l);
|
||||
};
|
||||
drop(l);
|
||||
self.finish(i, run_gated(item));
|
||||
}
|
||||
}
|
||||
|
||||
/// Photo `i`'s result, waiting for it; run here when no lane has started it yet.
|
||||
fn result(&self, i: usize) -> Result<Exported, String> {
|
||||
let mut l = self.lock();
|
||||
loop {
|
||||
if let Some(r) = l.done.remove(&i) {
|
||||
l.placed = i + 1;
|
||||
drop(l);
|
||||
self.cv.notify_all();
|
||||
return r;
|
||||
}
|
||||
if l.next == i {
|
||||
let Some((_, item)) = l.take_next() else {
|
||||
return Err("export: photo missing from the batch".into());
|
||||
};
|
||||
drop(l);
|
||||
self.finish(i, run_gated(item));
|
||||
l = self.lock();
|
||||
continue;
|
||||
}
|
||||
l = self.wait(l);
|
||||
}
|
||||
}
|
||||
|
||||
fn stop(&self) {
|
||||
self.lock().stop = true;
|
||||
self.cv.notify_all();
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a batch's files go: each photo's path (subfolder, conflict policy, sidecars, never an
|
||||
/// original) and its write, in batch order.
|
||||
struct Placer<'a> {
|
||||
o: &'a ExportOptions,
|
||||
to: &'a Destination,
|
||||
dir: String,
|
||||
single: bool,
|
||||
taken: std::collections::HashSet<String>,
|
||||
out: Vec<serde_json::Value>,
|
||||
write: &'a mut dyn FnMut(&str, &[u8]) -> Result<(), String>,
|
||||
exists: &'a dyn Fn(&str) -> bool,
|
||||
stop_on_error: bool,
|
||||
}
|
||||
|
||||
fn join_path(a: &str, b: &str) -> String {
|
||||
if a.is_empty() { b.to_string() } else { format!("{}/{b}", a.trim_end_matches('/')) }
|
||||
}
|
||||
|
||||
impl<'a> Placer<'a> {
|
||||
fn new(
|
||||
o: &'a ExportOptions,
|
||||
to: &'a Destination,
|
||||
write: &'a mut dyn FnMut(&str, &[u8]) -> Result<(), String>,
|
||||
exists: &'a dyn Fn(&str) -> bool,
|
||||
stop_on_error: bool,
|
||||
) -> Placer<'a> {
|
||||
let dir = if o.subfolder.is_empty() { to.dir.clone() } else { join_path(&to.dir, &o.subfolder) };
|
||||
Placer { o, to, dir, single: false, taken: Default::default(), out: Vec::new(), write, exists, stop_on_error }
|
||||
}
|
||||
|
||||
/// Place photo `photo`'s export result (file `name`): write it and record the outcome. `Err`
|
||||
/// only when it failed and `stop_on_error` is set.
|
||||
fn place(
|
||||
&mut self,
|
||||
photo: lightcraft_catalog::PhotoId,
|
||||
name: String,
|
||||
guard: &crate::originals::OriginalGuard,
|
||||
e: Result<Exported, String>,
|
||||
) -> Result<(), String> {
|
||||
use serde_json::json;
|
||||
let (o, dir, exists) = (self.o, &self.dir, self.exists);
|
||||
let e = match e {
|
||||
Ok(e) => e,
|
||||
Err(err) if self.stop_on_error => return Err(err),
|
||||
Err(err) => {
|
||||
self.out.push(json!({"photo": photo.0, "file": name, "error": err}));
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
// the exported file and its sidecars
|
||||
let group = |main: &str| std::iter::once(main.to_string()).chain(e.sidecars.iter().map(|(x, _)| sidecar_path(main, x))).collect::<Vec<_>>();
|
||||
let path = match to.exact.as_deref().filter(|_| single) {
|
||||
let path = match self.to.exact.as_deref().filter(|_| self.single) {
|
||||
Some(p) => Ok(p.to_string()),
|
||||
None => {
|
||||
let path = join(&dir, &e.file_name);
|
||||
let path = join_path(dir, &e.file_name);
|
||||
let taken = &self.taken;
|
||||
let busy = |main: &str| group(main).iter().any(|p| taken.contains(p) || exists(p));
|
||||
if !busy(&path) {
|
||||
Ok(path)
|
||||
@@ -1542,12 +1837,12 @@ pub fn run_batch(
|
||||
match o.conflict {
|
||||
Conflict::Overwrite => Ok(path),
|
||||
Conflict::Skip => {
|
||||
out.push(json!({"skipped": path}));
|
||||
continue;
|
||||
self.out.push(json!({"skipped": path}));
|
||||
return Ok(());
|
||||
}
|
||||
Conflict::Unique => {
|
||||
let (stem, ext) = e.file_name.rsplit_once('.').map_or((e.file_name.as_str(), None), |(a, b)| (a, Some(b)));
|
||||
let name = |n: usize| join(&dir, &ext.map_or(format!("{stem}-{n}"), |x| format!("{stem}-{n}.{x}")));
|
||||
let name = |n: usize| join_path(dir, &ext.map_or(format!("{stem}-{n}"), |x| format!("{stem}-{n}.{x}")));
|
||||
(2..1_000_000).map(name).find(|p| !busy(p)).ok_or_else(|| format!("{path}: no free file name"))
|
||||
}
|
||||
}
|
||||
@@ -1555,6 +1850,7 @@ pub fn run_batch(
|
||||
}
|
||||
};
|
||||
let file = path.clone().unwrap_or_else(|_| name.clone());
|
||||
let write = &mut *self.write;
|
||||
let written = path.and_then(|path| {
|
||||
let files = group(&path);
|
||||
// never over an original, whatever the conflict policy or the exact path said
|
||||
@@ -1571,14 +1867,14 @@ pub fn run_batch(
|
||||
});
|
||||
match written {
|
||||
Ok((path, files, sidecars)) => {
|
||||
taken.extend(files);
|
||||
out.push(json!({"path": path, "width": e.width, "height": e.height, "bytes": e.bytes.len(), "sidecars": sidecars}));
|
||||
self.taken.extend(files);
|
||||
self.out.push(json!({"path": path, "width": e.width, "height": e.height, "bytes": e.bytes.len(), "sidecars": sidecars}));
|
||||
}
|
||||
Err(err) if stop_on_error => return Err(err),
|
||||
Err(err) => out.push(json!({"photo": photo.0, "file": file, "error": err})),
|
||||
Err(err) if self.stop_on_error => return Err(err),
|
||||
Err(err) => self.out.push(json!({"photo": photo.0, "file": file, "error": err})),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1626,9 +1922,28 @@ mod tests {
|
||||
assert!(img.data[3][0] < 80 && img.data[4][0] > 170);
|
||||
}
|
||||
|
||||
/// Exported files carry keywords as Lightroom Classic writes them: names flat in `dc:subject`
|
||||
/// (with the keywords containing them), full paths in `lr:hierarchicalSubject`, and nothing of
|
||||
/// a keyword left out of export. They used to carry the `a|b` paths in `dc:subject`.
|
||||
#[test]
|
||||
fn exported_keywords_follow_their_options() {
|
||||
use lightcraft_catalog::{Catalog, Op, Photo, PhotoId, Source, keywords::KeywordInfo};
|
||||
let mut c = Catalog::new();
|
||||
c.apply(Op::SetKeyword { path: "Places".into(), info: Some(KeywordInfo { include_on_export: false, ..KeywordInfo::default() }) }).unwrap();
|
||||
c.apply(Op::SetKeyword { path: "draft".into(), info: Some(KeywordInfo { include_on_export: false, ..KeywordInfo::default() }) }).unwrap();
|
||||
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.jpg", "jpeg", 10, 10, "2026-09-30T00:00:00");
|
||||
p.meta.keywords = vec!["Places|Lisbon".into(), "travel|Italy".into(), "draft".into()];
|
||||
let m = export_metadata(&p, &c, &ExportOptions::default()).unwrap();
|
||||
assert_eq!(m.keywords, ["Lisbon", "Italy", "travel"]);
|
||||
assert_eq!(m.hierarchical_keywords, ["Places|Lisbon", "travel|Italy"]);
|
||||
let jpg = encode_with_metadata(&test_image(), &ExportOptions::default(), Some(&m)).unwrap();
|
||||
let back = lightcraft_meta::extract(&jpg);
|
||||
assert_eq!((back.keywords, back.hierarchical_keywords), (m.keywords.clone(), m.hierarchical_keywords.clone()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_policies() {
|
||||
use lightcraft_catalog::{Photo, PhotoId, Source};
|
||||
use lightcraft_catalog::{Catalog, Photo, PhotoId, Source};
|
||||
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.jpg", "jpeg", 10, 10, "2026-09-30T00:00:00");
|
||||
p.meta.camera = "Synthetic X2".into();
|
||||
p.meta.copyright = "(c) Me".into();
|
||||
@@ -1637,21 +1952,21 @@ mod tests {
|
||||
p.meta.copyright_url = "https://example.com/rights".into();
|
||||
p.meta.shutter = "1/250".into();
|
||||
p.meta.gps = Some((43.0, -110.0));
|
||||
let all = export_metadata(&p, &ExportOptions::default()).unwrap();
|
||||
let all = export_metadata(&p, &Catalog::new(), &ExportOptions::default()).unwrap();
|
||||
assert_eq!(all.model.as_deref(), Some("Synthetic X2"));
|
||||
assert!((all.exposure_time.unwrap() - 0.004).abs() < 1e-9);
|
||||
assert!(all.gps.is_some());
|
||||
let o = ExportOptions { metadata: MetadataPolicy::AllExceptCamera, remove_location: true, ..Default::default() };
|
||||
let m = export_metadata(&p, &o).unwrap();
|
||||
let m = export_metadata(&p, &Catalog::new(), &o).unwrap();
|
||||
assert!(m.model.is_none() && m.gps.is_none() && m.copyright.is_some());
|
||||
let c = export_metadata(&p, &ExportOptions { metadata: MetadataPolicy::Copyright, ..Default::default() }).unwrap();
|
||||
let c = export_metadata(&p, &Catalog::new(), &ExportOptions { metadata: MetadataPolicy::Copyright, ..Default::default() }).unwrap();
|
||||
assert!(c.model.is_none() && c.gps.is_none() && c.copyright.as_deref() == Some("(c) Me"));
|
||||
// "copyright only" keeps all the copyright info: status, usage terms, info URL
|
||||
assert_eq!(
|
||||
(c.copyright_marked, c.usage_terms.as_deref(), c.copyright_url.as_deref()),
|
||||
(Some(true), Some("Editorial use only"), Some("https://example.com/rights"))
|
||||
);
|
||||
assert!(export_metadata(&p, &ExportOptions { metadata: MetadataPolicy::None, ..Default::default() }).is_none());
|
||||
assert!(export_metadata(&p, &Catalog::new(), &ExportOptions { metadata: MetadataPolicy::None, ..Default::default() }).is_none());
|
||||
// embedded and readable back from the JPEG
|
||||
let jpg = encode_with_metadata(&test_image(), &ExportOptions::default(), Some(&all)).unwrap();
|
||||
let back = lightcraft_meta::extract(&jpg);
|
||||
@@ -2225,4 +2540,103 @@ mod tests {
|
||||
assert!(WATERMARK_SIZE_RANGE.0 <= 0.01 && WATERMARK_SIZE_RANGE.1 >= 0.15);
|
||||
assert!(WATERMARK_IMAGE_WIDTH_RANGE.0 <= 0.02 && WATERMARK_IMAGE_WIDTH_RANGE.1 >= 1.0);
|
||||
}
|
||||
|
||||
/// A batch with `lanes` photos in flight into a fake folder `out` (nothing exists there):
|
||||
/// results, writes (path, bytes) and progress calls; `progress` returns false at `cancel_at`.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn lanes_batch(
|
||||
s: &mut crate::Session,
|
||||
ids: &[lightcraft_catalog::PhotoId],
|
||||
o: &ExportOptions,
|
||||
lanes: usize,
|
||||
stop_on_error: bool,
|
||||
cancel_at: usize,
|
||||
) -> (Result<Vec<serde_json::Value>, String>, Vec<(String, Vec<u8>)>, Vec<(usize, String)>) {
|
||||
let items = prepare_batch(s, ids, o).unwrap();
|
||||
let to = Destination { dir: "out".into(), exact: None };
|
||||
let mut written = Vec::new();
|
||||
let mut write = |p: &str, b: &[u8]| {
|
||||
written.push((p.to_string(), b.to_vec()));
|
||||
Ok(())
|
||||
};
|
||||
let mut seen = Vec::new();
|
||||
let mut progress = |i: usize, name: &str| {
|
||||
seen.push((i, name.to_string()));
|
||||
i < cancel_at
|
||||
};
|
||||
let files = run_batch_with(items, &mut Placer::new(o, &to, &mut write, &|_| false, stop_on_error), &mut progress, lanes);
|
||||
(files, written, seen)
|
||||
}
|
||||
|
||||
/// The files of a batch without their byte counts: a GPU render (when another test turns the
|
||||
/// GPU off mid-run) may differ from a CPU one by an LSB, never in name, size or order.
|
||||
fn shape(files: &[serde_json::Value]) -> Vec<serde_json::Value> {
|
||||
files.iter().map(|f| json!({"path": f["path"], "width": f["width"], "height": f["height"], "error": f["error"]})).collect()
|
||||
}
|
||||
|
||||
// Issue #496: photos rendered side by side give the same files, names, order and progress as
|
||||
// one at a time, and a cancel stops at the same photo
|
||||
#[test]
|
||||
fn side_by_side_batches_match_one_at_a_time() {
|
||||
let mut s = crate::Session::with_demo();
|
||||
let ids: Vec<_> = s.visible().iter().copied().take(5).collect();
|
||||
// one name for all: the Unique numbering follows the order the files are placed in
|
||||
let o = ExportOptions::from_json(&json!({"format": "png", "width": 40, "naming": "same"}));
|
||||
let (files, written, seen) = lanes_batch(&mut s, &ids, &o, 1, true, usize::MAX);
|
||||
let files = files.unwrap();
|
||||
let paths: Vec<_> = written.iter().map(|(p, _)| p.clone()).collect();
|
||||
assert_eq!(paths, ["out/same.png", "out/same-2.png", "out/same-3.png", "out/same-4.png", "out/same-5.png"]);
|
||||
let heights: std::collections::HashSet<_> = files.iter().map(|f| f["height"].as_u64()).collect();
|
||||
assert!(heights.len() > 1, "photos of different shapes show the order: {files:?}");
|
||||
for lanes in [2, 3, 8] {
|
||||
let (f, w, sn) = lanes_batch(&mut s, &ids, &o, lanes, true, usize::MAX);
|
||||
assert_eq!(shape(&f.unwrap()), shape(&files), "{lanes} lanes");
|
||||
assert_eq!(w.iter().map(|(p, _)| p.clone()).collect::<Vec<_>>(), paths, "{lanes} lanes");
|
||||
assert_eq!(sn, seen, "{lanes} lanes");
|
||||
}
|
||||
let (f, w, sn) = lanes_batch(&mut s, &ids, &o, 3, true, 2);
|
||||
assert_eq!(shape(&f.unwrap()), shape(&files[..2]));
|
||||
assert_eq!((w.len(), sn.len()), (2, 3), "photos past the cancel are never written");
|
||||
}
|
||||
|
||||
// A photo that fails is reported in its place while the others are exported; with
|
||||
// stop_on_error the batch ends there and nothing after it is written
|
||||
#[test]
|
||||
fn side_by_side_batches_report_failures_in_order() {
|
||||
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
|
||||
let mut s = crate::Session::with_demo();
|
||||
let mut ids: Vec<_> = s.visible().iter().copied().take(3).collect();
|
||||
let gone =
|
||||
Photo::new(PhotoId(9_496), Source::File { path: "no/such/dir/gone.jpg".into() }, "gone.jpg", "JPEG", 600, 400, "2026-10-09T00:00:00");
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(gone) }).unwrap();
|
||||
ids.insert(1, PhotoId(9_496));
|
||||
let o = ExportOptions::from_json(&json!({"format": "jpeg", "width": 40}));
|
||||
let (files, written, _) = lanes_batch(&mut s, &ids, &o, 3, false, usize::MAX);
|
||||
let files = files.unwrap();
|
||||
assert_eq!(files.len(), 4);
|
||||
assert_eq!((files[1]["photo"].as_u64(), files[1]["file"].as_str()), (Some(9_496), Some("gone.jpg")), "{files:?}");
|
||||
assert!(files[1]["error"].is_string());
|
||||
assert!([0, 2, 3].iter().all(|&i| files[i]["path"].is_string()), "{files:?}");
|
||||
assert_eq!(written.len(), 3);
|
||||
let one = lanes_batch(&mut s, &ids, &o, 1, false, usize::MAX).0.unwrap();
|
||||
assert_eq!(shape(&files), shape(&one));
|
||||
|
||||
let (r, written, _) = lanes_batch(&mut s, &ids, &o, 3, true, usize::MAX);
|
||||
assert!(r.is_err());
|
||||
assert_eq!(written.len(), 1, "only the photo before the failure");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_weights_follow_the_source_and_output_sizes() {
|
||||
use crate::media::SourceLevel;
|
||||
let mb = |b: usize| b >> 20;
|
||||
// a full-size 24 MP export (measured peak ~1.2 GB)
|
||||
assert_eq!(mb(render_weight(6000 * 4000, 6000, SourceLevel::Full, 6000 * 4000)), 1098);
|
||||
// 2048 px from the 2560 px preview (measured ~150 MB)
|
||||
assert_eq!(mb(render_weight(6000 * 4000, 6000, SourceLevel::Preview, 2048 * 1365)), 145);
|
||||
// a photo smaller than the preview level is its own source
|
||||
assert_eq!(render_weight(1000 * 500, 1000, SourceLevel::Preview, 0), 1000 * 500 * 12);
|
||||
assert_eq!(render_weight(0, 0, SourceLevel::Full, 0), 0);
|
||||
assert!(render_weight(usize::MAX, 1, SourceLevel::Full, usize::MAX) > 0, "no overflow");
|
||||
}
|
||||
}
|
||||
+365
-20
@@ -37,7 +37,8 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option<String>) {
|
||||
usage_terms: m.usage_terms.clone().unwrap_or_default(),
|
||||
copyright_url: m.copyright_url.clone().unwrap_or_default(),
|
||||
creator: m.artist.clone().unwrap_or_default(),
|
||||
keywords: m.keywords.clone(),
|
||||
// paths from `lr:hierarchicalSubject`, names from `dc:subject` that aren't their levels
|
||||
keywords: lightcraft_catalog::keywords::from_file(&m.keywords, &m.hierarchical_keywords),
|
||||
regions: m.regions.clone(),
|
||||
};
|
||||
(meta, m.capture_time.as_ref().map(|d| d.to_iso()))
|
||||
@@ -151,8 +152,9 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
|
||||
});
|
||||
}
|
||||
let fmt = lightcraft_codecs::sniff(bytes).ok_or("unrecognized file format")?;
|
||||
if !fmt.can_decode() {
|
||||
return Err(format!("{fmt:?} files are not supported yet"));
|
||||
if let Some(why) = fmt.not_decodable() {
|
||||
// e.g. "Heif files can't be opened: HEIC/HEIF support isn't included in this build of LightCraft"
|
||||
return Err(format!("{fmt:?} files can't be opened: {why}"));
|
||||
}
|
||||
// headers only (issue #367: decoding the pixels was nearly all of an import's CPU time)
|
||||
let header = lightcraft_codecs::read_header(bytes).map_err(|e| e.to_string())?;
|
||||
@@ -200,6 +202,86 @@ fn preview_reason(bytes: &[u8], e: lightcraft_raw::RawError) -> Result<String, S
|
||||
/// Sensor clip level (normalised) for highlight reconstruction.
|
||||
const HIGHLIGHT_CLIP: f32 = 0.99;
|
||||
|
||||
/// How a raw's camera RGB becomes the linear Rec.2020 the pipeline takes.
|
||||
pub(crate) struct CameraColourModel<'a> {
|
||||
/// White-balance multipliers (as shot).
|
||||
pub wb: [f32; 3],
|
||||
/// White-balanced camera RGB → linear Rec.2020: the camera matrix, or a look fitted to the camera's JPEG on top.
|
||||
pub matrix: lightcraft_color::Mat3,
|
||||
/// Baseline exposure, as a gain.
|
||||
pub gain: f32,
|
||||
/// A DNG profile's hue/saturation map and look table.
|
||||
pub tables: Option<&'a lightcraft_raw::profile::ProfileTables>,
|
||||
/// A fitted camera look's hue/saturation table.
|
||||
pub hue_sat: Option<&'a crate::camera_preview::HueSat<'a>>,
|
||||
}
|
||||
|
||||
impl CameraColourModel<'_> {
|
||||
/// Rebuild sensor-clipped channels; fully clipped highlights become [`clipped_white`] of the matrix, so they
|
||||
/// come out of [`Self::apply`] neutral.
|
||||
pub fn rebuild_highlights(&self, img: &mut Rgb32f) {
|
||||
lightcraft_raw::highlight::reconstruct_with(img, self.wb, HIGHLIGHT_CLIP, clipped_white(&self.matrix));
|
||||
}
|
||||
|
||||
/// White balance, matrix, tables and baseline exposure, in place.
|
||||
pub fn apply(&self, img: &mut Rgb32f) {
|
||||
let (wb, m, gain) = (self.wb, self.matrix.to_f32(), self.gain);
|
||||
img.map_in_place(|p| {
|
||||
let c = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]];
|
||||
let rgb = [
|
||||
m[0][0] * c[0] + m[0][1] * c[1] + m[0][2] * c[2],
|
||||
m[1][0] * c[0] + m[1][1] * c[1] + m[1][2] * c[2],
|
||||
m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2],
|
||||
];
|
||||
let rgb = match self.tables {
|
||||
Some(tables) => tables.apply(rgb, gain),
|
||||
None => rgb.map(|v| v * gain),
|
||||
};
|
||||
self.hue_sat.map_or(rgb, |h| h.apply(rgb)).map(|v| v.max(0.0))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The white-balanced camera RGB that `model` (white-balanced camera RGB → linear Rec.2020) renders
|
||||
/// neutral, as bright as it renders `[1, 1, 1]`: what fully clipped highlights are rebuilt to, so they come out
|
||||
/// neutral whatever the colour model does to the camera's own neutral (a look fitted to the camera's JPEG or a
|
||||
/// camera profile often tints it by a few per cent, which any darkening of the highlights would show).
|
||||
/// `[1, 1, 1]` (the camera's own neutral, as before this existed) when the model can't be trusted with it: not
|
||||
/// invertible or nearly so, an answer far from the camera's neutral (a sane colour model moves white by a few per
|
||||
/// cent), or one that isn't neutral through the `f32` matrix the decode applies.
|
||||
pub(crate) fn clipped_white(model: &lightcraft_color::Mat3) -> [f32; 3] {
|
||||
const CAMERA: [f32; 3] = [1.0; 3];
|
||||
/// Largest condition number (max row sum norm) of a model trusted for this.
|
||||
const CONDITION: f64 = 1e3;
|
||||
/// Largest factor between the answer and the camera's neutral, per channel.
|
||||
const SPREAD: f64 = 4.0;
|
||||
let norm = |m: &lightcraft_color::Mat3| m.0.iter().map(|row| row.iter().map(|v| v.abs()).sum::<f64>()).fold(0.0, f64::max);
|
||||
let Some(inverse) = model.inverse() else { return CAMERA };
|
||||
let condition = norm(model) * norm(&inverse);
|
||||
if !condition.is_finite() || condition > CONDITION {
|
||||
return CAMERA;
|
||||
}
|
||||
let luma = |v: [f64; 3]| {
|
||||
let c = model.apply(v);
|
||||
(0..3).map(|i| c[i] * f64::from(lightcraft_color::LUMA_2020[i])).sum::<f64>()
|
||||
};
|
||||
let n = inverse.apply([1.0; 3]);
|
||||
let (target, own) = (luma([1.0; 3]), luma(n));
|
||||
if !(target > 0.0 && own > 0.0 && target.is_finite() && own.is_finite()) {
|
||||
return CAMERA;
|
||||
}
|
||||
let white = n.map(|v| v * target / own);
|
||||
if !white.iter().all(|v| v.is_finite() && (1.0 / SPREAD..=SPREAD).contains(v)) {
|
||||
return CAMERA;
|
||||
}
|
||||
let white = white.map(|v| v as f32);
|
||||
// neutral through the matrix as the decode applies it
|
||||
let m = model.to_f32();
|
||||
let out: [f32; 3] = std::array::from_fn(|i| m[i][0] * white[0] + m[i][1] * white[1] + m[i][2] * white[2]);
|
||||
let (lo, hi) = (out[0].min(out[1]).min(out[2]), out[0].max(out[1]).max(out[2]));
|
||||
if lo > 0.0 && hi.is_finite() && hi - lo <= 1e-3 * hi { white } else { CAMERA }
|
||||
}
|
||||
|
||||
/// The largest block size to bin a raw's mosaic by for a source of at most `max_edge` pixels:
|
||||
/// the binned image must keep at least 90 % of `max_edge` (a 16 MP sensor still bins 2× for the
|
||||
/// 2560 px preview). X-Trans can only bin 3× (its 6×6 pattern), and its full demosaic is ~5× the
|
||||
@@ -303,32 +385,24 @@ fn load_bytes_now(
|
||||
let twin = denoise.and_then(|spec| denoised_camera_rgb(&raw, spec, factor)).filter(|d| d.width == img.width && d.height == img.height);
|
||||
// the samples aren't needed any more (the colour model below reads only the tags)
|
||||
raw.data = lightcraft_raw::RawData::U16(Vec::new());
|
||||
let wb = t.wb;
|
||||
let m = camera_look.as_ref().map(|p| p.matrix.mul(&t.matrix)).unwrap_or(t.matrix).to_f32();
|
||||
let hue_sat = camera_look.as_ref().and_then(|p| p.hue_sat.as_ref()).and_then(crate::camera_preview::HueSat::new);
|
||||
let gain = 2f32.powf(t.baseline_exposure as f32);
|
||||
// A DNG's own profile look (hue/saturation map, look table), DNG spec chapter 6.
|
||||
let tables = lightcraft_raw::profile::ProfileTables::new(&raw.color.profile, lightcraft_raw::color::illuminant_weight(&raw.color, xy));
|
||||
let colour = CameraColourModel {
|
||||
wb: t.wb,
|
||||
matrix: camera_look.as_ref().map(|p| p.matrix.mul(&t.matrix)).unwrap_or(t.matrix),
|
||||
gain: 2f32.powf(t.baseline_exposure as f32),
|
||||
tables: tables.as_ref(),
|
||||
hue_sat: hue_sat.as_ref(),
|
||||
};
|
||||
// camera RGB → what the pipeline takes: clipped highlights rebuilt, white balance and the colour model applied,
|
||||
// fitted to `max_edge` and upright (the same for the plain and the denoised picture)
|
||||
let finish = |mut img: Rgb32f| {
|
||||
let mut stages = vec![("develop", t0.elapsed())];
|
||||
stages.push(("transform", t0.elapsed()));
|
||||
lightcraft_raw::highlight::reconstruct(&mut img, wb, HIGHLIGHT_CLIP);
|
||||
colour.rebuild_highlights(&mut img);
|
||||
stages.push(("highlights", t0.elapsed()));
|
||||
img.map_in_place(|p| {
|
||||
let c = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]];
|
||||
let rgb = [
|
||||
m[0][0] * c[0] + m[0][1] * c[1] + m[0][2] * c[2],
|
||||
m[1][0] * c[0] + m[1][1] * c[1] + m[1][2] * c[2],
|
||||
m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2],
|
||||
];
|
||||
let rgb = match &tables {
|
||||
Some(tables) => tables.apply(rgb, gain),
|
||||
None => rgb.map(|v| v * gain),
|
||||
};
|
||||
hue_sat.as_ref().map_or(rgb, |h| h.apply(rgb)).map(|v| v.max(0.0))
|
||||
});
|
||||
colour.apply(&mut img);
|
||||
stages.push(("colour", t0.elapsed()));
|
||||
let img = fit(&img, max_edge, max_edge, Filter::Box);
|
||||
stages.push(("fit", t0.elapsed()));
|
||||
@@ -588,6 +662,19 @@ impl crate::Session {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// Keywords from a file keep their hierarchy: the paths of `lr:hierarchicalSubject`, not the
|
||||
/// flat names of `dc:subject` that LightCraft's exports (and Lightroom Classic) write besides
|
||||
/// them; an exported `travel|Italy|Rome` comes back as itself.
|
||||
#[test]
|
||||
fn imported_keywords_keep_their_hierarchy() {
|
||||
let m = lightcraft_meta::Metadata {
|
||||
keywords: vec!["Rome".into(), "Italy".into(), "travel".into(), "beach".into()],
|
||||
hierarchical_keywords: vec!["travel|Italy|Rome".into()],
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(super::meta_of(&m).0.keywords, ["travel|Italy|Rome", "beach"]);
|
||||
}
|
||||
|
||||
use super::*;
|
||||
use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg};
|
||||
|
||||
@@ -869,6 +956,240 @@ mod tests {
|
||||
assert!((mean(&dim) / mean(&before) - 0.5).abs() < 0.02, "{} vs {}", mean(&dim), mean(&before));
|
||||
}
|
||||
|
||||
/// A look fitted to a camera's JPEG, as `camera_preview` makes them: its rows don't sum to one, so the camera's
|
||||
/// own white-balanced neutral comes out yellow (blue 12 % low).
|
||||
fn yellowing_look() -> lightcraft_color::Mat3 {
|
||||
lightcraft_color::Mat3([[0.42, 0.548, 0.04], [0.246, 0.728, 0.033], [-0.247, 0.679, 0.447]])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipped_white_renders_neutral_through_the_colour_model() {
|
||||
use lightcraft_color::{LUMA_2020, Mat3};
|
||||
let look = yellowing_look();
|
||||
let white = clipped_white(&look);
|
||||
let out = look.apply(white.map(f64::from));
|
||||
assert!(out.iter().all(|v| (v - out[1]).abs() < 1e-5), "{white:?} renders {out:?}");
|
||||
let luma = |c: [f64; 3]| (0..3).map(|i| c[i] * f64::from(LUMA_2020[i])).sum::<f64>();
|
||||
assert!((luma(out) - luma(look.apply([1.0; 3]))).abs() < 1e-5, "as bright as the camera's neutral");
|
||||
assert_eq!(clipped_white(&Mat3::IDENTITY), [1.0; 3]);
|
||||
// a model that can't be inverted, or only just, keeps the camera's neutral
|
||||
assert_eq!(clipped_white(&Mat3([[1.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]])), [1.0; 3]);
|
||||
assert_eq!(clipped_white(&Mat3([[1.0, 1.0, 0.0], [1.0, 1.0 + 1e-8, 0.0], [0.0, 0.0, 1.0]])), [1.0; 3]);
|
||||
// so does one whose white would overflow the rebuilt highlights (2.5e38 in red here)
|
||||
assert_eq!(clipped_white(&Mat3([[3e-39, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]])), [1.0; 3]);
|
||||
// or move the camera's neutral implausibly far
|
||||
assert_eq!(clipped_white(&Mat3([[0.1, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]])), [1.0; 3]);
|
||||
// what it answers renders neutral through the f32 matrix, and fully clipped highlights stay finite
|
||||
for model in [look, Mat3([[1.9, -0.7, -0.2], [-0.3, 1.4, -0.1], [0.0, -0.4, 1.4]])] {
|
||||
let w = clipped_white(&model);
|
||||
let m = model.to_f32();
|
||||
let out: [f32; 3] = std::array::from_fn(|i| m[i][0] * w[0] + m[i][1] * w[1] + m[i][2] * w[2]);
|
||||
assert!(out.iter().all(|v| (v - out[1]).abs() <= 1e-3 * out[1]), "{w:?} renders {out:?}");
|
||||
let colour = CameraColourModel { wb: [2.5, 1.0, 1.6], matrix: model, gain: 1.0, tables: None, hue_sat: None };
|
||||
let mut img = Rgb32f::filled(4, 4, [1.0; 3]);
|
||||
colour.rebuild_highlights(&mut img);
|
||||
colour.apply(&mut img);
|
||||
assert!(img.data.iter().flatten().all(|v| v.is_finite()), "{:?}", img.get(0, 0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue #523: fully clipped highlights are neutral in the white-balanced camera RGB, but a colour model that
|
||||
/// doesn't map that neutral to neutral tinted them. The tone map's roll-off to white hid the tint at Neutral;
|
||||
/// Highlights -100, or the issue's edit (Exposure -0.88, Highlights -75…), showed it.
|
||||
#[test]
|
||||
fn clipped_highlights_stay_neutral_when_darkened() {
|
||||
use lightcraft_pipeline::{RenderRequest, render};
|
||||
let wb = [2.4f32, 1.0, 1.6];
|
||||
let colour = CameraColourModel { wb, matrix: yellowing_look(), gain: 1.0, tables: None, hue_sat: None };
|
||||
// a grey wall (camera neutral) with a fully clipped light and a light clipped in green only
|
||||
let mut img = Rgb32f::from_fn(96, 64, |x, y| {
|
||||
let d = |cx: f32| ((x as f32 - cx).powi(2) + (y as f32 - 32.0).powi(2)).sqrt();
|
||||
if d(28.0) < 14.0 {
|
||||
[1.0; 3]
|
||||
} else if d(70.0) < 12.0 {
|
||||
[0.6 / wb[0], 1.0, 0.7 / wb[2]]
|
||||
} else {
|
||||
[0.3 / wb[0], 0.3, 0.3 / wb[2]]
|
||||
}
|
||||
});
|
||||
colour.rebuild_highlights(&mut img);
|
||||
colour.apply(&mut img);
|
||||
let info = SourceInfo { raw: true, ..Default::default() };
|
||||
let mut issue = lightcraft_develop::DevelopSettings::default();
|
||||
let l = &mut issue.light;
|
||||
(l.exposure, l.contrast, l.highlights, l.shadows, l.whites, l.blacks) = (-0.88, 5.0, -75.0, 52.0, 6.0, -37.0);
|
||||
let mut h100 = lightcraft_develop::DevelopSettings::default();
|
||||
h100.light.highlights = -100.0;
|
||||
for (name, s) in [("Highlights -100", h100), ("#523's edit", issue)] {
|
||||
let out = render(&img, &info, &s, &RenderRequest::fit(96, 64)).image;
|
||||
let p = out.get(28, 32);
|
||||
assert!(p[1] < 250, "{name}: the clipped light is darkened ({p:?})");
|
||||
let spread = p[0].max(p[1]).max(p[2]) - p[0].min(p[1]).min(p[2]);
|
||||
assert!(spread <= 1, "{name}: clipped light {p:?} is not neutral");
|
||||
}
|
||||
}
|
||||
|
||||
/// A smooth pink gradient (`[0.9k, k, 0.8k]` in the sensor, k from 0.8 to 1.35, white balance `[2.5, 1, 1.6]`)
|
||||
/// through the point where green clips, 1600 px long, in `rows` of a 1600 × `height` picture that is otherwise
|
||||
/// near black, rebuilt and rendered at Exposure -2. Returns the rendered colour of the middle row's last
|
||||
/// unclipped pixel and the next two, and the largest step between neighbouring pixels along that row.
|
||||
fn darkened_ramp(rows: std::ops::Range<usize>, height: usize) -> ([[u8; 4]; 3], u8) {
|
||||
use lightcraft_pipeline::{RenderRequest, render};
|
||||
let wb = [2.5f32, 1.0, 1.6];
|
||||
let colour = CameraColourModel { wb, matrix: lightcraft_color::Mat3::IDENTITY, gain: 1.0, tables: None, hue_sat: None };
|
||||
let w = 1600usize;
|
||||
let k = |x: usize| 0.8 + 0.55 * x as f32 / (w - 1) as f32;
|
||||
let sensor =
|
||||
Rgb32f::from_fn(w, height, |x, y| if rows.contains(&y) { [0.9 * k(x), k(x), 0.8 * k(x)].map(|v| v.min(1.0)) } else { [0.01; 3] });
|
||||
let y = (rows.start + rows.end) / 2;
|
||||
let first = (0..w).find(|&x| sensor.get(x, y)[1] >= HIGHLIGHT_CLIP).unwrap();
|
||||
let mut img = sensor.clone();
|
||||
colour.rebuild_highlights(&mut img);
|
||||
colour.apply(&mut img);
|
||||
let mut s = lightcraft_develop::DevelopSettings::default();
|
||||
s.light.exposure = -2.0;
|
||||
let out = render(&img, &SourceInfo { raw: true, ..Default::default() }, &s, &RenderRequest::fit(w, height)).image;
|
||||
let step = |x: usize| {
|
||||
let (a, b) = (out.get(x - 1, y), out.get(x, y));
|
||||
(0..3).map(|c| a[c].abs_diff(b[c])).max().unwrap_or(0)
|
||||
};
|
||||
([out.get(first - 1, y), out.get(first, y), out.get(first + 1, y)], (1..w).map(step).max().unwrap_or(0))
|
||||
}
|
||||
|
||||
/// The darkened pink gradient carries on across the point where green clips: before the fix the fade towards
|
||||
/// the clipped neutral, driven by how close the other channels were to their clip, made a step of 37 levels
|
||||
/// there.
|
||||
#[test]
|
||||
fn rendered_colour_is_continuous_where_the_first_channel_clips() {
|
||||
let ([a, b, c], worst) = darkened_ramp(0..16, 16);
|
||||
let step = |p: [u8; 4], q: [u8; 4]| (0..3).map(|i| p[i].abs_diff(q[i])).max().unwrap_or(0);
|
||||
assert!(step(a, b) <= 1 && step(b, c) <= 1, "at the first clip: {a:?} {b:?} {c:?}");
|
||||
assert!(worst <= 3, "largest step between neighbouring pixels: {worst} levels");
|
||||
}
|
||||
|
||||
/// The same gradient as a thin bright line on near black: every pixel of it is next to a strong edge, so there
|
||||
/// is no reliable colour around it. It still carries on across the first clip, however thin the line and however
|
||||
/// much dark background there is (a 4-row line on 32 rows used to jump from [225, 126, 153] to [198, 177, 163];
|
||||
/// on 512 rows, from [225, 126, 153] to [218, 145, 151]).
|
||||
#[test]
|
||||
fn rendered_thin_line_is_continuous_where_the_first_channel_clips() {
|
||||
let step = |p: [u8; 4], q: [u8; 4]| (0..3).map(|i| p[i].abs_diff(q[i])).max().unwrap_or(0);
|
||||
for height in [32usize, 128, 512, 1024] {
|
||||
for width in [1usize, 2, 4, 16] {
|
||||
let top = (height - width) / 2;
|
||||
let ([a, b, c], worst) = darkened_ramp(top..top + width, height);
|
||||
let case = format!("{width} rows of {height}");
|
||||
assert!(step(a, b) <= 1 && step(b, c) <= 1, "{case}, at the first clip: {a:?} {b:?} {c:?}");
|
||||
assert!(worst <= 3, "{case}: largest step between neighbouring pixels {worst} levels");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A band clipped in green whose red rises through its own clip from left to right (sensor `[0.985 + 0.01 t, 1,
|
||||
/// 0.8]`, t from 0 to 1 across 320 px), `band` rows at the bottom of the picture, below `strip` rows of a pale
|
||||
/// surface just under the clip (`[0.4, 0.95, 0.8]`: the band's rim, of another colour) and above those `top` rows
|
||||
/// of a strongly coloured reliable surface (`[0.8, 0.35, 0.3]`). Rebuilt and rendered at Exposure -2: the
|
||||
/// rendered colour on either side of where red clips on the band's middle row, and the largest step between
|
||||
/// neighbouring pixels along that row.
|
||||
fn second_clip(top: usize, strip: usize, band: usize) -> ([[u8; 4]; 2], u8) {
|
||||
use lightcraft_pipeline::{RenderRequest, render};
|
||||
let wb = [2.5f32, 1.0, 1.6];
|
||||
let colour = CameraColourModel { wb, matrix: lightcraft_color::Mat3::IDENTITY, gain: 1.0, tables: None, hue_sat: None };
|
||||
let (w, h) = (320usize, top + strip + band);
|
||||
let sensor = Rgb32f::from_fn(w, h, |x, y| {
|
||||
if y < top {
|
||||
[0.8, 0.35, 0.3]
|
||||
} else if y < top + strip {
|
||||
[0.4, 0.95, 0.8]
|
||||
} else {
|
||||
[0.985 + 0.01 * x as f32 / (w - 1) as f32, 1.0, 0.8]
|
||||
}
|
||||
});
|
||||
let y = top + strip + band / 2;
|
||||
let at = (0..w).find(|&x| sensor.get(x, y)[0] >= HIGHLIGHT_CLIP).unwrap();
|
||||
let mut img = sensor.clone();
|
||||
colour.rebuild_highlights(&mut img);
|
||||
colour.apply(&mut img);
|
||||
let mut s = lightcraft_develop::DevelopSettings::default();
|
||||
s.light.exposure = -2.0;
|
||||
let out = render(&img, &SourceInfo { raw: true, ..Default::default() }, &s, &RenderRequest::fit(w, h)).image;
|
||||
let step = |x: usize| {
|
||||
let (a, b) = (out.get(x - 1, y), out.get(x, y));
|
||||
(0..3).map(|c| a[c].abs_diff(b[c])).max().unwrap_or(0)
|
||||
};
|
||||
([out.get(at - 1, y), out.get(at, y)], (1..w).map(step).max().unwrap_or(0))
|
||||
}
|
||||
|
||||
/// Where a second channel clips within reach of the surface's own rim, the rebuilt colour carries on too. The
|
||||
/// rim's comparison with the pixel used to drop each clipped channel, so a rim far from the pixel in red suddenly
|
||||
/// matched it once red clipped (80 rows of colour, 16 of rim and 32 of band: [237, 124, 153] then
|
||||
/// [205, 181, 168]); and a channel about to clip counted as an uncapped floor that was capped once it clipped
|
||||
/// (without the coloured surface: [198, 195, 178] then [205, 181, 168]).
|
||||
#[test]
|
||||
fn rendered_colour_is_continuous_where_a_second_channel_clips() {
|
||||
for (top, strip, band) in [(80, 16, 32), (0, 16, 32), (80, 16, 4), (200, 4, 8), (16, 64, 16), (8, 32, 24)] {
|
||||
let ([a, b], worst) = second_clip(top, strip, band);
|
||||
let case = format!("{top} rows of colour, {strip} of rim, {band} of band");
|
||||
let step = (0..3).map(|i| a[i].abs_diff(b[i])).max().unwrap_or(0);
|
||||
assert!(step <= 1, "{case}: {a:?} then {b:?} where red clips");
|
||||
assert!(worst <= 3, "{case}: largest step between neighbouring pixels {worst} levels");
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue #523 on a public ILCE-7RM4 raw (raw.pixls.us, CC0; skipped without the corpus): a dusk sky clipped in
|
||||
/// green behind a poplar. Its rebuilt colour used to come from the tree's foliage and lens fringes, a green halo
|
||||
/// on one side of the tree and a magenta one on the other (Highlights -100 or the issue's edit show them).
|
||||
#[test]
|
||||
fn corpus_clipped_sky_beside_a_tree_has_no_coloured_halo() {
|
||||
let dir = std::env::var_os("LIGHTCRAFT_CORPUS")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
|
||||
.join("raw");
|
||||
let Ok(bytes) = std::fs::read(dir.join("arw-sony-a7rm4-14bit-compressed.arw")) else {
|
||||
eprintln!("skip: {} absent", dir.display());
|
||||
return;
|
||||
};
|
||||
let (img, _) = load_bytes(&bytes, 1600).unwrap();
|
||||
let (w, h) = (img.width, img.height);
|
||||
let lum = |p: [f32; 3]| lightcraft_color::luminance_2020(p);
|
||||
let chroma = |p: [f32; 3]| {
|
||||
let s = (p[0] + p[1] + p[2]).max(1e-6);
|
||||
[p[0] / s, p[2] / s]
|
||||
};
|
||||
// the poplar stands at about 30 % of the width, its top 25 to 34 % down the frame
|
||||
let px = |f: f32, n: usize| (f * n as f32) as usize;
|
||||
let (mut near, mut far) = ([[0f64; 2]; 2], [[0f64; 2]; 2]);
|
||||
let mut rows = 0;
|
||||
for y in px(0.24, h)..px(0.34, h) {
|
||||
let row = &img.data[y * w..(y + 1) * w];
|
||||
let sky = lum(row[px(0.2, w)]);
|
||||
let dark: Vec<usize> = (px(0.25, w)..px(0.35, w)).filter(|&x| lum(row[x]) < 0.5 * sky).collect();
|
||||
let (Some(&left), Some(&right)) = (dark.first(), dark.last()) else { continue };
|
||||
let k = w as f32 / 6000.0;
|
||||
let mean = |a: usize, b: usize| {
|
||||
let n = (b - a) as f64;
|
||||
(a..b).map(|x| chroma(row[x])).fold([0f64; 2], |s, c| [s[0] + c[0] as f64 / n, s[1] + c[1] as f64 / n])
|
||||
};
|
||||
let (n0, n1, f0, f1) = (px(8.0 / 6000.0, w).max(2), px(40.0 / 6000.0, w), (200.0 * k) as usize, (320.0 * k) as usize);
|
||||
for (side, (n, f)) in
|
||||
[(mean(left - n1, left - n0), mean(left - f1, left - f0)), (mean(right + n0, right + n1), mean(right + f0, right + f1))]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
for c in 0..2 {
|
||||
near[side][c] += n[c];
|
||||
far[side][c] += f[c];
|
||||
}
|
||||
}
|
||||
rows += 1;
|
||||
}
|
||||
assert!(rows > 50, "the tree was not found ({rows} rows)");
|
||||
for side in 0..2 {
|
||||
let d = (0..2).map(|c| ((near[side][c] - far[side][c]) / rows as f64).abs()).fold(0.0, f64::max);
|
||||
assert!(d < 0.004, "{} of the tree the clipped sky's chromaticity is off by {d:.4}", ["left", "right"][side]);
|
||||
}
|
||||
}
|
||||
|
||||
/// iPhone ProRAW-style semantic masks drive the Sky mask: Apple's sky matte (here the right
|
||||
/// half of the sensor, i.e. the bottom of the photo once turned 90° clockwise — where the sky
|
||||
/// heuristic would never look) is used; a matte with an unknown name is ignored.
|
||||
@@ -1167,6 +1488,30 @@ mod tests {
|
||||
assert!(e.contains("without an embedded preview"), "{e}");
|
||||
}
|
||||
|
||||
/// A GoPro GPR (DNG with VC-5 coded data, no preview anywhere) still imports, described from its
|
||||
/// headers; loading it says what is wrong instead of naming a bare TIFF compression number.
|
||||
#[test]
|
||||
fn gopro_vc5_dng_imports_and_fails_to_load_with_a_clear_reason() {
|
||||
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t};
|
||||
let mut ifd = IfdBuilder::new();
|
||||
ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0]));
|
||||
ifd.set(t::IMAGE_WIDTH, Value::Long(vec![32]));
|
||||
ifd.set(t::IMAGE_LENGTH, Value::Long(vec![16]));
|
||||
ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16]));
|
||||
ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1]));
|
||||
ifd.set(t::PHOTOMETRIC, Value::Short(vec![32803]));
|
||||
ifd.set(t::COMPRESSION, Value::Short(vec![9]));
|
||||
ifd.set(t::DNG_VERSION, Value::Byte(vec![1, 4, 0, 0]));
|
||||
ifd.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2]));
|
||||
ifd.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2]));
|
||||
ifd.set_image(ImageData::Tiles { tile_width: 32, tile_height: 16, tiles: vec![vec![0x5au8; 400]] });
|
||||
let f = TiffWriter::default().write(&[ifd]).unwrap();
|
||||
let p = probe_bytes("GOPR0001.GPR", &f).unwrap();
|
||||
assert_eq!((p.kind, p.preview_only, p.width, p.height), (MediaKind::Raw, None, 32, 16));
|
||||
let e = load_bytes(&f, 16).unwrap_err();
|
||||
assert!(e.contains("GoPro VC-5") && e.contains("not decoded yet"), "{e}");
|
||||
}
|
||||
|
||||
/// Any failure of a recognised raw (not only an unsupported variant) can fall back to its preview;
|
||||
/// CR3 keeps its own wording.
|
||||
#[test]
|
||||
|
||||
@@ -33,7 +33,10 @@ use crate::media::ProbeInfo;
|
||||
/// File extensions LightCraft imports (lower case).
|
||||
pub const EXTENSIONS: &[&str] = &[
|
||||
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "srw", "psd",
|
||||
"jxl", "gif", "bmp", "heic", "avif", // containers LightCraft cannot decode but imports as preview only (their embedded JPEG)
|
||||
"jxl", "gif", "bmp",
|
||||
// Decoded when built with the codecs' `heif` feature; otherwise the import reports them as
|
||||
// failed with the reason ("HEIC/HEIF support isn't included in this build").
|
||||
"heic", "heif", "avif", // no AV1 decoder yet: reported as failed, like a HEIC without the feature
|
||||
"iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf", "3fr", "fff",
|
||||
];
|
||||
|
||||
@@ -850,8 +853,17 @@ impl ImportJob {
|
||||
}
|
||||
}
|
||||
|
||||
// files a preceding scan already probed are not read again (a network share is slow to read)
|
||||
let cached: Vec<Option<ProbeInfo>> = todo.iter().map(|f| self.cache.remove(f)).collect();
|
||||
// files a preceding scan already probed are not read again (a network share is slow to read),
|
||||
// as long as they are still there, as they were: a file gone or changed since is probed again
|
||||
let cached: Vec<Option<ProbeInfo>> = todo
|
||||
.iter()
|
||||
.map(|f| {
|
||||
// (the web's files are not std::fs files: kept as probed)
|
||||
self.cache
|
||||
.remove(f)
|
||||
.filter(|info| cfg!(target_arch = "wasm32") || std::fs::metadata(f).is_ok_and(|m| m.is_file() && m.len() == info.file_size))
|
||||
})
|
||||
.collect();
|
||||
let missing: Vec<String> = todo.iter().zip(&cached).filter(|(_, c)| c.is_none()).map(|(f, _)| f.clone()).collect();
|
||||
let progress = ScanProgress::default();
|
||||
let mut fresh = probe_all(self.probe.as_ref(), &missing, &progress).into_iter();
|
||||
@@ -899,7 +911,8 @@ impl ImportJob {
|
||||
let packet = crate::sidecar::find_sidecar(&path, self.naming)
|
||||
.and_then(|f| std::fs::read_to_string(f).ok())
|
||||
.or_else(|| info.xmp.clone().filter(|_| raw));
|
||||
let sidecar = packet.and_then(|x| match crate::sidecar::parse_sidecar(&x, raw) {
|
||||
let target = crate::crs::Target::for_file(info.kind, &info.format, info.preview_only.is_some());
|
||||
let sidecar = packet.and_then(|x| match crate::sidecar::parse_sidecar(&x, target) {
|
||||
Ok(sc) => Some(sc),
|
||||
Err(e) => {
|
||||
log::warn!("import {path}: XMP: {e}");
|
||||
@@ -1044,7 +1057,7 @@ fn name_family(name: &str) -> String {
|
||||
|
||||
/// Threads for `jobs` file reads (probes, copies) at once.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn workers(jobs: usize) -> usize {
|
||||
pub(crate) fn workers(jobs: usize) -> usize {
|
||||
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(1, 8).min(jobs.max(1))
|
||||
}
|
||||
|
||||
@@ -1371,6 +1384,7 @@ impl Session {
|
||||
cache: None,
|
||||
stages: None,
|
||||
view_cache: None,
|
||||
display: None,
|
||||
};
|
||||
let embedded = match (&self.media.preview_loader, c.kind) {
|
||||
(Some(l), MediaKind::Raw) => Some((c.path.clone(), l.clone(), edge)),
|
||||
|
||||
@@ -19,11 +19,13 @@ mod camera_preview;
|
||||
pub mod camera_profiles;
|
||||
pub mod cmd;
|
||||
pub mod config;
|
||||
pub mod contact_sheet;
|
||||
pub mod crs;
|
||||
pub mod crs_masks;
|
||||
pub mod demo;
|
||||
pub mod denoise;
|
||||
pub mod devices;
|
||||
pub mod display;
|
||||
pub mod export;
|
||||
pub mod face_download;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -53,6 +55,7 @@ pub mod rename;
|
||||
pub mod segment;
|
||||
pub mod sidecar;
|
||||
pub mod smart;
|
||||
pub mod sync;
|
||||
mod view;
|
||||
pub mod walk;
|
||||
|
||||
@@ -166,6 +169,9 @@ pub struct Session {
|
||||
/// Photos in the current source with no filter on (cached like `visible`).
|
||||
total: Option<((u64, String), usize)>,
|
||||
pub undo: Vec<UndoEntry>,
|
||||
/// Undo steps committed in this session, ever (the history drops its oldest past 1000, so
|
||||
/// its length can't tell how many steps a command just made; see [`Session::commits`]).
|
||||
commits: u64,
|
||||
pub redo: Vec<UndoEntry>,
|
||||
pub interaction: Option<Interaction>,
|
||||
/// Set by a command whose change must not rewrite the photo's XMP sidecar even with auto-write on
|
||||
@@ -252,6 +258,9 @@ pub struct Session {
|
||||
pub before: std::collections::HashMap<PhotoId, Arc<DevelopSettings>>,
|
||||
/// File probes from the last import review (`library.importPreview`), reused by the import.
|
||||
pub import_probes: std::collections::HashMap<String, media::ProbeInfo>,
|
||||
/// The last Synchronize Folder scan, which `folder.synchronize` acts on while it is current
|
||||
/// (see [`Session::take_folder_changes`]).
|
||||
pub folder_changes: Option<sync::FolderChanges>,
|
||||
/// The last (or running) Build Previews.
|
||||
pub preview_build: Option<std::sync::Arc<cmd::previews::PreviewBuild>>,
|
||||
/// Develop defaults applied on import (persisted in prefs.json).
|
||||
@@ -290,6 +299,7 @@ impl Session {
|
||||
visible_gen: 0,
|
||||
total: None,
|
||||
undo: Vec::new(),
|
||||
commits: 0,
|
||||
redo: Vec::new(),
|
||||
interaction: None,
|
||||
skip_auto_write: false,
|
||||
@@ -334,6 +344,7 @@ impl Session {
|
||||
recent_keywords: Vec::new(),
|
||||
before: Default::default(),
|
||||
import_probes: Default::default(),
|
||||
folder_changes: None,
|
||||
preview_build: None,
|
||||
import_defaults: import::ImportDefaults::default(),
|
||||
cache_mb: 0,
|
||||
@@ -352,7 +363,10 @@ impl Session {
|
||||
/// Run a command by id. THE entry point for every frontend.
|
||||
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
||||
(spec.enabled)(self).map_err(|why| EngineError::Disabled(id.to_string(), why))?;
|
||||
// a command that validates the photos a call names isn't held back by the selection
|
||||
if !(spec.explicit_targets && cmd::names_photos(params)) {
|
||||
(spec.enabled)(self).map_err(|why| EngineError::Disabled(id.to_string(), why))?;
|
||||
}
|
||||
let empty = Value::Object(Default::default());
|
||||
let params = if params.is_null() { &empty } else { params };
|
||||
self.run_command(id, spec.journal.then_some(params), |s| (spec.run)(s, params))
|
||||
@@ -422,6 +436,7 @@ impl Session {
|
||||
let inv = self.catalog.apply(op)?;
|
||||
self.pending_log.push(fwd);
|
||||
self.undo.push(UndoEntry { label: label.to_string(), op: inv, folder: None });
|
||||
self.commits = self.commits.wrapping_add(1);
|
||||
if self.undo.len() > 1000 {
|
||||
self.undo.remove(0);
|
||||
}
|
||||
@@ -486,6 +501,12 @@ impl Session {
|
||||
|
||||
/// Fold the last `n` undo steps into one (commands that commit step by step because each op
|
||||
/// depends on the state the previous one left).
|
||||
/// How many undo steps were committed in this session so far: what a command that commits
|
||||
/// several steps compares before and after to merge them ([`Session::merge_undo`]).
|
||||
pub fn commits(&self) -> u64 {
|
||||
self.commits
|
||||
}
|
||||
|
||||
pub fn merge_undo(&mut self, n: usize, label: &str) {
|
||||
if n < 2 || n > self.undo.len() {
|
||||
return;
|
||||
@@ -626,7 +647,8 @@ impl Session {
|
||||
|
||||
/// With Auto Sync on, the ops that carry an edit of the active photo `id` (to `new`) over to the
|
||||
/// other selected photos: only the settings that changed; never spot removal or red eye (they
|
||||
/// belong to one photo's pixels), nor history / snapshot restores.
|
||||
/// belong to one photo's pixels), nor the rendering process (Sync doesn't carry it either), nor
|
||||
/// history / snapshot restores.
|
||||
fn auto_sync_ops(&self, id: PhotoId, new: &DevelopSettings, label: &str) -> Vec<Op> {
|
||||
if !self.auto_sync
|
||||
|| self.active() != Some(id)
|
||||
@@ -639,7 +661,7 @@ impl Session {
|
||||
let Some(old) = self.develop_of(id) else { return Vec::new() };
|
||||
let Some(mut delta) = json_delta(&old.to_json(), &new.to_json()) else { return Vec::new() };
|
||||
if let Some(o) = delta.as_object_mut() {
|
||||
for k in ["spots", "red_eye", "version"] {
|
||||
for k in ["spots", "red_eye", "version", "process"] {
|
||||
o.remove(k);
|
||||
}
|
||||
if o.is_empty() {
|
||||
@@ -859,6 +881,8 @@ mod tests_import;
|
||||
#[cfg(test)]
|
||||
mod tests_import_move;
|
||||
#[cfg(test)]
|
||||
mod tests_keyword_list;
|
||||
#[cfg(test)]
|
||||
mod tests_libops;
|
||||
#[cfg(test)]
|
||||
mod tests_library;
|
||||
@@ -871,10 +895,14 @@ mod tests_persist;
|
||||
#[cfg(test)]
|
||||
mod tests_prefs;
|
||||
#[cfg(test)]
|
||||
mod tests_process;
|
||||
#[cfg(test)]
|
||||
mod tests_segment;
|
||||
#[cfg(test)]
|
||||
mod tests_settings_files;
|
||||
#[cfg(test)]
|
||||
mod tests_spots;
|
||||
#[cfg(test)]
|
||||
mod tests_sync;
|
||||
#[cfg(test)]
|
||||
mod tests_xmp;
|
||||
@@ -478,7 +478,13 @@ fn ensure_read_active(cancel: &AtomicBool) -> Result<(), String> {
|
||||
if cancel.load(Ordering::Relaxed) { Err("Lightroom import cancelled".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn mapped_settings(text: &str, raw: bool, aspect: f64, orientation: Orientation, stored_aspect: f64) -> Result<(Value, Vec<String>), String> {
|
||||
fn mapped_settings(
|
||||
text: &str,
|
||||
target: crate::crs::Target,
|
||||
aspect: f64,
|
||||
orientation: Orientation,
|
||||
stored_aspect: f64,
|
||||
) -> Result<(Value, Vec<String>), String> {
|
||||
let mut root = crate::preset_import::parse_lua(text)?;
|
||||
let deferred = strip_lua_sentinels(&mut root);
|
||||
let (mut props, values) = crate::preset_import::lua_settings_props(&root)?;
|
||||
@@ -488,7 +494,7 @@ fn mapped_settings(text: &str, raw: bool, aspect: f64, orientation: Orientation,
|
||||
if !props.contains_key("crs:HasCrop") && CROP.iter().any(|k| props.contains_key(*k)) {
|
||||
props.insert("crs:HasCrop".into(), vec!["True".into()]);
|
||||
}
|
||||
let (mut partial, mut unknown) = crate::crs::to_partial_report(&props, Some(&values), Some(raw), aspect);
|
||||
let (mut partial, mut unknown) = crate::crs::to_partial_report(&props, Some(&values), target, aspect);
|
||||
catalog_crop(&mut partial, orientation, stored_aspect);
|
||||
if deferred > 0 {
|
||||
unknown.push("Deferred Lightroom adjustments (-999999)".into());
|
||||
@@ -717,7 +723,7 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex
|
||||
preserved += 1;
|
||||
}
|
||||
if !src.xmp.is_empty() {
|
||||
match crate::sidecar::parse_sidecar(&src.xmp, p.kind == lightcraft_catalog::MediaKind::Raw) {
|
||||
match crate::sidecar::parse_sidecar(&src.xmp, crate::crs::Target::for_photo(&p)) {
|
||||
Ok(mut sc) => {
|
||||
if let Some(crate::sidecar::DevelopPatch::Partial(partial)) = &mut sc.develop {
|
||||
strip_xmp_sentinels(partial);
|
||||
@@ -745,7 +751,7 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex
|
||||
let stored_aspect = number(&src.image, "fileWidth") as f64 / number(&src.image, "fileHeight") as f64;
|
||||
match mapped_settings(
|
||||
&src.settings,
|
||||
p.kind == lightcraft_catalog::MediaKind::Raw,
|
||||
crate::crs::Target::for_photo(&p),
|
||||
p.width.max(1) as f64 / p.height.max(1) as f64,
|
||||
orientation,
|
||||
stored_aspect,
|
||||
@@ -936,7 +942,10 @@ mod tests {
|
||||
cyclic.collections[0].insert("parent".into(), json!(10));
|
||||
assert!(apply(&mut s, cyclic, false).is_err());
|
||||
assert_eq!(s.catalog.to_snapshot(), before);
|
||||
assert!(mapped_settings(&format!("{}0{}", "{".repeat(1000), "}".repeat(1000)), true, 1.0, Orientation::Normal, 1.5).is_err());
|
||||
assert!(
|
||||
mapped_settings(&format!("{}0{}", "{".repeat(1000), "}".repeat(1000)), crate::crs::Target::RawAbsolute, 1.0, Orientation::Normal, 1.5)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn compressed_xmp_is_bounded_and_settings_are_data_only() {
|
||||
@@ -946,17 +955,41 @@ mod tests {
|
||||
assert_eq!(xmp(Some(&json!(encoded))).unwrap(), String::from_utf8_lossy(packet));
|
||||
encoded[0] = 0xff;
|
||||
assert!(xmp(Some(&json!(encoded))).is_err());
|
||||
let (partial, _) =
|
||||
mapped_settings("s = { Exposure2012 = 0.75, ToneCurvePV2012 = {0,0,128,150,255,255} }", true, 1.5, Orientation::Normal, 1.5).unwrap();
|
||||
let (partial, _) = mapped_settings(
|
||||
"s = { Exposure2012 = 0.75, ToneCurvePV2012 = {0,0,128,150,255,255} }",
|
||||
crate::crs::Target::RawAbsolute,
|
||||
1.5,
|
||||
Orientation::Normal,
|
||||
1.5,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(partial["light"]["exposure"], 0.75);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_white_balance_on_relative_wb_raws_is_not_read_as_kelvin() {
|
||||
// Issue #510: a catalog keeps no as-shot white for Custom photos, so on a raw developed
|
||||
// relative to its as-shot look the edit stays As Shot and is reported; a DNG keeps the Kelvin
|
||||
let settings =
|
||||
"s = { WhiteBalance = \"Custom\", Temperature = 3578, Tint = -5, CustomTemperature = 3578.95, CustomTint = -5, Exposure2012 = 0.5 }";
|
||||
let (partial, unknown) = mapped_settings(settings, crate::crs::Target::RawRelative, 1.5, Orientation::Normal, 1.5).unwrap();
|
||||
assert_eq!(partial["wb"], json!({"mode": "asShot"}), "{partial}");
|
||||
assert_eq!(partial["light"]["exposure"], 0.5);
|
||||
assert!(unknown.iter().any(|k| k.starts_with("Temperature, Tint")), "{unknown:?}");
|
||||
let (partial, unknown) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap();
|
||||
assert_eq!(
|
||||
(partial["wb"]["mode"].as_str(), partial["wb"]["temp"].as_f64(), partial["wb"]["tint"].as_f64()),
|
||||
(Some("custom"), Some(3578.0), Some(-5.0))
|
||||
);
|
||||
assert!(!unknown.iter().any(|k| k.starts_with("Temperature")), "{unknown:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_crops_without_has_crop_are_imported() {
|
||||
// Lightroom Classic catalogs store crop edges and angle but never `HasCrop`
|
||||
let (partial, unknown) = mapped_settings(
|
||||
"s = { CropConstrainAspectRatio = true, CropLeft = 0.042702, CropRight = 0.957298, CropTop = 0.1 }",
|
||||
true,
|
||||
crate::crs::Target::RawAbsolute,
|
||||
1.5,
|
||||
Orientation::Normal,
|
||||
1.5,
|
||||
@@ -970,11 +1003,11 @@ mod tests {
|
||||
assert_eq!(crop["angle"], 0.0);
|
||||
assert!(!unknown.iter().any(|k| k.starts_with("CropLeft")), "{unknown:?}");
|
||||
// an angle alone still means a crop (Lightroom's angle turns the other way to LightCraft's)
|
||||
let (partial, unknown) = mapped_settings("s = { CropAngle = -1.5 }", true, 1.5, Orientation::Normal, 1.5).unwrap();
|
||||
let (partial, unknown) = mapped_settings("s = { CropAngle = -1.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap();
|
||||
assert_eq!(partial["crop"]["geometry"]["angle"], 1.5);
|
||||
assert!(!unknown.iter().any(|k| k.starts_with("CropAngle")), "{unknown:?}");
|
||||
// no crop fields: the crop stays untouched
|
||||
let (partial, _) = mapped_settings("s = { Exposure2012 = 0.5 }", true, 1.5, Orientation::Normal, 1.5).unwrap();
|
||||
let (partial, _) = mapped_settings("s = { Exposure2012 = 0.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap();
|
||||
assert!(partial.get("crop").is_none());
|
||||
}
|
||||
|
||||
@@ -998,7 +1031,7 @@ mod tests {
|
||||
fn catalog_crops_follow_the_photo_orientation() {
|
||||
let crop = |o: Orientation| {
|
||||
let settings = "s = { CropLeft = 0.1, CropRight = 0.6, CropTop = 0.2, CropBottom = 0.9 }";
|
||||
let (partial, _) = mapped_settings(settings, true, 1.5, o, 1.5).unwrap();
|
||||
let (partial, _) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap();
|
||||
let g = &partial["crop"]["geometry"];
|
||||
let r = &g["rect"];
|
||||
[r["x0"].as_f64(), r["y0"].as_f64(), r["x1"].as_f64(), r["y1"].as_f64(), g["angle"].as_f64()].map(|v| (v.unwrap() * 1e9).round() / 1e9)
|
||||
@@ -1011,13 +1044,13 @@ mod tests {
|
||||
assert_eq!(crop(Orientation::FlipH), [0.4, 0.2, 0.9, 0.9, 0.0]);
|
||||
// a mirror turns the straighten the other way
|
||||
let angle = |o: Orientation| {
|
||||
let (partial, _) = mapped_settings("s = { CropAngle = 2 }", true, 1.5, o, 1.5).unwrap();
|
||||
let (partial, _) = mapped_settings("s = { CropAngle = 2 }", crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap();
|
||||
partial["crop"]["geometry"]["angle"].as_f64().unwrap()
|
||||
};
|
||||
assert_eq!(angle(Orientation::Rotate90), -2.0);
|
||||
assert_eq!(angle(Orientation::FlipH), 2.0);
|
||||
// no crop: nothing to orient
|
||||
let (partial, _) = mapped_settings("s = { Exposure2012 = 0.5 }", true, 1.5, Orientation::Rotate90, 1.5).unwrap();
|
||||
let (partial, _) = mapped_settings("s = { Exposure2012 = 0.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Rotate90, 1.5).unwrap();
|
||||
assert!(partial.get("crop").is_none());
|
||||
}
|
||||
|
||||
@@ -1043,7 +1076,7 @@ mod tests {
|
||||
for o in [Orientation::Normal, Orientation::Rotate90, Orientation::Rotate270, Orientation::Rotate180, Orientation::FlipH] {
|
||||
for angle in [6.5, -6.5] {
|
||||
let g = CropGeometry { angle, ..g };
|
||||
let (partial, _) = mapped_settings(&lightroom_crop(g, o, 1.5), true, 1.5, o, 1.5).unwrap();
|
||||
let (partial, _) = mapped_settings(&lightroom_crop(g, o, 1.5), crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap();
|
||||
let got: CropGeometry = serde_json::from_value(partial["crop"]["geometry"].clone()).unwrap();
|
||||
let close = |a: f64, b: f64| (a - b).abs() < 1e-9;
|
||||
assert!(
|
||||
@@ -1073,7 +1106,7 @@ mod tests {
|
||||
(6100.0, 9150.0),
|
||||
),
|
||||
] {
|
||||
let (partial, _) = mapped_settings(settings, true, 1.5, o, 9504.0 / 6336.0).unwrap();
|
||||
let (partial, _) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, o, 9504.0 / 6336.0).unwrap();
|
||||
let r: Rect = serde_json::from_value(partial["crop"]["geometry"]["rect"].clone()).unwrap();
|
||||
let (ow, oh) = if o.swaps_axes() { (6336.0, 9504.0) } else { (9504.0, 6336.0) };
|
||||
let (w, h) = (r.width() * ow, r.height() * oh);
|
||||
@@ -1112,7 +1145,9 @@ mod tests {
|
||||
assert_eq!(p.develop.light.shadows, 65.0);
|
||||
assert_eq!(p.develop.light.blacks, -22.0);
|
||||
assert!(report["unmapped"]["2"].as_array().unwrap().iter().any(|v| v.as_str().is_some_and(|v| v.contains("Deferred"))));
|
||||
let (partial, _) = mapped_settings("s = { Exposure2012 = -5, Contrast2012 = -100 }", true, 1.0, Orientation::Normal, 1.5).unwrap();
|
||||
let (partial, _) =
|
||||
mapped_settings("s = { Exposure2012 = -5, Contrast2012 = -100 }", crate::crs::Target::RawAbsolute, 1.0, Orientation::Normal, 1.5)
|
||||
.unwrap();
|
||||
assert_eq!(partial["light"]["exposure"], -5.0);
|
||||
assert_eq!(partial["light"]["contrast"], -100.0);
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ impl SettingsHashes {
|
||||
}
|
||||
|
||||
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
|
||||
pub const RENDER_CACHE_VERSION: u64 = 23;
|
||||
pub const RENDER_CACHE_VERSION: u64 = 38;
|
||||
|
||||
/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache).
|
||||
pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512];
|
||||
@@ -251,7 +251,7 @@ impl SourceRef {
|
||||
pub fn load_source(&self) -> Result<DecodedSource, String> {
|
||||
let image = match self {
|
||||
SourceRef::Loaded(a) => return Ok((**a).clone()),
|
||||
SourceRef::Demo { scene, max_edge } => crate::demo::scene_pixels(scene, *max_edge),
|
||||
SourceRef::Demo { scene, max_edge } => crate::demo::scene_pixels(scene, *max_edge)?,
|
||||
SourceRef::File { path, max_edge, loader, fallback, denoise } => {
|
||||
let plain = || match loader {
|
||||
Some(l) => l(path, *max_edge).map(|(image, info)| DecodedSource::new(Arc::new(image), Some(info))),
|
||||
@@ -674,6 +674,8 @@ pub struct RenderJob {
|
||||
pub stages: Option<Arc<StageCache>>,
|
||||
/// Keep a full-quality result here as the photo's view preview ([`crate::Session::loupe_job`]).
|
||||
pub view_cache: Option<(Arc<PreviewCache>, Hash128)>,
|
||||
/// Render for this monitor profile ([`Self::with_display`]).
|
||||
pub display: Option<Arc<crate::display::Display>>,
|
||||
}
|
||||
|
||||
pub struct RenderResult {
|
||||
@@ -687,6 +689,9 @@ pub struct RenderResult {
|
||||
pub loaded: Option<DecodedSource>,
|
||||
/// Set for a [`QuickJob`]'s stand-in: where the image came from.
|
||||
pub quick: Option<QuickSource>,
|
||||
/// The display profile (id) the image is encoded for; `None`: sRGB (see
|
||||
/// [`crate::display::present`]).
|
||||
pub display: Option<u64>,
|
||||
}
|
||||
|
||||
impl RenderJob {
|
||||
@@ -734,6 +739,21 @@ impl RenderJob {
|
||||
self
|
||||
}
|
||||
|
||||
/// Render for a monitor profile: into the display's primaries (its gamut, not sRGB's), ready
|
||||
/// to show ([`RenderResult::display`]). The histogram and the cached view preview stay sRGB.
|
||||
/// Gets its own result key. Thumbnail jobs (rendered-thumbnail cache) are left as they are:
|
||||
/// [`crate::display::present`] converts their sRGB results.
|
||||
pub fn with_display(mut self, display: Option<Arc<crate::display::Display>>) -> Self {
|
||||
if self.cache.is_some() || self.request.depth != lightcraft_pipeline::OutputDepth::U8 {
|
||||
return self;
|
||||
}
|
||||
let k = |d: &Option<Arc<crate::display::Display>>| d.as_ref().map_or(0, |d| d.id()).wrapping_mul(0xd6e8_feb8_6659_fd93);
|
||||
self.key ^= k(&self.display) ^ k(&display);
|
||||
self.request.display = display.as_ref().map(|d| d.space);
|
||||
self.display = display;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn run(self) -> RenderResult {
|
||||
if let Some((cache, key)) = &self.cache
|
||||
&& let Some(img) = cache.get_at(self.cache_generation, *key)
|
||||
@@ -749,10 +769,18 @@ impl RenderJob {
|
||||
rendered: Ok(Rendered { image, histogram, deep: None }),
|
||||
loaded: None,
|
||||
quick: None,
|
||||
display: None,
|
||||
};
|
||||
}
|
||||
let was_loaded = matches!(self.source, SourceRef::Loaded(_));
|
||||
match self.source.load_source() {
|
||||
// The app is closing (issue #620): nobody is left to show this, and quitting waits for the
|
||||
// jobs that are running. Without this, one that lost the GPU mid-way would render on the CPU.
|
||||
let closing = || lightcraft_gpu::shutting_down().then(|| "LightCraft is closing".to_string());
|
||||
let source = match closing() {
|
||||
Some(e) => Err(e),
|
||||
None => self.source.load_source().and_then(|s| closing().map_or(Ok(s), Err)),
|
||||
};
|
||||
match source {
|
||||
Ok(source) => {
|
||||
// the plain picture, or (Denoise amount above 0 and a denoised picture cached) the mix the amount asks for
|
||||
let src = &source.image_for(self.settings.denoise_amount());
|
||||
@@ -760,14 +788,30 @@ impl RenderJob {
|
||||
// Thumbnails (many small jobs side by side) stay on the CPU; views and exports use
|
||||
// the GPU when there is one.
|
||||
let gpu = self.cache.is_none();
|
||||
let rendered = develop(src, &info, &self.settings, &self.request, self.stages.as_deref(), gpu);
|
||||
let mut rendered = develop(src, &info, &self.settings, &self.request, self.stages.as_deref(), gpu);
|
||||
// for a display profile: the image goes to the display's values, the histogram
|
||||
// and the cached view preview stay sRGB
|
||||
let mut srgb = None;
|
||||
let display = match (&self.display, self.request.display) {
|
||||
(Some(d), Some(_)) => {
|
||||
let keep = self.view_cache.is_some() && self.request.quality == Quality::Full;
|
||||
let (s, h) = crate::display::finish_render(&mut rendered.image, d, keep);
|
||||
if let Some(h) = h {
|
||||
rendered.histogram = h;
|
||||
}
|
||||
srgb = s;
|
||||
Some(d.id())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some((cache, key)) = &self.cache {
|
||||
cache.put_at(self.cache_generation, *key, Arc::new(rendered.image.clone()));
|
||||
}
|
||||
if let Some((cache, key)) = &self.view_cache
|
||||
&& self.request.quality == Quality::Full
|
||||
&& (display.is_none() || srgb.is_some())
|
||||
{
|
||||
cache.put_deferred_at(self.cache_generation, *key, Arc::new(rendered.image.clone()));
|
||||
cache.put_deferred_at(self.cache_generation, *key, Arc::new(srgb.unwrap_or_else(|| rendered.image.clone())));
|
||||
}
|
||||
RenderResult {
|
||||
request_id: self.request_id,
|
||||
@@ -778,6 +822,7 @@ impl RenderJob {
|
||||
rendered: Ok(rendered),
|
||||
loaded: (!was_loaded).then_some(source),
|
||||
quick: None,
|
||||
display,
|
||||
}
|
||||
}
|
||||
Err(e) => RenderResult {
|
||||
@@ -789,6 +834,7 @@ impl RenderJob {
|
||||
rendered: Err(e),
|
||||
loaded: None,
|
||||
quick: None,
|
||||
display: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -853,6 +899,7 @@ impl QuickJob {
|
||||
rendered,
|
||||
loaded: None,
|
||||
quick,
|
||||
display: None,
|
||||
};
|
||||
let rendered = |image: Rgba8| {
|
||||
let histogram = Histogram::of_srgb8(&image);
|
||||
@@ -972,6 +1019,7 @@ impl crate::Session {
|
||||
cache,
|
||||
stages: None,
|
||||
view_cache: None,
|
||||
display: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1022,6 +1070,7 @@ impl crate::Session {
|
||||
cache: Some((self.media.rendered.clone(), ck)),
|
||||
stages: None,
|
||||
view_cache: None,
|
||||
display: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1067,6 +1116,7 @@ impl crate::Session {
|
||||
cache: Some((self.media.rendered.clone(), ck)),
|
||||
stages: None,
|
||||
view_cache: None,
|
||||
display: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,9 @@ use serde::Serialize;
|
||||
/// - a quarter for the working memory of decodes in flight ([`work_gate`]);
|
||||
/// - an eighth for the GPU renderer's pool of recycled buffers (trimmed when the app is idle).
|
||||
///
|
||||
/// The rest is headroom for what isn't cached (renders in progress, textures, the UI). Default:
|
||||
/// The rest is headroom for what isn't cached (renders in progress, textures, the UI). An export
|
||||
/// batch may, in addition, hold up to twice the budget in photos rendering side by side
|
||||
/// ([`export_gate`]): transient memory, given back when the batch ends. Default:
|
||||
/// a quarter of the machine's RAM, at most 1.5 GiB (`LIGHTCRAFT_MEMORY_MB` overrides it).
|
||||
pub fn default_budget() -> usize {
|
||||
if let Some(mb) = std::env::var("LIGHTCRAFT_MEMORY_MB").ok().and_then(|v| v.trim().parse::<usize>().ok()).filter(|m| *m >= 64) {
|
||||
@@ -83,6 +85,9 @@ fn apply(b: usize) {
|
||||
if let Some(g) = GATE.get() {
|
||||
g.set_limit(b / 4);
|
||||
}
|
||||
if let Some(g) = EXPORT_GATE.get() {
|
||||
g.set_limit(export_share(b));
|
||||
}
|
||||
lightcraft_gpu::set_pool_limit((b / 8) as u64);
|
||||
}
|
||||
|
||||
@@ -174,6 +179,19 @@ pub fn work_gate() -> &'static WorkGate {
|
||||
|
||||
static GATE: OnceLock<WorkGate> = OnceLock::new();
|
||||
|
||||
/// The process-wide gate for the photos of an export batch rendering side by side
|
||||
/// ([`crate::export::run_batch`]; limit: twice the budget). Separate from [`work_gate`], which the
|
||||
/// decodes inside each export take: one gate held across the other's acquire could deadlock.
|
||||
pub fn export_gate() -> &'static WorkGate {
|
||||
EXPORT_GATE.get_or_init(|| WorkGate::new(export_share(budget())))
|
||||
}
|
||||
|
||||
fn export_share(budget: usize) -> usize {
|
||||
budget.saturating_mul(2)
|
||||
}
|
||||
|
||||
static EXPORT_GATE: OnceLock<WorkGate> = OnceLock::new();
|
||||
|
||||
/// Entries and bytes held by one cache.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
|
||||
pub struct Usage {
|
||||
|
||||
@@ -464,7 +464,7 @@ pub fn group_from_dir(dir: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
pub(crate) fn build(props: &Props, values: &crate::crs_masks::Values, name: String, group: Option<String>, from_photo: bool) -> Option<Imported> {
|
||||
let (mut settings, unmapped) = crate::crs::to_partial_report(props, Some(values), None, crate::crs_masks::DEFAULT_ASPECT);
|
||||
let (mut settings, unmapped) = crate::crs::to_partial_report(props, Some(values), crate::crs::Target::Any, crate::crs_masks::DEFAULT_ASPECT);
|
||||
if from_photo && let Some(o) = settings.as_object_mut() {
|
||||
// a photo's own framing and absolute white balance don't belong in a look
|
||||
o.remove("crop");
|
||||
|
||||
@@ -114,8 +114,10 @@ pub enum DevelopPatch {
|
||||
Partial(Value),
|
||||
}
|
||||
|
||||
/// Parse a sidecar / XMP packet. `raw` selects absolute (raw) vs relative white balance for `crs:`.
|
||||
pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result<SidecarData, String> {
|
||||
/// Parse a sidecar / XMP packet. `target` selects how a `crs:` white balance is read
|
||||
/// ([`crate::crs::Target`]: absolute Kelvin for raws with a measured illuminant, a shift from the
|
||||
/// as-shot white for everything developed relative to it).
|
||||
pub fn parse_sidecar(xmp: &str, target: crate::crs::Target) -> std::result::Result<SidecarData, String> {
|
||||
let d = lightcraft_meta::parse_xmp(xmp).map_err(|e| e.to_string())?;
|
||||
let m = &d.metadata;
|
||||
let lc = |k: &str| d.properties.get(&format!("lc:{k}")).and_then(|v| v.first()).cloned();
|
||||
@@ -133,7 +135,8 @@ pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result<SidecarData, S
|
||||
country: m.country.clone(),
|
||||
alt_text: m.alt_text.clone(),
|
||||
extended_description: m.extended_description.clone(),
|
||||
keywords: (!m.keywords.is_empty()).then(|| m.keywords.clone()),
|
||||
keywords: (!m.keywords.is_empty() || !m.hierarchical_keywords.is_empty())
|
||||
.then(|| lightcraft_catalog::keywords::from_file(&m.keywords, &m.hierarchical_keywords)),
|
||||
// A sidecar that has `mwg-rs:Regions` at all (even an empty list) was written by an app that
|
||||
// knows about regions, so it's authoritative: its list, empty or not, replaces the catalog's.
|
||||
// One without it (most writers, LightCraft's own included, which keeps another app's
|
||||
@@ -162,7 +165,7 @@ pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result<SidecarData, S
|
||||
out.develop = match full {
|
||||
Some(s) => Some(DevelopPatch::Full(Box::new(s))),
|
||||
None if crate::crs::has_adjustments(&d.properties) => {
|
||||
Some(DevelopPatch::Partial(crate::crs::to_partial_report(&d.properties, Some(&d.values), Some(raw), crate::crs_masks::DEFAULT_ASPECT).0))
|
||||
Some(DevelopPatch::Partial(crate::crs::to_partial_report(&d.properties, Some(&d.values), target, crate::crs_masks::DEFAULT_ASPECT).0))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
@@ -445,6 +448,18 @@ impl Session {
|
||||
/// catalogued file shares the stem (`IMG_0001.CR3` + `IMG_0001.JPG`) and owns the stem
|
||||
/// sidecar — a raw first, else the first by file name. The others use Full naming
|
||||
/// (`IMG_0001.JPG.xmp`), so their metadata never overwrites each other.
|
||||
/// [`Session::sidecar_naming`] for many photos, the catalog's sidecar owners worked out once
|
||||
/// (per photo that is a pass over the whole catalog).
|
||||
pub fn sidecar_namings(&self, ids: &[PhotoId]) -> Vec<SidecarNaming> {
|
||||
let owners = (self.xmp.naming == SidecarNaming::Stem).then(|| StemOwners::of(&self.catalog));
|
||||
ids.iter()
|
||||
.map(|id| match (self.catalog.photo(*id), &owners) {
|
||||
(Some(p), Some(o)) => o.naming(p, self.xmp.naming),
|
||||
_ => self.xmp.naming,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn sidecar_naming(&self, id: PhotoId) -> SidecarNaming {
|
||||
match self.catalog.photo(id) {
|
||||
Some(p) if self.xmp.naming == SidecarNaming::Stem => StemOwners::of(&self.catalog).naming(p, self.xmp.naming),
|
||||
@@ -475,9 +490,15 @@ impl Session {
|
||||
let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?;
|
||||
let Some(orig) = file_path(p) else { return Ok(None) };
|
||||
let Some((packet, from)) = read_packet(orig, p.kind, self.sidecar_naming(id)) else { return Ok(None) };
|
||||
let sc = parse_sidecar(&packet, p.kind == MediaKind::Raw)
|
||||
.map_err(|e| EngineError::Other(format!("{}: {e}", from.display())))?
|
||||
.resolve_label(&self.catalog);
|
||||
let sc = parse_sidecar(&packet, crate::crs::Target::for_photo(p)).map_err(|e| EngineError::Other(format!("{}: {e}", from.display())))?;
|
||||
Ok(Some((self.sidecar_op(id, sc)?, from)))
|
||||
}
|
||||
|
||||
/// The op that applies what a sidecar says (already read and parsed, e.g. on a worker
|
||||
/// thread) to photo `id`: the sidecar wins. No file is read.
|
||||
pub fn sidecar_op(&self, id: PhotoId, sc: SidecarData) -> Result<Op> {
|
||||
let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?;
|
||||
let sc = sc.resolve_label(&self.catalog);
|
||||
let mut q = (**p).clone();
|
||||
let develop_changed = merge_into(&mut q, &sc, &(self.clock)());
|
||||
let mut ops = vec![
|
||||
@@ -492,7 +513,7 @@ impl Session {
|
||||
if develop_changed {
|
||||
ops.extend(self.develop_op(id, (*q.develop).clone(), "Read Metadata from File"));
|
||||
}
|
||||
Ok(Some((Op::Batch { ops }, from)))
|
||||
Ok(Op::Batch { ops })
|
||||
}
|
||||
|
||||
/// Auto-write: sidecars for photos changed by `ops` (errors are logged, not returned).
|
||||
@@ -515,6 +536,19 @@ impl Session {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// A sidecar written by Lightroom Classic (names flat, paths in `lr:hierarchicalSubject`)
|
||||
/// gives the photo its keywords as paths.
|
||||
#[test]
|
||||
fn sidecar_keywords_keep_their_hierarchy() {
|
||||
let m = lightcraft_meta::Metadata {
|
||||
keywords: vec!["Lisbon".into(), "Places".into()],
|
||||
hierarchical_keywords: vec!["Places|Lisbon".into()],
|
||||
..Default::default()
|
||||
};
|
||||
let sc = super::parse_sidecar(&lightcraft_meta::write_xmp(&m, None), crate::crs::Target::Rendered).unwrap();
|
||||
assert_eq!(sc.keywords, Some(vec!["Places|Lisbon".to_string()]));
|
||||
}
|
||||
|
||||
use super::*;
|
||||
use lightcraft_catalog::Meta;
|
||||
|
||||
@@ -553,7 +587,7 @@ mod tests {
|
||||
fn packet_roundtrip_restores_everything() {
|
||||
let p = photo();
|
||||
let x = sidecar_packet(&p, &lightcraft_catalog::Catalog::new());
|
||||
let sc = parse_sidecar(&x, false).unwrap();
|
||||
let sc = parse_sidecar(&x, crate::crs::Target::Rendered).unwrap();
|
||||
assert_eq!(sc.rating, Some(4));
|
||||
assert_eq!(sc.flag, Some(Flag::Pick));
|
||||
assert_eq!(sc.label, Some(Some(ColorLabel::Purple)));
|
||||
@@ -572,7 +606,7 @@ mod tests {
|
||||
let x = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
|
||||
xmp:Rating="-1" xmp:Label="Green" crs:Exposure2012="-0.40" crs:Vibrance="+12"/></rdf:RDF></x:xmpmeta>"#;
|
||||
let sc = parse_sidecar(x, true).unwrap();
|
||||
let sc = parse_sidecar(x, crate::crs::Target::RawAbsolute).unwrap();
|
||||
assert_eq!((sc.rating, sc.flag, sc.label), (Some(0), Some(Flag::Reject), Some(Some(ColorLabel::Green))));
|
||||
let mut p = photo();
|
||||
let before = p.develop.clone();
|
||||
|
||||
@@ -0,0 +1,702 @@
|
||||
//! Synchronize Folder: bring a folder of the library up to date with what is on disk, as
|
||||
//! Lightroom Classic's Synchronize Folder does.
|
||||
//!
|
||||
//! A [scan](scan_with) compares the folder (and the folders inside it) with the library and finds
|
||||
//! three kinds of change, touching nothing:
|
||||
//!
|
||||
//! - **new** files: supported files the library doesn't have, by path or by content (the import
|
||||
//! scan's duplicate rules, [`crate::import::scan_with`]);
|
||||
//! - **missing** photos: library photos in the folder whose file is gone (the Missing Photos rule,
|
||||
//! [`crate::cmd::missing::checked_path`]);
|
||||
//! - **metadata** updates: photos whose XMP sidecar file was saved after the photo came into the
|
||||
//! library or was last edited here, and says something the library doesn't (rating, flag,
|
||||
//! label, metadata, capture time or develop settings). Reading one makes the sidecar win, as
|
||||
//! Read Metadata from File does. Embedded XMP in a raw is not a sidecar and is not looked at.
|
||||
//!
|
||||
//! [`synchronize`] then imports the new files (in place), relinks the moved photos, moves the
|
||||
//! missing photos to Recently Deleted and reads the metadata updates — each only when chosen — as
|
||||
//! one undo step. It runs in halves ([`SyncJob`]) so that, in the app, every file it reads is
|
||||
//! read on a worker thread and only the catalog changes happen on the UI thread — with what every
|
||||
//! command does after them there: the library's durable save and, with XMP auto-write on, the
|
||||
//! sidecars of the photos whose metadata was read.
|
||||
//!
|
||||
//! The scan runs without the session ([`SyncInput`] + [`scan_with`]), so the app can run it on a
|
||||
//! worker thread: a folder on a sleeping network share must never stall a frame.
|
||||
//!
|
||||
//! Limits of telling an update from a sidecar that is merely older:
|
||||
//! - a sidecar the library itself wrote (auto-write on) agrees with it and is never an update;
|
||||
//! - the library records when a photo came in and when its develop settings last changed, not
|
||||
//! when its rating, flag, label or metadata did: a sidecar another app saved, followed by such
|
||||
//! a change made here (with auto-write off), is listed as an update, and reading it puts the
|
||||
//! older value back. Reading updates is therefore never the default;
|
||||
//! - times are compared as the clock the session runs with; headless sessions (CLI, MCP) start
|
||||
//! with a fixed clock, so their own times say little;
|
||||
//! - a sidecar is found as Read Metadata from File finds it, including the other naming
|
||||
//! scheme's file (`IMG_1.xmp` for `IMG_1.JPG` beside `IMG_1.CR3`).
|
||||
//!
|
||||
//! The walk for new files stops at the import's limits on depth and entries (logged), and is
|
||||
//! not cancelled midway.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use lightcraft_catalog::{Catalog, Filter, Op, Photo, PhotoId, Sort, Source};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::import::{ImportCandidate, ImportMode, ImportOptions, ScanInput, ScanProgress};
|
||||
use crate::media::ProbeInfo;
|
||||
use crate::sidecar::SidecarNaming;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
/// The largest XMP sidecar read when looking for metadata updates; a bigger one is skipped.
|
||||
const MAX_SIDECAR_BYTES: u64 = 16 * 1024 * 1024;
|
||||
|
||||
/// How far a scan is: the folder's files probed ([`ScanProgress`], whose `cancel` stops the
|
||||
/// whole scan) and the library's photos checked.
|
||||
#[derive(Default)]
|
||||
pub struct SyncProgress {
|
||||
pub files: ScanProgress,
|
||||
pub checked: std::sync::atomic::AtomicUsize,
|
||||
pub to_check: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
|
||||
impl SyncProgress {
|
||||
/// (done, of how many), both halves together.
|
||||
pub fn counts(&self) -> (usize, usize) {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let done = self.files.done.load(Relaxed).saturating_add(self.checked.load(Relaxed));
|
||||
(done, self.files.total.load(Relaxed).saturating_add(self.to_check.load(Relaxed)))
|
||||
}
|
||||
}
|
||||
|
||||
/// What a scan of a folder needs, taken from the session so it can run on a worker thread.
|
||||
pub struct SyncInput {
|
||||
folder: String,
|
||||
scan: ScanInput,
|
||||
/// The library's photos in the folder, each with how its sidecar is named.
|
||||
photos: Vec<(Arc<Photo>, SidecarNaming)>,
|
||||
/// For the colour label names a sidecar may use.
|
||||
labels: Catalog,
|
||||
}
|
||||
|
||||
/// What changed in a folder since it came into the library.
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct FolderChanges {
|
||||
/// The folder, as asked for.
|
||||
pub path: String,
|
||||
/// Files to import.
|
||||
pub new: Vec<ImportCandidate>,
|
||||
/// Files whose content the library already has (under another path).
|
||||
pub duplicates: usize,
|
||||
/// Supported files that couldn't be read.
|
||||
pub unreadable: Vec<ImportCandidate>,
|
||||
/// Photos whose file is gone.
|
||||
pub missing: Vec<FolderPhoto>,
|
||||
/// Photos whose sidecar has news.
|
||||
pub metadata: Vec<FolderPhoto>,
|
||||
/// Photos whose file was renamed or moved within the folder (found by its content).
|
||||
pub moved: Vec<FolderMoved>,
|
||||
/// The folder itself is not there (moved or renamed whole, or on a disk that isn't
|
||||
/// connected): nothing else is reported, rather than every photo as missing.
|
||||
pub offline: bool,
|
||||
/// The probes of the new files, kept for the import that follows.
|
||||
#[serde(skip)]
|
||||
probes: HashMap<String, ProbeInfo>,
|
||||
/// The folder's photos as the scan saw them: the scan is current while they are the same
|
||||
/// records (any change to one replaces it in the catalog).
|
||||
#[serde(skip)]
|
||||
seen: Vec<Arc<Photo>>,
|
||||
}
|
||||
|
||||
/// A photo of the folder and its file.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct FolderPhoto {
|
||||
pub id: u64,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// A photo whose file is now somewhere else in the folder.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct FolderMoved {
|
||||
pub id: u64,
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
}
|
||||
|
||||
/// What [`synchronize`] does with the changes found.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct SyncChoice {
|
||||
pub import_new: bool,
|
||||
pub relink_moved: bool,
|
||||
pub remove_missing: bool,
|
||||
pub read_metadata: bool,
|
||||
}
|
||||
|
||||
impl Default for SyncChoice {
|
||||
/// Lightroom Classic's defaults: import the new files, leave the rest to be looked at; and
|
||||
/// photos whose file moved follow it (nothing is lost, and undo puts them back).
|
||||
fn default() -> Self {
|
||||
SyncChoice { import_new: true, relink_moved: true, remove_missing: false, read_metadata: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`synchronize`] did.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
|
||||
pub struct SyncReport {
|
||||
pub imported: usize,
|
||||
pub relinked: usize,
|
||||
pub removed: usize,
|
||||
pub read: usize,
|
||||
/// (path or photo, why) for what couldn't be done.
|
||||
pub failed: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
fn invalid(msg: impl Into<String>) -> EngineError {
|
||||
EngineError::Other(msg.into())
|
||||
}
|
||||
|
||||
impl SyncInput {
|
||||
/// The scan of folder `path`: a folder the library holds photos in, not the startup disk. A
|
||||
/// whole disk or share, or a folder that holds disks (`/Volumes`, `/mnt`…), only with `disk`
|
||||
/// (as for Remove Disk from Library: its missing photos can be a whole disk's).
|
||||
pub fn new(s: &mut Session, path: &str, disk: bool) -> Result<SyncInput> {
|
||||
let path = path.trim();
|
||||
if path.is_empty() {
|
||||
return Err(invalid("missing `path`"));
|
||||
}
|
||||
if lightcraft_catalog::folders::is_startup_disk(path) {
|
||||
return Err(invalid("choose a folder, not the whole startup disk"));
|
||||
}
|
||||
let f = Filter { library_folder: Some(path.to_string()), ..Default::default() };
|
||||
let ids = s.catalog.query(&f, &Sort::default());
|
||||
if ids.is_empty() {
|
||||
return Err(invalid(format!("{path}: no photo in the library was imported from it")));
|
||||
}
|
||||
if !disk && (lightcraft_catalog::folders::is_disk_root(path) || crate::cmd::library::covers_other_disks(s, path, &ids)) {
|
||||
return Err(invalid(format!("{path} is a whole disk or holds disks: pass `disk: true` to synchronize it")));
|
||||
}
|
||||
let namings = s.sidecar_namings(&ids);
|
||||
let photos = ids.iter().zip(namings).filter_map(|(id, naming)| s.catalog.photo(*id).map(|p| (Arc::clone(p), naming))).collect();
|
||||
let (scan, _) = ScanInput::new(s, &[path.to_string()]);
|
||||
Ok(SyncInput { folder: path.to_string(), scan, photos, labels: s.catalog.clone() })
|
||||
}
|
||||
}
|
||||
|
||||
/// Find what changed in the folder (see the module docs). Reads the disk, never the session.
|
||||
///
|
||||
/// Both halves wait on the disk (a network share answers each file slowly), so they run at once:
|
||||
/// the look for new files (which probes them on its own threads) beside the checks of the
|
||||
/// library's photos, which run on several threads too. Stops early when `progress.files.cancel`
|
||||
/// is set.
|
||||
pub fn scan_with(input: SyncInput, progress: &SyncProgress) -> FolderChanges {
|
||||
let SyncInput { folder, scan, photos, labels } = input;
|
||||
let seen: Vec<Arc<Photo>> = photos.iter().map(|(p, _)| Arc::clone(p)).collect();
|
||||
if !cfg!(target_arch = "wasm32") && !Path::new(&folder).is_dir() {
|
||||
return FolderChanges { path: folder, seen, offline: true, ..Default::default() };
|
||||
}
|
||||
// (no threads on the web: one after the other there)
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let (out, checks) = (crate::import::scan_with(scan, std::slice::from_ref(&folder), &progress.files), check_photos(&photos, &labels, progress));
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let (out, checks) = std::thread::scope(|sc| {
|
||||
let photos = &photos;
|
||||
let labels = &labels;
|
||||
let checks = sc.spawn(move || check_photos(photos, labels, progress));
|
||||
let out = crate::import::scan_with(scan, std::slice::from_ref(&folder), &progress.files);
|
||||
// (a check that panicked counts as nothing found; the scan still reports the rest)
|
||||
(out, checks.join().unwrap_or_default())
|
||||
});
|
||||
let mut changes = FolderChanges { path: folder, seen, ..Default::default() };
|
||||
for ((p, _), check) in photos.iter().zip(checks) {
|
||||
let Some(file) = crate::cmd::missing::checked_path(p) else { continue };
|
||||
let found = FolderPhoto { id: p.id.0, path: file.to_string() };
|
||||
match check {
|
||||
Check::Missing => changes.missing.push(found),
|
||||
Check::News => changes.metadata.push(found),
|
||||
Check::Nothing => {}
|
||||
}
|
||||
}
|
||||
let moved_from = match_moved(&changes.missing, &out.candidates, &out.probes, &labels);
|
||||
for c in out.candidates {
|
||||
if moved_from.values().any(|to| *to == c.path) {
|
||||
continue;
|
||||
}
|
||||
let browsed = c.existing.and_then(|id| labels.photo(PhotoId(id))).is_some_and(|p| p.local && !p.deleted);
|
||||
match (c.duplicate.as_deref(), &c.error) {
|
||||
// a file only looked at in Local is not in the library yet: it is new
|
||||
(Some("path"), _) if browsed => changes.new.push(ImportCandidate { duplicate: None, existing: None, ..c }),
|
||||
(Some("path"), _) => {}
|
||||
(Some(_), _) => changes.duplicates += 1,
|
||||
(None, Some(_)) => changes.unreadable.push(c),
|
||||
(None, None) => changes.new.push(c),
|
||||
}
|
||||
}
|
||||
let missing = std::mem::take(&mut changes.missing);
|
||||
for m in missing {
|
||||
match moved_from.get(&m.path) {
|
||||
Some(to) => changes.moved.push(FolderMoved { id: m.id, from: m.path, to: to.clone() }),
|
||||
None => changes.missing.push(m),
|
||||
}
|
||||
}
|
||||
changes.probes = out.probes;
|
||||
changes
|
||||
}
|
||||
|
||||
/// Whether a photo is the same as far as synchronizing goes: its file, its place in the library
|
||||
/// and what a sidecar is compared with. Face and pet regions found in the background meanwhile
|
||||
/// don't make a scan stale.
|
||||
fn same_for_sync(a: &Arc<Photo>, b: &Arc<Photo>) -> bool {
|
||||
if Arc::ptr_eq(a, b) {
|
||||
return true;
|
||||
}
|
||||
let meta = |p: &Photo| lightcraft_catalog::Meta { regions: Vec::new(), ..p.meta.clone() };
|
||||
(&a.source, a.deleted, a.local, a.copy_of, &a.content_hash, a.file_size, a.rating, a.flag, a.label, &a.captured, &a.edited, &a.develop)
|
||||
== (&b.source, b.deleted, b.local, b.copy_of, &b.content_hash, b.file_size, b.rating, b.flag, b.label, &b.captured, &b.edited, &b.develop)
|
||||
&& meta(a) == meta(b)
|
||||
}
|
||||
|
||||
/// The content a hash stands for: a Duplicate's own hash is the original's plus `:dup<id>`.
|
||||
fn base_hash(h: &str) -> &str {
|
||||
h.split(':').next().unwrap_or(h)
|
||||
}
|
||||
|
||||
/// Missing photos' files found elsewhere in the folder (old path → new path): the file was
|
||||
/// renamed or moved, and the photo (with its edits) follows it rather than going missing.
|
||||
///
|
||||
/// By content first: each missing file's hash claims one file with that content (two missing
|
||||
/// files with the same content claim two). A photo with no hash on record (taken over from a
|
||||
/// Lightroom catalog) is matched by file size and dimensions, and only when exactly one missing
|
||||
/// file and exactly one unclaimed file of the folder agree on them.
|
||||
fn match_moved(
|
||||
missing: &[FolderPhoto],
|
||||
candidates: &[ImportCandidate],
|
||||
probes: &HashMap<String, ProbeInfo>,
|
||||
labels: &Catalog,
|
||||
) -> HashMap<String, String> {
|
||||
// distinct missing files (a photo's virtual copies share its file), with what is known of them
|
||||
let mut files: Vec<(&str, Option<&Photo>)> = Vec::new();
|
||||
for m in missing {
|
||||
if !files.iter().any(|(p, _)| *p == m.path) {
|
||||
files.push((m.path.as_str(), labels.photo(PhotoId(m.id)).map(|p| &**p)));
|
||||
}
|
||||
}
|
||||
let mut by_hash: HashMap<&str, std::collections::VecDeque<&str>> = HashMap::new();
|
||||
for (path, p) in &files {
|
||||
if let Some(h) = p.and_then(|p| p.content_hash.as_deref()) {
|
||||
by_hash.entry(base_hash(h)).or_default().push_back(path);
|
||||
}
|
||||
}
|
||||
let usable = |c: &&ImportCandidate| c.error.is_none() && c.duplicate.as_deref() != Some("path");
|
||||
let mut moved: HashMap<String, String> = HashMap::new();
|
||||
for c in candidates.iter().filter(usable) {
|
||||
let hash = probes.get(&c.path).and_then(|i| i.content_hash.as_deref());
|
||||
if let Some(from) = hash.and_then(|h| by_hash.get_mut(base_hash(h))).and_then(std::collections::VecDeque::pop_front) {
|
||||
moved.insert(from.to_string(), c.path.clone());
|
||||
}
|
||||
}
|
||||
let key = |size: u64, w: u32, h: u32| (size > 0 && w > 0 && h > 0).then_some((size, w, h));
|
||||
let mut hashless: HashMap<(u64, u32, u32), Vec<&str>> = HashMap::new();
|
||||
for (path, p) in &files {
|
||||
if let Some(p) = p.filter(|p| p.content_hash.is_none())
|
||||
&& let Some(k) = key(p.file_size, p.width, p.height)
|
||||
{
|
||||
hashless.entry(k).or_default().push(path);
|
||||
}
|
||||
}
|
||||
let mut found: HashMap<(u64, u32, u32), Vec<&str>> = HashMap::new();
|
||||
for c in candidates.iter().filter(usable).filter(|c| !moved.values().any(|to| *to == c.path)) {
|
||||
if let Some(k) = key(c.file_size, c.width, c.height) {
|
||||
found.entry(k).or_default().push(c.path.as_str());
|
||||
}
|
||||
}
|
||||
for (k, from) in hashless {
|
||||
if let ([from], Some([to])) = (from.as_slice(), found.get(&k).map(Vec::as_slice)) {
|
||||
moved.insert(from.to_string(), to.to_string());
|
||||
}
|
||||
}
|
||||
moved
|
||||
}
|
||||
|
||||
/// What checking one photo of the folder found.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
enum Check {
|
||||
#[default]
|
||||
Nothing,
|
||||
Missing,
|
||||
News,
|
||||
}
|
||||
|
||||
fn check_photo(p: &Photo, naming: SidecarNaming, labels: &Catalog) -> Check {
|
||||
let Some(file) = crate::cmd::missing::checked_path(p) else { return Check::Nothing };
|
||||
if !Path::new(file).exists() {
|
||||
Check::Missing
|
||||
} else if p.copy_of.is_none() && sidecar_has_news(labels, p, naming) {
|
||||
Check::News
|
||||
} else {
|
||||
Check::Nothing
|
||||
}
|
||||
}
|
||||
|
||||
/// [`check_photo`] for each photo, in order, on several threads (each file is a wait on the
|
||||
/// disk, not work for the CPU). Photos not reached before a cancel count as nothing found.
|
||||
fn check_photos(photos: &[(Arc<Photo>, SidecarNaming)], labels: &Catalog, progress: &SyncProgress) -> Vec<Check> {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
progress.to_check.store(photos.len(), Relaxed);
|
||||
let cancel = &progress.files.cancel;
|
||||
let check = |p: &Photo, naming: SidecarNaming| {
|
||||
let c = check_photo(p, naming, labels);
|
||||
progress.checked.fetch_add(1, Relaxed);
|
||||
c
|
||||
};
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return vec![Check::Nothing; photos.len()];
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let n = crate::import::workers(photos.len());
|
||||
if n > 1 {
|
||||
let next = std::sync::atomic::AtomicUsize::new(0);
|
||||
let found = std::sync::Mutex::new(vec![Check::Nothing; photos.len()]);
|
||||
std::thread::scope(|sc| {
|
||||
for _ in 0..n {
|
||||
sc.spawn(|| {
|
||||
while !cancel.load(Relaxed) {
|
||||
let i = next.fetch_add(1, Relaxed);
|
||||
let Some((p, naming)) = photos.get(i) else { break };
|
||||
let c = check(p, *naming);
|
||||
if let Some(slot) = found.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_mut(i) {
|
||||
*slot = c;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
return found.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
}
|
||||
}
|
||||
photos.iter().map(|(p, naming)| if cancel.load(Relaxed) { Check::Nothing } else { check(p, *naming) }).collect()
|
||||
}
|
||||
|
||||
/// [`scan_with`] on the calling thread.
|
||||
pub fn scan(s: &mut Session, path: &str, disk: bool) -> Result<FolderChanges> {
|
||||
let input = SyncInput::new(s, path, disk)?;
|
||||
let changes = scan_with(input, &SyncProgress::default());
|
||||
if changes.offline {
|
||||
return Err(not_there(&changes.path));
|
||||
}
|
||||
Ok(changes)
|
||||
}
|
||||
|
||||
fn not_there(path: &str) -> EngineError {
|
||||
invalid(format!("{path} is not there (moved, renamed, or on a disk that isn't connected)"))
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The last scan ([`Session::folder_changes`]) if it is of folder `path` and still current:
|
||||
/// the folder holds the same photos, none of them changed. What the person was shown is what
|
||||
/// gets done. Used once. (Changes elsewhere in the library don't matter.)
|
||||
pub fn take_folder_changes(&mut self, path: &str) -> Option<FolderChanges> {
|
||||
let c = self.folder_changes.take()?;
|
||||
if lightcraft_catalog::query::folder_key(&c.path) != lightcraft_catalog::query::folder_key(path) {
|
||||
return None;
|
||||
}
|
||||
let f = Filter { library_folder: Some(path.to_string()), ..Default::default() };
|
||||
let mut now: Vec<PhotoId> = self.catalog.query(&f, &Sort::default());
|
||||
let mut then: Vec<PhotoId> = c.seen.iter().map(|p| p.id).collect();
|
||||
now.sort();
|
||||
then.sort();
|
||||
let unchanged = now == then && c.seen.iter().all(|p| self.catalog.photo(p.id).is_some_and(|q| same_for_sync(p, q)));
|
||||
unchanged.then_some(c)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `p`'s XMP sidecar file was saved after the library last had its say about the photo
|
||||
/// (imported, or edited here) and says something the library doesn't.
|
||||
fn sidecar_has_news(labels: &Catalog, p: &Photo, naming: SidecarNaming) -> bool {
|
||||
let Source::File { path } = &p.source else { return false };
|
||||
let Some(file) = crate::sidecar::find_sidecar(path, naming) else { return false };
|
||||
let Ok(meta) = std::fs::metadata(&file) else { return false };
|
||||
if meta.len() > MAX_SIDECAR_BYTES {
|
||||
return false;
|
||||
}
|
||||
let saved = meta.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs());
|
||||
let Some(saved) = saved.and_then(|s| i64::try_from(s).ok()) else { return false };
|
||||
let seen = [Some(p.imported.as_str()), p.edited.as_deref()].into_iter().flatten().filter_map(lightcraft_catalog::stacks::iso_seconds).max();
|
||||
if seen.is_some_and(|t| saved <= t) {
|
||||
return false;
|
||||
}
|
||||
let Ok(text) = std::fs::read_to_string(&file) else { return false };
|
||||
let Ok(sc) = crate::sidecar::parse_sidecar(&text, crate::crs::Target::for_photo(p)) else { return false };
|
||||
let sc = sc.resolve_label(labels);
|
||||
let mut q = p.clone();
|
||||
crate::sidecar::merge_into(&mut q, &sc, "");
|
||||
(q.rating, q.flag, q.label, &q.meta, &q.captured, &q.develop) != (p.rating, p.flag, p.label, &p.meta, &p.captured, &p.develop)
|
||||
}
|
||||
|
||||
/// Act on `changes` as `choice` says, as one undo step ("Synchronize Folder"). A missing photo
|
||||
/// whose file is back by now is left alone. Once the import is in, a part that fails is
|
||||
/// reported in `failed` and the rest is still done (and still one step).
|
||||
pub fn synchronize(s: &mut Session, changes: FolderChanges, choice: SyncChoice) -> Result<SyncReport> {
|
||||
let (work, mut commit) = SyncJob::start(s, changes, choice)?;
|
||||
let mut steps = Vec::new();
|
||||
work.run(&SyncProgress::default(), |step| {
|
||||
steps.push(step);
|
||||
true
|
||||
});
|
||||
for step in steps {
|
||||
commit.apply(s, step);
|
||||
}
|
||||
Ok(commit.finish(s))
|
||||
}
|
||||
|
||||
/// Synchronizing in halves, so no file is touched on the UI thread: [`SyncJob::start`] takes what
|
||||
/// it needs from the session (no file-system call); the [`SyncWork`] does every file read (the
|
||||
/// import's probes, whether files are still where the scan found them, the sidecars) and hands
|
||||
/// over [`SyncStep`]s — on a worker thread in the app; [`SyncCommit::apply`] puts each into the
|
||||
/// catalog and [`SyncCommit::finish`] makes them one undo step.
|
||||
pub struct SyncJob;
|
||||
|
||||
/// The file-system half of a synchronize (see [`SyncJob`]).
|
||||
pub struct SyncWork {
|
||||
/// The import of the new files, and the files.
|
||||
import: Option<(crate::import::ImportJob, Vec<String>)>,
|
||||
moved: Vec<FolderMoved>,
|
||||
missing: Vec<FolderPhoto>,
|
||||
/// (photo, its file, how its sidecar is named)
|
||||
sidecars: Vec<(PhotoId, String, SidecarNaming, crate::crs::Target)>,
|
||||
}
|
||||
|
||||
/// The catalog half of a synchronize (see [`SyncJob`]).
|
||||
pub struct SyncCommit {
|
||||
opts: ImportOptions,
|
||||
now: String,
|
||||
/// The library the run was readied for: steps never land in another one (File › Open
|
||||
/// Library while the work runs), whose photo ids name other photos.
|
||||
library: Arc<()>,
|
||||
/// The run's own undo steps so far, and the history's length after the last of them: they
|
||||
/// are merged only while nothing else came in between (an Undo or an edit made meanwhile
|
||||
/// stays its own step, and the run's parts stay theirs).
|
||||
own: usize,
|
||||
after_last: Option<usize>,
|
||||
interleaved: bool,
|
||||
report: SyncReport,
|
||||
}
|
||||
|
||||
/// A piece of work done, for [`SyncCommit::apply`].
|
||||
pub enum SyncStep {
|
||||
/// A batch of new files, readied for the catalog.
|
||||
Import(crate::import::Prepared),
|
||||
/// Moved photos whose file is still at its new place (photo, new path).
|
||||
Relink(Vec<(PhotoId, String)>),
|
||||
/// Missing photos whose file is still gone.
|
||||
Remove(Vec<PhotoId>),
|
||||
/// Sidecars read (photo, its file, what the sidecar says or why it couldn't be read).
|
||||
Sidecars(Vec<(PhotoId, String, std::result::Result<crate::sidecar::SidecarData, String>)>),
|
||||
}
|
||||
|
||||
impl SyncJob {
|
||||
/// Ready a synchronize of `changes` as `choice` says. Reads no file.
|
||||
pub fn start(s: &mut Session, changes: FolderChanges, choice: SyncChoice) -> Result<(SyncWork, SyncCommit)> {
|
||||
if changes.offline {
|
||||
return Err(not_there(&changes.path));
|
||||
}
|
||||
let FolderChanges { new, missing, metadata, moved, probes, .. } = changes;
|
||||
let opts = ImportOptions { mode: ImportMode::Add, ..Default::default() };
|
||||
let import = if choice.import_new && !new.is_empty() {
|
||||
s.import_probes = probes;
|
||||
let job = crate::import::ImportJob::new(s, opts.clone())?;
|
||||
Some((job, new.into_iter().map(|c| c.path).collect()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let now = import.as_ref().map_or_else(|| (s.clock)(), |(j, _)| j.now().to_string());
|
||||
let sidecars = if choice.read_metadata {
|
||||
let namings = s.sidecar_namings(&metadata.iter().map(|m| PhotoId(m.id)).collect::<Vec<_>>());
|
||||
metadata
|
||||
.into_iter()
|
||||
.zip(namings)
|
||||
.filter_map(|(m, naming)| {
|
||||
let p = s.catalog.photo(PhotoId(m.id))?;
|
||||
Some((p.id, m.path, naming, crate::crs::Target::for_photo(p)))
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let work = SyncWork {
|
||||
import,
|
||||
moved: if choice.relink_moved { moved } else { Vec::new() },
|
||||
missing: if choice.remove_missing { missing } else { Vec::new() },
|
||||
sidecars,
|
||||
};
|
||||
let commit = SyncCommit {
|
||||
opts,
|
||||
now,
|
||||
library: Arc::clone(&s.library_identity),
|
||||
own: 0,
|
||||
after_last: None,
|
||||
interleaved: false,
|
||||
report: SyncReport::default(),
|
||||
};
|
||||
Ok((work, commit))
|
||||
}
|
||||
}
|
||||
|
||||
impl SyncWork {
|
||||
/// Do the work, handing each step to `send` (`false`: nobody takes them any more — stop).
|
||||
/// Stops before the next piece once `progress.files.cancel` is set; what was handed over
|
||||
/// stays handed over.
|
||||
pub fn run(self, progress: &SyncProgress, mut send: impl FnMut(SyncStep) -> bool) {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let cancel = &progress.files.cancel;
|
||||
let SyncWork { import, moved, missing, sidecars } = self;
|
||||
let pieces = import.as_ref().map_or(0, |(_, f)| f.len()) + moved.len() + missing.len() + sidecars.len();
|
||||
progress.to_check.store(pieces, Relaxed);
|
||||
progress.checked.store(0, Relaxed);
|
||||
let step = |n: usize| {
|
||||
progress.checked.fetch_add(n, Relaxed);
|
||||
};
|
||||
if let Some((mut job, files)) = import {
|
||||
for chunk in files.chunks(crate::import::batch_size()) {
|
||||
if cancel.load(Relaxed) {
|
||||
return;
|
||||
}
|
||||
let prepared = job.prepare_files(chunk.to_vec(), cancel);
|
||||
step(chunk.len());
|
||||
// (the files are added in place: a batch nobody takes leaves nothing behind on disk)
|
||||
if !send(SyncStep::Import(prepared)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if cancel.load(Relaxed) {
|
||||
return;
|
||||
}
|
||||
// still where the scan found it, and still gone from where the photo says
|
||||
let checked = moved.len();
|
||||
let relink: Vec<(PhotoId, String)> =
|
||||
moved.into_iter().filter(|m| Path::new(&m.to).is_file() && !Path::new(&m.from).exists()).map(|m| (PhotoId(m.id), m.to)).collect();
|
||||
step(checked);
|
||||
if !relink.is_empty() && !send(SyncStep::Relink(relink)) {
|
||||
return;
|
||||
}
|
||||
if cancel.load(Relaxed) {
|
||||
return;
|
||||
}
|
||||
let gone: Vec<PhotoId> = missing.iter().filter(|m| !Path::new(&m.path).exists()).map(|m| PhotoId(m.id)).collect();
|
||||
step(missing.len());
|
||||
if !gone.is_empty() && !send(SyncStep::Remove(gone)) {
|
||||
return;
|
||||
}
|
||||
let mut read = Vec::with_capacity(sidecars.len());
|
||||
for (id, path, naming, target) in sidecars {
|
||||
if cancel.load(Relaxed) {
|
||||
break;
|
||||
}
|
||||
read.push((id, path.clone(), read_sidecar(&path, naming, target)));
|
||||
step(1);
|
||||
}
|
||||
if !read.is_empty() {
|
||||
send(SyncStep::Sidecars(read));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read and parse photo file `path`'s XMP sidecar file.
|
||||
fn read_sidecar(path: &str, naming: SidecarNaming, target: crate::crs::Target) -> std::result::Result<crate::sidecar::SidecarData, String> {
|
||||
let file = crate::sidecar::find_sidecar(path, naming).ok_or_else(|| "no XMP sidecar".to_string())?;
|
||||
let len = std::fs::metadata(&file).map_err(|e| format!("{}: {e}", file.display()))?.len();
|
||||
if len > MAX_SIDECAR_BYTES {
|
||||
return Err(format!("{}: too large for an XMP sidecar", file.display()));
|
||||
}
|
||||
let text = std::fs::read_to_string(&file).map_err(|e| format!("{}: {e}", file.display()))?;
|
||||
crate::sidecar::parse_sidecar(&text, target).map_err(|e| format!("{}: {e}", file.display()))
|
||||
}
|
||||
|
||||
impl SyncCommit {
|
||||
/// Put a step into the catalog (no file is touched). A part that fails is reported in the
|
||||
/// end, and the rest is still done.
|
||||
pub fn apply(&mut self, s: &mut Session, step: SyncStep) {
|
||||
if !Arc::ptr_eq(&self.library, &s.library_identity) {
|
||||
self.report.failed.push((String::new(), "another library was opened: the rest of the synchronize was dropped".into()));
|
||||
return;
|
||||
}
|
||||
let commits = s.commits();
|
||||
if self.after_last.is_some_and(|n| n != s.undo.len()) {
|
||||
self.interleaved = true;
|
||||
}
|
||||
self.apply_step(s, step);
|
||||
if s.commits() != commits {
|
||||
self.own += usize::try_from(s.commits().wrapping_sub(commits)).unwrap_or(0);
|
||||
self.after_last = Some(s.undo.len());
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_step(&mut self, s: &mut Session, step: SyncStep) {
|
||||
let report = &mut self.report;
|
||||
match step {
|
||||
SyncStep::Import(prepared) => match crate::import::commit_prepared(s, &self.opts, &self.now, prepared) {
|
||||
Ok(r) => {
|
||||
report.imported += r.imported.len();
|
||||
report.failed.extend(r.failed);
|
||||
}
|
||||
Err(e) => report.failed.push((String::new(), e.to_string())),
|
||||
},
|
||||
SyncStep::Relink(moved) => {
|
||||
let ops: Vec<Op> =
|
||||
moved.iter().filter(|(id, _)| s.catalog.photo(*id).is_some()).map(|(id, to)| crate::cmd::missing::relink_op(*id, to)).collect();
|
||||
let n = ops.len();
|
||||
if n > 0 {
|
||||
match s.commit("Relink Moved Photos", Op::Batch { ops }) {
|
||||
Ok(()) => report.relinked += n,
|
||||
Err(e) => report.failed.push((String::new(), e.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
SyncStep::Remove(gone) => {
|
||||
let ops: Vec<Op> = gone
|
||||
.iter()
|
||||
.filter(|id| s.catalog.photo(**id).is_some_and(|p| p.in_library()))
|
||||
.map(|id| Op::SetDeleted { id: *id, deleted: true })
|
||||
.collect();
|
||||
let n = ops.len();
|
||||
if n > 0 {
|
||||
match s.commit("Remove Missing Photos", Op::Batch { ops }) {
|
||||
Ok(()) => report.removed += n,
|
||||
Err(e) => report.failed.push((String::new(), e.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
SyncStep::Sidecars(read) => {
|
||||
let mut ops = Vec::new();
|
||||
for (id, path, sc) in read {
|
||||
match sc.map_err(EngineError::Other).and_then(|sc| s.sidecar_op(id, sc)) {
|
||||
Ok(op) => ops.push(op),
|
||||
Err(e) => report.failed.push((path, e.to_string())),
|
||||
}
|
||||
}
|
||||
let n = ops.len();
|
||||
if n > 0 {
|
||||
match s.commit("Read Metadata from File", Op::Batch { ops }) {
|
||||
Ok(()) => report.read += n,
|
||||
Err(e) => report.failed.push((String::new(), e.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What was done, as one undo step ("Synchronize Folder").
|
||||
pub fn finish(mut self, s: &mut Session) -> SyncReport {
|
||||
// a report line once, however many steps came after a library switch
|
||||
self.report.failed.dedup();
|
||||
let ours = Arc::ptr_eq(&self.library, &s.library_identity) && !self.interleaved && self.after_last == Some(s.undo.len());
|
||||
let steps = if ours { self.own.min(s.undo.len()) } else { 0 };
|
||||
s.merge_undo(steps, "Synchronize Folder");
|
||||
if let Some(last) = s.undo.last_mut().filter(|_| steps == 1) {
|
||||
last.label = "Synchronize Folder".into();
|
||||
}
|
||||
self.report
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ fn active_dev(s: &Session) -> lightcraft_develop::DevelopSettings {
|
||||
fn changing_one_wb_control_resolves_as_shot_without_stale_tint() {
|
||||
use lightcraft_catalog::{Photo, PhotoId, Source};
|
||||
use lightcraft_develop::{DevelopSettings, WbMode};
|
||||
for format in ["ARW", "NEF", "NRW", "RAF", "CR2", "PEF"] {
|
||||
for format in ["ARW", "NEF", "NRW", "RAF", "CR2", "PEF", "ORF"] {
|
||||
let mut s = demo();
|
||||
let id = PhotoId(100);
|
||||
let name = format!("synthetic.{format}");
|
||||
@@ -113,9 +113,10 @@ fn albums_crud() {
|
||||
s.execute("library.selectAll", &json!({})).unwrap();
|
||||
s.execute("album.addPhotos", &json!({"id": a})).unwrap();
|
||||
assert_eq!(s.catalog.album(lightcraft_catalog::AlbumId(a)).unwrap().photos.len(), 24);
|
||||
assert!(s.execute("album.delete", &json!({"id": f})).is_err(), "non-empty folder");
|
||||
s.execute("album.delete", &json!({"id": a})).unwrap();
|
||||
s.execute("album.delete", &json!({"id": f})).unwrap();
|
||||
assert!(s.catalog.album(lightcraft_catalog::AlbumId(a)).is_none());
|
||||
assert!(s.catalog.album(lightcraft_catalog::AlbumId(f)).is_none());
|
||||
assert_eq!(s.catalog.photos().count(), 24, "album deletion keeps the photos");
|
||||
}
|
||||
|
||||
/// Given an album and a folder, when something is created inside each, then only the folder
|
||||
@@ -198,6 +199,60 @@ fn deleting_explicit_targets_keeps_the_surviving_selection() {
|
||||
assert_eq!(s.selection, Selection::single(visible[8]), "partial deletion retains a selected survivor");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permanently_deleting_photos_selects_a_neighbour_in_recently_deleted() {
|
||||
for params in [json!({}), json!({"key": "fileName", "ascending": false}), json!({"key": "random", "seed": 123})] {
|
||||
for at_end in [false, true] {
|
||||
let mut s = demo();
|
||||
s.execute("library.selectAll", &json!({})).unwrap();
|
||||
s.execute("photo.delete", &json!({})).unwrap();
|
||||
s.execute("library.source", &json!({"kind": "recentlyDeleted"})).unwrap();
|
||||
s.execute("library.sort", ¶ms).unwrap();
|
||||
s.execute("library.filter", &json!({"rating": 2})).unwrap();
|
||||
let visible = s.visible_cloned();
|
||||
assert!(visible.len() > 5);
|
||||
let at = if at_end { visible.len() - 2 } else { visible.len() / 2 };
|
||||
let deleted = &visible[at..at + 2];
|
||||
s.execute("library.select", &json!({"ids": deleted, "active": deleted[0]})).unwrap();
|
||||
let snapshot = s.catalog.to_snapshot();
|
||||
let undo_len = s.undo.len();
|
||||
let r = s.execute("photo.deletePermanently", &json!({})).unwrap();
|
||||
assert_eq!(r["deleted"], 2);
|
||||
let expected = if at_end { visible[at - 1] } else { visible[at + 2] };
|
||||
assert_eq!(s.selection, Selection::single(expected), "sort {params}, end {at_end}");
|
||||
assert!(deleted.iter().all(|id| s.catalog.photo(*id).is_none()));
|
||||
assert_eq!(s.undo.len(), undo_len + 1);
|
||||
let removed = s.catalog.to_snapshot();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.catalog.to_snapshot(), snapshot);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.catalog.to_snapshot(), removed);
|
||||
}
|
||||
}
|
||||
let mut s = demo();
|
||||
s.execute("library.selectAll", &json!({})).unwrap();
|
||||
s.execute("photo.deletePermanently", &json!({})).unwrap();
|
||||
assert!(s.visible().is_empty());
|
||||
assert_eq!(s.selection, Selection::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permanently_deleting_explicit_targets_keeps_the_surviving_selection() {
|
||||
let mut s = demo();
|
||||
let visible = s.visible_cloned();
|
||||
s.execute("library.select", &json!({"ids": [visible[8], visible[9]], "active": visible[9]})).unwrap();
|
||||
let before = s.selection.clone();
|
||||
s.execute("photo.deletePermanently", &json!({"ids": [visible[3]]})).unwrap();
|
||||
assert_eq!(s.selection, before, "deleting an unselected photo doesn't change selection");
|
||||
s.execute("photo.deletePermanently", &json!({"ids": [visible[9]]})).unwrap();
|
||||
assert_eq!(s.selection, Selection::single(visible[8]), "partial deletion retains a selected survivor");
|
||||
let before = s.selection.clone();
|
||||
let snapshot = s.catalog.to_snapshot();
|
||||
assert!(s.execute("photo.deletePermanently", &json!({"ids": [999_999]})).is_err());
|
||||
assert_eq!(s.selection, before, "a refused deletion doesn't change selection");
|
||||
assert_eq!(s.catalog.to_snapshot(), snapshot);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_crop_and_render() {
|
||||
let mut s = demo();
|
||||
|
||||
@@ -370,3 +370,73 @@ fn exports_are_atomic_without_a_sync() {
|
||||
assert!(syncs_on_this_thread() > before, "the durable writer syncs");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The demo library's ocean sunset (a sun far above SDR white), selected and active.
|
||||
fn sunset(s: &mut Session) -> lightcraft_catalog::PhotoId {
|
||||
let id = s.catalog.photos().find(|p| p.meta.title == "Golden horizon").map(|p| p.id).unwrap();
|
||||
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_jpeg_export_writes_a_gain_map_and_sdr_edits_stay_plain() {
|
||||
use lightcraft_codecs::gainmap;
|
||||
let mut s = Session::with_demo();
|
||||
let id = sunset(&mut s);
|
||||
let o = ExportOptions::from_json(&json!({"longEdge": 320, "hdr": true}));
|
||||
assert!(o.hdr_output());
|
||||
// no HDR edit: an ordinary JPEG even with HDR output asked for
|
||||
let plain = export_photo(&mut s, id, &o, 1).unwrap();
|
||||
assert!(!gainmap::is_gain_map_jpeg(&plain.bytes));
|
||||
|
||||
let r = s.execute("develop.hdr", &json!({"enabled": true, "maxEv": 3})).unwrap();
|
||||
assert_eq!(r["enabled"], true);
|
||||
let e = export_photo(&mut s, id, &o, 1).unwrap();
|
||||
assert!(gainmap::is_gain_map_jpeg(&e.bytes));
|
||||
let gm = gainmap::read_jpeg(&e.bytes).unwrap();
|
||||
assert!(gm.meta.alternate_headroom > 0.5 && gm.meta.alternate_headroom <= 3.05, "headroom {}", gm.meta.alternate_headroom);
|
||||
// the base is the SDR rendition: the same pixels as the SDR export (the decoders agree)
|
||||
let a = lightcraft_codecs::decode(&e.bytes, Default::default()).unwrap();
|
||||
let b = lightcraft_codecs::decode(&plain.bytes, Default::default()).unwrap();
|
||||
assert_eq!((a.width, a.height), (b.width, b.height));
|
||||
let diff = a.image.data.iter().zip(&b.image.data).map(|(p, q)| (p[1] - q[1]).abs()).fold(0.0f32, f32::max);
|
||||
assert!(diff < 0.02, "base differs from the SDR export by {diff}");
|
||||
// HDR off again: the gain map goes away
|
||||
s.execute("develop.hdr", &json!({"enabled": false})).unwrap();
|
||||
assert!(!gainmap::is_gain_map_jpeg(&export_photo(&mut s, id, &o, 1).unwrap().bytes));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_float_tiff_keeps_highlights_above_white() {
|
||||
let mut s = Session::with_demo();
|
||||
let id = sunset(&mut s);
|
||||
s.execute("develop.hdr", &json!({"enabled": true, "maxEv": 2})).unwrap();
|
||||
let max_of = |s: &mut Session, hdr: bool| {
|
||||
let o = ExportOptions::from_json(&json!({"longEdge": 240, "format": "tiff", "bitDepth": 32, "hdr": hdr}));
|
||||
let e = export_photo(s, id, &o, 1).unwrap();
|
||||
let d = lightcraft_codecs::decode(&e.bytes, Default::default()).unwrap();
|
||||
d.image.data.iter().flat_map(|p| p.iter().copied()).fold(0.0f32, f32::max)
|
||||
};
|
||||
let hdr = max_of(&mut s, true);
|
||||
let sdr = max_of(&mut s, false);
|
||||
assert!(sdr <= 1.0 + 1e-3, "SDR float TIFF stays in 0..1 ({sdr})");
|
||||
assert!(hdr > 1.2 && hdr <= 4.0 + 1e-3, "HDR float TIFF max {hdr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_avif_export_is_pq_rec2020() {
|
||||
let mut s = Session::with_demo();
|
||||
let id = sunset(&mut s);
|
||||
let o = ExportOptions::from_json(&json!({"longEdge": 160, "format": "avif", "hdr": true, "quality": 70}));
|
||||
assert!(o.hdr_output());
|
||||
let sdr = export_photo(&mut s, id, &o, 1).unwrap();
|
||||
s.execute("develop.hdr", &json!({"enabled": true, "maxEv": 2})).unwrap();
|
||||
let hdr = export_photo(&mut s, id, &o, 1).unwrap();
|
||||
// the `colr` nclx box: BT.2020 primaries (9), PQ (16), BT.2020 NCL (9), full range
|
||||
let nclx = |b: &[u8]| b.windows(4).position(|w| w == b"nclx").map(|i| b[i + 4..i + 11].to_vec());
|
||||
let c = nclx(&hdr.bytes).expect("colr nclx box");
|
||||
assert_eq!(c, vec![0, 9, 0, 16, 0, 9, 0x80]);
|
||||
assert!(hdr.bytes.windows(4).any(|w| w == b"clli"), "content light level box");
|
||||
// a photo without an HDR edit exports as an ordinary (sRGB) AVIF
|
||||
assert!(nclx(&sdr.bytes).is_none_or(|c| c[3] != 16));
|
||||
}
|
||||
@@ -8,6 +8,11 @@
|
||||
//! * Removing a folder from the library moves its photos to Recently Deleted (one undo step) and
|
||||
//! leaves every file where it is.
|
||||
//! * Renaming or moving a folder on disk keeps the chosen folder chosen.
|
||||
//! * Given a folder of the library, an agent gives it a colour label with `folder.label`; the
|
||||
//! folder list shows it, undo takes it back, and only folders the library holds photos in can
|
||||
//! be labelled.
|
||||
//! * A labelled folder keeps its label when it is renamed or moved on disk, and so do the folders
|
||||
//! inside it; undo puts the labels back where they were.
|
||||
|
||||
use lightcraft_catalog::{Op, Photo, Source};
|
||||
use serde_json::json;
|
||||
@@ -463,3 +468,99 @@ fn a_blank_folder_filter_is_no_filter() {
|
||||
s.execute("library.filter", &json!({"libraryFolder": " "})).unwrap();
|
||||
assert_eq!(s.filter, lightcraft_catalog::Filter::default(), "no hidden 'filters active' state");
|
||||
}
|
||||
|
||||
/// The label `library.folders` reports for the row at `path`.
|
||||
fn listed_label(s: &mut Session, path: &str) -> Option<String> {
|
||||
fn find(rows: &serde_json::Value, key: &str) -> Option<serde_json::Value> {
|
||||
rows.as_array()?.iter().find_map(|r| {
|
||||
if r["path"].as_str().map(lightcraft_catalog::query::folder_key).as_deref() == Some(key) {
|
||||
Some(r.clone())
|
||||
} else {
|
||||
find(&r["children"], key)
|
||||
}
|
||||
})
|
||||
}
|
||||
let rows = s.execute("library.folders", &json!({})).unwrap();
|
||||
let row = find(&rows, &lightcraft_catalog::query::folder_key(path)).unwrap_or_else(|| panic!("no row for {path}: {rows}"));
|
||||
row["label"].as_str().map(str::to_string)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_labelled_folder_shows_its_label_in_the_folder_list_until_undone() {
|
||||
let mut s = session();
|
||||
s.execute("folder.label", &json!({"path": "/pics/trip", "label": "green"})).unwrap();
|
||||
assert_eq!(listed_label(&mut s, "/pics/trip").as_deref(), Some("green"));
|
||||
assert_eq!(listed_label(&mut s, "/pics/home"), None, "its neighbour has none");
|
||||
s.execute("folder.label", &json!({"path": "/pics/trip", "label": "none"})).unwrap();
|
||||
assert_eq!(listed_label(&mut s, "/pics/trip"), None);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(listed_label(&mut s, "/pics/trip").as_deref(), Some("green"), "undo puts the label back");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(listed_label(&mut s, "/pics/trip"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_folder_of_the_library_takes_a_known_label() {
|
||||
let mut s = session();
|
||||
let before = s.catalog.to_snapshot();
|
||||
for (p, why) in [
|
||||
(json!({"path": "/pics/trip", "label": "mauve"}), "unknown label"),
|
||||
(json!({"path": "/pics/trip"}), "missing label"),
|
||||
(json!({"label": "red"}), "missing path"),
|
||||
(json!({"path": " ", "label": "red"}), "blank path"),
|
||||
(json!({"path": "/elsewhere", "label": "red"}), "no photo was imported from it"),
|
||||
(json!({"path": "pics/trip", "label": "red"}), "a relative path"),
|
||||
] {
|
||||
assert!(s.execute("folder.label", &p).is_err(), "{why}");
|
||||
}
|
||||
assert_eq!(s.catalog.to_snapshot(), before, "nothing changed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_follow_a_renamed_folder_and_undo_puts_them_back() {
|
||||
let dir = Scratch::new("label-rename");
|
||||
std::fs::create_dir_all(dir.0.join("trip/day1")).unwrap();
|
||||
let (trip, day1) = (dir.path("trip"), dir.path("trip/day1"));
|
||||
let mut s = Session::new();
|
||||
add(&mut s, &format!("{trip}/a.jpg"));
|
||||
add(&mut s, &format!("{day1}/b.jpg"));
|
||||
s.execute("folder.label", &json!({"path": trip, "label": "red"})).unwrap();
|
||||
s.execute("folder.label", &json!({"path": day1, "label": "blue"})).unwrap();
|
||||
s.execute("folder.rename", &json!({"path": trip, "name": "holiday"})).unwrap();
|
||||
assert_eq!(listed_label(&mut s, &dir.path("holiday")).as_deref(), Some("red"));
|
||||
assert_eq!(listed_label(&mut s, &dir.path("holiday/day1")).as_deref(), Some("blue"), "the folder inside goes along");
|
||||
assert_eq!(s.catalog.folder_record(&trip), None, "nothing stays behind");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(listed_label(&mut s, &trip).as_deref(), Some("red"));
|
||||
assert_eq!(listed_label(&mut s, &day1).as_deref(), Some("blue"));
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(listed_label(&mut s, &dir.path("holiday")).as_deref(), Some("red"), "redo carries them again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_follow_a_moved_folder() {
|
||||
let dir = Scratch::new("label-move");
|
||||
std::fs::create_dir_all(dir.0.join("trip")).unwrap();
|
||||
std::fs::create_dir_all(dir.0.join("archive")).unwrap();
|
||||
let trip = dir.path("trip");
|
||||
let mut s = Session::new();
|
||||
add(&mut s, &format!("{trip}/a.jpg"));
|
||||
s.execute("folder.label", &json!({"path": trip, "label": "yellow"})).unwrap();
|
||||
s.execute("folder.move", &json!({"path": trip, "into": dir.path("archive")})).unwrap();
|
||||
assert_eq!(listed_label(&mut s, &dir.path("archive/trip")).as_deref(), Some("yellow"));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(listed_label(&mut s, &trip).as_deref(), Some("yellow"), "undo moves it back with the folder");
|
||||
assert_eq!(s.catalog.folder_record(&dir.path("archive/trip")), None);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(listed_label(&mut s, &dir.path("archive/trip")).as_deref(), Some("yellow"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_label_comes_off_a_folder_whose_photos_are_gone() {
|
||||
let mut s = session();
|
||||
s.execute("folder.label", &json!({"path": "/pics/trip", "label": "red"})).unwrap();
|
||||
s.execute("library.removeFolder", &json!({"path": "/pics/trip"})).unwrap();
|
||||
s.execute("folder.label", &json!({"path": "/pics/trip", "label": "none"})).unwrap();
|
||||
assert_eq!(s.catalog.folder_record("/pics/trip"), None);
|
||||
assert!(s.execute("folder.label", &json!({"path": "/pics/trip", "label": "red"})).is_err(), "but a new one needs photos");
|
||||
}
|
||||
@@ -974,8 +974,27 @@ fn emptying_trashed_photos_of_one_album_and_stack_leaves_nothing_dangling() {
|
||||
fn deleting_several_photos_permanently_leaves_nothing_dangling() {
|
||||
let (mut s, src, ids, al) = two_trashed_in_album_and_stack("trash-perm-refs");
|
||||
s.execute("library.select", &json!({"ids": ids})).unwrap();
|
||||
let files = [src.join("a.png"), src.join("b.png")].map(|path| {
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
(path, bytes)
|
||||
});
|
||||
let snapshot = s.catalog.to_snapshot();
|
||||
s.drain_log();
|
||||
s.execute("photo.deletePermanently", &json!({"ids": ids})).unwrap();
|
||||
assert!(s.catalog.album(lightcraft_catalog::AlbumId(al)).unwrap().photos.is_empty());
|
||||
assert!(ids.iter().all(|i| s.catalog.photo(lightcraft_catalog::PhotoId(*i)).is_none()));
|
||||
let removed = s.catalog.to_snapshot();
|
||||
let log: String = s.drain_log().iter().map(lightcraft_catalog::Catalog::op_to_log_line).collect();
|
||||
let mut reopened = lightcraft_catalog::Catalog::from_snapshot(&snapshot).unwrap();
|
||||
reopened.replay(&log).unwrap();
|
||||
assert_eq!(reopened.to_snapshot(), removed);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.catalog.to_snapshot(), snapshot, "one undo restores photos, album and stack");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.catalog.to_snapshot(), removed);
|
||||
for (path, bytes) in files {
|
||||
assert_eq!(std::fs::read(path).unwrap(), bytes, "permanent deletion only removes catalog records");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
@@ -1105,3 +1124,54 @@ fn folder_import_picks_up_undecodable_raw_containers_as_preview_only() {
|
||||
assert!(s.catalog.photos().all(|p| p.preview_only.is_some() && p.kind == lightcraft_catalog::MediaKind::Raw && (p.width, p.height) == (40, 30)));
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_gone_since_the_import_review_is_not_imported() {
|
||||
let src = temp_dir("gone-since-review");
|
||||
write_png(&src.join("a.png"), 1);
|
||||
write_png(&src.join("b.png"), 2);
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.importPreview", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||||
std::fs::remove_file(src.join("b.png")).unwrap();
|
||||
let r = s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy(), src.join("b.png").to_string_lossy()]})).unwrap();
|
||||
assert_eq!((ids(&r, "imported"), ids(&r, "failed")), (1, 1), "{r}");
|
||||
assert_eq!(s.catalog.len(), 1, "no photo for a file that isn't there");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
/// A HEIC laid out like an iPhone's (a grid of HEVC pictures, a Display P3 profile, a quarter turn
|
||||
/// in the container, a thumbnail) imports as a normal photo that develops and exports upright like
|
||||
/// a JPEG. In a build without the codecs' `heif` feature it is reported as failed, saying why.
|
||||
#[test]
|
||||
fn heic_imports_as_a_normal_photo_or_says_why_not() {
|
||||
use lightcraft_heif::testdata::{Prop, Spec, build};
|
||||
let src = temp_dir("heic");
|
||||
let photo = |x: u32, y: u32| [((x * 7 + y * 13) % 200 + 30) as u16, 110, 150];
|
||||
let p3 = lightcraft_codecs::icc::write_named(lightcraft_codecs::NamedSpace::DisplayP3);
|
||||
let bytes = build(&Spec { props: vec![Prop::Icc(p3), Prop::Irot(1)], thumbnail: Some(&photo), ..Spec::new(96, 64, &photo) });
|
||||
std::fs::write(src.join("IMG_0001.HEIC"), &bytes).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||||
if !lightcraft_codecs::Format::Heif.can_decode() {
|
||||
assert_eq!((ids(&r, "imported"), ids(&r, "failed")), (0, 1), "{r}");
|
||||
assert!(r["failed"].to_string().contains("isn't included in this build"), "{r}");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
return;
|
||||
}
|
||||
assert_eq!((ids(&r, "imported"), ids(&r, "failed")), (1, 0), "{r}");
|
||||
let p = s.catalog.photos().next().unwrap();
|
||||
let id = p.id;
|
||||
// Upright (the container's turn applied) and not preview-only.
|
||||
assert_eq!((p.kind, p.preview_only.clone(), p.width, p.height), (lightcraft_catalog::MediaKind::Image, None, 64, 96));
|
||||
s.execute("library.select", &json!({"ids": [id.0], "active": id.0})).unwrap();
|
||||
let plain = crate::export::export_photo(&mut s, id, &crate::export::ExportOptions::from_json(&json!({"format": "png"})), 1).unwrap();
|
||||
assert_eq!((plain.width, plain.height), (64, 96));
|
||||
s.execute("develop.set", &json!({"control": "light.exposure", "value": 1.0})).unwrap();
|
||||
let brighter = crate::export::export_photo(&mut s, id, &crate::export::ExportOptions::from_json(&json!({"format": "png"})), 1).unwrap();
|
||||
let mean = |png: &[u8]| {
|
||||
let d = lightcraft_codecs::decode(png, Default::default()).unwrap();
|
||||
d.image.data.iter().map(|p| p[1]).sum::<f32>() / d.image.data.len() as f32
|
||||
};
|
||||
assert!(mean(&brighter.bytes) > mean(&plain.bytes) * 1.3, "exposure +1 brightens the HEIC");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
//! The keyword list's commands (Lightroom Classic's Keyword List): create, edit, move and purge
|
||||
//! keywords, their attributes, and the default parent for new keywords.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// The keyword filter follows a renamed keyword whatever the case it was typed in, also where a
|
||||
/// letter's case changes its length (ẞ / ß): it used to be cut by bytes, which could land inside
|
||||
/// a letter and panic.
|
||||
#[test]
|
||||
fn the_filter_follows_a_rename_whatever_the_case() {
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.visible_cloned()[0].0;
|
||||
s.execute("photo.setMeta", &json!({"ids": [id], "keywords": ["ßßa|é"]})).unwrap();
|
||||
s.execute("library.filter", &json!({"keyword": "ßßa|é"})).unwrap();
|
||||
s.execute("keyword.rename", &json!({"from": "ẞẞA", "to": "Road"})).unwrap();
|
||||
assert_eq!(s.filter.keyword.as_deref(), Some("Road|é"));
|
||||
s.execute("keyword.merge", &json!({"from": ["ROAD"], "into": "Weg"})).unwrap();
|
||||
assert_eq!(s.filter.keyword.as_deref(), Some("Weg|é"));
|
||||
}
|
||||
|
||||
fn temp_dir(tag: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lc-keyword-list-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn keywords_of(s: &Session, id: u64) -> Vec<String> {
|
||||
s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().meta.keywords.clone()
|
||||
}
|
||||
|
||||
/// Create Keyword: a name inside a parent, with attributes, given to the selected photos at once,
|
||||
/// in one undo step; `keyword.info` reads it back. A keyword that exists isn't created again.
|
||||
#[test]
|
||||
fn creating_a_keyword_with_attributes_and_the_selection() {
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.visible_cloned()[0].0;
|
||||
s.execute("library.select", &json!({"ids": [id]})).unwrap();
|
||||
let undo = s.undo.len();
|
||||
let r = s
|
||||
.execute("keyword.create", &json!({"name": "Weddings", "parent": "Events", "synonyms": ["marriage"], "person": false, "addToSelected": true}))
|
||||
.unwrap();
|
||||
assert_eq!(r["keyword"], "Events|Weddings", "{r}");
|
||||
assert_eq!(s.undo.len(), undo + 1, "one undo step");
|
||||
assert!(keywords_of(&s, id).contains(&"Events|Weddings".to_string()));
|
||||
let info = s.execute("keyword.info", &json!({"keyword": "events|weddings"})).unwrap();
|
||||
assert_eq!(info["path"], "Events|Weddings");
|
||||
assert_eq!(info["synonyms"], json!(["marriage"]));
|
||||
assert_eq!((info["includeOnExport"].as_bool(), info["listed"].as_bool(), info["count"].as_u64()), (Some(true), Some(true), Some(1)));
|
||||
let err = s.execute("keyword.create", &json!({"name": "weddings", "parent": "events"})).unwrap_err().to_string();
|
||||
assert!(err.contains("already"), "{err}");
|
||||
assert!(s.execute("keyword.info", &json!({"keyword": "lisbon"})).is_err(), "no such keyword");
|
||||
}
|
||||
|
||||
/// "Put new keywords inside this keyword": new keywords go inside the default parent unless the
|
||||
/// command says otherwise (`parent: null` = the top level). It belongs to its keyword in the
|
||||
/// library: it follows a rename, goes with a delete, and undo and redo bring it back with them.
|
||||
#[test]
|
||||
fn new_keywords_go_inside_the_default_parent() {
|
||||
let dir = temp_dir("default-parent");
|
||||
let mut s = Session::new();
|
||||
s.open_library(&dir, true).unwrap();
|
||||
s.execute("keyword.create", &json!({"name": "Events"})).unwrap();
|
||||
s.execute("keyword.setDefaultParent", &json!({"keyword": "events"})).unwrap();
|
||||
assert_eq!(s.catalog.default_keyword_parent().as_deref(), Some("Events"));
|
||||
assert_eq!(s.execute("keyword.create", &json!({"name": "Birthdays"})).unwrap()["keyword"], "Events|Birthdays");
|
||||
assert_eq!(s.execute("keyword.create", &json!({"name": "Travel", "parent": null})).unwrap()["keyword"], "Travel");
|
||||
s.execute("keyword.rename", &json!({"from": "Events", "to": "Occasions"})).unwrap();
|
||||
assert_eq!(s.catalog.default_keyword_parent().as_deref(), Some("Occasions"), "follows the rename");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.catalog.default_keyword_parent().as_deref(), Some("Events"), "undo takes it back");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
drop(s);
|
||||
let mut s = Session::new();
|
||||
s.open_library(&dir, false).unwrap();
|
||||
assert_eq!(s.catalog.default_keyword_parent().as_deref(), Some("Occasions"), "kept with the library");
|
||||
s.execute("keyword.delete", &json!({"keyword": "occasions"})).unwrap();
|
||||
assert_eq!(s.catalog.default_keyword_parent(), None, "gone with its keyword");
|
||||
assert_eq!(s.execute("keyword.create", &json!({"name": "Graduations"})).unwrap()["keyword"], "Graduations", "at the top level");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.catalog.default_keyword_parent().as_deref(), Some("Occasions"), "undoing the delete brings it back");
|
||||
s.execute("keyword.setDefaultParent", &json!({"keyword": null})).unwrap();
|
||||
assert_eq!(s.catalog.default_keyword_parent(), None);
|
||||
assert!(s.execute("keyword.setDefaultParent", &json!({"keyword": "lisbon"})).is_err(), "no such keyword");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Edit Keyword: a new name and attributes in one step; attributes not given keep their values.
|
||||
#[test]
|
||||
fn editing_a_keyword_changes_what_it_is_given() {
|
||||
let mut s = Session::with_demo();
|
||||
s.execute("keyword.create", &json!({"name": "Weddings", "synonyms": ["marriage"]})).unwrap();
|
||||
let undo = s.undo.len();
|
||||
s.execute("keyword.edit", &json!({"keyword": "weddings", "name": "Marriages", "includeOnExport": false})).unwrap();
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
let info = s.execute("keyword.info", &json!({"keyword": "Marriages"})).unwrap();
|
||||
assert_eq!((info["includeOnExport"].as_bool(), info["synonyms"].clone()), (Some(false), json!(["marriage"])));
|
||||
assert!(s.execute("keyword.info", &json!({"keyword": "Weddings"})).is_err());
|
||||
}
|
||||
|
||||
/// Move Keyword: inside another or to the top level. Onto a name that is taken it is refused with
|
||||
/// a message saying it would merge, unless `merge: true`.
|
||||
#[test]
|
||||
fn moving_a_keyword_merges_only_when_asked() {
|
||||
let mut s = Session::with_demo();
|
||||
let ids: Vec<u64> = s.visible_cloned().iter().take(2).map(|p| p.0).collect();
|
||||
s.execute("photo.setMeta", &json!({"ids": [ids[0]], "keywords": ["Rome"]})).unwrap();
|
||||
s.execute("photo.setMeta", &json!({"ids": [ids[1]], "keywords": ["Europe|Rome"]})).unwrap();
|
||||
let err = s.execute("keyword.move", &json!({"keyword": "rome", "parent": "europe"})).unwrap_err().to_string();
|
||||
assert!(err.contains("merge"), "{err}");
|
||||
s.execute("keyword.move", &json!({"keyword": "rome", "parent": "europe", "merge": true})).unwrap();
|
||||
assert_eq!(keywords_of(&s, ids[0]), ["Europe|Rome"]);
|
||||
s.execute("keyword.move", &json!({"keyword": "europe|rome", "parent": null})).unwrap();
|
||||
assert_eq!((keywords_of(&s, ids[0]), keywords_of(&s, ids[1])), (vec!["Rome".to_string()], vec!["Rome".to_string()]));
|
||||
assert!(s.execute("keyword.move", &json!({"keyword": "rome"})).is_err(), "`parent` must be said, null for the top level");
|
||||
}
|
||||
|
||||
/// Purge Unused Keywords takes off the list the keywords no photo has, in one undo step.
|
||||
#[test]
|
||||
fn purging_unused_keywords() {
|
||||
let mut s = Session::with_demo();
|
||||
s.execute("keyword.create", &json!({"name": "Weddings"})).unwrap();
|
||||
let r = s.execute("keyword.purgeUnused", &json!({})).unwrap();
|
||||
assert_eq!(r["purged"], 1, "{r}");
|
||||
assert!(s.execute("keyword.info", &json!({"keyword": "Weddings"})).is_err());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.execute("keyword.info", &json!({"keyword": "Weddings"})).is_ok());
|
||||
}
|
||||
|
||||
/// Moving a keyword into a parent that doesn't exist yet makes that parent, and the keyword filter
|
||||
/// follows the keyword there (the grid kept filtering by the old path, and showed nothing).
|
||||
#[test]
|
||||
fn the_filter_follows_a_keyword_moved_into_a_new_parent() {
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.visible_cloned()[0].0;
|
||||
s.execute("photo.setMeta", &json!({"ids": [id], "keywords": ["Rome"]})).unwrap();
|
||||
s.execute("library.filter", &json!({"keyword": "Rome"})).unwrap();
|
||||
s.execute("keyword.move", &json!({"keyword": "rome", "parent": "Italy"})).unwrap();
|
||||
assert_eq!(keywords_of(&s, id), ["Italy|Rome"]);
|
||||
assert_eq!(s.filter.keyword.as_deref(), Some("Italy|Rome"));
|
||||
}
|
||||
|
||||
/// Keywords given to photos are stored cleaned (" beach " is "beach", "Travel | Italy" is
|
||||
/// "Travel|Italy"), so the list's actions find them; one a photo has already, whatever the case of
|
||||
/// any letter, isn't added twice.
|
||||
#[test]
|
||||
fn keywords_given_to_photos_are_stored_cleaned() {
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.visible_cloned()[0].0;
|
||||
s.execute("photo.setMeta", &json!({"ids": [id], "keywords": [" beach ", "Travel | Italy", "Ärzte"]})).unwrap();
|
||||
s.execute("photo.setMeta", &json!({"ids": [id], "addKeywords": [" sea ", "ÄRZTE", " "]})).unwrap();
|
||||
assert_eq!(keywords_of(&s, id), ["beach", "Travel|Italy", "Ärzte", "sea"]);
|
||||
s.execute("photo.setMeta", &json!({"ids": [id], "removeKeywords": ["travel | italy"]})).unwrap();
|
||||
assert_eq!(keywords_of(&s, id), ["beach", "Ärzte", "sea"]);
|
||||
assert!(s.execute("keyword.info", &json!({"keyword": "beach"})).is_ok());
|
||||
}
|
||||
|
||||
/// Rename, merge and delete say how many photos they changed, as before the keyword list: the
|
||||
/// list's own changes aren't photos.
|
||||
#[test]
|
||||
fn keyword_actions_count_the_photos_they_change() {
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.visible_cloned()[0].0;
|
||||
s.execute("keyword.create", &json!({"name": "Weddings", "synonyms": ["marriage"]})).unwrap();
|
||||
assert_eq!(s.execute("keyword.rename", &json!({"from": "Weddings", "to": "Marriages"})).unwrap()["changed"], 0);
|
||||
s.execute("photo.setMeta", &json!({"ids": [id], "addKeywords": ["Marriages"]})).unwrap();
|
||||
assert_eq!(s.execute("keyword.rename", &json!({"from": "Marriages", "to": "Weddings"})).unwrap()["changed"], 1);
|
||||
assert_eq!(s.execute("keyword.delete", &json!({"keyword": "weddings"})).unwrap()["changed"], 1);
|
||||
}
|
||||
|
||||
/// ⌥1–⌥9 take a set's keyword off the selected photos when they all have it, whatever the case of
|
||||
/// any of its letters (the set has "ärzte", the photos "ÄRZTE" and "ärzte").
|
||||
#[test]
|
||||
fn a_set_keyword_toggles_whatever_its_case() {
|
||||
let mut s = Session::with_demo();
|
||||
let ids: Vec<u64> = s.visible_cloned().iter().take(2).map(|p| p.0).collect();
|
||||
s.execute("photo.setMeta", &json!({"ids": [ids[0]], "keywords": ["ÄRZTE"]})).unwrap();
|
||||
s.execute("photo.setMeta", &json!({"ids": [ids[1]], "keywords": ["ärzte"]})).unwrap();
|
||||
s.execute("keyword.saveSet", &json!({"name": "Clinic", "keywords": ["ärzte"]})).unwrap();
|
||||
s.execute("keyword.toggleFromSet", &json!({"index": 1, "ids": ids})).unwrap();
|
||||
assert!(ids.iter().all(|id| keywords_of(&s, *id).is_empty()), "taken off both");
|
||||
}
|
||||
|
||||
fn set_named(s: &mut Session, name: &str) -> serde_json::Value {
|
||||
let sets = s.execute("keyword.sets", &json!({})).unwrap();
|
||||
sets["sets"].as_array().unwrap().iter().find(|x| x["name"] == name).cloned().unwrap_or(serde_json::Value::Null)
|
||||
}
|
||||
|
||||
/// Edit Keyword Set: a set's nine slots keep their places, so each keyword stays on its ⌥ key; an
|
||||
/// empty slot is allowed and does nothing.
|
||||
#[test]
|
||||
fn a_keyword_sets_slots_keep_their_places() {
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.visible_cloned()[0].0;
|
||||
s.execute("keyword.saveSet", &json!({"name": "Weddings", "keywords": [" ceremony ", "", "reception", ""]})).unwrap();
|
||||
assert_eq!(set_named(&mut s, "Weddings")["keywords"], json!(["ceremony", "", "reception"]), "trailing empty slots go");
|
||||
let before = keywords_of(&s, id);
|
||||
let r = s.execute("keyword.toggleFromSet", &json!({"index": 2, "ids": [id]})).unwrap();
|
||||
assert_eq!((r["changed"].as_u64(), keywords_of(&s, id)), (Some(0), before), "⌥2 is empty: nothing");
|
||||
s.execute("keyword.toggleFromSet", &json!({"index": 3, "ids": [id]})).unwrap();
|
||||
assert!(keywords_of(&s, id).contains(&"reception".to_string()), "⌥3 is reception");
|
||||
}
|
||||
|
||||
/// A set holds each keyword once, whatever its case: a second one leaves its slot empty.
|
||||
#[test]
|
||||
fn a_keyword_set_holds_each_keyword_once() {
|
||||
let mut s = Session::with_demo();
|
||||
s.execute("keyword.saveSet", &json!({"name": "Travel", "keywords": ["Lisbon", "harbour", "LISBON"]})).unwrap();
|
||||
assert_eq!(set_named(&mut s, "Travel")["keywords"], json!(["Lisbon", "harbour"]));
|
||||
}
|
||||
|
||||
/// Renaming a set keeps its place among the sets and keeps it current; a name another set has is
|
||||
/// refused.
|
||||
#[test]
|
||||
fn renaming_a_keyword_set_keeps_its_place() {
|
||||
let mut s = Session::with_demo();
|
||||
s.execute("keyword.saveSet", &json!({"name": "Weddings", "keywords": ["ceremony"]})).unwrap();
|
||||
s.execute("keyword.saveSet", &json!({"name": "Travel", "keywords": ["harbour"]})).unwrap();
|
||||
s.execute("keyword.useSet", &json!({"name": "Weddings"})).unwrap();
|
||||
let r = s.execute("keyword.saveSet", &json!({"name": "Ceremonies", "replace": "weddings", "keywords": ["ceremony", "rings"]})).unwrap();
|
||||
let names: Vec<String> = r["sets"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap().to_string()).collect();
|
||||
assert_eq!(names, ["Recent Keywords", "Ceremonies", "Travel"]);
|
||||
assert_eq!(r["current"], "Ceremonies");
|
||||
assert_eq!(r["keywords"], json!(["ceremony", "rings"]));
|
||||
let err = s.execute("keyword.saveSet", &json!({"name": "travel", "replace": "Ceremonies", "keywords": []})).unwrap_err().to_string();
|
||||
assert!(err.contains("already"), "{err}");
|
||||
assert!(s.execute("keyword.saveSet", &json!({"name": "Rings", "replace": "Lisbon", "keywords": []})).is_err(), "no such set");
|
||||
}
|
||||
|
||||
/// Renaming a set without giving its keywords keeps its keywords (not the current set's).
|
||||
#[test]
|
||||
fn renaming_a_set_alone_keeps_its_keywords() {
|
||||
let mut s = Session::with_demo();
|
||||
s.execute("keyword.saveSet", &json!({"name": "Weddings", "keywords": ["ceremony"]})).unwrap();
|
||||
s.execute("keyword.saveSet", &json!({"name": "Travel", "keywords": ["harbour"]})).unwrap();
|
||||
s.execute("keyword.saveSet", &json!({"name": "Ceremonies", "replace": "Weddings"})).unwrap();
|
||||
assert_eq!(set_named(&mut s, "Ceremonies")["keywords"], json!(["ceremony"]));
|
||||
}
|
||||
|
||||
/// A new set (`new: true`, what Edit Set… on Recent Keywords saves) never takes another set's
|
||||
/// name: saving over it silently is refused.
|
||||
#[test]
|
||||
fn a_new_set_doesnt_take_a_sets_name() {
|
||||
let mut s = Session::with_demo();
|
||||
s.execute("keyword.saveSet", &json!({"name": "Travel", "keywords": ["harbour", "lisbon"]})).unwrap();
|
||||
let err = s.execute("keyword.saveSet", &json!({"name": "travel", "new": true, "keywords": ["x"]})).unwrap_err().to_string();
|
||||
assert!(err.contains("already"), "{err}");
|
||||
assert_eq!(set_named(&mut s, "Travel")["keywords"], json!(["harbour", "lisbon"]), "left as it was");
|
||||
}
|
||||
|
||||
/// Set names match whatever the case of any of their letters, for every set command alike.
|
||||
#[test]
|
||||
fn set_names_match_whatever_their_case() {
|
||||
let mut s = Session::with_demo();
|
||||
s.execute("keyword.saveSet", &json!({"name": "ÉTÉ", "keywords": ["sunset"]})).unwrap();
|
||||
s.execute("keyword.useSet", &json!({"name": "Recent Keywords"})).unwrap();
|
||||
s.execute("keyword.useSet", &json!({"name": "été"})).unwrap();
|
||||
assert_eq!(s.execute("keyword.sets", &json!({})).unwrap()["current"], "ÉTÉ");
|
||||
s.execute("keyword.deleteSet", &json!({"name": "été"})).unwrap();
|
||||
assert!(set_named(&mut s, "ÉTÉ").is_null(), "deleted");
|
||||
}
|
||||
|
||||
/// Export Keywords writes the keyword list file, and names the keywords Capture One's importer
|
||||
/// refuses (it allows none of ; , < > in a list); without a path it gives the text.
|
||||
#[test]
|
||||
fn exporting_keywords_writes_the_list() {
|
||||
let dir = temp_dir("export-keywords");
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.visible_cloned()[0].0;
|
||||
s.execute("photo.setMeta", &json!({"ids": [id], "keywords": ["Places|Lisbon", "fish, chips"]})).unwrap();
|
||||
let path = dir.join("keywords.txt");
|
||||
let r = s.execute("keyword.export", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(text.contains("Places\n\tLisbon\n"), "{text}");
|
||||
assert!(r["keywords"].as_u64().unwrap() >= 3, "{r}");
|
||||
assert_eq!(r["captureOneRefuses"], json!(["fish, chips"]), "{r}");
|
||||
assert_eq!(r["unwritable"], json!([]), "{r}");
|
||||
let r = s.execute("keyword.export", &json!({})).unwrap();
|
||||
assert_eq!(r["text"].as_str(), Some(text.as_str()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A keyword the format can't hold is left out of the file and named, so the file reads back.
|
||||
#[test]
|
||||
fn exporting_keywords_names_what_the_list_cant_hold() {
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.visible_cloned()[0].0;
|
||||
s.execute("photo.setMeta", &json!({"ids": [id], "keywords": ["Travel|[draft]", "Travel|Lisbon"]})).unwrap();
|
||||
let before = s.execute("keyword.export", &json!({})).unwrap()["keywords"].as_u64().unwrap();
|
||||
// a name starting with a brace is written, counted and checked for Capture One like any other
|
||||
s.execute("photo.setMeta", &json!({"ids": [id], "addKeywords": ["{draft, notes"]})).unwrap();
|
||||
let r = s.execute("keyword.export", &json!({})).unwrap();
|
||||
assert_eq!(r["keywords"].as_u64(), Some(before + 1), "{r}");
|
||||
assert_eq!(r["captureOneRefuses"], json!(["{draft, notes"]), "{r}");
|
||||
assert_eq!(r["unwritable"], json!(["Travel|[draft]"]), "{r}");
|
||||
let text = r["text"].as_str().unwrap();
|
||||
assert!(text.contains("Travel\n\tLisbon\n") && !text.contains("[draft]"), "{text}");
|
||||
s.execute("keyword.import", &json!({"text": text})).unwrap();
|
||||
}
|
||||
|
||||
/// A list saved as UTF-16 (as Windows' Notepad can) says to save it as UTF-8, and text too big to
|
||||
/// be a keyword list is refused, as a file that big is.
|
||||
#[test]
|
||||
fn importing_keywords_refuses_what_it_cant_read() {
|
||||
let dir = temp_dir("import-keywords-refused");
|
||||
let mut s = Session::with_demo();
|
||||
let utf16: Vec<u8> = [0xff, 0xfe].into_iter().chain("Travel\r\n".encode_utf16().flat_map(u16::to_le_bytes)).collect();
|
||||
std::fs::write(dir.join("travel.txt"), utf16).unwrap();
|
||||
let err = s.execute("keyword.import", &json!({"path": dir.join("travel.txt").to_string_lossy()})).unwrap_err().to_string();
|
||||
assert!(err.contains("UTF-16") && err.contains("UTF-8"), "{err}");
|
||||
let big = "Travel\n".repeat((17 << 20) / 7);
|
||||
let err = s.execute("keyword.import", &json!({"text": big})).unwrap_err().to_string();
|
||||
assert!(err.contains("16 MB"), "{err}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Import Keywords reads a keyword list file (or text) as one undo step and says what it added;
|
||||
/// a file it can't read says which line, and nothing is taken.
|
||||
#[test]
|
||||
fn importing_keywords_reads_a_list() {
|
||||
let dir = temp_dir("import-keywords");
|
||||
let mut s = Session::with_demo();
|
||||
let path = dir.join("vocabulary.utf8");
|
||||
std::fs::write(&path, "[Places]\r\n\tPortugal\r\n\t\tLisbon\r\n").unwrap();
|
||||
let undo = s.undo.len();
|
||||
let r = s.execute("keyword.import", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert_eq!((r["added"].as_u64(), r["updated"].as_u64()), (Some(3), Some(0)), "{r}");
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
assert!(s.catalog.has_keyword("Places|Portugal|Lisbon"));
|
||||
let r = s.execute("keyword.import", &json!({"text": "Events\n\t{celebrations}\n"})).unwrap();
|
||||
assert_eq!(r["added"], 1);
|
||||
let err = s.execute("keyword.import", &json!({"text": "Events\n\t\tWeddings\n"})).unwrap_err().to_string();
|
||||
assert!(err.contains("line 2"), "{err}");
|
||||
std::fs::write(dir.join("bad.txt"), [0xff, 0xfe, 0x00, 0x41]).unwrap();
|
||||
assert!(s.execute("keyword.import", &json!({"path": dir.join("bad.txt").to_string_lossy()})).is_err(), "not UTF-8: refused");
|
||||
assert!(s.execute("keyword.import", &json!({})).is_err(), "a path or text");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -355,9 +355,9 @@ fn label_sets_and_xmp_label_names() {
|
||||
p.label = Some(ColorLabel::Green);
|
||||
let x = crate::sidecar::sidecar_packet(&p, &s.catalog);
|
||||
assert!(x.contains("Approved"), "{x}");
|
||||
let sc = crate::sidecar::parse_sidecar(&x, false).unwrap().resolve_label(&s.catalog);
|
||||
let sc = crate::sidecar::parse_sidecar(&x, crate::crs::Target::Rendered).unwrap().resolve_label(&s.catalog);
|
||||
assert_eq!(sc.label, Some(Some(ColorLabel::Green)));
|
||||
let plain = crate::sidecar::parse_sidecar(&x.replace("Approved", "Blue"), false).unwrap().resolve_label(&s.catalog);
|
||||
let plain = crate::sidecar::parse_sidecar(&x.replace("Approved", "Blue"), crate::crs::Target::Rendered).unwrap().resolve_label(&s.catalog);
|
||||
assert_eq!(plain.label, Some(Some(ColorLabel::Blue)), "colour names still work");
|
||||
s.execute("label.deleteSet", &json!({"name": "Studio"})).unwrap();
|
||||
assert!(s.execute("label.deleteSet", &json!({"name": "Review"})).is_err(), "built-ins stay");
|
||||
@@ -551,3 +551,41 @@ fn choosing_random_starts_a_fresh_shuffle() {
|
||||
assert_eq!(s.sort.seed, 42, "an explicit seed is honoured");
|
||||
assert!(s.sort.seed < 1 << 53);
|
||||
}
|
||||
|
||||
fn capture_time_raw_metadata_session(raw: Option<&str>) -> Session {
|
||||
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
|
||||
let mut s = Session::new();
|
||||
let id = PhotoId(1);
|
||||
let mut p = Photo::new(id, Source::Demo { scene: 0 }, "one.jpg", "JPEG", 4, 3, "2026-04-01T00:00:00");
|
||||
p.captured = raw.map(str::to_string);
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
s.selection = crate::Selection::single(id);
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_time_invalid_calendar_anchor_keeps_other_targets_and_redo() {
|
||||
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
|
||||
for raw in ["2026-02-30T12:00:00", "2026x03x01T12:00:00"] {
|
||||
let mut s = capture_time_raw_metadata_session(Some(raw));
|
||||
let peer = PhotoId(2);
|
||||
let mut p = Photo::new(peer, Source::Demo { scene: 0 }, "peer.jpg", "JPEG", 4, 3, "2026-10-09T00:00:00");
|
||||
p.captured = Some("2026-03-01T12:00:00.25+02:00".into());
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
s.commit("Earlier rating", Op::SetRating { id: peer, rating: 4 }).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.selection.ids = vec![PhotoId(1), peer];
|
||||
s.selection.active = Some(PhotoId(1));
|
||||
let before = s.catalog.clone();
|
||||
let selection = s.selection.clone();
|
||||
let undo: Vec<_> = s.undo.iter().map(|e| (e.label.clone(), e.op.clone())).collect();
|
||||
let redo: Vec<_> = s.redo.iter().map(|e| (e.label.clone(), e.op.clone())).collect();
|
||||
let log = s.pending_log.clone();
|
||||
assert!(s.execute("photo.setCaptureTime", &json!({"time": "2026-03-03T12:00:00"})).is_err(), "{raw}");
|
||||
assert_eq!(s.catalog, before);
|
||||
assert_eq!(s.selection, selection);
|
||||
assert_eq!(s.undo.iter().map(|e| (e.label.clone(), e.op.clone())).collect::<Vec<_>>(), undo);
|
||||
assert_eq!(s.redo.iter().map(|e| (e.label.clone(), e.op.clone())).collect::<Vec<_>>(), redo);
|
||||
assert_eq!(s.pending_log, log);
|
||||
}
|
||||
}
|
||||
@@ -252,11 +252,43 @@ fn smart_album_rule_sets() {
|
||||
s.clock = Box::new(|| "2026-10-02T12:00:00".to_string());
|
||||
let fields = s.execute("album.ruleFields", &serde_json::json!({})).unwrap();
|
||||
assert!(fields.as_array().unwrap().iter().any(|f| f["field"] == "keywords" && f["ops"].as_array().unwrap().len() > 3));
|
||||
// each field names its field-menu group (null at the top level)
|
||||
let group = |id: &str| fields.as_array().unwrap().iter().find(|f| f["field"] == id).map(|f| f["group"].clone());
|
||||
assert_eq!(group("filePath"), Some(serde_json::json!("File")));
|
||||
assert_eq!(group("rating"), Some(serde_json::Value::Null));
|
||||
// choices stay the ids rules store; choiceLabels maps them to what people read
|
||||
let flag = fields.as_array().unwrap().iter().find(|f| f["field"] == "copyrightStatus").unwrap();
|
||||
assert_eq!(flag["choices"], serde_json::json!(["copyrighted", "publicDomain", "unknown"]));
|
||||
assert_eq!(flag["choiceLabels"]["publicDomain"], "Public Domain");
|
||||
let album = fields.as_array().unwrap().iter().find(|f| f["field"] == "album").unwrap();
|
||||
assert_eq!((album["kind"].as_str(), album["ops"].as_array().map(Vec::len)), (Some("album"), Some(2)), "an album id, is / isn't");
|
||||
let bad = s.execute(
|
||||
"album.createSmart",
|
||||
&serde_json::json!({"name": "Bad", "rules": {"ruleSet": {"rules": [{"field": "rating", "op": "contains", "value": 1}]}}}),
|
||||
);
|
||||
assert!(bad.is_err(), "operators are checked");
|
||||
// yes/no values: "false" means no, anything unreadable is an error rather than a silent yes
|
||||
let maybe = s.execute(
|
||||
"album.createSmart",
|
||||
&serde_json::json!({"name": "Maybe", "rules": {"ruleSet": {"rules": [{"field": "edited", "op": "is", "value": "maybe"}]}}}),
|
||||
);
|
||||
assert!(maybe.is_err_and(|e| e.to_string().contains("yes or no")), "unreadable yes/no value");
|
||||
// the library filter checks a rule set the same way, instead of quietly showing nothing
|
||||
for rules in [serde_json::json!([{"field": "edited", "op": "is", "value": "maybe"}]), serde_json::json!([{"field": "nope", "op": "is"}])] {
|
||||
let r = s.execute("library.filter", &serde_json::json!({"ruleSet": {"rules": rules}}));
|
||||
assert!(r.is_err(), "{rules}");
|
||||
}
|
||||
assert!(s.filter.rule_set.is_none(), "a refused filter isn't applied");
|
||||
s.execute("library.filter", &serde_json::json!({"ruleSet": {"rules": [{"field": "edited", "op": "is", "value": "no"}]}})).unwrap();
|
||||
s.execute("library.filter", &serde_json::json!({"ruleSet": null})).unwrap();
|
||||
let r = s
|
||||
.execute(
|
||||
"album.createSmart",
|
||||
&serde_json::json!({"name": "Unedited", "rules": {"ruleSet": {"rules": [{"field": "edited", "op": "is", "value": "false"}]}}}),
|
||||
)
|
||||
.unwrap();
|
||||
let unedited = s.catalog.photos().filter(|p| !p.deleted && !p.is_edited()).count();
|
||||
assert_eq!(r["count"].as_u64(), Some(unedited as u64));
|
||||
let r = s
|
||||
.execute(
|
||||
"album.createSmart",
|
||||
@@ -440,3 +472,206 @@ fn a_library_open_elsewhere_is_refused() {
|
||||
assert_eq!(other.catalog.to_snapshot(), expect);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A capture-time shift from an agent can't overflow or write a date no one can read back: a
|
||||
/// shift of more than 10,000 years is refused and nothing changes; an ordinary one still works.
|
||||
#[test]
|
||||
fn capture_time_shift_is_bounded() {
|
||||
let mut s = crate::Session::with_demo();
|
||||
let id = s.catalog.photos().next().unwrap().id;
|
||||
let before = s.catalog.photo(id).unwrap().captured.clone();
|
||||
for p in [
|
||||
serde_json::json!({"ids": [id.0], "shift": 1e30}),
|
||||
serde_json::json!({"ids": [id.0], "hours": -1e15}),
|
||||
serde_json::json!({"ids": [id.0], "shift": 9.3e18}),
|
||||
] {
|
||||
let r = s.execute("photo.setCaptureTime", &p);
|
||||
// a real refusal, not the panic guard catching an overflow ("failed unexpectedly")
|
||||
assert!(r.as_ref().is_err_and(|e| e.to_string().contains("10,000 years")), "{p}: {r:?}");
|
||||
assert_eq!(s.catalog.photo(id).unwrap().captured, before, "{p} changed nothing");
|
||||
}
|
||||
s.execute("photo.setCaptureTime", &serde_json::json!({"ids": [id.0], "hours": 1})).unwrap();
|
||||
assert_ne!(s.catalog.photo(id).unwrap().captured, before);
|
||||
// a shift that would take a photo past year 9999 says so, rather than skipping it quietly
|
||||
s.execute("photo.setCaptureTime", &serde_json::json!({"ids": [id.0], "time": "9999-12-31T12:00:00"})).unwrap();
|
||||
let r = s.execute("photo.setCaptureTime", &serde_json::json!({"ids": [id.0], "hours": 24}));
|
||||
assert!(r.as_ref().is_err_and(|e| e.to_string().contains("9999")), "{r:?}");
|
||||
assert_eq!(s.catalog.photo(id).unwrap().captured.as_deref(), Some("9999-12-31T12:00:00"), "unchanged");
|
||||
}
|
||||
|
||||
/// Values are checked too: an agent's rule set with a date that isn't one, or a rating of 9, is
|
||||
/// refused with the rule's position, by album.createSmart and library.filter alike.
|
||||
#[test]
|
||||
fn smart_rule_values_are_checked_by_commands() {
|
||||
let mut s = crate::Session::with_demo();
|
||||
let r = s.execute(
|
||||
"album.createSmart",
|
||||
&serde_json::json!({"name": "Odd", "rules": {"ruleSet": {"rules": [
|
||||
{"field": "rating", "op": "gte", "value": 3},
|
||||
{"field": "captureDate", "op": "is", "value": "banana"}
|
||||
]}}}),
|
||||
);
|
||||
let e = r.expect_err("a date that isn't one").to_string();
|
||||
assert!(e.contains("rule 2:") && e.contains("needs a date"), "{e}");
|
||||
assert!(s.catalog.albums().all(|a| a.name != "Odd"));
|
||||
let e = s
|
||||
.execute("library.filter", &serde_json::json!({"ruleSet": {"rules": [{"field": "rating", "op": "gte", "value": 9}]}}))
|
||||
.expect_err("rating 9")
|
||||
.to_string();
|
||||
assert!(e.contains("rule 1: no rating 0–5 is ≥ 9"), "{e}");
|
||||
}
|
||||
|
||||
/// A smart album saved with an old album operator (≥, from when Album was a number field) can
|
||||
/// still be edited: its rule is read as the "isn't" it always meant.
|
||||
#[test]
|
||||
fn old_album_operator_rules_stay_editable() {
|
||||
use lightcraft_catalog::{Album, AlbumId, Op};
|
||||
let mut s = crate::Session::with_demo();
|
||||
let trip = s.execute("album.create", &serde_json::json!({"name": "Trip", "addSelected": false})).unwrap()["id"].as_u64().unwrap();
|
||||
let rules: lightcraft_catalog::Filter =
|
||||
serde_json::from_value(serde_json::json!({"ruleSet": {"rules": [{"field": "album", "op": "gte", "value": trip}]}})).unwrap();
|
||||
let id = s.catalog.alloc_album_id();
|
||||
s.catalog.apply(Op::AddAlbum { album: Album { smart: Some(Box::new(rules)), ..Album::new(id, "Old") } }).unwrap();
|
||||
s.execute("album.setRules", &serde_json::json!({"id": id.0, "rules": {"rating": 2}})).unwrap();
|
||||
let saved = serde_json::to_string(&s.catalog.album(AlbumId(id.0)).unwrap().smart).unwrap();
|
||||
assert!(saved.contains(r#""op":"isNot""#), "{saved}");
|
||||
s.execute("library.filter", &serde_json::json!({"ruleSet": {"rules": [{"field": "album", "op": "lte", "value": trip}]}})).unwrap();
|
||||
}
|
||||
|
||||
/// Renaming and changing the rules is one step: both or neither, undone together.
|
||||
#[test]
|
||||
fn set_rules_with_a_name_is_one_step() {
|
||||
let mut s = crate::Session::with_demo();
|
||||
let id = s.execute("album.createSmart", &serde_json::json!({"name": "Old", "rules": {"rating": 3}})).unwrap()["id"].as_u64().unwrap();
|
||||
let album = |s: &crate::Session| s.catalog.album(lightcraft_catalog::AlbumId(id)).unwrap().clone();
|
||||
let good = serde_json::json!({"ruleSet": {"rules": [{"field": "rating", "op": "gte", "value": 4}]}});
|
||||
let bad = serde_json::json!({"ruleSet": {"rules": [{"field": "captureDate", "op": "is", "value": "banana"}]}});
|
||||
// refused rules: the name stays too
|
||||
assert!(s.execute("album.setRules", &serde_json::json!({"id": id, "name": "New", "replace": true, "rules": bad})).is_err());
|
||||
assert_eq!(album(&s).name, "Old");
|
||||
let undo = s.undo.len();
|
||||
s.execute("album.setRules", &serde_json::json!({"id": id, "name": "New", "replace": true, "rules": good})).unwrap();
|
||||
assert_eq!((album(&s).name.as_str(), s.undo.len()), ("New", undo + 1), "one undo step");
|
||||
assert!(album(&s).smart.unwrap().rule_set.is_some());
|
||||
s.execute("edit.undo", &serde_json::json!({})).unwrap();
|
||||
assert_eq!(album(&s).name, "Old");
|
||||
assert!(album(&s).smart.unwrap().rule_set.is_none(), "undone together");
|
||||
// a blank name keeps the album's
|
||||
s.execute("album.setRules", &serde_json::json!({"id": id, "name": " ", "rules": {"rating": 5}})).unwrap();
|
||||
assert_eq!(album(&s).name, "Old");
|
||||
}
|
||||
|
||||
/// albums.list says which smart albums have rules that no longer check, and why.
|
||||
#[test]
|
||||
fn album_list_reports_problems() {
|
||||
let mut s = crate::Session::with_demo();
|
||||
let trip = s.execute("album.create", &serde_json::json!({"name": "Trip", "addSelected": false})).unwrap()["id"].as_u64().unwrap();
|
||||
let rules = serde_json::json!({"ruleSet": {"rules": [{"field": "album", "op": "is", "value": trip}]}});
|
||||
let id = s.execute("album.createSmart", &serde_json::json!({"name": "In Trip", "rules": rules})).unwrap()["id"].as_u64().unwrap();
|
||||
let find = |s: &mut crate::Session| {
|
||||
let list = s.execute("albums.list", &serde_json::json!({})).unwrap();
|
||||
list.as_array().unwrap().iter().find(|a| a["id"] == id).cloned().unwrap()
|
||||
};
|
||||
assert_eq!(find(&mut s)["problems"], serde_json::json!([]));
|
||||
s.execute("album.delete", &serde_json::json!({"id": trip})).unwrap();
|
||||
assert_eq!(find(&mut s)["problems"], serde_json::json!([format!("rule 1: no album {trip}")]));
|
||||
}
|
||||
|
||||
/// "Travel" = keywords contain travel, but not in "Excluded Photos" (red or rejected): the counts
|
||||
/// follow, and Excluded Photos can't then test Travel back (a loop).
|
||||
#[test]
|
||||
fn smart_album_excluding_a_smart_album() {
|
||||
let mut s = crate::Session::with_demo();
|
||||
let excluded = s
|
||||
.execute(
|
||||
"album.createSmart",
|
||||
&serde_json::json!({"name": "Excluded Photos", "rules": {"ruleSet": {"match": "any", "rules": [
|
||||
{"field": "label", "op": "is", "value": "red"}, {"field": "flag", "op": "is", "value": "reject"}]}}}),
|
||||
)
|
||||
.unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let keyword = s.catalog.photos().flat_map(|p| p.meta.keywords.clone()).next().expect("a demo keyword");
|
||||
let travel = s
|
||||
.execute(
|
||||
"album.createSmart",
|
||||
&serde_json::json!({"name": "Travel", "rules": {"ruleSet": {"rules": [
|
||||
{"field": "keywords", "op": "contains", "value": keyword}, {"field": "album", "op": "isNot", "value": excluded}]}}}),
|
||||
)
|
||||
.unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let count = |s: &crate::Session| s.catalog.album_count(lightcraft_catalog::AlbumId(travel));
|
||||
let want = |s: &crate::Session| {
|
||||
s.catalog
|
||||
.photos()
|
||||
.filter(|p| !p.deleted && p.meta.keywords.iter().any(|k| k.to_lowercase().contains(&keyword.to_lowercase())))
|
||||
.filter(|p| p.label != Some(lightcraft_catalog::ColorLabel::Red) && p.flag != lightcraft_catalog::Flag::Reject)
|
||||
.count()
|
||||
};
|
||||
assert_eq!(count(&s), want(&s));
|
||||
// reject one of Travel's photos: it leaves Travel
|
||||
let before = count(&s);
|
||||
let id = s.catalog.photos().find(|p| s.catalog.album_contains(lightcraft_catalog::AlbumId(travel), p)).map(|p| p.id).expect("a travel photo");
|
||||
s.execute("photo.flag", &serde_json::json!({"ids": [id.0], "flag": "reject"})).unwrap();
|
||||
assert_eq!(count(&s), before - 1);
|
||||
// Excluded Photos testing Travel back would include itself
|
||||
let looped = s.execute(
|
||||
"album.setRules",
|
||||
&serde_json::json!({"id": excluded, "rules": {"ruleSet": {"rules": [{"field": "album", "op": "is", "value": travel}]}}, "replace": true}),
|
||||
);
|
||||
assert!(looped.is_err_and(|e| e.to_string().contains("would make this album include itself")), "a loop is refused");
|
||||
// agents read the album by name in the summary
|
||||
let list = s.execute("albums.list", &serde_json::json!({})).unwrap();
|
||||
let travel_json = list.as_array().unwrap().iter().find(|a| a["id"] == travel).cloned().unwrap();
|
||||
assert!(travel_json["rulesText"].as_str().unwrap_or("").contains("album isn't “Excluded Photos”"), "{travel_json}");
|
||||
}
|
||||
|
||||
/// "Update Rules from Current Filter" refuses a loop too: showing A ("Album isn't B") and
|
||||
/// updating B from that view would make B test itself.
|
||||
#[test]
|
||||
fn rules_from_the_view_cant_loop() {
|
||||
let mut s = crate::Session::with_demo();
|
||||
let b = s.execute("album.createSmart", &serde_json::json!({"name": "B", "rules": {"rating": 2}})).unwrap()["id"].as_u64().unwrap();
|
||||
let rules = serde_json::json!({"ruleSet": {"rules": [{"field": "album", "op": "isNot", "value": b}]}});
|
||||
let a = s.execute("album.createSmart", &serde_json::json!({"name": "A", "rules": rules})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("library.source", &serde_json::json!({"kind": "album", "id": a})).unwrap();
|
||||
let before = s.catalog.album(lightcraft_catalog::AlbumId(b)).unwrap().smart.clone();
|
||||
let r = s.execute("album.setRules", &serde_json::json!({"id": b, "fromView": true}));
|
||||
assert!(r.is_err_and(|e| e.to_string().contains("would make this album include itself")), "refused");
|
||||
assert_eq!(s.catalog.album(lightcraft_catalog::AlbumId(b)).unwrap().smart, before, "B unchanged");
|
||||
assert!(s.catalog.smart_album_problems(lightcraft_catalog::AlbumId(b)).is_empty());
|
||||
}
|
||||
|
||||
/// A rule that stops checking later (the album it tests is deleted) doesn't lock up what doesn't
|
||||
/// touch it: the filter bar keeps working, and an album's other settings can still be edited. A
|
||||
/// change to the rules themselves is still checked.
|
||||
#[test]
|
||||
fn a_stale_rule_doesnt_block_other_changes() {
|
||||
let mut s = crate::Session::with_demo();
|
||||
let trip = s.execute("album.create", &serde_json::json!({"name": "Trip", "addSelected": false})).unwrap()["id"].as_u64().unwrap();
|
||||
let rules = serde_json::json!({"rules": [{"field": "album", "op": "isNot", "value": trip}]});
|
||||
s.execute("library.filter", &serde_json::json!({"ruleSet": rules})).unwrap();
|
||||
let smart =
|
||||
s.execute("album.createSmart", &serde_json::json!({"name": "Not Trip", "rules": {"ruleSet": rules}})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("album.delete", &serde_json::json!({"id": trip})).unwrap();
|
||||
// the filter bar: a rating still applies on top of the stale rule
|
||||
s.execute("library.filter", &serde_json::json!({"rating": 3})).unwrap();
|
||||
assert_eq!(s.filter.rating, 3);
|
||||
// the album: a partial edit that leaves its rules alone
|
||||
s.execute("album.setRules", &serde_json::json!({"id": smart, "rules": {"rating": 2}})).unwrap();
|
||||
// changing the rules is still checked
|
||||
let bad = serde_json::json!({"ruleSet": {"rules": [{"field": "rating", "op": "gte", "value": 9}]}});
|
||||
assert!(s.execute("library.filter", &bad).is_err());
|
||||
assert!(s.execute("album.setRules", &serde_json::json!({"id": smart, "rules": bad})).is_err());
|
||||
}
|
||||
|
||||
/// album.setRules refuses a loop through the album filter as well as through the rules.
|
||||
#[test]
|
||||
fn an_album_filter_loop_is_refused() {
|
||||
let mut s = crate::Session::with_demo();
|
||||
let a = s.execute("album.createSmart", &serde_json::json!({"name": "A", "rules": {"rating": 2}})).unwrap()["id"].as_u64().unwrap();
|
||||
let r = s.execute("album.setRules", &serde_json::json!({"id": a, "rules": {"album": a}}));
|
||||
assert!(r.is_err_and(|e| e.to_string().contains("include itself")), "refused");
|
||||
assert_eq!(s.catalog.album(lightcraft_catalog::AlbumId(a)).unwrap().smart.as_ref().and_then(|f| f.album), None);
|
||||
}
|
||||
@@ -79,6 +79,22 @@ fn smart_album_from_smart_album_view_keeps_its_rules() {
|
||||
assert_eq!(s.source, LibrarySource::Album(lightcraft_catalog::AlbumId(id)));
|
||||
}
|
||||
|
||||
/// Saving the view of a smart album keeps the filter bar's settings even when the album holds a
|
||||
/// rule that no longer checks (it tests an album that has since been deleted).
|
||||
#[test]
|
||||
fn smart_album_view_keeps_the_filter_bar_over_a_stale_rule() {
|
||||
let mut s = Session::with_demo();
|
||||
let gone = s.execute("album.create", &json!({"name": "Gone", "addSelected": false})).unwrap()["id"].as_u64().unwrap();
|
||||
let rules = json!({"ruleSet": {"rules": [{"field": "album", "op": "isNot", "value": gone}]}});
|
||||
let id = s.execute("album.createSmart", &json!({"name": "Not in Gone", "rules": rules})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("album.delete", &json!({"id": gone})).unwrap();
|
||||
s.execute("library.source", &json!({"kind": "album", "id": id})).unwrap();
|
||||
s.execute("library.filter", &json!({"rating": 3})).unwrap();
|
||||
let r = s.view_rules();
|
||||
assert_eq!(r.rating, 3, "the filter bar's rating survives");
|
||||
assert!(r.rule_set.is_some(), "and so do the album's own rules");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn virtual_copies_are_independent_and_persist() {
|
||||
let dir = temp_dir("vc");
|
||||
@@ -256,3 +272,44 @@ fn random_sort_persists_with_the_view() {
|
||||
assert_eq!(s2.sort.seed, 123_456_789);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Issue #501: deleting a populated folder is one reversible, durable catalog edit.
|
||||
#[test]
|
||||
fn deleting_an_album_folder_preserves_photos_and_restores_its_subtree() {
|
||||
use lightcraft_catalog::AlbumId;
|
||||
let dir = temp_dir("delete-folder");
|
||||
let mut s = open(&dir);
|
||||
let make = |s: &mut Session, p| s.execute("album.create", &p).unwrap()["id"].as_u64().unwrap();
|
||||
let outer = make(&mut s, json!({"name": "Trips", "folder": true}));
|
||||
let nested = make(&mut s, json!({"name": "Europe", "folder": true, "parent": outer}));
|
||||
let album = make(&mut s, json!({"name": "Best", "parent": nested, "addSelected": true}));
|
||||
let sibling = make(&mut s, json!({"name": "Kept", "addSelected": true}));
|
||||
let smart = s.execute("album.createSmart", &json!({"name": "Rated", "parent": outer, "rules": {"rating": 4}})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("album.reorder", &json!({"id": album, "parent": nested})).unwrap();
|
||||
s.execute("library.source", &json!({"kind": "album", "id": album})).unwrap();
|
||||
let before = s.catalog.to_snapshot();
|
||||
let photos = serde_json::to_value(s.catalog.photos().collect::<Vec<_>>()).unwrap();
|
||||
let undo_before = s.undo.len();
|
||||
s.execute("album.delete", &json!({"id": outer})).unwrap();
|
||||
for id in [outer, nested, album, smart] {
|
||||
assert!(s.catalog.album(AlbumId(id)).is_none());
|
||||
}
|
||||
assert!(s.catalog.album(AlbumId(sibling)).is_some());
|
||||
assert_eq!(serde_json::to_value(s.catalog.photos().collect::<Vec<_>>()).unwrap(), photos);
|
||||
assert_eq!(s.source, LibrarySource::All, "a deleted descendant cannot remain the source");
|
||||
assert_eq!(s.undo.len(), undo_before + 1);
|
||||
let deleted = s.catalog.to_snapshot();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.catalog.to_snapshot(), before, "all album fields and memberships restored");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.catalog.to_snapshot(), deleted);
|
||||
let undo_after = s.undo.len();
|
||||
assert!(s.execute("album.delete", &json!({"id": outer})).is_err());
|
||||
assert_eq!(s.catalog.to_snapshot(), deleted, "missing id leaves the catalog unchanged");
|
||||
assert_eq!(s.undo.len(), undo_after);
|
||||
drop(s);
|
||||
let reopened = open(&dir);
|
||||
assert_eq!(reopened.catalog.to_snapshot(), deleted, "journal replay preserves the deletion");
|
||||
drop(reopened);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
//! Process versions through the commands (`docs/process-versions.md`): new photos and Reset get
|
||||
//! the latest process, Update to Current Process is one undo step, copies inherit, copy / paste /
|
||||
//! sync / presets / Auto Sync keep each photo's own, and numbers this build doesn't know survive a
|
||||
//! save and a sidecar round trip.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use lightcraft_catalog::{Photo, PhotoId, Source};
|
||||
use lightcraft_develop::{DevelopSettings, ProcessVersion};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
/// No process is older than V1 yet: 0 (which no LightCraft writes) stands in for one.
|
||||
const OLDER: ProcessVersion = ProcessVersion(0);
|
||||
/// A number from a newer LightCraft.
|
||||
const NEWER: ProcessVersion = ProcessVersion(ProcessVersion::LATEST.0 + 6);
|
||||
|
||||
fn process(s: &Session, id: PhotoId) -> ProcessVersion {
|
||||
s.develop_of(id).unwrap().process
|
||||
}
|
||||
|
||||
/// Put photo `id` on process `p` (with exposure `ev`) without going through a command.
|
||||
fn put(s: &mut Session, id: PhotoId, p: ProcessVersion, ev: f64) {
|
||||
let mut d = (*s.develop_of(id).unwrap()).clone();
|
||||
d.process = p;
|
||||
d.light.exposure = ev;
|
||||
s.set_develop(id, d, "test").unwrap();
|
||||
}
|
||||
|
||||
fn select(s: &mut Session, ids: &[PhotoId]) {
|
||||
s.execute("library.select", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
|
||||
}
|
||||
|
||||
fn update_info(s: &Session) -> crate::CommandInfo {
|
||||
s.commands().into_iter().find(|c| c.id == "develop.updateProcess").unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_photos_and_reset_get_the_latest_process() {
|
||||
let mut s = Session::with_demo();
|
||||
assert!(s.catalog.photos().all(|p| p.develop.process == ProcessVersion::LATEST));
|
||||
let ids: Vec<PhotoId> = s.visible_cloned().into_iter().take(2).collect();
|
||||
put(&mut s, ids[0], OLDER, 0.7);
|
||||
put(&mut s, ids[1], NEWER, -0.3);
|
||||
// a section or a slider reset leaves the process alone
|
||||
select(&mut s, &ids[1..]);
|
||||
s.execute("develop.resetSection", &json!({"section": "light"})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.2})).unwrap();
|
||||
s.execute("develop.resetControl", &json!({"control": "light.exposure"})).unwrap();
|
||||
let d = s.develop_of(ids[1]).unwrap();
|
||||
assert_eq!((d.process, d.light.exposure), (NEWER, 0.0));
|
||||
// a full reset is a fresh start on the latest process, also from a newer one
|
||||
select(&mut s, &ids);
|
||||
s.execute("develop.reset", &json!({})).unwrap();
|
||||
for id in &ids {
|
||||
assert_eq!(process(&s, *id), ProcessVersion::LATEST);
|
||||
assert!(!s.catalog.photo(*id).unwrap().is_edited());
|
||||
}
|
||||
// a photo whose default preset's look was recorded on an older process
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(process(&s, ids[0]), OLDER);
|
||||
let mut p = (**s.catalog.photo(ids[0]).unwrap()).clone();
|
||||
let mut look = p.camera_defaults();
|
||||
look.light.contrast = 20.0;
|
||||
look.process = OLDER;
|
||||
p.import_look = Some(Arc::new(look));
|
||||
s.catalog.apply(lightcraft_catalog::Op::RemovePhoto { id: p.id }).unwrap();
|
||||
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
s.execute("develop.reset", &json!({"ids": [ids[0].0]})).unwrap();
|
||||
let d = s.develop_of(ids[0]).unwrap();
|
||||
assert_eq!((d.process, d.light.contrast), (ProcessVersion::LATEST, 20.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_to_current_process_is_one_undo_step_and_only_when_needed() {
|
||||
let mut s = Session::with_demo();
|
||||
let ids: Vec<PhotoId> = s.visible_cloned().into_iter().take(3).collect();
|
||||
select(&mut s, &ids);
|
||||
// everything is current: disabled, with the reason
|
||||
let info = update_info(&s);
|
||||
assert!(!info.enabled && info.disabled_reason.as_deref().is_some_and(|r| r.contains("already on the current process")), "{info:?}");
|
||||
assert!(matches!(s.execute("develop.updateProcess", &json!({})), Err(EngineError::Disabled(..))));
|
||||
// in the Photo menu
|
||||
assert_eq!(info.menu, vec!["Photo"]);
|
||||
|
||||
put(&mut s, ids[0], OLDER, 0.5);
|
||||
put(&mut s, ids[1], OLDER, -0.5);
|
||||
put(&mut s, ids[2], NEWER, 0.25);
|
||||
select(&mut s, &ids);
|
||||
assert!(update_info(&s).enabled);
|
||||
let undo = s.undo.len();
|
||||
let r = s.execute("develop.updateProcess", &json!({})).unwrap();
|
||||
assert_eq!(r, json!({"changed": 2, "process": ProcessVersion::LATEST.0}));
|
||||
assert_eq!(s.undo.len(), undo + 1, "one undo step");
|
||||
assert_eq!(s.undo.last().unwrap().label, "Update to Current Process");
|
||||
assert_eq!([process(&s, ids[0]), process(&s, ids[1])], [ProcessVersion::LATEST; 2]);
|
||||
assert_eq!(process(&s, ids[2]), NEWER, "a newer process is never downgraded");
|
||||
assert_eq!(s.develop_of(ids[0]).unwrap().light.exposure, 0.5, "the sliders keep their values");
|
||||
assert_eq!(s.catalog.photo(ids[1]).unwrap().history.last().unwrap().label, "Update to Current Process");
|
||||
assert!(!update_info(&s).enabled, "nothing left to update");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!([process(&s, ids[0]), process(&s, ids[1])], [OLDER; 2]);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!([process(&s, ids[0]), process(&s, ids[1])], [ProcessVersion::LATEST; 2]);
|
||||
// a photo on a newer process alone: nothing to update
|
||||
select(&mut s, &ids[2..]);
|
||||
assert!(!update_info(&s).enabled);
|
||||
// the active photo when nothing else is selected
|
||||
put(&mut s, ids[0], OLDER, 0.5);
|
||||
s.selection = crate::Selection { ids: Vec::new(), active: Some(ids[0]) };
|
||||
assert!(update_info(&s).enabled);
|
||||
assert_eq!(s.execute("develop.updateProcess", &json!({})).unwrap()["changed"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn virtual_copies_inherit_the_process() {
|
||||
let mut s = Session::with_demo();
|
||||
let src = s.visible_cloned()[0];
|
||||
put(&mut s, src, NEWER, 0.4);
|
||||
select(&mut s, &[src]);
|
||||
let r = s.execute("photo.virtualCopy", &json!({})).unwrap();
|
||||
let copy = PhotoId(r["ids"][0].as_u64().unwrap());
|
||||
assert_eq!(process(&s, copy), NEWER);
|
||||
assert!(s.develop_of(copy).unwrap() == s.develop_of(src).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_paste_sync_presets_and_auto_sync_keep_each_photos_process() {
|
||||
let mut s = Session::with_demo();
|
||||
let ids: Vec<PhotoId> = s.visible_cloned().into_iter().take(3).collect();
|
||||
let (src, a, b) = (ids[0], ids[1], ids[2]);
|
||||
put(&mut s, src, NEWER, 1.0);
|
||||
put(&mut s, b, OLDER, 0.0);
|
||||
select(&mut s, &[src]);
|
||||
s.execute("develop.copy", &json!({"groups": lightcraft_develop::SettingsGroup::ALL})).unwrap();
|
||||
select(&mut s, &[a, b]);
|
||||
s.execute("develop.paste", &json!({})).unwrap();
|
||||
assert_eq!([process(&s, a), process(&s, b)], [ProcessVersion::LATEST, OLDER]);
|
||||
assert_eq!(s.develop_of(b).unwrap().light.exposure, 1.0, "the values are pasted");
|
||||
// sync from the active photo
|
||||
put(&mut s, src, NEWER, -1.0);
|
||||
s.selection = crate::Selection { ids: vec![src, a, b], active: Some(src) };
|
||||
s.execute("develop.sync", &json!({})).unwrap();
|
||||
assert_eq!([process(&s, a), process(&s, b)], [ProcessVersion::LATEST, OLDER]);
|
||||
assert_eq!(s.develop_of(a).unwrap().light.exposure, -1.0);
|
||||
// a preset made from the photo
|
||||
select(&mut s, &[src]);
|
||||
// (not Grain: its integer seed doesn't survive scaling, a separate issue)
|
||||
let groups: Vec<_> = lightcraft_develop::SettingsGroup::ALL.into_iter().filter(|g| *g != lightcraft_develop::SettingsGroup::Grain).collect();
|
||||
let pid = s.execute("preset.create", &json!({"name": "Everything", "groups": groups})).unwrap()["id"].clone();
|
||||
put(&mut s, b, OLDER, 0.0);
|
||||
select(&mut s, &[b]);
|
||||
s.execute("preset.apply", &json!({"id": pid, "amount": 50})).unwrap();
|
||||
assert_eq!(process(&s, b), OLDER);
|
||||
assert_eq!(s.develop_of(b).unwrap().light.exposure, -0.5, "the preset applied (at 50 %)");
|
||||
// Auto Sync carries an edit of the active photo, not its process
|
||||
s.selection = crate::Selection { ids: vec![src, a], active: Some(src) };
|
||||
s.execute("develop.autoSync", &json!({"on": true})).unwrap();
|
||||
s.execute("develop.merge", &json!({"settings": {"process": OLDER.0, "light": {"contrast": 15}}})).unwrap();
|
||||
assert_eq!(process(&s, src), OLDER, "Apply Settings JSON sets what it names");
|
||||
let d = s.develop_of(a).unwrap();
|
||||
assert_eq!((d.process, d.light.contrast), (ProcessVersion::LATEST, 15.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_numbers_survive_a_reopen_and_develop_get_reports_them() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-process-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let mut s = Session::new();
|
||||
s.open_library(&dir, true).unwrap();
|
||||
let ids: Vec<PhotoId> = s.visible_cloned().into_iter().take(2).collect();
|
||||
select(&mut s, &ids[..1]);
|
||||
s.execute("develop.merge", &json!({"settings": {"process": NEWER.0, "light": {"exposure": 0.5}}})).unwrap();
|
||||
assert_eq!(s.execute("develop.get", &json!({"id": ids[0].0})).unwrap()["process"], json!(NEWER.0));
|
||||
assert_eq!(s.execute("develop.get", &json!({"id": ids[1].0})).unwrap()["process"], json!(ProcessVersion::LATEST.0));
|
||||
// it renders (with the newest process this build has)
|
||||
assert!(s.render_now(ids[0], 48, 48).is_ok());
|
||||
let expect = s.catalog.to_snapshot();
|
||||
drop(s);
|
||||
let mut s = Session::new();
|
||||
s.open_library(&dir, false).unwrap();
|
||||
assert_eq!(s.catalog.to_snapshot(), expect);
|
||||
assert_eq!(process(&s, ids[0]), NEWER);
|
||||
assert_eq!(process(&s, ids[1]), ProcessVersion::LATEST);
|
||||
drop(s);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidecars_keep_the_process_and_interchange_edits_get_the_latest() {
|
||||
use crate::sidecar::{DevelopPatch, merge_into, parse_sidecar, sidecar_packet};
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.visible_cloned()[0];
|
||||
// our own settings round-trip with their process; V1 settings say nothing (as before process
|
||||
// versions, so sidecars written then read as V1)
|
||||
put(&mut s, id, NEWER, 0.3);
|
||||
let p = s.catalog.photo(id).unwrap().clone();
|
||||
let back = parse_sidecar(&sidecar_packet(&p, &s.catalog), crate::crs::Target::Rendered).unwrap();
|
||||
let Some(DevelopPatch::Full(d)) = &back.develop else { panic!("no lc:settings") };
|
||||
assert_eq!((d.process, d.light.exposure), (NEWER, 0.3));
|
||||
put(&mut s, id, ProcessVersion::V1, 0.3);
|
||||
let packet = sidecar_packet(s.catalog.photo(id).unwrap(), &s.catalog);
|
||||
assert!(!packet.contains("process"), "{packet}");
|
||||
let Some(DevelopPatch::Full(d)) = parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap().develop else { panic!("no lc:settings") };
|
||||
assert_eq!(d.process, ProcessVersion::V1);
|
||||
// a damaged process value reads as V1 and costs nothing else: the sidecar's edits still come back
|
||||
for bad in ["-1", "1.5", "\"two\"", "null", "4294967296"] {
|
||||
let json = format!(r#"{{"process": {bad}, "light": {{"exposure": 0.5}}}}"#);
|
||||
let packet = lightcraft_meta::write_xmp(&lightcraft_meta::Metadata::default(), Some(&json));
|
||||
let Some(DevelopPatch::Full(d)) = parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap().develop else {
|
||||
panic!("{bad}: the edit was dropped")
|
||||
};
|
||||
assert_eq!((d.process, d.light.exposure), (ProcessVersion::V1, 0.5), "{bad}");
|
||||
}
|
||||
// `crs:` edits (other raw developers, Lightroom): their ProcessVersion numbers another
|
||||
// renderer and is ignored; mapped like a preset, they leave the photo's process as it is,
|
||||
// which for a newly imported photo is the latest
|
||||
let xmp = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
|
||||
crs:ProcessVersion="11.0" crs:Exposure2012="+1.10" crs:Contrast2012="+20"/></rdf:RDF></x:xmpmeta>"#;
|
||||
let sc = parse_sidecar(xmp, crate::crs::Target::RawAbsolute).unwrap();
|
||||
let Some(DevelopPatch::Partial(partial)) = &sc.develop else { panic!("no crs: edit") };
|
||||
assert!(partial.get("process").is_none(), "{partial}");
|
||||
let mut fresh = Photo::new(PhotoId(999), Source::Demo { scene: 1 }, "a.jpg", "JPEG", 10, 10, "2026-10-09T00:00:00");
|
||||
assert!(merge_into(&mut fresh, &sc, "2026-10-09T00:00:00"));
|
||||
assert_eq!((fresh.develop.process, fresh.develop.light.exposure), (ProcessVersion::LATEST, 1.1));
|
||||
let mut older = fresh.clone();
|
||||
older.develop = Arc::new(DevelopSettings { process: OLDER, ..Default::default() });
|
||||
assert!(merge_into(&mut older, &sc, "2026-10-09T00:00:00"));
|
||||
assert_eq!(older.develop.process, OLDER);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_to_current_process_checks_named_photos_not_the_selection() {
|
||||
let mut s = Session::with_demo();
|
||||
let ids: Vec<PhotoId> = s.visible_cloned().into_iter().take(3).collect();
|
||||
let (old, current, other) = (ids[0], ids[1], ids[2]);
|
||||
put(&mut s, old, OLDER, 0.5);
|
||||
// a different, current photo is selected: the menu item is off, but naming the photo works
|
||||
select(&mut s, &[current]);
|
||||
assert!(!update_info(&s).enabled);
|
||||
let undo = s.undo.len();
|
||||
assert_eq!(s.execute("develop.updateProcess", &json!({"ids": [old.0]})).unwrap()["changed"], 1);
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
assert_eq!(process(&s, old), ProcessVersion::LATEST);
|
||||
assert_eq!(s.develop_of(old).unwrap().light.exposure, 0.5);
|
||||
// with nothing selected at all, by `ids` or `id`
|
||||
put(&mut s, old, OLDER, 0.5);
|
||||
put(&mut s, other, OLDER, 0.0);
|
||||
s.selection = crate::Selection { ids: Vec::new(), active: None };
|
||||
assert!(!update_info(&s).enabled, "no selection");
|
||||
assert_eq!(s.execute("develop.updateProcess", &json!({"ids": [old.0, current.0]})).unwrap()["changed"], 1);
|
||||
assert_eq!(s.execute("develop.updateProcess", &json!({"id": other.0})).unwrap()["changed"], 1);
|
||||
assert_eq!([process(&s, old), process(&s, other)], [ProcessVersion::LATEST; 2]);
|
||||
// named photos are validated: all current, unknown, malformed or none
|
||||
let err = s.execute("develop.updateProcess", &json!({"ids": [current.0]})).unwrap_err();
|
||||
assert!(matches!(&err, EngineError::Disabled(_, why) if why.contains("already on the current process")), "{err}");
|
||||
for bad in [json!({"ids": [99_999]}), json!({"ids": ["x"]}), json!({"ids": "x"}), json!({"ids": []}), json!({"id": -1})] {
|
||||
assert!(matches!(s.execute("develop.updateProcess", &bad), Err(EngineError::BadParams { .. })), "{bad}");
|
||||
}
|
||||
// `ids: null` names nothing: the selection decides (and there is none)
|
||||
assert!(matches!(s.execute("develop.updateProcess", &json!({"ids": null})), Err(EngineError::Disabled(..))));
|
||||
}
|
||||
@@ -0,0 +1,623 @@
|
||||
//! Synchronize Folder: bring a library folder up to date with what is on disk (see
|
||||
//! [`crate::sync`]).
|
||||
//!
|
||||
//! Scenarios, in the words of someone whose folder changed outside LightCraft:
|
||||
//!
|
||||
//! * Given a folder of the library, when files were added to it (or to a folder inside it), the
|
||||
//! scan lists them as new; files the library already has, by path or by content, are not new.
|
||||
//! * When a photo's file was deleted or moved away, the scan lists the photo as missing.
|
||||
//! * When another app saved a photo's XMP sidecar after the photo came into the library, and the
|
||||
//! sidecar says something the library doesn't, the scan lists a metadata update; a sidecar older
|
||||
//! than that, or one that agrees with the library, is not an update.
|
||||
//! * Scanning changes nothing.
|
||||
//! * Synchronizing imports the new files; it removes missing photos (to Recently Deleted) and
|
||||
//! reads metadata updates only when asked. Whatever it does is one undo step.
|
||||
//! * Only a folder of the library can be synchronized, never the whole startup disk.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// A scratch folder that goes away with the test, however it ends.
|
||||
struct Scratch(PathBuf);
|
||||
|
||||
impl Scratch {
|
||||
fn new(tag: &str) -> Scratch {
|
||||
let dir = std::env::temp_dir().join(format!("lc-sync-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
Scratch(dir)
|
||||
}
|
||||
fn path(&self, rel: &str) -> String {
|
||||
self.0.join(rel).to_string_lossy().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// A small procedural PNG (distinct per seed).
|
||||
fn write_png(path: &str, seed: u8) {
|
||||
let (w, h) = (24usize, 16usize);
|
||||
let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i % w * 9) as u8, (i / w * 13) as u8, seed, 255]).collect();
|
||||
let img = lightcraft_raster::Rgba8 { width: w, height: h, data };
|
||||
let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
|
||||
std::fs::create_dir_all(Path::new(path).parent().unwrap()).unwrap();
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
}
|
||||
|
||||
fn sidecar(rating: u8) -> String {
|
||||
format!(
|
||||
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmp:Rating="{rating}"/>
|
||||
</rdf:RDF></x:xmpmeta>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Write `a.xmp` beside `a.png`, dated `age` before now.
|
||||
fn write_sidecar(png: &str, rating: u8, age: Duration) {
|
||||
let xmp = Path::new(png).with_extension("xmp");
|
||||
std::fs::write(&xmp, sidecar(rating)).unwrap();
|
||||
let f = std::fs::File::options().write(true).open(&xmp).unwrap();
|
||||
f.set_modified(SystemTime::now() - age).unwrap();
|
||||
}
|
||||
|
||||
/// A library holding `trip/a.png` and `trip/day1/b.png`, the session clock at a fixed past time.
|
||||
fn library(dir: &Scratch) -> Session {
|
||||
write_png(&dir.path("trip/a.png"), 1);
|
||||
write_png(&dir.path("trip/day1/b.png"), 2);
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.import", &json!({"paths": [dir.path("trip")]})).unwrap();
|
||||
assert_eq!(s.catalog.len(), 2);
|
||||
s
|
||||
}
|
||||
|
||||
fn paths(v: &Value, key: &str) -> Vec<String> {
|
||||
let mut out: Vec<String> = v[key].as_array().unwrap().iter().map(|c| c["path"].as_str().unwrap().to_string()).collect();
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
/// What the library holds: each photo's file, whether it is in the library, and its rating.
|
||||
fn holdings(s: &Session) -> Vec<(String, bool, u8)> {
|
||||
let mut v: Vec<(String, bool, u8)> = s.catalog.photos().map(|p| (p.file_name.clone(), p.in_library(), p.rating)).collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
fn scan(s: &mut Session, path: &str) -> Value {
|
||||
s.execute("folder.scanChanges", &json!({"path": path})).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unchanged_folder_has_no_changes() {
|
||||
let dir = Scratch::new("none");
|
||||
let mut s = library(&dir);
|
||||
let r = scan(&mut s, &dir.path("trip"));
|
||||
assert!(paths(&r, "new").is_empty() && paths(&r, "missing").is_empty() && paths(&r, "metadata").is_empty(), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_added_to_the_folder_or_a_folder_inside_it_are_new() {
|
||||
let dir = Scratch::new("new");
|
||||
let mut s = library(&dir);
|
||||
write_png(&dir.path("trip/c.png"), 3);
|
||||
write_png(&dir.path("trip/day2/d.png"), 4);
|
||||
// the same bytes as a photo the library has: not new
|
||||
std::fs::copy(dir.path("trip/a.png"), dir.path("trip/a-copy.png")).unwrap();
|
||||
let before = s.catalog.to_snapshot();
|
||||
let r = scan(&mut s, &dir.path("trip"));
|
||||
assert_eq!(paths(&r, "new"), vec![dir.path("trip/c.png"), dir.path("trip/day2/d.png")], "{r}");
|
||||
assert_eq!(r["duplicates"], 1, "{r}");
|
||||
assert_eq!(s.catalog.to_snapshot(), before, "scanning changes nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_photo_whose_file_is_gone_is_missing() {
|
||||
let dir = Scratch::new("missing");
|
||||
let mut s = library(&dir);
|
||||
std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap();
|
||||
let r = scan(&mut s, &dir.path("trip"));
|
||||
assert_eq!(paths(&r, "missing"), vec![dir.path("trip/day1/b.png")], "{r}");
|
||||
assert!(r["missing"][0]["id"].is_u64());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sidecar_saved_by_another_app_is_a_metadata_update() {
|
||||
let dir = Scratch::new("meta");
|
||||
let mut s = library(&dir);
|
||||
// saved now: after the photo came in (the session clock says 2026-09-30)
|
||||
write_sidecar(&dir.path("trip/a.png"), 4, Duration::ZERO);
|
||||
let r = scan(&mut s, &dir.path("trip"));
|
||||
assert_eq!(paths(&r, "metadata"), vec![dir.path("trip/a.png")], "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_old_sidecar_or_one_that_agrees_is_no_update() {
|
||||
let dir = Scratch::new("meta-old");
|
||||
let mut s = library(&dir);
|
||||
// older than the import: whatever it says, the library has had its say since
|
||||
write_sidecar(&dir.path("trip/a.png"), 4, Duration::from_secs(400 * 24 * 3600));
|
||||
// new, but says what the library already knows
|
||||
write_sidecar(&dir.path("trip/day1/b.png"), 0, Duration::ZERO);
|
||||
let r = scan(&mut s, &dir.path("trip"));
|
||||
assert!(paths(&r, "metadata").is_empty(), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synchronizing_imports_new_files_and_leaves_the_rest_unless_asked() {
|
||||
let dir = Scratch::new("sync");
|
||||
let mut s = library(&dir);
|
||||
write_png(&dir.path("trip/c.png"), 3);
|
||||
std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap();
|
||||
write_sidecar(&dir.path("trip/a.png"), 5, Duration::ZERO);
|
||||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip")})).unwrap();
|
||||
assert_eq!((r["imported"].as_u64(), r["removed"].as_u64(), r["read"].as_u64()), (Some(1), Some(0), Some(0)), "{r}");
|
||||
assert_eq!(s.catalog.photos().filter(|p| p.in_library()).count(), 3, "the missing photo stays");
|
||||
let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap();
|
||||
assert_eq!(a.rating, 0, "metadata is read only when asked");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synchronizing_everything_is_one_undo_step() {
|
||||
let dir = Scratch::new("sync-all");
|
||||
let mut s = library(&dir);
|
||||
let before = holdings(&s);
|
||||
write_png(&dir.path("trip/c.png"), 3);
|
||||
std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap();
|
||||
write_sidecar(&dir.path("trip/a.png"), 5, Duration::ZERO);
|
||||
let r =
|
||||
s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "importNew": true, "removeMissing": true, "readMetadata": true})).unwrap();
|
||||
assert_eq!((r["imported"].as_u64(), r["removed"].as_u64(), r["read"].as_u64()), (Some(1), Some(1), Some(1)), "{r}");
|
||||
let library: Vec<String> = s.catalog.photos().filter(|p| p.in_library()).map(|p| p.file_name.clone()).collect();
|
||||
assert_eq!(library.len(), 2, "b went to Recently Deleted: {library:?}");
|
||||
assert_eq!(s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().rating, 5, "the sidecar was read");
|
||||
let undo = s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(undo["undone"], "Synchronize Folder");
|
||||
assert_eq!(holdings(&s), before, "one undo step puts everything back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_with_no_changes_synchronizes_without_an_undo_step() {
|
||||
let dir = Scratch::new("sync-none");
|
||||
let mut s = library(&dir);
|
||||
let steps = s.undo.len();
|
||||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "removeMissing": true, "readMetadata": true})).unwrap();
|
||||
assert_eq!((r["imported"].as_u64(), r["removed"].as_u64(), r["read"].as_u64()), (Some(0), Some(0), Some(0)), "{r}");
|
||||
assert_eq!(s.undo.len(), steps);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_folder_of_the_library_is_synchronized() {
|
||||
let dir = Scratch::new("refused");
|
||||
let mut s = library(&dir);
|
||||
std::fs::create_dir_all(dir.0.join("elsewhere")).unwrap();
|
||||
for (cmd, p, why) in [
|
||||
("folder.scanChanges", json!({}), "missing path"),
|
||||
("folder.scanChanges", json!({"path": " "}), "blank path"),
|
||||
("folder.scanChanges", json!({"path": dir.path("elsewhere")}), "no photo was imported from it"),
|
||||
("folder.scanChanges", json!({"path": "/"}), "the startup disk"),
|
||||
("folder.synchronize", json!({"path": dir.path("elsewhere")}), "no photo was imported from it"),
|
||||
("folder.synchronize", json!({"path": "/"}), "the startup disk"),
|
||||
] {
|
||||
assert!(s.execute(cmd, &p).is_err(), "{cmd}: {why}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synchronizing_acts_on_the_changes_just_scanned() {
|
||||
let dir = Scratch::new("cached");
|
||||
let mut s = library(&dir);
|
||||
write_png(&dir.path("trip/c.png"), 3);
|
||||
let r = scan(&mut s, &dir.path("trip"));
|
||||
assert_eq!(paths(&r, "new").len(), 1);
|
||||
// arrived after the scan the person was shown: not part of what they agreed to
|
||||
write_png(&dir.path("trip/d.png"), 4);
|
||||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip")})).unwrap();
|
||||
assert_eq!(r["imported"], 1, "{r}");
|
||||
// the scan is used once; the next synchronize looks again
|
||||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip")})).unwrap();
|
||||
assert_eq!(r["imported"], 1, "d.png now: {r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scan_is_stale_once_the_library_changed() {
|
||||
let dir = Scratch::new("stale");
|
||||
let mut s = library(&dir);
|
||||
write_png(&dir.path("trip/c.png"), 3);
|
||||
scan(&mut s, &dir.path("trip"));
|
||||
// c.png comes in some other way between the scan and the synchronize
|
||||
s.execute("library.import", &json!({"paths": [dir.path("trip/c.png")]})).unwrap();
|
||||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip")})).unwrap();
|
||||
assert_eq!(r["imported"], 0, "the library changed: it looked again: {r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cancelled_scan_stops_checking_files() {
|
||||
let dir = Scratch::new("cancel");
|
||||
let mut s = library(&dir);
|
||||
std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap();
|
||||
let input = crate::sync::SyncInput::new(&mut s, &dir.path("trip"), false).unwrap();
|
||||
let progress = crate::sync::SyncProgress::default();
|
||||
progress.files.cancel.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
let c = crate::sync::scan_with(input, &progress);
|
||||
assert!(c.missing.is_empty() && c.metadata.is_empty(), "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_photos_are_checked_in_parallel_with_the_same_answer() {
|
||||
let dir = Scratch::new("many");
|
||||
for i in 0..40u8 {
|
||||
write_png(&dir.path(&format!("big/{i:02}.png")), i);
|
||||
}
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.import", &json!({"paths": [dir.path("big")]})).unwrap();
|
||||
for i in (0..40u8).step_by(3) {
|
||||
std::fs::remove_file(dir.path(&format!("big/{i:02}.png"))).unwrap();
|
||||
}
|
||||
for i in (1..40u8).step_by(5) {
|
||||
write_sidecar(&dir.path(&format!("big/{i:02}.png")), 2, Duration::ZERO);
|
||||
}
|
||||
let r = scan(&mut s, &dir.path("big"));
|
||||
let expect_missing: Vec<String> = (0..40u8).step_by(3).map(|i| dir.path(&format!("big/{i:02}.png"))).collect();
|
||||
let expect_meta: Vec<String> = (1..40u8).step_by(5).filter(|i| i % 3 != 0).map(|i| dir.path(&format!("big/{i:02}.png"))).collect();
|
||||
assert_eq!(paths(&r, "missing"), expect_missing);
|
||||
assert_eq!(paths(&r, "metadata"), expect_meta);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_that_is_not_there_is_not_scanned_as_if_everything_went_missing() {
|
||||
let dir = Scratch::new("offline");
|
||||
let mut s = library(&dir);
|
||||
// an unplugged disk, or a folder moved away whole
|
||||
std::fs::rename(dir.0.join("trip"), dir.0.join("trip-elsewhere")).unwrap();
|
||||
for cmd in ["folder.scanChanges", "folder.synchronize"] {
|
||||
let e = s.execute(cmd, &json!({"path": dir.path("trip"), "removeMissing": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("not there"), "{cmd}: {e}");
|
||||
}
|
||||
assert_eq!(s.catalog.photos().filter(|p| p.in_library()).count(), 2, "nothing was removed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_whole_disk_or_a_folder_holding_disks_is_synchronized_only_on_request() {
|
||||
let dir = Scratch::new("disks");
|
||||
let mut s = library(&dir);
|
||||
// a library photo on another disk makes the scratch folder's root look like it holds disks
|
||||
let id = s.catalog.alloc_photo_id();
|
||||
let p = lightcraft_catalog::Photo::new(
|
||||
id,
|
||||
lightcraft_catalog::Source::File { path: "/Volumes/nas/x.jpg".into() },
|
||||
"x.jpg",
|
||||
"JPEG",
|
||||
6,
|
||||
4,
|
||||
"2026-01-01",
|
||||
);
|
||||
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
if std::path::Path::new("/Volumes").is_dir() {
|
||||
let e = s.execute("folder.scanChanges", &json!({"path": "/Volumes"})).unwrap_err().to_string();
|
||||
assert!(e.contains("disk: true"), "{e}");
|
||||
}
|
||||
// a folder inside one disk needs nothing more
|
||||
assert!(s.execute("folder.scanChanges", &json!({"path": dir.path("trip")})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_only_browsed_in_local_is_new_and_joins_the_library() {
|
||||
let dir = Scratch::new("local");
|
||||
let mut s = library(&dir);
|
||||
write_png(&dir.path("trip/c.png"), 3);
|
||||
// looking at the folder in Local catalogues c.png as a browse record, not a library photo
|
||||
s.execute("library.browse", &json!({"path": dir.path("trip")})).unwrap();
|
||||
assert!(s.catalog.photos().any(|p| p.file_name == "c.png" && p.local));
|
||||
let r = scan(&mut s, &dir.path("trip"));
|
||||
assert_eq!(paths(&r, "new"), vec![dir.path("trip/c.png")], "{r}");
|
||||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip")})).unwrap();
|
||||
assert_eq!(r["imported"], 1, "{r}");
|
||||
assert!(s.catalog.photos().any(|p| p.file_name == "c.png" && p.in_library()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_renamed_or_moved_inside_the_folder_is_relinked_not_lost() {
|
||||
let dir = Scratch::new("moved");
|
||||
let mut s = library(&dir);
|
||||
let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id;
|
||||
s.execute("photo.rate", &json!({"ids": [a.0], "rating": 4})).unwrap();
|
||||
std::fs::rename(dir.path("trip/a.png"), dir.path("trip/day1/a-renamed.png")).unwrap();
|
||||
let r = scan(&mut s, &dir.path("trip"));
|
||||
assert!(paths(&r, "missing").is_empty() && paths(&r, "new").is_empty(), "{r}");
|
||||
assert_eq!(r["moved"][0]["id"], a.0, "{r}");
|
||||
assert_eq!(r["moved"][0]["to"], dir.path("trip/day1/a-renamed.png"), "{r}");
|
||||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "removeMissing": true})).unwrap();
|
||||
assert_eq!((r["relinked"].as_u64(), r["removed"].as_u64(), r["imported"].as_u64()), (Some(1), Some(0), Some(0)), "{r}");
|
||||
let p = s.catalog.photo(a).unwrap();
|
||||
assert!(p.in_library() && p.rating == 4, "the same photo, edits and all");
|
||||
assert_eq!(p.source, lightcraft_catalog::Source::File { path: dir.path("trip/day1/a-renamed.png") });
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.catalog.photo(a).unwrap().source, lightcraft_catalog::Source::File { path: dir.path("trip/a.png") });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synchronizing_is_one_undo_step_even_with_a_full_undo_history() {
|
||||
let dir = Scratch::new("full-undo");
|
||||
let mut s = library(&dir);
|
||||
let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id;
|
||||
// a long session: the history is at its limit and drops its oldest steps
|
||||
for i in 0..1005u32 {
|
||||
s.execute("photo.rate", &json!({"ids": [a.0], "rating": i % 5})).unwrap();
|
||||
}
|
||||
let before = holdings(&s);
|
||||
write_png(&dir.path("trip/c.png"), 3);
|
||||
std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap();
|
||||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "removeMissing": true})).unwrap();
|
||||
assert_eq!((r["imported"].as_u64(), r["removed"].as_u64()), (Some(1), Some(1)), "{r}");
|
||||
let undo = s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(undo["undone"], "Synchronize Folder");
|
||||
assert_eq!(holdings(&s), before, "one step undoes the import and the removal");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scan_stays_current_while_only_other_folders_change() {
|
||||
let dir = Scratch::new("current");
|
||||
let mut s = library(&dir);
|
||||
write_png(&dir.path("other/x.png"), 9);
|
||||
s.execute("library.import", &json!({"paths": [dir.path("other")]})).unwrap();
|
||||
write_png(&dir.path("trip/c.png"), 3);
|
||||
scan(&mut s, &dir.path("trip"));
|
||||
// something elsewhere in the library changes while the dialog is open (a background import…)
|
||||
let x = s.catalog.photos().find(|p| p.file_name == "x.png").unwrap().id;
|
||||
s.execute("photo.rate", &json!({"ids": [x.0], "rating": 3})).unwrap();
|
||||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "scanned": true})).unwrap();
|
||||
assert_eq!(r["imported"], 1, "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_change_to_the_folders_photos_makes_the_scan_stale() {
|
||||
let dir = Scratch::new("stale-folder");
|
||||
let mut s = library(&dir);
|
||||
write_png(&dir.path("trip/c.png"), 3);
|
||||
scan(&mut s, &dir.path("trip"));
|
||||
let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id;
|
||||
s.execute("photo.rate", &json!({"ids": [a.0], "rating": 2})).unwrap();
|
||||
// the dialog asks for exactly what it showed: refused, never redone on the spot
|
||||
let e = s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "scanned": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("scan again"), "{e}");
|
||||
assert_eq!(s.catalog.photos().filter(|p| p.in_library()).count(), 2, "nothing was done");
|
||||
// an agent that just asks to synchronize gets a fresh scan
|
||||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip")})).unwrap();
|
||||
assert_eq!(r["imported"], 1, "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_progress_counts_the_photos_checked_too() {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let dir = Scratch::new("progress");
|
||||
let mut s = library(&dir);
|
||||
let input = crate::sync::SyncInput::new(&mut s, &dir.path("trip"), false).unwrap();
|
||||
let progress = crate::sync::SyncProgress::default();
|
||||
crate::sync::scan_with(input, &progress);
|
||||
assert_eq!((progress.checked.load(Relaxed), progress.to_check.load(Relaxed)), (2, 2));
|
||||
let (done, total) = progress.counts();
|
||||
assert!(done == total && total >= 2, "{done}/{total}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sidecar_the_library_wrote_itself_is_no_update() {
|
||||
let dir = Scratch::new("autowrite");
|
||||
let mut s = library(&dir);
|
||||
s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap();
|
||||
let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id;
|
||||
s.execute("photo.rate", &json!({"ids": [a.0], "rating": 3})).unwrap();
|
||||
s.execute("photo.label", &json!({"ids": [a.0], "label": "green"})).unwrap();
|
||||
assert!(Path::new(&dir.path("trip/a.xmp")).exists(), "auto-write wrote the sidecar");
|
||||
let r = scan(&mut s, &dir.path("trip"));
|
||||
assert!(paths(&r, "metadata").is_empty(), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_renamed_photo_with_no_content_hash_is_relinked_when_its_file_is_unmistakable() {
|
||||
// as photos taken over from a Lightroom catalog are: no hash on record
|
||||
let dir = Scratch::new("nohash");
|
||||
let mut s = library(&dir);
|
||||
let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id;
|
||||
let mut p = (**s.catalog.photo(a).unwrap()).clone();
|
||||
p.content_hash = None;
|
||||
s.catalog.apply(lightcraft_catalog::Op::RemovePhoto { id: a }).unwrap();
|
||||
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
std::fs::rename(dir.path("trip/a.png"), dir.path("trip/a-renamed.png")).unwrap();
|
||||
let r = scan(&mut s, &dir.path("trip"));
|
||||
assert_eq!(r["moved"][0]["to"], dir.path("trip/a-renamed.png"), "{r}");
|
||||
assert!(paths(&r, "missing").is_empty() && paths(&r, "new").is_empty(), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renamed_duplicates_are_each_relinked() {
|
||||
let dir = Scratch::new("dups");
|
||||
let mut s = library(&dir);
|
||||
let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id;
|
||||
s.execute("photo.duplicate", &json!({"ids": [a.0]})).unwrap();
|
||||
assert!(Path::new(&dir.path("trip/a-copy.png")).exists());
|
||||
std::fs::rename(dir.path("trip/a.png"), dir.path("trip/one.png")).unwrap();
|
||||
std::fs::rename(dir.path("trip/a-copy.png"), dir.path("trip/two.png")).unwrap();
|
||||
let r = scan(&mut s, &dir.path("trip"));
|
||||
assert_eq!(r["moved"].as_array().map(Vec::len), Some(2), "{r}");
|
||||
assert!(paths(&r, "missing").is_empty(), "{r}");
|
||||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip")})).unwrap();
|
||||
assert_eq!(r["relinked"], 2, "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_regions_found_meanwhile_leave_the_scan_current() {
|
||||
let dir = Scratch::new("faces");
|
||||
let mut s = library(&dir);
|
||||
write_png(&dir.path("trip/c.png"), 3);
|
||||
scan(&mut s, &dir.path("trip"));
|
||||
// background face search adds a region to a photo of the folder
|
||||
let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id;
|
||||
let mut meta = s.catalog.photo(a).unwrap().meta.clone();
|
||||
meta.regions.push(lightcraft_meta::Region { rect: Default::default(), kind: lightcraft_meta::RegionKind::Face, name: None, description: None });
|
||||
s.catalog.apply(lightcraft_catalog::Op::SetMeta { id: a, meta: Box::new(meta) }).unwrap();
|
||||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "scanned": true})).unwrap();
|
||||
assert_eq!(r["imported"], 1, "{r}");
|
||||
}
|
||||
|
||||
/// Synchronizing in halves, as the app does it: readied on the UI thread without touching the
|
||||
/// disk, the work (every file read) on a worker, each step committed back on the UI thread.
|
||||
fn sync_in_halves(s: &mut Session, path: &str, choice: crate::sync::SyncChoice) -> crate::sync::SyncReport {
|
||||
let changes = crate::sync::scan(s, path, false).unwrap();
|
||||
let (work, mut commit) = crate::sync::SyncJob::start(s, changes, choice).unwrap();
|
||||
let progress = crate::sync::SyncProgress::default();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || work.run(&progress, |step| tx.send(step).is_ok())).join().unwrap();
|
||||
for step in rx {
|
||||
commit.apply(s, step);
|
||||
}
|
||||
commit.finish(s)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_work_of_synchronizing_runs_apart_from_its_commits() {
|
||||
let dir = Scratch::new("halves");
|
||||
let mut s = library(&dir);
|
||||
let before = holdings(&s);
|
||||
write_png(&dir.path("trip/c.png"), 3);
|
||||
std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap();
|
||||
write_sidecar(&dir.path("trip/a.png"), 5, Duration::ZERO);
|
||||
let choice = crate::sync::SyncChoice { import_new: true, relink_moved: true, remove_missing: true, read_metadata: true };
|
||||
let r = sync_in_halves(&mut s, &dir.path("trip"), choice);
|
||||
assert_eq!((r.imported, r.removed, r.read), (1, 1, 1), "{r:?}");
|
||||
assert_eq!(s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().rating, 5);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(holdings(&s), before, "still one undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_work_checks_again_that_a_new_file_is_still_there() {
|
||||
let dir = Scratch::new("ready");
|
||||
let mut s = library(&dir);
|
||||
write_png(&dir.path("trip/c.png"), 3);
|
||||
let changes = crate::sync::scan(&mut s, &dir.path("trip"), false).unwrap();
|
||||
// the file goes away between readying (UI thread) and the work (worker): the work finds out
|
||||
let (work, mut commit) = crate::sync::SyncJob::start(&mut s, changes, crate::sync::SyncChoice::default()).unwrap();
|
||||
std::fs::remove_file(dir.path("trip/c.png")).unwrap();
|
||||
let mut steps = Vec::new();
|
||||
work.run(&crate::sync::SyncProgress::default(), |step| {
|
||||
steps.push(step);
|
||||
true
|
||||
});
|
||||
for step in steps {
|
||||
commit.apply(&mut s, step);
|
||||
}
|
||||
let r = commit.finish(&mut s);
|
||||
assert_eq!(r.imported, 0, "{r:?}");
|
||||
assert_eq!(r.failed.len(), 1, "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cancelled_synchronize_does_nothing_more() {
|
||||
let dir = Scratch::new("cancel-run");
|
||||
let mut s = library(&dir);
|
||||
let steps_before = s.undo.len();
|
||||
write_png(&dir.path("trip/c.png"), 3);
|
||||
let changes = crate::sync::scan(&mut s, &dir.path("trip"), false).unwrap();
|
||||
let (work, commit) = crate::sync::SyncJob::start(&mut s, changes, crate::sync::SyncChoice::default()).unwrap();
|
||||
let progress = crate::sync::SyncProgress::default();
|
||||
progress.files.cancel.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
let mut sent = 0;
|
||||
work.run(&progress, |_| {
|
||||
sent += 1;
|
||||
true
|
||||
});
|
||||
assert_eq!(sent, 0);
|
||||
let r = commit.finish(&mut s);
|
||||
assert_eq!((r.imported, s.undo.len()), (0, steps_before));
|
||||
}
|
||||
|
||||
/// Ready a synchronize of the trip folder and do its work, keeping the steps for the test to
|
||||
/// commit when it likes.
|
||||
fn readied(s: &mut Session, dir: &Scratch, choice: crate::sync::SyncChoice) -> (Vec<crate::sync::SyncStep>, crate::sync::SyncCommit) {
|
||||
let changes = crate::sync::scan(s, &dir.path("trip"), false).unwrap();
|
||||
let (work, commit) = crate::sync::SyncJob::start(s, changes, choice).unwrap();
|
||||
let mut steps = Vec::new();
|
||||
work.run(&crate::sync::SyncProgress::default(), |step| {
|
||||
steps.push(step);
|
||||
true
|
||||
});
|
||||
(steps, commit)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steps_for_a_library_that_was_closed_meanwhile_are_not_applied() {
|
||||
let dir = Scratch::new("switched");
|
||||
let mut s = library(&dir);
|
||||
write_png(&dir.path("trip/c.png"), 3);
|
||||
std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap();
|
||||
let choice = crate::sync::SyncChoice { remove_missing: true, ..Default::default() };
|
||||
let (steps, mut commit) = readied(&mut s, &dir, choice);
|
||||
// File › Open Library while the work runs: another catalog, whose photo ids mean other photos
|
||||
s.library_identity = std::sync::Arc::new(());
|
||||
let before = holdings(&s);
|
||||
let undo = s.undo.len();
|
||||
for step in steps {
|
||||
commit.apply(&mut s, step);
|
||||
}
|
||||
let r = commit.finish(&mut s);
|
||||
assert_eq!(holdings(&s), before, "nothing landed in the other library");
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
assert_eq!((r.imported, r.removed), (0, 0), "{r:?}");
|
||||
assert!(!r.failed.is_empty(), "and the report says why: {r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_undo_pressed_while_synchronizing_is_not_folded_into_it() {
|
||||
let dir = Scratch::new("undo-mid");
|
||||
let mut s = library(&dir);
|
||||
let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id;
|
||||
s.execute("photo.rate", &json!({"ids": [a.0], "rating": 4})).unwrap();
|
||||
write_png(&dir.path("trip/c.png"), 3);
|
||||
std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap();
|
||||
let choice = crate::sync::SyncChoice { remove_missing: true, ..Default::default() };
|
||||
let (mut steps, mut commit) = readied(&mut s, &dir, choice);
|
||||
assert!(steps.len() >= 2, "an import step and a remove step");
|
||||
let first = steps.remove(0);
|
||||
commit.apply(&mut s, first);
|
||||
// the person presses Undo between two steps of the run
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
for step in steps {
|
||||
commit.apply(&mut s, step);
|
||||
}
|
||||
commit.finish(&mut s);
|
||||
// undoing the run's last step never takes the rating with it
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.catalog.photo(a).unwrap().rating, 4, "the earlier, unrelated step stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidecar_namings_for_many_photos_agree_with_one_at_a_time() {
|
||||
let dir = Scratch::new("namings");
|
||||
let mut s = library(&dir);
|
||||
// a raw beside its JPEG: the raw owns IMG_1.xmp, the JPEG's is IMG_1.JPG.xmp
|
||||
for name in ["IMG_1.CR3", "IMG_1.JPG", "IMG_2.JPG"] {
|
||||
let id = s.catalog.alloc_photo_id();
|
||||
let p = lightcraft_catalog::Photo::new(
|
||||
id,
|
||||
lightcraft_catalog::Source::File { path: dir.path(&format!("trip/{name}")) },
|
||||
name,
|
||||
"X",
|
||||
6,
|
||||
4,
|
||||
"2026-01-01",
|
||||
);
|
||||
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
}
|
||||
let ids: Vec<lightcraft_catalog::PhotoId> = s.catalog.photos().map(|p| p.id).collect();
|
||||
let one_by_one: Vec<_> = ids.iter().map(|id| s.sidecar_naming(*id)).collect();
|
||||
assert_eq!(s.sidecar_namings(&ids), one_by_one);
|
||||
assert!(one_by_one.contains(&crate::sidecar::SidecarNaming::Full), "the case that matters is there");
|
||||
}
|
||||
@@ -94,7 +94,8 @@ fn auto_write_and_naming_preference() {
|
||||
// a slider drag writes once, at the end
|
||||
s.execute("develop.beginInteraction", &json!({"label": "Exposure"})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "light.exposure", "value": 1.5})).unwrap();
|
||||
let exposure = |p: &Path| match crate::sidecar::parse_sidecar(&std::fs::read_to_string(p).unwrap(), false).unwrap().develop {
|
||||
let exposure = |p: &Path| match crate::sidecar::parse_sidecar(&std::fs::read_to_string(p).unwrap(), crate::crs::Target::Rendered).unwrap().develop
|
||||
{
|
||||
Some(crate::sidecar::DevelopPatch::Full(d)) => d.light.exposure,
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
|
||||
@@ -212,7 +212,7 @@ pub fn filter_chips(f: &Filter, cat: &Catalog) -> Vec<FilterChip> {
|
||||
_ => {}
|
||||
}
|
||||
if let Some(rs) = f.rule_set.as_ref().filter(|r| !r.rules.is_empty()) {
|
||||
add(format!("Rules: {}", rs.describe()), json!({"ruleSet": Null}));
|
||||
add(format!("Rules: {}", rs.describe_with(cat)), json!({"ruleSet": Null}));
|
||||
}
|
||||
if !f.only.is_empty() {
|
||||
add(format!("Only {} photos", f.only.len()), json!({"only": []}));
|
||||
|
||||
@@ -30,3 +30,4 @@ denoise = ["dep:lightcraft-denoise", "lightcraft-denoise/runtime"]
|
||||
|
||||
[dev-dependencies]
|
||||
lightcraft-scenes = { workspace = true }
|
||||
lightcraft-raw = { workspace = true }
|
||||
@@ -0,0 +1,132 @@
|
||||
//! Leaving: no GPU work may be in flight when the process ends (issue #620).
|
||||
//!
|
||||
//! The devices are statics that are never dropped, and the threads that render (preview workers,
|
||||
//! export, denoise, the device warm-up) are not the one that ends the process. Exiting runs the
|
||||
//! driver's own teardown while those threads keep running: a render that is inside the driver at
|
||||
//! that moment crashes there (seen as a SIGSEGV in the NVIDIA Vulkan driver), where neither the
|
||||
//! panic hook nor `catch_unwind` can help.
|
||||
//!
|
||||
//! So every entry into a device goes through [`enter`], and the host ends the process with
|
||||
//! [`crate::begin_shutdown`] (nothing new enters) and [`crate::wait_idle`] (what is in flight
|
||||
//! finishes, up to a deadline).
|
||||
|
||||
use std::sync::{Condvar, Mutex, MutexGuard};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
struct State {
|
||||
closed: bool,
|
||||
/// [`Work`] in flight, on any thread.
|
||||
busy: usize,
|
||||
}
|
||||
|
||||
pub(crate) struct Gate {
|
||||
state: Mutex<State>,
|
||||
idle: Condvar,
|
||||
}
|
||||
|
||||
/// GPU work in flight (until dropped).
|
||||
pub(crate) struct Work<'a>(&'a Gate);
|
||||
|
||||
static GATE: Gate = Gate::new();
|
||||
|
||||
/// Start GPU work, or `None` when the process is ending: the caller then does without the GPU.
|
||||
/// Work may nest (a render that creates the device).
|
||||
pub(crate) fn enter() -> Option<Work<'static>> {
|
||||
GATE.enter()
|
||||
}
|
||||
|
||||
pub(crate) fn begin_shutdown() {
|
||||
GATE.close();
|
||||
}
|
||||
|
||||
pub(crate) fn shutting_down() -> bool {
|
||||
GATE.lock().closed
|
||||
}
|
||||
|
||||
pub(crate) fn wait_idle(timeout: Duration) -> bool {
|
||||
GATE.wait_idle(timeout)
|
||||
}
|
||||
|
||||
impl Gate {
|
||||
pub const fn new() -> Gate {
|
||||
Gate { state: Mutex::new(State { closed: false, busy: 0 }), idle: Condvar::new() }
|
||||
}
|
||||
|
||||
fn lock(&self) -> MutexGuard<'_, State> {
|
||||
self.state.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
pub fn enter(&self) -> Option<Work<'_>> {
|
||||
let mut s = self.lock();
|
||||
if s.closed {
|
||||
return None;
|
||||
}
|
||||
s.busy = s.busy.saturating_add(1);
|
||||
Some(Work(self))
|
||||
}
|
||||
|
||||
/// Nothing enters from now on.
|
||||
pub fn close(&self) {
|
||||
self.lock().closed = true;
|
||||
}
|
||||
|
||||
/// Wait up to `timeout` until no work is in flight; `false` when some still is.
|
||||
pub fn wait_idle(&self, timeout: Duration) -> bool {
|
||||
let t0 = Instant::now();
|
||||
let mut s = self.lock();
|
||||
while s.busy > 0 {
|
||||
let Some(left) = timeout.checked_sub(t0.elapsed()).filter(|d| !d.is_zero()) else { return false };
|
||||
s = self.idle.wait_timeout(s, left).unwrap_or_else(|e| e.into_inner()).0;
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Work<'_> {
|
||||
fn drop(&mut self) {
|
||||
let mut s = self.0.lock();
|
||||
s.busy = s.busy.saturating_sub(1);
|
||||
if s.busy == 0 {
|
||||
drop(s);
|
||||
self.0.idle.notify_all();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn closed_gate_lets_nothing_in_and_waits_for_what_is_inside() {
|
||||
let gate = Gate::new();
|
||||
assert!(gate.wait_idle(Duration::ZERO), "idle: no wait");
|
||||
let outer = gate.enter().unwrap();
|
||||
let inner = gate.enter().unwrap();
|
||||
gate.close();
|
||||
assert!(gate.enter().is_none(), "nothing new once closed");
|
||||
drop(inner);
|
||||
let t0 = Instant::now();
|
||||
assert!(!gate.wait_idle(Duration::from_millis(50)), "the outer work is still in flight");
|
||||
assert!(t0.elapsed() >= Duration::from_millis(50));
|
||||
std::thread::scope(|s| {
|
||||
s.spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
drop(outer);
|
||||
});
|
||||
assert!(gate.wait_idle(Duration::from_secs(30)), "woken when the work ends");
|
||||
});
|
||||
assert!(gate.enter().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn work_that_panics_still_leaves() {
|
||||
let gate = Gate::new();
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let _work = gate.enter();
|
||||
panic!("a render panics (expected in this test)");
|
||||
}));
|
||||
assert!(r.is_err());
|
||||
assert!(gate.wait_idle(Duration::ZERO));
|
||||
}
|
||||
}
|
||||
+59
-2
@@ -15,6 +15,8 @@
|
||||
//! and [`last_fallback`] say why (`ui.inspect` → `perf.gpuReason` / `perf.gpuFallback`).
|
||||
//! The browser build has no GPU path yet (WebGPU device creation is asynchronous): everything here
|
||||
//! compiles to the CPU fallback on wasm32.
|
||||
//!
|
||||
//! Before the process ends: [`begin_shutdown`], then [`wait_idle`] (issue #620, see `exit.rs`).
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
@@ -29,6 +31,8 @@ use lightcraft_raster::Rgb32f;
|
||||
pub mod backend;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod ctx;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod exit;
|
||||
#[cfg(all(feature = "denoise", not(target_arch = "wasm32")))]
|
||||
pub mod nn;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -77,6 +81,9 @@ pub fn unavailable_reason() -> Option<String> {
|
||||
if !ENABLED.load(Ordering::Relaxed) {
|
||||
return Some("disabled by the GPU rendering preference (app.gpu)".into());
|
||||
}
|
||||
if shutting_down() {
|
||||
return Some("LightCraft is closing".into());
|
||||
}
|
||||
if BROKEN.load(Ordering::Relaxed) {
|
||||
let r = BROKEN_REASON.lock().unwrap_or_else(|e| e.into_inner()).clone().unwrap_or_else(|| "device error".into());
|
||||
return Some(format!("stopped after a GPU failure: {r}"));
|
||||
@@ -147,6 +154,41 @@ pub fn enabled() -> bool {
|
||||
ENABLED.load(Ordering::Relaxed) && !BROKEN.load(Ordering::Relaxed) && !env_disabled()
|
||||
}
|
||||
|
||||
/// The process is about to end: no GPU work starts from now on, on any thread. [`render`] returns
|
||||
/// `None` (callers render on the CPU), the devices are not created and denoise tiles fail over to
|
||||
/// the CPU. There is no way back. Follow it with [`wait_idle`].
|
||||
pub fn begin_shutdown() {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
exit::begin_shutdown();
|
||||
}
|
||||
|
||||
/// Whether [`begin_shutdown`] was called.
|
||||
pub fn shutting_down() -> bool {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
exit::shutting_down()
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait up to `timeout` for the GPU work in flight on every thread to end. `true`: none is left,
|
||||
/// and after [`begin_shutdown`] none can start, so the process may end; `false`: the deadline
|
||||
/// passed with a render (or a device being created) still inside the driver.
|
||||
pub fn wait_idle(timeout: std::time::Duration) -> bool {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
exit::wait_idle(timeout)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = timeout;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Why GPU compute that has a device of its own (denoise) is switched off by the user's settings, or `None`: the
|
||||
/// environment switches and the rendering preference, but not a render-device failure (the devices are separate).
|
||||
#[cfg(all(feature = "denoise", not(target_arch = "wasm32")))]
|
||||
@@ -177,6 +219,11 @@ pub(crate) fn device() -> Option<&'static ctx::Gpu> {
|
||||
if env_disabled() || !ENABLED.load(Ordering::Relaxed) {
|
||||
return existing_device();
|
||||
}
|
||||
if let Some(g) = GPU.get() {
|
||||
return g.as_ref().ok();
|
||||
}
|
||||
// creating the device is a long call into the driver: not once the process is ending
|
||||
let _work = exit::enter()?;
|
||||
GPU.get_or_init(|| {
|
||||
let Some(backends) = backend::compute_backends() else { return Err("disabled by LIGHTCRAFT_GPU_BACKEND=off".into()) };
|
||||
backend::with_init_marker(backends, || {
|
||||
@@ -281,7 +328,9 @@ pub fn set_pool_limit(bytes: u64) {
|
||||
/// Free recycled buffers until at most `keep` bytes stay pooled (e.g. when the app goes idle).
|
||||
pub fn trim_pool(keep: u64) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(g) = existing_device() {
|
||||
if let Some(g) = existing_device()
|
||||
&& let Some(_work) = exit::enter()
|
||||
{
|
||||
if keep == 0 {
|
||||
render::release_shared_source();
|
||||
}
|
||||
@@ -337,9 +386,17 @@ pub fn render(src: &Arc<Rgb32f>, info: &SourceInfo, s: &DevelopSettings, req: &R
|
||||
let effective = s.effective();
|
||||
let s: &DevelopSettings = &effective;
|
||||
// the kernel writes 8-bit output: high-bit-depth exports (and soft proofs) render on the CPU
|
||||
if !enabled() || req.depth != lightcraft_pipeline::OutputDepth::U8 || req.proof.is_some() {
|
||||
// Visualize HDR needs the float HDR render: CPU too
|
||||
if !enabled()
|
||||
|| req.depth != lightcraft_pipeline::OutputDepth::U8
|
||||
|| req.proof.is_some()
|
||||
|| req.overlay == lightcraft_pipeline::Overlay::HdrRange
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let s = &*lightcraft_pipeline::settings_for(s, req);
|
||||
// in flight until this returns; `None` once the process is ending (issue #620)
|
||||
let _work = exit::enter()?;
|
||||
let gpu = device()?;
|
||||
let ext = stages.map(|c| c.extension::<GpuStages>());
|
||||
let fault = take_fault();
|
||||
|
||||
+27
-2
@@ -63,9 +63,17 @@ fn dev() -> Result<&'static Dev, String> {
|
||||
if let Some(why) = crate::switched_off() {
|
||||
return Err(why);
|
||||
}
|
||||
// creating the device is GPU work too: not once the process is ending (issue #620)
|
||||
let _work = match DEV.get() {
|
||||
Some(_) => None,
|
||||
None => Some(crate::exit::enter().ok_or(CLOSING)?),
|
||||
};
|
||||
DEV.get_or_init(create_device).as_ref().map_err(|e| e.clone())
|
||||
}
|
||||
|
||||
/// Why nothing runs on the GPU once [`crate::begin_shutdown`] was called.
|
||||
const CLOSING: &str = "LightCraft is closing";
|
||||
|
||||
fn create_device() -> Result<Dev, String> {
|
||||
let Some(backends) = crate::backend::compute_backends() else { return Err("disabled by LIGHTCRAFT_GPU_BACKEND=off".into()) };
|
||||
crate::backend::with_init_marker(backends, || {
|
||||
@@ -511,7 +519,6 @@ fn conv_source(bm: u32, bn: u32, bk: u32) -> String {
|
||||
.replace("{{BM}}", &bm.to_string())
|
||||
.replace("{{BN}}", &bn.to_string())
|
||||
.replace("{{BK}}", &bk.to_string())
|
||||
.replace("{{A_VECS}}", &(bk * bm / 4).to_string())
|
||||
.replace("{{B_VECS}}", &(bk * bn / 4).to_string())
|
||||
}
|
||||
|
||||
@@ -654,6 +661,7 @@ pub struct NetRunner {
|
||||
/// Put `net` on the GPU for `tile × tile` input cells, or say why it cannot be (no GPU, a layer or a size the kernels
|
||||
/// do not do, the device refusing the buffers). Creates the device on first use and compiles the kernels.
|
||||
pub fn runner(net: &Net, tile: usize) -> Result<NetRunner, String> {
|
||||
let _work = crate::exit::enter().ok_or(CLOSING)?;
|
||||
let d = dev()?;
|
||||
let max_binding = d.limits.max_storage_buffer_binding_size.min(d.limits.max_buffer_size).min(MAX_BUFFER);
|
||||
let (layout, layers) = plan(net, tile, max_binding)?;
|
||||
@@ -840,6 +848,8 @@ impl TileRunner for NetRunner {
|
||||
if input.len() != self.shared.layout.in_floats {
|
||||
return Err(Error::Input("the tile is not the size the model wants".into()));
|
||||
}
|
||||
// one tile is one piece of GPU work: a photo's worth would outlast any wait at exit
|
||||
let Some(_work) = crate::exit::enter() else { return Err(Error::Runtime(CLOSING.into())) };
|
||||
let w = self.take()?;
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.execute(&w, input)));
|
||||
match r {
|
||||
@@ -902,6 +912,7 @@ mod tests {
|
||||
|
||||
fn worst(a: &[f32], b: &[f32]) -> f32 {
|
||||
assert_eq!(a.len(), b.len());
|
||||
assert!(a.iter().chain(b).all(|v| v.is_finite()), "non-finite denoise output");
|
||||
let scale = b.iter().fold(1e-3f32, |m, v| m.max(v.abs()));
|
||||
a.iter().zip(b).fold(0f32, |m, (x, y)| m.max((x - y).abs())) / scale
|
||||
}
|
||||
@@ -925,6 +936,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// Issue #479: several pixels loaded by different invocations shared a vec4,
|
||||
// so lane writes raced on Metal. Exercise a partial block repeatedly with new
|
||||
// inputs; stale shared-memory values must never leak into the next dispatch.
|
||||
#[test]
|
||||
fn partial_tiles_remain_correct_when_reusing_a_runner() {
|
||||
let n = net(3, 4, 0, 8);
|
||||
let Some(g) = gpu(&n, 3) else { return };
|
||||
for seed in 100..112 {
|
||||
let x = picture(4 * 3 * 3, seed);
|
||||
let want = reference::run(&n, 3, &x).unwrap();
|
||||
let e = worst(&g.run(&x).unwrap(), &want);
|
||||
assert!(e < 2e-4, "seed {seed}: off by {e:e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_runner_serves_many_tiles_from_many_threads() {
|
||||
let n = net(32, 8, 2, 7);
|
||||
@@ -1022,7 +1048,6 @@ mod tests {
|
||||
.replace("{{BM}}", &bm.to_string())
|
||||
.replace("{{BN}}", &bn.to_string())
|
||||
.replace("{{BK}}", &bk.to_string())
|
||||
.replace("{{A_VECS}}", &(bk * bm / 4).to_string())
|
||||
.replace("{{B_VECS}}", &(bk * bn / 4).to_string());
|
||||
let d = dev().unwrap();
|
||||
let layout = conv_bgl(d);
|
||||
|
||||
Loaded 100 of 237 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user