Compare commits

..

No commits in common. "release" and "claude/docs-374-nvidia-cache" have entirely different histories.

526 changed files with 4661 additions and 120185 deletions

View File

@ -26,15 +26,15 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 300
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@v4
# Optional font build input, so the Japanese font tests run (they skip without it).
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: 8dcdacd5153e64560d109541a47d806f26f048c0
path: craft-fonts
- name: Build and test in a FreeBSD VM
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
with:
release: "14.3"
usesh: true

View File

@ -1,7 +1,5 @@
# Quick checks for the release pipeline (no Rust build): workflow syntax, packaging
# shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata (seconds), plus a
# Windows job that builds and installs throwaway per-user MSI probes for the install-folder
# searches (a few minutes).
# Quick static checks for the release pipeline (seconds, no Rust build): workflow syntax,
# packaging shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata.
name: Packaging lint
on:
@ -25,7 +23,7 @@ jobs:
env:
ACTIONLINT_VERSION: 1.7.12
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@v4
- name: Install tools
run: |
sudo apt-get update
@ -53,11 +51,6 @@ 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"
@ -73,17 +66,3 @@ jobs:
EOF
- name: FreeBSD tarball layout (stub binaries)
run: bash packaging/freebsd/package.sh --dry-run
windows-install-folder:
runs-on: windows-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2
- name: Install-folder fallback and precedence (real MSI AppSearch)
shell: pwsh
run: |
./packaging/windows/test-install-folder.ps1 -Arch x64
./packaging/windows/test-install-folder.ps1 -Arch x86

View File

@ -5,9 +5,8 @@
# The signing jobs (macOS, Windows) and the draft-release job run in the `release` environment
# (restricted to the `release` branch), which holds the signing secrets. Every secret is optional:
# missing ones produce unsigned artifacts and a warning, never a failed build. The jobs that sign
# nothing (version, Linux, Linux riscv64, Flatpak, FreeBSD, web) run without it, so a dispatch on
# any branch is a dry run of them; the release job needs the signed jobs, so a branch run never
# drafts a release.
# nothing (version, Linux, Flatpak, FreeBSD, web) run without it, so a dispatch on any branch is
# a dry run of them; the release job needs the signed jobs, so a branch run never drafts a release.
name: Release
on:
@ -45,7 +44,7 @@ jobs:
date: ${{ steps.v.outputs.date }}
prerelease: ${{ steps.v.outputs.prerelease }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@v4
- id: v
name: Resolve version
run: |
@ -73,17 +72,17 @@ jobs:
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@v4
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
path: craft-fonts
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin, x86_64-apple-darwin
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- uses: Swatinem/rust-cache@v2
with:
key: release-macos-universal
- name: Apply version override
@ -103,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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
- uses: actions/upload-artifact@v4
with:
name: release-macos
path: dist/release/*
@ -131,24 +130,23 @@ jobs:
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@v4
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
path: craft-fonts
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- uses: Swatinem/rust-cache@v2
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
@ -166,7 +164,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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
- uses: actions/upload-artifact@v4
with:
name: release-windows-${{ matrix.arch }}
path: dist/release/*
@ -189,9 +187,9 @@ jobs:
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
NFPM_VERSION: 2.47.0
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@v4
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
@ -205,8 +203,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@686976e191b89faba57d3206551f0f330d8cb249 # stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
key: release-linux-${{ matrix.arch }}
- name: Apply version override
@ -230,7 +228,7 @@ jobs:
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
test -s "$appimage.zsync"
done
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
- uses: actions/upload-artifact@v4
with:
name: release-linux-${{ matrix.arch }}
path: dist/release/*
@ -252,8 +250,8 @@ jobs:
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: release-linux-${{ matrix.arch }}
path: dist/linux
@ -267,85 +265,12 @@ 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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
- uses: actions/upload-artifact@v4
with:
name: release-flatpak-${{ matrix.arch }}
path: dist/release/*.flatpak
if-no-files-found: error
linux-riscv64:
name: Linux riscv64
needs: version
# Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release
# from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's
# riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the
# CLI is smoke-tested under QEMU.
runs-on: ubuntu-24.04
# No `environment: release`: this job signs nothing, so it gets no release secrets.
env:
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
path: craft-fonts
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: riscv64gc-unknown-linux-gnu
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-linux-riscv64
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$LIGHTCRAFT_VERSION"
- name: Install cross-compilation dependencies
run: |
. /etc/os-release
sudo dpkg --add-architecture riscv64
# Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports.
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources
fi
if [ -f /etc/apt/sources.list ]; then
sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list
sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list
fi
for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do
echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse"
done | sudo tee /etc/apt/sources.list.d/riscv64.list
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \
libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \
libxi-dev:riscv64 libgl1-mesa-dev:riscv64
- name: Build and package (tar.gz)
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc
PKG_CONFIG_SYSROOT_DIR: /
PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
PKG_CONFIG_ALLOW_CROSS: "1"
CROSS_ARCH: riscv64
CROSS_TARGET: riscv64gc-unknown-linux-gnu
CROSS_COMPILE: riscv64-linux-gnu-
EMULATOR: qemu-riscv64
QEMU_LD_PREFIX: /usr/riscv64-linux-gnu
run: packaging/linux/package.sh --formats tar
- name: Check the tarball
run: |
tar -tzf dist/release/lightcraft-*-linux-riscv64.tar.gz \
| grep -E '/bin/lightcraft$|/bin/lightcraft-cli$|/share/applications/ai\.storyteller\.lightcraft\.desktop$'
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-linux-riscv64
path: dist/release/*.tar.gz
if-no-files-found: error
freebsd:
name: FreeBSD x86_64
needs: version
@ -357,16 +282,16 @@ jobs:
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@v4
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
path: craft-fonts
- name: Build and package in a FreeBSD VM (tar.gz)
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
with:
release: "14.3"
usesh: true
@ -391,7 +316,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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
- uses: actions/upload-artifact@v4
with:
name: release-freebsd-x86_64
path: dist/release/*
@ -406,17 +331,17 @@ jobs:
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
WASM_BINDGEN_VERSION: 0.2.129
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@v4
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
path: craft-fonts
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- uses: Swatinem/rust-cache@v2
with:
key: release-web
- name: Install wasm-bindgen CLI
@ -434,7 +359,7 @@ jobs:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/web/package.sh
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
- uses: actions/upload-artifact@v4
with:
name: release-web
path: dist/release/*
@ -442,7 +367,7 @@ jobs:
release:
name: Draft GitHub Release
needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web]
needs: [version, macos, windows, linux, flatpak, freebsd, web]
runs-on: ubuntu-latest
environment: release
permissions:
@ -453,7 +378,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
steps:
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
- uses: actions/download-artifact@v4
with:
pattern: release-*
path: artifacts

View File

@ -36,11 +36,11 @@ jobs:
runs-on: windows-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
with:
targets: aarch64-pc-windows-msvc
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
- uses: actions/upload-artifact@v4
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@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
- uses: actions/download-artifact@v4
with:
name: lightcraft-windows-arm64
path: dist

View File

@ -5,18 +5,12 @@ 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) (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.
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.
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).
@ -80,10 +74,9 @@ 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); `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
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
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).

View File

@ -5,18 +5,12 @@ 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) (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.
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.
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).
@ -61,10 +55,6 @@ 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.
@ -84,10 +74,9 @@ 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); `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; the UI also falls back on the faces of other scripts (Thai, Arabic, …). Unset,
`CRAFT_FONTS` is empty: everything builds, tests and runs, but Chinese and Japanese text lack glyphs. Releases always build
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
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).
@ -113,9 +102,6 @@ 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.

611
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -3,19 +3,15 @@ resolver = "3"
members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.7.1"
version = "0.4.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
repository = "https://github.com/storytold/lightcraft"
[workspace.dependencies]
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
# 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" }
@ -24,8 +20,6 @@ lightcraft-raw = { path = "crates/raw" }
lightcraft-codecs = { path = "crates/codecs" }
lightcraft-meta = { path = "crates/meta" }
lightcraft-develop = { path = "crates/develop" }
lightcraft-lensdb = { path = "crates/lensdb" }
lightcraft-inpaint = { path = "crates/inpaint" }
lightcraft-scenes = { path = "crates/scenes" }
lightcraft-pipeline = { path = "crates/pipeline" }
lightcraft-gpu = { path = "crates/gpu" }
@ -36,13 +30,10 @@ 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" }
lightcraft-geocode = { path = "crates/geocode" }
# foundation
thiserror = "2"
sha2 = "0.10"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4"
@ -70,12 +61,7 @@ tiff = "0.11"
image-webp = "0.2"
moxcms = { version = "0.9", default-features = false, features = ["lut"] }
jxl-oxide = { version = "0.12", default-features = false }
jixel = "=0.3.4" # BSD-3-Clause OR Apache-2.0, pure-Rust JPEG XL encoder (export; port of libjxl-tiny)
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
@ -92,13 +78,6 @@ eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
rfd = "0.17"
muda = { version = "0.21", default-features = false } # native menu bar (macOS: objc2/AppKit, pure Rust; no GTK)
# macOS: the profile of the screen showing the window (safe AppKit bindings; muda and wgpu use the same)
objc2 = { version = "0.6", default-features = false, features = ["std"] }
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSColorSpace", "NSResponder", "NSScreen", "NSWindow"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSData"] }
# Linux: the desktop's dark/light setting from the XDG settings portal (Settings ▸ Interface ▸
# Appearance Mode ▸ Auto). Pure Rust; the version accesskit already brings in, pinned to it.
zbus = "=5.19.0"
web-time = "1"
# web (apps/lightcraft-web)

29
NOTICE
View File

@ -15,19 +15,6 @@ 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/inpaint (lightcraft-inpaint) ports the inpainting (PatchMatch completion) and Poisson membrane
fill code of PhotoCraft (https://github.com/storytold/photocraft, crates/algo/src/inpaint.rs and
poisson.rs), Copyright the PhotoCraft contributors, licensed under the MIT License or the Apache
License 2.0, at your option. The port was modified by the LightCraft contributors (2026); each
ported file says so.
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.
@ -40,24 +27,8 @@ 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
UDPGothic Regular), under the SIL Open Font License 1.1. Release packages include each font's
licence as OFL-<family>.txt.
Place names for reverse geocoding (Photo > Fill Location from GPS) are not part of LightCraft and
are never distributed with it. On the user's request (Photo > Download Place Names) the app
downloads cities15000.zip, admin1CodesASCII.txt and countryInfo.txt from GeoNames
(https://download.geonames.org/export/dump/) into the settings folder. GeoNames data is licensed
under Creative Commons Attribution 4.0 (https://creativecommons.org/licenses/by/4.0/);
attribution: "GeoNames, geonames.org". The lookup code (crates/geocode) is our own.

View File

@ -12,11 +12,6 @@
<h3 align="center">Your photos. Your pixels. Your machine.</h3>
<p align="center">
<a href="README.md">English</a> ·
<a href="README.zh-hans.md">简体中文</a>
</p>
<p align="center">
<b>Photo library and raw development; an open-source, clean-room reimplementation of Adobe Lightroom, rebuilt in pure Rust.</b><br>
Native on macOS, Windows and Linux. In the browser via WebAssembly. Drivable end to end by AI agents over MCP.
@ -254,20 +249,6 @@ 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.
@ -279,8 +260,7 @@ lightcraft --control 7980 ~/Pictures/trip
- **Pure Rust, no C.** Our own RAW decoders (DNG, Canon CR2/CR3, Sony ARW, Nikon NEF, Fujifilm RAF incl. X-Trans,
Panasonic RW2 / Leica RWL, Pentax PEF, Olympus ORF), our own colour science, our own pipeline. JPEG, PNG, TIFF, WebP,
PSD composites and JPEG XL open today; HEIC/HEIF photos too with `--features heif` (HEVC is a build-time choice,
as in PhotoCraft).
PSD composites and JPEG XL open today.
- **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
@ -288,8 +268,7 @@ 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. Batch exports
render several photos side by side, so one's decode and encode overlap another's render.
Without a GPU the same pipeline runs on all CPU cores, redoing only the stages a slider affects.
- **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.
@ -302,21 +281,18 @@ lightcraft --control 7980 ~/Pictures/trip
## Feature status
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)):
LightCraft is young and moving fast. **Where we honestly stand** (details in the [roadmap](ROADMAP.md#where-we-stand)):
- **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.
- **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.
- **The biggest gaps:**
- **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCE-7CR, ILCE-7M4, X-H2S and X-T4 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix;
- **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in 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;
- **network libraries:** a preview reads the raw's header and its embedded JPEG only (56.5 MB → 5.9 MB, 10 s → 1 s on the test share), but the *developed* picture still needs the sensor data over the wire; see [docs/raw-preview-reads.md](docs/raw-preview-reads.md);
- **on-screen HDR display, video and the Classic Print / Book / Map modules.**
- **What's next:** see [the ranked gaps](docs/gaps.md) and [the roadmap](docs/roadmap.md).
- **HDR, video and the Classic Print / Book / Map modules.**
- **What's next:** see [where we're going](ROADMAP.md#where-were-going).
| Area | Status |
|---|---|
@ -326,13 +302,12 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det
| 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, 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 ⬜ |
| RAW: DNG, CR2, CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), ARW, NEF (uncompressed + lossless/lossy compressed), Fujifilm RAF (uncompressed + lossless/lossy compressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed); embedded previews for every format incl. CR3 | 🟡 · CR3, compressed ORF decode ⬜ |
| Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise, Super Resolution ⬜ |
| Remove / Heal / Clone spots (auto source), Visualize Spots, Red Eye and Pet Eye (auto pupil detection, catchlight) | ✅ · content-aware fill, spot pin editing 🚧 |
| Export: JPEG / PNG / TIFF / WebP / AVIF / DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | ✅ |
| HDR: 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 ⬜ |
| 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 ⬜ |
| Library persistence (crash-safe op log + snapshots, background compaction, failed saves reported), disk thumbnail cache | ✅ |
| Import: Add in place / Copy / Move, rename and folder templates, devices, duplicate detection, watched folders; Local folder browsing | ✅ |
| MCP server (headless or live app, persistent libraries), CLI, control channel | ✅ |
@ -340,7 +315,7 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det
| Optics (distortion, vignetting, auto + manual CA, defringe, lens corrections embedded in DNG files 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; video | 🚧 (see [gaps](docs/gaps.md)) |
| AI: segmentation masks, AI denoise, super resolution, faces; HDR editing; video | ⬜ (see [roadmap](ROADMAP.md#where-were-going)) |
| Web build (same UI in the browser via WASM): persistent library in OPFS/IndexedDB, Web Worker rendering, export downloads | ✅ · WebGPU, Safari/Firefox testing 🚧 |
<sub>✅ works today · 🚧 in progress · ⬜ not started</sub>
@ -349,8 +324,6 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det
## Quick start
Workspace builds, including the desktop, web and CLI applications, require Rust 1.95 or newer.
```sh
git clone https://github.com/storytold/lightcraft && cd lightcraft
cargo run --release -p lightcraft # opens your library (~/Pictures/LightCraft Library; a new one starts with demo photos)
@ -378,16 +351,15 @@ CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft
```
Without it LightCraft builds and runs the same, but Chinese and Japanese text have no glyphs. Fonts are never committed to this
repo — the standard for what goes there lives with it: see the [craft-fonts README](https://github.com/storytold/craft-fonts#readme).
repo; see [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md).
**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français) or **Settings → General →
**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Deutsch) or **Settings → General →
Language**; the choice applies immediately and persists. See [docs/localization.md](docs/localization.md).
**Logs:** the desktop app writes its log to standard error and to `logs/lightcraft.log` in its settings folder
(Linux `$XDG_CONFIG_HOME/lightcraft/logs/`, by default `~/.config/lightcraft/logs/`; macOS
`~/Library/Application Support/LightCraft/logs/`; Windows `%APPDATA%\LightCraft\logs\`), never in the library. A
launch from a desktop menu or the Dock has no terminal, so attach this file to a bug report (**Help ▸ Open Log
Folder** shows it in the file manager). Each start moves the
launch from a desktop menu or the Dock has no terminal, so attach this file to a bug report. Each start moves the
previous log to `lightcraft.1.log` and that one to `lightcraft.2.log`, so the log of a run that crashed survives the
next start; the file stops growing at 16 MiB, `--version` and `--help` write none, and runs with
`LIGHTCRAFT_NO_PREFS` log to standard error only. By default LightCraft's own crates log at `info` and everything else
@ -470,9 +442,7 @@ 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. 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\"`.
Installers and executables are code-signed.
### macOS
@ -491,11 +461,6 @@ option to start it. For unattended installs: `msiexec /i lightcraft-<ver>-window
| Fedora/RHEL/openSUSE | `lightcraft-<ver>-linux-x86_64.rpm` | `lightcraft-<ver>-linux-aarch64.rpm` | |
| Tarball | `lightcraft-<ver>-linux-x86_64.tar.gz` | `lightcraft-<ver>-linux-aarch64.tar.gz` | Unpack anywhere |
RISC-V (riscv64): `lightcraft-<ver>-linux-riscv64.tar.gz`, a tarball only (glibc 2.39 or newer, e.g. Ubuntu 24.04+).
On a Wayland desktop LightCraft runs through XWayland when it is available, because only X11 gives
windows file drag-and-drop; set `LIGHTCRAFT_WAYLAND=1` to use native Wayland (sharper fractional scaling, no drop).
### FreeBSD
| Build | File |
@ -582,5 +547,3 @@ Forks and modified versions must remove them.
## Star history
[![Star History Chart](https://api.star-history.com/svg?repos=storytold/lightcraft&type=Date&legend=top-left)](https://www.star-history.com/?repos=storytold%2Flightcraft&type=date&legend=top-left)
Library, Tools, and Presets panels can be regrouped, split, floated within the app window, closed, and reopened. Tools keeps the current Edit/Crop/Masking mode; the photo view and filmstrip stay together. Drag a panel header to a target edge or another header. Its context menu offers Float panel, Move to group, and Close panel. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window.

View File

@ -1,567 +0,0 @@
<p align="center">
<a href="https://getartcraft.com/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/brand/artcraft-logo-white.svg">
<img alt="ArtCraft" src="docs/brand/artcraft-logo.svg" width="200">
</picture>
</a>
</p>
<h1 align="center">LightCraft</h1>
<h3 align="center">Your photos. Your pixels. Your machine.</h3>
<p align="center">
<a href="README.md">English</a> ·
<a href="README.zh-hans.md">简体中文</a>
</p>
<p align="center">
<b>照片图库与 raw 显影;Adobe Lightroom 的开源净室实现,用纯 Rust 重建。</b><br>
在 macOS、Windows 和 Linux 上原生运行,也可通过 WebAssembly 在浏览器中使用,并能由 AI 智能体经 MCP 端到端驱动。
</p>
<p align="center">
<img alt="Pure Rust" src="https://img.shields.io/badge/pure-Rust-f2a516?style=flat-square&logo=rust&logoColor=white">
<img alt="macOS, Windows, Linux and Web" src="https://img.shields.io/badge/macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20Web-8a5800?style=flat-square">
<img alt="MCP server included" src="https://img.shields.io/badge/MCP-ready-8a5800?style=flat-square">
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-8a5800?style=flat-square">
<a href="ROADMAP.md"><img alt="Status: young and moving fast" src="https://img.shields.io/badge/status-young%20%26%20moving%20fast-f2a516?style=flat-square"></a>
</p>
<p align="center">
<a href="https://discord.gg/artcraft"><img alt="Join the ArtCraft community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
</p>
<p align="center">
<a href="https://getartcraft.com/apps/lightcraft"><b>getartcraft.com 上的 LightCraft</b></a> ·
<a href="https://getartcraft.com/">ArtCraft</a> ·
<a href="https://getartcraft.com/apps">全部 Crafting Apps</a>
</p>
<br>
<p align="center">
<img src="docs/images/hero-tetons.jpg" alt="LightCraft 的编辑视图:放大视图中是安塞尔·亚当斯的《特顿山脉与蛇河》,右侧打开着 Light 与 Effects 面板,底部的胶片窗格中是四张展示照片" width="100%">
<br>
<sub><i>安塞尔·亚当斯,《特顿山脉与蛇河》(1942)。公有领域,美国国家档案馆。在 LightCraft 中显影。</i></sub>
</p>
> [!NOTE]
> **ArtCraft 是一个由形形色色的创作者组成的社区。** 数字艺术、生成艺术、音乐、游戏 &mdash;
> 只要你在创作,你就是我们中的一员。**[来 Discord 打个招呼吧](https://discord.gg/artcraft)。**
<p align="center">
<a href="#edit-like-you-mean-it">编辑</a> ·
<a href="#color-grading-the-cinematic-way">颜色分级</a> ·
<a href="#before--after">修改前后对比</a> ·
<a href="#masking-that-goes-where-you-point">蒙版</a> ·
<a href="#presets-profiles--the-color-mixer">预设</a> ·
<a href="#organize-everything">图库</a> ·
<a href="#built-for-agents">智能体与 MCP</a> ·
<a href="#fast-native-private">性能</a> ·
<a href="#feature-status">功能状态</a> ·
<a href="#quick-start">快速开始</a> ·
<a href="ROADMAP.md">路线图</a> ·
<a href="#downloads">下载</a> ·
<a href="#the-crafting-apps">Crafting Apps</a>
</p>
<br>
## Edit like you mean it
LightCraft 是一个装在单个原生应用里的完整暗房。每一项调整都是**非破坏性**的,原始文件永远不会被改动。
每个滑块都经过一条**场景参考、宽色域、32 位浮点的管线**渲染:高光像胶片一样柔和滚降,阴影被提亮却不产生光晕,
色彩从拍摄到导出始终保持干净。
<table>
<tr>
<td width="50%" valign="top">
### ☀️ Light
**曝光(Exposure)、对比度、高光、阴影、白色色阶、黑色色阶**,配合边缘感知的局部色调映射(对对数亮度做引导滤波)。
把高光拉到 −100,就能救回一片过曝的天空,而且不会出现用简单曲线时会有的灰色光晕。
### 🎨 Color
按**色温**和**色调**调整**白平衡**(raw 用开尔文,JPEG 用相对值),带预设、自动,以及点击即可中和的**吸管**。
保护肤色的**鲜艳度**、**饱和度**、8 个色段的**颜色混合器**(色相/饱和度/亮度),以及带混合与平衡的
三路**颜色分级**色轮。所有计算都在 OkLCh 这个现代感知色彩空间里完成。
</td>
<td width="50%" valign="top">
### ✨ Effects
让细节更细腻的**纹理(Texture)**、让中间调更有力的**清晰度**、**去朦胧**(暗通道先验加引导滤波细化;
推到负值可以增加空气感)、裁剪后带高光优先、圆度和羽化的**暗角**,以及分辨率无关、可调大小与粗糙度的
胶片**颗粒**。
### 📈 Tone Curve
带可移动分割点的参数化区域曲线,**外加** RGB、红、绿、蓝的点曲线。曲线在构造上就是单调的,
所以不会意外出现影调反转。
</td>
</tr>
</table>
<p align="center">
<img src="docs/images/curve-tetons.jpg" alt="Light 面板下方打开的色调曲线,RGB 点曲线上是一条平缓的 S 形曲线,作用于《特顿山脉与蛇河》" width="100%">
<br>
<sub>RGB 点曲线上一条平缓的 S 形曲线,就在 Light 滑块正下方。<i>安塞尔·亚当斯,1942(公有领域)。</i></sub>
</p>
<br>
## Color grading, the cinematic way
用随处拖动的色轮,分别给阴影、中间调和高光做分离色调。下图中,多萝西娅·兰格的
*《移民母亲》* 只拖动两次就获得了温暖的、类似照片冲印的色调(高光 42°,阴影 28°)。
<p align="center">
<img src="docs/images/grading-migrant-mother.jpg" alt="中间调、阴影和高光的颜色分级色轮,旁边是多萝西娅·兰格的《移民母亲》,被调成温暖的冲印色调" width="100%">
<br>
<sub>Color 面板中的阴影、中间调和高光色轮。<i>多萝西娅·兰格,《移民母亲》(1936)。公有领域,美国国会图书馆。</i></sub>
</p>
<br>
## Before & after
按住 <kbd>\\</kbd> 偷看原图,按 <kbd>Y</kbd> 并排对比,或按 <kbd>Shift</kbd>+<kbd>Y</kbd> 分屏查看。
下面每一张都是 LightCraft 的真实截图,由智能体通过[控制通道](#built-for-agents)自动截取。
<table>
<tr>
<td width="50%"><img src="docs/images/ba-tetons.jpg" alt="《特顿山脉与蛇河》的并排修改前后对比:修改后的云层更深,河岸边的阴影更开阔"><br><sub><b>《特顿山脉与蛇河》。</b> 高光 −45、阴影 +38、清晰度 +28、去朦胧 +18。<i>安塞尔·亚当斯,1942(公有领域)。</i></sub></td>
<td width="50%"><img src="docs/images/ba-migrant-mother.jpg" alt="《移民母亲》的并排修改前后对比:修改后更温暖,阴影被提亮"><br><sub><b>《移民母亲》。</b> 阴影 +42、纹理 +18、分离色调、暗角。<i>多萝西娅·兰格,1936(公有领域)。</i></sub></td>
</tr>
<tr>
<td width="50%"><img src="docs/images/ba-earthrise.jpg" alt="《地出》的并排修改前后对比:月球地平线上方是地球,修改后略暖、更浓郁"><br><sub><b>《地出》。</b> 去朦胧 +22、高光 −30、更暖的白平衡、鲜艳度 +22。<i>NASA/比尔·安德斯,阿波罗 8 号,1968(公有领域)。</i></sub></td>
<td width="50%"><img src="docs/images/ba-blue-marble.jpg" alt="《蓝色弹珠》的并排修改前后对比:修改后黑色更深,云层细节更结实"><br><sub><b>《蓝色弹珠》。</b> 高光 −38、黑色色阶 −20、去朦胧 +15、鲜艳度 +30。<i>NASA,阿波罗 17 号,1972(公有领域)。</i></sub></td>
</tr>
</table>
<br>
## Masking that goes where you point
用**画笔**涂抹(大小、羽化、流量、密度、擦除),放置带可拖动控制点的**线性渐变**和**径向渐变**,
或者按**亮度范围**、**颜色范围**、**天空**、**主体**和**背景**来选择。用**添加/减去/相交**组合各个组件,
任意反相,每个蒙版还能调 15 项局部调整(色温、色调、曝光、对比度、高光、阴影、白色色阶、黑色色阶、纹理、
清晰度、去朦胧、色相、饱和度、锐化、降噪)以及一个总体的数量。
<p align="center">
<img src="docs/images/masking.jpg" alt="蒙版面板中有一个线性天空蒙版和一个径向阳光光晕蒙版;径向渐变在湖景照片上以红色叠加层画在太阳周围" width="100%">
<br>
<sub>叠加在线性「天空」蒙版之上的径向「阳光光晕」蒙版(色温 +40、曝光 +0.50)。<i>照片来自 LightCraft 程序生成的演示图库。</i></sub>
</p>
<br>
## Presets, profiles & the Color Mixer
内置十八个手工制作的预设(*Golden Hour、Teal & Orange、Faded Matte、Selenium Tone、Crisp
Landscape* 等等),每个都有 0 到 200 % 的**数量**滑块。你可以从任意一组设置保存自己的预设、标记收藏,
并在整个选区之间只复制、粘贴或同步你选定的那些分组。
<p align="center">
<img src="docs/images/presets-mixer.jpg" alt="预设列按 B&amp;W、Color、Film、Landscape、Portrait 和 Style 分组,旁边是 Color 面板,8 色段颜色混合器打开在紫色上" width="100%">
<br>
<sub>预设列与 8 色段颜色混合器并排。<i>照片来自演示图库。</i></sub>
</p>
<table>
<tr>
<td width="50%" valign="top">
### ✂️ Crop, straighten & geometry
自由或锁定的长宽比(1:1、4:5、5:7、2:3、4:3、16:9、16:10、原始比例),拖动旋转拉直并始终把最大的裁剪框
保持在画面内,三分线/网格/黄金比例叠加,翻转和 90° 旋转,外加手动垂直、水平、旋转、长宽比、
缩放和位移变换。
</td>
<td width="50%" valign="top">
### ⚫️ Black & White
一键(<kbd>V</kbd>)转为单色,配合 8 色段**黑白混合**压暗天空或让树叶发亮。
之后再用颜色分级调出硒调、棕褐或分离色调的印相效果。
</td>
</tr>
<tr>
<td width="50%"><img src="docs/images/crop-tetons.jpg" alt="《特顿山脉与蛇河》上的裁剪工具:一个略微旋转的 16:9 裁剪框,带三分线叠加,右侧是几何滑块"><br><sub>一个 16:9 的裁剪框拉直了 +2.5°,带三分线叠加。<i>安塞尔·亚当斯,1942(公有领域)。</i></sub></td>
<td width="50%"><img src="docs/images/bw-split.jpg" alt="沙丘的分屏修改前后视图:左边是彩色,右边是带颗粒和暗角的黑白"><br><sub>分屏视图:左边是彩色原图,右边是带清晰度、暗角和颗粒的黑白。<i>演示图库。</i></sub></td>
</tr>
</table>
<br>
## Organize everything
一个不碍事的图库:**所有照片**、**最近添加**、**留用**、**按日期**、嵌套在**文件夹**里的**相册**,
以及**最近删除**。用 <kbd>0</kbd>–<kbd>5</kbd> 评星,用 <kbd>P</kbd> / <kbd>X</kbd> /
<kbd>U</kbd> 加旗标,用 <kbd>6</kbd>–<kbd>9</kbd> 加颜色标签。搜索支持字段:
`rating:>3 flag:pick iso:>800 camera:x2 date:2026-04 keyword:mountains`。每个视图都能按拍摄日期、
导入日期、编辑日期、名称、星级、大小或随机排序(一种稳定的乱序;「视图 → 排序 → 重新乱序」可换一个顺序)。
两端对齐的**照片网格**和**正方形网格**视图都做了虚拟化,
所以无论是四十张还是四万张照片都同样顺滑。
<table>
<tr>
<td width="50%"><img src="docs/images/grid-demo.jpg" alt="24 张演示照片的两端对齐照片网格,带星级和旗标,侧边栏是 Travel 2026 文件夹下嵌套的相册"><br><sub>照片网格,相册嵌套在文件夹里,带星级和旗标。<i>演示图库。</i></sub></td>
<td width="50%"><img src="docs/images/grid-pd.jpg" alt="正方形网格,显示四张公有领域展示照片及其星级和留用旗标"><br><sub>四张公有领域展示照片的正方形网格。</sub></td>
</tr>
<tr>
<td colspan="2"><img src="docs/images/info-earthrise.jpg" alt="《地出》的信息面板,显示文件名、2400 × 2400 的 JPEG 尺寸、星级、标题字段和相机元数据"><br><sub>信息面板:文件、尺寸、星级、标题、题注、版权和相机元数据。<i>NASA/比尔·安德斯,《地出》,阿波罗 8 号,1968(公有领域)。</i></sub></td>
</tr>
</table>
<br>
## Built for agents
LightCraft 里的每一个菜单项、滑块、画笔笔触、裁剪手柄和按键都是一个**命令**,拥有稳定的 ID 和
JSON 参数。界面、键盘、CLI、JSON-lines 控制通道和一个 **MCP 服务器**全都通过同一个入口分发。
智能体可以挑片、显影、给天空加蒙版并导出,而且能*看到*结果。
```sh
lightcraft --control 7980 ~/Pictures/trip
```
```jsonc
{"method": "engine.execute", "params": {"command": "photo.flag", "params": {"flag": "pick"}}}
{"method": "engine.execute", "params": {"command": "develop.set", "params": {"values": {"light.highlights": -45, "light.shadows": 38}}}}
{"method": "engine.execute", "params": {"command": "mask.add", "params": {"kind": "radial", "center": [0.62, 0.4], "rx": 0.2, "ry": 0.14}}}
{"method": "ui.clickWidget", "params": {"id": "slider:effects.clarity"}} // 按名称操作任意控件
{"method": "ui.pointer", "params": {"events": [{"kind":"down","x":0.2,"y":0.3}, {"kind":"up","x":0.4,"y":0.3}]}}
{"method": "ui.screenshot", "params": {"path": "after.png"}}
```
- **命令注册表。** `engine.commands` 列出可用命令;`develop.controls` 列出每个滑块的范围、
默认值和当前值。
- **MCP 服务器。** `lightcraft-cli mcp` 把命令注册表开放给 Claude(或任何 MCP 客户端),
同时提供导入、查询、显影、蒙版、渲染(以图像返回)和导出的辅助工具。它既能无界面运行,也能附着
到正在运行的应用上,支持截图、点击和手势。见 [docs/mcp.md](docs/mcp.md)。
```sh
cargo build --release -p lightcraft-cli
claude mcp add lightcraft -- "$PWD/target/release/lightcraft-cli" mcp ~/Pictures/shoot # 无界面
claude mcp add lightcraft-app -- "$PWD/target/release/lightcraft-cli" mcp --connect 127.0.0.1:7980 # 驱动运行中的应用
```
- **可脚本化的 CLI:** `lightcraft-cli run --import in.dng develop.set control=light.exposure value=0.7 app.export
path=out.jpg longEdge=2048` 可以运行任意命令链(无界面、在已保存的图库上,或对正在运行的应用),
每条命令打印一行 JSON 结果;`lightcraft-cli render in.dng -o out.jpg --set light.exposure=0.7 --preset …`。
- **一切皆可撤销**,包括智能体的操作:一次滑块拖动(或脚本里一连串更新)算一步撤销。
- **联系表(原生应用/CLI):** 选中照片,然后 **文件 → 联系表 PDF…**。可选择 A4 或 Letter、横向、
行数和列数以及文件名题注;保存分页 PDF,再用你的 PDF 阅读器打印。导出使用当前编辑,每张照片完整放入
而不再次裁剪,以 150 dpi 和 sRGB 渲染。后台导出支持取消,并保护原文件和 XMP 附属文件。从 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` 还接受显式的 `ids`、`landscape=true` 和 `paper=letter`。只有当整个 PDF 生成成功后,
它才会替换已有的输出文件。限制:1000 张照片、100 页、256 MiB;题注使用与水印相同的字体覆盖来栅格化
(中日韩文字需要 craft-fonts),过长的名称会在中间省略。
这是一套联系表工作流,
不是打印驱动或自定义拼版编辑器。
- **每个控件都可寻址**(`ui.widgets`)且可按名称点击,所以智能体操作的是真实界面,而不是绕到侧门。
- 本 README 中的截图全部由 [`docs/showcase/`](docs/showcase/) 脚本端到端生成。
协议参考:[docs/control-protocol.md](docs/control-protocol.md)。
<br>
## Fast, native, private
- **纯 Rust,无 C。** 我们自己的 RAW 解码器(DNG、佳能 CR2/CR3、索尼 ARW、尼康 NEF、富士 RAF 含 X-Trans、
松下 RW2/徕卡 RWL、宾得 PEF、奥林巴斯 ORF)、我们自己的色彩科学、我们自己的管线。JPEG、PNG、TIFF、WebP、
PSD 合成图和 JPEG XL 现在就能打开;加 `--features heif` 还能打开 HEIC/HEIF 照片(HEVC 是构建时的选择,
与 PhotoCraft 中一致)。
- **场景参考与宽色域。** 内部使用线性 Rec.2020 浮点、Bradford 适应的白平衡、用色域映射代替裁剪,
raw 走胶片式肩部,未改动过的 JPEG 则像素级直通。
- **分辨率无关的编辑。** 半径和画笔大小都相对于图像,所以 400 px 的预览、你的 5K 显示器和 60 MP 的导出
看起来完全一致。
- **GPU 加速,与 CPU 一致。** 整条显影管线都作为 wgpu 计算内核运行(Metal/Vulkan/DX12),
并与 CPU 管线比对到 1/255 以内。在 24 MP raw 上(Apple M4 Pro):一次滑块更新约 4 ms 重新渲染,
冷启动 2.5 MP 放大视图约 30 ms,全尺寸导出约 0.3 s(含并行 JPEG 编码)。
没有 GPU 时同一条管线在所有 CPU 核心上运行,只重做受滑块影响的那些阶段。批量导出会并排渲染多张照片,
所以一张的解码和编码能与另一张的渲染重叠。
- **即时挑片。** 打开一张 raw 会在约 0.1 s 内显示其内嵌的相机预览或缓存渲染,完整渲染随后跟上
(24 MP 约 0.2–0.5 s)。上一张和下一张会在后台准备好,因此翻阅一次拍摄时每张约 50 ms。
- **后台渲染。** 一个工作线程池在 UI 线程之外渲染放大视图、修改前后对比和每一个可见缩略图:
拖动时用草稿,松手后出全质量。
- **本地优先。** 没有账号、没有云、没有遥测、没有订阅。你的目录是一份只追加的可读操作日志,
可以 diff、备份或重放。
<br>
## Feature status
LightCraft 很年轻,而且进展很快。**我们如实所处的位置**(细节见[路线图](ROADMAP.md#where-we-stand)):
- **按功能数量算,我们达到了 Lightroom 的约 79%**(核心功能 98%),逐行记录在
[docs/parity.md](docs/parity.md)。
- **作为日常替代 Lightroom 的工具,我们更接近 60–70%。** 在单台机器上处理 JPEG/DNG 以及大多数尼康/索尼/
较老佳能的 raw 都很出色。
- **最大的缺口:**
- **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCE-7CR、ILCE-7M4、X-H2S 和 X-T4 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵;
- **压缩的奥林巴斯 raw 和不受支持的 CR3 变体:** 这些在有内嵌 JPEG 预览时使用预览。富士无损/有损压缩 RAF 现在能解码传感器数据;[验证与已有图库重新加载说明](docs/raf-compression.md);
- **AI 蒙版与降噪:** 主体和天空选择目前是经典启发式算法;
- **屏幕上的 HDR 显示、视频,以及经典版式中的打印/画册/地图模块。**
- **接下来做什么:** 见[我们要去往哪里](ROADMAP.md#where-were-going)。
| 领域 | 状态 |
|---|---|
| 图库:相册、文件夹、智能相册、堆叠(含自动堆叠)、虚拟副本、星级、旗标、标签、过滤器栏、搜索、排序、网格、胶片窗格 | ✅ |
| 挑片:比较(同步缩放)和筛选视图、自动前进、即时预览 | ✅ |
| Light、Color、Effects(暗角样式)、色调曲线(+ 细化饱和度、目标调整)、颜色混合器(+ 目标调整)、点颜色、颜色分级、校准、黑白 | ✅ |
| 蒙版:画笔、线性、径向、亮度/颜色范围、添加/减去/相交 | ✅(AI 主体/天空目前使用经典启发式算法) |
| 裁剪、拉直工具 + 自动拉直、翻转、旋转、长宽比、叠加参考线 | ✅ |
| 配置文件(Color、Neutral、Vivid、Landscape、Portrait、Monochrome:我们自己的风格)、预设、版本、历史记录、复制/粘贴/同步设置 | ✅ |
| 相机色彩:DNG 文件使用其自带矩阵 | ✅ DNG · 🟡 我们自己的索尼/富士配置文件;缺少实测校准数据库 |
| 原生 macOS 菜单栏(由命令注册表生成)、控制通道 + 每个控件可寻址、无界面 UI 快照 | ✅ |
| RAW:DNG、CR2、CR3(无损 CRX Bayer 以及版本 0x100/0x200 的 C-RAW)、ARW、NEF(未压缩 + 无损/有损压缩)、富士 RAF(未压缩 + 无损/有损压缩,Bayer + X-Trans)、松下 RW2/徕卡 RWL/Panasonic RAW(所有 raw 格式,从 DMC-LX1 到 DC-S1RM2)、宾得 PEF、奥林巴斯 ORF(未压缩)、三星 SRW(未压缩);所有格式的嵌入预览,含 CR3 | 🟡 · CR3、压缩 ORF 和压缩 SRW 解码 ⬜ |
| 细节:锐化、亮度 + 色彩降噪 | ✅ · AI 降噪、超分辨率 ⬜ |
| 污点去除/修复/仿制(自动来源)、可视化斑点、红眼和宠物眼(自动瞳孔检测、眼神光) | ✅ · 内容识别填充、污点图钉编辑 🚧 |
| 导出:JPEG/PNG/TIFF/WebP/AVIF/DNG/原始格式,尺寸调整、文件大小上限、输出锐化、命名模板、批处理、元数据策略、文字或图像水印 | ✅ |
| HDR:带余量上限的 HDR 编辑、SDR 呈现、可视化 HDR;导出为 ISO 21496-1 增益图 JPEG、PQ AVIF 或 32 位浮点 TIFF | ✅ · HDR 显示 ⬜ |
| 图库持久化(防崩溃的操作日志 + 快照、后台压缩、保存失败会上报)、磁盘缩略图缓存 | ✅ |
| 导入:在原位置添加/复制/移动、重命名与文件夹模板、设备、重复检测、监视文件夹;本地文件夹浏览 | ✅ |
| MCP 服务器(无界面或运行中的应用、持久化图库)、CLI、控制通道 | ✅ |
| XMP 附属文件(读取/写入、自动写入)、读取 `crs:` 显影设置、预设文件(`.lcpreset`、XMP 预设) | ✅ |
| 镜头校正(畸变、暗角、自动 + 手动色差、去边、DNG 文件内嵌的镜头校正以及松下/徕卡 RW2/RWL 畸变数据)、几何(变换、限制裁剪)、垂直校正(自动/水平/垂直/完整/引导式) | ✅ · 相机镜头配置文件(我们自己的)⬜ |
| 照片合并:HDR(自动对齐、去重影)、全景(球面/圆柱/透视、边界变形、自动裁剪)、HDR 全景 → DNG | ✅ |
| GPU 管线(wgpu 计算,与 CPU 一致到 1/255 以内)、设备限制/出错时回退到 CPU | ✅ · 浏览器中的 WebGPU 🚧 |
| AI:分割蒙版、AI 降噪、超分辨率、人脸;视频 | ⬜(见[路线图](ROADMAP.md#where-were-going)) |
| 网页版(通过 WASM 在浏览器中使用同一界面):图库持久化在 OPFS/IndexedDB、Web Worker 渲染、导出下载 | ✅ · WebGPU、Safari/Firefox 测试 🚧 |
<sub>✅ 现已可用 · 🚧 进行中 · ⬜ 尚未开始</sub>
<br>
## Quick start
```sh
git clone https://github.com/storytold/lightcraft && cd lightcraft
cargo run --release -p lightcraft # 打开你的图库(~/Pictures/LightCraft Library;新图库会先带演示照片)
cargo run --release -p lightcraft -- ~/Pictures/trip # 导入你的照片(扫描文件夹,跳过重复项)
cargo run --release -p lightcraft -- --memory # 用完即弃的内存演示会话(不写入任何内容)
cargo run --release -p lightcraft -- --control 7980 # 带自动化通道
cargo xtask web --serve # 在浏览器中运行同一个应用:http://127.0.0.1:8080/
cargo run --release -p lightcraft-cli -- render photo.jpg -o out.jpg --set light.exposure=0.5
cargo xtask ci # fmt、clippy、测试、分层、wasm 检查
```
CI 默认使用行表调试信息,并按启动时可用内存每 1.5 GB 开一个构建任务
(每个 CPU 最多一个;读不到内存时为 4 个),所以一台 8 GB、空闲 6 GB 的机器会用 4 个任务构建,
而一台空闲内存很少的繁忙机器会用更少。测试线程数随之调整,上限为 4。否则,每个 CPU 一个的
完整调试信息链接器会在任何测试运行之前就耗尽内存。GPU 覆盖范围不变。显式设置
`CARGO_PROFILE_DEV_DEBUG`、`CARGO_BUILD_JOBS` 和 `RUST_TEST_THREADS` 会覆盖这些默认值。
日常开发命令保持它们各自的设置。
**中文和日文文字**需要共享字体仓库,这是一个可选的构建输入(所有正式发布版都会包含它):
```sh
git clone https://github.com/storytold/craft-fonts ../craft-fonts
CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft
```
不指定它时,LightCraft 构建和运行的方式完全相同,只是中文和日文文字没有字形。字体从不提交到
本仓库;见 [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)。
**界面语言:** **编辑 → 语言**(English、简体中文、繁體中文(台灣)、日本語、Português (Brasil)、Español、Deutsch、Русский、Français)或 **设置 → 一般 →
语言**;选择立即生效并会保留。见 [docs/localization.md](docs/localization.md)。
**日志:** 桌面应用把日志写到标准错误,以及设置文件夹里的 `logs/lightcraft.log`
(Linux `$XDG_CONFIG_HOME/lightcraft/logs/`,默认 `~/.config/lightcraft/logs/`;macOS
`~/Library/Application Support/LightCraft/logs/`;Windows `%APPDATA%\LightCraft\logs\`),从不写进图库。
从桌面菜单或 Dock 启动没有终端,所以请把这个文件附在错误报告里(**帮助 ▸ 打开日志
文件夹** 会在文件管理器中显示它)。每次启动会把
上一个日志移到 `lightcraft.1.log`,再上一个移到 `lightcraft.2.log`,这样崩溃那次的日志能在下次启动后
保留下来;文件到 16 MiB 停止增长,`--version` 和 `--help` 不写日志,带
`LIGHTCRAFT_NO_PREFS` 运行时只写到标准错误。默认情况下 LightCraft 自己的 crate 记到 `info`,其他都记到
`warn`。`LIGHTCRAFT_LOG=info` 或 `debug` 的行为和以前一样(LightCraft 自己的 crate 用该级别,其余只记警告和
错误;其他值:只记警告和错误),并且优先于 `RUST_LOG`,后者会把默认值替换为
env_logger 风格的指令,例如 `RUST_LOG=debug` 或 `RUST_LOG=warn,lightcraft_pipeline=trace`(以
`*` 结尾的指令覆盖所有以它开头的目标,如 `lightcraft*=debug`)。panic 也会记录在那里,
并且仍然写在临时文件夹里的 `lightcraft-panics.log`。日志器是 `apps/lightcraft/src/logging.rs`。
网页版需要 `wasm32-unknown-unknown` target 和匹配的 `wasm-bindgen` CLI
(`cargo xtask web` 会打印确切的安装命令);见 [docs/web.md](docs/web.md)。
**Nix** 会构建桌面应用和 `lightcraft-cli`(Nix 构建总是包含 craft-fonts 输入,所以日文
文字有字形):
```sh
nix run github:storytold/lightcraft # 桌面应用
nix build github:storytold/lightcraft # → ./result/bin/{lightcraft,lightcraft-cli}
nix develop github:storytold/lightcraft # rust 工具链 + 原生依赖 + 字体
```
在 flake 配置中(NixOS、home-manager、nix-darwin):
```nix
# flake.nix
inputs.lightcraft.url = "github:storytold/lightcraft";
# 可选:使用你自己的 nixpkgs,而不是 LightCraft 锁定的那个
# inputs.lightcraft.inputs.nixpkgs.follows = "nixpkgs";
# 然后在 NixOS 或 home-manager 模块中(`inputs` 在作用域内):
nixpkgs.overlays = [ inputs.lightcraft.overlays.default ]; # 让 `pkgs.lightcraft` 可用
environment.systemPackages = [ pkgs.lightcraft ]; # home-manager:home.packages = [ pkgs.lightcraft ];
```
`nix build` 会安装与 .deb/.rpm 相同的 desktop 文件、hicolor 图标和 AppStream 元数据,并运行
`cargo test --workspace` 作为它的检查阶段(用 `pkgs.lightcraft.overrideAttrs { doCheck = false; }` 跳过)。
**键盘:** <kbd>G</kbd> 网格 · <kbd>D</kbd> 细节 · <kbd>E</kbd> 编辑 · <kbd>C</kbd> 裁剪 · <kbd>M</kbd> 蒙版 ·
<kbd>Shift</kbd>+<kbd>P</kbd> 预设 · <kbd>\\</kbd> 原图 · <kbd>Y</kbd> 修改前后 · <kbd>Z</kbd> 缩放 ·
<kbd>J</kbd> 溢出警告 · <kbd>⌘Z</kbd> 撤销 · <kbd>⌘/</kbd> 全部快捷键。
## How it's built
一个引擎优先的 Cargo 工作区,由小而经过测试的 crate 组成,并强制分层(`cargo xtask layers`):`geom`、
`color`、`raster`、`tiff` → `raw`、`codecs`、`meta`、`develop` → `pipeline` → `catalog` → `engine` → `ui-egui`。
egui 前端是一个可替换的 crate;它下面没有任何东西知道 UI 的存在。`mcp` 和应用(`lightcraft`、
`lightcraft-cli`)坐落在 `engine` 之上。
## Contributing
人和智能体遵循同样的规则,所以请先读 [AGENTS.md](AGENTS.md)。简短版本:
- **净室。** 绝不阅读 Adobe 的二进制文件或 GPL 的 raw/照片代码(darktable、RawTherapee、LibRaw、rawspeed、dcraw……);
只依据公开规范和黑盒观察来工作。
- **绝不使用 Adobe 资源:** 不使用 Adobe 产品的图标、截图、预设、配置文件、LUT 或字体。仓库中的每一个
图像、图标和字体都是原创、公有领域、知识共享、OFL 或宽松许可的,并在同一提交中
于 [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) 添加条目。新字体提交到
[storytold/craft-fonts](https://github.com/storytold/craft-fonts),而不是这里。
- **纯 Rust**、强制 crate 分层、一切皆命令,并且每次提交前 `cargo xtask ci` 必须通过
(一个提交一个任务 ID)。
- **绝不崩溃。** 非测试代码返回错误而不是 panic:不用 `unwrap()`、`expect()`、`panic!` 或
`unsafe`,对任何来自输入的数据都做受检索引,并且每个崩溃修复都带一个回归测试。细节见
[AGENTS.md](AGENTS.md#never-crash-outranks-feature-work)。
有问题、想法,或者想先讨论一个 bug?来 [Discord](https://discord.gg/artcraft)。
<br>
## Downloads
**第一次用 LightCraft?** 从 [getartcraft.com 的 LightCraft 页面](https://getartcraft.com/apps/lightcraft)下载。那是最简单的安装方式。
**想要特定的构建或格式?** 在 GitHub 上,[最新发布](https://github.com/storytold/lightcraft/releases/latest)包含下面列出的所有构建,[全部发布](https://github.com/storytold/lightcraft/releases)则有更早的版本及其说明。文件名中的 `<ver>` 是版本号,`SHA256SUMS.txt` 列出了每个文件的校验和。
### Windows
| 构建 | 安装程序 | 便携版 |
|---|---|---|
| x64(64 位 Intel/AMD) | `lightcraft-<ver>-windows-x64.msi` | `lightcraft-<ver>-windows-x64-portable.zip` |
| arm64(骁龙及其他 ARM PC) | `lightcraft-<ver>-windows-arm64.msi` | `lightcraft-<ver>-windows-arm64-portable.zip` |
| x86(32 位) | `lightcraft-<ver>-windows-x86.msi` | `lightcraft-<ver>-windows-x86-portable.zip` |
安装程序和可执行文件都有代码签名。安装程序会询问安装位置(默认
`C:\Program Files\LightCraft`;升级会留在你选择的文件夹),结束时有一页确认 LightCraft 已安装,
并可选择启动它。无人值守安装:`msiexec /i lightcraft-<ver>-windows-x64.msi /qn INSTALLFOLDER="D:\Apps\LightCraft\"`。
### macOS
| 构建 | 文件 | 说明 |
|---|---|---|
| 应用,通用(Apple 芯片 + Intel) | `lightcraft-<ver>-macos-universal.dmg` | 已签名并公证 |
| 命令行工具,通用 | `lightcraft-cli-<ver>-macos-universal.zip` | 已签名并公证 |
### Linux
| 格式 | x86_64 | aarch64 (ARM64) | 说明 |
|---|---|---|---|
| AppImage | `lightcraft-<ver>-linux-x86_64.AppImage` | `lightcraft-<ver>-linux-aarch64.AppImage` | 随处可运行;用 [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate)(`.zsync` 文件)自我更新 |
| Flatpak | `lightcraft-<ver>-linux-x86_64.flatpak` | `lightcraft-<ver>-linux-aarch64.flatpak` | 沙箱化;`flatpak install --user <file>` |
| Debian/Ubuntu | `lightcraft-<ver>-linux-x86_64.deb` | `lightcraft-<ver>-linux-aarch64.deb` | |
| Fedora/RHEL/openSUSE | `lightcraft-<ver>-linux-x86_64.rpm` | `lightcraft-<ver>-linux-aarch64.rpm` | |
| 压缩包 | `lightcraft-<ver>-linux-x86_64.tar.gz` | `lightcraft-<ver>-linux-aarch64.tar.gz` | 解压到任意位置 |
### FreeBSD
| 构建 | 文件 |
|---|---|
| x86_64 | `lightcraft-<ver>-freebsd-x86_64.tar.gz` |
### 网页版(WebAssembly)
| 构建 | 文件 | 说明 |
|---|---|---|
| 静态站点 | `lightcraft-web-<ver>.zip` | 在现代浏览器中运行;托管到任意静态服务器 |
<br>
## The Crafting Apps
LightCraft 是 **Crafting Apps** 之一:来自 [ArtCraft](https://getartcraft.com/) 团队的免费开源创作工具,
每一个都用 Rust 从零编写,每一个都能独立使用。
| | 应用 | 用途 | 代码 | 了解更多 |
|:-:|---|---|---|---|
| <img src="https://raw.githubusercontent.com/storytold/photocraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png" alt="" width="32" height="32"> | **PhotoCraft** | 图像编辑:图层、蒙版、文字和真正的 PSD 文件 | [GitHub](https://github.com/storytold/photocraft) | [网站](https://getartcraft.com/apps/photocraft) |
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | 矢量插画 | [GitHub](https://github.com/storytold/vectorcraft) | [网站](https://getartcraft.com/apps/vectorcraft) |
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | 视频剪辑、调色与声音 | [GitHub](https://github.com/storytold/filmcraft) | [网站](https://getartcraft.com/apps/filmcraft) |
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | **照片图库与 raw 显影 · 你在这里** | [GitHub](https://github.com/storytold/lightcraft) | [网站](https://getartcraft.com/apps/lightcraft) |
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | 阅读、整理和保护 PDF | [GitHub](https://github.com/storytold/pdfcraft) | [网站](https://getartcraft.com/apps/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | 动态图形与视觉特效 | [GitHub](https://github.com/storytold/effectcraft) | [网站](https://getartcraft.com/apps/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | 页面排版与出版 | [GitHub](https://github.com/storytold/designcraft) | [网站](https://getartcraft.com/apps/designcraft) |
以及 [**ArtCraft**](https://getartcraft.com/) 本身,我们为想要真正掌控力的创作者打造的 AI 图像与视频工作室。
<br>
<p align="center">
<a href="https://discord.gg/artcraft"><img alt="Join the ArtCraft community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
</p>
<h3 align="center">Come make things with us</h3>
<p align="center">
我们的 Discord 是各种创作者聚集的地方:画画的人、拍照的人、做动画的人、剪片子的人、
排版的人,还有仍在摸索自己喜欢做什么的人。分享你在做的东西,
寻求帮助,告诉我们哪里坏了,或者告诉我们你希望这些工具能做什么。
无论你用什么媒介,无论你做了多久,这里都欢迎你。
</p>
<p align="center">
<a href="https://discord.gg/artcraft"><b>discord.gg/artcraft</b></a> ·
<a href="https://getartcraft.com/">getartcraft.com</a> ·
<a href="https://getartcraft.com/apps">The Crafting Apps</a> ·
<a href="https://getartcraft.com/apps/lightcraft">LightCraft</a>
</p>
<br>
## License and credits
LightCraft 采用 [MIT](LICENSE-MIT) 或 [Apache-2.0](LICENSE-APACHE) 双许可,任你选择。
Copyright (c) 2026 ArtCraft Team and the LightCraft contributors. 必需的声明见 [NOTICE](NOTICE)。
随附的字体、图标、图像和其他资源保留各自的开放许可;每一项都在
[assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) 中列出了作者、来源和许可。
展示照片是经 Wikimedia Commons 使用的公有领域作品:安塞尔·亚当斯,*《特顿山脉与蛇河》*
(1942,美国国家档案馆);多萝西娅·兰格,*《移民母亲》*(1936,美国国会图书馆);比尔·安德斯/NASA,
*《地出》*(1968);NASA,*《蓝色弹珠》*(1972)。演示图库由 LightCraft 程序生成。界面字体:
Inter(SIL OFL 1.1)。用 [craft-fonts](https://github.com/storytold/craft-fonts) 构建的版本(所有正式发布版)
还会内嵌它的中文和日文字体(Noto Sans CJK SC、BIZ UDPGothic、BIZ UDMincho、Shippori Mincho;SIL OFL 1.1),列在其
[ATTRIBUTION.md](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md) 中。所有图标均为原创。
[`docs/brand/`](docs/brand/) 中的 ArtCraft 名称、字标和标志是
ArtCraft 团队的商标,不在本许可范围内。它们只能在未修改的情况下,作为
本仓库和 LightCraft 的一部分,按 [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) 使用。
分支和修改版本必须移除它们。
<sub>Adobe、Photoshop、Illustrator、Premiere Pro、Lightroom、Acrobat、After Effects 和 InDesign 是 Adobe Inc. 在美国和/或其他国家的商标或注册商标。LightCraft 是一个独立的开源项目,与 Adobe Inc. 无隶属关系,也未获其赞助或认可;这些名称仅用于说明它所兼容的工作流。</sub>
<p align="center">
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
<sub>由 <a href="https://getartcraft.com/">ArtCraft</a> 团队和社区制作。</sub>
</p>
## Star history
[![Star History Chart](https://api.star-history.com/svg?repos=storytold/lightcraft&type=Date&legend=top-left)](https://www.star-history.com/?repos=storytold%2Flightcraft&type=date&legend=top-left)

View File

@ -1,172 +1,234 @@
# LightCraft roadmap
**Stage: alpha** · next: beta, ~14 points and ~340–600 h away
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.
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EOS R5 Mark II CR3 verified; RAW decoding 77%) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
## Where we stand
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).
*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.*
## Headline numbers
**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.
| | Value | Kind |
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 |
|---|---:|---|
| **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 |
| 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 |
Each audience with its own hours (Opus 5.5 agent wall-clock hours to ~95%):
## Where we're going
| 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 |
Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/parity.md) → *Top gaps*.
The gap between breadth and readiness is quality and coverage, not missing buttons: camera colour is
estimated rather than measured, CR3 decodes on four verified Canon bodies, compressed ORF and HE NEF
open as previews, rendering is tuned by eye, AI masks are heuristics, there is no lens database, and
tests run in CI on FreeBSD only. **Why alpha:** core workflows run end to end, but readiness is below
the ~75% beta bar (it is above the ~40% alpha bar) and Lightroom's main inputs, raw files, still have blocking gaps (Canon, ~30% of
raw users, decodes CR3 on four verified bodies). It passes the alpha gate: all six core workflows
(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).
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.
## By dimension
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).
| 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) |
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).
## Features
## Milestones
| Area | Ready | Breadth | Remaining |
**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 |
|---|---:|---:|---:|
| Import and library | 80% | 90% | 25–45 h |
| RAW decoding and camera coverage | 77% | 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 |
| 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% |
Weights and evidence: [target-app-parity.md](docs/target-app-parity.md#features).
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.
## Languages
✅ counts 1, 🟡 ½, ⬜ 0; out-of-scope rows (cloud sharing, Adobe accounts…) are left out.
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).
**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):
| Language | Code | Translated | Status |
| Work package | Tracker rows | Agent-hours | Risk |
|---|---|---:|---|
| 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 |
| 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** | |
Also shipped: Traditional Chinese (87%), Russian (74%), Ukrainian (99%).
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).
## Upcoming
**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.
Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/gaps.md](docs/gaps.md).
## Totals
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)
Remaining from 2026-10-02 evening (see *Parity estimate* above for the breakdown):
## Progress log
| 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 |
- 2026-10-11: Canon EOS R5 Mark II C-RAW decodes from sensor data (57% of its files opened as a
preview): the CRX QP map's first-symbol literal and limited-length escape; four CC0 R5 Mark II files
pinned, the QP maps of 33,665 private C-RAW files decode exactly ([cr3.md](docs/cr3.md)). 235 models
verified; RAW decoding 76% → 77%; headline numbers unchanged.
- **2026-10-10 (UI docking):** Library, Tools, and Presets panels can be regrouped, split, floated within the app window, closed, and reopened. Tools keeps the current Edit/Crop/Masking mode; the photo view and filmstrip stay together. Panel moves and reset are available as UI commands, and saved UI preferences retain arrangements. Widget state styling uses the shared `craft-ui` theme helper, retaining the dark palette, typography, spacing and per-state borders. No readiness percentage change.
- 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.
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).
## Revision history
## Risks that coding hours alone don't retire
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | EOS R5 Mark II CR3 verified: CR3 on four verified bodies, RAW decoding 76% → 77%; headline numbers unchanged |
| 2026-10-10 | minor | RAW measured model by model; RAW decode and colour as explicit rows: full 59% → 61%, mainstream 51% → 49%, essentials 63% → 64%; beta ~14 points |
| 2026-10-10 | minor | Full ready restored to the additive weighted sum (~59%; method aligned with the standard, no new evidence); hours per audience; beta ~16 points away |
| 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 |
- **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.

View File

@ -15,7 +15,6 @@ workspace = true
[dependencies]
lightcraft-engine = { workspace = true }
lightcraft-denoise = { workspace = true }
lightcraft-sysmem = { workspace = true }
lightcraft-mcp = { workspace = true }
lightcraft-ui-egui = { workspace = true }
@ -26,14 +25,7 @@ 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"]

View File

@ -7,7 +7,6 @@
//! lightcraft-cli snapshot [--library DIR | --demo] [--script FILE.jsonl] [-o OUT.png] [--size WxH] [--scale S] [FILES…]
//! lightcraft-cli merge hdr|panorama|hdr-panorama [OPTIONS] FILES…
//! lightcraft-cli synth-merge hdr|panorama -o DIR
//! lightcraft-cli denoise-serve [--model ID] [--models DIR] [--listen ADDR] [--token-env VAR] [--run-on auto|gpu|cpu]
//! lightcraft-cli commands [--json]
//! lightcraft-cli controls [--json]
//! lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS/FILES…
@ -52,19 +51,17 @@ USAGE:
--library DIR headless: open (or create) a persistent LightCraft library; edits are saved
--compact list only the helper tools (every command stays reachable via run_command)
lightcraft-cli render <IN> -o <OUT> [OPTIONS]
Develop one file and export it (.jpg, .png, .tif, .webp, .avif, .jxl, .psd or .dng by extension) with the
Develop one file and export it (.jpg, .png, .tif, .webp, .avif or .dng by extension) with the
same encoder as the app's Export dialog. Options:
--set CONTROL=VALUE set a develop slider, repeatable (e.g. --set light.exposure=0.5)
--settings FILE merge a partial develop-settings JSON file
--preset ID apply a preset (see `commands`/presets.list)
--size N long edge in pixels (default: full size, cropped)
--quality Q JPEG/AVIF/JPEG XL quality 1..100 (default 92)
--quality Q JPEG/AVIF quality 1..100 (default 92)
--opt KEY=VALUE any export option of `app.export`, repeatable (VALUE is JSON or a
string), e.g. --opt colorSpace=displayP3 --opt bitDepth=16
--opt percent=50 --opt shortEdge=1080 --opt ppi=300
--opt format=original (copy + XMP sidecar) --opt metadata=none
--opt addToPhotos=true --opt photosAlbum=NAME (macOS: then add
the file to Apple Photos, into that album)
lightcraft-cli snapshot [OPTIONS] [FILES/FOLDERS…]
Run the full app UI headlessly (no window, no GPU: CPU-rasterized egui) and write PNGs.
Options:
@ -88,18 +85,6 @@ USAGE:
--preview OUT.png only render a ≤ 1024 px preview (nothing written next to the files)
lightcraft-cli synth-merge hdr|panorama -o DIR
Write synthetic merge inputs (procedural scene; bracketed DNGs or overlapping PNG views).
lightcraft-cli denoise-serve [OPTIONS]
Run AI Denoise tiles for LightCraft on other computers (LIGHTCRAFT_DENOISE_URL=<this host>:7990 and
LIGHTCRAFT_DENOISE_TOKEN there; see docs/denoise.md). Serves the installed model selected in Settings,
on this computer's graphics card when it can run it, else the CPU. Plain TCP: listen on a VPN/LAN
address (e.g. the Tailscale IP) or firewall the port to it. Options:
--model ID an installed model other than the selected one
--models DIR the models folder (default: LIGHTCRAFT_DENOISE_MODELS, else this user's)
--listen ADDR address:port to listen on (default 127.0.0.1:7990)
--token-env VAR environment variable holding the shared token, ≥ 16 characters
(default LIGHTCRAFT_DENOISE_TOKEN)
--run-on WHERE auto (default), gpu or cpu
--max-connections N concurrent clients (default 8)
lightcraft-cli commands [--json] list every command id with its parameters
lightcraft-cli controls [--json] list every develop control id with its range
lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS/FILES…
@ -130,9 +115,6 @@ fn library_error(dir: &str, e: lightcraft_engine::EngineError) -> String {
lightcraft_engine::EngineError::LibraryInUse(why) => format!(
"{dir}: {why}\nTo work with the library while the app has it open, start the app with `--control PORT` and use `lightcraft-cli mcp --connect 127.0.0.1:PORT`."
),
e @ lightcraft_engine::EngineError::LightroomCatalog { .. } => format!(
"{e}\nOpen a LightCraft library with `--library DIR` (an empty folder makes a new one) and run `library.importLightroom` with the catalog's path."
),
e => format!("{dir}: {e}"),
}
}
@ -163,7 +145,6 @@ fn main() -> ExitCode {
Some("merge") => merge(&args[1..]),
Some("synth-merge") => synth_merge(&args[1..]),
Some("controls") => controls(&args[1..]),
Some("denoise-serve") => denoise_serve(&args[1..]),
Some("calibrate") => calibrate(&args[1..]),
Some("--version" | "-V" | "version") => {
println!("lightcraft-cli {}", env!("CARGO_PKG_VERSION"));
@ -361,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), std::io::stdout()).map_err(|e| e.to_string())
server.serve(BufReader::new(stdin.lock()), std::io::stdout().lock()).map_err(|e| e.to_string())
}
fn merge(args: &[String]) -> Result<(), String> {
@ -410,7 +391,7 @@ fn merge(args: &[String]) -> Result<(), String> {
}
i += 1;
}
let mut s = Session::new().with_fs().with_default_denoise_models().with_system_clock();
let mut s = Session::new().with_fs();
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();
@ -671,7 +652,7 @@ fn render(args: &[String]) -> Result<(), String> {
}
let input = input.ok_or("render: missing input file")?;
let output = output.ok_or("render: missing -o OUTPUT")?;
let mut s = Session::new().with_fs().with_default_denoise_models().with_system_clock();
let mut s = Session::new().with_fs();
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"))?;
@ -700,8 +681,6 @@ fn render(args: &[String]) -> Result<(), String> {
o.format = ExportFormat::parse(&ext)
.ok_or_else(|| format!("{output}: unknown extension (use .jpg .png .tif .webp .avif .dng or --opt format=…)"))?;
}
// Add to Apple Photos: refused before anything is written when it can't be done
let after = s.after_export(&o)?;
let e = export_photo(&mut s, lightcraft_engine::catalog::PhotoId(id), &o, 1)?;
// never over the input (or its sidecar), however it is spelled: `render IMG.jpg -o IMG.jpg`
let sidecars: Vec<(String, &Vec<u8>)> =
@ -716,12 +695,6 @@ fn render(args: &[String]) -> Result<(), String> {
eprintln!("lightcraft-cli: wrote {sc}");
}
eprintln!("lightcraft-cli: wrote {output} ({}×{})", e.width, e.height);
if let Some(photos) = after.run(&[json!({"path": output})], false) {
if let Some(err) = photos.get("error").and_then(Value::as_str) {
return Err(format!("render: {output} was written, but {err}"));
}
eprintln!("lightcraft-cli: added to Apple Photos: {photos}");
}
Ok(())
}
@ -755,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());
}
lightcraft_ui_egui::headless::viewport_pixels(size, scale).map_err(|e| format!("snapshot: bad --size/--scale: {e}"))?;
if !(scale > 0.0 && size[0] >= 1.0 && size[1] >= 1.0) {
return Err("snapshot: bad --size/--scale".into());
}
let t0 = Instant::now();
let mut session = match &library {
Some(dir) => {
let mut s = Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models();
let mut s = Session::new().with_fs();
s.open_library(dir, false).map_err(|e| library_error(dir, e))?;
s
}
None if files.is_empty() => Session::with_demo().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models(),
None => Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models(),
None if files.is_empty() => Session::with_demo().with_fs(),
None => Session::new().with_fs(),
};
// 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())?;
@ -782,11 +755,7 @@ fn snapshot(args: &[String]) -> Result<(), String> {
})),
..Default::default()
};
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 app = lightcraft_ui_egui::LightcraftApp::new(session, services);
let mut h = Headless::new(app, size, scale);
let timeout = Duration::from_secs(60);
let mut shots = 0usize;
@ -924,48 +893,3 @@ fn controls(args: &[String]) -> Result<(), String> {
}
Ok(())
}
/// `denoise-serve`: the remote half of AI Denoise (`lightcraft_denoise::remote`).
#[cfg(feature = "denoise")]
fn denoise_serve(args: &[String]) -> Result<(), String> {
use lightcraft_denoise::remote::{DEFAULT_PORT, ServerOptions, serve};
use lightcraft_engine::denoise::RunOn;
let mut model: Option<String> = None;
let mut dir: Option<String> = None;
let mut listen = format!("127.0.0.1:{DEFAULT_PORT}");
let mut token_env = "LIGHTCRAFT_DENOISE_TOKEN".to_string();
let mut run_on = RunOn::Auto;
let mut max_connections = 8usize;
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--model" => model = Some(take_value(args, &mut i, "--model")?.to_string()),
"--models" => dir = Some(take_value(args, &mut i, "--models")?.to_string()),
"--listen" => listen = take_value(args, &mut i, "--listen")?.to_string(),
"--token-env" => token_env = take_value(args, &mut i, "--token-env")?.to_string(),
"--run-on" => {
let v = take_value(args, &mut i, "--run-on")?;
run_on = RunOn::parse(v).ok_or_else(|| format!("--run-on expects auto, gpu or cpu, not `{v}`"))?;
}
"--max-connections" => {
max_connections = take_value(args, &mut i, "--max-connections")?.parse().map_err(|_| "--max-connections expects a number")?
}
other => return Err(format!("denoise-serve: unknown option `{other}`")),
}
i += 1;
}
let token = std::env::var(&token_env).map_err(|_| format!("denoise-serve: set the shared token in ${token_env}"))?;
let s = lightcraft_engine::denoise::serving(dir.as_deref().map(Path::new), model.as_deref(), run_on)?;
let listener = std::net::TcpListener::bind(&listen).map_err(|e| format!("denoise-serve: {listen}: {e}"))?;
eprintln!("lightcraft-cli denoise-serve: {} {} ({}) on {listen} via {}", s.model.id, s.model.version, s.model.sha256, s.device);
if listen.starts_with("0.0.0.0") || listen.starts_with("[::]") {
eprintln!("lightcraft-cli denoise-serve: listening on every interface; firewall the port to your VPN/LAN (the protocol is not encrypted)");
}
let opts = ServerOptions { token, max_connections, ..Default::default() };
serve(listener, s.model, s.tile, s.device, s.run, opts, std::sync::Arc::new(|m: &str| eprintln!("lightcraft-cli denoise-serve: {m}")))
}
#[cfg(not(feature = "denoise"))]
fn denoise_serve(_: &[String]) -> Result<(), String> {
Err("denoise-serve: this build has no denoise support (build with the `denoise` feature)".into())
}

View File

@ -7,46 +7,6 @@ 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();
@ -273,55 +233,6 @@ 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]
@ -501,145 +412,3 @@ 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());
}
}
/// `denoise-serve` serves the selected installed model; a client greets it and gets the CPU's answer for a tile
/// (within half-float rounding on the wire), and a wrong token or a missing token variable is refused.
#[cfg(feature = "denoise")]
#[test]
fn denoise_serve_runs_tiles_for_another_computer() {
use lightcraft_denoise::manifest::{DenoiserManifest, Domain, Gain};
use lightcraft_denoise::remote::Remote;
use lightcraft_denoise::run::TileRunner;
let models = tmp("denoise-models");
let _ = std::fs::remove_dir_all(&models);
let dir = models.join("synthetic");
std::fs::create_dir_all(&dir).unwrap();
let manifest = DenoiserManifest {
id: "synthetic".into(),
name: "Synthetic".into(),
version: "1".into(),
licence: Default::default(),
source: None,
sha256: None,
size_bytes: None,
provenance: "made for a test".into(),
domain: Domain::BayerToRgb,
tile: 64,
overlap: 16,
gain: Gain::None,
};
let onnx = dir.join("model.onnx");
std::fs::write(&onnx, lightcraft_denoise::synthetic::unet_onnx(64, 8, 2, 7)).unwrap();
std::fs::write(dir.join("denoise-model.json"), serde_json::to_vec(&manifest).unwrap()).unwrap();
std::fs::write(models.join("settings.json"), r#"{"model": "synthetic"}"#).unwrap();
let sha = lightcraft_denoise::hash::sha256_file(&onnx).unwrap();
// without its token the server doesn't start
let o = Command::new(BIN).args(["denoise-serve", "--models", models.to_str().unwrap(), "--token-env", "LC_TEST_NO_SUCH_VAR"]).output().unwrap();
assert!(!o.status.success() && String::from_utf8_lossy(&o.stderr).contains("LC_TEST_NO_SUCH_VAR"));
let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
let addr = format!("127.0.0.1:{port}");
let token = "cli-test-token-0123456789";
let mut child = Command::new(BIN)
.args(["denoise-serve", "--models", models.to_str().unwrap(), "--listen", &addr, "--run-on", "cpu"])
.env("LIGHTCRAFT_DENOISE_TOKEN", token)
.stderr(Stdio::piped())
.spawn()
.unwrap();
// wait for the banner
let mut err = BufReader::new(child.stderr.take().unwrap());
let mut banner = String::new();
err.read_line(&mut banner).unwrap();
assert!(banner.contains("synthetic 1") && banner.contains(&addr), "{banner}");
let remote = Remote::new(&addr, token, &sha, 64);
let info = remote.hello().unwrap();
assert!(info.device.starts_with("CPU"), "{info:?}");
let (input, _) = lightcraft_denoise::runtime::test_tile(64);
let got = remote.try_run(&input).unwrap().unwrap();
let want = lightcraft_denoise::runtime::CpuRunner::load(&onnx, &manifest).unwrap().run(&input).unwrap();
let scale = want.iter().fold(1e-9f32, |m, v| m.max(v.abs()));
let worst = want.iter().zip(&got).fold(0f32, |m, (a, b)| m.max((a - b).abs())) / scale;
assert!(worst < 5e-3, "remote vs local: {worst}");
assert!(Remote::new(&addr, "a-wrong-token-000000", &sha, 64).hello().is_err());
let _ = child.kill();
let _ = child.wait();
let _ = std::fs::remove_dir_all(&models);
}

View File

@ -71,9 +71,3 @@ 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"]

View File

@ -24,21 +24,13 @@
<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, font] = await Promise.all([
WebAssembly.compileStreaming(fetch("./lightcraft_web_bg.wasm")),
chineseFont,
]);
const module = await WebAssembly.compileStreaming(fetch("./lightcraft_web_bg.wasm"));
globalThis.lightcraftModule = module;
await init({ module_or_path: module });
// automation: `await lightcraft.command("library.info", "{}")` → JSON text
globalThis.lightcraft = { command };
start(font);
start();
})().catch((e) => {
console.error(e);
const el = document.getElementById("lightcraft_loading");

View File

@ -11,9 +11,8 @@
//!
//! 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; 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.
//! 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.
//!
//! No zip64: a backup holds at most [`MAX_BYTES`] and 65 535 entries.
@ -242,16 +241,6 @@ 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)
@ -294,27 +283,6 @@ 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"));

View File

@ -1,6 +1,6 @@
//! LightCraft in the browser: the same egui UI as the desktop app, compiled to WASM.
//!
//! Build with `cargo xtask web` (→ `target/web-site/`), then serve that folder over HTTP (see
//! Build with `cargo xtask web` (→ `target/web/`), then serve that folder over HTTP (see
//! `docs/web.md`). Differences from the desktop host:
//! - the library lives in browser storage (OPFS, or IndexedDB where OPFS can't be written from the
//! main thread): catalog journal, presets, view state and prefs are mirrored in memory and

View File

@ -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, verify_original,
valid_hash, verify,
};
use crate::files::{Files, LIBRARY_FILES};
use crate::store::storage_key;
@ -93,41 +93,25 @@ 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, or
/// it refused the request).
/// `Some(false)`: another tab of this site has it; `None`: the browser can't tell (no Web Locks).
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 }
});
// 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);
}
if let Err(e) = request.call3(&locks, &TAB_LOCK.into(), &opts, &cb) {
log::warn!("Web Locks request failed: {e:?}");
}
});
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
}
}
JsFuture::from(granted).await.ok().map(|v| v.is_truthy())
}
/// Offer a blob as a download named `name`.
@ -224,9 +208,6 @@ 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;
}

View File

@ -180,7 +180,6 @@ pub fn mime_for(name: &str) -> &'static str {
Some("tif" | "tiff") => "image/tiff",
Some("webp") => "image/webp",
Some("avif") => "image/avif",
Some("jxl") => "image/jxl",
_ => "application/octet-stream",
}
}

View File

@ -168,9 +168,6 @@ fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen
let (backup_backend, backup_files) = (backend.clone(), files);
let restore_backend = backend.clone();
Services {
picker: None,
pick_display_profile: None,
screen_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
@ -207,16 +204,11 @@ 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)),
@ -226,16 +218,12 @@ fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default()
})),
reveal: None,
log_file: None,
open_with: None,
check_editor: None,
open_url: Some(Box::new(|url: &str| {
let w = web_sys::window().ok_or("no window")?;
w.open_with_url_and_target(url, "_blank").map(|_| ()).map_err(|_| "the browser blocked the new tab".to_string())
})),
pick_folder: None,
// the browser reports prefers-color-scheme to egui
system_theme: None,
}
}
@ -385,7 +373,7 @@ struct WebApp {
}
impl WebApp {
fn new(cc: &eframe::CreationContext<'_>, boot: Boot, chinese_font: Vec<u8>) -> Self {
fn new(cc: &eframe::CreationContext<'_>, boot: Boot) -> Self {
let Boot { opts, backend, files, index, lib_dir } = boot;
let originals = Originals::default();
let t = perf_now();
@ -430,8 +418,7 @@ 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()))
.with_chinese_font(chinese_font);
let mut app = LightcraftApp::new(session, services(originals.clone(), backend.clone(), files.clone(), frozen.clone(), cc.egui_ctx.clone()));
let ui_written = files.get("ui.json").unwrap_or_default();
if let Ok(ui) = serde_json::from_slice::<UiState>(&ui_written) {
app.ui = ui;
@ -664,15 +651,12 @@ impl eframe::App for WebApp {
}
}
/// Start the app with the separately fetched Simplified Chinese font (empty in font-free builds).
/// Start the app (called by `index.html` after instantiating the module).
#[wasm_bindgen]
pub fn start(chinese_font: Vec<u8>) {
pub fn start() {
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
@ -693,7 +677,7 @@ pub fn start(chinese_font: Vec<u8>) {
};
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, chinese_font))))).await;
let r = runner.start(canvas, eframe::WebOptions::default(), Box::new(move |cc| Ok(Box::new(WebApp::new(cc, boot))))).await;
match r {
Ok(()) => {
if let Some(el) = window().and_then(|w| w.document()).and_then(|d| d.get_element_by_id("lightcraft_loading")) {

View File

@ -36,8 +36,6 @@ pub struct WireJob {
pub raw: bool,
#[serde(default)]
pub relative_wb: bool,
#[serde(default)]
pub legacy_relative_wb: bool,
pub as_shot_temp: f64,
pub as_shot_tint: f64,
pub lens: Option<EmbeddedLens>,
@ -55,9 +53,6 @@ pub struct WireJob {
/// The request's diagnostic overlay (`Overlay::to_parts`).
#[serde(default)]
pub overlay: (u8, f64),
/// Render only this window `[x, y, w, h]` of the output `max_w × max_h` describes (a zoomed view).
#[serde(default)]
pub window: Option<[usize; 4]>,
}
impl WireJob {
@ -76,7 +71,6 @@ impl WireJob {
max_edge,
raw: job.info.raw,
relative_wb: job.info.relative_wb,
legacy_relative_wb: job.info.legacy_relative_wb,
as_shot_temp: job.info.as_shot_temp,
as_shot_tint: job.info.as_shot_tint,
lens: job.info.lens,
@ -89,7 +83,6 @@ impl WireJob {
thumb_cached,
stages: job.stages.is_some().then(|| view.to_string()),
overlay: job.request.overlay.to_parts(),
window: job.request.window.map(|w| [w.x, w.y, w.w, w.h]),
}
}
@ -100,7 +93,6 @@ impl WireJob {
as_shot_tint: self.as_shot_tint,
lens: self.lens,
relative_wb: self.relative_wb,
legacy_relative_wb: self.legacy_relative_wb,
..Default::default()
}
}
@ -116,8 +108,6 @@ impl WireJob {
space: lightcraft_engine::pipeline::OutputSpace::Srgb,
depth: lightcraft_engine::pipeline::OutputDepth::U8,
proof: None,
window: self.window.map(|[x, y, w, h]| lightcraft_engine::pipeline::PixelWindow { x, y, w, h }),
display: None,
}
}
@ -188,18 +178,13 @@ impl WorkerCore {
}
};
let info = src.info_or(job.info());
let mut rendered = match &job.stages {
Ok(match &job.stages {
Some(view) => {
let st = self.stages.entry(view.clone()).or_default().clone();
lightcraft_engine::pipeline::render_cached(&src.image, &info, &job.settings, &job.request(), &st)
}
None => lightcraft_engine::pipeline::render(&src.image, &info, &job.settings, &job.request()),
};
// an HDR edit's histogram shows its HDR range (as on native)
if let Some(h) = lightcraft_engine::pipeline::hdr_histogram(&src.image, &info, &job.settings, &job.request()) {
rendered.histogram = h;
}
Ok(rendered)
})
}
}
@ -341,46 +326,11 @@ impl CacheWatch {
}
}
/// Hands out the positions that render workers will have in the main thread's worker list.
/// A worker that fails to start gets no position, so the position a callback captures stays the
/// index of its own worker in that list (#229).
#[derive(Debug, Default)]
pub struct WorkerSlots {
next: usize,
}
impl WorkerSlots {
/// The position the worker being started will have, if it comes up.
pub fn peek(&self) -> usize {
self.next
}
/// The worker at [`Self::peek`] came up and was added to the list.
pub fn commit(&mut self) {
self.next += 1;
}
}
#[cfg(test)]
mod tests {
use super::*;
use lightcraft_engine::Session;
// Issue #229: a failed worker attempt must not shift the positions of the workers after it
#[test]
fn a_failed_worker_attempt_leaves_no_gap_in_the_slots() {
let mut slots = WorkerSlots::default();
let mut taken = Vec::new();
for attempt_ok in [false, true, true, true] {
let i = slots.peek();
if attempt_ok {
taken.push(i);
slots.commit();
}
}
assert_eq!(taken, vec![0, 1, 2]);
}
#[test]
fn clearing_index_changes_disk_namespace_and_survives_restart() {
let key = lightcraft_preview::hash_bytes(b"thumbnail");
@ -398,40 +348,6 @@ mod tests {
assert!(reopened.contains(&new));
}
// Issue #323: a worker given a zoomed view's window renders that window, not the whole frame
#[test]
fn a_window_job_crosses_the_wire_and_renders_the_same_window() {
use lightcraft_engine::pipeline::PixelWindow;
let mut s = Session::with_demo();
let id = s.visible_cloned()[0];
let win = PixelWindow { x: 300, y: 200, w: 160, h: 120 };
let job = s.region_job(id, 1920, 1280, win, true).unwrap();
let wire = WireJob::from_job(&job, false, "Region");
let wire: WireJob = serde_json::from_str(&serde_json::to_string(&wire).unwrap()).unwrap();
assert_eq!(wire.request().window, Some(win));
let mut core = WorkerCore::default();
let a = core.render(&wire, None).unwrap();
let b = job.run().rendered.unwrap();
assert_eq!((a.image.width, a.image.height), (160, 120));
assert_eq!((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
#[test]
fn a_job_without_a_window_field_has_no_window() {
let mut s = Session::with_demo();
let id = s.visible_cloned()[0];
let wire = WireJob::from_job(&s.thumb_job(id, 128).unwrap(), false, "Thumb");
let mut v = serde_json::to_value(&wire).unwrap();
v.as_object_mut().unwrap().remove("window");
let old: WireJob = serde_json::from_value(v).unwrap();
assert_eq!(old.request().window, None);
}
#[test]
fn wire_job_renders_like_the_engine() {
let mut s = Session::with_demo();
@ -465,7 +381,6 @@ mod tests {
max_edge: 512,
raw: false,
relative_wb: false,
legacy_relative_wb: false,
as_shot_temp: 6500.0,
as_shot_tint: 0.0,
lens: None,
@ -478,7 +393,6 @@ mod tests {
thumb_cached: false,
stages: Some("Main".into()),
overlay: (0, 0.0),
window: None,
};
let mut core = WorkerCore::default();
assert_eq!(core.needs_original(&job).as_deref(), Some(hash.as_str()));

View File

@ -27,7 +27,7 @@ use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;
use crate::backend::Backend;
use crate::wire::{CacheWatch, ThumbIndex, WireJob, WorkerCore, WorkerSlots, thumb_storage_key};
use crate::wire::{CacheWatch, ThumbIndex, WireJob, WorkerCore, thumb_storage_key};
/// Storage key of the thumbnail index.
pub const THUMB_INDEX: &str = "thumbs/index.json";
@ -127,7 +127,11 @@ async fn handle(scope: &web_sys::DedicatedWorkerGlobalScope, backend: Option<Bac
Ok((r, stored)) => {
let rgba = Uint8Array::from(r.image.as_bytes().as_ref());
let h = &r.histogram;
let hist = Uint32Array::from(h.to_words().as_slice());
let hist = Uint32Array::new_with_length(4 * 256 + 1);
for (i, ch) in [&h.r, &h.g, &h.b, &h.luma].into_iter().enumerate() {
hist.subarray((i * 256) as u32, ((i + 1) * 256) as u32).copy_from(ch);
}
hist.set_index(1024, h.total);
set(&out, "ok", &true.into());
set(&out, "w", &(r.image.width as u32).into());
set(&out, "h", &(r.image.height as u32).into());
@ -241,17 +245,14 @@ impl Workers {
log::warn!("render workers: no compiled module on the page (window.lightcraftModule); rendering on the main thread");
return w;
}
let mut slots = WorkerSlots::default();
for attempt in 0..n {
// The callbacks name their worker by its place in `workers`; a failed attempt takes none.
let i = slots.peek();
for i in 0..n {
let opts = web_sys::WorkerOptions::new();
opts.set_type(web_sys::WorkerType::Module);
opts.set_name(&format!("lightcraft-render-{attempt}"));
opts.set_name(&format!("lightcraft-render-{i}"));
let worker = match web_sys::Worker::new_with_options("./worker.js", &opts) {
Ok(x) => x,
Err(e) => {
log::warn!("render worker {attempt}: {e:?}");
log::warn!("render worker {i}: {e:?}");
continue;
}
};
@ -267,15 +268,10 @@ impl Workers {
set(&init, "module", &module);
set(&init, "store", &store.into());
if let Err(e) = worker.post_message(&init) {
log::warn!("render worker {attempt}: {e:?}");
// It never joins the list, so it must not answer to the index of the next worker.
worker.set_onmessage(None);
worker.set_onerror(None);
worker.terminate();
log::warn!("render worker {i}: {e:?}");
continue;
}
w.0.borrow_mut().workers.push(W { worker, ready: false, dead: false, busy: None });
slots.commit();
}
w
}
@ -295,10 +291,8 @@ impl Workers {
level: b.level,
key: b.key,
rendered: Err(msg),
gpu_fallback: None,
loaded: None,
quick: None,
display: None,
};
g.done.push((b.slot, r, 0.0));
}
@ -322,8 +316,12 @@ impl Workers {
let (wd, ht) = (get(&data, "w").as_f64().unwrap_or(0.0) as usize, get(&data, "h").as_f64().unwrap_or(0.0) as usize);
let rgba = Uint8Array::new(&get(&data, "rgba")).to_vec();
let hist = Uint32Array::new(&get(&data, "hist")).to_vec();
match (Rgba8::from_bytes(wd, ht, &rgba), Histogram::from_words(&hist)) {
(Some(image), Some(histogram)) => Ok(Rendered { image, histogram, deep: None }),
match Rgba8::from_bytes(wd, ht, &rgba) {
Some(image) if hist.len() == 1025 => {
let ch = |k: usize| hist[k * 256..(k + 1) * 256].to_vec();
let histogram = Histogram { r: ch(0), g: ch(1), b: ch(2), luma: ch(3), total: hist[1024] };
Ok(Rendered { image, histogram, deep: None })
}
_ => Err("render worker: bad image".to_string()),
}
} else {
@ -379,10 +377,8 @@ impl Workers {
level: b.level,
key: b.key,
rendered,
gpu_fallback: None,
loaded: None,
quick: None,
display: None,
},
ms,
));
@ -433,10 +429,8 @@ impl RenderOffload for Workers {
level: job.level,
key: job.key,
rendered: Ok(Rendered { image, histogram, deep: None }),
gpu_fallback: None,
loaded: None,
quick: None,
display: None,
};
g.done.push((slot, r, 0.0));
return None;

View File

@ -9,42 +9,23 @@ 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 }
lightcraft-ui-egui = { workspace = true }
lightcraft-codecs = { workspace = true }
lightcraft-raster = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "accesskit", "wayland", "x11"] }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
egui = { workspace = true }
serde_json = { workspace = true }
# probing the window's renderer before it opens (`window_gpu`)
pollster = { workspace = true }
log = { workspace = true }
[build-dependencies]
# Icon + VERSIONINFO for lightcraft.exe; build.rs only uses it when targeting Windows.
winresource = "0.1"
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
winit = { version = "0.30", default-features = false, features = ["x11"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd = { workspace = true }
[target.'cfg(target_os = "linux")'.dependencies]
zbus = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
muda = { workspace = true }
objc2 = { workspace = true }
objc2-app-kit = { workspace = true }
objc2-foundation = { workspace = true }

View File

@ -1,198 +0,0 @@
//! The desktop's dark/light setting for Settings ▸ Interface ▸ Appearance Mode ▸ Auto, on Linux
//! desktops whose windowing layer reports no theme to egui (many Wayland compositors).
//!
//! The XDG settings portal is read once, then its `SettingChanged` signal is followed on a worker
//! thread that wakes the UI only when the value changes. Nothing is polled. `gsettings` is a
//! one-shot fallback at start-up, run by absolute path, only when the portal gives no answer.
//! Elsewhere egui's own report (`Context::system_theme`) is used.
#[cfg(target_os = "linux")]
const PORTAL_NAMESPACE: &str = "org.freedesktop.appearance";
#[cfg(target_os = "linux")]
const PORTAL_KEY: &str = "color-scheme";
#[cfg(target_os = "linux")]
fn portal_proxy() -> Option<zbus::blocking::Proxy<'static>> {
let conn = zbus::blocking::connection::Builder::session().ok()?.method_timeout(std::time::Duration::from_secs(2)).build().ok()?;
zbus::blocking::Proxy::new(&conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings").ok()
}
#[cfg(target_os = "linux")]
fn portal_theme(proxy: &zbus::blocking::Proxy<'_>) -> Option<egui::Theme> {
let value: zbus::zvariant::OwnedValue = proxy.call("Read", &(PORTAL_NAMESPACE, PORTAL_KEY)).ok()?;
decode(portal_code(value)?)
}
/// The `u32` inside a portal value. `Settings.Read` answers a variant, and some portals nest
/// another variant inside it.
#[cfg(target_os = "linux")]
fn portal_code(value: zbus::zvariant::OwnedValue) -> Option<u32> {
let mut value: zbus::zvariant::Value<'_> = value.into();
for _ in 0..4 {
match value {
zbus::zvariant::Value::Value(inner) => value = *inner,
other => return u32::try_from(other).ok(),
}
}
None
}
/// The portal's `color-scheme`: 0 = no preference, 1 = prefer dark, 2 = prefer light.
#[cfg(target_os = "linux")]
fn decode(code: u32) -> Option<egui::Theme> {
match code {
1 => Some(egui::Theme::Dark),
2 => Some(egui::Theme::Light),
_ => None,
}
}
/// One-shot fallback for desktops without the settings portal. It runs once at start-up, by
/// absolute path, and is never repeated.
#[cfg(target_os = "linux")]
fn gtk_theme() -> Option<egui::Theme> {
let exe = ["/usr/bin/gsettings", "/bin/gsettings", "/usr/local/bin/gsettings"].into_iter().find(|p| std::path::Path::new(p).is_file())?;
let output = std::process::Command::new(exe).args(["get", "org.gnome.desktop.interface", "color-scheme"]).output().ok()?;
if !output.status.success() {
return None;
}
parse_gtk_scheme(std::str::from_utf8(&output.stdout).ok()?)
}
#[cfg(target_os = "linux")]
fn parse_gtk_scheme(s: &str) -> Option<egui::Theme> {
match s.trim().trim_matches('\'') {
"prefer-dark" => Some(egui::Theme::Dark),
"prefer-light" => Some(egui::Theme::Light),
_ => None,
}
}
#[cfg(target_os = "linux")]
fn code_of(theme: Option<egui::Theme>) -> u8 {
match theme {
Some(egui::Theme::Dark) => 1,
Some(egui::Theme::Light) => 2,
None => 0,
}
}
/// Wake the first frame after a slow initial Read as well as subsequent real changes.
#[cfg(target_os = "linux")]
fn publish(value: &std::sync::atomic::AtomicU8, wake: &std::sync::OnceLock<egui::Context>, code: u8) {
if value.swap(code, std::sync::atomic::Ordering::Relaxed) != code
&& let Some(ctx) = wake.get()
{
ctx.request_repaint();
}
}
/// The host's system-appearance service (Linux), or `None` where egui's report is enough.
pub fn service() -> Option<lightcraft_ui_egui::SystemThemeFn> {
#[cfg(target_os = "linux")]
{
use std::sync::{
Arc, OnceLock,
atomic::{AtomicU8, Ordering},
};
let value = Arc::new(AtomicU8::new(0));
let wake: Arc<OnceLock<egui::Context>> = Arc::new(OnceLock::new());
let (worker_value, worker_wake) = (Arc::clone(&value), Arc::clone(&wake));
let (ready, wait) = std::sync::mpsc::channel();
if std::thread::Builder::new()
.name("appearance-portal".into())
.spawn(move || {
let proxy = portal_proxy();
// Subscribe before Read so a change during startup is queued rather than lost.
let signals = proxy.as_ref().and_then(|proxy| proxy.receive_signal("SettingChanged").ok());
let initial = proxy.as_ref().and_then(portal_theme).or_else(gtk_theme);
publish(&worker_value, &worker_wake, code_of(initial));
let _ = ready.send(());
let Some(signals) = signals else { return };
// Blocks until the portal sends a signal: no polling.
for message in signals {
let Ok((namespace, key, changed)) = message.body().deserialize::<(String, String, zbus::zvariant::OwnedValue)>() else {
continue;
};
if namespace != PORTAL_NAMESPACE || key != PORTAL_KEY {
continue;
}
let code = code_of(portal_code(changed).and_then(decode));
publish(&worker_value, &worker_wake, code);
}
})
.is_err()
{
return None;
}
// The first frame shows the right theme when the portal answers quickly; a slow one
// only delays the switch (the worker wakes the UI when the value changes).
let _ = wait.recv_timeout(std::time::Duration::from_millis(250));
Some(Box::new(move |ctx| {
let _ = wake.set(ctx.clone());
decode(u32::from(value.load(Ordering::Relaxed)))
}))
}
#[cfg(not(target_os = "linux"))]
{
None
}
}
#[cfg(test)]
mod tests {
#[cfg(target_os = "linux")]
#[test]
fn delayed_initial_read_repaints_only_on_change() {
use std::sync::{
Arc, OnceLock,
atomic::{AtomicU8, AtomicUsize, Ordering},
};
let ctx = egui::Context::default();
let settle = |ctx: &egui::Context| {
for _ in 0..8 {
let mut output = ctx.run_ui(egui::RawInput::default(), |_| {});
output.textures_delta.clear();
}
};
settle(&ctx);
let calls = Arc::new(AtomicUsize::new(0));
let observed = Arc::clone(&calls);
ctx.set_request_repaint_callback(move |_| {
observed.fetch_add(1, Ordering::Relaxed);
});
let wake = OnceLock::new();
assert!(wake.set(ctx.clone()).is_ok());
let value = AtomicU8::new(0);
super::publish(&value, &wake, 2);
assert_eq!(value.load(Ordering::Relaxed), 2);
let after_change = calls.load(Ordering::Relaxed);
assert!(after_change > 0);
settle(&ctx);
let after_settle = calls.load(Ordering::Relaxed);
super::publish(&value, &wake, 2);
assert_eq!(calls.load(Ordering::Relaxed), after_settle);
}
#[cfg(target_os = "linux")]
#[test]
fn portal_values_and_gsettings_output_decode() {
assert_eq!(super::decode(0), None);
assert_eq!(super::decode(1), Some(egui::Theme::Dark));
assert_eq!(super::decode(2), Some(egui::Theme::Light));
assert_eq!(super::decode(9), None);
let nested = zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::U32(2)))));
assert_eq!(super::portal_code(zbus::zvariant::OwnedValue::try_from(nested).unwrap()), Some(2));
let plain = zbus::zvariant::OwnedValue::from(1u32);
assert_eq!(super::portal_code(plain), Some(1));
let wrong = zbus::zvariant::OwnedValue::try_from(zbus::zvariant::Value::from("dark")).unwrap();
assert_eq!(super::portal_code(wrong), None);
assert_eq!(super::parse_gtk_scheme("'prefer-light'\n"), Some(egui::Theme::Light));
assert_eq!(super::parse_gtk_scheme("'prefer-dark'\n"), Some(egui::Theme::Dark));
assert_eq!(super::parse_gtk_scheme("'default'\n"), None);
assert_eq!(super::parse_gtk_scheme(""), None);
for theme in [None, Some(egui::Theme::Dark), Some(egui::Theme::Light)] {
assert_eq!(super::decode(u32::from(super::code_of(theme))), theme);
}
}
}

View File

@ -1,242 +0,0 @@
//! Starting external editors for Photo ▸ Edit In: PhotoCraft, found where its installers put it,
//! or any other application the user named in Settings ▸ External Editing.
use std::path::{Path, PathBuf};
use std::process::Command;
/// The name the UI asks for when it means PhotoCraft (`lightcraft_ui_egui::state::PHOTOCRAFT`).
const PHOTOCRAFT: &str = lightcraft_ui_egui::state::PHOTOCRAFT;
/// PhotoCraft's application id (Flatpak, desktop entry).
const PHOTOCRAFT_ID: &str = "ai.storyteller.photocraft";
/// How an editor is started.
#[derive(Debug, PartialEq)]
enum Launcher {
/// The system's default application for the file.
Default,
/// A program file, given the photo as its argument.
Program(PathBuf),
/// macOS: `open -a <app>` (an application name or bundle path).
MacApp(String),
/// The PhotoCraft Flatpak, with the file forwarded into its sandbox.
Flatpak,
}
/// Whether `app` can be started (Settings ▸ External Editing names it; "" = the system default):
/// for PhotoCraft, that it is installed. The error says what to do instead.
pub fn check(app: &str) -> Result<(), String> {
launcher(app).map(|_| ())
}
/// Open `path` in `app` (see [`check`]). Spawned, never waited for: the editor runs alongside.
pub fn open_with(path: &str, app: &str) -> Result<(), String> {
let mut c = match launcher(app)? {
Launcher::Default if cfg!(target_os = "macos") => {
let mut c = Command::new("open");
c.arg(path);
c
}
Launcher::Default if cfg!(target_os = "windows") => windows_default_command(path),
Launcher::Default => {
let mut c = Command::new("xdg-open");
c.arg(path);
c
}
Launcher::MacApp(app) => {
let mut c = Command::new("open");
c.args(["-a", &app, path]);
c
}
Launcher::Program(p) => {
let mut c = Command::new(p);
c.arg(path);
c
}
Launcher::Flatpak => {
let mut c = Command::new("flatpak");
c.args(["run", "--file-forwarding", PHOTOCRAFT_ID, "@@", path, "@@"]);
c
}
};
c.spawn().map(|_| ()).map_err(|e| e.to_string())
}
/// Windows: open `path` in its default application through Explorer, given the path as its one
/// argument. Not `cmd /C start`: cmd.exe would read `&`, `^`, `%` or `|` in a file name as its own
/// syntax (running what follows an `&` as a command).
fn windows_default_command(path: &str) -> Command {
let mut c = Command::new("explorer.exe");
c.arg(path.replace('/', "\\"));
c
}
fn launcher(app: &str) -> Result<Launcher, String> {
let app = app.trim();
if app.is_empty() {
return Ok(Launcher::Default);
}
if !app.eq_ignore_ascii_case(PHOTOCRAFT) {
return Ok(if cfg!(target_os = "macos") { Launcher::MacApp(app.to_string()) } else { Launcher::Program(PathBuf::from(app)) });
}
find_photocraft().ok_or_else(|| {
"PhotoCraft isn't installed. Get it from getartcraft.com/apps/photocraft, or give where it is in Settings ▸ External Editing.".to_string()
})
}
fn find_photocraft() -> Option<Launcher> {
let env = |k: &str| std::env::var_os(k).map(|v| v.to_string_lossy().to_string());
if cfg!(target_os = "macos") {
// LaunchServices finds the app wherever it is; these are only the usual places
let home = env("HOME").map(|h| Path::new(&h).join("Applications/PhotoCraft.app"));
let found = [Some(PathBuf::from("/Applications/PhotoCraft.app")), home].into_iter().flatten().find(|p| p.is_dir());
return Some(Launcher::MacApp(found.map_or_else(|| PHOTOCRAFT.to_string(), |p| p.to_string_lossy().to_string())));
}
if cfg!(target_os = "windows") {
let install_dir = reg_query(r"HKLM\Software\PhotoCraft", Some("InstallDir"));
let app_path = reg_query(r"HKLM\Software\Microsoft\Windows\CurrentVersion\App Paths\photocraft.exe", None);
return windows_candidates(install_dir, app_path, env).into_iter().find(|p| p.is_file()).map(Launcher::Program);
}
// Linux, FreeBSD: on the PATH (.deb, .rpm, tarball), a desktop entry (AppImage, which
// registers one on first run), else the Flatpak
let on_path =
env("PATH").into_iter().flat_map(|p| std::env::split_paths(&p).collect::<Vec<_>>()).map(|d| d.join("photocraft")).find(|p| p.is_file());
let from_entry = || {
desktop_entry_dirs(env).into_iter().find_map(|d| {
let text = std::fs::read_to_string(d.join(format!("{PHOTOCRAFT_ID}.desktop"))).ok()?;
desktop_exec(&text).filter(|p| p.is_file())
})
};
if let Some(p) = on_path.or_else(from_entry) {
return Some(Launcher::Program(p));
}
let flatpak = Command::new("flatpak").args(["info", PHOTOCRAFT_ID]).output().is_ok_and(|o| o.status.success());
flatpak.then_some(Launcher::Flatpak)
}
/// Where the Windows installers put `photocraft.exe`, most specific first: the folder the MSI
/// recorded (`HKLM\Software\PhotoCraft\InstallDir`), its App Paths entry, then the default
/// folders (per machine, then per user).
fn windows_candidates(install_dir: Option<String>, app_path: Option<String>, env: impl Fn(&str) -> Option<String>) -> Vec<PathBuf> {
let mut out: Vec<PathBuf> = Vec::new();
out.extend(install_dir.map(|d| Path::new(&d).join("photocraft.exe")));
out.extend(app_path.map(|p| PathBuf::from(p.trim_matches('"'))));
for var in ["ProgramW6432", "ProgramFiles"] {
out.extend(env(var).map(|d| Path::new(&d).join("PhotoCraft").join("photocraft.exe")));
}
out.extend(env("LOCALAPPDATA").map(|d| Path::new(&d).join("Programs").join("PhotoCraft").join("photocraft.exe")));
out
}
/// A string value from the Windows registry (`reg query`; `None` = the key's default value).
fn reg_query(key: &str, name: Option<&str>) -> Option<String> {
let mut c = Command::new("reg");
c.args(["query", key]);
match name {
Some(n) => c.args(["/v", n]),
None => c.arg("/ve"),
};
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
c.creation_flags(CREATE_NO_WINDOW);
}
let out = c.output().ok().filter(|o| o.status.success())?;
reg_value(&String::from_utf8_lossy(&out.stdout))
}
/// The value in `reg query` output: the text after `REG_SZ` (or `REG_EXPAND_SZ`) on its line.
fn reg_value(output: &str) -> Option<String> {
output.lines().find_map(|l| {
let (_, v) = l.split_once("REG_EXPAND_SZ").or_else(|| l.split_once("REG_SZ"))?;
Some(v.trim().to_string()).filter(|v| !v.is_empty())
})
}
/// The folders desktop entries live in (XDG base directories).
fn desktop_entry_dirs(env: impl Fn(&str) -> Option<String>) -> Vec<PathBuf> {
let mut dirs = Vec::new();
match env("XDG_DATA_HOME") {
Some(d) if !d.is_empty() => dirs.push(PathBuf::from(d)),
_ => dirs.extend(env("HOME").map(|h| Path::new(&h).join(".local/share"))),
}
let system = env("XDG_DATA_DIRS").filter(|d| !d.is_empty()).unwrap_or_else(|| "/usr/local/share:/usr/share".into());
dirs.extend(system.split(':').filter(|d| !d.is_empty()).map(PathBuf::from));
dirs.into_iter().map(|d| d.join("applications")).collect()
}
/// The program a desktop entry's `Exec=` line starts (quoted or not, its field codes dropped).
fn desktop_exec(entry: &str) -> Option<PathBuf> {
let exec = entry.lines().find_map(|l| l.strip_prefix("Exec="))?.trim();
let program = match exec.strip_prefix('"') {
Some(rest) => rest.split('"').next()?,
None => exec.split_whitespace().next()?,
};
// a bare name (`photocraft %F`) was already looked for on the PATH
Some(PathBuf::from(program)).filter(|p| p.is_absolute())
}
#[cfg(test)]
mod tests {
use super::*;
/// A file name with cmd.exe syntax in it goes to Explorer as one plain argument, not
/// through a shell.
#[test]
fn windows_default_open_goes_through_explorer_not_cmd() {
let path = r"C:/Edits/a & b ^c %PATH% | d.psd";
let c = windows_default_command(path);
assert_eq!(c.get_program(), "explorer.exe");
let args: Vec<_> = c.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
assert_eq!(args, [r"C:\Edits\a & b ^c %PATH% | d.psd"]);
}
#[test]
fn windows_lookup_prefers_the_recorded_install_folder() {
let env = |k: &str| match k {
"ProgramFiles" => Some(r"C:\Program Files".to_string()),
"LOCALAPPDATA" => Some(r"C:\Users\Simon\AppData\Local".to_string()),
_ => None,
};
let c = windows_candidates(Some(r"D:\Apps\PhotoCraft\".into()), Some(r#""C:\Program Files\PhotoCraft\photocraft.exe""#.into()), env);
let c: Vec<String> = c.iter().map(|p| p.to_string_lossy().replace('/', "\\")).collect();
assert!(c[0].starts_with(r"D:\Apps\PhotoCraft") && c[0].ends_with("photocraft.exe"), "{c:?}");
assert_eq!(c[1], r"C:\Program Files\PhotoCraft\photocraft.exe", "App Paths, quotes dropped");
assert!(c[2].starts_with(r"C:\Program Files") && c[2].ends_with("photocraft.exe"), "{c:?}");
assert!(c[3].contains("Programs") && c[3].ends_with("photocraft.exe"), "{c:?}");
assert_eq!(c.len(), 4);
}
#[test]
fn reg_query_output_is_read() {
let out = "\r\nHKEY_LOCAL_MACHINE\\Software\\PhotoCraft\r\n InstallDir REG_SZ C:\\Program Files\\PhotoCraft\\\r\n\r\n";
assert_eq!(reg_value(out).as_deref(), Some(r"C:\Program Files\PhotoCraft\"));
let default = "\r\nHKEY_LOCAL_MACHINE\\...\\photocraft.exe\r\n (Default) REG_SZ C:\\Program Files\\PhotoCraft\\photocraft.exe\r\n";
assert_eq!(reg_value(default).as_deref(), Some(r"C:\Program Files\PhotoCraft\photocraft.exe"));
assert_eq!(reg_value("ERROR: The system was unable to find the specified registry key or value."), None);
}
#[test]
fn desktop_entries_give_their_program() {
let appimage = "[Desktop Entry]\nName=PhotoCraft\nExec=\"/home/simon/Apps/photocraft-1.2-linux-x86_64.AppImage\" %F\n";
assert_eq!(desktop_exec(appimage), Some(PathBuf::from("/home/simon/Apps/photocraft-1.2-linux-x86_64.AppImage")));
assert_eq!(desktop_exec("Exec=/opt/photocraft/photocraft %F\n"), Some(PathBuf::from("/opt/photocraft/photocraft")));
assert_eq!(desktop_exec("Exec=photocraft %F\nTryExec=photocraft\n"), None, "a bare name is a PATH lookup");
assert_eq!(desktop_exec("[Desktop Entry]\n"), None);
let dirs = desktop_entry_dirs(|k| (k == "HOME").then(|| "/home/simon".to_string()));
assert_eq!(dirs[0], PathBuf::from("/home/simon/.local/share/applications"));
assert!(dirs.contains(&PathBuf::from("/usr/share/applications")));
}
#[test]
fn other_editors_are_used_as_named() {
assert_eq!(launcher("").unwrap(), Launcher::Default);
assert_eq!(launcher(" ").unwrap(), Launcher::Default);
let named = launcher("/usr/bin/gimp").unwrap();
if cfg!(target_os = "macos") {
assert_eq!(named, Launcher::MacApp("/usr/bin/gimp".into()));
} else {
assert_eq!(named, Launcher::Program("/usr/bin/gimp".into()));
}
}
}

View File

@ -23,16 +23,11 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod alloc_release;
mod appearance;
mod control_server;
mod dialog_filter;
mod editors;
mod logging;
#[cfg(target_os = "macos")]
mod native_menu;
#[cfg(target_os = "macos")]
mod screen_profile;
mod window_gpu;
use dialog_filter::FileDialogExt as _;
use lightcraft_engine::Session;
@ -57,15 +52,8 @@ 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)
&& 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, None);
std::process::exit(0);
if let Some(m) = self.2.as_mut() {
m.update(&mut self.0, ctx);
}
self.0.logic(ctx);
self.1.tick(&mut self.0, ctx);
@ -76,38 +64,25 @@ impl eframe::App for App {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.0.ui(ui);
}
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
fn on_exit(&mut self) {
if let Err(e) = self.1.save(&self.0) {
log::error!("{e}");
}
if let Err(e) = self.0.session.close_library() {
log::error!("saving the library failed: {e}");
}
// last, with everything saved: no render may be inside the GPU driver when the process ends
if !self.0.shutdown(EXIT_WAIT) {
log::warn!("quit: background work was still running after {EXIT_WAIT:?}");
}
}
}
/// How long quitting waits for renders that are running (issue #620). A GPU render takes well under
/// a second; anything slower gives way to quitting.
const EXIT_WAIT: std::time::Duration = std::time::Duration::from_secs(2);
/// The window's renderer (egui-wgpu): eframe's defaults, with LightCraft's backend choice — DX12
/// alone on Windows unless `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND` say otherwise (issue #136:
/// with Vulkan in the set, wgpu loads the Vulkan driver even when it then picks DX12) — and DX12
/// shaders compiled with FXC, never a stray `dxcompiler.dll` (issue #471).
/// The fallback chain on top of it (DX12 → Vulkan → GL → glow) is in `window_gpu`.
#[cfg(test)]
/// with Vulkan in the set, wgpu loads the Vulkan driver even when it then picks DX12).
fn window_wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration {
window_gpu::base_options()
}
/// Where the window's renderer files live (`window_gpu::Files`): nothing for `--memory` sessions
/// and `LIGHTCRAFT_NO_PREFS`.
fn window_files(in_memory: bool) -> window_gpu::Files {
window_gpu::Files::new(config_dir().filter(|_| !in_memory && std::env::var_os("LIGHTCRAFT_NO_PREFS").is_none()))
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();
}
c
}
/// Written while the GPU compute device is created, removed once that returned
@ -149,25 +124,8 @@ fn gpu_crash_notice(what: &str) -> String {
)
}
/// Where the system keeps monitor profiles, for the Choose Profile dialog to start in: the user's
/// own profiles first (Linux: colord / DisplayCAL; macOS: ColorSync), then the system's.
fn display_profile_dir() -> Option<std::path::PathBuf> {
let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).filter(|h| !h.is_empty()).map(std::path::PathBuf::from);
let user = |rel: &str| home.as_ref().map(|h| h.join(rel));
let candidates: Vec<Option<std::path::PathBuf>> = if cfg!(target_os = "macos") {
vec![user("Library/ColorSync/Profiles"), Some("/Library/ColorSync/Profiles".into())]
} else if cfg!(windows) {
let root = std::env::var_os("SystemRoot").map(std::path::PathBuf::from).unwrap_or_else(|| "C:\\Windows".into());
vec![Some(root.join("System32").join("spool").join("drivers").join("color"))]
} else {
let data = std::env::var_os("XDG_DATA_HOME").filter(|d| !d.is_empty()).map(std::path::PathBuf::from).or_else(|| user(".local/share"));
vec![data.map(|d| d.join("icc")), user(".color/icc"), Some("/usr/share/color/icc".into())]
};
candidates.into_iter().flatten().find(|d| d.is_dir())
}
fn config_dir() -> Option<std::path::PathBuf> {
lightcraft_engine::config::config_dir()
lightcraft_engine::camera_profiles::config_dir()
}
/// `<config>/ui.json`, the saved UI state and app settings — `None` when nothing is read or
@ -191,23 +149,6 @@ fn load_prefs(in_memory: bool) -> (Option<UiState>, Option<String>, bool) {
load_prefs_at(&path, !in_memory)
}
/// Compatibility recovery is limited to persisted docking, not runtime UiState commands.
fn decode_saved_ui(bytes: &[u8]) -> Result<(UiState, bool), String> {
let mut saved: serde_json::Value = serde_json::from_slice(bytes).map_err(|error| error.to_string())?;
let fields = saved.as_object_mut().ok_or("settings must be an object")?;
let recovered =
fields.get("docking").is_some_and(|value| match serde_json::from_value::<Option<lightcraft_ui_egui::docking::Workspace>>(value.clone()) {
Ok(Some(workspace)) => workspace.validate().is_err(),
Ok(None) => false,
Err(_) => true,
});
if recovered {
fields.remove("docking");
}
let ui = serde_json::from_value(saved).map_err(|error| error.to_string())?;
Ok((ui, recovered))
}
fn load_prefs_at(path: &std::path::Path, set_aside_damaged: bool) -> (Option<UiState>, Option<String>, bool) {
let bytes = match std::fs::read(path) {
Ok(b) => b,
@ -217,15 +158,8 @@ fn load_prefs_at(path: &std::path::Path, set_aside_damaged: bool) -> (Option<UiS
return (None, Some(msg), true);
}
};
match decode_saved_ui(&bytes) {
Ok((ui, false)) => (Some(ui.sanitized()), None, false),
Ok((ui, true)) => {
let msg = format!(
"The saved panel layout in {} couldn't be restored. Other settings were loaded; the original file is preserved and settings won't be saved this session.",
path.display()
);
(Some(ui.sanitized()), Some(msg), true)
}
match serde_json::from_slice::<UiState>(&bytes) {
Ok(ui) => (Some(ui.sanitized()), None, false),
Err(e) if !set_aside_damaged => {
let msg = format!("The app settings ({}) are damaged ({e}). Defaults are used; nothing is saved in this session.", path.display());
(None, Some(msg), true)
@ -302,9 +236,6 @@ impl PrefsWriter {
return Ok(());
}
let Some(path) = self.path.clone() else { return Ok(()) };
if let Some(workspace) = &ui.docking {
workspace.validate()?;
}
let bytes = serde_json::to_vec_pretty(ui).map_err(|e| e.to_string())?;
if bytes == self.written {
return Ok(());
@ -350,107 +281,40 @@ 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 file dialogs' parent, set once the window exists (`services`). A dialog with a parent is
/// a child of the app's window; without one, the xdg-desktop-portal picker on Wayland is a stray
/// top-level ("Failed to associate portal window with parent window" in the portal's log): a
/// tiling compositor puts it beside the app, and a fullscreen window hides it entirely, so
/// Import Photos… looks like a hang. rfd exports the Wayland surface through xdg_foreign itself;
/// `FileDialog` is `Clone` and `Send`, so one parented template serves every picker, including
/// the one on its own thread (#191). Only set on Linux and FreeBSD: on macOS a parented dialog
/// becomes a sheet and on Windows a modal, a behaviour change this does not make.
static DIALOG_PARENT: std::sync::OnceLock<rfd::FileDialog> = std::sync::OnceLock::new();
/// A file dialog owned by the app's window, or a plain one before the window exists.
fn file_dialog() -> rfd::FileDialog {
DIALOG_PARENT.get().cloned().unwrap_or_default()
}
/// The platform services; `cc` is the window the file dialogs belong to (its `egui_ctx` repaints
/// when a dialog closes), and `log_file` is for Help ▸ Open Log Folder.
fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path>) -> Services {
let ctx = cc.egui_ctx.clone();
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
let _ = DIALOG_PARENT.set(rfd::FileDialog::new().set_parent(cc));
fn services() -> Services {
Services {
// Commands' file dialogs run on their own thread (#191): a dialog on the UI thread
// stopped the window answering the compositor, which then reported the app as hung.
picker: Some(Box::new(move |req: lightcraft_ui_egui::pick::PickRequest| {
use lightcraft_ui_egui::pick::PickKind;
let (tx, rx) = std::sync::mpsc::channel();
let repaint = ctx.clone();
std::thread::Builder::new()
.name("file-dialog".into())
.spawn(move || {
let mut d = file_dialog();
if let Some(t) = &req.title {
d = d.set_title(t);
}
if let Some((name, exts)) = &req.filter {
d = d.add_filter_nocase(name.clone(), exts);
}
if let Some(n) = &req.file_name {
d = d.set_file_name(n);
}
let s = |p: std::path::PathBuf| p.to_string_lossy().to_string();
let paths: Vec<String> = match req.kind {
PickKind::Files => d.pick_files().unwrap_or_default().into_iter().map(s).collect(),
PickKind::File => d.pick_file().map(s).into_iter().collect(),
PickKind::Folder => d.pick_folder().map(s).into_iter().collect(),
PickKind::Save => d.save_file().map(s).into_iter().collect(),
};
// the app may have quit meanwhile: nobody to tell
let _ = tx.send(paths);
repaint.request_repaint();
})
.map_err(|e| format!("could not start the file dialog: {e}"))?;
Ok(rx)
})),
log_file: log_file.map(|p| p.display().to_string()),
pick_lightroom_catalog: Some(Box::new(|| {
file_dialog()
rfd::FileDialog::new()
.set_title(lightcraft_ui_egui::i18n::tr("Import Lightroom Catalog"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Lightroom Classic Catalog"), &["lrcat"])
.pick_file()
.map(|p| p.to_string_lossy().to_string())
})),
pick_folder: Some(Box::new(|| {
file_dialog().set_title(lightcraft_ui_egui::i18n::tr("Open Library")).pick_folder().map(|p| p.to_string_lossy().to_string())
rfd::FileDialog::new().set_title(lightcraft_ui_egui::i18n::tr("Open Library")).pick_folder().map(|p| p.to_string_lossy().to_string())
})),
open_with: Some(Box::new(|path: &str, app: &str| {
// spawned, never waited for: the editor runs alongside
let app = app.trim();
let mut c = if cfg!(target_os = "macos") {
let mut c = std::process::Command::new("open");
if !app.is_empty() {
c.args(["-a", app]);
}
c
} else if app.is_empty() {
if cfg!(target_os = "windows") {
let mut c = std::process::Command::new("cmd");
c.args(["/C", "start", ""]);
c
} else {
std::process::Command::new("xdg-open")
}
} else {
std::process::Command::new(app)
};
c.arg(path).spawn().map(|_| ()).map_err(|e| e.to_string())
})),
open_with: Some(Box::new(editors::open_with)),
check_editor: Some(Box::new(editors::check)),
open_url: Some(Box::new(|url: &str| {
if !url.starts_with("https://") {
return Err("only https links are opened".into());
@ -468,7 +332,8 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path>
let status = if cfg!(target_os = "macos") {
std::process::Command::new("open").args(["-R", path]).status()
} else if cfg!(target_os = "windows") {
explorer_select_command(path).status()
let win_path = path.replace('/', "\\");
std::process::Command::new("explorer").arg(format!("/select,{win_path}")).status()
} else {
let dir = std::path::Path::new(path).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_else(|| ".".into());
std::process::Command::new("xdg-open").arg(dir).status()
@ -478,7 +343,7 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path>
.and_then(|s| if s.success() || cfg!(target_os = "windows") { Ok(()) } else { Err(format!("reveal failed: {s}")) })
})),
pick_files: Some(Box::new(|| {
file_dialog()
rfd::FileDialog::new()
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Photos"), lightcraft_engine::import::EXTENSIONS)
.pick_files()
.unwrap_or_default()
@ -486,15 +351,8 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path>
.map(|p| p.to_string_lossy().to_string())
.collect()
})),
pick_denoise_model: Some(Box::new(|| {
file_dialog()
.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(|| {
file_dialog()
rfd::FileDialog::new()
.set_title(lightcraft_ui_egui::i18n::tr("Import Presets"))
.add_filter_nocase(
lightcraft_ui_egui::i18n::tr("Presets & Profiles"),
@ -506,62 +364,24 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path>
.map(|p| p.to_string_lossy().to_string())
.collect()
})),
pick_model_file: Some(Box::new(|| {
file_dialog()
.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(|| {
file_dialog()
rfd::FileDialog::new()
.set_title(lightcraft_ui_egui::i18n::tr("Auto-Tag from Tracklog"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("GPS Track Log"), &["gpx"])
.pick_file()
.map(|p| vec![p.to_string_lossy().to_string()])
.unwrap_or_default()
})),
// macOS doesn't colour-manage the window: previews follow the screen's profile (#787)
#[cfg(target_os = "macos")]
screen_profile: Some(Box::new(screen_profile::icc)),
#[cfg(not(target_os = "macos"))]
screen_profile: None,
pick_display_profile: Some(Box::new(|| {
let mut d = file_dialog()
.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| {
file_dialog()
rfd::FileDialog::new()
.set_title(lightcraft_ui_egui::i18n::tr("Export Presets"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("LightCraft Preset"), &["lcpreset"])
.set_file_name(name)
.save_file()
.map(|p| p.to_string_lossy().to_string())
})),
pick_keyword_list: Some(Box::new(|| {
file_dialog()
.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| {
file_dialog()
.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(|| {
file_dialog()
rfd::FileDialog::new()
.set_title(lightcraft_ui_egui::i18n::tr("Import Point Curve Presets"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve", "json"])
.pick_files()
@ -571,7 +391,7 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path>
.collect()
})),
save_curve_preset_file: Some(Box::new(|name: &str| {
file_dialog()
rfd::FileDialog::new()
.set_title(lightcraft_ui_egui::i18n::tr("Export Point Curve Presets"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve"])
.set_file_name(name)
@ -587,35 +407,25 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path>
// the library is a folder on disk: backed up with the user's other files
backup_library: None,
restore_library: None,
system_theme: appearance::service(),
}
}
/// 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();
// Control requests are answered on the UI thread: Add to Apple Photos must not wait there
s.apple_photos_background = true;
(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_library_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: bool) -> (Session, Option<LibraryProblem>) {
fn open_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_default_denoise_models().with_system_clock(), None);
return (if seed_demo { Session::with_demo() } else { Session::new() }.with_fs().with_system_clock(), None);
}
let unopened = || Session::new().with_fs().with_default_denoise_models().with_system_clock();
let unopened = || Session::new().with_fs().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_default_denoise_models().with_system_clock();
let mut s = Session::new().with_fs().with_system_clock();
match s.open_library(&dir, seed_demo) {
Ok(r) => {
let (replayed, torn) = (r.replayed, r.torn_bytes);
@ -649,7 +459,6 @@ OPTIONS:
--version, --help
ENVIRONMENT:
LIGHTCRAFT_CONFIG_DIR=DIR use this settings folder instead of the platform default (empty = no settings folder)
LIGHTCRAFT_GPU_BACKEND=dx12|vulkan|metal|auto|off graphics backend (default: DX12 on Windows, Metal on macOS,
Vulkan on Linux; off = render on the CPU); else WGPU_BACKEND. LIGHTCRAFT_GPU=0: CPU rendering.
LIGHTCRAFT_SAM3_DIR=DIR the SAM 3 model for Object / Describe masks (default: <settings folder>/models/sam3;
@ -705,16 +514,10 @@ fn control_port_from(what: &str, value: Option<String>) -> Option<u16> {
port
}
/// An isolated profile keeps the emergency crash log with its other app data. With no
/// override (or an empty override), retain the historical system-temp location.
fn panic_log_path_with(env: impl Fn(&str) -> Option<std::ffi::OsString>, temp: std::path::PathBuf) -> std::path::PathBuf {
env("LIGHTCRAFT_CONFIG_DIR").filter(|value| !value.is_empty()).map(std::path::PathBuf::from).unwrap_or(temp).join("lightcraft-panics.log")
}
fn main() -> eframe::Result {
// First, so every start-up record is kept for the log file (`logging`).
let logger = logging::install();
lightcraft_engine::guard::install_hook(panic_log_path_with(|name| std::env::var_os(name), std::env::temp_dir()));
lightcraft_engine::guard::install_hook(std::env::temp_dir().join("lightcraft-panics.log"));
if let Some(logger) = logger {
logging::record_panics(logger);
}
@ -785,14 +588,7 @@ fn main() -> eframe::Result {
let gpu_crash = gpu_crash_check(in_memory);
let gpu_on = prefs.as_ref().is_none_or(|u| u.settings.gpu) && gpu_crash.is_none();
lightcraft_engine::gpu::set_enabled(gpu_on);
// the window's renderer: probed (Windows) and with a fallback chain, so a driver that can't
// build egui's pipeline never kills the start-up
let win_files = std::sync::Arc::new(window_files(in_memory));
let window_gpu::Chosen { renderer, wgpu_options, which: window_renderer, notice: window_notice } = window_gpu::choose(&win_files);
win_files.arm(window_renderer);
let win_files_app = win_files.clone();
let options = eframe::NativeOptions {
renderer,
viewport: egui::ViewportBuilder::default()
.with_title("LightCraft")
.with_inner_size([1600.0, 1000.0])
@ -804,97 +600,71 @@ fn main() -> eframe::Result {
.with_icon(app_icon())
// Wayland matches the window to packaging/linux/ai.storyteller.lightcraft.desktop by this id
.with_app_id(APP_ID),
wgpu_options,
wgpu_options: window_wgpu_options(),
..Default::default()
};
// the app's copy: `log_file` is still named in the message of a start that fails
let app_log_file = log_file.clone();
#[cfg(all(unix, not(target_os = "macos")))]
let mut options = options;
// winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file
// manager showed a "no" cursor. Run through XWayland when it's there; LIGHTCRAFT_WAYLAND=1
// keeps the native Wayland backend.
#[cfg(all(unix, not(target_os = "macos")))]
if std::env::var_os("DISPLAY").is_some() && std::env::var_os("LIGHTCRAFT_WAYLAND").is_none() {
options.event_loop_builder = Some(Box::new(|b| {
use winit::platform::x11::EventLoopBuilderExtX11;
b.with_x11();
}));
}
let started = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
eframe::run_native(
"LightCraft",
options,
Box::new(move |cc| {
// the window's renderer is up (eframe calls this after creating it)
win_files_app.started(window_renderer);
let (mut session, problem) = open_session(in_memory, library_dir, seed_demo && files.is_empty());
// AI masks: the SAM 3 checkpoint (facebook/sam3) in <config>/models/sam3, or LIGHTCRAFT_SAM3_DIR
// (never required: without it, AI masks offer to download it; see docs/ai-masks.md)
// and the user's own download locations, one base URL per line (LIGHTCRAFT_SAM3_MIRRORS too)
session = session.with_default_sam3_model();
let mut app = LightcraftApp::new(session, services(cc, app_log_file.as_deref()));
if let Some(ui) = prefs {
app.ui = ui;
let started = eframe::run_native(
"LightCraft",
options,
Box::new(move |cc| {
let (mut session, problem) = open_session(in_memory, library_dir, seed_demo && files.is_empty());
// AI masks: the SAM 3 checkpoint (facebook/sam3) in <config>/models/sam3, or LIGHTCRAFT_SAM3_DIR
// (never required: without it, AI masks offer to download it; see docs/ai-masks.md)
session.segmenter.dir =
std::env::var_os("LIGHTCRAFT_SAM3_DIR").map(std::path::PathBuf::from).or_else(|| config_dir().map(|d| d.join("models").join("sam3")));
// the user's own download locations, one base URL per line (LIGHTCRAFT_SAM3_MIRRORS too)
session.segmenter.mirrors_file = config_dir().map(|d| d.join("models").join("sam3-mirrors.txt"));
let mut app = LightcraftApp::new(session, services());
if let Some(ui) = prefs {
app.ui = ui;
}
lightcraft_ui_egui::i18n::set_language(app.ui.language);
app.integrated_titlebar = cfg!(target_os = "macos");
app.notices.extend(prefs_warning);
app.notices.extend(launch_notices);
// what's on disk now: only changes are written
let writer = PrefsWriter {
path: if in_memory { None } else { prefs_path() },
written: serde_json::to_vec_pretty(&app.ui).unwrap_or_default(),
library: app.ui.settings.library_path.clone(),
keep_file: keep_prefs_file,
..Default::default()
};
// after the snapshot above, so the writer saves the switched-off preference
if let Some(notice) = gpu_crash {
app.ui.settings.gpu = false;
app.notices.push(notice);
}
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
app = app.with_control(rx);
}
if let Some(mut p) = problem {
// imported once the user has chosen where (into the library, or the temporary session)
p.pending_import = files;
app.library_problem = Some(p);
} else if !files.is_empty() {
// listed and read on the import worker once the window shows (issue #374: never
// here, before the first frame, where a large folder kept the window from
// appearing)
if let Err(e) = lightcraft_ui_egui::import::start_paths(&mut app, files) {
log::warn!("import at launch: {e}");
}
lightcraft_ui_egui::i18n::set_language(app.ui.language);
app.integrated_titlebar = cfg!(target_os = "macos");
app.notices.extend(prefs_warning);
app.notices.extend(launch_notices);
app.notices.extend(window_notice);
// what's on disk now: only changes are written
let writer = PrefsWriter {
path: if in_memory { None } else { prefs_path() },
written: serde_json::to_vec_pretty(&app.ui).unwrap_or_default(),
library: app.ui.settings.library_path.clone(),
keep_file: keep_prefs_file,
..Default::default()
};
// after the snapshot above, so the writer saves the switched-off preference
if let Some(notice) = gpu_crash {
app.ui.settings.gpu = false;
app.notices.push(notice);
}
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
app = app.with_control(rx);
}
if let Some(mut p) = problem {
// imported once the user has chosen where (into the library, or the temporary session)
p.pending_import = files;
app.library_problem = Some(p);
} else if !files.is_empty() {
// listed and read on the import worker once the window shows (issue #374: never
// here, before the first frame, where a large folder kept the window from
// appearing)
if let Err(e) = lightcraft_ui_egui::import::start_paths(&mut app, files) {
log::warn!("import at launch: {e}");
}
app.ui.view = lightcraft_ui_egui::state::ViewMode::PhotoGrid;
}
// native menu bar generated from the command registry (macOS; elsewhere the menus are
// drawn in the window's top bar). LIGHTCRAFT_NO_NATIVE_MENU=1 keeps the in-window menus.
app.ui.view = lightcraft_ui_egui::state::ViewMode::PhotoGrid;
}
// native menu bar generated from the command registry (macOS; elsewhere the menus are
// drawn in the window's top bar). LIGHTCRAFT_NO_NATIVE_MENU=1 keeps the in-window menus.
#[cfg(target_os = "macos")]
let menu = (std::env::var_os("LIGHTCRAFT_NO_NATIVE_MENU").is_none()).then(|| native_menu::NativeMenu::install(&mut app, &cc.egui_ctx));
Ok(Box::new(App(
app,
writer,
#[cfg(target_os = "macos")]
let menu =
(std::env::var_os("LIGHTCRAFT_NO_NATIVE_MENU").is_none()).then(|| native_menu::NativeMenu::install(&mut app, &cc.egui_ctx));
Ok(Box::new(App(
app,
writer,
#[cfg(target_os = "macos")]
menu,
)))
}),
)
}));
// a panic that still escaped the renderer's start (the probe and error handler should
// prevent it): an error like any other failed start
let started = started.unwrap_or_else(|_| Err(eframe::Error::AppCreation("the window's renderer panicked while starting".into())));
menu,
)))
}),
);
if let Err(e) = &started {
// the renderer never came up: start again, which tries the next one in the chain
if window_gpu::relaunch_after_failure(&win_files) {
log::error!("LightCraft could not open its window with {}: {e}; trying the next renderer", window_renderer.name());
return Ok(());
}
startup_failed(&e.to_string(), log_file.as_deref());
}
started
@ -947,16 +717,6 @@ mod tests {
let _ = std::fs::remove_dir_all(&home);
}
/// Issue #236: the window answers control requests on its UI thread, so the desktop session
/// adds to Apple Photos on a worker (headless sessions wait instead).
#[test]
fn the_desktop_session_adds_to_apple_photos_in_the_background() {
let (s, problem) = open_session(true, None, false);
assert!(problem.is_none());
assert!(s.apple_photos_background);
assert!(!lightcraft_engine::Session::new().apple_photos_background);
}
/// Issue #103: a damaged ui.json (which holds the library location) is kept aside and
/// reported, not silently replaced by the defaults.
#[test]
@ -993,59 +753,6 @@ mod tests {
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn docking_survives_native_settings_file_reload() {
use lightcraft_ui_egui::docking::Panel;
let d = dir("docking-reload");
let path = d.join("ui.json");
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default());
app.run("ui.dock", serde_json::json!({"operation":"float", "panel":"tools", "rect":[70,80,310,500]})).unwrap();
app.run("ui.dock", serde_json::json!({"operation":"close", "panel":"tools"})).unwrap();
let mut writer = PrefsWriter { path: Some(path.clone()), ..Default::default() };
writer.save_ui(&app.ui, false).unwrap();
let (restored, warning, keep) = load_prefs_at(&path, false);
assert!(warning.is_none());
assert!(!keep);
let mut restored_app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default());
restored_app.ui = restored.unwrap();
restored_app.run("ui.dock", serde_json::json!({"operation":"open", "panel":"tools"})).unwrap();
assert_eq!(
restored_app.ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.panels.contains(&Panel::Tools)).unwrap().rect,
[70.0, 80.0, 310.0, 500.0]
);
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn future_docking_preserves_library_settings_and_the_original_preferences_file() {
let d = dir("future-docking");
let path = d.join("ui.json");
let mut ui = UiState { thumb_size: 210.0, filmstrip: false, ..Default::default() };
ui.settings.library_path = "/Volumes/Photos/Synthetic Library".into();
for docking in [serde_json::json!({"layout":{"root":{"FutureLayout":{}}}}), serde_json::json!(false)] {
let mut saved = serde_json::to_value(&ui).unwrap();
saved["docking"] = docking;
assert!(serde_json::from_value::<UiState>(saved.clone()).is_err(), "runtime state parsing stays strict");
let bytes = serde_json::to_vec(&saved).unwrap();
for set_aside in [false, true] {
std::fs::write(&path, &bytes).unwrap();
let (loaded, warning, keep) = load_prefs_at(&path, set_aside);
let loaded = loaded.unwrap();
assert_eq!(loaded.settings.library_path, ui.settings.library_path);
assert_eq!(loaded.thumb_size, ui.thumb_size);
assert!(!loaded.filmstrip);
assert!(loaded.docking.is_none());
assert!(warning.unwrap().contains("Other settings were loaded"));
assert!(keep);
let mut writer = PrefsWriter { path: Some(path.clone()), keep_file: keep, ..Default::default() };
writer.save_ui(&loaded, false).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), bytes);
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1, "a future layout does not rename or replace the settings file");
}
}
std::fs::remove_dir_all(d).unwrap();
}
/// Issue #164: the writer saves changed settings to its file — and nothing at all without one
/// (`--memory`) or while the session is the temporary one offered when the library can't open.
#[test]
@ -1131,26 +838,6 @@ 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 #250: the window's device is created without wgpu's indirect-call validation pipeline,
/// which Intel Macs' Metal compiler fails to build (the device was lost, the window never opened).
#[test]
fn window_has_no_indirect_call_validation_pipeline() {
let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = window_wgpu_options().wgpu_setup else { panic!("expected CreateNew") };
if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() {
assert!(!n.instance_descriptor.flags.contains(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL));
}
}
/// 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]
@ -1162,23 +849,6 @@ 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));
@ -1208,16 +878,3 @@ mod tests {
let _ = std::fs::remove_dir_all(&d);
}
}
#[cfg(test)]
mod config_path_tests {
use super::*;
#[test]
fn config_override_keeps_panic_log_in_isolated_profile() {
let temp = std::path::PathBuf::from("temp");
assert_eq!(panic_log_path_with(|_| Some("isolated".into()), temp.clone()), std::path::PathBuf::from("isolated/lightcraft-panics.log"));
assert_eq!(panic_log_path_with(|_| None, temp.clone()), temp.join("lightcraft-panics.log"));
assert_eq!(panic_log_path_with(|_| Some("".into()), temp.clone()), temp.join("lightcraft-panics.log"));
}
}

View File

@ -355,15 +355,12 @@ impl NativeMenu {
}
/// Per frame: run chosen items, then sync labels / enabled / checked and the text-focus
/// 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 {
/// accelerators with the app state.
pub fn update(&mut self, app: &mut LightcraftApp, ctx: &egui::Context) {
lightcraft_ui_egui::i18n::set_language(app.ui.language);
let mut quit = false;
while let Ok(key) = self.rx.try_recv() {
if key == QUIT {
quit = true;
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
continue;
}
if key == "app.about" || key == SETTINGS {
@ -378,7 +375,7 @@ impl NativeMenu {
let bar = menu_bar(app);
if structure_of(&bar) != self.structure {
self.rebuild(app);
return quit;
return;
}
fn walk(nodes: &[MenuNode], items: &mut HashMap<String, Item>) {
for n in nodes {
@ -432,7 +429,6 @@ impl NativeMenu {
}
self.publish_shortcuts(app);
}
quit
}
}

View File

@ -1,30 +0,0 @@
//! The ICC profile of the screen showing LightCraft's window (macOS).
//!
//! macOS colour-manages windows that say what colour space they draw in. wgpu leaves the window's
//! Metal layer without one for sRGB content, which Core Animation shows without colour matching, so
//! on a wide-gamut (P3) screen the previews looked more saturated than the exported files do in
//! Preview or Photos (issue #787). While no monitor profile is chosen in Settings ▸ Display, the
//! previews are shown through this profile instead (`Services::screen_profile`).
use objc2::MainThreadMarker;
use objc2_app_kit::{NSApplication, NSScreen};
/// The profile of the screen the window is mostly on (the main screen when it is hidden), or `None`
/// when it isn't known: off the main thread, or a screen without a usable profile.
pub fn icc() -> Option<Vec<u8>> {
let mtm = MainThreadMarker::new()?;
let app = NSApplication::sharedApplication(mtm);
let window = app.mainWindow().or_else(|| app.keyWindow()).or_else(|| app.windows().firstObject());
let screen = window.and_then(|w| w.screen()).or_else(|| NSScreen::mainScreen(mtm))?;
let icc = screen.colorSpace()?.ICCProfileData()?.to_vec();
(!icc.is_empty()).then_some(icc)
}
#[cfg(test)]
mod tests {
#[test]
fn unknown_off_the_main_thread() {
// AppKit is main-thread only; a test thread gets nothing rather than undefined behaviour
assert_eq!(std::thread::spawn(super::icc).join().unwrap(), None);
}
}

View File

@ -1,316 +0,0 @@
//! The window's renderer and its fallback chain (crash report: on some Windows machines FXC fails
//! to compile egui's own shaders on DX12 — "The parameter is incorrect. (0x80070057)" in
//! `egui_pipeline` — and wgpu's default error handler panicked, killing the app at start-up).
//!
//! - **Windows:** each renderer of [`backend::window_renderer_order`] (DX12, Vulkan, GL, then
//! eframe's glow) is *probed* before the window opens: a device is created with an error handler
//! that logs instead of panicking, and egui's pipeline is built inside error scopes. The first
//! that builds cleanly is handed to eframe as an existing device (so its error handler stays).
//! - **Everywhere:** a marker file names the renderer being started and is removed once the
//! window's renderer is up. Left behind — the start died or panicked — the next launch tries that
//! renderer last; [`relaunch_after_failure`] starts that next launch right away.
//! - The renderer that worked is remembered (`<config>/window-renderer`, Windows only) and tried
//! first next time. `LIGHTCRAFT_GPU_BACKEND=dx12|vulkan|gl|glow` (or `WGPU_BACKEND`) forces one.
use eframe::egui_wgpu::{WgpuConfiguration, WgpuSetup, WgpuSetupExisting};
use eframe::wgpu;
use lightcraft_engine::gpu::backend::{self, Backends, WindowRenderer};
use std::path::PathBuf;
/// Environment variable counting automatic relaunches after a failed start (bounds the chain).
const RETRY_VAR: &str = "LIGHTCRAFT_WINDOW_RETRY";
/// Never more relaunches than renderers to try.
const MAX_RETRIES: u32 = 4;
/// The window's renderer for this start.
pub struct Chosen {
pub renderer: eframe::Renderer,
pub wgpu_options: WgpuConfiguration,
pub which: WindowRenderer,
/// For the user: the last start failed with another renderer, or one failed now.
pub notice: Option<String>,
}
/// Files under the settings folder (`None` dir: write nothing — `--memory`, `LIGHTCRAFT_NO_PREFS`).
pub struct Files {
dir: Option<PathBuf>,
}
impl Files {
pub fn new(dir: Option<PathBuf>) -> Self {
Self { dir }
}
fn marker(&self) -> Option<PathBuf> {
self.dir.as_ref().map(|d| d.join("window-init.marker"))
}
fn remembered(&self) -> Option<PathBuf> {
self.dir.as_ref().map(|d| d.join("window-renderer"))
}
fn read(p: Option<PathBuf>) -> Option<WindowRenderer> {
std::fs::read_to_string(p?).ok().as_deref().and_then(WindowRenderer::parse)
}
fn write(p: Option<PathBuf>, r: WindowRenderer) {
let Some(p) = p else { return };
if let Some(d) = p.parent() {
let _ = std::fs::create_dir_all(d);
}
if let Err(e) = std::fs::write(&p, format!("{}\n", r.name())) {
log::warn!("window: could not write {}: {e}", p.display());
}
}
/// The renderer the last start died in (marker left behind), removing the marker.
pub fn take_failed(&self) -> Option<WindowRenderer> {
let m = self.marker()?;
let r = Self::read(Some(m.clone()));
let _ = std::fs::remove_file(m);
r
}
/// Before the window opens: record what is being started.
pub fn arm(&self, r: WindowRenderer) {
Self::write(self.marker(), r);
}
/// The window's renderer is up: clear the marker and (Windows) remember it.
pub fn started(&self, r: WindowRenderer) {
if let Some(m) = self.marker() {
let _ = std::fs::remove_file(m);
}
if cfg!(windows) {
Self::write(self.remembered(), r);
}
}
/// The marker is still there: the window never came up.
pub fn armed(&self) -> bool {
self.marker().is_some_and(|m| m.exists())
}
}
/// The renderer for this start: see the module docs.
pub fn choose(files: &Files) -> Chosen {
let forced =
backend::forced_window_renderer(std::env::var("LIGHTCRAFT_GPU_BACKEND").ok().as_deref(), std::env::var("WGPU_BACKEND").ok().as_deref());
let failed = files.take_failed();
let remembered = if cfg!(windows) { Files::read(files.remembered()) } else { None };
let compiled = wgpu::Instance::enabled_backend_features();
let os = std::env::consts::OS;
let order = backend::window_renderer_order(forced, remembered, failed, os, compiled, true);
let mut notice = failed.map(|f| format!("The window could not start with its {} renderer last time.", f.name()));
let mut problems = Vec::new();
let chosen = if cfg!(windows) {
pick_probed(&order, &mut problems)
} else if forced == Some(WindowRenderer::Glow) || (forced.is_none() && failed.is_some_and(|f| f != WindowRenderer::Glow)) {
// elsewhere eframe's own wgpu set-up stays the default; glow after a failed start
Some((WindowRenderer::Glow, None))
} else {
Some((WindowRenderer::Wgpu(backend::window_backends()), None))
};
let (which, existing) = chosen.unwrap_or((WindowRenderer::Glow, None));
if !problems.is_empty() {
notice = Some(format!("{} {}", notice.unwrap_or_default(), problems.join(" ")).trim().to_string());
}
let notice = notice.map(|n| {
format!(
"{n} {app} now draws its window with {}. To choose another, start {app} with {var}=dx12, vulkan, gl or glow \
(see docs/gpu-pipeline.md → Troubleshooting).",
which.name(),
app = "LightCraft",
var = "LIGHTCRAFT_GPU_BACKEND",
)
});
log::info!("window: renderer {} (order {:?})", which.name(), order.iter().map(|r| r.name()).collect::<Vec<_>>());
let mut wgpu_options = base_options();
let renderer = match (which, existing) {
(WindowRenderer::Glow, _) => eframe::Renderer::Glow,
(WindowRenderer::Wgpu(_), Some(e)) => {
wgpu_options.wgpu_setup = WgpuSetup::Existing(e);
eframe::Renderer::Wgpu
}
(WindowRenderer::Wgpu(b), None) => {
if let WgpuSetup::CreateNew(n) = &mut wgpu_options.wgpu_setup {
n.instance_descriptor.backends = b;
}
eframe::Renderer::Wgpu
}
};
Chosen { renderer, wgpu_options, which, notice }
}
/// eframe's defaults with the app's backends and DX12 shader compiler (issues #136, #471).
pub fn base_options() -> WgpuConfiguration {
let mut c = WgpuConfiguration::default();
if let WgpuSetup::CreateNew(n) = &mut c.wgpu_setup {
n.instance_descriptor.backends = backend::window_backends();
n.instance_descriptor.backend_options = backend::backend_options();
// no indirect-call validation pipeline: Intel Metal fails to compile it and the window never opens (#250)
n.instance_descriptor.flags = backend::instance_flags_from_env(n.instance_descriptor.flags);
}
c
}
/// The first renderer of `order` that probes cleanly (glow is taken without a probe).
fn pick_probed(order: &[WindowRenderer], problems: &mut Vec<String>) -> Option<(WindowRenderer, Option<WgpuSetupExisting>)> {
for &r in order {
match r {
WindowRenderer::Glow => return Some((r, None)),
WindowRenderer::Wgpu(b) => {
let probed = std::panic::catch_unwind(|| probe(b)).unwrap_or_else(|_| Err("panicked".into()));
match probed {
Ok(e) => return Some((r, Some(e))),
Err(e) => {
log::error!("window: the {} renderer failed: {e}", r.name());
problems.push(format!("The {} renderer failed ({e}).", r.name()));
}
}
}
}
}
None
}
/// Create a device on `backends` and build egui's pipeline on it; any wgpu error is returned.
fn probe(backends: Backends) -> Result<WgpuSetupExisting, String> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
backends,
flags: backend::instance_flags_from_env(wgpu::InstanceFlags::from_build_config().with_env()),
backend_options: backend::backend_options(),
memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(),
display: None,
});
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::from_env().unwrap_or(wgpu::PowerPreference::HighPerformance),
..Default::default()
}))
.map_err(|e| format!("no adapter: {e}"))?;
let base = if adapter.get_info().backend == wgpu::Backend::Gl { wgpu::Limits::downlevel_webgl2_defaults() } else { wgpu::Limits::default() };
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
label: Some("egui wgpu device"),
required_limits: wgpu::Limits { max_texture_dimension_2d: 8192, ..base },
..Default::default()
}))
.map_err(|e| format!("{}: no device: {e}", adapter.get_info().name))?;
install_error_handler(&device);
check_pipeline(&device, |d| {
drop(eframe::egui_wgpu::Renderer::new(d, wgpu::TextureFormat::Bgra8Unorm, eframe::egui_wgpu::RendererOptions::default()));
})?;
Ok(WgpuSetupExisting { instance, adapter, device, queue })
}
/// wgpu's default handler for errors outside an error scope panics ("Handling wgpu errors as
/// fatal by default"); this one logs.
pub fn install_error_handler(device: &wgpu::Device) {
device.on_uncaptured_error(std::sync::Arc::new(|e| log::error!("window: wgpu error: {e}")));
}
/// Run `build` inside validation, internal and out-of-memory error scopes: the first error is
/// returned instead of reaching the uncaptured-error handler.
pub fn check_pipeline(device: &wgpu::Device, build: impl FnOnce(&wgpu::Device)) -> Result<(), String> {
let oom = device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
build(device);
let errors = [pollster::block_on(validation.pop()), pollster::block_on(internal.pop()), pollster::block_on(oom.pop())];
match errors.into_iter().flatten().next() {
Some(e) => Err(e.to_string()),
None => Ok(()),
}
}
/// The start failed (error or panic) before the window's renderer came up: start the app again,
/// which then tries the next renderer. `false`: not relaunched (it came up, nothing written to
/// disk, or the retries are used up).
pub fn relaunch_after_failure(files: &Files) -> bool {
if !files.armed() {
return false;
}
let tries = std::env::var(RETRY_VAR).ok().and_then(|v| v.parse::<u32>().ok()).unwrap_or(0);
if tries >= MAX_RETRIES {
return false;
}
let Ok(exe) = std::env::current_exe() else { return false };
log::warn!("window: the renderer failed to start; starting again with the next one");
std::process::Command::new(exe)
.args(std::env::args_os().skip(1))
.env(RETRY_VAR, (tries + 1).to_string())
.spawn()
.map_err(|e| log::error!("window: could not start again: {e}"))
.is_ok()
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("app-window-gpu-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
d
}
/// The marker names the renderer being started; left behind, the next start reads it as the
/// failed one (and removes it); a good start removes it and remembers the renderer (Windows).
#[test]
fn marker_records_the_failed_renderer() {
let d = dir("marker");
let f = Files::new(Some(d.clone()));
let dx = WindowRenderer::Wgpu(Backends::DX12);
f.arm(dx);
assert!(f.armed());
assert_eq!(f.take_failed(), Some(dx));
assert!(!f.armed());
assert_eq!(f.take_failed(), None);
f.arm(WindowRenderer::Glow);
f.started(WindowRenderer::Glow);
assert!(!f.armed());
assert_eq!(Files::read(f.remembered()), cfg!(windows).then_some(WindowRenderer::Glow));
// nothing written without a settings folder
let none = Files::new(None);
none.arm(dx);
assert!(!none.armed() && !relaunch_after_failure(&none));
let _ = std::fs::remove_dir_all(&d);
}
/// The crash report: a pipeline that fails validation must come back as an error, never as
/// wgpu's default panic. Needs an adapter; skipped where there is none (CI without a GPU).
#[test]
fn pipeline_errors_are_captured_not_panics() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
backends: backend::window_backends(),
..wgpu::InstanceDescriptor::new_without_display_handle()
});
let Ok(adapter) = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) else {
eprintln!("no GPU adapter: skipped");
return;
};
let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())) else {
eprintln!("no GPU device: skipped");
return;
};
install_error_handler(&device);
// a shader that does not validate, then a pipeline using it (like a failed FXC compile)
let r = check_pipeline(&device, |d| {
let m = d.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("broken"),
source: wgpu::ShaderSource::Wgsl("@vertex fn vs_main() -> @builtin(position) vec4<f32> { return undefined_thing; }".into()),
});
let _ = d.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("egui_pipeline"),
layout: None,
vertex: wgpu::VertexState { module: &m, entry_point: Some("vs_main"), compilation_options: Default::default(), buffers: &[] },
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: None,
multiview_mask: None,
cache: None,
});
});
assert!(r.is_err(), "{r:?}");
// egui's own pipeline builds cleanly here
check_pipeline(&device, |d| {
drop(eframe::egui_wgpu::Renderer::new(d, wgpu::TextureFormat::Bgra8Unorm, eframe::egui_wgpu::RendererOptions::default()));
})
.unwrap();
// outside a scope: logged by our handler, not a panic
let _ = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: None, source: wgpu::ShaderSource::Wgsl("not wgsl".into()) });
}
}

View File

@ -12,10 +12,7 @@ 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 |
@ -24,13 +21,12 @@ 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, 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 |
| 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 |
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.
- CJK fonts (BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans CJK SC) are not in this repository. They live in
- Japanese fonts (BIZ UDPGothic, BIZ UDMincho, Shippori Mincho) 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

View File

@ -1,52 +0,0 @@
{
"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]
]
}

View File

@ -2,23 +2,23 @@
"schema": 1,
"repo": "storytold/lightcraft",
"branch": "main",
"head": "c545cc5e842a74d65f32187d9fdee86ada3099de",
"head_date": "2026-10-10T05:40:18Z",
"commits": 1033,
"head": "2694d7089fbf4ada62c6738a7c62fecdfd5711fb",
"head_date": "2026-10-08T00:53:19Z",
"commits": 424,
"unattributed_commits": 0,
"contributors": [
{
"login": "echelon",
"people_entry": true,
"github_name": null,
"prs": 85,
"commits": 370,
"lines_added": 131038,
"lines_deleted": 9013,
"prs": 71,
"commits": 330,
"lines_added": 125623,
"lines_deleted": 8643,
"binary_added": 38,
"binary_deleted": 8,
"first_commit": "2026-09-30T14:04:12Z",
"last_commit": "2026-10-10T05:56:02Z"
"last_commit": "2026-10-08T00:32:34Z"
},
{
"login": "leipsfur",
@ -50,53 +50,40 @@
"login": "storybored379",
"people_entry": false,
"github_name": null,
"prs": 8,
"commits": 9,
"lines_added": 1925,
"lines_deleted": 119,
"prs": 7,
"commits": 7,
"lines_added": 1194,
"lines_deleted": 82,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-04T00:28:49Z",
"last_commit": "2026-10-09T06:02:21Z"
"last_commit": "2026-10-06T06:51:43Z"
},
{
"login": "midasdf",
"people_entry": false,
"github_name": null,
"prs": 6,
"commits": 6,
"lines_added": 884,
"lines_deleted": 93,
"prs": 1,
"commits": 1,
"lines_added": 424,
"lines_deleted": 25,
"binary_added": 1,
"binary_deleted": 0,
"first_commit": "2026-10-05T16:07:26Z",
"last_commit": "2026-10-09T18:24:49Z"
"last_commit": "2026-10-05T16:07:26Z"
},
{
"login": "voshart",
"people_entry": false,
"github_name": null,
"prs": 8,
"commits": 26,
"lines_added": 30524,
"lines_deleted": 4416,
"prs": 3,
"commits": 12,
"lines_added": 2481,
"lines_deleted": 1389,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-05T16:23:27Z",
"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"
"last_commit": "2026-10-07T16:07:24Z"
},
{
"login": "arata-1972",
@ -137,19 +124,6 @@
"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,
@ -219,7 +193,7 @@
"login": "itsmunzir",
"people_entry": false,
"github_name": null,
"prs": 2,
"prs": 1,
"commits": 1,
"lines_added": 46,
"lines_deleted": 30,
@ -271,40 +245,40 @@
"login": "radialmonster",
"people_entry": false,
"github_name": null,
"prs": 35,
"commits": 64,
"lines_added": 8732,
"lines_deleted": 822,
"prs": 3,
"commits": 19,
"lines_added": 2630,
"lines_deleted": 259,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T12:33:51Z",
"last_commit": "2026-10-10T02:01:36Z"
"last_commit": "2026-10-08T00:46:10Z"
},
{
"login": "bogusyogi",
"people_entry": false,
"github_name": null,
"prs": 2,
"commits": 17,
"lines_added": 5124,
"lines_deleted": 603,
"prs": 1,
"commits": 1,
"lines_added": 434,
"lines_deleted": 29,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T17:06:00Z",
"last_commit": "2026-10-08T19:26:28Z"
"last_commit": "2026-10-07T17:06:00Z"
},
{
"login": "nledford",
"people_entry": false,
"github_name": null,
"prs": 19,
"commits": 305,
"lines_added": 24796,
"lines_deleted": 2896,
"prs": 4,
"commits": 8,
"lines_added": 525,
"lines_deleted": 60,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T17:33:38Z",
"last_commit": "2026-10-09T23:05:22Z"
"last_commit": "2026-10-07T22:49:00Z"
},
{
"login": "MusseJusse",
@ -362,27 +336,14 @@
"login": "DWAA1660",
"people_entry": false,
"github_name": null,
"prs": 3,
"commits": 2,
"lines_added": 206,
"lines_deleted": 42,
"prs": 1,
"commits": 1,
"lines_added": 54,
"lines_deleted": 12,
"binary_added": 0,
"binary_deleted": 0,
"first_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"
"last_commit": "2026-10-07T22:08:01Z"
},
{
"login": "agorevski",
@ -410,487 +371,6 @@
"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,
@ -904,45 +384,6 @@
"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,
@ -963,45 +404,36 @@
"company": "Anthropic",
"model": "Claude Opus",
"version": "5.5",
"commits": 747,
"lines_added": 178263,
"lines_deleted": 14199
"commits": 364,
"lines_added": 131810,
"lines_deleted": 9367
},
{
"id": "anthropic/claude-sonnet/5.5",
"company": "Anthropic",
"model": "Claude Sonnet",
"version": "5.5",
"commits": 116,
"lines_added": 15071,
"lines_deleted": 2624
},
{
"id": "anthropic/claude",
"company": "Anthropic",
"model": "Claude",
"version": "",
"commits": 66,
"lines_added": 8450,
"lines_deleted": 956
"commits": 31,
"lines_added": 3941,
"lines_deleted": 1543
},
{
"id": "openai/codex",
"company": "OpenAI",
"model": "Codex",
"version": "",
"commits": 38,
"lines_added": 17120,
"lines_deleted": 1703
"commits": 14,
"lines_added": 8341,
"lines_deleted": 995
},
{
"id": "anthropic/claude-fable/5.1",
"id": "anthropic/claude",
"company": "Anthropic",
"model": "Claude Fable",
"version": "5.1",
"commits": 9,
"lines_added": 1548,
"lines_deleted": 112
"model": "Claude",
"version": "",
"commits": 2,
"lines_added": 1304,
"lines_deleted": 192
},
{
"id": "anthropic/claude-sonnet/5",
@ -1021,15 +453,6 @@
"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",
@ -1038,33 +461,6 @@
"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
}
]
}

View File

@ -21,9 +21,3 @@
real_name = "Brandon Thomas"
display_name = "Brandon Thomas"
sync_github_name = false
[people.rock-meister]
sync_github_name = true
[people.sanyamsingh001]
display_name = "sanyamsingh001"

View File

@ -11,7 +11,6 @@ workspace = true
[dependencies]
lightcraft-develop = { workspace = true }
lightcraft-meta = { workspace = true }
lightcraft-raw = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
@ -20,5 +19,4 @@ log = { workspace = true }
web-time = { workspace = true }
[dev-dependencies]
lightcraft-geom = { workspace = true }
proptest = { workspace = true }

View File

@ -40,17 +40,11 @@ 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, 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.
/// A date without a time gets one. `None` if it doesn't parse.
pub fn shift_iso(iso: &str, secs: i64) -> Option<String> {
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 t = iso_seconds(iso)?;
let tail = iso.trim().get(19..).unwrap_or("");
Some(format!("{}{tail}", civil(t)))
Some(format!("{}{tail}", civil(t + secs)))
}
/// Normalize user input to `YYYY-MM-DDTHH:MM:SS` (accepts a space instead of `T`, missing
@ -161,7 +155,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 => p.captured.as_deref(),
SortKey::CaptureDate => Some(p.date()),
SortKey::ImportDate => Some(p.imported.as_str()),
SortKey::EditDate => p.edited.as_deref(),
_ => None,
@ -210,14 +204,6 @@ 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);
}
@ -236,7 +222,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 is grouped under Unknown Date
// the undated photo falls back to its import date
assert_eq!(
shape(&day),
vec![
@ -244,20 +230,16 @@ mod tests {
("2026-09-29".into(), 2, 1),
("2026-08-01".into(), 3, 1),
("2025-12-31".into(), 4, 1),
(String::new(), 5, 1)
("2026-10-01".into(), 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, 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)]
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)]
);
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

View File

@ -23,21 +23,15 @@
//! (`/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::{Deserialize, Serialize};
use serde::Serialize;
use crate::query::{folder_key, folder_rest, folder_within, key_within, split_path};
use crate::{Catalog, ColorLabel, Op, Source};
use crate::query::{folder_key, folder_within, split_path};
use crate::{Catalog, Source};
/// Folder levels kept per photo. No real path comes near it; a deeper one is counted at its
/// ancestor this many levels down, which bounds the tree's depth (and so every walk of it)
@ -64,35 +58,9 @@ 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,
@ -183,53 +151,6 @@ 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> {
@ -275,7 +196,7 @@ impl Catalog {
l.own += n;
}
}
let mut tree: Vec<FolderNode> = roots.iter().filter_map(|k| build(k, &levels, &self.folder_records)).collect();
let mut tree: Vec<FolderNode> = roots.iter().filter_map(|k| build(k, &levels)).collect();
sort(&mut tree);
// a folder that holds another disk's mount covers that disk's photos too
let mounts: Vec<String> = tree.iter().filter(|v| v.path != "/").map(|v| v.path.clone()).collect();
@ -285,21 +206,12 @@ impl Catalog {
}
/// The node for `key` with everything below it (at most [`MAX_DEPTH`] deep).
fn build(key: &str, levels: &HashMap<String, Level>, records: &BTreeMap<String, FolderRecord>) -> Option<FolderNode> {
fn build(key: &str, levels: &HashMap<String, Level>) -> Option<FolderNode> {
let l = levels.get(key)?;
let mut children: Vec<FolderNode> = l.children.iter().filter_map(|k| build(k, levels, records)).collect();
let mut children: Vec<FolderNode> = l.children.iter().filter_map(|k| build(k, levels)).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,
label: records.get(key).and_then(|r| r.label),
children,
})
Some(FolderNode { name: l.name.clone(), path: l.path.clone(), count, own: l.own, volume: l.volume, selectable: true, children })
}
fn mark_selectable(nodes: &mut [FolderNode], mounts: &[String]) {

View File

@ -45,11 +45,6 @@
//! | (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` |
//! | 7 | Kelvin white balance for raws whose camera has matrices (#730) | `Op::SetAsShotWb`, `WhiteBalance.scale` |
//!
//! Rules:
//! - **Bump [`VERSION`]** (and add a row above) in the change that adds an [`Op`] variant or a
@ -74,7 +69,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 = 7;
pub const VERSION: u32 = 2;
/// 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

View File

@ -30,7 +30,7 @@ use std::collections::BTreeMap;
use std::sync::Arc;
pub use dates::{DateRun, GroupBy};
pub use folders::{FolderNode, FolderRecord};
pub use folders::FolderNode;
pub use journal::{Journal, LoadReport, PersistStats, SnapshotPolicy, SnapshotTiming};
pub use keywords::KeywordNode;
use lightcraft_develop::DevelopSettings;
@ -52,9 +52,6 @@ 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}")]
@ -139,11 +136,6 @@ 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>,
@ -209,18 +201,6 @@ 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>>,
},
/// A raw's as-shot white balance (Kelvin, tint) as its file reads now: what a decode or Reload finds when the
/// camera's colour matrices give the file a Kelvin scale it was imported without (issue #730). Bookkeeping,
/// not an edit: the look does not change, only the As Shot readout.
SetAsShotWb {
id: PhotoId,
wb: Option<(f64, f64)>,
},
/// The name shown for a colour label (`None` = its colour's name).
SetLabelName {
label: ColorLabel,
@ -232,18 +212,6 @@ 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>,
@ -266,12 +234,6 @@ 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,
@ -320,28 +282,6 @@ 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)
@ -357,57 +297,12 @@ impl Catalog {
/// no smart album).
pub fn album_contains(&self, id: AlbumId, p: &Photo) -> bool {
match self.albums.get(&id) {
// guarded: a smart album testing smart albums can't loop or recurse without end
Some(Album { smart: Some(rules), .. }) => !p.deleted && rules::smart_album_holds(id, p.id, || rules.matches(p, self)),
Some(Album { smart: Some(rules), .. }) => !p.deleted && 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) {
@ -444,18 +339,6 @@ 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)
@ -463,9 +346,7 @@ 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();
// 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))
self.label_names.iter().find(|(_, n)| n.trim().eq_ignore_ascii_case(name)).map(|(l, _)| *l).or_else(|| ColorLabel::parse(name))
}
// ---- writes
@ -598,20 +479,7 @@ impl Catalog {
}
}
let a = self.album_mut(id)?;
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::MoveAlbum { id, parent: std::mem::replace(&mut a.parent, parent) }
}
Op::SetAlbumPhotos { id, photos } => {
let a = self.album_mut(id)?;
@ -688,17 +556,6 @@ 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::SetAsShotWb { id, wb } => {
if wb.is_some_and(|(t, tint)| !(t.is_finite() && tint.is_finite())) {
return Err(CatalogError::Invalid("as-shot white balance is not a number".into()));
}
let p = self.photo_mut(id)?;
Op::SetAsShotWb { id, wb: std::mem::replace(&mut p.as_shot_wb, wb) }
}
Op::SetFile { id, file_name, source } => {
if file_name.trim().is_empty() {
return Err(CatalogError::Invalid("empty file name".into()));
@ -715,21 +572,6 @@ 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 {
@ -738,14 +580,6 @@ 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 {
@ -834,21 +668,11 @@ 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;

View File

@ -283,9 +283,7 @@ impl Op {
| Op::SetAnalysis { id, .. }
| Op::SetFile { id, .. }
| Op::Relink { id, .. }
| Op::SetContent { id, .. }
| Op::SetEmbeddedLens { id, .. }
| Op::SetAsShotWb { id, .. } => out(*id),
| Op::SetContent { id, .. } => out(*id),
Op::AddAlbum { album } => {
album.photos.iter().for_each(|p| out(*p));
album.cover.into_iter().for_each(&mut *out);
@ -297,13 +295,10 @@ impl Op {
Op::RemoveAlbum { .. }
| Op::RenameAlbum { .. }
| Op::MoveAlbum { .. }
| Op::SetAlbumOrder { .. }
| Op::SetAlbumRules { .. }
| Op::RemoveStack { .. }
| Op::SetLabelName { .. }
| Op::SetKeyword { .. }
| Op::SetBrowsed { .. }
| Op::SetFolderRecord { .. } => {}
| Op::SetBrowsed { .. } => {}
}
}
}

View File

@ -7,15 +7,9 @@
//! takes the lock first, and a second opener gets [`LockError::InUse`] instead.
//!
//! - The lock is the OS's (`flock` on Unix, `LockFileEx` on Windows, via [`std::fs::File::try_lock`]):
//! it is released explicitly when the [`LibraryLock`] is dropped and, after a crash or a kill, by
//! the OS when the process ends. A `catalog.lock` file left behind is therefore never "stale":
//! only a lock that's actually held refuses the open.
//! - Dropping the lock calls [`std::fs::File::unlock`] rather than relying on the handle closing.
//! On Unix an `flock` lock belongs to the open file description and lasts while *any* descriptor
//! on it is open; a process that another thread is spawning holds a copy of every descriptor
//! (`O_CLOEXEC` only closes them at `execve`), so closing ours would leave the library "already
//! open" for a moment and a re-open would be refused (issue #680). `LOCK_UN` through our
//! descriptor frees the lock whatever copies a child still holds.
//! it is released when the [`LibraryLock`] is dropped and, after a crash or a kill, by the OS when
//! the process ends. A `catalog.lock` file left behind is therefore never "stale": only a lock
//! that's actually held refuses the open.
//! - Who holds it (process id, computer, program, version) is written to `catalog.lock.owner`, for
//! the error message. It's a separate file because a Windows lock also blocks reading the
//! locked file. It's only a hint: it may be missing or left over from a crash.
@ -93,7 +87,7 @@ pub enum LockError {
pub struct LibraryLock {
dir: PathBuf,
/// `None` when the file system can't lock (the library is open unlocked).
file: Option<File>,
_file: Option<File>,
held: bool,
}
@ -102,7 +96,7 @@ impl LibraryLock {
pub fn acquire(dir: &Path, program: &str) -> Result<LibraryLock, LockError> {
let unlocked = |why: io::Error| {
log::warn!("library {}: can't lock {LOCK} ({why}); opening it without protection against a second program", dir.display());
LibraryLock { dir: dir.to_path_buf(), file: None, held: false }
LibraryLock { dir: dir.to_path_buf(), _file: None, held: false }
};
let file = match OpenOptions::new().read(true).write(true).create(true).truncate(false).open(dir.join(LOCK)) {
Ok(f) => f,
@ -122,7 +116,7 @@ impl LibraryLock {
{
log::warn!("library {}: can't write {OWNER}: {e}", dir.display());
}
Ok(LibraryLock { dir: dir.to_path_buf(), file: Some(file), held: true })
Ok(LibraryLock { dir: dir.to_path_buf(), _file: Some(file), held: true })
}
/// The OS lock is held (`false`: the file system can't lock; opened unprotected).
@ -133,29 +127,14 @@ impl LibraryLock {
pub fn dir(&self) -> &Path {
&self.dir
}
/// Another descriptor on the locked file: what a process spawned by another thread holds
/// between fork and exec (a `dup` refers to the same open file description). Tests only.
#[cfg(test)]
pub(crate) fn descriptor_copy(&self) -> Option<File> {
self.file.as_ref().and_then(|f| f.try_clone().ok())
}
}
impl Drop for LibraryLock {
/// Remove the owner note while still holding the lock (so it can't delete the next holder's),
/// then release the lock explicitly. Closing the handle isn't enough on Unix: `flock` lasts
/// while any copy of the descriptor is open, and a process another thread is spawning holds a
/// copy of every descriptor until it execs (issue #680). If the unlock fails the handle still
/// closes right after, which releases the lock once every copy is gone, as before.
/// then the file handle closes and the OS releases the lock.
fn drop(&mut self) {
if self.held {
let _ = std::fs::remove_file(self.dir.join(OWNER));
if let Some(f) = &self.file
&& let Err(e) = f.unlock()
{
log::warn!("library {}: can't release {LOCK}: {e}", self.dir.display());
}
}
}
}

View File

@ -152,17 +152,6 @@ 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)]
@ -307,25 +296,6 @@ pub struct Analysis {
pub best: bool,
}
/// Raw formats whose files carry vendor white-balance multipliers but no colour matrices: their starting
/// look is fitted to the file's own JPEG (the engine's `camera_preview::file_local_look` lists the same
/// formats). `format` is the file's extension or Lightroom's `fileFormat` name, in any case. Whether such a
/// file's white balance is edited relative to its as-shot look or in Kelvin depends on the camera:
/// [`relative_wb_camera`].
pub fn relative_wb_format(format: &str) -> bool {
["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF", "SRW", "ORF"].iter().any(|f| format.eq_ignore_ascii_case(f))
}
/// Whether a raw of `format` shot on `camera` (Exif make and model, as [`Meta::camera`] keeps them) is developed
/// with a white balance relative to its as-shot look (6500 K / 0 means as shot, as for rendered photographs):
/// a file without colour matrices ([`relative_wb_format`]) whose camera has none measured either. A camera
/// with matrices in `lightcraft_raw::spectral` edits in Kelvin: the matrices turn its as-shot multipliers into
/// a temperature, and the slider back, while its colour look stays whatever the file's fit gives (issue #730).
/// The engine's render path (`files.rs`) decides the same way from the file; both must agree.
pub fn relative_wb_camera(format: &str, camera: &str) -> bool {
relative_wb_format(format) && lightcraft_raw::spectral::find_camera(camera).is_none()
}
impl Photo {
pub fn new(id: PhotoId, source: Source, file_name: &str, format: &str, width: u32, height: u32, imported: &str) -> Photo {
Photo {
@ -366,88 +336,35 @@ impl Photo {
pub fn develops_raw(&self) -> bool {
self.kind == MediaKind::Raw && self.preview_only.is_none()
}
/// A raw developed from a look fitted to its own JPEG (ARW, NEF, RW2, RAF, CR3, CR2, PEF, SRW, ORF: vendor
/// WB multipliers, no colour matrices in the file; RWL and RAW are Leica's and the oldest Panasonic bodies'
/// names for RW2 files). The engine's `camera_preview::file_local_look` covers the same formats.
pub fn fitted_look(&self) -> bool {
self.develops_raw() && relative_wb_format(&self.format)
}
/// White balance relative to the camera's as-shot look, as for rendered photographs: a
/// [`Photo::fitted_look`] raw whose camera has no colour matrices measured either
/// ([`relative_wb_camera`]). Other raws edit white balance in Kelvin.
/// The current ARW, NEF, RW2, RAF, CR3, CR2 and PEF readers have vendor WB multipliers but no measured camera
/// illuminant. Use adjustments relative to the camera's as-shot look, as for rendered
/// photographs (the engine's `camera_preview::file_local_look` covers the same formats; RWL and
/// RAW are Leica's and the oldest Panasonic bodies' names for RW2 files).
pub fn relative_wb(&self) -> bool {
self.develops_raw() && relative_wb_camera(&self.format, &self.meta.camera)
self.develops_raw()
&& ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF"].iter().any(|f| self.format.eq_ignore_ascii_case(f))
}
/// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's
/// default preset applied on top of them ([`Photo::import_look`]). Like the camera defaults,
/// in the photo's own rendering process.
/// default preset applied on top of them ([`Photo::import_look`]).
pub fn import_defaults(&self) -> DevelopSettings {
match &self.import_look {
Some(l) => DevelopSettings { process: self.develop.process, ..(**l).clone() },
Some(l) => (**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. 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.
/// their as-shot white balance; embedded lens corrections on when the file has them.
pub fn camera_defaults(&self) -> DevelopSettings {
let relative = self.relative_wb();
let wb = if relative { Some((6500.0, 0.0)) } else { self.as_shot_wb };
let wb = if self.relative_wb() { Some((6500.0, 0.0)) } else { self.as_shot_wb };
let mut d = match wb {
Some((t, tint)) => DevelopSettings::for_raw(t, tint),
None => DevelopSettings::default(),
};
if self.develops_raw() && !relative {
d.wb.scale = lightcraft_develop::WbScale::Kelvin;
}
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
@ -488,16 +405,12 @@ 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, order: None }
Album { id, name: name.into(), parent: None, folder: false, photos: Vec::new(), cover: None, smart: None, quick: false }
}
pub fn is_smart(&self) -> bool {
self.smart.is_some()
@ -507,7 +420,6 @@ impl Album {
#[cfg(test)]
mod edited_tests {
use super::*;
use lightcraft_develop::WbScale;
#[test]
fn raw_with_import_settings_is_not_edited() {
@ -523,32 +435,6 @@ 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 [
@ -560,21 +446,14 @@ 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");
p.kind = MediaKind::Raw;
p.meta.camera = "Pentax K-1".into();
p.as_shot_wb = Some((5200.0, 4.0));
assert_eq!(p.relative_wb(), relative, "{format}");
assert_eq!(p.fitted_look(), relative, "{format}");
let wb = p.camera_defaults().wb;
assert_eq!((wb.temp, wb.tint), if relative { (6500.0, 0.0) } else { (5200.0, 4.0) }, "{format}");
assert_eq!(wb.scale, if relative { WbScale::Legacy } else { WbScale::Kelvin }, "{format}");
p.develop = Arc::new(DevelopSettings::for_raw(5200.0, 4.0));
assert!(!p.is_edited(), "{format}: old As Shot values are not an edit");
let mut custom = (*p.develop).clone();
@ -583,43 +462,7 @@ mod edited_tests {
assert!(p.is_edited(), "{format}: Custom WB is still an edit");
// shown from the embedded preview: a rendered image, not a relative-WB raw
p.preview_only = Some("unsupported".into());
assert!(!p.relative_wb() && !p.fitted_look());
}
}
/// A raw without matrices in the file edits in Kelvin when its camera's matrices are measured (issue #730):
/// the decision reads the catalog's camera string, and the photo's defaults say which scale they are on.
#[test]
fn raws_of_cameras_with_measured_matrices_use_kelvin_white_balance() {
for (camera, format, relative) in [
("SONY ILCE-7M3", "ARW", false),
("SONY ILCE-7RM4A", "ARW", false),
("NIKON CORPORATION NIKON D850", "NEF", false),
("Canon Canon EOS 5D Mark III", "CR2", false),
("FUJIFILM X-T4", "RAF", false),
("ILCE-7M3", "ARW", false),
("SONY ILCE-7M2", "ARW", true),
("SONY ILCE-1", "ARW", true),
("", "ARW", true),
("SONY ILCE-7M3", "DNG", false),
] {
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.raw", format, 10, 10, "2026-10-01T00:00:00");
p.kind = MediaKind::Raw;
p.meta.camera = camera.into();
p.as_shot_wb = Some((4986.0, -2.0));
assert_eq!(p.relative_wb(), relative, "{camera} {format}");
assert_eq!(relative_wb_camera(format, camera), relative, "{camera} {format}");
assert_eq!(p.fitted_look(), format == "ARW" || format == "NEF" || format == "CR2" || format == "RAF", "{camera} {format}");
let wb = p.camera_defaults().wb;
assert_eq!((wb.temp, wb.tint), if relative { (6500.0, 0.0) } else { (4986.0, -2.0) }, "{camera} {format}");
assert_eq!(wb.scale, if relative { WbScale::Legacy } else { WbScale::Kelvin }, "{camera} {format}");
// a photo from an older catalog: stored As Shot on the relative scale, still not an edit
p.develop = Arc::new(DevelopSettings::for_raw(6500.0, 0.0));
assert!(!p.is_edited(), "{camera} {format}: old As Shot values are not an edit");
// its as-shot white not yet read through the matrices (6500 / 0 from the old import): defaults still As Shot
p.as_shot_wb = Some((6500.0, 0.0));
assert_eq!(p.camera_defaults().wb.mode, WbMode::AsShot);
assert!(!p.is_edited());
assert!(!p.relative_wb());
}
}
}

View File

@ -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.captured.as_deref().is_some_and(|c| c.starts_with(val)),
"date" => p.date().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,21 +154,12 @@ 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.people().iter().any(|n| n.to_lowercase() == name)
p.meta.regions.iter().any(|r| r.kind == lightcraft_meta::RegionKind::Face && r.name.as_deref().is_some_and(|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 {
@ -216,18 +207,11 @@ 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(cat.map_or_else(|| rs.describe(), |c| rs.describe_with(c)));
v.push(rs.describe());
}
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)
@ -300,18 +284,12 @@ impl Filter {
return false;
}
if let Some(from) = &self.date_from
&& match p.captured.as_deref() {
Some(c) => c < from.as_str(),
None => true,
}
&& p.date() < from.as_str()
{
return false;
}
if let Some(to) = &self.date_to
&& match p.captured.as_deref() {
Some(c) => c.get(..to.len()).unwrap_or(c) > to.as_str(),
None => true,
}
&& p.date().get(..to.len()).unwrap_or(p.date()) > to.as_str()
{
return false;
}
@ -321,7 +299,7 @@ impl Filter {
return false;
}
if let Some(d) = &self.date
&& !p.captured.as_deref().is_some_and(|c| c.starts_with(d.as_str()))
&& !p.date().starts_with(d.as_str())
{
return false;
}
@ -366,7 +344,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.captured.cmp(&b.captured).then_with(|| a.imported.cmp(&b.imported)),
SortKey::CaptureDate => a.date().cmp(b.date()),
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()),
@ -385,7 +363,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 Some(d) = p.captured.as_deref() else { continue };
let d = p.date();
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

View File

@ -58,22 +58,12 @@ 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, order: None },
album: Album { id: f, name: "Trips".into(), parent: None, folder: true, photos: vec![], cover: None, smart: None, quick: false },
})
.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,
order: None,
},
album: Album { id: al, name: "Alps".into(), parent: Some(f), folder: false, photos: vec![a], cover: None, smart: None, quick: false },
})
.unwrap();
assert_eq!(c.albums_of(a), vec![al]);
@ -456,241 +446,3 @@ 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, stages: Default::default() };
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 decode or Reload stores the as-shot white a raw reads through its camera's matrices; the inverse (through the
/// journal's JSON) takes it back, and a value that is not a number is refused.
#[test]
fn set_as_shot_wb_is_undoable_journaled_and_checked() {
let mut c = Catalog::default();
let a = c.alloc_photo_id();
let mut p = Photo::new(a, Source::File { path: "/x.arw".into() }, "x.arw", "ARW", 4000, 3000, "2026-10-06T00:00:00");
p.as_shot_wb = Some((6500.0, 0.0));
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
let op = Op::SetAsShotWb { id: a, wb: Some((4986.0, -2.0)) };
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().as_shot_wb, Some((4986.0, -2.0)));
let inv: Op = serde_json::from_str(&serde_json::to_string(&inv).unwrap()).unwrap();
c.apply(inv).unwrap();
assert_eq!(c.photo(a).unwrap().as_shot_wb, Some((6500.0, 0.0)));
assert!(c.apply(Op::SetAsShotWb { id: a, wb: Some((f64::NAN, 0.0)) }).is_err());
assert!(c.apply(Op::SetAsShotWb { id: PhotoId(999), wb: None }).is_err());
assert_eq!(c.photo(a).unwrap().as_shot_wb, Some((6500.0, 0.0)));
}
/// 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]);
}

View File

@ -1,122 +0,0 @@
//! 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));
}

View File

@ -1,199 +0,0 @@
//! 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);
}

View File

@ -47,33 +47,6 @@ 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();
@ -206,13 +179,8 @@ 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,
Op::SetAsShotWb { .. } => 7,
}
}
let newest = since(&Op::SetAsShotWb { id: PhotoId(1), wb: None });
let newest = since(&Op::SetBrowsed { folder: String::new(), at: None });
assert_eq!(newest, VERSION, "the newest op's version must be the current format version");
}

View File

@ -59,7 +59,6 @@ fn op_for(c: &mut Catalog, kind: u8, a: u8, b: u8) -> Op {
cover: None,
smart: None,
quick: false,
order: None,
},
}
}
@ -78,16 +77,6 @@ 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") } }
}
@ -335,44 +324,3 @@ 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));
}

View File

@ -5,17 +5,6 @@ use std::path::PathBuf;
use crate::lock::{LOCK, OWNER};
use crate::*;
/// Taken by every test here that holds a lock in this process. On Unix, spawning the child of
/// [`lock_held_by_another_process_is_released_when_it_dies`] briefly copies this process's file
/// descriptors, close-on-exec lock files included, until the child's exec closes them. A lock
/// another test drops in that moment stays held by the copy, so acquiring it again could fail with
/// "in use".
static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn serial() -> std::sync::MutexGuard<'static, ()> {
SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn temp_dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("lc-lock-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
@ -25,7 +14,6 @@ fn temp_dir(tag: &str) -> PathBuf {
#[test]
fn second_opener_is_refused_until_the_first_lets_go() {
let _serial = serial();
let dir = temp_dir("twice");
let first = LibraryLock::acquire(&dir, "LightCraft").unwrap();
assert!(first.held());
@ -50,7 +38,6 @@ fn second_opener_is_refused_until_the_first_lets_go() {
/// Files left behind by a crash (lock file + owner note) don't lock anyone out.
#[test]
fn leftover_lock_files_are_not_a_lock() {
let _serial = serial();
let dir = temp_dir("stale");
std::fs::write(dir.join(LOCK), b"").unwrap();
std::fs::write(dir.join(OWNER), br#"{"pid":999999,"host":"elsewhere","program":"LightCraft","version":"0.2.0","since":1}"#).unwrap();
@ -65,7 +52,6 @@ fn leftover_lock_files_are_not_a_lock() {
/// Another process holds the lock; when it is killed (a crash), the OS releases it.
#[test]
fn lock_held_by_another_process_is_released_when_it_dies() {
let _serial = serial();
let dir = temp_dir("child");
let exe = std::env::current_exe().unwrap();
let mut child = std::process::Command::new(exe)
@ -105,69 +91,3 @@ fn lock_holder_child() {
std::fs::write(std::path::Path::new(&dir).join("child-locked"), b"").unwrap();
std::thread::sleep(std::time::Duration::from_secs(120));
}
/// Issue #680: a process spawned by any thread holds a copy of every descriptor until it execs
/// (close-on-exec only closes them then), and `flock` lasts while any copy of the descriptor is
/// open. Dropping the `LibraryLock` must still free the library, so it releases explicitly rather
/// than relying on its handle closing. A `try_clone` of the descriptor stands in for the child's
/// copy: it refers to the same open file description, which is exactly what the child gets.
#[cfg(unix)]
#[test]
fn the_library_is_free_once_dropped_even_if_a_child_holds_a_copy() {
let dir = temp_dir("copy");
let first = LibraryLock::acquire(&dir, "LightCraft").unwrap();
let copy = first.descriptor_copy().unwrap();
drop(first);
let again = LibraryLock::acquire(&dir, "lightcraft-cli").expect("the library is free once its LibraryLock is dropped");
assert!(again.held());
drop((again, copy));
let _ = std::fs::remove_dir_all(&dir);
}
/// Issue #680, as it happens in practice: one thread spawns cheap children while another acquires,
/// drops and re-acquires the lock on its own directory. Every acquire must succeed (before the fix
/// a few per cent were refused with `InUse(None)`). Bounded: about two seconds of cycles, pass or fail.
#[cfg(unix)]
#[test]
fn dropping_the_lock_frees_the_library_while_another_thread_spawns_processes() {
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
let dir = temp_dir("spawn");
let stop = Arc::new(AtomicBool::new(false));
let spawner = {
let stop = Arc::clone(&stop);
std::thread::spawn(move || {
let mut spawned = 0usize;
while !stop.load(Ordering::Relaxed) {
if let Ok(mut c) =
std::process::Command::new("/bin/true").stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn()
{
let _ = c.wait();
spawned += 1;
}
}
spawned
})
};
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
let mut cycles = 0usize;
let mut refused = Vec::new();
while cycles < 200_000 && std::time::Instant::now() < deadline {
match LibraryLock::acquire(&dir, "LightCraft") {
Ok(l) => assert!(l.held()), // dropped here: the next cycle must get it back
Err(e) => refused.push(format!("cycle {cycles}: {e:?}")),
}
cycles += 1;
}
stop.store(true, Ordering::Relaxed);
let spawned = spawner.join().unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert!(spawned > 0, "the spawning thread never got a child off the ground");
assert!(
refused.is_empty(),
"{} of {cycles} re-acquires refused while {spawned} children were spawned: {:?}",
refused.len(),
&refused[..refused.len().min(3)]
);
}

View File

@ -12,7 +12,6 @@ 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 }
@ -30,26 +29,13 @@ 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 }
# spawns threads of its own (std::thread), which wasm32 has not got
jixel = { workspace = true, optional = true }
[features]
default = ["parallel", "jxl", "avif"]
parallel = ["dep:rayon", "lightcraft-raster/parallel", "jpeg-decoder/rayon"]
## JPEG XL decode (jxl-oxide) and, on native targets, encode (jixel); both pure Rust.
jxl = ["dep:jxl-oxide", "dep:jixel"]
# 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"]
jxl = ["dep:jxl-oxide"]
## AVIF encoding via ravif/rav1e (pure Rust, BSD). Native targets only; a no-op on wasm32.
avif = ["dep:ravif", "dep:rav1e", "dep:avif-serialize"]
avif = ["dep:ravif"]
[dev-dependencies]
# JPEG XL tests read encoded samples without our colour conversion
jxl-oxide = { workspace = true }
proptest = { workspace = true }
# Lossless synthetic HEIC files for the `heif` feature's tests.
lightcraft-heif = { workspace = true, features = ["testdata"] }

View File

@ -23,13 +23,6 @@ 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`
encode_jxl(&img, &JxlOptions { quality: 90, ..Default::default() }, &meta)?; // native + feature `jxl`
// 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.
@ -52,10 +45,9 @@ CRW, MRW, X3F) are detected so the engine can route them to `lightcraft-raw`; `d
| WebP | lossy + lossless, alpha, first frame of animations | lossless only | ICCP/EXIF/XMP chunks |
| 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) | yes (jixel, `encode_jxl`, feature `jxl`, native only): lossy VarDCT / lossless Modular, 8/16-bit, effort (fast / normal), progressive; HDR as 16-bit Rec. 2020 PQ | decode: enum colour → rendered straight to linear Rec.2020 (XYB via linear sRGB primaries); ICC → our ICC path; orientation applied by the decoder (reported as 1). Encode: lossy files declare an enum encoding (sRGB, Display P3, Rec. 2020 BT.709 curve, Rec. 2020 PQ), lossless ones embed the ICC profile; EXIF / XMP in `Exif` / `xml ` boxes |
| 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 |
| 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 |
## Colour
@ -88,7 +80,6 @@ All pure Rust, verified with `cargo info` / `cargo tree`:
| image (gif, bmp only) | MIT OR Apache-2.0 | GIF/BMP |
| moxcms | BSD-3-Clause OR Apache-2.0 | ICC parse/write, CMS |
| jxl-oxide | MIT OR Apache-2.0 | JPEG XL decode |
| jixel | BSD-3-Clause OR Apache-2.0 | JPEG XL encode (native only) |
| ravif (+ rav1e, avif-serialize) | BSD-3-Clause / BSD-2-Clause | AVIF encode (native only; heavy to compile) |
Deliberately **not** used: anything AGPL from imazen (heic, zenjpeg, jxl-encoder, rav1d-safe),
@ -109,11 +100,9 @@ Run `cargo test --release -p lightcraft-codecs --test bench -- --ignored --nocap
## Gaps / limitations
- **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.
- **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.
- 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.

View File

@ -1,442 +0,0 @@
//! 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);
}
}

View File

@ -1,6 +1,6 @@
//! Encoders: JPEG (`jpeg-encoder`), PNG (`png`), TIFF (`tiff`), flat PSD (our own), lossless WebP (`image-webp`) and,
//! on native targets with the `avif` feature, AVIF (`ravif`/`rav1e`); JPEG XL is in `jxl_encode`.
//! Metadata (ICC/EXIF/XMP) is embedded where the format allows.
//! Encoders: JPEG (`jpeg-encoder`), PNG (`png`), TIFF (`tiff`), lossless WebP (`image-webp`) and,
//! on native targets with the `avif` feature, AVIF (`ravif`/`rav1e`). Metadata (ICC/EXIF/XMP) is
//! embedded where the format allows.
use crate::{Error, Result};
use lightcraft_raster::Rgba8;
@ -32,7 +32,7 @@ impl<'a> EncodeImage<'a> {
EncodeImage { width, height, channels, samples }
}
pub(crate) fn validate(&self) -> Result<()> {
fn validate(&self) -> Result<()> {
if !(1..=4).contains(&self.channels) {
return Err(Error::Encode(format!("unsupported channel count {}", self.channels)));
}
@ -79,46 +79,6 @@ 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()?;
@ -137,7 +97,35 @@ pub fn encode_jpeg(img: &EncodeImage, quality: u8, subsampling: ChromaSubsamplin
};
if subsampling != ChromaSubsampling::S422 {
// Parallel encoder (restart-interval bands on all cores).
let segs = jpeg_app_segments(meta)?;
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 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));
@ -291,12 +279,6 @@ pub fn encode_tiff(img: &EncodeImage, compression: TiffCompression, meta: &Encod
Ok(cur.into_inner())
}
/// Encode a flat PSD (8/16-bit gray or RGB, no layers; alpha dropped): the hand-off an image
/// editor such as PhotoCraft saves back to in place. ICC, EXIF, XMP and resolution are embedded.
pub fn encode_psd(img: &EncodeImage, meta: &EncodeMeta) -> Result<Vec<u8>> {
crate::psd::encode(img, meta)
}
/// Encode lossless WebP (8-bit). ICC/EXIF/XMP are embedded in the extended (VP8X) container.
pub fn encode_webp_lossless(img: &EncodeImage, meta: &EncodeMeta) -> Result<Vec<u8>> {
img.validate()?;
@ -369,195 +351,6 @@ 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()))
}
}
/// Encode an HDR PNG: `rgb` is linear light in **Rec. 2020** primaries with SDR white at 1.0
/// (`width × height × 3`), as for [`encode_avif_pq`]. Written as 16-bit RGB with the PQ curve
/// (SDR white at [`HDR_REFERENCE_WHITE_NITS`]), described by a `cICP` chunk (PNG third edition:
/// BT.2020 primaries, PQ, RGB, full range) and a `cLLI` content light level chunk (the same
/// levels the HDR AVIF records). EXIF and XMP are embedded; an ICC profile is not (`cICP`
/// describes the colour, and readers prefer it). Readers without `cICP` support show the PQ
/// samples as if they were sRGB (flat, washed out): expected for a PQ PNG, there is no fallback.
pub fn encode_png_pq(width: u32, height: u32, rgb: &[f32], 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 PNG: sample buffer too short".into()));
}
const K: [f32; 3] = [0.2627, 0.6780, 0.0593]; // BT.2020 luminance
let mut max_nits = 0f32;
let mut sum_nits = 0f64;
let mut bytes = Vec::with_capacity(n * 6);
for c in rgb.as_chunks::<3>().0.iter().take(n) {
let nits = c.map(|v| (if v.is_finite() { v.max(0.0) } else { 0.0 }) * HDR_REFERENCE_WHITE_NITS);
max_nits = max_nits.max(nits[0].max(nits[1]).max(nits[2]));
sum_nits += (K[0] * nits[0] + K[1] * nits[1] + K[2] * nits[2]) as f64;
for v in nits {
bytes.extend_from_slice(&((pq_encode(v) * 65535.0).round().clamp(0.0, 65535.0) as u16).to_be_bytes());
}
}
let cll = (max_nits.ceil().clamp(1.0, 10_000.0) as u32, ((sum_nits / n as f64).ceil().clamp(1.0, 10_000.0)) as u32);
let mut info = png::Info::with_size(width, height);
info.color_type = png::ColorType::Rgb;
info.bit_depth = png::BitDepth::Sixteen;
info.exif_metadata = meta.exif.map(|b| b.to_vec().into());
info.pixel_dims = meta.ppi.filter(|p| *p > 0).map(|p| {
let ppm = (p as f64 / 0.0254).round() as u32;
png::PixelDimensions { xppu: ppm, yppu: ppm, unit: png::Unit::Meter }
});
let mut out = Vec::new();
let e = |e: png::EncodingError| Error::Encode(e.to_string());
{
let mut enc = png::Encoder::with_info(&mut out, info).map_err(e)?;
enc.set_compression(png::Compression::Fast);
if let Some(xmp) = meta.xmp {
enc.add_itxt_chunk("XML:com.adobe.xmp".into(), xmp.into()).map_err(e)?;
}
let mut wr = enc.write_header().map_err(e)?;
// BT.2020 primaries (9), PQ (16), RGB (0), full range (1)
wr.write_chunk(png::chunk::ChunkType(*b"cICP"), &[9, 16, 0, 1]).map_err(e)?;
// MaxCLL and MaxFALL in units of 0.0001 cd/m²
let clli = [(cll.0 * 10_000).to_be_bytes(), (cll.1 * 10_000).to_be_bytes()].concat();
wr.write_chunk(png::chunk::ChunkType(*b"cLLI"), &clli).map_err(e)?;
wr.write_image_data(&bytes).map_err(e)?;
wr.finish().map_err(e)?;
}
Ok(out)
}
/// 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>> {

View File

@ -1,834 +0,0 @@
//! 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());
}
}

View File

@ -1,151 +0,0 @@
//! 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:?}");
}
}

View File

@ -205,7 +205,7 @@ pub fn write_named(n: NamedSpace) -> Vec<u8> {
write_matrix_trc(&n.rgb_space(), &n.trc())
}
pub(crate) fn linear_rec2020_profile() -> ColorProfile {
fn linear_rec2020_profile() -> ColorProfile {
let mut p = ColorProfile::new_bt2020();
p.cicp = None;
let lin = ToneReprCurve::Parametric(vec![1.0]);

View File

@ -25,17 +25,6 @@ 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> {
@ -99,10 +88,6 @@ 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 => {
@ -180,7 +165,7 @@ pub(crate) fn decode_with_fallback(bytes: &[u8], opts: &DecodeOptions, fallback:
return Err(Error::Malformed(F, "no frame header".into()));
}
check_size(F, m.width as u64, m.height as u64, opts)?;
let scale_to = opts.max_size.filter(|&(mw, mh)| mw > 0 && mh > 0 && (m.width >= mw.saturating_mul(2) || m.height >= mh.saturating_mul(2)));
let scale_to = opts.max_size.filter(|&(mw, mh)| mw > 0 && mh > 0 && (m.width >= 2 * mw || m.height >= 2 * mh));
// zune-jpeg handles the common cases fastest; jpeg-decoder covers DCT scaling, CMYK/YCCK,
// 12-bit and non-interleaved sequential scans (which zune-jpeg 0.5 mis-decodes with subsampling).
let non_interleaved = matches!(m.sof, 0xC0 | 0xC1) && m.first_scan_components < m.components;
@ -275,11 +260,8 @@ 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 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)) {
for (o, l) in mpf_images(&m) {
if let Some(s) = bytes.get(o..o.saturating_add(l)) {
candidates.push((s, ThumbnailSource::MpfPreview));
}
}
@ -287,10 +269,6 @@ 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
@ -312,21 +290,8 @@ 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 })
}
/// 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> {
/// (file offset, length) of MPF-listed images other than the primary.
fn mpf_images(m: &Markers) -> Vec<(usize, usize)> {
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![] };
@ -342,34 +307,14 @@ pub(crate) fn mpf_images(m: &Markers) -> Vec<MpfEntry> {
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 (attr, size, off) = (rd(0), rd(4) as usize, rd(8) as usize);
let (size, off) = (rd(4) as usize, rd(8) as usize);
if size > 0 && off > 0 {
out.push(MpfEntry { offset: base.saturating_add(off), len: size, kind: attr & 0x00FF_FFFF });
out.push((base + off, size));
}
}
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::*;
@ -432,17 +377,6 @@ mod tests {
}
}
#[test]
fn unbounded_fit_box_decodes_at_full_size() {
// a full-size load asks for the largest box (`usize::MAX` saturated to `u32::MAX`): no DCT scaling,
// and no overflow working out whether to scale
use crate::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg};
let pixels = vec![128u8; 32 * 24 * 3];
let jpeg = encode_jpeg(&EncodeImage::new(32, 24, 3, Samples::U8(&pixels)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap();
let d = crate::decode(&jpeg, DecodeOptions::fit(u32::MAX, u32::MAX)).unwrap();
assert_eq!((d.width, d.height), (32, 24));
}
#[test]
fn markers_on_garbage() {
assert!(parse_markers(b"").is_none());

View File

@ -1,7 +1,5 @@
//! JPEG XL decode via `jxl-oxide` (pure Rust). Enum-encoded images are rendered straight into linear
//! Rec.2020; ICC-tagged ones are rendered in their own space and interpreted with our ICC path.
//! XYB (lossy) images are rendered to linear sRGB primaries and converted to Rec.2020 here:
//! jxl-oxide 0.12 desaturates colours outside sRGB when it renders XYB to wider primaries.
//! jxl-oxide applies the codestream orientation itself, so `orientation` is reported as 1.
use crate::convert::{Buf, Meta, Model, Raw, check_size, finish};
@ -26,13 +24,11 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
let gray = header.metadata.grayscale();
let icc = img.original_icc().map(|v| v.to_vec());
let mut meta = Meta { orientation: Some(1), ..Default::default() };
// linear sRGB primaries, unclamped, then our own matrix to Rec.2020 (see the module docs)
let via_srgb = icc.is_none() && !gray && header.metadata.xyb_encoded;
if icc.is_none() {
img.request_color_encoding(EnumColourEncoding {
colour_space: if gray { ColourSpace::Grey } else { ColourSpace::Rgb },
white_point: WhitePoint::D65,
primaries: if via_srgb { Primaries::Srgb } else { Primaries::Bt2100 },
primaries: Primaries::Bt2100,
tf: TransferFunction::Linear,
rendering_intent: RenderingIntent::Relative,
});
@ -59,19 +55,11 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
let keep = n_color + has_alpha as usize;
let src = fb.buf();
let mut v = Vec::with_capacity(fw * fh * keep);
let to_2020 = lightcraft_color::SRGB.to_space(&lightcraft_color::REC2020).to_f32();
for px in src.chunks_exact(ch) {
let start = v.len();
v.extend_from_slice(&px[..keep.min(ch)]);
if keep > ch {
v.push(1.0);
}
if via_srgb && let Some(rgb) = v.get_mut(start..start + 3) {
let [r, g, b] = [rgb[0], rgb[1], rgb[2]];
for (o, m) in rgb.iter_mut().zip(&to_2020) {
*o = m[0] * r + m[1] * g + m[2] * b;
}
}
}
let raw = Raw {
width: fw,

View File

@ -1,188 +0,0 @@
//! JPEG XL encode via `jixel` (pure Rust, BSD-3-Clause OR Apache-2.0; native targets with the `jxl`
//! feature: it spawns threads of its own). Lossy files are VarDCT in XYB, tagged with an enum colour
//! encoding the decoder renders back to; lossless files are Modular and keep their samples as they
//! are, tagged with the ICC profile when one is given. EXIF and XMP go in `Exif` / `xml ` boxes.
use crate::encode::{EncodeImage, EncodeMeta, HDR_REFERENCE_WHITE_NITS, Samples, pq_encode};
use crate::{Error, Result};
use serde::{Deserialize, Serialize};
/// Encoder effort: time spent searching for a smaller file at the same quality. jixel's slowest
/// search (about 20 % smaller again, five times slower) is not offered: its files are valid, but
/// jxl-oxide, our decoder, renders blocks of them wrongly, so LightCraft could not read back its own
/// exports.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum JxlEffort {
/// Plain 8×8 transforms only: quickest, largest (about a third larger than `Normal`).
Fast,
#[default]
Normal,
}
impl JxlEffort {
pub fn parse(s: &str) -> Option<Self> {
Some(match s.to_ascii_lowercase().as_str() {
"fast" => Self::Fast,
"normal" => Self::Normal,
_ => return None,
})
}
}
/// The colour encoding a JPEG XL file declares. Integer samples are display-encoded with its curve.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum JxlColour {
#[default]
Srgb,
/// P3 primaries, D65, sRGB curve.
DisplayP3,
/// Rec. 2020 primaries with the BT.709 curve.
Rec2020,
/// HDR: Rec. 2020 primaries with the PQ curve (SMPTE ST 2084). Float samples are linear light
/// with SDR white at 1.0 (written with SDR white at [`HDR_REFERENCE_WHITE_NITS`]); 16-bit
/// samples are already PQ-encoded.
Rec2020Pq,
}
/// JPEG XL encoder settings.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct JxlOptions {
/// 1..=100 on a libjpeg-like scale (90 ≈ visually lossless). Ignored when `lossless`.
pub quality: u8,
/// Mathematically lossless (Modular) instead of lossy (VarDCT).
pub lossless: bool,
pub effort: JxlEffort,
/// Lossy: two passes, a coarse image first; lossless: the Squeeze transform (a low-resolution
/// preview that refines to the exact image).
pub progressive: bool,
pub colour: JxlColour,
}
impl Default for JxlOptions {
fn default() -> Self {
Self { quality: 90, lossless: false, effort: JxlEffort::Normal, progressive: false, colour: JxlColour::Srgb }
}
}
/// Encode JPEG XL from 8- or 16-bit samples (float samples only with [`JxlColour::Rec2020Pq`]).
/// Gray is widened to RGB; alpha is kept only when some pixel is not opaque. The ICC profile in
/// `meta` is embedded in lossless files (lossy ones carry the enum encoding of `o.colour`); `ppi`
/// has no place in the format. Returns [`Error::Encode`] on wasm32 or without the `jxl` feature.
pub fn encode_jxl(img: &EncodeImage, o: &JxlOptions, meta: &EncodeMeta) -> Result<Vec<u8>> {
img.validate()?;
#[cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
{
let pixels = Pixels::from(img, o.colour)?;
// jixel asserts internally; a panic there is an export error, not a crash
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&pixels, img.width as usize, img.height as usize, o, meta)))
.unwrap_or_else(|_| Err(Error::Encode("JPEG XL encoder panicked".into())))
}
#[cfg(not(all(feature = "jxl", not(target_arch = "wasm32"))))]
{
let _ = (o, meta);
Err(Error::Encode("JPEG XL encoding is not available in this build".into()))
}
}
/// Interleaved RGB or RGBA samples for jixel.
#[cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
enum Pixels {
U8 { data: Vec<u8>, alpha: bool },
U16 { data: Vec<u16>, alpha: bool },
}
#[cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
impl Pixels {
fn from(img: &EncodeImage, colour: JxlColour) -> Result<Self> {
let n = (img.width as usize).checked_mul(img.height as usize).ok_or_else(|| Error::Encode("image too large".into()))?;
let ch = img.channels as usize;
// keep alpha only when it says something
fn rgba<T: Copy>(s: &[T], ch: usize, n: usize, opaque: impl Fn(T) -> bool) -> (Vec<T>, bool) {
let px = s.chunks_exact(ch).take(n);
let alpha = (ch == 2 || ch == 4) && s.chunks_exact(ch).take(n).any(|c| c.last().is_some_and(|a| !opaque(*a)));
let out_ch = if alpha { 4 } else { 3 };
let mut v = Vec::with_capacity(n * out_ch);
for c in px {
match c {
[g] => v.extend_from_slice(&[*g, *g, *g]),
[g, a] => v.extend_from_slice(&[*g, *g, *g, *a][..out_ch]),
[r, g, b] => v.extend_from_slice(&[*r, *g, *b]),
[r, g, b, a, ..] => v.extend_from_slice(&[*r, *g, *b, *a][..out_ch]),
[] => {}
}
}
(v, alpha)
}
Ok(match img.samples {
Samples::U8(s) => {
let (data, alpha) = rgba(s, ch, n, |a| a == u8::MAX);
Pixels::U8 { data, alpha }
}
Samples::U16(s) => {
let (data, alpha) = rgba(s, ch, n, |a| a == u16::MAX);
Pixels::U16 { data, alpha }
}
Samples::F32(s) if colour == JxlColour::Rec2020Pq => {
let pq = |v: f32| (pq_encode(if v.is_finite() { v.max(0.0) } else { 0.0 } * HDR_REFERENCE_WHITE_NITS) * 65535.0).round() as u16;
let alpha = |a: f32| (if a.is_finite() { a.clamp(0.0, 1.0) } else { 1.0 } * 65535.0).round() as u16;
let s: Vec<u16> = s
.chunks_exact(ch)
.take(n)
.flat_map(|c| match c {
[g] => [pq(*g), pq(*g), pq(*g), u16::MAX],
[g, a] => [pq(*g), pq(*g), pq(*g), alpha(*a)],
[r, g, b] => [pq(*r), pq(*g), pq(*b), u16::MAX],
[r, g, b, a, ..] => [pq(*r), pq(*g), pq(*b), alpha(*a)],
[] => [0; 4],
})
.collect();
let (data, alpha) = rgba(&s, 4, n, |a| a == u16::MAX);
Pixels::U16 { data, alpha }
}
Samples::F32(_) => return Err(Error::Encode("JPEG XL float samples are written as HDR (Rec. 2020 PQ) only".into())),
})
}
}
#[cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
fn run(px: &Pixels, w: usize, h: usize, o: &JxlOptions, meta: &EncodeMeta) -> Result<Vec<u8>> {
use jixel::{ColorEncoding, EncodeConfig, Primaries, Speed, TransferFunction};
let colour = match o.colour {
JxlColour::Srgb => ColorEncoding::srgb(),
JxlColour::DisplayP3 => ColorEncoding::display_p3(),
JxlColour::Rec2020 => ColorEncoding { primaries: Primaries::Bt2020, transfer: TransferFunction::Bt709, ..ColorEncoding::srgb() },
JxlColour::Rec2020Pq => ColorEncoding::bt2020_pq(),
};
let mut c = EncodeConfig::default()
.with_quality(f32::from(o.quality.clamp(1, 100)))
.with_color_encoding(colour)
.with_lossless(o.lossless)
.with_progressive(o.progressive)
.with_speed(match o.effort {
JxlEffort::Fast => Speed::Fastest,
JxlEffort::Normal => Speed::Fast,
});
if o.colour == JxlColour::Rec2020Pq {
// PQ code values are absolute: 1.0 = 10 000 cd/m²
c = c.with_intensity_target(10_000.0);
}
if o.lossless
&& let Some(icc) = meta.icc
{
c = c.with_icc_profile(icc.to_vec());
}
if let Some(exif) = meta.exif {
c = c.with_exif(exif.to_vec());
}
if let Some(xmp) = meta.xmp {
c = c.with_xmp(xmp.as_bytes().to_vec());
}
let r = match px {
Pixels::U8 { data, alpha: false } => jixel::encode_image(data, w, h, &c),
Pixels::U8 { data, alpha: true } => jixel::encode_image_with_alpha(data, w, h, &c),
Pixels::U16 { data, alpha: false } => jixel::encode_image_16bit(data, w, h, &c),
Pixels::U16 { data, alpha: true } => jixel::encode_image_with_alpha_16bit(data, w, h, &c),
};
r.map_err(|e| Error::Encode(format!("JPEG XL: {e}")))
}

View File

@ -9,8 +9,7 @@
//! - [`read_header`] reads a file's stored dimensions and orientation from its headers, without
//! decoding pixels (import probes).
//! - [`decode_thumbnail`] is the fast path for grid thumbnails (EXIF thumbnail or DCT-scaled decode).
//! - [`encode`] writes JPEG, PNG, TIFF, lossless WebP and, on native targets, AVIF (feature `avif`) and
//! JPEG XL ([`encode_jxl`], feature `jxl`), embedding
//! - [`encode`] writes JPEG, PNG, TIFF, lossless WebP and (native, feature `avif`) AVIF, embedding
//! ICC/EXIF/XMP. [`icc::write_matrix_trc`] builds profiles for export.
//!
//! No decoder panics on malformed input (property-tested).
@ -18,20 +17,15 @@
#![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;
#[cfg(feature = "jxl")]
mod jxl;
mod jxl_encode;
mod other;
mod png_codec;
pub mod proof;
mod psd;
mod sniff;
pub mod space;
@ -39,10 +33,8 @@ mod tiff_codec;
mod webp;
pub use encode::{
ChromaSubsampling, EncodeImage, EncodeMeta, HDR_REFERENCE_WHITE_NITS, Samples, TiffCompression, encode_avif, encode_avif_pq, encode_jpeg,
encode_png, encode_png_pq, encode_psd, encode_tiff, encode_webp_lossless,
ChromaSubsampling, EncodeImage, EncodeMeta, Samples, TiffCompression, encode_avif, encode_jpeg, encode_png, encode_tiff, encode_webp_lossless,
};
pub use jxl_encode::{JxlColour, JxlEffort, JxlOptions, encode_jxl};
pub use sniff::{Format, sniff};
pub use space::{NamedSpace, SourceSpace, SpaceOrigin, Trc};
@ -178,9 +170,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, PSD and HEIF are read
/// from their headers (markers, chunks, IFD, resources, boxes). GIF, BMP and JPEG XL fall back to
/// a small decode.
/// what a decode reports, without decoding pixels: JPEG, PNG, TIFF, WebP and PSD are read from
/// their headers (markers, chunks, IFD, resources). GIF, BMP and JPEG XL fall back to a small
/// decode.
///
/// The header readers also refuse what a decode would refuse before reaching the pixels (an
/// unsupported layout, compression or frame type) and files whose image data is cut short
@ -203,7 +195,6 @@ 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))?;
@ -225,8 +216,6 @@ 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),
@ -237,9 +226,10 @@ 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 => unsupported(),
Format::Heif => heif::decode(bytes, &opts),
Format::Avif | Format::RawTiffLike | Format::RawOther => unsupported(),
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")),
}
}

View File

@ -127,10 +127,25 @@ pub(crate) fn header(b: &[u8]) -> Result<(u32, u32, u16)> {
}
fn container_hint(info: &png::Info) -> Option<SourceSpace> {
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 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 info.srgb.is_some() {
return Some(SourceSpace::named(NamedSpace::Srgb, SpaceOrigin::Container));

View File

@ -1,174 +0,0 @@
//! Soft proofing against an output-device ICC profile (a printer/paper profile, RGB or CMYK, matrix
//! or LUT based): a 3D table that takes a colour in the pipeline's working space (linear Rec.2020)
//! through the device and back, so the loupe shows what the print can hold.
//!
//! - Forward with the chosen intent (perceptual or relative colorimetric), back with relative
//! colorimetric: colours outside the device gamut come back mapped into it.
//! - **Simulate Paper & Ink**: absolute colorimetric rendering of the way back. The paper's white
//! (the profile's media white point) replaces the screen's white, and the ink's black is the
//! darkest the device tables give (no black-point compensation). The white is applied as a
//! per-channel scale in linear Rec.2020 (a von Kries adaptation in RGB, close enough for the
//! near-neutral whites of papers).
//! - Each entry carries a gamut flag: how far the relative-colorimetric round trip moved the colour.
//!
//! Sources: ICC.1:2022 (rendering intents, media white point, absolute colorimetric scaling);
//! the transforms are moxcms's. Own design otherwise.
use lightcraft_color::{D50, D65, REC2020, bradford};
use moxcms::{ColorProfile, DataColorSpace, Layout, RenderingIntent, TransformOptions};
use crate::display::MAX_PROFILE_BYTES;
/// Rendering intent of the forward (image → device) conversion.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum ProofIntent {
#[default]
Perceptual,
Relative,
}
impl ProofIntent {
pub fn parse(s: &str) -> Option<ProofIntent> {
match s.to_ascii_lowercase().as_str() {
"perceptual" => Some(ProofIntent::Perceptual),
"relative" | "relativecolorimetric" => Some(ProofIntent::Relative),
_ => None,
}
}
pub fn name(self) -> &'static str {
match self {
ProofIntent::Perceptual => "perceptual",
ProofIntent::Relative => "relative",
}
}
}
/// A proof table: `n³` entries over working-space RGB encoded with the sRGB curve (index
/// `(r * n + g) * n + b`), each the simulated linear Rec.2020 colour plus a gamut distance
/// (`[3]`, 0 = in gamut; colours whose round trip moved them more than ~0.02 are outside).
#[derive(Clone, Debug, PartialEq)]
pub struct ProofTable {
pub n: usize,
pub data: Vec<[f32; 4]>,
pub description: Option<String>,
}
/// Grid size of [`ProofTable`]s.
pub const PROOF_GRID: usize = 33;
fn guarded<T>(f: impl FnOnce() -> Option<T>) -> Option<T> {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).ok().flatten()
}
/// Build the proof table for an output profile. Errors say what's wrong with the file; nothing here
/// panics on a hostile profile.
pub fn proof_table(bytes: &[u8], intent: ProofIntent, paper_ink: bool) -> Result<ProofTable, String> {
if bytes.len() > MAX_PROFILE_BYTES {
return Err("the profile is too large".into());
}
let dev = guarded(|| ColorProfile::new_from_slice(bytes).ok()).ok_or("not a valid ICC profile")?;
let (layout, ch) = match dev.color_space {
DataColorSpace::Rgb => (Layout::Rgb, 3),
DataColorSpace::Cmyk => (Layout::Rgba, 4),
DataColorSpace::Gray => (Layout::Gray, 1),
_ => return Err("only RGB, CMYK and grey output profiles can be proofed".into()),
};
let lin = crate::icc::linear_rec2020_profile();
let opts = |i| TransformOptions { rendering_intent: i, ..TransformOptions::default() };
let fwd_i = match intent {
ProofIntent::Perceptual => RenderingIntent::Perceptual,
ProofIntent::Relative => RenderingIntent::RelativeColorimetric,
};
let rel = RenderingIntent::RelativeColorimetric;
let fwd = guarded(|| lin.create_transform_f32(Layout::Rgb, &dev, layout, opts(fwd_i)).ok()).ok_or("can't convert to this profile")?;
let fwd_rel = guarded(|| lin.create_transform_f32(Layout::Rgb, &dev, layout, opts(rel)).ok()).ok_or("can't convert to this profile")?;
let back = guarded(|| dev.create_transform_f32(layout, &lin, Layout::Rgb, opts(rel)).ok()).ok_or("can't convert from this profile")?;
let n = PROOF_GRID;
let grid: Vec<f32> = (0..n * n * n)
.flat_map(|i| [i / (n * n), (i / n) % n, i % n].map(|k| lightcraft_color::transfer::srgb_to_linear(k as f32 / (n - 1) as f32)))
.collect();
let round_trip = |there: &dyn moxcms::TransformExecutor<f32>| -> Option<Vec<f32>> {
let mut device = vec![0f32; n * n * n * ch];
there.transform(&grid, &mut device).ok()?;
let mut out = vec![0f32; n * n * n * 3];
back.transform(&device, &mut out).ok()?;
Some(out)
};
let sim = guarded(|| round_trip(fwd.as_ref())).ok_or("the profile's tables can't be applied")?;
let colorimetric = guarded(|| round_trip(fwd_rel.as_ref())).ok_or("the profile's tables can't be applied")?;
let paper = if paper_ink { paper_white(&dev) } else { [1.0; 3] };
let enc = |v: f32| lightcraft_color::transfer::linear_to_srgb(v.clamp(0.0, 1.0));
let data = (0..n * n * n)
.map(|i| {
let c = |v: &[f32], k: usize| v.get(i * 3 + k).copied().filter(|x| x.is_finite()).unwrap_or(0.0);
let dist = (0..3).map(|k| (enc(c(&colorimetric, k)) - enc(c(&grid, k))).abs()).fold(0.0f32, f32::max);
[c(&sim, 0) * paper[0], c(&sim, 1) * paper[1], c(&sim, 2) * paper[2], dist]
})
.collect();
let description = crate::icc::parse(bytes).and_then(|i| i.description);
Ok(ProofTable { n, data, description })
}
/// The paper's white in linear Rec.2020 (screen white = (1, 1, 1)), from the media white point.
fn paper_white(p: &ColorProfile) -> [f32; 3] {
let Some(w) = p.media_white_point else { return [1.0; 3] };
let to_xyz = bradford(D65, D50).mul(&REC2020.to_xyz());
let Some(from_xyz) = to_xyz.inverse() else { return [1.0; 3] };
// the PCS white is D50 = Rec.2020 (1, 1, 1) after adaptation; the paper is relative to it
let d50 = to_xyz.apply([1.0; 3]);
let s = [w.x / d50[0], w.y / d50[1], w.z / d50[2]];
let rgb = from_xyz.apply([d50[0] * s[0], d50[1] * s[1], d50[2] * s[2]]);
if rgb.iter().all(|v| v.is_finite() && *v > 0.05 && *v <= 1.5) { rgb.map(|v| (v as f32).min(1.0)) } else { [1.0; 3] }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::space::NamedSpace;
fn at(t: &ProofTable, rgb: [usize; 3]) -> [f32; 4] {
t.data[(rgb[0] * t.n + rgb[1]) * t.n + rgb[2]]
}
#[test]
fn srgb_proof_flags_wide_colours_and_keeps_grey() {
let t = proof_table(&crate::icc::write_named(NamedSpace::Srgb), ProofIntent::Relative, false).unwrap();
let top = t.n - 1;
// Rec.2020 pure green is far outside sRGB; mid grey and white are inside and unchanged
assert!(at(&t, [0, top, 0])[3] > 0.05, "{:?}", at(&t, [0, top, 0]));
let g = at(&t, [16, 16, 16]);
assert!(g[3] < 0.01, "{g:?}");
let lin = lightcraft_color::transfer::srgb_to_linear(16.0 / 32.0);
assert!((0..3).all(|k| (g[k] - lin).abs() < 0.01), "{g:?} vs {lin}");
let w = at(&t, [top; 3]);
assert!((0..3).all(|k| (w[k] - 1.0).abs() < 0.01), "{w:?}");
}
#[test]
fn paper_white_dims_and_tints_white() {
let mut p = ColorProfile::new_from_slice(&crate::icc::write_named(NamedSpace::AdobeRgb)).unwrap();
// a warm, slightly dull paper
p.media_white_point = Some(moxcms::Xyzd { x: 0.9642 * 0.92, y: 0.90, z: 0.8249 * 0.80 });
let bytes = p.encode().unwrap();
let top = PROOF_GRID - 1;
let plain = proof_table(&bytes, ProofIntent::Perceptual, false).unwrap();
let paper = proof_table(&bytes, ProofIntent::Perceptual, true).unwrap();
let (w0, w1) = (at(&plain, [top; 3]), at(&paper, [top; 3]));
assert!((0..3).all(|k| (w0[k] - 1.0).abs() < 0.01), "{w0:?}");
assert!(w1[1] < 0.95 && w1[2] < w1[0], "paper white is dimmer and warmer: {w1:?}");
}
#[test]
fn hostile_profiles_are_errors() {
assert!(proof_table(&[], ProofIntent::Perceptual, false).is_err());
assert!(proof_table(&[0u8; 400], ProofIntent::Relative, true).is_err());
let good = crate::icc::write_named(NamedSpace::DisplayP3);
for i in (0..good.len()).step_by(11) {
let mut b = good.clone();
b[i] ^= 0x5a;
let _ = proof_table(&b, ProofIntent::Perceptual, true);
}
}
}

View File

@ -1,11 +1,8 @@
//! Photoshop PSD/PSB merged-composite reader (clean-room, from the public file-format description):
//! 8/16/32-bit gray, RGB, CMYK, indexed, duotone (as gray); raw or PackBits/RLE image data;
//! ICC (resource 1039), EXIF (1058), XMP (1060); transparency when the layer count is negative.
//! Also a flat-file writer ([`encode`]): the merged image of an 8- or 16-bit gray/RGB document
//! with no layers, so an image editor opens it as one background layer and saves back to it.
use crate::convert::{Buf, Meta, Model, Raw, check_size, finish};
use crate::encode::{EncodeImage, EncodeMeta, Samples};
use crate::{DecodeOptions, Decoded, Error, Format, Result};
const F: Format = Format::Psd;
@ -170,9 +167,9 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
};
let idx = planes.first().ok_or_else(|| err("missing plane"))?;
let mut v = zeroed::<u8>(n.checked_mul(3).ok_or_else(too_large)?, too_large)?;
for (o, &i) in v.as_chunks_mut::<3>().0.iter_mut().zip(idx) {
for (o, &i) in v.chunks_exact_mut(3).zip(idx) {
let i = i as usize;
*o = [pal[i], pal[256 + i], pal[512 + i]];
o.copy_from_slice(&[pal[i], pal[256 + i], pal[512 + i]]);
}
Buf::U8(v)
} else {
@ -191,8 +188,8 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
2 => {
let mut v = zeroed::<u16>(samples, too_large)?;
for (c, p) in planes.iter().enumerate() {
for (o, s) in v.iter_mut().skip(c).step_by(used).zip(p.as_chunks::<2>().0) {
let x = u16::from_be_bytes(*s);
for (o, s) in v.iter_mut().skip(c).step_by(used).zip(p.chunks_exact(2)) {
let x = u16::from_be_bytes([s[0], s[1]]);
*o = if cmyk && c < 4 { 65535 - x } else { x };
}
}
@ -201,8 +198,8 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
_ => {
let mut v = zeroed::<f32>(samples, too_large)?;
for (c, p) in planes.iter().enumerate() {
for (o, s) in v.iter_mut().skip(c).step_by(used).zip(p.as_chunks::<4>().0) {
let x = f32::from_be_bytes(*s);
for (o, s) in v.iter_mut().skip(c).step_by(used).zip(p.chunks_exact(4)) {
let x = f32::from_be_bytes([s[0], s[1], s[2], s[3]]);
*o = if cmyk && c < 4 { 1.0 - x } else { x };
}
}
@ -214,129 +211,6 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
finish(F, raw, Meta { icc, exif, xmp, ..Default::default() }, (width as u32, height as u32), opts)
}
/// The largest side a PSD (version 1) may have; larger documents need PSB.
pub const MAX_SIDE: u32 = 30_000;
/// Write a flat PSD: header, the image resources for `meta` (resolution 1005, ICC 1039, EXIF
/// 1058, XMP 1060), an empty layer section and the merged image. 8-bit planes are PackBits
/// compressed, 16-bit ones stored raw (PackBits saves little on 16-bit photos). Gray or RGB;
/// alpha is dropped (the render is opaque). Float samples are refused.
pub(crate) fn encode(img: &EncodeImage, meta: &EncodeMeta) -> Result<Vec<u8>> {
img.validate()?;
let (w, h) = (img.width, img.height);
if w > MAX_SIDE || h > MAX_SIDE {
return Err(Error::Encode(format!("{w} × {h} is larger than a PSD can hold ({MAX_SIDE} px a side): use TIFF")));
}
let ch = usize::from(img.channels);
let color = if ch <= 2 { 1 } else { 3 };
let (depth, bpc) = match img.samples {
Samples::U8(_) => (8u16, 1usize),
Samples::U16(_) => (16, 2),
Samples::F32(_) => return Err(Error::Encode("32-bit float PSD is not supported: use TIFF".into())),
};
let (wu, hu) = (w as usize, h as usize);
let mut b = Vec::new();
b.extend_from_slice(b"8BPS");
b.extend_from_slice(&1u16.to_be_bytes());
b.extend_from_slice(&[0; 6]);
b.extend_from_slice(&(color as u16).to_be_bytes());
b.extend_from_slice(&h.to_be_bytes());
b.extend_from_slice(&w.to_be_bytes());
b.extend_from_slice(&depth.to_be_bytes());
b.extend_from_slice(&(if color == 1 { 1u16 } else { 3 }).to_be_bytes());
b.extend_from_slice(&0u32.to_be_bytes()); // colour mode data
let res = resource_section(meta);
b.extend_from_slice(&u32::try_from(res.len()).map_err(|_| Error::Encode("metadata too large for PSD".into()))?.to_be_bytes());
b.extend_from_slice(&res);
b.extend_from_slice(&0u32.to_be_bytes()); // layer and mask information: none
// planar samples, one plane per colour channel (big-endian)
let plane = |c: usize| -> Vec<u8> {
let mut p = Vec::with_capacity(wu * hu * bpc);
match img.samples {
Samples::U8(s) => p.extend(s.iter().skip(c).step_by(ch).take(wu * hu)),
Samples::U16(s) => s.iter().skip(c).step_by(ch).take(wu * hu).for_each(|v| p.extend_from_slice(&v.to_be_bytes())),
Samples::F32(_) => {}
}
p
};
if bpc == 1 {
b.extend_from_slice(&1u16.to_be_bytes());
let mut counts = Vec::with_capacity(color * hu * 2);
let mut data = Vec::new();
for c in 0..color {
for row in plane(c).chunks_exact(wu) {
let start = data.len();
packbits(row, &mut data);
let n = u16::try_from(data.len() - start).map_err(|_| Error::Encode("PSD row too long".into()))?;
counts.extend_from_slice(&n.to_be_bytes());
}
}
b.extend_from_slice(&counts);
b.extend_from_slice(&data);
} else {
b.extend_from_slice(&0u16.to_be_bytes());
for c in 0..color {
b.extend_from_slice(&plane(c));
}
}
Ok(b)
}
/// The image resources section for `meta`.
fn resource_section(meta: &EncodeMeta) -> Vec<u8> {
let mut out = Vec::new();
let mut add = |id: u16, data: &[u8]| {
out.extend_from_slice(b"8BIM");
out.extend_from_slice(&id.to_be_bytes());
out.extend_from_slice(&[0, 0]); // empty Pascal name, padded to even
out.extend_from_slice(&(data.len() as u32).to_be_bytes());
out.extend_from_slice(data);
if data.len() % 2 == 1 {
out.push(0);
}
};
if let Some(ppi) = meta.ppi.filter(|p| *p > 0) {
// ResolutionInfo: 16.16 fixed pixels per inch, unit 1 (ppi), display unit 1 (inches), twice
let fixed = (u32::from(ppi) << 16).to_be_bytes();
let one = 1u16.to_be_bytes();
add(1005, &[&fixed[..], &one, &one, &fixed, &one, &one].concat());
}
if let Some(icc) = meta.icc {
add(1039, icc);
}
if let Some(exif) = meta.exif {
add(1058, exif);
}
if let Some(xmp) = meta.xmp {
add(1060, xmp.as_bytes());
}
out
}
/// PackBits: append one encoded row of `src` to `out` (runs of 2+ equal bytes as repeats, the
/// rest as literals of at most 128 bytes).
fn packbits(src: &[u8], out: &mut Vec<u8>) {
let mut rest = src;
while let Some(&b) = rest.first() {
// a run of 2 to 128 equal bytes
let run = rest.iter().take(128).take_while(|&&x| x == b).count();
if run >= 2 {
// 1 - run in 2..=128: -1..=-127, as a signed byte
out.push((1 - run as i16) as i8 as u8);
out.push(b);
rest = rest.get(run..).unwrap_or_default();
continue;
}
// a literal of 1 to 128 bytes: up to the next pair of equal bytes, or the end of the row
let unpaired = rest.windows(2).take(128).take_while(|w| w.first() != w.get(1)).count();
let len = if unpaired.saturating_add(1) == rest.len() { rest.len() } else { unpaired };
let (lit, tail) = rest.split_at_checked(len.clamp(1, 128)).unwrap_or((rest, &[][..]));
out.push(lit.len().saturating_sub(1) as u8);
out.extend_from_slice(lit);
rest = tail;
}
}
/// Stored dimensions and EXIF orientation (resource 1058) from the header and image resources,
/// without reading the merged image data. Refused, as a decode would refuse it: an unsupported
/// depth, colour mode or compression, or merged image data that runs past the end of the file
@ -560,55 +434,4 @@ mod tests {
let q = d.image.get(0, 0);
assert!(p[0] > 0.0 && (p[0] - q[0]).abs() < 1e-6, "{p:?}");
}
#[test]
fn packbits_round_trips() {
let rows: [&[u8]; 6] = [&[], &[7], &[1, 1], &[1, 2, 3, 3, 3, 4, 5, 5], &[9; 300], &(0..=255u8).cycle().take(700).collect::<Vec<_>>()];
for row in rows {
let mut enc = Vec::new();
super::packbits(row, &mut enc);
let mut out = vec![0u8; row.len()];
unpackbits(&enc, &mut out).unwrap();
assert_eq!(out, row);
}
}
#[test]
fn flat_psd_round_trips_8_and_16_bit() {
let icc = crate::icc::write_named(crate::NamedSpace::AdobeRgb);
let meta = EncodeMeta { icc: Some(&icc), xmp: Some("<x:xmpmeta/>"), ppi: Some(300), ..Default::default() };
// 8-bit RGBA (alpha dropped), 5 × 3 with a flat run and a gradient
let px: Vec<u8> = (0..15u8).flat_map(|i| [i * 10, if i < 8 { 200 } else { i }, 255 - i, 255]).collect();
let b = encode(&EncodeImage::new(5, 3, 4, Samples::U8(&px)), &meta).unwrap();
assert_eq!(super::header(&b).unwrap(), (5, 3, 1));
let d = decode(&b, &DecodeOptions::default()).unwrap();
assert_eq!((d.width, d.height), (5, 3));
assert_eq!(d.icc.as_deref(), Some(&icc[..]), "the ICC profile is kept");
assert_eq!(d.xmp.as_deref(), Some("<x:xmpmeta/>"));
// 16-bit RGB, raw planes: the samples come back exactly
let px16: Vec<u16> = (0..4 * 2 * 3).map(|i| (i as u16) * 2731).collect();
let b = encode(&EncodeImage::new(4, 2, 3, Samples::U16(&px16)), &meta).unwrap();
assert_eq!(&b[22..24], &16u16.to_be_bytes());
let mut r = Rd { b: &b, p: 26 };
let cm = r.u32().unwrap() as usize;
r.take(cm).unwrap();
let res = r.u32().unwrap() as usize;
r.take(res).unwrap();
assert_eq!(r.u32().unwrap(), 0, "no layers");
assert_eq!(r.u16().unwrap(), 0, "16-bit planes are raw");
let red: Vec<u16> = (0..8).map(|_| r.u16().unwrap()).collect();
assert_eq!(red, px16.iter().step_by(3).copied().collect::<Vec<_>>());
assert!(decode(&b, &DecodeOptions::default()).is_ok());
// gray 16-bit
let g = encode(&EncodeImage::new(2, 2, 1, Samples::U16(&[0, 1000, 40000, 65535])), &EncodeMeta::default()).unwrap();
assert_eq!((&g[12..14], &g[24..26]), (&1u16.to_be_bytes()[..], &1u16.to_be_bytes()[..]), "one channel, grayscale mode");
assert!(decode(&g, &DecodeOptions::default()).is_ok());
}
#[test]
fn flat_psd_refuses_what_it_cannot_hold() {
let e = encode(&EncodeImage::new(1, 1, 3, Samples::F32(&[0.5; 3])), &EncodeMeta::default()).unwrap_err();
assert!(e.to_string().contains("float"), "{e}");
let e = encode(&EncodeImage::new(MAX_SIDE + 1, 1, 1, Samples::U8(&vec![0; MAX_SIDE as usize + 1])), &EncodeMeta::default()).unwrap_err();
assert!(e.to_string().contains("TIFF"), "{e}");
}
}

View File

@ -64,20 +64,10 @@ 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 {
#[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,
Format::Jxl => cfg!(feature = "jxl"),
Format::Avif | Format::Heif | Format::RawTiffLike | Format::RawOther => false,
_ => true,
}
}

View File

@ -255,27 +255,6 @@ 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 {

View File

@ -66,15 +66,6 @@ fn bench_24mp_jpeg() {
let rgba = d.to_srgb8();
time("encode PNG 8-bit", mp, 1, || encode_png(&EncodeImage::rgba8(&rgba), &EncodeMeta::default()).unwrap());
time("encode TIFF 8-bit deflate", mp, 1, || encode_tiff(&EncodeImage::rgba8(&rgba), TiffCompression::Deflate, &EncodeMeta::default()).unwrap());
#[cfg(feature = "jxl")]
for (label, o) in [
("encode JPEG XL q90 effort fast", JxlOptions { effort: JxlEffort::Fast, ..Default::default() }),
("encode JPEG XL q90 effort normal", JxlOptions::default()),
("encode JPEG XL lossless", JxlOptions { lossless: true, ..Default::default() }),
] {
let b = time(label, mp, 1, || encode_jxl(&img, &o, &EncodeMeta::default()).unwrap());
println!(" size {:.1} MB", b.len() as f64 / 1e6);
}
}
fn exif_with_thumbnail(jpeg: &[u8]) -> Vec<u8> {

View File

@ -36,17 +36,6 @@ 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]
@ -102,7 +91,6 @@ 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),
]
})
}
@ -118,7 +106,7 @@ proptest! {
}
#[test]
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>()) {
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>()) {
let mut b = seeds()[which].clone();
for (i, v) in flips {
let i = i.index(b.len());

View File

@ -1,98 +0,0 @@
//! 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:?}");
}

View File

@ -635,183 +635,3 @@ fn print_resolution_is_written() {
let j = encode_jpeg(&img, 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap();
assert_eq!(&j[13..18], &[0, 0, 1, 0, 1]);
}
#[test]
fn pq_png_is_16_bit_rec2020_pq_with_cicp_and_content_light() {
// linear Rec. 2020, SDR white at 1.0, up to 8x SDR white along x
let (w, h) = (64u32, 4u32);
let rgb: Vec<f32> = (0..w * h)
.flat_map(|i| {
let v = 0.01 * 1.11f32.powi((i % w) as i32);
[v, v * 0.5, v * 0.25]
})
.collect();
let xmp = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"/>"#;
let bytes = encode_png_pq(w, h, &rgb, &EncodeMeta { xmp: Some(xmp), ppi: Some(240), ..Default::default() }).unwrap();
let mut r = png::Decoder::new(std::io::Cursor::new(bytes.as_slice())).read_info().unwrap();
let cicp = r.info().coding_independent_code_points.expect("cICP");
assert_eq!((cicp.color_primaries, cicp.transfer_function), (9, 16), "BT.2020, PQ");
let mut buf = vec![0; r.output_buffer_size().unwrap()];
let f = r.next_frame(&mut buf).unwrap();
assert_eq!((f.bit_depth, f.color_type, f.width, f.height), (png::BitDepth::Sixteen, png::ColorType::Rgb, w, h));
// every sample decodes back to its luminance
for x in [0usize, 20, 63] {
for c in 0..3 {
let o = (x * 3 + c) * 2;
let e = u16::from_be_bytes([buf[o], buf[o + 1]]) as f32 / 65535.0;
let want = rgb[x * 3 + c] * HDR_REFERENCE_WHITE_NITS;
assert!((encode::pq_decode(e) - want).abs() <= want * 0.01 + 0.01, "x {x} c {c}: {} vs {want}", encode::pq_decode(e));
}
}
// cLLI: MaxCLL is the brightest channel, in 0.0001 cd/m²
let at = bytes.windows(4).position(|w| w == b"cLLI").expect("cLLI");
let max_cll = u32::from_be_bytes(bytes[at + 4..at + 8].try_into().unwrap()) as f32 / 10_000.0;
let brightest = rgb.iter().copied().fold(0.0f32, f32::max) * HDR_REFERENCE_WHITE_NITS;
assert!((max_cll - brightest.ceil()).abs() <= 1.0, "{max_cll} vs {brightest}");
assert!(bytes.windows(17).any(|w| w == b"XML:com.adobe.xmp"), "XMP kept");
assert!(bytes.windows(4).any(|w| w == b"pHYs"), "print resolution kept");
assert!(!bytes.windows(4).any(|w| w == b"iCCP"), "no ICC profile next to cICP");
}
#[test]
fn pq_png_rejects_short_buffers_and_survives_hostile_samples() {
assert!(encode_png_pq(4, 4, &[1.0; 3], &EncodeMeta::default()).is_err());
assert!(encode_png_pq(0, 4, &[], &EncodeMeta::default()).is_err());
assert!(encode_png_pq(u32::MAX, u32::MAX, &[], &EncodeMeta::default()).is_err());
let hostile = [f32::NAN, f32::INFINITY, -5.0, 1e30, 0.5, 2.0];
assert!(encode_png_pq(2, 1, &hostile, &EncodeMeta::default()).is_ok());
}
/// Largest per-channel difference between two linear images.
fn max_diff(a: &lightcraft_raster::Rgb32f, b: &lightcraft_raster::Rgb32f) -> f32 {
a.data.iter().zip(&b.data).flat_map(|(p, q)| (0..3).map(move |c| (p[c] - q[c]).abs())).fold(0.0, f32::max)
}
#[test]
#[cfg(feature = "jxl")]
fn jxl_lossless_exact_with_metadata() {
let img = gradient(97, 61);
let icc = write_named(NamedSpace::Srgb);
let exif = minimal_exif(1);
let xmp = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF/></x:xmpmeta>"#;
let meta = EncodeMeta { icc: Some(&icc), exif: Some(&exif), xmp: Some(xmp), ..Default::default() };
let o = JxlOptions { lossless: true, ..Default::default() };
let b = encode_jxl(&EncodeImage::rgba8(&img), &o, &meta).unwrap();
assert_eq!(sniff(&b), Some(Format::Jxl));
let d = decode(&b, DecodeOptions::default()).unwrap();
assert_eq!((d.width, d.height), (97, 61));
assert!(!d.has_alpha, "opaque alpha is dropped");
assert_eq!(d.icc.as_deref(), Some(&icc[..]));
assert_eq!(d.exif.as_deref(), Some(&exif[..]));
assert_eq!(d.xmp.as_deref(), Some(xmp));
assert_eq!(d.to_srgb8().data, img.data, "lossless round trip is exact");
// 16-bit lossless keeps every code value
let px: Vec<u16> = (0..40 * 30 * 3).map(|i| (i as u32 * 2741 % 65536) as u16).collect();
let b = encode_jxl(&EncodeImage::new(40, 30, 3, Samples::U16(&px)), &o, &meta).unwrap();
let d = decode(&b, DecodeOptions::default()).unwrap();
assert_eq!(d.bit_depth, 16);
let png = encode_png(&EncodeImage::new(40, 30, 3, Samples::U16(&px)), &meta).unwrap();
let want = decode(&png, DecodeOptions::default()).unwrap();
assert!(max_diff(&d.image, &want.image) < 1e-5, "16-bit lossless differs by {}", max_diff(&d.image, &want.image));
}
#[test]
#[cfg(feature = "jxl")]
fn jxl_lossy_quality_and_effort() {
let img = smooth(160, 120);
let opaque = Rgba8 { data: img.data.iter().map(|p| [p[0], p[1], p[2], 255]).collect(), ..img.clone() };
let enc = |o: JxlOptions| encode_jxl(&EncodeImage::rgba8(&opaque), &o, &EncodeMeta::default()).unwrap();
let hi = enc(JxlOptions { quality: 95, ..Default::default() });
let lo = enc(JxlOptions { quality: 40, ..Default::default() });
assert!(lo.len() < hi.len(), "q40 {} ≥ q95 {}", lo.len(), hi.len());
for (q, b) in [(95, &hi), (40, &lo)] {
let d = decode(b, DecodeOptions::default()).unwrap();
let p = psnr8(&opaque, &d.to_srgb8());
assert!(p > if q == 95 { 38.0 } else { 28.0 }, "q{q} psnr {p}");
}
for effort in [JxlEffort::Fast, JxlEffort::Normal] {
for progressive in [false, true] {
let b = enc(JxlOptions { effort, progressive, ..Default::default() });
let p = psnr8(&opaque, &decode(&b, DecodeOptions::default()).unwrap().to_srgb8());
assert!(p > 34.0, "{effort:?} progressive={progressive} psnr {p}");
}
}
}
#[test]
#[cfg(feature = "jxl")]
fn jxl_lossy_wide_gamut_matches_lossless_icc() {
// the same display-encoded samples, lossy with the enum encoding and lossless with the ICC
// profile, decode to the same colours in the working space
let img = smooth(96, 64);
let opaque = Rgba8 { data: img.data.iter().map(|p| [p[0], p[1], p[2], 255]).collect(), ..img.clone() };
for (colour, named) in [(JxlColour::DisplayP3, NamedSpace::DisplayP3), (JxlColour::Rec2020, NamedSpace::Rec2020)] {
let icc = write_named(named);
let meta = EncodeMeta { icc: Some(&icc), ..Default::default() };
let e = EncodeImage::rgba8(&opaque);
let lossy = encode_jxl(&e, &JxlOptions { quality: 98, colour, ..Default::default() }, &meta).unwrap();
let exact = encode_jxl(&e, &JxlOptions { lossless: true, colour, ..Default::default() }, &meta).unwrap();
let a = decode(&lossy, DecodeOptions::default()).unwrap();
assert!(a.icc.is_none(), "lossy files carry the enum encoding");
let b = decode(&exact, DecodeOptions::default()).unwrap();
let d = max_diff(&a.to_working(), &b.to_working());
assert!(d < 0.03, "{colour:?}: lossy differs from lossless by {d}");
}
}
#[test]
#[cfg(feature = "jxl")]
fn jxl_alpha_and_gray() {
let mut img = gradient(32, 24);
img.data[5][3] = 0;
let b = encode_jxl(&EncodeImage::rgba8(&img), &JxlOptions { lossless: true, ..Default::default() }, &EncodeMeta::default()).unwrap();
let d = decode(&b, DecodeOptions::default()).unwrap();
assert!(d.has_alpha);
assert_eq!(d.alpha.as_ref().map(|a| a.data[5]), Some(0.0));
let g: Vec<u8> = (0..32 * 24).map(|i| (i % 256) as u8).collect();
let b = encode_jxl(&EncodeImage::new(32, 24, 1, Samples::U8(&g)), &JxlOptions { lossless: true, ..Default::default() }, &EncodeMeta::default())
.unwrap();
let d = decode(&b, DecodeOptions::default()).unwrap();
assert_eq!(d.to_srgb8().data.iter().map(|p| p[0]).collect::<Vec<_>>(), g);
}
#[test]
#[cfg(feature = "jxl")]
fn jxl_hdr_pq() {
// linear Rec. 2020, SDR white = 1.0: the left half at SDR white, the right half four times brighter
let (w, h) = (64usize, 32usize);
let px: Vec<f32> = (0..w * h).flat_map(|i| [if i % w < w / 2 { 1.0 } else { 4.0 }; 3]).collect();
for lossless in [false, true] {
let o = JxlOptions { lossless, quality: 95, colour: JxlColour::Rec2020Pq, ..Default::default() };
let b = encode_jxl(&EncodeImage::new(w as u32, h as u32, 3, Samples::F32(&px)), &o, &EncodeMeta::default()).unwrap();
// the file's own samples: PQ code values for 203 and 812 cd/m²
let img = jxl_oxide::JxlImage::builder().read(std::io::Cursor::new(&b)).unwrap();
let m = &img.image_header().metadata;
assert_eq!(m.tone_mapping.intensity_target, 10_000.0);
assert!(format!("{:?}", m.colour_encoding).contains("Pq"), "{:?}", m.colour_encoding);
let r = img.render_frame(0).unwrap();
let fb = r.image_all_channels();
let at = |x: usize| fb.buf()[((h / 2) * w + x) * fb.channels() + 1];
for (x, nits) in [(w / 4, HDR_REFERENCE_WHITE_NITS), (w * 3 / 4, 4.0 * HDR_REFERENCE_WHITE_NITS)] {
let (got, want) = (at(x), lightcraft_codecs::encode::pq_encode(nits));
assert!((got - want).abs() < 0.005, "lossless={lossless}: {nits} cd/m² written as {got}, want {want}");
}
}
// float samples are HDR only
let sdr = encode_jxl(&EncodeImage::new(w as u32, h as u32, 3, Samples::F32(&px)), &JxlOptions::default(), &EncodeMeta::default());
assert!(matches!(sdr, Err(Error::Encode(_))));
}
#[test]
#[cfg(feature = "jxl")]
fn jxl_bad_input_is_an_error() {
let px = [0u8; 12];
assert!(encode_jxl(&EncodeImage::new(4, 4, 3, Samples::U8(&px)), &JxlOptions::default(), &EncodeMeta::default()).is_err());
assert!(encode_jxl(&EncodeImage::new(0, 4, 3, Samples::U8(&px)), &JxlOptions::default(), &EncodeMeta::default()).is_err());
// a 1×1 image and quality 1 still encode
let one = [10u8, 20, 30];
let b =
encode_jxl(&EncodeImage::new(1, 1, 3, Samples::U8(&one)), &JxlOptions { quality: 1, ..Default::default() }, &EncodeMeta::default()).unwrap();
assert_eq!(decode(&b, DecodeOptions::default()).unwrap().width, 1);
}

View File

@ -149,8 +149,7 @@ impl RgbSpace {
}
}
/// The Bradford cone-response matrix (XYZ → sharpened cones) that [`bradford`] adapts in.
pub const BRADFORD: Mat3 = Mat3([[0.8951, 0.2664, -0.1614], [-0.7502, 1.7135, 0.0367], [0.0389, -0.0685, 1.0296]]);
const BRADFORD: Mat3 = Mat3([[0.8951, 0.2664, -0.1614], [-0.7502, 1.7135, 0.0367], [0.0389, -0.0685, 1.0296]]);
/// Bradford chromatic adaptation matrix (XYZ → XYZ) from white `src` to white `dst`.
pub fn bradford(src: Xy, dst: Xy) -> Mat3 {

View File

@ -1,26 +0,0 @@
[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 }

View File

@ -1,7 +0,0 @@
# 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`.

View File

@ -1,279 +0,0 @@
//! 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);
}
}
}

View File

@ -1,191 +0,0 @@
//! 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));
}
}

View File

@ -1,46 +0,0 @@
//! 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"));
}
}

View File

@ -1,128 +0,0 @@
//! 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("")
}

View File

@ -1,16 +0,0 @@
//! 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};

View File

@ -1,34 +0,0 @@
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'-'))
}

View File

@ -1,217 +0,0 @@
//! 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());
}
}

View File

@ -1,616 +0,0 @@
//! 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);
}
}

View File

@ -1,589 +0,0 @@
//! 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, &params(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 }, &params(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 }, &params(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()),
&params(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 }, &params(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 }, &params(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 }, &params(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 }, &params(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 },
&params(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 }, &params(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, &params(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(_))));
}
}

View File

@ -1,100 +0,0 @@
//! 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);
}
}
}

View File

@ -1,32 +0,0 @@
[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 }

View File

@ -1,18 +0,0 @@
# 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`.

View File

@ -1,386 +0,0 @@
//! 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;

View File

@ -1,151 +0,0 @@
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();
}

View File

@ -1,39 +0,0 @@
//! 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.
//! - [`remote`]: running tiles on another computer's graphics card over TCP (a [`run::TileRunner`] and its server).
//!
//! 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;
pub mod remote;
#[cfg(feature = "runtime")]
pub mod runtime;
#[doc(hidden)]
pub mod synthetic;
pub use lightcraft_denoise_core::*;
pub mod synthetic_proto;

View File

@ -1,345 +0,0 @@
//! 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");
}
}

View File

@ -1,369 +0,0 @@
//! 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());
}
}

View File

@ -1,295 +0,0 @@
//! 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)
}

View File

@ -1,133 +0,0 @@
//! 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()))
}
}

View File

@ -1,567 +0,0 @@
//! Remote denoise: run a model's tiles on another computer's graphics card over TCP. Meant for a LAN or a VPN such as
//! Tailscale or WireGuard: the connection itself is not encrypted.
//!
//! [`Remote`] is a [`TileRunner`] that sends each tile to a server; [`serve`] runs one (`lightcraft-cli
//! denoise-serve`). Both ends must run the same model, checked by its identity ([`ModelId`]) when they greet.
//!
//! Protocol (version 1). Each message is one JSON line (`\n`-terminated, ≤ 4 KiB), optionally followed by a payload of
//! `bytes` bytes: the tile's samples as little-endian half floats, byte-shuffled (every sample's low byte, then every
//! high byte) and deflated, which roughly halves what crosses a slow link.
//!
//! ```text
//! → {"v":1,"token":"…","op":"hello"}
//! ← {"ok":true,"v":1,"device":"GPU: …","model":{"id":"…","version":"…","sha256":"…"}}
//! → {"v":1,"token":"…","op":"run","model":"<sha256>","tile":T,"bytes":N} + N bytes (4 planes of T × T)
//! ← {"ok":true,"tile":T,"bytes":N} + N bytes (3 planes of 2T × 2T)
//! ← {"ok":false,"error":"…"} (any failure)
//! ```
//!
//! A connection serves any number of requests. The server refuses a wrong token, another model than its own, tiles of
//! another size, oversized headers and payloads (also ones that inflate past their tile), and caps concurrent
//! connections; the client gives up on a server that keeps failing, so the computer's own runner does the rest.
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream, ToSocketAddrs};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::Duration;
use serde::Serialize;
use serde_json::{Value, json};
use crate::product::{from_f16, to_f16};
use crate::run::{Error, TileRunner};
pub const PROTOCOL: u64 = 1;
pub const DEFAULT_PORT: u16 = 7990;
const MAX_LINE: usize = 4096;
/// Largest tile side in cells (a 1024-cell tile is a 2048 × 2048 pixel answer).
pub const MAX_TILE: usize = 1024;
/// Shortest token a server accepts.
pub const MIN_TOKEN: usize = 16;
/// Which model a server runs: both ends must agree before tiles cross.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct ModelId {
pub id: String,
pub version: String,
/// SHA-256 of the model file (hex).
pub sha256: String,
}
fn bad(m: impl Into<String>) -> std::io::Error {
std::io::Error::new(std::io::ErrorKind::InvalidData, m.into())
}
fn read_line(r: &mut impl BufRead) -> std::io::Result<Option<String>> {
let mut buf = Vec::new();
let n = r.by_ref().take(MAX_LINE as u64 + 1).read_until(b'\n', &mut buf)?;
if n == 0 {
return Ok(None);
}
if buf.last() != Some(&b'\n') {
return Err(bad("message header too long or truncated"));
}
String::from_utf8(buf).map(Some).map_err(|_| bad("header is not UTF-8"))
}
/// Samples → half floats → byte-shuffled → deflated.
pub fn pack(data: &[f32]) -> Vec<u8> {
let n = data.len();
let mut b = vec![0u8; n * 2];
let (lo, hi) = b.split_at_mut(n);
for ((v, l), h) in data.iter().zip(lo.iter_mut()).zip(hi.iter_mut()) {
[*l, *h] = to_f16(*v).to_le_bytes();
}
miniz_oxide::deflate::compress_to_vec(&b, 1)
}
/// The inverse of [`pack`] for exactly `n` samples; refuses anything that inflates to more.
pub fn unpack(bytes: &[u8], n: usize) -> std::io::Result<Vec<f32>> {
let b = miniz_oxide::inflate::decompress_to_vec_with_limit(bytes, n * 2).map_err(|_| bad("payload doesn't inflate to the tile size"))?;
if b.len() != n * 2 {
return Err(bad("payload size doesn't match the tile"));
}
let (lo, hi) = b.split_at(n);
Ok(lo.iter().zip(hi).map(|(l, h)| from_f16(u16::from_le_bytes([*l, *h]))).collect())
}
fn write_payload(w: &mut impl Write, mut header: Value, data: &[f32]) -> std::io::Result<()> {
let packed = pack(data);
header["bytes"] = json!(packed.len());
writeln!(w, "{header}")?;
w.write_all(&packed)
}
/// Read a `bytes`-byte payload holding `n` samples (`bytes` is capped by the tile, so a hostile header can't make us
/// allocate more than an uncompressed tile plus slack).
fn read_payload(r: &mut impl Read, bytes: usize, n: usize) -> std::io::Result<Vec<f32>> {
if bytes > n * 2 + 4096 {
return Err(bad("payload larger than its tile"));
}
let mut b = vec![0u8; bytes];
r.read_exact(&mut b)?;
unpack(&b, n)
}
fn field(v: &Value, k: &str) -> Option<usize> {
v.get(k).and_then(Value::as_u64).and_then(|x| usize::try_from(x).ok())
}
/// Constant-time comparison (the token must not leak through timing).
fn same(a: &str, b: &str) -> bool {
let (a, b) = (a.as_bytes(), b.as_bytes());
let mut d = a.len() ^ b.len();
for i in 0..a.len().max(b.len()) {
d |= (a.get(i).copied().unwrap_or(0) ^ b.get(i).copied().unwrap_or(0)) as usize;
}
d == 0
}
/// What a server runs a tile with: four planes of `tile × tile` cells in, three planes of `2·tile × 2·tile` out.
pub type RunTile = Arc<dyn Fn(&[f32]) -> Result<Vec<f32>, Error> + Send + Sync>;
/// A server's settings.
#[derive(Clone, Debug)]
pub struct ServerOptions {
/// Shared secret every request must carry (at least [`MIN_TOKEN`] characters).
pub token: String,
pub max_connections: usize,
/// Idle and transfer limit per connection.
pub timeout: Duration,
}
impl Default for ServerOptions {
fn default() -> Self {
ServerOptions { token: String::new(), max_connections: 8, timeout: Duration::from_secs(120) }
}
}
/// Serve `model` (tiles of `tile` cells, run by `run` on `device`) until the listener fails. `log` gets one line per event.
pub fn serve(
listener: TcpListener,
model: ModelId,
tile: usize,
device: String,
run: RunTile,
opts: ServerOptions,
log: Arc<dyn Fn(&str) + Send + Sync>,
) -> Result<(), String> {
if opts.token.trim().chars().count() < MIN_TOKEN {
return Err(format!("the token must be at least {MIN_TOKEN} characters"));
}
if tile == 0 || tile > MAX_TILE {
return Err(format!("tile size {tile} is outside 1..={MAX_TILE}"));
}
let active = Arc::new(AtomicUsize::new(0));
let shared = Arc::new((model, device));
for conn in listener.incoming() {
let Ok(stream) = conn else { continue };
let peer = stream.peer_addr().map(|a| a.to_string()).unwrap_or_else(|_| "?".into());
if active.load(Ordering::SeqCst) >= opts.max_connections.max(1) {
let mut s = stream;
let _ = writeln!(s, "{}", json!({"ok": false, "error": "server busy"}));
log(&format!("{peer}: refused (busy)"));
continue;
}
active.fetch_add(1, Ordering::SeqCst);
let (sh, r, o, l, n) = (shared.clone(), run.clone(), opts.clone(), log.clone(), active.clone());
let spawned = std::thread::Builder::new().name(format!("denoise {peer}")).spawn(move || {
if let Err(e) = handle(stream, &sh.0, tile, &sh.1, &*r, &o, &*l) {
l(&format!("{peer}: {e}"));
}
n.fetch_sub(1, Ordering::SeqCst);
});
if spawned.is_err() {
active.fetch_sub(1, Ordering::SeqCst);
log("could not start a connection thread");
}
}
Ok(())
}
fn handle(
stream: TcpStream,
model: &ModelId,
tile: usize,
device: &str,
run: &dyn Fn(&[f32]) -> Result<Vec<f32>, Error>,
opts: &ServerOptions,
log: &dyn Fn(&str),
) -> std::io::Result<()> {
stream.set_read_timeout(Some(opts.timeout))?;
stream.set_write_timeout(Some(opts.timeout))?;
stream.set_nodelay(true)?;
let peer = stream.peer_addr().map(|a| a.to_string()).unwrap_or_default();
let mut r = BufReader::new(stream.try_clone()?);
let mut w = stream;
let mut tiles = 0usize;
let fail = |w: &mut TcpStream, msg: &str| writeln!(w, "{}", json!({"ok": false, "error": msg}));
while let Some(line) = read_line(&mut r)? {
let Ok(req) = serde_json::from_str::<Value>(&line) else {
fail(&mut w, "malformed request")?;
break;
};
if req.get("v").and_then(Value::as_u64) != Some(PROTOCOL) {
fail(&mut w, &format!("protocol version {PROTOCOL} only"))?;
break;
}
if !same(req.get("token").and_then(Value::as_str).unwrap_or(""), &opts.token) {
fail(&mut w, "bad token")?;
log(&format!("{peer}: bad token"));
break;
}
match req.get("op").and_then(Value::as_str) {
Some("hello") => writeln!(w, "{}", json!({"ok": true, "v": PROTOCOL, "device": device, "model": model, "tile": tile}))?,
Some("run") => {
let (Some(t), Some(bytes)) = (field(&req, "tile"), field(&req, "bytes")) else {
fail(&mut w, "missing tile size or payload size")?;
break;
};
if t != tile {
fail(&mut w, &format!("this server runs {tile}-cell tiles, not {t}"))?;
break;
}
// the payload is read before any refusal below, so the stream stays in step
let input = match read_payload(&mut r, bytes, 4 * t * t) {
Ok(d) => d,
Err(e) => {
fail(&mut w, &e.to_string())?;
break;
}
};
if req.get("model").and_then(Value::as_str) != Some(model.sha256.as_str()) {
fail(&mut w, &format!("model mismatch: this server runs {} {}", model.id, model.version))?;
continue;
}
match run(&input) {
Ok(out) if out.len() == 3 * 4 * t * t && out.iter().all(|v| v.is_finite()) => {
write_payload(&mut w, json!({"ok": true, "tile": t}), &out)?;
tiles += 1;
}
Ok(_) => fail(&mut w, "the model's answer has the wrong size or numbers that are not finite")?,
Err(e) => fail(&mut w, &e.to_string())?,
}
}
_ => {
fail(&mut w, "unknown op")?;
break;
}
}
w.flush()?;
}
if tiles > 0 {
log(&format!("{peer}: {tiles} tiles"));
}
Ok(())
}
/// What a server said in its greeting.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct ServerInfo {
pub address: String,
pub device: String,
pub model: ModelId,
pub tile: usize,
}
/// Tiles the server may get wrong (an error, a broken connection) before it stops being used.
pub const MAX_FAILURES: usize = 3;
/// A server as a [`TileRunner`], for one model: keeps up to `slots` connections open and gives up on the server after
/// [`MAX_FAILURES`] failed tiles (the caller runs those tiles itself).
pub struct Remote {
address: String,
token: String,
model_sha256: String,
tile: usize,
slots: usize,
timeout: Duration,
busy: AtomicUsize,
failures: AtomicUsize,
last_error: Mutex<Option<String>>,
pool: Mutex<Vec<(BufReader<TcpStream>, TcpStream)>>,
}
impl Remote {
/// `address` as `host[:port]` (default port [`DEFAULT_PORT`]; `tcp://` and a trailing `/` are ignored).
pub fn new(address: &str, token: &str, model_sha256: &str, tile: usize) -> Remote {
let a = address.trim().trim_start_matches("tcp://").trim_end_matches('/');
let has_port = a.rsplit_once(':').is_some_and(|(h, p)| !h.is_empty() && !h.ends_with(':') && p.parse::<u16>().is_ok()) && !a.ends_with(']');
let address = if has_port { a.to_string() } else { format!("{a}:{DEFAULT_PORT}") };
Remote {
address,
token: token.into(),
model_sha256: model_sha256.to_ascii_lowercase(),
tile,
slots: 4,
timeout: Duration::from_secs(60),
busy: AtomicUsize::new(0),
failures: AtomicUsize::new(0),
last_error: Mutex::new(None),
pool: Mutex::new(Vec::new()),
}
}
pub fn with_slots(mut self, n: usize) -> Remote {
self.slots = n.clamp(1, 16);
self
}
pub fn address(&self) -> &str {
&self.address
}
/// Tiles it can take at once.
pub fn slots(&self) -> usize {
self.slots
}
/// Still used (fewer than [`MAX_FAILURES`] failed tiles).
pub fn healthy(&self) -> bool {
self.failures.load(Ordering::Relaxed) < MAX_FAILURES
}
pub fn failures(&self) -> usize {
self.failures.load(Ordering::Relaxed)
}
/// Why the last tile failed.
pub fn last_error(&self) -> Option<String> {
self.last_error.lock().unwrap_or_else(PoisonError::into_inner).clone()
}
fn err(&self, reason: impl std::fmt::Display) -> Error {
Error::Runtime(format!("remote {}: {reason}", self.address))
}
fn connect(&self) -> Result<(BufReader<TcpStream>, TcpStream), Error> {
let addr = self.address.to_socket_addrs().map_err(|e| self.err(e))?.next().ok_or_else(|| self.err("the address doesn't resolve"))?;
let s = TcpStream::connect_timeout(&addr, Duration::from_secs(5)).map_err(|e| self.err(e))?;
s.set_read_timeout(Some(self.timeout)).map_err(|e| self.err(e))?;
s.set_write_timeout(Some(self.timeout)).map_err(|e| self.err(e))?;
s.set_nodelay(true).map_err(|e| self.err(e))?;
let r = BufReader::new(s.try_clone().map_err(|e| self.err(e))?);
Ok((r, s))
}
fn reply(&self, r: &mut BufReader<TcpStream>) -> Result<Value, Error> {
let line = read_line(r).map_err(|e| self.err(e))?.ok_or_else(|| self.err("the connection closed"))?;
let v: Value = serde_json::from_str(&line).map_err(|_| self.err("malformed reply"))?;
if v.get("ok").and_then(Value::as_bool) != Some(true) {
return Err(self.err(v.get("error").and_then(Value::as_str).unwrap_or("request failed")));
}
Ok(v)
}
/// Greet the server: its device and model. Fails when it runs another model or tile size than this one.
pub fn hello(&self) -> Result<ServerInfo, Error> {
let (mut r, mut s) = self.connect()?;
writeln!(s, "{}", json!({"v": PROTOCOL, "token": self.token, "op": "hello"})).map_err(|e| self.err(e))?;
let v = self.reply(&mut r)?;
let m = v.get("model").cloned().unwrap_or_default();
let text = |o: &Value, k: &str| o.get(k).and_then(Value::as_str).unwrap_or("").to_string();
let info = ServerInfo {
address: self.address.clone(),
device: text(&v, "device"),
model: ModelId { id: text(&m, "id"), version: text(&m, "version"), sha256: text(&m, "sha256") },
tile: field(&v, "tile").unwrap_or(0),
};
if !info.model.sha256.eq_ignore_ascii_case(&self.model_sha256) {
return Err(self.err(format!("it runs another model ({} {})", info.model.id, info.model.version)));
}
if info.tile != self.tile {
return Err(self.err(format!("it runs {}-cell tiles, this model {}", info.tile, self.tile)));
}
self.pool.lock().unwrap_or_else(PoisonError::into_inner).push((r, s));
Ok(info)
}
/// Run a tile if a slot is free right now; `None` when all are busy or the server was given up (the caller runs
/// the tile elsewhere). A failure counts towards giving up on the server.
pub fn try_run(&self, input: &[f32]) -> Option<Result<Vec<f32>, Error>> {
if !self.healthy() {
return None;
}
if self.busy.fetch_add(1, Ordering::SeqCst) >= self.slots {
self.busy.fetch_sub(1, Ordering::SeqCst);
return None;
}
let r = self.run_now(input);
self.busy.fetch_sub(1, Ordering::SeqCst);
if let Err(e) = &r {
self.failures.fetch_add(1, Ordering::Relaxed);
*self.last_error.lock().unwrap_or_else(PoisonError::into_inner) = Some(e.to_string());
}
Some(r)
}
fn run_now(&self, input: &[f32]) -> Result<Vec<f32>, Error> {
let t = self.tile;
if input.len() != 4 * t * t {
return Err(Error::Input(format!("a tile of {} samples for {t}-cell tiles", input.len())));
}
let pooled = self.pool.lock().unwrap_or_else(PoisonError::into_inner).pop();
let (mut r, mut s) = match pooled {
Some(c) => c,
None => self.connect()?,
};
let req = json!({"v": PROTOCOL, "token": self.token, "op": "run", "model": self.model_sha256, "tile": t});
write_payload(&mut s, req, input).map_err(|e| self.err(e))?;
s.flush().map_err(|e| self.err(e))?;
let v = self.reply(&mut r)?;
if field(&v, "tile") != Some(t) {
return Err(self.err("the answer is for another tile size"));
}
let bytes = field(&v, "bytes").ok_or_else(|| self.err("an answer without its size"))?;
let out = read_payload(&mut r, bytes, 3 * 4 * t * t).map_err(|e| self.err(e))?;
self.pool.lock().unwrap_or_else(PoisonError::into_inner).push((r, s));
// the other computer's numbers go straight into the render: NaN or infinity (an f16 can carry
// both) is a failed tile, which `Shared` runs here instead
if !out.iter().all(|v| v.is_finite()) {
return Err(self.err("the answer holds values that aren't finite"));
}
Ok(out)
}
}
/// Tiles on the server when one of its slots is free, else (and for any tile it fails) on `local`: the server adds
/// speed to this computer's runner instead of replacing it.
pub struct Shared<'a> {
pub remote: &'a Remote,
pub local: &'a dyn TileRunner,
}
impl TileRunner for Shared<'_> {
fn run(&self, input: &[f32]) -> Result<Vec<f32>, Error> {
match self.remote.try_run(input) {
Some(Ok(out)) => Ok(out),
Some(Err(_)) | None => self.local.run(input),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const TOKEN: &str = "0123456789abcdef-test";
const T: usize = 8;
/// A stand-in model: each output pixel is its cell's first plane, times `k`.
fn model(k: f32) -> RunTile {
Arc::new(move |input: &[f32]| {
let t = T;
let mut out = vec![0f32; 3 * 4 * t * t];
for c in 0..3 {
for y in 0..2 * t {
for x in 0..2 * t {
out[(c * 2 * t + y) * 2 * t + x] = input[(y / 2) * t + x / 2] * k;
}
}
}
Ok(out)
})
}
fn id(sha: &str) -> ModelId {
ModelId { id: "test".into(), version: "1".into(), sha256: sha.into() }
}
fn start(sha: &str, run: RunTile) -> String {
let l = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = l.local_addr().unwrap().to_string();
let opts = ServerOptions { token: TOKEN.into(), ..Default::default() };
let m = id(sha);
std::thread::spawn(move || serve(l, m, T, "CPU (test)".into(), run, opts, Arc::new(|_| {})));
addr
}
struct Local(RunTile, AtomicUsize);
impl TileRunner for Local {
fn run(&self, input: &[f32]) -> Result<Vec<f32>, Error> {
self.1.fetch_add(1, Ordering::SeqCst);
(self.0)(input)
}
}
fn tile_input(seed: f32) -> Vec<f32> {
(0..4 * T * T).map(|i| (i as f32 * 0.37 + seed).sin() * 0.5 + 0.5).collect()
}
#[test]
fn remote_tiles_match_local_ones_within_half_float_rounding() {
let addr = start("aa11", model(2.0));
let remote = Remote::new(&addr, TOKEN, "AA11", T);
let info = remote.hello().unwrap();
assert_eq!((info.device.as_str(), info.tile), ("CPU (test)", T));
let input = tile_input(0.3);
let got = remote.try_run(&input).unwrap().unwrap();
let want = model(2.0)(&input).unwrap();
assert!(got.iter().zip(&want).all(|(a, b)| (a - b).abs() < 2e-3));
}
#[test]
fn shared_runner_falls_back_and_gives_up_on_a_failing_server() {
let local = Local(model(2.0), AtomicUsize::new(0));
// nobody listening: every tile runs locally, and after a few the server is given up
let dead = Remote::new("127.0.0.1:1", TOKEN, "aa11", T);
let shared = Shared { remote: &dead, local: &local };
for i in 0..6 {
assert!(shared.run(&tile_input(i as f32)).is_ok());
}
assert_eq!(local.1.load(Ordering::SeqCst), 6);
assert!(!dead.healthy() && dead.last_error().is_some());
// a server with another model is refused at the greeting and per tile
let addr = start("bb22", model(2.0));
let other = Remote::new(&addr, TOKEN, "aa11", T);
assert!(other.hello().unwrap_err().to_string().contains("another model"));
assert!(other.try_run(&tile_input(0.0)).unwrap().unwrap_err().to_string().contains("model mismatch"));
// a wrong token
let addr = start("aa11", model(2.0));
assert!(Remote::new(&addr, "wrong-token-0000000", "aa11", T).hello().unwrap_err().to_string().contains("bad token"));
// all slots busy: the tile runs locally without waiting
let busy = Remote::new(&addr, TOKEN, "aa11", T).with_slots(1);
busy.busy.store(1, Ordering::SeqCst);
assert!(busy.try_run(&tile_input(0.0)).is_none());
}
#[test]
fn hostile_requests_are_refused_and_the_server_keeps_working() {
let addr = start("aa11", model(1.0));
let talk = |bytes: &[u8]| -> String {
let mut s = TcpStream::connect(&addr).unwrap();
s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
s.write_all(bytes).unwrap();
let mut line = String::new();
let _ = BufReader::new(s).read_line(&mut line);
line
};
assert!(talk(b"not json\n").contains("malformed"));
let v2 = format!("{}\n", json!({"v": 2, "token": TOKEN, "op": "hello"}));
assert!(talk(v2.as_bytes()).contains("protocol"));
let big = format!("{}\n", json!({"v": 1, "token": TOKEN, "op": "run", "model": "aa11", "tile": 4096, "bytes": 10}));
assert!(talk(big.as_bytes()).contains("8-cell"));
// a payload that inflates past its tile
let bomb = miniz_oxide::deflate::compress_to_vec(&vec![0u8; 1 << 20], 9);
let hdr = format!("{}\n", json!({"v": 1, "token": TOKEN, "op": "run", "model": "aa11", "tile": T, "bytes": bomb.len()}));
assert!(talk(&[hdr.as_bytes(), &bomb].concat()).contains("false"));
assert!(Remote::new(&addr, TOKEN, "aa11", T).hello().is_ok());
assert!(same("abc", "abc") && !same("abc", "abd") && !same("abc", "abcd"));
// a short token is refused when the server starts
let l = TcpListener::bind("127.0.0.1:0").unwrap();
let opts = ServerOptions { token: "short".into(), ..Default::default() };
assert!(serve(l, id("aa11"), T, "x".into(), model(1.0), opts, Arc::new(|_| {})).is_err());
}
#[test]
fn packing_round_trips_and_addresses_are_normalised() {
let v: Vec<f32> = (0..4096).map(|i| 0.5 + ((i * 7919 % 101) as f32 - 50.0) * 0.002).collect();
let back = unpack(&pack(&v), v.len()).unwrap();
assert!(v.iter().zip(&back).all(|(a, b)| (a - b).abs() < 1e-3));
assert!(unpack(&pack(&v), v.len() + 1).is_err());
assert_eq!(Remote::new("tcp://100.1.2.3/", TOKEN, "a", T).address(), "100.1.2.3:7990");
assert_eq!(Remote::new("box:8000", TOKEN, "a", T).address(), "box:8000");
assert_eq!(Remote::new("[::1]", TOKEN, "a", T).address(), "[::1]:7990");
}
}

View File

@ -1,433 +0,0 @@
//! 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();
}
}

View File

@ -1,234 +0,0 @@
//! 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
}

View File

@ -1,44 +0,0 @@
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
}

View File

@ -1,250 +0,0 @@
//! 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, &params, &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");
}
}
}

Some files were not shown because too many files have changed in this diff Show More