XMP.434: reconcile current main & complete warning locales
Preserve Target-aware sidecar operations, add French/Ukrainian warnings, and ensure omitted diagnostic fields decode in the native UI. Generated with Codex.
This commit is contained in:
commit
5855ab80a6
|
|
@ -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
|
||||
|
|
|
|||
25
AGENTS.md
25
AGENTS.md
|
|
@ -5,12 +5,18 @@ LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructi
|
|||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next unchecked task, blockers).
|
||||
2. Read the task in `plan/execution-plan.md` §3, the relevant section of `plan/architecture.md`, and the README/docs of the crate you touch. Behaviour/visual reference: `plan/lightroom/` (incl. `10-observed-ui.md` + screenshots).
|
||||
3. **Know where we stand:** read [`ROADMAP.md`](ROADMAP.md) → *Where we stand* (honest assessment by dimension and
|
||||
by kind of user) and *Where we're going* (ordered priorities). The checklist counts features that *exist*; the real
|
||||
gaps are quality and coverage: camera colour calibration, CR3 / compressed raws, per-model verification, render
|
||||
fidelity against Lightroom, AI models, HDR / video / Classic modules. A ✅ row is not proof of parity — if you find a
|
||||
✅ feature that is wrong or incomplete, downgrade it to 🟡 with a note.
|
||||
4. **Pick work** from [`docs/parity.md`](docs/parity.md) → *Top gaps* (ordered by user impact; the tracker has one row per feature, menu item and shortcut). When you land a feature, update its row(s) and the gap list in the same commit, and the *Where we stand* / *Where we're going* sections of `ROADMAP.md` when a listed gap closes; `cargo xtask parity` (part of `ci`) checks that every `cmd:`/`ctl:` id and path the tracker cites still exists, and `cargo xtask parity --write` refreshes its summary.
|
||||
3. **Know where we stand:** read [`ROADMAP.md`](ROADMAP.md) (stage, headline numbers, every dimension) and, for
|
||||
method and evidence, [`docs/target-app-parity.md`](docs/target-app-parity.md). The checklist counts features that
|
||||
*exist*; the real gaps are quality and coverage: camera colour calibration, CR3 / compressed raws, per-model
|
||||
verification, render fidelity against Lightroom, AI models, HDR / video / Classic modules (raw detail:
|
||||
[`docs/raw-parity.md`](docs/raw-parity.md)). A ✅ row is not proof of parity — if you find a ✅ feature that is
|
||||
wrong or incomplete, downgrade it to 🟡 with a note.
|
||||
4. **Pick work** from [`docs/gaps.md`](docs/gaps.md) (ranked by user impact; `B` marks beta blockers). The row-by-row
|
||||
tracker is [`docs/parity-checklist.md`](docs/parity-checklist.md) (one row per feature, menu item and shortcut).
|
||||
When you land a feature, update its row(s) and the gap entry in the same commit, and the numbers in `ROADMAP.md` /
|
||||
`docs/target-app-parity.md` when a listed gap closes (bump each doc's *Last updated* line and revision history, per
|
||||
craftrules `standards/progress-docs.md`); `cargo xtask parity` (part of `ci`) checks that every `cmd:`/`ctl:` id and
|
||||
path the tracker cites still exists, and `cargo xtask parity --write` refreshes its summary.
|
||||
5. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
|
||||
|
||||
`plan/` is gitignored (local-only).
|
||||
|
|
@ -74,9 +80,10 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
|||
font files here (Inter, already in `assets/fonts/`, is the one exception); add new fonts to craft-fonts. LightCraft
|
||||
uses it as the optional build input `CRAFT_FONTS_DIR`: `git clone https://github.com/storytold/craft-fonts ../craft-fonts`
|
||||
then `CRAFT_FONTS_DIR=../craft-fonts cargo run -p lightcraft` (or any cargo/xtask command). `crates/engine/build.rs`
|
||||
embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); the UI
|
||||
(`theme::font_definitions`) and the export watermark renderer use its CJK faces (picked by script) as fallbacks after Inter. Unset,
|
||||
`CRAFT_FONTS` is empty: everything builds, tests and runs, but Japanese text has no glyphs. Releases always build
|
||||
embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); `cargo xtask web`
|
||||
ships Noto Sans CJK SC Regular as a separate asset loaded by the UI before startup. The UI
|
||||
(`theme::font_definitions`) and the export watermark renderer use CJK faces (picked by script) as fallbacks after Inter. Unset,
|
||||
`CRAFT_FONTS` is empty: everything builds, tests and runs, but Chinese and Japanese text lack glyphs. Releases always build
|
||||
with it (`release.yml`, `CRAFT_FONTS_REQUIRED=1`) and ship the fonts' OFL licences. Tests that need these fonts skip
|
||||
without it; the FreeBSD CI job runs them with it. Rules: craftrules
|
||||
[`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md).
|
||||
|
|
|
|||
32
CLAUDE.md
32
CLAUDE.md
|
|
@ -5,12 +5,18 @@ LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructi
|
|||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next unchecked task, blockers).
|
||||
2. Read the task in `plan/execution-plan.md` §3, the relevant section of `plan/architecture.md`, and the README/docs of the crate you touch. Behaviour/visual reference: `plan/lightroom/` (incl. `10-observed-ui.md` + screenshots).
|
||||
3. **Know where we stand:** read [`ROADMAP.md`](ROADMAP.md) → *Where we stand* (honest assessment by dimension and
|
||||
by kind of user) and *Where we're going* (ordered priorities). The checklist counts features that *exist*; the real
|
||||
gaps are quality and coverage: camera colour calibration, CR3 / compressed raws, per-model verification, render
|
||||
fidelity against Lightroom, AI models, HDR / video / Classic modules. A ✅ row is not proof of parity — if you find a
|
||||
✅ feature that is wrong or incomplete, downgrade it to 🟡 with a note.
|
||||
4. **Pick work** from [`docs/parity.md`](docs/parity.md) → *Top gaps* (ordered by user impact; the tracker has one row per feature, menu item and shortcut). When you land a feature, update its row(s) and the gap list in the same commit, and the *Where we stand* / *Where we're going* sections of `ROADMAP.md` when a listed gap closes; `cargo xtask parity` (part of `ci`) checks that every `cmd:`/`ctl:` id and path the tracker cites still exists, and `cargo xtask parity --write` refreshes its summary.
|
||||
3. **Know where we stand:** read [`ROADMAP.md`](ROADMAP.md) (stage, headline numbers, every dimension) and, for
|
||||
method and evidence, [`docs/target-app-parity.md`](docs/target-app-parity.md). The checklist counts features that
|
||||
*exist*; the real gaps are quality and coverage: camera colour calibration, CR3 / compressed raws, per-model
|
||||
verification, render fidelity against Lightroom, AI models, HDR / video / Classic modules (raw detail:
|
||||
[`docs/raw-parity.md`](docs/raw-parity.md)). A ✅ row is not proof of parity — if you find a ✅ feature that is
|
||||
wrong or incomplete, downgrade it to 🟡 with a note.
|
||||
4. **Pick work** from [`docs/gaps.md`](docs/gaps.md) (ranked by user impact; `B` marks beta blockers). The row-by-row
|
||||
tracker is [`docs/parity-checklist.md`](docs/parity-checklist.md) (one row per feature, menu item and shortcut).
|
||||
When you land a feature, update its row(s) and the gap entry in the same commit, and the numbers in `ROADMAP.md` /
|
||||
`docs/target-app-parity.md` when a listed gap closes (bump each doc's *Last updated* line and revision history, per
|
||||
craftrules `standards/progress-docs.md`); `cargo xtask parity` (part of `ci`) checks that every `cmd:`/`ctl:` id and
|
||||
path the tracker cites still exists, and `cargo xtask parity --write` refreshes its summary.
|
||||
5. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
|
||||
|
||||
`plan/` is gitignored (local-only).
|
||||
|
|
@ -55,6 +61,10 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
|||
- **Pure Rust** in the product. No C/C++ dependencies.
|
||||
- **Layering** (`plan/architecture.md` §3, enforced by `cargo xtask layers`): nothing below L5 depends on egui/eframe/winit/rfd.
|
||||
- **Everything is a command** (`crates/engine`): id, label, menu path, shortcut, params, enabled(), run(). UI, CLI, control channel and MCP all dispatch by id. Every slider is a `develop` control spec.
|
||||
- **Long-running work shows in the activity stack** ([`docs/background-tasks.md`](docs/background-tasks.md)): anything
|
||||
that runs off the UI thread and can take more than a moment holds a `TaskGuard` from `session.activity.start(kind,
|
||||
label, cancel)` for its whole run. It adopts the cancel flag the job already checks (`Cancel::Flag`), or uses
|
||||
`Cancel::No` when the job can't stop. No progress window, panel or progress toast of its own; its tests assert the row.
|
||||
- **Resolution independence:** settings use normalized image coordinates and relative radii; previews and exports must match.
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, assets, wasm).
|
||||
- **Commits:** one task id per commit (`M2.3: local Laplacian highlights/shadows`). Only green states. End messages with the attribution line required by the environment.
|
||||
|
|
@ -74,9 +84,10 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
|||
font files here (Inter, already in `assets/fonts/`, is the one exception); add new fonts to craft-fonts. LightCraft
|
||||
uses it as the optional build input `CRAFT_FONTS_DIR`: `git clone https://github.com/storytold/craft-fonts ../craft-fonts`
|
||||
then `CRAFT_FONTS_DIR=../craft-fonts cargo run -p lightcraft` (or any cargo/xtask command). `crates/engine/build.rs`
|
||||
embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); the UI
|
||||
(`theme::font_definitions`) and the export watermark renderer use its CJK faces (picked by script) as fallbacks after Inter. Unset,
|
||||
`CRAFT_FONTS` is empty: everything builds, tests and runs, but Japanese text has no glyphs. Releases always build
|
||||
embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); `cargo xtask web`
|
||||
ships Noto Sans CJK SC Regular as a separate asset loaded by the UI before startup. The UI
|
||||
(`theme::font_definitions`) and the export watermark renderer use CJK faces (picked by script) as fallbacks after Inter. Unset,
|
||||
`CRAFT_FONTS` is empty: everything builds, tests and runs, but Chinese and Japanese text lack glyphs. Releases always build
|
||||
with it (`release.yml`, `CRAFT_FONTS_REQUIRED=1`) and ship the fonts' OFL licences. Tests that need these fonts skip
|
||||
without it; the FreeBSD CI job runs them with it. Rules: craftrules
|
||||
[`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md).
|
||||
|
|
@ -102,6 +113,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.
|
||||
|
|
|
|||
|
|
@ -209,7 +209,7 @@ version = "0.6.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b"
|
||||
dependencies = [
|
||||
"equator",
|
||||
"equator 0.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -967,6 +967,12 @@ dependencies = [
|
|||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coe-rs"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e8f1e641542c07631228b1e0dc04b69ae3c1d58ef65d5691a439711d805c698"
|
||||
|
||||
[[package]]
|
||||
name = "color"
|
||||
version = "0.3.3"
|
||||
|
|
@ -1256,6 +1262,16 @@ version = "0.1.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
||||
|
||||
[[package]]
|
||||
name = "dyn-stack"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56e53799688f5632f364f8fb387488dd05db9fe45db7011be066fc20e7027f8b"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"reborrow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dyn-stack"
|
||||
version = "0.13.2"
|
||||
|
|
@ -1537,13 +1553,33 @@ version = "0.36.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13"
|
||||
|
||||
[[package]]
|
||||
name = "equator"
|
||||
version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3b0a88aa91d0ad2b9684e4479aed31a17d3f9051bdbbc634bd2c01bc5a5eee8"
|
||||
dependencies = [
|
||||
"equator-macro 0.1.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equator"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc"
|
||||
dependencies = [
|
||||
"equator-macro",
|
||||
"equator-macro 0.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equator-macro"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60d08acb9849f7fb4401564f251be5a526829183a3645a90197dea8e786cf3ae"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1608,6 +1644,42 @@ dependencies = [
|
|||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "faer-core"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32da0f6262445261106407bdfb548df4f8f1c89bcc3cff1bb6d08e7f525b575a"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"coe-rs",
|
||||
"dyn-stack 0.10.0",
|
||||
"equator 0.1.10",
|
||||
"faer-entity",
|
||||
"gemm 0.17.1",
|
||||
"matrixcompare-core",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"pulp 0.18.22",
|
||||
"reborrow",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "faer-entity"
|
||||
version = "0.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93fb12f36da3162b3fefc0c7343d905d5bd2c2bf444a7f63ceeaa2bc003311d3"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"coe-rs",
|
||||
"libm",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"pulp 0.18.22",
|
||||
"reborrow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
|
|
@ -1783,13 +1855,33 @@ dependencies = [
|
|||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gemm"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ab24cc62135b40090e31a76a9b2766a501979f3070fa27f689c27ec04377d32"
|
||||
dependencies = [
|
||||
"dyn-stack 0.10.0",
|
||||
"gemm-c32 0.17.1",
|
||||
"gemm-c64 0.17.1",
|
||||
"gemm-common 0.17.1",
|
||||
"gemm-f16 0.17.1",
|
||||
"gemm-f32 0.17.1",
|
||||
"gemm-f64 0.17.1",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid 10.7.0",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gemm"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab96b703d31950f1aeddded248bc95543c9efc7ac9c4a21fda8703a83ee35451"
|
||||
dependencies = [
|
||||
"dyn-stack",
|
||||
"dyn-stack 0.13.2",
|
||||
"gemm-c32 0.18.2",
|
||||
"gemm-c64 0.18.2",
|
||||
"gemm-common 0.18.2",
|
||||
|
|
@ -1799,7 +1891,7 @@ dependencies = [
|
|||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid",
|
||||
"raw-cpuid 11.6.0",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
|
|
@ -1809,7 +1901,7 @@ version = "0.19.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa0673db364b12263d103b68337a68fbecc541d6f6b61ba72fe438654709eacb"
|
||||
dependencies = [
|
||||
"dyn-stack",
|
||||
"dyn-stack 0.13.2",
|
||||
"gemm-c32 0.19.0",
|
||||
"gemm-c64 0.19.0",
|
||||
"gemm-common 0.19.0",
|
||||
|
|
@ -1819,7 +1911,22 @@ dependencies = [
|
|||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid",
|
||||
"raw-cpuid 11.6.0",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gemm-c32"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9c030d0b983d1e34a546b86e08f600c11696fde16199f971cd46c12e67512c0"
|
||||
dependencies = [
|
||||
"dyn-stack 0.10.0",
|
||||
"gemm-common 0.17.1",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid 10.7.0",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
|
|
@ -1829,12 +1936,12 @@ version = "0.18.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6db9fd9f40421d00eea9dd0770045a5603b8d684654816637732463f4073847"
|
||||
dependencies = [
|
||||
"dyn-stack",
|
||||
"dyn-stack 0.13.2",
|
||||
"gemm-common 0.18.2",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid",
|
||||
"raw-cpuid 11.6.0",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
|
|
@ -1844,12 +1951,27 @@ version = "0.19.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "086936dbdcb99e37aad81d320f98f670e53c1e55a98bee70573e83f95beb128c"
|
||||
dependencies = [
|
||||
"dyn-stack",
|
||||
"dyn-stack 0.13.2",
|
||||
"gemm-common 0.19.0",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid",
|
||||
"raw-cpuid 11.6.0",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gemm-c64"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fbb5f2e79fefb9693d18e1066a557b4546cd334b226beadc68b11a8f9431852a"
|
||||
dependencies = [
|
||||
"dyn-stack 0.10.0",
|
||||
"gemm-common 0.17.1",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid 10.7.0",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
|
|
@ -1859,12 +1981,12 @@ version = "0.18.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfcad8a3d35a43758330b635d02edad980c1e143dc2f21e6fd25f9e4eada8edf"
|
||||
dependencies = [
|
||||
"dyn-stack",
|
||||
"dyn-stack 0.13.2",
|
||||
"gemm-common 0.18.2",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid",
|
||||
"raw-cpuid 11.6.0",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
|
|
@ -1874,15 +1996,34 @@ version = "0.19.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20c8aeeeec425959bda4d9827664029ba1501a90a0d1e6228e48bef741db3a3f"
|
||||
dependencies = [
|
||||
"dyn-stack",
|
||||
"dyn-stack 0.13.2",
|
||||
"gemm-common 0.19.0",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid",
|
||||
"raw-cpuid 11.6.0",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gemm-common"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2e7ea062c987abcd8db95db917b4ffb4ecdfd0668471d8dc54734fdff2354e8"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"dyn-stack 0.10.0",
|
||||
"half",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"paste",
|
||||
"pulp 0.18.22",
|
||||
"raw-cpuid 10.7.0",
|
||||
"seq-macro",
|
||||
"sysctl 0.5.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gemm-common"
|
||||
version = "0.18.2"
|
||||
|
|
@ -1890,7 +2031,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "a352d4a69cbe938b9e2a9cb7a3a63b7e72f9349174a2752a558a8a563510d0f3"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"dyn-stack",
|
||||
"dyn-stack 0.13.2",
|
||||
"half",
|
||||
"libm",
|
||||
"num-complex",
|
||||
|
|
@ -1898,10 +2039,10 @@ dependencies = [
|
|||
"once_cell",
|
||||
"paste",
|
||||
"pulp 0.21.5",
|
||||
"raw-cpuid",
|
||||
"raw-cpuid 11.6.0",
|
||||
"rayon",
|
||||
"seq-macro",
|
||||
"sysctl",
|
||||
"sysctl 0.6.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1911,7 +2052,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "88027625910cc9b1085aaaa1c4bc46bb3a36aad323452b33c25b5e4e7c8e2a3e"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"dyn-stack",
|
||||
"dyn-stack 0.13.2",
|
||||
"half",
|
||||
"libm",
|
||||
"num-complex",
|
||||
|
|
@ -1919,10 +2060,27 @@ dependencies = [
|
|||
"once_cell",
|
||||
"paste",
|
||||
"pulp 0.22.3",
|
||||
"raw-cpuid",
|
||||
"raw-cpuid 11.6.0",
|
||||
"rayon",
|
||||
"seq-macro",
|
||||
"sysctl",
|
||||
"sysctl 0.6.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gemm-f16"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ca4c06b9b11952071d317604acb332e924e817bd891bec8dfb494168c7cedd4"
|
||||
dependencies = [
|
||||
"dyn-stack 0.10.0",
|
||||
"gemm-common 0.17.1",
|
||||
"gemm-f32 0.17.1",
|
||||
"half",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid 10.7.0",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1931,14 +2089,14 @@ version = "0.18.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cff95ae3259432f3c3410eaa919033cd03791d81cebd18018393dc147952e109"
|
||||
dependencies = [
|
||||
"dyn-stack",
|
||||
"dyn-stack 0.13.2",
|
||||
"gemm-common 0.18.2",
|
||||
"gemm-f32 0.18.2",
|
||||
"half",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid",
|
||||
"raw-cpuid 11.6.0",
|
||||
"rayon",
|
||||
"seq-macro",
|
||||
]
|
||||
|
|
@ -1949,30 +2107,45 @@ version = "0.19.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3df7a55202e6cd6739d82ae3399c8e0c7e1402859b30e4cb780e61525d9486e"
|
||||
dependencies = [
|
||||
"dyn-stack",
|
||||
"dyn-stack 0.13.2",
|
||||
"gemm-common 0.19.0",
|
||||
"gemm-f32 0.19.0",
|
||||
"half",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid",
|
||||
"raw-cpuid 11.6.0",
|
||||
"rayon",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gemm-f32"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9a69f51aaefbd9cf12d18faf273d3e982d9d711f60775645ed5c8047b4ae113"
|
||||
dependencies = [
|
||||
"dyn-stack 0.10.0",
|
||||
"gemm-common 0.17.1",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid 10.7.0",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gemm-f32"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc8d3d4385393304f407392f754cd2dc4b315d05063f62cf09f47b58de276864"
|
||||
dependencies = [
|
||||
"dyn-stack",
|
||||
"dyn-stack 0.13.2",
|
||||
"gemm-common 0.18.2",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid",
|
||||
"raw-cpuid 11.6.0",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
|
|
@ -1982,12 +2155,27 @@ version = "0.19.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02e0b8c9da1fbec6e3e3ab2ce6bc259ef18eb5f6f0d3e4edf54b75f9fd41a81c"
|
||||
dependencies = [
|
||||
"dyn-stack",
|
||||
"dyn-stack 0.13.2",
|
||||
"gemm-common 0.19.0",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid",
|
||||
"raw-cpuid 11.6.0",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gemm-f64"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa397a48544fadf0b81ec8741e5c0fba0043008113f71f2034def1935645d2b0"
|
||||
dependencies = [
|
||||
"dyn-stack 0.10.0",
|
||||
"gemm-common 0.17.1",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid 10.7.0",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
|
|
@ -1997,12 +2185,12 @@ version = "0.18.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35b2a4f76ce4b8b16eadc11ccf2e083252d8237c1b589558a49b0183545015bd"
|
||||
dependencies = [
|
||||
"dyn-stack",
|
||||
"dyn-stack 0.13.2",
|
||||
"gemm-common 0.18.2",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid",
|
||||
"raw-cpuid 11.6.0",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
|
|
@ -2012,12 +2200,12 @@ version = "0.19.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "056131e8f2a521bfab322f804ccd652520c79700d81209e9d9275bbdecaadc6a"
|
||||
dependencies = [
|
||||
"dyn-stack",
|
||||
"dyn-stack 0.13.2",
|
||||
"gemm-common 0.19.0",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid",
|
||||
"raw-cpuid 11.6.0",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
|
|
@ -2296,6 +2484,15 @@ version = "0.5.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "heic-rs"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adf9dde634bb6d5b718bacd01bef80192f2334306b2f785519a0136cca7b9618"
|
||||
dependencies = [
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.3"
|
||||
|
|
@ -2779,7 +2976,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "lightcraft"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
|
|
@ -2797,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",
|
||||
|
|
@ -2812,7 +3010,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "lightcraft-cli"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"dhat",
|
||||
"lightcraft-codecs",
|
||||
|
|
@ -2827,8 +3025,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "lightcraft-codecs"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"avif-serialize",
|
||||
"bytemuck",
|
||||
"image",
|
||||
"image-webp",
|
||||
|
|
@ -2837,10 +3036,12 @@ dependencies = [
|
|||
"jxl-oxide",
|
||||
"lightcraft-color",
|
||||
"lightcraft-geom",
|
||||
"lightcraft-heif",
|
||||
"lightcraft-raster",
|
||||
"moxcms 0.9.1",
|
||||
"png",
|
||||
"proptest",
|
||||
"rav1e",
|
||||
"ravif",
|
||||
"rayon",
|
||||
"serde",
|
||||
|
|
@ -2852,16 +3053,48 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "lightcraft-color"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"lightcraft-geom",
|
||||
"proptest",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-denoise"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"faer-core",
|
||||
"lightcraft-denoise-core",
|
||||
"lightcraft-raster",
|
||||
"miniz_oxide 0.8.9",
|
||||
"proptest",
|
||||
"rayon",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 2.0.21",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-denoise-core"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"lightcraft-raster",
|
||||
"miniz_oxide 0.8.9",
|
||||
"proptest",
|
||||
"rayon",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 2.0.21",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-develop"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"lightcraft-color",
|
||||
"lightcraft-geom",
|
||||
|
|
@ -2873,7 +3106,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "lightcraft-engine"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"harfrust",
|
||||
|
|
@ -2881,9 +3114,14 @@ dependencies = [
|
|||
"lightcraft-catalog",
|
||||
"lightcraft-codecs",
|
||||
"lightcraft-color",
|
||||
"lightcraft-denoise",
|
||||
"lightcraft-denoise-core",
|
||||
"lightcraft-develop",
|
||||
"lightcraft-faces",
|
||||
"lightcraft-fetch",
|
||||
"lightcraft-geom",
|
||||
"lightcraft-gpu",
|
||||
"lightcraft-heif",
|
||||
"lightcraft-merge",
|
||||
"lightcraft-meta",
|
||||
"lightcraft-pipeline",
|
||||
|
|
@ -2903,9 +3141,23 @@ dependencies = [
|
|||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-faces"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"matrixmultiply",
|
||||
"ndarray",
|
||||
"proptest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 2.0.21",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-fetch"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"rustls",
|
||||
|
|
@ -2916,7 +3168,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "lightcraft-geom"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
|
|
@ -2924,23 +3176,33 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "lightcraft-gpu"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"lightcraft-color",
|
||||
"lightcraft-denoise",
|
||||
"lightcraft-develop",
|
||||
"lightcraft-geom",
|
||||
"lightcraft-pipeline",
|
||||
"lightcraft-raster",
|
||||
"lightcraft-raw",
|
||||
"lightcraft-scenes",
|
||||
"log",
|
||||
"pollster 1.0.1",
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-heif"
|
||||
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",
|
||||
|
|
@ -2951,7 +3213,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "lightcraft-merge"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"lightcraft-codecs",
|
||||
"lightcraft-color",
|
||||
|
|
@ -2967,7 +3229,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "lightcraft-meta"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"lightcraft-geom",
|
||||
"lightcraft-tiff",
|
||||
|
|
@ -2979,13 +3241,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "lightcraft-pipeline"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"lightcraft-color",
|
||||
"lightcraft-develop",
|
||||
"lightcraft-geom",
|
||||
"lightcraft-raster",
|
||||
"lightcraft-raw",
|
||||
"lightcraft-scenes",
|
||||
"proptest",
|
||||
"rayon",
|
||||
|
|
@ -2995,7 +3258,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "lightcraft-preview"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"jpeg-encoder",
|
||||
"lightcraft-raster",
|
||||
|
|
@ -3007,7 +3270,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "lightcraft-raster"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"lightcraft-color",
|
||||
|
|
@ -3019,9 +3282,10 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "lightcraft-raw"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"jpeg-encoder",
|
||||
"jxl-oxide",
|
||||
"lightcraft-color",
|
||||
"lightcraft-geom",
|
||||
"lightcraft-meta",
|
||||
|
|
@ -3035,11 +3299,12 @@ dependencies = [
|
|||
"thiserror 2.0.21",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
"zune-jpegxl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-scenes"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"lightcraft-color",
|
||||
|
|
@ -3050,7 +3315,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "lightcraft-segment"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"candle-core",
|
||||
"candle-nn",
|
||||
|
|
@ -3063,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",
|
||||
|
|
@ -3076,15 +3341,18 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "lightcraft-ui-egui"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"lightcraft-catalog",
|
||||
"lightcraft-codecs",
|
||||
"lightcraft-color",
|
||||
"lightcraft-denoise",
|
||||
"lightcraft-develop",
|
||||
"lightcraft-engine",
|
||||
"lightcraft-faces",
|
||||
"lightcraft-geom",
|
||||
"lightcraft-merge",
|
||||
"lightcraft-meta",
|
||||
"lightcraft-pipeline",
|
||||
"lightcraft-preview",
|
||||
|
|
@ -3098,7 +3366,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "lightcraft-web"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"eframe",
|
||||
|
|
@ -3174,6 +3442,22 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matrixcompare-core"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b0bdabb30db18805d5290b3da7ceaccbddba795620b86c02145d688e04900a73"
|
||||
|
||||
[[package]]
|
||||
name = "matrixmultiply"
|
||||
version = "0.3.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"rawpointer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "maybe-rayon"
|
||||
version = "0.1.1"
|
||||
|
|
@ -3327,6 +3611,21 @@ dependencies = [
|
|||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndarray"
|
||||
version = "0.17.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d"
|
||||
dependencies = [
|
||||
"matrixmultiply",
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"portable-atomic",
|
||||
"portable-atomic-util",
|
||||
"rawpointer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
|
|
@ -4291,6 +4590,18 @@ dependencies = [
|
|||
"unarray",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pulp"
|
||||
version = "0.18.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0a01a0dc67cf4558d279f0c25b0962bd08fc6dec0137699eae304103e882fe6"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"libm",
|
||||
"num-complex",
|
||||
"reborrow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pulp"
|
||||
version = "0.21.5"
|
||||
|
|
@ -4317,7 +4628,7 @@ dependencies = [
|
|||
"num-complex",
|
||||
"paste",
|
||||
"pulp-wasm-simd-flag",
|
||||
"raw-cpuid",
|
||||
"raw-cpuid 11.6.0",
|
||||
"reborrow",
|
||||
"version_check",
|
||||
]
|
||||
|
|
@ -4518,6 +4829,15 @@ dependencies = [
|
|||
"rgb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-cpuid"
|
||||
version = "10.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c297679cb867470fa8c9f67dbba74a78d78e3e98d7cf2b08d6d71540f797332"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-cpuid"
|
||||
version = "11.6.0"
|
||||
|
|
@ -4545,6 +4865,12 @@ dependencies = [
|
|||
"objc2-quartz-core 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rawpointer"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.12.0"
|
||||
|
|
@ -5245,6 +5571,20 @@ dependencies = [
|
|||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sysctl"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec7dddc5f0fee506baf8b9fdb989e242f17e4b11c61dfbb0635b705217199eea"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"byteorder",
|
||||
"enum-as-inner",
|
||||
"libc",
|
||||
"thiserror 1.0.69",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sysctl"
|
||||
version = "0.6.0"
|
||||
|
|
@ -6486,11 +6826,12 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
|||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"flate2",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -6752,6 +7093,15 @@ dependencies = [
|
|||
"zune-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpegxl"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cba11ecd07cc23351500e840ae03841b7e4997923ec8e4832ee3ae199ea0b6b4"
|
||||
dependencies = [
|
||||
"zune-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.15.0"
|
||||
|
|
|
|||
11
Cargo.toml
11
Cargo.toml
|
|
@ -3,7 +3,7 @@ resolver = "3"
|
|||
members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
|
|
@ -11,7 +11,10 @@ repository = "https://github.com/storytold/lightcraft"
|
|||
|
||||
[workspace.dependencies]
|
||||
# internal
|
||||
lightcraft-denoise-core = { path = "crates/denoise-core" }
|
||||
lightcraft-denoise = { path = "crates/denoise", default-features = false }
|
||||
lightcraft-geom = { path = "crates/geom" }
|
||||
lightcraft-faces = { path = "crates/faces" }
|
||||
lightcraft-sysmem = { path = "crates/sysmem" }
|
||||
lightcraft-color = { path = "crates/color" }
|
||||
lightcraft-raster = { path = "crates/raster" }
|
||||
|
|
@ -30,10 +33,12 @@ lightcraft-engine = { path = "crates/engine" }
|
|||
lightcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
lightcraft-mcp = { path = "crates/mcp" }
|
||||
lightcraft-fetch = { path = "crates/fetch" }
|
||||
lightcraft-heif = { path = "crates/heif" }
|
||||
lightcraft-segment = { path = "crates/segment" }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
sha2 = "0.10"
|
||||
serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
log = "0.4"
|
||||
|
|
@ -61,7 +66,11 @@ tiff = "0.11"
|
|||
image-webp = "0.2"
|
||||
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
|
||||
|
|
|
|||
16
NOTICE
16
NOTICE
|
|
@ -15,6 +15,13 @@ list (author, source, license) is in assets/ATTRIBUTION.md.
|
|||
Inter (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt.
|
||||
Showcase photographs by Ansel Adams, Dorothea Lange and NASA (docs/images/) — public domain. See assets/ATTRIBUTION.md.
|
||||
|
||||
crates/raw/src/spectral_table.rs (lightcraft-raw) holds camera colour matrices derived from the
|
||||
camera spectral sensitivities, training reflectances and model alias list of rawtoaces-data
|
||||
(https://github.com/AcademySoftwareFoundation/rawtoaces-data, v1.1.0), an Academy Software
|
||||
Foundation project licensed under the Apache License 2.0, and from the CIE 1931 colour-matching
|
||||
functions it ships (CIE, CC-BY-4.0). The matrices were fitted by the LightCraft contributors
|
||||
(2026) with tools/spectral_camera_table.py. See assets/ATTRIBUTION.md.
|
||||
|
||||
crates/segment (lightcraft-segment) ports the SAM 3 model code of Hugging Face Transformers
|
||||
(https://github.com/huggingface/transformers, src/transformers/models/sam3 and sam3_tracker),
|
||||
Copyright The HuggingFace Team and Meta Platforms, Inc., licensed under the Apache License 2.0.
|
||||
|
|
@ -27,6 +34,15 @@ the facebook/sam3 checkpoint only when they choose to (in the app, or by hand),
|
|||
License (https://github.com/facebookresearch/sam3/blob/main/LICENSE), a separate, non-OSI
|
||||
licence. See docs/ai-masks.md.
|
||||
|
||||
AI denoise weights are not part of LightCraft and are never distributed with it.
|
||||
The default build offers user-supplied models only. The optional rawnind-model
|
||||
feature offers a separately downloaded RawNIND UtNet2 Bayer checkpoint under
|
||||
GPL-3.0 (https://www.gnu.org/licenses/gpl-3.0.html), after the user accepts its terms.
|
||||
Commercial use is subject to those terms, including applicable source, licence and
|
||||
notice obligations on redistribution. Enabling that offer in releases remains a
|
||||
maintainer decision. The ONNX reader and CPU/GPU executor are our Rust implementation;
|
||||
no darktable, RawSpeed, LibRaw or other GPL decoder/runtime code is included.
|
||||
|
||||
Builds made with craft-fonts (the optional CRAFT_FONTS_DIR build input; all official releases)
|
||||
also embed the fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md
|
||||
(the Japanese fonts BIZ UDPGothic, BIZ UDMincho and Shippori Mincho; the web build embeds only BIZ
|
||||
|
|
|
|||
55
README.md
55
README.md
|
|
@ -249,6 +249,20 @@ lightcraft --control 7980 ~/Pictures/trip
|
|||
path=out.jpg longEdge=2048` runs any chain of commands (headless, on a saved library, or against the running app)
|
||||
and prints one JSON result per command; `lightcraft-cli render in.dng -o out.jpg --set light.exposure=0.7 --preset …`.
|
||||
- **Undo for everything**, including agent actions: a slider drag (or a scripted burst of updates) is one undo step.
|
||||
- **Contact sheets (native app / CLI):** select photos, then **File → Contact Sheet PDF…**. Choose A4 or Letter, landscape,
|
||||
rows/columns and filename captions; save a paginated PDF and print it from your PDF viewer. Exports use the
|
||||
current edits, fit each photo without further cropping, and render at 150 dpi in sRGB. The background export
|
||||
supports cancellation and protects originals and XMP sidecars. From the CLI:
|
||||
|
||||
```sh
|
||||
lightcraft-cli run --import ~/Pictures/shoot library.selectAll export.contactSheet path=Contact.pdf paper=a4 columns=3 rows=4 captions=true
|
||||
```
|
||||
|
||||
`export.contactSheet` also accepts explicit `ids`, `landscape=true`, and `paper=letter`. It replaces an existing
|
||||
output only after the entire PDF succeeds. Limits: 1,000 photos, 100 pages, 256 MiB; captions are rasterized
|
||||
with the same font coverage as watermarks (CJK requires craft-fonts), with long names shortened in the middle.
|
||||
This is a contact-sheet workflow,
|
||||
not a printer-driver or custom picture-package layout editor.
|
||||
- **Every widget is addressable** (`ui.widgets`) and clickable by name, so agents operate the real UI, not a
|
||||
side door.
|
||||
- The screenshots in this README were produced end to end by the [`docs/showcase/`](docs/showcase/) scripts.
|
||||
|
|
@ -260,7 +274,8 @@ lightcraft --control 7980 ~/Pictures/trip
|
|||
|
||||
- **Pure Rust, no C.** Our own RAW decoders (DNG, Canon CR2/CR3, Sony ARW, Nikon NEF, Fujifilm RAF incl. X-Trans,
|
||||
Panasonic RW2 / Leica RWL, Pentax PEF, Olympus ORF), our own colour science, our own pipeline. JPEG, PNG, TIFF, WebP,
|
||||
PSD composites and JPEG XL open today.
|
||||
PSD composites and JPEG XL open today; HEIC/HEIF photos too with `--features heif` (HEVC is a build-time choice,
|
||||
as in PhotoCraft).
|
||||
- **Scene-referred & wide-gamut.** Linear Rec.2020 float internally, Bradford-adapted white balance, gamut mapping
|
||||
instead of clipping, a filmic shoulder for raw and pixel-exact pass-through for JPEGs you haven't touched.
|
||||
- **Resolution-independent edits.** Radii and brush sizes are relative to the image, so a 400 px preview, your
|
||||
|
|
@ -268,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.
|
||||
|
|
@ -281,18 +297,20 @@ lightcraft --control 7980 ~/Pictures/trip
|
|||
|
||||
## Feature status
|
||||
|
||||
LightCraft is young and moving fast. **Where we honestly stand** (details in the [roadmap](ROADMAP.md#where-we-stand)):
|
||||
LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (details in the [roadmap](ROADMAP.md) and
|
||||
[docs/target-app-parity.md](docs/target-app-parity.md)):
|
||||
|
||||
- **By feature count we're at ~79%** of Lightroom (core features 98%), tracked row by row in
|
||||
[docs/parity.md](docs/parity.md).
|
||||
- **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.
|
||||
- **By feature count we're at ~81%** of Lightroom (core features 98%), tracked row by row in
|
||||
[docs/parity-checklist.md](docs/parity-checklist.md).
|
||||
- **As a day-to-day Lightroom replacement we're nearer 61% for the whole product, ~49% for a typical working photographer and ~64% for
|
||||
casual use.** It's great for JPEG/DNG and most Sony / Panasonic /
|
||||
Fujifilm / Nikon / older-Canon raws on one machine; see [docs/raw-parity.md](docs/raw-parity.md) for your camera.
|
||||
- **The biggest gaps:**
|
||||
- **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCE-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.**
|
||||
- **What's next:** see [where we're going](ROADMAP.md#where-were-going).
|
||||
- **on-screen HDR display, video and the Classic Print / Book / Map modules.**
|
||||
- **What's next:** see [the ranked gaps](docs/gaps.md) and [the roadmap](docs/roadmap.md).
|
||||
|
||||
| Area | Status |
|
||||
|---|---|
|
||||
|
|
@ -302,12 +320,13 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
|
|||
| Masking: brush, linear, radial, luminance/colour range, add/subtract/intersect | ✅ (AI subject/sky use classical heuristics for now) |
|
||||
| Crop, straighten tool + auto straighten, flip, rotate, aspect ratios, overlays | ✅ |
|
||||
| Profiles (Color, Neutral, Vivid, Landscape, Portrait, Monochrome: our own looks), presets, versions, history, copy/paste/sync settings | ✅ |
|
||||
| Camera colour: DNG files use their own matrices | ✅ DNG · 🟡 own Sony/Fujifilm profiles; measured calibration database missing |
|
||||
| Camera colour: DNG files use their own matrices | ✅ DNG · 🚧 own Sony/Fujifilm profiles; measured calibration database missing |
|
||||
| Native macOS menu bar (generated from the command registry), control channel + every widget addressable, headless UI snapshots | ✅ |
|
||||
| RAW: DNG, CR2, CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), ARW, NEF (uncompressed + lossless/lossy compressed), Fujifilm RAF (uncompressed + lossless/lossy compressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed); embedded previews for every format incl. CR3 | 🟡 · CR3, compressed ORF decode ⬜ |
|
||||
| Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise, Super Resolution ⬜ |
|
||||
| RAW: DNG, CR2, CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), ARW, NEF (uncompressed + lossless/lossy compressed, incl. lossy after split), Fujifilm RAF (uncompressed + lossless/lossy compressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed, 12.8 bpp), Samsung SRW (uncompressed); embedded previews for every format incl. CR3 | 🚧 · other CR3 variants, compressed ORF / SRW and Nikon HE NEF decode ⬜ ([raw parity](docs/raw-parity.md)) |
|
||||
| Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise 🚧 (Bayer, user-installed weights), Super Resolution ⬜ |
|
||||
| Remove / Heal / Clone spots (auto source), Visualize Spots, Red Eye and Pet Eye (auto pupil detection, catchlight) | ✅ · content-aware fill, spot pin editing 🚧 |
|
||||
| Export: JPEG / PNG / TIFF / WebP / AVIF / DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | ✅ · HDR 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 | ✅ |
|
||||
|
|
@ -315,7 +334,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 [gaps](docs/gaps.md)) |
|
||||
| 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>
|
||||
|
|
@ -353,7 +372,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
|
||||
|
|
@ -443,7 +462,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
|
||||
|
||||
|
|
|
|||
344
ROADMAP.md
344
ROADMAP.md
|
|
@ -1,234 +1,166 @@
|
|||
# LightCraft roadmap
|
||||
|
||||
Milestones toward full Adobe Lightroom parity (cloud Lightroom first, then every Lightroom Classic module), with wall-clock
|
||||
estimates for continuous (24/7) agent-driven development with 4–6 parallel agents. Estimates are calibrated on the sibling
|
||||
projects (DrawCraft reached its first four milestones in ≈ 4½ h) and are revised as milestones land.
|
||||
**Stage: alpha** · next: beta, ~14 points and ~340–600 h away
|
||||
|
||||
## Where we stand
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (full re-measure against Lightroom Classic 15.5 and the installed Lightroom 9.6; restructured to the progress-docs standard) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
|
||||
|
||||
*Honest assessment, 2026-10-08. Agents: read this before picking work. The checklist in
|
||||
[`docs/parity.md`](docs/parity.md) counts features that **exist**; this section is about whether a photographer can
|
||||
**switch** from Lightroom. Update it when a gap below closes.*
|
||||
One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work
|
||||
from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in
|
||||
[docs/target-app-parity.md](docs/target-app-parity.md).
|
||||
|
||||
**In one line:** the checklist says **79.7%** (P0 98.2%, P1 95.6%, P2 41.6%), but measured by whether a working
|
||||
photographer could replace Lightroom without noticing, we are at roughly **60–70%**. The remaining gap is mostly
|
||||
**quality of results and camera coverage**, not missing buttons.
|
||||
## Headline numbers
|
||||
|
||||
Caveat on the checklist: ✅ is set by whoever lands a feature, and nobody has systematically checked rows against
|
||||
Lightroom's behaviour or output. Bugs keep turning up in ✅ areas (CR2 colour-filter phase on some Canon models #85,
|
||||
duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
|
||||
|
||||
### By dimension
|
||||
|
||||
| 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, RW2 / RWL / Panasonic RAW (every raw format, checked on 178 files from 118 Panasonic and Leica bodies), PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed ORF, Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Per-model verification is thin (~55 corpus files vs >1,000 models) |
|
||||
| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF, RW2, RAF, CR3, CR2 and PEF have guarded camera-JPEG colour estimates, with bundled ILCE-7M4, X-H2S and X-T4 profiles (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
|
||||
| **AI & computational** | ~15–20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics | Real segmentation masks (subject, sky, people, objects, landscape, depth), AI denoise, super resolution, lens blur, generative remove, faces/people, natural-language search. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) |
|
||||
| **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering |
|
||||
| **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic) | **Map view, Book, Print, Slideshow module, Web, publish services**: ~40 tracker rows ⬜ |
|
||||
| **HDR & video** | 0% | | HDR edit/display/export; video play/trim/edit/export |
|
||||
| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; English, Simplified/Traditional Chinese, Japanese, Brazilian Portuguese and German UI (see docs/localization.md); remaining technical errors; Traditional Chinese uses the Simplified Chinese font until craft-fonts has a TC face; accessibility partial; headless UI tests time out under machine load |
|
||||
|
||||
### By kind of user
|
||||
|
||||
| User | Readiness | What blocks them |
|
||||
| | Value | Kind |
|
||||
|---|---:|---|
|
||||
| JPEG / DNG shooter, single machine | ~85% | Fidelity polish, AI masks |
|
||||
| Nikon / Sony / Panasonic / older-Canon raw shooter | ~65% | Camera colour fidelity and coverage (ARW, NEF and RW2 preview estimates are only a starting point); Panasonic compacts and kit zooms also miss their embedded distortion correction |
|
||||
| Fujifilm raw shooter | ~65% | Uncompressed and lossless/lossy compressed RAF decoded; camera colour calibration remains missing. Verified on 13 bodies, including 14/16-bit GFX; see `docs/raf-compression.md` |
|
||||
| Canon CR3 / Olympus compressed-raw shooter | ~45% | CR3 lossless Bayer and version 0x100/0x200 C-RAW develop from sensor data (M50, R100, R8 verified pixel for pixel); other CRX variants and compressed ORF open as embedded JPEG previews. Camera colour and broader model verification remain |
|
||||
| Lightroom Classic power user | ~45% | Print, Book, Map, publish, tethering |
|
||||
| Relies on AI (masks, denoise) | ~25% | Object / Describe masks via optional SAM 3 (download not yet hosted); no AI denoise |
|
||||
| **Feature breadth** (checklist) | **81.1%** of 517 rows (P0 98.0%, P1 95.1%, P2 46.9%) | measured: [parity-checklist.md](docs/parity-checklist.md) recount |
|
||||
| **Feature breadth** (weighted by use) | **~77%** | estimated |
|
||||
| **Ready for real work** (full target) | **~61%** (56–66%) | estimated: weighted sum over the dimensions |
|
||||
| **Mainstream practitioner** | **~49%** (44–54%) | estimated |
|
||||
| **Essentials user** | **~64%** (59–69%) | estimated |
|
||||
| Remaining to **beta** | **~340–600 h** (Opus 5.5 agent hours, ~60% parallel) | estimated |
|
||||
| Remaining to **full parity** | **~1,000–1,800 h** (~70% parallel) | estimated |
|
||||
|
||||
## Where we're going
|
||||
Each audience with its own hours (Opus 5.5 agent wall-clock hours to ~95%):
|
||||
|
||||
Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/parity.md) → *Top gaps*.
|
||||
| Audience | Ready | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
|
||||
|---|---:|---:|---|
|
||||
| Full target (ready for real work) | **~61%** | **1,000–1,800 h** (~70% parallelizes) | raw colour, lens and camera data; AI models; Classic output modules; localization; plug-in ecosystem |
|
||||
| Mainstream practitioner | **~49%** | **450–840 h** (~60% parallelizes) | camera colour and render fidelity; CR3 / ORF / HE NEF; lens database; subject / sky masks; Lightroom XMP exchange; stability |
|
||||
| Essentials user | **~64%** | **160–300 h** (~50% parallelizes) | default raw look for popular cameras; launch stability; basic-slider feel; opening CR3 / HEIC files people send |
|
||||
|
||||
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
|
||||
and Pentax PEF have the file-local fit (matrix + tone curve from their own JPEG; CR2 and PEF since issue #310, not yet
|
||||
checked on the corpus); then ORF, then validate fidelity.
|
||||
2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): Remaining **unverified CR3 CRX** variants, compressed ORF, NEF
|
||||
lossy-after-split, sRAW. Decided 2026-10-05: write our own decoders from prose descriptions (never decoder source,
|
||||
no LGPL dependency); compressed NEF (#86) is the template.
|
||||
3. **Verified camera coverage** (LR-IMP-CAMERA-COVERAGE, P0): a CC0 sample per model in the corpus, each decoded and
|
||||
checked for plausible colour; fix per-model bugs (#85).
|
||||
4. **Render fidelity suite** (LR-BEHAV-RENDER-FIDELITY, P1): measure our output against Lightroom on the same CC0 raws
|
||||
(references stay in the local `plan/`), then tune against the numbers.
|
||||
5. **Lens profiles of our own** (LR-EDIT-OPTICS-PROFILE, P1).
|
||||
6. **AI model strategy** (maintainer decision): which permissively licensed models (or our own training) for
|
||||
segmentation masks and denoise; then pure-Rust inference. Unblocks M12 and Enhance. 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 and Enhance are still open.
|
||||
7. **Then:** HDR (Q), the Classic output modules (Print first, then Map view, Book, Slideshow), video (R), localisation
|
||||
and accessibility.
|
||||
The gap between breadth and readiness is quality and coverage, not missing buttons: camera colour is
|
||||
estimated rather than measured, CR3 decodes on three verified Canon bodies, compressed ORF and HE NEF
|
||||
open as previews, rendering is tuned by eye, AI masks are heuristics, there is no lens database, and
|
||||
tests run in CI on FreeBSD only. **Why alpha:** core workflows run end to end, but readiness is below
|
||||
the ~75% beta bar (it is above the ~40% alpha bar) and Lightroom's main inputs, raw files, still have blocking gaps (Canon, ~30% of
|
||||
raw users, decodes CR3 on three verified bodies). It passes the alpha gate: all six core workflows
|
||||
(import, cull, develop, save and reopen, batch looks, export) work end to end on macOS
|
||||
([docs/roadmap.md](docs/roadmap.md#alpha-gate)). The three readiness numbers and their weights and discounts are in
|
||||
[target-app-parity.md](docs/target-app-parity.md#ready-for-real-work-mainstream-practitioner-and-essentials-user).
|
||||
Hours are calibrated on this repo's PR history; see
|
||||
[target-app-parity.md](docs/target-app-parity.md#how-hours-are-calibrated).
|
||||
|
||||
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).
|
||||
## By dimension
|
||||
|
||||
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).
|
||||
| Dimension | Ready | Breadth | Remaining | Doc |
|
||||
|---|---:|---:|---:|---|
|
||||
| Features | ~61% | ~77% (checklist 81.1%) | 600–1,100 h | [target-app-parity](docs/target-app-parity.md#features) |
|
||||
| ↳ RAW cameras, colour, lens profiles | ~56% | 81% of 1,446 Adobe models decode, 16% verified | 255–480 h | [raw-parity](docs/raw-parity.md) |
|
||||
| UI/UX fidelity | ~70% | ~85% | 40–70 h | [ui-parity](docs/ui-parity.md) |
|
||||
| File formats (non-raw) | ~70% | ~85% | 30–55 h | [file-format-parity](docs/file-format-parity.md) |
|
||||
| Hardware | ~40% | ~50% | 40–80 h | [hardware-parity](docs/hardware-parity.md) |
|
||||
| Localization | ~52% of the 12 key languages | 7 of 12 present | 130–210 h (+145–255 h new scripts) | [localization-parity](docs/localization-parity.md) |
|
||||
| Performance | ~70% | ~80% | 30–50 h | [target-app-parity](docs/target-app-parity.md#by-dimension) |
|
||||
| Stability | ~60% | — | 30–60 h | [target-app-parity](docs/target-app-parity.md#by-dimension) |
|
||||
| Platforms | ~60% | macOS, Windows, Linux, FreeBSD, web | 25–45 h | [target-app-parity](docs/target-app-parity.md#by-dimension) |
|
||||
| Ecosystem / plug-ins | ~15% | ~25% | 40–80 h | [target-app-parity](docs/target-app-parity.md#by-dimension) |
|
||||
| AI features | ~20% | ~30% | 120–220 h (overlaps features) | [target-app-parity](docs/target-app-parity.md#ai) |
|
||||
|
||||
## Milestones
|
||||
## Features
|
||||
|
||||
**Status legend:** ✅ done · 🚧 in progress · ⬜ not started
|
||||
|
||||
| # | Milestone | Scope (summary) | Estimate (h) | Status |
|
||||
|---|---|---|---|---|
|
||||
| M0 | Skeleton + visual shell | workspace, xtask CI + layering, geom/color/raster, develop model, pipeline v0, catalog v0, engine commands, Lightroom-look UI (grid, loupe, filmstrip, Edit panel), control channel, MCP, web build | 3–5 | ✅ |
|
||||
| M1 | Library core | import (JPEG/PNG/TIFF/WebP), EXIF/XMP, persistent catalog (op log + snapshots), albums, ratings/flags/labels, filter/search/sort, thumbnail cache, 100k-photo grid | 6–10 | ✅ (100k-photo grid scale test pending) |
|
||||
| M2 | Pipeline v1 (quality) | WB temp/tint, profiles, local tone mapping (highlights/shadows), curves, HSL, point colour, colour grading, texture/clarity/dehaze, vignette, grain, B&W, auto tone/WB, histogram, before/after | 10–15 | ✅ (look tuning vs our references ongoing) |
|
||||
| M3 | RAW I | TIFF/DNG (LJ92, deflate, tiles, opcodes), demosaic (AHD/PPG/bilinear), highlight recovery, DNG colour model, CR2, NEF, ARW, embedded previews | 10–15 | 🚧 (DNG, CR2, ARW, NEF uncompressed + Huffman lossless/lossy, embedded previews ✅; NEF lossy-after-split ⬜) |
|
||||
| M4 | Crop, geometry, optics | crop tool + overlays, straighten, Upright (auto/level/vertical/full/guided), manual transforms, CA, defringe, manual lens corrections | 6–10 | ✅ |
|
||||
| M5 | Performance | source pyramids, wgpu compute pipeline (CPU oracle), draft/full renders, prefetch, budgets (16 ms slider updates on 24 MP) | 10–15 | 🚧 (stage cache, source pyramid, wgpu pipeline, prefetch, memory budget ✅; colour NR at half resolution, GPU histogram ⬜) |
|
||||
| M6 | Masking | brush, linear/radial gradients, colour/luminance/depth range, add/subtract/intersect/invert, all local adjustments, masks panel | 8–12 | 🚧 (brush/linear/radial/colour/luminance range, add/subtract/intersect, masks panel ✅; depth range, AI masks ⬜) |
|
||||
| M7 | Detail | sharpening + masking preview, luminance/colour NR, Denoise, Raw Details, Super Resolution | 6–10 | 🚧 (sharpening, luminance/colour NR ✅; AI Denoise, 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 ⬜) |
|
||||
| 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 ⬜) |
|
||||
|
||||
## Parity estimate (feature count updated 2026-10-08; effort estimate from 2026-10-02)
|
||||
|
||||
**By feature count** — from `docs/parity.md` (one row per Lightroom feature, menu item and shortcut; `cargo xtask
|
||||
parity` prints this line on every run, so it stays current):
|
||||
|
||||
| Scope | Weighted completion | Rows | 2026-10-02 |
|
||||
| Area | Ready | Breadth | Remaining |
|
||||
|---|---:|---:|---:|
|
||||
| P0 (core) | **98.2%** | 200 | 98.7% |
|
||||
| P1 (important parity) | **95.6%** | 149 | 94.4% |
|
||||
| P2 (later / AI / niche, incl. Classic modules) | **41.6%** | 160 | 29.4% |
|
||||
| **All in-scope rows** | **79.7%** | 509 | 75.6% |
|
||||
| Import and library | 80% | 90% | 25–45 h |
|
||||
| RAW decoding and camera coverage | 76% | 81% | 120–220 h |
|
||||
| Colour, camera profiles, render fidelity | 35% | 65% | 80–150 h |
|
||||
| Global develop adjustments | 70% | 93% | 30–60 h |
|
||||
| Optics, lens profiles, crop and geometry | 50% | 80% | 40–80 h |
|
||||
| Masking | 45% | 67% | 60–110 h |
|
||||
| Remove, heal, Enhance | 30% | 45% | 70–130 h |
|
||||
| Presets, profiles, versions, sync | 85% | 95% | 8–15 h |
|
||||
| Export | 75% | 89% | 15–30 h |
|
||||
| Merge and HDR | 60% | 85% | 20–35 h |
|
||||
| Views, compare, zoom, culling | 70% | 90% | 15–30 h |
|
||||
| Classic output modules (Map, Book, Slideshow, Print, publish, tethering) | 10% | 15% | 90–150 h |
|
||||
| Video | 0% | 0% | 25–45 h |
|
||||
|
||||
The 2026-10-05 count includes three new 🟡 rows that make quality gaps visible (camera colour calibration, verified
|
||||
camera coverage, render fidelity), which is why P0 dipped slightly.
|
||||
Weights and evidence: [target-app-parity.md](docs/target-app-parity.md#features).
|
||||
|
||||
✅ counts 1, 🟡 ½, ⬜ 0; out-of-scope rows (cloud sharing, Adobe accounts…) are left out.
|
||||
## Languages
|
||||
|
||||
**By remaining effort** — rows are not equal: a shortcut and the whole Book module are one row each, and what is
|
||||
left is the heavy part (AI, video, Classic output modules, undocumented raw codecs). Remaining work in **Opus agent-hours**
|
||||
(one agent working continuously; calibrated on this project — a single lead agent closed ≈ 45 tracker rows of
|
||||
UI/feature work in ≈ 5 h on 2026-10-01, and ≈ 60 more (preset import incl. `.lrtemplate` / DNG / zip and masks, smart-album
|
||||
rule editor, auto sync, keyword and label sets, import options and DNG conversion, smart previews, external-editor round
|
||||
trip, slideshow, auto import…) in ≈ 12 h on 2026-10-02; four to six parallel agents built M0–M13 in ≈ 25 active hours):
|
||||
Measured from `crates/ui-egui/locales` (share of 2,304 catalog messages translated); none is `full`
|
||||
while lower-layer errors stay English. Detail: [localization-parity.md](docs/localization-parity.md).
|
||||
|
||||
| Work package | Tracker rows | Agent-hours | Risk |
|
||||
| Language | Code | Translated | Status |
|
||||
|---|---|---:|---|
|
||||
| Remaining P0/P1 UI and library features (folder rename/move, keyword painter, people view…) | ≈ 8 | 5–10 | low |
|
||||
| Raw codecs: CR3 (CRX), compressed ORF, NEF lossy-after-split, HEIC/AVIF decode, JPEG XL DNG | LR-IMP-FORMATS | 40–80 | **high** — clean-room black-box analysis, no permissive specs |
|
||||
| Lens-profile database of our own (calibration targets, fitting, data) | LR-EDIT-OPTICS-PROFILE | 15–30 | data collection |
|
||||
| Video: playback, trim, edits, export (pure-Rust decode, ideally shared with FilmCraft) | R. Video | 20–40 | medium |
|
||||
| AI: subject / sky / background / people / object masks, object-aware remove, AI denoise, super resolution, lens blur, people & faces, natural-language search, culling | ≈ 30 | 80–150 | **high** — permissively licensed weights, pure-Rust inference, maybe training |
|
||||
| HDR editing, display, visualisation and export | Q. HDR, LR-EXP-HDR | 15–25 | medium |
|
||||
| Classic modules: Map, Book, Slideshow module, Print, publish services, tethering, soft proofing | ≈ 50 | 60–100 | medium (large, well-understood) |
|
||||
| Smaller P2 items: Enhance dialog, export to Photos, help / what's new, accessibility, localisation, sidecar variants | ≈ 15 | 12–25 | low |
|
||||
| Look tuning, performance budgets (colour NR at half resolution on CPU + GPU, 100k-photo library), packaging, hardening | — | 25–45 | medium |
|
||||
| **Total remaining** | | **≈ 270–505** | |
|
||||
| English | en | 100% | full (source) |
|
||||
| Simplified Chinese | zh-hans | 87% | partial |
|
||||
| Spanish | es | 86% | partial |
|
||||
| Hindi | hi | 0% | none (no Devanagari shaping) |
|
||||
| Arabic | ar | 0% | none (no RTL / shaping) |
|
||||
| French | fr | 88% | partial |
|
||||
| Portuguese (Brazil) | pt-br | 85% | partial |
|
||||
| Indonesian | id | 0% | none |
|
||||
| Japanese | ja | 87% | partial |
|
||||
| German | de | 91% | partial |
|
||||
| Korean | ko | 0% | none (no Hangul face) |
|
||||
| Vietnamese | vi | 0% | none |
|
||||
|
||||
Spent so far ≈ 105–145 agent-hours, so **by effort the project is ≈ 20–35% of the way to complete Lightroom + Classic
|
||||
parity**, and ≈ 45–60% of the way for cloud-Lightroom parity without AI and the Classic modules (remaining ≈ 130–255 h,
|
||||
most of it the raw codecs, lens data, video and HDR).
|
||||
Also shipped: Traditional Chinese (87%), Russian (74%), Ukrainian (99%).
|
||||
|
||||
**Wall clock:** ≈ 270–505 h for one agent working alone; with 4–6 parallel agents (≈ 70% parallel efficiency, merges and
|
||||
CI under load cost the rest) **≈ 65–125 h of continuous work**. The AI package and the raw codecs carry most of the
|
||||
uncertainty: they can finish faster if suitable permissive models / documentation turn up, or stall on licensing.
|
||||
## Upcoming
|
||||
|
||||
## Totals
|
||||
Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/gaps.md](docs/gaps.md).
|
||||
|
||||
Remaining from 2026-10-02 evening (see *Parity estimate* above for the breakdown):
|
||||
1. Camera colour of our own: measured calibration for the popular models (50–90 h)
|
||||
2. CR3 for every Canon body (25–45 h)
|
||||
3. Render-fidelity suite against Lightroom, then tuning (30–60 h)
|
||||
4. Full-resolution zoom (8–15 h)
|
||||
5. AI masks: Subject / Sky / People (40–70 h + model decision)
|
||||
6. Compressed ORF and HE NEF (28–55 h)
|
||||
7. Lens profile database of our own (40–80 h)
|
||||
8. Tests in CI on macOS, Windows and Linux (6–12 h)
|
||||
9. Per-model raw verification (20–35 h)
|
||||
|
||||
| Target | Remaining agent-hours | Wall clock, 4–6 parallel agents |
|
||||
|---|---:|---:|
|
||||
| Cloud-Lightroom parity without AI or Classic modules | ≈ 130–255 h | ≈ 35–65 h |
|
||||
| Full parity incl. AI, video and the Classic modules | ≈ 270–505 h | ≈ 65–125 h |
|
||||
## Progress log
|
||||
|
||||
The milestone estimates in the table above were made before work started and are kept for calibration (M0–M13 took
|
||||
≈ 25 active hours with parallel agents against an estimate of ≈ 110–170 h for those milestones).
|
||||
- 2026-10-10 (RAW): RAW measured model by model against Adobe's 1,446-model list (234 verified, 943
|
||||
unverified, 233 preview-only, 36 unsupported; ~84% of photographers' cameras decode, ~40% on a
|
||||
verified body). Full ~61%, mainstream ~49%, essentials ~64%; beta ~14 points away.
|
||||
- 2026-10-10 (final): full ready is a weighted sum over the dimensions (~59%; method aligned with
|
||||
the standard); mainstream practitioner ~51% (450–840 h), essentials user ~63% (160–300 h);
|
||||
alpha gate passes.
|
||||
- 2026-10-10: full re-measure for the progress-docs standard: stage alpha, ready ~55%, checklist
|
||||
81.1%; new docs (target-app-parity, gaps, roadmap, architecture, raw / file-format / hardware / UI
|
||||
/ localization parity); `docs/parity.md` renamed `docs/parity-checklist.md`. Landed today: Nikon
|
||||
lossy-after-split NEF (#633), ORF 12.8 bpp (#651), HDR gain map JPEG / PQ AVIF / float TIFF export
|
||||
(#646), Lightroom-like Auto (#628), activity stack.
|
||||
- 2026-10-09: Sony ILCE-7CR camera colour: bundled profile from 125 photos; on 26 withheld photos mean
|
||||
ΔE vs the camera JPEG 4.07 → 3.29. HEIC in release builds (#609), Samsung uncompressed SRW,
|
||||
contact-sheet PDF export, Ukrainian and French UI, release v0.5.0.
|
||||
- 2026-10-08: independent Rust CR3 lossless and version 0x100/0x200 C-RAW decoding, six exact
|
||||
full-sensor regressions on M50 / R100 / R8; guarded CR3 camera-JPEG colour fitting
|
||||
([cr3.md](docs/cr3.md)).
|
||||
- 2026-10-08: Fujifilm lossless / lossy compressed RAF: pure-Rust striped decoder, full sensor arrays
|
||||
verified on 17 CC0 and 7 supplied files (13 bodies). Fujifilm camera colour: bundled X-H2S / X-T4
|
||||
profiles from 201 / 545 photos; on 150 withheld files mean ΔE X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34.
|
||||
AI RAW denoise (#384), Lightroom catalog import, German, Spanish and Russian UI.
|
||||
- 2026-10-07: Panasonic RW2 / Leica RWL / Panasonic RAW decode in every raw format, established on
|
||||
178 CC0 files from 118 bodies; RW2 file-local camera look (140 of 174 files). Faces: recognition in
|
||||
pure Rust and People view.
|
||||
- 2026-10-03 – 10-05: community PRs (#42–#51, #60) and 25+ issues: compressed NEF, Move import,
|
||||
folder templates, Local roots and cleanup, 85k-photo grid and catalog performance, background
|
||||
compaction, failed-save errors, GPU export hardening. Added the honest *Where we stand* assessment
|
||||
and tracker rows for camera colour, camera coverage and render fidelity.
|
||||
- 2026-10-02: parity estimate added (`cargo xtask parity` prints weighted completion); milestones
|
||||
refreshed.
|
||||
- 2026-10-01: RAW II formats: RAF (uncompressed Bayer + X-Trans), RW2 (packed), PEF (incl. Huffman),
|
||||
ORF (uncompressed); embedded previews for every container incl. CR3; raw corpus test with 37 CC0
|
||||
samples.
|
||||
- 2026-09-30 (later): app running with the full Lightroom-style UI; pipeline v0; DNG / CR2 / ARW;
|
||||
README showcase. ≈ 8 h elapsed.
|
||||
- 2026-09-30: roadmap created; M0 in progress; research docs (Lightroom reference, Rust imaging
|
||||
ecosystem) complete.
|
||||
|
||||
## Risks that coding hours alone don't retire
|
||||
## Revision history
|
||||
|
||||
- **AI features** (subject/sky/people masks, generative remove) need model weights with licences we can ship; classical
|
||||
fallbacks first. No permissively licensed sky-segmentation or raw-denoise model was found — we may need to train our own.
|
||||
- **Camera colour and lens data** is a data problem: we never use Adobe's matrices, DCPs or LCPs. DNG-embedded data first,
|
||||
then our own calibration; long-tail camera/lens coverage grows over time.
|
||||
- **Raw-format sources:** decided 2026-10-05: decoders are written from *prose* format descriptions (even ones
|
||||
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.
|
||||
|
||||
## Raw format coverage and known gaps
|
||||
|
||||
Sony ILCE-7M4 downsized lossless ARW (subsampled YCbCr 4:2:0 / 4:2:2 tiles) now decodes to linear RGB;
|
||||
private 3:2 & 4:3 examples verified through native rendering & full-resolution 16-bit export.
|
||||
|
||||
CR3 lossless Bayer and version 0x100/0x200 C-RAW sensor decoding is verified pixel-exactly on Canon M50, R100 and R8; any other CR3, or one that fails to decode, opens from its embedded JPEG as before. See [`docs/cr3.md`](docs/cr3.md) for coding limits, reference provenance and colour limitations.
|
||||
|
||||
Decoded (CC0 corpus from raw.pixls.us, `cargo xtask corpus --download`, `crates/raw/tests/corpus.rs`): DNG (uncompressed,
|
||||
LJ92, lossy JPEG / Smart Previews, Deflate, float, linear), CR2, ARW (uncompressed, ARW2, LJ92; as-shot white balance and black level of pre-2017 bodies from the enciphered
|
||||
maker-note `Tag2010` and the encrypted `SR2SubIFD`, both recovered by black-box analysis, `crates/raw/src/vendor/arw.rs`), NEF/NRW uncompressed and Huffman-compressed (lossless, lossy type 1/2, 12/14-bit), RAF uncompressed 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**, lossy DNG.
|
||||
|
||||
**Camera colour matrices:** ARW, NEF, RW2, RAF, CR3, CR2 and PEF files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
|
||||
`matrix_is_fallback`) with the file's as-shot white-balance multipliers. Clean sources to evaluate next: manufacturer
|
||||
matrices stored in the files themselves (Olympus ImageProcessing `ColorMatrix`, Pentax equivalents; Panasonic RW2 files carry none) and our
|
||||
own chart-based calibration (M11.4). Adobe matrices are never used.
|
||||
|
||||
## Lightroom Classic catalog migration
|
||||
|
||||
File → Import Lightroom Catalog… reads `.lrcat` plus committed WAL pages directly through a pure-Rust
|
||||
SQLite reader. Originals stay in place; ratings, flags, labels, XMP metadata, hierarchical keywords,
|
||||
collections/sets, virtual copies and supported develop settings migrate into LightCraft. Existing edits
|
||||
are preserved by default, reimport identities are persistent, and source settings/history/snapshots are
|
||||
archived before catalog mutation. Missing originals remain available for relinking.
|
||||
|
||||
Rendering is approximate: unsupported Adobe profiles/AI/process settings are reported and archived;
|
||||
history/snapshots remain source data, and smart collections import current membership. The Lightroom
|
||||
database is read-only; LightCraft owns subsequent edits. See `docs/lightroom-catalog-import.md`.
|
||||
|
||||
## Log
|
||||
- 2026-09-30: roadmap created; M0 in progress; research docs (Lightroom reference, Rust imaging ecosystem) complete.
|
||||
- 2026-09-30 (later): app running with the full Lightroom-style UI; pipeline v0; DNG/CR2/ARW; README showcase. ≈ 8 h elapsed.
|
||||
- 2026-10-02: parity estimate added (xtask parity prints weighted completion); milestone statuses refreshed.
|
||||
- 2026-10-08: Fujifilm camera colour: guarded JPEG matching and relative WB, plus bundled X-H2S/X-T4 profiles from 201/545 photos. On 150 withheld files the pooled profiles improve mean ΔE slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); dark X-H2S samples 8.74 → 2.25. Measured calibration remains open.
|
||||
- 2026-10-08: Fujifilm lossless/lossy compressed RAF: pure-Rust striped decoder; full sensor arrays verified against a black-box reference on 17 CC0 files and 7 supplied X-H2S / X-T4 files (13 camera bodies, 14/16-bit Bayer and X-Trans). Camera colour calibration remains a separate gap.
|
||||
- 2026-10-01: RAW II formats: RAF (uncompressed Bayer + X-Trans), RW2 (packed), PEF (incl. Huffman), ORF (uncompressed); embedded previews for every container incl. CR3; raw corpus test with 37 CC0 samples.
|
||||
- 2026-10-03 – 10-05: community PRs (#42–#51, #60) and 25+ issues landed: compressed NEF, Move import, folder templates,
|
||||
Local roots and cleanup, 85k-photo grid and catalog performance, background compaction, failed-save errors, GPU
|
||||
export hardening, clippy 1.99. Added the honest *Where we stand* assessment and *Where we're going* priorities; added
|
||||
tracker rows for camera colour, camera coverage and render fidelity.
|
||||
- 2026-10-07: Panasonic RW2 / Leica RWL / Panasonic RAW decode in every raw format (compressed 4/6/8, packed 2/5/7,
|
||||
16-bit words), black levels, in-camera aspect crops and mapped-out defects established on 178 CC0 files from 118
|
||||
bodies; RW2 gets the file-local camera look (accepted on 140 of 174 files with a preview).
|
||||
- 2026-10-08: independent Rust CR3 lossless and version 0x100/0x200 C-RAW decoding, six exact full-sensor regressions on M50/R100/R8; any CR3 decode error falls back to the embedded JPEG; guarded CR3 camera-JPEG colour fitting with independent framing validation (`docs/cr3.md`). Unverified CRX variants and broader model verification remain.
|
||||
|
||||
## Chinese and Japanese interfaces, and text watermarks
|
||||
|
||||
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:
|
||||
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
|
||||
characters stay upright in top-to-bottom columns, with newlines starting columns to the
|
||||
left. This is basic lettering, without tate-chu-yoko, ruby, kinsoku, or general vertical
|
||||
OpenType shaping. The same coverage renderer serves 8/16/32-bit exports.
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | minor | RAW measured model by model; RAW decode and colour as explicit rows: full 59% → 61%, mainstream 51% → 49%, essentials 63% → 64%; beta ~14 points |
|
||||
| 2026-10-10 | minor | Full ready restored to the additive weighted sum (~59%; method aligned with the standard, no new evidence); hours per audience; beta ~16 points away |
|
||||
| 2026-10-10 | minor | Mainstream practitioner and essentials user numbers; full ready 55% → 46% (method written down); beta 340–600 h |
|
||||
| 2026-10-10 | minor | Alpha gate added (core-workflow check): passes, stays alpha |
|
||||
| 2026-10-10 | major | Restructured to the progress-docs standard; re-measured; *Where we stand*, *Parity estimate* and *Totals* moved to docs/target-app-parity.md, milestones and risks to docs/roadmap.md, raw coverage to docs/raw-parity.md, catalog migration to docs/file-format-parity.md, CJK notes to docs/localization-parity.md |
|
||||
| 2026-10-08 | minor | *Where we stand* by dimension and by kind of user |
|
||||
| 2026-10-02 | major | Parity estimate and effort table |
|
||||
| 2026-09-30 | major | Created |
|
||||
|
|
|
|||
|
|
@ -25,7 +25,14 @@ serde_json = { workspace = true }
|
|||
dhat = { workspace = true, optional = true }
|
||||
|
||||
[features]
|
||||
default = ["denoise"]
|
||||
denoise = ["lightcraft-engine/denoise"]
|
||||
rawnind-model = ["denoise", "lightcraft-engine/rawnind-model"]
|
||||
# Heap profiling: `cargo run --release -p lightcraft-cli --features dhat-heap -- snapshot …` counts
|
||||
# every allocation (live/peak bytes in `library.memory` / `ui.inspect`) and writes dhat-heap.json
|
||||
# (open it in DHAT's viewer: allocation sites by bytes at the peak). Slows allocation-heavy work.
|
||||
dhat-heap = ["dep:dhat"]
|
||||
|
||||
# Open HEIC/HEIF photos (the optional lightcraft-heif crate). Not default so distributors choose
|
||||
# it (HEVC patents); build with `--features heif` to include it.
|
||||
heif = ["lightcraft-codecs/heif"]
|
||||
|
|
|
|||
|
|
@ -342,7 +342,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
|||
eprintln!("lightcraft-cli mcp: serving MCP on stdio ({})", backend.describe());
|
||||
let mut server = Server::new(backend).with_command_tools(!compact);
|
||||
let stdin = std::io::stdin();
|
||||
server.serve(BufReader::new(stdin.lock()), std::io::stdout().lock()).map_err(|e| e.to_string())
|
||||
server.serve(BufReader::new(stdin), std::io::stdout()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn merge(args: &[String]) -> Result<(), String> {
|
||||
|
|
@ -391,7 +391,7 @@ fn merge(args: &[String]) -> Result<(), String> {
|
|||
}
|
||||
i += 1;
|
||||
}
|
||||
let mut s = Session::new().with_fs();
|
||||
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();
|
||||
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"))?;
|
||||
|
|
@ -728,19 +728,19 @@ fn snapshot(args: &[String]) -> Result<(), String> {
|
|||
if script.is_none() && output.is_none() {
|
||||
return Err("snapshot: give -o OUT.png and/or --script FILE".into());
|
||||
}
|
||||
if !(scale > 0.0 && size[0] >= 1.0 && size[1] >= 1.0) {
|
||||
return Err("snapshot: bad --size/--scale".into());
|
||||
}
|
||||
lightcraft_ui_egui::headless::viewport_pixels(size, scale).map_err(|e| format!("snapshot: bad --size/--scale: {e}"))?;
|
||||
let t0 = Instant::now();
|
||||
let mut session = match &library {
|
||||
Some(dir) => {
|
||||
let mut s = Session::new().with_fs();
|
||||
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(),
|
||||
None => Session::new().with_fs(),
|
||||
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())?;
|
||||
if !files.is_empty() {
|
||||
let ti = Instant::now();
|
||||
let r = session.execute("library.import", &json!({"paths": expand_paths(&files)})).map_err(|e| e.to_string())?;
|
||||
|
|
@ -755,7 +755,11 @@ fn snapshot(args: &[String]) -> Result<(), String> {
|
|||
})),
|
||||
..Default::default()
|
||||
};
|
||||
let app = lightcraft_ui_egui::LightcraftApp::new(session, services);
|
||||
let mut app = lightcraft_ui_egui::LightcraftApp::new(session, services);
|
||||
// Snapshot sessions promise no GPU: disable photo compute as well as the compositor
|
||||
// before the first frame can start background adapter discovery.
|
||||
// This UI state is session-local: the CLI neither loads nor saves desktop ui.json.
|
||||
app.ui.settings.gpu = false;
|
||||
let mut h = Headless::new(app, size, scale);
|
||||
let timeout = Duration::from_secs(60);
|
||||
let mut shots = 0usize;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -233,6 +273,55 @@ fn snapshot_script_failure_exits_non_zero() {
|
|||
assert_eq!((d.width, d.height), (320, 200));
|
||||
}
|
||||
|
||||
#[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.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)
|
||||
.args(["snapshot", "--script", script.to_str().unwrap(), "-o", out.to_str().unwrap(), "--size", size, "--scale", scale])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
let replies: Vec<Value> = String::from_utf8_lossy(&o.stdout).lines().map(|line| serde_json::from_str(line).unwrap()).collect();
|
||||
assert!(replies.iter().all(|reply| reply["ok"] == true), "{replies:?}");
|
||||
assert!(replies[1]["result"]["pixelsPerPoint"].as_f64().unwrap() > scale.parse::<f64>().unwrap());
|
||||
let decoded = lightcraft_codecs::decode(&std::fs::read(out).unwrap(), Default::default()).unwrap();
|
||||
assert_eq!((decoded.width, decoded.height), expected);
|
||||
}
|
||||
}
|
||||
|
||||
/// Headless snapshots default to CPU photo rendering as well as CPU UI rasterization.
|
||||
#[test]
|
||||
fn snapshot_defaults_to_cpu_without_gpu_environment_overrides() {
|
||||
let script = tmp("default-cpu.jsonl");
|
||||
std::fs::write(
|
||||
&script,
|
||||
format!("{}\n{}\n", json!({"method": "engine.execute", "params": {"command": "app.gpu"}}), json!({"method": "ui.screenshot"})),
|
||||
)
|
||||
.unwrap();
|
||||
let image = tmp("default-cpu.png");
|
||||
let output = Command::new(BIN)
|
||||
.env_remove("LIGHTCRAFT_GPU")
|
||||
.env_remove("LIGHTCRAFT_GPU_BACKEND")
|
||||
.env_remove("WGPU_BACKEND")
|
||||
.args(["snapshot", "--demo", "--script", script.to_str().unwrap(), "-o", image.to_str().unwrap(), "--size", "480x320"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "status {}: {}", output.status, String::from_utf8_lossy(&output.stderr));
|
||||
let replies: Vec<Value> = String::from_utf8_lossy(&output.stdout).lines().map(|line| serde_json::from_str(line).unwrap()).collect();
|
||||
assert!(replies.iter().all(|reply| reply["ok"] == true), "{replies:?}");
|
||||
let gpu = &replies[0]["result"];
|
||||
assert_eq!(gpu["enabled"], false, "{gpu}");
|
||||
assert_eq!(gpu["available"], false, "{gpu}");
|
||||
assert_eq!(gpu["adapter"], Value::Null, "no device is created: {gpu}");
|
||||
assert!(gpu["reason"].as_str().is_some_and(|reason| reason.contains("preference")), "{gpu}");
|
||||
let decoded = lightcraft_codecs::decode(&std::fs::read(image).unwrap(), Default::default()).unwrap();
|
||||
assert_eq!((decoded.width, decoded.height), (480, 320));
|
||||
}
|
||||
|
||||
/// Issue #136: with the GPU switched off from the environment the UI starts and renders on the CPU,
|
||||
/// without creating a GPU device (no driver is loaded), and says why.
|
||||
#[test]
|
||||
|
|
@ -412,3 +501,79 @@ fn warnings_are_logged_on_stderr() {
|
|||
let off = run("off");
|
||||
assert!(!off.contains("names no known backend"), "{off}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_rejects_invalid_dimensions_before_creating_an_image() {
|
||||
for (index, (size, scale)) in
|
||||
[("NaNx100", "1"), ("100x100", "inf"), ("9000x9000", "1"), ("9000x100", "2"), ("100x100", "0.001")].into_iter().enumerate()
|
||||
{
|
||||
let image = tmp(&format!("invalid-viewport-{index}.png"));
|
||||
let output = Command::new(BIN).args(["snapshot", "--size", size, "--scale", scale, "-o"]).arg(&image).output().unwrap();
|
||||
assert!(!output.status.success());
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("snapshot: bad --size/--scale"), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
assert!(!image.exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_preserves_fractional_scale_rounding() {
|
||||
let image = tmp("fractional-scale-rounding.png");
|
||||
let output =
|
||||
Command::new(BIN).env("LIGHTCRAFT_GPU", "0").args(["snapshot", "--size", "345x200", "--scale", "0.9", "-o"]).arg(&image).output().unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
let decoded = lightcraft_codecs::decode(&std::fs::read(image).unwrap(), Default::default()).unwrap();
|
||||
assert_eq!((decoded.width, decoded.height), (311, 180));
|
||||
}
|
||||
|
||||
/// Snapshot's CPU setting must never replace the desktop preference, including with --library.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn snapshot_leaves_desktop_gpu_preferences_untouched() {
|
||||
let dir = tmp("snapshot-desktop-preferences");
|
||||
let library = dir.join("library");
|
||||
let mut session = lightcraft_engine::Session::new().with_fs();
|
||||
session.open_library(&library, true).unwrap();
|
||||
drop(session);
|
||||
let config = dir.join("config");
|
||||
let app_config = config.join("lightcraft");
|
||||
std::fs::create_dir_all(&app_config).unwrap();
|
||||
let ui_path = app_config.join("ui.json");
|
||||
let mut ui = lightcraft_ui_egui::UiState::default();
|
||||
ui.settings.gpu = true;
|
||||
let saved = serde_json::to_vec_pretty(&ui).unwrap();
|
||||
std::fs::write(&ui_path, &saved).unwrap();
|
||||
let script = dir.join("preferences.jsonl");
|
||||
std::fs::write(
|
||||
&script,
|
||||
format!("{}\n{}\n", json!({"method": "engine.execute", "params": {"command": "app.gpu"}}), json!({"method": "ui.inspect"})),
|
||||
)
|
||||
.unwrap();
|
||||
for gpu_environment in [None, Some("1")] {
|
||||
let image = dir.join(format!("{}.png", gpu_environment.unwrap_or("default")));
|
||||
let mut cmd = Command::new(BIN);
|
||||
cmd.env("XDG_CONFIG_HOME", &config).env_remove("LIGHTCRAFT_GPU").env_remove("LIGHTCRAFT_GPU_BACKEND").env_remove("WGPU_BACKEND");
|
||||
if let Some(value) = gpu_environment {
|
||||
cmd.env("LIGHTCRAFT_GPU", value);
|
||||
}
|
||||
let output = cmd
|
||||
.args(["snapshot", "--library"])
|
||||
.arg(&library)
|
||||
.args(["--script"])
|
||||
.arg(&script)
|
||||
.args(["-o"])
|
||||
.arg(&image)
|
||||
.args(["--size", "480x320"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
let replies: Vec<Value> = String::from_utf8_lossy(&output.stdout).lines().map(|line| serde_json::from_str(line).unwrap()).collect();
|
||||
assert!(replies.iter().all(|reply| reply["ok"] == true), "{replies:?}");
|
||||
assert_eq!(replies[0]["result"]["enabled"], false);
|
||||
assert_eq!(replies[0]["result"]["available"], false);
|
||||
assert_eq!(replies[0]["result"]["adapter"], Value::Null);
|
||||
assert_eq!(replies[1]["result"]["ui"]["settings"]["gpu"], false);
|
||||
assert_eq!(std::fs::read(&ui_path).unwrap(), saved, "snapshot changed the desktop preferences");
|
||||
assert!(!app_config.join("ui.json.tmp").exists());
|
||||
assert!(image.exists());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,3 +71,9 @@ web-sys = { workspace = true, features = [
|
|||
"WorkerType",
|
||||
"WritableStream",
|
||||
] }
|
||||
|
||||
[features]
|
||||
# Open HEIC/HEIF photos (the optional lightcraft-heif crate). Not default so distributors choose
|
||||
# it (HEVC patents); build with `--features heif` to include it. On wasm32 panics abort, so
|
||||
# heic-rs's `catch_unwind` guard does not apply there.
|
||||
heif = ["lightcraft-codecs/heif"]
|
||||
|
|
|
|||
|
|
@ -24,13 +24,21 @@
|
|||
<script type="module">
|
||||
import init, { start, command } from "./lightcraft_web.js";
|
||||
(async () => {
|
||||
// The Chinese font is a separate file: embedding it would exceed the host's WASM size limit.
|
||||
// Font-free development builds can still run, with the same missing-glyph warning as before.
|
||||
const chineseFont = fetch("./lightcraft_zh_hans.otf")
|
||||
.then(async response => response.ok ? new Uint8Array(await response.arrayBuffer()) : new Uint8Array())
|
||||
.catch(() => new Uint8Array());
|
||||
// Compile once; the render workers (worker.js) instantiate the same compiled module.
|
||||
const module = await WebAssembly.compileStreaming(fetch("./lightcraft_web_bg.wasm"));
|
||||
const [module, font] = await Promise.all([
|
||||
WebAssembly.compileStreaming(fetch("./lightcraft_web_bg.wasm")),
|
||||
chineseFont,
|
||||
]);
|
||||
globalThis.lightcraftModule = module;
|
||||
await init({ module_or_path: module });
|
||||
// automation: `await lightcraft.command("library.info", "{}")` → JSON text
|
||||
globalThis.lightcraft = { command };
|
||||
start();
|
||||
start(font);
|
||||
})().catch((e) => {
|
||||
console.error(e);
|
||||
const el = document.getElementById("lightcraft_loading");
|
||||
|
|
|
|||
|
|
@ -11,8 +11,9 @@
|
|||
//!
|
||||
//! Restoring never deletes anything: the library files go to a new library folder in browser
|
||||
//! storage, originals that aren't stored yet are added (they're named by content, so existing
|
||||
//! ones are identical), and only then does a small pointer file ([`ACTIVE_LIBRARY`]) switch to
|
||||
//! the restored library. The library that was there before stays in storage.
|
||||
//! ones are identical; a restored one must match its content hash, see [`verify_original`]),
|
||||
//! and only then does a small pointer file ([`ACTIVE_LIBRARY`]) switch to the restored library.
|
||||
//! The library that was there before stays in storage.
|
||||
//!
|
||||
//! No zip64: a backup holds at most [`MAX_BYTES`] and 65 535 entries.
|
||||
|
||||
|
|
@ -241,6 +242,16 @@ pub fn verify(entry: &ZipEntry, data: &[u8]) -> Result<(), String> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Check that an original's bytes are the photo its storage key names: originals are stored
|
||||
/// under their content hash, and a restore that kept bytes under another photo's hash would
|
||||
/// give that photo's catalog record a different image (the zip checksum can't tell).
|
||||
pub fn verify_original(entry: &ZipEntry, hash: &str, data: &[u8]) -> Result<(), String> {
|
||||
if crate::store::content_hash(data) != hash {
|
||||
return Err(format!("{}: the file doesn't match its content hash (damaged or altered backup)", entry.name));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A name for the restored library's folder.
|
||||
pub fn restored_dir_name(now_ms: f64) -> String {
|
||||
format!("library-restored-{}", now_ms.max(0.0) as u64)
|
||||
|
|
@ -283,6 +294,27 @@ mod tests {
|
|||
assert!(find_central(b"not a zip at all, just some text").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn originals_must_match_their_content_hash() {
|
||||
let red: &[u8] = b"red corner photo";
|
||||
let blue: &[u8] = b"blue corner photo";
|
||||
let hash = crate::store::content_hash(red);
|
||||
let name = original_entry(&hash, "IMG 1.png");
|
||||
let key = restore_key(&name, "d").unwrap();
|
||||
let key_hash = key.strip_prefix("originals/").unwrap();
|
||||
assert_eq!(key_hash, hash);
|
||||
// the entry's own bytes pass; other bytes under the same name (valid zip CRC) don't
|
||||
let zip = build(&[(name.as_str(), blue)]);
|
||||
let tail = &zip[zip.len() - tail_len(zip.len() as u64) as usize..];
|
||||
let (off, size) = find_central(tail).unwrap();
|
||||
let entries = parse_central(&zip[off as usize..(off + size) as usize]).unwrap();
|
||||
let entry = &entries[0];
|
||||
verify(entry, blue).unwrap();
|
||||
let err = verify_original(entry, key_hash, blue).unwrap_err();
|
||||
assert!(err.contains(&name), "{err}");
|
||||
verify_original(entry, key_hash, red).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_keys_are_safe() {
|
||||
assert_eq!(restore_key("library/catalog.snap", "library-restored-1").as_deref(), Some("library-restored-1/catalog.snap"));
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ use wasm_bindgen_futures::JsFuture;
|
|||
use crate::backend::Backend;
|
||||
use crate::backup::{
|
||||
ACTIVE_LIBRARY, README, ZipWriter, data_offset, find_central, original_entry, parse_central, restore_key, restored_dir_name, tail_len,
|
||||
valid_hash, verify,
|
||||
valid_hash, verify, verify_original,
|
||||
};
|
||||
use crate::files::{Files, LIBRARY_FILES};
|
||||
use crate::store::storage_key;
|
||||
|
|
@ -93,25 +93,41 @@ pub fn install_panic_hook() {
|
|||
const TAB_LOCK: &str = "lightcraft-library";
|
||||
|
||||
/// Hold the library lock for as long as this page lives (Web Locks API). `Some(true)`: ours;
|
||||
/// `Some(false)`: another tab of this site has it; `None`: the browser can't tell (no Web Locks).
|
||||
/// `Some(false)`: another tab of this site has it; `None`: the browser can't tell (no Web Locks, or
|
||||
/// it refused the request).
|
||||
pub async fn acquire_tab_lock() -> Option<bool> {
|
||||
let nav = Reflect::get(&js_sys::global(), &"navigator".into()).ok()?;
|
||||
let locks = Reflect::get(&nav, &"locks".into()).ok().filter(|l| !l.is_undefined() && !l.is_null())?;
|
||||
let request: js_sys::Function = Reflect::get(&locks, &"request".into()).ok()?.dyn_into().ok()?;
|
||||
let opts = js_sys::Object::new();
|
||||
Reflect::set(&opts, &"ifAvailable".into(), &true.into()).ok()?;
|
||||
let granted = js_sys::Promise::new(&mut |resolve, _reject| {
|
||||
let granted = js_sys::Promise::new(&mut |resolve, reject| {
|
||||
let cb = Closure::once_into_js(move |lock: JsValue| -> JsValue {
|
||||
let ours = !lock.is_null() && !lock.is_undefined();
|
||||
let _ = resolve.call1(&JsValue::NULL, &ours.into());
|
||||
// keep the lock until the page goes away: a promise that never settles
|
||||
if ours { js_sys::Promise::new(&mut |_, _| {}).into() } else { JsValue::NULL }
|
||||
});
|
||||
if let Err(e) = request.call3(&locks, &TAB_LOCK.into(), &opts, &cb) {
|
||||
log::warn!("Web Locks request failed: {e:?}");
|
||||
// a refused request never calls `cb`: settle with its error instead of waiting forever
|
||||
match request.call3(&locks, &TAB_LOCK.into(), &opts, &cb) {
|
||||
Ok(pending) => {
|
||||
let catch = Reflect::get(&pending, &"catch".into()).ok().and_then(|c| c.dyn_into::<js_sys::Function>().ok());
|
||||
if let Some(catch) = catch {
|
||||
let _ = catch.call1(&pending, &reject);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = reject.call1(&JsValue::NULL, &e);
|
||||
}
|
||||
}
|
||||
});
|
||||
JsFuture::from(granted).await.ok().map(|v| v.is_truthy())
|
||||
match JsFuture::from(granted).await {
|
||||
Ok(v) => Some(v.is_truthy()),
|
||||
Err(e) => {
|
||||
log::warn!("Web Locks request failed, starting without the one-tab guard: {e:?}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Offer a blob as a download named `name`.
|
||||
|
|
@ -208,6 +224,9 @@ pub async fn restore(file: web_sys::File, backend: Backend) -> Result<String, St
|
|||
let start = data_offset(e, &local)?;
|
||||
let data = read_range(&file, start, start + e.size).await?;
|
||||
verify(e, &data)?;
|
||||
if let Some(hash) = key.strip_prefix("originals/") {
|
||||
verify_original(e, hash, &data)?;
|
||||
}
|
||||
backend.write(&key, &data).await.map_err(|err| format!("storing {}: {err}", e.name))?;
|
||||
written += 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -205,11 +206,16 @@ fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen
|
|||
open_picker(originals.clone(), backend.clone(), ctx.clone());
|
||||
Vec::new() // files arrive asynchronously and are imported on a later frame
|
||||
})),
|
||||
pick_denoise_model: None,
|
||||
// Preset files: browser pickers are asynchronous; not wired on the web yet.
|
||||
pick_preset_files: None,
|
||||
pick_tracklog: None,
|
||||
pick_lightroom_catalog: None,
|
||||
// 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)),
|
||||
|
|
@ -375,7 +381,7 @@ struct WebApp {
|
|||
}
|
||||
|
||||
impl WebApp {
|
||||
fn new(cc: &eframe::CreationContext<'_>, boot: Boot) -> Self {
|
||||
fn new(cc: &eframe::CreationContext<'_>, boot: Boot, chinese_font: Vec<u8>) -> Self {
|
||||
let Boot { opts, backend, files, index, lib_dir } = boot;
|
||||
let originals = Originals::default();
|
||||
let t = perf_now();
|
||||
|
|
@ -420,7 +426,8 @@ impl WebApp {
|
|||
// previews are large (≤ 2560 px, f32): keep few in a 32-bit address space
|
||||
session.media.preview_capacity = 3;
|
||||
let frozen = Rc::new(Cell::new(false));
|
||||
let mut app = LightcraftApp::new(session, services(originals.clone(), backend.clone(), files.clone(), frozen.clone(), cc.egui_ctx.clone()));
|
||||
let mut app = LightcraftApp::new(session, services(originals.clone(), backend.clone(), files.clone(), frozen.clone(), cc.egui_ctx.clone()))
|
||||
.with_chinese_font(chinese_font);
|
||||
let ui_written = files.get("ui.json").unwrap_or_default();
|
||||
if let Ok(ui) = serde_json::from_slice::<UiState>(&ui_written) {
|
||||
app.ui = ui;
|
||||
|
|
@ -653,12 +660,15 @@ impl eframe::App for WebApp {
|
|||
}
|
||||
}
|
||||
|
||||
/// Start the app (called by `index.html` after instantiating the module).
|
||||
/// Start the app with the separately fetched Simplified Chinese font (empty in font-free builds).
|
||||
#[wasm_bindgen]
|
||||
pub fn start() {
|
||||
pub fn start(chinese_font: Vec<u8>) {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
safety::install_panic_hook();
|
||||
log::info!("lightcraft: wasm instantiated at {:.0} ms", perf_now());
|
||||
if chinese_font.is_empty() {
|
||||
log::warn!("lightcraft: no Simplified Chinese font supplied; Chinese-only glyphs will show as boxes");
|
||||
}
|
||||
let opts = Options::from_url();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
// one tab per library: two would each keep their own copy and overwrite each other's saves
|
||||
|
|
@ -679,7 +689,7 @@ pub fn start() {
|
|||
};
|
||||
let boot = boot(opts).await;
|
||||
let runner = eframe::WebRunner::new();
|
||||
let r = runner.start(canvas, eframe::WebOptions::default(), Box::new(move |cc| Ok(Box::new(WebApp::new(cc, boot))))).await;
|
||||
let r = runner.start(canvas, eframe::WebOptions::default(), Box::new(move |cc| Ok(Box::new(WebApp::new(cc, boot, chinese_font))))).await;
|
||||
match r {
|
||||
Ok(()) => {
|
||||
if let Some(el) = window().and_then(|w| w.document()).and_then(|d| d.get_element_by_id("lightcraft_loading")) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -368,7 +369,11 @@ mod tests {
|
|||
let a = core.render(&wire, None).unwrap();
|
||||
let b = job.run().rendered.unwrap();
|
||||
assert_eq!((a.image.width, a.image.height), (160, 120));
|
||||
assert_eq!(a.image.data, b.image.data);
|
||||
assert_eq!((b.image.width, b.image.height), (160, 120));
|
||||
// the worker renders on the CPU; the engine job uses a GPU when the machine has one, which
|
||||
// stays within docs/gpu-pipeline.md's bound (≤ 3 LSB per channel) rather than bit-exact
|
||||
let max = a.image.data.iter().zip(&b.image.data).flat_map(|(p, q)| (0..3).map(move |c| p[c].abs_diff(q[c]))).max();
|
||||
assert!(max.is_some_and(|m| m <= 3), "max channel difference {max:?}");
|
||||
}
|
||||
|
||||
// a job from before windows existed has 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;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,14 @@ default-run = "lightcraft"
|
|||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
default = ["denoise"]
|
||||
denoise = ["lightcraft-engine/denoise"]
|
||||
rawnind-model = ["denoise", "lightcraft-engine/rawnind-model"]
|
||||
# Open HEIC/HEIF photos (the optional lightcraft-heif crate). Not default so distributors choose
|
||||
# it (HEVC patents); build with `--features heif` to include it.
|
||||
heif = ["lightcraft-codecs/heif"]
|
||||
|
||||
[dependencies]
|
||||
lightcraft-engine = { workspace = true, features = ["sam"] }
|
||||
lightcraft-sysmem = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -52,8 +52,15 @@ struct App(LightcraftApp, PrefsWriter, #[cfg(target_os = "macos")] Option<native
|
|||
impl eframe::App for App {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(m) = self.2.as_mut() {
|
||||
m.update(&mut self.0, ctx);
|
||||
if let Some(m) = self.2.as_mut()
|
||||
&& m.update(&mut self.0, ctx)
|
||||
&& lightcraft_ui_egui::panels::notices::may_close(&mut self.0)
|
||||
{
|
||||
// Quit from the menu bar: save exactly what closing the window saves (settings and
|
||||
// library, after the unsaved-changes check; otherwise its prompt shows), then end the
|
||||
// process instead of closing the viewport while AppKit is terminating (PR #444).
|
||||
eframe::App::on_exit(self);
|
||||
std::process::exit(0);
|
||||
}
|
||||
self.0.logic(ctx);
|
||||
self.1.tick(&mut self.0, ctx);
|
||||
|
|
@ -71,16 +78,26 @@ 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).
|
||||
/// with Vulkan in the set, wgpu loads the Vulkan driver even when it then picks DX12) — and DX12
|
||||
/// shaders compiled with FXC, never a stray `dxcompiler.dll` (issue #471).
|
||||
fn window_wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration {
|
||||
let mut c = eframe::egui_wgpu::WgpuConfiguration::default();
|
||||
if let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = &mut c.wgpu_setup {
|
||||
n.instance_descriptor.backends = lightcraft_engine::gpu::backend::window_backends();
|
||||
n.instance_descriptor.backend_options = lightcraft_engine::gpu::backend::backend_options();
|
||||
}
|
||||
c
|
||||
}
|
||||
|
|
@ -124,8 +141,25 @@ 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::camera_profiles::config_dir()
|
||||
lightcraft_engine::config::config_dir()
|
||||
}
|
||||
|
||||
/// `<config>/ui.json`, the saved UI state and app settings — `None` when nothing is read or
|
||||
|
|
@ -281,6 +315,39 @@ fn windows_open_url_command(url: &str) -> std::process::Command {
|
|||
c
|
||||
}
|
||||
|
||||
/// Explorer's `/select,<path>` switch (issue #272). Explorer parses its own command line and only
|
||||
/// selects the file when the path after the comma is quoted (`/select,"C:\My Photos\a.jpg"`),
|
||||
/// spelled with backslashes and free of the verbatim prefix (`\\?\`, `\\?\UNC\`) that
|
||||
/// `canonicalize` adds; given anything else it opens Documents. Windows file names can't contain
|
||||
/// `"`; a path that does anyway (a hostile catalog) has them dropped, so the raw command line
|
||||
/// always holds exactly this one argument.
|
||||
fn explorer_select_arg(path: &str) -> String {
|
||||
let path = path.replace('/', "\\").replace('"', "");
|
||||
let path = match path.strip_prefix(r"\\?\UNC\") {
|
||||
Some(unc) => format!(r"\\{unc}"),
|
||||
None => path.strip_prefix(r"\\?\").unwrap_or(path.as_str()).to_string(),
|
||||
};
|
||||
format!("/select,\"{path}\"")
|
||||
}
|
||||
|
||||
/// `explorer /select,"<path>"`. Rust's own argument quoting wraps the whole switch in quotes
|
||||
/// (`"/select,C:\My Photos\a.jpg"`) whenever the path has a space, which Explorer doesn't
|
||||
/// recognise, so on Windows the switch goes on the command line exactly as built.
|
||||
fn explorer_select_command(path: &str) -> std::process::Command {
|
||||
let mut c = std::process::Command::new("explorer");
|
||||
let arg = explorer_select_arg(path);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
c.raw_arg(arg);
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
c.arg(arg);
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// The platform services; `ctx` repaints when a file dialog closes, and `log_file` is
|
||||
/// for Help ▸ Open Log Folder.
|
||||
fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services {
|
||||
|
|
@ -368,8 +435,7 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
|
|||
let status = if cfg!(target_os = "macos") {
|
||||
std::process::Command::new("open").args(["-R", path]).status()
|
||||
} else if cfg!(target_os = "windows") {
|
||||
let win_path = path.replace('/', "\\");
|
||||
std::process::Command::new("explorer").arg(format!("/select,{win_path}")).status()
|
||||
explorer_select_command(path).status()
|
||||
} else {
|
||||
let dir = std::path::Path::new(path).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_else(|| ".".into());
|
||||
std::process::Command::new("xdg-open").arg(dir).status()
|
||||
|
|
@ -387,6 +453,13 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
|
|||
.map(|p| p.to_string_lossy().to_string())
|
||||
.collect()
|
||||
})),
|
||||
pick_denoise_model: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("ONNX model"), &["onnx"])
|
||||
.pick_file()
|
||||
.map(|p| vec![p.to_string_lossy().into_owned()])
|
||||
.unwrap_or_default()
|
||||
})),
|
||||
pick_preset_files: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Import Presets"))
|
||||
|
|
@ -400,6 +473,14 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
|
|||
.map(|p| p.to_string_lossy().to_string())
|
||||
.collect()
|
||||
})),
|
||||
pick_model_file: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Add a Face Recognition Model"))
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Face models (ONNX)"), &["onnx"])
|
||||
.pick_file()
|
||||
.map(|p| vec![p.to_string_lossy().to_string()])
|
||||
.unwrap_or_default()
|
||||
})),
|
||||
pick_tracklog: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Auto-Tag from Tracklog"))
|
||||
|
|
@ -408,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"))
|
||||
|
|
@ -416,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"))
|
||||
|
|
@ -446,22 +552,29 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
|
|||
}
|
||||
}
|
||||
|
||||
/// The library session with the folder face models are kept in (`<config>/models`).
|
||||
fn open_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: bool) -> (Session, Option<LibraryProblem>) {
|
||||
let (mut s, problem) = open_library_session(in_memory, dir, seed_demo);
|
||||
s.face_models_dir = lightcraft_engine::config::default_face_models_dir();
|
||||
(s, problem)
|
||||
}
|
||||
|
||||
/// The persistent library session, or with `--memory` an in-memory one (demo photos).
|
||||
///
|
||||
/// If the library can't be opened the session is empty and in memory — never seeded with the
|
||||
/// demo photos, never written anywhere — and the problem is returned: the window then says so
|
||||
/// and offers Try Again / Choose Another Library… / Continue Without Saving / Quit (issue #100).
|
||||
fn open_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: bool) -> (Session, Option<LibraryProblem>) {
|
||||
fn open_library_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: bool) -> (Session, Option<LibraryProblem>) {
|
||||
if in_memory {
|
||||
return (if seed_demo { Session::with_demo() } else { Session::new() }.with_fs().with_system_clock(), None);
|
||||
return (if seed_demo { Session::with_demo() } else { Session::new() }.with_fs().with_default_denoise_models().with_system_clock(), None);
|
||||
}
|
||||
let unopened = || Session::new().with_fs().with_system_clock();
|
||||
let unopened = || Session::new().with_fs().with_default_denoise_models().with_system_clock();
|
||||
let Some(dir) = dir else {
|
||||
log::warn!("no library location (set --library or LIGHTCRAFT_LIBRARY)");
|
||||
return (unopened(), Some(LibraryProblem::new("", "There is no home folder to keep the library in. Choose a folder for it.")));
|
||||
};
|
||||
let t0 = std::time::Instant::now();
|
||||
let mut s = Session::new().with_fs().with_system_clock();
|
||||
let mut s = Session::new().with_fs().with_default_denoise_models().with_system_clock();
|
||||
match s.open_library(&dir, seed_demo) {
|
||||
Ok(r) => {
|
||||
let (replayed, torn) = (r.replayed, r.torn_bytes);
|
||||
|
|
@ -876,6 +989,16 @@ mod tests {
|
|||
assert!(!b.is_empty());
|
||||
}
|
||||
|
||||
/// Issue #471: the window's DX12 shaders compile with FXC — wgpu's default loaded any
|
||||
/// `dxcompiler.dll` on the search path, and one without `dxil.dll` kept the window from opening.
|
||||
#[test]
|
||||
fn window_compiles_dx12_shaders_with_fxc() {
|
||||
let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = window_wgpu_options().wgpu_setup else { panic!("expected CreateNew") };
|
||||
if std::env::var_os("WGPU_DX12_COMPILER").is_none() {
|
||||
assert!(matches!(n.instance_descriptor.backend_options.dx12.shader_compiler, eframe::wgpu::Dx12Compiler::Fxc));
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue #234: Windows opens links with the URL protocol handler (the default browser), not
|
||||
/// File Explorer, and passes the whole map link, `?`, `&` and `#` included, as one argument.
|
||||
#[test]
|
||||
|
|
@ -887,6 +1010,23 @@ mod tests {
|
|||
assert_eq!(args, ["url.dll,FileProtocolHandler", url]);
|
||||
}
|
||||
|
||||
/// Issue #272: Explorer gets the file to select quoted after `/select,`, with backslashes and
|
||||
/// without the verbatim prefix, even when the path has spaces (else it opens Documents).
|
||||
#[test]
|
||||
fn explorer_select_quotes_the_path() {
|
||||
assert_eq!(explorer_select_arg(r"C:\Users\Me\My Photos\IMG 1.jpg"), r#"/select,"C:\Users\Me\My Photos\IMG 1.jpg""#);
|
||||
assert_eq!(explorer_select_arg("D:/Example/Photos/IMG_0001.CR3"), r#"/select,"D:\Example\Photos\IMG_0001.CR3""#);
|
||||
assert_eq!(explorer_select_arg(r"\\server\share\My Photos\a.jpg"), r#"/select,"\\server\share\My Photos\a.jpg""#);
|
||||
assert_eq!(explorer_select_arg(r"\\?\D:\My Photos\a.jpg"), r#"/select,"D:\My Photos\a.jpg""#);
|
||||
assert_eq!(explorer_select_arg(r"\\?\UNC\server\share\a.jpg"), r#"/select,"\\server\share\a.jpg""#);
|
||||
// a `"` can't end the quoted path and add arguments to the raw command line
|
||||
assert_eq!(explorer_select_arg(r#"C:\a" "C:\b.jpg"#), r#"/select,"C:\a C:\b.jpg""#);
|
||||
let c = explorer_select_command(r"C:\My Photos\a.jpg");
|
||||
assert_eq!(c.get_program(), "explorer");
|
||||
let args: Vec<&std::ffi::OsStr> = c.get_args().collect();
|
||||
assert_eq!(args, [r#"/select,"C:\My Photos\a.jpg""#]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_control_port_that_is_not_a_number_is_ignored() {
|
||||
assert_eq!(control_port_from("--control", Some("7980".into())), Some(7980));
|
||||
|
|
|
|||
|
|
@ -355,12 +355,15 @@ impl NativeMenu {
|
|||
}
|
||||
|
||||
/// Per frame: run chosen items, then sync labels / enabled / checked and the text-focus
|
||||
/// accelerators with the app state.
|
||||
pub fn update(&mut self, app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
/// accelerators with the app state. Returns whether Quit was chosen: the caller saves
|
||||
/// everything and exits the process itself (closing the viewport while AppKit terminates the
|
||||
/// app can crash in winit's teardown, PR #444).
|
||||
pub fn update(&mut self, app: &mut LightcraftApp, ctx: &egui::Context) -> bool {
|
||||
lightcraft_ui_egui::i18n::set_language(app.ui.language);
|
||||
let mut quit = false;
|
||||
while let Ok(key) = self.rx.try_recv() {
|
||||
if key == QUIT {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
quit = true;
|
||||
continue;
|
||||
}
|
||||
if key == "app.about" || key == SETTINGS {
|
||||
|
|
@ -375,7 +378,7 @@ impl NativeMenu {
|
|||
let bar = menu_bar(app);
|
||||
if structure_of(&bar) != self.structure {
|
||||
self.rebuild(app);
|
||||
return;
|
||||
return quit;
|
||||
}
|
||||
fn walk(nodes: &[MenuNode], items: &mut HashMap<String, Item>) {
|
||||
for n in nodes {
|
||||
|
|
@ -429,6 +432,7 @@ impl NativeMenu {
|
|||
}
|
||||
self.publish_shortcuts(app);
|
||||
}
|
||||
quit
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +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 |
|
||||
|
|
@ -21,12 +24,13 @@ covers a whole directory), or if a licence file referenced in the Licence column
|
|||
| `docs/images/*-blue-marble.jpg`, `docs/images/grid-pd.jpg` | "The Blue Marble" (AS17-148-22727) | NASA, Apollo 17 crew (1972) | https://commons.wikimedia.org/wiki/File:The_Earth_seen_from_Apollo_17.jpg | Public domain (NASA) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
|
||||
| `docs/images/*.jpg` (UI) | LightCraft application screenshots | LightCraft contributors | original work (captured with `docs/showcase/`) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
|
||||
| `docs/brand/` (artcraft-logo and artcraft-mark, SVG and PNG) | ArtCraft wordmark and mark | ArtCraft Team | original work (https://getartcraft.com/) | ArtCraft trademark, see `docs/brand/LICENSE-brand.txt` (not open source) | 2026-10-01 | none |
|
||||
| craft-fonts: optional build input, not files in this repository | BIZ UDPGothic (Regular, Bold), BIZ UDMincho Regular, Shippori Mincho Regular | Morisawa Inc. / The BIZ UDGothic and BIZ UDMincho Project Authors; The Shippori Mincho Project Authors (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; embedded only in builds made with `CRAFT_FONTS_DIR` (all official releases); the web (wasm32) build embeds BIZ UDPGothic Regular only |
|
||||
| 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
|
||||
OFL-licensed: the asset rule excludes anything from Adobe. Source Sans 3 was removed on 2026-09-30 and replaced by Inter.
|
||||
- Japanese fonts (BIZ UDPGothic, BIZ UDMincho, Shippori Mincho) are not in this repository. They live in
|
||||
- CJK fonts (BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans CJK SC) are not in this repository. They live in
|
||||
[storytold/craft-fonts](https://github.com/storytold/craft-fonts), with their licences and attribution in its
|
||||
`ATTRIBUTION.md`, and are embedded only in builds made with the optional `CRAFT_FONTS_DIR` build input (all official
|
||||
releases). Never commit font files here; add new fonts to craft-fonts (craftrules `standards/fonts.md`).
|
||||
|
|
|
|||
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]
|
||||
]
|
||||
}
|
||||
|
|
@ -2,23 +2,23 @@
|
|||
"schema": 1,
|
||||
"repo": "storytold/lightcraft",
|
||||
"branch": "main",
|
||||
"head": "2694d7089fbf4ada62c6738a7c62fecdfd5711fb",
|
||||
"head_date": "2026-10-08T00:53:19Z",
|
||||
"commits": 424,
|
||||
"head": "c545cc5e842a74d65f32187d9fdee86ada3099de",
|
||||
"head_date": "2026-10-10T05:40:18Z",
|
||||
"commits": 1033,
|
||||
"unattributed_commits": 0,
|
||||
"contributors": [
|
||||
{
|
||||
"login": "echelon",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 71,
|
||||
"commits": 330,
|
||||
"lines_added": 125623,
|
||||
"lines_deleted": 8643,
|
||||
"prs": 85,
|
||||
"commits": 370,
|
||||
"lines_added": 131038,
|
||||
"lines_deleted": 9013,
|
||||
"binary_added": 38,
|
||||
"binary_deleted": 8,
|
||||
"first_commit": "2026-09-30T14:04:12Z",
|
||||
"last_commit": "2026-10-08T00:32:34Z"
|
||||
"last_commit": "2026-10-10T05:56:02Z"
|
||||
},
|
||||
{
|
||||
"login": "leipsfur",
|
||||
|
|
@ -50,40 +50,53 @@
|
|||
"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": 6,
|
||||
"commits": 6,
|
||||
"lines_added": 884,
|
||||
"lines_deleted": 93,
|
||||
"binary_added": 1,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-05T16:07:26Z",
|
||||
"last_commit": "2026-10-05T16:07:26Z"
|
||||
"last_commit": "2026-10-09T18:24:49Z"
|
||||
},
|
||||
{
|
||||
"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": "akvaithi",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 5,
|
||||
"lines_added": 1799,
|
||||
"lines_deleted": 114,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-05T16:53:56Z",
|
||||
"last_commit": "2026-10-05T19:55:59Z"
|
||||
},
|
||||
{
|
||||
"login": "arata-1972",
|
||||
|
|
@ -124,6 +137,19 @@
|
|||
"first_commit": "2026-10-06T20:21:14Z",
|
||||
"last_commit": "2026-10-06T20:21:14Z"
|
||||
},
|
||||
{
|
||||
"login": "Exalted5373",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 9,
|
||||
"commits": 15,
|
||||
"lines_added": 4316,
|
||||
"lines_deleted": 309,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-06T21:01:23Z",
|
||||
"last_commit": "2026-10-09T21:58:32Z"
|
||||
},
|
||||
{
|
||||
"login": "treyreynolds",
|
||||
"people_entry": false,
|
||||
|
|
@ -193,7 +219,7 @@
|
|||
"login": "itsmunzir",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"prs": 2,
|
||||
"commits": 1,
|
||||
"lines_added": 46,
|
||||
"lines_deleted": 30,
|
||||
|
|
@ -245,40 +271,40 @@
|
|||
"login": "radialmonster",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 3,
|
||||
"commits": 19,
|
||||
"lines_added": 2630,
|
||||
"lines_deleted": 259,
|
||||
"prs": 35,
|
||||
"commits": 64,
|
||||
"lines_added": 8732,
|
||||
"lines_deleted": 822,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T12:33:51Z",
|
||||
"last_commit": "2026-10-08T00:46:10Z"
|
||||
"last_commit": "2026-10-10T02:01:36Z"
|
||||
},
|
||||
{
|
||||
"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": 19,
|
||||
"commits": 305,
|
||||
"lines_added": 24796,
|
||||
"lines_deleted": 2896,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T17:33:38Z",
|
||||
"last_commit": "2026-10-07T22:49:00Z"
|
||||
"last_commit": "2026-10-09T23:05:22Z"
|
||||
},
|
||||
{
|
||||
"login": "MusseJusse",
|
||||
|
|
@ -336,14 +362,27 @@
|
|||
"login": "DWAA1660",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 54,
|
||||
"lines_deleted": 12,
|
||||
"prs": 3,
|
||||
"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 +410,487 @@
|
|||
"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": 6,
|
||||
"commits": 7,
|
||||
"lines_added": 351,
|
||||
"lines_deleted": 36,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T13:44:47Z",
|
||||
"last_commit": "2026-10-09T17:29:39Z"
|
||||
},
|
||||
{
|
||||
"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": 4,
|
||||
"commits": 5,
|
||||
"lines_added": 841,
|
||||
"lines_deleted": 31,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T03:19:47Z",
|
||||
"last_commit": "2026-10-10T01:46:07Z"
|
||||
},
|
||||
{
|
||||
"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": 6,
|
||||
"commits": 6,
|
||||
"lines_added": 1247,
|
||||
"lines_deleted": 80,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T09:34:51Z",
|
||||
"last_commit": "2026-10-10T05:46:24Z"
|
||||
},
|
||||
{
|
||||
"login": "rock-meister",
|
||||
"people_entry": true,
|
||||
"github_name": "rock-meister",
|
||||
"prs": 4,
|
||||
"commits": 5,
|
||||
"lines_added": 291,
|
||||
"lines_deleted": 46,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T10:15:52Z",
|
||||
"last_commit": "2026-10-09T15:00:07Z"
|
||||
},
|
||||
{
|
||||
"login": "QuirkyTurtle94",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 5,
|
||||
"commits": 5,
|
||||
"lines_added": 475,
|
||||
"lines_deleted": 77,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T11:08:11Z",
|
||||
"last_commit": "2026-10-09T14:32:04Z"
|
||||
},
|
||||
{
|
||||
"login": "AER00",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 2,
|
||||
"lines_added": 207,
|
||||
"lines_deleted": 21,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T12:48:11Z",
|
||||
"last_commit": "2026-10-09T13:57:38Z"
|
||||
},
|
||||
{
|
||||
"login": "AlexChambers",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 11,
|
||||
"commits": 16,
|
||||
"lines_added": 7202,
|
||||
"lines_deleted": 954,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T13:59:07Z",
|
||||
"last_commit": "2026-10-10T05:40:18Z"
|
||||
},
|
||||
{
|
||||
"login": "bitshiftza",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 342,
|
||||
"lines_deleted": 97,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T15:03:06Z",
|
||||
"last_commit": "2026-10-09T18:14:49Z"
|
||||
},
|
||||
{
|
||||
"login": "vbasky",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 622,
|
||||
"lines_deleted": 10,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T17:43:31Z",
|
||||
"last_commit": "2026-10-09T17:43:31Z"
|
||||
},
|
||||
{
|
||||
"login": "kelanna",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 1169,
|
||||
"lines_deleted": 24,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T17:51:06Z",
|
||||
"last_commit": "2026-10-09T17:51:06Z"
|
||||
},
|
||||
{
|
||||
"login": "dmatviichuk",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 2292,
|
||||
"lines_deleted": 44,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T18:02:42Z",
|
||||
"last_commit": "2026-10-09T18:02:42Z"
|
||||
},
|
||||
{
|
||||
"login": "GarrettGR",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 5,
|
||||
"lines_deleted": 5,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T19:37:15Z",
|
||||
"last_commit": "2026-10-09T19:37:15Z"
|
||||
},
|
||||
{
|
||||
"login": "dylanmtaylor",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 38,
|
||||
"lines_deleted": 44,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-10T00:04:24Z",
|
||||
"last_commit": "2026-10-10T00:04:24Z"
|
||||
},
|
||||
{
|
||||
"login": "sawizzle",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 2,
|
||||
"lines_added": 23,
|
||||
"lines_deleted": 3,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-10T00:06:24Z",
|
||||
"last_commit": "2026-10-10T04:50:51Z"
|
||||
},
|
||||
{
|
||||
"login": "KitsuneSemCalda",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 11,
|
||||
"lines_deleted": 0,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-10T01:50:45Z",
|
||||
"last_commit": "2026-10-10T01:50:45Z"
|
||||
},
|
||||
{
|
||||
"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": "enzoflayac",
|
||||
"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 +904,45 @@
|
|||
"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": "pikkemaat",
|
||||
"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 +963,45 @@
|
|||
"company": "Anthropic",
|
||||
"model": "Claude Opus",
|
||||
"version": "5.5",
|
||||
"commits": 364,
|
||||
"lines_added": 131810,
|
||||
"lines_deleted": 9367
|
||||
"commits": 747,
|
||||
"lines_added": 178263,
|
||||
"lines_deleted": 14199
|
||||
},
|
||||
{
|
||||
"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": 66,
|
||||
"lines_added": 8450,
|
||||
"lines_deleted": 956
|
||||
},
|
||||
{
|
||||
"id": "openai/codex",
|
||||
"company": "OpenAI",
|
||||
"model": "Codex",
|
||||
"version": "",
|
||||
"commits": 38,
|
||||
"lines_added": 17120,
|
||||
"lines_deleted": 1703
|
||||
},
|
||||
{
|
||||
"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 +1021,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 +1038,33 @@
|
|||
"commits": 1,
|
||||
"lines_added": 1895,
|
||||
"lines_deleted": 294
|
||||
},
|
||||
{
|
||||
"id": "openai/gpt-6-luna",
|
||||
"company": "OpenAI",
|
||||
"model": "GPT-6 Luna",
|
||||
"version": "",
|
||||
"commits": 1,
|
||||
"lines_added": 68,
|
||||
"lines_deleted": 19
|
||||
},
|
||||
{
|
||||
"id": "openai/gpt/6",
|
||||
"company": "OpenAI",
|
||||
"model": "GPT",
|
||||
"version": "6",
|
||||
"commits": 1,
|
||||
"lines_added": 2864,
|
||||
"lines_deleted": 261
|
||||
},
|
||||
{
|
||||
"id": "opencode/opencode",
|
||||
"company": "opencode",
|
||||
"model": "opencode",
|
||||
"version": "",
|
||||
"commits": 1,
|
||||
"lines_added": 459,
|
||||
"lines_deleted": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,3 +21,9 @@
|
|||
real_name = "Brandon Thomas"
|
||||
display_name = "Brandon Thomas"
|
||||
sync_github_name = false
|
||||
|
||||
[people.rock-meister]
|
||||
sync_github_name = true
|
||||
|
||||
[people.sanyamsingh001]
|
||||
display_name = "sanyamsingh001"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -155,7 +161,7 @@ impl Catalog {
|
|||
let date_of = |id: &PhotoId| -> String {
|
||||
let Some(p) = self.photo(*id) else { return String::new() };
|
||||
let d = match key {
|
||||
SortKey::CaptureDate => Some(p.date()),
|
||||
SortKey::CaptureDate => p.captured.as_deref(),
|
||||
SortKey::ImportDate => Some(p.imported.as_str()),
|
||||
SortKey::EditDate => p.edited.as_deref(),
|
||||
_ => None,
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -222,7 +236,7 @@ mod tests {
|
|||
}
|
||||
let shape = |r: &[DateRun]| r.iter().map(|r| (r.key.clone(), r.start, r.count)).collect::<Vec<_>>();
|
||||
let day = c.date_runs(&ids, SortKey::CaptureDate, GroupBy::Day);
|
||||
// the undated photo falls back to its import date
|
||||
// the undated photo is grouped under Unknown Date
|
||||
assert_eq!(
|
||||
shape(&day),
|
||||
vec![
|
||||
|
|
@ -230,16 +244,20 @@ mod tests {
|
|||
("2026-09-29".into(), 2, 1),
|
||||
("2026-08-01".into(), 3, 1),
|
||||
("2025-12-31".into(), 4, 1),
|
||||
("2026-10-01".into(), 5, 1)
|
||||
(String::new(), 5, 1)
|
||||
]
|
||||
);
|
||||
assert_eq!(day[0].label, "Wednesday, 30 September 2026");
|
||||
assert_eq!(day[4].label, "Unknown Date");
|
||||
assert_eq!(c.date_runs(&ids, SortKey::CaptureDate, GroupBy::Auto), day);
|
||||
assert_eq!(
|
||||
shape(&c.date_runs(&ids[..5], SortKey::CaptureDate, GroupBy::Month)),
|
||||
vec![("2026-09".into(), 0, 3), ("2026-08".into(), 3, 1), ("2025-12".into(), 4, 1)]
|
||||
shape(&c.date_runs(&ids, SortKey::CaptureDate, GroupBy::Month)),
|
||||
vec![("2026-09".into(), 0, 3), ("2026-08".into(), 3, 1), ("2025-12".into(), 4, 1), (String::new(), 5, 1)]
|
||||
);
|
||||
assert_eq!(
|
||||
shape(&c.date_runs(&ids, SortKey::CaptureDate, GroupBy::Year)),
|
||||
vec![("2026".into(), 0, 4), ("2025".into(), 4, 1), (String::new(), 5, 1)]
|
||||
);
|
||||
assert_eq!(shape(&c.date_runs(&ids[..5], SortKey::CaptureDate, GroupBy::Year)), vec![("2026".into(), 0, 4), ("2025".into(), 4, 1)]);
|
||||
assert!(c.date_runs(&ids, SortKey::FileName, GroupBy::Day).is_empty());
|
||||
assert!(c.date_runs(&ids, SortKey::CaptureDate, GroupBy::None).is_empty());
|
||||
// edit date: unedited photos have no date
|
||||
|
|
|
|||
|
|
@ -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]) {
|
||||
|
|
|
|||
|
|
@ -45,6 +45,10 @@
|
|||
//! | (none) | early builds: a log without a snapshot | |
|
||||
//! | 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
|
||||
|
|
@ -69,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 = 2;
|
||||
pub const VERSION: u32 = 6;
|
||||
|
||||
/// When [`Journal::wants_snapshot`] says it's time to compact the log.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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}")]
|
||||
|
|
@ -136,6 +139,11 @@ pub enum Op {
|
|||
id: AlbumId,
|
||||
parent: Option<AlbumId>,
|
||||
},
|
||||
/// Where the album stands among its siblings (`None`: by name). Format version 3.
|
||||
SetAlbumOrder {
|
||||
id: AlbumId,
|
||||
order: Option<u32>,
|
||||
},
|
||||
SetAlbumPhotos {
|
||||
id: AlbumId,
|
||||
photos: Vec<PhotoId>,
|
||||
|
|
@ -201,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,
|
||||
|
|
@ -212,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>,
|
||||
|
|
@ -234,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,
|
||||
|
|
@ -282,6 +313,28 @@ impl Catalog {
|
|||
pub fn albums(&self) -> impl Iterator<Item = &Album> {
|
||||
self.albums.values()
|
||||
}
|
||||
/// The albums and folders directly inside `parent` (`None`: the top level), in the order the
|
||||
/// sidebar lists them: folders first, then by their place if the user ordered them (those
|
||||
/// without one after, by name), else by name.
|
||||
pub fn album_children(&self, parent: Option<AlbumId>) -> Vec<&Album> {
|
||||
let mut kids: Vec<&Album> = self.albums.values().filter(|a| a.parent == parent).collect();
|
||||
kids.sort_by_cached_key(|a| album_order_key(a));
|
||||
kids
|
||||
}
|
||||
/// [`Self::album_children`] of every folder (and of the top level, under `None`) in one pass:
|
||||
/// what a tree view that draws all of them wants each frame.
|
||||
pub fn album_children_by_parent(&self) -> std::collections::HashMap<Option<AlbumId>, Vec<&Album>> {
|
||||
let mut map: std::collections::HashMap<Option<AlbumId>, Vec<&Album>> = std::collections::HashMap::new();
|
||||
for a in self.albums.values() {
|
||||
map.entry(a.parent).or_default().push(a);
|
||||
}
|
||||
map.values_mut().for_each(|kids| kids.sort_by_cached_key(|a| album_order_key(a)));
|
||||
map
|
||||
}
|
||||
/// Whether anything inside `parent` has a place of its own (the folder is ordered by hand).
|
||||
pub fn album_children_are_ordered(&self, parent: Option<AlbumId>) -> bool {
|
||||
self.albums.values().any(|a| a.parent == parent && a.order.is_some())
|
||||
}
|
||||
/// The Quick Collection, once something was added to it.
|
||||
pub fn quick_collection(&self) -> Option<AlbumId> {
|
||||
self.albums.values().find(|a| a.quick).map(|a| a.id)
|
||||
|
|
@ -297,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) {
|
||||
|
|
@ -339,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)
|
||||
|
|
@ -346,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
|
||||
|
|
@ -479,7 +591,20 @@ impl Catalog {
|
|||
}
|
||||
}
|
||||
let a = self.album_mut(id)?;
|
||||
Op::MoveAlbum { id, parent: std::mem::replace(&mut a.parent, parent) }
|
||||
if a.parent == parent {
|
||||
return Ok(Op::MoveAlbum { id, parent });
|
||||
}
|
||||
// its place belonged to the old neighbours: the undo gives it back with the folder
|
||||
let old_order = a.order.take();
|
||||
let old_parent = std::mem::replace(&mut a.parent, parent);
|
||||
match old_order {
|
||||
Some(_) => Op::Batch { ops: vec![Op::MoveAlbum { id, parent: old_parent }, Op::SetAlbumOrder { id, order: old_order }] },
|
||||
None => Op::MoveAlbum { id, parent: old_parent },
|
||||
}
|
||||
}
|
||||
Op::SetAlbumOrder { id, order } => {
|
||||
let a = self.album_mut(id)?;
|
||||
Op::SetAlbumOrder { id, order: std::mem::replace(&mut a.order, order) }
|
||||
}
|
||||
Op::SetAlbumPhotos { id, photos } => {
|
||||
let a = self.album_mut(id)?;
|
||||
|
|
@ -556,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()));
|
||||
|
|
@ -572,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 {
|
||||
|
|
@ -580,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 {
|
||||
|
|
@ -668,11 +820,21 @@ impl Catalog {
|
|||
}
|
||||
}
|
||||
|
||||
/// How siblings are listed: folders first, then the ones with a place of their own by it, then by
|
||||
/// name; the id settles any tie.
|
||||
fn album_order_key(a: &Album) -> (bool, bool, u32, String, AlbumId) {
|
||||
(!a.folder, a.order.is_none(), a.order.unwrap_or(0), a.name.to_lowercase(), a.id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
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);
|
||||
|
|
@ -295,10 +296,13 @@ impl Op {
|
|||
Op::RemoveAlbum { .. }
|
||||
| Op::RenameAlbum { .. }
|
||||
| Op::MoveAlbum { .. }
|
||||
| Op::SetAlbumOrder { .. }
|
||||
| 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 and PEF 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"].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
|
||||
|
|
@ -405,12 +468,16 @@ pub struct Album {
|
|||
/// The Quick Collection: a regular album that B adds to (one per library).
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub quick: bool,
|
||||
/// Its place among the albums of the same kind in its folder, once the user ordered them by
|
||||
/// hand (see [`crate::Catalog::album_children`]); `None`: listed by name.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub order: Option<u32>,
|
||||
}
|
||||
|
||||
impl Album {
|
||||
/// A regular (manual) album.
|
||||
pub fn new(id: AlbumId, name: impl Into<String>) -> Album {
|
||||
Album { id, name: name.into(), parent: None, folder: false, photos: Vec::new(), cover: None, smart: None, quick: false }
|
||||
Album { id, name: name.into(), parent: None, folder: false, photos: Vec::new(), cover: None, smart: None, quick: false, order: None }
|
||||
}
|
||||
pub fn is_smart(&self) -> bool {
|
||||
self.smart.is_some()
|
||||
|
|
@ -435,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 [
|
||||
|
|
@ -446,6 +539,10 @@ mod edited_tests {
|
|||
("a.raw", "RAW", true),
|
||||
("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");
|
||||
|
|
|
|||
|
|
@ -141,7 +141,7 @@ fn token_matches(p: &Photo, tok: &str) -> bool {
|
|||
"person" | "who" => has_person(p, val),
|
||||
"type" | "kind" => format!("{:?}", p.kind).eq_ignore_ascii_case(val),
|
||||
"edited" => (val == "true" || val == "yes") == p.is_edited(),
|
||||
"date" => p.date().starts_with(val),
|
||||
"date" => p.captured.as_deref().is_some_and(|c| c.starts_with(val)),
|
||||
"copy" | "virtual" => (val == "true" || val == "yes") == p.copy_of.is_some(),
|
||||
"name" | "file" => p.file_name.to_lowercase().contains(val),
|
||||
_ => false,
|
||||
|
|
@ -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)
|
||||
|
|
@ -284,12 +300,18 @@ impl Filter {
|
|||
return false;
|
||||
}
|
||||
if let Some(from) = &self.date_from
|
||||
&& p.date() < from.as_str()
|
||||
&& match p.captured.as_deref() {
|
||||
Some(c) => c < from.as_str(),
|
||||
None => true,
|
||||
}
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(to) = &self.date_to
|
||||
&& p.date().get(..to.len()).unwrap_or(p.date()) > to.as_str()
|
||||
&& match p.captured.as_deref() {
|
||||
Some(c) => c.get(..to.len()).unwrap_or(c) > to.as_str(),
|
||||
None => true,
|
||||
}
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
|
@ -299,7 +321,7 @@ impl Filter {
|
|||
return false;
|
||||
}
|
||||
if let Some(d) = &self.date
|
||||
&& !p.date().starts_with(d.as_str())
|
||||
&& !p.captured.as_deref().is_some_and(|c| c.starts_with(d.as_str()))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
|
@ -344,7 +366,7 @@ impl Catalog {
|
|||
let mut v: Vec<&Photo> = self.photos().map(|p| p.as_ref()).filter(|p| filter.matches_in(p, self, root.as_deref())).collect();
|
||||
v.sort_by(|a, b| {
|
||||
let o = match sort.key {
|
||||
SortKey::CaptureDate => a.date().cmp(b.date()),
|
||||
SortKey::CaptureDate => a.captured.cmp(&b.captured).then_with(|| a.imported.cmp(&b.imported)),
|
||||
SortKey::ImportDate => a.imported.cmp(&b.imported),
|
||||
SortKey::EditDate => a.edited.cmp(&b.edited),
|
||||
SortKey::FileName => a.file_name.to_lowercase().cmp(&b.file_name.to_lowercase()),
|
||||
|
|
@ -363,7 +385,7 @@ impl Catalog {
|
|||
use std::collections::BTreeMap;
|
||||
let mut years: BTreeMap<&str, (BTreeMap<&str, usize>, BTreeMap<&str, usize>)> = BTreeMap::new();
|
||||
for p in self.photos().filter(|p| p.in_library()) {
|
||||
let d = p.date();
|
||||
let Some(d) = p.captured.as_deref() else { continue };
|
||||
if d.len() >= 10 {
|
||||
let (months, days) = years.entry(&d[..4]).or_default();
|
||||
*months.entry(&d[..7]).or_default() += 1;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -58,12 +58,22 @@ fn albums_and_folders() {
|
|||
let a = photo(&mut c, "a.jpg", "2026-04-01");
|
||||
let f = c.alloc_album_id();
|
||||
c.apply(Op::AddAlbum {
|
||||
album: Album { id: f, name: "Trips".into(), parent: None, folder: true, photos: vec![], cover: None, smart: None, quick: false },
|
||||
album: Album { id: f, name: "Trips".into(), parent: None, folder: true, photos: vec![], cover: None, smart: None, quick: false, order: None },
|
||||
})
|
||||
.unwrap();
|
||||
let al = c.alloc_album_id();
|
||||
c.apply(Op::AddAlbum {
|
||||
album: Album { id: al, name: "Alps".into(), parent: Some(f), folder: false, photos: vec![a], cover: None, smart: None, quick: false },
|
||||
album: Album {
|
||||
id: al,
|
||||
name: "Alps".into(),
|
||||
parent: Some(f),
|
||||
folder: false,
|
||||
photos: vec![a],
|
||||
cover: None,
|
||||
smart: None,
|
||||
quick: false,
|
||||
order: None,
|
||||
},
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(c.albums_of(a), vec![al]);
|
||||
|
|
@ -446,3 +456,220 @@ fn random_sort_has_no_date_headers() {
|
|||
let ids = many(&mut c, 5);
|
||||
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();
|
||||
let add_photo = |c: &mut Catalog, name: &str, captured: Option<&str>, imported: &str| {
|
||||
let id = c.alloc_photo_id();
|
||||
let mut p = Photo::new(id, Source::Demo { scene: 0 }, name, "JPEG", 100, 100, imported);
|
||||
p.captured = captured.map(str::to_string);
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
id
|
||||
};
|
||||
let p_dated1 = add_photo(&mut c, "dated1.jpg", Some("2026-05-10T12:00:00"), "2026-10-01T00:00:00");
|
||||
let p_dated2 = add_photo(&mut c, "dated2.jpg", Some("2026-05-20T12:00:00"), "2026-10-01T00:00:00");
|
||||
let p_undated1 = add_photo(&mut c, "undated1.jpg", None, "2026-10-05T00:00:00");
|
||||
let p_undated2 = add_photo(&mut c, "undated2.jpg", None, "2026-10-02T00:00:00");
|
||||
|
||||
// Descending sort (default, newest first): dated photos first (newest to oldest), undated photos at the end
|
||||
let desc = c.query(&Filter::default(), &Sort { key: SortKey::CaptureDate, ascending: false, ..Default::default() });
|
||||
assert_eq!(desc, vec![p_dated2, p_dated1, p_undated1, p_undated2]);
|
||||
|
||||
// Ascending sort (oldest first): undated photos at the beginning, dated photos from oldest to newest
|
||||
let asc = c.query(&Filter::default(), &Sort { key: SortKey::CaptureDate, ascending: true, ..Default::default() });
|
||||
assert_eq!(asc, vec![p_undated2, p_undated1, p_dated1, p_dated2]);
|
||||
|
||||
// date_runs groups undated photos under Unknown Date
|
||||
let runs = c.date_runs(&desc, SortKey::CaptureDate, GroupBy::Day);
|
||||
assert_eq!(runs.len(), 3);
|
||||
assert_eq!(runs[0].key, "2026-05-20");
|
||||
assert_eq!(runs[1].key, "2026-05-10");
|
||||
assert_eq!(runs[2].key, "");
|
||||
assert_eq!(runs[2].label, "Unknown Date");
|
||||
assert_eq!(runs[2].count, 2);
|
||||
|
||||
// date_groups sidebar tree only counts photos with capture dates
|
||||
let groups = c.date_groups();
|
||||
assert_eq!(groups.len(), 1);
|
||||
assert_eq!(groups[0].year, "2026");
|
||||
assert_eq!(groups[0].count, 2);
|
||||
|
||||
// Filter by capture date prefix does not match undated photos
|
||||
let filter_day = Filter { date: Some("2026-10-05".into()), ..Default::default() };
|
||||
assert_eq!(c.query(&filter_day, &Sort::default()), vec![]);
|
||||
let filter_year = Filter { date: Some("2026".into()), ..Default::default() };
|
||||
assert_eq!(c.query(&filter_year, &Sort { key: SortKey::CaptureDate, ascending: false, ..Default::default() }), vec![p_dated2, p_dated1]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
//! Album order: the children of a folder (or of the top level) are listed folders first, then
|
||||
//! alphabetically, until some of them are given a place of their own (`Album.order`); moving an
|
||||
//! album to another folder drops its place, and undoing the move brings it back.
|
||||
|
||||
use crate::*;
|
||||
|
||||
fn add(c: &mut Catalog, name: &str, parent: Option<AlbumId>, folder: bool) -> AlbumId {
|
||||
let id = c.alloc_album_id();
|
||||
c.apply(Op::AddAlbum { album: Album { parent, folder, ..Album::new(id, name) } }).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
fn names(c: &Catalog, parent: Option<AlbumId>) -> Vec<String> {
|
||||
c.album_children(parent).iter().map(|a| a.name.clone()).collect()
|
||||
}
|
||||
|
||||
fn place(c: &mut Catalog, id: AlbumId, order: Option<u32>) {
|
||||
c.apply(Op::SetAlbumOrder { id, order }).unwrap();
|
||||
}
|
||||
|
||||
/// Given albums that were never ordered, when they are listed, then folders come first and each
|
||||
/// group is alphabetical regardless of case.
|
||||
#[test]
|
||||
fn children_are_folders_first_then_alphabetical() {
|
||||
let mut c = Catalog::new();
|
||||
add(&mut c, "beta", None, false);
|
||||
add(&mut c, "Alpha", None, false);
|
||||
add(&mut c, "zoo", None, true);
|
||||
add(&mut c, "Archive", None, true);
|
||||
assert_eq!(names(&c, None), ["Archive", "zoo", "Alpha", "beta"]);
|
||||
assert!(!c.album_children_are_ordered(None));
|
||||
}
|
||||
|
||||
/// Given some children with a place of their own, then they follow it; the others come after, by
|
||||
/// name; folders stay ahead of albums.
|
||||
#[test]
|
||||
fn ordered_children_follow_their_order() {
|
||||
let mut c = Catalog::new();
|
||||
let a = add(&mut c, "A", None, false);
|
||||
let b = add(&mut c, "B", None, false);
|
||||
let cc = add(&mut c, "C", None, false);
|
||||
add(&mut c, "D", None, false);
|
||||
let f = add(&mut c, "F", None, true);
|
||||
place(&mut c, cc, Some(0));
|
||||
place(&mut c, a, Some(1));
|
||||
assert_eq!(names(&c, None), ["F", "C", "A", "B", "D"]);
|
||||
assert!(c.album_children_are_ordered(None));
|
||||
// equal places fall back to the name, so a damaged catalog still lists deterministically
|
||||
place(&mut c, b, Some(1));
|
||||
assert_eq!(names(&c, None), ["F", "C", "A", "B", "D"]);
|
||||
// another container is unaffected
|
||||
let inner = add(&mut c, "Z", Some(f), false);
|
||||
add(&mut c, "Y", Some(f), false);
|
||||
assert_eq!(names(&c, Some(f)), ["Y", "Z"]);
|
||||
assert!(!c.album_children_are_ordered(Some(f)));
|
||||
place(&mut c, inner, Some(0));
|
||||
assert_eq!(names(&c, Some(f)), ["Z", "Y"]);
|
||||
}
|
||||
|
||||
/// The one-pass map lists every folder's children exactly as `album_children` does.
|
||||
#[test]
|
||||
fn the_children_map_matches_the_children_of_each_folder() {
|
||||
let mut c = Catalog::new();
|
||||
let f = add(&mut c, "F", None, true);
|
||||
let g = add(&mut c, "G", Some(f), true);
|
||||
for (name, parent) in [("b", None), ("a", None), ("y", Some(f)), ("x", Some(f)), ("q", Some(g))] {
|
||||
add(&mut c, name, parent, false);
|
||||
}
|
||||
let x = c.albums().find(|a| a.name == "x").unwrap().id;
|
||||
place(&mut c, x, Some(7));
|
||||
let map = c.album_children_by_parent();
|
||||
for parent in [None, Some(f), Some(g)] {
|
||||
let from_map: Vec<AlbumId> = map.get(&parent).map(|v| v.iter().map(|a| a.id).collect()).unwrap_or_default();
|
||||
let direct: Vec<AlbumId> = c.album_children(parent).iter().map(|a| a.id).collect();
|
||||
assert_eq!(from_map, direct, "{parent:?}");
|
||||
}
|
||||
assert!(!map.contains_key(&Some(AlbumId(999))));
|
||||
}
|
||||
|
||||
/// A place is one undo step, and `None` puts the album back among the alphabetical ones.
|
||||
#[test]
|
||||
fn a_place_can_be_set_cleared_and_undone() {
|
||||
let mut c = Catalog::new();
|
||||
let a = add(&mut c, "A", None, false);
|
||||
add(&mut c, "B", None, false);
|
||||
let inv = c.apply(Op::SetAlbumOrder { id: a, order: Some(5) }).unwrap();
|
||||
assert_eq!(c.album(a).unwrap().order, Some(5));
|
||||
c.apply(inv).unwrap();
|
||||
assert_eq!(c.album(a).unwrap().order, None);
|
||||
assert!(c.apply(Op::SetAlbumOrder { id: AlbumId(999), order: Some(1) }).is_err(), "no such album");
|
||||
}
|
||||
|
||||
/// Moving to another folder drops the album's place there (it belonged to the old neighbours),
|
||||
/// and undoing the move gives it back.
|
||||
#[test]
|
||||
fn moving_drops_the_place_and_undo_restores_it() {
|
||||
let mut c = Catalog::new();
|
||||
let from = add(&mut c, "From", None, true);
|
||||
let to = add(&mut c, "To", None, true);
|
||||
let a = add(&mut c, "A", Some(from), false);
|
||||
place(&mut c, a, Some(3));
|
||||
let inv = c.apply(Op::MoveAlbum { id: a, parent: Some(to) }).unwrap();
|
||||
assert_eq!((c.album(a).unwrap().parent, c.album(a).unwrap().order), (Some(to), None));
|
||||
c.apply(inv).unwrap();
|
||||
assert_eq!((c.album(a).unwrap().parent, c.album(a).unwrap().order), (Some(from), Some(3)));
|
||||
// "moving" to where it already is changes nothing
|
||||
c.apply(Op::MoveAlbum { id: a, parent: Some(from) }).unwrap();
|
||||
assert_eq!(c.album(a).unwrap().order, Some(3));
|
||||
}
|
||||
|
||||
/// Catalogs written before albums had an order load as they were; an unset order is not written.
|
||||
#[test]
|
||||
fn albums_without_an_order_load_and_save_unchanged() {
|
||||
let old = r#"{"id":4,"name":"Old","parent":null,"folder":false,"photos":[],"cover":null}"#;
|
||||
let a: Album = serde_json::from_str(old).unwrap();
|
||||
assert_eq!(a.order, None);
|
||||
assert!(!serde_json::to_string(&a).unwrap().contains("order"));
|
||||
let mut a = a;
|
||||
a.order = Some(2);
|
||||
let back: Album = serde_json::from_str(&serde_json::to_string(&a).unwrap()).unwrap();
|
||||
assert_eq!(back.order, Some(2));
|
||||
}
|
||||
|
|
@ -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,6 +47,33 @@ 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 in the current format.
|
||||
#[test]
|
||||
fn v2_library_loads_and_is_upgraded() {
|
||||
let (base, log, full) = legacy_parts();
|
||||
let m = MemStore::new();
|
||||
m.set(SNAPSHOT, format!("{{\"format\":\"lightcraft-catalog\",\"version\":2,\"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(2)));
|
||||
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]
|
||||
fn versionless_log_only_library_is_upgraded() {
|
||||
let mut c = Catalog::new();
|
||||
|
|
@ -179,8 +206,12 @@ fn op_variants_are_versioned() {
|
|||
| Op::SetLabelName { .. }
|
||||
| Op::Batch { .. } => 1,
|
||||
Op::SetBrowsed { .. } => 2,
|
||||
Op::SetAlbumOrder { .. } => 3,
|
||||
Op::SetEmbeddedLens { .. } => 4,
|
||||
Op::SetKeyword { .. } => 5,
|
||||
Op::SetFolderRecord { .. } => 6,
|
||||
}
|
||||
}
|
||||
let newest = since(&Op::SetBrowsed { folder: String::new(), at: 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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ fn op_for(c: &mut Catalog, kind: u8, a: u8, b: u8) -> Op {
|
|||
cover: None,
|
||||
smart: None,
|
||||
quick: false,
|
||||
order: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -77,6 +78,16 @@ 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") } }
|
||||
}
|
||||
|
|
@ -324,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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ workspace = true
|
|||
lightcraft-geom = { workspace = true }
|
||||
lightcraft-color = { workspace = true }
|
||||
lightcraft-raster = { workspace = true }
|
||||
lightcraft-heif = { workspace = true, optional = true }
|
||||
serde = { workspace = true }
|
||||
bytemuck = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
|
@ -29,13 +30,21 @@ 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"]
|
||||
parallel = ["dep:rayon", "lightcraft-raster/parallel", "jpeg-decoder/rayon"]
|
||||
jxl = ["dep:jxl-oxide"]
|
||||
# HEIC/HEIF decode via the optional lightcraft-heif crate (heic-rs, pure Rust). Off by default so
|
||||
# distributors choose it at build time (HEVC patents are a distributor's call, same policy as
|
||||
# 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"] }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
//! 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, 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 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};
|
||||
use crate::{DecodeOptions, Decoded, Error, Format, Result};
|
||||
|
||||
const F: Format = Format::Heif;
|
||||
|
||||
/// The reason given when this build has no HEIF decoder.
|
||||
#[cfg(not(feature = "heif"))]
|
||||
pub(crate) const NOT_IN_BUILD: &str = "HEIC/HEIF support isn't included in this build of LightCraft";
|
||||
|
||||
#[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 {
|
||||
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.
|
||||
let info = lightcraft_heif::probe(bytes).map_err(err)?;
|
||||
if (info.width as u64).saturating_mul(info.height as u64) > opts.max_pixels {
|
||||
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 };
|
||||
// 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
|
||||
})
|
||||
}
|
||||
_ => 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,
|
||||
height: decoded.height as usize,
|
||||
model: Model::Rgb,
|
||||
alpha: decoded.has_alpha,
|
||||
premultiplied: false,
|
||||
buf: Buf::U16(std::mem::take(&mut decoded.data)),
|
||||
bit_depth: decoded.bit_depth,
|
||||
};
|
||||
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)]
|
||||
mod tests {
|
||||
use crate::{Format, sniff};
|
||||
|
||||
/// The container header both builds recognise, whatever the feature says.
|
||||
const HEIC_HEADER: &[u8] = b"\0\0\0\x18ftypheic\0\0\0\0mif1heic";
|
||||
|
||||
#[test]
|
||||
fn heif_is_always_recognised() {
|
||||
assert_eq!(sniff(HEIC_HEADER), Some(Format::Heif));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn can_decode_follows_the_feature() {
|
||||
assert_eq!(Format::Heif.can_decode(), cfg!(feature = "heif"));
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "heif"))]
|
||||
#[test]
|
||||
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
|
||||
/// out of range (`boxes.rs:130`). It must be an error, not a crash.
|
||||
#[cfg(feature = "heif")]
|
||||
const HEIC_RS_BOX_PANIC: [u8; 72] = [
|
||||
0x00, 0x00, 0x00, 0x24, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63, 0x00, 0x00, 0x00, 0x00, 0x6d, 0x69, 0x66, 0x31, 0x4d, 0x69, 0x50,
|
||||
0x72, 0x6d, 0x69, 0x61, 0x66, 0x4d, 0x69, 0x48, 0x42, 0x72, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x6d, 0x65, 0x74, 0x61, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x75, 0x75, 0x69, 0x64, 0x00, 0x00, 0x00, 0x00, 0x03, 0x08, 0x08, 0x08, 0x00, 0x03, 0x01, 0x03, 0x70,
|
||||
0x00, 0xa8, 0x00,
|
||||
];
|
||||
|
||||
#[cfg(feature = "heif")]
|
||||
#[test]
|
||||
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")]
|
||||
#[test]
|
||||
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, 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,11 @@
|
|||
#![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;
|
||||
pub mod jpeg_par;
|
||||
|
|
@ -33,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};
|
||||
|
|
@ -170,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
|
||||
|
|
@ -195,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))?;
|
||||
|
|
@ -216,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),
|
||||
|
|
@ -226,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::Avif => Err(Error::Unsupported(format, "no pure-Rust, permissively licensed AV1 decoder yet")),
|
||||
Format::Heif => Err(Error::Unsupported(format, "no pure-Rust, permissively licensed HEVC decoder yet")),
|
||||
Format::RawTiffLike | Format::RawOther => Err(Error::Unsupported(format, "camera raw: decode with lightcraft-raw")),
|
||||
Format::Jxl => unsupported(),
|
||||
Format::Heif => heif::decode(bytes, &opts),
|
||||
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,10 +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::Avif | Format::Heif | 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:?}");
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
[package]
|
||||
name = "lightcraft-denoise-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "LightCraft denoise model metadata, Bayer tiling and cache formats (L1; no inference backend)"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
rawnind-model = []
|
||||
|
||||
[dependencies]
|
||||
lightcraft-raster = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
miniz_oxide = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
# lightcraft-denoise-core
|
||||
|
||||
L1 model metadata, bounded archive/cache formats and Bayer packing/tiling helpers.
|
||||
No weights or inference backend. See [the user workflow](../../docs/denoise.md).
|
||||
`lightcraft-denoise` re-exports these types for CPU/GPU users; the engine uses this
|
||||
crate directly when inference is disabled. The default download catalog is empty.
|
||||
Run `cargo test -p lightcraft-denoise-core`; optional catalog checks use `--features rawnind-model`.
|
||||
|
|
@ -0,0 +1,279 @@
|
|||
//! Taking one file out of a `.zip` (a `.dtmodel` is one), the little of the format a model download needs: stored and
|
||||
//! deflated entries, found by name, checked against their CRC-32 and a size limit, written next to where they are
|
||||
//! going and moved into place. It is a stranger's file, so every length and offset in it is checked, a name is only
|
||||
//! ever compared (never used as a path), and zip64, encrypted and multi-disk archives are refused.
|
||||
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
use std::path::Path;
|
||||
|
||||
/// How far from the end the end-of-central-directory record can start (a 22-byte record and a 64 KiB comment).
|
||||
const EOCD_SEARCH: u64 = 22 + 65535;
|
||||
/// Largest central directory we read.
|
||||
const MAX_DIRECTORY: u64 = 1 << 20;
|
||||
|
||||
#[derive(Debug, PartialEq, thiserror::Error)]
|
||||
pub enum ArchiveError {
|
||||
#[error("the archive could not be read: {0}")]
|
||||
Io(String),
|
||||
#[error("not a usable archive: {0}")]
|
||||
Format(String),
|
||||
#[error("the archive has no file called `{0}`")]
|
||||
Missing(String),
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for ArchiveError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
ArchiveError::Io(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn format<T>(why: &str) -> Result<T, ArchiveError> {
|
||||
Err(ArchiveError::Format(why.into()))
|
||||
}
|
||||
|
||||
fn u16_at(b: &[u8], at: usize) -> Option<u16> {
|
||||
Some(u16::from_le_bytes(b.get(at..at.checked_add(2)?)?.try_into().ok()?))
|
||||
}
|
||||
|
||||
fn u32_at(b: &[u8], at: usize) -> Option<u32> {
|
||||
Some(u32::from_le_bytes(b.get(at..at.checked_add(4)?)?.try_into().ok()?))
|
||||
}
|
||||
|
||||
/// CRC-32 (IEEE) of `bytes`.
|
||||
fn crc32(bytes: &[u8]) -> u32 {
|
||||
let mut table = [0u32; 256];
|
||||
for (i, t) in table.iter_mut().enumerate() {
|
||||
let mut c = i as u32;
|
||||
for _ in 0..8 {
|
||||
c = if c & 1 != 0 { 0xedb8_8320 ^ (c >> 1) } else { c >> 1 };
|
||||
}
|
||||
*t = c;
|
||||
}
|
||||
!bytes.iter().fold(!0u32, |c, &b| table[((c ^ u32::from(b)) & 0xff) as usize] ^ (c >> 8))
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
method: u16,
|
||||
crc: u32,
|
||||
compressed: u64,
|
||||
size: u64,
|
||||
local_header: u64,
|
||||
}
|
||||
|
||||
/// Find the entry called `name` (exactly, or as the last part of a longer name: `folder/name`) in the central directory.
|
||||
fn find(f: &mut (impl Read + Seek), name: &str) -> Result<Entry, ArchiveError> {
|
||||
let len = f.seek(SeekFrom::End(0))?;
|
||||
let tail_len = len.min(EOCD_SEARCH);
|
||||
f.seek(SeekFrom::Start(len - tail_len))?;
|
||||
let mut tail = vec![0u8; tail_len as usize];
|
||||
f.read_exact(&mut tail)?;
|
||||
let eocd = (0..tail.len().saturating_sub(21)).rev().find(|&i| u32_at(&tail, i) == Some(0x0605_4b50));
|
||||
let Some(e) = eocd else { return format("this is not a zip file") };
|
||||
let (entries, size, offset) = (u16_at(&tail, e + 10), u32_at(&tail, e + 12), u32_at(&tail, e + 16));
|
||||
let (Some(entries), Some(size), Some(offset)) = (entries, size, offset) else { return format("the end of the archive is cut short") };
|
||||
if entries == 0xffff || size == u32::MAX || offset == u32::MAX {
|
||||
return format("zip64 archives are not supported");
|
||||
}
|
||||
if u64::from(size) > MAX_DIRECTORY || u64::from(offset) + u64::from(size) > len {
|
||||
return format("the list of files is not where it says");
|
||||
}
|
||||
f.seek(SeekFrom::Start(u64::from(offset)))?;
|
||||
let mut dir = vec![0u8; size as usize];
|
||||
f.read_exact(&mut dir)?;
|
||||
let mut at = 0usize;
|
||||
for _ in 0..entries {
|
||||
if u32_at(&dir, at) != Some(0x0201_4b50) {
|
||||
return format("the list of files is damaged");
|
||||
}
|
||||
let get = |o: usize| at.checked_add(o);
|
||||
let (flags, method, crc) = (u16_at(&dir, at + 8), u16_at(&dir, at + 10), u32_at(&dir, at + 16));
|
||||
let (compressed, uncompressed) = (u32_at(&dir, at + 20), u32_at(&dir, at + 24));
|
||||
let (n, x, c) = (u16_at(&dir, at + 28), u16_at(&dir, at + 30), u16_at(&dir, at + 32));
|
||||
let local = u32_at(&dir, at + 42);
|
||||
let (Some(flags), Some(method), Some(crc), Some(compressed), Some(uncompressed), Some(n), Some(x), Some(c), Some(local), Some(start)) =
|
||||
(flags, method, crc, compressed, uncompressed, n, x, c, local, get(46))
|
||||
else {
|
||||
return format("the list of files is cut short");
|
||||
};
|
||||
let Some(entry_name) = dir.get(start..start + usize::from(n)) else { return format("a file name is cut short") };
|
||||
let matches = entry_name == name.as_bytes() || entry_name.strip_suffix(name.as_bytes()).is_some_and(|p| p.ends_with(b"/"));
|
||||
if matches {
|
||||
if flags & 1 != 0 {
|
||||
return format("encrypted files are not supported");
|
||||
}
|
||||
if compressed == u32::MAX || uncompressed == u32::MAX {
|
||||
return format("zip64 files are not supported");
|
||||
}
|
||||
return Ok(Entry { method, crc, compressed: u64::from(compressed), size: u64::from(uncompressed), local_header: u64::from(local) });
|
||||
}
|
||||
at = start + usize::from(n) + usize::from(x) + usize::from(c);
|
||||
}
|
||||
Err(ArchiveError::Missing(name.into()))
|
||||
}
|
||||
|
||||
/// Take the file called `name` out of the archive at `archive` and write it to `dest` (through a temporary file next
|
||||
/// to it); `max_bytes` bounds its size. Returns the number of bytes written.
|
||||
pub fn extract(archive: &Path, name: &str, dest: &Path, max_bytes: u64) -> Result<u64, ArchiveError> {
|
||||
let mut f = std::io::BufReader::new(std::fs::File::open(archive)?);
|
||||
let e = find(&mut f, name)?;
|
||||
if e.size > max_bytes || e.compressed > max_bytes {
|
||||
return format("the file in the archive is larger than allowed");
|
||||
}
|
||||
f.seek(SeekFrom::Start(e.local_header))?;
|
||||
let mut head = [0u8; 30];
|
||||
f.read_exact(&mut head)?;
|
||||
if u32_at(&head, 0) != Some(0x0403_4b50) {
|
||||
return format("the file's header is not where it says");
|
||||
}
|
||||
let skip = u64::from(u16_at(&head, 26).unwrap_or(0)) + u64::from(u16_at(&head, 28).unwrap_or(0));
|
||||
f.seek_relative(i64::try_from(skip).map_err(|_| ArchiveError::Format("a header is too long".into()))?)?;
|
||||
let len = usize::try_from(e.compressed)
|
||||
.ok()
|
||||
.filter(|&n| n <= 256 * 1024 * 1024)
|
||||
.ok_or_else(|| ArchiveError::Format("the compressed model is too large".into()))?;
|
||||
if e.size > 256 * 1024 * 1024 {
|
||||
return format("the extracted model is too large");
|
||||
}
|
||||
let mut packed = Vec::new();
|
||||
packed.try_reserve_exact(len).map_err(|_| ArchiveError::Format("not enough memory for the model archive".into()))?;
|
||||
packed.resize(len, 0u8);
|
||||
f.read_exact(&mut packed)?;
|
||||
let data = match e.method {
|
||||
0 => packed,
|
||||
8 => miniz_oxide::inflate::decompress_to_vec_with_limit(&packed, max_bytes.min(256 * 1024 * 1024) as usize)
|
||||
.map_err(|err| ArchiveError::Format(format!("{err:?}")))?,
|
||||
m => return format(&format!("compression method {m} is not supported")),
|
||||
};
|
||||
if data.len() as u64 != e.size || crc32(&data) != e.crc {
|
||||
return format("the file in the archive is damaged");
|
||||
}
|
||||
let tmp = dest.with_extension("part");
|
||||
let written = (|| -> std::io::Result<()> {
|
||||
let mut out = std::fs::File::create(&tmp)?;
|
||||
out.write_all(&data)?;
|
||||
out.flush()?;
|
||||
drop(out);
|
||||
std::fs::rename(&tmp, dest)
|
||||
})();
|
||||
if let Err(err) = written {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(err.into());
|
||||
}
|
||||
Ok(data.len() as u64)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A zip with the given (name, bytes, deflate) entries, written by hand.
|
||||
pub(crate) fn make_zip(files: &[(&str, &[u8], bool)]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
let mut dir = Vec::new();
|
||||
for (name, data, deflate) in files {
|
||||
let packed = if *deflate { miniz_oxide::deflate::compress_to_vec(data, 6) } else { data.to_vec() };
|
||||
let offset = out.len() as u32;
|
||||
let method: u16 = if *deflate { 8 } else { 0 };
|
||||
out.extend_from_slice(&0x0403_4b50u32.to_le_bytes());
|
||||
out.extend_from_slice(&[20, 0, 0, 0]);
|
||||
out.extend_from_slice(&method.to_le_bytes());
|
||||
out.extend_from_slice(&[0, 0, 0, 0]);
|
||||
out.extend_from_slice(&crc32(data).to_le_bytes());
|
||||
out.extend_from_slice(&(packed.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&(name.len() as u16).to_le_bytes());
|
||||
out.extend_from_slice(&[0, 0]);
|
||||
out.extend_from_slice(name.as_bytes());
|
||||
out.extend_from_slice(&packed);
|
||||
dir.extend_from_slice(&0x0201_4b50u32.to_le_bytes());
|
||||
dir.extend_from_slice(&[20, 0, 20, 0, 0, 0]);
|
||||
dir.extend_from_slice(&method.to_le_bytes());
|
||||
dir.extend_from_slice(&[0, 0, 0, 0]);
|
||||
dir.extend_from_slice(&crc32(data).to_le_bytes());
|
||||
dir.extend_from_slice(&(packed.len() as u32).to_le_bytes());
|
||||
dir.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
dir.extend_from_slice(&(name.len() as u16).to_le_bytes());
|
||||
dir.extend_from_slice(&[0; 12]);
|
||||
dir.extend_from_slice(&offset.to_le_bytes());
|
||||
dir.extend_from_slice(name.as_bytes());
|
||||
}
|
||||
let cd_offset = out.len() as u32;
|
||||
out.extend_from_slice(&dir);
|
||||
out.extend_from_slice(&0x0605_4b50u32.to_le_bytes());
|
||||
out.extend_from_slice(&[0, 0, 0, 0]);
|
||||
out.extend_from_slice(&(files.len() as u16).to_le_bytes());
|
||||
out.extend_from_slice(&(files.len() as u16).to_le_bytes());
|
||||
out.extend_from_slice(&(dir.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&cd_offset.to_le_bytes());
|
||||
out.extend_from_slice(&[0, 0]);
|
||||
out
|
||||
}
|
||||
|
||||
fn temp(name: &str) -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(format!("lc-denoise-zip-{name}-{}", std::process::id()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stored_and_a_deflated_file_come_out_whole() {
|
||||
let big: Vec<u8> = (0..200_000u32).map(|i| (i * 7 % 251) as u8).collect();
|
||||
let zip = make_zip(&[("m/config.json", b"{}", false), ("m/model.onnx", &big, true), ("m/other.onnx", b"zz", true)]);
|
||||
let (a, out) = (temp("a.zip"), temp("a.onnx"));
|
||||
std::fs::write(&a, &zip).unwrap();
|
||||
assert_eq!(extract(&a, "model.onnx", &out, 1 << 20).unwrap(), big.len() as u64);
|
||||
assert_eq!(std::fs::read(&out).unwrap(), big);
|
||||
assert_eq!(extract(&a, "m/config.json", &out, 1 << 20).unwrap(), 2);
|
||||
assert_eq!(std::fs::read(&out).unwrap(), b"{}");
|
||||
// a name must match whole path parts: `del.onnx` is not `model.onnx`
|
||||
assert!(matches!(extract(&a, "del.onnx", &out, 1 << 20), Err(ArchiveError::Missing(_))));
|
||||
assert!(matches!(extract(&a, "nothing", &out, 1 << 20), Err(ArchiveError::Missing(_))));
|
||||
// the size limit applies
|
||||
assert!(matches!(extract(&a, "model.onnx", &out, 1000), Err(ArchiveError::Format(_))));
|
||||
for p in [a, out] {
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
}
|
||||
|
||||
/// Opt-in: `LC_DENOISE_DTMODEL=<rawdenoise-nind.dtmodel> cargo test -p lightcraft-denoise -- --ignored archive`
|
||||
#[test]
|
||||
#[ignore = "needs the real download: set LC_DENOISE_DTMODEL"]
|
||||
fn the_real_download_gives_the_model_it_should() {
|
||||
let Some(path) = std::env::var_os("LC_DENOISE_DTMODEL") else { return };
|
||||
let known = crate::known::find("rawnind-bayer").unwrap();
|
||||
let (d, entry) = (known.download.unwrap(), known.entry.unwrap());
|
||||
assert_eq!(crate::hash::sha256_file(Path::new(&path)).unwrap(), d.sha256);
|
||||
let out = temp("real.onnx");
|
||||
let n = extract(Path::new(&path), entry, &out, crate::manifest::MAX_MODEL_BYTES).unwrap();
|
||||
assert_eq!(Some(n), known.manifest.size_bytes);
|
||||
assert_eq!(Some(crate::hash::sha256_file(&out).unwrap()), known.manifest.sha256);
|
||||
let _ = std::fs::remove_file(out);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_and_hostile_archives_are_errors_not_panics() {
|
||||
let zip = make_zip(&[("m/model.onnx", &vec![9u8; 5000], true)]);
|
||||
let (a, out) = (temp("b.zip"), temp("b.onnx"));
|
||||
// cut anywhere
|
||||
for n in (0..zip.len()).step_by(3) {
|
||||
std::fs::write(&a, &zip[..n]).unwrap();
|
||||
assert!(extract(&a, "model.onnx", &out, 1 << 20).is_err(), "cut at {n}");
|
||||
}
|
||||
// one byte changed anywhere: an error, or (when it falls in an unused field) the right file, never a panic
|
||||
for at in 0..zip.len() {
|
||||
let mut bad = zip.clone();
|
||||
bad[at] ^= 0x5a;
|
||||
std::fs::write(&a, &bad).unwrap();
|
||||
if extract(&a, "model.onnx", &out, 1 << 20).is_ok() {
|
||||
assert_eq!(std::fs::read(&out).unwrap(), vec![9u8; 5000], "a changed byte at {at} gave another file");
|
||||
}
|
||||
}
|
||||
// not a zip at all, and an empty file
|
||||
for junk in [Vec::new(), b"PK".to_vec(), vec![0u8; 100_000]] {
|
||||
std::fs::write(&a, junk).unwrap();
|
||||
assert!(extract(&a, "model.onnx", &out, 1 << 20).is_err());
|
||||
}
|
||||
assert!(extract(&temp("missing.zip"), "model.onnx", &out, 1 << 20).is_err());
|
||||
for p in [a, out] {
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
//! Bayer geometry: which way a mosaic is laid out, and how a patch of it is packed into the four planes
|
||||
//! (red, green on the red row, green on the blue row, blue) a denoise model takes.
|
||||
//!
|
||||
//! Every Bayer layout is the same 2 × 2 cell shifted by one pixel at most, so a layout is just where its
|
||||
//! first red sample is. The cell grid starts one pixel before the picture when that red sample is in
|
||||
//! column or row 1, and the picture is read with its edge mirrored (without repeating the edge sample, so
|
||||
//! the colour of a mirrored sample is the colour that belongs at that place).
|
||||
|
||||
/// Where the first red sample of a Bayer mosaic sits, `(dx, dy)` in `{0, 1}²`: RGGB is `(0, 0)`, GRBG `(1, 0)`,
|
||||
/// GBRG `(0, 1)`, BGGR `(1, 1)`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Layout {
|
||||
pub dx: usize,
|
||||
pub dy: usize,
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
/// The layout of a 2 × 2 colour pattern, row-major, colours 0 = red, 1 = green, 2 = blue; `None` when
|
||||
/// it is not a Bayer cell (one red, one blue, the greens on the other diagonal).
|
||||
pub fn from_cell(cell: [u8; 4]) -> Option<Layout> {
|
||||
let at = |c: u8| cell.iter().position(|&v| v == c);
|
||||
let red = at(0)?;
|
||||
let blue = at(2)?;
|
||||
let greens = cell.iter().filter(|&&v| v == 1).count();
|
||||
// red and blue sit on one diagonal: indices 0+3 or 1+2
|
||||
if greens != 2 || red + blue != 3 {
|
||||
return None;
|
||||
}
|
||||
Some(Layout { dx: red % 2, dy: red / 2 })
|
||||
}
|
||||
|
||||
/// The cell grid's origin: `-1` along an axis whose first red sample is at 1, so that cell 0 starts on a red column or row.
|
||||
fn origin(self) -> (isize, isize) {
|
||||
(isize::try_from(self.dx).unwrap_or(isize::MAX).saturating_neg(), isize::try_from(self.dy).unwrap_or(isize::MAX).saturating_neg())
|
||||
}
|
||||
|
||||
/// Cells across and down covering a `width × height` mosaic.
|
||||
pub fn cells(self, width: usize, height: usize) -> (usize, usize) {
|
||||
let cells = |n: usize, phase: usize| n / 2 + (n % 2 + phase.min(1)).div_ceil(2);
|
||||
(cells(width, self.dx), cells(height, self.dy))
|
||||
}
|
||||
|
||||
/// The mosaic position of cell `(cx, cy)`'s top-left sample (may lie outside the picture).
|
||||
pub fn position(self, cx: isize, cy: isize) -> (isize, isize) {
|
||||
let (ox, oy) = self.origin();
|
||||
(ox.saturating_add(cx.saturating_mul(2)), oy.saturating_add(cy.saturating_mul(2)))
|
||||
}
|
||||
}
|
||||
|
||||
/// `i` mirrored into `0..n` (ping-pong, edge not repeated), so `-1` is `1` and `n` is `n - 2`. `n == 0` gives 0.
|
||||
pub fn reflect(i: isize, n: usize) -> usize {
|
||||
if n <= 1 {
|
||||
return 0;
|
||||
}
|
||||
if let Some(period) = isize::try_from(n).ok().and_then(|n| (n - 1).checked_mul(2)) {
|
||||
let m = i.rem_euclid(period);
|
||||
return (if m < n as isize { m } else { period - m }) as usize;
|
||||
}
|
||||
let period = 2 * (n as i128 - 1);
|
||||
let m = (i as i128).rem_euclid(period);
|
||||
// The reflected value is in 0..n, so this conversion always fits usize.
|
||||
(if m < n as i128 { m } else { period - m }) as usize
|
||||
}
|
||||
|
||||
/// Pack the `tile × tile` cells whose top-left cell is `(cx0, cy0)` into `out` as four planes `[R, G1, G2, B]`
|
||||
/// (`4 · tile²` values, NCHW). G1 is the green beside the red sample, G2 the green under it. Samples outside
|
||||
/// the picture are mirrored. Returns `false` (and writes nothing) when the sizes do not fit.
|
||||
pub fn pack_tile(mosaic: &[f32], width: usize, height: usize, layout: Layout, cx0: isize, cy0: isize, tile: usize, out: &mut [f32]) -> bool {
|
||||
let Some(plane) = tile.checked_mul(tile) else { return false };
|
||||
let Some(values) = plane.checked_mul(4) else { return false };
|
||||
if width == 0
|
||||
|| height == 0
|
||||
|| tile == 0
|
||||
|| layout.dx > 1
|
||||
|| layout.dy > 1
|
||||
|| width.checked_mul(height) != Some(mosaic.len())
|
||||
|| out.len() != values
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let Ok(last) = isize::try_from(tile - 1) else { return false };
|
||||
let (ox, oy) = layout.origin();
|
||||
for (start, origin) in [(cx0, ox), (cy0, oy)] {
|
||||
if start.checked_mul(2).and_then(|v| v.checked_add(origin)).is_none()
|
||||
|| start.checked_add(last).and_then(|v| v.checked_mul(2)).and_then(|v| v.checked_add(origin)).and_then(|v| v.checked_add(1)).is_none()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// the four samples of a cell, as (dx, dy) from its top-left sample
|
||||
const SUB: [(isize, isize); 4] = [(0, 0), (1, 0), (0, 1), (1, 1)];
|
||||
for (p, &(sx, sy)) in SUB.iter().enumerate() {
|
||||
let Some(dst) = out.get_mut(p * plane..(p + 1) * plane) else { return false };
|
||||
for (j, row) in dst.chunks_exact_mut(tile).enumerate() {
|
||||
let (_, y) = layout.position(cx0, cy0 + j as isize);
|
||||
let ry = reflect(y + sy, height);
|
||||
let Some(src) = mosaic.get(ry * width..(ry + 1) * width) else { return false };
|
||||
for (i, v) in row.iter_mut().enumerate() {
|
||||
let (x, _) = layout.position(cx0 + i as isize, cy0);
|
||||
*v = src.get(reflect(x + sx, width)).copied().unwrap_or(0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hostile_sizes_and_positions_never_panic_or_write_output() {
|
||||
let layout = Layout::from_cell([0, 1, 1, 2]).unwrap();
|
||||
let mut out = [7.0; 4];
|
||||
for (w, h, tile, x) in [(usize::MAX, 2, 1, 0), (1, 1, usize::MAX, 0), (1, 1, 0, 0), (1, 1, 1, isize::MAX), (1, 1, 1, isize::MIN)] {
|
||||
assert!(!pack_tile(&[0.5], w, h, layout, x, 0, tile, &mut out));
|
||||
assert_eq!(out, [7.0; 4]);
|
||||
}
|
||||
for n in [1, 2, isize::MAX as usize, usize::MAX] {
|
||||
for i in [isize::MIN, -1, 0, isize::MAX] {
|
||||
assert!(reflect(i, n) < n);
|
||||
}
|
||||
}
|
||||
assert_eq!(layout.cells(usize::MAX, usize::MAX), (usize::MAX / 2 + 1, usize::MAX / 2 + 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layouts_from_cells() {
|
||||
assert_eq!(Layout::from_cell([0, 1, 1, 2]), Some(Layout { dx: 0, dy: 0 }));
|
||||
assert_eq!(Layout::from_cell([1, 0, 2, 1]), Some(Layout { dx: 1, dy: 0 }));
|
||||
assert_eq!(Layout::from_cell([1, 2, 0, 1]), Some(Layout { dx: 0, dy: 1 }));
|
||||
assert_eq!(Layout::from_cell([2, 1, 1, 0]), Some(Layout { dx: 1, dy: 1 }));
|
||||
// not Bayer: two reds, a missing blue, greens on the wrong diagonal
|
||||
assert_eq!(Layout::from_cell([0, 0, 1, 2]), None);
|
||||
assert_eq!(Layout::from_cell([0, 1, 2, 1]), None);
|
||||
assert_eq!(Layout::from_cell([1, 1, 1, 1]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reflection_keeps_the_colour_of_a_site() {
|
||||
assert_eq!(reflect(-1, 10), 1);
|
||||
assert_eq!(reflect(-2, 10), 2);
|
||||
assert_eq!(reflect(10, 10), 8);
|
||||
assert_eq!(reflect(11, 10), 7);
|
||||
assert_eq!(reflect(25, 10), 7);
|
||||
assert_eq!(reflect(5, 0), 0);
|
||||
assert_eq!(reflect(5, 1), 0);
|
||||
for i in -40..60 {
|
||||
assert_eq!(reflect(i, 11) % 2, (i.rem_euclid(2)) as usize, "parity at {i}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packing_puts_each_colour_in_its_plane_for_every_layout() {
|
||||
// a mosaic whose sample value says which colour it is: R = 10, G = 20, B = 30
|
||||
for cell in [[0u8, 1, 1, 2], [1, 0, 2, 1], [1, 2, 0, 1], [2, 1, 1, 0]] {
|
||||
let layout = Layout::from_cell(cell).unwrap();
|
||||
let (w, h) = (10usize, 8usize);
|
||||
let mosaic: Vec<f32> = (0..w * h).map(|i| [10.0, 20.0, 30.0][cell[(i / w % 2) * 2 + i % w % 2] as usize]).collect();
|
||||
let (nx, ny) = layout.cells(w, h);
|
||||
let tile = nx.max(ny);
|
||||
let mut out = vec![0.0; 4 * tile * tile];
|
||||
assert!(pack_tile(&mosaic, w, h, layout, 0, 0, tile, &mut out));
|
||||
let plane = tile * tile;
|
||||
for (p, want) in [10.0, 20.0, 20.0, 30.0].into_iter().enumerate() {
|
||||
assert!(out[p * plane..(p + 1) * plane].iter().all(|&v| v == want), "layout {cell:?} plane {p}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn g1_is_beside_the_red_sample_and_g2_under_it() {
|
||||
// RGGB with a distinct value at every site
|
||||
let (w, h) = (4, 4);
|
||||
let mosaic: Vec<f32> = (0..16).map(|i| i as f32).collect();
|
||||
let mut out = vec![0.0; 4 * 4];
|
||||
assert!(pack_tile(&mosaic, w, h, Layout { dx: 0, dy: 0 }, 0, 0, 2, &mut out));
|
||||
// cell (0,0): R = site (0,0)=0, G1 = site (1,0)=1, G2 = site (0,1)=4, B = site (1,1)=5
|
||||
assert_eq!([out[0], out[4], out[8], out[12]], [0.0, 1.0, 4.0, 5.0]);
|
||||
// cell (1,0): sites (2,0)=2, (3,0)=3, (2,1)=6, (3,1)=7
|
||||
assert_eq!([out[1], out[5], out[9], out[13]], [2.0, 3.0, 6.0, 7.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packing_rejects_wrong_sizes() {
|
||||
let mut out = vec![0.0; 16];
|
||||
assert!(!pack_tile(&[0.0; 15], 4, 4, Layout { dx: 0, dy: 0 }, 0, 0, 2, &mut out));
|
||||
assert!(!pack_tile(&[0.0; 16], 4, 4, Layout { dx: 0, dy: 0 }, 0, 0, 3, &mut out));
|
||||
assert!(!pack_tile(&[], 0, 0, Layout { dx: 0, dy: 0 }, 0, 0, 2, &mut out));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
//! SHA-256 of a model file, streamed (a model can be hundreds of megabytes).
|
||||
|
||||
use std::io::{self, Read};
|
||||
use std::path::Path;
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// Lowercase hex SHA-256 of everything `r` yields.
|
||||
pub fn sha256_reader<R: Read>(mut r: R) -> io::Result<String> {
|
||||
let mut h = Sha256::new();
|
||||
let mut buf = vec![0u8; 1 << 20];
|
||||
loop {
|
||||
let n = r.read(&mut buf)?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
h.update(buf.get(..n).unwrap_or(&[]));
|
||||
}
|
||||
Ok(hex(&h.finalize()))
|
||||
}
|
||||
|
||||
/// Lowercase hex SHA-256 of a file.
|
||||
pub fn sha256_file(path: &Path) -> io::Result<String> {
|
||||
sha256_reader(io::BufReader::new(std::fs::File::open(path)?))
|
||||
}
|
||||
|
||||
/// Lowercase hex SHA-256 of `bytes`.
|
||||
pub fn sha256_hex(bytes: &[u8]) -> String {
|
||||
hex(&Sha256::digest(bytes))
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn known_digests() {
|
||||
assert_eq!(sha256_hex(b""), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
|
||||
assert_eq!(sha256_hex(b"abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
|
||||
assert_eq!(sha256_reader(&b"abc"[..]).unwrap(), sha256_hex(b"abc"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
//! The denoise models LightCraft knows about: what each one is, who may use it, and where to fetch it.
|
||||
//!
|
||||
//! None is part of LightCraft. A model with an address here can be downloaded when the user asks and has accepted
|
||||
//! its terms; anything else is brought by the user as an `.onnx` file with a `denoise-model.json` beside it.
|
||||
|
||||
#[cfg(any(feature = "rawnind-model", test))]
|
||||
use crate::licence::{Commercial, Licence};
|
||||
|
||||
use crate::manifest::DenoiserManifest;
|
||||
#[cfg(any(feature = "rawnind-model", test))]
|
||||
use crate::manifest::{Domain, Gain};
|
||||
|
||||
/// A known model, and how to get it.
|
||||
pub struct Known {
|
||||
pub manifest: DenoiserManifest,
|
||||
/// What to fetch (an archive or the `.onnx` itself); `None` when the user must bring the file.
|
||||
pub download: Option<Download>,
|
||||
/// The `.onnx` inside the downloaded archive (`None`: the download is the `.onnx`).
|
||||
pub entry: Option<&'static str>,
|
||||
}
|
||||
|
||||
/// RawNIND's UtNet2 for Bayer sensors, as published for darktable (`rawdenoise-nind.dtmodel`, a zip with a Bayer and a
|
||||
/// linear model). Its weights are GPL-3.0; the training pictures are CC BY 4.0 and CC0.
|
||||
#[cfg(any(feature = "rawnind-model", test))]
|
||||
fn rawnind_bayer() -> Known {
|
||||
let manifest = DenoiserManifest {
|
||||
id: "rawnind-bayer".into(),
|
||||
name: "RawNIND UtNet2 (Bayer)".into(),
|
||||
version: "1.0".into(),
|
||||
licence: Licence {
|
||||
name: "GPL-3.0".into(),
|
||||
commercial: Commercial::Yes,
|
||||
url: Some("https://www.gnu.org/licenses/gpl-3.0.html".into()),
|
||||
notice: "The weights are published under the GNU General Public License v3. LightCraft does not include them: they \
|
||||
are downloaded to your computer at your request and used by it only. If you pass the model file on, the \
|
||||
GPL applies to that copy. Commercial use is subject to the GPL terms, including applicable source, licence and notice obligations on redistribution."
|
||||
.into(),
|
||||
},
|
||||
source: Some("https://github.com/darktable-org/darktable-ai/tree/master/models/rawdenoise-nind".into()),
|
||||
sha256: Some("da27509dab6a2915da67e988acd86cf71f9d5bbc8d1aa0ed32933578a887b901".into()),
|
||||
size_bytes: Some(31_056_425),
|
||||
provenance: "Trained on RawNIND, real photographs of the same scenes taken noisy and clean (images CC BY 4.0 and CC0, \
|
||||
published on Wikimedia Commons and by the University of Louvain). Paper: arXiv 2501.08924."
|
||||
.into(),
|
||||
domain: Domain::BayerToRgb,
|
||||
tile: 512,
|
||||
overlap: 64,
|
||||
gain: Gain::MatchMean { nominal: 1.0e6, max_deviation: 0.05 },
|
||||
};
|
||||
let download = Download {
|
||||
id: manifest.id.clone(),
|
||||
url: "https://github.com/darktable-org/darktable-ai/releases/download/release-5.6.0/rawdenoise-nind.dtmodel".into(),
|
||||
file_name: "rawdenoise-nind.dtmodel".into(),
|
||||
size_bytes: 57_700_134,
|
||||
sha256: "d71b5f1e727c85a359e6f74dca9e2016c9d8fc3e2f7ac3e9b347d80ceca969af".into(),
|
||||
};
|
||||
Known { manifest, download: Some(download), entry: Some("model_bayer.onnx") }
|
||||
}
|
||||
|
||||
/// Every model LightCraft knows.
|
||||
pub fn all() -> Vec<Known> {
|
||||
#[cfg(feature = "rawnind-model")]
|
||||
{
|
||||
vec![rawnind_bayer()]
|
||||
}
|
||||
#[cfg(not(feature = "rawnind-model"))]
|
||||
{
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// The known model with this id.
|
||||
pub fn find(id: &str) -> Option<Known> {
|
||||
all().into_iter().find(|k| k.manifest.id == id)
|
||||
}
|
||||
|
||||
/// The known model whose `.onnx` has this SHA-256.
|
||||
pub fn by_sha256(sha256: &str) -> Option<DenoiserManifest> {
|
||||
all().into_iter().map(|k| k.manifest).find(|m| m.sha256.as_deref() == Some(sha256))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::manifest::validate;
|
||||
|
||||
#[test]
|
||||
fn known_models_are_valid_unique_and_downloaded_over_https() {
|
||||
let all = all();
|
||||
assert_eq!(all.is_empty(), !cfg!(feature = "rawnind-model"));
|
||||
validate(&rawnind_bayer().manifest).unwrap();
|
||||
for (i, k) in all.iter().enumerate() {
|
||||
validate(&k.manifest).unwrap();
|
||||
assert!(all.iter().skip(i + 1).all(|o| o.manifest.id != k.manifest.id), "unique ids");
|
||||
assert!(!k.manifest.licence.name.is_empty() && !k.manifest.licence.notice.is_empty(), "terms are shown before use");
|
||||
if let Some(d) = &k.download {
|
||||
assert_eq!(d.id, k.manifest.id);
|
||||
assert!(d.url.starts_with("https://") && d.sha256.len() == 64 && d.size_bytes > 0);
|
||||
// a name that cannot leave the staging folder
|
||||
assert!(!d.file_name.contains(['/', '\\']) && !d.file_name.starts_with('.'));
|
||||
}
|
||||
// a model that may not be used commercially is never offered for download
|
||||
if k.manifest.licence.commercial == Commercial::No {
|
||||
assert!(k.download.is_none(), "{}", k.manifest.id);
|
||||
}
|
||||
}
|
||||
assert_eq!(find("rawnind-bayer").is_some(), cfg!(feature = "rawnind-model"));
|
||||
assert!(find("nothing").is_none());
|
||||
assert_eq!(
|
||||
by_sha256("da27509dab6a2915da67e988acd86cf71f9d5bbc8d1aa0ed32933578a887b901").map(|m| m.id),
|
||||
if cfg!(feature = "rawnind-model") { Some("rawnind-bayer".into()) } else { None }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Download {
|
||||
pub id: String,
|
||||
pub url: String,
|
||||
/// The name the file is kept under while it waits for the user to accept its terms.
|
||||
pub file_name: String,
|
||||
pub size_bytes: u64,
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
pub fn host(url: &str) -> &str {
|
||||
url.strip_prefix("https://").and_then(|r| r.split('/').next()).unwrap_or("")
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
//! Model metadata, bounded archive/cache formats and Bayer tiling, independent of inference.
|
||||
//! Used by featureless engine and web builds; no neural-network or matrix backend is linked.
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
pub mod archive;
|
||||
pub mod bayer;
|
||||
pub mod hash;
|
||||
pub mod known;
|
||||
pub mod licence;
|
||||
pub mod manifest;
|
||||
pub mod product;
|
||||
pub mod run;
|
||||
pub mod tiles;
|
||||
|
||||
pub use manifest::{DenoiserManifest, Domain, Gain, ManifestError};
|
||||
pub use run::{Control, Error, Params, TileRunner, denoise_bayer};
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Commercial {
|
||||
Yes,
|
||||
/// Research or personal use only. Never bundled or redistributed by LightCraft.
|
||||
No,
|
||||
/// The licence or the training data is unclear: the user is told so before installing.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Licence {
|
||||
/// SPDX id or a short name ("MIT", "Apache-2.0", "InsightFace non-commercial research").
|
||||
pub name: String,
|
||||
pub commercial: Commercial,
|
||||
pub url: Option<String>,
|
||||
/// Plain-language terms shown before the model is enabled.
|
||||
pub notice: String,
|
||||
}
|
||||
|
||||
impl Default for Licence {
|
||||
fn default() -> Self {
|
||||
Self { name: String::new(), commercial: Commercial::Unknown, url: None, notice: String::new() }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn valid_id(id: &str) -> bool {
|
||||
let mut bytes = id.bytes();
|
||||
let first_ok = bytes.next().is_some_and(|b| b.is_ascii_lowercase() || b.is_ascii_digit());
|
||||
first_ok && id.len() <= 64 && bytes.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'_' | b'-'))
|
||||
}
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
//! A denoise model's manifest: what the model takes and gives, how big its tiles are and who may use it, as
|
||||
//! plain data.
|
||||
//!
|
||||
//! It comes from a `denoise-model.json` next to the `.onnx` file or from the user's `catalog.json`. Both are
|
||||
//! untrusted: [`validate`] must pass before a manifest is used, and [`parse`] applies it after reading JSON of
|
||||
//! bounded size. The licence, id and address rules are the face models' (`crate::licence`).
|
||||
|
||||
use crate::licence::{Licence, valid_id};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Largest `denoise-model.json` we read.
|
||||
pub const MAX_MANIFEST_BYTES: usize = 64 * 1024;
|
||||
/// Largest model file we accept (bytes).
|
||||
pub const MAX_MODEL_BYTES: u64 = 2 * 1024 * 1024 * 1024;
|
||||
|
||||
/// What the model takes and what it gives.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Domain {
|
||||
/// The sensor's Bayer mosaic (black subtracted, white at 1, not white balanced) packed as four planes
|
||||
/// `[R, G1, G2, B]` of its 2 × 2 cells, forced to RGGB; gives camera RGB at the mosaic's resolution, so
|
||||
/// the picture is denoised and demosaiced in one step and must not be demosaiced again.
|
||||
BayerToRgb,
|
||||
}
|
||||
|
||||
/// How the model's output scale is brought back to the input's.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum Gain {
|
||||
/// The output already has the input's scale.
|
||||
None,
|
||||
/// The output has a scale of its own, `nominal` times the input's (a million, for one model) and a little
|
||||
/// different from tile to tile: each tile is scaled so that its channel means equal the input's. A tile too
|
||||
/// dark to measure, or whose measured scale is further than `maxDeviation` (a fraction, 0.05 = 5 %) from
|
||||
/// `nominal`, is scaled as if it were within that limit.
|
||||
MatchMean { nominal: f32, max_deviation: f32 },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DenoiserManifest {
|
||||
/// Lowercase letters, digits, `.` `_` `-`.
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
#[serde(default)]
|
||||
pub licence: Licence,
|
||||
/// Where the weights come from (an `https://` page or file), for the user's information.
|
||||
#[serde(default)]
|
||||
pub source: Option<String>,
|
||||
/// Lowercase hex SHA-256 of the `.onnx` file, when known.
|
||||
#[serde(default)]
|
||||
pub sha256: Option<String>,
|
||||
#[serde(default)]
|
||||
pub size_bytes: Option<u64>,
|
||||
/// What the model was trained on, in plain words; says "undisclosed" when it is.
|
||||
#[serde(default)]
|
||||
pub provenance: String,
|
||||
pub domain: Domain,
|
||||
/// Side of the square the model runs on, in packed cells (the file's fixed input size).
|
||||
pub tile: u32,
|
||||
/// Cells neighbouring tiles share; the picture is blended across them.
|
||||
pub overlap: u32,
|
||||
pub gain: Gain,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, thiserror::Error)]
|
||||
pub enum ManifestError {
|
||||
#[error("`{0}` is not valid: {1}")]
|
||||
Field(&'static str, String),
|
||||
#[error("not a denoise-model manifest: {0}")]
|
||||
Parse(String),
|
||||
#[error("a manifest may be at most {MAX_MANIFEST_BYTES} bytes")]
|
||||
TooLarge,
|
||||
}
|
||||
|
||||
fn bad<T>(field: &'static str, why: impl Into<String>) -> Result<T, ManifestError> {
|
||||
Err(ManifestError::Field(field, why.into()))
|
||||
}
|
||||
|
||||
fn text(field: &'static str, s: &str, min: usize, max: usize) -> Result<(), ManifestError> {
|
||||
let n = s.chars().count();
|
||||
if n < min || n > max {
|
||||
return bad(field, format!("must be {min} to {max} characters"));
|
||||
}
|
||||
if s.chars().any(|c| c.is_control() && c != '\n') {
|
||||
return bad(field, "must not contain control characters");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn url(field: &'static str, u: &Option<String>) -> Result<(), ManifestError> {
|
||||
match u {
|
||||
None => Ok(()),
|
||||
Some(u) if u.len() <= 500 && u.starts_with("https://") && !u.chars().any(|c| c.is_control() || c.is_whitespace()) => Ok(()),
|
||||
Some(_) => bad(field, "must be an https:// address of at most 500 characters"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check a manifest from outside before trusting it.
|
||||
pub fn validate(m: &DenoiserManifest) -> Result<(), ManifestError> {
|
||||
if !valid_id(&m.id) {
|
||||
return bad("id", "1 to 64 lowercase letters, digits, `.`, `_` or `-`, starting with a letter or digit");
|
||||
}
|
||||
text("name", &m.name, 1, 80)?;
|
||||
text("version", &m.version, 1, 40)?;
|
||||
text("provenance", &m.provenance, 0, 1000)?;
|
||||
text("licence.name", &m.licence.name, 0, 80)?;
|
||||
text("licence.notice", &m.licence.notice, 0, 2000)?;
|
||||
url("licence.url", &m.licence.url)?;
|
||||
url("source", &m.source)?;
|
||||
if let Some(h) = &m.sha256
|
||||
&& !(h.len() == 64 && h.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)))
|
||||
{
|
||||
return bad("sha256", "must be 64 lowercase hex digits");
|
||||
}
|
||||
if let Some(n) = m.size_bytes
|
||||
&& (n == 0 || n > MAX_MODEL_BYTES)
|
||||
{
|
||||
return bad("sizeBytes", format!("must be between 1 and {MAX_MODEL_BYTES}"));
|
||||
}
|
||||
// a U-Net with four poolings wants a multiple of 16; nothing smaller than 64 or larger than 2048 is a tile
|
||||
if !(64..=2048).contains(&m.tile) || !m.tile.is_multiple_of(16) {
|
||||
return bad("tile", "must be a multiple of 16 from 64 to 2048");
|
||||
}
|
||||
// tiles must overlap by less than half, or one tile's blend ramps would run into its other neighbour's
|
||||
if !m.overlap.is_multiple_of(2) || m.overlap < 16 || m.overlap.saturating_mul(2) >= m.tile {
|
||||
return bad("overlap", "must be even, at least 16 and less than half the tile");
|
||||
}
|
||||
if let Gain::MatchMean { nominal, max_deviation } = m.gain {
|
||||
if !(nominal.is_finite() && (1e-6..=1e12).contains(&nominal)) {
|
||||
return bad("gain.nominal", "must be between 0.000001 and 1000000000000");
|
||||
}
|
||||
if !(max_deviation.is_finite() && (0.0..=0.5).contains(&max_deviation)) {
|
||||
return bad("gain.maxDeviation", "must be between 0 and 0.5");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read and validate a `denoise-model.json`.
|
||||
pub fn parse(bytes: &[u8]) -> Result<DenoiserManifest, ManifestError> {
|
||||
if bytes.len() > MAX_MANIFEST_BYTES {
|
||||
return Err(ManifestError::TooLarge);
|
||||
}
|
||||
let bytes = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(bytes);
|
||||
let m: DenoiserManifest = serde_json::from_slice(bytes).map_err(|e| ManifestError::Parse(e.to_string()))?;
|
||||
validate(&m)?;
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
pub(crate) fn sample() -> DenoiserManifest {
|
||||
DenoiserManifest {
|
||||
id: "nind-bayer".into(),
|
||||
name: "Bayer denoiser".into(),
|
||||
version: "1".into(),
|
||||
licence: Licence::default(),
|
||||
source: Some("https://example.org/model.onnx".into()),
|
||||
sha256: None,
|
||||
size_bytes: Some(31_000_000),
|
||||
provenance: "trained on real raw noise pairs".into(),
|
||||
domain: Domain::BayerToRgb,
|
||||
tile: 512,
|
||||
overlap: 64,
|
||||
gain: Gain::MatchMean { nominal: 1.0e6, max_deviation: 0.05 },
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_good_manifest_round_trips() {
|
||||
let m = sample();
|
||||
let json = serde_json::to_vec(&m).unwrap();
|
||||
assert_eq!(parse(&json).unwrap(), m);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_fields_are_named() {
|
||||
let cases: Vec<(&str, Box<dyn Fn(&mut DenoiserManifest)>)> = vec![
|
||||
("id", Box::new(|m| m.id = "../evil".into())),
|
||||
("id", Box::new(|m| m.id = String::new())),
|
||||
("name", Box::new(|m| m.name = "x\u{7}".into())),
|
||||
("source", Box::new(|m| m.source = Some("http://insecure".into()))),
|
||||
("sha256", Box::new(|m| m.sha256 = Some("XYZ".into()))),
|
||||
("sizeBytes", Box::new(|m| m.size_bytes = Some(0))),
|
||||
("tile", Box::new(|m| m.tile = 500)),
|
||||
("tile", Box::new(|m| m.tile = 4096)),
|
||||
("overlap", Box::new(|m| m.overlap = 300)),
|
||||
("overlap", Box::new(|m| m.overlap = 63)),
|
||||
("gain.maxDeviation", Box::new(|m| m.gain = Gain::MatchMean { nominal: 1.0, max_deviation: f32::NAN })),
|
||||
("gain.maxDeviation", Box::new(|m| m.gain = Gain::MatchMean { nominal: 1.0, max_deviation: 2.0 })),
|
||||
("gain.nominal", Box::new(|m| m.gain = Gain::MatchMean { nominal: 0.0, max_deviation: 0.1 })),
|
||||
("gain.nominal", Box::new(|m| m.gain = Gain::MatchMean { nominal: f32::INFINITY, max_deviation: 0.1 })),
|
||||
];
|
||||
for (field, edit) in cases {
|
||||
let mut m = sample();
|
||||
edit(&mut m);
|
||||
match validate(&m) {
|
||||
Err(ManifestError::Field(f, _)) => assert_eq!(f, field),
|
||||
other => panic!("{field}: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_is_bounded_and_tolerant() {
|
||||
assert_eq!(parse(&vec![b' '; MAX_MANIFEST_BYTES + 1]), Err(ManifestError::TooLarge));
|
||||
assert!(matches!(parse(b"{ nope"), Err(ManifestError::Parse(_))));
|
||||
assert!(matches!(parse(b""), Err(ManifestError::Parse(_))));
|
||||
let mut with_bom = vec![0xEF, 0xBB, 0xBF];
|
||||
with_bom.extend(serde_json::to_vec(&sample()).unwrap());
|
||||
assert!(parse(&with_bom).is_ok());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,616 @@
|
|||
//! The denoised picture as cached data, and the blend the Amount slider controls.
|
||||
//!
|
||||
//! A product is camera RGB at the sensor's resolution (what the model gave, before the raw's own opcodes and
|
||||
//! crop), kept as half floats in strips of rows, each strip byte-shuffled and deflated with its own checksum.
|
||||
//! It is derived data: any problem reading it (missing, truncated, corrupt, made for another photo or model) is
|
||||
//! just a cache miss, never a panic, and the picture can be made again.
|
||||
//!
|
||||
//! File: `LCDN`, version, header length, header JSON (`width`, `height`, `strip`, `key`), then for each strip its
|
||||
//! compressed length and CRC-32, then the strips' bytes. `key` says what the product was made from (the photo's
|
||||
//! content, the model, this code's version, the settings it depends on); a file with another key is stale.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::path::Path;
|
||||
|
||||
use lightcraft_raster::Rgb32f;
|
||||
use rayon::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const MAGIC: &[u8; 4] = b"LCDN";
|
||||
const VERSION: u8 = 1;
|
||||
const MAX_HEADER: usize = 4096;
|
||||
const MAX_SIDE: usize = 1 << 17;
|
||||
/// Most pixels a product may have (the denoiser's own limit, [`crate::run::MAX_PIXELS`]).
|
||||
const MAX_PIXELS: usize = crate::run::MAX_PIXELS;
|
||||
/// Rows per strip: about a megapixel of three planes at 24 MP widths.
|
||||
const STRIP_ROWS: usize = 128;
|
||||
/// Bumped when the meaning of a product changes (included in keys by the caller).
|
||||
pub const ALGORITHM: u32 = 1;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ProductError {
|
||||
#[error("the denoised picture could not be read or written: {0}")]
|
||||
Io(String),
|
||||
#[error("not a usable denoised picture: {0}")]
|
||||
Format(String),
|
||||
/// A product made from something else (another photo state, model or version).
|
||||
#[error("the denoised picture is out of date")]
|
||||
Stale,
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for ProductError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
ProductError::Io(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Header {
|
||||
pub width: usize,
|
||||
pub height: usize,
|
||||
pub strip: usize,
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
fn allocate<T: Clone>(n: usize, value: T) -> Result<Vec<T>, ProductError> {
|
||||
let mut v = Vec::new();
|
||||
v.try_reserve_exact(n).map_err(|_| ProductError::Format("not enough memory for the denoised picture".into()))?;
|
||||
v.resize(n, value);
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// `f32` as IEEE half-float bits, rounded to nearest even; beyond the range it saturates, NaN becomes 0.
|
||||
pub fn to_f16(v: f32) -> u16 {
|
||||
if !v.is_finite() {
|
||||
return if v.is_nan() {
|
||||
0
|
||||
} else if v > 0.0 {
|
||||
0x7bff
|
||||
} else {
|
||||
0xfbff
|
||||
};
|
||||
}
|
||||
let b = v.to_bits();
|
||||
let sign = ((b >> 16) & 0x8000) as u16;
|
||||
let exp = ((b >> 23) & 0xff) as i32 - 127 + 15;
|
||||
let man = b & 0x7f_ffff;
|
||||
if exp >= 31 {
|
||||
return sign | 0x7bff;
|
||||
}
|
||||
if exp <= 0 {
|
||||
if exp < -10 {
|
||||
return sign;
|
||||
}
|
||||
let man = man | 0x80_0000;
|
||||
let shift = (14 - exp) as u32;
|
||||
let (half, rem, halfway) = (man >> shift, man & ((1u32 << shift) - 1), 1u32 << (shift - 1));
|
||||
let mut h = half as u16;
|
||||
if rem > halfway || (rem == halfway && h & 1 == 1) {
|
||||
h += 1;
|
||||
}
|
||||
return sign | h;
|
||||
}
|
||||
let mut h = (((exp as u32) << 10) | (man >> 13)) as u16;
|
||||
let rem = man & 0x1fff;
|
||||
if rem > 0x1000 || (rem == 0x1000 && h & 1 == 1) {
|
||||
h += 1;
|
||||
}
|
||||
if h >= 0x7c00 {
|
||||
h = 0x7bff;
|
||||
}
|
||||
sign | h
|
||||
}
|
||||
|
||||
/// Half-float bits as `f32`.
|
||||
pub fn from_f16(h: u16) -> f32 {
|
||||
let sign = if h & 0x8000 != 0 { -1.0f32 } else { 1.0 };
|
||||
let exp = i32::from((h >> 10) & 0x1f);
|
||||
let man = f32::from(h & 0x3ff);
|
||||
match exp {
|
||||
0 => sign * man * 2f32.powi(-24),
|
||||
31 => sign * 65504.0,
|
||||
_ => sign * (1.0 + man / 1024.0) * 2f32.powi(exp - 15),
|
||||
}
|
||||
}
|
||||
|
||||
fn crc(bytes: &[u8]) -> u32 {
|
||||
// CRC-32 (IEEE), bitwise: strips are small and this runs once per strip
|
||||
let mut table = [0u32; 256];
|
||||
for (i, t) in table.iter_mut().enumerate() {
|
||||
let mut c = i as u32;
|
||||
for _ in 0..8 {
|
||||
c = if c & 1 != 0 { 0xedb8_8320 ^ (c >> 1) } else { c >> 1 };
|
||||
}
|
||||
*t = c;
|
||||
}
|
||||
!bytes.iter().fold(!0u32, |c, &b| table[((c ^ u32::from(b)) & 0xff) as usize] ^ (c >> 8))
|
||||
}
|
||||
|
||||
/// One strip as bytes: for each plane (R, G, B) the low bytes of its half floats, then the high bytes.
|
||||
fn encode_strip(img: &Rgb32f, y0: usize, y1: usize) -> Result<Vec<u8>, ProductError> {
|
||||
let n = (y1 - y0) * img.width;
|
||||
let mut raw = allocate(6 * n, 0u8)?;
|
||||
let rows = img.data.get(y0 * img.width..y1 * img.width).unwrap_or(&[]);
|
||||
for (i, px) in rows.iter().enumerate() {
|
||||
for (c, &v) in px.iter().enumerate() {
|
||||
let h = to_f16(v);
|
||||
if let Some(lo) = raw.get_mut(2 * c * n + i) {
|
||||
*lo = h as u8;
|
||||
}
|
||||
if let Some(hi) = raw.get_mut((2 * c + 1) * n + i) {
|
||||
*hi = (h >> 8) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(miniz_oxide::deflate::compress_to_vec(&raw, 3))
|
||||
}
|
||||
|
||||
/// Write `img` to `path` (through a temporary file next to it, so a reader never sees half a product).
|
||||
pub fn write(path: &Path, img: &Rgb32f, key: &str) -> Result<(), ProductError> {
|
||||
let pixels = img.width.checked_mul(img.height).filter(|&n| n <= MAX_PIXELS);
|
||||
if img.width == 0 || img.height == 0 || img.width > MAX_SIDE || img.height > MAX_SIDE || pixels != Some(img.data.len()) {
|
||||
return Err(ProductError::Format("the picture has no usable size".into()));
|
||||
}
|
||||
let strips: Vec<(usize, usize)> = (0..img.height).step_by(STRIP_ROWS).map(|y| (y, (y + STRIP_ROWS).min(img.height))).collect();
|
||||
let packed: Vec<Vec<u8>> = strips.par_iter().map(|&(a, b)| encode_strip(img, a, b)).collect::<Result<_, _>>()?;
|
||||
let header = serde_json::to_vec(&Header { width: img.width, height: img.height, strip: STRIP_ROWS, key: key.to_string() })
|
||||
.map_err(|e| ProductError::Format(e.to_string()))?;
|
||||
let tmp = path.with_extension("tmp");
|
||||
let result = (|| -> Result<(), ProductError> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let mut f = std::io::BufWriter::new(std::fs::File::create(&tmp)?);
|
||||
f.write_all(MAGIC)?;
|
||||
f.write_all(&[VERSION, 0, 0, 0])?;
|
||||
f.write_all(&(header.len() as u32).to_le_bytes())?;
|
||||
f.write_all(&header)?;
|
||||
for p in &packed {
|
||||
f.write_all(&(p.len() as u32).to_le_bytes())?;
|
||||
f.write_all(&crc(p).to_le_bytes())?;
|
||||
}
|
||||
for p in &packed {
|
||||
f.write_all(p)?;
|
||||
}
|
||||
f.flush()?;
|
||||
drop(f);
|
||||
std::fs::rename(&tmp, path)?;
|
||||
Ok(())
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// The parsed start of a product file: header, each strip's (compressed length, CRC-32), and the file length the
|
||||
/// start says the whole file has.
|
||||
struct Start {
|
||||
header: Header,
|
||||
table: Vec<(usize, u32)>,
|
||||
expected_len: u64,
|
||||
}
|
||||
|
||||
fn read_start(r: &mut impl Read) -> Result<Start, ProductError> {
|
||||
let mut fixed = [0u8; 12];
|
||||
r.read_exact(&mut fixed)?;
|
||||
if fixed.get(0..4) != Some(&MAGIC[..]) || fixed.get(4) != Some(&VERSION) {
|
||||
return Err(ProductError::Format("not a denoised picture of this version".into()));
|
||||
}
|
||||
let len = fixed.get(8..12).and_then(|b| <[u8; 4]>::try_from(b).ok()).map(u32::from_le_bytes).unwrap_or(u32::MAX) as usize;
|
||||
if len > MAX_HEADER {
|
||||
return Err(ProductError::Format("the header is too large".into()));
|
||||
}
|
||||
let mut hb = allocate(len, 0u8)?;
|
||||
r.read_exact(&mut hb)?;
|
||||
let h: Header = serde_json::from_slice(&hb).map_err(|e| ProductError::Format(e.to_string()))?;
|
||||
let pixels = h.width.saturating_mul(h.height);
|
||||
if h.width == 0 || h.height == 0 || h.width > MAX_SIDE || h.height > MAX_SIDE || pixels > MAX_PIXELS || h.strip != STRIP_ROWS {
|
||||
return Err(ProductError::Format("the size is not usable".into()));
|
||||
}
|
||||
let n = h.height.div_ceil(h.strip);
|
||||
let mut table = Vec::with_capacity(n);
|
||||
let mut total = 12 + len as u64 + 8 * n as u64;
|
||||
for _ in 0..n {
|
||||
let mut e = [0u8; 8];
|
||||
r.read_exact(&mut e)?;
|
||||
let l = u32::from_le_bytes([e[0], e[1], e[2], e[3]]) as usize;
|
||||
let c = u32::from_le_bytes([e[4], e[5], e[6], e[7]]);
|
||||
// a strip never compresses to much more than its raw size
|
||||
if l > 12 * h.strip * h.width + 4096 {
|
||||
return Err(ProductError::Format("a strip is larger than it can be".into()));
|
||||
}
|
||||
total += l as u64;
|
||||
table.push((l, c));
|
||||
}
|
||||
Ok(Start { header: h, table, expected_len: total })
|
||||
}
|
||||
|
||||
/// The header of the product at `path`, when it is one.
|
||||
pub fn read_header_at(path: &Path) -> Result<Header, ProductError> {
|
||||
let mut f = std::io::BufReader::new(std::fs::File::open(path)?);
|
||||
Ok(read_start(&mut f)?.header)
|
||||
}
|
||||
|
||||
/// Whether `path` holds a complete-looking product made from `key` (the header and the file's length are checked;
|
||||
/// strips are checked when read).
|
||||
pub fn is_current(path: &Path, key: &str) -> bool {
|
||||
let check = || -> Result<bool, ProductError> {
|
||||
let mut f = std::io::BufReader::new(std::fs::File::open(path)?);
|
||||
let s = read_start(&mut f)?;
|
||||
Ok(s.header.key == key && std::fs::metadata(path)?.len() == s.expected_len)
|
||||
};
|
||||
check().unwrap_or(false)
|
||||
}
|
||||
|
||||
/// A rectangle of a product, in the product's pixels.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Window {
|
||||
pub x: usize,
|
||||
pub y: usize,
|
||||
pub width: usize,
|
||||
pub height: usize,
|
||||
}
|
||||
|
||||
/// Half floats as `f32`, for all 65536 bit patterns (a table is much faster than computing each).
|
||||
fn f16_table() -> &'static [f32] {
|
||||
static TABLE: std::sync::OnceLock<Vec<f32>> = std::sync::OnceLock::new();
|
||||
TABLE.get_or_init(|| (0..=u16::MAX).map(from_f16).collect())
|
||||
}
|
||||
|
||||
/// Strips decoded together (they are inflated in parallel, then folded in order).
|
||||
const GROUP: usize = 8;
|
||||
|
||||
/// One strip, checked and inflated: for each plane the low bytes of its half floats, then the high bytes.
|
||||
fn decode_strip(index: usize, packed: &[u8], expect_crc: u32, n: usize) -> Result<Vec<u8>, ProductError> {
|
||||
if crc(packed) != expect_crc {
|
||||
return Err(ProductError::Format(format!("strip {index} is damaged")));
|
||||
}
|
||||
let raw = miniz_oxide::inflate::decompress_to_vec_with_limit(packed, 6 * n).map_err(|e| ProductError::Format(format!("strip {index}: {e:?}")))?;
|
||||
if raw.len() != 6 * n {
|
||||
return Err(ProductError::Format(format!("strip {index} has the wrong size")));
|
||||
}
|
||||
Ok(raw)
|
||||
}
|
||||
|
||||
/// Read the product at `path`, made from `key`, with every `factor × factor` block of pixels averaged into one
|
||||
/// (`factor` 1 = full size). The result is camera RGB.
|
||||
///
|
||||
/// With a `window`, only that rectangle is read and the blocks start at its corner; only whole blocks are made, so
|
||||
/// the result is `window.width / factor × window.height / factor` (the same blocks a binned development of the same
|
||||
/// crop makes). Without one the whole product is read and blocks at the right and bottom edges may be smaller.
|
||||
///
|
||||
/// With `clip` (white is 1), a block in which any sample of a colour is at or above it takes that colour's
|
||||
/// maximum instead of the mean, so a clipped highlight stays clipped for highlight reconstruction.
|
||||
pub fn read_window(path: &Path, key: &str, window: Option<Window>, factor: usize, clip: Option<f32>) -> Result<Rgb32f, ProductError> {
|
||||
let factor = factor.clamp(1, 1 << 12);
|
||||
let mut f = std::io::BufReader::new(std::fs::File::open(path)?);
|
||||
let Start { header: h, table, expected_len } = read_start(&mut f)?;
|
||||
if h.key != key {
|
||||
return Err(ProductError::Stale);
|
||||
}
|
||||
if std::fs::metadata(path)?.len() != expected_len {
|
||||
return Err(ProductError::Format("the file is not the length its header says".into()));
|
||||
}
|
||||
let w = window.unwrap_or(Window { x: 0, y: 0, width: h.width, height: h.height });
|
||||
let fits = |a: usize, b: usize, limit: usize| a.checked_add(b).is_some_and(|e| e <= limit);
|
||||
if w.width == 0 || w.height == 0 || !fits(w.x, w.width, h.width) || !fits(w.y, w.height, h.height) {
|
||||
return Err(ProductError::Format("the window does not lie inside the picture".into()));
|
||||
}
|
||||
// whole blocks for a window; partial ones at the edges when the whole picture is read
|
||||
let whole = window.is_none();
|
||||
let (ow, oh) = if whole { (w.width.div_ceil(factor), w.height.div_ceil(factor)) } else { (w.width / factor, w.height / factor) };
|
||||
if ow == 0 || oh == 0 {
|
||||
return Err(ProductError::Format("the window is smaller than one block".into()));
|
||||
}
|
||||
// the picture rows and columns the blocks cover
|
||||
let (x_end, y_end) = if whole { (w.x + w.width, w.y + w.height) } else { (w.x + ow * factor, w.y + oh * factor) };
|
||||
let mut out = Rgb32f { width: ow, height: oh, data: allocate(ow * oh, [0.0; 3])? };
|
||||
let lut = f16_table();
|
||||
let value = |raw: &[u8], c: usize, n: usize, i: usize| -> f32 {
|
||||
let (lo, hi) = (raw.get(2 * c * n + i).copied().unwrap_or(0), raw.get((2 * c + 1) * n + i).copied().unwrap_or(0));
|
||||
lut.get(usize::from(lo) | usize::from(hi) << 8).copied().unwrap_or(0.0)
|
||||
};
|
||||
// which strips are needed, and where each starts in the file (after the header and the strip table)
|
||||
let wanted = |s: usize| s * h.strip < y_end && (s + 1) * h.strip > w.y;
|
||||
let (mut sum, mut peak) = (allocate(ow, [0f32; 3])?, allocate(ow, [f32::MIN; 3])?);
|
||||
let (mut rows_in, mut oy) = (0usize, 0usize);
|
||||
let strips: Vec<usize> = (0..table.len()).filter(|&s| wanted(s)).collect();
|
||||
// skip the strips before the first one wanted
|
||||
let skipped: usize = table.iter().take(strips.first().copied().unwrap_or(0)).map(|e| e.0).sum();
|
||||
f.seek_relative(i64::try_from(skipped).map_err(|_| ProductError::Format("the file is too large".into()))?)?;
|
||||
for group in strips.chunks(GROUP) {
|
||||
let mut packed = Vec::with_capacity(group.len());
|
||||
for &s in group {
|
||||
let Some(&(len, _)) = table.get(s) else { return Err(ProductError::Format("a strip is missing".into())) };
|
||||
let mut p = allocate(len, 0u8)?;
|
||||
f.read_exact(&mut p)?;
|
||||
packed.push(p);
|
||||
}
|
||||
let decoded: Vec<Result<Vec<u8>, ProductError>> = group
|
||||
.par_iter()
|
||||
.zip(packed.par_iter())
|
||||
.map(|(&s, p)| {
|
||||
let rows = h.height.saturating_sub(s * h.strip).min(h.strip);
|
||||
decode_strip(s, p, table.get(s).map_or(0, |e| e.1), rows * h.width)
|
||||
})
|
||||
.collect();
|
||||
for (&s, raw) in group.iter().zip(decoded) {
|
||||
let raw = raw?;
|
||||
let y0 = s * h.strip;
|
||||
let rows = h.height.saturating_sub(y0).min(h.strip);
|
||||
let n = rows * h.width;
|
||||
for r in 0..rows {
|
||||
let y = y0 + r;
|
||||
if y < w.y || y >= y_end {
|
||||
continue;
|
||||
}
|
||||
let base = r * h.width;
|
||||
for (ox, (sm, pk)) in sum.iter_mut().zip(peak.iter_mut()).enumerate() {
|
||||
let x0 = w.x + ox * factor;
|
||||
let x1 = (x0 + factor).min(x_end);
|
||||
for x in x0..x1 {
|
||||
for c in 0..3 {
|
||||
let v = value(&raw, c, n, base + x);
|
||||
if let (Some(s_), Some(p_)) = (sm.get_mut(c), pk.get_mut(c)) {
|
||||
*s_ += v;
|
||||
*p_ = p_.max(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rows_in += 1;
|
||||
if rows_in == factor || y + 1 == y_end {
|
||||
for (ox, (sm, pk)) in sum.iter_mut().zip(peak.iter_mut()).enumerate() {
|
||||
// the block's size at the right and bottom edges may be smaller
|
||||
let bw = (x_end - (w.x + ox * factor)).min(factor);
|
||||
let k = 1.0 / (rows_in * bw).max(1) as f32;
|
||||
if let Some(o) = out.data.get_mut(oy * ow + ox) {
|
||||
for c in 0..3 {
|
||||
let (s_, p_) = (sm.get(c).copied().unwrap_or(0.0), pk.get(c).copied().unwrap_or(0.0));
|
||||
if let Some(slot) = o.get_mut(c) {
|
||||
*slot = if factor > 1 && clip.is_some_and(|t| p_ >= t) { p_ } else { s_ * k };
|
||||
}
|
||||
}
|
||||
}
|
||||
*sm = [0.0; 3];
|
||||
*pk = [f32::MIN; 3];
|
||||
}
|
||||
rows_in = 0;
|
||||
oy += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// [`read_window`] of the whole product: every `factor × factor` block averaged into one.
|
||||
pub fn read(path: &Path, key: &str, factor: usize) -> Result<Rgb32f, ProductError> {
|
||||
read_window(path, key, None, factor, None)
|
||||
}
|
||||
|
||||
/// `base + (denoised − base) · amount` for every pixel (`amount` 0 to 1), in place in `base`. The two pictures must
|
||||
/// be the same size; if they are not, `base` is left as it was and `false` is returned.
|
||||
pub fn blend(base: &mut Rgb32f, denoised: &Rgb32f, amount: f32) -> bool {
|
||||
if base.width != denoised.width || base.height != denoised.height {
|
||||
return false;
|
||||
}
|
||||
let a = if amount.is_finite() { amount.clamp(0.0, 1.0) } else { 0.0 };
|
||||
base.data.par_iter_mut().zip(denoised.data.par_iter()).for_each(|(b, d)| {
|
||||
for c in 0..3 {
|
||||
b[c] += (d[c] - b[c]) * a;
|
||||
}
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn oversized_product_header_is_rejected_before_allocating_pixels() {
|
||||
let p = temp("oversized-header");
|
||||
let header = serde_json::to_vec(&Header { width: 10001, height: 10000, strip: STRIP_ROWS, key: "k".into() }).unwrap();
|
||||
let mut bytes = Vec::from(MAGIC.as_slice());
|
||||
bytes.extend_from_slice(&[VERSION, 0, 0, 0]);
|
||||
bytes.extend_from_slice(&(header.len() as u32).to_le_bytes());
|
||||
bytes.extend_from_slice(&header);
|
||||
std::fs::write(&p, bytes).unwrap();
|
||||
assert!(read_header_at(&p).is_err());
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
|
||||
fn temp(name: &str) -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(format!("lc-denoise-{name}-{}.lcdn", std::process::id()))
|
||||
}
|
||||
|
||||
fn picture(w: usize, h: usize) -> Rgb32f {
|
||||
Rgb32f {
|
||||
width: w,
|
||||
height: h,
|
||||
data: (0..w * h).map(|i| [(i % w) as f32 / w as f32, (i / w) as f32 / h as f32, 0.001 + 0.5 * ((i % 7) as f32 / 7.0)]).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_floats_round_trip_closely() {
|
||||
for v in [0.0f32, 1.0, -1.0, 0.5, 0.333_333, 1e-4, 6.1e-5, 3e-6, 123.456, 65000.0, -0.002] {
|
||||
let back = from_f16(to_f16(v));
|
||||
assert!((back - v).abs() <= v.abs() * 0.001 + 1e-7, "{v} -> {back}");
|
||||
}
|
||||
assert_eq!(from_f16(to_f16(1e10)), 65504.0);
|
||||
assert_eq!(to_f16(f32::NAN), 0);
|
||||
assert_eq!(from_f16(to_f16(f32::NEG_INFINITY)), -65504.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_product_survives_a_round_trip_at_full_and_reduced_size() {
|
||||
let (w, h) = (301, 517);
|
||||
let img = picture(w, h);
|
||||
let p = temp("round");
|
||||
write(&p, &img, "k1").unwrap();
|
||||
assert!(is_current(&p, "k1") && !is_current(&p, "k2"));
|
||||
assert_eq!(read_header_at(&p).unwrap().width, w);
|
||||
let full = read(&p, "k1", 1).unwrap();
|
||||
assert_eq!((full.width, full.height), (w, h));
|
||||
for (a, b) in full.data.iter().zip(&img.data) {
|
||||
for c in 0..3 {
|
||||
assert!((a[c] - b[c]).abs() < 0.002, "{a:?} vs {b:?}");
|
||||
}
|
||||
}
|
||||
// averaged into blocks of 4: the sizes round up, and the mean of the picture is kept
|
||||
let small = read(&p, "k1", 4).unwrap();
|
||||
assert_eq!((small.width, small.height), (w.div_ceil(4), h.div_ceil(4)));
|
||||
let mean = |i: &Rgb32f| i.data.iter().map(|p| f64::from(p[1])).sum::<f64>() / i.data.len() as f64;
|
||||
assert!((mean(&small) - mean(&img)).abs() < 0.01);
|
||||
// a factor that does not divide the strip height is the same average
|
||||
let odd = read(&p, "k1", 5).unwrap();
|
||||
assert_eq!((odd.width, odd.height), (w.div_ceil(5), h.div_ceil(5)));
|
||||
assert!((mean(&odd) - mean(&img)).abs() < 0.01);
|
||||
assert!(matches!(read(&p, "other", 1), Err(ProductError::Stale)));
|
||||
std::fs::remove_file(&p).unwrap();
|
||||
}
|
||||
|
||||
/// What binning a window of `img` by `k` means, written the slow obvious way.
|
||||
fn blocks(img: &Rgb32f, win: Window, k: usize, clip: Option<f32>) -> Rgb32f {
|
||||
let (ow, oh) = (win.width / k, win.height / k);
|
||||
let mut out = Rgb32f { width: ow, height: oh, data: vec![[0.0; 3]; ow * oh] };
|
||||
for by in 0..oh {
|
||||
for bx in 0..ow {
|
||||
for c in 0..3 {
|
||||
let vals: Vec<f32> = (0..k * k).map(|i| img.data[(win.y + by * k + i / k) * img.width + win.x + bx * k + i % k][c]).collect();
|
||||
let max = vals.iter().copied().fold(f32::MIN, f32::max);
|
||||
out.data[by * ow + bx][c] =
|
||||
if k > 1 && clip.is_some_and(|t| max >= t) { max } else { vals.iter().sum::<f32>() / vals.len() as f32 };
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_is_read_as_the_blocks_of_that_crop() {
|
||||
let (w, h) = (301, 517);
|
||||
let mut img = picture(w, h);
|
||||
// a clipped sample in the middle of a block
|
||||
img.data[(130 + 2) * w + 61 + 1] = [1.0, 0.4, 0.4];
|
||||
let p = temp("window");
|
||||
write(&p, &img, "k1").unwrap();
|
||||
let near = |a: &Rgb32f, b: &Rgb32f| {
|
||||
assert_eq!((a.width, a.height), (b.width, b.height));
|
||||
for (x, y) in a.data.iter().zip(&b.data) {
|
||||
for c in 0..3 {
|
||||
assert!((x[c] - y[c]).abs() < 0.003, "{x:?} vs {y:?}");
|
||||
}
|
||||
}
|
||||
};
|
||||
// windows at odd places, across strip boundaries (128 rows), with factors that do and do not divide them
|
||||
for (win, k) in [
|
||||
(Window { x: 3, y: 5, width: 200, height: 300 }, 1),
|
||||
(Window { x: 3, y: 5, width: 200, height: 300 }, 4),
|
||||
(Window { x: 61, y: 130, width: 121, height: 250 }, 5),
|
||||
(Window { x: 0, y: 0, width: w, height: h }, 2),
|
||||
(Window { x: 1, y: 380, width: 90, height: 137 }, 3),
|
||||
] {
|
||||
let got = read_window(&p, "k1", Some(win), k, None).unwrap();
|
||||
near(&got, &blocks(&img, win, k, None));
|
||||
}
|
||||
// with a clip level the block with the clipped sample keeps its highest value, the others stay means
|
||||
let win = Window { x: 61, y: 130, width: 40, height: 20 };
|
||||
let clipped = read_window(&p, "k1", Some(win), 4, Some(0.99)).unwrap();
|
||||
near(&clipped, &blocks(&img, win, 4, Some(0.99)));
|
||||
assert!(clipped.data[0][0] > 0.99, "the clipped block stays clipped: {:?}", clipped.data[0]);
|
||||
let plain = read_window(&p, "k1", Some(win), 4, None).unwrap();
|
||||
assert!(plain.data[0][0] < 0.99, "an average falls below the clip level");
|
||||
// windows that do not fit, or are smaller than a block, are errors
|
||||
for bad in [
|
||||
Window { x: 0, y: 0, width: 0, height: 5 },
|
||||
Window { x: 290, y: 0, width: 20, height: 5 },
|
||||
Window { x: 0, y: 510, width: 5, height: 10 },
|
||||
Window { x: usize::MAX, y: 0, width: 2, height: 2 },
|
||||
Window { x: 0, y: 0, width: 3, height: 3 },
|
||||
] {
|
||||
assert!(read_window(&p, "k1", Some(bad), 4, None).is_err(), "{bad:?}");
|
||||
}
|
||||
std::fs::remove_file(&p).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_smooth_picture_compresses_well() {
|
||||
let img = Rgb32f {
|
||||
width: 512,
|
||||
height: 512,
|
||||
data: (0..512 * 512)
|
||||
.map(|i| {
|
||||
let v = 0.2 + 0.0001 * (i % 512) as f32;
|
||||
[v, v * 0.8, v * 0.6]
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
let p = temp("size");
|
||||
write(&p, &img, "k").unwrap();
|
||||
let bytes = std::fs::metadata(&p).unwrap().len() as usize;
|
||||
assert!(bytes < 512 * 512 * 6 / 2, "{bytes} bytes for a smooth picture");
|
||||
std::fs::remove_file(&p).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_files_are_errors_not_panics() {
|
||||
let img = picture(100, 300);
|
||||
let p = temp("damage");
|
||||
write(&p, &img, "k").unwrap();
|
||||
let good = std::fs::read(&p).unwrap();
|
||||
// truncated at every length
|
||||
for cut in (0..good.len()).step_by(37) {
|
||||
std::fs::write(&p, &good[..cut]).unwrap();
|
||||
assert!(read(&p, "k", 1).is_err(), "cut at {cut}");
|
||||
assert!(!is_current(&p, "k"));
|
||||
}
|
||||
// a flipped byte anywhere is caught by a checksum, the header parse, or the key
|
||||
for at in (0..good.len()).step_by(53) {
|
||||
let mut bad = good.clone();
|
||||
bad[at] ^= 0xff;
|
||||
std::fs::write(&p, &bad).unwrap();
|
||||
let _ = read(&p, "k", 1); // must not panic; most are errors
|
||||
}
|
||||
// a header that claims an absurd size
|
||||
let mut huge = b"LCDN\x01\0\0\0".to_vec();
|
||||
let hdr = br#"{"width":4000000000,"height":4000000000,"strip":128,"key":"k"}"#;
|
||||
huge.extend((hdr.len() as u32).to_le_bytes());
|
||||
huge.extend(hdr);
|
||||
std::fs::write(&p, &huge).unwrap();
|
||||
assert!(matches!(read(&p, "k", 1), Err(ProductError::Format(_))));
|
||||
std::fs::write(&p, b"").unwrap();
|
||||
assert!(read(&p, "k", 1).is_err());
|
||||
assert!(read(&temp("missing"), "k", 1).is_err());
|
||||
std::fs::remove_file(&p).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writing_rejects_unusable_pictures() {
|
||||
let p = temp("bad");
|
||||
assert!(write(&p, &Rgb32f { width: 0, height: 0, data: vec![] }, "k").is_err());
|
||||
assert!(write(&p, &Rgb32f { width: 3, height: 3, data: vec![[0.0; 3]; 8] }, "k").is_err());
|
||||
assert!(!p.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_mixes_by_amount_and_rejects_other_sizes() {
|
||||
let mut base = Rgb32f { width: 2, height: 1, data: vec![[0.0; 3], [1.0; 3]] };
|
||||
let dn = Rgb32f { width: 2, height: 1, data: vec![[1.0; 3], [0.0; 3]] };
|
||||
assert!(blend(&mut base, &dn, 0.25));
|
||||
assert_eq!(base.data, vec![[0.25; 3], [0.75; 3]]);
|
||||
assert!(blend(&mut base, &dn, f32::NAN));
|
||||
assert_eq!(base.data, vec![[0.25; 3], [0.75; 3]]);
|
||||
assert!(!blend(&mut base, &Rgb32f { width: 1, height: 1, data: vec![[0.0; 3]] }, 1.0));
|
||||
assert!(blend(&mut base, &dn, 1.0));
|
||||
assert_eq!(base.data, dn.data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc_matches_the_standard_check_value() {
|
||||
assert_eq!(crc(b"123456789"), 0xcbf4_3926);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,589 @@
|
|||
//! Denoising a whole Bayer mosaic with a tile model: cut it into overlapping tiles, run the model on each, bring
|
||||
//! each tile's scale back to the input's, keep clipped highlights as they were, and blend the tiles together.
|
||||
//!
|
||||
//! The model itself is a [`TileRunner`], so everything around it (packing, tiling, scale matching, highlight
|
||||
//! protection, blending, cancelling, progress) is tested without one.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use lightcraft_raster::Rgb32f;
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::bayer::{Layout, pack_tile};
|
||||
use crate::manifest::Gain;
|
||||
use crate::tiles::{starts, weight};
|
||||
|
||||
/// Most pixels one picture may have (guards the allocations that follow from its size).
|
||||
pub const MAX_PIXELS: usize = 100_000_000;
|
||||
/// A tile whose mean signal is below this (white = 1) is too dark to measure its scale from.
|
||||
const MIN_SIGNAL: f32 = 0.003;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("{0}")]
|
||||
Input(String),
|
||||
#[error("the model gave {0}")]
|
||||
Model(String),
|
||||
#[error("the model could not run: {0}")]
|
||||
Runtime(String),
|
||||
#[error("the model could not be loaded: {0}")]
|
||||
Load(String),
|
||||
#[error("cancelled")]
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
fn allocate<T: Clone>(n: usize, value: T) -> Result<Vec<T>, Error> {
|
||||
let mut v = Vec::new();
|
||||
v.try_reserve_exact(n).map_err(|_| Error::Input("not enough memory for denoise".into()))?;
|
||||
v.resize(n, value);
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// Something that runs the model on one tile.
|
||||
pub trait TileRunner: Sync {
|
||||
/// `input` is the tile as four planes `[R, G1, G2, B]` of `tile × tile` cells (NCHW, batch 1); the answer is
|
||||
/// camera RGB, three planes of `2·tile × 2·tile` pixels (CHW), in the model's own scale.
|
||||
fn run(&self, input: &[f32]) -> Result<Vec<f32>, Error>;
|
||||
}
|
||||
|
||||
pub struct Params {
|
||||
/// Side of the model's square, in cells.
|
||||
pub tile: usize,
|
||||
/// Cells neighbouring tiles share.
|
||||
pub overlap: usize,
|
||||
pub gain: Gain,
|
||||
/// White is 1; samples at or above this (minus a little) are clipped and keep their value.
|
||||
pub clip: Option<f32>,
|
||||
/// Tiles run at once (each model call may itself be single-threaded).
|
||||
pub parallel: usize,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Control<'a> {
|
||||
pub cancel: Option<&'a AtomicBool>,
|
||||
/// `(tiles done, tiles in all)`.
|
||||
pub progress: Option<&'a (dyn Fn(usize, usize) + Sync)>,
|
||||
}
|
||||
|
||||
/// The tile grid of a picture: `(starts across, starts down)`.
|
||||
pub fn grid(width: usize, height: usize, layout: Layout, tile: usize, overlap: usize) -> (Vec<usize>, Vec<usize>) {
|
||||
let (nx, ny) = layout.cells(width, height);
|
||||
(starts(nx, tile, overlap), starts(ny, tile, overlap))
|
||||
}
|
||||
|
||||
/// How many model calls [`denoise_bayer`] makes for a picture.
|
||||
pub fn tile_count(width: usize, height: usize, layout: Layout, tile: usize, overlap: usize) -> usize {
|
||||
let (sx, sy) = grid(width, height, layout, tile, overlap);
|
||||
sx.len().saturating_mul(sy.len())
|
||||
}
|
||||
|
||||
/// Denoise (and demosaic) the normalised mosaic `mosaic` of `width × height` samples, laid out as `layout`.
|
||||
/// Gives camera RGB at the mosaic's resolution.
|
||||
pub fn denoise_bayer(
|
||||
mosaic: &[f32],
|
||||
width: usize,
|
||||
height: usize,
|
||||
layout: Layout,
|
||||
runner: &dyn TileRunner,
|
||||
p: &Params,
|
||||
ctl: &Control<'_>,
|
||||
) -> Result<Rgb32f, Error> {
|
||||
let pixels = width
|
||||
.checked_mul(height)
|
||||
.filter(|&n| n > 0 && n <= MAX_PIXELS)
|
||||
.ok_or_else(|| Error::Input("the picture has no size, or is too large".into()))?;
|
||||
if mosaic.len() != pixels {
|
||||
return Err(Error::Input("the mosaic does not have width × height samples".into()));
|
||||
}
|
||||
if !(16..=2048).contains(&p.tile) || p.overlap >= p.tile.div_ceil(2) || layout.dx > 1 || layout.dy > 1 {
|
||||
return Err(Error::Input("the tile size and overlap do not fit".into()));
|
||||
}
|
||||
if let Gain::MatchMean { nominal, max_deviation } = p.gain
|
||||
&& !(nominal.is_finite() && (1e-6..=1e12).contains(&nominal) && max_deviation.is_finite() && (0.0..=0.5).contains(&max_deviation))
|
||||
{
|
||||
return Err(Error::Input("the model gain is not finite or within its supported range".into()));
|
||||
}
|
||||
if p.clip.is_some_and(|v| !v.is_finite() || v <= 0.0) {
|
||||
return Err(Error::Input("the highlight clip must be finite and positive".into()));
|
||||
}
|
||||
let (nx, ny) = layout.cells(width, height);
|
||||
let (sx, sy) = (starts(nx, p.tile, p.overlap), starts(ny, p.tile, p.overlap));
|
||||
let (cols, rows) = (sx.len(), sy.len());
|
||||
if cols == 0 || rows == 0 {
|
||||
return Err(Error::Input("the picture needs too many tiles".into()));
|
||||
}
|
||||
let tiles: Vec<(usize, usize, bool, bool, bool, bool)> = sy
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(j, &y0)| sx.iter().enumerate().map(move |(i, &x0)| (x0, y0, i > 0, i + 1 < cols, j > 0, j + 1 < rows)))
|
||||
.collect();
|
||||
let total = tiles.len();
|
||||
|
||||
let mut acc = allocate(pixels, [0f32; 3])?;
|
||||
let mut wsum = allocate(nx * ny, 0f32)?;
|
||||
let group = p.parallel.clamp(1, 4);
|
||||
let mut done = 0;
|
||||
// where the time goes, printed under `LIGHTCRAFT_PROFILE`
|
||||
let (mut t_wait, mut t_total) = (std::time::Duration::ZERO, std::time::Duration::ZERO);
|
||||
let began = web_time::Instant::now();
|
||||
|
||||
// One group of tiles at a time: pack, run the model, check and bring each to the input's scale, in parallel.
|
||||
// Blending (sequential: the tiles add into one picture, in tile order so the result does not depend on timing) of
|
||||
// the previous group runs while the next group is on the model, so neither the card nor the cores wait for it.
|
||||
type Tile = (usize, usize, bool, bool, bool, bool);
|
||||
let compute = |chunk: &[Tile]| -> Vec<Result<Vec<f32>, Error>> {
|
||||
chunk
|
||||
.par_iter()
|
||||
.map(|&(x0, y0, bx, ax, by, ay)| {
|
||||
let mut input = allocate(4 * p.tile * p.tile, 0f32)?;
|
||||
if !pack_tile(mosaic, width, height, layout, x0 as isize, y0 as isize, p.tile, &mut input) {
|
||||
return Err(Error::Input("a tile could not be packed".into()));
|
||||
}
|
||||
let mut out = runner.run(&input)?;
|
||||
let side = 2 * p.tile;
|
||||
if out.len() != 3 * side * side {
|
||||
return Err(Error::Model(format!("{} numbers for a tile, not the {} expected", out.len(), 3 * side * side)));
|
||||
}
|
||||
if out.iter().any(|v| !v.is_finite()) {
|
||||
return Err(Error::Model("numbers that are not finite".into()));
|
||||
}
|
||||
finish_tile(&input, &mut out, p, (bx, ax, by, ay));
|
||||
Ok(out)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let mut blend = |chunk: &[Tile], outs: Vec<Result<Vec<f32>, Error>>| -> Result<(), Error> {
|
||||
for (&(x0, y0, bx, ax, by, ay), r) in chunk.iter().zip(outs) {
|
||||
blend_tile(&r?, &mut acc, &mut wsum, (width, height), (nx, ny), layout, (x0, y0), p, (bx, ax, by, ay));
|
||||
done += 1;
|
||||
if let Some(f) = ctl.progress {
|
||||
f(done, total);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
let mut previous: Option<(&[Tile], Vec<Result<Vec<f32>, Error>>)> = None;
|
||||
for chunk in tiles.chunks(group) {
|
||||
if ctl.cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
|
||||
return Err(Error::Cancelled);
|
||||
}
|
||||
let (outs, blended) = rayon::join(
|
||||
|| compute(chunk),
|
||||
|| match previous.take() {
|
||||
Some((c, o)) => {
|
||||
let started = web_time::Instant::now();
|
||||
let r = blend(c, o);
|
||||
(r, started.elapsed())
|
||||
}
|
||||
None => (Ok(()), std::time::Duration::ZERO),
|
||||
},
|
||||
);
|
||||
blended.0?;
|
||||
t_wait += blended.1;
|
||||
previous = Some((chunk, outs));
|
||||
}
|
||||
if let Some((c, o)) = previous.take() {
|
||||
let started = web_time::Instant::now();
|
||||
blend(c, o)?;
|
||||
t_wait += started.elapsed();
|
||||
}
|
||||
t_total += began.elapsed();
|
||||
let started = web_time::Instant::now();
|
||||
// normalise by the weights
|
||||
let (ox, oy) = (layout.position(0, 0).0, layout.position(0, 0).1);
|
||||
acc.par_chunks_mut(width).enumerate().for_each(|(y, row)| {
|
||||
let cy = ((y as isize - oy) / 2) as usize;
|
||||
for (x, px) in row.iter_mut().enumerate() {
|
||||
let cx = ((x as isize - ox) / 2) as usize;
|
||||
let w = wsum.get(cy * nx + cx).copied().unwrap_or(0.0);
|
||||
if w > 0.0 {
|
||||
*px = px.map(|v| v / w);
|
||||
}
|
||||
}
|
||||
});
|
||||
if std::env::var_os("LIGHTCRAFT_PROFILE").is_some() {
|
||||
eprintln!(
|
||||
"[denoise] {total} tiles, {} at once: tiles {:.0} ms (blending, {:.0} ms of it, overlaps the model), normalise {:.0} ms",
|
||||
group,
|
||||
t_total.as_secs_f64() * 1e3,
|
||||
t_wait.as_secs_f64() * 1e3,
|
||||
started.elapsed().as_secs_f64() * 1e3
|
||||
);
|
||||
}
|
||||
Ok(Rgb32f { width, height, data: acc })
|
||||
}
|
||||
|
||||
/// Bring a tile's output to the input's scale and keep clipped highlights as they were (in place).
|
||||
fn finish_tile(input: &[f32], out: &mut [f32], p: &Params, edges: (bool, bool, bool, bool)) {
|
||||
let t = p.tile;
|
||||
let side = 2 * t;
|
||||
if let Gain::MatchMean { nominal, max_deviation } = p.gain {
|
||||
// the part of the tile whose scale is measured: away from the edges that blend into a neighbour
|
||||
let m = p.overlap / 2;
|
||||
let (i0, i1) = (if edges.0 { m } else { 0 }, t - if edges.1 { m } else { 0 });
|
||||
let (j0, j1) = (if edges.2 { m } else { 0 }, t - if edges.3 { m } else { 0 });
|
||||
let cells = ((i1 - i0) * (j1 - j0)) as f64;
|
||||
let plane_sum = |pl: usize| -> f64 {
|
||||
(j0..j1)
|
||||
.map(|j| input.get(pl * t * t + j * t + i0..pl * t * t + j * t + i1).map_or(0.0, |r| r.iter().map(|&v| f64::from(v)).sum::<f64>()))
|
||||
.sum()
|
||||
};
|
||||
let in_mean = [plane_sum(0) / cells, (plane_sum(1) + plane_sum(2)) / (2.0 * cells), plane_sum(3) / cells];
|
||||
for (c, &mean_in) in in_mean.iter().enumerate() {
|
||||
let sum_out: f64 = (2 * j0..2 * j1)
|
||||
.map(|y| {
|
||||
out.get(c * side * side + y * side + 2 * i0..c * side * side + y * side + 2 * i1)
|
||||
.map_or(0.0, |r| r.iter().map(|&v| f64::from(v)).sum::<f64>())
|
||||
})
|
||||
.sum();
|
||||
let mean_out = sum_out / (4.0 * cells);
|
||||
let measured = if mean_in as f32 >= MIN_SIGNAL && mean_out > 0.0 { (mean_out / mean_in) as f32 } else { nominal };
|
||||
let g = measured.clamp(nominal * (1.0 - max_deviation), nominal * (1.0 + max_deviation));
|
||||
if g.is_finite() && g > 0.0 {
|
||||
let inv = 1.0 / g;
|
||||
if let Some(plane) = out.get_mut(c * side * side..(c + 1) * side * side) {
|
||||
plane.iter_mut().for_each(|v| *v *= inv);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(clip) = p.clip {
|
||||
protect_clipped(input, out, t, clip);
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the mosaic is at (or within a cell of) white, the output becomes the plain cell values again, so a clipped
|
||||
/// area stays exactly as clipped as the camera recorded it (highlight recovery later depends on that).
|
||||
fn protect_clipped(input: &[f32], out: &mut [f32], t: usize, clip: f32) {
|
||||
let side = 2 * t;
|
||||
let plane = t * t;
|
||||
let (Some(r), Some(g1), Some(g2), Some(b)) =
|
||||
(input.get(0..plane), input.get(plane..2 * plane), input.get(2 * plane..3 * plane), input.get(3 * plane..4 * plane))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let mut m: Vec<f32> = (0..plane).map(|i| r[i].max(g1[i]).max(g2[i]).max(b[i])).collect();
|
||||
if !m.iter().any(|&v| v >= clip) {
|
||||
return;
|
||||
}
|
||||
// 3×3 maximum over cells (separable)
|
||||
let dilate = |src: &[f32], horizontal: bool| -> Vec<f32> {
|
||||
(0..plane)
|
||||
.map(|i| {
|
||||
let (x, y) = (i % t, i / t);
|
||||
let mut best = src[i];
|
||||
for d in [-1isize, 1] {
|
||||
let (nx, ny) = if horizontal { (x as isize + d, y as isize) } else { (x as isize, y as isize + d) };
|
||||
if nx >= 0 && ny >= 0 && (nx as usize) < t && (ny as usize) < t {
|
||||
best = best.max(src[ny as usize * t + nx as usize]);
|
||||
}
|
||||
}
|
||||
best
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
m = dilate(&dilate(&m, true), false);
|
||||
let lo = clip - 0.03;
|
||||
for j in 0..t {
|
||||
for i in 0..t {
|
||||
let k = j * t + i;
|
||||
let s = ((m[k] - lo) / (clip - lo)).clamp(0.0, 1.0);
|
||||
if s <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let s = s * s * (3.0 - 2.0 * s);
|
||||
let plain = [r[k], 0.5 * (g1[k] + g2[k]), b[k]];
|
||||
for (c, &pv) in plain.iter().enumerate() {
|
||||
for (sy, sx) in [(0, 0), (0, 1), (1, 0), (1, 1)] {
|
||||
if let Some(v) = out.get_mut(c * side * side + (2 * j + sy) * side + 2 * i + sx) {
|
||||
*v += (pv - *v) * s;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a finished tile to the picture with its blend weights.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn blend_tile(
|
||||
out: &[f32],
|
||||
acc: &mut [[f32; 3]],
|
||||
wsum: &mut [f32],
|
||||
size: (usize, usize),
|
||||
cells: (usize, usize),
|
||||
layout: Layout,
|
||||
at: (usize, usize),
|
||||
p: &Params,
|
||||
edges: (bool, bool, bool, bool),
|
||||
) {
|
||||
let (width, height) = size;
|
||||
let (nx, ny) = cells;
|
||||
let (t, side) = (p.tile, 2 * p.tile);
|
||||
let fade = p.overlap;
|
||||
let wx: Vec<f32> = (0..t).map(|i| weight(i, t, fade, edges.0, edges.1)).collect();
|
||||
let wy: Vec<f32> = (0..t).map(|j| weight(j, t, fade, edges.2, edges.3)).collect();
|
||||
for j in 0..t {
|
||||
let cy = at.1 + j;
|
||||
for i in 0..t {
|
||||
let cx = at.0 + i;
|
||||
// cells past the picture's edge (a picture smaller than one tile is padded) belong to no pixel
|
||||
if cx >= nx || cy >= ny {
|
||||
continue;
|
||||
}
|
||||
let (Some(&a), Some(&b)) = (wx.get(i), wy.get(j)) else { continue };
|
||||
let w = a * b;
|
||||
if let Some(s) = wsum.get_mut(cy * nx + cx) {
|
||||
*s += w;
|
||||
}
|
||||
let (px0, py0) = layout.position(cx as isize, cy as isize);
|
||||
for (sy, sx) in [(0isize, 0isize), (0, 1), (1, 0), (1, 1)] {
|
||||
let (x, y) = (px0 + sx, py0 + sy);
|
||||
if x < 0 || y < 0 || x as usize >= width || y as usize >= height {
|
||||
continue;
|
||||
}
|
||||
let o = (2 * j + sy as usize) * side + 2 * i + sx as usize;
|
||||
let (Some(&r), Some(&g), Some(&bl)) = (out.get(o), out.get(side * side + o), out.get(2 * side * side + o)) else { continue };
|
||||
if let Some(px) = acc.get_mut(y as usize * width + x as usize) {
|
||||
px[0] += w * r;
|
||||
px[1] += w * g;
|
||||
px[2] += w * bl;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A stand-in model: the cell's samples as an RGB picture at twice the resolution (a plain 2 × 2 "demosaic"),
|
||||
/// times `scale`, so the output has a scale of its own like the real model's.
|
||||
struct Mock {
|
||||
tile: usize,
|
||||
scale: f32,
|
||||
}
|
||||
impl TileRunner for Mock {
|
||||
fn run(&self, input: &[f32]) -> Result<Vec<f32>, Error> {
|
||||
let (t, side) = (self.tile, 2 * self.tile);
|
||||
let plane = t * t;
|
||||
let mut out = vec![0.0; 3 * side * side];
|
||||
for j in 0..t {
|
||||
for i in 0..t {
|
||||
let k = j * t + i;
|
||||
let rgb = [input[k], 0.5 * (input[plane + k] + input[2 * plane + k]), input[3 * plane + k]];
|
||||
for (c, v) in rgb.iter().enumerate() {
|
||||
for (sy, sx) in [(0, 0), (0, 1), (1, 0), (1, 1)] {
|
||||
out[c * side * side + (2 * j + sy) * side + 2 * i + sx] = v * self.scale;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
fn params(tile: usize, overlap: usize, gain: Gain) -> Params {
|
||||
Params { tile, overlap, gain, clip: None, parallel: 2 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflowing_tiles_and_nan_gain_are_errors_before_the_model_runs() {
|
||||
let layout = Layout::from_cell([0, 1, 1, 2]).unwrap();
|
||||
let model = Mock { tile: 16, scale: 1.0 };
|
||||
for (tile, overlap, gain) in [
|
||||
(usize::MAX, 0, Gain::None),
|
||||
(16, usize::MAX, Gain::None),
|
||||
(16, 0, Gain::MatchMean { nominal: 1.0, max_deviation: f32::NAN }),
|
||||
(16, 0, Gain::MatchMean { nominal: f32::INFINITY, max_deviation: 0.1 }),
|
||||
] {
|
||||
assert!(matches!(denoise_bayer(&[0.5], 1, 1, layout, &model, ¶ms(tile, overlap, gain), &Control::default()), Err(Error::Input(_))));
|
||||
}
|
||||
}
|
||||
|
||||
/// A smooth picture whose colour at a sample is `base[colour] · (1 + shading)`.
|
||||
fn mosaic(w: usize, h: usize, cell: [u8; 4]) -> Vec<f32> {
|
||||
(0..w * h)
|
||||
.map(|i| {
|
||||
let (x, y) = (i % w, i / w);
|
||||
let c = cell[(y % 2) * 2 + x % 2] as usize;
|
||||
[0.5, 0.3, 0.2][c] * (0.6 + 0.4 * ((x as f32 / 23.0).sin() * (y as f32 / 31.0).cos()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn reference(m: &[f32], w: usize, h: usize, layout: Layout) -> Rgb32f {
|
||||
// what the mock gives for the whole picture in one tile
|
||||
let (nx, ny) = layout.cells(w, h);
|
||||
let t = nx.max(ny).div_ceil(16) * 16;
|
||||
denoise_bayer(m, w, h, layout, &Mock { tile: t, scale: 1.0 }, ¶ms(t, 16, Gain::None), &Control::default()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiles_blend_to_the_same_picture_as_one_tile_for_every_layout() {
|
||||
for cell in [[0u8, 1, 1, 2], [1, 0, 2, 1], [1, 2, 0, 1], [2, 1, 1, 0]] {
|
||||
let layout = Layout::from_cell(cell).unwrap();
|
||||
let (w, h) = (150usize, 110usize);
|
||||
let m = mosaic(w, h, cell);
|
||||
let want = reference(&m, w, h, layout);
|
||||
let got = denoise_bayer(&m, w, h, layout, &Mock { tile: 32, scale: 1.0 }, ¶ms(32, 8, Gain::None), &Control::default()).unwrap();
|
||||
assert_eq!((got.width, got.height), (w, h));
|
||||
for (a, b) in got.data.iter().zip(&want.data) {
|
||||
for c in 0..3 {
|
||||
assert!((a[c] - b[c]).abs() < 1e-5, "layout {cell:?}: {a:?} vs {b:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_picture_does_not_depend_on_how_many_tiles_run_at_once_or_how_long_each_takes() {
|
||||
// tiles that finish in a different order every time: blending still adds them in tile order
|
||||
struct Jittery(Mock);
|
||||
impl TileRunner for Jittery {
|
||||
fn run(&self, input: &[f32]) -> Result<Vec<f32>, Error> {
|
||||
let wait = (input.iter().take(64).sum::<f32>() * 1000.0) as u64 % 7;
|
||||
std::thread::sleep(std::time::Duration::from_millis(wait));
|
||||
self.0.run(input)
|
||||
}
|
||||
}
|
||||
let layout = Layout { dx: 0, dy: 1 };
|
||||
let (w, h) = (150usize, 110usize);
|
||||
let m = mosaic(w, h, [1, 0, 2, 1]);
|
||||
let run = |parallel: usize| {
|
||||
let mut p = params(32, 8, Gain::MatchMean { nominal: 1.0e6, max_deviation: 0.05 });
|
||||
p.parallel = parallel;
|
||||
denoise_bayer(&m, w, h, layout, &Jittery(Mock { tile: 32, scale: 1.01e6 }), &p, &Control::default()).unwrap()
|
||||
};
|
||||
let one = run(1);
|
||||
for parallel in [2, 3, 8] {
|
||||
assert_eq!(run(parallel).data, one.data, "{parallel} at once");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failing_tile_or_a_cancel_ends_the_picture_without_a_panic() {
|
||||
struct Fails(Mock, std::sync::atomic::AtomicUsize);
|
||||
impl TileRunner for Fails {
|
||||
fn run(&self, input: &[f32]) -> Result<Vec<f32>, Error> {
|
||||
if self.1.fetch_add(1, Ordering::Relaxed) == 4 {
|
||||
return Err(Error::Runtime("boom".into()));
|
||||
}
|
||||
self.0.run(input)
|
||||
}
|
||||
}
|
||||
let layout = Layout { dx: 0, dy: 0 };
|
||||
let (w, h) = (150usize, 110usize);
|
||||
let m = mosaic(w, h, [0, 1, 1, 2]);
|
||||
let r = denoise_bayer(
|
||||
&m,
|
||||
w,
|
||||
h,
|
||||
layout,
|
||||
&Fails(Mock { tile: 32, scale: 1.0 }, Default::default()),
|
||||
¶ms(32, 8, Gain::None),
|
||||
&Control::default(),
|
||||
);
|
||||
assert!(matches!(r, Err(Error::Runtime(_))), "{r:?}");
|
||||
let stop = AtomicBool::new(true);
|
||||
let ctl = Control { cancel: Some(&stop), progress: None };
|
||||
let r = denoise_bayer(&m, w, h, layout, &Mock { tile: 32, scale: 1.0 }, ¶ms(32, 8, Gain::None), &ctl);
|
||||
assert!(matches!(r, Err(Error::Cancelled)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scale_of_its_own_is_taken_out_tile_by_tile() {
|
||||
let layout = Layout { dx: 0, dy: 0 };
|
||||
let (w, h) = (150usize, 110usize);
|
||||
let m = mosaic(w, h, [0, 1, 1, 2]);
|
||||
let want = reference(&m, w, h, layout);
|
||||
let gain = Gain::MatchMean { nominal: 1.0e6, max_deviation: 0.05 };
|
||||
// the scale differs a little from nominal; each tile's own scale is measured and removed
|
||||
let got = denoise_bayer(&m, w, h, layout, &Mock { tile: 32, scale: 1.02e6 }, ¶ms(32, 8, gain), &Control::default()).unwrap();
|
||||
let mean = |img: &Rgb32f, c: usize| img.data.iter().map(|p| f64::from(p[c])).sum::<f64>() / img.data.len() as f64;
|
||||
for c in 0..3 {
|
||||
let (a, b) = (mean(&got, c), mean(&want, c));
|
||||
assert!((a - b).abs() / b < 0.01, "channel {c}: {a} vs {b}");
|
||||
}
|
||||
// a scale far from nominal is clamped rather than trusted
|
||||
let wild = denoise_bayer(&m, w, h, layout, &Mock { tile: 32, scale: 3.0e6 }, ¶ms(32, 8, gain), &Control::default()).unwrap();
|
||||
assert!(mean(&wild, 1) > 2.0 * mean(&want, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picture_smaller_than_a_tile_is_padded_by_mirroring() {
|
||||
let layout = Layout { dx: 1, dy: 1 };
|
||||
let (w, h) = (20usize, 14usize);
|
||||
let m = mosaic(w, h, [2, 1, 1, 0]);
|
||||
let got = denoise_bayer(&m, w, h, layout, &Mock { tile: 64, scale: 1.0 }, ¶ms(64, 16, Gain::None), &Control::default()).unwrap();
|
||||
assert_eq!((got.width, got.height), (w, h));
|
||||
assert!(got.data.iter().all(|p| p.iter().all(|v| v.is_finite() && *v > 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipped_highlights_stay_clipped() {
|
||||
// a model that darkens everything by 10 % would pull a clipped plateau below white
|
||||
struct Dim(Mock);
|
||||
impl TileRunner for Dim {
|
||||
fn run(&self, input: &[f32]) -> Result<Vec<f32>, Error> {
|
||||
Ok(self.0.run(input)?.into_iter().map(|v| v * 0.9).collect())
|
||||
}
|
||||
}
|
||||
let layout = Layout { dx: 0, dy: 0 };
|
||||
let (w, h) = (96usize, 96usize);
|
||||
let m: Vec<f32> = (0..w * h).map(|i| if i % w > 40 && i % w < 70 { 1.0 } else { 0.4 }).collect();
|
||||
let mut p = params(32, 8, Gain::None);
|
||||
p.clip = Some(0.99);
|
||||
let got = denoise_bayer(&m, w, h, layout, &Dim(Mock { tile: 32, scale: 1.0 }), &p, &Control::default()).unwrap();
|
||||
let mid = got.data[48 * w + 55];
|
||||
assert!(mid.iter().all(|&v| v > 0.995), "plateau {mid:?}");
|
||||
let dark = got.data[48 * w + 10];
|
||||
assert!(dark.iter().all(|&v| (v - 0.36).abs() < 0.01), "unclipped area still goes through the model: {dark:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelling_and_progress_work() {
|
||||
let layout = Layout { dx: 0, dy: 0 };
|
||||
let (w, h) = (150usize, 110usize);
|
||||
let m = mosaic(w, h, [0, 1, 1, 2]);
|
||||
let cancel = AtomicBool::new(true);
|
||||
let r = denoise_bayer(
|
||||
&m,
|
||||
w,
|
||||
h,
|
||||
layout,
|
||||
&Mock { tile: 32, scale: 1.0 },
|
||||
¶ms(32, 8, Gain::None),
|
||||
&Control { cancel: Some(&cancel), progress: None },
|
||||
);
|
||||
assert!(matches!(r, Err(Error::Cancelled)));
|
||||
let seen = std::sync::Mutex::new(Vec::new());
|
||||
let f = |d: usize, n: usize| seen.lock().unwrap().push((d, n));
|
||||
denoise_bayer(&m, w, h, layout, &Mock { tile: 32, scale: 1.0 }, ¶ms(32, 8, Gain::None), &Control { cancel: None, progress: Some(&f) })
|
||||
.unwrap();
|
||||
let seen = seen.into_inner().unwrap();
|
||||
let total = tile_count(w, h, layout, 32, 8);
|
||||
assert!(total > 1);
|
||||
assert_eq!(seen.last(), Some(&(total, total)));
|
||||
assert!(seen.windows(2).all(|p| p[1].0 == p[0].0 + 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_inputs_are_errors() {
|
||||
let layout = Layout { dx: 0, dy: 0 };
|
||||
let mock = Mock { tile: 32, scale: 1.0 };
|
||||
let p = params(32, 8, Gain::None);
|
||||
assert!(matches!(denoise_bayer(&[], 0, 0, layout, &mock, &p, &Control::default()), Err(Error::Input(_))));
|
||||
assert!(matches!(denoise_bayer(&[0.0; 10], 4, 4, layout, &mock, &p, &Control::default()), Err(Error::Input(_))));
|
||||
assert!(matches!(denoise_bayer(&[0.0; 16], 4, 4, layout, &mock, ¶ms(32, 16, Gain::None), &Control::default()), Err(Error::Input(_))));
|
||||
struct Bad(Vec<f32>);
|
||||
impl TileRunner for Bad {
|
||||
fn run(&self, _: &[f32]) -> Result<Vec<f32>, Error> {
|
||||
Ok(self.0.clone())
|
||||
}
|
||||
}
|
||||
assert!(matches!(denoise_bayer(&[0.5; 16], 4, 4, layout, &Bad(vec![0.0; 5]), &p, &Control::default()), Err(Error::Model(_))));
|
||||
assert!(matches!(denoise_bayer(&[0.5; 16], 4, 4, layout, &Bad(vec![f32::NAN; 3 * 64 * 64]), &p, &Control::default()), Err(Error::Model(_))));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
//! Cutting a picture into overlapping tiles and putting the results back together.
|
||||
//!
|
||||
//! A model runs on a fixed square, so a picture is covered by tiles spaced evenly along each axis, each sharing
|
||||
//! at least `overlap` cells with its neighbours. Each tile's influence fades towards its own edges (except at the
|
||||
//! picture's edge), and the result is the sum of tile × weight divided by the sum of weights, so any set of
|
||||
//! overlapping tiles blends consistently and a seam between two tiles of slightly different scale is a smooth ramp.
|
||||
|
||||
/// Where the tiles start along one axis of `n` cells: `[0]` when the axis fits one tile (the tile is then padded by
|
||||
/// mirroring), else evenly spaced starts, the first at 0 and the last at `n − tile`, each tile sharing at least
|
||||
/// `overlap` cells with the next.
|
||||
pub fn starts(n: usize, tile: usize, overlap: usize) -> Vec<usize> {
|
||||
if n <= tile || tile == 0 {
|
||||
return vec![0];
|
||||
}
|
||||
let step = tile.saturating_sub(overlap).max(1);
|
||||
// tiles needed so that start k·spacing with spacing ≤ step reaches n − tile
|
||||
let Some(k) = (n - tile).div_ceil(step).checked_add(1).filter(|&k| k <= 1_000_000) else { return Vec::new() };
|
||||
let last = n - tile;
|
||||
(0..k).map(|i| if k == 1 { 0 } else { ((i as u128 * last as u128 + (k - 1) as u128 / 2) / (k - 1) as u128) as usize }).collect()
|
||||
}
|
||||
|
||||
/// Weight of the cell at offset `p` (0-based, inside a tile of `tile` cells) along one axis: 1 in the middle, easing
|
||||
/// down to a sliver over `fade` cells at an edge that has a neighbour, 1 right up to an edge that is the picture's.
|
||||
pub fn weight(p: usize, tile: usize, fade: usize, has_before: bool, has_after: bool) -> f32 {
|
||||
let fade = fade.max(1) as f32;
|
||||
let ramp = |d: usize| {
|
||||
let t = ((d as f32 + 0.5) / fade).min(1.0);
|
||||
// smoothstep, never exactly 0 so that a cell is always covered
|
||||
(t * t * (3.0 - 2.0 * t)).max(1e-3)
|
||||
};
|
||||
let before = if has_before { ramp(p) } else { 1.0 };
|
||||
let after = if has_after { ramp(tile.saturating_sub(1).saturating_sub(p)) } else { 1.0 };
|
||||
before.min(after)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hostile_axes_have_bounded_tile_counts_and_checked_spacing() {
|
||||
assert!(starts(usize::MAX, 1, 0).is_empty());
|
||||
assert!(starts(usize::MAX, 2, usize::MAX).is_empty());
|
||||
let s = starts(usize::MAX, usize::MAX / 2, 0);
|
||||
assert_eq!(s.first(), Some(&0));
|
||||
assert_eq!(s.last(), Some(&(usize::MAX - usize::MAX / 2)));
|
||||
assert!(s.windows(2).all(|s| s[0] < s[1]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_tile_when_it_fits() {
|
||||
assert_eq!(starts(100, 512, 64), vec![0]);
|
||||
assert_eq!(starts(512, 512, 64), vec![0]);
|
||||
assert_eq!(starts(0, 512, 64), vec![0]);
|
||||
assert_eq!(starts(10, 0, 0), vec![0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiles_cover_the_axis_and_overlap_enough() {
|
||||
for n in [513usize, 600, 900, 1000, 1024, 1500, 3024, 3600, 5000, 12345] {
|
||||
let s = starts(n, 512, 64);
|
||||
assert_eq!(s[0], 0, "n={n}");
|
||||
assert_eq!(*s.last().unwrap(), n - 512, "n={n}");
|
||||
for w in s.windows(2) {
|
||||
assert!(w[1] > w[0], "starts increase, n={n}");
|
||||
assert!(w[0] + 512 >= w[1] + 64, "at least 64 shared cells between {} and {}, n={n}", w[0], w[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weights_fade_only_towards_neighbours() {
|
||||
let (t, f) = (512, 64);
|
||||
// a lone tile: full weight everywhere
|
||||
assert!((0..t).all(|p| weight(p, t, f, false, false) == 1.0));
|
||||
// the first tile fades on its far side only
|
||||
assert_eq!(weight(0, t, f, false, true), 1.0);
|
||||
assert!(weight(t - 1, t, f, false, true) < 0.01);
|
||||
// the last tile fades on its near side only
|
||||
assert!(weight(0, t, f, true, false) < 0.01);
|
||||
assert_eq!(weight(t - 1, t, f, true, false), 1.0);
|
||||
// a middle tile is 1 in the middle and everywhere positive
|
||||
assert_eq!(weight(t / 2, t, f, true, true), 1.0);
|
||||
assert!((0..t).all(|p| weight(p, t, f, true, true) > 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_overlapping_tiles_blend_to_a_partition_of_unity() {
|
||||
// at every cell of the shared region the normalised weights are in [0, 1] and sum to 1
|
||||
let (t, f) = (512usize, 64usize);
|
||||
let s = starts(900, t, 64);
|
||||
assert_eq!(s.len(), 2);
|
||||
for cell in s[1]..s[0] + t {
|
||||
let a = weight(cell - s[0], t, f, false, true);
|
||||
let b = weight(cell - s[1], t, f, true, false);
|
||||
let (na, nb) = (a / (a + b), b / (a + b));
|
||||
assert!((na + nb - 1.0).abs() < 1e-6 && na >= 0.0 && nb >= 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
[package]
|
||||
name = "lightcraft-denoise"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "LightCraft AI denoise (L1): denoise-model manifests, Bayer packing, tiling and blending, the cache file, and pure-Rust CPU inference"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
runtime = ["dep:faer"]
|
||||
# Product-policy switch: absent by default; users may install their own model.
|
||||
rawnind-model = ["lightcraft-denoise-core/rawnind-model"]
|
||||
|
||||
[dependencies]
|
||||
lightcraft-denoise-core = { workspace = true }
|
||||
# Safe matrix views over the pure-Rust GEMM backend; no BLAS, cc/build scripts
|
||||
# or inner thread pool. Its dependencies include architecture-specific assembly.
|
||||
faer = { package = "faer-core", version = "=0.17.1", default-features = false, features = ["std"], optional = true }
|
||||
lightcraft-raster = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
miniz_oxide = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
# lightcraft-denoise
|
||||
|
||||
L1 pure-Rust AI RAW denoise primitives and CPU inference. No weights are bundled.
|
||||
See [user workflow and limitations](../../docs/denoise.md).
|
||||
|
||||
`lightcraft-denoise-core` provides metadata, Bayer helpers and cache formats,
|
||||
re-exported here. `manifest` and `known` validate opt-in models; `onnx` reads a bounded operator subset
|
||||
into `net`; `cpu` executes it and `reference` supplies scalar checks. `bayer`, `tiles`
|
||||
and `run` pack sensor data and blend overlapping tiles while preserving clipped
|
||||
highlights. `product` reads/writes the disposable `.lcdn` cache. `runtime` supplies
|
||||
the installation self-test. Synthetic networks are generated in Rust for tests.
|
||||
|
||||
Run `cargo test -p lightcraft-denoise --features runtime`. The CPU backend is optional;
|
||||
featureless and web builds keep the model metadata and cache format without faer/GEMM.
|
||||
The `rawnind-model` feature enables the otherwise empty download catalog and requires
|
||||
an explicit release-policy decision. Real-model tests are ignored by default and
|
||||
require an explicitly supplied local model. The GPU twin is `lightcraft-gpu::nn`;
|
||||
model management, background jobs and export integration live in `lightcraft-engine`.
|
||||
|
|
@ -0,0 +1,386 @@
|
|||
//! Single-thread CPU execution of the checked denoise network.
|
||||
//!
|
||||
//! Convolutions lower at most 256 spatial positions at a time, then use faer's safe views over pure-Rust GEMM.
|
||||
//! No inner thread pool: `run::denoise_bayer` already runs tiles on the application's Rayon pool. Tensor lifetimes
|
||||
//! are planned once; buffers and im2col storage are recycled between calls, independently for concurrent callers.
|
||||
|
||||
use crate::net::{Net, NetError, Op};
|
||||
use crate::run::{Error, TileRunner};
|
||||
use faer::{Parallelism, mat};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
const BLOCK: usize = 256;
|
||||
/// Cap all tensor buffers and convolution scratch for one tile together (512 MiB).
|
||||
const MAX_VALUES: usize = 128 * 1024 * 1024;
|
||||
const MAX_TILE: usize = 2048;
|
||||
const MAX_IDLE: usize = 1;
|
||||
|
||||
type Result<T> = std::result::Result<T, NetError>;
|
||||
fn bad(why: &str) -> NetError {
|
||||
NetError::Invalid(why.into())
|
||||
}
|
||||
fn count(c: usize, side: usize) -> Result<usize> {
|
||||
c.checked_mul(side)
|
||||
.and_then(|n| n.checked_mul(side))
|
||||
.filter(|&n| n > 0 && n <= MAX_VALUES)
|
||||
.ok_or_else(|| bad("a tensor has no size or is too large"))
|
||||
}
|
||||
fn zeros(n: usize) -> Result<Vec<f32>> {
|
||||
let mut v = Vec::new();
|
||||
v.try_reserve_exact(n).map_err(|_| bad("not enough memory for a denoise tile"))?;
|
||||
v.resize(n, 0.0);
|
||||
Ok(v)
|
||||
}
|
||||
fn sources(op: &Op) -> (usize, Option<usize>) {
|
||||
match op {
|
||||
Op::Conv(c) => (c.src, c.src2),
|
||||
Op::ConvT2(c) => (c.src, None),
|
||||
Op::MaxPool2 { src } | Op::DepthToSpace2 { src } => (*src, None),
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
struct Tensor {
|
||||
slot: usize,
|
||||
side: usize,
|
||||
channels: usize,
|
||||
}
|
||||
struct Plan {
|
||||
net: Net,
|
||||
tensors: Vec<Tensor>,
|
||||
sizes: Vec<usize>,
|
||||
lower: usize,
|
||||
product: usize,
|
||||
up_weights: Vec<Option<Vec<f32>>>,
|
||||
}
|
||||
struct Workspace {
|
||||
buffers: Vec<Vec<f32>>,
|
||||
lower: Vec<f32>,
|
||||
product: Vec<f32>,
|
||||
}
|
||||
|
||||
/// A checked Net on square CHW tiles. Clones share immutable weights and the idle workspace cache.
|
||||
#[derive(Clone)]
|
||||
pub struct NetRunner {
|
||||
plan: Arc<Plan>,
|
||||
idle: Arc<Mutex<Vec<Workspace>>>,
|
||||
}
|
||||
|
||||
impl NetRunner {
|
||||
pub fn new(net: &Net, tile: usize) -> Result<Self> {
|
||||
if tile == 0
|
||||
|| tile > MAX_TILE
|
||||
|| !net.fits_tile(tile)
|
||||
|| net.in_channels != 4
|
||||
|| net.out_channels() != 3
|
||||
|| net.shape(0).is_none_or(|s| s.channels != net.in_channels)
|
||||
{
|
||||
return Err(bad("the tile or Bayer-to-RGB contract does not fit the network"));
|
||||
}
|
||||
let mut last = vec![0; net.ops().len() + 1];
|
||||
for (i, op) in net.ops().iter().enumerate() {
|
||||
let (a, b) = sources(op);
|
||||
for id in std::iter::once(a).chain(b) {
|
||||
*last.get_mut(id).ok_or_else(|| bad("missing tensor"))? = i + 1;
|
||||
}
|
||||
}
|
||||
let mut tensors = vec![Tensor { slot: 0, side: tile, channels: net.in_channels }];
|
||||
let mut sizes = vec![count(net.in_channels, tile)?];
|
||||
let mut busy = vec![true];
|
||||
let (mut lower, mut product) = (0, 0);
|
||||
let mut up_weights = Vec::new();
|
||||
for (i, op) in net.ops().iter().enumerate() {
|
||||
let (src, src2) = sources(op);
|
||||
let a = *tensors.get(src).ok_or_else(|| bad("missing tensor"))?;
|
||||
let (channels, side) = match op {
|
||||
Op::Conv(c) => {
|
||||
if let Some(s) = src2
|
||||
&& tensors.get(s).is_none_or(|b| b.side != a.side)
|
||||
{
|
||||
return Err(bad("a join has different sizes"));
|
||||
}
|
||||
(c.cout, a.side)
|
||||
}
|
||||
Op::ConvT2(c) => (c.cout, a.side.checked_mul(2).ok_or_else(|| bad("size overflow"))?),
|
||||
Op::MaxPool2 { .. } => {
|
||||
if a.side < 2 || !a.side.is_multiple_of(2) {
|
||||
return Err(bad("pooling does not divide the tile"));
|
||||
}
|
||||
(a.channels, a.side / 2)
|
||||
}
|
||||
Op::DepthToSpace2 { .. } => (a.channels / 4, a.side.checked_mul(2).ok_or_else(|| bad("size overflow"))?),
|
||||
};
|
||||
let n = count(channels, side)?;
|
||||
let slot = busy
|
||||
.iter()
|
||||
.zip(&sizes)
|
||||
.enumerate()
|
||||
.filter(|(_, (used, _))| !**used)
|
||||
.min_by_key(|(_, (_, size))| (n.saturating_sub(**size), **size))
|
||||
.map_or(busy.len(), |(i, _)| i);
|
||||
if slot == sizes.len() {
|
||||
sizes.push(n);
|
||||
busy.push(true);
|
||||
} else {
|
||||
let sz = sizes.get_mut(slot).ok_or_else(|| bad("missing buffer"))?;
|
||||
*sz = (*sz).max(n);
|
||||
*busy.get_mut(slot).ok_or_else(|| bad("missing buffer"))? = true;
|
||||
}
|
||||
tensors.push(Tensor { slot, side, channels });
|
||||
// Release sources only after the output has its own buffer (also handles reading the same tensor twice).
|
||||
for id in std::iter::once(src).chain(src2) {
|
||||
if last.get(id) == Some(&(i + 1)) {
|
||||
let t = tensors.get(id).ok_or_else(|| bad("missing tensor"))?;
|
||||
*busy.get_mut(t.slot).ok_or_else(|| bad("missing buffer"))? = false;
|
||||
}
|
||||
}
|
||||
let (k, m, positions) = match op {
|
||||
Op::Conv(c) => (c.cin * c.k * c.k, c.cout, a.side * a.side),
|
||||
Op::ConvT2(c) => (c.cin, c.cout * 4, a.side * a.side),
|
||||
_ => (0, 0, 0),
|
||||
};
|
||||
lower = lower.max(k.saturating_mul(BLOCK.min(positions)));
|
||||
product = product.max(m.saturating_mul(BLOCK.min(positions)));
|
||||
let packed = if let Op::ConvT2(c) = op {
|
||||
let mut w = zeros(c.weight.len())?;
|
||||
// [cin][cout][dy][dx] -> [cout*4][cin], so an up-convolution is one GEMM and a shuffle.
|
||||
for (ci, row) in c.weight.chunks_exact(c.cout * 4).enumerate() {
|
||||
for (co, &v) in row.iter().enumerate() {
|
||||
*w.get_mut(co * c.cin + ci).ok_or_else(|| bad("invalid transposed weights"))? = v;
|
||||
}
|
||||
}
|
||||
Some(w)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
up_weights.push(packed);
|
||||
}
|
||||
let total = sizes
|
||||
.iter()
|
||||
.try_fold(lower.saturating_add(product), |n, &s| n.checked_add(s))
|
||||
.filter(|&n| n <= MAX_VALUES)
|
||||
.ok_or_else(|| bad("the tile needs too much working memory"))?;
|
||||
let _ = total;
|
||||
Ok(Self { plan: Arc::new(Plan { net: net.clone(), tensors, sizes, lower, product, up_weights }), idle: Arc::new(Mutex::new(Vec::new())) })
|
||||
}
|
||||
/// Immutable data for the GPU runner, without re-reading the model.
|
||||
pub fn net(&self) -> &Net {
|
||||
&self.plan.net
|
||||
}
|
||||
pub fn bytes_per_tile(&self) -> usize {
|
||||
(self.plan.sizes.iter().sum::<usize>() + self.plan.lower + self.plan.product) * 4
|
||||
}
|
||||
fn workspace(&self) -> Result<Workspace> {
|
||||
if let Some(w) = self.idle.lock().unwrap_or_else(std::sync::PoisonError::into_inner).pop() {
|
||||
return Ok(w);
|
||||
}
|
||||
Ok(Workspace {
|
||||
buffers: self.plan.sizes.iter().map(|&n| zeros(n)).collect::<Result<_>>()?,
|
||||
lower: zeros(self.plan.lower)?,
|
||||
product: zeros(self.plan.product)?,
|
||||
})
|
||||
}
|
||||
fn execute(&self, input: &[f32], w: &mut Workspace) -> Result<Vec<f32>> {
|
||||
let first = self.plan.tensors.first().ok_or_else(|| bad("no input"))?;
|
||||
let want = count(first.channels, first.side)?;
|
||||
w.buffers.first_mut().and_then(|b| b.get_mut(..want)).ok_or_else(|| bad("missing input buffer"))?.copy_from_slice(input);
|
||||
let profile = std::env::var_os("LIGHTCRAFT_PROFILE").is_some();
|
||||
for (i, op) in self.plan.net.ops().iter().enumerate() {
|
||||
let started = web_time::Instant::now();
|
||||
let out = *self.plan.tensors.get(i + 1).ok_or_else(|| bad("missing output tensor"))?;
|
||||
let (before, rest) = w.buffers.split_at_mut_checked(out.slot).ok_or_else(|| bad("missing output buffer"))?;
|
||||
let (dest, after) = rest.split_first_mut().ok_or_else(|| bad("missing output buffer"))?;
|
||||
let get = |id: usize| -> Result<(&[f32], Tensor)> {
|
||||
let t = *self.plan.tensors.get(id).ok_or_else(|| bad("missing source tensor"))?;
|
||||
let data = if t.slot < out.slot {
|
||||
before.get(t.slot)
|
||||
} else {
|
||||
after.get(t.slot.checked_sub(out.slot + 1).ok_or_else(|| bad("aliased buffers"))?)
|
||||
}
|
||||
.and_then(|b| b.get(..count(t.channels, t.side).ok()?))
|
||||
.ok_or_else(|| bad("missing source buffer"))?;
|
||||
Ok((data, t))
|
||||
};
|
||||
let (src, src2) = sources(op);
|
||||
let (a, shape) = get(src)?;
|
||||
let b = src2.map(get).transpose()?.map(|v| v.0);
|
||||
let dest = dest.get_mut(..count(out.channels, out.side)?).ok_or_else(|| bad("short output buffer"))?;
|
||||
match op {
|
||||
Op::Conv(c) => conv(c, a, b, shape.side, dest, &mut w.lower, &mut w.product)?,
|
||||
Op::ConvT2(c) => up(
|
||||
c,
|
||||
a,
|
||||
shape.side,
|
||||
dest,
|
||||
self.plan.up_weights.get(i).and_then(Option::as_deref).ok_or_else(|| bad("missing up weights"))?,
|
||||
&mut w.lower,
|
||||
&mut w.product,
|
||||
)?,
|
||||
Op::MaxPool2 { .. } => pool(a, shape.side, dest)?,
|
||||
Op::DepthToSpace2 { .. } => shuffle(a, shape.side, dest)?,
|
||||
}
|
||||
if dest.iter().any(|v| !v.is_finite()) {
|
||||
return Err(bad("the model produced non-finite values"));
|
||||
}
|
||||
if profile {
|
||||
eprintln!("[denoise-cpu] layer {i}: {}x{}x{} {:.2} ms", out.channels, out.side, out.side, started.elapsed().as_secs_f64() * 1e3);
|
||||
}
|
||||
}
|
||||
let t = self.plan.tensors.last().ok_or_else(|| bad("no output"))?;
|
||||
let data = w.buffers.get(t.slot).and_then(|b| b.get(..count(t.channels, t.side).ok()?)).ok_or_else(|| bad("missing result"))?;
|
||||
let mut out = zeros(data.len())?;
|
||||
out.copy_from_slice(data);
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
impl TileRunner for NetRunner {
|
||||
fn run(&self, input: &[f32]) -> std::result::Result<Vec<f32>, Error> {
|
||||
let first = self.plan.tensors.first().ok_or_else(|| Error::Runtime("missing input shape".into()))?;
|
||||
let want = count(first.channels, first.side).map_err(|e| Error::Runtime(e.to_string()))?;
|
||||
if input.len() != want || input.iter().any(|v| !v.is_finite()) {
|
||||
return Err(Error::Input("the tile has the wrong size or non-finite values".into()));
|
||||
}
|
||||
let mut w = self.workspace().map_err(|e| Error::Runtime(e.to_string()))?;
|
||||
let result = self.execute(input, &mut w).map_err(|e| Error::Runtime(e.to_string()));
|
||||
let mut idle = self.idle.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if idle.len() < MAX_IDLE {
|
||||
idle.push(w);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
fn activate(x: f32, bias: f32, alpha: Option<f32>) -> f32 {
|
||||
let x = x + bias;
|
||||
if x < 0.0 { x * alpha.unwrap_or(1.0) } else { x }
|
||||
}
|
||||
|
||||
/// All views are over exact, checked slices. The matrix library's assertions therefore never see file-derived sizes.
|
||||
fn multiply(w: &[f32], x: &[f32], y: &mut [f32], m: usize, k: usize, n: usize) -> Result<()> {
|
||||
if m.checked_mul(k) != Some(w.len()) || k.checked_mul(n) != Some(x.len()) || m.checked_mul(n) != Some(y.len()) || m == 0 || k == 0 || n == 0 {
|
||||
return Err(bad("invalid matrix dimensions"));
|
||||
}
|
||||
faer::mul::matmul(
|
||||
mat::from_row_major_slice_mut::<f32>(y, m, n),
|
||||
mat::from_row_major_slice::<f32>(w, m, k),
|
||||
mat::from_row_major_slice::<f32>(x, k, n),
|
||||
None,
|
||||
1.0,
|
||||
Parallelism::None,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
fn lower(a: &[f32], b: Option<&[f32]>, side: usize, k: usize, start: usize, n: usize, dest: &mut [f32]) -> Result<()> {
|
||||
let pad = k / 2;
|
||||
let pixels = side * side;
|
||||
// Whole row segments are copied, so bounds checks and edge branches stay out of the multiply loop.
|
||||
for (ci, plane) in a.chunks_exact(pixels).chain(b.into_iter().flat_map(|b| b.chunks_exact(pixels))).enumerate() {
|
||||
for ky in 0..k {
|
||||
for kx in 0..k {
|
||||
let row = (ci * k * k + ky * k + kx) * n;
|
||||
let out = dest.get_mut(row..row + n).ok_or_else(|| bad("short im2col buffer"))?;
|
||||
let mut at = 0;
|
||||
while at < n {
|
||||
let p = start + at;
|
||||
let (y, x) = (p / side, p % side);
|
||||
let len = (side - x).min(n - at);
|
||||
if let Some(yy) = y.checked_add(ky).and_then(|v| v.checked_sub(pad)).filter(|&v| v < side) {
|
||||
let lo = x.max(pad.saturating_sub(kx));
|
||||
let hi = (x + len).min((side + pad).saturating_sub(kx));
|
||||
if lo < hi {
|
||||
let from = yy * side + lo + kx - pad;
|
||||
let to = at + lo - x;
|
||||
out.get_mut(at..to).ok_or_else(|| bad("short im2col padding"))?.fill(0.0);
|
||||
out.get_mut(to..to + hi - lo)
|
||||
.ok_or_else(|| bad("short im2col row"))?
|
||||
.copy_from_slice(plane.get(from..from + hi - lo).ok_or_else(|| bad("short source row"))?);
|
||||
out.get_mut(to + hi - lo..at + len).ok_or_else(|| bad("short im2col padding"))?.fill(0.0);
|
||||
} else {
|
||||
out.get_mut(at..at + len).ok_or_else(|| bad("short im2col row"))?.fill(0.0);
|
||||
}
|
||||
} else {
|
||||
out.get_mut(at..at + len).ok_or_else(|| bad("short im2col row"))?.fill(0.0);
|
||||
}
|
||||
at += len;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn conv(c: &crate::net::Conv, a: &[f32], b: Option<&[f32]>, side: usize, dest: &mut [f32], scratch: &mut [f32], product: &mut [f32]) -> Result<()> {
|
||||
let pixels = side * side;
|
||||
let k = c.cin * c.k * c.k;
|
||||
for start in (0..pixels).step_by(BLOCK) {
|
||||
let n = BLOCK.min(pixels - start);
|
||||
let x = scratch.get_mut(..k * n).ok_or_else(|| bad("short im2col storage"))?;
|
||||
lower(a, b, side, c.k, start, n, x)?;
|
||||
let y = product.get_mut(..c.cout * n).ok_or_else(|| bad("short product storage"))?;
|
||||
multiply(&c.weight, x, y, c.cout, k, n)?;
|
||||
for ((row, &bias), out) in y.chunks_exact(n).zip(&c.bias).zip(dest.chunks_exact_mut(pixels)) {
|
||||
let to = out.get_mut(start..start + n).ok_or_else(|| bad("short convolution output"))?;
|
||||
for (d, &v) in to.iter_mut().zip(row) {
|
||||
*d = activate(v, bias, c.leaky);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn up(c: &crate::net::ConvT2, a: &[f32], side: usize, dest: &mut [f32], weights: &[f32], scratch: &mut [f32], product: &mut [f32]) -> Result<()> {
|
||||
let pixels = side * side;
|
||||
let out_side = 2 * side;
|
||||
for start in (0..pixels).step_by(BLOCK) {
|
||||
let n = BLOCK.min(pixels - start);
|
||||
let x = scratch.get_mut(..c.cin * n).ok_or_else(|| bad("short up input storage"))?;
|
||||
for (to, from) in x.chunks_exact_mut(n).zip(a.chunks_exact(pixels)) {
|
||||
to.copy_from_slice(from.get(start..start + n).ok_or_else(|| bad("short up input"))?);
|
||||
}
|
||||
let y = product.get_mut(..c.cout * 4 * n).ok_or_else(|| bad("short up product storage"))?;
|
||||
multiply(weights, x, y, c.cout * 4, c.cin, n)?;
|
||||
for (co, (&bias, out)) in c.bias.iter().zip(dest.chunks_exact_mut(4 * pixels)).enumerate() {
|
||||
for d in 0..4 {
|
||||
let row = y.get((4 * co + d) * n..(4 * co + d + 1) * n).ok_or_else(|| bad("short up product"))?;
|
||||
for (j, &v) in row.iter().enumerate() {
|
||||
let p = start + j;
|
||||
let at = (2 * (p / side) + d / 2) * out_side + 2 * (p % side) + d % 2;
|
||||
*out.get_mut(at).ok_or_else(|| bad("short up output"))? = activate(v, bias, c.leaky);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn pool(a: &[f32], side: usize, dest: &mut [f32]) -> Result<()> {
|
||||
let s = side / 2;
|
||||
for (input, out) in a.chunks_exact(side * side).zip(dest.chunks_exact_mut(s * s)) {
|
||||
for (y, row) in out.chunks_exact_mut(s).enumerate() {
|
||||
let lo = input.get(2 * y * side..(2 * y + 1) * side).ok_or_else(|| bad("short pool input"))?;
|
||||
let hi = input.get((2 * y + 1) * side..(2 * y + 2) * side).ok_or_else(|| bad("short pool input"))?;
|
||||
for ((d, lo), hi) in row.iter_mut().zip(lo.as_chunks::<2>().0).zip(hi.as_chunks::<2>().0) {
|
||||
let [a, b] = *lo;
|
||||
let [c, e] = *hi;
|
||||
*d = a.max(b).max(c).max(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn shuffle(a: &[f32], side: usize, dest: &mut [f32]) -> Result<()> {
|
||||
let pixels = side * side;
|
||||
for (input, out) in a.chunks_exact(4 * pixels).zip(dest.chunks_exact_mut(4 * pixels)) {
|
||||
for d in 0..4 {
|
||||
let plane = input.get(d * pixels..(d + 1) * pixels).ok_or_else(|| bad("short shuffle input"))?;
|
||||
for (y, row) in plane.chunks_exact(side).enumerate() {
|
||||
let at = (2 * y + d / 2) * 2 * side;
|
||||
let to = out.get_mut(at..at + 2 * side).ok_or_else(|| bad("short shuffle output"))?;
|
||||
for (to, &v) in to.as_chunks_mut::<2>().0.iter_mut().zip(row) {
|
||||
if let Some(t) = to.get_mut(d % 2) {
|
||||
*t = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
use super::*;
|
||||
use crate::{
|
||||
net::{Conv, ConvT2},
|
||||
reference, synthetic,
|
||||
};
|
||||
|
||||
fn random(n: usize, seed: u32) -> Vec<f32> {
|
||||
let mut s = seed | 1;
|
||||
(0..n)
|
||||
.map(|_| {
|
||||
s ^= s << 13;
|
||||
s ^= s >> 17;
|
||||
s ^= s << 5;
|
||||
(s >> 8) as f32 / (1u32 << 23) as f32 - 1.0
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn net(tile: usize, ch: u64, depth: usize, seed: u64) -> Net {
|
||||
let p = std::env::temp_dir().join(format!("lc-purecpu-{}-{tile}-{ch}-{depth}-{seed}.onnx", std::process::id()));
|
||||
std::fs::write(&p, synthetic::unet_onnx(tile as u64, ch, depth, seed)).unwrap();
|
||||
let n = crate::onnx::read(&p).unwrap();
|
||||
std::fs::remove_file(p).unwrap();
|
||||
n
|
||||
}
|
||||
fn agree(a: &[f32], b: &[f32]) {
|
||||
assert_eq!(a.len(), b.len());
|
||||
assert!(a.iter().chain(b).all(|v| v.is_finite()));
|
||||
let scale = b.iter().fold(1e-6f32, |m, v| m.max(v.abs()));
|
||||
let error = a.iter().zip(b).fold(0f32, |m, (a, b)| m.max((a - b).abs())) / scale;
|
||||
assert!(error <= 1e-3, "relative maximum error {error:e}");
|
||||
}
|
||||
#[test]
|
||||
fn all_operators_match_scalar_on_random_nets_and_odd_and_tiny_shapes() {
|
||||
for (t, ch, d) in [(1, 3, 0), (3, 5, 0), (5, 7, 0), (6, 3, 1), (20, 5, 2), (36, 9, 2), (64, 8, 3)] {
|
||||
for seed in [1, 73] {
|
||||
let n = net(t, ch, d, seed);
|
||||
let r = NetRunner::new(&n, t).unwrap();
|
||||
let x = random(4 * t * t, seed as u32);
|
||||
let want = reference::run(&n, t, &x).unwrap();
|
||||
agree(&r.run(&x).unwrap(), &want);
|
||||
assert_eq!(r.run(&x).unwrap(), r.run(&x).unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_values_fail_before_execution_and_workspace_sizes_are_bounded() {
|
||||
let mut n = net(4, 3, 1, 11);
|
||||
for t in [0, 1, 3, usize::MAX, 4096] {
|
||||
assert!(NetRunner::new(&n, t).is_err());
|
||||
}
|
||||
for ch in [0, 3, usize::MAX] {
|
||||
n.in_channels = ch;
|
||||
assert!(NetRunner::new(&n, 4).is_err());
|
||||
}
|
||||
let n = Net::new(
|
||||
4,
|
||||
vec![
|
||||
Op::Conv(Conv { src: 0, src2: None, cin: 4, cout: 4096, k: 1, weight: vec![0.1; 4 * 4096], bias: vec![0.0; 4096], leaky: None }),
|
||||
Op::Conv(Conv { src: 1, src2: None, cin: 4096, cout: 12, k: 1, weight: vec![0.1; 4096 * 12], bias: vec![0.0; 12], leaky: None }),
|
||||
Op::DepthToSpace2 { src: 2 },
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(NetRunner::new(&n, 2048).is_err());
|
||||
let ops = (0..100).map(|i| Op::MaxPool2 { src: i }).collect();
|
||||
let n = Net::new(4, ops).unwrap();
|
||||
assert!(NetRunner::new(&n, 16).is_err());
|
||||
let c = Conv { src: 0, src2: None, cin: 4, cout: 12, k: 1, weight: vec![0.25; 48], bias: vec![0.0; 12], leaky: None };
|
||||
for mode in 0..6 {
|
||||
let mut bad = c.clone();
|
||||
match mode {
|
||||
0 => bad.src = usize::MAX,
|
||||
1 => bad.weight.pop().map(|_| ()).unwrap(),
|
||||
2 => bad.bias[0] = f32::NAN,
|
||||
3 => bad.leaky = Some(f32::INFINITY),
|
||||
4 => bad.cout = usize::MAX,
|
||||
_ => bad.k = usize::MAX,
|
||||
}
|
||||
assert!(Net::new(4, vec![Op::Conv(bad), Op::DepthToSpace2 { src: 1 }]).is_err());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn bad_tiles_and_arithmetic_overflow_are_errors_and_do_not_poison_reuse() {
|
||||
let c = Conv { src: 0, src2: None, cin: 4, cout: 12, k: 1, weight: vec![f32::MAX; 48], bias: vec![0.0; 12], leaky: None };
|
||||
let n = Net::new(4, vec![Op::Conv(c), Op::DepthToSpace2 { src: 1 }]).unwrap();
|
||||
let r = NetRunner::new(&n, 1).unwrap();
|
||||
assert!(r.run(&[]).is_err());
|
||||
assert!(r.run(&[f32::NAN; 4]).is_err());
|
||||
assert!(r.run(&[f32::INFINITY; 4]).is_err());
|
||||
assert_eq!(r.idle.lock().unwrap().len(), 0, "invalid input never allocates a workspace");
|
||||
assert!(r.run(&[2.0; 4]).is_err());
|
||||
assert_eq!(r.run(&[0.0; 4]).unwrap(), vec![0.0; 12]);
|
||||
// Matrix shape checks are the boundary in front of the dependency's asserting constructors.
|
||||
assert!(multiply(&[], &[], &mut [], usize::MAX, 2, 2).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn shared_runner_serves_concurrent_tiles_without_cross_contamination() {
|
||||
let n = net(12, 5, 1, 97);
|
||||
let r = NetRunner::new(&n, 12).unwrap();
|
||||
let inputs: Vec<_> = (0..8).map(|s| random(4 * 12 * 12, s)).collect();
|
||||
let answers: Vec<_> = inputs.iter().map(|x| reference::run(&n, 12, x).unwrap()).collect();
|
||||
std::thread::scope(|scope| {
|
||||
let mut handles = Vec::new();
|
||||
for (x, want) in inputs.iter().zip(&answers) {
|
||||
let r = &r;
|
||||
handles.push(scope.spawn(move || {
|
||||
for _ in 0..3 {
|
||||
agree(&r.run(x).unwrap(), want);
|
||||
}
|
||||
}));
|
||||
}
|
||||
for h in handles {
|
||||
h.join().unwrap();
|
||||
}
|
||||
});
|
||||
}
|
||||
#[test]
|
||||
fn join_may_read_the_same_tensor_twice_and_leaky_up_convolution_is_supported() {
|
||||
let c = ConvT2 { src: 0, cin: 4, cout: 3, weight: random(48, 2), bias: vec![0.1, -0.1, 0.0], leaky: Some(0.13) };
|
||||
let n = Net::new(
|
||||
4,
|
||||
vec![
|
||||
Op::ConvT2(c),
|
||||
Op::Conv(Conv { src: 1, src2: Some(1), cin: 6, cout: 3, k: 3, weight: random(162, 3), bias: vec![0.0; 3], leaky: Some(0.0) }),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
let x = random(4 * 3 * 3, 7);
|
||||
agree(&NetRunner::new(&n, 3).unwrap().run(&x).unwrap(), &reference::run(&n, 3, &x).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reused_im2col_clears_all_edge_padding_between_images() {
|
||||
for t in [1, 3, 5, 36] {
|
||||
let n = net(t, 5, 0, 121);
|
||||
let r = NetRunner::new(&n, t).unwrap();
|
||||
for x in [vec![1.0; 4 * t * t], vec![0.0; 4 * t * t], random(4 * t * t, 9)] {
|
||||
agree(&r.run(&x).unwrap(), &reference::run(&n, t, &x).unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "needs LC_DENOISE_MODEL; one tile with LIGHTCRAFT_PROFILE=1"]
|
||||
fn profile_one_real_tile() {
|
||||
let model = std::env::var_os("LC_DENOISE_MODEL").expect("set LC_DENOISE_MODEL");
|
||||
let m = crate::known::find("rawnind-bayer").unwrap().manifest;
|
||||
let r = crate::runtime::CpuRunner::load(std::path::Path::new(&model), &m).unwrap();
|
||||
println!("workspace {:.1} MB", r.bytes_per_tile() as f64 / 1e6);
|
||||
r.run(&crate::runtime::check_tile(512)).unwrap();
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
//! AI denoise for LightCraft, as a step between the sensor and the demosaic that the rest of the develop
|
||||
//! pipeline never sees: the raw file stays untouched, the denoised picture is cached data that can always be
|
||||
//! made again, and the amount is an ordinary develop setting.
|
||||
//!
|
||||
//! - [`manifest`]: what a denoise model takes and gives, and who may use it (untrusted JSON, validated).
|
||||
//! - [`known`] and [`archive`]: the models LightCraft can fetch, and taking the model file out of the `.zip` it comes in.
|
||||
//! - [`bayer`]: Bayer layouts and the packing of a mosaic into the four planes a model takes.
|
||||
//! - [`tiles`] and [`run`]: cutting a picture into overlapping tiles for a fixed-size model, bringing each tile's
|
||||
//! output scale back to the input's, keeping clipped highlights as they were, and blending the tiles together.
|
||||
//! The model itself is a [`run::TileRunner`], so all of this is tested without one.
|
||||
//! - [`product`]: the cache file the denoised picture is kept in (half floats, compressed in strips) and the
|
||||
//! blend with the plain demosaic that the Amount slider controls.
|
||||
//! - [`cpu`] and [`runtime`]: a single-thread pure-Rust CPU runner and its installation self-test.
|
||||
//! - [`net`] and [`onnx`]: checked plain network data and bounded ONNX decoding, shared by CPU and GPU.
|
||||
//!
|
||||
//! Everything here treats model files, manifests and cache files as hostile input: sizes are capped, numbers must
|
||||
//! be finite, and a malformed file is an error, never a panic.
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(feature = "runtime")]
|
||||
pub mod cpu;
|
||||
|
||||
pub mod net;
|
||||
pub mod onnx;
|
||||
mod onnx_proto;
|
||||
|
||||
pub mod reference;
|
||||
|
||||
#[cfg(feature = "runtime")]
|
||||
pub mod runtime;
|
||||
#[doc(hidden)]
|
||||
pub mod synthetic;
|
||||
|
||||
pub use lightcraft_denoise_core::*;
|
||||
|
||||
pub mod synthetic_proto;
|
||||
|
|
@ -0,0 +1,345 @@
|
|||
//! A denoise network as plain data: the handful of layer kinds a U-Net denoiser is made of, with their weights.
|
||||
//!
|
||||
//! [`onnx`](crate::onnx) reads the file into this description, shared by the pure-Rust CPU and GPU runners.
|
||||
//! Unsupported operators, groups, dilation or activations are reported as errors when loading the model.
|
||||
//!
|
||||
//! The description is checked ([`Net::new`]) because it comes from a stranger's file: channel counts must chain,
|
||||
//! weights must have the sizes the shapes need and be finite, and the depth must stay small.
|
||||
|
||||
/// Most layers a network may have.
|
||||
pub const MAX_OPS: usize = 512;
|
||||
/// Most channels a layer may have.
|
||||
pub const MAX_CHANNELS: usize = 4096;
|
||||
/// Most weights (numbers) a network may have in all.
|
||||
pub const MAX_WEIGHTS: usize = 128 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug, PartialEq, thiserror::Error)]
|
||||
pub enum NetError {
|
||||
/// The model uses something this denoise network description cannot express.
|
||||
#[error("not supported by the denoise runners: {0}")]
|
||||
Unsupported(String),
|
||||
/// The model is not a well-formed network.
|
||||
#[error("not a valid network: {0}")]
|
||||
Invalid(String),
|
||||
}
|
||||
|
||||
fn invalid<T>(why: impl Into<String>) -> Result<T, NetError> {
|
||||
Err(NetError::Invalid(why.into()))
|
||||
}
|
||||
|
||||
/// A tensor is named by its number: 0 is the network's input, and layer `i` makes tensor `i + 1`.
|
||||
pub type TensorId = usize;
|
||||
|
||||
/// A convolution with stride 1 and "same" padding (`k` is 1 or 3), over one tensor or two joined along the channels
|
||||
/// (`src` first), then a bias and optionally a leaky ReLU.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Conv {
|
||||
pub src: TensorId,
|
||||
/// A second source, whose channels follow `src`'s (a skip connection's concatenation).
|
||||
pub src2: Option<TensorId>,
|
||||
pub cin: usize,
|
||||
pub cout: usize,
|
||||
pub k: usize,
|
||||
/// `[cout][cin][k][k]`, as ONNX has it.
|
||||
pub weight: Vec<f32>,
|
||||
pub bias: Vec<f32>,
|
||||
/// `Some(alpha)`: a leaky ReLU with this slope follows.
|
||||
pub leaky: Option<f32>,
|
||||
}
|
||||
|
||||
/// A transposed convolution with a 2 × 2 kernel and stride 2: every input pixel becomes a 2 × 2 block.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ConvT2 {
|
||||
pub src: TensorId,
|
||||
pub cin: usize,
|
||||
pub cout: usize,
|
||||
/// `[cin][cout][2][2]`, as ONNX has it.
|
||||
pub weight: Vec<f32>,
|
||||
pub bias: Vec<f32>,
|
||||
pub leaky: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Op {
|
||||
Conv(Conv),
|
||||
ConvT2(ConvT2),
|
||||
/// 2 × 2 maximum, stride 2.
|
||||
MaxPool2 {
|
||||
src: TensorId,
|
||||
},
|
||||
/// Depth-to-space with block size 2 in "CRD" order: output channel `c` at `(2y + dy, 2x + dx)` is input channel
|
||||
/// `4c + 2dy + dx` at `(y, x)`.
|
||||
DepthToSpace2 {
|
||||
src: TensorId,
|
||||
},
|
||||
}
|
||||
|
||||
/// What a tensor looks like: its channels, and its resolution as a power of two below the input's (a negative
|
||||
/// level is above it).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Shape {
|
||||
pub channels: usize,
|
||||
pub level: i32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Net {
|
||||
pub in_channels: usize,
|
||||
ops: Vec<Op>,
|
||||
shapes: Vec<Shape>,
|
||||
}
|
||||
|
||||
impl Net {
|
||||
/// Check `ops` (layer `i` makes tensor `i + 1`) over an input of `in_channels` and describe every tensor.
|
||||
pub fn new(in_channels: usize, ops: Vec<Op>) -> Result<Net, NetError> {
|
||||
if in_channels == 0 || in_channels > MAX_CHANNELS {
|
||||
return invalid("the input has no channels or too many");
|
||||
}
|
||||
if ops.is_empty() || ops.len() > MAX_OPS {
|
||||
return invalid("the network has no layers or too many");
|
||||
}
|
||||
let mut shapes = vec![Shape { channels: in_channels, level: 0 }];
|
||||
let mut weights = 0usize;
|
||||
for (i, op) in ops.iter().enumerate() {
|
||||
let shape_of = |t: TensorId| {
|
||||
shapes
|
||||
.get(t)
|
||||
.copied()
|
||||
.filter(|_| t <= i)
|
||||
.ok_or_else(|| NetError::Invalid(format!("layer {i} reads tensor {t}, which does not exist yet")))
|
||||
};
|
||||
let out = match op {
|
||||
Op::Conv(c) => {
|
||||
let a = shape_of(c.src)?;
|
||||
let (cin, level) = match c.src2 {
|
||||
Some(s2) => {
|
||||
let b = shape_of(s2)?;
|
||||
if b.level != a.level {
|
||||
return invalid(format!("layer {i} joins tensors of different resolutions"));
|
||||
}
|
||||
(a.channels + b.channels, a.level)
|
||||
}
|
||||
None => (a.channels, a.level),
|
||||
};
|
||||
if c.cin != cin || c.cout == 0 || c.cout > MAX_CHANNELS || !(c.k == 1 || c.k == 3) {
|
||||
return invalid(format!(
|
||||
"layer {i}: a convolution of {} to {} channels with a {}-wide kernel does not fit",
|
||||
c.cin, c.cout, c.k
|
||||
));
|
||||
}
|
||||
check_weights(
|
||||
i,
|
||||
&c.weight,
|
||||
c.cout.checked_mul(cin).and_then(|n| n.checked_mul(c.k * c.k)),
|
||||
&c.bias,
|
||||
c.cout,
|
||||
c.leaky,
|
||||
&mut weights,
|
||||
)?;
|
||||
Shape { channels: c.cout, level }
|
||||
}
|
||||
Op::ConvT2(c) => {
|
||||
let a = shape_of(c.src)?;
|
||||
if c.cin != a.channels || c.cout == 0 || c.cout > MAX_CHANNELS {
|
||||
return invalid(format!("layer {i}: a transposed convolution of {} to {} channels does not fit", c.cin, c.cout));
|
||||
}
|
||||
check_weights(i, &c.weight, c.cin.checked_mul(c.cout).and_then(|n| n.checked_mul(4)), &c.bias, c.cout, c.leaky, &mut weights)?;
|
||||
Shape { channels: c.cout, level: a.level - 1 }
|
||||
}
|
||||
Op::MaxPool2 { src } => {
|
||||
let a = shape_of(*src)?;
|
||||
Shape { channels: a.channels, level: a.level + 1 }
|
||||
}
|
||||
Op::DepthToSpace2 { src } => {
|
||||
let a = shape_of(*src)?;
|
||||
if !a.channels.is_multiple_of(4) {
|
||||
return invalid(format!("layer {i}: depth-to-space needs a multiple of 4 channels"));
|
||||
}
|
||||
Shape { channels: a.channels / 4, level: a.level - 1 }
|
||||
}
|
||||
};
|
||||
shapes.push(out);
|
||||
}
|
||||
Ok(Net { in_channels, ops, shapes })
|
||||
}
|
||||
|
||||
/// The layers: layer `i` makes tensor `i + 1`.
|
||||
pub fn ops(&self) -> &[Op] {
|
||||
&self.ops
|
||||
}
|
||||
|
||||
/// The shape of tensor `t` (0 is the input).
|
||||
pub fn shape(&self, t: TensorId) -> Option<Shape> {
|
||||
self.shapes.get(t).copied()
|
||||
}
|
||||
|
||||
/// The tensor the network gives (the last layer's).
|
||||
pub fn output(&self) -> TensorId {
|
||||
self.ops.len()
|
||||
}
|
||||
|
||||
pub fn out_channels(&self) -> usize {
|
||||
self.shapes.last().map_or(0, |s| s.channels)
|
||||
}
|
||||
|
||||
/// The deepest resolution level: a tile must be a multiple of `2^this` cells wide.
|
||||
pub fn depth(&self) -> u32 {
|
||||
self.shapes.iter().map(|s| s.level.max(0) as u32).max().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Can the network run on a `tile × tile` input and give `[out_channels, 2·tile, 2·tile]`? (Pooling must divide
|
||||
/// the tile, and the output must be one level above the input.)
|
||||
pub fn fits_tile(&self, tile: usize) -> bool {
|
||||
let Some(multiple) = 1usize.checked_shl(self.depth()) else { return false };
|
||||
tile > 0 && tile.is_multiple_of(multiple) && self.shapes.last().is_some_and(|s| s.level == -1)
|
||||
}
|
||||
|
||||
/// Multiply-adds for one tile, for estimating time.
|
||||
pub fn macs(&self, tile: usize) -> u64 {
|
||||
let pixels = |level: i32| -> u64 {
|
||||
let side = if level >= 0 {
|
||||
tile.checked_shr(level as u32).unwrap_or(0)
|
||||
} else {
|
||||
tile.saturating_mul(1usize.checked_shl(level.unsigned_abs()).unwrap_or(usize::MAX))
|
||||
};
|
||||
(side as u64).saturating_mul(side as u64)
|
||||
};
|
||||
self.ops
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, op)| {
|
||||
let out = self.shapes.get(i + 1).map_or(0, |s| pixels(s.level));
|
||||
match op {
|
||||
Op::Conv(c) => out.saturating_mul((c.cin * c.cout * c.k * c.k) as u64),
|
||||
// every input pixel makes a 2 × 2 block: as many multiply-adds as the 2 × 2-wide kernel has weights
|
||||
Op::ConvT2(c) => pixels(self.shapes.get(c.src).map_or(0, |s| s.level)).saturating_mul((c.cin * c.cout * 4) as u64),
|
||||
_ => 0,
|
||||
}
|
||||
})
|
||||
.fold(0u64, u64::saturating_add)
|
||||
}
|
||||
}
|
||||
|
||||
fn check_weights(
|
||||
layer: usize,
|
||||
weight: &[f32],
|
||||
want: Option<usize>,
|
||||
bias: &[f32],
|
||||
cout: usize,
|
||||
leaky: Option<f32>,
|
||||
total: &mut usize,
|
||||
) -> Result<(), NetError> {
|
||||
if want != Some(weight.len()) || bias.len() != cout {
|
||||
return invalid(format!("layer {layer}: the weights are not the size its shape needs"));
|
||||
}
|
||||
*total = total.saturating_add(weight.len()).saturating_add(bias.len());
|
||||
if *total > MAX_WEIGHTS {
|
||||
return invalid("the network has too many weights");
|
||||
}
|
||||
if weight.iter().chain(bias).any(|v| !v.is_finite()) || leaky.is_some_and(|a| !a.is_finite()) {
|
||||
return invalid(format!("layer {layer}: a weight is not a finite number"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn conv(src: TensorId, src2: Option<TensorId>, cin: usize, cout: usize, k: usize) -> Op {
|
||||
Op::Conv(Conv { src, src2, cin, cout, k, weight: vec![0.1; cout * cin * k * k], bias: vec![0.0; cout], leaky: Some(0.2) })
|
||||
}
|
||||
|
||||
fn convt(src: TensorId, cin: usize, cout: usize) -> Op {
|
||||
Op::ConvT2(ConvT2 { src, cin, cout, weight: vec![0.1; cin * cout * 4], bias: vec![0.0; cout], leaky: None })
|
||||
}
|
||||
|
||||
/// A two-level network of the shape the denoisers have: 4 → 8 channels, pool, 16 channels, up, join, 12, and the
|
||||
/// 1 × 1 convolution to the colours.
|
||||
fn unet() -> Vec<Op> {
|
||||
vec![
|
||||
conv(0, None, 4, 8, 3), // 1: 8 ch, level 0
|
||||
Op::MaxPool2 { src: 1 }, // 2: level 1
|
||||
conv(2, None, 8, 16, 3), // 3: 16 ch, level 1
|
||||
convt(3, 16, 8), // 4: 8 ch, level 0
|
||||
conv(4, Some(1), 16, 8, 3), // 5: join [up, skip]
|
||||
conv(5, None, 8, 12, 1), // 6: 12 ch
|
||||
Op::DepthToSpace2 { src: 6 }, // 7: 3 ch, level -1
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_networks_and_huge_cost_queries_never_overflow_or_truncate_depth() {
|
||||
let n = Net::new(4, unet()).unwrap();
|
||||
assert_eq!(n.macs(usize::MAX), u64::MAX);
|
||||
assert_eq!(n.macs(0), 0);
|
||||
let mut ops = Vec::new();
|
||||
for i in 0..21 {
|
||||
ops.push(Op::MaxPool2 { src: i });
|
||||
}
|
||||
for i in 21..42 {
|
||||
ops.push(convt(i, 4, 4));
|
||||
}
|
||||
ops.push(conv(42, None, 4, 12, 1));
|
||||
ops.push(Op::DepthToSpace2 { src: 43 });
|
||||
let n = Net::new(4, ops).unwrap();
|
||||
assert!(!n.fits_tile(1 << 20));
|
||||
assert!(n.fits_tile(1 << 21));
|
||||
let _ = n.macs(16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_u_net_is_described_with_its_shapes_and_costs() {
|
||||
let net = Net::new(4, unet()).unwrap();
|
||||
assert_eq!(net.out_channels(), 3);
|
||||
assert_eq!(net.shape(3), Some(Shape { channels: 16, level: 1 }));
|
||||
assert_eq!(net.shape(7), Some(Shape { channels: 3, level: -1 }));
|
||||
assert_eq!(net.depth(), 1);
|
||||
assert!(net.fits_tile(64) && !net.fits_tile(63) && !net.fits_tile(0));
|
||||
// 64 × 64 cells: layer 1 = 4096·4·8·9, and so on; just check it is the sum of what the layers cost
|
||||
let t = 64u64;
|
||||
let want = t * t * 4 * 8 * 9 + (t / 2) * (t / 2) * 8 * 16 * 9 + (t / 2) * (t / 2) * 16 * 8 * 4 + t * t * 16 * 8 * 9 + t * t * 8 * 12;
|
||||
assert_eq!(net.macs(64), want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_network_that_does_not_chain_is_refused() {
|
||||
// wrong input channel count for the first convolution
|
||||
assert!(Net::new(3, unet()).is_err());
|
||||
// a layer reading a tensor that does not exist yet
|
||||
let mut ops = unet();
|
||||
ops[2] = conv(9, None, 8, 16, 3);
|
||||
assert!(Net::new(4, ops).is_err());
|
||||
// joining tensors of different resolutions
|
||||
let mut ops = unet();
|
||||
ops[4] = conv(3, Some(1), 24, 8, 3);
|
||||
assert!(Net::new(4, ops).is_err());
|
||||
// weights of the wrong size, and not finite
|
||||
let mut ops = unet();
|
||||
if let Op::Conv(c) = &mut ops[0] {
|
||||
c.weight.pop();
|
||||
}
|
||||
assert!(Net::new(4, ops).is_err());
|
||||
let mut ops = unet();
|
||||
if let Op::Conv(c) = &mut ops[0] {
|
||||
c.bias[0] = f32::NAN;
|
||||
}
|
||||
assert!(Net::new(4, ops).is_err());
|
||||
// a kernel that is neither 1 nor 3 wide, no layers, depth-to-space on a channel count it cannot split
|
||||
let mut ops = unet();
|
||||
if let Op::Conv(c) = &mut ops[0] {
|
||||
c.k = 5;
|
||||
}
|
||||
assert!(Net::new(4, ops).is_err());
|
||||
assert!(Net::new(4, vec![]).is_err());
|
||||
assert!(Net::new(4, vec![Op::DepthToSpace2 { src: 0 }]).is_ok());
|
||||
assert!(Net::new(4, vec![Op::DepthToSpace2 { src: 0 }, Op::DepthToSpace2 { src: 1 }]).is_err());
|
||||
assert!(Net::new(2, vec![Op::DepthToSpace2 { src: 0 }]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_network_that_does_not_end_one_level_up_does_not_fit_a_tile() {
|
||||
let net = Net::new(4, unet()[..6].to_vec()).unwrap();
|
||||
assert!(!net.fits_tile(64), "no depth-to-space: the output is not at twice the resolution");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,369 @@
|
|||
//! Reading an `.onnx` file into a [`Net`] with bounded, pure-Rust protobuf decoding.
|
||||
//!
|
||||
//! Only what the denoise U-Nets are made of is understood: `Conv` (3 × 3 "same" or 1 × 1, stride 1, no groups, no
|
||||
//! dilation) optionally followed by `LeakyRelu`, `ConvTranspose` (2 × 2, stride 2) likewise, `MaxPool` (2 × 2, stride 2),
|
||||
//! a two-input `Concat` on the channels that feeds a convolution, and a final `DepthToSpace` (2, `CRD`). Any other
|
||||
//! operator or attribute is [`NetError::Unsupported`], which is reported to the user by both runners.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::onnx_proto::{self, AttributeProto, NodeProto, TensorProto, ValueInfoProto};
|
||||
use std::io::Read;
|
||||
|
||||
use crate::net::{Conv, ConvT2, MAX_WEIGHTS, Net, NetError, Op, TensorId};
|
||||
|
||||
fn unsupported<T>(why: impl Into<String>) -> Result<T, NetError> {
|
||||
Err(NetError::Unsupported(why.into()))
|
||||
}
|
||||
|
||||
fn invalid<T>(why: impl Into<String>) -> Result<T, NetError> {
|
||||
Err(NetError::Invalid(why.into()))
|
||||
}
|
||||
|
||||
/// Read the model at `path`.
|
||||
pub fn read(path: &Path) -> Result<Net, NetError> {
|
||||
let file = std::fs::File::open(path).map_err(|e| NetError::Invalid(e.to_string()))?;
|
||||
let size = usize::try_from(file.metadata().map_err(|e| NetError::Invalid(e.to_string()))?.len())
|
||||
.ok()
|
||||
.filter(|&n| n <= onnx_proto::MAX_BYTES)
|
||||
.ok_or_else(|| NetError::Invalid("model file too large".into()))?;
|
||||
let mut bytes = Vec::new();
|
||||
bytes.try_reserve_exact(size).map_err(|_| NetError::Invalid("not enough memory to load the model".into()))?;
|
||||
file.take(onnx_proto::MAX_BYTES as u64 + 1).read_to_end(&mut bytes).map_err(|e| NetError::Invalid(e.to_string()))?;
|
||||
read_bytes(&bytes)
|
||||
}
|
||||
|
||||
fn read_bytes(bytes: &[u8]) -> Result<Net, NetError> {
|
||||
let graph = onnx_proto::decode(bytes)?;
|
||||
|
||||
let initialisers: HashMap<&str, &TensorProto> = graph.initializer.iter().map(|t| (t.name.as_str(), t)).collect();
|
||||
if initialisers.len() != graph.initializer.len() {
|
||||
return invalid("duplicate initializer names");
|
||||
}
|
||||
let inputs: Vec<_> = graph.input.iter().filter(|i| !initialisers.contains_key(i.name.as_str())).collect();
|
||||
let ([input], [output]) = (inputs.as_slice(), graph.output.as_slice()) else { return unsupported("not one input and one output") };
|
||||
let in_channels = match channel_dim(input) {
|
||||
Some(c) => c,
|
||||
None => return unsupported("the input's channel count is not written in the file"),
|
||||
};
|
||||
|
||||
// how many nodes read each name (a graph output counts as a reader)
|
||||
let mut readers: HashMap<&str, usize> = HashMap::new();
|
||||
for n in &graph.node {
|
||||
for i in &n.input {
|
||||
*readers.entry(i.as_str()).or_default() += 1;
|
||||
}
|
||||
}
|
||||
*readers.entry(output.name.as_str()).or_default() += 1;
|
||||
|
||||
let mut ids: HashMap<String, TensorId> = HashMap::new();
|
||||
ids.insert(input.name.clone(), 0);
|
||||
// a Concat is not a layer of its own: the convolution that reads it takes both sources
|
||||
let mut joins: HashMap<String, (TensorId, TensorId)> = HashMap::new();
|
||||
let mut ops: Vec<Op> = Vec::new();
|
||||
let mut weights_total = 0usize;
|
||||
|
||||
let nodes = &graph.node;
|
||||
let mut i = 0;
|
||||
while i < nodes.len() {
|
||||
let Some(node) = nodes.get(i) else { break };
|
||||
i += 1;
|
||||
if node.output.len() != 1 || node.output.first().is_none_or(|n| n.is_empty() || ids.contains_key(n) || joins.contains_key(n)) {
|
||||
return invalid("a layer has missing, repeated or multiple outputs");
|
||||
}
|
||||
let allowed: &[&str] = match node.op_type.as_str() {
|
||||
"Conv" => &["kernel_shape", "pads", "strides", "dilations", "group", "auto_pad"],
|
||||
"ConvTranspose" => &["kernel_shape", "pads", "strides", "dilations", "group", "output_padding", "auto_pad"],
|
||||
"MaxPool" => &["kernel_shape", "pads", "strides", "dilations", "ceil_mode", "storage_order", "auto_pad"],
|
||||
"Concat" => &["axis"],
|
||||
"DepthToSpace" => &["blocksize", "mode"],
|
||||
_ => &[],
|
||||
};
|
||||
if node.attribute.iter().any(|a| !allowed.contains(&a.name.as_str())) {
|
||||
return unsupported("an unknown operator attribute");
|
||||
}
|
||||
let this = ops.len() + 1;
|
||||
let lookup = |name: &str| -> Result<TensorId, NetError> {
|
||||
ids.get(name).copied().ok_or_else(|| NetError::Invalid(format!("`{}` reads `{name}`, which nothing makes", node.op_type)))
|
||||
};
|
||||
// a LeakyRelu straight after this layer, which nothing else reads, is part of it
|
||||
let mut fuse_leaky = |out: &str| -> Result<(Option<f32>, String), NetError> {
|
||||
if let Some(next) = nodes.get(i)
|
||||
&& next.op_type == "LeakyRelu"
|
||||
&& next.input.first().map(String::as_str) == Some(out)
|
||||
&& readers.get(out).copied() == Some(1)
|
||||
{
|
||||
if next.input.len() != 1 || next.output.len() != 1 || next.attribute.iter().any(|a| a.name != "alpha") {
|
||||
return invalid("malformed LeakyRelu");
|
||||
}
|
||||
let alpha = attr(next, "alpha").map_or(0.01, |a| a.f);
|
||||
i += 1;
|
||||
let name = next.output.first().cloned().ok_or_else(|| NetError::Invalid("LeakyRelu without an output".into()))?;
|
||||
return Ok((Some(alpha), name));
|
||||
}
|
||||
Ok((None, out.to_string()))
|
||||
};
|
||||
match node.op_type.as_str() {
|
||||
"Concat" => {
|
||||
if attr(node, "axis").map(|a| a.i) != Some(1) {
|
||||
return unsupported("Concat on another axis than the channels");
|
||||
}
|
||||
let [a, b] = node.input.as_slice() else { return unsupported("Concat of other than two tensors") };
|
||||
let out = node.output.first().ok_or_else(|| NetError::Invalid("Concat without an output".into()))?;
|
||||
if readers.get(out.as_str()).copied() != Some(1) {
|
||||
return unsupported("a Concat that is read by more than one layer");
|
||||
}
|
||||
joins.insert(out.clone(), (lookup(a)?, lookup(b)?));
|
||||
}
|
||||
"Conv" => {
|
||||
let (w, b) = weights_of(node, &initialisers, &mut weights_total)?;
|
||||
let (cout, cin, kh, kw) = four(&w.0)?;
|
||||
let b = if b.is_empty() { vec![0.0; cout] } else { b };
|
||||
if kh != kw || !(kh == 1 || kh == 3) {
|
||||
return unsupported(format!("a {kh} × {kw} convolution"));
|
||||
}
|
||||
if ints_or(node, "strides", &[1, 1]) != [1, 1]
|
||||
|| ints_or(node, "dilations", &[1, 1]) != [1, 1]
|
||||
|| attr(node, "group").is_some_and(|a| a.i != 1)
|
||||
{
|
||||
return unsupported("a convolution with strides, dilation or groups");
|
||||
}
|
||||
if attr(node, "kernel_shape").is_some_and(|a| a.ints != [kh as i64, kw as i64]) {
|
||||
return invalid("kernel shape does not match the weights");
|
||||
}
|
||||
let pad = (kh as i64 - 1) / 2;
|
||||
if ints_or(node, "pads", &[0; 4]) != [pad; 4] || attr(node, "auto_pad").is_some_and(|a| !a.s.is_empty() && a.s != b"NOTSET") {
|
||||
return unsupported("a convolution that is not padded to keep its size");
|
||||
}
|
||||
let src_name = node.input.first().ok_or_else(|| NetError::Invalid("Conv without an input".into()))?;
|
||||
let (src, src2) = match joins.remove(src_name) {
|
||||
Some((a, b)) => (a, Some(b)),
|
||||
None => (lookup(src_name)?, None),
|
||||
};
|
||||
let out = node.output.first().ok_or_else(|| NetError::Invalid("Conv without an output".into()))?;
|
||||
let (leaky, out_name) = fuse_leaky(out)?;
|
||||
ids.insert(out_name, this);
|
||||
ops.push(Op::Conv(Conv { src, src2, cin, cout, k: kh, weight: w.1, bias: b, leaky }));
|
||||
}
|
||||
"ConvTranspose" => {
|
||||
let (w, b) = weights_of(node, &initialisers, &mut weights_total)?;
|
||||
let (cin, cout, kh, kw) = four(&w.0)?;
|
||||
let b = if b.is_empty() { vec![0.0; cout] } else { b };
|
||||
if (kh, kw) != (2, 2)
|
||||
|| ints_or(node, "strides", &[1, 1]) != [2, 2]
|
||||
|| ints_or(node, "pads", &[0; 4]) != [0; 4]
|
||||
|| ints_or(node, "dilations", &[1, 1]) != [1, 1]
|
||||
|| attr(node, "group").is_some_and(|a| a.i != 1)
|
||||
|| attr(node, "output_padding").is_some_and(|a| a.ints.iter().any(|&v| v != 0))
|
||||
{
|
||||
return unsupported("a transposed convolution other than 2 × 2 with stride 2");
|
||||
}
|
||||
if attr(node, "auto_pad").is_some_and(|a| !a.s.is_empty() && a.s != b"NOTSET") {
|
||||
return unsupported("transposed convolution auto padding");
|
||||
}
|
||||
if attr(node, "kernel_shape").is_some_and(|a| a.ints != [2, 2]) {
|
||||
return invalid("transposed kernel shape does not match the weights");
|
||||
}
|
||||
let src = lookup(node.input.first().map_or("", String::as_str))?;
|
||||
let out = node.output.first().ok_or_else(|| NetError::Invalid("ConvTranspose without an output".into()))?;
|
||||
let (leaky, out_name) = fuse_leaky(out)?;
|
||||
ids.insert(out_name, this);
|
||||
ops.push(Op::ConvT2(ConvT2 { src, cin, cout, weight: w.1, bias: b, leaky }));
|
||||
}
|
||||
"MaxPool" => {
|
||||
if ints_or(node, "kernel_shape", &[]) != [2, 2]
|
||||
|| ints_or(node, "strides", &[1, 1]) != [2, 2]
|
||||
|| ints_or(node, "pads", &[0; 4]) != [0; 4]
|
||||
|| ints_or(node, "dilations", &[1, 1]) != [1, 1]
|
||||
|| attr(node, "ceil_mode").is_some_and(|a| a.i != 0)
|
||||
|| attr(node, "storage_order").is_some_and(|a| a.i != 0)
|
||||
|| attr(node, "auto_pad").is_some_and(|a| !a.s.is_empty() && a.s != b"NOTSET")
|
||||
{
|
||||
return unsupported("a max-pool other than 2 × 2 with stride 2");
|
||||
}
|
||||
let src = lookup(node.input.first().map_or("", String::as_str))?;
|
||||
let out = node.output.first().ok_or_else(|| NetError::Invalid("MaxPool without an output".into()))?;
|
||||
ids.insert(out.clone(), this);
|
||||
ops.push(Op::MaxPool2 { src });
|
||||
}
|
||||
"DepthToSpace" => {
|
||||
if attr(node, "blocksize").map(|a| a.i) != Some(2) || attr(node, "mode").map(|a| a.s.as_slice()) != Some(&b"CRD"[..]) {
|
||||
return unsupported("depth-to-space other than block size 2 in CRD order");
|
||||
}
|
||||
let src = lookup(node.input.first().map_or("", String::as_str))?;
|
||||
let out = node.output.first().ok_or_else(|| NetError::Invalid("DepthToSpace without an output".into()))?;
|
||||
ids.insert(out.clone(), this);
|
||||
ops.push(Op::DepthToSpace2 { src });
|
||||
}
|
||||
other => return unsupported(format!("the `{other}` operator")),
|
||||
}
|
||||
}
|
||||
if !joins.is_empty() {
|
||||
return unsupported("a Concat whose result no convolution reads");
|
||||
}
|
||||
if ids.get(output.name.as_str()) != Some(&ops.len()) {
|
||||
return unsupported("the output is not the last layer");
|
||||
}
|
||||
Net::new(in_channels, ops)
|
||||
}
|
||||
|
||||
fn attr<'a>(n: &'a NodeProto, name: &str) -> Option<&'a AttributeProto> {
|
||||
n.attribute.iter().find(|a| a.name == name)
|
||||
}
|
||||
|
||||
/// The integer list attribute `name`, or `default` when the node does not have it.
|
||||
fn ints_or(n: &NodeProto, name: &str, default: &[i64]) -> Vec<i64> {
|
||||
attr(n, name).map_or_else(|| default.to_vec(), |a| a.ints.clone())
|
||||
}
|
||||
|
||||
fn four(dims: &[i64]) -> Result<(usize, usize, usize, usize), NetError> {
|
||||
match dims {
|
||||
[a, b, c, d] if dims.iter().all(|&v| v > 0 && v <= 1 << 20) => Ok((*a as usize, *b as usize, *c as usize, *d as usize)),
|
||||
_ => invalid("a weight tensor that is not four-dimensional"),
|
||||
}
|
||||
}
|
||||
|
||||
type Weights = ((Vec<i64>, Vec<f32>), Vec<f32>);
|
||||
|
||||
/// The weight and bias initialisers of a convolution node.
|
||||
fn weights_of(node: &NodeProto, initialisers: &HashMap<&str, &TensorProto>, total: &mut usize) -> Result<Weights, NetError> {
|
||||
let mut get = |idx: usize| -> Result<(Vec<i64>, Vec<f32>), NetError> {
|
||||
let name = node.input.get(idx).ok_or_else(|| NetError::Invalid(format!("`{}` without its weights", node.op_type)))?;
|
||||
let t = initialisers.get(name.as_str()).ok_or_else(|| NetError::Unsupported(format!("weights that are computed (`{name}`)")))?;
|
||||
let values = floats(t)?;
|
||||
*total = total.saturating_add(values.len());
|
||||
if *total > MAX_WEIGHTS {
|
||||
return invalid("the network has too many weights");
|
||||
}
|
||||
Ok((t.dims.clone(), values))
|
||||
};
|
||||
let w = get(1)?;
|
||||
let b = if node.input.len() > 2 {
|
||||
let (dims, values) = get(2)?;
|
||||
if dims.len() != 1 || values.is_empty() {
|
||||
return invalid("a bias tensor must be a nonempty vector");
|
||||
}
|
||||
values
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
Ok((w, b))
|
||||
}
|
||||
|
||||
fn floats(t: &TensorProto) -> Result<Vec<f32>, NetError> {
|
||||
if t.data_type != 1 {
|
||||
return unsupported("weights that are not 32-bit floats");
|
||||
}
|
||||
let want: usize = t
|
||||
.dims
|
||||
.iter()
|
||||
.try_fold(1usize, |n, &d| usize::try_from(d).ok().and_then(|d| n.checked_mul(d)))
|
||||
.ok_or_else(|| NetError::Invalid("a tensor with a negative or huge size".into()))?;
|
||||
if want > MAX_WEIGHTS {
|
||||
return invalid("a tensor with too many numbers");
|
||||
}
|
||||
if !t.raw_data.is_empty() && (!t.float_data.is_empty() || !t.raw_data.len().is_multiple_of(4)) {
|
||||
return invalid("ambiguous or truncated tensor data");
|
||||
}
|
||||
let actual = if t.raw_data.is_empty() { t.float_data.len() } else { t.raw_data.len() / 4 };
|
||||
if actual != want {
|
||||
return invalid("tensor data does not match its dimensions");
|
||||
}
|
||||
let mut values = Vec::new();
|
||||
values.try_reserve_exact(want).map_err(|_| NetError::Invalid("not enough memory for weights".into()))?;
|
||||
if !t.raw_data.is_empty() {
|
||||
values.extend(t.raw_data.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)));
|
||||
} else {
|
||||
values.extend_from_slice(&t.float_data);
|
||||
}
|
||||
if values.len() != want {
|
||||
return invalid(format!("tensor `{}` has {} numbers, not the {want} its shape says", t.name, values.len()));
|
||||
}
|
||||
Ok(values)
|
||||
}
|
||||
|
||||
/// The channel count in an input's declared shape `[batch, channels, …]`.
|
||||
fn channel_dim(v: &ValueInfoProto) -> Option<usize> {
|
||||
v.channels
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::synthetic::unet_onnx;
|
||||
|
||||
fn temp_model(name: &str, bytes: &[u8]) -> std::path::PathBuf {
|
||||
let p = std::env::temp_dir().join(format!("lc-denoise-onnx-{name}-{}.onnx", std::process::id()));
|
||||
std::fs::write(&p, bytes).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_u_net_is_read_with_its_layers_fused() {
|
||||
let (tile, depth) = (32usize, 2usize);
|
||||
let p = temp_model("unet", &unet_onnx(tile as u64, 8, depth, 7));
|
||||
let net = read(&p).unwrap();
|
||||
// per level two convolutions; a pool per level but the last; going up a transposed convolution and two convolutions
|
||||
// per level (the join is part of the first); then the 1 × 1 convolution and depth-to-space
|
||||
assert_eq!(net.ops().len(), 2 * (depth + 1) + depth + depth * 3 + 2);
|
||||
assert!(matches!(net.ops().last(), Some(crate::net::Op::DepthToSpace2 { .. })));
|
||||
assert!(net.ops().iter().any(|o| matches!(o, crate::net::Op::Conv(c) if c.src2.is_some())), "the joins became two-source convolutions");
|
||||
assert!(
|
||||
net.ops().iter().all(|o| !matches!(o, crate::net::Op::Conv(c) if c.leaky.is_none() && c.k == 3)),
|
||||
"every 3 × 3 convolution has its activation"
|
||||
);
|
||||
assert_eq!((net.in_channels, net.out_channels(), net.depth()), (4, 3, 2));
|
||||
assert!(net.fits_tile(tile) && !net.fits_tile(30));
|
||||
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_empty_or_nonvector_bias_is_not_treated_as_missing() {
|
||||
let w = TensorProto { name: "w".into(), dims: vec![12, 4, 1, 1], data_type: 1, float_data: vec![0.1; 48], ..Default::default() };
|
||||
let n = NodeProto { input: vec!["data".into(), "w".into(), "b".into()], ..Default::default() };
|
||||
for dims in [vec![0], vec![3, 4]] {
|
||||
let b = TensorProto {
|
||||
name: "b".into(),
|
||||
float_data: vec![0.0; dims.iter().product::<i64>() as usize],
|
||||
dims,
|
||||
data_type: 1,
|
||||
..Default::default()
|
||||
};
|
||||
let inits = HashMap::from([("w", &w), ("b", &b)]);
|
||||
assert!(weights_of(&n, &inits, &mut 0).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_and_mutated_protobuf_is_bounded_and_never_panics() {
|
||||
let good = unet_onnx(4, 3, 1, 7);
|
||||
for n in (0..good.len()).step_by(31) {
|
||||
let _ = read_bytes(&good[..n]);
|
||||
}
|
||||
for at in (0..good.len()).step_by(37) {
|
||||
let mut bad = good.clone();
|
||||
bad[at] ^= 0xff;
|
||||
let _ = read_bytes(&bad);
|
||||
}
|
||||
assert!(read_bytes(&[0x3a, 0xff, 0xff, 0xff, 0xff, 0x7f]).is_err());
|
||||
assert!(read_bytes(&[0x3a, 0, 0x3a, 0]).is_err());
|
||||
}
|
||||
proptest::proptest! {
|
||||
#[test]
|
||||
fn arbitrary_model_bytes_never_panic(bytes in proptest::collection::vec(proptest::prelude::any::<u8>(), 0..4096)) {
|
||||
let _ = read_bytes(&bytes);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_the_description_cannot_say_is_unsupported_and_garbage_is_an_error() {
|
||||
// not a model
|
||||
let p = temp_model("junk", b"this is not an onnx file");
|
||||
assert!(matches!(read(&p), Err(NetError::Invalid(_)) | Err(NetError::Unsupported(_))));
|
||||
let _ = std::fs::remove_file(p);
|
||||
// a missing file
|
||||
assert!(read(Path::new("/does/not/exist.onnx")).is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,295 @@
|
|||
//! Bounded, borrowed decoding of the ONNX fields used by denoise, from the public Apache-2.0 schema:
|
||||
//! https://github.com/onnx/onnx/blob/main/onnx/onnx.proto3
|
||||
//! No generated code, protoc, recursive graph decoding, or external tensor files.
|
||||
|
||||
use crate::net::{MAX_OPS, MAX_WEIGHTS, NetError};
|
||||
|
||||
const MAX_ITEMS: usize = MAX_OPS * 4;
|
||||
const MAX_STRING: usize = 4096;
|
||||
pub(super) const MAX_BYTES: usize = MAX_WEIGHTS * 4 + 16 * 1024 * 1024;
|
||||
|
||||
type Result<T> = std::result::Result<T, NetError>;
|
||||
fn bad() -> NetError {
|
||||
NetError::Invalid("malformed or oversized ONNX protobuf".into())
|
||||
}
|
||||
|
||||
struct Fields<'a> {
|
||||
rest: &'a [u8],
|
||||
count: usize,
|
||||
}
|
||||
enum Wire<'a> {
|
||||
Int(u64),
|
||||
Bytes(&'a [u8]),
|
||||
F32(f32),
|
||||
Other,
|
||||
}
|
||||
|
||||
fn varint(rest: &mut &[u8]) -> Result<u64> {
|
||||
let mut value = 0u64;
|
||||
for shift in (0..70).step_by(7) {
|
||||
let (b, tail) = rest.split_first().ok_or_else(bad)?;
|
||||
*rest = tail;
|
||||
if shift == 63 && *b > 1 {
|
||||
return Err(bad());
|
||||
}
|
||||
value |= u64::from(*b & 127) << shift;
|
||||
if b & 128 == 0 {
|
||||
return Ok(value);
|
||||
}
|
||||
}
|
||||
Err(bad())
|
||||
}
|
||||
|
||||
impl<'a> Fields<'a> {
|
||||
fn new(rest: &'a [u8]) -> Self {
|
||||
Self { rest, count: 0 }
|
||||
}
|
||||
fn take(&mut self, n: usize) -> Result<&'a [u8]> {
|
||||
let out = self.rest.get(..n).ok_or_else(bad)?;
|
||||
self.rest = self.rest.get(n..).ok_or_else(bad)?;
|
||||
Ok(out)
|
||||
}
|
||||
fn next(&mut self) -> Result<Option<(u32, Wire<'a>)>> {
|
||||
if self.rest.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
self.count += 1;
|
||||
if self.count > MAX_WEIGHTS {
|
||||
return Err(bad());
|
||||
}
|
||||
let tag = varint(&mut self.rest)?;
|
||||
let number = u32::try_from(tag >> 3).map_err(|_| bad())?;
|
||||
if number == 0 || number >= 1 << 29 {
|
||||
return Err(bad());
|
||||
}
|
||||
let wire = match tag & 7 {
|
||||
0 => Wire::Int(varint(&mut self.rest)?),
|
||||
1 => {
|
||||
self.take(8)?;
|
||||
Wire::Other
|
||||
}
|
||||
2 => {
|
||||
let n = usize::try_from(varint(&mut self.rest)?).map_err(|_| bad())?;
|
||||
Wire::Bytes(self.take(n)?)
|
||||
}
|
||||
5 => {
|
||||
let b: [u8; 4] = self.take(4)?.try_into().map_err(|_| bad())?;
|
||||
Wire::F32(f32::from_le_bytes(b))
|
||||
}
|
||||
_ => return Err(bad()),
|
||||
};
|
||||
Ok(Some((number, wire)))
|
||||
}
|
||||
}
|
||||
|
||||
fn string(b: &[u8]) -> Result<String> {
|
||||
if b.len() > MAX_STRING {
|
||||
return Err(bad());
|
||||
}
|
||||
std::str::from_utf8(b).map(str::to_owned).map_err(|_| bad())
|
||||
}
|
||||
fn push<T>(v: &mut Vec<T>, value: T, cap: usize) -> Result<()> {
|
||||
if v.len() >= cap {
|
||||
return Err(bad());
|
||||
}
|
||||
v.try_reserve(1).map_err(|_| bad())?;
|
||||
v.push(value);
|
||||
Ok(())
|
||||
}
|
||||
fn ints(w: Wire<'_>, out: &mut Vec<i64>, cap: usize) -> Result<()> {
|
||||
match w {
|
||||
Wire::Int(v) => push(out, v as i64, cap),
|
||||
Wire::Bytes(mut b) => {
|
||||
while !b.is_empty() {
|
||||
let v = varint(&mut b)?;
|
||||
push(out, v as i64, cap)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(bad()),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct AttributeProto {
|
||||
pub name: String,
|
||||
pub f: f32,
|
||||
pub i: i64,
|
||||
pub s: Vec<u8>,
|
||||
pub ints: Vec<i64>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub(super) struct NodeProto {
|
||||
pub input: Vec<String>,
|
||||
pub output: Vec<String>,
|
||||
pub op_type: String,
|
||||
pub attribute: Vec<AttributeProto>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub(super) struct TensorProto<'a> {
|
||||
pub name: String,
|
||||
pub dims: Vec<i64>,
|
||||
pub data_type: i32,
|
||||
pub raw_data: &'a [u8],
|
||||
pub float_data: Vec<f32>,
|
||||
}
|
||||
pub(super) struct ValueInfoProto {
|
||||
pub name: String,
|
||||
pub channels: Option<usize>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub(super) struct Graph<'a> {
|
||||
pub node: Vec<NodeProto>,
|
||||
pub initializer: Vec<TensorProto<'a>>,
|
||||
pub input: Vec<ValueInfoProto>,
|
||||
pub output: Vec<ValueInfoProto>,
|
||||
}
|
||||
|
||||
fn attribute(b: &[u8]) -> Result<AttributeProto> {
|
||||
let mut a = AttributeProto::default();
|
||||
let mut fields = Fields::new(b);
|
||||
while let Some((n, w)) = fields.next()? {
|
||||
match (n, w) {
|
||||
(1, Wire::Bytes(b)) => a.name = string(b)?,
|
||||
(2, Wire::F32(v)) => a.f = v,
|
||||
(3, Wire::Int(v)) => a.i = v as i64,
|
||||
(4, Wire::Bytes(b)) => {
|
||||
if b.len() > MAX_STRING {
|
||||
return Err(bad());
|
||||
}
|
||||
a.s = b.to_vec();
|
||||
}
|
||||
(8, w) => ints(w, &mut a.ints, 16)?,
|
||||
(20, Wire::Int(1 | 2 | 3 | 7)) => {}
|
||||
_ => return Err(NetError::Unsupported("unknown attribute value".into())),
|
||||
}
|
||||
}
|
||||
Ok(a)
|
||||
}
|
||||
fn node(b: &[u8]) -> Result<NodeProto> {
|
||||
let mut a = NodeProto::default();
|
||||
let mut fields = Fields::new(b);
|
||||
while let Some((n, w)) = fields.next()? {
|
||||
match (n, w) {
|
||||
(1, Wire::Bytes(b)) => push(&mut a.input, string(b)?, 4)?,
|
||||
(2, Wire::Bytes(b)) => push(&mut a.output, string(b)?, 2)?,
|
||||
(4, Wire::Bytes(b)) => a.op_type = string(b)?,
|
||||
(5, Wire::Bytes(b)) => push(&mut a.attribute, attribute(b)?, 16)?,
|
||||
(7, Wire::Bytes(b)) if !b.is_empty() => return Err(NetError::Unsupported("a custom operator domain".into())),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(a)
|
||||
}
|
||||
fn tensor(b: &[u8]) -> Result<TensorProto<'_>> {
|
||||
let mut a = TensorProto::default();
|
||||
let mut fields = Fields::new(b);
|
||||
while let Some((n, w)) = fields.next()? {
|
||||
match (n, w) {
|
||||
(1, w) => ints(w, &mut a.dims, 8)?,
|
||||
(2, Wire::Int(v)) => a.data_type = i32::try_from(v).map_err(|_| bad())?,
|
||||
(4, Wire::F32(v)) => push(&mut a.float_data, v, MAX_WEIGHTS)?,
|
||||
(4, Wire::Bytes(b)) => {
|
||||
if !b.len().is_multiple_of(4) {
|
||||
return Err(bad());
|
||||
}
|
||||
for chunk in b.as_chunks::<4>().0 {
|
||||
push(&mut a.float_data, f32::from_le_bytes(*chunk), MAX_WEIGHTS)?;
|
||||
}
|
||||
}
|
||||
(8, Wire::Bytes(b)) => a.name = string(b)?,
|
||||
(9, Wire::Bytes(b)) => {
|
||||
if b.len() > MAX_WEIGHTS * 4 {
|
||||
return Err(bad());
|
||||
}
|
||||
a.raw_data = b;
|
||||
}
|
||||
(13, _) | (14, Wire::Int(1..)) => return Err(NetError::Unsupported("external tensor data".into())),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(a)
|
||||
}
|
||||
fn channel_shape(b: &[u8]) -> Result<Option<usize>> {
|
||||
let mut fields = Fields::new(b);
|
||||
let mut dim = 0;
|
||||
let mut channels = None;
|
||||
while let Some((n, w)) = fields.next()? {
|
||||
if let (1, Wire::Bytes(b)) = (n, w) {
|
||||
if dim >= 8 {
|
||||
return Err(bad());
|
||||
}
|
||||
let mut df = Fields::new(b);
|
||||
while let Some((n, w)) = df.next()? {
|
||||
if let (1, Wire::Int(v)) = (n, w)
|
||||
&& dim == 1
|
||||
{
|
||||
channels = usize::try_from(v).ok().filter(|&v| v > 0);
|
||||
}
|
||||
}
|
||||
dim += 1;
|
||||
}
|
||||
}
|
||||
Ok(channels)
|
||||
}
|
||||
fn channel_type(b: &[u8]) -> Result<Option<usize>> {
|
||||
let mut fields = Fields::new(b);
|
||||
while let Some((n, w)) = fields.next()? {
|
||||
if let (1, Wire::Bytes(b)) = (n, w) {
|
||||
let mut tf = Fields::new(b);
|
||||
let (mut elem, mut channels) = (0, None);
|
||||
while let Some((n, w)) = tf.next()? {
|
||||
match (n, w) {
|
||||
(1, Wire::Int(v)) => elem = v,
|
||||
(2, Wire::Bytes(b)) => channels = channel_shape(b)?,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
return Ok(if elem == 1 { channels } else { None });
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
fn value_info(b: &[u8]) -> Result<ValueInfoProto> {
|
||||
let mut out = ValueInfoProto { name: String::new(), channels: None };
|
||||
let mut fields = Fields::new(b);
|
||||
while let Some((n, w)) = fields.next()? {
|
||||
match (n, w) {
|
||||
(1, Wire::Bytes(b)) => out.name = string(b)?,
|
||||
(2, Wire::Bytes(b)) => out.channels = channel_type(b)?,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
fn graph(b: &[u8]) -> Result<Graph<'_>> {
|
||||
let mut out = Graph::default();
|
||||
let mut fields = Fields::new(b);
|
||||
while let Some((n, w)) = fields.next()? {
|
||||
match (n, w) {
|
||||
(1, Wire::Bytes(b)) => push(&mut out.node, node(b)?, MAX_ITEMS)?,
|
||||
(5, Wire::Bytes(b)) => push(&mut out.initializer, tensor(b)?, MAX_ITEMS)?,
|
||||
(11, Wire::Bytes(b)) => push(&mut out.input, value_info(b)?, MAX_ITEMS)?,
|
||||
(12, Wire::Bytes(b)) => push(&mut out.output, value_info(b)?, 1)?,
|
||||
(15, _) => return Err(NetError::Unsupported("sparse initializers".into())),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
pub(super) fn decode(b: &[u8]) -> Result<Graph<'_>> {
|
||||
if b.len() > MAX_BYTES {
|
||||
return Err(bad());
|
||||
}
|
||||
let mut fields = Fields::new(b);
|
||||
let mut out = None;
|
||||
while let Some((n, w)) = fields.next()? {
|
||||
if let (7, Wire::Bytes(b)) = (n, w) {
|
||||
if out.is_some() {
|
||||
return Err(bad());
|
||||
}
|
||||
out = Some(graph(b)?);
|
||||
}
|
||||
}
|
||||
out.ok_or_else(bad)
|
||||
}
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
//! A plain-loop interpreter of a [`Net`], slow and obviously right: the reference the parser and the GPU runner are
|
||||
//! tested against (tract's answer on the real model is the other). It is not used on photos.
|
||||
|
||||
use crate::net::{Net, NetError, Op, TensorId};
|
||||
use crate::run::{Error, TileRunner};
|
||||
|
||||
/// A tensor in `[channels][side][side]` order.
|
||||
struct T {
|
||||
channels: usize,
|
||||
side: usize,
|
||||
data: Vec<f32>,
|
||||
}
|
||||
|
||||
/// Run `net` on `input` (`in_channels` planes of `tile × tile`).
|
||||
pub fn run(net: &Net, tile: usize, input: &[f32]) -> Result<Vec<f32>, NetError> {
|
||||
if !net.fits_tile(tile) || input.len() != net.in_channels * tile * tile {
|
||||
return Err(NetError::Invalid("the tile does not fit the network".into()));
|
||||
}
|
||||
let mut tensors: Vec<T> = vec![T { channels: net.in_channels, side: tile, data: input.to_vec() }];
|
||||
let get = |tensors: &[T], id: TensorId| -> Result<usize, NetError> {
|
||||
if id < tensors.len() { Ok(id) } else { Err(NetError::Invalid("a layer reads a tensor that does not exist yet".into())) }
|
||||
};
|
||||
for op in net.ops() {
|
||||
let out = match op {
|
||||
Op::Conv(c) => {
|
||||
let a = get(&tensors, c.src)?;
|
||||
let b = c.src2.map(|s| get(&tensors, s)).transpose()?;
|
||||
let side = tensors[a].side;
|
||||
let pad = (c.k - 1) / 2;
|
||||
let mut data = vec![0f32; c.cout * side * side];
|
||||
// the input channels in order: the first source's, then the second's
|
||||
let planes: Vec<&[f32]> =
|
||||
std::iter::once(&tensors[a]).chain(b.map(|b| &tensors[b])).flat_map(|t| t.data.chunks(side * side)).collect();
|
||||
for co in 0..c.cout {
|
||||
for y in 0..side {
|
||||
for x in 0..side {
|
||||
let mut sum = c.bias[co];
|
||||
for (ci, plane) in planes.iter().enumerate() {
|
||||
for ky in 0..c.k {
|
||||
for kx in 0..c.k {
|
||||
let (yy, xx) = ((y + ky) as isize - pad as isize, (x + kx) as isize - pad as isize);
|
||||
if yy < 0 || xx < 0 || yy >= side as isize || xx >= side as isize {
|
||||
continue;
|
||||
}
|
||||
sum += plane[yy as usize * side + xx as usize] * c.weight[((co * c.cin + ci) * c.k + ky) * c.k + kx];
|
||||
}
|
||||
}
|
||||
}
|
||||
data[(co * side + y) * side + x] = leaky(sum, c.leaky);
|
||||
}
|
||||
}
|
||||
}
|
||||
T { channels: c.cout, side, data }
|
||||
}
|
||||
Op::ConvT2(c) => {
|
||||
let a = get(&tensors, c.src)?;
|
||||
let side = tensors[a].side;
|
||||
let out_side = 2 * side;
|
||||
let mut data = vec![0f32; c.cout * out_side * out_side];
|
||||
for co in 0..c.cout {
|
||||
for y in 0..side {
|
||||
for x in 0..side {
|
||||
for dy in 0..2 {
|
||||
for dx in 0..2 {
|
||||
let mut sum = c.bias[co];
|
||||
for ci in 0..c.cin {
|
||||
sum += tensors[a].data[(ci * side + y) * side + x] * c.weight[((ci * c.cout + co) * 2 + dy) * 2 + dx];
|
||||
}
|
||||
data[(co * out_side + 2 * y + dy) * out_side + 2 * x + dx] = leaky(sum, c.leaky);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
T { channels: c.cout, side: out_side, data }
|
||||
}
|
||||
Op::MaxPool2 { src } => {
|
||||
let a = get(&tensors, *src)?;
|
||||
let (channels, side) = (tensors[a].channels, tensors[a].side / 2);
|
||||
let mut data = vec![0f32; channels * side * side];
|
||||
for ch in 0..channels {
|
||||
for y in 0..side {
|
||||
for x in 0..side {
|
||||
let at = |dy: usize, dx: usize| tensors[a].data[(ch * 2 * side + 2 * y + dy) * 2 * side + 2 * x + dx];
|
||||
data[(ch * side + y) * side + x] = at(0, 0).max(at(0, 1)).max(at(1, 0)).max(at(1, 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
T { channels, side, data }
|
||||
}
|
||||
Op::DepthToSpace2 { src } => {
|
||||
let a = get(&tensors, *src)?;
|
||||
let (channels, side) = (tensors[a].channels / 4, tensors[a].side);
|
||||
let out_side = 2 * side;
|
||||
let mut data = vec![0f32; channels * out_side * out_side];
|
||||
for ch in 0..channels {
|
||||
for y in 0..side {
|
||||
for x in 0..side {
|
||||
for dy in 0..2 {
|
||||
for dx in 0..2 {
|
||||
data[(ch * out_side + 2 * y + dy) * out_side + 2 * x + dx] =
|
||||
tensors[a].data[((4 * ch + 2 * dy + dx) * side + y) * side + x];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
T { channels, side: out_side, data }
|
||||
}
|
||||
};
|
||||
tensors.push(out);
|
||||
}
|
||||
tensors.pop().map(|t| t.data).ok_or_else(|| NetError::Invalid("no layers".into()))
|
||||
}
|
||||
|
||||
fn leaky(v: f32, alpha: Option<f32>) -> f32 {
|
||||
match alpha {
|
||||
Some(a) if v < 0.0 => v * a,
|
||||
_ => v,
|
||||
}
|
||||
}
|
||||
|
||||
/// The reference as a [`TileRunner`] (for tests of the code around the model).
|
||||
pub struct ReferenceRunner {
|
||||
pub net: Net,
|
||||
pub tile: usize,
|
||||
}
|
||||
|
||||
impl TileRunner for ReferenceRunner {
|
||||
fn run(&self, input: &[f32]) -> Result<Vec<f32>, Error> {
|
||||
run(&self.net, self.tile, input).map_err(|e| Error::Runtime(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,433 @@
|
|||
//! Model loading, a shared installation self-test, and deterministic test tiles.
|
||||
//! `CpuRunner` is the pure-Rust executor; scalar operator tests live in `reference`.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use web_time::Instant;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::manifest::{DenoiserManifest, Gain};
|
||||
use crate::run::{Error, TileRunner};
|
||||
|
||||
/// What `self_test` found.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SelfTest {
|
||||
pub ok: bool,
|
||||
/// Milliseconds to load and optimise the model.
|
||||
pub load_ms: f64,
|
||||
/// Median milliseconds to run one tile.
|
||||
pub tile_ms: f64,
|
||||
/// Megapixels of picture one tile covers, for estimating a whole photo's time.
|
||||
pub tile_megapixels: f64,
|
||||
/// The model's output scale relative to its input on the test picture (what `Gain::MatchMean` takes out).
|
||||
pub scale: f64,
|
||||
/// Each check: what was checked and whether it held.
|
||||
pub checks: Vec<(String, bool)>,
|
||||
}
|
||||
|
||||
/// The default pure-Rust CPU model, with the same manifest and installation self-test as the reference.
|
||||
#[derive(Clone)]
|
||||
pub struct CpuRunner {
|
||||
runner: crate::cpu::NetRunner,
|
||||
manifest: DenoiserManifest,
|
||||
load_ms: f64,
|
||||
}
|
||||
impl CpuRunner {
|
||||
pub fn load(path: &Path, manifest: &DenoiserManifest) -> Result<Self, Error> {
|
||||
crate::manifest::validate(manifest).map_err(|e| Error::Load(e.to_string()))?;
|
||||
let started = Instant::now();
|
||||
let net = crate::onnx::read(path).map_err(|e| Error::Load(e.to_string()))?;
|
||||
let runner = crate::cpu::NetRunner::new(&net, manifest.tile as usize).map_err(|e| Error::Load(e.to_string()))?;
|
||||
Ok(Self { runner, manifest: manifest.clone(), load_ms: started.elapsed().as_secs_f64() * 1e3 })
|
||||
}
|
||||
pub fn manifest(&self) -> &DenoiserManifest {
|
||||
&self.manifest
|
||||
}
|
||||
pub fn net(&self) -> &crate::net::Net {
|
||||
self.runner.net()
|
||||
}
|
||||
pub fn bytes_per_tile(&self) -> usize {
|
||||
self.runner.bytes_per_tile()
|
||||
}
|
||||
pub fn self_test(&self) -> SelfTest {
|
||||
self_test(self, &self.manifest, self.load_ms)
|
||||
}
|
||||
}
|
||||
impl TileRunner for CpuRunner {
|
||||
fn run(&self, input: &[f32]) -> Result<Vec<f32>, Error> {
|
||||
self.runner.run(input)
|
||||
}
|
||||
}
|
||||
fn self_test(runner: &dyn TileRunner, manifest: &DenoiserManifest, load_ms: f64) -> SelfTest {
|
||||
let t = manifest.tile as usize;
|
||||
let mut checks: Vec<(String, bool)> = Vec::new();
|
||||
let (input, base) = test_tile(t);
|
||||
let (mut times, mut scale) = (Vec::new(), 0.0f64);
|
||||
let mut run = || {
|
||||
let started = Instant::now();
|
||||
let r = runner.run(&input);
|
||||
times.push(started.elapsed().as_secs_f64() * 1000.0);
|
||||
r
|
||||
};
|
||||
let (a, b) = (run(), run());
|
||||
match (&a, &b) {
|
||||
(Ok(a), Ok(b)) => {
|
||||
checks.push((format!("gives {} finite numbers per tile", a.len()), true));
|
||||
checks.push((
|
||||
"gives the same answer for the same tile".into(),
|
||||
a.iter().zip(b).all(|(x, y)| (x - y).abs() <= 1e-4 * x.abs().max(y.abs()).max(1e-9)),
|
||||
));
|
||||
let side = 2 * t;
|
||||
let mean_in = base.iter().map(|&v| f64::from(v)).sum::<f64>() / base.len().max(1) as f64;
|
||||
let mean_out = a.iter().map(|&v| f64::from(v)).sum::<f64>() / a.len().max(1) as f64;
|
||||
scale = if mean_in > 0.0 { mean_out / mean_in } else { 0.0 };
|
||||
if let Gain::MatchMean { nominal, max_deviation } = manifest.gain {
|
||||
let ratio = scale / f64::from(nominal);
|
||||
let tolerance = f64::from(max_deviation).max(0.05) * 4.0;
|
||||
checks.push((format!("its scale is the {nominal:.3e} the description says (found {scale:.3e})"), (ratio - 1.0).abs() <= tolerance));
|
||||
}
|
||||
// noise left after bringing the answer back to the input's scale, against the noise that went in
|
||||
let g = if scale > 0.0 { 1.0 / scale } else { 1.0 };
|
||||
let (mut noise_in, mut noise_out, mut n_in, mut n_out) = (0.0f64, 0.0f64, 0usize, 0usize);
|
||||
for (i, (&v, &s)) in input.iter().zip(base.iter().cycle()).enumerate() {
|
||||
// centre of each plane only: edges are the model's guess
|
||||
let (x, y) = ((i % (t * t)) % t, (i % (t * t)) / t);
|
||||
if x >= t / 4 && x < 3 * t / 4 && y >= t / 4 && y < 3 * t / 4 {
|
||||
noise_in += f64::from(v - s).powi(2);
|
||||
n_in += 1;
|
||||
}
|
||||
}
|
||||
for c in 0..3 {
|
||||
for y in side / 4..3 * side / 4 {
|
||||
for x in side / 4..3 * side / 4 {
|
||||
if let (Some(&o), Some(&s)) = (a.get(c * side * side + y * side + x), base.get((y / 2) * t + x / 2)) {
|
||||
noise_out += (f64::from(o) * g - f64::from(s)).powi(2);
|
||||
n_out += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let (rms_in, rms_out) = ((noise_in / n_in.max(1) as f64).sqrt(), (noise_out / n_out.max(1) as f64).sqrt());
|
||||
checks.push((format!("takes noise down (from {rms_in:.4} to {rms_out:.4})"), rms_out < 0.8 * rms_in));
|
||||
}
|
||||
(Err(e), _) | (_, Err(e)) => checks.push((format!("runs: {e}"), false)),
|
||||
}
|
||||
times.sort_by(|x, y| x.total_cmp(y));
|
||||
SelfTest {
|
||||
ok: !checks.is_empty() && checks.iter().all(|(_, ok)| *ok),
|
||||
load_ms,
|
||||
tile_ms: times.get(times.len() / 2).copied().unwrap_or(0.0),
|
||||
tile_megapixels: (2 * t * 2 * t) as f64 / 1e6,
|
||||
scale,
|
||||
checks,
|
||||
}
|
||||
}
|
||||
|
||||
/// A deterministic test tile: four planes of a smooth gradient with noise on top, and the gradient alone for one
|
||||
/// plane (`tile × tile`).
|
||||
pub fn test_tile(t: usize) -> (Vec<f32>, Vec<f32>) {
|
||||
if t == 0 || t > 2048 {
|
||||
return (Vec::new(), Vec::new());
|
||||
}
|
||||
let mut seed = 0x2545_f491u32;
|
||||
let mut next = move || {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
(seed >> 8) as f32 / (1u32 << 24) as f32
|
||||
};
|
||||
let base: Vec<f32> = (0..t * t).map(|i| 0.12 + 0.3 * (((i % t) + (i / t)) as f32 / (2 * t) as f32)).collect();
|
||||
let input = (0..4 * t * t)
|
||||
.map(|i| {
|
||||
// sum of four uniforms: close to Gaussian, deviation 0.0577 × 2 before scaling
|
||||
let n = next() + next() + next() + next() - 2.0;
|
||||
base.get(i % (t * t)).copied().unwrap_or(0.0) + 0.05 * n
|
||||
})
|
||||
.collect();
|
||||
(input, base)
|
||||
}
|
||||
|
||||
/// A tile for checking that two runners of one model agree: the whole range a raw photo has rather than the test tile's
|
||||
/// mid-greys — a noisy ramp from black to white across the tile, a flat black corner and a clipped white one (four
|
||||
/// planes of `tile × tile`).
|
||||
pub fn check_tile(t: usize) -> Vec<f32> {
|
||||
if t == 0 || t > 2048 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut seed = 0x9e37_79b9u32;
|
||||
let mut next = move || {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
(seed >> 8) as f32 / (1u32 << 24) as f32
|
||||
};
|
||||
let (edge, span) = ((t / 8).max(1), (2 * t).saturating_sub(2).max(1) as f32);
|
||||
(0..4 * t * t)
|
||||
.map(|i| {
|
||||
let (x, y) = ((i % (t * t)) % t, (i % (t * t)) / t);
|
||||
let n = next() + next() + next() + next() - 2.0;
|
||||
if x < edge && y < edge {
|
||||
0.0
|
||||
} else if x + edge >= t && y + edge >= t {
|
||||
1.0
|
||||
} else {
|
||||
((x + y) as f32 / span + 0.05 * n).clamp(0.0, 1.0)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::licence::Licence;
|
||||
use crate::manifest::Domain;
|
||||
use crate::synthetic_proto::{len_field, value_info_bytes};
|
||||
|
||||
fn varint(mut v: u64, out: &mut Vec<u8>) {
|
||||
loop {
|
||||
let b = (v & 0x7f) as u8;
|
||||
v >>= 7;
|
||||
if v == 0 {
|
||||
out.push(b);
|
||||
return;
|
||||
}
|
||||
out.push(b | 0x80);
|
||||
}
|
||||
}
|
||||
|
||||
fn varint_field(n: u64, v: u64, out: &mut Vec<u8>) {
|
||||
varint(n << 3, out);
|
||||
varint(v, out);
|
||||
}
|
||||
|
||||
fn node(op: &str, inputs: &[&str], output: &str, ints: &[(&str, i64)]) -> Vec<u8> {
|
||||
let mut n = Vec::new();
|
||||
for i in inputs {
|
||||
len_field(1, i.as_bytes(), &mut n);
|
||||
}
|
||||
len_field(2, output.as_bytes(), &mut n);
|
||||
len_field(4, op.as_bytes(), &mut n);
|
||||
for (name, value) in ints {
|
||||
let mut a = Vec::new();
|
||||
len_field(1, name.as_bytes(), &mut a);
|
||||
varint_field(3, *value as u64, &mut a);
|
||||
varint_field(20, 2, &mut a);
|
||||
len_field(5, &a, &mut n);
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
fn tensor(name: &str, dims: &[u64], values: &[f32]) -> Vec<u8> {
|
||||
let mut t = Vec::new();
|
||||
for d in dims {
|
||||
varint_field(1, *d, &mut t);
|
||||
}
|
||||
varint_field(2, 1, &mut t);
|
||||
len_field(8, name.as_bytes(), &mut t);
|
||||
let raw: Vec<u8> = values.iter().flat_map(|v| v.to_le_bytes()).collect();
|
||||
len_field(9, &raw, &mut t);
|
||||
t
|
||||
}
|
||||
|
||||
/// A tiny working denoiser-shaped network: `[1, 4, t, t]` → a 1 × 1 convolution to 12 channels (each output
|
||||
/// channel is `scale` times one input plane, so the scale is known) → depth-to-space → `[1, 3, 2t, 2t]`.
|
||||
fn tiny_model(t: u64, scale: f32) -> Vec<u8> {
|
||||
tiny_model_with(t, scale, 12)
|
||||
}
|
||||
|
||||
/// The same with another number of convolution channels; only 12 can be arranged into the three colours.
|
||||
fn tiny_model_with(t: u64, scale: f32, channels: u64) -> Vec<u8> {
|
||||
let mut graph = Vec::new();
|
||||
len_field(1, &node("Conv", &["data", "w"], if channels == 12 { "c" } else { "rgb" }, &[("group", 1)]), &mut graph);
|
||||
if channels == 12 {
|
||||
let mut depth_to_space = node("DepthToSpace", &["c"], "rgb", &[("blocksize", 2)]);
|
||||
let mut mode = Vec::new();
|
||||
len_field(1, b"mode", &mut mode);
|
||||
len_field(4, b"CRD", &mut mode);
|
||||
varint_field(20, 3, &mut mode);
|
||||
len_field(5, &mode, &mut depth_to_space);
|
||||
len_field(1, &depth_to_space, &mut graph);
|
||||
}
|
||||
// CRD output channel colour·4 + dy·2 + dx reads input plane [R, G1, B] of the tile.
|
||||
let mut w = vec![0f32; channels as usize * 4];
|
||||
for oc in 0..channels as usize {
|
||||
let colour = (oc / 4).min(2);
|
||||
let plane = [0, 1, 3][colour];
|
||||
w[oc * 4 + plane] = scale;
|
||||
}
|
||||
len_field(5, &tensor("w", &[channels, 4, 1, 1], &w), &mut graph);
|
||||
len_field(11, &value_info_bytes("data", 1, &[Ok(1), Ok(4), Ok(t), Ok(t)]), &mut graph);
|
||||
len_field(12, &value_info_bytes("rgb", 1, &[Ok(1), Ok(3), Ok(2 * t), Ok(2 * t)]), &mut graph);
|
||||
let mut ops = Vec::new();
|
||||
len_field(1, b"", &mut ops);
|
||||
varint_field(2, 13, &mut ops);
|
||||
let mut model = Vec::new();
|
||||
varint_field(1, 8, &mut model);
|
||||
len_field(7, &graph, &mut model);
|
||||
len_field(8, &ops, &mut model);
|
||||
model
|
||||
}
|
||||
|
||||
fn manifest(tile: u32, nominal: f32) -> DenoiserManifest {
|
||||
DenoiserManifest {
|
||||
id: "tiny".into(),
|
||||
name: "Tiny".into(),
|
||||
version: "1".into(),
|
||||
licence: Licence::default(),
|
||||
source: None,
|
||||
sha256: None,
|
||||
size_bytes: None,
|
||||
provenance: String::new(),
|
||||
domain: Domain::BayerToRgb,
|
||||
tile,
|
||||
overlap: 16,
|
||||
gain: Gain::MatchMean { nominal, max_deviation: 0.05 },
|
||||
}
|
||||
}
|
||||
|
||||
fn temp_model(name: &str, bytes: &[u8]) -> std::path::PathBuf {
|
||||
let p = std::env::temp_dir().join(format!("lc-denoise-runtime-{name}-{}.onnx", std::process::id()));
|
||||
std::fs::write(&p, bytes).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_working_model_loads_runs_and_has_the_scale_it_says() {
|
||||
let p = temp_model("ok", &tiny_model(64, 1000.0));
|
||||
let r = CpuRunner::load(&p, &manifest(64, 1000.0)).unwrap();
|
||||
let input: Vec<f32> = (0..4 * 64 * 64).map(|i| (i % 97) as f32 / 97.0).collect();
|
||||
let out = r.run(&input).unwrap();
|
||||
assert_eq!(out.len(), 3 * 128 * 128);
|
||||
// output (colour c, pixel (2y+dy, 2x+dx)) = scale × input plane [R, G1, B][c] at (y, x)
|
||||
for (c, plane) in [0usize, 1, 3].into_iter().enumerate() {
|
||||
let (y, x) = (5usize, 9usize);
|
||||
assert!((out[c * 128 * 128 + (2 * y + 1) * 128 + 2 * x] - 1000.0 * input[plane * 64 * 64 + y * 64 + x]).abs() < 1e-2);
|
||||
}
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_self_test_checks_scale_and_noise() {
|
||||
// a model that only multiplies cannot take noise down: it must fail that check but pass the others
|
||||
let p = temp_model("selftest", &tiny_model(64, 1000.0));
|
||||
let t = CpuRunner::load(&p, &manifest(64, 1000.0)).unwrap().self_test();
|
||||
assert!(!t.ok, "{t:?}");
|
||||
assert!(t.checks.iter().any(|(c, ok)| c.contains("same answer") && *ok), "{t:?}");
|
||||
assert!(t.checks.iter().any(|(c, ok)| c.contains("scale") && *ok), "{t:?}");
|
||||
assert!(t.checks.iter().any(|(c, ok)| c.contains("noise") && !*ok), "{t:?}");
|
||||
// and one that claims the wrong scale fails that check
|
||||
let q = CpuRunner::load(&p, &manifest(64, 5.0)).unwrap().self_test();
|
||||
assert!(q.checks.iter().any(|(c, ok)| c.contains("scale") && !*ok), "{q:?}");
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_sizes_and_broken_files_are_errors() {
|
||||
let p = temp_model("sizes", &tiny_model(64, 1.0));
|
||||
let r = CpuRunner::load(&p, &manifest(64, 1.0)).unwrap();
|
||||
assert!(matches!(r.run(&[0.0; 10]), Err(Error::Input(_))));
|
||||
// a fully convolutional model works at whatever tile size the manifest asks for
|
||||
let wide = CpuRunner::load(&p, &manifest(128, 1.0)).unwrap();
|
||||
assert_eq!(wide.run(&vec![0.5; 4 * 128 * 128]).unwrap().len(), 3 * 256 * 256);
|
||||
// a model that gives another number of values than the manifest promises is an error
|
||||
let q = temp_model("channels", &tiny_model_with(64, 1.0, 8));
|
||||
assert!(CpuRunner::load(&q, &manifest(64, 1.0)).is_err(), "shape is refused before execution");
|
||||
let _ = std::fs::remove_file(q);
|
||||
for (name, bytes) in [("junk", b"nonsense".to_vec()), ("empty", Vec::new())] {
|
||||
let q = temp_model(name, &bytes);
|
||||
assert!(CpuRunner::load(&q, &manifest(64, 1.0)).is_err(), "{name}");
|
||||
let _ = std::fs::remove_file(q);
|
||||
}
|
||||
assert!(CpuRunner::load(&std::env::temp_dir().join("does-not-exist.onnx"), &manifest(64, 1.0)).is_err());
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_check_tile_covers_black_to_white() {
|
||||
for t in [1, 2, 7, 64] {
|
||||
let c = check_tile(t);
|
||||
assert_eq!(c.len(), 4 * t * t);
|
||||
assert!(c.iter().all(|v| (0.0..=1.0).contains(v)), "{t}");
|
||||
}
|
||||
let c = check_tile(64);
|
||||
let near = |lo: f32, hi: f32| c.iter().filter(|v| (lo..=hi).contains(*v)).count();
|
||||
assert!(near(0.0, 0.0) >= 4 * 8 * 8 && near(1.0, 1.0) >= 4 * 8 * 8, "a flat black and a clipped white corner");
|
||||
for band in 0..10 {
|
||||
let lo = band as f32 / 10.0;
|
||||
assert!(near(lo, lo + 0.1) > 100, "values around {lo}");
|
||||
}
|
||||
assert_eq!(check_tile(64), c, "the same tile every time");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_and_corrupted_models_never_panic() {
|
||||
let good = tiny_model(64, 1.0);
|
||||
for n in (0..good.len()).step_by(5) {
|
||||
let q = temp_model("trunc", &good[..n]);
|
||||
let _ = CpuRunner::load(&q, &manifest(64, 1.0));
|
||||
let _ = std::fs::remove_file(q);
|
||||
}
|
||||
for at in (0..good.len()).step_by(7) {
|
||||
let mut bad = good.clone();
|
||||
if let Some(b) = bad.get_mut(at) {
|
||||
*b = b.wrapping_add(0x55);
|
||||
}
|
||||
let q = temp_model("flip", &bad);
|
||||
if let Ok(r) = CpuRunner::load(&q, &manifest(64, 1.0)) {
|
||||
let _ = r.run(&vec![0.5; 4 * 64 * 64]);
|
||||
}
|
||||
let _ = std::fs::remove_file(q);
|
||||
}
|
||||
}
|
||||
|
||||
/// Opt-in: `LC_DENOISE_MODEL=<model_bayer.onnx> cargo test -p lightcraft-denoise --release
|
||||
/// -- --ignored --nocapture` self-tests a real model (RawNIND's Bayer model: tile 512, scale about 1e6).
|
||||
#[test]
|
||||
#[ignore = "needs a real model file: set LC_DENOISE_MODEL"]
|
||||
fn a_real_model_passes_the_self_test() {
|
||||
let Some(path) = std::env::var_os("LC_DENOISE_MODEL") else { return };
|
||||
let m = manifest(512, 1.0e6);
|
||||
let r = CpuRunner::load(Path::new(&path), &m).unwrap();
|
||||
let t = r.self_test();
|
||||
println!("load {:.0} ms | {:.0} ms per tile | scale {:.4e} | {:?}", t.load_ms, t.tile_ms, t.scale, t.checks);
|
||||
assert!(t.ok);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cpu_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn manifest_and_test_tile_sizes_are_checked_without_tract() {
|
||||
for t in [0, 2049, usize::MAX] {
|
||||
assert!(test_tile(t).0.is_empty());
|
||||
assert!(check_tile(t).is_empty());
|
||||
}
|
||||
let path = std::env::temp_dir().join(format!("lc-cpu-load-{}.onnx", std::process::id()));
|
||||
std::fs::write(&path, crate::synthetic::unet_onnx(64, 5, 1, 7)).unwrap();
|
||||
let mut m = DenoiserManifest {
|
||||
id: "synthetic".into(),
|
||||
name: "Synthetic".into(),
|
||||
version: "1".into(),
|
||||
licence: Default::default(),
|
||||
source: None,
|
||||
sha256: None,
|
||||
size_bytes: None,
|
||||
provenance: "synthetic test".into(),
|
||||
domain: crate::manifest::Domain::BayerToRgb,
|
||||
tile: 64,
|
||||
overlap: 16,
|
||||
gain: Gain::None,
|
||||
};
|
||||
let r = CpuRunner::load(&path, &m).unwrap();
|
||||
let x = check_tile(64);
|
||||
assert_eq!(r.run(&x).unwrap(), r.run(&x).unwrap());
|
||||
assert!(r.self_test().checks.iter().any(|(text, ok)| text.contains("same answer") && *ok));
|
||||
m.tile = u32::MAX;
|
||||
assert!(CpuRunner::load(&path, &m).is_err());
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,234 @@
|
|||
//! A small ONNX U-Net built in memory, for tests of the layers that read and run denoise networks (here, in the GPU
|
||||
//! runner and in the engine): the same layer kinds and wiring as the real denoisers, with deterministic random weights.
|
||||
//! Nothing here is a model anyone would use.
|
||||
|
||||
use crate::synthetic_proto::{len_field, value_info_bytes};
|
||||
|
||||
fn varint(mut v: u64, out: &mut Vec<u8>) {
|
||||
loop {
|
||||
let b = (v & 0x7f) as u8;
|
||||
v >>= 7;
|
||||
if v == 0 {
|
||||
out.push(b);
|
||||
return;
|
||||
}
|
||||
out.push(b | 0x80);
|
||||
}
|
||||
}
|
||||
|
||||
fn varint_field(n: u64, v: u64, out: &mut Vec<u8>) {
|
||||
varint(n << 3, out);
|
||||
varint(v, out);
|
||||
}
|
||||
|
||||
enum A<'a> {
|
||||
Int(i64),
|
||||
Ints(&'a [i64]),
|
||||
Float(f32),
|
||||
Str(&'a str),
|
||||
}
|
||||
|
||||
fn node(op: &str, inputs: &[&str], output: &str, attrs: &[(&str, A)]) -> Vec<u8> {
|
||||
let mut n = Vec::new();
|
||||
for i in inputs {
|
||||
len_field(1, i.as_bytes(), &mut n);
|
||||
}
|
||||
len_field(2, output.as_bytes(), &mut n);
|
||||
len_field(4, op.as_bytes(), &mut n);
|
||||
for (name, value) in attrs {
|
||||
let mut a = Vec::new();
|
||||
len_field(1, name.as_bytes(), &mut a);
|
||||
match value {
|
||||
A::Int(v) => {
|
||||
varint_field(3, *v as u64, &mut a);
|
||||
varint_field(20, 2, &mut a);
|
||||
}
|
||||
A::Ints(vs) => {
|
||||
for v in *vs {
|
||||
varint_field(8, *v as u64, &mut a);
|
||||
}
|
||||
varint_field(20, 7, &mut a);
|
||||
}
|
||||
A::Float(v) => {
|
||||
varint((2 << 3) | 5, &mut a);
|
||||
a.extend_from_slice(&v.to_le_bytes());
|
||||
varint_field(20, 1, &mut a);
|
||||
}
|
||||
A::Str(s) => {
|
||||
len_field(4, s.as_bytes(), &mut a);
|
||||
varint_field(20, 3, &mut a);
|
||||
}
|
||||
}
|
||||
len_field(5, &a, &mut n);
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
fn tensor(name: &str, dims: &[u64], values: &[f32]) -> Vec<u8> {
|
||||
let mut t = Vec::new();
|
||||
for d in dims {
|
||||
varint_field(1, *d, &mut t);
|
||||
}
|
||||
varint_field(2, 1, &mut t);
|
||||
len_field(8, name.as_bytes(), &mut t);
|
||||
let raw: Vec<u8> = values.iter().flat_map(|v| v.to_le_bytes()).collect();
|
||||
len_field(9, &raw, &mut t);
|
||||
t
|
||||
}
|
||||
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn next(&mut self) -> f32 {
|
||||
self.0 ^= self.0 >> 12;
|
||||
self.0 ^= self.0 << 25;
|
||||
self.0 ^= self.0 >> 27;
|
||||
((self.0.wrapping_mul(0x2545_f491_4f6c_dd1d) >> 40) as f32 / (1u64 << 24) as f32) * 2.0 - 1.0
|
||||
}
|
||||
}
|
||||
|
||||
struct Builder {
|
||||
nodes: Vec<Vec<u8>>,
|
||||
inits: Vec<Vec<u8>>,
|
||||
rng: Rng,
|
||||
n: usize,
|
||||
}
|
||||
|
||||
impl Builder {
|
||||
fn name(&mut self, kind: &str) -> String {
|
||||
self.n += 1;
|
||||
format!("{kind}{}", self.n)
|
||||
}
|
||||
|
||||
/// Random weights scaled so activations keep a sane size through the layers.
|
||||
fn weights(&mut self, name: &str, dims: &[u64], fan_in: usize) -> String {
|
||||
let count: u64 = dims.iter().product();
|
||||
let scale = (3.0 / fan_in as f32).sqrt();
|
||||
let values: Vec<f32> = (0..count).map(|_| self.rng.next() * scale).collect();
|
||||
self.inits.push(tensor(name, dims, &values));
|
||||
name.to_string()
|
||||
}
|
||||
|
||||
fn bias(&mut self, name: &str, n: u64) -> String {
|
||||
let values: Vec<f32> = (0..n).map(|_| self.rng.next() * 0.1).collect();
|
||||
self.inits.push(tensor(name, &[n], &values));
|
||||
name.to_string()
|
||||
}
|
||||
|
||||
/// `Conv` (+ `LeakyRelu` when `leaky`); gives the name of the tensor that results.
|
||||
fn conv(&mut self, src: &str, cin: u64, cout: u64, k: u64, leaky: bool) -> String {
|
||||
let (w, b, out) = (self.name("w"), self.name("b"), self.name("conv"));
|
||||
let w = self.weights(&w, &[cout, cin, k, k], (cin * k * k) as usize);
|
||||
let b = self.bias(&b, cout);
|
||||
let pad = (k as i64 - 1) / 2;
|
||||
self.nodes.push(node(
|
||||
"Conv",
|
||||
&[src, &w, &b],
|
||||
&out,
|
||||
&[("kernel_shape", A::Ints(&[k as i64, k as i64])), ("pads", A::Ints(&[pad; 4])), ("strides", A::Ints(&[1, 1])), ("group", A::Int(1))],
|
||||
));
|
||||
if !leaky {
|
||||
return out;
|
||||
}
|
||||
let act = self.name("act");
|
||||
self.nodes.push(node("LeakyRelu", &[&out], &act, &[("alpha", A::Float(0.2))]));
|
||||
act
|
||||
}
|
||||
|
||||
fn pool(&mut self, src: &str) -> String {
|
||||
let out = self.name("pool");
|
||||
self.nodes.push(node(
|
||||
"MaxPool",
|
||||
&[src],
|
||||
&out,
|
||||
&[("kernel_shape", A::Ints(&[2, 2])), ("strides", A::Ints(&[2, 2])), ("pads", A::Ints(&[0; 4]))],
|
||||
));
|
||||
out
|
||||
}
|
||||
|
||||
fn up(&mut self, src: &str, cin: u64, cout: u64) -> String {
|
||||
let (w, b, out) = (self.name("uw"), self.name("ub"), self.name("up"));
|
||||
let w = self.weights(&w, &[cin, cout, 2, 2], (cin * 4) as usize);
|
||||
let b = self.bias(&b, cout);
|
||||
self.nodes.push(node(
|
||||
"ConvTranspose",
|
||||
&[src, &w, &b],
|
||||
&out,
|
||||
&[("kernel_shape", A::Ints(&[2, 2])), ("strides", A::Ints(&[2, 2])), ("pads", A::Ints(&[0; 4]))],
|
||||
));
|
||||
out
|
||||
}
|
||||
|
||||
fn concat(&mut self, a: &str, b: &str) -> String {
|
||||
let out = self.name("cat");
|
||||
self.nodes.push(node("Concat", &[a, b], &out, &[("axis", A::Int(1))]));
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// A denoiser-shaped U-Net as ONNX bytes: `[1, 4, tile, tile]` → `[1, 3, 2·tile, 2·tile]`, with `depth` poolings
|
||||
/// (so `tile` must be a multiple of `2^depth`) and `ch` channels on the first level, doubling on each (every count a
|
||||
/// multiple of 4). Each level is two 3 × 3 convolutions with a leaky ReLU, as in the real networks; the output is a
|
||||
/// 1 × 1 convolution to 12 channels and depth-to-space.
|
||||
pub fn unet_onnx(tile: u64, ch: u64, depth: usize, seed: u64) -> Vec<u8> {
|
||||
let mut b = Builder { nodes: Vec::new(), inits: Vec::new(), rng: Rng(seed | 1), n: 0 };
|
||||
let mut skips: Vec<(String, u64)> = Vec::new();
|
||||
let (mut cur, mut c_in, mut c) = ("data".to_string(), 4u64, ch);
|
||||
for level in 0..=depth {
|
||||
let x = b.conv(&cur, c_in, c, 3, true);
|
||||
let x = b.conv(&x, c, c, 3, true);
|
||||
if level < depth {
|
||||
skips.push((x.clone(), c));
|
||||
cur = b.pool(&x);
|
||||
c_in = c;
|
||||
c *= 2;
|
||||
} else {
|
||||
cur = x;
|
||||
}
|
||||
}
|
||||
for (skip, sc) in skips.into_iter().rev() {
|
||||
let up = b.up(&cur, c, sc);
|
||||
let joined = b.concat(&up, &skip);
|
||||
let x = b.conv(&joined, 2 * sc, sc, 3, true);
|
||||
cur = b.conv(&x, sc, sc, 3, true);
|
||||
c = sc;
|
||||
}
|
||||
let rgb = b.conv(&cur, c, 12, 1, false);
|
||||
b.nodes.push(node("DepthToSpace", &[&rgb], "rgb", &[("blocksize", A::Int(2)), ("mode", A::Str("CRD"))]));
|
||||
|
||||
let mut graph = Vec::new();
|
||||
for n in &b.nodes {
|
||||
len_field(1, n, &mut graph);
|
||||
}
|
||||
for t in &b.inits {
|
||||
len_field(5, t, &mut graph);
|
||||
}
|
||||
len_field(11, &value_info_bytes("data", 1, &[Ok(1), Ok(4), Ok(tile), Ok(tile)]), &mut graph);
|
||||
len_field(12, &value_info_bytes("rgb", 1, &[Ok(1), Ok(3), Ok(2 * tile), Ok(2 * tile)]), &mut graph);
|
||||
let mut ops = Vec::new();
|
||||
len_field(1, b"", &mut ops);
|
||||
varint_field(2, 13, &mut ops);
|
||||
let mut model = Vec::new();
|
||||
varint_field(1, 8, &mut model);
|
||||
len_field(7, &graph, &mut model);
|
||||
len_field(8, &ops, &mut model);
|
||||
model
|
||||
}
|
||||
|
||||
/// A tiny smoothing network for installing a real CPU model in setup-flow tests.
|
||||
/// It averages a 3 × 3 neighborhood of the four Bayer planes and returns three identical channels.
|
||||
pub fn smoothing_onnx() -> Vec<u8> {
|
||||
let mut graph = Vec::new();
|
||||
len_field(1, &node("Conv", &["data", "weights"], "samples", &[("kernel_shape", A::Ints(&[3, 3])), ("pads", A::Ints(&[1, 1, 1, 1]))]), &mut graph);
|
||||
len_field(1, &node("DepthToSpace", &["samples"], "rgb", &[("blocksize", A::Int(2)), ("mode", A::Str("CRD"))]), &mut graph);
|
||||
len_field(5, &tensor("weights", &[12, 4, 3, 3], &[1.0 / 36.0; 12 * 4 * 3 * 3]), &mut graph);
|
||||
len_field(11, &value_info_bytes("data", 1, &[Ok(1), Ok(4), Ok(64), Ok(64)]), &mut graph);
|
||||
len_field(12, &value_info_bytes("rgb", 1, &[Ok(1), Ok(3), Ok(128), Ok(128)]), &mut graph);
|
||||
let mut model = Vec::new();
|
||||
varint_field(1, 8, &mut model);
|
||||
len_field(7, &graph, &mut model);
|
||||
let mut ops = Vec::new();
|
||||
varint_field(2, 13, &mut ops);
|
||||
len_field(8, &ops, &mut model);
|
||||
model
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
pub(crate) fn varint(mut v: u64, out: &mut Vec<u8>) {
|
||||
loop {
|
||||
let b = (v & 0x7f) as u8;
|
||||
v >>= 7;
|
||||
if v == 0 {
|
||||
out.push(b);
|
||||
return;
|
||||
}
|
||||
out.push(b | 0x80);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn len_field(n: u64, body: &[u8], out: &mut Vec<u8>) {
|
||||
varint(n << 3 | 2, out);
|
||||
varint(body.len() as u64, out);
|
||||
out.extend_from_slice(body);
|
||||
}
|
||||
|
||||
pub(crate) fn varint_field(n: u64, v: u64, out: &mut Vec<u8>) {
|
||||
varint(n << 3, out);
|
||||
varint(v, out);
|
||||
}
|
||||
|
||||
/// A graph input or output: `Ok(n)` is a fixed dimension, `Err(name)` a named (dynamic) one.
|
||||
pub fn value_info_bytes(name: &str, elem: u64, dims: &[std::result::Result<u64, &str>]) -> Vec<u8> {
|
||||
let mut shape = Vec::new();
|
||||
for d in dims {
|
||||
let mut dim = Vec::new();
|
||||
match d {
|
||||
Ok(v) => varint_field(1, *v, &mut dim),
|
||||
Err(p) => len_field(2, p.as_bytes(), &mut dim),
|
||||
}
|
||||
len_field(1, &dim, &mut shape);
|
||||
}
|
||||
let mut tensor = Vec::new();
|
||||
varint_field(1, elem, &mut tensor);
|
||||
len_field(2, &shape, &mut tensor);
|
||||
let mut ty = Vec::new();
|
||||
len_field(1, &tensor, &mut ty);
|
||||
let mut vi = Vec::new();
|
||||
len_field(1, name.as_bytes(), &mut vi);
|
||||
len_field(2, &ty, &mut vi);
|
||||
vi
|
||||
}
|
||||
|
|
@ -0,0 +1,250 @@
|
|||
//! Opt-in end-to-end check of the real RawNIND Bayer model on real mosaics with noise added:
|
||||
//!
|
||||
//! ```text
|
||||
//! LC_DENOISE_MODEL=<model_bayer.onnx> LC_DENOISE_MOSAICS=<folder> LC_DENOISE_OUT=<folder> \
|
||||
//! cargo test --release -p lightcraft-denoise --features runtime --test real_model -- --ignored --nocapture
|
||||
//! ```
|
||||
//!
|
||||
//! `LC_DENOISE_MOSAICS` holds `<name>.mosaic.f32` (little-endian f32, normalised: black 0, white 1, not white
|
||||
//! balanced), `<name>.rgb.f32` (a clean demosaic of it, camera RGB, interleaved) and `<name>.txt` (`width`,
|
||||
//! `height`, `cfa`, `wb` lines). Noise is Poisson-Gaussian, made here. Optional: `LC_DENOISE_PARALLEL` (tiles at
|
||||
//! once, default 8), `LC_DENOISE_ONLY` (one name), `LC_DENOISE_ISO` (noise strength, default 1.0; 0 adds none),
|
||||
//! `LC_DENOISE_FULL` (the whole frame instead of a 2048 × 1536 crop).
|
||||
|
||||
#![cfg(feature = "runtime")]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Instant;
|
||||
|
||||
use lightcraft_denoise::bayer::Layout;
|
||||
use lightcraft_denoise::manifest::{DenoiserManifest, Domain, Gain};
|
||||
use lightcraft_denoise::run::{Control, Params, denoise_bayer};
|
||||
use lightcraft_denoise::runtime::CpuRunner;
|
||||
|
||||
struct Rng(u64);
|
||||
impl Rng {
|
||||
fn next(&mut self) -> f64 {
|
||||
self.0 ^= self.0 >> 12;
|
||||
self.0 ^= self.0 << 25;
|
||||
self.0 ^= self.0 >> 27;
|
||||
((self.0.wrapping_mul(0x2545_f491_4f6c_dd1d) >> 11) as f64 + 0.5) / (1u64 << 53) as f64
|
||||
}
|
||||
fn gauss(&mut self) -> f64 {
|
||||
(-2.0 * self.next().ln()).sqrt() * (std::f64::consts::TAU * self.next()).cos()
|
||||
}
|
||||
}
|
||||
|
||||
fn read_f32(path: &Path) -> Vec<f32> {
|
||||
std::fs::read(path).unwrap().as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect()
|
||||
}
|
||||
|
||||
struct Dump {
|
||||
width: usize,
|
||||
height: usize,
|
||||
layout: Layout,
|
||||
wb: [f32; 3],
|
||||
mosaic: Vec<f32>,
|
||||
rgb: Vec<f32>,
|
||||
}
|
||||
|
||||
fn load(dir: &Path, name: &str) -> Dump {
|
||||
let text = std::fs::read_to_string(dir.join(format!("{name}.txt"))).unwrap();
|
||||
let field = |k: &str| text.lines().find_map(|l| l.strip_prefix(k).map(|v| v.trim().to_string())).unwrap();
|
||||
let cell = match field("cfa").as_str() {
|
||||
"RGGB" => [0, 1, 1, 2],
|
||||
"BGGR" => [2, 1, 1, 0],
|
||||
"GRBG" => [1, 0, 2, 1],
|
||||
"GBRG" => [1, 2, 0, 1],
|
||||
other => panic!("cfa {other}"),
|
||||
};
|
||||
let wb: Vec<f32> = field("wb").split_whitespace().map(|v| v.parse().unwrap()).collect();
|
||||
Dump {
|
||||
width: field("width").parse().unwrap(),
|
||||
height: field("height").parse().unwrap(),
|
||||
layout: Layout::from_cell(cell).unwrap(),
|
||||
wb: [wb[0], wb[1], wb[2]],
|
||||
mosaic: read_f32(&dir.join(format!("{name}.mosaic.f32"))),
|
||||
rgb: read_f32(&dir.join(format!("{name}.rgb.f32"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Crop with an even origin so the Bayer phase is kept.
|
||||
fn crop(d: &Dump, x0: usize, y0: usize, w: usize, h: usize) -> (Vec<f32>, Vec<f32>) {
|
||||
let mut m = Vec::with_capacity(w * h);
|
||||
let mut c = Vec::with_capacity(w * h * 3);
|
||||
for y in y0..y0 + h {
|
||||
m.extend_from_slice(&d.mosaic[y * d.width + x0..y * d.width + x0 + w]);
|
||||
c.extend_from_slice(&d.rgb[(y * d.width + x0) * 3..(y * d.width + x0 + w) * 3]);
|
||||
}
|
||||
(m, c)
|
||||
}
|
||||
|
||||
/// 0 = red, 1 = green, 2 = blue at a mosaic position.
|
||||
fn site(layout: Layout, x: usize, y: usize) -> usize {
|
||||
match ((x + layout.dx) % 2, (y + layout.dy) % 2) {
|
||||
(0, 0) => 0,
|
||||
(1, 1) => 2,
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// The simplest demosaic (each missing colour is the mean of the same-coloured samples in the 3 × 3 around),
|
||||
/// as the baseline a user would get from the noisy picture without AI.
|
||||
fn bilinear(m: &[f32], w: usize, h: usize, layout: Layout) -> Vec<f32> {
|
||||
let mut out = vec![0f32; w * h * 3];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let mut sum = [0f32; 3];
|
||||
let mut n = [0f32; 3];
|
||||
for yy in y.saturating_sub(1)..(y + 2).min(h) {
|
||||
for xx in x.saturating_sub(1)..(x + 2).min(w) {
|
||||
let c = site(layout, xx, yy);
|
||||
sum[c] += m[yy * w + xx];
|
||||
n[c] += 1.0;
|
||||
}
|
||||
}
|
||||
for c in 0..3 {
|
||||
out[(y * w + x) * 3 + c] = if n[c] > 0.0 { sum[c] / n[c] } else { 0.0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Signal-to-error ratio in dB of `got` against `want` (both interleaved RGB), ignoring a border.
|
||||
fn snr(got: &[f32], want: &[f32], w: usize, h: usize, border: usize) -> f64 {
|
||||
let (mut sig, mut err) = (0f64, 0f64);
|
||||
for y in border..h - border {
|
||||
for x in border..w - border {
|
||||
for c in 0..3 {
|
||||
let (g, t) = (f64::from(got[(y * w + x) * 3 + c]), f64::from(want[(y * w + x) * 3 + c]));
|
||||
sig += t * t;
|
||||
err += (g - t).powi(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
10.0 * (sig / err.max(1e-30)).log10()
|
||||
}
|
||||
|
||||
fn crc32(data: &[u8]) -> u32 {
|
||||
let mut c = !0u32;
|
||||
for &b in data {
|
||||
c ^= u32::from(b);
|
||||
for _ in 0..8 {
|
||||
c = if c & 1 != 0 { 0xedb8_8320 ^ (c >> 1) } else { c >> 1 };
|
||||
}
|
||||
}
|
||||
!c
|
||||
}
|
||||
|
||||
/// A viewable PNG of interleaved camera-RGB panels side by side: white balanced, one exposure for all, gamma.
|
||||
fn write_png(path: &Path, panels: &[&[f32]], w: usize, h: usize, wb: [f32; 3], exposure: f32) {
|
||||
let total_w = w * panels.len();
|
||||
let mut raw = Vec::with_capacity((total_w * 3 + 1) * h);
|
||||
for y in 0..h {
|
||||
raw.push(0u8);
|
||||
for p in panels {
|
||||
for x in 0..w {
|
||||
for c in 0..3 {
|
||||
let v = (p[(y * w + x) * 3 + c] * wb[c] * exposure).clamp(0.0, 1.0);
|
||||
raw.push((v.powf(1.0 / 2.2) * 255.0 + 0.5) as u8);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let z = miniz_oxide::deflate::compress_to_vec_zlib(&raw, 6);
|
||||
let mut png = b"\x89PNG\r\n\x1a\n".to_vec();
|
||||
let mut chunk = |kind: &[u8; 4], body: &[u8]| {
|
||||
png.extend_from_slice(&(body.len() as u32).to_be_bytes());
|
||||
let mut k = kind.to_vec();
|
||||
k.extend_from_slice(body);
|
||||
png.extend_from_slice(&k);
|
||||
png.extend_from_slice(&crc32(&k).to_be_bytes());
|
||||
};
|
||||
let mut ihdr = Vec::new();
|
||||
ihdr.extend_from_slice(&(total_w as u32).to_be_bytes());
|
||||
ihdr.extend_from_slice(&(h as u32).to_be_bytes());
|
||||
ihdr.extend_from_slice(&[8, 2, 0, 0, 0]);
|
||||
chunk(b"IHDR", &ihdr);
|
||||
chunk(b"IDAT", &z);
|
||||
chunk(b"IEND", &[]);
|
||||
std::fs::write(path, png).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs a real model and mosaics: see the module docs"]
|
||||
fn the_real_model_denoises_real_mosaics() {
|
||||
let (Some(model), Some(dir)) = (std::env::var_os("LC_DENOISE_MODEL"), std::env::var_os("LC_DENOISE_MOSAICS")) else { return };
|
||||
let dir = PathBuf::from(dir);
|
||||
let out_dir = std::env::var_os("LC_DENOISE_OUT").map(PathBuf::from);
|
||||
let parallel: usize = std::env::var("LC_DENOISE_PARALLEL").ok().and_then(|v| v.parse().ok()).unwrap_or(8);
|
||||
let iso: f64 = std::env::var("LC_DENOISE_ISO").ok().and_then(|v| v.parse().ok()).unwrap_or(1.0);
|
||||
let names: Vec<String> = match std::env::var("LC_DENOISE_ONLY") {
|
||||
Ok(n) => vec![n],
|
||||
Err(_) => ["arw-sony-a7m3-compressed", "cr2-canon-6d", "nef-nikon-d5100-lossless", "dng-google-pixel2xl", "pef-pentax-k3"]
|
||||
.map(String::from)
|
||||
.to_vec(),
|
||||
};
|
||||
let manifest = DenoiserManifest {
|
||||
id: "rawnind-bayer".into(),
|
||||
name: "RawNIND Bayer".into(),
|
||||
version: "1".into(),
|
||||
licence: Default::default(),
|
||||
source: None,
|
||||
sha256: None,
|
||||
size_bytes: None,
|
||||
provenance: String::new(),
|
||||
domain: Domain::BayerToRgb,
|
||||
tile: 512,
|
||||
overlap: 64,
|
||||
gain: Gain::MatchMean { nominal: 1.0e6, max_deviation: 0.05 },
|
||||
};
|
||||
let runner = CpuRunner::load(Path::new(&model), &manifest).unwrap();
|
||||
let params = Params { tile: 512, overlap: 64, gain: manifest.gain, clip: Some(1.0), parallel };
|
||||
for name in names {
|
||||
let d = load(&dir, &name);
|
||||
// a crop of about 2 × 1.5 tiles, away from the edges, even origin
|
||||
let full = std::env::var_os("LC_DENOISE_FULL").is_some();
|
||||
let (w, h) = if full { (d.width & !1, d.height & !1) } else { (2048usize, 1536usize) };
|
||||
let (x0, y0) = (((d.width - w) / 3) & !1, ((d.height - h) / 2) & !1);
|
||||
let (clean, reference) = crop(&d, x0, y0, w, h);
|
||||
// Poisson-Gaussian noise: variance a·signal + b, as a sensor at high ISO
|
||||
let (a, b) = (1.5e-3 * iso, (4.0e-3 * iso.sqrt()).powi(2));
|
||||
let mut rng = Rng(0x9e37_79b9_7f4a_7c15 ^ name.len() as u64);
|
||||
let noisy: Vec<f32> = clean
|
||||
.iter()
|
||||
.map(|&v| {
|
||||
let v = f64::from(v);
|
||||
let sigma = (a * v.max(0.0) + b).sqrt();
|
||||
((v + sigma * rng.gauss()) as f32).min(1.0)
|
||||
})
|
||||
.collect();
|
||||
let started = Instant::now();
|
||||
let done = std::sync::atomic::AtomicUsize::new(0);
|
||||
let progress = |n: usize, total: usize| done.store(n * 1000 / total, std::sync::atomic::Ordering::Relaxed);
|
||||
let denoised = denoise_bayer(&noisy, w, h, d.layout, &runner, ¶ms, &Control { cancel: None, progress: Some(&progress) }).unwrap();
|
||||
let secs = started.elapsed().as_secs_f64();
|
||||
let baseline = bilinear(&noisy, w, h, d.layout);
|
||||
let clean_bilinear = bilinear(&clean, w, h, d.layout);
|
||||
let den: Vec<f32> = denoised.data.iter().flat_map(|p| p.iter().copied()).collect();
|
||||
let (s_noisy, s_den) = (snr(&baseline, &reference, w, h, 64), snr(&den, &reference, w, h, 64));
|
||||
let s_floor = snr(&clean_bilinear, &reference, w, h, 64);
|
||||
println!(
|
||||
"{name}: {w}×{h} crop, {} tiles, parallel {parallel}, {secs:.1} s | SNR vs clean demosaic: noisy bilinear {s_noisy:.2} dB, denoised {s_den:.2} dB (clean bilinear {s_floor:.2} dB)",
|
||||
lightcraft_denoise::run::tile_count(w, h, d.layout, 512, 64)
|
||||
);
|
||||
if let Some(out) = &out_dir {
|
||||
std::fs::create_dir_all(out).unwrap();
|
||||
let mean = reference.iter().map(|&v| f64::from(v)).sum::<f64>() / reference.len() as f64;
|
||||
let exposure = (0.18 / mean.max(1e-6)) as f32;
|
||||
// three panels, a 512 × 384 window each, at 1:1
|
||||
let (pw, ph, px, py) = (512usize, 384usize, 700usize, 500usize);
|
||||
let window =
|
||||
|buf: &[f32]| -> Vec<f32> { (0..ph).flat_map(|y| buf[((py + y) * w + px) * 3..((py + y) * w + px + pw) * 3].to_vec()).collect() };
|
||||
write_png(&out.join(format!("{name}.png")), &[&window(&baseline), &window(&den), &window(&reference)], pw, ph, d.wb, exposure);
|
||||
}
|
||||
if iso >= 4.0 {
|
||||
assert!(s_den > s_noisy + 2.0, "{name}: denoised {s_den:.2} dB is not clearly better than noisy {s_noisy:.2} dB");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -207,6 +214,7 @@ controls! {
|
|||
"grain.amount" => grain.amount, "Grain", Grain, 0, 100, 0, 1, 0, Plain;
|
||||
"grain.size" => grain.size, "Size", Grain, 0, 100, 25, 1, 0, Plain;
|
||||
"grain.roughness" => grain.roughness, "Roughness", Grain, 0, 100, 50, 1, 0, Plain;
|
||||
"enhance.denoise" => enhance.denoise, "Amount", Detail, 0, 100, 0, 1, 0, Plain;
|
||||
"detail.sharpenAmount" => detail.sharpen_amount, "Sharpening", Detail, 0, 150, 0, 1, 0, Plain;
|
||||
"detail.sharpenRadius" => detail.sharpen_radius, "Radius", Detail, 0.5, 3, 1, 0.1, 1, Plain;
|
||||
"detail.sharpenDetail" => detail.sharpen_detail, "Detail", Detail, 0, 100, 25, 1, 0, Plain;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
@ -94,6 +96,13 @@ impl DevelopSettings {
|
|||
std::borrow::Cow::Owned(d)
|
||||
}
|
||||
|
||||
/// How much of the AI-denoised picture to mix in, 0 (none) to 1 (all of it): the Denoise amount, unless the Detail
|
||||
/// section is switched off or the value is not a number.
|
||||
pub fn denoise_amount(&self) -> f32 {
|
||||
let a = self.enhance.denoise;
|
||||
if a.is_finite() && self.enhance.denoise_enabled() && self.section_enabled("detail") { (a / 100.0).clamp(0.0, 1.0) as f32 } else { 0.0 }
|
||||
}
|
||||
|
||||
pub fn set_section_enabled(&mut self, section: &str, on: bool) {
|
||||
self.disabled_sections.retain(|s| s != section);
|
||||
if !on {
|
||||
|
|
@ -112,6 +121,7 @@ impl DevelopSettings {
|
|||
self.curve = d;
|
||||
}
|
||||
Section::Color => self.wb.mode = WbMode::AsShot,
|
||||
Section::Detail => self.enhance.denoise_on = None,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
@ -122,6 +132,9 @@ impl DevelopSettings {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_process;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -154,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();
|
||||
|
|
@ -193,4 +227,40 @@ mod tests {
|
|||
s.set_section_enabled("effects", true);
|
||||
assert!(s.section_enabled("effects"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_denoise_amount_is_a_slider_and_follows_the_detail_section() {
|
||||
let mut s = DevelopSettings::default();
|
||||
assert_eq!(s.denoise_amount(), 0.0);
|
||||
assert!(controls::set(&mut s, "enhance.denoise", 60.0));
|
||||
assert_eq!(controls::get(&s, "enhance.denoise"), Some(60.0));
|
||||
assert!((s.denoise_amount() - 0.6).abs() < 1e-6);
|
||||
// the slider's range is 0 to 100; values outside it are brought in
|
||||
controls::set(&mut s, "enhance.denoise", 500.0);
|
||||
assert_eq!(s.denoise_amount(), 1.0);
|
||||
controls::set(&mut s, "enhance.denoise", -5.0);
|
||||
assert_eq!(s.denoise_amount(), 0.0);
|
||||
// switching the Detail section off switches the denoise off with it; a settings file with a bad number does nothing
|
||||
controls::set(&mut s, "enhance.denoise", 80.0);
|
||||
s.set_section_enabled("detail", false);
|
||||
assert_eq!(s.denoise_amount(), 0.0);
|
||||
s.set_section_enabled("detail", true);
|
||||
s.enhance.denoise = f64::NAN;
|
||||
assert_eq!(s.denoise_amount(), 0.0);
|
||||
// it is part of Detail: a reset of the section clears it
|
||||
controls::set(&mut s, "enhance.denoise", 40.0);
|
||||
s.reset_section(Section::Detail);
|
||||
assert_eq!(s.enhance.denoise, 0.0);
|
||||
// The later independent switch preserves Amount when off, and can be on at zero.
|
||||
controls::set(&mut s, "enhance.denoise", 75.0);
|
||||
s.enhance.denoise_on = Some(false);
|
||||
assert_eq!(s.denoise_amount(), 0.0);
|
||||
assert_eq!(s.enhance.denoise, 75.0);
|
||||
s.enhance.denoise_on = Some(true);
|
||||
assert_eq!(s.denoise_amount(), 0.75);
|
||||
controls::set(&mut s, "enhance.denoise", 0.0);
|
||||
assert!(s.enhance.denoise_enabled());
|
||||
s.reset_section(Section::Detail);
|
||||
assert_eq!(s.enhance.denoise_on, None);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
@ -88,11 +189,16 @@ pub struct Profile {
|
|||
pub id: String,
|
||||
/// Creative profile amount 0..200 (%).
|
||||
pub amount: f64,
|
||||
/// Render the raw's own local tone mapping (DNG `ProfileGainTableMap`, Apple ProRAW), as the
|
||||
/// camera does. Off: rendered as Lightroom Classic does, without it. Omitted when off, so
|
||||
/// settings without it keep their JSON (and hash).
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub camera_local_tone: bool,
|
||||
}
|
||||
|
||||
impl Default for Profile {
|
||||
fn default() -> Self {
|
||||
Self { id: "lc.color".into(), amount: 100.0 }
|
||||
Self { id: "lc.color".into(), amount: 100.0, camera_local_tone: false }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -915,6 +1021,89 @@ pub struct LensBlur {
|
|||
#[serde(default)]
|
||||
pub struct Enhance {
|
||||
pub denoise: f64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub denoise_on: Option<bool>,
|
||||
pub raw_details: bool,
|
||||
pub super_resolution: bool,
|
||||
}
|
||||
|
||||
impl Enhance {
|
||||
pub fn denoise_enabled(&self) -> bool {
|
||||
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;
|
||||
|
|
@ -21,7 +21,10 @@ web-time = { workspace = true }
|
|||
lightcraft-preview = { workspace = true }
|
||||
lightcraft-codecs = { workspace = true }
|
||||
lightcraft-raw = { workspace = true }
|
||||
lightcraft-denoise-core = { workspace = true }
|
||||
lightcraft-denoise = { workspace = true, optional = true }
|
||||
lightcraft-meta = { workspace = true }
|
||||
lightcraft-faces = { workspace = true }
|
||||
miniz_oxide = { workspace = true }
|
||||
lightcraft-merge = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
|
|
@ -35,10 +38,18 @@ harfrust = { version = "0.12", default-features = false, features = ["std"] }
|
|||
unicode-segmentation = "1.13"
|
||||
lightcraft-segment = { workspace = true, optional = true }
|
||||
|
||||
# model downloads run natively only (the web build fetches no models)
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
lightcraft-fetch = { workspace = true }
|
||||
|
||||
[features]
|
||||
# AI masks with SAM 3 (Object / Describe). Off for the web and CLI builds.
|
||||
sam = ["dep:lightcraft-segment"]
|
||||
denoise = ["dep:lightcraft-denoise", "lightcraft-denoise/runtime", "lightcraft-gpu/denoise"]
|
||||
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 }
|
||||
|
|
|
|||
|
|
@ -13,19 +13,22 @@ fn main() {
|
|||
println!("cargo::rerun-if-changed=build.rs");
|
||||
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR");
|
||||
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED");
|
||||
let required = std::env::var_os("CRAFT_FONTS_REQUIRED").is_some();
|
||||
let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n");
|
||||
let mut embedded = 0;
|
||||
if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from).map(resolve) {
|
||||
match craft_fonts(&dir) {
|
||||
match craft_fonts(&dir, required) {
|
||||
Ok(entries) => {
|
||||
embedded = entries.lines().count();
|
||||
src.push_str(&entries);
|
||||
}
|
||||
Err(e) if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() => {
|
||||
Err(e) if required => {
|
||||
println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display());
|
||||
}
|
||||
Err(e) => println!("cargo::warning=building without craft-fonts: CRAFT_FONTS_DIR={}: {e}", dir.display()),
|
||||
}
|
||||
} else if required {
|
||||
println!("cargo::error=CRAFT_FONTS_REQUIRED is set but CRAFT_FONTS_DIR is missing");
|
||||
}
|
||||
src.push_str("];\n");
|
||||
// Without them the app still runs and every language still selectable, so Chinese and Japanese
|
||||
|
|
@ -58,17 +61,21 @@ fn resolve(dir: PathBuf) -> PathBuf {
|
|||
}
|
||||
|
||||
/// One `CraftFont { .. }` initialiser per manifest line.
|
||||
fn craft_fonts(dir: &Path) -> Result<String, String> {
|
||||
fn craft_fonts(dir: &Path, required: bool) -> Result<String, String> {
|
||||
let manifest = dir.join("fonts/manifest.txt");
|
||||
println!("cargo::rerun-if-changed={}", manifest.display());
|
||||
let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?;
|
||||
let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32");
|
||||
let mut out = String::new();
|
||||
let mut has_japanese_ui = false;
|
||||
let mut has_chinese_ui = false;
|
||||
for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) {
|
||||
let f: Vec<&str> = line.split(" | ").map(str::trim).collect();
|
||||
let [family, style, file, scripts, ..] = f.as_slice() else {
|
||||
return Err(format!("malformed manifest line: {line}"));
|
||||
};
|
||||
has_japanese_ui |= (*family, *style) == ("BIZ UDPGothic", "Regular");
|
||||
has_chinese_ui |= (*family, *style) == ("Noto Sans CJK SC", "Regular") && scripts.split(',').any(|script| script.trim() == "Hans");
|
||||
if wasm && (*family, *style) != ("BIZ UDPGothic", "Regular") {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -82,5 +89,11 @@ fn craft_fonts(dir: &Path) -> Result<String, String> {
|
|||
path.display().to_string(),
|
||||
);
|
||||
}
|
||||
if required && !has_japanese_ui {
|
||||
return Err("required Japanese UI face BIZ UDPGothic Regular is missing from fonts/manifest.txt".into());
|
||||
}
|
||||
if required && !wasm && !has_chinese_ui {
|
||||
return Err("required Simplified Chinese UI face Noto Sans CJK SC Regular (Hans) is missing from fonts/manifest.txt".into());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,391 @@
|
|||
//! Background tasks in flight (issue #345): imports, exports, preview builds, downloads, the face scan… Each one
|
||||
//! registers here while it runs, so every frontend can show them in one place (the desktop app's activity stack) and
|
||||
//! cancel the ones that can be cancelled (`activity.list`, `activity.cancel`).
|
||||
//!
|
||||
//! A job holds a [`TaskGuard`] for as long as it runs; dropping it — when the job ends, fails or its thread unwinds
|
||||
//! from a panic — removes the row, so a dead worker never leaves a stuck bar. Jobs keep the cancel flag they already
|
||||
//! check between files or photos: the guard adopts it ([`Cancel::Flag`]), so the stack's ✕, `activity.cancel` and the
|
||||
//! job's own Cancel all set the same atomic. Numbers are worded by the frontend from `done`, `total` and [`Unit`]; a
|
||||
//! detail set here is a name (a file, a model), never a sentence, so the frontend can translate everything else.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// The longest label or detail kept, in characters (a file name can be anything).
|
||||
const MAX_TEXT: usize = 200;
|
||||
|
||||
/// The background tasks in flight. Cheap to clone (one `Arc`), so worker threads can hold it.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Activity {
|
||||
inner: Arc<Inner>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Inner {
|
||||
/// Oldest first.
|
||||
tasks: Mutex<Vec<Arc<Entry>>>,
|
||||
next: AtomicU64,
|
||||
}
|
||||
|
||||
/// How a task can be cancelled.
|
||||
pub enum Cancel {
|
||||
/// Not at all (no ✕): the face scan, AI Denoise.
|
||||
No,
|
||||
/// With a flag of its own ([`TaskGuard::cancel_flag`]).
|
||||
Yes,
|
||||
/// With the job's existing flag.
|
||||
Flag(Arc<AtomicBool>),
|
||||
}
|
||||
|
||||
/// How the frontend words `done` / `total`: "3 of 25", "12 of 340 MB", "40 %".
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Unit {
|
||||
#[default]
|
||||
Count,
|
||||
Bytes,
|
||||
Percent,
|
||||
}
|
||||
|
||||
impl Unit {
|
||||
fn from_u8(v: u8) -> Unit {
|
||||
match v {
|
||||
1 => Unit::Bytes,
|
||||
2 => Unit::Percent,
|
||||
_ => Unit::Count,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_u8(self) -> u8 {
|
||||
match self {
|
||||
Unit::Count => 0,
|
||||
Unit::Bytes => 1,
|
||||
Unit::Percent => 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One task as the frontends see it (`activity.list`).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TaskInfo {
|
||||
pub id: u64,
|
||||
/// A stable id: `import`, `export`, `scan`, `previews`, `smartPreviews`, `lightroom`, `merge`, `download`,
|
||||
/// `faces`, `denoise`, `findMissing`.
|
||||
pub kind: &'static str,
|
||||
/// English; the frontend translates it.
|
||||
pub label: String,
|
||||
pub done: u64,
|
||||
/// 0 while the amount of work isn't known yet (an indeterminate bar).
|
||||
pub total: u64,
|
||||
pub unit: Unit,
|
||||
/// The file, photo or model being worked on, if any.
|
||||
pub detail: String,
|
||||
pub cancellable: bool,
|
||||
/// Cancel was asked for; the job stops at its next check.
|
||||
pub cancelling: bool,
|
||||
pub age_ms: u64,
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
id: u64,
|
||||
kind: &'static str,
|
||||
label: String,
|
||||
started: web_time::Instant,
|
||||
done: AtomicU64,
|
||||
total: AtomicU64,
|
||||
unit: AtomicU8,
|
||||
detail: Mutex<String>,
|
||||
cancel: Option<Arc<AtomicBool>>,
|
||||
cancellable: AtomicBool,
|
||||
}
|
||||
|
||||
fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
|
||||
m.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn capped(text: &str) -> String {
|
||||
text.chars().take(MAX_TEXT).collect()
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
fn is_cancellable(&self) -> bool {
|
||||
self.cancel.is_some() && self.cancellable.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
fn is_cancelled(&self) -> bool {
|
||||
self.cancel.as_ref().is_some_and(|c| c.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
fn progress(&self, done: u64, total: u64) {
|
||||
self.total.store(total, Ordering::Relaxed);
|
||||
self.done.store(if total > 0 { done.min(total) } else { done }, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn detail(&self, text: &str) {
|
||||
*lock(&self.detail) = capped(text);
|
||||
}
|
||||
|
||||
fn info(&self) -> TaskInfo {
|
||||
TaskInfo {
|
||||
id: self.id,
|
||||
kind: self.kind,
|
||||
label: self.label.clone(),
|
||||
done: self.done.load(Ordering::Relaxed),
|
||||
total: self.total.load(Ordering::Relaxed),
|
||||
unit: Unit::from_u8(self.unit.load(Ordering::Relaxed)),
|
||||
detail: lock(&self.detail).clone(),
|
||||
cancellable: self.is_cancellable(),
|
||||
cancelling: self.is_cancelled(),
|
||||
age_ms: u64::try_from(self.started.elapsed().as_millis()).unwrap_or(u64::MAX),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Activity {
|
||||
/// Register a task; it is listed until the returned guard is dropped. `label` is English (the frontend
|
||||
/// translates it).
|
||||
pub fn start(&self, kind: &'static str, label: &str, cancel: Cancel) -> TaskGuard {
|
||||
let id = self.inner.next.fetch_add(1, Ordering::Relaxed).saturating_add(1);
|
||||
let cancel = match cancel {
|
||||
Cancel::No => None,
|
||||
Cancel::Yes => Some(Arc::new(AtomicBool::new(false))),
|
||||
Cancel::Flag(flag) => Some(flag),
|
||||
};
|
||||
let entry = Arc::new(Entry {
|
||||
id,
|
||||
kind,
|
||||
label: capped(label),
|
||||
started: web_time::Instant::now(),
|
||||
done: AtomicU64::new(0),
|
||||
total: AtomicU64::new(0),
|
||||
unit: AtomicU8::new(Unit::Count.to_u8()),
|
||||
detail: Mutex::new(String::new()),
|
||||
cancellable: AtomicBool::new(cancel.is_some()),
|
||||
cancel,
|
||||
});
|
||||
lock(&self.inner.tasks).push(entry.clone());
|
||||
TaskGuard { activity: self.clone(), entry }
|
||||
}
|
||||
|
||||
/// Every task in flight, oldest first.
|
||||
pub fn list(&self) -> Vec<TaskInfo> {
|
||||
lock(&self.inner.tasks).iter().map(|e| e.info()).collect()
|
||||
}
|
||||
|
||||
/// Ask task `id` to stop (it stops at its next check). An unknown id (the task may have just ended) or a task
|
||||
/// that can't be cancelled is an error.
|
||||
pub fn cancel(&self, id: u64) -> Result<(), String> {
|
||||
let tasks = lock(&self.inner.tasks);
|
||||
let entry = tasks.iter().find(|e| e.id == id).ok_or_else(|| format!("no task {id}"))?;
|
||||
match (&entry.cancel, entry.is_cancellable()) {
|
||||
(Some(flag), true) => {
|
||||
flag.store(true, Ordering::Relaxed);
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(format!("{} can't be cancelled", entry.label)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask every cancellable task to stop; returns how many were asked.
|
||||
pub fn cancel_all(&self) -> usize {
|
||||
let tasks = lock(&self.inner.tasks);
|
||||
let mut n = 0;
|
||||
for e in tasks.iter().filter(|e| e.is_cancellable()) {
|
||||
if let Some(flag) = &e.cancel {
|
||||
flag.store(true, Ordering::Relaxed);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// Tasks that quitting would cut short: cancellable ones not already stopping.
|
||||
pub fn running_cancellable(&self) -> Vec<TaskInfo> {
|
||||
lock(&self.inner.tasks).iter().filter(|e| e.is_cancellable() && !e.is_cancelled()).map(|e| e.info()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A task's place in the list, held by whoever owns the job; dropping it removes the row.
|
||||
pub struct TaskGuard {
|
||||
activity: Activity,
|
||||
entry: Arc<Entry>,
|
||||
}
|
||||
|
||||
impl TaskGuard {
|
||||
pub fn id(&self) -> u64 {
|
||||
self.entry.id
|
||||
}
|
||||
|
||||
/// `done` of `total` (`total` 0 = not known yet). `done` is kept at most `total`.
|
||||
pub fn progress(&self, done: u64, total: u64) {
|
||||
self.entry.progress(done, total);
|
||||
}
|
||||
|
||||
/// The file, photo or model being worked on (a name, not a sentence).
|
||||
pub fn detail(&self, text: &str) {
|
||||
self.entry.detail(text);
|
||||
}
|
||||
|
||||
pub fn set_unit(&self, unit: Unit) {
|
||||
self.entry.unit.store(unit.to_u8(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// A step that must not be cut short (the Lightroom import's commit) turns cancelling off for its duration.
|
||||
pub fn set_cancellable(&self, yes: bool) {
|
||||
self.entry.cancellable.store(yes, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn cancel_flag(&self) -> Option<Arc<AtomicBool>> {
|
||||
self.entry.cancel.clone()
|
||||
}
|
||||
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.entry.is_cancelled()
|
||||
}
|
||||
|
||||
/// For progress from another thread; it does not keep the row.
|
||||
pub fn handle(&self) -> TaskHandle {
|
||||
TaskHandle { entry: self.entry.clone() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TaskGuard {
|
||||
fn drop(&mut self) {
|
||||
let id = self.entry.id;
|
||||
lock(&self.activity.inner.tasks).retain(|e| e.id != id);
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for TaskGuard {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("TaskGuard").field("id", &self.entry.id).field("kind", &self.entry.kind).finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Progress for a task from another thread (see [`TaskGuard::handle`]).
|
||||
#[derive(Clone)]
|
||||
pub struct TaskHandle {
|
||||
entry: Arc<Entry>,
|
||||
}
|
||||
|
||||
impl TaskHandle {
|
||||
pub fn progress(&self, done: u64, total: u64) {
|
||||
self.entry.progress(done, total);
|
||||
}
|
||||
|
||||
pub fn detail(&self, text: &str) {
|
||||
self.entry.detail(text);
|
||||
}
|
||||
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.entry.is_cancelled()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
#[test]
|
||||
fn guard_drop_removes_the_row() {
|
||||
let a = Activity::default();
|
||||
let g = a.start("export", "Exporting", Cancel::Yes);
|
||||
assert_eq!(a.list().len(), 1);
|
||||
drop(g);
|
||||
assert!(a.list().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guard_drop_on_panic_unwind_removes_the_row() {
|
||||
let a = Activity::default();
|
||||
let a2 = a.clone();
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
|
||||
let _g = a2.start("import", "Importing", Cancel::Yes);
|
||||
panic!("synthetic worker panic");
|
||||
}));
|
||||
assert!(r.is_err());
|
||||
assert!(a.list().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_sets_the_adopted_flag() {
|
||||
let a = Activity::default();
|
||||
let flag = Arc::new(AtomicBool::new(false));
|
||||
let g = a.start("export", "Exporting", Cancel::Flag(flag.clone()));
|
||||
a.cancel(g.id()).unwrap();
|
||||
assert!(flag.load(Ordering::Relaxed) && g.is_cancelled());
|
||||
assert!(a.list()[0].cancelling);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_errors_instead_of_panicking() {
|
||||
let a = Activity::default();
|
||||
assert!(a.cancel(42).is_err());
|
||||
let g = a.start("faces", "Finding faces", Cancel::No);
|
||||
assert!(a.cancel(g.id()).is_err());
|
||||
let h = a.start("lightroom", "Importing Lightroom catalog", Cancel::Yes);
|
||||
h.set_cancellable(false);
|
||||
assert!(a.cancel(h.id()).is_err());
|
||||
assert!(!h.is_cancelled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_all_counts_only_cancellable() {
|
||||
let a = Activity::default();
|
||||
let (_x, _y, _z) = (a.start("export", "E", Cancel::Yes), a.start("import", "I", Cancel::Yes), a.start("faces", "F", Cancel::No));
|
||||
assert_eq!(a.cancel_all(), 2);
|
||||
assert!(a.running_cancellable().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_is_clamped_and_detail_capped() {
|
||||
let a = Activity::default();
|
||||
let g = a.start("export", "E", Cancel::Yes);
|
||||
g.progress(12, 10);
|
||||
g.detail(&"é".repeat(300));
|
||||
let t = &a.list()[0];
|
||||
assert_eq!((t.done, t.total), (10, 10));
|
||||
assert_eq!(t.detail.chars().count(), 200);
|
||||
g.progress(5, 0); // indeterminate keeps done as given
|
||||
assert_eq!(a.list()[0].total, 0);
|
||||
assert_eq!(a.list()[0].unit, Unit::Count);
|
||||
g.set_unit(Unit::Bytes);
|
||||
assert_eq!(a.list()[0].unit, Unit::Bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_are_unique_and_list_is_oldest_first() {
|
||||
let a = Activity::default();
|
||||
let (g1, g2) = (a.start("export", "A", Cancel::Yes), a.start("import", "B", Cancel::Yes));
|
||||
assert!(g2.id() > g1.id());
|
||||
assert_eq!(a.list().iter().map(|t| t.id).collect::<Vec<_>>(), vec![g1.id(), g2.id()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poisoned_lock_still_works() {
|
||||
let a = Activity::default();
|
||||
let a2 = a.clone();
|
||||
let _ = std::thread::spawn(move || {
|
||||
let _held = a2.inner.tasks.lock().unwrap();
|
||||
panic!("synthetic panic while holding the task list");
|
||||
})
|
||||
.join();
|
||||
assert!(a.inner.tasks.is_poisoned());
|
||||
let _g = a.start("export", "E", Cancel::Yes);
|
||||
assert_eq!(a.list().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_updates_from_another_thread() {
|
||||
let a = Activity::default();
|
||||
let g = a.start("previews", "Building previews", Cancel::Yes);
|
||||
let h = g.handle();
|
||||
std::thread::spawn(move || h.progress(3, 7)).join().unwrap();
|
||||
assert_eq!((a.list()[0].done, a.list()[0].total), (3, 7));
|
||||
}
|
||||
}
|
||||
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")),
|
||||
|
|
@ -65,19 +66,8 @@ impl CameraProfile {
|
|||
}
|
||||
}
|
||||
|
||||
/// LightCraft's configuration folder (settings, GPU marker, camera profiles).
|
||||
pub fn config_dir() -> Option<PathBuf> {
|
||||
if cfg!(target_os = "macos") {
|
||||
std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Application Support/LightCraft"))
|
||||
} else if cfg!(windows) {
|
||||
std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("LightCraft"))
|
||||
} else {
|
||||
std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
|
||||
.map(|c| c.join("lightcraft"))
|
||||
}
|
||||
}
|
||||
/// LightCraft's configuration folder (settings, GPU marker, camera profiles); one helper for the app.
|
||||
pub use crate::config::config_dir;
|
||||
|
||||
/// Where camera profiles are read from and written to.
|
||||
pub fn dir() -> Option<PathBuf> {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
//! `activity.list` / `activity.cancel`: the background tasks in flight (see [`crate::activity`]).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, cmd};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
query "activity.list",
|
||||
"Background Tasks",
|
||||
[],
|
||||
None,
|
||||
"{} → {tasks: [{id, kind, label, done, total, unit: count|bytes|percent, detail, cancellable, cancelling, ageMs}]} — the long-running tasks in flight (imports, exports, preview builds, downloads, the face scan…), oldest first; total 0 = not known yet",
|
||||
always,
|
||||
|s, _| Ok(json!({"tasks": s.activity.list()}))
|
||||
),
|
||||
cmd!(
|
||||
"activity.cancel",
|
||||
"Cancel Background Task",
|
||||
[],
|
||||
None,
|
||||
"{id} | {all: true} → {cancelled: n} — ask a task (or every cancellable task) to stop; it stops at its next file or photo. An unknown id (it may have just finished) or a task that can't be cancelled is an error",
|
||||
always,
|
||||
|s, p| {
|
||||
const C: &str = "activity.cancel";
|
||||
if p.get("all").and_then(Value::as_bool) == Some(true) {
|
||||
return Ok(json!({"cancelled": s.activity.cancel_all()}));
|
||||
}
|
||||
let id = p.get("id").and_then(Value::as_u64).ok_or_else(|| bad(C, "id or all"))?;
|
||||
s.activity.cancel(id).map_err(|e| bad(C, e))?;
|
||||
Ok(json!({"cancelled": 1}))
|
||||
}
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::activity::Cancel;
|
||||
|
||||
#[test]
|
||||
fn list_and_cancel_through_execute() {
|
||||
let mut s = Session::new();
|
||||
let g = s.activity.start("export", "Exporting", Cancel::Yes);
|
||||
let r = s.execute("activity.list", &json!({})).unwrap();
|
||||
assert_eq!(r["tasks"][0]["kind"], "export");
|
||||
assert_eq!(r["tasks"][0]["cancellable"], true);
|
||||
let r = s.execute("activity.cancel", &json!({"id": g.id()})).unwrap();
|
||||
assert_eq!(r["cancelled"], 1);
|
||||
assert!(g.is_cancelled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_all_and_errors() {
|
||||
let mut s = Session::new();
|
||||
let (_a, _b) = (s.activity.start("export", "E", Cancel::Yes), s.activity.start("faces", "F", Cancel::No));
|
||||
assert_eq!(s.execute("activity.cancel", &json!({"all": true})).unwrap()["cancelled"], 1);
|
||||
assert!(s.execute("activity.cancel", &json!({"id": 999})).is_err());
|
||||
assert!(s.execute("activity.cancel", &json!({})).is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,660 @@
|
|||
//! Denoise commands (`denoise.*`): the models (list, install, download, test, remove, choose), the settings, and the
|
||||
//! work (queue, cancel, status, pump, clear). The machinery they drive is [`crate::denoise`].
|
||||
//!
|
||||
//! Models live in the folder the host provides (`Session::denoise.models_dir`): one subfolder per model with
|
||||
//! `model.onnx`, `denoise-model.json` (its manifest) and `installed.json` (what the user accepted and the file's hash),
|
||||
//! plus `settings.json` beside them. Model files, archives and manifests are hostile input: sizes are capped, ids are
|
||||
//! validated before they become folder names, and nothing outside a model's own folder is ever touched.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, PoisonError, mpsc};
|
||||
|
||||
use lightcraft_catalog::PhotoId;
|
||||
use lightcraft_denoise::archive;
|
||||
use lightcraft_denoise::hash::sha256_file;
|
||||
use lightcraft_denoise::known::{self, Known};
|
||||
use lightcraft_denoise::manifest::{self, DenoiserManifest, MAX_MANIFEST_BYTES, MAX_MODEL_BYTES};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, bool_or, cmd, str_param};
|
||||
use crate::denoise::{Pace, RunOn, Settings, forget_gpu_set_ups, installed_models, read_capped, read_settings, write_atomic, write_settings};
|
||||
use crate::model_download::State;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
fn fail(what: &str, e: impl std::fmt::Display) -> EngineError {
|
||||
EngineError::Other(format!("{what}: {e}"))
|
||||
}
|
||||
|
||||
fn models_dir(s: &Session, cmd: &str) -> Result<PathBuf> {
|
||||
s.denoise.models_dir.clone().ok_or_else(|| bad(cmd, "this build has nowhere to keep denoise models (the desktop app does)"))
|
||||
}
|
||||
|
||||
/// What a command says about a model.
|
||||
fn row(m: &DenoiserManifest, download_host: Option<String>, installed: bool, selected: bool, accepted: &Value) -> Value {
|
||||
json!({
|
||||
"id": m.id,
|
||||
"name": m.name,
|
||||
"version": m.version,
|
||||
"licence": m.licence,
|
||||
"provenance": m.provenance,
|
||||
"source": m.source,
|
||||
"sizeBytes": m.size_bytes,
|
||||
"sha256": m.sha256,
|
||||
"tile": m.tile,
|
||||
"known": known::find(&m.id).is_some(),
|
||||
// a pinned address LightCraft can fetch it from (the user presses Download), and which site that is
|
||||
"downloadHost": download_host,
|
||||
"installed": installed,
|
||||
"selected": selected,
|
||||
"accepted": accepted,
|
||||
})
|
||||
}
|
||||
|
||||
fn host_of(k: &Known) -> Option<String> {
|
||||
k.download.as_ref().map(|d| lightcraft_denoise::known::host(&d.url).to_string())
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.denoise_refresh_active(true);
|
||||
let dir = s.denoise.models_dir.clone();
|
||||
let st = dir.as_deref().map(read_settings).unwrap_or_default();
|
||||
let installed = dir.as_deref().map(installed_models).unwrap_or_default();
|
||||
let on_disk = |id: &str| installed.iter().find(|i| i.manifest.id == id);
|
||||
let chosen = |id: &str| st.model.as_deref() == Some(id);
|
||||
let mut models: Vec<Value> = Vec::new();
|
||||
for k in known::all() {
|
||||
let found = on_disk(&k.manifest.id);
|
||||
models.push(row(&k.manifest, host_of(&k), found.is_some(), chosen(&k.manifest.id), found.map_or(&Value::Null, |f| &f.accepted)));
|
||||
}
|
||||
for i in installed.iter().filter(|i| known::find(&i.manifest.id).is_none()) {
|
||||
models.push(row(&i.manifest, None, true, chosen(&i.manifest.id), &i.accepted));
|
||||
}
|
||||
Ok(json!({
|
||||
"dir": dir.as_ref().map(|d| d.display().to_string()),
|
||||
"productsDir": s.denoise_products_dir().map(|d| d.display().to_string()),
|
||||
"model": st.model,
|
||||
// whether this build can run denoise models (the `denoise` feature: our pure-Rust CPU/GPU runners)
|
||||
"runtime": cfg!(feature = "denoise"),
|
||||
"auto": st.auto(),
|
||||
"cacheGb": st.cache_bytes() >> 30,
|
||||
"threads": st.threads,
|
||||
"runOn": st.run_on().name(),
|
||||
"models": models,
|
||||
}))
|
||||
}
|
||||
|
||||
/// What a file the user pointed at is, as far as installing it goes.
|
||||
struct Source {
|
||||
manifest: DenoiserManifest,
|
||||
/// The `.onnx` inside the archive; `None` when the file is the `.onnx`.
|
||||
entry: Option<&'static str>,
|
||||
file_name: String,
|
||||
}
|
||||
|
||||
fn identify(path: &Path, c: &str, sha_known: Option<&str>) -> Result<Source> {
|
||||
let meta = std::fs::metadata(path).map_err(|e| bad(c, format!("cannot read `{}`: {e}", path.display())))?;
|
||||
if !meta.is_file() {
|
||||
return Err(bad(c, format!("`{}` is not a file", path.display())));
|
||||
}
|
||||
if meta.len() == 0 || meta.len() > MAX_MODEL_BYTES {
|
||||
return Err(bad(c, "a model file must be between 1 byte and 2 GiB"));
|
||||
}
|
||||
let sha256 = match sha_known {
|
||||
Some(h) => h.to_string(),
|
||||
None => sha256_file(path).map_err(|e| fail(&format!("could not read {}", path.display()), e))?,
|
||||
};
|
||||
let file_name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
// an archive LightCraft knows (the file darktable publishes the model in)
|
||||
if let Some(k) = known::all().into_iter().find(|k| k.download.as_ref().is_some_and(|d| d.sha256 == sha256)) {
|
||||
return Ok(Source { manifest: k.manifest, entry: k.entry, file_name });
|
||||
}
|
||||
// the `.onnx` of a known model
|
||||
if let Some(m) = known::by_sha256(&sha256) {
|
||||
return Ok(Source { manifest: m, entry: None, file_name });
|
||||
}
|
||||
// an `.onnx` with its manifest beside it
|
||||
let sibling = path.with_file_name("denoise-model.json");
|
||||
if path.extension().is_some_and(|e| e.eq_ignore_ascii_case("onnx"))
|
||||
&& let Some(bytes) = read_capped(&sibling, MAX_MANIFEST_BYTES)
|
||||
{
|
||||
let m = manifest::parse(&bytes).map_err(|e| bad(c, format!("{}: {e}", sibling.display())))?;
|
||||
return Ok(Source { manifest: m, entry: None, file_name });
|
||||
}
|
||||
Err(bad(
|
||||
c,
|
||||
"LightCraft does not know this file. A denoise model is an .onnx file with a denoise-model.json beside it that says how to use it \
|
||||
(see docs/denoise.md), or an archive LightCraft can fetch itself (`denoise.models.download`)",
|
||||
))
|
||||
}
|
||||
|
||||
/// Install the model in `src`. A model that passes its self-test becomes the one in use (`activate`, the default): installing
|
||||
/// a model is how someone says they want it. `verified` is for a download whose hash was already checked.
|
||||
fn install_file(
|
||||
dir: &Path,
|
||||
loader: &crate::denoise::Loader,
|
||||
run_on: RunOn,
|
||||
accepted_at: String,
|
||||
c: &str,
|
||||
src: &Path,
|
||||
acknowledged: bool,
|
||||
verified: bool,
|
||||
) -> Result<(Value, Arc<dyn crate::denoise::Model>)> {
|
||||
let ident = identify(src, c, None)?;
|
||||
let m = ident.manifest.clone();
|
||||
manifest::validate(&m).map_err(|e| bad(c, e.to_string()))?;
|
||||
if !acknowledged {
|
||||
return Err(bad(
|
||||
c,
|
||||
"the licence has not been accepted: show the user its terms (see `denoise.models.list`) and pass `acknowledged: true` once they agree",
|
||||
));
|
||||
}
|
||||
let home = dir.join(&m.id);
|
||||
if home.join("model.onnx").exists() {
|
||||
return Err(bad(c, "that model is already installed; remove it before installing a replacement"));
|
||||
}
|
||||
std::fs::create_dir_all(&home).map_err(|e| fail("could not create the model's folder", e))?;
|
||||
let mut cleanup = InstallCleanup { home: home.clone(), armed: true };
|
||||
let (part, final_path) = (home.join("model.onnx.part"), home.join("model.onnx"));
|
||||
let _ = std::fs::remove_file(&part);
|
||||
let placed: Result<()> = match ident.entry {
|
||||
Some(entry) => {
|
||||
archive::extract(src, entry, &part, MAX_MODEL_BYTES).map(|_| ()).map_err(|e| fail("could not take the model out of the archive", e))
|
||||
}
|
||||
None if verified && std::fs::rename(src, &part).is_ok() => Ok(()),
|
||||
None => std::fs::copy(src, &part).map(|_| ()).map_err(|e| fail("could not copy the model (is the disk full?)", e)),
|
||||
};
|
||||
let sha256 = placed.and_then(|_| sha256_file(&part).map_err(|e| fail("could not check the model", e))).and_then(|h| match &m.sha256 {
|
||||
Some(want) if *want != h => Err(EngineError::Other("the model file is not the one its description names (its SHA-256 differs)".into())),
|
||||
_ => Ok(h),
|
||||
});
|
||||
let sha256 = match sha256 {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(&part);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
// Test the staged file first. A failed replacement preserves the installed model.
|
||||
let model = loader(&part, &m).map_err(|e| bad(c, e))?;
|
||||
let test = match model.self_test(run_on) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(&part);
|
||||
return Err(bad(c, e));
|
||||
}
|
||||
};
|
||||
// An installed id is immutable: installing again must never destroy a usable model.
|
||||
if final_path.exists() {
|
||||
let _ = std::fs::remove_file(&part);
|
||||
return Err(bad(c, "that model is already installed; remove it before installing a replacement"));
|
||||
}
|
||||
std::fs::rename(&part, &final_path).map_err(|e| fail("could not finish the copy", e))?;
|
||||
write_atomic(&home.join("denoise-model.json"), &serde_json::to_vec_pretty(&m).map_err(|e| fail("manifest", e))?).map_err(EngineError::Other)?;
|
||||
let accepted = json!({
|
||||
"acceptedAt": accepted_at,
|
||||
"licence": m.licence.name,
|
||||
"commercial": m.licence.commercial,
|
||||
"fileName": ident.file_name,
|
||||
"sha256": sha256,
|
||||
"selfTest": test,
|
||||
});
|
||||
write_atomic(&home.join("installed.json"), &serde_json::to_vec_pretty(&accepted).map_err(|e| fail("record", e))?).map_err(EngineError::Other)?;
|
||||
cleanup.armed = false;
|
||||
Ok((json!({"installed": row(&m, known::find(&m.id).and_then(|k| host_of(&k)), true, false, &accepted), "model": m.id}), model))
|
||||
}
|
||||
|
||||
/// Removes only files created for this new installation, including on a worker panic.
|
||||
struct InstallCleanup {
|
||||
home: PathBuf,
|
||||
armed: bool,
|
||||
}
|
||||
impl Drop for InstallCleanup {
|
||||
fn drop(&mut self) {
|
||||
if self.armed {
|
||||
for name in ["model.onnx.part", "model.onnx", "denoise-model.json", "installed.json"] {
|
||||
let _ = std::fs::remove_file(self.home.join(name));
|
||||
}
|
||||
let _ = std::fs::remove_dir(&self.home);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct InstallJob {
|
||||
id: String,
|
||||
activate: bool,
|
||||
result: mpsc::Receiver<std::result::Result<(Value, Arc<dyn crate::denoise::Model>), String>>,
|
||||
}
|
||||
|
||||
fn activate_install(s: &mut Session, result: &Value, model: Arc<dyn crate::denoise::Model>, activate: bool) -> Result<Value> {
|
||||
let dir = models_dir(s, "denoise.models.install")?;
|
||||
let id = result["installed"]["id"].as_str().ok_or_else(|| bad("denoise.models.install", "missing installed id"))?;
|
||||
let mut st = read_settings(&dir);
|
||||
if activate {
|
||||
st.model = Some(id.to_string());
|
||||
write_settings(&dir, &st).map_err(EngineError::Other)?;
|
||||
}
|
||||
s.denoise.touch();
|
||||
s.denoise_refresh_active(true);
|
||||
if let Some(a) = &s.denoise.active
|
||||
&& a.id == id
|
||||
{
|
||||
*a.model.lock().unwrap_or_else(PoisonError::into_inner) = Some(model);
|
||||
}
|
||||
let mut v = result.clone();
|
||||
v["installed"]["selected"] = json!(st.model.as_deref() == Some(id));
|
||||
v["model"] = json!(st.model);
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn start_install(s: &mut Session, src: PathBuf, id: String, activate: bool, verified: bool) -> Result<Value> {
|
||||
const C: &str = "denoise.models.install";
|
||||
if s.denoise.install.is_some() {
|
||||
return Err(bad(C, "another model is being installed"));
|
||||
}
|
||||
let dir = models_dir(s, C)?;
|
||||
let loader = s.denoise.loader.clone();
|
||||
let run_on = s.denoise.settings.run_on();
|
||||
let at = (s.clock)();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
std::thread::Builder::new()
|
||||
.name("denoise-model-install".into())
|
||||
.stack_size(16 * 1024 * 1024)
|
||||
.spawn(move || {
|
||||
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let _held = crate::memory::work_gate().acquire(512 * 1024 * 1024);
|
||||
install_file(&dir, &loader, run_on, at, C, &src, true, verified).map_err(|e| plain(&e))
|
||||
}))
|
||||
.unwrap_or_else(|_| Err("the model installation stopped unexpectedly".into()));
|
||||
if verified {
|
||||
let _ = std::fs::remove_file(&src);
|
||||
}
|
||||
let _ = tx.send(outcome);
|
||||
})
|
||||
.map_err(|e| bad(C, format!("could not start installation: {e}")))?;
|
||||
s.denoise.install = Some(InstallJob { id: id.clone(), activate, result: rx });
|
||||
s.denoise.downloads.set_outcome(&id, State::Installing);
|
||||
Ok(json!({"started": id}))
|
||||
}
|
||||
|
||||
fn install(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "denoise.models.install";
|
||||
let path = str_param(p, "path").ok_or_else(|| bad(C, "missing `path`"))?;
|
||||
let acknowledged = bool_or(p, "acknowledged", false);
|
||||
let activate = bool_or(p, "activate", true);
|
||||
if !acknowledged {
|
||||
return Err(bad(C, "the licence has not been accepted: show its terms first"));
|
||||
}
|
||||
if bool_or(p, "background", false) {
|
||||
return start_install(s, PathBuf::from(path), "local-install".into(), activate, false);
|
||||
}
|
||||
if s.denoise.install.is_some() {
|
||||
return Err(bad(C, "another model is being installed"));
|
||||
}
|
||||
let (result, model) =
|
||||
install_file(&models_dir(s, C)?, &s.denoise.loader, s.denoise.settings.run_on(), (s.clock)(), C, Path::new(path), acknowledged, false)?;
|
||||
activate_install(s, &result, model, activate)
|
||||
}
|
||||
|
||||
/// Read only the small adjacent manifest; hashing and execution happen in the install worker.
|
||||
fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "denoise.models.inspect";
|
||||
let path = str_param(p, "path").ok_or_else(|| bad(C, "missing `path`"))?;
|
||||
let src = Path::new(path);
|
||||
if !src.extension().is_some_and(|e| e.eq_ignore_ascii_case("onnx")) {
|
||||
return Err(bad(C, "choose an .onnx file with denoise-model.json beside it"));
|
||||
}
|
||||
let bytes = read_capped(&src.with_file_name("denoise-model.json"), MAX_MANIFEST_BYTES)
|
||||
.ok_or_else(|| bad(C, "denoise-model.json is missing or too large"))?;
|
||||
let m = manifest::parse(&bytes).map_err(|e| bad(C, e.to_string()))?;
|
||||
let dir = models_dir(s, C)?;
|
||||
Ok(json!({"kind": "model", "domain": "denoise", "path": path, "fileName": src.file_name().map(|n| n.to_string_lossy()), "model": m,
|
||||
"sizeBytes": std::fs::metadata(src).ok().map(|m| m.len()), "alreadyInstalled": dir.join(&m.id).join("model.onnx").is_file()}))
|
||||
}
|
||||
|
||||
/// What an error says, without the command it came from (for a line shown to the user).
|
||||
fn plain(e: &EngineError) -> String {
|
||||
match e {
|
||||
EngineError::BadParams { msg, .. } => msg.clone(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Install every download that has arrived: each was accepted when it was started, so it is installed and chosen without
|
||||
/// another question. Called by `denoise.models.downloads` and by every frame's `denoise.pump`.
|
||||
pub(crate) fn finish_downloads(s: &mut Session) {
|
||||
if let Some(job) = &s.denoise.install {
|
||||
let outcome = match job.result.try_recv() {
|
||||
Ok(v) => Some(v),
|
||||
Err(mpsc::TryRecvError::Empty) => None,
|
||||
Err(mpsc::TryRecvError::Disconnected) => Some(Err("the installation worker stopped".into())),
|
||||
};
|
||||
if let Some(outcome) = outcome
|
||||
&& let Some(job) = s.denoise.install.take()
|
||||
{
|
||||
let state = match outcome {
|
||||
Ok((v, model)) => match activate_install(s, &v, model, job.activate) {
|
||||
Ok(_) => State::Installed,
|
||||
Err(e) => State::Failed(plain(&e)),
|
||||
},
|
||||
Err(e) => State::Failed(e),
|
||||
};
|
||||
s.denoise.downloads.set_outcome(&job.id, state);
|
||||
}
|
||||
}
|
||||
if s.denoise.install.is_none()
|
||||
&& let Some((id, path, _sha)) = s.denoise.downloads.finished().into_iter().next()
|
||||
&& let Err(e) = start_install(s, path, id.clone(), true, true)
|
||||
{
|
||||
s.denoise.downloads.set_outcome(&id, State::Failed(plain(&e)));
|
||||
}
|
||||
}
|
||||
|
||||
fn download(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "denoise.models.download";
|
||||
let id = str_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
let dir = models_dir(s, C)?;
|
||||
let k = known::find(id).ok_or_else(|| bad(C, "LightCraft has no download for that model: get the file from its page, then add it"))?;
|
||||
let spec = k.download.clone().ok_or_else(|| bad(C, "LightCraft has no download for that model: get the file from its page, then add it"))?;
|
||||
if !cfg!(feature = "denoise") {
|
||||
return Err(bad(C, "this build cannot run denoise models, so there is nothing to download for it"));
|
||||
}
|
||||
if !bool_or(p, "acknowledged", false) {
|
||||
return Err(bad(
|
||||
C,
|
||||
"the licence has not been accepted: show the user the model's terms (see `denoise.models.list`) and pass `acknowledged: true` once they agree",
|
||||
));
|
||||
}
|
||||
if installed_models(&dir).iter().any(|i| i.manifest.id == spec.id) {
|
||||
return Err(bad(C, "that model is already installed"));
|
||||
}
|
||||
let host = lightcraft_denoise::known::host(&spec.url).to_string();
|
||||
s.denoise.downloads.start(spec, &dir, &s.activity, &k.manifest.name).map_err(|e| bad(C, e))?;
|
||||
Ok(json!({"started": id, "from": host}))
|
||||
}
|
||||
|
||||
fn download_row(id: &str, state: &State) -> Value {
|
||||
let from = known::find(id).and_then(|k| host_of(&k));
|
||||
match state {
|
||||
State::Running { bytes, total } => json!({"id": id, "state": "running", "bytes": bytes, "total": total, "from": from}),
|
||||
State::Done { path, .. } => json!({"id": id, "state": "done", "path": path.display().to_string(), "from": from}),
|
||||
State::Installing => json!({"id": id, "state": "installing", "from": from}),
|
||||
State::Installed => json!({"id": id, "state": "installed", "from": from}),
|
||||
State::Failed(why) => json!({"id": id, "state": "failed", "error": why, "from": from}),
|
||||
State::Cancelled => json!({"id": id, "state": "cancelled", "from": from}),
|
||||
}
|
||||
}
|
||||
|
||||
fn downloads(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
finish_downloads(s);
|
||||
let all: Vec<Value> = s.denoise.downloads.snapshot().iter().map(|(id, st)| download_row(id, st)).collect();
|
||||
Ok(json!({"running": s.denoise.downloads.running(), "downloads": all}))
|
||||
}
|
||||
|
||||
fn download_cancel(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "denoise.models.downloadCancel";
|
||||
let id = str_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
Ok(json!({"discarded": s.denoise.downloads.discard(id)}))
|
||||
}
|
||||
|
||||
fn test(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "denoise.models.test";
|
||||
let id = str_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
let dir = models_dir(s, C)?;
|
||||
let found = installed_models(&dir).into_iter().find(|i| i.manifest.id == id).ok_or_else(|| bad(C, "that model is not installed"))?;
|
||||
let run_on = s.denoise.settings.run_on();
|
||||
let result = (s.denoise.loader)(&found.onnx, &found.manifest).and_then(|m| m.self_test(run_on));
|
||||
let (ok, detail) = match &result {
|
||||
Ok(v) => (true, v.clone()),
|
||||
Err(e) => (false, json!(e)),
|
||||
};
|
||||
// keep the latest outcome with the model's record
|
||||
let mut record = found.accepted;
|
||||
if let Some(o) = record.as_object_mut() {
|
||||
o.insert("selfTest".into(), if ok { detail.clone() } else { json!({"ok": false, "error": detail}) });
|
||||
write_atomic(&found.onnx.with_file_name("installed.json"), &serde_json::to_vec_pretty(&record).map_err(|e| fail("record", e))?)
|
||||
.map_err(EngineError::Other)?;
|
||||
}
|
||||
Ok(json!({"id": id, "ok": ok, "result": detail}))
|
||||
}
|
||||
|
||||
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "denoise.models.remove";
|
||||
let id = str_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
let dir = models_dir(s, C)?;
|
||||
if !lightcraft_denoise::licence::valid_id(id) {
|
||||
return Err(bad(C, "not a model id"));
|
||||
}
|
||||
let home = dir.join(id);
|
||||
// only a folder that is a model (it has a manifest) is ever deleted
|
||||
if !home.join("denoise-model.json").is_file() {
|
||||
return Err(bad(C, "that model is not installed"));
|
||||
}
|
||||
std::fs::remove_dir_all(&home).map_err(|e| fail("could not remove the model", e))?;
|
||||
let mut st = read_settings(&dir);
|
||||
if st.model.as_deref() == Some(id) {
|
||||
st.model = installed_models(&dir).into_iter().map(|i| i.manifest.id).find(|m| m != id);
|
||||
write_settings(&dir, &st).map_err(EngineError::Other)?;
|
||||
}
|
||||
s.denoise.touch();
|
||||
s.denoise_refresh_active(true);
|
||||
Ok(json!({"removed": id, "model": st.model}))
|
||||
}
|
||||
|
||||
fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "denoise.models.select";
|
||||
let dir = models_dir(s, C)?;
|
||||
let id = match p.get("id") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(v.as_str().ok_or_else(|| bad(C, "`id` must be a model id or null"))?.to_string()),
|
||||
};
|
||||
if let Some(id) = &id
|
||||
&& !installed_models(&dir).iter().any(|i| &i.manifest.id == id)
|
||||
{
|
||||
return Err(bad(C, "that denoise model is not installed"));
|
||||
}
|
||||
let mut st = read_settings(&dir);
|
||||
st.model = id;
|
||||
write_settings(&dir, &st).map_err(EngineError::Other)?;
|
||||
s.denoise.touch();
|
||||
s.denoise_refresh_active(true);
|
||||
Ok(json!({"model": st.model}))
|
||||
}
|
||||
|
||||
fn settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "denoise.settings";
|
||||
let dir = models_dir(s, C)?;
|
||||
let mut st: Settings = read_settings(&dir);
|
||||
if let Some(v) = p.get("auto") {
|
||||
st.auto = Some(v.as_bool().ok_or_else(|| bad(C, "`auto` must be true or false"))?);
|
||||
}
|
||||
if let Some(v) = p.get("cacheGb") {
|
||||
let n =
|
||||
v.as_u64().filter(|n| (1..=10_000).contains(n)).ok_or_else(|| bad(C, "`cacheGb` must be a whole number of gigabytes from 1 to 10000"))?;
|
||||
st.cache_gb = Some(n as u32);
|
||||
}
|
||||
if let Some(v) = p.get("threads") {
|
||||
st.threads = match v {
|
||||
Value::Null => None,
|
||||
v => Some(v.as_u64().filter(|n| (1..=64).contains(n)).ok_or_else(|| bad(C, "`threads` must be from 1 to 64, or null"))? as u32),
|
||||
};
|
||||
}
|
||||
let run_on = match p.get("runOn") {
|
||||
None => None,
|
||||
Some(v) => Some(v.as_str().and_then(RunOn::parse).ok_or_else(|| bad(C, "`runOn` must be auto, gpu or cpu"))?),
|
||||
};
|
||||
if let Some(r) = run_on {
|
||||
st.run_on = Some(r.name().to_string());
|
||||
st.gpu = None;
|
||||
}
|
||||
write_settings(&dir, &st).map_err(EngineError::Other)?;
|
||||
if run_on.is_some() {
|
||||
// a choice of where denoise runs is also a go-ahead to try the card again: a set-up that crashed or gave up
|
||||
// before is forgotten, and the loaded model sets the card up afresh for the next photo
|
||||
forget_gpu_set_ups(&dir);
|
||||
if let Some(a) = &s.denoise.active {
|
||||
*a.model.lock().unwrap_or_else(PoisonError::into_inner) = None;
|
||||
}
|
||||
}
|
||||
s.denoise.touch();
|
||||
s.denoise_refresh_active(true);
|
||||
// a smaller limit takes effect now
|
||||
s.denoise_evict(None);
|
||||
Ok(json!({"auto": st.auto(), "cacheGb": st.cache_bytes() >> 30, "threads": st.threads, "runOn": st.run_on().name()}))
|
||||
}
|
||||
|
||||
fn ids_param(p: &Value) -> Option<Vec<PhotoId>> {
|
||||
p.get("ids").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(PhotoId).collect())
|
||||
}
|
||||
|
||||
fn status_json(s: &Session, photo: Option<PhotoId>) -> Value {
|
||||
let (files, bytes) = s.denoise_cache_usage();
|
||||
let st = &s.denoise.settings;
|
||||
let model = s.denoise.active.as_ref().map(|a| json!({"id": a.id, "name": a.manifest.name}));
|
||||
let mut v = json!({
|
||||
"enabled": s.denoise.active.is_some(),
|
||||
"model": model,
|
||||
"runtime": cfg!(feature = "denoise"),
|
||||
"productsDir": s.denoise_products_dir().map(|d| d.display().to_string()),
|
||||
"queued": s.denoise.queued(),
|
||||
"running": s.denoise_running_json(),
|
||||
"ready": s.media.denoise.len(),
|
||||
"made": s.denoise_made(),
|
||||
"auto": st.auto(),
|
||||
"runOn": st.run_on().name(),
|
||||
"device": s.denoise_device(),
|
||||
"cache": {"files": files, "bytes": bytes, "limitBytes": st.cache_bytes()},
|
||||
"generation": s.denoise.generation,
|
||||
});
|
||||
if let (Some(id), Some(o)) = (photo, v.as_object_mut()) {
|
||||
o.insert("photo".into(), serde_json::to_value(s.denoise_photo_state(id)).unwrap_or(Value::Null));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// A per-photo switch, independent of the selection and Auto Sync. Turning on starts at 50%.
|
||||
fn toggle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "denoise.toggle";
|
||||
let id = match p.get("id") {
|
||||
Some(v) => PhotoId(v.as_u64().ok_or_else(|| bad(C, "`id` must be a photo id"))?),
|
||||
None => s.active().ok_or_else(|| bad(C, "select a raw photo"))?,
|
||||
};
|
||||
let photo = s.catalog.photo(id).ok_or_else(|| bad(C, "no such photo"))?;
|
||||
if !crate::denoise::eligible(photo) {
|
||||
return Err(bad(C, "AI Denoise applies to raw photos developed from their sensor data"));
|
||||
}
|
||||
let mut d = (*s.develop_of(id).ok_or_else(|| bad(C, "no such photo"))?).clone();
|
||||
let on = match p.get("enabled") {
|
||||
Some(v) => v.as_bool().ok_or_else(|| bad(C, "`enabled` must be true or false"))?,
|
||||
None => !d.enhance.denoise_enabled(),
|
||||
};
|
||||
if on {
|
||||
if !d.enhance.denoise.is_finite() {
|
||||
return Err(bad(C, "the photo has an invalid Denoise amount"));
|
||||
}
|
||||
s.denoise_refresh_active(true);
|
||||
if s.denoise.active.is_none() {
|
||||
return Err(bad(C, "AI Denoise needs a model: install one in Settings > AI Denoise"));
|
||||
}
|
||||
}
|
||||
// The switch is its own setting: the Amount stays where it is (0 included) when it is flipped, so flipping compares
|
||||
// with and without. A photo that never had an Amount starts at 50 the first time.
|
||||
let amount = if on && d.enhance.denoise_on.is_none() && d.enhance.denoise <= 0.0 { 50.0 } else { d.enhance.denoise };
|
||||
if d.enhance.denoise != amount || d.enhance.denoise_on != Some(on) {
|
||||
s.end_interaction()?;
|
||||
d.enhance.denoise = amount;
|
||||
d.enhance.denoise_on = Some(on);
|
||||
let op = s.develop_op(id, d, "AI Denoise").ok_or_else(|| bad(C, "no such photo"))?;
|
||||
s.commit("AI Denoise", op)?;
|
||||
}
|
||||
if on {
|
||||
s.denoise_enqueue(&[id], true, false);
|
||||
} else {
|
||||
s.denoise_cancel(Some(id), false);
|
||||
}
|
||||
Ok(json!({"id": id.0, "enabled": on, "amount": amount}))
|
||||
}
|
||||
|
||||
fn queue(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "denoise.queue";
|
||||
s.denoise_refresh_active(true);
|
||||
if s.denoise.active.is_none() {
|
||||
return Err(bad(C, "no denoise model is chosen: install one first (`denoise.models.download` or `denoise.models.install`)"));
|
||||
}
|
||||
if s.denoise_products_dir().is_none() {
|
||||
return Err(bad(C, "denoise keeps its pictures in the library's folder: open a library on disk first"));
|
||||
}
|
||||
let ids = match ids_param(p) {
|
||||
Some(ids) => ids,
|
||||
None => match str_param(p, "scope").unwrap_or("selected") {
|
||||
"selected" => s.selection.active.iter().chain(s.selection.ids.iter()).copied().collect(),
|
||||
"visible" => s.visible_cloned().to_vec(),
|
||||
"withAmount" => s.catalog.photos().filter(|ph| ph.develop.denoise_amount() > 0.0).map(|ph| ph.id).collect(),
|
||||
other => return Err(bad(C, format!("unknown scope `{other}`: use selected, visible or withAmount"))),
|
||||
},
|
||||
};
|
||||
let queued = s.denoise_enqueue(&ids, false, bool_or(p, "retry", false));
|
||||
let mut v = status_json(s, None);
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("added".into(), json!(queued));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn cancel(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let photo = p.get("id").and_then(Value::as_u64).map(PhotoId);
|
||||
let n = s.denoise_cancel(photo, bool_or(p, "all", photo.is_none()));
|
||||
Ok(json!({"cancelled": n}))
|
||||
}
|
||||
|
||||
fn status(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.denoise_refresh_active(false);
|
||||
Ok(status_json(s, p.get("id").and_then(Value::as_u64).map(PhotoId)))
|
||||
}
|
||||
|
||||
/// `denoise.pump {pace?}`: cheap to call every frame. Takes in what the background job has finished and keeps one going.
|
||||
fn pump(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.denoise_pump(Pace::parse(p.get("pace").and_then(Value::as_str)));
|
||||
s.denoise_track();
|
||||
Ok(json!({
|
||||
"active": s.denoise.active.is_some(),
|
||||
"running": s.denoise_running_json(),
|
||||
"queued": s.denoise.queued(),
|
||||
"ready": s.media.denoise.len(),
|
||||
"generation": s.denoise.generation,
|
||||
"downloading": s.denoise.downloads.running(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn clear(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let (files, bytes) = s.denoise_clear();
|
||||
Ok(json!({"deleted": files, "bytes": bytes}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "denoise.models.list", "Denoise Models", [], None, "{} → {dir, productsDir, model, runtime, auto, cacheGb, threads, runOn, models: [{id, name, version, licence{name, commercial, url, notice}, provenance, source, sizeBytes, sha256, tile, known, downloadHost, installed, selected, accepted}]}", always, list),
|
||||
cmd!(query "denoise.models.inspect", "Inspect Denoise Model", [], None, "{path} → model terms from an adjacent manifest; never executes the file", always, inspect),
|
||||
cmd!(query "denoise.models.install", "Install Denoise Model", [], None, "{path, acknowledged: true, activate?: true, background?: false} → {installed, model} or {started} — install a denoise model from a file: an .onnx with a denoise-model.json beside it, or an archive LightCraft knows (the darktable `.dtmodel`). `acknowledged` must be true: the user has been shown the model's licence and accepted it. Unless `activate` is false it becomes the model in use", always, install),
|
||||
cmd!(query "denoise.models.download", "Download Denoise Model", [], None, "{id, acknowledged: true} → {started, from} — fetch a model LightCraft has a pinned address for (see `downloadHost` in the list) in the background over HTTPS with lightcraft-fetch. `acknowledged` must be true: the user has been shown the model's terms and accepted them. It is checked against its size and SHA-256, installed and chosen by itself; `denoise.models.downloads` shows how far it is", always, download),
|
||||
cmd!(query "denoise.models.downloads", "Denoise Model Downloads", [], None, "{} → {running, downloads: [{id, state: running | done | installed | failed | cancelled, bytes, total, error, from}]} — also installs any download that has arrived", always, downloads),
|
||||
cmd!(query "denoise.models.downloadCancel", "Cancel Denoise Model Download", [], None, "{id} → {discarded} — stop a download, or delete a finished one that was not installed", always, download_cancel),
|
||||
cmd!(query "denoise.models.test", "Test Denoise Model", [], None, "{id} → {ok, result} — load an installed model and check it gives sensible pictures; needs the denoise runtime", always, test),
|
||||
cmd!(query "denoise.models.remove", "Remove Denoise Model", [], None, "{id} → {removed, model} — delete an installed model (its cached pictures stay until they are evicted)", always, remove),
|
||||
cmd!(query "denoise.models.select", "Choose Denoise Model", [], None, "{id: installed model | null} → {model} — null switches denoise off", always, select),
|
||||
cmd!(query "denoise.settings", "Denoise Settings", [], None, "{auto?: bool, cacheGb?: 1..10000, threads?: 1..64 | null, runOn?: auto | gpu | cpu} → the settings — `auto`: make the picture of the photos being looked at that have a Denoise amount; `cacheGb`: how much the cached pictures may take; `threads`: most tiles run at once; `runOn`: where the model runs — `auto` (the default) the graphics card where it is faster than the processor (both timed on this computer when the card is set up), `gpu` the card whenever it can run the model, `cpu` the processor. Setting `runOn` also lets the card be tried again after a set-up that failed or closed LightCraft", always, settings),
|
||||
cmd!(
|
||||
"denoise.toggle",
|
||||
"AI Denoise",
|
||||
[],
|
||||
None,
|
||||
"{id?: photoId, enabled?: bool} → {id, enabled, amount} — turn AI Denoise on (50% initially) or off for one raw photo, independent of selection and Auto Sync; needs an installed model to turn on",
|
||||
always,
|
||||
toggle
|
||||
),
|
||||
cmd!(query "denoise.queue", "Denoise Photos", [], None, "{ids?: [photoId], scope?: selected | visible | withAmount, retry?: false} → {added, …status} — make the denoised pictures of raw photos that have none, in the background. `retry` tries photos that failed before", always, queue),
|
||||
cmd!(query "denoise.cancel", "Cancel Denoise", [], None, "{id?, all?} → {cancelled} — stop the photo being made and/or forget the ones waiting (all of them when `id` is omitted)", always, cancel),
|
||||
cmd!(query "denoise.status", "Denoise Status", [], None, "{id?} → {enabled, model, runtime, productsDir, queued, running: {photo, done, total, seconds} | null, ready, made, auto, runOn, device: {kind: gpu | cpu | pending | none, adapter?, reason?, cardMs?, cpuMs?, fellBack?}, cache: {files, bytes, limitBytes}, generation, photo?: {state: notApplicable | noModel | ready | queued | running | failed | idle, …}}", always, status),
|
||||
cmd!(query "denoise.pump", "Denoise Pump", [], None, "{pace?: pause | light | normal | full} → {active, running, queued, ready, generation, downloading} — called every frame by the app: takes in finished work and keeps one photo going", always, pump),
|
||||
cmd!(query "denoise.clear", "Clear Denoise Cache", [], None, "{} → {deleted, bytes} — delete every cached denoised picture of this library (they are made again when wanted)", always, clear),
|
||||
]
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue