diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml
index 0b528ae1..c63fdeef 100644
--- a/.github/workflows/freebsd.yml
+++ b/.github/workflows/freebsd.yml
@@ -26,15 +26,15 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 300
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Optional font build input, so the Japanese font tests run (they skip without it).
- - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 8dcdacd5153e64560d109541a47d806f26f048c0
path: craft-fonts
- name: Build and test in a FreeBSD VM
- uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
+ uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
with:
release: "14.3"
usesh: true
diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml
index 0e2388a6..46ce3c90 100644
--- a/.github/workflows/packaging-lint.yml
+++ b/.github/workflows/packaging-lint.yml
@@ -23,7 +23,7 @@ jobs:
env:
ACTIONLINT_VERSION: 1.7.12
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install tools
run: |
sudo apt-get update
@@ -51,6 +51,11 @@ jobs:
if grep -oE '' 'Value="InstallDirDlg"' '' \
+ 'Key="Software\LightCraft" Name="InstallFolder"' 'Value="LaunchLightcraft"'; do
+ grep -qF "$want" packaging/windows/lightcraft.wxs || { echo "::error::lightcraft.wxs lost: $want"; exit 1; }
+ done
desktop-file-validate packaging/linux/ai.storyteller.lightcraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.lightcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.lightcraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.lightcraft.metainfo.xml"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index b9c3a1ea..c81bf050 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -44,7 +44,7 @@ jobs:
date: ${{ steps.v.outputs.date }}
prerelease: ${{ steps.v.outputs.prerelease }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- id: v
name: Resolve version
run: |
@@ -72,17 +72,17 @@ jobs:
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
- - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
path: craft-fonts
- - uses: dtolnay/rust-toolchain@stable
+ - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: aarch64-apple-darwin, x86_64-apple-darwin
- - uses: Swatinem/rust-cache@v2
+ - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-macos-universal
- name: Apply version override
@@ -102,7 +102,7 @@ jobs:
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: packaging/macos/package.sh --arch universal
- - uses: actions/upload-artifact@v4
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-macos
path: dist/release/*
@@ -130,23 +130,24 @@ jobs:
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
- - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
path: craft-fonts
- - uses: dtolnay/rust-toolchain@stable
+ - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: ${{ matrix.target }}
- - uses: Swatinem/rust-cache@v2
+ - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-windows-${{ matrix.arch }}
- name: Apply version override
if: inputs.version != ''
shell: bash
run: cargo xtask version set "$LIGHTCRAFT_VERSION"
+ # package.ps1 adds the matching WixToolset.UI/Util.wixext (5.0.2) to the global extension cache.
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2
@@ -164,7 +165,7 @@ jobs:
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
- - uses: actions/upload-artifact@v4
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-windows-${{ matrix.arch }}
path: dist/release/*
@@ -187,9 +188,9 @@ jobs:
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
NFPM_VERSION: 2.47.0
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
- - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
@@ -203,8 +204,8 @@ jobs:
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
- - uses: dtolnay/rust-toolchain@stable
- - uses: Swatinem/rust-cache@v2
+ - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
+ - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-linux-${{ matrix.arch }}
- name: Apply version override
@@ -228,7 +229,7 @@ jobs:
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
test -s "$appimage.zsync"
done
- - uses: actions/upload-artifact@v4
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/release/*
@@ -250,8 +251,8 @@ jobs:
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- - uses: actions/checkout@v4
- - uses: actions/download-artifact@v4
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/linux
@@ -265,7 +266,7 @@ jobs:
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
- name: Build the Flatpak bundle
run: packaging/linux/flatpak-bundle.sh "dist/linux/lightcraft-$LIGHTCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
- - uses: actions/upload-artifact@v4
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-flatpak-${{ matrix.arch }}
path: dist/release/*.flatpak
@@ -282,16 +283,16 @@ jobs:
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
- - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
path: craft-fonts
- name: Build and package in a FreeBSD VM (tar.gz)
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
- uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
+ uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
with:
release: "14.3"
usesh: true
@@ -316,7 +317,7 @@ jobs:
bash packaging/freebsd/package.sh
tar -tzf dist/release/lightcraft-*-freebsd-x86_64.tar.gz \
| grep -E '/bin/lightcraft$|/bin/lightcraft-cli$|/share/applications/ai\.storyteller\.lightcraft\.desktop$|/share/doc/lightcraft/LICENSE-MIT$'
- - uses: actions/upload-artifact@v4
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-freebsd-x86_64
path: dist/release/*
@@ -331,17 +332,17 @@ jobs:
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
WASM_BINDGEN_VERSION: 0.2.129
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
- - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
path: craft-fonts
- - uses: dtolnay/rust-toolchain@stable
+ - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: wasm32-unknown-unknown
- - uses: Swatinem/rust-cache@v2
+ - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-web
- name: Install wasm-bindgen CLI
@@ -359,7 +360,7 @@ jobs:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/web/package.sh
- - uses: actions/upload-artifact@v4
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-web
path: dist/release/*
@@ -378,7 +379,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
steps:
- - uses: actions/download-artifact@v4
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: release-*
path: artifacts
diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml
index d6067b3f..1cccd858 100644
--- a/.github/workflows/windows-arm64.yml
+++ b/.github/workflows/windows-arm64.yml
@@ -36,11 +36,11 @@ jobs:
runs-on: windows-latest
timeout-minutes: 90
steps:
- - uses: actions/checkout@v4
- - uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: aarch64-pc-windows-msvc
- - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
+ - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: windows-arm64-package
- name: Install WiX
@@ -51,7 +51,7 @@ jobs:
env:
DIST: ${{ github.workspace }}\dist\arm64
run: ./packaging/windows/package.ps1 -Arch arm64
- - uses: actions/upload-artifact@v4
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: lightcraft-windows-arm64
path: dist/arm64/*
@@ -64,7 +64,7 @@ jobs:
runs-on: windows-11-arm
timeout-minutes: 30
steps:
- - uses: actions/download-artifact@v4
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: lightcraft-windows-arm64
path: dist
diff --git a/AGENTS.md b/AGENTS.md
index 3ab653c7..8cd199ff 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -5,12 +5,18 @@ LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructi
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next unchecked task, blockers).
2. Read the task in `plan/execution-plan.md` §3, the relevant section of `plan/architecture.md`, and the README/docs of the crate you touch. Behaviour/visual reference: `plan/lightroom/` (incl. `10-observed-ui.md` + screenshots).
-3. **Know where we stand:** read [`ROADMAP.md`](ROADMAP.md) → *Where we stand* (honest assessment by dimension and
- by kind of user) and *Where we're going* (ordered priorities). The checklist counts features that *exist*; the real
- gaps are quality and coverage: camera colour calibration, CR3 / compressed raws, per-model verification, render
- fidelity against Lightroom, AI models, HDR / video / Classic modules. A ✅ row is not proof of parity — if you find a
- ✅ feature that is wrong or incomplete, downgrade it to 🟡 with a note.
-4. **Pick work** from [`docs/parity.md`](docs/parity.md) → *Top gaps* (ordered by user impact; the tracker has one row per feature, menu item and shortcut). When you land a feature, update its row(s) and the gap list in the same commit, and the *Where we stand* / *Where we're going* sections of `ROADMAP.md` when a listed gap closes; `cargo xtask parity` (part of `ci`) checks that every `cmd:`/`ctl:` id and path the tracker cites still exists, and `cargo xtask parity --write` refreshes its summary.
+3. **Know where we stand:** read [`ROADMAP.md`](ROADMAP.md) (stage, headline numbers, every dimension) and, for
+ method and evidence, [`docs/target-app-parity.md`](docs/target-app-parity.md). The checklist counts features that
+ *exist*; the real gaps are quality and coverage: camera colour calibration, CR3 / compressed raws, per-model
+ verification, render fidelity against Lightroom, AI models, HDR / video / Classic modules (raw detail:
+ [`docs/raw-parity.md`](docs/raw-parity.md)). A ✅ row is not proof of parity — if you find a ✅ feature that is
+ wrong or incomplete, downgrade it to 🟡 with a note.
+4. **Pick work** from [`docs/gaps.md`](docs/gaps.md) (ranked by user impact; `B` marks beta blockers). The row-by-row
+ tracker is [`docs/parity-checklist.md`](docs/parity-checklist.md) (one row per feature, menu item and shortcut).
+ When you land a feature, update its row(s) and the gap entry in the same commit, and the numbers in `ROADMAP.md` /
+ `docs/target-app-parity.md` when a listed gap closes (bump each doc's *Last updated* line and revision history, per
+ craftrules `standards/progress-docs.md`); `cargo xtask parity` (part of `ci`) checks that every `cmd:`/`ctl:` id and
+ path the tracker cites still exists, and `cargo xtask parity --write` refreshes its summary.
5. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
`plan/` is gitignored (local-only).
@@ -74,9 +80,10 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
font files here (Inter, already in `assets/fonts/`, is the one exception); add new fonts to craft-fonts. LightCraft
uses it as the optional build input `CRAFT_FONTS_DIR`: `git clone https://github.com/storytold/craft-fonts ../craft-fonts`
then `CRAFT_FONTS_DIR=../craft-fonts cargo run -p lightcraft` (or any cargo/xtask command). `crates/engine/build.rs`
- embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); the UI
- (`theme::font_definitions`) and the export watermark renderer use its CJK faces (picked by script) as fallbacks after Inter. Unset,
- `CRAFT_FONTS` is empty: everything builds, tests and runs, but Japanese text has no glyphs. Releases always build
+ embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); `cargo xtask web`
+ ships Noto Sans CJK SC Regular as a separate asset loaded by the UI before startup. The UI
+ (`theme::font_definitions`) and the export watermark renderer use CJK faces (picked by script) as fallbacks after Inter. Unset,
+ `CRAFT_FONTS` is empty: everything builds, tests and runs, but Chinese and Japanese text lack glyphs. Releases always build
with it (`release.yml`, `CRAFT_FONTS_REQUIRED=1`) and ship the fonts' OFL licences. Tests that need these fonts skip
without it; the FreeBSD CI job runs them with it. Rules: craftrules
[`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md).
diff --git a/CLAUDE.md b/CLAUDE.md
index bdf7eceb..38f5bde4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -5,12 +5,18 @@ LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructi
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next unchecked task, blockers).
2. Read the task in `plan/execution-plan.md` §3, the relevant section of `plan/architecture.md`, and the README/docs of the crate you touch. Behaviour/visual reference: `plan/lightroom/` (incl. `10-observed-ui.md` + screenshots).
-3. **Know where we stand:** read [`ROADMAP.md`](ROADMAP.md) → *Where we stand* (honest assessment by dimension and
- by kind of user) and *Where we're going* (ordered priorities). The checklist counts features that *exist*; the real
- gaps are quality and coverage: camera colour calibration, CR3 / compressed raws, per-model verification, render
- fidelity against Lightroom, AI models, HDR / video / Classic modules. A ✅ row is not proof of parity — if you find a
- ✅ feature that is wrong or incomplete, downgrade it to 🟡 with a note.
-4. **Pick work** from [`docs/parity.md`](docs/parity.md) → *Top gaps* (ordered by user impact; the tracker has one row per feature, menu item and shortcut). When you land a feature, update its row(s) and the gap list in the same commit, and the *Where we stand* / *Where we're going* sections of `ROADMAP.md` when a listed gap closes; `cargo xtask parity` (part of `ci`) checks that every `cmd:`/`ctl:` id and path the tracker cites still exists, and `cargo xtask parity --write` refreshes its summary.
+3. **Know where we stand:** read [`ROADMAP.md`](ROADMAP.md) (stage, headline numbers, every dimension) and, for
+ method and evidence, [`docs/target-app-parity.md`](docs/target-app-parity.md). The checklist counts features that
+ *exist*; the real gaps are quality and coverage: camera colour calibration, CR3 / compressed raws, per-model
+ verification, render fidelity against Lightroom, AI models, HDR / video / Classic modules (raw detail:
+ [`docs/raw-parity.md`](docs/raw-parity.md)). A ✅ row is not proof of parity — if you find a ✅ feature that is
+ wrong or incomplete, downgrade it to 🟡 with a note.
+4. **Pick work** from [`docs/gaps.md`](docs/gaps.md) (ranked by user impact; `B` marks beta blockers). The row-by-row
+ tracker is [`docs/parity-checklist.md`](docs/parity-checklist.md) (one row per feature, menu item and shortcut).
+ When you land a feature, update its row(s) and the gap entry in the same commit, and the numbers in `ROADMAP.md` /
+ `docs/target-app-parity.md` when a listed gap closes (bump each doc's *Last updated* line and revision history, per
+ craftrules `standards/progress-docs.md`); `cargo xtask parity` (part of `ci`) checks that every `cmd:`/`ctl:` id and
+ path the tracker cites still exists, and `cargo xtask parity --write` refreshes its summary.
5. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
`plan/` is gitignored (local-only).
@@ -55,6 +61,10 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
- **Pure Rust** in the product. No C/C++ dependencies.
- **Layering** (`plan/architecture.md` §3, enforced by `cargo xtask layers`): nothing below L5 depends on egui/eframe/winit/rfd.
- **Everything is a command** (`crates/engine`): id, label, menu path, shortcut, params, enabled(), run(). UI, CLI, control channel and MCP all dispatch by id. Every slider is a `develop` control spec.
+- **Long-running work shows in the activity stack** ([`docs/background-tasks.md`](docs/background-tasks.md)): anything
+ that runs off the UI thread and can take more than a moment holds a `TaskGuard` from `session.activity.start(kind,
+ label, cancel)` for its whole run. It adopts the cancel flag the job already checks (`Cancel::Flag`), or uses
+ `Cancel::No` when the job can't stop. No progress window, panel or progress toast of its own; its tests assert the row.
- **Resolution independence:** settings use normalized image coordinates and relative radii; previews and exports must match.
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, assets, wasm).
- **Commits:** one task id per commit (`M2.3: local Laplacian highlights/shadows`). Only green states. End messages with the attribution line required by the environment.
@@ -74,9 +84,10 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
font files here (Inter, already in `assets/fonts/`, is the one exception); add new fonts to craft-fonts. LightCraft
uses it as the optional build input `CRAFT_FONTS_DIR`: `git clone https://github.com/storytold/craft-fonts ../craft-fonts`
then `CRAFT_FONTS_DIR=../craft-fonts cargo run -p lightcraft` (or any cargo/xtask command). `crates/engine/build.rs`
- embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); the UI
- (`theme::font_definitions`) and the export watermark renderer use its CJK faces (picked by script) as fallbacks after Inter. Unset,
- `CRAFT_FONTS` is empty: everything builds, tests and runs, but Japanese text has no glyphs. Releases always build
+ embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); `cargo xtask web`
+ ships Noto Sans CJK SC Regular as a separate asset loaded by the UI before startup. The UI
+ (`theme::font_definitions`) and the export watermark renderer use CJK faces (picked by script) as fallbacks after Inter. Unset,
+ `CRAFT_FONTS` is empty: everything builds, tests and runs, but Chinese and Japanese text lack glyphs. Releases always build
with it (`release.yml`, `CRAFT_FONTS_REQUIRED=1`) and ship the fonts' OFL licences. Tests that need these fonts skip
without it; the FreeBSD CI job runs them with it. Rules: craftrules
[`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md).
@@ -102,6 +113,9 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
the running app). See `docs/mcp.md`. Quick non-UI checks: `lightcraft-cli render in.jpg -o out.jpg --set light.exposure=1`.
- Export goes through `lightcraft_engine::export` (one encoder for app, CLI, MCP and web); UI-only commands live in
`crates/ui-egui/src/menus.rs`.
+- Text boxes use `crates/ui-egui/src/text_field.rs` (`TextField`), not `egui::TextEdit`: it gives every field the
+ Cut / Copy / Paste / Select All menu, Esc restoring the text, and reports how an edit ended. Give each a widget id
+ that is unique on screen. Older fields still on `TextEdit` move over as they are touched.
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`.
- Parallel agents: separate git worktrees and `CARGO_TARGET_DIR=target/agent-`; each agent uses its **own control port** (pick one in 18000–19999, never the default 7980) and its own scratch subfolder (`//`) — never `rm -rf` shared paths; delete your target dir when done (disk is shared); keep every `Cargo.toml` valid at all times (the `crates/*` glob means one broken manifest breaks everyone).
- Test corpora: `cargo xtask corpus --download` into `corpus/` (gitignored, CC0 only). Never commit media.
diff --git a/Cargo.lock b/Cargo.lock
index 86d4bca6..0ad4ab84 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -209,7 +209,7 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b"
dependencies = [
- "equator",
+ "equator 0.4.2",
]
[[package]]
@@ -967,6 +967,12 @@ dependencies = [
"unicode-width",
]
+[[package]]
+name = "coe-rs"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e8f1e641542c07631228b1e0dc04b69ae3c1d58ef65d5691a439711d805c698"
+
[[package]]
name = "color"
version = "0.3.3"
@@ -1256,6 +1262,16 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
+[[package]]
+name = "dyn-stack"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56e53799688f5632f364f8fb387488dd05db9fe45db7011be066fc20e7027f8b"
+dependencies = [
+ "bytemuck",
+ "reborrow",
+]
+
[[package]]
name = "dyn-stack"
version = "0.13.2"
@@ -1537,13 +1553,33 @@ version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13"
+[[package]]
+name = "equator"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3b0a88aa91d0ad2b9684e4479aed31a17d3f9051bdbbc634bd2c01bc5a5eee8"
+dependencies = [
+ "equator-macro 0.1.9",
+]
+
[[package]]
name = "equator"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc"
dependencies = [
- "equator-macro",
+ "equator-macro 0.4.2",
+]
+
+[[package]]
+name = "equator-macro"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60d08acb9849f7fb4401564f251be5a526829183a3645a90197dea8e786cf3ae"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
]
[[package]]
@@ -1608,6 +1644,42 @@ dependencies = [
"pin-project-lite",
]
+[[package]]
+name = "faer-core"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32da0f6262445261106407bdfb548df4f8f1c89bcc3cff1bb6d08e7f525b575a"
+dependencies = [
+ "bytemuck",
+ "coe-rs",
+ "dyn-stack 0.10.0",
+ "equator 0.1.10",
+ "faer-entity",
+ "gemm 0.17.1",
+ "matrixcompare-core",
+ "num-complex",
+ "num-traits",
+ "paste",
+ "pulp 0.18.22",
+ "reborrow",
+ "seq-macro",
+]
+
+[[package]]
+name = "faer-entity"
+version = "0.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fb12f36da3162b3fefc0c7343d905d5bd2c2bf444a7f63ceeaa2bc003311d3"
+dependencies = [
+ "bytemuck",
+ "coe-rs",
+ "libm",
+ "num-complex",
+ "num-traits",
+ "pulp 0.18.22",
+ "reborrow",
+]
+
[[package]]
name = "fastrand"
version = "2.5.0"
@@ -1783,13 +1855,33 @@ dependencies = [
"slab",
]
+[[package]]
+name = "gemm"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ab24cc62135b40090e31a76a9b2766a501979f3070fa27f689c27ec04377d32"
+dependencies = [
+ "dyn-stack 0.10.0",
+ "gemm-c32 0.17.1",
+ "gemm-c64 0.17.1",
+ "gemm-common 0.17.1",
+ "gemm-f16 0.17.1",
+ "gemm-f32 0.17.1",
+ "gemm-f64 0.17.1",
+ "num-complex",
+ "num-traits",
+ "paste",
+ "raw-cpuid 10.7.0",
+ "seq-macro",
+]
+
[[package]]
name = "gemm"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab96b703d31950f1aeddded248bc95543c9efc7ac9c4a21fda8703a83ee35451"
dependencies = [
- "dyn-stack",
+ "dyn-stack 0.13.2",
"gemm-c32 0.18.2",
"gemm-c64 0.18.2",
"gemm-common 0.18.2",
@@ -1799,7 +1891,7 @@ dependencies = [
"num-complex",
"num-traits",
"paste",
- "raw-cpuid",
+ "raw-cpuid 11.6.0",
"seq-macro",
]
@@ -1809,7 +1901,7 @@ version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa0673db364b12263d103b68337a68fbecc541d6f6b61ba72fe438654709eacb"
dependencies = [
- "dyn-stack",
+ "dyn-stack 0.13.2",
"gemm-c32 0.19.0",
"gemm-c64 0.19.0",
"gemm-common 0.19.0",
@@ -1819,7 +1911,22 @@ dependencies = [
"num-complex",
"num-traits",
"paste",
- "raw-cpuid",
+ "raw-cpuid 11.6.0",
+ "seq-macro",
+]
+
+[[package]]
+name = "gemm-c32"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9c030d0b983d1e34a546b86e08f600c11696fde16199f971cd46c12e67512c0"
+dependencies = [
+ "dyn-stack 0.10.0",
+ "gemm-common 0.17.1",
+ "num-complex",
+ "num-traits",
+ "paste",
+ "raw-cpuid 10.7.0",
"seq-macro",
]
@@ -1829,12 +1936,12 @@ version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6db9fd9f40421d00eea9dd0770045a5603b8d684654816637732463f4073847"
dependencies = [
- "dyn-stack",
+ "dyn-stack 0.13.2",
"gemm-common 0.18.2",
"num-complex",
"num-traits",
"paste",
- "raw-cpuid",
+ "raw-cpuid 11.6.0",
"seq-macro",
]
@@ -1844,12 +1951,27 @@ version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "086936dbdcb99e37aad81d320f98f670e53c1e55a98bee70573e83f95beb128c"
dependencies = [
- "dyn-stack",
+ "dyn-stack 0.13.2",
"gemm-common 0.19.0",
"num-complex",
"num-traits",
"paste",
- "raw-cpuid",
+ "raw-cpuid 11.6.0",
+ "seq-macro",
+]
+
+[[package]]
+name = "gemm-c64"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fbb5f2e79fefb9693d18e1066a557b4546cd334b226beadc68b11a8f9431852a"
+dependencies = [
+ "dyn-stack 0.10.0",
+ "gemm-common 0.17.1",
+ "num-complex",
+ "num-traits",
+ "paste",
+ "raw-cpuid 10.7.0",
"seq-macro",
]
@@ -1859,12 +1981,12 @@ version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfcad8a3d35a43758330b635d02edad980c1e143dc2f21e6fd25f9e4eada8edf"
dependencies = [
- "dyn-stack",
+ "dyn-stack 0.13.2",
"gemm-common 0.18.2",
"num-complex",
"num-traits",
"paste",
- "raw-cpuid",
+ "raw-cpuid 11.6.0",
"seq-macro",
]
@@ -1874,15 +1996,34 @@ version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20c8aeeeec425959bda4d9827664029ba1501a90a0d1e6228e48bef741db3a3f"
dependencies = [
- "dyn-stack",
+ "dyn-stack 0.13.2",
"gemm-common 0.19.0",
"num-complex",
"num-traits",
"paste",
- "raw-cpuid",
+ "raw-cpuid 11.6.0",
"seq-macro",
]
+[[package]]
+name = "gemm-common"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2e7ea062c987abcd8db95db917b4ffb4ecdfd0668471d8dc54734fdff2354e8"
+dependencies = [
+ "bytemuck",
+ "dyn-stack 0.10.0",
+ "half",
+ "num-complex",
+ "num-traits",
+ "once_cell",
+ "paste",
+ "pulp 0.18.22",
+ "raw-cpuid 10.7.0",
+ "seq-macro",
+ "sysctl 0.5.5",
+]
+
[[package]]
name = "gemm-common"
version = "0.18.2"
@@ -1890,7 +2031,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a352d4a69cbe938b9e2a9cb7a3a63b7e72f9349174a2752a558a8a563510d0f3"
dependencies = [
"bytemuck",
- "dyn-stack",
+ "dyn-stack 0.13.2",
"half",
"libm",
"num-complex",
@@ -1898,10 +2039,10 @@ dependencies = [
"once_cell",
"paste",
"pulp 0.21.5",
- "raw-cpuid",
+ "raw-cpuid 11.6.0",
"rayon",
"seq-macro",
- "sysctl",
+ "sysctl 0.6.0",
]
[[package]]
@@ -1911,7 +2052,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88027625910cc9b1085aaaa1c4bc46bb3a36aad323452b33c25b5e4e7c8e2a3e"
dependencies = [
"bytemuck",
- "dyn-stack",
+ "dyn-stack 0.13.2",
"half",
"libm",
"num-complex",
@@ -1919,10 +2060,27 @@ dependencies = [
"once_cell",
"paste",
"pulp 0.22.3",
- "raw-cpuid",
+ "raw-cpuid 11.6.0",
"rayon",
"seq-macro",
- "sysctl",
+ "sysctl 0.6.0",
+]
+
+[[package]]
+name = "gemm-f16"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ca4c06b9b11952071d317604acb332e924e817bd891bec8dfb494168c7cedd4"
+dependencies = [
+ "dyn-stack 0.10.0",
+ "gemm-common 0.17.1",
+ "gemm-f32 0.17.1",
+ "half",
+ "num-complex",
+ "num-traits",
+ "paste",
+ "raw-cpuid 10.7.0",
+ "seq-macro",
]
[[package]]
@@ -1931,14 +2089,14 @@ version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cff95ae3259432f3c3410eaa919033cd03791d81cebd18018393dc147952e109"
dependencies = [
- "dyn-stack",
+ "dyn-stack 0.13.2",
"gemm-common 0.18.2",
"gemm-f32 0.18.2",
"half",
"num-complex",
"num-traits",
"paste",
- "raw-cpuid",
+ "raw-cpuid 11.6.0",
"rayon",
"seq-macro",
]
@@ -1949,30 +2107,45 @@ version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3df7a55202e6cd6739d82ae3399c8e0c7e1402859b30e4cb780e61525d9486e"
dependencies = [
- "dyn-stack",
+ "dyn-stack 0.13.2",
"gemm-common 0.19.0",
"gemm-f32 0.19.0",
"half",
"num-complex",
"num-traits",
"paste",
- "raw-cpuid",
+ "raw-cpuid 11.6.0",
"rayon",
"seq-macro",
]
+[[package]]
+name = "gemm-f32"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e9a69f51aaefbd9cf12d18faf273d3e982d9d711f60775645ed5c8047b4ae113"
+dependencies = [
+ "dyn-stack 0.10.0",
+ "gemm-common 0.17.1",
+ "num-complex",
+ "num-traits",
+ "paste",
+ "raw-cpuid 10.7.0",
+ "seq-macro",
+]
+
[[package]]
name = "gemm-f32"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc8d3d4385393304f407392f754cd2dc4b315d05063f62cf09f47b58de276864"
dependencies = [
- "dyn-stack",
+ "dyn-stack 0.13.2",
"gemm-common 0.18.2",
"num-complex",
"num-traits",
"paste",
- "raw-cpuid",
+ "raw-cpuid 11.6.0",
"seq-macro",
]
@@ -1982,12 +2155,27 @@ version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02e0b8c9da1fbec6e3e3ab2ce6bc259ef18eb5f6f0d3e4edf54b75f9fd41a81c"
dependencies = [
- "dyn-stack",
+ "dyn-stack 0.13.2",
"gemm-common 0.19.0",
"num-complex",
"num-traits",
"paste",
- "raw-cpuid",
+ "raw-cpuid 11.6.0",
+ "seq-macro",
+]
+
+[[package]]
+name = "gemm-f64"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa397a48544fadf0b81ec8741e5c0fba0043008113f71f2034def1935645d2b0"
+dependencies = [
+ "dyn-stack 0.10.0",
+ "gemm-common 0.17.1",
+ "num-complex",
+ "num-traits",
+ "paste",
+ "raw-cpuid 10.7.0",
"seq-macro",
]
@@ -1997,12 +2185,12 @@ version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35b2a4f76ce4b8b16eadc11ccf2e083252d8237c1b589558a49b0183545015bd"
dependencies = [
- "dyn-stack",
+ "dyn-stack 0.13.2",
"gemm-common 0.18.2",
"num-complex",
"num-traits",
"paste",
- "raw-cpuid",
+ "raw-cpuid 11.6.0",
"seq-macro",
]
@@ -2012,12 +2200,12 @@ version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "056131e8f2a521bfab322f804ccd652520c79700d81209e9d9275bbdecaadc6a"
dependencies = [
- "dyn-stack",
+ "dyn-stack 0.13.2",
"gemm-common 0.19.0",
"num-complex",
"num-traits",
"paste",
- "raw-cpuid",
+ "raw-cpuid 11.6.0",
"seq-macro",
]
@@ -2296,6 +2484,15 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+[[package]]
+name = "heic-rs"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "adf9dde634bb6d5b718bacd01bef80192f2334306b2f785519a0136cca7b9618"
+dependencies = [
+ "rayon",
+]
+
[[package]]
name = "hermit-abi"
version = "0.5.3"
@@ -2779,7 +2976,7 @@ dependencies = [
[[package]]
name = "lightcraft"
-version = "0.4.0"
+version = "0.5.0"
dependencies = [
"eframe",
"egui",
@@ -2797,10 +2994,11 @@ dependencies = [
[[package]]
name = "lightcraft-catalog"
-version = "0.4.0"
+version = "0.5.0"
dependencies = [
"crc32fast",
"lightcraft-develop",
+ "lightcraft-geom",
"lightcraft-meta",
"log",
"proptest",
@@ -2812,7 +3010,7 @@ dependencies = [
[[package]]
name = "lightcraft-cli"
-version = "0.4.0"
+version = "0.5.0"
dependencies = [
"dhat",
"lightcraft-codecs",
@@ -2827,8 +3025,9 @@ dependencies = [
[[package]]
name = "lightcraft-codecs"
-version = "0.4.0"
+version = "0.5.0"
dependencies = [
+ "avif-serialize",
"bytemuck",
"image",
"image-webp",
@@ -2837,10 +3036,12 @@ dependencies = [
"jxl-oxide",
"lightcraft-color",
"lightcraft-geom",
+ "lightcraft-heif",
"lightcraft-raster",
"moxcms 0.9.1",
"png",
"proptest",
+ "rav1e",
"ravif",
"rayon",
"serde",
@@ -2852,16 +3053,48 @@ dependencies = [
[[package]]
name = "lightcraft-color"
-version = "0.4.0"
+version = "0.5.0"
dependencies = [
"lightcraft-geom",
"proptest",
"serde",
]
+[[package]]
+name = "lightcraft-denoise"
+version = "0.5.0"
+dependencies = [
+ "faer-core",
+ "lightcraft-denoise-core",
+ "lightcraft-raster",
+ "miniz_oxide 0.8.9",
+ "proptest",
+ "rayon",
+ "serde",
+ "serde_json",
+ "sha2",
+ "thiserror 2.0.21",
+ "web-time",
+]
+
+[[package]]
+name = "lightcraft-denoise-core"
+version = "0.5.0"
+dependencies = [
+ "lightcraft-raster",
+ "miniz_oxide 0.8.9",
+ "proptest",
+ "rayon",
+ "serde",
+ "serde_json",
+ "sha2",
+ "thiserror 2.0.21",
+ "web-time",
+]
+
[[package]]
name = "lightcraft-develop"
-version = "0.4.0"
+version = "0.5.0"
dependencies = [
"lightcraft-color",
"lightcraft-geom",
@@ -2873,7 +3106,7 @@ dependencies = [
[[package]]
name = "lightcraft-engine"
-version = "0.4.0"
+version = "0.5.0"
dependencies = [
"ab_glyph",
"harfrust",
@@ -2881,9 +3114,14 @@ dependencies = [
"lightcraft-catalog",
"lightcraft-codecs",
"lightcraft-color",
+ "lightcraft-denoise",
+ "lightcraft-denoise-core",
"lightcraft-develop",
+ "lightcraft-faces",
+ "lightcraft-fetch",
"lightcraft-geom",
"lightcraft-gpu",
+ "lightcraft-heif",
"lightcraft-merge",
"lightcraft-meta",
"lightcraft-pipeline",
@@ -2903,9 +3141,23 @@ dependencies = [
"web-time",
]
+[[package]]
+name = "lightcraft-faces"
+version = "0.5.0"
+dependencies = [
+ "matrixmultiply",
+ "ndarray",
+ "proptest",
+ "serde",
+ "serde_json",
+ "sha2",
+ "thiserror 2.0.21",
+ "web-time",
+]
+
[[package]]
name = "lightcraft-fetch"
-version = "0.4.0"
+version = "0.5.0"
dependencies = [
"log",
"rustls",
@@ -2916,7 +3168,7 @@ dependencies = [
[[package]]
name = "lightcraft-geom"
-version = "0.4.0"
+version = "0.5.0"
dependencies = [
"proptest",
"serde",
@@ -2924,23 +3176,33 @@ dependencies = [
[[package]]
name = "lightcraft-gpu"
-version = "0.4.0"
+version = "0.5.0"
dependencies = [
"bytemuck",
"lightcraft-color",
+ "lightcraft-denoise",
"lightcraft-develop",
"lightcraft-geom",
"lightcraft-pipeline",
"lightcraft-raster",
+ "lightcraft-raw",
"lightcraft-scenes",
"log",
"pollster 1.0.1",
"wgpu",
]
+[[package]]
+name = "lightcraft-heif"
+version = "0.5.0"
+dependencies = [
+ "heic-rs",
+ "rayon",
+]
+
[[package]]
name = "lightcraft-mcp"
-version = "0.4.0"
+version = "0.5.0"
dependencies = [
"lightcraft-codecs",
"lightcraft-engine",
@@ -2951,7 +3213,7 @@ dependencies = [
[[package]]
name = "lightcraft-merge"
-version = "0.4.0"
+version = "0.5.0"
dependencies = [
"lightcraft-codecs",
"lightcraft-color",
@@ -2967,7 +3229,7 @@ dependencies = [
[[package]]
name = "lightcraft-meta"
-version = "0.4.0"
+version = "0.5.0"
dependencies = [
"lightcraft-geom",
"lightcraft-tiff",
@@ -2979,13 +3241,14 @@ dependencies = [
[[package]]
name = "lightcraft-pipeline"
-version = "0.4.0"
+version = "0.5.0"
dependencies = [
"image",
"lightcraft-color",
"lightcraft-develop",
"lightcraft-geom",
"lightcraft-raster",
+ "lightcraft-raw",
"lightcraft-scenes",
"proptest",
"rayon",
@@ -2995,7 +3258,7 @@ dependencies = [
[[package]]
name = "lightcraft-preview"
-version = "0.4.0"
+version = "0.5.0"
dependencies = [
"jpeg-encoder",
"lightcraft-raster",
@@ -3007,7 +3270,7 @@ dependencies = [
[[package]]
name = "lightcraft-raster"
-version = "0.4.0"
+version = "0.5.0"
dependencies = [
"libc",
"lightcraft-color",
@@ -3019,9 +3282,10 @@ dependencies = [
[[package]]
name = "lightcraft-raw"
-version = "0.4.0"
+version = "0.5.0"
dependencies = [
"jpeg-encoder",
+ "jxl-oxide",
"lightcraft-color",
"lightcraft-geom",
"lightcraft-meta",
@@ -3035,11 +3299,12 @@ dependencies = [
"thiserror 2.0.21",
"zune-core",
"zune-jpeg",
+ "zune-jpegxl",
]
[[package]]
name = "lightcraft-scenes"
-version = "0.4.0"
+version = "0.5.0"
dependencies = [
"image",
"lightcraft-color",
@@ -3050,7 +3315,7 @@ dependencies = [
[[package]]
name = "lightcraft-segment"
-version = "0.4.0"
+version = "0.5.0"
dependencies = [
"candle-core",
"candle-nn",
@@ -3063,11 +3328,11 @@ dependencies = [
[[package]]
name = "lightcraft-sysmem"
-version = "0.4.0"
+version = "0.5.0"
[[package]]
name = "lightcraft-tiff"
-version = "0.4.0"
+version = "0.5.0"
dependencies = [
"proptest",
"serde",
@@ -3076,15 +3341,18 @@ dependencies = [
[[package]]
name = "lightcraft-ui-egui"
-version = "0.4.0"
+version = "0.5.0"
dependencies = [
"egui",
"lightcraft-catalog",
"lightcraft-codecs",
"lightcraft-color",
+ "lightcraft-denoise",
"lightcraft-develop",
"lightcraft-engine",
+ "lightcraft-faces",
"lightcraft-geom",
+ "lightcraft-merge",
"lightcraft-meta",
"lightcraft-pipeline",
"lightcraft-preview",
@@ -3098,7 +3366,7 @@ dependencies = [
[[package]]
name = "lightcraft-web"
-version = "0.4.0"
+version = "0.5.0"
dependencies = [
"crc32fast",
"eframe",
@@ -3174,6 +3442,22 @@ dependencies = [
"libc",
]
+[[package]]
+name = "matrixcompare-core"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0bdabb30db18805d5290b3da7ceaccbddba795620b86c02145d688e04900a73"
+
+[[package]]
+name = "matrixmultiply"
+version = "0.3.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7"
+dependencies = [
+ "autocfg",
+ "rawpointer",
+]
+
[[package]]
name = "maybe-rayon"
version = "0.1.1"
@@ -3327,6 +3611,21 @@ dependencies = [
"thiserror 2.0.21",
]
+[[package]]
+name = "ndarray"
+version = "0.17.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d"
+dependencies = [
+ "matrixmultiply",
+ "num-complex",
+ "num-integer",
+ "num-traits",
+ "portable-atomic",
+ "portable-atomic-util",
+ "rawpointer",
+]
+
[[package]]
name = "ndk"
version = "0.9.0"
@@ -4291,6 +4590,18 @@ dependencies = [
"unarray",
]
+[[package]]
+name = "pulp"
+version = "0.18.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a0a01a0dc67cf4558d279f0c25b0962bd08fc6dec0137699eae304103e882fe6"
+dependencies = [
+ "bytemuck",
+ "libm",
+ "num-complex",
+ "reborrow",
+]
+
[[package]]
name = "pulp"
version = "0.21.5"
@@ -4317,7 +4628,7 @@ dependencies = [
"num-complex",
"paste",
"pulp-wasm-simd-flag",
- "raw-cpuid",
+ "raw-cpuid 11.6.0",
"reborrow",
"version_check",
]
@@ -4518,6 +4829,15 @@ dependencies = [
"rgb",
]
+[[package]]
+name = "raw-cpuid"
+version = "10.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c297679cb867470fa8c9f67dbba74a78d78e3e98d7cf2b08d6d71540f797332"
+dependencies = [
+ "bitflags 1.3.2",
+]
+
[[package]]
name = "raw-cpuid"
version = "11.6.0"
@@ -4545,6 +4865,12 @@ dependencies = [
"objc2-quartz-core 0.3.2",
]
+[[package]]
+name = "rawpointer"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
+
[[package]]
name = "rayon"
version = "1.12.0"
@@ -5245,6 +5571,20 @@ dependencies = [
"syn 3.0.6",
]
+[[package]]
+name = "sysctl"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec7dddc5f0fee506baf8b9fdb989e242f17e4b11c61dfbb0635b705217199eea"
+dependencies = [
+ "bitflags 2.13.2",
+ "byteorder",
+ "enum-as-inner",
+ "libc",
+ "thiserror 1.0.69",
+ "walkdir",
+]
+
[[package]]
name = "sysctl"
version = "0.6.0"
@@ -6486,11 +6826,12 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
-version = "0.4.0"
+version = "0.5.0"
dependencies = [
"brotli",
"flate2",
"serde_json",
+ "sha2",
]
[[package]]
@@ -6752,6 +7093,15 @@ dependencies = [
"zune-core",
]
+[[package]]
+name = "zune-jpegxl"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cba11ecd07cc23351500e840ae03841b7e4997923ec8e4832ee3ae199ea0b6b4"
+dependencies = [
+ "zune-core",
+]
+
[[package]]
name = "zvariant"
version = "5.15.0"
diff --git a/Cargo.toml b/Cargo.toml
index 77b20666..58e5383b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
-version = "0.4.0"
+version = "0.5.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@@ -11,7 +11,10 @@ repository = "https://github.com/storytold/lightcraft"
[workspace.dependencies]
# internal
+lightcraft-denoise-core = { path = "crates/denoise-core" }
+lightcraft-denoise = { path = "crates/denoise", default-features = false }
lightcraft-geom = { path = "crates/geom" }
+lightcraft-faces = { path = "crates/faces" }
lightcraft-sysmem = { path = "crates/sysmem" }
lightcraft-color = { path = "crates/color" }
lightcraft-raster = { path = "crates/raster" }
@@ -30,10 +33,12 @@ lightcraft-engine = { path = "crates/engine" }
lightcraft-ui-egui = { path = "crates/ui-egui" }
lightcraft-mcp = { path = "crates/mcp" }
lightcraft-fetch = { path = "crates/fetch" }
+lightcraft-heif = { path = "crates/heif" }
lightcraft-segment = { path = "crates/segment" }
# foundation
thiserror = "2"
+sha2 = "0.10"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4"
@@ -61,7 +66,11 @@ tiff = "0.11"
image-webp = "0.2"
moxcms = { version = "0.9", default-features = false, features = ["lut"] }
jxl-oxide = { version = "0.12", default-features = false }
+zune-jpegxl = { version = "0.5", default-features = false, features = ["std"] } # MIT/Apache-2.0/Zlib lossless JXL encoder (test fixtures only)
ravif = { version = "0.13", default-features = false, features = ["threading"] } # no "asm": it needs nasm (non-Rust)
+# HDR AVIF (PQ): ravif fixes the sRGB transfer curve, so HDR files drive its rav1e + avif-serialize directly
+rav1e = { version = "0.8.1", default-features = false, features = ["threading"] }
+avif-serialize = "0.8.6"
# gpu (crates/gpu; native backends only, no GL: everything stays pure Rust)
# SAM 3 inference (lightcraft-segment). 0.9.2: later releases pull `tokenizers` with the
diff --git a/NOTICE b/NOTICE
index f3f6499f..5fd60227 100644
--- a/NOTICE
+++ b/NOTICE
@@ -15,6 +15,13 @@ list (author, source, license) is in assets/ATTRIBUTION.md.
Inter (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt.
Showcase photographs by Ansel Adams, Dorothea Lange and NASA (docs/images/) — public domain. See assets/ATTRIBUTION.md.
+crates/raw/src/spectral_table.rs (lightcraft-raw) holds camera colour matrices derived from the
+camera spectral sensitivities, training reflectances and model alias list of rawtoaces-data
+(https://github.com/AcademySoftwareFoundation/rawtoaces-data, v1.1.0), an Academy Software
+Foundation project licensed under the Apache License 2.0, and from the CIE 1931 colour-matching
+functions it ships (CIE, CC-BY-4.0). The matrices were fitted by the LightCraft contributors
+(2026) with tools/spectral_camera_table.py. See assets/ATTRIBUTION.md.
+
crates/segment (lightcraft-segment) ports the SAM 3 model code of Hugging Face Transformers
(https://github.com/huggingface/transformers, src/transformers/models/sam3 and sam3_tracker),
Copyright The HuggingFace Team and Meta Platforms, Inc., licensed under the Apache License 2.0.
@@ -27,6 +34,15 @@ the facebook/sam3 checkpoint only when they choose to (in the app, or by hand),
License (https://github.com/facebookresearch/sam3/blob/main/LICENSE), a separate, non-OSI
licence. See docs/ai-masks.md.
+AI denoise weights are not part of LightCraft and are never distributed with it.
+The default build offers user-supplied models only. The optional rawnind-model
+feature offers a separately downloaded RawNIND UtNet2 Bayer checkpoint under
+GPL-3.0 (https://www.gnu.org/licenses/gpl-3.0.html), after the user accepts its terms.
+Commercial use is subject to those terms, including applicable source, licence and
+notice obligations on redistribution. Enabling that offer in releases remains a
+maintainer decision. The ONNX reader and CPU/GPU executor are our Rust implementation;
+no darktable, RawSpeed, LibRaw or other GPL decoder/runtime code is included.
+
Builds made with craft-fonts (the optional CRAFT_FONTS_DIR build input; all official releases)
also embed the fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md
(the Japanese fonts BIZ UDPGothic, BIZ UDMincho and Shippori Mincho; the web build embeds only BIZ
diff --git a/README.md b/README.md
index 5aacbee0..d395a104 100644
--- a/README.md
+++ b/README.md
@@ -249,6 +249,20 @@ lightcraft --control 7980 ~/Pictures/trip
path=out.jpg longEdge=2048` runs any chain of commands (headless, on a saved library, or against the running app)
and prints one JSON result per command; `lightcraft-cli render in.dng -o out.jpg --set light.exposure=0.7 --preset …`.
- **Undo for everything**, including agent actions: a slider drag (or a scripted burst of updates) is one undo step.
+- **Contact sheets (native app / CLI):** select photos, then **File → Contact Sheet PDF…**. Choose A4 or Letter, landscape,
+ rows/columns and filename captions; save a paginated PDF and print it from your PDF viewer. Exports use the
+ current edits, fit each photo without further cropping, and render at 150 dpi in sRGB. The background export
+ supports cancellation and protects originals and XMP sidecars. From the CLI:
+
+ ```sh
+ lightcraft-cli run --import ~/Pictures/shoot library.selectAll export.contactSheet path=Contact.pdf paper=a4 columns=3 rows=4 captions=true
+ ```
+
+ `export.contactSheet` also accepts explicit `ids`, `landscape=true`, and `paper=letter`. It replaces an existing
+ output only after the entire PDF succeeds. Limits: 1,000 photos, 100 pages, 256 MiB; captions are rasterized
+ with the same font coverage as watermarks (CJK requires craft-fonts), with long names shortened in the middle.
+ This is a contact-sheet workflow,
+ not a printer-driver or custom picture-package layout editor.
- **Every widget is addressable** (`ui.widgets`) and clickable by name, so agents operate the real UI, not a
side door.
- The screenshots in this README were produced end to end by the [`docs/showcase/`](docs/showcase/) scripts.
@@ -260,7 +274,8 @@ lightcraft --control 7980 ~/Pictures/trip
- **Pure Rust, no C.** Our own RAW decoders (DNG, Canon CR2/CR3, Sony ARW, Nikon NEF, Fujifilm RAF incl. X-Trans,
Panasonic RW2 / Leica RWL, Pentax PEF, Olympus ORF), our own colour science, our own pipeline. JPEG, PNG, TIFF, WebP,
- PSD composites and JPEG XL open today.
+ PSD composites and JPEG XL open today; HEIC/HEIF photos too with `--features heif` (HEVC is a build-time choice,
+ as in PhotoCraft).
- **Scene-referred & wide-gamut.** Linear Rec.2020 float internally, Bradford-adapted white balance, gamut mapping
instead of clipping, a filmic shoulder for raw and pixel-exact pass-through for JPEGs you haven't touched.
- **Resolution-independent edits.** Radii and brush sizes are relative to the image, so a 400 px preview, your
@@ -268,7 +283,8 @@ lightcraft --control 7980 ~/Pictures/trip
- **GPU-accelerated, CPU-exact.** The whole develop pipeline runs as wgpu compute kernels (Metal / Vulkan / DX12),
checked against the CPU pipeline to within 1/255. On a 24 MP raw (Apple M4 Pro): a slider update re-renders in
~4 ms, a cold 2.5 MP loupe in ~30 ms, and a full-size export in ~0.3 s including a parallel JPEG encode.
- Without a GPU the same pipeline runs on all CPU cores, redoing only the stages a slider affects.
+ Without a GPU the same pipeline runs on all CPU cores, redoing only the stages a slider affects. Batch exports
+ render several photos side by side, so one's decode and encode overlap another's render.
- **Instant culling.** Opening a raw shows its embedded camera preview or cached render within ~0.1 s while the
full render follows (~0.2–0.5 s for 24 MP). The next and previous photos are prepared in the background, so stepping
through a shoot takes ~50 ms per photo.
@@ -281,18 +297,20 @@ lightcraft --control 7980 ~/Pictures/trip
## Feature status
-LightCraft is young and moving fast. **Where we honestly stand** (details in the [roadmap](ROADMAP.md#where-we-stand)):
+LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (details in the [roadmap](ROADMAP.md) and
+[docs/target-app-parity.md](docs/target-app-parity.md)):
-- **By feature count we're at ~79%** of Lightroom (core features 98%), tracked row by row in
- [docs/parity.md](docs/parity.md).
-- **As a day-to-day Lightroom replacement we're nearer 60–70%.** It's great for JPEG/DNG and most Nikon / Sony /
- older-Canon raws on one machine.
+- **By feature count we're at ~81%** of Lightroom (core features 98%), tracked row by row in
+ [docs/parity-checklist.md](docs/parity-checklist.md).
+- **As a day-to-day Lightroom replacement we're nearer 61% for the whole product, ~49% for a typical working photographer and ~64% for
+ casual use.** It's great for JPEG/DNG and most Sony / Panasonic /
+ Fujifilm / Nikon / older-Canon raws on one machine; see [docs/raw-parity.md](docs/raw-parity.md) for your camera.
- **The biggest gaps:**
- - **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCE-7M4, X-H2S and X-T4 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix;
+ - **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCE-7CR, ILCE-7M4, X-H2S and X-T4 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix;
- **compressed Olympus raws and unsupported CR3 variants:** these use embedded JPEG previews when present. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md);
- **AI masks and denoise:** subject and sky selection are classical heuristics;
- - **HDR, video and the Classic Print / Book / Map modules.**
-- **What's next:** see [where we're going](ROADMAP.md#where-were-going).
+ - **on-screen HDR display, video and the Classic Print / Book / Map modules.**
+- **What's next:** see [the ranked gaps](docs/gaps.md) and [the roadmap](docs/roadmap.md).
| Area | Status |
|---|---|
@@ -302,12 +320,13 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
| Masking: brush, linear, radial, luminance/colour range, add/subtract/intersect | ✅ (AI subject/sky use classical heuristics for now) |
| Crop, straighten tool + auto straighten, flip, rotate, aspect ratios, overlays | ✅ |
| Profiles (Color, Neutral, Vivid, Landscape, Portrait, Monochrome: our own looks), presets, versions, history, copy/paste/sync settings | ✅ |
-| Camera colour: DNG files use their own matrices | ✅ DNG · 🟡 own Sony/Fujifilm profiles; measured calibration database missing |
+| Camera colour: DNG files use their own matrices | ✅ DNG · 🚧 own Sony/Fujifilm profiles; measured calibration database missing |
| Native macOS menu bar (generated from the command registry), control channel + every widget addressable, headless UI snapshots | ✅ |
-| RAW: DNG, CR2, CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), ARW, NEF (uncompressed + lossless/lossy compressed), Fujifilm RAF (uncompressed + lossless/lossy compressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed); embedded previews for every format incl. CR3 | 🟡 · CR3, compressed ORF decode ⬜ |
-| Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise, Super Resolution ⬜ |
+| RAW: DNG, CR2, CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), ARW, NEF (uncompressed + lossless/lossy compressed, incl. lossy after split), Fujifilm RAF (uncompressed + lossless/lossy compressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed, 12.8 bpp), Samsung SRW (uncompressed); embedded previews for every format incl. CR3 | 🚧 · other CR3 variants, compressed ORF / SRW and Nikon HE NEF decode ⬜ ([raw parity](docs/raw-parity.md)) |
+| Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise 🚧 (Bayer, user-installed weights), Super Resolution ⬜ |
| Remove / Heal / Clone spots (auto source), Visualize Spots, Red Eye and Pet Eye (auto pupil detection, catchlight) | ✅ · content-aware fill, spot pin editing 🚧 |
-| Export: JPEG / PNG / TIFF / WebP / AVIF / DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | ✅ · HDR export ⬜ |
+| Export: JPEG / PNG / TIFF / WebP / AVIF / DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | ✅ |
+| HDR: HDR editing with a headroom limit, SDR rendition, Visualize HDR; export as ISO 21496-1 gain map JPEG, PQ AVIF or 32-bit float TIFF | ✅ · HDR display ⬜ |
| Library persistence (crash-safe op log + snapshots, background compaction, failed saves reported), disk thumbnail cache | ✅ |
| Import: Add in place / Copy / Move, rename and folder templates, devices, duplicate detection, watched folders; Local folder browsing | ✅ |
| MCP server (headless or live app, persistent libraries), CLI, control channel | ✅ |
@@ -315,7 +334,7 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
| Optics (distortion, vignetting, auto + manual CA, defringe, lens corrections embedded in DNG files and Panasonic / Leica RW2 / RWL distortion data), Geometry (transforms, Constrain Crop), Upright (Auto/Level/Vertical/Full/Guided) | ✅ · camera lens profiles (our own) ⬜ |
| Photo Merge: HDR (auto-align, deghost), Panorama (spherical/cylindrical/perspective, boundary warp, auto crop), HDR Panorama → DNG | ✅ |
| GPU pipeline (wgpu compute, CPU-exact within 1/255), CPU fallback on device limits / errors | ✅ · WebGPU in the browser 🚧 |
-| AI: segmentation masks, AI denoise, super resolution, faces; HDR editing; video | ⬜ (see [roadmap](ROADMAP.md#where-were-going)) |
+| AI: segmentation masks, AI denoise, super resolution, faces; video | 🚧 (see [gaps](docs/gaps.md)) |
| Web build (same UI in the browser via WASM): persistent library in OPFS/IndexedDB, Web Worker rendering, export downloads | ✅ · WebGPU, Safari/Firefox testing 🚧 |
✅ works today · 🚧 in progress · ⬜ not started
@@ -353,7 +372,7 @@ CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft
Without it LightCraft builds and runs the same, but Chinese and Japanese text have no glyphs. Fonts are never committed to this
repo; see [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md).
-**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Deutsch) or **Settings → General →
+**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français) or **Settings → General →
Language**; the choice applies immediately and persists. See [docs/localization.md](docs/localization.md).
**Logs:** the desktop app writes its log to standard error and to `logs/lightcraft.log` in its settings folder
@@ -443,7 +462,9 @@ Questions, ideas or a bug you'd like to talk through first? Bring them to [Disco
| arm64 (Snapdragon and other ARM PCs) | `lightcraft--windows-arm64.msi` | `lightcraft--windows-arm64-portable.zip` |
| x86 (32-bit) | `lightcraft--windows-x86.msi` | `lightcraft--windows-x86-portable.zip` |
-Installers and executables are code-signed.
+Installers and executables are code-signed. The installer asks where to install (`C:\Program Files\LightCraft` by
+default; upgrades stay in the folder you chose) and ends on a page confirming LightCraft was installed, with an
+option to start it. For unattended installs: `msiexec /i lightcraft--windows-x64.msi /qn INSTALLFOLDER="D:\Apps\LightCraft\"`.
### macOS
diff --git a/ROADMAP.md b/ROADMAP.md
index 5e793e8b..c1cfbcf5 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -1,234 +1,166 @@
# LightCraft roadmap
-Milestones toward full Adobe Lightroom parity (cloud Lightroom first, then every Lightroom Classic module), with wall-clock
-estimates for continuous (24/7) agent-driven development with 4–6 parallel agents. Estimates are calibrated on the sibling
-projects (DrawCraft reached its first four milestones in ≈ 4½ h) and are revised as milestones land.
+**Stage: alpha** · next: beta, ~14 points and ~340–600 h away
-## Where we stand
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (full re-measure against Lightroom Classic 15.5 and the installed Lightroom 9.6; restructured to the progress-docs standard) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
-*Honest assessment, 2026-10-08. Agents: read this before picking work. The checklist in
-[`docs/parity.md`](docs/parity.md) counts features that **exist**; this section is about whether a photographer can
-**switch** from Lightroom. Update it when a gap below closes.*
+One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work
+from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in
+[docs/target-app-parity.md](docs/target-app-parity.md).
-**In one line:** the checklist says **79.7%** (P0 98.2%, P1 95.6%, P2 41.6%), but measured by whether a working
-photographer could replace Lightroom without noticing, we are at roughly **60–70%**. The remaining gap is mostly
-**quality of results and camera coverage**, not missing buttons.
+## Headline numbers
-Caveat on the checklist: ✅ is set by whoever lands a feature, and nobody has systematically checked rows against
-Lightroom's behaviour or output. Bugs keep turning up in ✅ areas (CR2 colour-filter phase on some Canon models #85,
-duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
-
-### By dimension
-
-| Dimension | Estimate | What's true today | Biggest gaps |
-|---|---:|---|---|
-| **Feature checklist** | 79.7% | P0 core and P1 nearly complete: import (Add / Copy / Move, templates, devices), library, grid/loupe/compare/survey, every Edit slider, curves, colour grading, masking tools, crop/Upright, heal/clone, presets/profiles, versions/history, sync, export, menus, shortcuts | P0: full-resolution zoom detail; P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim |
-| **RAW coverage** (formats people shoot) | ~55% | DNG (all kinds), CR2, CR3 lossless Bayer and version 0x100/0x200 C-RAW (M50/R100/R8 pixel-exact corpus), ARW, NEF (uncompressed + Huffman lossless/lossy), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans), uncompressed ORF, RW2 / RWL / Panasonic RAW (every raw format, checked on 178 files from 118 Panasonic and Leica bodies), PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed ORF, Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Per-model verification is thin (~55 corpus files vs >1,000 models) |
-| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF, RW2, RAF, CR3, CR2 and PEF have guarded camera-JPEG colour estimates, with bundled ILCE-7M4, X-H2S and X-T4 profiles (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
-| **AI & computational** | ~15–20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics | Real segmentation masks (subject, sky, people, objects, landscape, depth), AI denoise, super resolution, lens blur, generative remove, faces/people, natural-language search. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) |
-| **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering |
-| **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic) | **Map view, Book, Print, Slideshow module, Web, publish services**: ~40 tracker rows ⬜ |
-| **HDR & video** | 0% | | HDR edit/display/export; video play/trim/edit/export |
-| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; English, Simplified/Traditional Chinese, Japanese, Brazilian Portuguese and German UI (see docs/localization.md); remaining technical errors; Traditional Chinese uses the Simplified Chinese font until craft-fonts has a TC face; accessibility partial; headless UI tests time out under machine load |
-
-### By kind of user
-
-| User | Readiness | What blocks them |
+| | Value | Kind |
|---|---:|---|
-| JPEG / DNG shooter, single machine | ~85% | Fidelity polish, AI masks |
-| Nikon / Sony / Panasonic / older-Canon raw shooter | ~65% | Camera colour fidelity and coverage (ARW, NEF and RW2 preview estimates are only a starting point); Panasonic compacts and kit zooms also miss their embedded distortion correction |
-| Fujifilm raw shooter | ~65% | Uncompressed and lossless/lossy compressed RAF decoded; camera colour calibration remains missing. Verified on 13 bodies, including 14/16-bit GFX; see `docs/raf-compression.md` |
-| Canon CR3 / Olympus compressed-raw shooter | ~45% | CR3 lossless Bayer and version 0x100/0x200 C-RAW develop from sensor data (M50, R100, R8 verified pixel for pixel); other CRX variants and compressed ORF open as embedded JPEG previews. Camera colour and broader model verification remain |
-| Lightroom Classic power user | ~45% | Print, Book, Map, publish, tethering |
-| Relies on AI (masks, denoise) | ~25% | Object / Describe masks via optional SAM 3 (download not yet hosted); no AI denoise |
+| **Feature breadth** (checklist) | **81.1%** of 517 rows (P0 98.0%, P1 95.1%, P2 46.9%) | measured: [parity-checklist.md](docs/parity-checklist.md) recount |
+| **Feature breadth** (weighted by use) | **~77%** | estimated |
+| **Ready for real work** (full target) | **~61%** (56–66%) | estimated: weighted sum over the dimensions |
+| **Mainstream practitioner** | **~49%** (44–54%) | estimated |
+| **Essentials user** | **~64%** (59–69%) | estimated |
+| Remaining to **beta** | **~340–600 h** (Opus 5.5 agent hours, ~60% parallel) | estimated |
+| Remaining to **full parity** | **~1,000–1,800 h** (~70% parallel) | estimated |
-## Where we're going
+Each audience with its own hours (Opus 5.5 agent wall-clock hours to ~95%):
-Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/parity.md) → *Top gaps*.
+| Audience | Ready | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
+|---|---:|---:|---|
+| Full target (ready for real work) | **~61%** | **1,000–1,800 h** (~70% parallelizes) | raw colour, lens and camera data; AI models; Classic output modules; localization; plug-in ecosystem |
+| Mainstream practitioner | **~49%** | **450–840 h** (~60% parallelizes) | camera colour and render fidelity; CR3 / ORF / HE NEF; lens database; subject / sky masks; Lightroom XMP exchange; stability |
+| Essentials user | **~64%** | **160–300 h** (~50% parallelizes) | default raw look for popular cameras; launch stability; basic-slider feel; opening CR3 / HEIC files people send |
-1. **Camera colour calibration of our own** (LR-PROF-CAMERACOLOR, P0): fit each camera to its own embedded JPEG, use
- matrices the files carry themselves, then chart shots. Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2
- and Pentax PEF have the file-local fit (matrix + tone curve from their own JPEG; CR2 and PEF since issue #310, not yet
- checked on the corpus); then ORF, then validate fidelity.
-2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): Remaining **unverified CR3 CRX** variants, compressed ORF, NEF
- lossy-after-split, sRAW. Decided 2026-10-05: write our own decoders from prose descriptions (never decoder source,
- no LGPL dependency); compressed NEF (#86) is the template.
-3. **Verified camera coverage** (LR-IMP-CAMERA-COVERAGE, P0): a CC0 sample per model in the corpus, each decoded and
- checked for plausible colour; fix per-model bugs (#85).
-4. **Render fidelity suite** (LR-BEHAV-RENDER-FIDELITY, P1): measure our output against Lightroom on the same CC0 raws
- (references stay in the local `plan/`), then tune against the numbers.
-5. **Lens profiles of our own** (LR-EDIT-OPTICS-PROFILE, P1).
-6. **AI model strategy** (maintainer decision): which permissively licensed models (or our own training) for
- segmentation masks and denoise; then pure-Rust inference. Unblocks M12 and Enhance. Object / Describe masks now
- run Meta's SAM 3 in pure Rust (optional, user-consented download under the non-OSI SAM License; CDN mirrors still
- to be set up, docs/ai-masks.md); Subject / Sky / People and Enhance are still open.
-7. **Then:** HDR (Q), the Classic output modules (Print first, then Map view, Book, Slideshow), video (R), localisation
- and accessibility.
+The gap between breadth and readiness is quality and coverage, not missing buttons: camera colour is
+estimated rather than measured, CR3 decodes on three verified Canon bodies, compressed ORF and HE NEF
+open as previews, rendering is tuned by eye, AI masks are heuristics, there is no lens database, and
+tests run in CI on FreeBSD only. **Why alpha:** core workflows run end to end, but readiness is below
+the ~75% beta bar (it is above the ~40% alpha bar) and Lightroom's main inputs, raw files, still have blocking gaps (Canon, ~30% of
+raw users, decodes CR3 on three verified bodies). It passes the alpha gate: all six core workflows
+(import, cull, develop, save and reopen, batch looks, export) work end to end on macOS
+([docs/roadmap.md](docs/roadmap.md#alpha-gate)). The three readiness numbers and their weights and discounts are in
+[target-app-parity.md](docs/target-app-parity.md#ready-for-real-work-mainstream-practitioner-and-essentials-user).
+Hours are calibrated on this repo's PR history; see
+[target-app-parity.md](docs/target-app-parity.md#how-hours-are-calibrated).
-Already closed in the week of 2026-10-03: compressed NEF (#10), Move import (#29), per-library smart-preview folder,
-Local roots / cleanup, grid and catalog performance at 85k photos (#35, #37), failed-save reporting, GPU export
-hardening (#78), copyright metadata (#51), GPX geotagging (#60), import tag help and folder templates (#31, #32).
+## By dimension
-Image navigation now supports native pinch zoom and two-finger pan in Detail, Compare and Reference views.
-The whole-image preview still has a configurable size cap (2,560 px default), so 100% on larger photos does not
-yet guarantee original-pixel detail; visible-region rendering remains a gap (LR-VIEW-ZOOM).
+| Dimension | Ready | Breadth | Remaining | Doc |
+|---|---:|---:|---:|---|
+| Features | ~61% | ~77% (checklist 81.1%) | 600–1,100 h | [target-app-parity](docs/target-app-parity.md#features) |
+| ↳ RAW cameras, colour, lens profiles | ~56% | 81% of 1,446 Adobe models decode, 16% verified | 255–480 h | [raw-parity](docs/raw-parity.md) |
+| UI/UX fidelity | ~70% | ~85% | 40–70 h | [ui-parity](docs/ui-parity.md) |
+| File formats (non-raw) | ~70% | ~85% | 30–55 h | [file-format-parity](docs/file-format-parity.md) |
+| Hardware | ~40% | ~50% | 40–80 h | [hardware-parity](docs/hardware-parity.md) |
+| Localization | ~52% of the 12 key languages | 7 of 12 present | 130–210 h (+145–255 h new scripts) | [localization-parity](docs/localization-parity.md) |
+| Performance | ~70% | ~80% | 30–50 h | [target-app-parity](docs/target-app-parity.md#by-dimension) |
+| Stability | ~60% | — | 30–60 h | [target-app-parity](docs/target-app-parity.md#by-dimension) |
+| Platforms | ~60% | macOS, Windows, Linux, FreeBSD, web | 25–45 h | [target-app-parity](docs/target-app-parity.md#by-dimension) |
+| Ecosystem / plug-ins | ~15% | ~25% | 40–80 h | [target-app-parity](docs/target-app-parity.md#by-dimension) |
+| AI features | ~20% | ~30% | 120–220 h (overlaps features) | [target-app-parity](docs/target-app-parity.md#ai) |
-## Milestones
+## Features
-**Status legend:** ✅ done · 🚧 in progress · ⬜ not started
-
-| # | Milestone | Scope (summary) | Estimate (h) | Status |
-|---|---|---|---|---|
-| M0 | Skeleton + visual shell | workspace, xtask CI + layering, geom/color/raster, develop model, pipeline v0, catalog v0, engine commands, Lightroom-look UI (grid, loupe, filmstrip, Edit panel), control channel, MCP, web build | 3–5 | ✅ |
-| M1 | Library core | import (JPEG/PNG/TIFF/WebP), EXIF/XMP, persistent catalog (op log + snapshots), albums, ratings/flags/labels, filter/search/sort, thumbnail cache, 100k-photo grid | 6–10 | ✅ (100k-photo grid scale test pending) |
-| M2 | Pipeline v1 (quality) | WB temp/tint, profiles, local tone mapping (highlights/shadows), curves, HSL, point colour, colour grading, texture/clarity/dehaze, vignette, grain, B&W, auto tone/WB, histogram, before/after | 10–15 | ✅ (look tuning vs our references ongoing) |
-| M3 | RAW I | TIFF/DNG (LJ92, deflate, tiles, opcodes), demosaic (AHD/PPG/bilinear), highlight recovery, DNG colour model, CR2, NEF, ARW, embedded previews | 10–15 | 🚧 (DNG, CR2, ARW, NEF uncompressed + Huffman lossless/lossy, embedded previews ✅; NEF lossy-after-split ⬜) |
-| M4 | Crop, geometry, optics | crop tool + overlays, straighten, Upright (auto/level/vertical/full/guided), manual transforms, CA, defringe, manual lens corrections | 6–10 | ✅ |
-| M5 | Performance | source pyramids, wgpu compute pipeline (CPU oracle), draft/full renders, prefetch, budgets (16 ms slider updates on 24 MP) | 10–15 | 🚧 (stage cache, source pyramid, wgpu pipeline, prefetch, memory budget ✅; colour NR at half resolution, GPU histogram ⬜) |
-| M6 | Masking | brush, linear/radial gradients, colour/luminance/depth range, add/subtract/intersect/invert, all local adjustments, masks panel | 8–12 | 🚧 (brush/linear/radial/colour/luminance range, add/subtract/intersect, masks panel ✅; depth range, AI masks ⬜) |
-| M7 | Detail | sharpening + masking preview, luminance/colour NR, Denoise, Raw Details, Super Resolution | 6–10 | 🚧 (sharpening, luminance/colour NR ✅; AI Denoise, Raw Details, Super Resolution ⬜) |
-| M8 | Heal / Remove | content-aware remove (PatchMatch), heal, clone, brush spots, visualize spots, red/pet eye | 6–10 | 🚧 (heal, clone, auto source, visualize spots, red/pet eye ✅; PatchMatch remove ⬜) |
-| M9 | Presets, profiles, versions, sync | preset browser + amount, create/import presets, profile browser, versions, history, copy/paste/sync settings | 5–8 | ✅ |
-| M10 | Export & share | export dialog (JPEG/PNG/TIFF/DNG/AVIF/JXL/original), sizing, sharpening, metadata, watermark, naming, batch jobs, XMP sidecars, HDR export | 6–10 | 🚧 (all formats incl. DNG/original, sizing, presets, background jobs ✅; JXL encode, HDR export ⬜) |
-| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (CR3 lossless CRX and C-RAW, RAF uncompressed and lossless/lossy compressed, RW2 (every raw format), PEF, ORF uncompressed ✅; **camera colour calibration** 🚧 (guarded ARW, NEF, RW2 and RAF preview fitting; measured database still missing), CR3, compressed ORF ⬜) |
-| M12 | AI & smart features | subject/sky/background/people/object masks, semantic search, faces/People (permissively licensed models, pure-Rust inference) | 20–40 | ⬜ |
-| M13 | Merge | HDR merge (deghost), panorama (projections, boundary warp, fill edges), HDR panorama | 10–15 | ✅ |
-| M14 | Video | import/playback/trim via FilmCraft crates, global edits + presets on video, video export | 6–10 | ⬜ |
-| M15 | Classic modules | Map, Book, Slideshow, Print, Web; smart collections, stacks, virtual copies, publish services, tethering | 25–40 | 🚧 (smart albums, stacks, virtual copies, compare/survey ✅; Map/Book/Slideshow/Print/Web ⬜) |
-| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; English/Chinese (Simplified, Traditional)/Japanese/Brazilian Portuguese/German localisation 🟡; accessibility and further locales ⬜) |
-
-## Parity estimate (feature count updated 2026-10-08; effort estimate from 2026-10-02)
-
-**By feature count** — from `docs/parity.md` (one row per Lightroom feature, menu item and shortcut; `cargo xtask
-parity` prints this line on every run, so it stays current):
-
-| Scope | Weighted completion | Rows | 2026-10-02 |
+| Area | Ready | Breadth | Remaining |
|---|---:|---:|---:|
-| P0 (core) | **98.2%** | 200 | 98.7% |
-| P1 (important parity) | **95.6%** | 149 | 94.4% |
-| P2 (later / AI / niche, incl. Classic modules) | **41.6%** | 160 | 29.4% |
-| **All in-scope rows** | **79.7%** | 509 | 75.6% |
+| Import and library | 80% | 90% | 25–45 h |
+| RAW decoding and camera coverage | 76% | 81% | 120–220 h |
+| Colour, camera profiles, render fidelity | 35% | 65% | 80–150 h |
+| Global develop adjustments | 70% | 93% | 30–60 h |
+| Optics, lens profiles, crop and geometry | 50% | 80% | 40–80 h |
+| Masking | 45% | 67% | 60–110 h |
+| Remove, heal, Enhance | 30% | 45% | 70–130 h |
+| Presets, profiles, versions, sync | 85% | 95% | 8–15 h |
+| Export | 75% | 89% | 15–30 h |
+| Merge and HDR | 60% | 85% | 20–35 h |
+| Views, compare, zoom, culling | 70% | 90% | 15–30 h |
+| Classic output modules (Map, Book, Slideshow, Print, publish, tethering) | 10% | 15% | 90–150 h |
+| Video | 0% | 0% | 25–45 h |
-The 2026-10-05 count includes three new 🟡 rows that make quality gaps visible (camera colour calibration, verified
-camera coverage, render fidelity), which is why P0 dipped slightly.
+Weights and evidence: [target-app-parity.md](docs/target-app-parity.md#features).
-✅ counts 1, 🟡 ½, ⬜ 0; out-of-scope rows (cloud sharing, Adobe accounts…) are left out.
+## Languages
-**By remaining effort** — rows are not equal: a shortcut and the whole Book module are one row each, and what is
-left is the heavy part (AI, video, Classic output modules, undocumented raw codecs). Remaining work in **Opus agent-hours**
-(one agent working continuously; calibrated on this project — a single lead agent closed ≈ 45 tracker rows of
-UI/feature work in ≈ 5 h on 2026-10-01, and ≈ 60 more (preset import incl. `.lrtemplate` / DNG / zip and masks, smart-album
-rule editor, auto sync, keyword and label sets, import options and DNG conversion, smart previews, external-editor round
-trip, slideshow, auto import…) in ≈ 12 h on 2026-10-02; four to six parallel agents built M0–M13 in ≈ 25 active hours):
+Measured from `crates/ui-egui/locales` (share of 2,304 catalog messages translated); none is `full`
+while lower-layer errors stay English. Detail: [localization-parity.md](docs/localization-parity.md).
-| Work package | Tracker rows | Agent-hours | Risk |
+| Language | Code | Translated | Status |
|---|---|---:|---|
-| Remaining P0/P1 UI and library features (folder rename/move, keyword painter, people view…) | ≈ 8 | 5–10 | low |
-| Raw codecs: CR3 (CRX), compressed ORF, NEF lossy-after-split, HEIC/AVIF decode, JPEG XL DNG | LR-IMP-FORMATS | 40–80 | **high** — clean-room black-box analysis, no permissive specs |
-| Lens-profile database of our own (calibration targets, fitting, data) | LR-EDIT-OPTICS-PROFILE | 15–30 | data collection |
-| Video: playback, trim, edits, export (pure-Rust decode, ideally shared with FilmCraft) | R. Video | 20–40 | medium |
-| AI: subject / sky / background / people / object masks, object-aware remove, AI denoise, super resolution, lens blur, people & faces, natural-language search, culling | ≈ 30 | 80–150 | **high** — permissively licensed weights, pure-Rust inference, maybe training |
-| HDR editing, display, visualisation and export | Q. HDR, LR-EXP-HDR | 15–25 | medium |
-| Classic modules: Map, Book, Slideshow module, Print, publish services, tethering, soft proofing | ≈ 50 | 60–100 | medium (large, well-understood) |
-| Smaller P2 items: Enhance dialog, export to Photos, help / what's new, accessibility, localisation, sidecar variants | ≈ 15 | 12–25 | low |
-| Look tuning, performance budgets (colour NR at half resolution on CPU + GPU, 100k-photo library), packaging, hardening | — | 25–45 | medium |
-| **Total remaining** | | **≈ 270–505** | |
+| English | en | 100% | full (source) |
+| Simplified Chinese | zh-hans | 87% | partial |
+| Spanish | es | 86% | partial |
+| Hindi | hi | 0% | none (no Devanagari shaping) |
+| Arabic | ar | 0% | none (no RTL / shaping) |
+| French | fr | 88% | partial |
+| Portuguese (Brazil) | pt-br | 85% | partial |
+| Indonesian | id | 0% | none |
+| Japanese | ja | 87% | partial |
+| German | de | 91% | partial |
+| Korean | ko | 0% | none (no Hangul face) |
+| Vietnamese | vi | 0% | none |
-Spent so far ≈ 105–145 agent-hours, so **by effort the project is ≈ 20–35% of the way to complete Lightroom + Classic
-parity**, and ≈ 45–60% of the way for cloud-Lightroom parity without AI and the Classic modules (remaining ≈ 130–255 h,
-most of it the raw codecs, lens data, video and HDR).
+Also shipped: Traditional Chinese (87%), Russian (74%), Ukrainian (99%).
-**Wall clock:** ≈ 270–505 h for one agent working alone; with 4–6 parallel agents (≈ 70% parallel efficiency, merges and
-CI under load cost the rest) **≈ 65–125 h of continuous work**. The AI package and the raw codecs carry most of the
-uncertainty: they can finish faster if suitable permissive models / documentation turn up, or stall on licensing.
+## Upcoming
-## Totals
+Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/gaps.md](docs/gaps.md).
-Remaining from 2026-10-02 evening (see *Parity estimate* above for the breakdown):
+1. Camera colour of our own: measured calibration for the popular models (50–90 h)
+2. CR3 for every Canon body (25–45 h)
+3. Render-fidelity suite against Lightroom, then tuning (30–60 h)
+4. Full-resolution zoom (8–15 h)
+5. AI masks: Subject / Sky / People (40–70 h + model decision)
+6. Compressed ORF and HE NEF (28–55 h)
+7. Lens profile database of our own (40–80 h)
+8. Tests in CI on macOS, Windows and Linux (6–12 h)
+9. Per-model raw verification (20–35 h)
-| Target | Remaining agent-hours | Wall clock, 4–6 parallel agents |
-|---|---:|---:|
-| Cloud-Lightroom parity without AI or Classic modules | ≈ 130–255 h | ≈ 35–65 h |
-| Full parity incl. AI, video and the Classic modules | ≈ 270–505 h | ≈ 65–125 h |
+## Progress log
-The milestone estimates in the table above were made before work started and are kept for calibration (M0–M13 took
-≈ 25 active hours with parallel agents against an estimate of ≈ 110–170 h for those milestones).
+- 2026-10-10 (RAW): RAW measured model by model against Adobe's 1,446-model list (234 verified, 943
+ unverified, 233 preview-only, 36 unsupported; ~84% of photographers' cameras decode, ~40% on a
+ verified body). Full ~61%, mainstream ~49%, essentials ~64%; beta ~14 points away.
+- 2026-10-10 (final): full ready is a weighted sum over the dimensions (~59%; method aligned with
+ the standard); mainstream practitioner ~51% (450–840 h), essentials user ~63% (160–300 h);
+ alpha gate passes.
+- 2026-10-10: full re-measure for the progress-docs standard: stage alpha, ready ~55%, checklist
+ 81.1%; new docs (target-app-parity, gaps, roadmap, architecture, raw / file-format / hardware / UI
+ / localization parity); `docs/parity.md` renamed `docs/parity-checklist.md`. Landed today: Nikon
+ lossy-after-split NEF (#633), ORF 12.8 bpp (#651), HDR gain map JPEG / PQ AVIF / float TIFF export
+ (#646), Lightroom-like Auto (#628), activity stack.
+- 2026-10-09: Sony ILCE-7CR camera colour: bundled profile from 125 photos; on 26 withheld photos mean
+ ΔE vs the camera JPEG 4.07 → 3.29. HEIC in release builds (#609), Samsung uncompressed SRW,
+ contact-sheet PDF export, Ukrainian and French UI, release v0.5.0.
+- 2026-10-08: independent Rust CR3 lossless and version 0x100/0x200 C-RAW decoding, six exact
+ full-sensor regressions on M50 / R100 / R8; guarded CR3 camera-JPEG colour fitting
+ ([cr3.md](docs/cr3.md)).
+- 2026-10-08: Fujifilm lossless / lossy compressed RAF: pure-Rust striped decoder, full sensor arrays
+ verified on 17 CC0 and 7 supplied files (13 bodies). Fujifilm camera colour: bundled X-H2S / X-T4
+ profiles from 201 / 545 photos; on 150 withheld files mean ΔE X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34.
+ AI RAW denoise (#384), Lightroom catalog import, German, Spanish and Russian UI.
+- 2026-10-07: Panasonic RW2 / Leica RWL / Panasonic RAW decode in every raw format, established on
+ 178 CC0 files from 118 bodies; RW2 file-local camera look (140 of 174 files). Faces: recognition in
+ pure Rust and People view.
+- 2026-10-03 – 10-05: community PRs (#42–#51, #60) and 25+ issues: compressed NEF, Move import,
+ folder templates, Local roots and cleanup, 85k-photo grid and catalog performance, background
+ compaction, failed-save errors, GPU export hardening. Added the honest *Where we stand* assessment
+ and tracker rows for camera colour, camera coverage and render fidelity.
+- 2026-10-02: parity estimate added (`cargo xtask parity` prints weighted completion); milestones
+ refreshed.
+- 2026-10-01: RAW II formats: RAF (uncompressed Bayer + X-Trans), RW2 (packed), PEF (incl. Huffman),
+ ORF (uncompressed); embedded previews for every container incl. CR3; raw corpus test with 37 CC0
+ samples.
+- 2026-09-30 (later): app running with the full Lightroom-style UI; pipeline v0; DNG / CR2 / ARW;
+ README showcase. ≈ 8 h elapsed.
+- 2026-09-30: roadmap created; M0 in progress; research docs (Lightroom reference, Rust imaging
+ ecosystem) complete.
-## Risks that coding hours alone don't retire
+## Revision history
-- **AI features** (subject/sky/people masks, generative remove) need model weights with licences we can ship; classical
- fallbacks first. No permissively licensed sky-segmentation or raw-denoise model was found — we may need to train our own.
-- **Camera colour and lens data** is a data problem: we never use Adobe's matrices, DCPs or LCPs. DNG-embedded data first,
- then our own calibration; long-tail camera/lens coverage grows over time.
-- **Raw-format sources:** decided 2026-10-05: decoders are written from *prose* format descriptions (even ones
- published alongside GPL code); decoder source is never read. Still open: freedom-to-operate review for local
- Laplacian filters, PatchMatch and HEVC (HEIC).
-- **Look parity** with Adobe's default rendering is tuned by eye today; the planned fidelity suite (LR-BEHAV-RENDER-FIDELITY)
- turns it into measured comparisons against local-only Lightroom references.
-
-## Raw format coverage and known gaps
-
-Sony ILCE-7M4 downsized lossless ARW (subsampled YCbCr 4:2:0 / 4:2:2 tiles) now decodes to linear RGB;
-private 3:2 & 4:3 examples verified through native rendering & full-resolution 16-bit export.
-
-CR3 lossless Bayer and version 0x100/0x200 C-RAW sensor decoding is verified pixel-exactly on Canon M50, R100 and R8; any other CR3, or one that fails to decode, opens from its embedded JPEG as before. See [`docs/cr3.md`](docs/cr3.md) for coding limits, reference provenance and colour limitations.
-
-Decoded (CC0 corpus from raw.pixls.us, `cargo xtask corpus --download`, `crates/raw/tests/corpus.rs`): DNG (uncompressed,
-LJ92, lossy JPEG / Smart Previews, Deflate, float, linear), CR2, ARW (uncompressed, ARW2, LJ92; as-shot white balance and black level of pre-2017 bodies from the enciphered
-maker-note `Tag2010` and the encrypted `SR2SubIFD`, both recovered by black-box analysis, `crates/raw/src/vendor/arw.rs`), NEF/NRW uncompressed and Huffman-compressed (lossless, lossy type 1/2, 12/14-bit), RAF uncompressed and lossless/lossy compressed (Bayer and
-X-Trans, 14/16-bit real-file verification), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed formats 4 and 6, the prefix-coded strips of format 8,
-packed formats 2, 5 and 7, the 16-bit words of the 2005–2007 bodies; all recovered by black-box analysis of 178 CC0 files from
-118 bodies, `crates/raw/src/vendor/rw2.rs`), PEF (uncompressed and Huffman), ORF uncompressed (16-bit and 12-bit packed). Supported containers yield their embedded JPEG preview when present (including CR3). The engine uses it for RAW variants it cannot decode yet; HEVC-only CR3 previews are not decoded.
-
-Not decoded yet — preview only (no permissively licensed description; black-box analysis incomplete):
-- **Nikon "lossy after split" NEF** (non-zero split row in maker note `0x0096`, e.g. some D3400/D5000/D5200/D5500/D5600
- files): rows above the split decode with the regular lossy table; from the split row on a different code is used
- that our black-box analysis has not recovered yet (none of the four regular tables fits, also not with byte
- alignment or reset predictors at the split row). The other Nikon Huffman variants are decoded (`crates/raw/src/vendor/nefc.rs` documents the analysis).
-- **Olympus compressed ORF**, **Canon CR3 unverified CRX variants** (M11.1), **Canon sRAW/mRAW**, lossy DNG.
-
-**Camera colour matrices:** ARW, NEF, RW2, RAF, CR3, CR2 and PEF files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
-`matrix_is_fallback`) with the file's as-shot white-balance multipliers. Clean sources to evaluate next: manufacturer
-matrices stored in the files themselves (Olympus ImageProcessing `ColorMatrix`, Pentax equivalents; Panasonic RW2 files carry none) and our
-own chart-based calibration (M11.4). Adobe matrices are never used.
-
-## Lightroom Classic catalog migration
-
-File → Import Lightroom Catalog… reads `.lrcat` plus committed WAL pages directly through a pure-Rust
-SQLite reader. Originals stay in place; ratings, flags, labels, XMP metadata, hierarchical keywords,
-collections/sets, virtual copies and supported develop settings migrate into LightCraft. Existing edits
-are preserved by default, reimport identities are persistent, and source settings/history/snapshots are
-archived before catalog mutation. Missing originals remain available for relinking.
-
-Rendering is approximate: unsupported Adobe profiles/AI/process settings are reported and archived;
-history/snapshots remain source data, and smart collections import current membership. The Lightroom
-database is read-only; LightCraft owns subsequent edits. See `docs/lightroom-catalog-import.md`.
-
-## Log
-- 2026-09-30: roadmap created; M0 in progress; research docs (Lightroom reference, Rust imaging ecosystem) complete.
-- 2026-09-30 (later): app running with the full Lightroom-style UI; pipeline v0; DNG/CR2/ARW; README showcase. ≈ 8 h elapsed.
-- 2026-10-02: parity estimate added (xtask parity prints weighted completion); milestone statuses refreshed.
-- 2026-10-08: Fujifilm camera colour: guarded JPEG matching and relative WB, plus bundled X-H2S/X-T4 profiles from 201/545 photos. On 150 withheld files the pooled profiles improve mean ΔE slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); dark X-H2S samples 8.74 → 2.25. Measured calibration remains open.
-- 2026-10-08: Fujifilm lossless/lossy compressed RAF: pure-Rust striped decoder; full sensor arrays verified against a black-box reference on 17 CC0 files and 7 supplied X-H2S / X-T4 files (13 camera bodies, 14/16-bit Bayer and X-Trans). Camera colour calibration remains a separate gap.
-- 2026-10-01: RAW II formats: RAF (uncompressed Bayer + X-Trans), RW2 (packed), PEF (incl. Huffman), ORF (uncompressed); embedded previews for every container incl. CR3; raw corpus test with 37 CC0 samples.
-- 2026-10-03 – 10-05: community PRs (#42–#51, #60) and 25+ issues landed: compressed NEF, Move import, folder templates,
- Local roots and cleanup, 85k-photo grid and catalog performance, background compaction, failed-save errors, GPU
- export hardening, clippy 1.99. Added the honest *Where we stand* assessment and *Where we're going* priorities; added
- tracker rows for camera colour, camera coverage and render fidelity.
-- 2026-10-07: Panasonic RW2 / Leica RWL / Panasonic RAW decode in every raw format (compressed 4/6/8, packed 2/5/7,
- 16-bit words), black levels, in-camera aspect crops and mapped-out defects established on 178 CC0 files from 118
- bodies; RW2 gets the file-local camera look (accepted on 140 of 174 files with a preview).
-- 2026-10-08: independent Rust CR3 lossless and version 0x100/0x200 C-RAW decoding, six exact full-sensor regressions on M50/R100/R8; any CR3 decode error falls back to the embedded JPEG; guarded CR3 camera-JPEG colour fitting with independent framing validation (`docs/cr3.md`). Unverified CRX variants and broader model verification remain.
-
-## Chinese and Japanese interfaces, and text watermarks
-
-English, Simplified Chinese, Traditional Chinese (Taiwan) and Japanese interface languages are persisted in UI state, and the
-language table (`crates/ui-egui/src/i18n.rs`) drives the menus, settings and fonts, so another
-language is a table entry plus two catalogs (docs/localization.md). Core menus and panels are
-translated; untranslated text retains English. CJK glyphs (Chinese: Noto Sans CJK SC; Japanese UI:
-BIZ UDPGothic; watermarks: BIZ UDMincho) come from storytold/craft-fonts, embedded by builds made
-with the optional `CRAFT_FONTS_DIR` input (all releases), so no system fonts are needed. Text watermarks now accept
-`vertical: true` in export JSON/presets and expose an orientation selector. Japanese
-characters stay upright in top-to-bottom columns, with newlines starting columns to the
-left. This is basic lettering, without tate-chu-yoko, ruby, kinsoku, or general vertical
-OpenType shaping. The same coverage renderer serves 8/16/32-bit exports.
+| Date | Change | Summary |
+|---|---|---|
+| 2026-10-10 | minor | RAW measured model by model; RAW decode and colour as explicit rows: full 59% → 61%, mainstream 51% → 49%, essentials 63% → 64%; beta ~14 points |
+| 2026-10-10 | minor | Full ready restored to the additive weighted sum (~59%; method aligned with the standard, no new evidence); hours per audience; beta ~16 points away |
+| 2026-10-10 | minor | Mainstream practitioner and essentials user numbers; full ready 55% → 46% (method written down); beta 340–600 h |
+| 2026-10-10 | minor | Alpha gate added (core-workflow check): passes, stays alpha |
+| 2026-10-10 | major | Restructured to the progress-docs standard; re-measured; *Where we stand*, *Parity estimate* and *Totals* moved to docs/target-app-parity.md, milestones and risks to docs/roadmap.md, raw coverage to docs/raw-parity.md, catalog migration to docs/file-format-parity.md, CJK notes to docs/localization-parity.md |
+| 2026-10-08 | minor | *Where we stand* by dimension and by kind of user |
+| 2026-10-02 | major | Parity estimate and effort table |
+| 2026-09-30 | major | Created |
diff --git a/apps/lightcraft-cli/Cargo.toml b/apps/lightcraft-cli/Cargo.toml
index 3f60fe32..6471ab86 100644
--- a/apps/lightcraft-cli/Cargo.toml
+++ b/apps/lightcraft-cli/Cargo.toml
@@ -25,7 +25,14 @@ serde_json = { workspace = true }
dhat = { workspace = true, optional = true }
[features]
+default = ["denoise"]
+denoise = ["lightcraft-engine/denoise"]
+rawnind-model = ["denoise", "lightcraft-engine/rawnind-model"]
# Heap profiling: `cargo run --release -p lightcraft-cli --features dhat-heap -- snapshot …` counts
# every allocation (live/peak bytes in `library.memory` / `ui.inspect`) and writes dhat-heap.json
# (open it in DHAT's viewer: allocation sites by bytes at the peak). Slows allocation-heavy work.
dhat-heap = ["dep:dhat"]
+
+# Open HEIC/HEIF photos (the optional lightcraft-heif crate). Not default so distributors choose
+# it (HEVC patents); build with `--features heif` to include it.
+heif = ["lightcraft-codecs/heif"]
diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs
index 6c5e6e44..592a534a 100644
--- a/apps/lightcraft-cli/src/main.rs
+++ b/apps/lightcraft-cli/src/main.rs
@@ -342,7 +342,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
eprintln!("lightcraft-cli mcp: serving MCP on stdio ({})", backend.describe());
let mut server = Server::new(backend).with_command_tools(!compact);
let stdin = std::io::stdin();
- server.serve(BufReader::new(stdin.lock()), std::io::stdout().lock()).map_err(|e| e.to_string())
+ server.serve(BufReader::new(stdin), std::io::stdout()).map_err(|e| e.to_string())
}
fn merge(args: &[String]) -> Result<(), String> {
@@ -391,7 +391,7 @@ fn merge(args: &[String]) -> Result<(), String> {
}
i += 1;
}
- let mut s = Session::new().with_fs();
+ let mut s = Session::new().with_fs().with_default_denoise_models().with_system_clock();
let paths = expand_paths(&files);
let r = s.execute("library.import", &json!({"paths": paths})).map_err(|e| e.to_string())?;
let mut ids: Vec = r["imported"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
@@ -652,7 +652,7 @@ fn render(args: &[String]) -> Result<(), String> {
}
let input = input.ok_or("render: missing input file")?;
let output = output.ok_or("render: missing -o OUTPUT")?;
- let mut s = Session::new().with_fs();
+ let mut s = Session::new().with_fs().with_default_denoise_models().with_system_clock();
let abs = expand_paths(std::slice::from_ref(&input));
let r = s.execute("library.import", &json!({"paths": abs})).map_err(|e| e.to_string())?;
let id = r["imported"][0].as_u64().ok_or_else(|| format!("{input}: not a readable photo"))?;
@@ -728,19 +728,19 @@ fn snapshot(args: &[String]) -> Result<(), String> {
if script.is_none() && output.is_none() {
return Err("snapshot: give -o OUT.png and/or --script FILE".into());
}
- if !(scale > 0.0 && size[0] >= 1.0 && size[1] >= 1.0) {
- return Err("snapshot: bad --size/--scale".into());
- }
+ lightcraft_ui_egui::headless::viewport_pixels(size, scale).map_err(|e| format!("snapshot: bad --size/--scale: {e}"))?;
let t0 = Instant::now();
let mut session = match &library {
Some(dir) => {
- let mut s = Session::new().with_fs();
+ let mut s = Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models();
s.open_library(dir, false).map_err(|e| library_error(dir, e))?;
s
}
- None if files.is_empty() => Session::with_demo().with_fs(),
- None => Session::new().with_fs(),
+ None if files.is_empty() => Session::with_demo().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models(),
+ None => Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models(),
};
+ // Apply compute policy before imports or the first scripted query can discover an adapter.
+ session.execute("app.gpu", &json!({"enabled": false})).map_err(|e| e.to_string())?;
if !files.is_empty() {
let ti = Instant::now();
let r = session.execute("library.import", &json!({"paths": expand_paths(&files)})).map_err(|e| e.to_string())?;
@@ -755,7 +755,11 @@ fn snapshot(args: &[String]) -> Result<(), String> {
})),
..Default::default()
};
- let app = lightcraft_ui_egui::LightcraftApp::new(session, services);
+ let mut app = lightcraft_ui_egui::LightcraftApp::new(session, services);
+ // Snapshot sessions promise no GPU: disable photo compute as well as the compositor
+ // before the first frame can start background adapter discovery.
+ // This UI state is session-local: the CLI neither loads nor saves desktop ui.json.
+ app.ui.settings.gpu = false;
let mut h = Headless::new(app, size, scale);
let timeout = Duration::from_secs(60);
let mut shots = 0usize;
diff --git a/apps/lightcraft-cli/tests/cli.rs b/apps/lightcraft-cli/tests/cli.rs
index d1b178e4..ecda1102 100644
--- a/apps/lightcraft-cli/tests/cli.rs
+++ b/apps/lightcraft-cli/tests/cli.rs
@@ -7,6 +7,46 @@ use serde_json::{Value, json};
const BIN: &str = env!("CARGO_BIN_EXE_lightcraft-cli");
+#[test]
+fn contact_sheet_exports_pdf_and_protects_originals() {
+ let input = tmp("contact-sheet-input.png");
+ gradient_png(&input);
+ let out = tmp("contact-sheet.pdf");
+ let (ok, lines, err) = run_cli(
+ &[
+ "--import",
+ input.to_str().unwrap(),
+ "export.contactSheet",
+ &format!("path={}", out.display()),
+ "paper=letter",
+ "landscape=true",
+ "columns=2",
+ "rows=3",
+ ],
+ None,
+ );
+ assert!(ok, "{err}: {lines:?}");
+ assert_eq!(lines[0]["result"]["pages"], 1);
+ assert_eq!(lines[0]["result"]["photos"], 1);
+ let bytes = std::fs::read(&out).unwrap();
+ assert!(bytes.starts_with(b"%PDF-1.4"));
+ assert!(String::from_utf8_lossy(&bytes).contains("/MediaBox [0 0 792.00 612.00]"));
+ let before = std::fs::read(&input).unwrap();
+ for target in [input.clone(), input.with_extension("xmp")] {
+ if target != input {
+ std::fs::write(&target, b"existing sidecar").unwrap();
+ }
+ let (ok, _, _) = run_cli(&["--import", input.to_str().unwrap(), "export.contactSheet", &format!("path={}", target.display())], None);
+ assert!(!ok, "overwrote protected file");
+ }
+ assert_eq!(std::fs::read(&input).unwrap(), before);
+ assert_eq!(std::fs::read(input.with_extension("xmp")).unwrap(), b"existing sidecar");
+ let prior = std::fs::read(&out).unwrap();
+ let (ok, _, _) = run_cli(&["--import", input.to_str().unwrap(), "export.contactSheet", &format!("path={}", out.display()), "columns=0"], None);
+ assert!(!ok);
+ assert_eq!(std::fs::read(&out).unwrap(), prior, "failed export changed prior output");
+}
+
fn tmp(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("lightcraft-cli-test-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
@@ -233,6 +273,55 @@ fn snapshot_script_failure_exits_non_zero() {
assert_eq!((d.width, d.height), (320, 200));
}
+#[test]
+fn snapshot_ui_zoom_keeps_requested_pixel_dimensions() {
+ let script = tmp("snap-ui-zoom.jsonl");
+ std::fs::write(&script, format!("{}\n{}\n", json!({"method": "ui.zoomFactor", "params": {"factor": 1.1}}), json!({"method": "ui.inspect"})))
+ .unwrap();
+ for (size, scale, expected) in [("400x240", "1", (400, 240)), ("345x200", "0.9", (311, 180))] {
+ let out = tmp(&format!("snap-ui-zoom-{scale}.png"));
+ let o = Command::new(BIN)
+ .args(["snapshot", "--script", script.to_str().unwrap(), "-o", out.to_str().unwrap(), "--size", size, "--scale", scale])
+ .output()
+ .unwrap();
+ assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
+ let replies: Vec = 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::().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 = String::from_utf8_lossy(&output.stdout).lines().map(|line| serde_json::from_str(line).unwrap()).collect();
+ assert!(replies.iter().all(|reply| reply["ok"] == true), "{replies:?}");
+ let gpu = &replies[0]["result"];
+ assert_eq!(gpu["enabled"], false, "{gpu}");
+ assert_eq!(gpu["available"], false, "{gpu}");
+ assert_eq!(gpu["adapter"], Value::Null, "no device is created: {gpu}");
+ assert!(gpu["reason"].as_str().is_some_and(|reason| reason.contains("preference")), "{gpu}");
+ let decoded = lightcraft_codecs::decode(&std::fs::read(image).unwrap(), Default::default()).unwrap();
+ assert_eq!((decoded.width, decoded.height), (480, 320));
+}
+
/// Issue #136: with the GPU switched off from the environment the UI starts and renders on the CPU,
/// without creating a GPU device (no driver is loaded), and says why.
#[test]
@@ -412,3 +501,79 @@ fn warnings_are_logged_on_stderr() {
let off = run("off");
assert!(!off.contains("names no known backend"), "{off}");
}
+
+#[test]
+fn snapshot_rejects_invalid_dimensions_before_creating_an_image() {
+ for (index, (size, scale)) in
+ [("NaNx100", "1"), ("100x100", "inf"), ("9000x9000", "1"), ("9000x100", "2"), ("100x100", "0.001")].into_iter().enumerate()
+ {
+ let image = tmp(&format!("invalid-viewport-{index}.png"));
+ let output = Command::new(BIN).args(["snapshot", "--size", size, "--scale", scale, "-o"]).arg(&image).output().unwrap();
+ assert!(!output.status.success());
+ assert!(String::from_utf8_lossy(&output.stderr).contains("snapshot: bad --size/--scale"), "{}", String::from_utf8_lossy(&output.stderr));
+ assert!(!image.exists());
+ }
+}
+
+#[test]
+fn snapshot_preserves_fractional_scale_rounding() {
+ let image = tmp("fractional-scale-rounding.png");
+ let output =
+ Command::new(BIN).env("LIGHTCRAFT_GPU", "0").args(["snapshot", "--size", "345x200", "--scale", "0.9", "-o"]).arg(&image).output().unwrap();
+ assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
+ let decoded = lightcraft_codecs::decode(&std::fs::read(image).unwrap(), Default::default()).unwrap();
+ assert_eq!((decoded.width, decoded.height), (311, 180));
+}
+
+/// Snapshot's CPU setting must never replace the desktop preference, including with --library.
+#[cfg(target_os = "linux")]
+#[test]
+fn snapshot_leaves_desktop_gpu_preferences_untouched() {
+ let dir = tmp("snapshot-desktop-preferences");
+ let library = dir.join("library");
+ let mut session = lightcraft_engine::Session::new().with_fs();
+ session.open_library(&library, true).unwrap();
+ drop(session);
+ let config = dir.join("config");
+ let app_config = config.join("lightcraft");
+ std::fs::create_dir_all(&app_config).unwrap();
+ let ui_path = app_config.join("ui.json");
+ let mut ui = lightcraft_ui_egui::UiState::default();
+ ui.settings.gpu = true;
+ let saved = serde_json::to_vec_pretty(&ui).unwrap();
+ std::fs::write(&ui_path, &saved).unwrap();
+ let script = dir.join("preferences.jsonl");
+ std::fs::write(
+ &script,
+ format!("{}\n{}\n", json!({"method": "engine.execute", "params": {"command": "app.gpu"}}), json!({"method": "ui.inspect"})),
+ )
+ .unwrap();
+ for gpu_environment in [None, Some("1")] {
+ let image = dir.join(format!("{}.png", gpu_environment.unwrap_or("default")));
+ let mut cmd = Command::new(BIN);
+ cmd.env("XDG_CONFIG_HOME", &config).env_remove("LIGHTCRAFT_GPU").env_remove("LIGHTCRAFT_GPU_BACKEND").env_remove("WGPU_BACKEND");
+ if let Some(value) = gpu_environment {
+ cmd.env("LIGHTCRAFT_GPU", value);
+ }
+ let output = cmd
+ .args(["snapshot", "--library"])
+ .arg(&library)
+ .args(["--script"])
+ .arg(&script)
+ .args(["-o"])
+ .arg(&image)
+ .args(["--size", "480x320"])
+ .output()
+ .unwrap();
+ assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
+ let replies: Vec = 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());
+ }
+}
diff --git a/apps/lightcraft-web/Cargo.toml b/apps/lightcraft-web/Cargo.toml
index a3331a60..aaa14a64 100644
--- a/apps/lightcraft-web/Cargo.toml
+++ b/apps/lightcraft-web/Cargo.toml
@@ -71,3 +71,9 @@ web-sys = { workspace = true, features = [
"WorkerType",
"WritableStream",
] }
+
+[features]
+# Open HEIC/HEIF photos (the optional lightcraft-heif crate). Not default so distributors choose
+# it (HEVC patents); build with `--features heif` to include it. On wasm32 panics abort, so
+# heic-rs's `catch_unwind` guard does not apply there.
+heif = ["lightcraft-codecs/heif"]
diff --git a/apps/lightcraft-web/index.html b/apps/lightcraft-web/index.html
index bc0ad203..b088ad18 100644
--- a/apps/lightcraft-web/index.html
+++ b/apps/lightcraft-web/index.html
@@ -24,13 +24,21 @@