Compare commits
389
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
701f78ea2e | ||
|
|
25ef65c81e | ||
|
|
76b5377868 | ||
|
|
5343fd10ff | ||
|
|
9e41805a12 | ||
|
|
3b306259e5 | ||
|
|
76a2899383 | ||
|
|
995d5ea49b | ||
|
|
ea51574172 | ||
|
|
f01d3304e2 | ||
|
|
45b174a781 | ||
|
|
4ebb934c56 | ||
|
|
9cf47c86fe | ||
|
|
7fe8f8dbd1 | ||
|
|
4ff1d9761d | ||
|
|
9978dc739d | ||
|
|
0d5f8b1ea4 | ||
|
|
705c59cb42 | ||
|
|
6bc25940b8 | ||
|
|
bd3b058a8f | ||
|
|
f4a205b4ff | ||
|
|
255b23816d | ||
|
|
4a0d04b994 | ||
|
|
4ec3494733 | ||
|
|
e4a248c91f | ||
|
|
c54328b2d3 | ||
|
|
eae1eb5ba2 | ||
|
|
870eaa74e6 | ||
|
|
fce79feadb | ||
|
|
44f86dc855 | ||
|
|
47b5e0ae1b | ||
|
|
8df1ad0392 | ||
|
|
0dd6593d9d | ||
|
|
37b26c56a3 | ||
|
|
d761e1b231 | ||
|
|
155ad70c6e | ||
|
|
10ebeccdc8 | ||
|
|
2e5a9f92ca | ||
|
|
acdfa08f00 | ||
|
|
b89fb8bbab | ||
|
|
e6af53d46c | ||
|
|
d1e20f0292 | ||
|
|
b04c2c831c | ||
|
|
e1016acc0f | ||
|
|
8c4898ec04 | ||
|
|
59011d114f | ||
|
|
f7ffa1433c | ||
|
|
33686e6951 | ||
|
|
45a6a42ebf | ||
|
|
a0e422ce4a | ||
|
|
56a62f6d8b | ||
|
|
6b22c5ca25 | ||
|
|
c53b61732d | ||
|
|
76fc27a7bc | ||
|
|
c9d131bfbc | ||
|
|
8339028a80 | ||
|
|
6308ca1088 | ||
|
|
11f41cbe97 | ||
|
|
7ff683f037 | ||
|
|
477c078b35 | ||
|
|
5ba413baf2 | ||
|
|
421000916e | ||
|
|
860b0d69c6 | ||
|
|
111bb42aa3 | ||
|
|
8380415222 | ||
|
|
d7f868fc6d | ||
|
|
da008ce53c | ||
|
|
5df7f733d3 | ||
|
|
7ecb0b0191 | ||
|
|
17dfa9709e | ||
|
|
3b0131aaea | ||
|
|
2efa01767f | ||
|
|
05939cbc54 | ||
|
|
b8d39063db | ||
|
|
5861591307 | ||
|
|
6c18c0a7c6 | ||
|
|
95007492cc | ||
|
|
5ce115a505 | ||
|
|
62ece65ac9 | ||
|
|
44acde3a41 | ||
|
|
c04eec3f9d | ||
|
|
b6ad706aa6 | ||
|
|
31734dea86 | ||
|
|
76cf8ca4c9 | ||
|
|
4e6cd5d7b9 | ||
|
|
9dc0d0b62b | ||
|
|
efd29ef21a | ||
|
|
72d77d7b41 | ||
|
|
2fedd92077 | ||
|
|
40492d238e | ||
|
|
8d319563ca | ||
|
|
edb95894b4 | ||
|
|
16bbaff14c | ||
|
|
db8b82c6d7 | ||
|
|
7c4fd3abaa | ||
|
|
7afbc3ecb3 | ||
|
|
0f9415b320 | ||
|
|
27d2bfda86 | ||
|
|
d098ffac42 | ||
|
|
ae2d936c4d | ||
|
|
96988afe6d | ||
|
|
1f3bfbdb25 | ||
|
|
9847383bf2 | ||
|
|
ab86ed7c84 | ||
|
|
b46adf0640 | ||
|
|
c97b2790d3 | ||
|
|
82f74e31fd | ||
|
|
1d11e5bd89 | ||
|
|
06ef27bb37 | ||
|
|
f7730b5877 | ||
|
|
34b55101fe | ||
|
|
3ac2b21d9e | ||
|
|
5ced33fd9a | ||
|
|
0d022b7506 | ||
|
|
38f16ff1b4 | ||
|
|
33a29dcdc3 | ||
|
|
36cd72bb33 | ||
|
|
605641ab04 | ||
|
|
9efc0950cf | ||
|
|
fbc7ba042b | ||
|
|
4e473b2b37 | ||
|
|
63059a4303 | ||
|
|
0ea19a8a62 | ||
|
|
47fe7f70be | ||
|
|
cb5cd22bd3 | ||
|
|
cba621e55c | ||
|
|
4f9b77cad9 | ||
|
|
814e35b103 | ||
|
|
8df7b008c0 | ||
|
|
0449a3fc51 | ||
|
|
1ac1bc009b | ||
|
|
ee2cd6f4bb | ||
|
|
17e1a34d14 | ||
|
|
4b93c90024 | ||
|
|
e2a38c26e0 | ||
|
|
761a91c233 | ||
|
|
a33dc70a3c | ||
|
|
bb4cdeed2a | ||
|
|
805a15ae9b | ||
|
|
69b90ee7c4 | ||
|
|
170f3956a1 | ||
|
|
2bc33268d2 | ||
|
|
0e4b52e29b | ||
|
|
dba63bc9d5 | ||
|
|
69f117632b | ||
|
|
5350a8ed70 | ||
|
|
aa48f6f6e7 | ||
|
|
f9f3fbe4db | ||
|
|
f4d8a428ac | ||
|
|
f9919acc7f | ||
|
|
f8c01f70fb | ||
|
|
8f42f4a96d | ||
|
|
d5d9daa555 | ||
|
|
bda8569f67 | ||
|
|
0a46f615dc | ||
|
|
b23b20638e | ||
|
|
9ecd3309b2 | ||
|
|
5abc39ae7d | ||
|
|
11dd3bd6b3 | ||
|
|
68f75cc07a | ||
|
|
72b02c65b7 | ||
|
|
5bbce8b9fd | ||
|
|
f30ad32d93 | ||
|
|
09461d04c7 | ||
|
|
f98749343f | ||
|
|
95662cfff5 | ||
|
|
14679de823 | ||
|
|
b038c018c2 | ||
|
|
72a7cd80b8 | ||
|
|
0b960d39e3 | ||
|
|
bbd973f321 | ||
|
|
a7382aacfb | ||
|
|
353c1cf566 | ||
|
|
5a60d94f2f | ||
|
|
88e4188e78 | ||
|
|
43662e6959 | ||
|
|
23e046daba | ||
|
|
d94d6856e9 | ||
|
|
b28b05c475 | ||
|
|
8924ea9afd | ||
|
|
75cf78175a | ||
|
|
457c899160 | ||
|
|
48edfb8d3a | ||
|
|
9815ca1310 | ||
|
|
2c8520371c | ||
|
|
44b12efea3 | ||
|
|
5f35b5af17 | ||
|
|
f05a52968f | ||
|
|
e142075998 | ||
|
|
ad3c7315bb | ||
|
|
bac0f75551 | ||
|
|
fe6d7b0192 | ||
|
|
ab240d6ece | ||
|
|
07ae88e923 | ||
|
|
6873aab6eb | ||
|
|
db7e37843e | ||
|
|
80e58ed55b | ||
|
|
00ea386f7f | ||
|
|
385348b258 | ||
|
|
4700c8a74e | ||
|
|
484c160c13 | ||
|
|
c722e6bb64 | ||
|
|
a6431c152d | ||
|
|
43f31532a7 | ||
|
|
5bb43a51f7 | ||
|
|
15f6718a2e | ||
|
|
7154ee149b | ||
|
|
fb678b9e35 | ||
|
|
c9c0268f8b | ||
|
|
cbc0e2d7db | ||
|
|
8c84677079 | ||
|
|
a82ecb3753 | ||
|
|
c536986c48 | ||
|
|
c52ff0f014 | ||
|
|
61fef5cfd5 | ||
|
|
38756b89d0 | ||
|
|
789019714a | ||
|
|
d9d1a5a730 | ||
|
|
32a358180c | ||
|
|
21d71feb04 | ||
|
|
0b61450c7b | ||
|
|
aec298c116 | ||
|
|
dac0d4c6e2 | ||
|
|
0ba118de92 | ||
|
|
bc2cbf41db | ||
|
|
77fcd81dde | ||
|
|
a7e8f1cc26 | ||
|
|
d76a4121e6 | ||
|
|
32e78b352c | ||
|
|
7d27cf8bfb | ||
|
|
6807421f64 | ||
|
|
f032d6e389 | ||
|
|
9e6c4c6fc9 | ||
|
|
6f44b33ca7 | ||
|
|
ffd737ceab | ||
|
|
aa6054cd1a | ||
|
|
9bfabc98b1 | ||
|
|
40d7b86ecb | ||
|
|
8f3f168802 | ||
|
|
a66a133041 | ||
|
|
e90d83ecc3 | ||
|
|
a67d571656 | ||
|
|
7ce13cfd07 | ||
|
|
ba7df9c7fe | ||
|
|
bbedde35cb | ||
|
|
a940dab727 | ||
|
|
8e9c5aff7e | ||
|
|
d6cfab8223 | ||
|
|
5a975d0d75 | ||
|
|
0067879a58 | ||
|
|
0adcb21166 | ||
|
|
93d76a24db | ||
|
|
22a836316d | ||
|
|
88f424e7e7 | ||
|
|
bc5c561dd8 | ||
|
|
5855ab80a6 | ||
|
|
84b8995d7b | ||
|
|
fe92e844d0 | ||
|
|
e1267a7139 | ||
|
|
a3c33658bb | ||
|
|
7216c8589a | ||
|
|
45227de921 | ||
|
|
0fdf6c5785 | ||
|
|
78c5b4d099 | ||
|
|
62dacf2791 | ||
|
|
7a232ada8b | ||
|
|
7765255ec6 | ||
|
|
de6b33c662 | ||
|
|
b4d7cd1a03 | ||
|
|
89516d2e3e | ||
|
|
e7076762c0 | ||
|
|
bf4a271a71 | ||
|
|
be4cbebbd3 | ||
|
|
2c2f28952f | ||
|
|
85b6fdaedd | ||
|
|
6ae35b0a5b | ||
|
|
46ff7bcf4e | ||
|
|
4dd6fb5089 | ||
|
|
dd73753723 | ||
|
|
179941f4ff | ||
|
|
3cd471b32d | ||
|
|
4860f5e8d4 | ||
|
|
4d26a4d7b4 | ||
|
|
2218a53f2b | ||
|
|
2f75b21ac3 | ||
|
|
1be303ca00 | ||
|
|
fa7f1e8d6c | ||
|
|
69d3d4cf91 | ||
|
|
aea5236790 | ||
|
|
9aa9bcb54e | ||
|
|
458b938248 | ||
|
|
1064e9847d | ||
|
|
7b4bdfa68b | ||
|
|
1a146c12b0 | ||
|
|
1bbb57650c | ||
|
|
15397a712e | ||
|
|
6cba574990 | ||
|
|
e45c4625b4 | ||
|
|
f101a09142 | ||
|
|
868ac707dd | ||
|
|
3a4a70fa8f | ||
|
|
01438b8979 | ||
|
|
083685a34d | ||
|
|
732a2f04fd | ||
|
|
7f966965fc | ||
|
|
e363d298d9 | ||
|
|
964d9338ba | ||
|
|
0041aef955 | ||
|
|
9da719c6ed | ||
|
|
d7eb6b2ab4 | ||
|
|
70103a7e55 | ||
|
|
02a21b860d | ||
|
|
d331829770 | ||
|
|
bf49935fe7 | ||
|
|
9cd9e29614 | ||
|
|
19833e56e3 | ||
|
|
5e68d92c9f | ||
|
|
5ac3e556ac | ||
|
|
a64efb149c | ||
|
|
338b67ffbe | ||
|
|
afbc0f9744 | ||
|
|
0bd3f3715d | ||
|
|
6dca631482 | ||
|
|
61df5d304f | ||
|
|
923b1cde5e | ||
|
|
bbcbbebf8e | ||
|
|
ab438b05ca | ||
|
|
e7f8faf77d | ||
|
|
76e633c9e1 | ||
|
|
b59ffcc4c9 | ||
|
|
b8f868830b | ||
|
|
c8bca35210 | ||
|
|
d803d905b4 | ||
|
|
be2dac6d85 | ||
|
|
5bcedbe6fc | ||
|
|
60844cf162 | ||
|
|
0ed5fcd938 | ||
|
|
943448e7c9 | ||
|
|
1b178ca2e5 | ||
|
|
e40bf5792a | ||
|
|
90875cf8f2 | ||
|
|
d35d0a3dd2 | ||
|
|
3c5e2f3cb2 | ||
|
|
3285e0282d | ||
|
|
aa31ecdc56 | ||
|
|
0de789bac9 | ||
|
|
58f7e74f2a | ||
|
|
584a26085c | ||
|
|
9c2bad1146 | ||
|
|
47e9181f69 | ||
|
|
7883333420 | ||
|
|
8166185a0f | ||
|
|
ec1e26545e | ||
|
|
997bd7a4ed | ||
|
|
ea7777107d | ||
|
|
c739f12e45 | ||
|
|
8a60e3cd30 | ||
|
|
94fed226df | ||
|
|
28a773d460 | ||
|
|
9682caae2a | ||
|
|
ea224d899e | ||
|
|
4cce2d169e | ||
|
|
ba65d0c8d1 | ||
|
|
b70ae1741e | ||
|
|
bec920f67d | ||
|
|
ed705a4483 | ||
|
|
87cd873d78 | ||
|
|
a4436597ad | ||
|
|
4721627d31 | ||
|
|
216efab838 | ||
|
|
0b1d80f08d | ||
|
|
04f8146d10 | ||
|
|
2aa21c2b12 | ||
|
|
d2ec802902 | ||
|
|
02a9f3a5fb | ||
|
|
c606bb4b0e | ||
|
|
f9bbeb9d00 | ||
|
|
fd5d05e256 | ||
|
|
51d98f9256 | ||
|
|
760819eab3 | ||
|
|
5240bdaa6f | ||
|
|
01fc7a5e75 | ||
|
|
06550937e8 | ||
|
|
128539a939 | ||
|
|
330f824c31 | ||
|
|
57f54868de | ||
|
|
7d3cd8afe5 | ||
|
|
ec77f60b1b | ||
|
|
20b6eb885e |
No files matched your search
@@ -1,5 +1,7 @@
|
||||
# Quick static checks for the release pipeline (seconds, no Rust build): workflow syntax,
|
||||
# packaging shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata.
|
||||
# Quick checks for the release pipeline (no Rust build): workflow syntax, packaging
|
||||
# shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata (seconds), plus a
|
||||
# Windows job that builds and installs throwaway per-user MSI probes for the install-folder
|
||||
# searches (a few minutes).
|
||||
name: Packaging lint
|
||||
|
||||
on:
|
||||
@@ -71,3 +73,17 @@ jobs:
|
||||
EOF
|
||||
- name: FreeBSD tarball layout (stub binaries)
|
||||
run: bash packaging/freebsd/package.sh --dry-run
|
||||
|
||||
windows-install-folder:
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: dotnet tool install --global wix --version 5.0.2
|
||||
- name: Install-folder fallback and precedence (real MSI AppSearch)
|
||||
shell: pwsh
|
||||
run: |
|
||||
./packaging/windows/test-install-folder.ps1 -Arch x64
|
||||
./packaging/windows/test-install-folder.ps1 -Arch x86
|
||||
@@ -5,8 +5,9 @@
|
||||
# The signing jobs (macOS, Windows) and the draft-release job run in the `release` environment
|
||||
# (restricted to the `release` branch), which holds the signing secrets. Every secret is optional:
|
||||
# missing ones produce unsigned artifacts and a warning, never a failed build. The jobs that sign
|
||||
# nothing (version, Linux, Flatpak, FreeBSD, web) run without it, so a dispatch on any branch is
|
||||
# a dry run of them; the release job needs the signed jobs, so a branch run never drafts a release.
|
||||
# nothing (version, Linux, Linux riscv64, Flatpak, FreeBSD, web) run without it, so a dispatch on
|
||||
# any branch is a dry run of them; the release job needs the signed jobs, so a branch run never
|
||||
# drafts a release.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
@@ -272,6 +273,79 @@ jobs:
|
||||
path: dist/release/*.flatpak
|
||||
if-no-files-found: error
|
||||
|
||||
linux-riscv64:
|
||||
name: Linux riscv64
|
||||
needs: version
|
||||
# Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release
|
||||
# from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's
|
||||
# riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the
|
||||
# CLI is smoke-tested under QEMU.
|
||||
runs-on: ubuntu-24.04
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
env:
|
||||
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: riscv64gc-unknown-linux-gnu
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-linux-riscv64
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$LIGHTCRAFT_VERSION"
|
||||
- name: Install cross-compilation dependencies
|
||||
run: |
|
||||
. /etc/os-release
|
||||
sudo dpkg --add-architecture riscv64
|
||||
# Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports.
|
||||
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
|
||||
sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources
|
||||
fi
|
||||
if [ -f /etc/apt/sources.list ]; then
|
||||
sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list
|
||||
sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list
|
||||
fi
|
||||
for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do
|
||||
echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse"
|
||||
done | sudo tee /etc/apt/sources.list.d/riscv64.list
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \
|
||||
libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \
|
||||
libxi-dev:riscv64 libgl1-mesa-dev:riscv64
|
||||
- name: Build and package (tar.gz)
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc
|
||||
PKG_CONFIG_SYSROOT_DIR: /
|
||||
PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
|
||||
PKG_CONFIG_ALLOW_CROSS: "1"
|
||||
CROSS_ARCH: riscv64
|
||||
CROSS_TARGET: riscv64gc-unknown-linux-gnu
|
||||
CROSS_COMPILE: riscv64-linux-gnu-
|
||||
EMULATOR: qemu-riscv64
|
||||
QEMU_LD_PREFIX: /usr/riscv64-linux-gnu
|
||||
run: packaging/linux/package.sh --formats tar
|
||||
- name: Check the tarball
|
||||
run: |
|
||||
tar -tzf dist/release/lightcraft-*-linux-riscv64.tar.gz \
|
||||
| grep -E '/bin/lightcraft$|/bin/lightcraft-cli$|/share/applications/ai\.storyteller\.lightcraft\.desktop$'
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-linux-riscv64
|
||||
path: dist/release/*.tar.gz
|
||||
if-no-files-found: error
|
||||
|
||||
freebsd:
|
||||
name: FreeBSD x86_64
|
||||
needs: version
|
||||
@@ -368,7 +442,7 @@ jobs:
|
||||
|
||||
release:
|
||||
name: Draft GitHub Release
|
||||
needs: [version, macos, windows, linux, flatpak, freebsd, web]
|
||||
needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web]
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
permissions:
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
@@ -80,7 +86,7 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
then `CRAFT_FONTS_DIR=../craft-fonts cargo run -p lightcraft` (or any cargo/xtask command). `crates/engine/build.rs`
|
||||
embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); `cargo xtask web`
|
||||
ships Noto Sans CJK SC Regular as a separate asset loaded by the UI before startup. The UI
|
||||
(`theme::font_definitions`) and the export watermark renderer use CJK faces (picked by script) as fallbacks after Inter. Unset,
|
||||
(`theme::font_definitions`) and the export watermark renderer use CJK faces (picked by script) as fallbacks after Inter; the UI also falls back on the faces of other scripts (Thai, Arabic, …). Unset,
|
||||
`CRAFT_FONTS` is empty: everything builds, tests and runs, but Chinese and Japanese text lack glyphs. Releases always build
|
||||
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
|
||||
|
||||
Generated
+174
-28
@@ -988,6 +988,16 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||
|
||||
[[package]]
|
||||
name = "colored"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.8"
|
||||
@@ -1062,6 +1072,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
@@ -1202,6 +1221,19 @@ dependencies = [
|
||||
"thousands",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dify"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90ce0fb972943b4e88cd03b8f92953df0c71bb05e0bde8e5b684895d808013cc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"colored",
|
||||
"getopts",
|
||||
"image",
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@@ -1447,6 +1479,27 @@ dependencies = [
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_kittest"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5"
|
||||
dependencies = [
|
||||
"dify",
|
||||
"eframe",
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
"image",
|
||||
"kittest",
|
||||
"log",
|
||||
"open",
|
||||
"pollster 1.0.1",
|
||||
"serde",
|
||||
"tempfile",
|
||||
"toml",
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.18.0"
|
||||
@@ -2230,6 +2283,15 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getopts"
|
||||
version = "0.2.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
|
||||
dependencies = [
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
@@ -2588,6 +2650,25 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-docker"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-wsl"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
|
||||
dependencies = [
|
||||
"is-docker",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.14.0"
|
||||
@@ -2612,6 +2693,12 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jixel"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "278a75e12caacec34be774d62ad05e45fc0b6a52d1f4e9f7fbc0209ecdf794f3"
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.22.4"
|
||||
@@ -2909,6 +2996,16 @@ version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
|
||||
|
||||
[[package]]
|
||||
name = "kittest"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_consumer 0.35.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kurbo"
|
||||
version = "0.13.1"
|
||||
@@ -2976,7 +3073,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -2987,19 +3084,26 @@ dependencies = [
|
||||
"lightcraft-ui-egui",
|
||||
"log",
|
||||
"muda",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"pollster 1.0.1",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"winit",
|
||||
"winresource",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-catalog"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"lightcraft-develop",
|
||||
"lightcraft-geom",
|
||||
"lightcraft-meta",
|
||||
"lightcraft-raw",
|
||||
"log",
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -3010,10 +3114,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-cli"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"dhat",
|
||||
"lightcraft-codecs",
|
||||
"lightcraft-denoise",
|
||||
"lightcraft-engine",
|
||||
"lightcraft-mcp",
|
||||
"lightcraft-merge",
|
||||
@@ -3025,12 +3130,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-codecs"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"avif-serialize",
|
||||
"bytemuck",
|
||||
"image",
|
||||
"image-webp",
|
||||
"jixel",
|
||||
"jpeg-decoder",
|
||||
"jpeg-encoder",
|
||||
"jxl-oxide",
|
||||
@@ -3053,7 +3159,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-color"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"lightcraft-geom",
|
||||
"proptest",
|
||||
@@ -3062,7 +3168,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-denoise"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"faer-core",
|
||||
"lightcraft-denoise-core",
|
||||
@@ -3079,7 +3185,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-denoise-core"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"lightcraft-raster",
|
||||
"miniz_oxide 0.8.9",
|
||||
@@ -3094,7 +3200,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-develop"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"lightcraft-color",
|
||||
"lightcraft-geom",
|
||||
@@ -3106,10 +3212,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-engine"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"harfrust",
|
||||
"jxl-oxide",
|
||||
"libc",
|
||||
"lightcraft-catalog",
|
||||
"lightcraft-codecs",
|
||||
@@ -3119,9 +3226,11 @@ dependencies = [
|
||||
"lightcraft-develop",
|
||||
"lightcraft-faces",
|
||||
"lightcraft-fetch",
|
||||
"lightcraft-geocode",
|
||||
"lightcraft-geom",
|
||||
"lightcraft-gpu",
|
||||
"lightcraft-heif",
|
||||
"lightcraft-lensdb",
|
||||
"lightcraft-merge",
|
||||
"lightcraft-meta",
|
||||
"lightcraft-pipeline",
|
||||
@@ -3143,7 +3252,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-faces"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"matrixmultiply",
|
||||
"ndarray",
|
||||
@@ -3157,7 +3266,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-fetch"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"rustls",
|
||||
@@ -3166,9 +3275,17 @@ dependencies = [
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-geocode"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"miniz_oxide 0.8.9",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-geom"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -3176,7 +3293,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-gpu"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"lightcraft-color",
|
||||
@@ -3194,15 +3311,31 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-heif"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"heic-rs",
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-inpaint"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-lensdb"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"lightcraft-develop",
|
||||
"lightcraft-geom",
|
||||
"quick-xml 0.38.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-mcp"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"lightcraft-codecs",
|
||||
"lightcraft-engine",
|
||||
@@ -3213,7 +3346,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-merge"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"lightcraft-codecs",
|
||||
"lightcraft-color",
|
||||
@@ -3229,7 +3362,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-meta"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"lightcraft-geom",
|
||||
"lightcraft-tiff",
|
||||
@@ -3241,12 +3374,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-pipeline"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"lightcraft-color",
|
||||
"lightcraft-develop",
|
||||
"lightcraft-geom",
|
||||
"lightcraft-inpaint",
|
||||
"lightcraft-raster",
|
||||
"lightcraft-raw",
|
||||
"lightcraft-scenes",
|
||||
@@ -3258,7 +3392,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-preview"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"jpeg-encoder",
|
||||
"lightcraft-raster",
|
||||
@@ -3270,7 +3404,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-raster"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"lightcraft-color",
|
||||
@@ -3282,7 +3416,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-raw"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"jpeg-encoder",
|
||||
"jxl-oxide",
|
||||
@@ -3304,7 +3438,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-scenes"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"lightcraft-color",
|
||||
@@ -3315,7 +3449,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-segment"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"candle-core",
|
||||
"candle-nn",
|
||||
@@ -3328,11 +3462,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-sysmem"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-tiff"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -3341,9 +3475,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-ui-egui"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
"egui_kittest",
|
||||
"lightcraft-catalog",
|
||||
"lightcraft-codecs",
|
||||
"lightcraft-color",
|
||||
@@ -3366,7 +3502,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-web"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"eframe",
|
||||
@@ -4169,6 +4305,16 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc"
|
||||
dependencies = [
|
||||
"is-wsl",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "orbclient"
|
||||
version = "0.3.55"
|
||||
@@ -6826,7 +6972,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"flate2",
|
||||
|
||||
+13
-1
@@ -3,13 +3,14 @@ resolver = "3"
|
||||
members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
repository = "https://github.com/storytold/lightcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
# internal
|
||||
lightcraft-denoise-core = { path = "crates/denoise-core" }
|
||||
lightcraft-denoise = { path = "crates/denoise", default-features = false }
|
||||
@@ -23,6 +24,8 @@ lightcraft-raw = { path = "crates/raw" }
|
||||
lightcraft-codecs = { path = "crates/codecs" }
|
||||
lightcraft-meta = { path = "crates/meta" }
|
||||
lightcraft-develop = { path = "crates/develop" }
|
||||
lightcraft-lensdb = { path = "crates/lensdb" }
|
||||
lightcraft-inpaint = { path = "crates/inpaint" }
|
||||
lightcraft-scenes = { path = "crates/scenes" }
|
||||
lightcraft-pipeline = { path = "crates/pipeline" }
|
||||
lightcraft-gpu = { path = "crates/gpu" }
|
||||
@@ -35,6 +38,7 @@ lightcraft-mcp = { path = "crates/mcp" }
|
||||
lightcraft-fetch = { path = "crates/fetch" }
|
||||
lightcraft-heif = { path = "crates/heif" }
|
||||
lightcraft-segment = { path = "crates/segment" }
|
||||
lightcraft-geocode = { path = "crates/geocode" }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
@@ -66,6 +70,7 @@ tiff = "0.11"
|
||||
image-webp = "0.2"
|
||||
moxcms = { version = "0.9", default-features = false, features = ["lut"] }
|
||||
jxl-oxide = { version = "0.12", default-features = false }
|
||||
jixel = "=0.3.4" # BSD-3-Clause OR Apache-2.0, pure-Rust JPEG XL encoder (export; port of libjxl-tiny)
|
||||
zune-jpegxl = { version = "0.5", default-features = false, features = ["std"] } # MIT/Apache-2.0/Zlib lossless JXL encoder (test fixtures only)
|
||||
ravif = { version = "0.13", default-features = false, features = ["threading"] } # no "asm": it needs nasm (non-Rust)
|
||||
# HDR AVIF (PQ): ravif fixes the sRGB transfer curve, so HDR files drive its rav1e + avif-serialize directly
|
||||
@@ -87,6 +92,13 @@ eframe = { version = "0.36", default-features = false }
|
||||
egui_kittest = "0.36"
|
||||
rfd = "0.17"
|
||||
muda = { version = "0.21", default-features = false } # native menu bar (macOS: objc2/AppKit, pure Rust; no GTK)
|
||||
# macOS: the profile of the screen showing the window (safe AppKit bindings; muda and wgpu use the same)
|
||||
objc2 = { version = "0.6", default-features = false, features = ["std"] }
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSColorSpace", "NSResponder", "NSScreen", "NSWindow"] }
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSData"] }
|
||||
# Linux: the desktop's dark/light setting from the XDG settings portal (Settings ▸ Interface ▸
|
||||
# Appearance Mode ▸ Auto). Pure Rust; the version accesskit already brings in, pinned to it.
|
||||
zbus = "=5.19.0"
|
||||
web-time = "1"
|
||||
|
||||
# web (apps/lightcraft-web)
|
||||
|
||||
@@ -22,6 +22,12 @@ Foundation project licensed under the Apache License 2.0, and from the CIE 1931
|
||||
functions it ships (CIE, CC-BY-4.0). The matrices were fitted by the LightCraft contributors
|
||||
(2026) with tools/spectral_camera_table.py. See assets/ATTRIBUTION.md.
|
||||
|
||||
crates/inpaint (lightcraft-inpaint) ports the inpainting (PatchMatch completion) and Poisson membrane
|
||||
fill code of PhotoCraft (https://github.com/storytold/photocraft, crates/algo/src/inpaint.rs and
|
||||
poisson.rs), Copyright the PhotoCraft contributors, licensed under the MIT License or the Apache
|
||||
License 2.0, at your option. The port was modified by the LightCraft contributors (2026); each
|
||||
ported file says so.
|
||||
|
||||
crates/segment (lightcraft-segment) ports the SAM 3 model code of Hugging Face Transformers
|
||||
(https://github.com/huggingface/transformers, src/transformers/models/sam3 and sam3_tracker),
|
||||
Copyright The HuggingFace Team and Meta Platforms, Inc., licensed under the Apache License 2.0.
|
||||
@@ -48,3 +54,10 @@ also embed the fonts listed in https://github.com/storytold/craft-fonts/blob/mai
|
||||
(the Japanese fonts BIZ UDPGothic, BIZ UDMincho and Shippori Mincho; the web build embeds only BIZ
|
||||
UDPGothic Regular), under the SIL Open Font License 1.1. Release packages include each font's
|
||||
licence as OFL-<family>.txt.
|
||||
|
||||
Place names for reverse geocoding (Photo > Fill Location from GPS) are not part of LightCraft and
|
||||
are never distributed with it. On the user's request (Photo > Download Place Names) the app
|
||||
downloads cities15000.zip, admin1CodesASCII.txt and countryInfo.txt from GeoNames
|
||||
(https://download.geonames.org/export/dump/) into the settings folder. GeoNames data is licensed
|
||||
under Creative Commons Attribution 4.0 (https://creativecommons.org/licenses/by/4.0/);
|
||||
attribution: "GeoNames, geonames.org". The lookup code (crates/geocode) is our own.
|
||||
@@ -12,6 +12,11 @@
|
||||
|
||||
<h3 align="center">Your photos. Your pixels. Your machine.</h3>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> ·
|
||||
<a href="README.zh-hans.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<b>Photo library and raw development; an open-source, clean-room reimplementation of Adobe Lightroom, rebuilt in pure Rust.</b><br>
|
||||
Native on macOS, Windows and Linux. In the browser via WebAssembly. Drivable end to end by AI agents over MCP.
|
||||
@@ -297,18 +302,21 @@ 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-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;
|
||||
- **network libraries:** a preview reads the raw's header and its embedded JPEG only (56.5 MB → 5.9 MB, 10 s → 1 s on the test share), but the *developed* picture still needs the sensor data over the wire; see [docs/raw-preview-reads.md](docs/raw-preview-reads.md);
|
||||
- **on-screen HDR display, video and the Classic Print / Book / Map modules.**
|
||||
- **What's next:** see [where we're going](ROADMAP.md#where-were-going).
|
||||
- **What's next:** see [the ranked gaps](docs/gaps.md) and [the roadmap](docs/roadmap.md).
|
||||
|
||||
| Area | Status |
|
||||
|---|---|
|
||||
@@ -318,10 +326,10 @@ 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), Samsung SRW (uncompressed); embedded previews for every format incl. CR3 | 🟡 · CR3, compressed ORF and compressed SRW decode ⬜ |
|
||||
| Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise, Super Resolution ⬜ |
|
||||
| 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: 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 ⬜ |
|
||||
@@ -332,7 +340,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; video | ⬜ (see [roadmap](ROADMAP.md#where-were-going)) |
|
||||
| AI: segmentation masks, AI denoise, super resolution, faces; video | 🚧 (see [gaps](docs/gaps.md)) |
|
||||
| Web build (same UI in the browser via WASM): persistent library in OPFS/IndexedDB, Web Worker rendering, export downloads | ✅ · WebGPU, Safari/Firefox testing 🚧 |
|
||||
|
||||
<sub>✅ works today · 🚧 in progress · ⬜ not started</sub>
|
||||
@@ -341,6 +349,8 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
|
||||
|
||||
## Quick start
|
||||
|
||||
Workspace builds, including the desktop, web and CLI applications, require Rust 1.95 or newer.
|
||||
|
||||
```sh
|
||||
git clone https://github.com/storytold/lightcraft && cd lightcraft
|
||||
cargo run --release -p lightcraft # opens your library (~/Pictures/LightCraft Library; a new one starts with demo photos)
|
||||
@@ -368,7 +378,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).
|
||||
repo — the standard for what goes there lives with it: see the [craft-fonts README](https://github.com/storytold/craft-fonts#readme).
|
||||
|
||||
**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).
|
||||
@@ -481,6 +491,11 @@ option to start it. For unattended installs: `msiexec /i lightcraft-<ver>-window
|
||||
| Fedora/RHEL/openSUSE | `lightcraft-<ver>-linux-x86_64.rpm` | `lightcraft-<ver>-linux-aarch64.rpm` | |
|
||||
| Tarball | `lightcraft-<ver>-linux-x86_64.tar.gz` | `lightcraft-<ver>-linux-aarch64.tar.gz` | Unpack anywhere |
|
||||
|
||||
RISC-V (riscv64): `lightcraft-<ver>-linux-riscv64.tar.gz`, a tarball only (glibc 2.39 or newer, e.g. Ubuntu 24.04+).
|
||||
|
||||
On a Wayland desktop LightCraft runs through XWayland when it is available, because only X11 gives
|
||||
windows file drag-and-drop; set `LIGHTCRAFT_WAYLAND=1` to use native Wayland (sharper fractional scaling, no drop).
|
||||
|
||||
### FreeBSD
|
||||
|
||||
| Build | File |
|
||||
@@ -567,3 +582,5 @@ Forks and modified versions must remove them.
|
||||
## Star history
|
||||
|
||||
[](https://www.star-history.com/?repos=storytold%2Flightcraft&type=date&legend=top-left)
|
||||
|
||||
Library, Tools, and Presets panels can be regrouped, split, floated within the app window, closed, and reopened. Tools keeps the current Edit/Crop/Masking mode; the photo view and filmstrip stay together. Drag a panel header to a target edge or another header. Its context menu offers Float panel, Move to group, and Close panel. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window.
|
||||
@@ -0,0 +1,567 @@
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/brand/artcraft-logo-white.svg">
|
||||
<img alt="ArtCraft" src="docs/brand/artcraft-logo.svg" width="200">
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
<h1 align="center">LightCraft</h1>
|
||||
|
||||
<h3 align="center">Your photos. Your pixels. Your machine.</h3>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> ·
|
||||
<a href="README.zh-hans.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<b>照片图库与 raw 显影;Adobe Lightroom 的开源净室实现,用纯 Rust 重建。</b><br>
|
||||
在 macOS、Windows 和 Linux 上原生运行,也可通过 WebAssembly 在浏览器中使用,并能由 AI 智能体经 MCP 端到端驱动。
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Pure Rust" src="https://img.shields.io/badge/pure-Rust-f2a516?style=flat-square&logo=rust&logoColor=white">
|
||||
<img alt="macOS, Windows, Linux and Web" src="https://img.shields.io/badge/macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20Web-8a5800?style=flat-square">
|
||||
<img alt="MCP server included" src="https://img.shields.io/badge/MCP-ready-8a5800?style=flat-square">
|
||||
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-8a5800?style=flat-square">
|
||||
<a href="ROADMAP.md"><img alt="Status: young and moving fast" src="https://img.shields.io/badge/status-young%20%26%20moving%20fast-f2a516?style=flat-square"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/artcraft"><img alt="Join the ArtCraft community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/apps/lightcraft"><b>getartcraft.com 上的 LightCraft</b></a> ·
|
||||
<a href="https://getartcraft.com/">ArtCraft</a> ·
|
||||
<a href="https://getartcraft.com/apps">全部 Crafting Apps</a>
|
||||
</p>
|
||||
|
||||
<br>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/hero-tetons.jpg" alt="LightCraft 的编辑视图:放大视图中是安塞尔·亚当斯的《特顿山脉与蛇河》,右侧打开着 Light 与 Effects 面板,底部的胶片窗格中是四张展示照片" width="100%">
|
||||
<br>
|
||||
<sub><i>安塞尔·亚当斯,《特顿山脉与蛇河》(1942)。公有领域,美国国家档案馆。在 LightCraft 中显影。</i></sub>
|
||||
</p>
|
||||
|
||||
> [!NOTE]
|
||||
> **ArtCraft 是一个由形形色色的创作者组成的社区。** 数字艺术、生成艺术、音乐、游戏 —
|
||||
> 只要你在创作,你就是我们中的一员。**[来 Discord 打个招呼吧](https://discord.gg/artcraft)。**
|
||||
|
||||
<p align="center">
|
||||
<a href="#edit-like-you-mean-it">编辑</a> ·
|
||||
<a href="#color-grading-the-cinematic-way">颜色分级</a> ·
|
||||
<a href="#before--after">修改前后对比</a> ·
|
||||
<a href="#masking-that-goes-where-you-point">蒙版</a> ·
|
||||
<a href="#presets-profiles--the-color-mixer">预设</a> ·
|
||||
<a href="#organize-everything">图库</a> ·
|
||||
<a href="#built-for-agents">智能体与 MCP</a> ·
|
||||
<a href="#fast-native-private">性能</a> ·
|
||||
<a href="#feature-status">功能状态</a> ·
|
||||
<a href="#quick-start">快速开始</a> ·
|
||||
<a href="ROADMAP.md">路线图</a> ·
|
||||
<a href="#downloads">下载</a> ·
|
||||
<a href="#the-crafting-apps">Crafting Apps</a>
|
||||
</p>
|
||||
|
||||
<br>
|
||||
|
||||
## Edit like you mean it
|
||||
|
||||
LightCraft 是一个装在单个原生应用里的完整暗房。每一项调整都是**非破坏性**的,原始文件永远不会被改动。
|
||||
每个滑块都经过一条**场景参考、宽色域、32 位浮点的管线**渲染:高光像胶片一样柔和滚降,阴影被提亮却不产生光晕,
|
||||
色彩从拍摄到导出始终保持干净。
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
### ☀️ Light
|
||||
**曝光(Exposure)、对比度、高光、阴影、白色色阶、黑色色阶**,配合边缘感知的局部色调映射(对对数亮度做引导滤波)。
|
||||
把高光拉到 −100,就能救回一片过曝的天空,而且不会出现用简单曲线时会有的灰色光晕。
|
||||
|
||||
### 🎨 Color
|
||||
按**色温**和**色调**调整**白平衡**(raw 用开尔文,JPEG 用相对值),带预设、自动,以及点击即可中和的**吸管**。
|
||||
保护肤色的**鲜艳度**、**饱和度**、8 个色段的**颜色混合器**(色相/饱和度/亮度),以及带混合与平衡的
|
||||
三路**颜色分级**色轮。所有计算都在 OkLCh 这个现代感知色彩空间里完成。
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
### ✨ Effects
|
||||
让细节更细腻的**纹理(Texture)**、让中间调更有力的**清晰度**、**去朦胧**(暗通道先验加引导滤波细化;
|
||||
推到负值可以增加空气感)、裁剪后带高光优先、圆度和羽化的**暗角**,以及分辨率无关、可调大小与粗糙度的
|
||||
胶片**颗粒**。
|
||||
|
||||
### 📈 Tone Curve
|
||||
带可移动分割点的参数化区域曲线,**外加** RGB、红、绿、蓝的点曲线。曲线在构造上就是单调的,
|
||||
所以不会意外出现影调反转。
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/curve-tetons.jpg" alt="Light 面板下方打开的色调曲线,RGB 点曲线上是一条平缓的 S 形曲线,作用于《特顿山脉与蛇河》" width="100%">
|
||||
<br>
|
||||
<sub>RGB 点曲线上一条平缓的 S 形曲线,就在 Light 滑块正下方。<i>安塞尔·亚当斯,1942(公有领域)。</i></sub>
|
||||
</p>
|
||||
|
||||
<br>
|
||||
|
||||
## Color grading, the cinematic way
|
||||
|
||||
用随处拖动的色轮,分别给阴影、中间调和高光做分离色调。下图中,多萝西娅·兰格的
|
||||
*《移民母亲》* 只拖动两次就获得了温暖的、类似照片冲印的色调(高光 42°,阴影 28°)。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/grading-migrant-mother.jpg" alt="中间调、阴影和高光的颜色分级色轮,旁边是多萝西娅·兰格的《移民母亲》,被调成温暖的冲印色调" width="100%">
|
||||
<br>
|
||||
<sub>Color 面板中的阴影、中间调和高光色轮。<i>多萝西娅·兰格,《移民母亲》(1936)。公有领域,美国国会图书馆。</i></sub>
|
||||
</p>
|
||||
|
||||
<br>
|
||||
|
||||
## Before & after
|
||||
|
||||
按住 <kbd>\\</kbd> 偷看原图,按 <kbd>Y</kbd> 并排对比,或按 <kbd>Shift</kbd>+<kbd>Y</kbd> 分屏查看。
|
||||
下面每一张都是 LightCraft 的真实截图,由智能体通过[控制通道](#built-for-agents)自动截取。
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/images/ba-tetons.jpg" alt="《特顿山脉与蛇河》的并排修改前后对比:修改后的云层更深,河岸边的阴影更开阔"><br><sub><b>《特顿山脉与蛇河》。</b> 高光 −45、阴影 +38、清晰度 +28、去朦胧 +18。<i>安塞尔·亚当斯,1942(公有领域)。</i></sub></td>
|
||||
<td width="50%"><img src="docs/images/ba-migrant-mother.jpg" alt="《移民母亲》的并排修改前后对比:修改后更温暖,阴影被提亮"><br><sub><b>《移民母亲》。</b> 阴影 +42、纹理 +18、分离色调、暗角。<i>多萝西娅·兰格,1936(公有领域)。</i></sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/images/ba-earthrise.jpg" alt="《地出》的并排修改前后对比:月球地平线上方是地球,修改后略暖、更浓郁"><br><sub><b>《地出》。</b> 去朦胧 +22、高光 −30、更暖的白平衡、鲜艳度 +22。<i>NASA/比尔·安德斯,阿波罗 8 号,1968(公有领域)。</i></sub></td>
|
||||
<td width="50%"><img src="docs/images/ba-blue-marble.jpg" alt="《蓝色弹珠》的并排修改前后对比:修改后黑色更深,云层细节更结实"><br><sub><b>《蓝色弹珠》。</b> 高光 −38、黑色色阶 −20、去朦胧 +15、鲜艳度 +30。<i>NASA,阿波罗 17 号,1972(公有领域)。</i></sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<br>
|
||||
|
||||
## Masking that goes where you point
|
||||
|
||||
用**画笔**涂抹(大小、羽化、流量、密度、擦除),放置带可拖动控制点的**线性渐变**和**径向渐变**,
|
||||
或者按**亮度范围**、**颜色范围**、**天空**、**主体**和**背景**来选择。用**添加/减去/相交**组合各个组件,
|
||||
任意反相,每个蒙版还能调 15 项局部调整(色温、色调、曝光、对比度、高光、阴影、白色色阶、黑色色阶、纹理、
|
||||
清晰度、去朦胧、色相、饱和度、锐化、降噪)以及一个总体的数量。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/masking.jpg" alt="蒙版面板中有一个线性天空蒙版和一个径向阳光光晕蒙版;径向渐变在湖景照片上以红色叠加层画在太阳周围" width="100%">
|
||||
<br>
|
||||
<sub>叠加在线性「天空」蒙版之上的径向「阳光光晕」蒙版(色温 +40、曝光 +0.50)。<i>照片来自 LightCraft 程序生成的演示图库。</i></sub>
|
||||
</p>
|
||||
|
||||
<br>
|
||||
|
||||
## Presets, profiles & the Color Mixer
|
||||
|
||||
内置十八个手工制作的预设(*Golden Hour、Teal & Orange、Faded Matte、Selenium Tone、Crisp
|
||||
Landscape* 等等),每个都有 0 到 200 % 的**数量**滑块。你可以从任意一组设置保存自己的预设、标记收藏,
|
||||
并在整个选区之间只复制、粘贴或同步你选定的那些分组。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/presets-mixer.jpg" alt="预设列按 B&W、Color、Film、Landscape、Portrait 和 Style 分组,旁边是 Color 面板,8 色段颜色混合器打开在紫色上" width="100%">
|
||||
<br>
|
||||
<sub>预设列与 8 色段颜色混合器并排。<i>照片来自演示图库。</i></sub>
|
||||
</p>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
### ✂️ Crop, straighten & geometry
|
||||
自由或锁定的长宽比(1:1、4:5、5:7、2:3、4:3、16:9、16:10、原始比例),拖动旋转拉直并始终把最大的裁剪框
|
||||
保持在画面内,三分线/网格/黄金比例叠加,翻转和 90° 旋转,外加手动垂直、水平、旋转、长宽比、
|
||||
缩放和位移变换。
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
### ⚫️ Black & White
|
||||
一键(<kbd>V</kbd>)转为单色,配合 8 色段**黑白混合**压暗天空或让树叶发亮。
|
||||
之后再用颜色分级调出硒调、棕褐或分离色调的印相效果。
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/images/crop-tetons.jpg" alt="《特顿山脉与蛇河》上的裁剪工具:一个略微旋转的 16:9 裁剪框,带三分线叠加,右侧是几何滑块"><br><sub>一个 16:9 的裁剪框拉直了 +2.5°,带三分线叠加。<i>安塞尔·亚当斯,1942(公有领域)。</i></sub></td>
|
||||
<td width="50%"><img src="docs/images/bw-split.jpg" alt="沙丘的分屏修改前后视图:左边是彩色,右边是带颗粒和暗角的黑白"><br><sub>分屏视图:左边是彩色原图,右边是带清晰度、暗角和颗粒的黑白。<i>演示图库。</i></sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<br>
|
||||
|
||||
## Organize everything
|
||||
|
||||
一个不碍事的图库:**所有照片**、**最近添加**、**留用**、**按日期**、嵌套在**文件夹**里的**相册**,
|
||||
以及**最近删除**。用 <kbd>0</kbd>–<kbd>5</kbd> 评星,用 <kbd>P</kbd> / <kbd>X</kbd> /
|
||||
<kbd>U</kbd> 加旗标,用 <kbd>6</kbd>–<kbd>9</kbd> 加颜色标签。搜索支持字段:
|
||||
`rating:>3 flag:pick iso:>800 camera:x2 date:2026-04 keyword:mountains`。每个视图都能按拍摄日期、
|
||||
导入日期、编辑日期、名称、星级、大小或随机排序(一种稳定的乱序;「视图 → 排序 → 重新乱序」可换一个顺序)。
|
||||
两端对齐的**照片网格**和**正方形网格**视图都做了虚拟化,
|
||||
所以无论是四十张还是四万张照片都同样顺滑。
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/images/grid-demo.jpg" alt="24 张演示照片的两端对齐照片网格,带星级和旗标,侧边栏是 Travel 2026 文件夹下嵌套的相册"><br><sub>照片网格,相册嵌套在文件夹里,带星级和旗标。<i>演示图库。</i></sub></td>
|
||||
<td width="50%"><img src="docs/images/grid-pd.jpg" alt="正方形网格,显示四张公有领域展示照片及其星级和留用旗标"><br><sub>四张公有领域展示照片的正方形网格。</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"><img src="docs/images/info-earthrise.jpg" alt="《地出》的信息面板,显示文件名、2400 × 2400 的 JPEG 尺寸、星级、标题字段和相机元数据"><br><sub>信息面板:文件、尺寸、星级、标题、题注、版权和相机元数据。<i>NASA/比尔·安德斯,《地出》,阿波罗 8 号,1968(公有领域)。</i></sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<br>
|
||||
|
||||
## Built for agents
|
||||
|
||||
LightCraft 里的每一个菜单项、滑块、画笔笔触、裁剪手柄和按键都是一个**命令**,拥有稳定的 ID 和
|
||||
JSON 参数。界面、键盘、CLI、JSON-lines 控制通道和一个 **MCP 服务器**全都通过同一个入口分发。
|
||||
智能体可以挑片、显影、给天空加蒙版并导出,而且能*看到*结果。
|
||||
|
||||
```sh
|
||||
lightcraft --control 7980 ~/Pictures/trip
|
||||
```
|
||||
```jsonc
|
||||
{"method": "engine.execute", "params": {"command": "photo.flag", "params": {"flag": "pick"}}}
|
||||
{"method": "engine.execute", "params": {"command": "develop.set", "params": {"values": {"light.highlights": -45, "light.shadows": 38}}}}
|
||||
{"method": "engine.execute", "params": {"command": "mask.add", "params": {"kind": "radial", "center": [0.62, 0.4], "rx": 0.2, "ry": 0.14}}}
|
||||
{"method": "ui.clickWidget", "params": {"id": "slider:effects.clarity"}} // 按名称操作任意控件
|
||||
{"method": "ui.pointer", "params": {"events": [{"kind":"down","x":0.2,"y":0.3}, {"kind":"up","x":0.4,"y":0.3}]}}
|
||||
{"method": "ui.screenshot", "params": {"path": "after.png"}}
|
||||
```
|
||||
|
||||
- **命令注册表。** `engine.commands` 列出可用命令;`develop.controls` 列出每个滑块的范围、
|
||||
默认值和当前值。
|
||||
- **MCP 服务器。** `lightcraft-cli mcp` 把命令注册表开放给 Claude(或任何 MCP 客户端),
|
||||
同时提供导入、查询、显影、蒙版、渲染(以图像返回)和导出的辅助工具。它既能无界面运行,也能附着
|
||||
到正在运行的应用上,支持截图、点击和手势。见 [docs/mcp.md](docs/mcp.md)。
|
||||
|
||||
```sh
|
||||
cargo build --release -p lightcraft-cli
|
||||
claude mcp add lightcraft -- "$PWD/target/release/lightcraft-cli" mcp ~/Pictures/shoot # 无界面
|
||||
claude mcp add lightcraft-app -- "$PWD/target/release/lightcraft-cli" mcp --connect 127.0.0.1:7980 # 驱动运行中的应用
|
||||
```
|
||||
- **可脚本化的 CLI:** `lightcraft-cli run --import in.dng develop.set control=light.exposure value=0.7 app.export
|
||||
path=out.jpg longEdge=2048` 可以运行任意命令链(无界面、在已保存的图库上,或对正在运行的应用),
|
||||
每条命令打印一行 JSON 结果;`lightcraft-cli render in.dng -o out.jpg --set light.exposure=0.7 --preset …`。
|
||||
- **一切皆可撤销**,包括智能体的操作:一次滑块拖动(或脚本里一连串更新)算一步撤销。
|
||||
- **联系表(原生应用/CLI):** 选中照片,然后 **文件 → 联系表 PDF…**。可选择 A4 或 Letter、横向、
|
||||
行数和列数以及文件名题注;保存分页 PDF,再用你的 PDF 阅读器打印。导出使用当前编辑,每张照片完整放入
|
||||
而不再次裁剪,以 150 dpi 和 sRGB 渲染。后台导出支持取消,并保护原文件和 XMP 附属文件。从 CLI 调用:
|
||||
|
||||
```sh
|
||||
lightcraft-cli run --import ~/Pictures/shoot library.selectAll export.contactSheet path=Contact.pdf paper=a4 columns=3 rows=4 captions=true
|
||||
```
|
||||
|
||||
`export.contactSheet` 还接受显式的 `ids`、`landscape=true` 和 `paper=letter`。只有当整个 PDF 生成成功后,
|
||||
它才会替换已有的输出文件。限制:1000 张照片、100 页、256 MiB;题注使用与水印相同的字体覆盖来栅格化
|
||||
(中日韩文字需要 craft-fonts),过长的名称会在中间省略。
|
||||
这是一套联系表工作流,
|
||||
不是打印驱动或自定义拼版编辑器。
|
||||
- **每个控件都可寻址**(`ui.widgets`)且可按名称点击,所以智能体操作的是真实界面,而不是绕到侧门。
|
||||
- 本 README 中的截图全部由 [`docs/showcase/`](docs/showcase/) 脚本端到端生成。
|
||||
协议参考:[docs/control-protocol.md](docs/control-protocol.md)。
|
||||
|
||||
<br>
|
||||
|
||||
## Fast, native, private
|
||||
|
||||
- **纯 Rust,无 C。** 我们自己的 RAW 解码器(DNG、佳能 CR2/CR3、索尼 ARW、尼康 NEF、富士 RAF 含 X-Trans、
|
||||
松下 RW2/徕卡 RWL、宾得 PEF、奥林巴斯 ORF)、我们自己的色彩科学、我们自己的管线。JPEG、PNG、TIFF、WebP、
|
||||
PSD 合成图和 JPEG XL 现在就能打开;加 `--features heif` 还能打开 HEIC/HEIF 照片(HEVC 是构建时的选择,
|
||||
与 PhotoCraft 中一致)。
|
||||
- **场景参考与宽色域。** 内部使用线性 Rec.2020 浮点、Bradford 适应的白平衡、用色域映射代替裁剪,
|
||||
raw 走胶片式肩部,未改动过的 JPEG 则像素级直通。
|
||||
- **分辨率无关的编辑。** 半径和画笔大小都相对于图像,所以 400 px 的预览、你的 5K 显示器和 60 MP 的导出
|
||||
看起来完全一致。
|
||||
- **GPU 加速,与 CPU 一致。** 整条显影管线都作为 wgpu 计算内核运行(Metal/Vulkan/DX12),
|
||||
并与 CPU 管线比对到 1/255 以内。在 24 MP raw 上(Apple M4 Pro):一次滑块更新约 4 ms 重新渲染,
|
||||
冷启动 2.5 MP 放大视图约 30 ms,全尺寸导出约 0.3 s(含并行 JPEG 编码)。
|
||||
没有 GPU 时同一条管线在所有 CPU 核心上运行,只重做受滑块影响的那些阶段。批量导出会并排渲染多张照片,
|
||||
所以一张的解码和编码能与另一张的渲染重叠。
|
||||
- **即时挑片。** 打开一张 raw 会在约 0.1 s 内显示其内嵌的相机预览或缓存渲染,完整渲染随后跟上
|
||||
(24 MP 约 0.2–0.5 s)。上一张和下一张会在后台准备好,因此翻阅一次拍摄时每张约 50 ms。
|
||||
- **后台渲染。** 一个工作线程池在 UI 线程之外渲染放大视图、修改前后对比和每一个可见缩略图:
|
||||
拖动时用草稿,松手后出全质量。
|
||||
- **本地优先。** 没有账号、没有云、没有遥测、没有订阅。你的目录是一份只追加的可读操作日志,
|
||||
可以 diff、备份或重放。
|
||||
|
||||
<br>
|
||||
|
||||
## Feature status
|
||||
|
||||
LightCraft 很年轻,而且进展很快。**我们如实所处的位置**(细节见[路线图](ROADMAP.md#where-we-stand)):
|
||||
|
||||
- **按功能数量算,我们达到了 Lightroom 的约 79%**(核心功能 98%),逐行记录在
|
||||
[docs/parity.md](docs/parity.md)。
|
||||
- **作为日常替代 Lightroom 的工具,我们更接近 60–70%。** 在单台机器上处理 JPEG/DNG 以及大多数尼康/索尼/
|
||||
较老佳能的 raw 都很出色。
|
||||
- **最大的缺口:**
|
||||
- **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCE-7CR、ILCE-7M4、X-H2S 和 X-T4 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵;
|
||||
- **压缩的奥林巴斯 raw 和不受支持的 CR3 变体:** 这些在有内嵌 JPEG 预览时使用预览。富士无损/有损压缩 RAF 现在能解码传感器数据;[验证与已有图库重新加载说明](docs/raf-compression.md);
|
||||
- **AI 蒙版与降噪:** 主体和天空选择目前是经典启发式算法;
|
||||
- **屏幕上的 HDR 显示、视频,以及经典版式中的打印/画册/地图模块。**
|
||||
- **接下来做什么:** 见[我们要去往哪里](ROADMAP.md#where-were-going)。
|
||||
|
||||
| 领域 | 状态 |
|
||||
|---|---|
|
||||
| 图库:相册、文件夹、智能相册、堆叠(含自动堆叠)、虚拟副本、星级、旗标、标签、过滤器栏、搜索、排序、网格、胶片窗格 | ✅ |
|
||||
| 挑片:比较(同步缩放)和筛选视图、自动前进、即时预览 | ✅ |
|
||||
| Light、Color、Effects(暗角样式)、色调曲线(+ 细化饱和度、目标调整)、颜色混合器(+ 目标调整)、点颜色、颜色分级、校准、黑白 | ✅ |
|
||||
| 蒙版:画笔、线性、径向、亮度/颜色范围、添加/减去/相交 | ✅(AI 主体/天空目前使用经典启发式算法) |
|
||||
| 裁剪、拉直工具 + 自动拉直、翻转、旋转、长宽比、叠加参考线 | ✅ |
|
||||
| 配置文件(Color、Neutral、Vivid、Landscape、Portrait、Monochrome:我们自己的风格)、预设、版本、历史记录、复制/粘贴/同步设置 | ✅ |
|
||||
| 相机色彩:DNG 文件使用其自带矩阵 | ✅ DNG · 🟡 我们自己的索尼/富士配置文件;缺少实测校准数据库 |
|
||||
| 原生 macOS 菜单栏(由命令注册表生成)、控制通道 + 每个控件可寻址、无界面 UI 快照 | ✅ |
|
||||
| RAW:DNG、CR2、CR3(无损 CRX Bayer 以及版本 0x100/0x200 的 C-RAW)、ARW、NEF(未压缩 + 无损/有损压缩)、富士 RAF(未压缩 + 无损/有损压缩,Bayer + X-Trans)、松下 RW2/徕卡 RWL/Panasonic RAW(所有 raw 格式,从 DMC-LX1 到 DC-S1RM2)、宾得 PEF、奥林巴斯 ORF(未压缩)、三星 SRW(未压缩);所有格式的嵌入预览,含 CR3 | 🟡 · CR3、压缩 ORF 和压缩 SRW 解码 ⬜ |
|
||||
| 细节:锐化、亮度 + 色彩降噪 | ✅ · AI 降噪、超分辨率 ⬜ |
|
||||
| 污点去除/修复/仿制(自动来源)、可视化斑点、红眼和宠物眼(自动瞳孔检测、眼神光) | ✅ · 内容识别填充、污点图钉编辑 🚧 |
|
||||
| 导出:JPEG/PNG/TIFF/WebP/AVIF/DNG/原始格式,尺寸调整、文件大小上限、输出锐化、命名模板、批处理、元数据策略、文字或图像水印 | ✅ |
|
||||
| HDR:带余量上限的 HDR 编辑、SDR 呈现、可视化 HDR;导出为 ISO 21496-1 增益图 JPEG、PQ AVIF 或 32 位浮点 TIFF | ✅ · HDR 显示 ⬜ |
|
||||
| 图库持久化(防崩溃的操作日志 + 快照、后台压缩、保存失败会上报)、磁盘缩略图缓存 | ✅ |
|
||||
| 导入:在原位置添加/复制/移动、重命名与文件夹模板、设备、重复检测、监视文件夹;本地文件夹浏览 | ✅ |
|
||||
| MCP 服务器(无界面或运行中的应用、持久化图库)、CLI、控制通道 | ✅ |
|
||||
| XMP 附属文件(读取/写入、自动写入)、读取 `crs:` 显影设置、预设文件(`.lcpreset`、XMP 预设) | ✅ |
|
||||
| 镜头校正(畸变、暗角、自动 + 手动色差、去边、DNG 文件内嵌的镜头校正以及松下/徕卡 RW2/RWL 畸变数据)、几何(变换、限制裁剪)、垂直校正(自动/水平/垂直/完整/引导式) | ✅ · 相机镜头配置文件(我们自己的)⬜ |
|
||||
| 照片合并:HDR(自动对齐、去重影)、全景(球面/圆柱/透视、边界变形、自动裁剪)、HDR 全景 → DNG | ✅ |
|
||||
| GPU 管线(wgpu 计算,与 CPU 一致到 1/255 以内)、设备限制/出错时回退到 CPU | ✅ · 浏览器中的 WebGPU 🚧 |
|
||||
| AI:分割蒙版、AI 降噪、超分辨率、人脸;视频 | ⬜(见[路线图](ROADMAP.md#where-were-going)) |
|
||||
| 网页版(通过 WASM 在浏览器中使用同一界面):图库持久化在 OPFS/IndexedDB、Web Worker 渲染、导出下载 | ✅ · WebGPU、Safari/Firefox 测试 🚧 |
|
||||
|
||||
<sub>✅ 现已可用 · 🚧 进行中 · ⬜ 尚未开始</sub>
|
||||
|
||||
<br>
|
||||
|
||||
## Quick start
|
||||
|
||||
```sh
|
||||
git clone https://github.com/storytold/lightcraft && cd lightcraft
|
||||
cargo run --release -p lightcraft # 打开你的图库(~/Pictures/LightCraft Library;新图库会先带演示照片)
|
||||
cargo run --release -p lightcraft -- ~/Pictures/trip # 导入你的照片(扫描文件夹,跳过重复项)
|
||||
cargo run --release -p lightcraft -- --memory # 用完即弃的内存演示会话(不写入任何内容)
|
||||
cargo run --release -p lightcraft -- --control 7980 # 带自动化通道
|
||||
cargo xtask web --serve # 在浏览器中运行同一个应用:http://127.0.0.1:8080/
|
||||
cargo run --release -p lightcraft-cli -- render photo.jpg -o out.jpg --set light.exposure=0.5
|
||||
cargo xtask ci # fmt、clippy、测试、分层、wasm 检查
|
||||
```
|
||||
|
||||
CI 默认使用行表调试信息,并按启动时可用内存每 1.5 GB 开一个构建任务
|
||||
(每个 CPU 最多一个;读不到内存时为 4 个),所以一台 8 GB、空闲 6 GB 的机器会用 4 个任务构建,
|
||||
而一台空闲内存很少的繁忙机器会用更少。测试线程数随之调整,上限为 4。否则,每个 CPU 一个的
|
||||
完整调试信息链接器会在任何测试运行之前就耗尽内存。GPU 覆盖范围不变。显式设置
|
||||
`CARGO_PROFILE_DEV_DEBUG`、`CARGO_BUILD_JOBS` 和 `RUST_TEST_THREADS` 会覆盖这些默认值。
|
||||
日常开发命令保持它们各自的设置。
|
||||
|
||||
**中文和日文文字**需要共享字体仓库,这是一个可选的构建输入(所有正式发布版都会包含它):
|
||||
|
||||
```sh
|
||||
git clone https://github.com/storytold/craft-fonts ../craft-fonts
|
||||
CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft
|
||||
```
|
||||
|
||||
不指定它时,LightCraft 构建和运行的方式完全相同,只是中文和日文文字没有字形。字体从不提交到
|
||||
本仓库;见 [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)。
|
||||
|
||||
**界面语言:** **编辑 → 语言**(English、简体中文、繁體中文(台灣)、日本語、Português (Brasil)、Español、Deutsch、Русский、Français)或 **设置 → 一般 →
|
||||
语言**;选择立即生效并会保留。见 [docs/localization.md](docs/localization.md)。
|
||||
|
||||
**日志:** 桌面应用把日志写到标准错误,以及设置文件夹里的 `logs/lightcraft.log`
|
||||
(Linux `$XDG_CONFIG_HOME/lightcraft/logs/`,默认 `~/.config/lightcraft/logs/`;macOS
|
||||
`~/Library/Application Support/LightCraft/logs/`;Windows `%APPDATA%\LightCraft\logs\`),从不写进图库。
|
||||
从桌面菜单或 Dock 启动没有终端,所以请把这个文件附在错误报告里(**帮助 ▸ 打开日志
|
||||
文件夹** 会在文件管理器中显示它)。每次启动会把
|
||||
上一个日志移到 `lightcraft.1.log`,再上一个移到 `lightcraft.2.log`,这样崩溃那次的日志能在下次启动后
|
||||
保留下来;文件到 16 MiB 停止增长,`--version` 和 `--help` 不写日志,带
|
||||
`LIGHTCRAFT_NO_PREFS` 运行时只写到标准错误。默认情况下 LightCraft 自己的 crate 记到 `info`,其他都记到
|
||||
`warn`。`LIGHTCRAFT_LOG=info` 或 `debug` 的行为和以前一样(LightCraft 自己的 crate 用该级别,其余只记警告和
|
||||
错误;其他值:只记警告和错误),并且优先于 `RUST_LOG`,后者会把默认值替换为
|
||||
env_logger 风格的指令,例如 `RUST_LOG=debug` 或 `RUST_LOG=warn,lightcraft_pipeline=trace`(以
|
||||
`*` 结尾的指令覆盖所有以它开头的目标,如 `lightcraft*=debug`)。panic 也会记录在那里,
|
||||
并且仍然写在临时文件夹里的 `lightcraft-panics.log`。日志器是 `apps/lightcraft/src/logging.rs`。
|
||||
|
||||
网页版需要 `wasm32-unknown-unknown` target 和匹配的 `wasm-bindgen` CLI
|
||||
(`cargo xtask web` 会打印确切的安装命令);见 [docs/web.md](docs/web.md)。
|
||||
|
||||
**Nix** 会构建桌面应用和 `lightcraft-cli`(Nix 构建总是包含 craft-fonts 输入,所以日文
|
||||
文字有字形):
|
||||
|
||||
```sh
|
||||
nix run github:storytold/lightcraft # 桌面应用
|
||||
nix build github:storytold/lightcraft # → ./result/bin/{lightcraft,lightcraft-cli}
|
||||
nix develop github:storytold/lightcraft # rust 工具链 + 原生依赖 + 字体
|
||||
```
|
||||
|
||||
在 flake 配置中(NixOS、home-manager、nix-darwin):
|
||||
|
||||
```nix
|
||||
# flake.nix
|
||||
inputs.lightcraft.url = "github:storytold/lightcraft";
|
||||
# 可选:使用你自己的 nixpkgs,而不是 LightCraft 锁定的那个
|
||||
# inputs.lightcraft.inputs.nixpkgs.follows = "nixpkgs";
|
||||
|
||||
# 然后在 NixOS 或 home-manager 模块中(`inputs` 在作用域内):
|
||||
nixpkgs.overlays = [ inputs.lightcraft.overlays.default ]; # 让 `pkgs.lightcraft` 可用
|
||||
environment.systemPackages = [ pkgs.lightcraft ]; # home-manager:home.packages = [ pkgs.lightcraft ];
|
||||
```
|
||||
|
||||
`nix build` 会安装与 .deb/.rpm 相同的 desktop 文件、hicolor 图标和 AppStream 元数据,并运行
|
||||
`cargo test --workspace` 作为它的检查阶段(用 `pkgs.lightcraft.overrideAttrs { doCheck = false; }` 跳过)。
|
||||
|
||||
**键盘:** <kbd>G</kbd> 网格 · <kbd>D</kbd> 细节 · <kbd>E</kbd> 编辑 · <kbd>C</kbd> 裁剪 · <kbd>M</kbd> 蒙版 ·
|
||||
<kbd>Shift</kbd>+<kbd>P</kbd> 预设 · <kbd>\\</kbd> 原图 · <kbd>Y</kbd> 修改前后 · <kbd>Z</kbd> 缩放 ·
|
||||
<kbd>J</kbd> 溢出警告 · <kbd>⌘Z</kbd> 撤销 · <kbd>⌘/</kbd> 全部快捷键。
|
||||
|
||||
## How it's built
|
||||
|
||||
一个引擎优先的 Cargo 工作区,由小而经过测试的 crate 组成,并强制分层(`cargo xtask layers`):`geom`、
|
||||
`color`、`raster`、`tiff` → `raw`、`codecs`、`meta`、`develop` → `pipeline` → `catalog` → `engine` → `ui-egui`。
|
||||
egui 前端是一个可替换的 crate;它下面没有任何东西知道 UI 的存在。`mcp` 和应用(`lightcraft`、
|
||||
`lightcraft-cli`)坐落在 `engine` 之上。
|
||||
|
||||
## Contributing
|
||||
|
||||
人和智能体遵循同样的规则,所以请先读 [AGENTS.md](AGENTS.md)。简短版本:
|
||||
|
||||
- **净室。** 绝不阅读 Adobe 的二进制文件或 GPL 的 raw/照片代码(darktable、RawTherapee、LibRaw、rawspeed、dcraw……);
|
||||
只依据公开规范和黑盒观察来工作。
|
||||
- **绝不使用 Adobe 资源:** 不使用 Adobe 产品的图标、截图、预设、配置文件、LUT 或字体。仓库中的每一个
|
||||
图像、图标和字体都是原创、公有领域、知识共享、OFL 或宽松许可的,并在同一提交中
|
||||
于 [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) 添加条目。新字体提交到
|
||||
[storytold/craft-fonts](https://github.com/storytold/craft-fonts),而不是这里。
|
||||
- **纯 Rust**、强制 crate 分层、一切皆命令,并且每次提交前 `cargo xtask ci` 必须通过
|
||||
(一个提交一个任务 ID)。
|
||||
- **绝不崩溃。** 非测试代码返回错误而不是 panic:不用 `unwrap()`、`expect()`、`panic!` 或
|
||||
`unsafe`,对任何来自输入的数据都做受检索引,并且每个崩溃修复都带一个回归测试。细节见
|
||||
[AGENTS.md](AGENTS.md#never-crash-outranks-feature-work)。
|
||||
|
||||
有问题、想法,或者想先讨论一个 bug?来 [Discord](https://discord.gg/artcraft)。
|
||||
|
||||
<br>
|
||||
|
||||
## Downloads
|
||||
|
||||
**第一次用 LightCraft?** 从 [getartcraft.com 的 LightCraft 页面](https://getartcraft.com/apps/lightcraft)下载。那是最简单的安装方式。
|
||||
|
||||
**想要特定的构建或格式?** 在 GitHub 上,[最新发布](https://github.com/storytold/lightcraft/releases/latest)包含下面列出的所有构建,[全部发布](https://github.com/storytold/lightcraft/releases)则有更早的版本及其说明。文件名中的 `<ver>` 是版本号,`SHA256SUMS.txt` 列出了每个文件的校验和。
|
||||
|
||||
### Windows
|
||||
|
||||
| 构建 | 安装程序 | 便携版 |
|
||||
|---|---|---|
|
||||
| x64(64 位 Intel/AMD) | `lightcraft-<ver>-windows-x64.msi` | `lightcraft-<ver>-windows-x64-portable.zip` |
|
||||
| arm64(骁龙及其他 ARM PC) | `lightcraft-<ver>-windows-arm64.msi` | `lightcraft-<ver>-windows-arm64-portable.zip` |
|
||||
| x86(32 位) | `lightcraft-<ver>-windows-x86.msi` | `lightcraft-<ver>-windows-x86-portable.zip` |
|
||||
|
||||
安装程序和可执行文件都有代码签名。安装程序会询问安装位置(默认
|
||||
`C:\Program Files\LightCraft`;升级会留在你选择的文件夹),结束时有一页确认 LightCraft 已安装,
|
||||
并可选择启动它。无人值守安装:`msiexec /i lightcraft-<ver>-windows-x64.msi /qn INSTALLFOLDER="D:\Apps\LightCraft\"`。
|
||||
|
||||
### macOS
|
||||
|
||||
| 构建 | 文件 | 说明 |
|
||||
|---|---|---|
|
||||
| 应用,通用(Apple 芯片 + Intel) | `lightcraft-<ver>-macos-universal.dmg` | 已签名并公证 |
|
||||
| 命令行工具,通用 | `lightcraft-cli-<ver>-macos-universal.zip` | 已签名并公证 |
|
||||
|
||||
### Linux
|
||||
|
||||
| 格式 | x86_64 | aarch64 (ARM64) | 说明 |
|
||||
|---|---|---|---|
|
||||
| AppImage | `lightcraft-<ver>-linux-x86_64.AppImage` | `lightcraft-<ver>-linux-aarch64.AppImage` | 随处可运行;用 [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate)(`.zsync` 文件)自我更新 |
|
||||
| Flatpak | `lightcraft-<ver>-linux-x86_64.flatpak` | `lightcraft-<ver>-linux-aarch64.flatpak` | 沙箱化;`flatpak install --user <file>` |
|
||||
| Debian/Ubuntu | `lightcraft-<ver>-linux-x86_64.deb` | `lightcraft-<ver>-linux-aarch64.deb` | |
|
||||
| Fedora/RHEL/openSUSE | `lightcraft-<ver>-linux-x86_64.rpm` | `lightcraft-<ver>-linux-aarch64.rpm` | |
|
||||
| 压缩包 | `lightcraft-<ver>-linux-x86_64.tar.gz` | `lightcraft-<ver>-linux-aarch64.tar.gz` | 解压到任意位置 |
|
||||
|
||||
### FreeBSD
|
||||
|
||||
| 构建 | 文件 |
|
||||
|---|---|
|
||||
| x86_64 | `lightcraft-<ver>-freebsd-x86_64.tar.gz` |
|
||||
|
||||
### 网页版(WebAssembly)
|
||||
|
||||
| 构建 | 文件 | 说明 |
|
||||
|---|---|---|
|
||||
| 静态站点 | `lightcraft-web-<ver>.zip` | 在现代浏览器中运行;托管到任意静态服务器 |
|
||||
|
||||
<br>
|
||||
|
||||
## The Crafting Apps
|
||||
|
||||
LightCraft 是 **Crafting Apps** 之一:来自 [ArtCraft](https://getartcraft.com/) 团队的免费开源创作工具,
|
||||
每一个都用 Rust 从零编写,每一个都能独立使用。
|
||||
|
||||
| | 应用 | 用途 | 代码 | 了解更多 |
|
||||
|:-:|---|---|---|---|
|
||||
| <img src="https://raw.githubusercontent.com/storytold/photocraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png" alt="" width="32" height="32"> | **PhotoCraft** | 图像编辑:图层、蒙版、文字和真正的 PSD 文件 | [GitHub](https://github.com/storytold/photocraft) | [网站](https://getartcraft.com/apps/photocraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | 矢量插画 | [GitHub](https://github.com/storytold/vectorcraft) | [网站](https://getartcraft.com/apps/vectorcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | 视频剪辑、调色与声音 | [GitHub](https://github.com/storytold/filmcraft) | [网站](https://getartcraft.com/apps/filmcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | **照片图库与 raw 显影 · 你在这里** | [GitHub](https://github.com/storytold/lightcraft) | [网站](https://getartcraft.com/apps/lightcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | 阅读、整理和保护 PDF | [GitHub](https://github.com/storytold/pdfcraft) | [网站](https://getartcraft.com/apps/pdfcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | 动态图形与视觉特效 | [GitHub](https://github.com/storytold/effectcraft) | [网站](https://getartcraft.com/apps/effectcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | 页面排版与出版 | [GitHub](https://github.com/storytold/designcraft) | [网站](https://getartcraft.com/apps/designcraft) |
|
||||
|
||||
以及 [**ArtCraft**](https://getartcraft.com/) 本身,我们为想要真正掌控力的创作者打造的 AI 图像与视频工作室。
|
||||
|
||||
<br>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/artcraft"><img alt="Join the ArtCraft community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
|
||||
</p>
|
||||
|
||||
<h3 align="center">Come make things with us</h3>
|
||||
|
||||
<p align="center">
|
||||
我们的 Discord 是各种创作者聚集的地方:画画的人、拍照的人、做动画的人、剪片子的人、
|
||||
排版的人,还有仍在摸索自己喜欢做什么的人。分享你在做的东西,
|
||||
寻求帮助,告诉我们哪里坏了,或者告诉我们你希望这些工具能做什么。
|
||||
无论你用什么媒介,无论你做了多久,这里都欢迎你。
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/artcraft"><b>discord.gg/artcraft</b></a> ·
|
||||
<a href="https://getartcraft.com/">getartcraft.com</a> ·
|
||||
<a href="https://getartcraft.com/apps">The Crafting Apps</a> ·
|
||||
<a href="https://getartcraft.com/apps/lightcraft">LightCraft</a>
|
||||
</p>
|
||||
|
||||
<br>
|
||||
|
||||
## License and credits
|
||||
|
||||
LightCraft 采用 [MIT](LICENSE-MIT) 或 [Apache-2.0](LICENSE-APACHE) 双许可,任你选择。
|
||||
Copyright (c) 2026 ArtCraft Team and the LightCraft contributors. 必需的声明见 [NOTICE](NOTICE)。
|
||||
|
||||
随附的字体、图标、图像和其他资源保留各自的开放许可;每一项都在
|
||||
[assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) 中列出了作者、来源和许可。
|
||||
|
||||
展示照片是经 Wikimedia Commons 使用的公有领域作品:安塞尔·亚当斯,*《特顿山脉与蛇河》*
|
||||
(1942,美国国家档案馆);多萝西娅·兰格,*《移民母亲》*(1936,美国国会图书馆);比尔·安德斯/NASA,
|
||||
*《地出》*(1968);NASA,*《蓝色弹珠》*(1972)。演示图库由 LightCraft 程序生成。界面字体:
|
||||
Inter(SIL OFL 1.1)。用 [craft-fonts](https://github.com/storytold/craft-fonts) 构建的版本(所有正式发布版)
|
||||
还会内嵌它的中文和日文字体(Noto Sans CJK SC、BIZ UDPGothic、BIZ UDMincho、Shippori Mincho;SIL OFL 1.1),列在其
|
||||
[ATTRIBUTION.md](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md) 中。所有图标均为原创。
|
||||
|
||||
[`docs/brand/`](docs/brand/) 中的 ArtCraft 名称、字标和标志是
|
||||
ArtCraft 团队的商标,不在本许可范围内。它们只能在未修改的情况下,作为
|
||||
本仓库和 LightCraft 的一部分,按 [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) 使用。
|
||||
分支和修改版本必须移除它们。
|
||||
|
||||
<sub>Adobe、Photoshop、Illustrator、Premiere Pro、Lightroom、Acrobat、After Effects 和 InDesign 是 Adobe Inc. 在美国和/或其他国家的商标或注册商标。LightCraft 是一个独立的开源项目,与 Adobe Inc. 无隶属关系,也未获其赞助或认可;这些名称仅用于说明它所兼容的工作流。</sub>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
|
||||
<sub>由 <a href="https://getartcraft.com/">ArtCraft</a> 团队和社区制作。</sub>
|
||||
</p>
|
||||
|
||||
## Star history
|
||||
|
||||
[](https://www.star-history.com/?repos=storytold%2Flightcraft&type=date&legend=top-left)
|
||||
+144
-214
@@ -1,242 +1,172 @@
|
||||
# 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-11 · **Change:** minor (EOS R5 Mark II CR3 verified; RAW decoding 77%) · **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, uncompressed Samsung SRW (NX5 to NX1100, EX1, WB2000), RW2 / RWL / Panasonic RAW (every raw format, checked on 178 files from 118 Panasonic and Leica bodies), PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini), Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Nikon High Efficiency NEF (HE / HE★: Z 8, Z 9, Z 6III, Z f; JPEG XS-based, preview only, #193). Per-model verification is thin (~55 corpus files vs >1,000 models) |
|
||||
| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF, RW2, RAF, CR3, CR2 and PEF have guarded camera-JPEG colour estimates, with bundled ILCE-7CR, ILCE-7M4, X-H2S and X-T4 profiles (docs/camera-preview-colour.md); 52 models whose JPEG fit is rejected start from matrices fitted to measured spectral sensitivities; other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
|
||||
| **AI & computational** | ~15–20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics, or the photo's own segmentation mattes when it ships them (DNG semantic masks, e.g. iPhone ProRAW's sky matte); non-destructive Bayer RAW denoise with opt-in weights on pure-Rust CPU/GPU; **faces (MVP, [docs/faces.md](docs/faces.md))**: a pure-Rust detector (a 232 KB opt-in download), opt-in recognition models (one-click download or add a file; they run on the shared pure-Rust CPU interpreter; pinned SFace/AuraFace embeddings agree with an independent reference runtime on 40 public crops; one-thread medians 34–36 / 233–252 ms (AuraFace remains expensive)), a background scan that finds and embeds faces, name suggestions, a person's page with look-alike faces to confirm, an Unnamed faces section (look-alikes together, select a group and name it at once) | Real segmentation masks (subject, sky, people, objects, landscape, depth), denoise model policy / linear RGB / quality verification, super resolution, lens blur, generative remove, natural-language search; for faces: clusters with separators between groups, small faces in large photos (the detector looks at 640 px), thresholds calibrated on live faces, writing face regions back to XMP. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) |
|
||||
| **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering |
|
||||
| **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic), paginated contact-sheet PDF export (A4/Letter, layout/captions, current edits, 150 dpi sRGB) | **Map view, Book, full Print module, Slideshow module, Web, publish services**: contact sheets work, custom print packages and printer colour management remain missing |
|
||||
| **HDR & video** | ~25% | HDR editing (headroom limit, SDR rendition, Visualize HDR); gain map JPEG, PQ AVIF and float TIFF export | On-screen HDR display, HLG, HDR settings from crs XMP; video play/trim/edit/export |
|
||||
| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; English, Simplified/Traditional Chinese, Japanese, Brazilian Portuguese, Spanish, German, Russian and Ukrainian UI (see docs/localization.md); remaining technical errors; Traditional Chinese uses the Simplified Chinese font until craft-fonts has a TC face; accessibility partial; headless UI tests time out under machine load |
|
||||
|
||||
### By kind of user
|
||||
|
||||
| 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); embedded distortion is supported for Panasonic / Leica RW2 and the Sony ILCE-7RM4A (Bayer ARW, 24–105 / 200–600 tested); other lens metadata needs coverage and validation |
|
||||
| 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); Bayer AI denoise needs separately installed weights; X-Trans/linear RGB, model policy and quality parity remain open |
|
||||
| **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,
|
||||
Pentax PEF, Samsung SRW and Olympus ORF have the file-local fit (matrix + tone curve from their own JPEG; CR2 and PEF
|
||||
since issue #310, not yet checked on the corpus); 52 models whose fit is rejected fall back to matrices fitted to
|
||||
measured spectral sensitivities (rawtoaces-data); then validate fidelity.
|
||||
2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): Remaining **unverified CR3 CRX** variants, compressed ORF, NEF
|
||||
lossy-after-split, sRAW. Decided 2026-10-05: write our own decoders from prose descriptions (never decoder source,
|
||||
no LGPL dependency); compressed NEF (#86) is the template.
|
||||
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): DNG, Panasonic / Leica RW2 and Sony ILCE-7RM4A Bayer ARW
|
||||
embedded distortion are supported; broaden validation and decode remaining vignetting / lateral CA metadata.
|
||||
6. **AI model strategy** (maintainer decision): which permissively licensed models (or our own training) for
|
||||
segmentation masks and denoise. Bayer denoise inference is implemented in pure Rust with an opt-in GPL model; release policy and independently licensed weights remain open. Unblocks M12 and Enhance. Face detection and recognition run on our own checked Rust engine. Object / Describe masks now
|
||||
run Meta's SAM 3 in pure Rust (optional, user-consented download under the non-OSI SAM License; CDN mirrors still
|
||||
to be set up, docs/ai-masks.md); Subject / Sky / People, Raw Details and Super Resolution are still open.
|
||||
7. **Then:** HDR (Q), the Classic output modules (expand contact-sheet PDF export into full Print, 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 four verified Canon bodies, compressed ORF and HE NEF
|
||||
open as previews, rendering is tuned by eye, AI masks are heuristics, there is no lens database, and
|
||||
tests run in CI on FreeBSD only. **Why alpha:** core workflows run end to end, but readiness is below
|
||||
the ~75% beta bar (it is above the ~40% alpha bar) and Lightroom's main inputs, raw files, still have blocking gaps (Canon, ~30% of
|
||||
raw users, decodes CR3 on four verified bodies). It passes the alpha gate: all six core workflows
|
||||
(import, cull, develop, save and reopen, batch looks, export) work end to end on macOS
|
||||
([docs/roadmap.md](docs/roadmap.md#alpha-gate)). The three readiness numbers and their weights and discounts are in
|
||||
[target-app-parity.md](docs/target-app-parity.md#ready-for-real-work-mainstream-practitioner-and-essentials-user).
|
||||
Hours are calibrated on this repo's PR history; see
|
||||
[target-app-parity.md](docs/target-app-parity.md#how-hours-are-calibrated).
|
||||
|
||||
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
|
||||
|
||||
Library folders now have records of their own in the catalog (`Catalog.folder_records`, keyed by folder path; colour
|
||||
labels first). Photos still store full paths, so moving a whole disk or folder relinks each photo. If that becomes a
|
||||
problem (an "Update Folder Location" for a renamed or replaced drive, journal growth on large moves), the next step is
|
||||
the Classic model: photos point to a folder id (root + relative path), and the records keyed by path become those rows.
|
||||
| Dimension | Ready | Breadth | Remaining | Doc |
|
||||
|---|---:|---:|---:|---|
|
||||
| Features | ~61% | ~77% (checklist 81.1%) | 600–1,100 h | [target-app-parity](docs/target-app-parity.md#features) |
|
||||
| ↳ RAW cameras, colour, lens profiles | ~56% | 81% of 1,446 Adobe models decode, 16% verified | 255–480 h | [raw-parity](docs/raw-parity.md) |
|
||||
| UI/UX fidelity | ~70% | ~85% | 40–70 h | [ui-parity](docs/ui-parity.md) |
|
||||
| File formats (non-raw) | ~70% | ~85% | 30–55 h | [file-format-parity](docs/file-format-parity.md) |
|
||||
| Hardware | ~40% | ~50% | 40–80 h | [hardware-parity](docs/hardware-parity.md) |
|
||||
| Localization | ~52% of the 12 key languages | 7 of 12 present | 130–210 h (+145–255 h new scripts) | [localization-parity](docs/localization-parity.md) |
|
||||
| Performance | ~70% | ~80% | 30–50 h | [target-app-parity](docs/target-app-parity.md#by-dimension) |
|
||||
| Stability | ~60% | — | 30–60 h | [target-app-parity](docs/target-app-parity.md#by-dimension) |
|
||||
| Platforms | ~60% | macOS, Windows, Linux, FreeBSD, web | 25–45 h | [target-app-parity](docs/target-app-parity.md#by-dimension) |
|
||||
| Ecosystem / plug-ins | ~15% | ~25% | 40–80 h | [target-app-parity](docs/target-app-parity.md#by-dimension) |
|
||||
| AI features | ~20% | ~30% | 120–220 h (overlaps features) | [target-app-parity](docs/target-app-parity.md#ai) |
|
||||
|
||||
Image navigation now supports native pinch zoom and two-finger pan in Detail, Compare and Reference views.
|
||||
The whole-image preview still has a configurable size cap (2,560 px default), so 100% on larger photos does not
|
||||
yet guarantee original-pixel detail; visible-region rendering remains a gap (LR-VIEW-ZOOM).
|
||||
## Features
|
||||
|
||||
## Milestones
|
||||
|
||||
**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 🟡 (Bayer, optional weights; policy/fidelity open); Raw Details, Super Resolution ⬜) |
|
||||
| M8 | Heal / Remove | content-aware remove (PatchMatch), heal, clone, brush spots, visualize spots, red/pet eye | 6–10 | 🚧 (heal, clone, auto source, visualize spots, red/pet eye ✅; PatchMatch remove ⬜) |
|
||||
| M9 | Presets, profiles, versions, sync | preset browser + amount, create/import presets, profile browser, versions, history, copy/paste/sync settings | 5–8 | ✅ |
|
||||
| M10 | Export & share | export dialog (JPEG/PNG/TIFF/DNG/AVIF/JXL/original), sizing, sharpening, metadata, watermark, naming, batch jobs, XMP sidecars, HDR export | 6–10 | 🚧 (all formats incl. DNG/original, sizing, presets, background jobs, HDR gain map JPEG / PQ AVIF ✅; JXL encode ⬜) |
|
||||
| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (CR3 lossless CRX and C-RAW, RAF uncompressed and lossless/lossy compressed, RW2 (every raw format), PEF, ORF uncompressed ✅; **camera colour calibration** 🚧 (guarded ARW, NEF, RW2 and RAF preview fitting; measured database still missing), CR3, compressed ORF ⬜) |
|
||||
| M12 | AI & smart features | subject/sky/background/people/object masks, semantic search, faces/People (permissively licensed models, pure-Rust inference) | 20–40 | ⬜ |
|
||||
| M13 | Merge | HDR merge (deghost), panorama (projections, boundary warp, fill edges), HDR panorama | 10–15 | ✅ |
|
||||
| M14 | Video | import/playback/trim via FilmCraft crates, global edits + presets on video, video export | 6–10 | ⬜ |
|
||||
| M15 | Classic modules | Map, Book, Slideshow, Print, Web; smart collections, stacks, virtual copies, publish services, tethering | 25–40 | 🚧 (smart albums, stacks, virtual copies, compare/survey ✅; Map/Book/Slideshow/Print/Web ⬜) |
|
||||
| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; English/Chinese (Simplified, Traditional)/Japanese/Brazilian Portuguese/Spanish/German/Russian/Ukrainian 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 | 77% | 81% | 120–220 h |
|
||||
| Colour, camera profiles, render fidelity | 35% | 65% | 80–150 h |
|
||||
| Global develop adjustments | 70% | 93% | 30–60 h |
|
||||
| Optics, lens profiles, crop and geometry | 50% | 80% | 40–80 h |
|
||||
| Masking | 45% | 67% | 60–110 h |
|
||||
| Remove, heal, Enhance | 30% | 45% | 70–130 h |
|
||||
| Presets, profiles, versions, sync | 85% | 95% | 8–15 h |
|
||||
| Export | 75% | 89% | 15–30 h |
|
||||
| Merge and HDR | 60% | 85% | 20–35 h |
|
||||
| Views, compare, zoom, culling | 70% | 90% | 15–30 h |
|
||||
| Classic output modules (Map, Book, Slideshow, Print, publish, tethering) | 10% | 15% | 90–150 h |
|
||||
| Video | 0% | 0% | 25–45 h |
|
||||
|
||||
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 | 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-11: Canon EOS R5 Mark II C-RAW decodes from sensor data (57% of its files opened as a
|
||||
preview): the CRX QP map's first-symbol literal and limited-length escape; four CC0 R5 Mark II files
|
||||
pinned, the QP maps of 33,665 private C-RAW files decode exactly ([cr3.md](docs/cr3.md)). 235 models
|
||||
verified; RAW decoding 76% → 77%; headline numbers unchanged.
|
||||
- **2026-10-10 (UI docking):** Library, Tools, and Presets panels can be regrouped, split, floated within the app window, closed, and reopened. Tools keeps the current Edit/Crop/Masking mode; the photo view and filmstrip stay together. Panel moves and reset are available as UI commands, and saved UI preferences retain arrangements. Widget state styling uses the shared `craft-ui` theme helper, retaining the dark palette, typography, spacing and per-state borders. No readiness percentage change.
|
||||
- 2026-10-10 (RAW): RAW measured model by model against Adobe's 1,446-model list (234 verified, 943
|
||||
unverified, 233 preview-only, 36 unsupported; ~84% of photographers' cameras decode, ~40% on a
|
||||
verified body). Full ~61%, mainstream ~49%, essentials ~64%; beta ~14 points away.
|
||||
- 2026-10-10 (final): full ready is a weighted sum over the dimensions (~59%; method aligned with
|
||||
the standard); mainstream practitioner ~51% (450–840 h), essentials user ~63% (160–300 h);
|
||||
alpha gate passes.
|
||||
- 2026-10-10: full re-measure for the progress-docs standard: stage alpha, ready ~55%, checklist
|
||||
81.1%; new docs (target-app-parity, gaps, roadmap, architecture, raw / file-format / hardware / UI
|
||||
/ localization parity); `docs/parity.md` renamed `docs/parity-checklist.md`. Landed today: Nikon
|
||||
lossy-after-split NEF (#633), ORF 12.8 bpp (#651), HDR gain map JPEG / PQ AVIF / float TIFF export
|
||||
(#646), Lightroom-like Auto (#628), activity stack.
|
||||
- 2026-10-09: Sony ILCE-7CR camera colour: bundled profile from 125 photos; on 26 withheld photos mean
|
||||
ΔE vs the camera JPEG 4.07 → 3.29. HEIC in release builds (#609), Samsung uncompressed SRW,
|
||||
contact-sheet PDF export, Ukrainian and French UI, release v0.5.0.
|
||||
- 2026-10-08: independent Rust CR3 lossless and version 0x100/0x200 C-RAW decoding, six exact
|
||||
full-sensor regressions on M50 / R100 / R8; guarded CR3 camera-JPEG colour fitting
|
||||
([cr3.md](docs/cr3.md)).
|
||||
- 2026-10-08: Fujifilm lossless / lossy compressed RAF: pure-Rust striped decoder, full sensor arrays
|
||||
verified on 17 CC0 and 7 supplied files (13 bodies). Fujifilm camera colour: bundled X-H2S / X-T4
|
||||
profiles from 201 / 545 photos; on 150 withheld files mean ΔE X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34.
|
||||
AI RAW denoise (#384), Lightroom catalog import, German, Spanish and Russian UI.
|
||||
- 2026-10-07: Panasonic RW2 / Leica RWL / Panasonic RAW decode in every raw format, established on
|
||||
178 CC0 files from 118 bodies; RW2 file-local camera look (140 of 174 files). Faces: recognition in
|
||||
pure Rust and People view.
|
||||
- 2026-10-03 – 10-05: community PRs (#42–#51, #60) and 25+ issues: compressed NEF, Move import,
|
||||
folder templates, Local roots and cleanup, 85k-photo grid and catalog performance, background
|
||||
compaction, failed-save errors, GPU export hardening. Added the honest *Where we stand* assessment
|
||||
and tracker rows for camera colour, camera coverage and render fidelity.
|
||||
- 2026-10-02: parity estimate added (`cargo xtask parity` prints weighted completion); milestones
|
||||
refreshed.
|
||||
- 2026-10-01: RAW II formats: RAF (uncompressed Bayer + X-Trans), RW2 (packed), PEF (incl. Huffman),
|
||||
ORF (uncompressed); embedded previews for every container incl. CR3; raw corpus test with 37 CC0
|
||||
samples.
|
||||
- 2026-09-30 (later): app running with the full Lightroom-style UI; pipeline v0; DNG / CR2 / ARW;
|
||||
README showcase. ≈ 8 h elapsed.
|
||||
- 2026-09-30: roadmap created; M0 in progress; research docs (Lightroom reference, Rust imaging
|
||||
ecosystem) complete.
|
||||
|
||||
## 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. Retuning changes existing edits, so it
|
||||
ships as a new process version (`docs/process-versions.md`): photos keep their look until they are updated.
|
||||
|
||||
## Raw format coverage and known gaps
|
||||
|
||||
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, DNG 1.7 JPEG XL tiles: lossless ones bit-exact on synthetic files,
|
||||
lossy ones unclipped in the declared linear primaries, checked on one real file), CR2, ARW (uncompressed, ARW2, LJ92; as-shot white balance and black level of pre-2017 bodies from the encrypted
|
||||
`SR2SubIFD`, decrypted with a keystream recovered by black-box analysis, `crates/raw/src/vendor/sr2.rs`, `crates/raw/src/vendor/arw.rs`), NEF/NRW uncompressed and Huffman-compressed (lossless, lossy type 1/2, 12/14-bit), RAF uncompressed and lossless/lossy compressed (Bayer and
|
||||
X-Trans, 14/16-bit real-file verification), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed formats 4 and 6, the prefix-coded strips of format 8,
|
||||
packed formats 2, 5 and 7, the 16-bit words of the 2005–2007 bodies; all recovered by black-box analysis of 178 CC0 files from
|
||||
118 bodies, `crates/raw/src/vendor/rw2.rs`), PEF (uncompressed and Huffman), ORF uncompressed (16-bit and 12-bit packed). Supported containers yield their embedded JPEG preview when present (including CR3). The engine uses it for RAW variants it cannot decode yet; HEVC-only CR3 previews are not decoded.
|
||||
|
||||
Not decoded yet — preview only (no permissively licensed description; black-box analysis incomplete):
|
||||
- **Olympus compressed ORF**, **Canon CR3 unverified CRX variants** (M11.1), **Canon sRAW/mRAW**.
|
||||
|
||||
**Camera colour matrices:** ARW, NEF, RW2, RAF, CR3, CR2, PEF, Samsung SRW and Olympus ORF files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. 52 models whose fit is rejected (or that have no usable JPEG) start from matrices fitted to their measured spectral sensitivities instead (rawtoaces-data, Apache-2.0; `crates/raw/src/spectral.rs`). Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
|
||||
`matrix_is_fallback`) with the file's as-shot white-balance multipliers. Clean sources to evaluate next: manufacturer
|
||||
matrices stored in the files themselves (Olympus ImageProcessing `ColorMatrix`, Pentax equivalents; Panasonic RW2 files carry none) and our
|
||||
own chart-based calibration (M11.4). Adobe matrices are never used.
|
||||
|
||||
## 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.
|
||||
- 2026-10-09: Sony ILCE-7CR camera colour: bundled profile from 125 photos. On 26 withheld photos mean ΔE vs the camera JPEG 4.07 → 3.29; all 131 photos accepted, where 8 fell back to the neutral matrix without it. Measured calibration remains open.
|
||||
|
||||
## Chinese and Japanese interfaces, and text watermarks
|
||||
|
||||
English, Simplified Chinese, Traditional Chinese (Taiwan) and Japanese interface languages are persisted in UI state, and the
|
||||
language table (`crates/ui-egui/src/i18n.rs`) drives the menus, settings and fonts, so another
|
||||
language is a table entry plus two catalogs (docs/localization.md). Core menus and panels are
|
||||
translated; untranslated text retains English. Ukrainian covers all existing catalog keys, command/control/rule labels
|
||||
and format messages, with count-neutral labels and numeric dates; technical errors from lower layers remain English.
|
||||
CJK glyphs (Chinese: Noto Sans CJK SC; Japanese UI:
|
||||
BIZ UDPGothic; watermarks: BIZ UDMincho) come from storytold/craft-fonts, embedded by builds made
|
||||
with the optional `CRAFT_FONTS_DIR` input (all releases), so no system fonts are needed. Text watermarks now accept
|
||||
`vertical: true` in export JSON/presets and expose an orientation selector. Japanese
|
||||
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-11 | minor | EOS R5 Mark II CR3 verified: CR3 on four verified bodies, RAW decoding 76% → 77%; headline numbers unchanged |
|
||||
| 2026-10-10 | minor | RAW measured model by model; RAW decode and colour as explicit rows: full 59% → 61%, mainstream 51% → 49%, essentials 63% → 64%; beta ~14 points |
|
||||
| 2026-10-10 | minor | Full ready restored to the additive weighted sum (~59%; method aligned with the standard, no new evidence); hours per audience; beta ~16 points away |
|
||||
| 2026-10-10 | minor | Mainstream practitioner and essentials user numbers; full ready 55% → 46% (method written down); beta 340–600 h |
|
||||
| 2026-10-10 | minor | Alpha gate added (core-workflow check): passes, stays alpha |
|
||||
| 2026-10-10 | major | Restructured to the progress-docs standard; re-measured; *Where we stand*, *Parity estimate* and *Totals* moved to docs/target-app-parity.md, milestones and risks to docs/roadmap.md, raw coverage to docs/raw-parity.md, catalog migration to docs/file-format-parity.md, CJK notes to docs/localization-parity.md |
|
||||
| 2026-10-08 | minor | *Where we stand* by dimension and by kind of user |
|
||||
| 2026-10-02 | major | Parity estimate and effort table |
|
||||
| 2026-09-30 | major | Created |
|
||||
@@ -15,6 +15,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
lightcraft-engine = { workspace = true }
|
||||
lightcraft-denoise = { workspace = true }
|
||||
lightcraft-sysmem = { workspace = true }
|
||||
lightcraft-mcp = { workspace = true }
|
||||
lightcraft-ui-egui = { workspace = true }
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//! lightcraft-cli snapshot [--library DIR | --demo] [--script FILE.jsonl] [-o OUT.png] [--size WxH] [--scale S] [FILES…]
|
||||
//! lightcraft-cli merge hdr|panorama|hdr-panorama [OPTIONS] FILES…
|
||||
//! lightcraft-cli synth-merge hdr|panorama -o DIR
|
||||
//! lightcraft-cli denoise-serve [--model ID] [--models DIR] [--listen ADDR] [--token-env VAR] [--run-on auto|gpu|cpu]
|
||||
//! lightcraft-cli commands [--json]
|
||||
//! lightcraft-cli controls [--json]
|
||||
//! lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS/FILES…
|
||||
@@ -51,17 +52,19 @@ USAGE:
|
||||
--library DIR headless: open (or create) a persistent LightCraft library; edits are saved
|
||||
--compact list only the helper tools (every command stays reachable via run_command)
|
||||
lightcraft-cli render <IN> -o <OUT> [OPTIONS]
|
||||
Develop one file and export it (.jpg, .png, .tif, .webp, .avif or .dng by extension) with the
|
||||
Develop one file and export it (.jpg, .png, .tif, .webp, .avif, .jxl, .psd or .dng by extension) with the
|
||||
same encoder as the app's Export dialog. Options:
|
||||
--set CONTROL=VALUE set a develop slider, repeatable (e.g. --set light.exposure=0.5)
|
||||
--settings FILE merge a partial develop-settings JSON file
|
||||
--preset ID apply a preset (see `commands`/presets.list)
|
||||
--size N long edge in pixels (default: full size, cropped)
|
||||
--quality Q JPEG/AVIF quality 1..100 (default 92)
|
||||
--quality Q JPEG/AVIF/JPEG XL quality 1..100 (default 92)
|
||||
--opt KEY=VALUE any export option of `app.export`, repeatable (VALUE is JSON or a
|
||||
string), e.g. --opt colorSpace=displayP3 --opt bitDepth=16
|
||||
--opt percent=50 --opt shortEdge=1080 --opt ppi=300
|
||||
--opt format=original (copy + XMP sidecar) --opt metadata=none
|
||||
--opt addToPhotos=true --opt photosAlbum=NAME (macOS: then add
|
||||
the file to Apple Photos, into that album)
|
||||
lightcraft-cli snapshot [OPTIONS] [FILES/FOLDERS…]
|
||||
Run the full app UI headlessly (no window, no GPU: CPU-rasterized egui) and write PNGs.
|
||||
Options:
|
||||
@@ -85,6 +88,18 @@ USAGE:
|
||||
--preview OUT.png only render a ≤ 1024 px preview (nothing written next to the files)
|
||||
lightcraft-cli synth-merge hdr|panorama -o DIR
|
||||
Write synthetic merge inputs (procedural scene; bracketed DNGs or overlapping PNG views).
|
||||
lightcraft-cli denoise-serve [OPTIONS]
|
||||
Run AI Denoise tiles for LightCraft on other computers (LIGHTCRAFT_DENOISE_URL=<this host>:7990 and
|
||||
LIGHTCRAFT_DENOISE_TOKEN there; see docs/denoise.md). Serves the installed model selected in Settings,
|
||||
on this computer's graphics card when it can run it, else the CPU. Plain TCP: listen on a VPN/LAN
|
||||
address (e.g. the Tailscale IP) or firewall the port to it. Options:
|
||||
--model ID an installed model other than the selected one
|
||||
--models DIR the models folder (default: LIGHTCRAFT_DENOISE_MODELS, else this user's)
|
||||
--listen ADDR address:port to listen on (default 127.0.0.1:7990)
|
||||
--token-env VAR environment variable holding the shared token, ≥ 16 characters
|
||||
(default LIGHTCRAFT_DENOISE_TOKEN)
|
||||
--run-on WHERE auto (default), gpu or cpu
|
||||
--max-connections N concurrent clients (default 8)
|
||||
lightcraft-cli commands [--json] list every command id with its parameters
|
||||
lightcraft-cli controls [--json] list every develop control id with its range
|
||||
lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS/FILES…
|
||||
@@ -115,6 +130,9 @@ fn library_error(dir: &str, e: lightcraft_engine::EngineError) -> String {
|
||||
lightcraft_engine::EngineError::LibraryInUse(why) => format!(
|
||||
"{dir}: {why}\nTo work with the library while the app has it open, start the app with `--control PORT` and use `lightcraft-cli mcp --connect 127.0.0.1:PORT`."
|
||||
),
|
||||
e @ lightcraft_engine::EngineError::LightroomCatalog { .. } => format!(
|
||||
"{e}\nOpen a LightCraft library with `--library DIR` (an empty folder makes a new one) and run `library.importLightroom` with the catalog's path."
|
||||
),
|
||||
e => format!("{dir}: {e}"),
|
||||
}
|
||||
}
|
||||
@@ -145,6 +163,7 @@ fn main() -> ExitCode {
|
||||
Some("merge") => merge(&args[1..]),
|
||||
Some("synth-merge") => synth_merge(&args[1..]),
|
||||
Some("controls") => controls(&args[1..]),
|
||||
Some("denoise-serve") => denoise_serve(&args[1..]),
|
||||
Some("calibrate") => calibrate(&args[1..]),
|
||||
Some("--version" | "-V" | "version") => {
|
||||
println!("lightcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
@@ -681,6 +700,8 @@ fn render(args: &[String]) -> Result<(), String> {
|
||||
o.format = ExportFormat::parse(&ext)
|
||||
.ok_or_else(|| format!("{output}: unknown extension (use .jpg .png .tif .webp .avif .dng or --opt format=…)"))?;
|
||||
}
|
||||
// Add to Apple Photos: refused before anything is written when it can't be done
|
||||
let after = s.after_export(&o)?;
|
||||
let e = export_photo(&mut s, lightcraft_engine::catalog::PhotoId(id), &o, 1)?;
|
||||
// never over the input (or its sidecar), however it is spelled: `render IMG.jpg -o IMG.jpg`
|
||||
let sidecars: Vec<(String, &Vec<u8>)> =
|
||||
@@ -695,6 +716,12 @@ fn render(args: &[String]) -> Result<(), String> {
|
||||
eprintln!("lightcraft-cli: wrote {sc}");
|
||||
}
|
||||
eprintln!("lightcraft-cli: wrote {output} ({}×{})", e.width, e.height);
|
||||
if let Some(photos) = after.run(&[json!({"path": output})], false) {
|
||||
if let Some(err) = photos.get("error").and_then(Value::as_str) {
|
||||
return Err(format!("render: {output} was written, but {err}"));
|
||||
}
|
||||
eprintln!("lightcraft-cli: added to Apple Photos: {photos}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -897,3 +924,48 @@ fn controls(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `denoise-serve`: the remote half of AI Denoise (`lightcraft_denoise::remote`).
|
||||
#[cfg(feature = "denoise")]
|
||||
fn denoise_serve(args: &[String]) -> Result<(), String> {
|
||||
use lightcraft_denoise::remote::{DEFAULT_PORT, ServerOptions, serve};
|
||||
use lightcraft_engine::denoise::RunOn;
|
||||
let mut model: Option<String> = None;
|
||||
let mut dir: Option<String> = None;
|
||||
let mut listen = format!("127.0.0.1:{DEFAULT_PORT}");
|
||||
let mut token_env = "LIGHTCRAFT_DENOISE_TOKEN".to_string();
|
||||
let mut run_on = RunOn::Auto;
|
||||
let mut max_connections = 8usize;
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--model" => model = Some(take_value(args, &mut i, "--model")?.to_string()),
|
||||
"--models" => dir = Some(take_value(args, &mut i, "--models")?.to_string()),
|
||||
"--listen" => listen = take_value(args, &mut i, "--listen")?.to_string(),
|
||||
"--token-env" => token_env = take_value(args, &mut i, "--token-env")?.to_string(),
|
||||
"--run-on" => {
|
||||
let v = take_value(args, &mut i, "--run-on")?;
|
||||
run_on = RunOn::parse(v).ok_or_else(|| format!("--run-on expects auto, gpu or cpu, not `{v}`"))?;
|
||||
}
|
||||
"--max-connections" => {
|
||||
max_connections = take_value(args, &mut i, "--max-connections")?.parse().map_err(|_| "--max-connections expects a number")?
|
||||
}
|
||||
other => return Err(format!("denoise-serve: unknown option `{other}`")),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
let token = std::env::var(&token_env).map_err(|_| format!("denoise-serve: set the shared token in ${token_env}"))?;
|
||||
let s = lightcraft_engine::denoise::serving(dir.as_deref().map(Path::new), model.as_deref(), run_on)?;
|
||||
let listener = std::net::TcpListener::bind(&listen).map_err(|e| format!("denoise-serve: {listen}: {e}"))?;
|
||||
eprintln!("lightcraft-cli denoise-serve: {} {} ({}) on {listen} via {}", s.model.id, s.model.version, s.model.sha256, s.device);
|
||||
if listen.starts_with("0.0.0.0") || listen.starts_with("[::]") {
|
||||
eprintln!("lightcraft-cli denoise-serve: listening on every interface; firewall the port to your VPN/LAN (the protocol is not encrypted)");
|
||||
}
|
||||
let opts = ServerOptions { token, max_connections, ..Default::default() };
|
||||
serve(listener, s.model, s.tile, s.device, s.run, opts, std::sync::Arc::new(|m: &str| eprintln!("lightcraft-cli denoise-serve: {m}")))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "denoise"))]
|
||||
fn denoise_serve(_: &[String]) -> Result<(), String> {
|
||||
Err("denoise-serve: this build has no denoise support (build with the `denoise` feature)".into())
|
||||
}
|
||||
@@ -577,3 +577,69 @@ fn snapshot_leaves_desktop_gpu_preferences_untouched() {
|
||||
assert!(image.exists());
|
||||
}
|
||||
}
|
||||
|
||||
/// `denoise-serve` serves the selected installed model; a client greets it and gets the CPU's answer for a tile
|
||||
/// (within half-float rounding on the wire), and a wrong token or a missing token variable is refused.
|
||||
#[cfg(feature = "denoise")]
|
||||
#[test]
|
||||
fn denoise_serve_runs_tiles_for_another_computer() {
|
||||
use lightcraft_denoise::manifest::{DenoiserManifest, Domain, Gain};
|
||||
use lightcraft_denoise::remote::Remote;
|
||||
use lightcraft_denoise::run::TileRunner;
|
||||
let models = tmp("denoise-models");
|
||||
let _ = std::fs::remove_dir_all(&models);
|
||||
let dir = models.join("synthetic");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let manifest = DenoiserManifest {
|
||||
id: "synthetic".into(),
|
||||
name: "Synthetic".into(),
|
||||
version: "1".into(),
|
||||
licence: Default::default(),
|
||||
source: None,
|
||||
sha256: None,
|
||||
size_bytes: None,
|
||||
provenance: "made for a test".into(),
|
||||
domain: Domain::BayerToRgb,
|
||||
tile: 64,
|
||||
overlap: 16,
|
||||
gain: Gain::None,
|
||||
};
|
||||
let onnx = dir.join("model.onnx");
|
||||
std::fs::write(&onnx, lightcraft_denoise::synthetic::unet_onnx(64, 8, 2, 7)).unwrap();
|
||||
std::fs::write(dir.join("denoise-model.json"), serde_json::to_vec(&manifest).unwrap()).unwrap();
|
||||
std::fs::write(models.join("settings.json"), r#"{"model": "synthetic"}"#).unwrap();
|
||||
let sha = lightcraft_denoise::hash::sha256_file(&onnx).unwrap();
|
||||
|
||||
// without its token the server doesn't start
|
||||
let o = Command::new(BIN).args(["denoise-serve", "--models", models.to_str().unwrap(), "--token-env", "LC_TEST_NO_SUCH_VAR"]).output().unwrap();
|
||||
assert!(!o.status.success() && String::from_utf8_lossy(&o.stderr).contains("LC_TEST_NO_SUCH_VAR"));
|
||||
|
||||
let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
|
||||
let addr = format!("127.0.0.1:{port}");
|
||||
let token = "cli-test-token-0123456789";
|
||||
let mut child = Command::new(BIN)
|
||||
.args(["denoise-serve", "--models", models.to_str().unwrap(), "--listen", &addr, "--run-on", "cpu"])
|
||||
.env("LIGHTCRAFT_DENOISE_TOKEN", token)
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
// wait for the banner
|
||||
let mut err = BufReader::new(child.stderr.take().unwrap());
|
||||
let mut banner = String::new();
|
||||
err.read_line(&mut banner).unwrap();
|
||||
assert!(banner.contains("synthetic 1") && banner.contains(&addr), "{banner}");
|
||||
|
||||
let remote = Remote::new(&addr, token, &sha, 64);
|
||||
let info = remote.hello().unwrap();
|
||||
assert!(info.device.starts_with("CPU"), "{info:?}");
|
||||
let (input, _) = lightcraft_denoise::runtime::test_tile(64);
|
||||
let got = remote.try_run(&input).unwrap().unwrap();
|
||||
let want = lightcraft_denoise::runtime::CpuRunner::load(&onnx, &manifest).unwrap().run(&input).unwrap();
|
||||
let scale = want.iter().fold(1e-9f32, |m, v| m.max(v.abs()));
|
||||
let worst = want.iter().zip(&got).fold(0f32, |m, (a, b)| m.max((a - b).abs())) / scale;
|
||||
assert!(worst < 5e-3, "remote vs local: {worst}");
|
||||
assert!(Remote::new(&addr, "a-wrong-token-000000", &sha, 64).hello().is_err());
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
let _ = std::fs::remove_dir_all(&models);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! LightCraft in the browser: the same egui UI as the desktop app, compiled to WASM.
|
||||
//!
|
||||
//! Build with `cargo xtask web` (→ `target/web/`), then serve that folder over HTTP (see
|
||||
//! Build with `cargo xtask web` (→ `target/web-site/`), then serve that folder over HTTP (see
|
||||
//! `docs/web.md`). Differences from the desktop host:
|
||||
//! - the library lives in browser storage (OPFS, or IndexedDB where OPFS can't be written from the
|
||||
//! main thread): catalog journal, presets, view state and prefs are mirrored in memory and
|
||||
|
||||
@@ -180,6 +180,7 @@ pub fn mime_for(name: &str) -> &'static str {
|
||||
Some("tif" | "tiff") => "image/tiff",
|
||||
Some("webp") => "image/webp",
|
||||
Some("avif") => "image/avif",
|
||||
Some("jxl") => "image/jxl",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +170,7 @@ fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen
|
||||
Services {
|
||||
picker: None,
|
||||
pick_display_profile: None,
|
||||
screen_profile: None,
|
||||
backup_library: Some(Box::new(move |session: &mut Session| {
|
||||
let Some(b) = backup_backend.clone() else { return Err("nothing is stored in this browser session (?store=memory)".into()) };
|
||||
// the photos' own names for the originals in the zip
|
||||
@@ -227,11 +228,14 @@ fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen
|
||||
reveal: None,
|
||||
log_file: None,
|
||||
open_with: None,
|
||||
check_editor: None,
|
||||
open_url: Some(Box::new(|url: &str| {
|
||||
let w = web_sys::window().ok_or("no window")?;
|
||||
w.open_with_url_and_target(url, "_blank").map(|_| ()).map_err(|_| "the browser blocked the new tab".to_string())
|
||||
})),
|
||||
pick_folder: None,
|
||||
// the browser reports prefers-color-scheme to egui
|
||||
system_theme: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ pub struct WireJob {
|
||||
pub raw: bool,
|
||||
#[serde(default)]
|
||||
pub relative_wb: bool,
|
||||
#[serde(default)]
|
||||
pub legacy_relative_wb: bool,
|
||||
pub as_shot_temp: f64,
|
||||
pub as_shot_tint: f64,
|
||||
pub lens: Option<EmbeddedLens>,
|
||||
@@ -74,6 +76,7 @@ impl WireJob {
|
||||
max_edge,
|
||||
raw: job.info.raw,
|
||||
relative_wb: job.info.relative_wb,
|
||||
legacy_relative_wb: job.info.legacy_relative_wb,
|
||||
as_shot_temp: job.info.as_shot_temp,
|
||||
as_shot_tint: job.info.as_shot_tint,
|
||||
lens: job.info.lens,
|
||||
@@ -97,6 +100,7 @@ impl WireJob {
|
||||
as_shot_tint: self.as_shot_tint,
|
||||
lens: self.lens,
|
||||
relative_wb: self.relative_wb,
|
||||
legacy_relative_wb: self.legacy_relative_wb,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -184,13 +188,18 @@ impl WorkerCore {
|
||||
}
|
||||
};
|
||||
let info = src.info_or(job.info());
|
||||
Ok(match &job.stages {
|
||||
let mut rendered = match &job.stages {
|
||||
Some(view) => {
|
||||
let st = self.stages.entry(view.clone()).or_default().clone();
|
||||
lightcraft_engine::pipeline::render_cached(&src.image, &info, &job.settings, &job.request(), &st)
|
||||
}
|
||||
None => lightcraft_engine::pipeline::render(&src.image, &info, &job.settings, &job.request()),
|
||||
})
|
||||
};
|
||||
// an HDR edit's histogram shows its HDR range (as on native)
|
||||
if let Some(h) = lightcraft_engine::pipeline::hdr_histogram(&src.image, &info, &job.settings, &job.request()) {
|
||||
rendered.histogram = h;
|
||||
}
|
||||
Ok(rendered)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,11 +341,46 @@ impl CacheWatch {
|
||||
}
|
||||
}
|
||||
|
||||
/// Hands out the positions that render workers will have in the main thread's worker list.
|
||||
/// A worker that fails to start gets no position, so the position a callback captures stays the
|
||||
/// index of its own worker in that list (#229).
|
||||
#[derive(Debug, Default)]
|
||||
pub struct WorkerSlots {
|
||||
next: usize,
|
||||
}
|
||||
|
||||
impl WorkerSlots {
|
||||
/// The position the worker being started will have, if it comes up.
|
||||
pub fn peek(&self) -> usize {
|
||||
self.next
|
||||
}
|
||||
|
||||
/// The worker at [`Self::peek`] came up and was added to the list.
|
||||
pub fn commit(&mut self) {
|
||||
self.next += 1;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lightcraft_engine::Session;
|
||||
|
||||
// Issue #229: a failed worker attempt must not shift the positions of the workers after it
|
||||
#[test]
|
||||
fn a_failed_worker_attempt_leaves_no_gap_in_the_slots() {
|
||||
let mut slots = WorkerSlots::default();
|
||||
let mut taken = Vec::new();
|
||||
for attempt_ok in [false, true, true, true] {
|
||||
let i = slots.peek();
|
||||
if attempt_ok {
|
||||
taken.push(i);
|
||||
slots.commit();
|
||||
}
|
||||
}
|
||||
assert_eq!(taken, vec![0, 1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_index_changes_disk_namespace_and_survives_restart() {
|
||||
let key = lightcraft_preview::hash_bytes(b"thumbnail");
|
||||
@@ -421,6 +465,7 @@ mod tests {
|
||||
max_edge: 512,
|
||||
raw: false,
|
||||
relative_wb: false,
|
||||
legacy_relative_wb: false,
|
||||
as_shot_temp: 6500.0,
|
||||
as_shot_tint: 0.0,
|
||||
lens: None,
|
||||
|
||||
@@ -27,7 +27,7 @@ use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::backend::Backend;
|
||||
use crate::wire::{CacheWatch, ThumbIndex, WireJob, WorkerCore, thumb_storage_key};
|
||||
use crate::wire::{CacheWatch, ThumbIndex, WireJob, WorkerCore, WorkerSlots, thumb_storage_key};
|
||||
|
||||
/// Storage key of the thumbnail index.
|
||||
pub const THUMB_INDEX: &str = "thumbs/index.json";
|
||||
@@ -127,11 +127,7 @@ async fn handle(scope: &web_sys::DedicatedWorkerGlobalScope, backend: Option<Bac
|
||||
Ok((r, stored)) => {
|
||||
let rgba = Uint8Array::from(r.image.as_bytes().as_ref());
|
||||
let h = &r.histogram;
|
||||
let hist = Uint32Array::new_with_length(4 * 256 + 1);
|
||||
for (i, ch) in [&h.r, &h.g, &h.b, &h.luma].into_iter().enumerate() {
|
||||
hist.subarray((i * 256) as u32, ((i + 1) * 256) as u32).copy_from(ch);
|
||||
}
|
||||
hist.set_index(1024, h.total);
|
||||
let hist = Uint32Array::from(h.to_words().as_slice());
|
||||
set(&out, "ok", &true.into());
|
||||
set(&out, "w", &(r.image.width as u32).into());
|
||||
set(&out, "h", &(r.image.height as u32).into());
|
||||
@@ -245,14 +241,17 @@ impl Workers {
|
||||
log::warn!("render workers: no compiled module on the page (window.lightcraftModule); rendering on the main thread");
|
||||
return w;
|
||||
}
|
||||
for i in 0..n {
|
||||
let mut slots = WorkerSlots::default();
|
||||
for attempt in 0..n {
|
||||
// The callbacks name their worker by its place in `workers`; a failed attempt takes none.
|
||||
let i = slots.peek();
|
||||
let opts = web_sys::WorkerOptions::new();
|
||||
opts.set_type(web_sys::WorkerType::Module);
|
||||
opts.set_name(&format!("lightcraft-render-{i}"));
|
||||
opts.set_name(&format!("lightcraft-render-{attempt}"));
|
||||
let worker = match web_sys::Worker::new_with_options("./worker.js", &opts) {
|
||||
Ok(x) => x,
|
||||
Err(e) => {
|
||||
log::warn!("render worker {i}: {e:?}");
|
||||
log::warn!("render worker {attempt}: {e:?}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
@@ -268,10 +267,15 @@ impl Workers {
|
||||
set(&init, "module", &module);
|
||||
set(&init, "store", &store.into());
|
||||
if let Err(e) = worker.post_message(&init) {
|
||||
log::warn!("render worker {i}: {e:?}");
|
||||
log::warn!("render worker {attempt}: {e:?}");
|
||||
// It never joins the list, so it must not answer to the index of the next worker.
|
||||
worker.set_onmessage(None);
|
||||
worker.set_onerror(None);
|
||||
worker.terminate();
|
||||
continue;
|
||||
}
|
||||
w.0.borrow_mut().workers.push(W { worker, ready: false, dead: false, busy: None });
|
||||
slots.commit();
|
||||
}
|
||||
w
|
||||
}
|
||||
@@ -291,6 +295,7 @@ impl Workers {
|
||||
level: b.level,
|
||||
key: b.key,
|
||||
rendered: Err(msg),
|
||||
gpu_fallback: None,
|
||||
loaded: None,
|
||||
quick: None,
|
||||
display: None,
|
||||
@@ -317,12 +322,8 @@ impl Workers {
|
||||
let (wd, ht) = (get(&data, "w").as_f64().unwrap_or(0.0) as usize, get(&data, "h").as_f64().unwrap_or(0.0) as usize);
|
||||
let rgba = Uint8Array::new(&get(&data, "rgba")).to_vec();
|
||||
let hist = Uint32Array::new(&get(&data, "hist")).to_vec();
|
||||
match Rgba8::from_bytes(wd, ht, &rgba) {
|
||||
Some(image) if hist.len() == 1025 => {
|
||||
let ch = |k: usize| hist[k * 256..(k + 1) * 256].to_vec();
|
||||
let histogram = Histogram { r: ch(0), g: ch(1), b: ch(2), luma: ch(3), total: hist[1024] };
|
||||
Ok(Rendered { image, histogram, deep: None })
|
||||
}
|
||||
match (Rgba8::from_bytes(wd, ht, &rgba), Histogram::from_words(&hist)) {
|
||||
(Some(image), Some(histogram)) => Ok(Rendered { image, histogram, deep: None }),
|
||||
_ => Err("render worker: bad image".to_string()),
|
||||
}
|
||||
} else {
|
||||
@@ -378,6 +379,7 @@ impl Workers {
|
||||
level: b.level,
|
||||
key: b.key,
|
||||
rendered,
|
||||
gpu_fallback: None,
|
||||
loaded: None,
|
||||
quick: None,
|
||||
display: None,
|
||||
@@ -431,6 +433,7 @@ impl RenderOffload for Workers {
|
||||
level: job.level,
|
||||
key: job.key,
|
||||
rendered: Ok(Rendered { image, histogram, deep: None }),
|
||||
gpu_fallback: None,
|
||||
loaded: None,
|
||||
quick: None,
|
||||
display: None,
|
||||
|
||||
@@ -23,17 +23,28 @@ lightcraft-sysmem = { workspace = true }
|
||||
lightcraft-ui-egui = { workspace = true }
|
||||
lightcraft-codecs = { workspace = true }
|
||||
lightcraft-raster = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "accesskit", "wayland", "x11"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
# probing the window's renderer before it opens (`window_gpu`)
|
||||
pollster = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
# Icon + VERSIONINFO for lightcraft.exe; build.rs only uses it when targeting Windows.
|
||||
winresource = "0.1"
|
||||
|
||||
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
|
||||
winit = { version = "0.30", default-features = false, features = ["x11"] }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
rfd = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
zbus = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
muda = { workspace = true }
|
||||
objc2 = { workspace = true }
|
||||
objc2-app-kit = { workspace = true }
|
||||
objc2-foundation = { workspace = true }
|
||||
@@ -0,0 +1,198 @@
|
||||
//! The desktop's dark/light setting for Settings ▸ Interface ▸ Appearance Mode ▸ Auto, on Linux
|
||||
//! desktops whose windowing layer reports no theme to egui (many Wayland compositors).
|
||||
//!
|
||||
//! The XDG settings portal is read once, then its `SettingChanged` signal is followed on a worker
|
||||
//! thread that wakes the UI only when the value changes. Nothing is polled. `gsettings` is a
|
||||
//! one-shot fallback at start-up, run by absolute path, only when the portal gives no answer.
|
||||
//! Elsewhere egui's own report (`Context::system_theme`) is used.
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
const PORTAL_NAMESPACE: &str = "org.freedesktop.appearance";
|
||||
#[cfg(target_os = "linux")]
|
||||
const PORTAL_KEY: &str = "color-scheme";
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn portal_proxy() -> Option<zbus::blocking::Proxy<'static>> {
|
||||
let conn = zbus::blocking::connection::Builder::session().ok()?.method_timeout(std::time::Duration::from_secs(2)).build().ok()?;
|
||||
zbus::blocking::Proxy::new(&conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings").ok()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn portal_theme(proxy: &zbus::blocking::Proxy<'_>) -> Option<egui::Theme> {
|
||||
let value: zbus::zvariant::OwnedValue = proxy.call("Read", &(PORTAL_NAMESPACE, PORTAL_KEY)).ok()?;
|
||||
decode(portal_code(value)?)
|
||||
}
|
||||
|
||||
/// The `u32` inside a portal value. `Settings.Read` answers a variant, and some portals nest
|
||||
/// another variant inside it.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn portal_code(value: zbus::zvariant::OwnedValue) -> Option<u32> {
|
||||
let mut value: zbus::zvariant::Value<'_> = value.into();
|
||||
for _ in 0..4 {
|
||||
match value {
|
||||
zbus::zvariant::Value::Value(inner) => value = *inner,
|
||||
other => return u32::try_from(other).ok(),
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The portal's `color-scheme`: 0 = no preference, 1 = prefer dark, 2 = prefer light.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn decode(code: u32) -> Option<egui::Theme> {
|
||||
match code {
|
||||
1 => Some(egui::Theme::Dark),
|
||||
2 => Some(egui::Theme::Light),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// One-shot fallback for desktops without the settings portal. It runs once at start-up, by
|
||||
/// absolute path, and is never repeated.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn gtk_theme() -> Option<egui::Theme> {
|
||||
let exe = ["/usr/bin/gsettings", "/bin/gsettings", "/usr/local/bin/gsettings"].into_iter().find(|p| std::path::Path::new(p).is_file())?;
|
||||
let output = std::process::Command::new(exe).args(["get", "org.gnome.desktop.interface", "color-scheme"]).output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
parse_gtk_scheme(std::str::from_utf8(&output.stdout).ok()?)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parse_gtk_scheme(s: &str) -> Option<egui::Theme> {
|
||||
match s.trim().trim_matches('\'') {
|
||||
"prefer-dark" => Some(egui::Theme::Dark),
|
||||
"prefer-light" => Some(egui::Theme::Light),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn code_of(theme: Option<egui::Theme>) -> u8 {
|
||||
match theme {
|
||||
Some(egui::Theme::Dark) => 1,
|
||||
Some(egui::Theme::Light) => 2,
|
||||
None => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Wake the first frame after a slow initial Read as well as subsequent real changes.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn publish(value: &std::sync::atomic::AtomicU8, wake: &std::sync::OnceLock<egui::Context>, code: u8) {
|
||||
if value.swap(code, std::sync::atomic::Ordering::Relaxed) != code
|
||||
&& let Some(ctx) = wake.get()
|
||||
{
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
/// The host's system-appearance service (Linux), or `None` where egui's report is enough.
|
||||
pub fn service() -> Option<lightcraft_ui_egui::SystemThemeFn> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::sync::{
|
||||
Arc, OnceLock,
|
||||
atomic::{AtomicU8, Ordering},
|
||||
};
|
||||
let value = Arc::new(AtomicU8::new(0));
|
||||
let wake: Arc<OnceLock<egui::Context>> = Arc::new(OnceLock::new());
|
||||
let (worker_value, worker_wake) = (Arc::clone(&value), Arc::clone(&wake));
|
||||
let (ready, wait) = std::sync::mpsc::channel();
|
||||
if std::thread::Builder::new()
|
||||
.name("appearance-portal".into())
|
||||
.spawn(move || {
|
||||
let proxy = portal_proxy();
|
||||
// Subscribe before Read so a change during startup is queued rather than lost.
|
||||
let signals = proxy.as_ref().and_then(|proxy| proxy.receive_signal("SettingChanged").ok());
|
||||
let initial = proxy.as_ref().and_then(portal_theme).or_else(gtk_theme);
|
||||
publish(&worker_value, &worker_wake, code_of(initial));
|
||||
let _ = ready.send(());
|
||||
let Some(signals) = signals else { return };
|
||||
// Blocks until the portal sends a signal: no polling.
|
||||
for message in signals {
|
||||
let Ok((namespace, key, changed)) = message.body().deserialize::<(String, String, zbus::zvariant::OwnedValue)>() else {
|
||||
continue;
|
||||
};
|
||||
if namespace != PORTAL_NAMESPACE || key != PORTAL_KEY {
|
||||
continue;
|
||||
}
|
||||
let code = code_of(portal_code(changed).and_then(decode));
|
||||
publish(&worker_value, &worker_wake, code);
|
||||
}
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// The first frame shows the right theme when the portal answers quickly; a slow one
|
||||
// only delays the switch (the worker wakes the UI when the value changes).
|
||||
let _ = wait.recv_timeout(std::time::Duration::from_millis(250));
|
||||
Some(Box::new(move |ctx| {
|
||||
let _ = wake.set(ctx.clone());
|
||||
decode(u32::from(value.load(Ordering::Relaxed)))
|
||||
}))
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn delayed_initial_read_repaints_only_on_change() {
|
||||
use std::sync::{
|
||||
Arc, OnceLock,
|
||||
atomic::{AtomicU8, AtomicUsize, Ordering},
|
||||
};
|
||||
let ctx = egui::Context::default();
|
||||
let settle = |ctx: &egui::Context| {
|
||||
for _ in 0..8 {
|
||||
let mut output = ctx.run_ui(egui::RawInput::default(), |_| {});
|
||||
output.textures_delta.clear();
|
||||
}
|
||||
};
|
||||
settle(&ctx);
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let observed = Arc::clone(&calls);
|
||||
ctx.set_request_repaint_callback(move |_| {
|
||||
observed.fetch_add(1, Ordering::Relaxed);
|
||||
});
|
||||
let wake = OnceLock::new();
|
||||
assert!(wake.set(ctx.clone()).is_ok());
|
||||
let value = AtomicU8::new(0);
|
||||
super::publish(&value, &wake, 2);
|
||||
assert_eq!(value.load(Ordering::Relaxed), 2);
|
||||
let after_change = calls.load(Ordering::Relaxed);
|
||||
assert!(after_change > 0);
|
||||
settle(&ctx);
|
||||
let after_settle = calls.load(Ordering::Relaxed);
|
||||
super::publish(&value, &wake, 2);
|
||||
assert_eq!(calls.load(Ordering::Relaxed), after_settle);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn portal_values_and_gsettings_output_decode() {
|
||||
assert_eq!(super::decode(0), None);
|
||||
assert_eq!(super::decode(1), Some(egui::Theme::Dark));
|
||||
assert_eq!(super::decode(2), Some(egui::Theme::Light));
|
||||
assert_eq!(super::decode(9), None);
|
||||
let nested = zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::U32(2)))));
|
||||
assert_eq!(super::portal_code(zbus::zvariant::OwnedValue::try_from(nested).unwrap()), Some(2));
|
||||
let plain = zbus::zvariant::OwnedValue::from(1u32);
|
||||
assert_eq!(super::portal_code(plain), Some(1));
|
||||
let wrong = zbus::zvariant::OwnedValue::try_from(zbus::zvariant::Value::from("dark")).unwrap();
|
||||
assert_eq!(super::portal_code(wrong), None);
|
||||
assert_eq!(super::parse_gtk_scheme("'prefer-light'\n"), Some(egui::Theme::Light));
|
||||
assert_eq!(super::parse_gtk_scheme("'prefer-dark'\n"), Some(egui::Theme::Dark));
|
||||
assert_eq!(super::parse_gtk_scheme("'default'\n"), None);
|
||||
assert_eq!(super::parse_gtk_scheme(""), None);
|
||||
for theme in [None, Some(egui::Theme::Dark), Some(egui::Theme::Light)] {
|
||||
assert_eq!(super::decode(u32::from(super::code_of(theme))), theme);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
//! Starting external editors for Photo ▸ Edit In: PhotoCraft, found where its installers put it,
|
||||
//! or any other application the user named in Settings ▸ External Editing.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
/// The name the UI asks for when it means PhotoCraft (`lightcraft_ui_egui::state::PHOTOCRAFT`).
|
||||
const PHOTOCRAFT: &str = lightcraft_ui_egui::state::PHOTOCRAFT;
|
||||
/// PhotoCraft's application id (Flatpak, desktop entry).
|
||||
const PHOTOCRAFT_ID: &str = "ai.storyteller.photocraft";
|
||||
|
||||
/// How an editor is started.
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum Launcher {
|
||||
/// The system's default application for the file.
|
||||
Default,
|
||||
/// A program file, given the photo as its argument.
|
||||
Program(PathBuf),
|
||||
/// macOS: `open -a <app>` (an application name or bundle path).
|
||||
MacApp(String),
|
||||
/// The PhotoCraft Flatpak, with the file forwarded into its sandbox.
|
||||
Flatpak,
|
||||
}
|
||||
|
||||
/// Whether `app` can be started (Settings ▸ External Editing names it; "" = the system default):
|
||||
/// for PhotoCraft, that it is installed. The error says what to do instead.
|
||||
pub fn check(app: &str) -> Result<(), String> {
|
||||
launcher(app).map(|_| ())
|
||||
}
|
||||
|
||||
/// Open `path` in `app` (see [`check`]). Spawned, never waited for: the editor runs alongside.
|
||||
pub fn open_with(path: &str, app: &str) -> Result<(), String> {
|
||||
let mut c = match launcher(app)? {
|
||||
Launcher::Default if cfg!(target_os = "macos") => {
|
||||
let mut c = Command::new("open");
|
||||
c.arg(path);
|
||||
c
|
||||
}
|
||||
Launcher::Default if cfg!(target_os = "windows") => windows_default_command(path),
|
||||
Launcher::Default => {
|
||||
let mut c = Command::new("xdg-open");
|
||||
c.arg(path);
|
||||
c
|
||||
}
|
||||
Launcher::MacApp(app) => {
|
||||
let mut c = Command::new("open");
|
||||
c.args(["-a", &app, path]);
|
||||
c
|
||||
}
|
||||
Launcher::Program(p) => {
|
||||
let mut c = Command::new(p);
|
||||
c.arg(path);
|
||||
c
|
||||
}
|
||||
Launcher::Flatpak => {
|
||||
let mut c = Command::new("flatpak");
|
||||
c.args(["run", "--file-forwarding", PHOTOCRAFT_ID, "@@", path, "@@"]);
|
||||
c
|
||||
}
|
||||
};
|
||||
c.spawn().map(|_| ()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Windows: open `path` in its default application through Explorer, given the path as its one
|
||||
/// argument. Not `cmd /C start`: cmd.exe would read `&`, `^`, `%` or `|` in a file name as its own
|
||||
/// syntax (running what follows an `&` as a command).
|
||||
fn windows_default_command(path: &str) -> Command {
|
||||
let mut c = Command::new("explorer.exe");
|
||||
c.arg(path.replace('/', "\\"));
|
||||
c
|
||||
}
|
||||
|
||||
fn launcher(app: &str) -> Result<Launcher, String> {
|
||||
let app = app.trim();
|
||||
if app.is_empty() {
|
||||
return Ok(Launcher::Default);
|
||||
}
|
||||
if !app.eq_ignore_ascii_case(PHOTOCRAFT) {
|
||||
return Ok(if cfg!(target_os = "macos") { Launcher::MacApp(app.to_string()) } else { Launcher::Program(PathBuf::from(app)) });
|
||||
}
|
||||
find_photocraft().ok_or_else(|| {
|
||||
"PhotoCraft isn't installed. Get it from getartcraft.com/apps/photocraft, or give where it is in Settings ▸ External Editing.".to_string()
|
||||
})
|
||||
}
|
||||
|
||||
fn find_photocraft() -> Option<Launcher> {
|
||||
let env = |k: &str| std::env::var_os(k).map(|v| v.to_string_lossy().to_string());
|
||||
if cfg!(target_os = "macos") {
|
||||
// LaunchServices finds the app wherever it is; these are only the usual places
|
||||
let home = env("HOME").map(|h| Path::new(&h).join("Applications/PhotoCraft.app"));
|
||||
let found = [Some(PathBuf::from("/Applications/PhotoCraft.app")), home].into_iter().flatten().find(|p| p.is_dir());
|
||||
return Some(Launcher::MacApp(found.map_or_else(|| PHOTOCRAFT.to_string(), |p| p.to_string_lossy().to_string())));
|
||||
}
|
||||
if cfg!(target_os = "windows") {
|
||||
let install_dir = reg_query(r"HKLM\Software\PhotoCraft", Some("InstallDir"));
|
||||
let app_path = reg_query(r"HKLM\Software\Microsoft\Windows\CurrentVersion\App Paths\photocraft.exe", None);
|
||||
return windows_candidates(install_dir, app_path, env).into_iter().find(|p| p.is_file()).map(Launcher::Program);
|
||||
}
|
||||
// Linux, FreeBSD: on the PATH (.deb, .rpm, tarball), a desktop entry (AppImage, which
|
||||
// registers one on first run), else the Flatpak
|
||||
let on_path =
|
||||
env("PATH").into_iter().flat_map(|p| std::env::split_paths(&p).collect::<Vec<_>>()).map(|d| d.join("photocraft")).find(|p| p.is_file());
|
||||
let from_entry = || {
|
||||
desktop_entry_dirs(env).into_iter().find_map(|d| {
|
||||
let text = std::fs::read_to_string(d.join(format!("{PHOTOCRAFT_ID}.desktop"))).ok()?;
|
||||
desktop_exec(&text).filter(|p| p.is_file())
|
||||
})
|
||||
};
|
||||
if let Some(p) = on_path.or_else(from_entry) {
|
||||
return Some(Launcher::Program(p));
|
||||
}
|
||||
let flatpak = Command::new("flatpak").args(["info", PHOTOCRAFT_ID]).output().is_ok_and(|o| o.status.success());
|
||||
flatpak.then_some(Launcher::Flatpak)
|
||||
}
|
||||
|
||||
/// Where the Windows installers put `photocraft.exe`, most specific first: the folder the MSI
|
||||
/// recorded (`HKLM\Software\PhotoCraft\InstallDir`), its App Paths entry, then the default
|
||||
/// folders (per machine, then per user).
|
||||
fn windows_candidates(install_dir: Option<String>, app_path: Option<String>, env: impl Fn(&str) -> Option<String>) -> Vec<PathBuf> {
|
||||
let mut out: Vec<PathBuf> = Vec::new();
|
||||
out.extend(install_dir.map(|d| Path::new(&d).join("photocraft.exe")));
|
||||
out.extend(app_path.map(|p| PathBuf::from(p.trim_matches('"'))));
|
||||
for var in ["ProgramW6432", "ProgramFiles"] {
|
||||
out.extend(env(var).map(|d| Path::new(&d).join("PhotoCraft").join("photocraft.exe")));
|
||||
}
|
||||
out.extend(env("LOCALAPPDATA").map(|d| Path::new(&d).join("Programs").join("PhotoCraft").join("photocraft.exe")));
|
||||
out
|
||||
}
|
||||
|
||||
/// A string value from the Windows registry (`reg query`; `None` = the key's default value).
|
||||
fn reg_query(key: &str, name: Option<&str>) -> Option<String> {
|
||||
let mut c = Command::new("reg");
|
||||
c.args(["query", key]);
|
||||
match name {
|
||||
Some(n) => c.args(["/v", n]),
|
||||
None => c.arg("/ve"),
|
||||
};
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
c.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
let out = c.output().ok().filter(|o| o.status.success())?;
|
||||
reg_value(&String::from_utf8_lossy(&out.stdout))
|
||||
}
|
||||
|
||||
/// The value in `reg query` output: the text after `REG_SZ` (or `REG_EXPAND_SZ`) on its line.
|
||||
fn reg_value(output: &str) -> Option<String> {
|
||||
output.lines().find_map(|l| {
|
||||
let (_, v) = l.split_once("REG_EXPAND_SZ").or_else(|| l.split_once("REG_SZ"))?;
|
||||
Some(v.trim().to_string()).filter(|v| !v.is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
/// The folders desktop entries live in (XDG base directories).
|
||||
fn desktop_entry_dirs(env: impl Fn(&str) -> Option<String>) -> Vec<PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
match env("XDG_DATA_HOME") {
|
||||
Some(d) if !d.is_empty() => dirs.push(PathBuf::from(d)),
|
||||
_ => dirs.extend(env("HOME").map(|h| Path::new(&h).join(".local/share"))),
|
||||
}
|
||||
let system = env("XDG_DATA_DIRS").filter(|d| !d.is_empty()).unwrap_or_else(|| "/usr/local/share:/usr/share".into());
|
||||
dirs.extend(system.split(':').filter(|d| !d.is_empty()).map(PathBuf::from));
|
||||
dirs.into_iter().map(|d| d.join("applications")).collect()
|
||||
}
|
||||
|
||||
/// The program a desktop entry's `Exec=` line starts (quoted or not, its field codes dropped).
|
||||
fn desktop_exec(entry: &str) -> Option<PathBuf> {
|
||||
let exec = entry.lines().find_map(|l| l.strip_prefix("Exec="))?.trim();
|
||||
let program = match exec.strip_prefix('"') {
|
||||
Some(rest) => rest.split('"').next()?,
|
||||
None => exec.split_whitespace().next()?,
|
||||
};
|
||||
// a bare name (`photocraft %F`) was already looked for on the PATH
|
||||
Some(PathBuf::from(program)).filter(|p| p.is_absolute())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A file name with cmd.exe syntax in it goes to Explorer as one plain argument, not
|
||||
/// through a shell.
|
||||
#[test]
|
||||
fn windows_default_open_goes_through_explorer_not_cmd() {
|
||||
let path = r"C:/Edits/a & b ^c %PATH% | d.psd";
|
||||
let c = windows_default_command(path);
|
||||
assert_eq!(c.get_program(), "explorer.exe");
|
||||
let args: Vec<_> = c.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
|
||||
assert_eq!(args, [r"C:\Edits\a & b ^c %PATH% | d.psd"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_lookup_prefers_the_recorded_install_folder() {
|
||||
let env = |k: &str| match k {
|
||||
"ProgramFiles" => Some(r"C:\Program Files".to_string()),
|
||||
"LOCALAPPDATA" => Some(r"C:\Users\Simon\AppData\Local".to_string()),
|
||||
_ => None,
|
||||
};
|
||||
let c = windows_candidates(Some(r"D:\Apps\PhotoCraft\".into()), Some(r#""C:\Program Files\PhotoCraft\photocraft.exe""#.into()), env);
|
||||
let c: Vec<String> = c.iter().map(|p| p.to_string_lossy().replace('/', "\\")).collect();
|
||||
assert!(c[0].starts_with(r"D:\Apps\PhotoCraft") && c[0].ends_with("photocraft.exe"), "{c:?}");
|
||||
assert_eq!(c[1], r"C:\Program Files\PhotoCraft\photocraft.exe", "App Paths, quotes dropped");
|
||||
assert!(c[2].starts_with(r"C:\Program Files") && c[2].ends_with("photocraft.exe"), "{c:?}");
|
||||
assert!(c[3].contains("Programs") && c[3].ends_with("photocraft.exe"), "{c:?}");
|
||||
assert_eq!(c.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reg_query_output_is_read() {
|
||||
let out = "\r\nHKEY_LOCAL_MACHINE\\Software\\PhotoCraft\r\n InstallDir REG_SZ C:\\Program Files\\PhotoCraft\\\r\n\r\n";
|
||||
assert_eq!(reg_value(out).as_deref(), Some(r"C:\Program Files\PhotoCraft\"));
|
||||
let default = "\r\nHKEY_LOCAL_MACHINE\\...\\photocraft.exe\r\n (Default) REG_SZ C:\\Program Files\\PhotoCraft\\photocraft.exe\r\n";
|
||||
assert_eq!(reg_value(default).as_deref(), Some(r"C:\Program Files\PhotoCraft\photocraft.exe"));
|
||||
assert_eq!(reg_value("ERROR: The system was unable to find the specified registry key or value."), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_entries_give_their_program() {
|
||||
let appimage = "[Desktop Entry]\nName=PhotoCraft\nExec=\"/home/simon/Apps/photocraft-1.2-linux-x86_64.AppImage\" %F\n";
|
||||
assert_eq!(desktop_exec(appimage), Some(PathBuf::from("/home/simon/Apps/photocraft-1.2-linux-x86_64.AppImage")));
|
||||
assert_eq!(desktop_exec("Exec=/opt/photocraft/photocraft %F\n"), Some(PathBuf::from("/opt/photocraft/photocraft")));
|
||||
assert_eq!(desktop_exec("Exec=photocraft %F\nTryExec=photocraft\n"), None, "a bare name is a PATH lookup");
|
||||
assert_eq!(desktop_exec("[Desktop Entry]\n"), None);
|
||||
let dirs = desktop_entry_dirs(|k| (k == "HOME").then(|| "/home/simon".to_string()));
|
||||
assert_eq!(dirs[0], PathBuf::from("/home/simon/.local/share/applications"));
|
||||
assert!(dirs.contains(&PathBuf::from("/usr/share/applications")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_editors_are_used_as_named() {
|
||||
assert_eq!(launcher("").unwrap(), Launcher::Default);
|
||||
assert_eq!(launcher(" ").unwrap(), Launcher::Default);
|
||||
let named = launcher("/usr/bin/gimp").unwrap();
|
||||
if cfg!(target_os = "macos") {
|
||||
assert_eq!(named, Launcher::MacApp("/usr/bin/gimp".into()));
|
||||
} else {
|
||||
assert_eq!(named, Launcher::Program("/usr/bin/gimp".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
+275
-110
@@ -23,11 +23,16 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod alloc_release;
|
||||
mod appearance;
|
||||
mod control_server;
|
||||
mod dialog_filter;
|
||||
mod editors;
|
||||
mod logging;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod screen_profile;
|
||||
mod window_gpu;
|
||||
|
||||
use dialog_filter::FileDialogExt as _;
|
||||
use lightcraft_engine::Session;
|
||||
@@ -59,7 +64,7 @@ impl eframe::App for App {
|
||||
// Quit from the menu bar: save exactly what closing the window saves (settings and
|
||||
// library, after the unsaved-changes check; otherwise its prompt shows), then end the
|
||||
// process instead of closing the viewport while AppKit is terminating (PR #444).
|
||||
eframe::App::on_exit(self);
|
||||
eframe::App::on_exit(self, None);
|
||||
std::process::exit(0);
|
||||
}
|
||||
self.0.logic(ctx);
|
||||
@@ -71,7 +76,7 @@ impl eframe::App for App {
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.0.ui(ui);
|
||||
}
|
||||
fn on_exit(&mut self) {
|
||||
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
|
||||
if let Err(e) = self.1.save(&self.0) {
|
||||
log::error!("{e}");
|
||||
}
|
||||
@@ -93,13 +98,16 @@ const EXIT_WAIT: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
/// alone on Windows unless `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND` say otherwise (issue #136:
|
||||
/// with Vulkan in the set, wgpu loads the Vulkan driver even when it then picks DX12) — and DX12
|
||||
/// shaders compiled with FXC, never a stray `dxcompiler.dll` (issue #471).
|
||||
/// The fallback chain on top of it (DX12 → Vulkan → GL → glow) is in `window_gpu`.
|
||||
#[cfg(test)]
|
||||
fn window_wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration {
|
||||
let mut c = eframe::egui_wgpu::WgpuConfiguration::default();
|
||||
if let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = &mut c.wgpu_setup {
|
||||
n.instance_descriptor.backends = lightcraft_engine::gpu::backend::window_backends();
|
||||
n.instance_descriptor.backend_options = lightcraft_engine::gpu::backend::backend_options();
|
||||
}
|
||||
c
|
||||
window_gpu::base_options()
|
||||
}
|
||||
|
||||
/// Where the window's renderer files live (`window_gpu::Files`): nothing for `--memory` sessions
|
||||
/// and `LIGHTCRAFT_NO_PREFS`.
|
||||
fn window_files(in_memory: bool) -> window_gpu::Files {
|
||||
window_gpu::Files::new(config_dir().filter(|_| !in_memory && std::env::var_os("LIGHTCRAFT_NO_PREFS").is_none()))
|
||||
}
|
||||
|
||||
/// Written while the GPU compute device is created, removed once that returned
|
||||
@@ -183,6 +191,23 @@ fn load_prefs(in_memory: bool) -> (Option<UiState>, Option<String>, bool) {
|
||||
load_prefs_at(&path, !in_memory)
|
||||
}
|
||||
|
||||
/// Compatibility recovery is limited to persisted docking, not runtime UiState commands.
|
||||
fn decode_saved_ui(bytes: &[u8]) -> Result<(UiState, bool), String> {
|
||||
let mut saved: serde_json::Value = serde_json::from_slice(bytes).map_err(|error| error.to_string())?;
|
||||
let fields = saved.as_object_mut().ok_or("settings must be an object")?;
|
||||
let recovered =
|
||||
fields.get("docking").is_some_and(|value| match serde_json::from_value::<Option<lightcraft_ui_egui::docking::Workspace>>(value.clone()) {
|
||||
Ok(Some(workspace)) => workspace.validate().is_err(),
|
||||
Ok(None) => false,
|
||||
Err(_) => true,
|
||||
});
|
||||
if recovered {
|
||||
fields.remove("docking");
|
||||
}
|
||||
let ui = serde_json::from_value(saved).map_err(|error| error.to_string())?;
|
||||
Ok((ui, recovered))
|
||||
}
|
||||
|
||||
fn load_prefs_at(path: &std::path::Path, set_aside_damaged: bool) -> (Option<UiState>, Option<String>, bool) {
|
||||
let bytes = match std::fs::read(path) {
|
||||
Ok(b) => b,
|
||||
@@ -192,8 +217,15 @@ fn load_prefs_at(path: &std::path::Path, set_aside_damaged: bool) -> (Option<UiS
|
||||
return (None, Some(msg), true);
|
||||
}
|
||||
};
|
||||
match serde_json::from_slice::<UiState>(&bytes) {
|
||||
Ok(ui) => (Some(ui.sanitized()), None, false),
|
||||
match decode_saved_ui(&bytes) {
|
||||
Ok((ui, false)) => (Some(ui.sanitized()), None, false),
|
||||
Ok((ui, true)) => {
|
||||
let msg = format!(
|
||||
"The saved panel layout in {} couldn't be restored. Other settings were loaded; the original file is preserved and settings won't be saved this session.",
|
||||
path.display()
|
||||
);
|
||||
(Some(ui.sanitized()), Some(msg), true)
|
||||
}
|
||||
Err(e) if !set_aside_damaged => {
|
||||
let msg = format!("The app settings ({}) are damaged ({e}). Defaults are used; nothing is saved in this session.", path.display());
|
||||
(None, Some(msg), true)
|
||||
@@ -270,6 +302,9 @@ impl PrefsWriter {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(path) = self.path.clone() else { return Ok(()) };
|
||||
if let Some(workspace) = &ui.docking {
|
||||
workspace.validate()?;
|
||||
}
|
||||
let bytes = serde_json::to_vec_pretty(ui).map_err(|e| e.to_string())?;
|
||||
if bytes == self.written {
|
||||
return Ok(());
|
||||
@@ -348,9 +383,27 @@ fn explorer_select_command(path: &str) -> std::process::Command {
|
||||
c
|
||||
}
|
||||
|
||||
/// The platform services; `ctx` repaints when a file dialog closes, and `log_file` is
|
||||
/// for Help ▸ Open Log Folder.
|
||||
fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services {
|
||||
/// The file dialogs' parent, set once the window exists (`services`). A dialog with a parent is
|
||||
/// a child of the app's window; without one, the xdg-desktop-portal picker on Wayland is a stray
|
||||
/// top-level ("Failed to associate portal window with parent window" in the portal's log): a
|
||||
/// tiling compositor puts it beside the app, and a fullscreen window hides it entirely, so
|
||||
/// Import Photos… looks like a hang. rfd exports the Wayland surface through xdg_foreign itself;
|
||||
/// `FileDialog` is `Clone` and `Send`, so one parented template serves every picker, including
|
||||
/// the one on its own thread (#191). Only set on Linux and FreeBSD: on macOS a parented dialog
|
||||
/// becomes a sheet and on Windows a modal, a behaviour change this does not make.
|
||||
static DIALOG_PARENT: std::sync::OnceLock<rfd::FileDialog> = std::sync::OnceLock::new();
|
||||
|
||||
/// A file dialog owned by the app's window, or a plain one before the window exists.
|
||||
fn file_dialog() -> rfd::FileDialog {
|
||||
DIALOG_PARENT.get().cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The platform services; `cc` is the window the file dialogs belong to (its `egui_ctx` repaints
|
||||
/// when a dialog closes), and `log_file` is for Help ▸ Open Log Folder.
|
||||
fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path>) -> Services {
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
|
||||
let _ = DIALOG_PARENT.set(rfd::FileDialog::new().set_parent(cc));
|
||||
Services {
|
||||
// Commands' file dialogs run on their own thread (#191): a dialog on the UI thread
|
||||
// stopped the window answering the compositor, which then reported the app as hung.
|
||||
@@ -361,7 +414,7 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
|
||||
std::thread::Builder::new()
|
||||
.name("file-dialog".into())
|
||||
.spawn(move || {
|
||||
let mut d = rfd::FileDialog::new();
|
||||
let mut d = file_dialog();
|
||||
if let Some(t) = &req.title {
|
||||
d = d.set_title(t);
|
||||
}
|
||||
@@ -387,37 +440,17 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
|
||||
})),
|
||||
log_file: log_file.map(|p| p.display().to_string()),
|
||||
pick_lightroom_catalog: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
file_dialog()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Import Lightroom Catalog"))
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Lightroom Classic Catalog"), &["lrcat"])
|
||||
.pick_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
pick_folder: Some(Box::new(|| {
|
||||
rfd::FileDialog::new().set_title(lightcraft_ui_egui::i18n::tr("Open Library")).pick_folder().map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
open_with: Some(Box::new(|path: &str, app: &str| {
|
||||
// spawned, never waited for: the editor runs alongside
|
||||
let app = app.trim();
|
||||
let mut c = if cfg!(target_os = "macos") {
|
||||
let mut c = std::process::Command::new("open");
|
||||
if !app.is_empty() {
|
||||
c.args(["-a", app]);
|
||||
}
|
||||
c
|
||||
} else if app.is_empty() {
|
||||
if cfg!(target_os = "windows") {
|
||||
let mut c = std::process::Command::new("cmd");
|
||||
c.args(["/C", "start", ""]);
|
||||
c
|
||||
} else {
|
||||
std::process::Command::new("xdg-open")
|
||||
}
|
||||
} else {
|
||||
std::process::Command::new(app)
|
||||
};
|
||||
c.arg(path).spawn().map(|_| ()).map_err(|e| e.to_string())
|
||||
file_dialog().set_title(lightcraft_ui_egui::i18n::tr("Open Library")).pick_folder().map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
open_with: Some(Box::new(editors::open_with)),
|
||||
check_editor: Some(Box::new(editors::check)),
|
||||
open_url: Some(Box::new(|url: &str| {
|
||||
if !url.starts_with("https://") {
|
||||
return Err("only https links are opened".into());
|
||||
@@ -445,7 +478,7 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
|
||||
.and_then(|s| if s.success() || cfg!(target_os = "windows") { Ok(()) } else { Err(format!("reveal failed: {s}")) })
|
||||
})),
|
||||
pick_files: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
file_dialog()
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Photos"), lightcraft_engine::import::EXTENSIONS)
|
||||
.pick_files()
|
||||
.unwrap_or_default()
|
||||
@@ -454,14 +487,14 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
|
||||
.collect()
|
||||
})),
|
||||
pick_denoise_model: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
file_dialog()
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("ONNX model"), &["onnx"])
|
||||
.pick_file()
|
||||
.map(|p| vec![p.to_string_lossy().into_owned()])
|
||||
.unwrap_or_default()
|
||||
})),
|
||||
pick_preset_files: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
file_dialog()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Import Presets"))
|
||||
.add_filter_nocase(
|
||||
lightcraft_ui_egui::i18n::tr("Presets & Profiles"),
|
||||
@@ -474,7 +507,7 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
|
||||
.collect()
|
||||
})),
|
||||
pick_model_file: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
file_dialog()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Add a Face Recognition Model"))
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Face models (ONNX)"), &["onnx"])
|
||||
.pick_file()
|
||||
@@ -482,15 +515,20 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
|
||||
.unwrap_or_default()
|
||||
})),
|
||||
pick_tracklog: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
file_dialog()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Auto-Tag from Tracklog"))
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("GPS Track Log"), &["gpx"])
|
||||
.pick_file()
|
||||
.map(|p| vec![p.to_string_lossy().to_string()])
|
||||
.unwrap_or_default()
|
||||
})),
|
||||
// macOS doesn't colour-manage the window: previews follow the screen's profile (#787)
|
||||
#[cfg(target_os = "macos")]
|
||||
screen_profile: Some(Box::new(screen_profile::icc)),
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
screen_profile: None,
|
||||
pick_display_profile: Some(Box::new(|| {
|
||||
let mut d = rfd::FileDialog::new()
|
||||
let mut d = file_dialog()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Choose Monitor Profile"))
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("ICC Profiles"), &["icc", "icm"]);
|
||||
if let Some(dir) = display_profile_dir() {
|
||||
@@ -499,7 +537,7 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
|
||||
d.pick_file().map(|p| vec![p.to_string_lossy().to_string()]).unwrap_or_default()
|
||||
})),
|
||||
save_preset_file: Some(Box::new(|name: &str| {
|
||||
rfd::FileDialog::new()
|
||||
file_dialog()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Export Presets"))
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("LightCraft Preset"), &["lcpreset"])
|
||||
.set_file_name(name)
|
||||
@@ -507,7 +545,7 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
pick_keyword_list: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
file_dialog()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Import Keywords"))
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Keyword Lists"), &["txt", "utf8"])
|
||||
.pick_file()
|
||||
@@ -515,7 +553,7 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
|
||||
.unwrap_or_default()
|
||||
})),
|
||||
save_keyword_list: Some(Box::new(|name: &str| {
|
||||
rfd::FileDialog::new()
|
||||
file_dialog()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Export Keywords"))
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Keyword Lists"), &["txt"])
|
||||
.set_file_name(name)
|
||||
@@ -523,7 +561,7 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
pick_curve_preset_files: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
file_dialog()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Import Point Curve Presets"))
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve", "json"])
|
||||
.pick_files()
|
||||
@@ -533,7 +571,7 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
|
||||
.collect()
|
||||
})),
|
||||
save_curve_preset_file: Some(Box::new(|name: &str| {
|
||||
rfd::FileDialog::new()
|
||||
file_dialog()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Export Point Curve Presets"))
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve"])
|
||||
.set_file_name(name)
|
||||
@@ -549,6 +587,7 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
|
||||
// the library is a folder on disk: backed up with the user's other files
|
||||
backup_library: None,
|
||||
restore_library: None,
|
||||
system_theme: appearance::service(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -556,6 +595,8 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
|
||||
fn open_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: bool) -> (Session, Option<LibraryProblem>) {
|
||||
let (mut s, problem) = open_library_session(in_memory, dir, seed_demo);
|
||||
s.face_models_dir = lightcraft_engine::config::default_face_models_dir();
|
||||
// Control requests are answered on the UI thread: Add to Apple Photos must not wait there
|
||||
s.apple_photos_background = true;
|
||||
(s, problem)
|
||||
}
|
||||
|
||||
@@ -608,6 +649,7 @@ OPTIONS:
|
||||
--version, --help
|
||||
|
||||
ENVIRONMENT:
|
||||
LIGHTCRAFT_CONFIG_DIR=DIR use this settings folder instead of the platform default (empty = no settings folder)
|
||||
LIGHTCRAFT_GPU_BACKEND=dx12|vulkan|metal|auto|off graphics backend (default: DX12 on Windows, Metal on macOS,
|
||||
Vulkan on Linux; off = render on the CPU); else WGPU_BACKEND. LIGHTCRAFT_GPU=0: CPU rendering.
|
||||
LIGHTCRAFT_SAM3_DIR=DIR the SAM 3 model for Object / Describe masks (default: <settings folder>/models/sam3;
|
||||
@@ -663,10 +705,16 @@ fn control_port_from(what: &str, value: Option<String>) -> Option<u16> {
|
||||
port
|
||||
}
|
||||
|
||||
/// An isolated profile keeps the emergency crash log with its other app data. With no
|
||||
/// override (or an empty override), retain the historical system-temp location.
|
||||
fn panic_log_path_with(env: impl Fn(&str) -> Option<std::ffi::OsString>, temp: std::path::PathBuf) -> std::path::PathBuf {
|
||||
env("LIGHTCRAFT_CONFIG_DIR").filter(|value| !value.is_empty()).map(std::path::PathBuf::from).unwrap_or(temp).join("lightcraft-panics.log")
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
// First, so every start-up record is kept for the log file (`logging`).
|
||||
let logger = logging::install();
|
||||
lightcraft_engine::guard::install_hook(std::env::temp_dir().join("lightcraft-panics.log"));
|
||||
lightcraft_engine::guard::install_hook(panic_log_path_with(|name| std::env::var_os(name), std::env::temp_dir()));
|
||||
if let Some(logger) = logger {
|
||||
logging::record_panics(logger);
|
||||
}
|
||||
@@ -737,7 +785,14 @@ fn main() -> eframe::Result {
|
||||
let gpu_crash = gpu_crash_check(in_memory);
|
||||
let gpu_on = prefs.as_ref().is_none_or(|u| u.settings.gpu) && gpu_crash.is_none();
|
||||
lightcraft_engine::gpu::set_enabled(gpu_on);
|
||||
// the window's renderer: probed (Windows) and with a fallback chain, so a driver that can't
|
||||
// build egui's pipeline never kills the start-up
|
||||
let win_files = std::sync::Arc::new(window_files(in_memory));
|
||||
let window_gpu::Chosen { renderer, wgpu_options, which: window_renderer, notice: window_notice } = window_gpu::choose(&win_files);
|
||||
win_files.arm(window_renderer);
|
||||
let win_files_app = win_files.clone();
|
||||
let options = eframe::NativeOptions {
|
||||
renderer,
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("LightCraft")
|
||||
.with_inner_size([1600.0, 1000.0])
|
||||
@@ -749,73 +804,97 @@ fn main() -> eframe::Result {
|
||||
.with_icon(app_icon())
|
||||
// Wayland matches the window to packaging/linux/ai.storyteller.lightcraft.desktop by this id
|
||||
.with_app_id(APP_ID),
|
||||
wgpu_options: window_wgpu_options(),
|
||||
wgpu_options,
|
||||
..Default::default()
|
||||
};
|
||||
// the app's copy: `log_file` is still named in the message of a start that fails
|
||||
let app_log_file = log_file.clone();
|
||||
let started = eframe::run_native(
|
||||
"LightCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let (mut session, problem) = open_session(in_memory, library_dir, seed_demo && files.is_empty());
|
||||
// AI masks: the SAM 3 checkpoint (facebook/sam3) in <config>/models/sam3, or LIGHTCRAFT_SAM3_DIR
|
||||
// (never required: without it, AI masks offer to download it; see docs/ai-masks.md)
|
||||
session.segmenter.dir =
|
||||
std::env::var_os("LIGHTCRAFT_SAM3_DIR").map(std::path::PathBuf::from).or_else(|| config_dir().map(|d| d.join("models").join("sam3")));
|
||||
// the user's own download locations, one base URL per line (LIGHTCRAFT_SAM3_MIRRORS too)
|
||||
session.segmenter.mirrors_file = config_dir().map(|d| d.join("models").join("sam3-mirrors.txt"));
|
||||
let mut app = LightcraftApp::new(session, services(cc.egui_ctx.clone(), app_log_file.as_deref()));
|
||||
if let Some(ui) = prefs {
|
||||
app.ui = ui;
|
||||
}
|
||||
lightcraft_ui_egui::i18n::set_language(app.ui.language);
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
app.notices.extend(prefs_warning);
|
||||
app.notices.extend(launch_notices);
|
||||
// what's on disk now: only changes are written
|
||||
let writer = PrefsWriter {
|
||||
path: if in_memory { None } else { prefs_path() },
|
||||
written: serde_json::to_vec_pretty(&app.ui).unwrap_or_default(),
|
||||
library: app.ui.settings.library_path.clone(),
|
||||
keep_file: keep_prefs_file,
|
||||
..Default::default()
|
||||
};
|
||||
// after the snapshot above, so the writer saves the switched-off preference
|
||||
if let Some(notice) = gpu_crash {
|
||||
app.ui.settings.gpu = false;
|
||||
app.notices.push(notice);
|
||||
}
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx);
|
||||
}
|
||||
if let Some(mut p) = problem {
|
||||
// imported once the user has chosen where (into the library, or the temporary session)
|
||||
p.pending_import = files;
|
||||
app.library_problem = Some(p);
|
||||
} else if !files.is_empty() {
|
||||
// listed and read on the import worker once the window shows (issue #374: never
|
||||
// here, before the first frame, where a large folder kept the window from
|
||||
// appearing)
|
||||
if let Err(e) = lightcraft_ui_egui::import::start_paths(&mut app, files) {
|
||||
log::warn!("import at launch: {e}");
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let mut options = options;
|
||||
// winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file
|
||||
// manager showed a "no" cursor. Run through XWayland when it's there; LIGHTCRAFT_WAYLAND=1
|
||||
// keeps the native Wayland backend.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
if std::env::var_os("DISPLAY").is_some() && std::env::var_os("LIGHTCRAFT_WAYLAND").is_none() {
|
||||
options.event_loop_builder = Some(Box::new(|b| {
|
||||
use winit::platform::x11::EventLoopBuilderExtX11;
|
||||
b.with_x11();
|
||||
}));
|
||||
}
|
||||
let started = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
eframe::run_native(
|
||||
"LightCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
// the window's renderer is up (eframe calls this after creating it)
|
||||
win_files_app.started(window_renderer);
|
||||
let (mut session, problem) = open_session(in_memory, library_dir, seed_demo && files.is_empty());
|
||||
// AI masks: the SAM 3 checkpoint (facebook/sam3) in <config>/models/sam3, or LIGHTCRAFT_SAM3_DIR
|
||||
// (never required: without it, AI masks offer to download it; see docs/ai-masks.md)
|
||||
// and the user's own download locations, one base URL per line (LIGHTCRAFT_SAM3_MIRRORS too)
|
||||
session = session.with_default_sam3_model();
|
||||
let mut app = LightcraftApp::new(session, services(cc, app_log_file.as_deref()));
|
||||
if let Some(ui) = prefs {
|
||||
app.ui = ui;
|
||||
}
|
||||
app.ui.view = lightcraft_ui_egui::state::ViewMode::PhotoGrid;
|
||||
}
|
||||
// native menu bar generated from the command registry (macOS; elsewhere the menus are
|
||||
// drawn in the window's top bar). LIGHTCRAFT_NO_NATIVE_MENU=1 keeps the in-window menus.
|
||||
#[cfg(target_os = "macos")]
|
||||
let menu = (std::env::var_os("LIGHTCRAFT_NO_NATIVE_MENU").is_none()).then(|| native_menu::NativeMenu::install(&mut app, &cc.egui_ctx));
|
||||
Ok(Box::new(App(
|
||||
app,
|
||||
writer,
|
||||
lightcraft_ui_egui::i18n::set_language(app.ui.language);
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
app.notices.extend(prefs_warning);
|
||||
app.notices.extend(launch_notices);
|
||||
app.notices.extend(window_notice);
|
||||
// what's on disk now: only changes are written
|
||||
let writer = PrefsWriter {
|
||||
path: if in_memory { None } else { prefs_path() },
|
||||
written: serde_json::to_vec_pretty(&app.ui).unwrap_or_default(),
|
||||
library: app.ui.settings.library_path.clone(),
|
||||
keep_file: keep_prefs_file,
|
||||
..Default::default()
|
||||
};
|
||||
// after the snapshot above, so the writer saves the switched-off preference
|
||||
if let Some(notice) = gpu_crash {
|
||||
app.ui.settings.gpu = false;
|
||||
app.notices.push(notice);
|
||||
}
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx);
|
||||
}
|
||||
if let Some(mut p) = problem {
|
||||
// imported once the user has chosen where (into the library, or the temporary session)
|
||||
p.pending_import = files;
|
||||
app.library_problem = Some(p);
|
||||
} else if !files.is_empty() {
|
||||
// listed and read on the import worker once the window shows (issue #374: never
|
||||
// here, before the first frame, where a large folder kept the window from
|
||||
// appearing)
|
||||
if let Err(e) = lightcraft_ui_egui::import::start_paths(&mut app, files) {
|
||||
log::warn!("import at launch: {e}");
|
||||
}
|
||||
app.ui.view = lightcraft_ui_egui::state::ViewMode::PhotoGrid;
|
||||
}
|
||||
// native menu bar generated from the command registry (macOS; elsewhere the menus are
|
||||
// drawn in the window's top bar). LIGHTCRAFT_NO_NATIVE_MENU=1 keeps the in-window menus.
|
||||
#[cfg(target_os = "macos")]
|
||||
menu,
|
||||
)))
|
||||
}),
|
||||
);
|
||||
let menu =
|
||||
(std::env::var_os("LIGHTCRAFT_NO_NATIVE_MENU").is_none()).then(|| native_menu::NativeMenu::install(&mut app, &cc.egui_ctx));
|
||||
Ok(Box::new(App(
|
||||
app,
|
||||
writer,
|
||||
#[cfg(target_os = "macos")]
|
||||
menu,
|
||||
)))
|
||||
}),
|
||||
)
|
||||
}));
|
||||
// a panic that still escaped the renderer's start (the probe and error handler should
|
||||
// prevent it): an error like any other failed start
|
||||
let started = started.unwrap_or_else(|_| Err(eframe::Error::AppCreation("the window's renderer panicked while starting".into())));
|
||||
if let Err(e) = &started {
|
||||
// the renderer never came up: start again, which tries the next one in the chain
|
||||
if window_gpu::relaunch_after_failure(&win_files) {
|
||||
log::error!("LightCraft could not open its window with {}: {e}; trying the next renderer", window_renderer.name());
|
||||
return Ok(());
|
||||
}
|
||||
startup_failed(&e.to_string(), log_file.as_deref());
|
||||
}
|
||||
started
|
||||
@@ -868,6 +947,16 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&home);
|
||||
}
|
||||
|
||||
/// Issue #236: the window answers control requests on its UI thread, so the desktop session
|
||||
/// adds to Apple Photos on a worker (headless sessions wait instead).
|
||||
#[test]
|
||||
fn the_desktop_session_adds_to_apple_photos_in_the_background() {
|
||||
let (s, problem) = open_session(true, None, false);
|
||||
assert!(problem.is_none());
|
||||
assert!(s.apple_photos_background);
|
||||
assert!(!lightcraft_engine::Session::new().apple_photos_background);
|
||||
}
|
||||
|
||||
/// Issue #103: a damaged ui.json (which holds the library location) is kept aside and
|
||||
/// reported, not silently replaced by the defaults.
|
||||
#[test]
|
||||
@@ -904,6 +993,59 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docking_survives_native_settings_file_reload() {
|
||||
use lightcraft_ui_egui::docking::Panel;
|
||||
let d = dir("docking-reload");
|
||||
let path = d.join("ui.json");
|
||||
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default());
|
||||
app.run("ui.dock", serde_json::json!({"operation":"float", "panel":"tools", "rect":[70,80,310,500]})).unwrap();
|
||||
app.run("ui.dock", serde_json::json!({"operation":"close", "panel":"tools"})).unwrap();
|
||||
let mut writer = PrefsWriter { path: Some(path.clone()), ..Default::default() };
|
||||
writer.save_ui(&app.ui, false).unwrap();
|
||||
let (restored, warning, keep) = load_prefs_at(&path, false);
|
||||
assert!(warning.is_none());
|
||||
assert!(!keep);
|
||||
let mut restored_app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default());
|
||||
restored_app.ui = restored.unwrap();
|
||||
restored_app.run("ui.dock", serde_json::json!({"operation":"open", "panel":"tools"})).unwrap();
|
||||
assert_eq!(
|
||||
restored_app.ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.panels.contains(&Panel::Tools)).unwrap().rect,
|
||||
[70.0, 80.0, 310.0, 500.0]
|
||||
);
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn future_docking_preserves_library_settings_and_the_original_preferences_file() {
|
||||
let d = dir("future-docking");
|
||||
let path = d.join("ui.json");
|
||||
let mut ui = UiState { thumb_size: 210.0, filmstrip: false, ..Default::default() };
|
||||
ui.settings.library_path = "/Volumes/Photos/Synthetic Library".into();
|
||||
for docking in [serde_json::json!({"layout":{"root":{"FutureLayout":{}}}}), serde_json::json!(false)] {
|
||||
let mut saved = serde_json::to_value(&ui).unwrap();
|
||||
saved["docking"] = docking;
|
||||
assert!(serde_json::from_value::<UiState>(saved.clone()).is_err(), "runtime state parsing stays strict");
|
||||
let bytes = serde_json::to_vec(&saved).unwrap();
|
||||
for set_aside in [false, true] {
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
let (loaded, warning, keep) = load_prefs_at(&path, set_aside);
|
||||
let loaded = loaded.unwrap();
|
||||
assert_eq!(loaded.settings.library_path, ui.settings.library_path);
|
||||
assert_eq!(loaded.thumb_size, ui.thumb_size);
|
||||
assert!(!loaded.filmstrip);
|
||||
assert!(loaded.docking.is_none());
|
||||
assert!(warning.unwrap().contains("Other settings were loaded"));
|
||||
assert!(keep);
|
||||
let mut writer = PrefsWriter { path: Some(path.clone()), keep_file: keep, ..Default::default() };
|
||||
writer.save_ui(&loaded, false).unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), bytes);
|
||||
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1, "a future layout does not rename or replace the settings file");
|
||||
}
|
||||
}
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
/// Issue #164: the writer saves changed settings to its file — and nothing at all without one
|
||||
/// (`--memory`) or while the session is the temporary one offered when the library can't open.
|
||||
#[test]
|
||||
@@ -999,6 +1141,16 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue #250: the window's device is created without wgpu's indirect-call validation pipeline,
|
||||
/// which Intel Macs' Metal compiler fails to build (the device was lost, the window never opened).
|
||||
#[test]
|
||||
fn window_has_no_indirect_call_validation_pipeline() {
|
||||
let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = window_wgpu_options().wgpu_setup else { panic!("expected CreateNew") };
|
||||
if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() {
|
||||
assert!(!n.instance_descriptor.flags.contains(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL));
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue #234: Windows opens links with the URL protocol handler (the default browser), not
|
||||
/// File Explorer, and passes the whole map link, `?`, `&` and `#` included, as one argument.
|
||||
#[test]
|
||||
@@ -1056,3 +1208,16 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod config_path_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn config_override_keeps_panic_log_in_isolated_profile() {
|
||||
let temp = std::path::PathBuf::from("temp");
|
||||
assert_eq!(panic_log_path_with(|_| Some("isolated".into()), temp.clone()), std::path::PathBuf::from("isolated/lightcraft-panics.log"));
|
||||
assert_eq!(panic_log_path_with(|_| None, temp.clone()), temp.join("lightcraft-panics.log"));
|
||||
assert_eq!(panic_log_path_with(|_| Some("".into()), temp.clone()), temp.join("lightcraft-panics.log"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//! The ICC profile of the screen showing LightCraft's window (macOS).
|
||||
//!
|
||||
//! macOS colour-manages windows that say what colour space they draw in. wgpu leaves the window's
|
||||
//! Metal layer without one for sRGB content, which Core Animation shows without colour matching, so
|
||||
//! on a wide-gamut (P3) screen the previews looked more saturated than the exported files do in
|
||||
//! Preview or Photos (issue #787). While no monitor profile is chosen in Settings ▸ Display, the
|
||||
//! previews are shown through this profile instead (`Services::screen_profile`).
|
||||
|
||||
use objc2::MainThreadMarker;
|
||||
use objc2_app_kit::{NSApplication, NSScreen};
|
||||
|
||||
/// The profile of the screen the window is mostly on (the main screen when it is hidden), or `None`
|
||||
/// when it isn't known: off the main thread, or a screen without a usable profile.
|
||||
pub fn icc() -> Option<Vec<u8>> {
|
||||
let mtm = MainThreadMarker::new()?;
|
||||
let app = NSApplication::sharedApplication(mtm);
|
||||
let window = app.mainWindow().or_else(|| app.keyWindow()).or_else(|| app.windows().firstObject());
|
||||
let screen = window.and_then(|w| w.screen()).or_else(|| NSScreen::mainScreen(mtm))?;
|
||||
let icc = screen.colorSpace()?.ICCProfileData()?.to_vec();
|
||||
(!icc.is_empty()).then_some(icc)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn unknown_off_the_main_thread() {
|
||||
// AppKit is main-thread only; a test thread gets nothing rather than undefined behaviour
|
||||
assert_eq!(std::thread::spawn(super::icc).join().unwrap(), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
//! The window's renderer and its fallback chain (crash report: on some Windows machines FXC fails
|
||||
//! to compile egui's own shaders on DX12 — "The parameter is incorrect. (0x80070057)" in
|
||||
//! `egui_pipeline` — and wgpu's default error handler panicked, killing the app at start-up).
|
||||
//!
|
||||
//! - **Windows:** each renderer of [`backend::window_renderer_order`] (DX12, Vulkan, GL, then
|
||||
//! eframe's glow) is *probed* before the window opens: a device is created with an error handler
|
||||
//! that logs instead of panicking, and egui's pipeline is built inside error scopes. The first
|
||||
//! that builds cleanly is handed to eframe as an existing device (so its error handler stays).
|
||||
//! - **Everywhere:** a marker file names the renderer being started and is removed once the
|
||||
//! window's renderer is up. Left behind — the start died or panicked — the next launch tries that
|
||||
//! renderer last; [`relaunch_after_failure`] starts that next launch right away.
|
||||
//! - The renderer that worked is remembered (`<config>/window-renderer`, Windows only) and tried
|
||||
//! first next time. `LIGHTCRAFT_GPU_BACKEND=dx12|vulkan|gl|glow` (or `WGPU_BACKEND`) forces one.
|
||||
|
||||
use eframe::egui_wgpu::{WgpuConfiguration, WgpuSetup, WgpuSetupExisting};
|
||||
use eframe::wgpu;
|
||||
use lightcraft_engine::gpu::backend::{self, Backends, WindowRenderer};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Environment variable counting automatic relaunches after a failed start (bounds the chain).
|
||||
const RETRY_VAR: &str = "LIGHTCRAFT_WINDOW_RETRY";
|
||||
/// Never more relaunches than renderers to try.
|
||||
const MAX_RETRIES: u32 = 4;
|
||||
|
||||
/// The window's renderer for this start.
|
||||
pub struct Chosen {
|
||||
pub renderer: eframe::Renderer,
|
||||
pub wgpu_options: WgpuConfiguration,
|
||||
pub which: WindowRenderer,
|
||||
/// For the user: the last start failed with another renderer, or one failed now.
|
||||
pub notice: Option<String>,
|
||||
}
|
||||
|
||||
/// Files under the settings folder (`None` dir: write nothing — `--memory`, `LIGHTCRAFT_NO_PREFS`).
|
||||
pub struct Files {
|
||||
dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Files {
|
||||
pub fn new(dir: Option<PathBuf>) -> Self {
|
||||
Self { dir }
|
||||
}
|
||||
fn marker(&self) -> Option<PathBuf> {
|
||||
self.dir.as_ref().map(|d| d.join("window-init.marker"))
|
||||
}
|
||||
fn remembered(&self) -> Option<PathBuf> {
|
||||
self.dir.as_ref().map(|d| d.join("window-renderer"))
|
||||
}
|
||||
fn read(p: Option<PathBuf>) -> Option<WindowRenderer> {
|
||||
std::fs::read_to_string(p?).ok().as_deref().and_then(WindowRenderer::parse)
|
||||
}
|
||||
fn write(p: Option<PathBuf>, r: WindowRenderer) {
|
||||
let Some(p) = p else { return };
|
||||
if let Some(d) = p.parent() {
|
||||
let _ = std::fs::create_dir_all(d);
|
||||
}
|
||||
if let Err(e) = std::fs::write(&p, format!("{}\n", r.name())) {
|
||||
log::warn!("window: could not write {}: {e}", p.display());
|
||||
}
|
||||
}
|
||||
/// The renderer the last start died in (marker left behind), removing the marker.
|
||||
pub fn take_failed(&self) -> Option<WindowRenderer> {
|
||||
let m = self.marker()?;
|
||||
let r = Self::read(Some(m.clone()));
|
||||
let _ = std::fs::remove_file(m);
|
||||
r
|
||||
}
|
||||
/// Before the window opens: record what is being started.
|
||||
pub fn arm(&self, r: WindowRenderer) {
|
||||
Self::write(self.marker(), r);
|
||||
}
|
||||
/// The window's renderer is up: clear the marker and (Windows) remember it.
|
||||
pub fn started(&self, r: WindowRenderer) {
|
||||
if let Some(m) = self.marker() {
|
||||
let _ = std::fs::remove_file(m);
|
||||
}
|
||||
if cfg!(windows) {
|
||||
Self::write(self.remembered(), r);
|
||||
}
|
||||
}
|
||||
/// The marker is still there: the window never came up.
|
||||
pub fn armed(&self) -> bool {
|
||||
self.marker().is_some_and(|m| m.exists())
|
||||
}
|
||||
}
|
||||
|
||||
/// The renderer for this start: see the module docs.
|
||||
pub fn choose(files: &Files) -> Chosen {
|
||||
let forced =
|
||||
backend::forced_window_renderer(std::env::var("LIGHTCRAFT_GPU_BACKEND").ok().as_deref(), std::env::var("WGPU_BACKEND").ok().as_deref());
|
||||
let failed = files.take_failed();
|
||||
let remembered = if cfg!(windows) { Files::read(files.remembered()) } else { None };
|
||||
let compiled = wgpu::Instance::enabled_backend_features();
|
||||
let os = std::env::consts::OS;
|
||||
let order = backend::window_renderer_order(forced, remembered, failed, os, compiled, true);
|
||||
let mut notice = failed.map(|f| format!("The window could not start with its {} renderer last time.", f.name()));
|
||||
let mut problems = Vec::new();
|
||||
let chosen = if cfg!(windows) {
|
||||
pick_probed(&order, &mut problems)
|
||||
} else if forced == Some(WindowRenderer::Glow) || (forced.is_none() && failed.is_some_and(|f| f != WindowRenderer::Glow)) {
|
||||
// elsewhere eframe's own wgpu set-up stays the default; glow after a failed start
|
||||
Some((WindowRenderer::Glow, None))
|
||||
} else {
|
||||
Some((WindowRenderer::Wgpu(backend::window_backends()), None))
|
||||
};
|
||||
let (which, existing) = chosen.unwrap_or((WindowRenderer::Glow, None));
|
||||
if !problems.is_empty() {
|
||||
notice = Some(format!("{} {}", notice.unwrap_or_default(), problems.join(" ")).trim().to_string());
|
||||
}
|
||||
let notice = notice.map(|n| {
|
||||
format!(
|
||||
"{n} {app} now draws its window with {}. To choose another, start {app} with {var}=dx12, vulkan, gl or glow \
|
||||
(see docs/gpu-pipeline.md → Troubleshooting).",
|
||||
which.name(),
|
||||
app = "LightCraft",
|
||||
var = "LIGHTCRAFT_GPU_BACKEND",
|
||||
)
|
||||
});
|
||||
log::info!("window: renderer {} (order {:?})", which.name(), order.iter().map(|r| r.name()).collect::<Vec<_>>());
|
||||
let mut wgpu_options = base_options();
|
||||
let renderer = match (which, existing) {
|
||||
(WindowRenderer::Glow, _) => eframe::Renderer::Glow,
|
||||
(WindowRenderer::Wgpu(_), Some(e)) => {
|
||||
wgpu_options.wgpu_setup = WgpuSetup::Existing(e);
|
||||
eframe::Renderer::Wgpu
|
||||
}
|
||||
(WindowRenderer::Wgpu(b), None) => {
|
||||
if let WgpuSetup::CreateNew(n) = &mut wgpu_options.wgpu_setup {
|
||||
n.instance_descriptor.backends = b;
|
||||
}
|
||||
eframe::Renderer::Wgpu
|
||||
}
|
||||
};
|
||||
Chosen { renderer, wgpu_options, which, notice }
|
||||
}
|
||||
|
||||
/// eframe's defaults with the app's backends and DX12 shader compiler (issues #136, #471).
|
||||
pub fn base_options() -> WgpuConfiguration {
|
||||
let mut c = WgpuConfiguration::default();
|
||||
if let WgpuSetup::CreateNew(n) = &mut c.wgpu_setup {
|
||||
n.instance_descriptor.backends = backend::window_backends();
|
||||
n.instance_descriptor.backend_options = backend::backend_options();
|
||||
// no indirect-call validation pipeline: Intel Metal fails to compile it and the window never opens (#250)
|
||||
n.instance_descriptor.flags = backend::instance_flags_from_env(n.instance_descriptor.flags);
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// The first renderer of `order` that probes cleanly (glow is taken without a probe).
|
||||
fn pick_probed(order: &[WindowRenderer], problems: &mut Vec<String>) -> Option<(WindowRenderer, Option<WgpuSetupExisting>)> {
|
||||
for &r in order {
|
||||
match r {
|
||||
WindowRenderer::Glow => return Some((r, None)),
|
||||
WindowRenderer::Wgpu(b) => {
|
||||
let probed = std::panic::catch_unwind(|| probe(b)).unwrap_or_else(|_| Err("panicked".into()));
|
||||
match probed {
|
||||
Ok(e) => return Some((r, Some(e))),
|
||||
Err(e) => {
|
||||
log::error!("window: the {} renderer failed: {e}", r.name());
|
||||
problems.push(format!("The {} renderer failed ({e}).", r.name()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Create a device on `backends` and build egui's pipeline on it; any wgpu error is returned.
|
||||
fn probe(backends: Backends) -> Result<WgpuSetupExisting, String> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
|
||||
backends,
|
||||
flags: backend::instance_flags_from_env(wgpu::InstanceFlags::from_build_config().with_env()),
|
||||
backend_options: backend::backend_options(),
|
||||
memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(),
|
||||
display: None,
|
||||
});
|
||||
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
|
||||
power_preference: wgpu::PowerPreference::from_env().unwrap_or(wgpu::PowerPreference::HighPerformance),
|
||||
..Default::default()
|
||||
}))
|
||||
.map_err(|e| format!("no adapter: {e}"))?;
|
||||
let base = if adapter.get_info().backend == wgpu::Backend::Gl { wgpu::Limits::downlevel_webgl2_defaults() } else { wgpu::Limits::default() };
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
label: Some("egui wgpu device"),
|
||||
required_limits: wgpu::Limits { max_texture_dimension_2d: 8192, ..base },
|
||||
..Default::default()
|
||||
}))
|
||||
.map_err(|e| format!("{}: no device: {e}", adapter.get_info().name))?;
|
||||
install_error_handler(&device);
|
||||
check_pipeline(&device, |d| {
|
||||
drop(eframe::egui_wgpu::Renderer::new(d, wgpu::TextureFormat::Bgra8Unorm, eframe::egui_wgpu::RendererOptions::default()));
|
||||
})?;
|
||||
Ok(WgpuSetupExisting { instance, adapter, device, queue })
|
||||
}
|
||||
|
||||
/// wgpu's default handler for errors outside an error scope panics ("Handling wgpu errors as
|
||||
/// fatal by default"); this one logs.
|
||||
pub fn install_error_handler(device: &wgpu::Device) {
|
||||
device.on_uncaptured_error(std::sync::Arc::new(|e| log::error!("window: wgpu error: {e}")));
|
||||
}
|
||||
|
||||
/// Run `build` inside validation, internal and out-of-memory error scopes: the first error is
|
||||
/// returned instead of reaching the uncaptured-error handler.
|
||||
pub fn check_pipeline(device: &wgpu::Device, build: impl FnOnce(&wgpu::Device)) -> Result<(), String> {
|
||||
let oom = device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
|
||||
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
|
||||
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
build(device);
|
||||
let errors = [pollster::block_on(validation.pop()), pollster::block_on(internal.pop()), pollster::block_on(oom.pop())];
|
||||
match errors.into_iter().flatten().next() {
|
||||
Some(e) => Err(e.to_string()),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The start failed (error or panic) before the window's renderer came up: start the app again,
|
||||
/// which then tries the next renderer. `false`: not relaunched (it came up, nothing written to
|
||||
/// disk, or the retries are used up).
|
||||
pub fn relaunch_after_failure(files: &Files) -> bool {
|
||||
if !files.armed() {
|
||||
return false;
|
||||
}
|
||||
let tries = std::env::var(RETRY_VAR).ok().and_then(|v| v.parse::<u32>().ok()).unwrap_or(0);
|
||||
if tries >= MAX_RETRIES {
|
||||
return false;
|
||||
}
|
||||
let Ok(exe) = std::env::current_exe() else { return false };
|
||||
log::warn!("window: the renderer failed to start; starting again with the next one");
|
||||
std::process::Command::new(exe)
|
||||
.args(std::env::args_os().skip(1))
|
||||
.env(RETRY_VAR, (tries + 1).to_string())
|
||||
.spawn()
|
||||
.map_err(|e| log::error!("window: could not start again: {e}"))
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn dir(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("app-window-gpu-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
d
|
||||
}
|
||||
|
||||
/// The marker names the renderer being started; left behind, the next start reads it as the
|
||||
/// failed one (and removes it); a good start removes it and remembers the renderer (Windows).
|
||||
#[test]
|
||||
fn marker_records_the_failed_renderer() {
|
||||
let d = dir("marker");
|
||||
let f = Files::new(Some(d.clone()));
|
||||
let dx = WindowRenderer::Wgpu(Backends::DX12);
|
||||
f.arm(dx);
|
||||
assert!(f.armed());
|
||||
assert_eq!(f.take_failed(), Some(dx));
|
||||
assert!(!f.armed());
|
||||
assert_eq!(f.take_failed(), None);
|
||||
f.arm(WindowRenderer::Glow);
|
||||
f.started(WindowRenderer::Glow);
|
||||
assert!(!f.armed());
|
||||
assert_eq!(Files::read(f.remembered()), cfg!(windows).then_some(WindowRenderer::Glow));
|
||||
// nothing written without a settings folder
|
||||
let none = Files::new(None);
|
||||
none.arm(dx);
|
||||
assert!(!none.armed() && !relaunch_after_failure(&none));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// The crash report: a pipeline that fails validation must come back as an error, never as
|
||||
/// wgpu's default panic. Needs an adapter; skipped where there is none (CI without a GPU).
|
||||
#[test]
|
||||
fn pipeline_errors_are_captured_not_panics() {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
|
||||
backends: backend::window_backends(),
|
||||
..wgpu::InstanceDescriptor::new_without_display_handle()
|
||||
});
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
};
|
||||
let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())) else {
|
||||
eprintln!("no GPU device: skipped");
|
||||
return;
|
||||
};
|
||||
install_error_handler(&device);
|
||||
// a shader that does not validate, then a pipeline using it (like a failed FXC compile)
|
||||
let r = check_pipeline(&device, |d| {
|
||||
let m = d.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("broken"),
|
||||
source: wgpu::ShaderSource::Wgsl("@vertex fn vs_main() -> @builtin(position) vec4<f32> { return undefined_thing; }".into()),
|
||||
});
|
||||
let _ = d.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("egui_pipeline"),
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState { module: &m, entry_point: Some("vs_main"), compilation_options: Default::default(), buffers: &[] },
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: None,
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
});
|
||||
assert!(r.is_err(), "{r:?}");
|
||||
// egui's own pipeline builds cleanly here
|
||||
check_pipeline(&device, |d| {
|
||||
drop(eframe::egui_wgpu::Renderer::new(d, wgpu::TextureFormat::Bgra8Unorm, eframe::egui_wgpu::RendererOptions::default()));
|
||||
})
|
||||
.unwrap();
|
||||
// outside a scope: logged by our handler, not a panic
|
||||
let _ = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: None, source: wgpu::ShaderSource::Wgsl("not wgsl".into()) });
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ workspace = true
|
||||
[dependencies]
|
||||
lightcraft-develop = { workspace = true }
|
||||
lightcraft-meta = { workspace = true }
|
||||
lightcraft-raw = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
//! | 4 | Reload reads lens data | `Op::SetEmbeddedLens` |
|
||||
//! | 5 | the keyword list | `Op::SetKeyword`, `Catalog.keyword_list` |
|
||||
//! | 6 | folder labels | `Op::SetFolderRecord`, `Catalog.folder_records` |
|
||||
//! | 7 | Kelvin white balance for raws whose camera has matrices (#730) | `Op::SetAsShotWb`, `WhiteBalance.scale` |
|
||||
//!
|
||||
//! Rules:
|
||||
//! - **Bump [`VERSION`]** (and add a row above) in the change that adds an [`Op`] variant or a
|
||||
@@ -73,7 +74,7 @@ pub const LOG: &str = "catalog.log";
|
||||
const FORMAT: &str = "lightcraft-catalog";
|
||||
/// The catalog format this build writes (and the newest it reads). See the module docs →
|
||||
/// *Format versions*; bump it whenever an [`Op`] variant or a serialized field is added.
|
||||
pub const VERSION: u32 = 6;
|
||||
pub const VERSION: u32 = 7;
|
||||
|
||||
/// When [`Journal::wants_snapshot`] says it's time to compact the log.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
|
||||
@@ -214,6 +214,13 @@ pub enum Op {
|
||||
id: PhotoId,
|
||||
lens: Option<Box<lightcraft_develop::EmbeddedLens>>,
|
||||
},
|
||||
/// A raw's as-shot white balance (Kelvin, tint) as its file reads now: what a decode or Reload finds when the
|
||||
/// camera's colour matrices give the file a Kelvin scale it was imported without (issue #730). Bookkeeping,
|
||||
/// not an edit: the look does not change, only the As Shot readout.
|
||||
SetAsShotWb {
|
||||
id: PhotoId,
|
||||
wb: Option<(f64, f64)>,
|
||||
},
|
||||
/// The name shown for a colour label (`None` = its colour's name).
|
||||
SetLabelName {
|
||||
label: ColorLabel,
|
||||
@@ -685,6 +692,13 @@ impl Catalog {
|
||||
let p = self.photo_mut(id)?;
|
||||
Op::SetEmbeddedLens { id, lens: std::mem::replace(&mut p.embedded_lens, lens.map(|l| *l)).map(Box::new) }
|
||||
}
|
||||
Op::SetAsShotWb { id, wb } => {
|
||||
if wb.is_some_and(|(t, tint)| !(t.is_finite() && tint.is_finite())) {
|
||||
return Err(CatalogError::Invalid("as-shot white balance is not a number".into()));
|
||||
}
|
||||
let p = self.photo_mut(id)?;
|
||||
Op::SetAsShotWb { id, wb: std::mem::replace(&mut p.as_shot_wb, wb) }
|
||||
}
|
||||
Op::SetFile { id, file_name, source } => {
|
||||
if file_name.trim().is_empty() {
|
||||
return Err(CatalogError::Invalid("empty file name".into()));
|
||||
|
||||
@@ -284,7 +284,8 @@ impl Op {
|
||||
| Op::SetFile { id, .. }
|
||||
| Op::Relink { id, .. }
|
||||
| Op::SetContent { id, .. }
|
||||
| Op::SetEmbeddedLens { id, .. } => out(*id),
|
||||
| Op::SetEmbeddedLens { id, .. }
|
||||
| Op::SetAsShotWb { id, .. } => out(*id),
|
||||
Op::AddAlbum { album } => {
|
||||
album.photos.iter().for_each(|p| out(*p));
|
||||
album.cover.into_iter().for_each(&mut *out);
|
||||
|
||||
@@ -7,9 +7,15 @@
|
||||
//! takes the lock first, and a second opener gets [`LockError::InUse`] instead.
|
||||
//!
|
||||
//! - The lock is the OS's (`flock` on Unix, `LockFileEx` on Windows, via [`std::fs::File::try_lock`]):
|
||||
//! it is released when the [`LibraryLock`] is dropped and, after a crash or a kill, by the OS when
|
||||
//! the process ends. A `catalog.lock` file left behind is therefore never "stale": only a lock
|
||||
//! that's actually held refuses the open.
|
||||
//! it is released explicitly when the [`LibraryLock`] is dropped and, after a crash or a kill, by
|
||||
//! the OS when the process ends. A `catalog.lock` file left behind is therefore never "stale":
|
||||
//! only a lock that's actually held refuses the open.
|
||||
//! - Dropping the lock calls [`std::fs::File::unlock`] rather than relying on the handle closing.
|
||||
//! On Unix an `flock` lock belongs to the open file description and lasts while *any* descriptor
|
||||
//! on it is open; a process that another thread is spawning holds a copy of every descriptor
|
||||
//! (`O_CLOEXEC` only closes them at `execve`), so closing ours would leave the library "already
|
||||
//! open" for a moment and a re-open would be refused (issue #680). `LOCK_UN` through our
|
||||
//! descriptor frees the lock whatever copies a child still holds.
|
||||
//! - Who holds it (process id, computer, program, version) is written to `catalog.lock.owner`, for
|
||||
//! the error message. It's a separate file because a Windows lock also blocks reading the
|
||||
//! locked file. It's only a hint: it may be missing or left over from a crash.
|
||||
@@ -87,7 +93,7 @@ pub enum LockError {
|
||||
pub struct LibraryLock {
|
||||
dir: PathBuf,
|
||||
/// `None` when the file system can't lock (the library is open unlocked).
|
||||
_file: Option<File>,
|
||||
file: Option<File>,
|
||||
held: bool,
|
||||
}
|
||||
|
||||
@@ -96,7 +102,7 @@ impl LibraryLock {
|
||||
pub fn acquire(dir: &Path, program: &str) -> Result<LibraryLock, LockError> {
|
||||
let unlocked = |why: io::Error| {
|
||||
log::warn!("library {}: can't lock {LOCK} ({why}); opening it without protection against a second program", dir.display());
|
||||
LibraryLock { dir: dir.to_path_buf(), _file: None, held: false }
|
||||
LibraryLock { dir: dir.to_path_buf(), file: None, held: false }
|
||||
};
|
||||
let file = match OpenOptions::new().read(true).write(true).create(true).truncate(false).open(dir.join(LOCK)) {
|
||||
Ok(f) => f,
|
||||
@@ -116,7 +122,7 @@ impl LibraryLock {
|
||||
{
|
||||
log::warn!("library {}: can't write {OWNER}: {e}", dir.display());
|
||||
}
|
||||
Ok(LibraryLock { dir: dir.to_path_buf(), _file: Some(file), held: true })
|
||||
Ok(LibraryLock { dir: dir.to_path_buf(), file: Some(file), held: true })
|
||||
}
|
||||
|
||||
/// The OS lock is held (`false`: the file system can't lock; opened unprotected).
|
||||
@@ -127,14 +133,29 @@ impl LibraryLock {
|
||||
pub fn dir(&self) -> &Path {
|
||||
&self.dir
|
||||
}
|
||||
|
||||
/// Another descriptor on the locked file: what a process spawned by another thread holds
|
||||
/// between fork and exec (a `dup` refers to the same open file description). Tests only.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn descriptor_copy(&self) -> Option<File> {
|
||||
self.file.as_ref().and_then(|f| f.try_clone().ok())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LibraryLock {
|
||||
/// Remove the owner note while still holding the lock (so it can't delete the next holder's),
|
||||
/// then the file handle closes and the OS releases the lock.
|
||||
/// then release the lock explicitly. Closing the handle isn't enough on Unix: `flock` lasts
|
||||
/// while any copy of the descriptor is open, and a process another thread is spawning holds a
|
||||
/// copy of every descriptor until it execs (issue #680). If the unlock fails the handle still
|
||||
/// closes right after, which releases the lock once every copy is gone, as before.
|
||||
fn drop(&mut self) {
|
||||
if self.held {
|
||||
let _ = std::fs::remove_file(self.dir.join(OWNER));
|
||||
if let Some(f) = &self.file
|
||||
&& let Err(e) = f.unlock()
|
||||
{
|
||||
log::warn!("library {}: can't release {LOCK}: {e}", self.dir.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+71
-11
@@ -307,14 +307,25 @@ pub struct Analysis {
|
||||
pub best: bool,
|
||||
}
|
||||
|
||||
/// Raw formats whose readers have vendor white-balance multipliers but no measured camera
|
||||
/// illuminant: their white balance is developed relative to the as-shot look (6500 K / 0 means
|
||||
/// as shot), as for rendered photographs. `format` is the file's extension or Lightroom's
|
||||
/// `fileFormat` name, in any case. See [`Photo::relative_wb`].
|
||||
/// Raw formats whose files carry vendor white-balance multipliers but no colour matrices: their starting
|
||||
/// look is fitted to the file's own JPEG (the engine's `camera_preview::file_local_look` lists the same
|
||||
/// formats). `format` is the file's extension or Lightroom's `fileFormat` name, in any case. Whether such a
|
||||
/// file's white balance is edited relative to its as-shot look or in Kelvin depends on the camera:
|
||||
/// [`relative_wb_camera`].
|
||||
pub fn relative_wb_format(format: &str) -> bool {
|
||||
["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF", "SRW", "ORF"].iter().any(|f| format.eq_ignore_ascii_case(f))
|
||||
}
|
||||
|
||||
/// Whether a raw of `format` shot on `camera` (Exif make and model, as [`Meta::camera`] keeps them) is developed
|
||||
/// with a white balance relative to its as-shot look (6500 K / 0 means as shot, as for rendered photographs):
|
||||
/// a file without colour matrices ([`relative_wb_format`]) whose camera has none measured either. A camera
|
||||
/// with matrices in `lightcraft_raw::spectral` edits in Kelvin: the matrices turn its as-shot multipliers into
|
||||
/// a temperature, and the slider back, while its colour look stays whatever the file's fit gives (issue #730).
|
||||
/// The engine's render path (`files.rs`) decides the same way from the file; both must agree.
|
||||
pub fn relative_wb_camera(format: &str, camera: &str) -> bool {
|
||||
relative_wb_format(format) && lightcraft_raw::spectral::find_camera(camera).is_none()
|
||||
}
|
||||
|
||||
impl Photo {
|
||||
pub fn new(id: PhotoId, source: Source, file_name: &str, format: &str, width: u32, height: u32, imported: &str) -> Photo {
|
||||
Photo {
|
||||
@@ -355,13 +366,18 @@ impl Photo {
|
||||
pub fn develops_raw(&self) -> bool {
|
||||
self.kind == MediaKind::Raw && self.preview_only.is_none()
|
||||
}
|
||||
/// The current ARW, NEF, RW2, RAF, CR3, CR2, PEF, SRW and ORF readers have vendor WB multipliers but no measured camera
|
||||
/// illuminant. Use adjustments relative to the camera's as-shot look, as for rendered
|
||||
/// photographs (the engine's `camera_preview::file_local_look` covers the same formats; RWL and
|
||||
/// RAW are Leica's and the oldest Panasonic bodies' names for RW2 files).
|
||||
pub fn relative_wb(&self) -> bool {
|
||||
/// A raw developed from a look fitted to its own JPEG (ARW, NEF, RW2, RAF, CR3, CR2, PEF, SRW, ORF: vendor
|
||||
/// WB multipliers, no colour matrices in the file; RWL and RAW are Leica's and the oldest Panasonic bodies'
|
||||
/// names for RW2 files). The engine's `camera_preview::file_local_look` covers the same formats.
|
||||
pub fn fitted_look(&self) -> bool {
|
||||
self.develops_raw() && relative_wb_format(&self.format)
|
||||
}
|
||||
/// White balance relative to the camera's as-shot look, as for rendered photographs: a
|
||||
/// [`Photo::fitted_look`] raw whose camera has no colour matrices measured either
|
||||
/// ([`relative_wb_camera`]). Other raws edit white balance in Kelvin.
|
||||
pub fn relative_wb(&self) -> bool {
|
||||
self.develops_raw() && relative_wb_camera(&self.format, &self.meta.camera)
|
||||
}
|
||||
/// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's
|
||||
/// default preset applied on top of them ([`Photo::import_look`]). Like the camera defaults,
|
||||
/// in the photo's own rendering process.
|
||||
@@ -377,11 +393,15 @@ impl Photo {
|
||||
/// process a photo renders with is not an edit, so comparisons with its defaults (edited?
|
||||
/// still as imported?) don't change when a newer process exists. A new photo has the latest.
|
||||
pub fn camera_defaults(&self) -> DevelopSettings {
|
||||
let wb = if self.relative_wb() { Some((6500.0, 0.0)) } else { self.as_shot_wb };
|
||||
let relative = self.relative_wb();
|
||||
let wb = if relative { Some((6500.0, 0.0)) } else { self.as_shot_wb };
|
||||
let mut d = match wb {
|
||||
Some((t, tint)) => DevelopSettings::for_raw(t, tint),
|
||||
None => DevelopSettings::default(),
|
||||
};
|
||||
if self.develops_raw() && !relative {
|
||||
d.wb.scale = lightcraft_develop::WbScale::Kelvin;
|
||||
}
|
||||
if self.embedded_lens.is_some() {
|
||||
d.optics.lens_profile = true;
|
||||
}
|
||||
@@ -487,6 +507,7 @@ impl Album {
|
||||
#[cfg(test)]
|
||||
mod edited_tests {
|
||||
use super::*;
|
||||
use lightcraft_develop::WbScale;
|
||||
|
||||
#[test]
|
||||
fn raw_with_import_settings_is_not_edited() {
|
||||
@@ -547,10 +568,13 @@ mod edited_tests {
|
||||
] {
|
||||
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, name, format, 10, 10, "2026-10-01T00:00:00");
|
||||
p.kind = MediaKind::Raw;
|
||||
p.meta.camera = "Pentax K-1".into();
|
||||
p.as_shot_wb = Some((5200.0, 4.0));
|
||||
assert_eq!(p.relative_wb(), relative, "{format}");
|
||||
assert_eq!(p.fitted_look(), relative, "{format}");
|
||||
let wb = p.camera_defaults().wb;
|
||||
assert_eq!((wb.temp, wb.tint), if relative { (6500.0, 0.0) } else { (5200.0, 4.0) }, "{format}");
|
||||
assert_eq!(wb.scale, if relative { WbScale::Legacy } else { WbScale::Kelvin }, "{format}");
|
||||
p.develop = Arc::new(DevelopSettings::for_raw(5200.0, 4.0));
|
||||
assert!(!p.is_edited(), "{format}: old As Shot values are not an edit");
|
||||
let mut custom = (*p.develop).clone();
|
||||
@@ -559,7 +583,43 @@ mod edited_tests {
|
||||
assert!(p.is_edited(), "{format}: Custom WB is still an edit");
|
||||
// shown from the embedded preview: a rendered image, not a relative-WB raw
|
||||
p.preview_only = Some("unsupported".into());
|
||||
assert!(!p.relative_wb());
|
||||
assert!(!p.relative_wb() && !p.fitted_look());
|
||||
}
|
||||
}
|
||||
|
||||
/// A raw without matrices in the file edits in Kelvin when its camera's matrices are measured (issue #730):
|
||||
/// the decision reads the catalog's camera string, and the photo's defaults say which scale they are on.
|
||||
#[test]
|
||||
fn raws_of_cameras_with_measured_matrices_use_kelvin_white_balance() {
|
||||
for (camera, format, relative) in [
|
||||
("SONY ILCE-7M3", "ARW", false),
|
||||
("SONY ILCE-7RM4A", "ARW", false),
|
||||
("NIKON CORPORATION NIKON D850", "NEF", false),
|
||||
("Canon Canon EOS 5D Mark III", "CR2", false),
|
||||
("FUJIFILM X-T4", "RAF", false),
|
||||
("ILCE-7M3", "ARW", false),
|
||||
("SONY ILCE-7M2", "ARW", true),
|
||||
("SONY ILCE-1", "ARW", true),
|
||||
("", "ARW", true),
|
||||
("SONY ILCE-7M3", "DNG", false),
|
||||
] {
|
||||
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.raw", format, 10, 10, "2026-10-01T00:00:00");
|
||||
p.kind = MediaKind::Raw;
|
||||
p.meta.camera = camera.into();
|
||||
p.as_shot_wb = Some((4986.0, -2.0));
|
||||
assert_eq!(p.relative_wb(), relative, "{camera} {format}");
|
||||
assert_eq!(relative_wb_camera(format, camera), relative, "{camera} {format}");
|
||||
assert_eq!(p.fitted_look(), format == "ARW" || format == "NEF" || format == "CR2" || format == "RAF", "{camera} {format}");
|
||||
let wb = p.camera_defaults().wb;
|
||||
assert_eq!((wb.temp, wb.tint), if relative { (6500.0, 0.0) } else { (4986.0, -2.0) }, "{camera} {format}");
|
||||
assert_eq!(wb.scale, if relative { WbScale::Legacy } else { WbScale::Kelvin }, "{camera} {format}");
|
||||
// a photo from an older catalog: stored As Shot on the relative scale, still not an edit
|
||||
p.develop = Arc::new(DevelopSettings::for_raw(6500.0, 0.0));
|
||||
assert!(!p.is_edited(), "{camera} {format}: old As Shot values are not an edit");
|
||||
// its as-shot white not yet read through the matrices (6500 / 0 from the old import): defaults still As Shot
|
||||
p.as_shot_wb = Some((6500.0, 0.0));
|
||||
assert_eq!(p.camera_defaults().wb.mode, WbMode::AsShot);
|
||||
assert!(!p.is_edited());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -363,7 +363,7 @@ pub fn rule_date(s: &str) -> Result<String, Issue> {
|
||||
_ => false,
|
||||
}
|
||||
};
|
||||
if !shaped(&s) && !(s.get(..19).is_some_and(shaped) && s.get(19..).is_some_and(tail_ok)) {
|
||||
if !(shaped(&s) || s.get(..19).is_some_and(shaped) && s.get(19..).is_some_and(tail_ok)) {
|
||||
return Err(Issue::NeedsDate);
|
||||
}
|
||||
let part = |a: usize, b: usize| s.get(a..b).and_then(|t| t.parse::<u32>().ok());
|
||||
|
||||
@@ -466,7 +466,7 @@ fn set_embedded_lens_is_undoable_and_journaled() {
|
||||
photo: Box::new(Photo::new(a, Source::File { path: "/x.rw2".into() }, "x.rw2", "RW2", 4000, 3000, "2026-10-06T00:00:00")),
|
||||
})
|
||||
.unwrap();
|
||||
let lens = lightcraft_develop::EmbeddedLens { warp: Some(Default::default()), vignette: None };
|
||||
let lens = lightcraft_develop::EmbeddedLens { warp: Some(Default::default()), vignette: None, stages: Default::default() };
|
||||
let op = Op::SetEmbeddedLens { id: a, lens: Some(Box::new(lens)) };
|
||||
let op: Op = serde_json::from_str(&serde_json::to_string(&op).unwrap()).unwrap();
|
||||
let inv = c.apply(op).unwrap();
|
||||
@@ -476,6 +476,27 @@ fn set_embedded_lens_is_undoable_and_journaled() {
|
||||
assert_eq!(c.photo(a).unwrap().embedded_lens, None);
|
||||
}
|
||||
|
||||
/// A decode or Reload stores the as-shot white a raw reads through its camera's matrices; the inverse (through the
|
||||
/// journal's JSON) takes it back, and a value that is not a number is refused.
|
||||
#[test]
|
||||
fn set_as_shot_wb_is_undoable_journaled_and_checked() {
|
||||
let mut c = Catalog::default();
|
||||
let a = c.alloc_photo_id();
|
||||
let mut p = Photo::new(a, Source::File { path: "/x.arw".into() }, "x.arw", "ARW", 4000, 3000, "2026-10-06T00:00:00");
|
||||
p.as_shot_wb = Some((6500.0, 0.0));
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
let op = Op::SetAsShotWb { id: a, wb: Some((4986.0, -2.0)) };
|
||||
let op: Op = serde_json::from_str(&serde_json::to_string(&op).unwrap()).unwrap();
|
||||
let inv = c.apply(op).unwrap();
|
||||
assert_eq!(c.photo(a).unwrap().as_shot_wb, Some((4986.0, -2.0)));
|
||||
let inv: Op = serde_json::from_str(&serde_json::to_string(&inv).unwrap()).unwrap();
|
||||
c.apply(inv).unwrap();
|
||||
assert_eq!(c.photo(a).unwrap().as_shot_wb, Some((6500.0, 0.0)));
|
||||
assert!(c.apply(Op::SetAsShotWb { id: a, wb: Some((f64::NAN, 0.0)) }).is_err());
|
||||
assert!(c.apply(Op::SetAsShotWb { id: PhotoId(999), wb: None }).is_err());
|
||||
assert_eq!(c.photo(a).unwrap().as_shot_wb, Some((6500.0, 0.0)));
|
||||
}
|
||||
|
||||
/// A saved smart album whose rules no longer check (the album a rule tests was deleted) is found,
|
||||
/// with its problems; a good one has none; an old album operator is read as it was meant.
|
||||
#[test]
|
||||
|
||||
@@ -210,8 +210,9 @@ fn op_variants_are_versioned() {
|
||||
Op::SetEmbeddedLens { .. } => 4,
|
||||
Op::SetKeyword { .. } => 5,
|
||||
Op::SetFolderRecord { .. } => 6,
|
||||
Op::SetAsShotWb { .. } => 7,
|
||||
}
|
||||
}
|
||||
let newest = since(&Op::SetFolderRecord { folder: "/".into(), record: None });
|
||||
let newest = since(&Op::SetAsShotWb { id: PhotoId(1), wb: None });
|
||||
assert_eq!(newest, VERSION, "the newest op's version must be the current format version");
|
||||
}
|
||||
@@ -5,6 +5,17 @@ use std::path::PathBuf;
|
||||
use crate::lock::{LOCK, OWNER};
|
||||
use crate::*;
|
||||
|
||||
/// Taken by every test here that holds a lock in this process. On Unix, spawning the child of
|
||||
/// [`lock_held_by_another_process_is_released_when_it_dies`] briefly copies this process's file
|
||||
/// descriptors, close-on-exec lock files included, until the child's exec closes them. A lock
|
||||
/// another test drops in that moment stays held by the copy, so acquiring it again could fail with
|
||||
/// "in use".
|
||||
static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
fn serial() -> std::sync::MutexGuard<'static, ()> {
|
||||
SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lc-lock-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
@@ -14,6 +25,7 @@ fn temp_dir(tag: &str) -> PathBuf {
|
||||
|
||||
#[test]
|
||||
fn second_opener_is_refused_until_the_first_lets_go() {
|
||||
let _serial = serial();
|
||||
let dir = temp_dir("twice");
|
||||
let first = LibraryLock::acquire(&dir, "LightCraft").unwrap();
|
||||
assert!(first.held());
|
||||
@@ -38,6 +50,7 @@ fn second_opener_is_refused_until_the_first_lets_go() {
|
||||
/// Files left behind by a crash (lock file + owner note) don't lock anyone out.
|
||||
#[test]
|
||||
fn leftover_lock_files_are_not_a_lock() {
|
||||
let _serial = serial();
|
||||
let dir = temp_dir("stale");
|
||||
std::fs::write(dir.join(LOCK), b"").unwrap();
|
||||
std::fs::write(dir.join(OWNER), br#"{"pid":999999,"host":"elsewhere","program":"LightCraft","version":"0.2.0","since":1}"#).unwrap();
|
||||
@@ -52,6 +65,7 @@ fn leftover_lock_files_are_not_a_lock() {
|
||||
/// Another process holds the lock; when it is killed (a crash), the OS releases it.
|
||||
#[test]
|
||||
fn lock_held_by_another_process_is_released_when_it_dies() {
|
||||
let _serial = serial();
|
||||
let dir = temp_dir("child");
|
||||
let exe = std::env::current_exe().unwrap();
|
||||
let mut child = std::process::Command::new(exe)
|
||||
@@ -91,3 +105,69 @@ fn lock_holder_child() {
|
||||
std::fs::write(std::path::Path::new(&dir).join("child-locked"), b"").unwrap();
|
||||
std::thread::sleep(std::time::Duration::from_secs(120));
|
||||
}
|
||||
|
||||
/// Issue #680: a process spawned by any thread holds a copy of every descriptor until it execs
|
||||
/// (close-on-exec only closes them then), and `flock` lasts while any copy of the descriptor is
|
||||
/// open. Dropping the `LibraryLock` must still free the library, so it releases explicitly rather
|
||||
/// than relying on its handle closing. A `try_clone` of the descriptor stands in for the child's
|
||||
/// copy: it refers to the same open file description, which is exactly what the child gets.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn the_library_is_free_once_dropped_even_if_a_child_holds_a_copy() {
|
||||
let dir = temp_dir("copy");
|
||||
let first = LibraryLock::acquire(&dir, "LightCraft").unwrap();
|
||||
let copy = first.descriptor_copy().unwrap();
|
||||
drop(first);
|
||||
let again = LibraryLock::acquire(&dir, "lightcraft-cli").expect("the library is free once its LibraryLock is dropped");
|
||||
assert!(again.held());
|
||||
drop((again, copy));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Issue #680, as it happens in practice: one thread spawns cheap children while another acquires,
|
||||
/// drops and re-acquires the lock on its own directory. Every acquire must succeed (before the fix
|
||||
/// a few per cent were refused with `InUse(None)`). Bounded: about two seconds of cycles, pass or fail.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn dropping_the_lock_frees_the_library_while_another_thread_spawns_processes() {
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
let dir = temp_dir("spawn");
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let spawner = {
|
||||
let stop = Arc::clone(&stop);
|
||||
std::thread::spawn(move || {
|
||||
let mut spawned = 0usize;
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
if let Ok(mut c) =
|
||||
std::process::Command::new("/bin/true").stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn()
|
||||
{
|
||||
let _ = c.wait();
|
||||
spawned += 1;
|
||||
}
|
||||
}
|
||||
spawned
|
||||
})
|
||||
};
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
|
||||
let mut cycles = 0usize;
|
||||
let mut refused = Vec::new();
|
||||
while cycles < 200_000 && std::time::Instant::now() < deadline {
|
||||
match LibraryLock::acquire(&dir, "LightCraft") {
|
||||
Ok(l) => assert!(l.held()), // dropped here: the next cycle must get it back
|
||||
Err(e) => refused.push(format!("cycle {cycles}: {e:?}")),
|
||||
}
|
||||
cycles += 1;
|
||||
}
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
let spawned = spawner.join().unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert!(spawned > 0, "the spawning thread never got a child off the ground");
|
||||
assert!(
|
||||
refused.is_empty(),
|
||||
"{} of {cycles} re-acquires refused while {spawned} children were spawned: {:?}",
|
||||
refused.len(),
|
||||
&refused[..refused.len().min(3)]
|
||||
);
|
||||
}
|
||||
@@ -32,11 +32,14 @@ jxl-oxide = { workspace = true, optional = true }
|
||||
ravif = { workspace = true, optional = true }
|
||||
rav1e = { workspace = true, optional = true }
|
||||
avif-serialize = { workspace = true, optional = true }
|
||||
# spawns threads of its own (std::thread), which wasm32 has not got
|
||||
jixel = { workspace = true, optional = true }
|
||||
|
||||
[features]
|
||||
default = ["parallel", "jxl", "avif"]
|
||||
parallel = ["dep:rayon", "lightcraft-raster/parallel", "jpeg-decoder/rayon"]
|
||||
jxl = ["dep:jxl-oxide"]
|
||||
## JPEG XL decode (jxl-oxide) and, on native targets, encode (jixel); both pure Rust.
|
||||
jxl = ["dep:jxl-oxide", "dep:jixel"]
|
||||
# HEIC/HEIF decode via the optional lightcraft-heif crate (heic-rs, pure Rust). Off by default so
|
||||
# distributors choose it at build time (HEVC patents are a distributor's call, same policy as
|
||||
# PhotoCraft); `cargo xtask ci` clippies and tests it (`--features heif`), releases don't ship it yet.
|
||||
@@ -45,6 +48,8 @@ heif = ["dep:lightcraft-heif"]
|
||||
avif = ["dep:ravif", "dep:rav1e", "dep:avif-serialize"]
|
||||
|
||||
[dev-dependencies]
|
||||
# JPEG XL tests read encoded samples without our colour conversion
|
||||
jxl-oxide = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
# Lossless synthetic HEIC files for the `heif` feature's tests.
|
||||
lightcraft-heif = { workspace = true, features = ["testdata"] }
|
||||
@@ -23,6 +23,7 @@ encode_png(&EncodeImage::new(w, h, 4, Samples::U16(&px)), &meta)?;
|
||||
encode_tiff(&EncodeImage::new(w, h, 3, Samples::F32(&px)), TiffCompression::Deflate, &meta)?;
|
||||
encode_webp_lossless(&img, &meta)?;
|
||||
encode_avif(&img, 70, 6, &meta)?; // native + feature `avif`
|
||||
encode_jxl(&img, &JxlOptions { quality: 90, ..Default::default() }, &meta)?; // native + feature `jxl`
|
||||
|
||||
// HDR gain map JPEG (ISO 21496-1): SDR + HDR renditions → base + gain map, and back
|
||||
let (map, gm) = gainmap::compute(&sdr_linear, &hdr_linear, w, h, luma, &GainMapOptions::default())?;
|
||||
@@ -51,7 +52,7 @@ CRW, MRW, X3F) are detected so the engine can route them to `lightcraft-raw`; `d
|
||||
| WebP | lossy + lossless, alpha, first frame of animations | lossless only | ICCP/EXIF/XMP chunks |
|
||||
| GIF / BMP | yes (first GIF frame) | — | via `image` |
|
||||
| PSD / PSB | merged composite: 8/16/32-bit gray, RGB, CMYK, indexed, duotone (as gray); raw/RLE | — | ICC (1039), EXIF (1058), XMP (1060); ZIP-compressed composite, Lab and 1-bit unsupported |
|
||||
| JPEG XL | yes (jxl-oxide, feature `jxl`, default on) | — | enum colour → rendered straight to linear Rec.2020; ICC → our ICC path; orientation applied by the decoder (reported as 1) |
|
||||
| JPEG XL | yes (jxl-oxide, feature `jxl`, default on) | yes (jixel, `encode_jxl`, feature `jxl`, native only): lossy VarDCT / lossless Modular, 8/16-bit, effort (fast / normal), progressive; HDR as 16-bit Rec. 2020 PQ | decode: enum colour → rendered straight to linear Rec.2020 (XYB via linear sRGB primaries); ICC → our ICC path; orientation applied by the decoder (reported as 1). Encode: lossy files declare an enum encoding (sRGB, Display P3, Rec. 2020 BT.709 curve, Rec. 2020 PQ), lossless ones embed the ICC profile; EXIF / XMP in `Exif` / `xml ` boxes |
|
||||
| AVIF | **no** | yes (ravif/rav1e, native only, feature `avif`) | 8/10-bit sRGB, EXIF; no ICC in the muxer. HDR: `encode_avif_pq` writes 10-bit BT.2020 PQ (SDR white at 203 cd/m², CICP `nclx` + `clli`) |
|
||||
| HEIC/HEIF | yes with feature `heif` (lightcraft-heif: heic-rs + libheif-matching colour; 4:2:0/mono, 8–10-bit, grids, irot/imir/clap, ICC/nclx/VUI, EXIF/XMP, thumbnail item) | — | 4:2:2/4:4:4 (Canon/Sony HIF) unsupported |
|
||||
| Gain map JPEG (HDR) | gain map found and decoded (`gainmap::read_jpeg`): ISO 21496-1 metadata, else Adobe `hdrgm` XMP | yes (`gainmap::encode_jpeg`) | CIPA DC-007 MPF index (gain map typed `0x050000`), ISO 21496-1 APP2 on both images, `hdrgm` + Container XMP for Android/Chrome, Apple `HDRGainMap`/`HDRToneMap` XMP; gain maps are never used as thumbnails |
|
||||
@@ -87,6 +88,7 @@ All pure Rust, verified with `cargo info` / `cargo tree`:
|
||||
| image (gif, bmp only) | MIT OR Apache-2.0 | GIF/BMP |
|
||||
| moxcms | BSD-3-Clause OR Apache-2.0 | ICC parse/write, CMS |
|
||||
| jxl-oxide | MIT OR Apache-2.0 | JPEG XL decode |
|
||||
| jixel | BSD-3-Clause OR Apache-2.0 | JPEG XL encode (native only) |
|
||||
| ravif (+ rav1e, avif-serialize) | BSD-3-Clause / BSD-2-Clause | AVIF encode (native only; heavy to compile) |
|
||||
|
||||
Deliberately **not** used: anything AGPL from imazen (heic, zenjpeg, jxl-encoder, rav1d-safe),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Encoders: JPEG (`jpeg-encoder`), PNG (`png`), TIFF (`tiff`), lossless WebP (`image-webp`) and,
|
||||
//! on native targets with the `avif` feature, AVIF (`ravif`/`rav1e`). Metadata (ICC/EXIF/XMP) is
|
||||
//! embedded where the format allows.
|
||||
//! Encoders: JPEG (`jpeg-encoder`), PNG (`png`), TIFF (`tiff`), flat PSD (our own), lossless WebP (`image-webp`) and,
|
||||
//! on native targets with the `avif` feature, AVIF (`ravif`/`rav1e`); JPEG XL is in `jxl_encode`.
|
||||
//! Metadata (ICC/EXIF/XMP) is embedded where the format allows.
|
||||
|
||||
use crate::{Error, Result};
|
||||
use lightcraft_raster::Rgba8;
|
||||
@@ -32,7 +32,7 @@ impl<'a> EncodeImage<'a> {
|
||||
EncodeImage { width, height, channels, samples }
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<()> {
|
||||
pub(crate) fn validate(&self) -> Result<()> {
|
||||
if !(1..=4).contains(&self.channels) {
|
||||
return Err(Error::Encode(format!("unsupported channel count {}", self.channels)));
|
||||
}
|
||||
@@ -291,6 +291,12 @@ pub fn encode_tiff(img: &EncodeImage, compression: TiffCompression, meta: &Encod
|
||||
Ok(cur.into_inner())
|
||||
}
|
||||
|
||||
/// Encode a flat PSD (8/16-bit gray or RGB, no layers; alpha dropped): the hand-off an image
|
||||
/// editor such as PhotoCraft saves back to in place. ICC, EXIF, XMP and resolution are embedded.
|
||||
pub fn encode_psd(img: &EncodeImage, meta: &EncodeMeta) -> Result<Vec<u8>> {
|
||||
crate::psd::encode(img, meta)
|
||||
}
|
||||
|
||||
/// Encode lossless WebP (8-bit). ICC/EXIF/XMP are embedded in the extended (VP8X) container.
|
||||
pub fn encode_webp_lossless(img: &EncodeImage, meta: &EncodeMeta) -> Result<Vec<u8>> {
|
||||
img.validate()?;
|
||||
@@ -498,6 +504,60 @@ pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed:
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode an HDR PNG: `rgb` is linear light in **Rec. 2020** primaries with SDR white at 1.0
|
||||
/// (`width × height × 3`), as for [`encode_avif_pq`]. Written as 16-bit RGB with the PQ curve
|
||||
/// (SDR white at [`HDR_REFERENCE_WHITE_NITS`]), described by a `cICP` chunk (PNG third edition:
|
||||
/// BT.2020 primaries, PQ, RGB, full range) and a `cLLI` content light level chunk (the same
|
||||
/// levels the HDR AVIF records). EXIF and XMP are embedded; an ICC profile is not (`cICP`
|
||||
/// describes the colour, and readers prefer it). Readers without `cICP` support show the PQ
|
||||
/// samples as if they were sRGB (flat, washed out): expected for a PQ PNG, there is no fallback.
|
||||
pub fn encode_png_pq(width: u32, height: u32, rgb: &[f32], meta: &EncodeMeta) -> Result<Vec<u8>> {
|
||||
let (w, h) = (width as usize, height as usize);
|
||||
let n = w.checked_mul(h).ok_or_else(|| Error::Encode("image too large".into()))?;
|
||||
if n == 0 || n.checked_mul(3).is_none_or(|need| rgb.len() < need) {
|
||||
return Err(Error::Encode("HDR PNG: sample buffer too short".into()));
|
||||
}
|
||||
const K: [f32; 3] = [0.2627, 0.6780, 0.0593]; // BT.2020 luminance
|
||||
let mut max_nits = 0f32;
|
||||
let mut sum_nits = 0f64;
|
||||
let mut bytes = Vec::with_capacity(n * 6);
|
||||
for c in rgb.as_chunks::<3>().0.iter().take(n) {
|
||||
let nits = c.map(|v| (if v.is_finite() { v.max(0.0) } else { 0.0 }) * HDR_REFERENCE_WHITE_NITS);
|
||||
max_nits = max_nits.max(nits[0].max(nits[1]).max(nits[2]));
|
||||
sum_nits += (K[0] * nits[0] + K[1] * nits[1] + K[2] * nits[2]) as f64;
|
||||
for v in nits {
|
||||
bytes.extend_from_slice(&((pq_encode(v) * 65535.0).round().clamp(0.0, 65535.0) as u16).to_be_bytes());
|
||||
}
|
||||
}
|
||||
let cll = (max_nits.ceil().clamp(1.0, 10_000.0) as u32, ((sum_nits / n as f64).ceil().clamp(1.0, 10_000.0)) as u32);
|
||||
let mut info = png::Info::with_size(width, height);
|
||||
info.color_type = png::ColorType::Rgb;
|
||||
info.bit_depth = png::BitDepth::Sixteen;
|
||||
info.exif_metadata = meta.exif.map(|b| b.to_vec().into());
|
||||
info.pixel_dims = meta.ppi.filter(|p| *p > 0).map(|p| {
|
||||
let ppm = (p as f64 / 0.0254).round() as u32;
|
||||
png::PixelDimensions { xppu: ppm, yppu: ppm, unit: png::Unit::Meter }
|
||||
});
|
||||
let mut out = Vec::new();
|
||||
let e = |e: png::EncodingError| Error::Encode(e.to_string());
|
||||
{
|
||||
let mut enc = png::Encoder::with_info(&mut out, info).map_err(e)?;
|
||||
enc.set_compression(png::Compression::Fast);
|
||||
if let Some(xmp) = meta.xmp {
|
||||
enc.add_itxt_chunk("XML:com.adobe.xmp".into(), xmp.into()).map_err(e)?;
|
||||
}
|
||||
let mut wr = enc.write_header().map_err(e)?;
|
||||
// BT.2020 primaries (9), PQ (16), RGB (0), full range (1)
|
||||
wr.write_chunk(png::chunk::ChunkType(*b"cICP"), &[9, 16, 0, 1]).map_err(e)?;
|
||||
// MaxCLL and MaxFALL in units of 0.0001 cd/m²
|
||||
let clli = [(cll.0 * 10_000).to_be_bytes(), (cll.1 * 10_000).to_be_bytes()].concat();
|
||||
wr.write_chunk(png::chunk::ChunkType(*b"cLLI"), &clli).map_err(e)?;
|
||||
wr.write_image_data(&bytes).map_err(e)?;
|
||||
wr.finish().map_err(e)?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// 16-bit RGB(A) → 10-bit BT.601 full-range YCbCr planes (the matrix `ravif` uses for 8-bit input).
|
||||
#[cfg(all(feature = "avif", not(target_arch = "wasm32")))]
|
||||
fn avif_10bit(enc: &ravif::Encoder, img: &EncodeImage, s: &[u16]) -> Result<Vec<u8>> {
|
||||
|
||||
@@ -411,7 +411,7 @@ pub fn compute(
|
||||
if n == 0 || sdr.len() < n || hdr.len() < n {
|
||||
return Err(Error::Encode("gain map: SDR and HDR renditions must match the image size".into()));
|
||||
}
|
||||
if !matches!(o.scale, 1 | 2 | 4) || !(o.gamma > 0.0 && o.gamma.is_finite()) || !(o.offset > 0.0 && o.offset.is_finite()) {
|
||||
if !(matches!(o.scale, 1 | 2 | 4) && o.gamma > 0.0 && o.gamma.is_finite() && o.offset > 0.0 && o.offset.is_finite()) {
|
||||
return Err(Error::Encode("gain map: scale must be 1, 2 or 4, gamma and offset positive".into()));
|
||||
}
|
||||
let s = o.scale;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! JPEG XL decode via `jxl-oxide` (pure Rust). Enum-encoded images are rendered straight into linear
|
||||
//! Rec.2020; ICC-tagged ones are rendered in their own space and interpreted with our ICC path.
|
||||
//! XYB (lossy) images are rendered to linear sRGB primaries and converted to Rec.2020 here:
|
||||
//! jxl-oxide 0.12 desaturates colours outside sRGB when it renders XYB to wider primaries.
|
||||
//! jxl-oxide applies the codestream orientation itself, so `orientation` is reported as 1.
|
||||
|
||||
use crate::convert::{Buf, Meta, Model, Raw, check_size, finish};
|
||||
@@ -24,11 +26,13 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
let gray = header.metadata.grayscale();
|
||||
let icc = img.original_icc().map(|v| v.to_vec());
|
||||
let mut meta = Meta { orientation: Some(1), ..Default::default() };
|
||||
// linear sRGB primaries, unclamped, then our own matrix to Rec.2020 (see the module docs)
|
||||
let via_srgb = icc.is_none() && !gray && header.metadata.xyb_encoded;
|
||||
if icc.is_none() {
|
||||
img.request_color_encoding(EnumColourEncoding {
|
||||
colour_space: if gray { ColourSpace::Grey } else { ColourSpace::Rgb },
|
||||
white_point: WhitePoint::D65,
|
||||
primaries: Primaries::Bt2100,
|
||||
primaries: if via_srgb { Primaries::Srgb } else { Primaries::Bt2100 },
|
||||
tf: TransferFunction::Linear,
|
||||
rendering_intent: RenderingIntent::Relative,
|
||||
});
|
||||
@@ -55,11 +59,19 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
let keep = n_color + has_alpha as usize;
|
||||
let src = fb.buf();
|
||||
let mut v = Vec::with_capacity(fw * fh * keep);
|
||||
let to_2020 = lightcraft_color::SRGB.to_space(&lightcraft_color::REC2020).to_f32();
|
||||
for px in src.chunks_exact(ch) {
|
||||
let start = v.len();
|
||||
v.extend_from_slice(&px[..keep.min(ch)]);
|
||||
if keep > ch {
|
||||
v.push(1.0);
|
||||
}
|
||||
if via_srgb && let Some(rgb) = v.get_mut(start..start + 3) {
|
||||
let [r, g, b] = [rgb[0], rgb[1], rgb[2]];
|
||||
for (o, m) in rgb.iter_mut().zip(&to_2020) {
|
||||
*o = m[0] * r + m[1] * g + m[2] * b;
|
||||
}
|
||||
}
|
||||
}
|
||||
let raw = Raw {
|
||||
width: fw,
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
//! JPEG XL encode via `jixel` (pure Rust, BSD-3-Clause OR Apache-2.0; native targets with the `jxl`
|
||||
//! feature: it spawns threads of its own). Lossy files are VarDCT in XYB, tagged with an enum colour
|
||||
//! encoding the decoder renders back to; lossless files are Modular and keep their samples as they
|
||||
//! are, tagged with the ICC profile when one is given. EXIF and XMP go in `Exif` / `xml ` boxes.
|
||||
|
||||
use crate::encode::{EncodeImage, EncodeMeta, HDR_REFERENCE_WHITE_NITS, Samples, pq_encode};
|
||||
use crate::{Error, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Encoder effort: time spent searching for a smaller file at the same quality. jixel's slowest
|
||||
/// search (about 20 % smaller again, five times slower) is not offered: its files are valid, but
|
||||
/// jxl-oxide, our decoder, renders blocks of them wrongly, so LightCraft could not read back its own
|
||||
/// exports.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum JxlEffort {
|
||||
/// Plain 8×8 transforms only: quickest, largest (about a third larger than `Normal`).
|
||||
Fast,
|
||||
#[default]
|
||||
Normal,
|
||||
}
|
||||
|
||||
impl JxlEffort {
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
Some(match s.to_ascii_lowercase().as_str() {
|
||||
"fast" => Self::Fast,
|
||||
"normal" => Self::Normal,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour encoding a JPEG XL file declares. Integer samples are display-encoded with its curve.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum JxlColour {
|
||||
#[default]
|
||||
Srgb,
|
||||
/// P3 primaries, D65, sRGB curve.
|
||||
DisplayP3,
|
||||
/// Rec. 2020 primaries with the BT.709 curve.
|
||||
Rec2020,
|
||||
/// HDR: Rec. 2020 primaries with the PQ curve (SMPTE ST 2084). Float samples are linear light
|
||||
/// with SDR white at 1.0 (written with SDR white at [`HDR_REFERENCE_WHITE_NITS`]); 16-bit
|
||||
/// samples are already PQ-encoded.
|
||||
Rec2020Pq,
|
||||
}
|
||||
|
||||
/// JPEG XL encoder settings.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct JxlOptions {
|
||||
/// 1..=100 on a libjpeg-like scale (90 ≈ visually lossless). Ignored when `lossless`.
|
||||
pub quality: u8,
|
||||
/// Mathematically lossless (Modular) instead of lossy (VarDCT).
|
||||
pub lossless: bool,
|
||||
pub effort: JxlEffort,
|
||||
/// Lossy: two passes, a coarse image first; lossless: the Squeeze transform (a low-resolution
|
||||
/// preview that refines to the exact image).
|
||||
pub progressive: bool,
|
||||
pub colour: JxlColour,
|
||||
}
|
||||
|
||||
impl Default for JxlOptions {
|
||||
fn default() -> Self {
|
||||
Self { quality: 90, lossless: false, effort: JxlEffort::Normal, progressive: false, colour: JxlColour::Srgb }
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode JPEG XL from 8- or 16-bit samples (float samples only with [`JxlColour::Rec2020Pq`]).
|
||||
/// Gray is widened to RGB; alpha is kept only when some pixel is not opaque. The ICC profile in
|
||||
/// `meta` is embedded in lossless files (lossy ones carry the enum encoding of `o.colour`); `ppi`
|
||||
/// has no place in the format. Returns [`Error::Encode`] on wasm32 or without the `jxl` feature.
|
||||
pub fn encode_jxl(img: &EncodeImage, o: &JxlOptions, meta: &EncodeMeta) -> Result<Vec<u8>> {
|
||||
img.validate()?;
|
||||
#[cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
|
||||
{
|
||||
let pixels = Pixels::from(img, o.colour)?;
|
||||
// jixel asserts internally; a panic there is an export error, not a crash
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&pixels, img.width as usize, img.height as usize, o, meta)))
|
||||
.unwrap_or_else(|_| Err(Error::Encode("JPEG XL encoder panicked".into())))
|
||||
}
|
||||
#[cfg(not(all(feature = "jxl", not(target_arch = "wasm32"))))]
|
||||
{
|
||||
let _ = (o, meta);
|
||||
Err(Error::Encode("JPEG XL encoding is not available in this build".into()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Interleaved RGB or RGBA samples for jixel.
|
||||
#[cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
|
||||
enum Pixels {
|
||||
U8 { data: Vec<u8>, alpha: bool },
|
||||
U16 { data: Vec<u16>, alpha: bool },
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
|
||||
impl Pixels {
|
||||
fn from(img: &EncodeImage, colour: JxlColour) -> Result<Self> {
|
||||
let n = (img.width as usize).checked_mul(img.height as usize).ok_or_else(|| Error::Encode("image too large".into()))?;
|
||||
let ch = img.channels as usize;
|
||||
// keep alpha only when it says something
|
||||
fn rgba<T: Copy>(s: &[T], ch: usize, n: usize, opaque: impl Fn(T) -> bool) -> (Vec<T>, bool) {
|
||||
let px = s.chunks_exact(ch).take(n);
|
||||
let alpha = (ch == 2 || ch == 4) && s.chunks_exact(ch).take(n).any(|c| c.last().is_some_and(|a| !opaque(*a)));
|
||||
let out_ch = if alpha { 4 } else { 3 };
|
||||
let mut v = Vec::with_capacity(n * out_ch);
|
||||
for c in px {
|
||||
match c {
|
||||
[g] => v.extend_from_slice(&[*g, *g, *g]),
|
||||
[g, a] => v.extend_from_slice(&[*g, *g, *g, *a][..out_ch]),
|
||||
[r, g, b] => v.extend_from_slice(&[*r, *g, *b]),
|
||||
[r, g, b, a, ..] => v.extend_from_slice(&[*r, *g, *b, *a][..out_ch]),
|
||||
[] => {}
|
||||
}
|
||||
}
|
||||
(v, alpha)
|
||||
}
|
||||
Ok(match img.samples {
|
||||
Samples::U8(s) => {
|
||||
let (data, alpha) = rgba(s, ch, n, |a| a == u8::MAX);
|
||||
Pixels::U8 { data, alpha }
|
||||
}
|
||||
Samples::U16(s) => {
|
||||
let (data, alpha) = rgba(s, ch, n, |a| a == u16::MAX);
|
||||
Pixels::U16 { data, alpha }
|
||||
}
|
||||
Samples::F32(s) if colour == JxlColour::Rec2020Pq => {
|
||||
let pq = |v: f32| (pq_encode(if v.is_finite() { v.max(0.0) } else { 0.0 } * HDR_REFERENCE_WHITE_NITS) * 65535.0).round() as u16;
|
||||
let alpha = |a: f32| (if a.is_finite() { a.clamp(0.0, 1.0) } else { 1.0 } * 65535.0).round() as u16;
|
||||
let s: Vec<u16> = s
|
||||
.chunks_exact(ch)
|
||||
.take(n)
|
||||
.flat_map(|c| match c {
|
||||
[g] => [pq(*g), pq(*g), pq(*g), u16::MAX],
|
||||
[g, a] => [pq(*g), pq(*g), pq(*g), alpha(*a)],
|
||||
[r, g, b] => [pq(*r), pq(*g), pq(*b), u16::MAX],
|
||||
[r, g, b, a, ..] => [pq(*r), pq(*g), pq(*b), alpha(*a)],
|
||||
[] => [0; 4],
|
||||
})
|
||||
.collect();
|
||||
let (data, alpha) = rgba(&s, 4, n, |a| a == u16::MAX);
|
||||
Pixels::U16 { data, alpha }
|
||||
}
|
||||
Samples::F32(_) => return Err(Error::Encode("JPEG XL float samples are written as HDR (Rec. 2020 PQ) only".into())),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
|
||||
fn run(px: &Pixels, w: usize, h: usize, o: &JxlOptions, meta: &EncodeMeta) -> Result<Vec<u8>> {
|
||||
use jixel::{ColorEncoding, EncodeConfig, Primaries, Speed, TransferFunction};
|
||||
let colour = match o.colour {
|
||||
JxlColour::Srgb => ColorEncoding::srgb(),
|
||||
JxlColour::DisplayP3 => ColorEncoding::display_p3(),
|
||||
JxlColour::Rec2020 => ColorEncoding { primaries: Primaries::Bt2020, transfer: TransferFunction::Bt709, ..ColorEncoding::srgb() },
|
||||
JxlColour::Rec2020Pq => ColorEncoding::bt2020_pq(),
|
||||
};
|
||||
let mut c = EncodeConfig::default()
|
||||
.with_quality(f32::from(o.quality.clamp(1, 100)))
|
||||
.with_color_encoding(colour)
|
||||
.with_lossless(o.lossless)
|
||||
.with_progressive(o.progressive)
|
||||
.with_speed(match o.effort {
|
||||
JxlEffort::Fast => Speed::Fastest,
|
||||
JxlEffort::Normal => Speed::Fast,
|
||||
});
|
||||
if o.colour == JxlColour::Rec2020Pq {
|
||||
// PQ code values are absolute: 1.0 = 10 000 cd/m²
|
||||
c = c.with_intensity_target(10_000.0);
|
||||
}
|
||||
if o.lossless
|
||||
&& let Some(icc) = meta.icc
|
||||
{
|
||||
c = c.with_icc_profile(icc.to_vec());
|
||||
}
|
||||
if let Some(exif) = meta.exif {
|
||||
c = c.with_exif(exif.to_vec());
|
||||
}
|
||||
if let Some(xmp) = meta.xmp {
|
||||
c = c.with_xmp(xmp.as_bytes().to_vec());
|
||||
}
|
||||
let r = match px {
|
||||
Pixels::U8 { data, alpha: false } => jixel::encode_image(data, w, h, &c),
|
||||
Pixels::U8 { data, alpha: true } => jixel::encode_image_with_alpha(data, w, h, &c),
|
||||
Pixels::U16 { data, alpha: false } => jixel::encode_image_16bit(data, w, h, &c),
|
||||
Pixels::U16 { data, alpha: true } => jixel::encode_image_with_alpha_16bit(data, w, h, &c),
|
||||
};
|
||||
r.map_err(|e| Error::Encode(format!("JPEG XL: {e}")))
|
||||
}
|
||||
@@ -9,7 +9,8 @@
|
||||
//! - [`read_header`] reads a file's stored dimensions and orientation from its headers, without
|
||||
//! decoding pixels (import probes).
|
||||
//! - [`decode_thumbnail`] is the fast path for grid thumbnails (EXIF thumbnail or DCT-scaled decode).
|
||||
//! - [`encode`] writes JPEG, PNG, TIFF, lossless WebP and (native, feature `avif`) AVIF, embedding
|
||||
//! - [`encode`] writes JPEG, PNG, TIFF, lossless WebP and, on native targets, AVIF (feature `avif`) and
|
||||
//! JPEG XL ([`encode_jxl`], feature `jxl`), embedding
|
||||
//! ICC/EXIF/XMP. [`icc::write_matrix_trc`] builds profiles for export.
|
||||
//!
|
||||
//! No decoder panics on malformed input (property-tested).
|
||||
@@ -27,8 +28,10 @@ mod jpeg;
|
||||
pub mod jpeg_par;
|
||||
#[cfg(feature = "jxl")]
|
||||
mod jxl;
|
||||
mod jxl_encode;
|
||||
mod other;
|
||||
mod png_codec;
|
||||
pub mod proof;
|
||||
mod psd;
|
||||
mod sniff;
|
||||
pub mod space;
|
||||
@@ -37,8 +40,9 @@ mod webp;
|
||||
|
||||
pub use encode::{
|
||||
ChromaSubsampling, EncodeImage, EncodeMeta, HDR_REFERENCE_WHITE_NITS, Samples, TiffCompression, encode_avif, encode_avif_pq, encode_jpeg,
|
||||
encode_png, encode_tiff, encode_webp_lossless,
|
||||
encode_png, encode_png_pq, encode_psd, encode_tiff, encode_webp_lossless,
|
||||
};
|
||||
pub use jxl_encode::{JxlColour, JxlEffort, JxlOptions, encode_jxl};
|
||||
pub use sniff::{Format, sniff};
|
||||
pub use space::{NamedSpace, SourceSpace, SpaceOrigin, Trc};
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
//! Soft proofing against an output-device ICC profile (a printer/paper profile, RGB or CMYK, matrix
|
||||
//! or LUT based): a 3D table that takes a colour in the pipeline's working space (linear Rec.2020)
|
||||
//! through the device and back, so the loupe shows what the print can hold.
|
||||
//!
|
||||
//! - Forward with the chosen intent (perceptual or relative colorimetric), back with relative
|
||||
//! colorimetric: colours outside the device gamut come back mapped into it.
|
||||
//! - **Simulate Paper & Ink**: absolute colorimetric rendering of the way back. The paper's white
|
||||
//! (the profile's media white point) replaces the screen's white, and the ink's black is the
|
||||
//! darkest the device tables give (no black-point compensation). The white is applied as a
|
||||
//! per-channel scale in linear Rec.2020 (a von Kries adaptation in RGB, close enough for the
|
||||
//! near-neutral whites of papers).
|
||||
//! - Each entry carries a gamut flag: how far the relative-colorimetric round trip moved the colour.
|
||||
//!
|
||||
//! Sources: ICC.1:2022 (rendering intents, media white point, absolute colorimetric scaling);
|
||||
//! the transforms are moxcms's. Own design otherwise.
|
||||
|
||||
use lightcraft_color::{D50, D65, REC2020, bradford};
|
||||
use moxcms::{ColorProfile, DataColorSpace, Layout, RenderingIntent, TransformOptions};
|
||||
|
||||
use crate::display::MAX_PROFILE_BYTES;
|
||||
|
||||
/// Rendering intent of the forward (image → device) conversion.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||||
pub enum ProofIntent {
|
||||
#[default]
|
||||
Perceptual,
|
||||
Relative,
|
||||
}
|
||||
|
||||
impl ProofIntent {
|
||||
pub fn parse(s: &str) -> Option<ProofIntent> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"perceptual" => Some(ProofIntent::Perceptual),
|
||||
"relative" | "relativecolorimetric" => Some(ProofIntent::Relative),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
ProofIntent::Perceptual => "perceptual",
|
||||
ProofIntent::Relative => "relative",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A proof table: `n³` entries over working-space RGB encoded with the sRGB curve (index
|
||||
/// `(r * n + g) * n + b`), each the simulated linear Rec.2020 colour plus a gamut distance
|
||||
/// (`[3]`, 0 = in gamut; colours whose round trip moved them more than ~0.02 are outside).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ProofTable {
|
||||
pub n: usize,
|
||||
pub data: Vec<[f32; 4]>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// Grid size of [`ProofTable`]s.
|
||||
pub const PROOF_GRID: usize = 33;
|
||||
|
||||
fn guarded<T>(f: impl FnOnce() -> Option<T>) -> Option<T> {
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).ok().flatten()
|
||||
}
|
||||
|
||||
/// Build the proof table for an output profile. Errors say what's wrong with the file; nothing here
|
||||
/// panics on a hostile profile.
|
||||
pub fn proof_table(bytes: &[u8], intent: ProofIntent, paper_ink: bool) -> Result<ProofTable, String> {
|
||||
if bytes.len() > MAX_PROFILE_BYTES {
|
||||
return Err("the profile is too large".into());
|
||||
}
|
||||
let dev = guarded(|| ColorProfile::new_from_slice(bytes).ok()).ok_or("not a valid ICC profile")?;
|
||||
let (layout, ch) = match dev.color_space {
|
||||
DataColorSpace::Rgb => (Layout::Rgb, 3),
|
||||
DataColorSpace::Cmyk => (Layout::Rgba, 4),
|
||||
DataColorSpace::Gray => (Layout::Gray, 1),
|
||||
_ => return Err("only RGB, CMYK and grey output profiles can be proofed".into()),
|
||||
};
|
||||
let lin = crate::icc::linear_rec2020_profile();
|
||||
let opts = |i| TransformOptions { rendering_intent: i, ..TransformOptions::default() };
|
||||
let fwd_i = match intent {
|
||||
ProofIntent::Perceptual => RenderingIntent::Perceptual,
|
||||
ProofIntent::Relative => RenderingIntent::RelativeColorimetric,
|
||||
};
|
||||
let rel = RenderingIntent::RelativeColorimetric;
|
||||
let fwd = guarded(|| lin.create_transform_f32(Layout::Rgb, &dev, layout, opts(fwd_i)).ok()).ok_or("can't convert to this profile")?;
|
||||
let fwd_rel = guarded(|| lin.create_transform_f32(Layout::Rgb, &dev, layout, opts(rel)).ok()).ok_or("can't convert to this profile")?;
|
||||
let back = guarded(|| dev.create_transform_f32(layout, &lin, Layout::Rgb, opts(rel)).ok()).ok_or("can't convert from this profile")?;
|
||||
|
||||
let n = PROOF_GRID;
|
||||
let grid: Vec<f32> = (0..n * n * n)
|
||||
.flat_map(|i| [i / (n * n), (i / n) % n, i % n].map(|k| lightcraft_color::transfer::srgb_to_linear(k as f32 / (n - 1) as f32)))
|
||||
.collect();
|
||||
let round_trip = |there: &dyn moxcms::TransformExecutor<f32>| -> Option<Vec<f32>> {
|
||||
let mut device = vec![0f32; n * n * n * ch];
|
||||
there.transform(&grid, &mut device).ok()?;
|
||||
let mut out = vec![0f32; n * n * n * 3];
|
||||
back.transform(&device, &mut out).ok()?;
|
||||
Some(out)
|
||||
};
|
||||
let sim = guarded(|| round_trip(fwd.as_ref())).ok_or("the profile's tables can't be applied")?;
|
||||
let colorimetric = guarded(|| round_trip(fwd_rel.as_ref())).ok_or("the profile's tables can't be applied")?;
|
||||
let paper = if paper_ink { paper_white(&dev) } else { [1.0; 3] };
|
||||
let enc = |v: f32| lightcraft_color::transfer::linear_to_srgb(v.clamp(0.0, 1.0));
|
||||
let data = (0..n * n * n)
|
||||
.map(|i| {
|
||||
let c = |v: &[f32], k: usize| v.get(i * 3 + k).copied().filter(|x| x.is_finite()).unwrap_or(0.0);
|
||||
let dist = (0..3).map(|k| (enc(c(&colorimetric, k)) - enc(c(&grid, k))).abs()).fold(0.0f32, f32::max);
|
||||
[c(&sim, 0) * paper[0], c(&sim, 1) * paper[1], c(&sim, 2) * paper[2], dist]
|
||||
})
|
||||
.collect();
|
||||
let description = crate::icc::parse(bytes).and_then(|i| i.description);
|
||||
Ok(ProofTable { n, data, description })
|
||||
}
|
||||
|
||||
/// The paper's white in linear Rec.2020 (screen white = (1, 1, 1)), from the media white point.
|
||||
fn paper_white(p: &ColorProfile) -> [f32; 3] {
|
||||
let Some(w) = p.media_white_point else { return [1.0; 3] };
|
||||
let to_xyz = bradford(D65, D50).mul(&REC2020.to_xyz());
|
||||
let Some(from_xyz) = to_xyz.inverse() else { return [1.0; 3] };
|
||||
// the PCS white is D50 = Rec.2020 (1, 1, 1) after adaptation; the paper is relative to it
|
||||
let d50 = to_xyz.apply([1.0; 3]);
|
||||
let s = [w.x / d50[0], w.y / d50[1], w.z / d50[2]];
|
||||
let rgb = from_xyz.apply([d50[0] * s[0], d50[1] * s[1], d50[2] * s[2]]);
|
||||
if rgb.iter().all(|v| v.is_finite() && *v > 0.05 && *v <= 1.5) { rgb.map(|v| (v as f32).min(1.0)) } else { [1.0; 3] }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::space::NamedSpace;
|
||||
|
||||
fn at(t: &ProofTable, rgb: [usize; 3]) -> [f32; 4] {
|
||||
t.data[(rgb[0] * t.n + rgb[1]) * t.n + rgb[2]]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn srgb_proof_flags_wide_colours_and_keeps_grey() {
|
||||
let t = proof_table(&crate::icc::write_named(NamedSpace::Srgb), ProofIntent::Relative, false).unwrap();
|
||||
let top = t.n - 1;
|
||||
// Rec.2020 pure green is far outside sRGB; mid grey and white are inside and unchanged
|
||||
assert!(at(&t, [0, top, 0])[3] > 0.05, "{:?}", at(&t, [0, top, 0]));
|
||||
let g = at(&t, [16, 16, 16]);
|
||||
assert!(g[3] < 0.01, "{g:?}");
|
||||
let lin = lightcraft_color::transfer::srgb_to_linear(16.0 / 32.0);
|
||||
assert!((0..3).all(|k| (g[k] - lin).abs() < 0.01), "{g:?} vs {lin}");
|
||||
let w = at(&t, [top; 3]);
|
||||
assert!((0..3).all(|k| (w[k] - 1.0).abs() < 0.01), "{w:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paper_white_dims_and_tints_white() {
|
||||
let mut p = ColorProfile::new_from_slice(&crate::icc::write_named(NamedSpace::AdobeRgb)).unwrap();
|
||||
// a warm, slightly dull paper
|
||||
p.media_white_point = Some(moxcms::Xyzd { x: 0.9642 * 0.92, y: 0.90, z: 0.8249 * 0.80 });
|
||||
let bytes = p.encode().unwrap();
|
||||
let top = PROOF_GRID - 1;
|
||||
let plain = proof_table(&bytes, ProofIntent::Perceptual, false).unwrap();
|
||||
let paper = proof_table(&bytes, ProofIntent::Perceptual, true).unwrap();
|
||||
let (w0, w1) = (at(&plain, [top; 3]), at(&paper, [top; 3]));
|
||||
assert!((0..3).all(|k| (w0[k] - 1.0).abs() < 0.01), "{w0:?}");
|
||||
assert!(w1[1] < 0.95 && w1[2] < w1[0], "paper white is dimmer and warmer: {w1:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_profiles_are_errors() {
|
||||
assert!(proof_table(&[], ProofIntent::Perceptual, false).is_err());
|
||||
assert!(proof_table(&[0u8; 400], ProofIntent::Relative, true).is_err());
|
||||
let good = crate::icc::write_named(NamedSpace::DisplayP3);
|
||||
for i in (0..good.len()).step_by(11) {
|
||||
let mut b = good.clone();
|
||||
b[i] ^= 0x5a;
|
||||
let _ = proof_table(&b, ProofIntent::Perceptual, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
//! Photoshop PSD/PSB merged-composite reader (clean-room, from the public file-format description):
|
||||
//! 8/16/32-bit gray, RGB, CMYK, indexed, duotone (as gray); raw or PackBits/RLE image data;
|
||||
//! ICC (resource 1039), EXIF (1058), XMP (1060); transparency when the layer count is negative.
|
||||
//! Also a flat-file writer ([`encode`]): the merged image of an 8- or 16-bit gray/RGB document
|
||||
//! with no layers, so an image editor opens it as one background layer and saves back to it.
|
||||
|
||||
use crate::convert::{Buf, Meta, Model, Raw, check_size, finish};
|
||||
use crate::encode::{EncodeImage, EncodeMeta, Samples};
|
||||
use crate::{DecodeOptions, Decoded, Error, Format, Result};
|
||||
|
||||
const F: Format = Format::Psd;
|
||||
@@ -211,6 +214,129 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
finish(F, raw, Meta { icc, exif, xmp, ..Default::default() }, (width as u32, height as u32), opts)
|
||||
}
|
||||
|
||||
/// The largest side a PSD (version 1) may have; larger documents need PSB.
|
||||
pub const MAX_SIDE: u32 = 30_000;
|
||||
|
||||
/// Write a flat PSD: header, the image resources for `meta` (resolution 1005, ICC 1039, EXIF
|
||||
/// 1058, XMP 1060), an empty layer section and the merged image. 8-bit planes are PackBits
|
||||
/// compressed, 16-bit ones stored raw (PackBits saves little on 16-bit photos). Gray or RGB;
|
||||
/// alpha is dropped (the render is opaque). Float samples are refused.
|
||||
pub(crate) fn encode(img: &EncodeImage, meta: &EncodeMeta) -> Result<Vec<u8>> {
|
||||
img.validate()?;
|
||||
let (w, h) = (img.width, img.height);
|
||||
if w > MAX_SIDE || h > MAX_SIDE {
|
||||
return Err(Error::Encode(format!("{w} × {h} is larger than a PSD can hold ({MAX_SIDE} px a side): use TIFF")));
|
||||
}
|
||||
let ch = usize::from(img.channels);
|
||||
let color = if ch <= 2 { 1 } else { 3 };
|
||||
let (depth, bpc) = match img.samples {
|
||||
Samples::U8(_) => (8u16, 1usize),
|
||||
Samples::U16(_) => (16, 2),
|
||||
Samples::F32(_) => return Err(Error::Encode("32-bit float PSD is not supported: use TIFF".into())),
|
||||
};
|
||||
let (wu, hu) = (w as usize, h as usize);
|
||||
let mut b = Vec::new();
|
||||
b.extend_from_slice(b"8BPS");
|
||||
b.extend_from_slice(&1u16.to_be_bytes());
|
||||
b.extend_from_slice(&[0; 6]);
|
||||
b.extend_from_slice(&(color as u16).to_be_bytes());
|
||||
b.extend_from_slice(&h.to_be_bytes());
|
||||
b.extend_from_slice(&w.to_be_bytes());
|
||||
b.extend_from_slice(&depth.to_be_bytes());
|
||||
b.extend_from_slice(&(if color == 1 { 1u16 } else { 3 }).to_be_bytes());
|
||||
b.extend_from_slice(&0u32.to_be_bytes()); // colour mode data
|
||||
let res = resource_section(meta);
|
||||
b.extend_from_slice(&u32::try_from(res.len()).map_err(|_| Error::Encode("metadata too large for PSD".into()))?.to_be_bytes());
|
||||
b.extend_from_slice(&res);
|
||||
b.extend_from_slice(&0u32.to_be_bytes()); // layer and mask information: none
|
||||
// planar samples, one plane per colour channel (big-endian)
|
||||
let plane = |c: usize| -> Vec<u8> {
|
||||
let mut p = Vec::with_capacity(wu * hu * bpc);
|
||||
match img.samples {
|
||||
Samples::U8(s) => p.extend(s.iter().skip(c).step_by(ch).take(wu * hu)),
|
||||
Samples::U16(s) => s.iter().skip(c).step_by(ch).take(wu * hu).for_each(|v| p.extend_from_slice(&v.to_be_bytes())),
|
||||
Samples::F32(_) => {}
|
||||
}
|
||||
p
|
||||
};
|
||||
if bpc == 1 {
|
||||
b.extend_from_slice(&1u16.to_be_bytes());
|
||||
let mut counts = Vec::with_capacity(color * hu * 2);
|
||||
let mut data = Vec::new();
|
||||
for c in 0..color {
|
||||
for row in plane(c).chunks_exact(wu) {
|
||||
let start = data.len();
|
||||
packbits(row, &mut data);
|
||||
let n = u16::try_from(data.len() - start).map_err(|_| Error::Encode("PSD row too long".into()))?;
|
||||
counts.extend_from_slice(&n.to_be_bytes());
|
||||
}
|
||||
}
|
||||
b.extend_from_slice(&counts);
|
||||
b.extend_from_slice(&data);
|
||||
} else {
|
||||
b.extend_from_slice(&0u16.to_be_bytes());
|
||||
for c in 0..color {
|
||||
b.extend_from_slice(&plane(c));
|
||||
}
|
||||
}
|
||||
Ok(b)
|
||||
}
|
||||
|
||||
/// The image resources section for `meta`.
|
||||
fn resource_section(meta: &EncodeMeta) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
let mut add = |id: u16, data: &[u8]| {
|
||||
out.extend_from_slice(b"8BIM");
|
||||
out.extend_from_slice(&id.to_be_bytes());
|
||||
out.extend_from_slice(&[0, 0]); // empty Pascal name, padded to even
|
||||
out.extend_from_slice(&(data.len() as u32).to_be_bytes());
|
||||
out.extend_from_slice(data);
|
||||
if data.len() % 2 == 1 {
|
||||
out.push(0);
|
||||
}
|
||||
};
|
||||
if let Some(ppi) = meta.ppi.filter(|p| *p > 0) {
|
||||
// ResolutionInfo: 16.16 fixed pixels per inch, unit 1 (ppi), display unit 1 (inches), twice
|
||||
let fixed = (u32::from(ppi) << 16).to_be_bytes();
|
||||
let one = 1u16.to_be_bytes();
|
||||
add(1005, &[&fixed[..], &one, &one, &fixed, &one, &one].concat());
|
||||
}
|
||||
if let Some(icc) = meta.icc {
|
||||
add(1039, icc);
|
||||
}
|
||||
if let Some(exif) = meta.exif {
|
||||
add(1058, exif);
|
||||
}
|
||||
if let Some(xmp) = meta.xmp {
|
||||
add(1060, xmp.as_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// PackBits: append one encoded row of `src` to `out` (runs of 2+ equal bytes as repeats, the
|
||||
/// rest as literals of at most 128 bytes).
|
||||
fn packbits(src: &[u8], out: &mut Vec<u8>) {
|
||||
let mut rest = src;
|
||||
while let Some(&b) = rest.first() {
|
||||
// a run of 2 to 128 equal bytes
|
||||
let run = rest.iter().take(128).take_while(|&&x| x == b).count();
|
||||
if run >= 2 {
|
||||
// 1 - run in 2..=128: -1..=-127, as a signed byte
|
||||
out.push((1 - run as i16) as i8 as u8);
|
||||
out.push(b);
|
||||
rest = rest.get(run..).unwrap_or_default();
|
||||
continue;
|
||||
}
|
||||
// a literal of 1 to 128 bytes: up to the next pair of equal bytes, or the end of the row
|
||||
let unpaired = rest.windows(2).take(128).take_while(|w| w.first() != w.get(1)).count();
|
||||
let len = if unpaired.saturating_add(1) == rest.len() { rest.len() } else { unpaired };
|
||||
let (lit, tail) = rest.split_at_checked(len.clamp(1, 128)).unwrap_or((rest, &[][..]));
|
||||
out.push(lit.len().saturating_sub(1) as u8);
|
||||
out.extend_from_slice(lit);
|
||||
rest = tail;
|
||||
}
|
||||
}
|
||||
|
||||
/// Stored dimensions and EXIF orientation (resource 1058) from the header and image resources,
|
||||
/// without reading the merged image data. Refused, as a decode would refuse it: an unsupported
|
||||
/// depth, colour mode or compression, or merged image data that runs past the end of the file
|
||||
@@ -434,4 +560,55 @@ mod tests {
|
||||
let q = d.image.get(0, 0);
|
||||
assert!(p[0] > 0.0 && (p[0] - q[0]).abs() < 1e-6, "{p:?}");
|
||||
}
|
||||
#[test]
|
||||
fn packbits_round_trips() {
|
||||
let rows: [&[u8]; 6] = [&[], &[7], &[1, 1], &[1, 2, 3, 3, 3, 4, 5, 5], &[9; 300], &(0..=255u8).cycle().take(700).collect::<Vec<_>>()];
|
||||
for row in rows {
|
||||
let mut enc = Vec::new();
|
||||
super::packbits(row, &mut enc);
|
||||
let mut out = vec![0u8; row.len()];
|
||||
unpackbits(&enc, &mut out).unwrap();
|
||||
assert_eq!(out, row);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_psd_round_trips_8_and_16_bit() {
|
||||
let icc = crate::icc::write_named(crate::NamedSpace::AdobeRgb);
|
||||
let meta = EncodeMeta { icc: Some(&icc), xmp: Some("<x:xmpmeta/>"), ppi: Some(300), ..Default::default() };
|
||||
// 8-bit RGBA (alpha dropped), 5 × 3 with a flat run and a gradient
|
||||
let px: Vec<u8> = (0..15u8).flat_map(|i| [i * 10, if i < 8 { 200 } else { i }, 255 - i, 255]).collect();
|
||||
let b = encode(&EncodeImage::new(5, 3, 4, Samples::U8(&px)), &meta).unwrap();
|
||||
assert_eq!(super::header(&b).unwrap(), (5, 3, 1));
|
||||
let d = decode(&b, &DecodeOptions::default()).unwrap();
|
||||
assert_eq!((d.width, d.height), (5, 3));
|
||||
assert_eq!(d.icc.as_deref(), Some(&icc[..]), "the ICC profile is kept");
|
||||
assert_eq!(d.xmp.as_deref(), Some("<x:xmpmeta/>"));
|
||||
// 16-bit RGB, raw planes: the samples come back exactly
|
||||
let px16: Vec<u16> = (0..4 * 2 * 3).map(|i| (i as u16) * 2731).collect();
|
||||
let b = encode(&EncodeImage::new(4, 2, 3, Samples::U16(&px16)), &meta).unwrap();
|
||||
assert_eq!(&b[22..24], &16u16.to_be_bytes());
|
||||
let mut r = Rd { b: &b, p: 26 };
|
||||
let cm = r.u32().unwrap() as usize;
|
||||
r.take(cm).unwrap();
|
||||
let res = r.u32().unwrap() as usize;
|
||||
r.take(res).unwrap();
|
||||
assert_eq!(r.u32().unwrap(), 0, "no layers");
|
||||
assert_eq!(r.u16().unwrap(), 0, "16-bit planes are raw");
|
||||
let red: Vec<u16> = (0..8).map(|_| r.u16().unwrap()).collect();
|
||||
assert_eq!(red, px16.iter().step_by(3).copied().collect::<Vec<_>>());
|
||||
assert!(decode(&b, &DecodeOptions::default()).is_ok());
|
||||
// gray 16-bit
|
||||
let g = encode(&EncodeImage::new(2, 2, 1, Samples::U16(&[0, 1000, 40000, 65535])), &EncodeMeta::default()).unwrap();
|
||||
assert_eq!((&g[12..14], &g[24..26]), (&1u16.to_be_bytes()[..], &1u16.to_be_bytes()[..]), "one channel, grayscale mode");
|
||||
assert!(decode(&g, &DecodeOptions::default()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_psd_refuses_what_it_cannot_hold() {
|
||||
let e = encode(&EncodeImage::new(1, 1, 3, Samples::F32(&[0.5; 3])), &EncodeMeta::default()).unwrap_err();
|
||||
assert!(e.to_string().contains("float"), "{e}");
|
||||
let e = encode(&EncodeImage::new(MAX_SIDE + 1, 1, 1, Samples::U8(&vec![0; MAX_SIDE as usize + 1])), &EncodeMeta::default()).unwrap_err();
|
||||
assert!(e.to_string().contains("TIFF"), "{e}");
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,15 @@ fn bench_24mp_jpeg() {
|
||||
let rgba = d.to_srgb8();
|
||||
time("encode PNG 8-bit", mp, 1, || encode_png(&EncodeImage::rgba8(&rgba), &EncodeMeta::default()).unwrap());
|
||||
time("encode TIFF 8-bit deflate", mp, 1, || encode_tiff(&EncodeImage::rgba8(&rgba), TiffCompression::Deflate, &EncodeMeta::default()).unwrap());
|
||||
#[cfg(feature = "jxl")]
|
||||
for (label, o) in [
|
||||
("encode JPEG XL q90 effort fast", JxlOptions { effort: JxlEffort::Fast, ..Default::default() }),
|
||||
("encode JPEG XL q90 effort normal", JxlOptions::default()),
|
||||
("encode JPEG XL lossless", JxlOptions { lossless: true, ..Default::default() }),
|
||||
] {
|
||||
let b = time(label, mp, 1, || encode_jxl(&img, &o, &EncodeMeta::default()).unwrap());
|
||||
println!(" size {:.1} MB", b.len() as f64 / 1e6);
|
||||
}
|
||||
}
|
||||
|
||||
fn exif_with_thumbnail(jpeg: &[u8]) -> Vec<u8> {
|
||||
|
||||
@@ -635,3 +635,183 @@ fn print_resolution_is_written() {
|
||||
let j = encode_jpeg(&img, 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap();
|
||||
assert_eq!(&j[13..18], &[0, 0, 1, 0, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pq_png_is_16_bit_rec2020_pq_with_cicp_and_content_light() {
|
||||
// linear Rec. 2020, SDR white at 1.0, up to 8x SDR white along x
|
||||
let (w, h) = (64u32, 4u32);
|
||||
let rgb: Vec<f32> = (0..w * h)
|
||||
.flat_map(|i| {
|
||||
let v = 0.01 * 1.11f32.powi((i % w) as i32);
|
||||
[v, v * 0.5, v * 0.25]
|
||||
})
|
||||
.collect();
|
||||
let xmp = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"/>"#;
|
||||
let bytes = encode_png_pq(w, h, &rgb, &EncodeMeta { xmp: Some(xmp), ppi: Some(240), ..Default::default() }).unwrap();
|
||||
let mut r = png::Decoder::new(std::io::Cursor::new(bytes.as_slice())).read_info().unwrap();
|
||||
let cicp = r.info().coding_independent_code_points.expect("cICP");
|
||||
assert_eq!((cicp.color_primaries, cicp.transfer_function), (9, 16), "BT.2020, PQ");
|
||||
let mut buf = vec![0; r.output_buffer_size().unwrap()];
|
||||
let f = r.next_frame(&mut buf).unwrap();
|
||||
assert_eq!((f.bit_depth, f.color_type, f.width, f.height), (png::BitDepth::Sixteen, png::ColorType::Rgb, w, h));
|
||||
// every sample decodes back to its luminance
|
||||
for x in [0usize, 20, 63] {
|
||||
for c in 0..3 {
|
||||
let o = (x * 3 + c) * 2;
|
||||
let e = u16::from_be_bytes([buf[o], buf[o + 1]]) as f32 / 65535.0;
|
||||
let want = rgb[x * 3 + c] * HDR_REFERENCE_WHITE_NITS;
|
||||
assert!((encode::pq_decode(e) - want).abs() <= want * 0.01 + 0.01, "x {x} c {c}: {} vs {want}", encode::pq_decode(e));
|
||||
}
|
||||
}
|
||||
// cLLI: MaxCLL is the brightest channel, in 0.0001 cd/m²
|
||||
let at = bytes.windows(4).position(|w| w == b"cLLI").expect("cLLI");
|
||||
let max_cll = u32::from_be_bytes(bytes[at + 4..at + 8].try_into().unwrap()) as f32 / 10_000.0;
|
||||
let brightest = rgb.iter().copied().fold(0.0f32, f32::max) * HDR_REFERENCE_WHITE_NITS;
|
||||
assert!((max_cll - brightest.ceil()).abs() <= 1.0, "{max_cll} vs {brightest}");
|
||||
assert!(bytes.windows(17).any(|w| w == b"XML:com.adobe.xmp"), "XMP kept");
|
||||
assert!(bytes.windows(4).any(|w| w == b"pHYs"), "print resolution kept");
|
||||
assert!(!bytes.windows(4).any(|w| w == b"iCCP"), "no ICC profile next to cICP");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pq_png_rejects_short_buffers_and_survives_hostile_samples() {
|
||||
assert!(encode_png_pq(4, 4, &[1.0; 3], &EncodeMeta::default()).is_err());
|
||||
assert!(encode_png_pq(0, 4, &[], &EncodeMeta::default()).is_err());
|
||||
assert!(encode_png_pq(u32::MAX, u32::MAX, &[], &EncodeMeta::default()).is_err());
|
||||
let hostile = [f32::NAN, f32::INFINITY, -5.0, 1e30, 0.5, 2.0];
|
||||
assert!(encode_png_pq(2, 1, &hostile, &EncodeMeta::default()).is_ok());
|
||||
}
|
||||
|
||||
/// Largest per-channel difference between two linear images.
|
||||
fn max_diff(a: &lightcraft_raster::Rgb32f, b: &lightcraft_raster::Rgb32f) -> f32 {
|
||||
a.data.iter().zip(&b.data).flat_map(|(p, q)| (0..3).map(move |c| (p[c] - q[c]).abs())).fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "jxl")]
|
||||
fn jxl_lossless_exact_with_metadata() {
|
||||
let img = gradient(97, 61);
|
||||
let icc = write_named(NamedSpace::Srgb);
|
||||
let exif = minimal_exif(1);
|
||||
let xmp = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF/></x:xmpmeta>"#;
|
||||
let meta = EncodeMeta { icc: Some(&icc), exif: Some(&exif), xmp: Some(xmp), ..Default::default() };
|
||||
let o = JxlOptions { lossless: true, ..Default::default() };
|
||||
let b = encode_jxl(&EncodeImage::rgba8(&img), &o, &meta).unwrap();
|
||||
assert_eq!(sniff(&b), Some(Format::Jxl));
|
||||
let d = decode(&b, DecodeOptions::default()).unwrap();
|
||||
assert_eq!((d.width, d.height), (97, 61));
|
||||
assert!(!d.has_alpha, "opaque alpha is dropped");
|
||||
assert_eq!(d.icc.as_deref(), Some(&icc[..]));
|
||||
assert_eq!(d.exif.as_deref(), Some(&exif[..]));
|
||||
assert_eq!(d.xmp.as_deref(), Some(xmp));
|
||||
assert_eq!(d.to_srgb8().data, img.data, "lossless round trip is exact");
|
||||
|
||||
// 16-bit lossless keeps every code value
|
||||
let px: Vec<u16> = (0..40 * 30 * 3).map(|i| (i as u32 * 2741 % 65536) as u16).collect();
|
||||
let b = encode_jxl(&EncodeImage::new(40, 30, 3, Samples::U16(&px)), &o, &meta).unwrap();
|
||||
let d = decode(&b, DecodeOptions::default()).unwrap();
|
||||
assert_eq!(d.bit_depth, 16);
|
||||
let png = encode_png(&EncodeImage::new(40, 30, 3, Samples::U16(&px)), &meta).unwrap();
|
||||
let want = decode(&png, DecodeOptions::default()).unwrap();
|
||||
assert!(max_diff(&d.image, &want.image) < 1e-5, "16-bit lossless differs by {}", max_diff(&d.image, &want.image));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "jxl")]
|
||||
fn jxl_lossy_quality_and_effort() {
|
||||
let img = smooth(160, 120);
|
||||
let opaque = Rgba8 { data: img.data.iter().map(|p| [p[0], p[1], p[2], 255]).collect(), ..img.clone() };
|
||||
let enc = |o: JxlOptions| encode_jxl(&EncodeImage::rgba8(&opaque), &o, &EncodeMeta::default()).unwrap();
|
||||
let hi = enc(JxlOptions { quality: 95, ..Default::default() });
|
||||
let lo = enc(JxlOptions { quality: 40, ..Default::default() });
|
||||
assert!(lo.len() < hi.len(), "q40 {} ≥ q95 {}", lo.len(), hi.len());
|
||||
for (q, b) in [(95, &hi), (40, &lo)] {
|
||||
let d = decode(b, DecodeOptions::default()).unwrap();
|
||||
let p = psnr8(&opaque, &d.to_srgb8());
|
||||
assert!(p > if q == 95 { 38.0 } else { 28.0 }, "q{q} psnr {p}");
|
||||
}
|
||||
for effort in [JxlEffort::Fast, JxlEffort::Normal] {
|
||||
for progressive in [false, true] {
|
||||
let b = enc(JxlOptions { effort, progressive, ..Default::default() });
|
||||
let p = psnr8(&opaque, &decode(&b, DecodeOptions::default()).unwrap().to_srgb8());
|
||||
assert!(p > 34.0, "{effort:?} progressive={progressive} psnr {p}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "jxl")]
|
||||
fn jxl_lossy_wide_gamut_matches_lossless_icc() {
|
||||
// the same display-encoded samples, lossy with the enum encoding and lossless with the ICC
|
||||
// profile, decode to the same colours in the working space
|
||||
let img = smooth(96, 64);
|
||||
let opaque = Rgba8 { data: img.data.iter().map(|p| [p[0], p[1], p[2], 255]).collect(), ..img.clone() };
|
||||
for (colour, named) in [(JxlColour::DisplayP3, NamedSpace::DisplayP3), (JxlColour::Rec2020, NamedSpace::Rec2020)] {
|
||||
let icc = write_named(named);
|
||||
let meta = EncodeMeta { icc: Some(&icc), ..Default::default() };
|
||||
let e = EncodeImage::rgba8(&opaque);
|
||||
let lossy = encode_jxl(&e, &JxlOptions { quality: 98, colour, ..Default::default() }, &meta).unwrap();
|
||||
let exact = encode_jxl(&e, &JxlOptions { lossless: true, colour, ..Default::default() }, &meta).unwrap();
|
||||
let a = decode(&lossy, DecodeOptions::default()).unwrap();
|
||||
assert!(a.icc.is_none(), "lossy files carry the enum encoding");
|
||||
let b = decode(&exact, DecodeOptions::default()).unwrap();
|
||||
let d = max_diff(&a.to_working(), &b.to_working());
|
||||
assert!(d < 0.03, "{colour:?}: lossy differs from lossless by {d}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "jxl")]
|
||||
fn jxl_alpha_and_gray() {
|
||||
let mut img = gradient(32, 24);
|
||||
img.data[5][3] = 0;
|
||||
let b = encode_jxl(&EncodeImage::rgba8(&img), &JxlOptions { lossless: true, ..Default::default() }, &EncodeMeta::default()).unwrap();
|
||||
let d = decode(&b, DecodeOptions::default()).unwrap();
|
||||
assert!(d.has_alpha);
|
||||
assert_eq!(d.alpha.as_ref().map(|a| a.data[5]), Some(0.0));
|
||||
let g: Vec<u8> = (0..32 * 24).map(|i| (i % 256) as u8).collect();
|
||||
let b = encode_jxl(&EncodeImage::new(32, 24, 1, Samples::U8(&g)), &JxlOptions { lossless: true, ..Default::default() }, &EncodeMeta::default())
|
||||
.unwrap();
|
||||
let d = decode(&b, DecodeOptions::default()).unwrap();
|
||||
assert_eq!(d.to_srgb8().data.iter().map(|p| p[0]).collect::<Vec<_>>(), g);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "jxl")]
|
||||
fn jxl_hdr_pq() {
|
||||
// linear Rec. 2020, SDR white = 1.0: the left half at SDR white, the right half four times brighter
|
||||
let (w, h) = (64usize, 32usize);
|
||||
let px: Vec<f32> = (0..w * h).flat_map(|i| [if i % w < w / 2 { 1.0 } else { 4.0 }; 3]).collect();
|
||||
for lossless in [false, true] {
|
||||
let o = JxlOptions { lossless, quality: 95, colour: JxlColour::Rec2020Pq, ..Default::default() };
|
||||
let b = encode_jxl(&EncodeImage::new(w as u32, h as u32, 3, Samples::F32(&px)), &o, &EncodeMeta::default()).unwrap();
|
||||
// the file's own samples: PQ code values for 203 and 812 cd/m²
|
||||
let img = jxl_oxide::JxlImage::builder().read(std::io::Cursor::new(&b)).unwrap();
|
||||
let m = &img.image_header().metadata;
|
||||
assert_eq!(m.tone_mapping.intensity_target, 10_000.0);
|
||||
assert!(format!("{:?}", m.colour_encoding).contains("Pq"), "{:?}", m.colour_encoding);
|
||||
let r = img.render_frame(0).unwrap();
|
||||
let fb = r.image_all_channels();
|
||||
let at = |x: usize| fb.buf()[((h / 2) * w + x) * fb.channels() + 1];
|
||||
for (x, nits) in [(w / 4, HDR_REFERENCE_WHITE_NITS), (w * 3 / 4, 4.0 * HDR_REFERENCE_WHITE_NITS)] {
|
||||
let (got, want) = (at(x), lightcraft_codecs::encode::pq_encode(nits));
|
||||
assert!((got - want).abs() < 0.005, "lossless={lossless}: {nits} cd/m² written as {got}, want {want}");
|
||||
}
|
||||
}
|
||||
// float samples are HDR only
|
||||
let sdr = encode_jxl(&EncodeImage::new(w as u32, h as u32, 3, Samples::F32(&px)), &JxlOptions::default(), &EncodeMeta::default());
|
||||
assert!(matches!(sdr, Err(Error::Encode(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "jxl")]
|
||||
fn jxl_bad_input_is_an_error() {
|
||||
let px = [0u8; 12];
|
||||
assert!(encode_jxl(&EncodeImage::new(4, 4, 3, Samples::U8(&px)), &JxlOptions::default(), &EncodeMeta::default()).is_err());
|
||||
assert!(encode_jxl(&EncodeImage::new(0, 4, 3, Samples::U8(&px)), &JxlOptions::default(), &EncodeMeta::default()).is_err());
|
||||
// a 1×1 image and quality 1 still encode
|
||||
let one = [10u8, 20, 30];
|
||||
let b =
|
||||
encode_jxl(&EncodeImage::new(1, 1, 3, Samples::U8(&one)), &JxlOptions { quality: 1, ..Default::default() }, &EncodeMeta::default()).unwrap();
|
||||
assert_eq!(decode(&b, DecodeOptions::default()).unwrap().width, 1);
|
||||
}
|
||||
@@ -149,7 +149,8 @@ impl RgbSpace {
|
||||
}
|
||||
}
|
||||
|
||||
const BRADFORD: Mat3 = Mat3([[0.8951, 0.2664, -0.1614], [-0.7502, 1.7135, 0.0367], [0.0389, -0.0685, 1.0296]]);
|
||||
/// The Bradford cone-response matrix (XYZ → sharpened cones) that [`bradford`] adapts in.
|
||||
pub const BRADFORD: Mat3 = Mat3([[0.8951, 0.2664, -0.1614], [-0.7502, 1.7135, 0.0367], [0.0389, -0.0685, 1.0296]]);
|
||||
|
||||
/// Bradford chromatic adaptation matrix (XYZ → XYZ) from white `src` to white `dst`.
|
||||
pub fn bradford(src: Xy, dst: Xy) -> Mat3 {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
//! blend with the plain demosaic that the Amount slider controls.
|
||||
//! - [`cpu`] and [`runtime`]: a single-thread pure-Rust CPU runner and its installation self-test.
|
||||
//! - [`net`] and [`onnx`]: checked plain network data and bounded ONNX decoding, shared by CPU and GPU.
|
||||
//! - [`remote`]: running tiles on another computer's graphics card over TCP (a [`run::TileRunner`] and its server).
|
||||
//!
|
||||
//! Everything here treats model files, manifests and cache files as hostile input: sizes are capped, numbers must
|
||||
//! be finite, and a malformed file is an error, never a panic.
|
||||
@@ -26,6 +27,7 @@ pub mod onnx;
|
||||
mod onnx_proto;
|
||||
|
||||
pub mod reference;
|
||||
pub mod remote;
|
||||
|
||||
#[cfg(feature = "runtime")]
|
||||
pub mod runtime;
|
||||
|
||||
@@ -0,0 +1,567 @@
|
||||
//! Remote denoise: run a model's tiles on another computer's graphics card over TCP. Meant for a LAN or a VPN such as
|
||||
//! Tailscale or WireGuard: the connection itself is not encrypted.
|
||||
//!
|
||||
//! [`Remote`] is a [`TileRunner`] that sends each tile to a server; [`serve`] runs one (`lightcraft-cli
|
||||
//! denoise-serve`). Both ends must run the same model, checked by its identity ([`ModelId`]) when they greet.
|
||||
//!
|
||||
//! Protocol (version 1). Each message is one JSON line (`\n`-terminated, ≤ 4 KiB), optionally followed by a payload of
|
||||
//! `bytes` bytes: the tile's samples as little-endian half floats, byte-shuffled (every sample's low byte, then every
|
||||
//! high byte) and deflated, which roughly halves what crosses a slow link.
|
||||
//!
|
||||
//! ```text
|
||||
//! → {"v":1,"token":"…","op":"hello"}
|
||||
//! ← {"ok":true,"v":1,"device":"GPU: …","model":{"id":"…","version":"…","sha256":"…"}}
|
||||
//! → {"v":1,"token":"…","op":"run","model":"<sha256>","tile":T,"bytes":N} + N bytes (4 planes of T × T)
|
||||
//! ← {"ok":true,"tile":T,"bytes":N} + N bytes (3 planes of 2T × 2T)
|
||||
//! ← {"ok":false,"error":"…"} (any failure)
|
||||
//! ```
|
||||
//!
|
||||
//! A connection serves any number of requests. The server refuses a wrong token, another model than its own, tiles of
|
||||
//! another size, oversized headers and payloads (also ones that inflate past their tile), and caps concurrent
|
||||
//! connections; the client gives up on a server that keeps failing, so the computer's own runner does the rest.
|
||||
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream, ToSocketAddrs};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::product::{from_f16, to_f16};
|
||||
use crate::run::{Error, TileRunner};
|
||||
|
||||
pub const PROTOCOL: u64 = 1;
|
||||
pub const DEFAULT_PORT: u16 = 7990;
|
||||
const MAX_LINE: usize = 4096;
|
||||
/// Largest tile side in cells (a 1024-cell tile is a 2048 × 2048 pixel answer).
|
||||
pub const MAX_TILE: usize = 1024;
|
||||
/// Shortest token a server accepts.
|
||||
pub const MIN_TOKEN: usize = 16;
|
||||
|
||||
/// Which model a server runs: both ends must agree before tiles cross.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct ModelId {
|
||||
pub id: String,
|
||||
pub version: String,
|
||||
/// SHA-256 of the model file (hex).
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
fn bad(m: impl Into<String>) -> std::io::Error {
|
||||
std::io::Error::new(std::io::ErrorKind::InvalidData, m.into())
|
||||
}
|
||||
|
||||
fn read_line(r: &mut impl BufRead) -> std::io::Result<Option<String>> {
|
||||
let mut buf = Vec::new();
|
||||
let n = r.by_ref().take(MAX_LINE as u64 + 1).read_until(b'\n', &mut buf)?;
|
||||
if n == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
if buf.last() != Some(&b'\n') {
|
||||
return Err(bad("message header too long or truncated"));
|
||||
}
|
||||
String::from_utf8(buf).map(Some).map_err(|_| bad("header is not UTF-8"))
|
||||
}
|
||||
|
||||
/// Samples → half floats → byte-shuffled → deflated.
|
||||
pub fn pack(data: &[f32]) -> Vec<u8> {
|
||||
let n = data.len();
|
||||
let mut b = vec![0u8; n * 2];
|
||||
let (lo, hi) = b.split_at_mut(n);
|
||||
for ((v, l), h) in data.iter().zip(lo.iter_mut()).zip(hi.iter_mut()) {
|
||||
[*l, *h] = to_f16(*v).to_le_bytes();
|
||||
}
|
||||
miniz_oxide::deflate::compress_to_vec(&b, 1)
|
||||
}
|
||||
|
||||
/// The inverse of [`pack`] for exactly `n` samples; refuses anything that inflates to more.
|
||||
pub fn unpack(bytes: &[u8], n: usize) -> std::io::Result<Vec<f32>> {
|
||||
let b = miniz_oxide::inflate::decompress_to_vec_with_limit(bytes, n * 2).map_err(|_| bad("payload doesn't inflate to the tile size"))?;
|
||||
if b.len() != n * 2 {
|
||||
return Err(bad("payload size doesn't match the tile"));
|
||||
}
|
||||
let (lo, hi) = b.split_at(n);
|
||||
Ok(lo.iter().zip(hi).map(|(l, h)| from_f16(u16::from_le_bytes([*l, *h]))).collect())
|
||||
}
|
||||
|
||||
fn write_payload(w: &mut impl Write, mut header: Value, data: &[f32]) -> std::io::Result<()> {
|
||||
let packed = pack(data);
|
||||
header["bytes"] = json!(packed.len());
|
||||
writeln!(w, "{header}")?;
|
||||
w.write_all(&packed)
|
||||
}
|
||||
|
||||
/// Read a `bytes`-byte payload holding `n` samples (`bytes` is capped by the tile, so a hostile header can't make us
|
||||
/// allocate more than an uncompressed tile plus slack).
|
||||
fn read_payload(r: &mut impl Read, bytes: usize, n: usize) -> std::io::Result<Vec<f32>> {
|
||||
if bytes > n * 2 + 4096 {
|
||||
return Err(bad("payload larger than its tile"));
|
||||
}
|
||||
let mut b = vec![0u8; bytes];
|
||||
r.read_exact(&mut b)?;
|
||||
unpack(&b, n)
|
||||
}
|
||||
|
||||
fn field(v: &Value, k: &str) -> Option<usize> {
|
||||
v.get(k).and_then(Value::as_u64).and_then(|x| usize::try_from(x).ok())
|
||||
}
|
||||
|
||||
/// Constant-time comparison (the token must not leak through timing).
|
||||
fn same(a: &str, b: &str) -> bool {
|
||||
let (a, b) = (a.as_bytes(), b.as_bytes());
|
||||
let mut d = a.len() ^ b.len();
|
||||
for i in 0..a.len().max(b.len()) {
|
||||
d |= (a.get(i).copied().unwrap_or(0) ^ b.get(i).copied().unwrap_or(0)) as usize;
|
||||
}
|
||||
d == 0
|
||||
}
|
||||
|
||||
/// What a server runs a tile with: four planes of `tile × tile` cells in, three planes of `2·tile × 2·tile` out.
|
||||
pub type RunTile = Arc<dyn Fn(&[f32]) -> Result<Vec<f32>, Error> + Send + Sync>;
|
||||
|
||||
/// A server's settings.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ServerOptions {
|
||||
/// Shared secret every request must carry (at least [`MIN_TOKEN`] characters).
|
||||
pub token: String,
|
||||
pub max_connections: usize,
|
||||
/// Idle and transfer limit per connection.
|
||||
pub timeout: Duration,
|
||||
}
|
||||
|
||||
impl Default for ServerOptions {
|
||||
fn default() -> Self {
|
||||
ServerOptions { token: String::new(), max_connections: 8, timeout: Duration::from_secs(120) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve `model` (tiles of `tile` cells, run by `run` on `device`) until the listener fails. `log` gets one line per event.
|
||||
pub fn serve(
|
||||
listener: TcpListener,
|
||||
model: ModelId,
|
||||
tile: usize,
|
||||
device: String,
|
||||
run: RunTile,
|
||||
opts: ServerOptions,
|
||||
log: Arc<dyn Fn(&str) + Send + Sync>,
|
||||
) -> Result<(), String> {
|
||||
if opts.token.trim().chars().count() < MIN_TOKEN {
|
||||
return Err(format!("the token must be at least {MIN_TOKEN} characters"));
|
||||
}
|
||||
if tile == 0 || tile > MAX_TILE {
|
||||
return Err(format!("tile size {tile} is outside 1..={MAX_TILE}"));
|
||||
}
|
||||
let active = Arc::new(AtomicUsize::new(0));
|
||||
let shared = Arc::new((model, device));
|
||||
for conn in listener.incoming() {
|
||||
let Ok(stream) = conn else { continue };
|
||||
let peer = stream.peer_addr().map(|a| a.to_string()).unwrap_or_else(|_| "?".into());
|
||||
if active.load(Ordering::SeqCst) >= opts.max_connections.max(1) {
|
||||
let mut s = stream;
|
||||
let _ = writeln!(s, "{}", json!({"ok": false, "error": "server busy"}));
|
||||
log(&format!("{peer}: refused (busy)"));
|
||||
continue;
|
||||
}
|
||||
active.fetch_add(1, Ordering::SeqCst);
|
||||
let (sh, r, o, l, n) = (shared.clone(), run.clone(), opts.clone(), log.clone(), active.clone());
|
||||
let spawned = std::thread::Builder::new().name(format!("denoise {peer}")).spawn(move || {
|
||||
if let Err(e) = handle(stream, &sh.0, tile, &sh.1, &*r, &o, &*l) {
|
||||
l(&format!("{peer}: {e}"));
|
||||
}
|
||||
n.fetch_sub(1, Ordering::SeqCst);
|
||||
});
|
||||
if spawned.is_err() {
|
||||
active.fetch_sub(1, Ordering::SeqCst);
|
||||
log("could not start a connection thread");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle(
|
||||
stream: TcpStream,
|
||||
model: &ModelId,
|
||||
tile: usize,
|
||||
device: &str,
|
||||
run: &dyn Fn(&[f32]) -> Result<Vec<f32>, Error>,
|
||||
opts: &ServerOptions,
|
||||
log: &dyn Fn(&str),
|
||||
) -> std::io::Result<()> {
|
||||
stream.set_read_timeout(Some(opts.timeout))?;
|
||||
stream.set_write_timeout(Some(opts.timeout))?;
|
||||
stream.set_nodelay(true)?;
|
||||
let peer = stream.peer_addr().map(|a| a.to_string()).unwrap_or_default();
|
||||
let mut r = BufReader::new(stream.try_clone()?);
|
||||
let mut w = stream;
|
||||
let mut tiles = 0usize;
|
||||
let fail = |w: &mut TcpStream, msg: &str| writeln!(w, "{}", json!({"ok": false, "error": msg}));
|
||||
while let Some(line) = read_line(&mut r)? {
|
||||
let Ok(req) = serde_json::from_str::<Value>(&line) else {
|
||||
fail(&mut w, "malformed request")?;
|
||||
break;
|
||||
};
|
||||
if req.get("v").and_then(Value::as_u64) != Some(PROTOCOL) {
|
||||
fail(&mut w, &format!("protocol version {PROTOCOL} only"))?;
|
||||
break;
|
||||
}
|
||||
if !same(req.get("token").and_then(Value::as_str).unwrap_or(""), &opts.token) {
|
||||
fail(&mut w, "bad token")?;
|
||||
log(&format!("{peer}: bad token"));
|
||||
break;
|
||||
}
|
||||
match req.get("op").and_then(Value::as_str) {
|
||||
Some("hello") => writeln!(w, "{}", json!({"ok": true, "v": PROTOCOL, "device": device, "model": model, "tile": tile}))?,
|
||||
Some("run") => {
|
||||
let (Some(t), Some(bytes)) = (field(&req, "tile"), field(&req, "bytes")) else {
|
||||
fail(&mut w, "missing tile size or payload size")?;
|
||||
break;
|
||||
};
|
||||
if t != tile {
|
||||
fail(&mut w, &format!("this server runs {tile}-cell tiles, not {t}"))?;
|
||||
break;
|
||||
}
|
||||
// the payload is read before any refusal below, so the stream stays in step
|
||||
let input = match read_payload(&mut r, bytes, 4 * t * t) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
fail(&mut w, &e.to_string())?;
|
||||
break;
|
||||
}
|
||||
};
|
||||
if req.get("model").and_then(Value::as_str) != Some(model.sha256.as_str()) {
|
||||
fail(&mut w, &format!("model mismatch: this server runs {} {}", model.id, model.version))?;
|
||||
continue;
|
||||
}
|
||||
match run(&input) {
|
||||
Ok(out) if out.len() == 3 * 4 * t * t && out.iter().all(|v| v.is_finite()) => {
|
||||
write_payload(&mut w, json!({"ok": true, "tile": t}), &out)?;
|
||||
tiles += 1;
|
||||
}
|
||||
Ok(_) => fail(&mut w, "the model's answer has the wrong size or numbers that are not finite")?,
|
||||
Err(e) => fail(&mut w, &e.to_string())?,
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
fail(&mut w, "unknown op")?;
|
||||
break;
|
||||
}
|
||||
}
|
||||
w.flush()?;
|
||||
}
|
||||
if tiles > 0 {
|
||||
log(&format!("{peer}: {tiles} tiles"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What a server said in its greeting.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
pub struct ServerInfo {
|
||||
pub address: String,
|
||||
pub device: String,
|
||||
pub model: ModelId,
|
||||
pub tile: usize,
|
||||
}
|
||||
|
||||
/// Tiles the server may get wrong (an error, a broken connection) before it stops being used.
|
||||
pub const MAX_FAILURES: usize = 3;
|
||||
|
||||
/// A server as a [`TileRunner`], for one model: keeps up to `slots` connections open and gives up on the server after
|
||||
/// [`MAX_FAILURES`] failed tiles (the caller runs those tiles itself).
|
||||
pub struct Remote {
|
||||
address: String,
|
||||
token: String,
|
||||
model_sha256: String,
|
||||
tile: usize,
|
||||
slots: usize,
|
||||
timeout: Duration,
|
||||
busy: AtomicUsize,
|
||||
failures: AtomicUsize,
|
||||
last_error: Mutex<Option<String>>,
|
||||
pool: Mutex<Vec<(BufReader<TcpStream>, TcpStream)>>,
|
||||
}
|
||||
|
||||
impl Remote {
|
||||
/// `address` as `host[:port]` (default port [`DEFAULT_PORT`]; `tcp://` and a trailing `/` are ignored).
|
||||
pub fn new(address: &str, token: &str, model_sha256: &str, tile: usize) -> Remote {
|
||||
let a = address.trim().trim_start_matches("tcp://").trim_end_matches('/');
|
||||
let has_port = a.rsplit_once(':').is_some_and(|(h, p)| !h.is_empty() && !h.ends_with(':') && p.parse::<u16>().is_ok()) && !a.ends_with(']');
|
||||
let address = if has_port { a.to_string() } else { format!("{a}:{DEFAULT_PORT}") };
|
||||
Remote {
|
||||
address,
|
||||
token: token.into(),
|
||||
model_sha256: model_sha256.to_ascii_lowercase(),
|
||||
tile,
|
||||
slots: 4,
|
||||
timeout: Duration::from_secs(60),
|
||||
busy: AtomicUsize::new(0),
|
||||
failures: AtomicUsize::new(0),
|
||||
last_error: Mutex::new(None),
|
||||
pool: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_slots(mut self, n: usize) -> Remote {
|
||||
self.slots = n.clamp(1, 16);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn address(&self) -> &str {
|
||||
&self.address
|
||||
}
|
||||
|
||||
/// Tiles it can take at once.
|
||||
pub fn slots(&self) -> usize {
|
||||
self.slots
|
||||
}
|
||||
|
||||
/// Still used (fewer than [`MAX_FAILURES`] failed tiles).
|
||||
pub fn healthy(&self) -> bool {
|
||||
self.failures.load(Ordering::Relaxed) < MAX_FAILURES
|
||||
}
|
||||
|
||||
pub fn failures(&self) -> usize {
|
||||
self.failures.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Why the last tile failed.
|
||||
pub fn last_error(&self) -> Option<String> {
|
||||
self.last_error.lock().unwrap_or_else(PoisonError::into_inner).clone()
|
||||
}
|
||||
|
||||
fn err(&self, reason: impl std::fmt::Display) -> Error {
|
||||
Error::Runtime(format!("remote {}: {reason}", self.address))
|
||||
}
|
||||
|
||||
fn connect(&self) -> Result<(BufReader<TcpStream>, TcpStream), Error> {
|
||||
let addr = self.address.to_socket_addrs().map_err(|e| self.err(e))?.next().ok_or_else(|| self.err("the address doesn't resolve"))?;
|
||||
let s = TcpStream::connect_timeout(&addr, Duration::from_secs(5)).map_err(|e| self.err(e))?;
|
||||
s.set_read_timeout(Some(self.timeout)).map_err(|e| self.err(e))?;
|
||||
s.set_write_timeout(Some(self.timeout)).map_err(|e| self.err(e))?;
|
||||
s.set_nodelay(true).map_err(|e| self.err(e))?;
|
||||
let r = BufReader::new(s.try_clone().map_err(|e| self.err(e))?);
|
||||
Ok((r, s))
|
||||
}
|
||||
|
||||
fn reply(&self, r: &mut BufReader<TcpStream>) -> Result<Value, Error> {
|
||||
let line = read_line(r).map_err(|e| self.err(e))?.ok_or_else(|| self.err("the connection closed"))?;
|
||||
let v: Value = serde_json::from_str(&line).map_err(|_| self.err("malformed reply"))?;
|
||||
if v.get("ok").and_then(Value::as_bool) != Some(true) {
|
||||
return Err(self.err(v.get("error").and_then(Value::as_str).unwrap_or("request failed")));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// Greet the server: its device and model. Fails when it runs another model or tile size than this one.
|
||||
pub fn hello(&self) -> Result<ServerInfo, Error> {
|
||||
let (mut r, mut s) = self.connect()?;
|
||||
writeln!(s, "{}", json!({"v": PROTOCOL, "token": self.token, "op": "hello"})).map_err(|e| self.err(e))?;
|
||||
let v = self.reply(&mut r)?;
|
||||
let m = v.get("model").cloned().unwrap_or_default();
|
||||
let text = |o: &Value, k: &str| o.get(k).and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let info = ServerInfo {
|
||||
address: self.address.clone(),
|
||||
device: text(&v, "device"),
|
||||
model: ModelId { id: text(&m, "id"), version: text(&m, "version"), sha256: text(&m, "sha256") },
|
||||
tile: field(&v, "tile").unwrap_or(0),
|
||||
};
|
||||
if !info.model.sha256.eq_ignore_ascii_case(&self.model_sha256) {
|
||||
return Err(self.err(format!("it runs another model ({} {})", info.model.id, info.model.version)));
|
||||
}
|
||||
if info.tile != self.tile {
|
||||
return Err(self.err(format!("it runs {}-cell tiles, this model {}", info.tile, self.tile)));
|
||||
}
|
||||
self.pool.lock().unwrap_or_else(PoisonError::into_inner).push((r, s));
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// Run a tile if a slot is free right now; `None` when all are busy or the server was given up (the caller runs
|
||||
/// the tile elsewhere). A failure counts towards giving up on the server.
|
||||
pub fn try_run(&self, input: &[f32]) -> Option<Result<Vec<f32>, Error>> {
|
||||
if !self.healthy() {
|
||||
return None;
|
||||
}
|
||||
if self.busy.fetch_add(1, Ordering::SeqCst) >= self.slots {
|
||||
self.busy.fetch_sub(1, Ordering::SeqCst);
|
||||
return None;
|
||||
}
|
||||
let r = self.run_now(input);
|
||||
self.busy.fetch_sub(1, Ordering::SeqCst);
|
||||
if let Err(e) = &r {
|
||||
self.failures.fetch_add(1, Ordering::Relaxed);
|
||||
*self.last_error.lock().unwrap_or_else(PoisonError::into_inner) = Some(e.to_string());
|
||||
}
|
||||
Some(r)
|
||||
}
|
||||
|
||||
fn run_now(&self, input: &[f32]) -> Result<Vec<f32>, Error> {
|
||||
let t = self.tile;
|
||||
if input.len() != 4 * t * t {
|
||||
return Err(Error::Input(format!("a tile of {} samples for {t}-cell tiles", input.len())));
|
||||
}
|
||||
let pooled = self.pool.lock().unwrap_or_else(PoisonError::into_inner).pop();
|
||||
let (mut r, mut s) = match pooled {
|
||||
Some(c) => c,
|
||||
None => self.connect()?,
|
||||
};
|
||||
let req = json!({"v": PROTOCOL, "token": self.token, "op": "run", "model": self.model_sha256, "tile": t});
|
||||
write_payload(&mut s, req, input).map_err(|e| self.err(e))?;
|
||||
s.flush().map_err(|e| self.err(e))?;
|
||||
let v = self.reply(&mut r)?;
|
||||
if field(&v, "tile") != Some(t) {
|
||||
return Err(self.err("the answer is for another tile size"));
|
||||
}
|
||||
let bytes = field(&v, "bytes").ok_or_else(|| self.err("an answer without its size"))?;
|
||||
let out = read_payload(&mut r, bytes, 3 * 4 * t * t).map_err(|e| self.err(e))?;
|
||||
self.pool.lock().unwrap_or_else(PoisonError::into_inner).push((r, s));
|
||||
// the other computer's numbers go straight into the render: NaN or infinity (an f16 can carry
|
||||
// both) is a failed tile, which `Shared` runs here instead
|
||||
if !out.iter().all(|v| v.is_finite()) {
|
||||
return Err(self.err("the answer holds values that aren't finite"));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Tiles on the server when one of its slots is free, else (and for any tile it fails) on `local`: the server adds
|
||||
/// speed to this computer's runner instead of replacing it.
|
||||
pub struct Shared<'a> {
|
||||
pub remote: &'a Remote,
|
||||
pub local: &'a dyn TileRunner,
|
||||
}
|
||||
|
||||
impl TileRunner for Shared<'_> {
|
||||
fn run(&self, input: &[f32]) -> Result<Vec<f32>, Error> {
|
||||
match self.remote.try_run(input) {
|
||||
Some(Ok(out)) => Ok(out),
|
||||
Some(Err(_)) | None => self.local.run(input),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const TOKEN: &str = "0123456789abcdef-test";
|
||||
const T: usize = 8;
|
||||
|
||||
/// A stand-in model: each output pixel is its cell's first plane, times `k`.
|
||||
fn model(k: f32) -> RunTile {
|
||||
Arc::new(move |input: &[f32]| {
|
||||
let t = T;
|
||||
let mut out = vec![0f32; 3 * 4 * t * t];
|
||||
for c in 0..3 {
|
||||
for y in 0..2 * t {
|
||||
for x in 0..2 * t {
|
||||
out[(c * 2 * t + y) * 2 * t + x] = input[(y / 2) * t + x / 2] * k;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
})
|
||||
}
|
||||
|
||||
fn id(sha: &str) -> ModelId {
|
||||
ModelId { id: "test".into(), version: "1".into(), sha256: sha.into() }
|
||||
}
|
||||
|
||||
fn start(sha: &str, run: RunTile) -> String {
|
||||
let l = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = l.local_addr().unwrap().to_string();
|
||||
let opts = ServerOptions { token: TOKEN.into(), ..Default::default() };
|
||||
let m = id(sha);
|
||||
std::thread::spawn(move || serve(l, m, T, "CPU (test)".into(), run, opts, Arc::new(|_| {})));
|
||||
addr
|
||||
}
|
||||
|
||||
struct Local(RunTile, AtomicUsize);
|
||||
impl TileRunner for Local {
|
||||
fn run(&self, input: &[f32]) -> Result<Vec<f32>, Error> {
|
||||
self.1.fetch_add(1, Ordering::SeqCst);
|
||||
(self.0)(input)
|
||||
}
|
||||
}
|
||||
|
||||
fn tile_input(seed: f32) -> Vec<f32> {
|
||||
(0..4 * T * T).map(|i| (i as f32 * 0.37 + seed).sin() * 0.5 + 0.5).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_tiles_match_local_ones_within_half_float_rounding() {
|
||||
let addr = start("aa11", model(2.0));
|
||||
let remote = Remote::new(&addr, TOKEN, "AA11", T);
|
||||
let info = remote.hello().unwrap();
|
||||
assert_eq!((info.device.as_str(), info.tile), ("CPU (test)", T));
|
||||
let input = tile_input(0.3);
|
||||
let got = remote.try_run(&input).unwrap().unwrap();
|
||||
let want = model(2.0)(&input).unwrap();
|
||||
assert!(got.iter().zip(&want).all(|(a, b)| (a - b).abs() < 2e-3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_runner_falls_back_and_gives_up_on_a_failing_server() {
|
||||
let local = Local(model(2.0), AtomicUsize::new(0));
|
||||
// nobody listening: every tile runs locally, and after a few the server is given up
|
||||
let dead = Remote::new("127.0.0.1:1", TOKEN, "aa11", T);
|
||||
let shared = Shared { remote: &dead, local: &local };
|
||||
for i in 0..6 {
|
||||
assert!(shared.run(&tile_input(i as f32)).is_ok());
|
||||
}
|
||||
assert_eq!(local.1.load(Ordering::SeqCst), 6);
|
||||
assert!(!dead.healthy() && dead.last_error().is_some());
|
||||
// a server with another model is refused at the greeting and per tile
|
||||
let addr = start("bb22", model(2.0));
|
||||
let other = Remote::new(&addr, TOKEN, "aa11", T);
|
||||
assert!(other.hello().unwrap_err().to_string().contains("another model"));
|
||||
assert!(other.try_run(&tile_input(0.0)).unwrap().unwrap_err().to_string().contains("model mismatch"));
|
||||
// a wrong token
|
||||
let addr = start("aa11", model(2.0));
|
||||
assert!(Remote::new(&addr, "wrong-token-0000000", "aa11", T).hello().unwrap_err().to_string().contains("bad token"));
|
||||
// all slots busy: the tile runs locally without waiting
|
||||
let busy = Remote::new(&addr, TOKEN, "aa11", T).with_slots(1);
|
||||
busy.busy.store(1, Ordering::SeqCst);
|
||||
assert!(busy.try_run(&tile_input(0.0)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_requests_are_refused_and_the_server_keeps_working() {
|
||||
let addr = start("aa11", model(1.0));
|
||||
let talk = |bytes: &[u8]| -> String {
|
||||
let mut s = TcpStream::connect(&addr).unwrap();
|
||||
s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
s.write_all(bytes).unwrap();
|
||||
let mut line = String::new();
|
||||
let _ = BufReader::new(s).read_line(&mut line);
|
||||
line
|
||||
};
|
||||
assert!(talk(b"not json\n").contains("malformed"));
|
||||
let v2 = format!("{}\n", json!({"v": 2, "token": TOKEN, "op": "hello"}));
|
||||
assert!(talk(v2.as_bytes()).contains("protocol"));
|
||||
let big = format!("{}\n", json!({"v": 1, "token": TOKEN, "op": "run", "model": "aa11", "tile": 4096, "bytes": 10}));
|
||||
assert!(talk(big.as_bytes()).contains("8-cell"));
|
||||
// a payload that inflates past its tile
|
||||
let bomb = miniz_oxide::deflate::compress_to_vec(&vec![0u8; 1 << 20], 9);
|
||||
let hdr = format!("{}\n", json!({"v": 1, "token": TOKEN, "op": "run", "model": "aa11", "tile": T, "bytes": bomb.len()}));
|
||||
assert!(talk(&[hdr.as_bytes(), &bomb].concat()).contains("false"));
|
||||
assert!(Remote::new(&addr, TOKEN, "aa11", T).hello().is_ok());
|
||||
assert!(same("abc", "abc") && !same("abc", "abd") && !same("abc", "abcd"));
|
||||
// a short token is refused when the server starts
|
||||
let l = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let opts = ServerOptions { token: "short".into(), ..Default::default() };
|
||||
assert!(serve(l, id("aa11"), T, "x".into(), model(1.0), opts, Arc::new(|_| {})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packing_round_trips_and_addresses_are_normalised() {
|
||||
let v: Vec<f32> = (0..4096).map(|i| 0.5 + ((i * 7919 % 101) as f32 - 50.0) * 0.002).collect();
|
||||
let back = unpack(&pack(&v), v.len()).unwrap();
|
||||
assert!(v.iter().zip(&back).all(|(a, b)| (a - b).abs() < 1e-3));
|
||||
assert!(unpack(&pack(&v), v.len() + 1).is_err());
|
||||
assert_eq!(Remote::new("tcp://100.1.2.3/", TOKEN, "a", T).address(), "100.1.2.3:7990");
|
||||
assert_eq!(Remote::new("box:8000", TOKEN, "a", T).address(), "box:8000");
|
||||
assert_eq!(Remote::new("[::1]", TOKEN, "a", T).address(), "[::1]:7990");
|
||||
}
|
||||
}
|
||||
@@ -38,11 +38,21 @@ impl DevelopSettings {
|
||||
serde_json::from_value(v.clone())
|
||||
}
|
||||
|
||||
/// Merge a partial JSON object into these settings (fields not mentioned are kept).
|
||||
/// Merge a partial JSON object into these settings (fields not mentioned are kept). A white balance that
|
||||
/// names no [`WbScale`] keeps the meaning it always had (`Legacy`), whatever scale these settings were on: a
|
||||
/// partial from before the scale existed, or one that never said, must not become Kelvin by landing on
|
||||
/// Kelvin settings.
|
||||
pub fn merged(&self, partial: &Value) -> Result<DevelopSettings, serde_json::Error> {
|
||||
let mut v = self.to_json();
|
||||
presets::deep_merge(&mut v, partial);
|
||||
DevelopSettings::from_json(&v)
|
||||
let mut out = DevelopSettings::from_json(&v)?;
|
||||
if let Some(wb) = partial.get("wb").and_then(Value::as_object)
|
||||
&& (wb.contains_key("temp") || wb.contains_key("tint"))
|
||||
&& !wb.contains_key("scale")
|
||||
{
|
||||
out.wb.scale = WbScale::Legacy;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Stable 64-bit hash of the settings (FNV-1a over canonical JSON), for preview cache keys.
|
||||
@@ -167,6 +177,47 @@ mod tests {
|
||||
assert_ne!(back.hash64(), DevelopSettings::default().hash64());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn white_balance_scale_reads_absent_as_legacy_and_writes_only_kelvin() {
|
||||
// settings from before the scale existed: no field, `Legacy`, and written back without it
|
||||
let old = json!({"wb": {"mode": "custom", "temp": 5800.0, "tint": 3.0}});
|
||||
let s = DevelopSettings::from_json(&old).unwrap();
|
||||
assert_eq!(s.wb.scale, WbScale::Legacy);
|
||||
assert_eq!(s.to_json()["wb"], old["wb"]);
|
||||
assert_eq!(DevelopSettings::default().wb.scale, WbScale::Legacy);
|
||||
assert!(DevelopSettings::default().to_json()["wb"].get("scale").is_none());
|
||||
// written on the Kelvin scale: said so, and read back
|
||||
let mut k = s.clone();
|
||||
k.wb.scale = WbScale::Kelvin;
|
||||
assert_eq!(k.to_json()["wb"]["scale"], json!("kelvin"));
|
||||
assert_eq!(DevelopSettings::from_json(&k.to_json()).unwrap(), k);
|
||||
assert_ne!(k.hash64(), s.hash64());
|
||||
// an unknown scale from a newer build is an error, not a panic
|
||||
assert!(DevelopSettings::from_json(&json!({"wb": {"scale": "mired"}})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_partial_white_balance_without_a_scale_is_legacy() {
|
||||
let mut base = DevelopSettings::default();
|
||||
base.wb.scale = WbScale::Kelvin;
|
||||
// an old preset's white balance lands on Kelvin settings: it keeps its relative meaning
|
||||
let out = apply_partial(&base, &json!({"wb": {"mode": "custom", "temp": 5800.0}}), 1.0);
|
||||
assert_eq!((out.wb.scale, out.wb.temp), (WbScale::Legacy, 5800.0));
|
||||
let out = apply_partial(&base, &json!({"wb": {"tint": 12.0}}), 1.0);
|
||||
assert_eq!(out.wb.scale, WbScale::Legacy);
|
||||
// one that says Kelvin stays Kelvin; a mode alone leaves the scale as it was
|
||||
let out = apply_partial(&base, &json!({"wb": {"mode": "custom", "temp": 5800.0, "scale": "kelvin"}}), 1.0);
|
||||
assert_eq!(out.wb.scale, WbScale::Kelvin);
|
||||
let out = apply_partial(&base, &json!({"wb": {"mode": "cloudy"}}), 1.0);
|
||||
assert_eq!(out.wb.scale, WbScale::Kelvin);
|
||||
let out = apply_partial(&base, &json!({"light": {"exposure": 1.0}}), 1.0);
|
||||
assert_eq!(out.wb.scale, WbScale::Kelvin);
|
||||
// the plain merge (`develop.merge`) follows the same rule
|
||||
assert_eq!(base.merged(&json!({"wb": {"temp": 5800.0}})).unwrap().wb.scale, WbScale::Legacy);
|
||||
assert_eq!(base.merged(&json!({"wb": {"temp": 5800.0, "scale": "kelvin"}})).unwrap().wb.scale, WbScale::Kelvin);
|
||||
assert_eq!(base.merged(&json!({"wb": {"mode": "auto"}})).unwrap().wb.scale, WbScale::Kelvin);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_hdr_is_not_serialized_so_existing_hashes_stay() {
|
||||
let s = DevelopSettings::default();
|
||||
|
||||
@@ -187,6 +187,7 @@ fn scale_patch(base: &Value, patch: &Value, t: f64) -> Value {
|
||||
pub fn apply_partial(s: &DevelopSettings, partial: &Value, amount: f64) -> DevelopSettings {
|
||||
let base = s.to_json();
|
||||
let patch = if (amount - 1.0).abs() < 1e-9 { partial.clone() } else { scale_patch(&base, partial, amount) };
|
||||
// (a white balance without a scale keeps its old meaning: `DevelopSettings::merged`)
|
||||
let mut out = s.merged(&patch).unwrap_or_else(|_| s.clone());
|
||||
for c in crate::controls::CONTROLS {
|
||||
if let Some(v) = crate::controls::get(&out, c.id) {
|
||||
|
||||
@@ -81,16 +81,20 @@ impl ProcessVersion {
|
||||
pub enum Process {
|
||||
/// The rendering of LightCraft before process versions existed (2026-10).
|
||||
V1,
|
||||
/// Highlights and Shadows keep the local contrast of the tones they move: their edge-aware base
|
||||
/// is taken of a fine median of log luminance (issue #632).
|
||||
V2,
|
||||
}
|
||||
|
||||
impl Process {
|
||||
/// Every process, oldest first.
|
||||
pub const ALL: [Process; 1] = [Process::V1];
|
||||
pub const LATEST: Process = Process::V1;
|
||||
pub const ALL: [Process; 2] = [Process::V1, Process::V2];
|
||||
pub const LATEST: Process = Process::V2;
|
||||
|
||||
pub const fn version(self) -> ProcessVersion {
|
||||
match self {
|
||||
Process::V1 => ProcessVersion(1),
|
||||
Process::V2 => ProcessVersion(2),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -242,7 +246,13 @@ impl WbMode {
|
||||
WbMode::Custom => "Custom",
|
||||
}
|
||||
}
|
||||
/// Preset temperature/tint for raw files (K, tint).
|
||||
/// Preset temperature/tint for raw files with a Kelvin scale (K, tint): the CIE daylight series D55 / D65 /
|
||||
/// D75 (5503, 6504, 7504 K; CIE 15:2004) for Daylight / Cloudy / Shade, CIE Illuminant A (2856 K) for
|
||||
/// Tungsten, a typical cool-white fluorescent (CIE F2, 4230 K, is the lamp; 3800 K with a magenta correction
|
||||
/// is what a camera's preset neutralises) and a flash at daylight; the tints are the small magenta
|
||||
/// corrections daylight-series whites need above the Planckian locus, as Lightroom's presets of the same
|
||||
/// names show them in its panel (observed values, no Adobe data). On the relative scale (rendered files, raws
|
||||
/// without matrices) `develop.wb` scales them around 6500 K = as shot.
|
||||
pub fn preset(self) -> Option<(f64, f64)> {
|
||||
match self {
|
||||
WbMode::Daylight => Some((5500.0, 10.0)),
|
||||
@@ -256,9 +266,35 @@ impl WbMode {
|
||||
}
|
||||
}
|
||||
|
||||
/// White balance. For raw files `temp` is the scene illuminant in Kelvin; for rendered files
|
||||
/// (JPEG etc.) the image is assumed D65-balanced and `temp`/`tint` are the same model with an
|
||||
/// "as shot" of 6500 K / 0 (the UI shows a relative −100..100 scale for those, like Lightroom).
|
||||
/// Which scale a [`WhiteBalance`]'s `temp` / `tint` were written on.
|
||||
///
|
||||
/// Raws whose camera has colour matrices (in the file, or measured for the model) edit white balance in Kelvin;
|
||||
/// before that, raws without matrices in the file were all edited relative to their as-shot look, where 6500 K / 0
|
||||
/// means *as shot*. Settings written then carry no scale and keep that meaning (`Legacy`): the pipeline turns them
|
||||
/// into the same colour change on the Kelvin scale (`lightcraft_pipeline::local::legacy_to_kelvin`), so no photo's
|
||||
/// look changes. Settings written on the Kelvin scale say so (`Kelvin`). The scale is irrelevant on files the
|
||||
/// Kelvin scale does not reach (rendered photos, raws without matrices): their values keep the relative meaning.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum WbScale {
|
||||
/// Written before the scale existed, or by a preset / partial JSON without one: relative (6500 K / 0 = as shot)
|
||||
/// on a raw developed from a fitted look, Kelvin on a raw whose file carries matrices. Not written.
|
||||
#[default]
|
||||
Legacy,
|
||||
/// Absolute Kelvin and tint, as Lightroom writes them for raws.
|
||||
Kelvin,
|
||||
}
|
||||
|
||||
impl WbScale {
|
||||
fn is_legacy(&self) -> bool {
|
||||
*self == WbScale::Legacy
|
||||
}
|
||||
}
|
||||
|
||||
/// White balance. For raw files with colour matrices `temp` is the scene illuminant in Kelvin (`scale` says the
|
||||
/// values were written on that scale); for rendered files (JPEG etc.) and raws without matrices the image is
|
||||
/// assumed D65-balanced and `temp`/`tint` are the same model with an "as shot" of 6500 K / 0 (the UI shows a
|
||||
/// relative −100..100 scale for those, like Lightroom).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct WhiteBalance {
|
||||
@@ -266,11 +302,17 @@ pub struct WhiteBalance {
|
||||
pub temp: f64,
|
||||
/// Correction direction: negative adds green, positive adds magenta.
|
||||
pub tint: f64,
|
||||
/// The scale `temp` / `tint` were written on; left out of the JSON when [`WbScale::Legacy`], so settings
|
||||
/// from before it existed read and write unchanged.
|
||||
#[serde(skip_serializing_if = "WbScale::is_legacy")]
|
||||
pub scale: WbScale,
|
||||
}
|
||||
|
||||
impl Default for WhiteBalance {
|
||||
fn default() -> Self {
|
||||
Self { mode: WbMode::AsShot, temp: 6500.0, tint: 0.0 }
|
||||
// (`Legacy`, like an absent field: a default photo's settings JSON stays what it was; the engine marks the
|
||||
// scale when white balance is written on a raw that has one, `lightcraft_pipeline::local::normalize_wb`)
|
||||
Self { mode: WbMode::AsShot, temp: 6500.0, tint: 0.0, scale: WbScale::Legacy }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -656,6 +698,33 @@ impl Default for Optics {
|
||||
pub struct EmbeddedLens {
|
||||
pub warp: Option<EmbeddedWarp>,
|
||||
pub vignette: Option<EmbeddedVignette>,
|
||||
/// A DNG 1.6 `WarpRectilinear2` chain (up to [`WARP_STAGES`] stages, in `OpcodeList3` order; each stage warps
|
||||
/// the result of the one before it). When any is set, `warp` is `None`: the chain holds every warp opcode.
|
||||
pub stages: [Option<EmbeddedWarpStage>; WARP_STAGES],
|
||||
}
|
||||
|
||||
/// Most warp stages an [`EmbeddedLens`] chain keeps.
|
||||
pub const WARP_STAGES: usize = 3;
|
||||
|
||||
/// One DNG `WarpRectilinear2` (or a `WarpRectilinear` in a chain with one): per plane (R, G, B)
|
||||
/// `[kr0 .. kr14, kt0, kt1, min_valid_radius, max_valid_radius]`. For an output point at offset
|
||||
/// `d = (p − center) / radius` with `r = |d|` and `f = Σ krₚ·clamp(r, min, max)ᵖ`, the source point is
|
||||
/// `center + radius · (d·f + tangential(d))`, or `d / f` for the radial part when `reciprocal`.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct EmbeddedWarpStage {
|
||||
pub planes: [[f64; 19]; 3],
|
||||
/// Optical centre, normalized to the oriented image (0..1).
|
||||
pub center: Point,
|
||||
/// Normalisation radius as a fraction of the oriented image's long edge.
|
||||
pub radius: f64,
|
||||
pub reciprocal: bool,
|
||||
}
|
||||
|
||||
impl EmbeddedLens {
|
||||
/// Whether a `WarpRectilinear2` chain is set.
|
||||
pub fn has_stages(&self) -> bool {
|
||||
self.stages.iter().any(Option::is_some)
|
||||
}
|
||||
}
|
||||
|
||||
/// DNG `WarpRectilinear`: per plane (R, G, B) `[kr0, kr1, kr2, kr3, kt0, kt1]`. For an output (corrected) point at
|
||||
@@ -963,6 +1032,8 @@ pub enum SpotMode {
|
||||
Remove,
|
||||
Heal,
|
||||
Clone,
|
||||
/// Content-aware: the spot is rebuilt from patches of its surroundings (no single source).
|
||||
Fill,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
@@ -24,9 +24,11 @@ lightcraft-raw = { workspace = true }
|
||||
lightcraft-denoise-core = { workspace = true }
|
||||
lightcraft-denoise = { workspace = true, optional = true }
|
||||
lightcraft-meta = { workspace = true }
|
||||
lightcraft-lensdb = { workspace = true }
|
||||
lightcraft-faces = { workspace = true }
|
||||
miniz_oxide = { workspace = true }
|
||||
lightcraft-merge = { workspace = true }
|
||||
lightcraft-geocode = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -49,6 +51,8 @@ denoise = ["dep:lightcraft-denoise", "lightcraft-denoise/runtime", "lightcraft-g
|
||||
rawnind-model = ["denoise", "lightcraft-denoise/rawnind-model", "lightcraft-denoise-core/rawnind-model"]
|
||||
|
||||
[dev-dependencies]
|
||||
# HDR JPEG XL tests read the file's colour encoding
|
||||
jxl-oxide = { workspace = true }
|
||||
# Lossless synthetic HEIC files (the import test; decoded when the codecs' `heif` feature is on).
|
||||
lightcraft-heif = { workspace = true, features = ["testdata"] }
|
||||
libc = { workspace = true }
|
||||
|
||||
@@ -73,7 +73,7 @@ impl Unit {
|
||||
pub struct TaskInfo {
|
||||
pub id: u64,
|
||||
/// A stable id: `import`, `export`, `scan`, `previews`, `smartPreviews`, `lightroom`, `merge`, `download`,
|
||||
/// `faces`, `denoise`, `findMissing`.
|
||||
/// `faces`, `denoise`, `findMissing`, `applePhotos`.
|
||||
pub kind: &'static str,
|
||||
/// English; the frontend translates it.
|
||||
pub label: String,
|
||||
@@ -100,6 +100,12 @@ struct Entry {
|
||||
detail: Mutex<String>,
|
||||
cancel: Option<Arc<AtomicBool>>,
|
||||
cancellable: AtomicBool,
|
||||
/// Held while a cancel is decided ([`Activity::cancel`], [`Activity::cancel_all`]) and while
|
||||
/// cancelling is switched ([`TaskGuard::set_cancellable`], [`TaskHandle::set_cancellable`]): a
|
||||
/// step that turns it off and then reads the cancel flag either sees a cancel that was
|
||||
/// accepted, or makes the cancel fail. Without it a cancel could pass the check, the step
|
||||
/// start, and the flag be set after the step's last look at it: accepted but ignored.
|
||||
gate: Mutex<()>,
|
||||
}
|
||||
|
||||
fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
|
||||
@@ -111,6 +117,23 @@ fn capped(text: &str) -> String {
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
fn set_cancellable(&self, yes: bool) {
|
||||
let _gate = lock(&self.gate);
|
||||
self.cancellable.store(yes, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Set the cancel flag if the task can be cancelled now (see [`Entry::gate`]).
|
||||
fn try_cancel(&self) -> bool {
|
||||
let _gate = lock(&self.gate);
|
||||
match &self.cancel {
|
||||
Some(flag) if self.is_cancellable() => {
|
||||
flag.store(true, Ordering::Relaxed);
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_cancellable(&self) -> bool {
|
||||
self.cancel.is_some() && self.cancellable.load(Ordering::Relaxed)
|
||||
}
|
||||
@@ -165,6 +188,7 @@ impl Activity {
|
||||
detail: Mutex::new(String::new()),
|
||||
cancellable: AtomicBool::new(cancel.is_some()),
|
||||
cancel,
|
||||
gate: Mutex::new(()),
|
||||
});
|
||||
lock(&self.inner.tasks).push(entry.clone());
|
||||
TaskGuard { activity: self.clone(), entry }
|
||||
@@ -180,26 +204,13 @@ impl Activity {
|
||||
pub fn cancel(&self, id: u64) -> Result<(), String> {
|
||||
let tasks = lock(&self.inner.tasks);
|
||||
let entry = tasks.iter().find(|e| e.id == id).ok_or_else(|| format!("no task {id}"))?;
|
||||
match (&entry.cancel, entry.is_cancellable()) {
|
||||
(Some(flag), true) => {
|
||||
flag.store(true, Ordering::Relaxed);
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(format!("{} can't be cancelled", entry.label)),
|
||||
}
|
||||
if entry.try_cancel() { Ok(()) } else { Err(format!("{} can't be cancelled", entry.label)) }
|
||||
}
|
||||
|
||||
/// Ask every cancellable task to stop; returns how many were asked.
|
||||
pub fn cancel_all(&self) -> usize {
|
||||
let tasks = lock(&self.inner.tasks);
|
||||
let mut n = 0;
|
||||
for e in tasks.iter().filter(|e| e.is_cancellable()) {
|
||||
if let Some(flag) = &e.cancel {
|
||||
flag.store(true, Ordering::Relaxed);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
n
|
||||
tasks.iter().filter(|e| e.try_cancel()).count()
|
||||
}
|
||||
|
||||
/// Tasks that quitting would cut short: cancellable ones not already stopping.
|
||||
@@ -235,7 +246,7 @@ impl TaskGuard {
|
||||
|
||||
/// A step that must not be cut short (the Lightroom import's commit) turns cancelling off for its duration.
|
||||
pub fn set_cancellable(&self, yes: bool) {
|
||||
self.entry.cancellable.store(yes, Ordering::Relaxed);
|
||||
self.entry.set_cancellable(yes);
|
||||
}
|
||||
|
||||
pub fn cancel_flag(&self) -> Option<Arc<AtomicBool>> {
|
||||
@@ -283,6 +294,13 @@ impl TaskHandle {
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.entry.is_cancelled()
|
||||
}
|
||||
|
||||
/// [`TaskGuard::set_cancellable`] from the worker, at the moment its step starts (an export
|
||||
/// handing its files to Apple Photos, which can't be stopped once asked). Read the cancel flag
|
||||
/// after turning it off: a cancel accepted before is seen then, any later one is refused.
|
||||
pub fn set_cancellable(&self, yes: bool) {
|
||||
self.entry.set_cancellable(yes);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -380,6 +398,35 @@ mod tests {
|
||||
assert_eq!(a.list().len(), 1);
|
||||
}
|
||||
|
||||
/// Codex review of #643: a worker that turns cancelling off and then reads the cancel flag
|
||||
/// (an export handing its files to Apple Photos) must either see a cancel that was accepted or
|
||||
/// make it fail, never let one be accepted and then ignored. Here the test holds the task's gate
|
||||
/// as a cancel does while it decides: the worker's switch waits for it, and then sees the cancel.
|
||||
#[test]
|
||||
fn a_step_that_turns_cancelling_off_sees_or_refuses_every_cancel() {
|
||||
let a = Activity::default();
|
||||
let flag = Arc::new(AtomicBool::new(false));
|
||||
let g = a.start("export", "Exporting", Cancel::Flag(flag.clone()));
|
||||
let handle = g.handle();
|
||||
let gate = lock(&g.entry.gate); // a cancel deciding
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let (h, f) = (handle.clone(), flag.clone());
|
||||
let worker = std::thread::spawn(move || {
|
||||
h.set_cancellable(false);
|
||||
tx.send(f.load(Ordering::Relaxed)).unwrap();
|
||||
});
|
||||
assert!(rx.recv_timeout(std::time::Duration::from_millis(100)).is_err(), "the switch waits for the cancel");
|
||||
flag.store(true, Ordering::Relaxed); // the cancel goes through...
|
||||
drop(gate);
|
||||
assert!(rx.recv_timeout(std::time::Duration::from_secs(10)).unwrap(), "...and the step sees it");
|
||||
worker.join().unwrap();
|
||||
// once it is off, cancels are refused
|
||||
let g2 = a.start("export", "Exporting", Cancel::Yes);
|
||||
g2.handle().set_cancellable(false);
|
||||
assert!(a.cancel(g2.id()).is_err());
|
||||
assert_eq!(a.cancel_all(), 0, "{:?}", a.list());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_updates_from_another_thread() {
|
||||
let a = Activity::default();
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -22,8 +22,10 @@ use lightcraft_raw::{RawFormat, RawImage, color::CameraTransform, profile::HsvTa
|
||||
/// that leave the fitted look alone. `RENDER_CACHE_VERSION` covers renders; this covers proxies.
|
||||
///
|
||||
/// 1: the fit as of #499's follow-up; 2: Sony DRO (tone curve lowered to Sony's curve without DRO)
|
||||
/// and the ILCE-7CR profile (#528, #583, #568, #616).
|
||||
pub const LOOK_VERSION: u32 = 2;
|
||||
/// and the ILCE-7CR profile (#528, #583, #568, #616); 3: the camera JPEG read in the colour space the raw's EXIF
|
||||
/// states, for every make (#778).
|
||||
/// 4: a tone curve from black-and-white camera JPEGs (#577).
|
||||
pub const LOOK_VERSION: u32 = 4;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct CameraLook {
|
||||
@@ -63,16 +65,20 @@ pub(crate) fn file_local_look(format: RawFormat) -> bool {
|
||||
/// file's JPEG ([`fit_preview`]: with the camera profile for its model when there is one, else with colour fitted to
|
||||
/// this photo alone); the model's colour matrices from measured spectral sensitivities (`lightcraft_raw::spectral`);
|
||||
/// the neutral fallback. The spectral matrices only stand in for the neutral fallback, so a photo whose JPEG fit is
|
||||
/// accepted renders as without them; when used they are written into `raw.color`. Returns the as-shot white, the
|
||||
/// camera transform at it and the fitted look.
|
||||
/// accepted renders as without them; when used they are written into `raw.color`. A photo whose JPEG is black and
|
||||
/// white (a monochrome picture style) has no colour to fit, but still gives its tone curve: with the camera profile's
|
||||
/// colour in [`fit_preview`], else on top of the spectral matrices or the fallback ([`monochrome_look`]). Returns the
|
||||
/// as-shot white, the camera transform at it and the fitted look.
|
||||
pub(crate) fn starting_colour(raw: &mut RawImage, bytes: &[u8]) -> (Xy, CameraTransform, Option<CameraLook>) {
|
||||
starting_colour_with(raw, |raw, t| fit_preview(raw, bytes, t))
|
||||
starting_colour_with(raw, |raw, t| fit_preview(raw, bytes, t), |raw, t| monochrome_look(raw, bytes, t))
|
||||
}
|
||||
|
||||
/// [`starting_colour`] with the JPEG fit passed in (tests pin the order with it).
|
||||
/// [`starting_colour`] with the JPEG fit (the look, and whether the JPEG is black and white) and the black-and-white
|
||||
/// JPEG's tone fit passed in (tests pin the order with them).
|
||||
fn starting_colour_with(
|
||||
raw: &mut RawImage,
|
||||
fit: impl FnOnce(&RawImage, &CameraTransform) -> Option<CameraLook>,
|
||||
fit: impl FnOnce(&RawImage, &CameraTransform) -> (Option<CameraLook>, bool),
|
||||
monochrome: impl FnOnce(&RawImage, &CameraTransform) -> Option<CameraLook>,
|
||||
) -> (Xy, CameraTransform, Option<CameraLook>) {
|
||||
let start = |raw: &RawImage| {
|
||||
let xy = lightcraft_raw::color::as_shot_white_xy(raw);
|
||||
@@ -82,30 +88,47 @@ fn starting_colour_with(
|
||||
if !t.matrix_is_fallback {
|
||||
return (xy, t, None);
|
||||
}
|
||||
if let Some(look) = fit(raw, &t) {
|
||||
let (look, black_and_white) = fit(raw, &t);
|
||||
if let Some(look) = look {
|
||||
return (xy, t, Some(look));
|
||||
}
|
||||
let Some(camera) = raw.metadata.model.as_deref().and_then(|model| lightcraft_raw::spectral::find(raw.metadata.make.as_deref(), model)) else {
|
||||
return (xy, t, None);
|
||||
let (xy, t) = match raw.metadata.model.as_deref().and_then(|model| lightcraft_raw::spectral::find(raw.metadata.make.as_deref(), model)) {
|
||||
Some(camera) => {
|
||||
camera.fill(&mut raw.color);
|
||||
if lightcraft_pipeline::profiling() {
|
||||
eprintln!("[profile] {:?} camera colour from spectral sensitivities: {} {}", raw.format, camera.make, camera.model);
|
||||
}
|
||||
start(raw)
|
||||
}
|
||||
None => (xy, t),
|
||||
};
|
||||
camera.fill(&mut raw.color);
|
||||
if lightcraft_pipeline::profiling() {
|
||||
eprintln!("[profile] {:?} camera colour from spectral sensitivities: {} {}", raw.format, camera.make, camera.model);
|
||||
}
|
||||
let (xy, t) = start(raw);
|
||||
(xy, t, None)
|
||||
// only a black-and-white JPEG: a colour one that gave no look pays nothing more
|
||||
let look = if black_and_white { monochrome(raw, &t) } else { None };
|
||||
(xy, t, look)
|
||||
}
|
||||
|
||||
pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> Option<CameraLook> {
|
||||
/// The look fitted to the file's camera JPEG, and whether that JPEG is black and white ([`monochrome`]).
|
||||
pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> (Option<CameraLook>, bool) {
|
||||
if !transform.matrix_is_fallback || !file_local_look(raw.format) {
|
||||
return None;
|
||||
return (None, false);
|
||||
}
|
||||
let (sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY)?;
|
||||
let Some((sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY) else { return (None, false) };
|
||||
let black_and_white = monochrome(&reference);
|
||||
// A camera profile pooled from many photos knows colours this photo shows too little of;
|
||||
// try its colour first and fit tone/chroma per photo (picture styles vary).
|
||||
let profile = raw.metadata.model.as_deref().and_then(crate::camera_profiles::get);
|
||||
let colour = profile.as_ref().and_then(|p| Some((p.matrix().mul(&transform.matrix.inverse()?), p.hue_sat.clone())));
|
||||
let look = fit_look(&sensor, &reference, &clipped, colour)?;
|
||||
let look = match fit_look(&sensor, &reference, &clipped, colour.clone()) {
|
||||
Some(look) => look,
|
||||
// A black-and-white JPEG has no colour to fit: the profile's colour and the JPEG's tone curve
|
||||
// (`monochrome_look` does it without a profile).
|
||||
None if colour.is_some() && black_and_white => {
|
||||
let unclipped = without_clipped(&sensor, &clipped);
|
||||
let Some(look) = fit_tone_only(unclipped.as_ref().unwrap_or(&sensor), &reference, colour) else { return (None, true) };
|
||||
look
|
||||
}
|
||||
None => return (None, black_and_white),
|
||||
};
|
||||
// Sony's Dynamic Range Optimizer (on by default) brightens the camera JPEG's darker regions,
|
||||
// not the raw: keep the colour fitted to the JPEG, take the tone curve without DRO.
|
||||
let dro = lightcraft_raw::embedded_preview_dynamic_range_optimized(bytes) == Some(true);
|
||||
@@ -120,7 +143,7 @@ pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransf
|
||||
profile.is_some()
|
||||
);
|
||||
}
|
||||
Some(look)
|
||||
(Some(look), black_and_white)
|
||||
}
|
||||
|
||||
/// Sensor level (normalised, white = 1) at or above which a raw channel counts as clipped, as in
|
||||
@@ -761,6 +784,38 @@ fn rank_correlation(pairs: &[([f64; 3], [f64; 3])]) -> f64 {
|
||||
if variance > 0.0 { covariance / variance } else { 0.0 }
|
||||
}
|
||||
|
||||
/// [`rank_correlation`] with tied luminances sharing their mean rank, and 0 when either side has no spread. Without
|
||||
/// colour to require, a JPEG may be flat (one grey for every pixel): [`rank_correlation`] ranks ties in pixel order,
|
||||
/// so a flat JPEG of a scene whose pixels are stored in brightness order looks perfectly related.
|
||||
fn rank_correlation_ties(pairs: &[([f64; 3], [f64; 3])]) -> f64 {
|
||||
let ranks = |values: Vec<f64>| {
|
||||
let mut order: Vec<usize> = (0..values.len()).collect();
|
||||
order.sort_by(|a, b| values[*a].total_cmp(&values[*b]));
|
||||
let mut ranks = vec![0.0; values.len()];
|
||||
let mut start = 0;
|
||||
while start < order.len() {
|
||||
let mut end = start + 1;
|
||||
while end < order.len() && values[order[end]] == values[order[start]] {
|
||||
end += 1;
|
||||
}
|
||||
let mean = (start + end - 1) as f64 / 2.0;
|
||||
for &i in &order[start..end] {
|
||||
ranks[i] = mean;
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
ranks
|
||||
};
|
||||
let scene = ranks(pairs.iter().map(|(x, _)| luma(*x)).collect());
|
||||
let jpeg = ranks(pairs.iter().map(|(_, y)| luma(*y)).collect());
|
||||
let mean = (pairs.len() as f64 - 1.0) / 2.0;
|
||||
let (covariance, scene_variance, jpeg_variance) = scene
|
||||
.iter()
|
||||
.zip(&jpeg)
|
||||
.fold((0.0, 0.0, 0.0), |(c, va, vb), (a, b)| (c + (a - mean) * (b - mean), va + (a - mean) * (a - mean), vb + (b - mean) * (b - mean)));
|
||||
if scene_variance > 0.0 && jpeg_variance > 0.0 { covariance / (scene_variance * jpeg_variance).sqrt() } else { 0.0 }
|
||||
}
|
||||
|
||||
/// A partial look for a photo whose full look misses the acceptance gates (lens-corrected JPEGs of
|
||||
/// compacts, dull or nearly colourless scenes): the tone curve by quantile matching, which needs
|
||||
/// no pixel-exact registration and was the largest part of the neutral fallback's error (rendered
|
||||
@@ -828,11 +883,128 @@ fn fit_partial_on(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option<f32>)
|
||||
(samples > 0 && better).then_some(look)
|
||||
}
|
||||
|
||||
/// A camera JPEG pixel's channels may differ by this much (linear) and still count as grey.
|
||||
const GREY_SPREAD: f32 = 0.002;
|
||||
|
||||
/// Whether the camera JPEG is black and white: a monochrome picture style (Canon Monochrome, Sony B/W, Nikon
|
||||
/// Monochrome), shot from a full-colour raw. All but one proxy pixel in a thousand are grey within
|
||||
/// [`GREY_SPREAD`]. A toned monochrome (sepia) has colour, so it isn't one.
|
||||
fn monochrome(reference: &Rgb32f) -> bool {
|
||||
let coloured = reference
|
||||
.data
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
let (min, max) = p.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), v| (lo.min(*v), hi.max(*v)));
|
||||
!p.iter().all(|v| v.is_finite()) || max - min > GREY_SPREAD
|
||||
})
|
||||
.count();
|
||||
reference.data.len() >= 256 && coloured.saturating_mul(1000) <= reference.data.len()
|
||||
}
|
||||
|
||||
/// The look of a raw whose camera JPEG is black and white ([`monochrome`]; issue #577) and gave no look with a camera
|
||||
/// profile's colour in [`fit_preview`] (or whose model has no profile). Such a JPEG has no colour to fit, so the photo
|
||||
/// would start from the spectral matrices or the neutral fallback with the default tone curve, about 1.3 EV darker
|
||||
/// and flatter than the same body's photos shot in colour. But the JPEG still shows the camera's tone curve.
|
||||
/// `transform` is the colour the photo gets otherwise (the spectral matrices or the fallback's); the look keeps it and
|
||||
/// adds the tone curve alone ([`fit_tone_only`]), as [`fit_look`] tries the photo's own colour after a profile's. The
|
||||
/// chroma curve stays neutral: the JPEG has no colourfulness to learn.
|
||||
fn monochrome_look(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> Option<CameraLook> {
|
||||
if !file_local_look(raw.format) {
|
||||
return None;
|
||||
}
|
||||
let (sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY)?;
|
||||
if !monochrome(&reference) {
|
||||
return None;
|
||||
}
|
||||
let unclipped = without_clipped(&sensor, &clipped);
|
||||
let look = fit_tone_only(unclipped.as_ref().unwrap_or(&sensor), &reference, None)?;
|
||||
// Sony's Dynamic Range Optimizer brightens a B/W JPEG as much as a colour one
|
||||
let dro = lightcraft_raw::embedded_preview_dynamic_range_optimized(bytes) == Some(true);
|
||||
let look = if dro { with_dro_off_tone(look) } else { look };
|
||||
if lightcraft_pipeline::profiling() {
|
||||
eprintln!(
|
||||
"[profile] {:?} camera look from a black-and-white JPEG: tone {:?}, {} colour, DRO tone replaced {dro}",
|
||||
raw.format,
|
||||
look.tone,
|
||||
if transform.matrix_is_fallback { "fallback" } else { "spectral" }
|
||||
);
|
||||
}
|
||||
Some(look)
|
||||
}
|
||||
|
||||
/// The tone curve of a black-and-white camera JPEG, on top of `colour` (a camera profile's, in the sensor proxy's
|
||||
/// space) or, without one, of the sensor proxy's own colour. Fitted like [`fit_partial`]'s, by quantile matching of
|
||||
/// the luminances, on all pixels and then away from edges, and used only when the JPEG shows the same picture
|
||||
/// ([`PARTIAL_MIN_RANK_CORRELATION`]) and the look cuts the held-out squared luminance error to below
|
||||
/// [`MIN_IMPROVEMENT`] of the default curve's, in linear and in gamma-encoded display values.
|
||||
fn fit_tone_only(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option<HsvTable>)>) -> Option<CameraLook> {
|
||||
[None, Some(EDGE_CONTRAST)].into_iter().find_map(|edge_limit| fit_tone_only_on(sensor, reference, &colour, edge_limit))
|
||||
}
|
||||
|
||||
fn fit_tone_only_on(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3, Option<HsvTable>)>, edge_limit: Option<f32>) -> Option<CameraLook> {
|
||||
// every pair counts: the JPEG has no colour to require
|
||||
let (pairs, _) = collect_pairs(sensor, reference, f32::NEG_INFINITY, edge_limit)?;
|
||||
let related = rank_correlation_ties(&pairs);
|
||||
if related < PARTIAL_MIN_RANK_CORRELATION {
|
||||
if lightcraft_pipeline::profiling() {
|
||||
eprintln!("[profile] camera look from a black-and-white JPEG: luminance rank correlation {related:.3}, not the same picture");
|
||||
}
|
||||
return None;
|
||||
}
|
||||
let (matrix, hue_sat) = colour.clone().unwrap_or((Mat3::IDENTITY, None));
|
||||
let correction = hue_sat.as_ref().and_then(HueSat::new);
|
||||
let coloured = |x: [f64; 3]| {
|
||||
let p = matrix.apply(x);
|
||||
correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from))
|
||||
};
|
||||
let tone_pairs: Vec<_> =
|
||||
pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(coloured(*x).map(|v| v.max(0.0))), luma(*y))).collect();
|
||||
let look = CameraLook { matrix, tone: fit_tone(tone_pairs)?, hue_sat: hue_sat.clone() };
|
||||
let (tone, neutral) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0));
|
||||
let encoded = |v: f64| v.max(0.0).powf(1.0 / 2.2);
|
||||
let (mut before, mut after, mut before_encoded, mut after_encoded, mut samples) = (0.0, 0.0, 0.0, 0.0, 0);
|
||||
for (x, target) in pairs.iter().step_by(3) {
|
||||
let (fallback, toned, target) = (luma(displayed(*x, &neutral)), luma(displayed(coloured(*x), &tone)), luma(*target));
|
||||
before += (fallback - target).powi(2);
|
||||
after += (toned - target).powi(2);
|
||||
before_encoded += (encoded(fallback) - encoded(target)).powi(2);
|
||||
after_encoded += (encoded(toned) - encoded(target)).powi(2);
|
||||
samples += 1;
|
||||
}
|
||||
if lightcraft_pipeline::profiling() {
|
||||
eprintln!(
|
||||
"[profile] camera look from a black-and-white JPEG: holdout luminance RMS {:.5} -> {:.5} ({samples} pixels{})",
|
||||
(before / samples.max(1) as f64).sqrt(),
|
||||
(after / samples.max(1) as f64).sqrt(),
|
||||
if edge_limit.is_some() { ", away from edges" } else { "" }
|
||||
);
|
||||
}
|
||||
let better =
|
||||
after.is_finite() && after_encoded.is_finite() && after < before * MIN_IMPROVEMENT && after_encoded < before_encoded * MIN_IMPROVEMENT;
|
||||
(samples > 0 && better).then_some(look)
|
||||
}
|
||||
|
||||
/// Linear Rec.2020 D65 → linear ProPhoto RGB D50, the space DNG hue/saturation tables work in.
|
||||
fn to_prophoto() -> Mat3 {
|
||||
PROPHOTO.from_xyz().mul(&bradford(D65, D50)).mul(&REC2020.to_xyz())
|
||||
}
|
||||
|
||||
/// [`HueSat::apply`] restores the luminance in full only if the table kept at least this share of it. A colour
|
||||
/// the table darkens to [`NO_LUMINANCE_KEPT`] or less (only near-black noise, whose channels go negative) keeps
|
||||
/// its colour; in between the two are blended, so noisy shadows show no step.
|
||||
const MIN_LUMINANCE_KEPT: f32 = 0.25;
|
||||
const NO_LUMINANCE_KEPT: f32 = 0.15;
|
||||
|
||||
/// How much of the table's colour a pixel gets when the table kept `share` of its luminance: 0 up to
|
||||
/// [`NO_LUMINANCE_KEPT`], 1 from [`MIN_LUMINANCE_KEPT`], a smoothstep between.
|
||||
fn restore_weight(share: f32) -> f32 {
|
||||
if !share.is_finite() {
|
||||
return 0.0;
|
||||
}
|
||||
let t = ((share - NO_LUMINANCE_KEPT) / (MIN_LUMINANCE_KEPT - NO_LUMINANCE_KEPT)).clamp(0.0, 1.0);
|
||||
t * t * (3.0 - 2.0 * t)
|
||||
}
|
||||
|
||||
/// A fitted [`CameraLook::hue_sat`] table, ready to apply to linear Rec.2020 pixels. It changes
|
||||
/// hue and saturation only: luminance is restored, the camera tone curve owns it.
|
||||
pub(crate) struct HueSat<'a> {
|
||||
@@ -852,7 +1024,19 @@ impl<'a> HueSat<'a> {
|
||||
let mul = |m: &[[f32; 3]; 3], v: [f32; 3]| -> [f32; 3] { std::array::from_fn(|i| m[i][0] * v[0] + m[i][1] * v[1] + m[i][2] * v[2]) };
|
||||
let out = mul(&self.from, self.table.apply(mul(&self.to, rgb)));
|
||||
let (before, after) = (luminance_2020(rgb), luminance_2020(out));
|
||||
if before > 0.0 && after > 0.0 && out.iter().all(|v| v.is_finite()) { out.map(|v| v * before / after) } else { rgb }
|
||||
// restoring the luminance divides by the table's: near black, where noise makes some channels
|
||||
// negative, the table can bring it close to 0, and the gain would turn the pixel white (#820)
|
||||
if before <= 0.0 || after <= 0.0 || !out.iter().all(|v| v.is_finite()) {
|
||||
return rgb;
|
||||
}
|
||||
let w = restore_weight(after / before);
|
||||
if w >= 1.0 {
|
||||
return out.map(|v| v * before / after);
|
||||
}
|
||||
if w <= 0.0 {
|
||||
return rgb;
|
||||
}
|
||||
std::array::from_fn(|i| rgb[i] + (out[i] * before / after - rgb[i]) * w)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1439,6 +1623,61 @@ mod tests {
|
||||
assert!(fit_hue_sat(&[], &Mat3::IDENTITY).is_none(), "too few samples");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hue_sat_never_turns_near_black_noise_white() {
|
||||
// a saturating table, as fitted to high-ISO Sony files; near-black noise with mixed-sign channels
|
||||
let table = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false };
|
||||
let hue_sat = HueSat::new(&table).unwrap();
|
||||
let mut seed = 0x9e37_79b9_7f4a_7c15u64;
|
||||
let mut next = || {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
(seed >> 11) as f32 / (1u64 << 53) as f32 * 2.0 - 1.0
|
||||
};
|
||||
let mut worst = 0.0f32;
|
||||
for _ in 0..200_000 {
|
||||
let rgb = [0.002 + 0.004 * next(), 0.002 + 0.004 * next(), 0.002 + 0.004 * next()];
|
||||
if luminance_2020(rgb) <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let out = hue_sat.apply(rgb);
|
||||
let scale = rgb.iter().fold(0.0f32, |m, v| m.max(v.abs()));
|
||||
worst = worst.max(out.iter().fold(0.0f32, |m, v| m.max(v.abs())) / scale);
|
||||
}
|
||||
assert!(worst < 20.0, "a near-black pixel came out {worst}× its largest channel");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hue_sat_restore_weight_blends_smoothly_between_the_thresholds() {
|
||||
assert_eq!(restore_weight(0.0), 0.0);
|
||||
assert_eq!(restore_weight(NO_LUMINANCE_KEPT), 0.0);
|
||||
assert_eq!(restore_weight(MIN_LUMINANCE_KEPT), 1.0);
|
||||
assert_eq!(restore_weight(1.0), 1.0);
|
||||
assert_eq!(restore_weight(f32::NAN), 0.0);
|
||||
// monotonic and without a step: neighbouring shares 0.001 apart differ by little
|
||||
let mut last = 0.0f32;
|
||||
for i in 0..=400 {
|
||||
let w = restore_weight(i as f32 * 0.001);
|
||||
assert!(w >= last && w - last < 0.02, "step at {}: {last} → {w}", i as f32 * 0.001);
|
||||
last = w;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hue_sat_keeps_colours_the_table_leaves_bright_exactly_as_before() {
|
||||
// the blend only touches colours whose luminance the table cut below MIN_LUMINANCE_KEPT
|
||||
let table = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false };
|
||||
let hue_sat = HueSat::new(&table).unwrap();
|
||||
let mul = |m: &[[f32; 3]; 3], v: [f32; 3]| -> [f32; 3] { std::array::from_fn(|i| m[i][0] * v[0] + m[i][1] * v[1] + m[i][2] * v[2]) };
|
||||
for rgb in [[0.4, 0.2, 0.1], [0.05, 0.3, 0.2], [0.18, 0.18, 0.18], [0.6, 0.1, 0.5]] {
|
||||
let out = mul(&hue_sat.from, table.apply(mul(&hue_sat.to, rgb)));
|
||||
let (before, after) = (luminance_2020(rgb), luminance_2020(out));
|
||||
assert!(after >= MIN_LUMINANCE_KEPT * before);
|
||||
assert_eq!(hue_sat.apply(rgb), out.map(|v| v * before / after));
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_of(format: RawFormat, make: &str, model: &str, color: lightcraft_raw::ColorData) -> RawImage {
|
||||
let area = lightcraft_raw::Rect::new(0, 0, 2, 2);
|
||||
RawImage {
|
||||
@@ -1469,7 +1708,11 @@ mod tests {
|
||||
let own = Mat3([[0.6, -0.1, -0.05], [-0.4, 1.3, 0.1], [-0.1, 0.2, 0.7]]);
|
||||
let color = lightcraft_raw::ColorData { illuminant: [21, 0], color_matrix: [Some(own), None], ..Default::default() };
|
||||
let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 600D", color.clone());
|
||||
let (_, t, look) = starting_colour_with(&mut raw, |_, _| panic!("no JPEG fit for a raw with its own matrices"));
|
||||
let (_, t, look) = starting_colour_with(
|
||||
&mut raw,
|
||||
|_, _| panic!("no JPEG fit for a raw with its own matrices"),
|
||||
|_, _| panic!("no black-and-white JPEG fit for a raw with its own matrices"),
|
||||
);
|
||||
assert_eq!(raw.color, color);
|
||||
assert!(!t.matrix_is_fallback && look.is_none());
|
||||
}
|
||||
@@ -1483,10 +1726,14 @@ mod tests {
|
||||
assert!(lightcraft_raw::spectral::find(Some(make), model).is_some(), "{model}");
|
||||
let mut raw = raw_of(RawFormat::Cr2, make, model, Default::default());
|
||||
let fitted = dro_test_look(&std::array::from_fn(|i| 0.006 * 1.13f32.powi(i as i32)), &|x| x.sqrt());
|
||||
let (xy, t, look) = starting_colour_with(&mut raw, |raw, t| {
|
||||
assert!(t.matrix_is_fallback && !lightcraft_raw::color::has_matrix(&raw.color), "the fit sees the raw without spectral matrices");
|
||||
Some(fitted.clone())
|
||||
});
|
||||
let (xy, t, look) = starting_colour_with(
|
||||
&mut raw,
|
||||
|raw, t| {
|
||||
assert!(t.matrix_is_fallback && !lightcraft_raw::color::has_matrix(&raw.color), "the fit sees the raw without spectral matrices");
|
||||
(Some(fitted.clone()), false)
|
||||
},
|
||||
|_, _| panic!("no black-and-white JPEG fit after an accepted fit"),
|
||||
);
|
||||
assert_eq!(look.map(|l| l.matrix), Some(fitted.matrix), "{model}");
|
||||
assert_eq!(raw.color, lightcraft_raw::ColorData::default(), "{model}: spectral matrices left out");
|
||||
assert!(t.matrix_is_fallback, "{model}");
|
||||
@@ -1508,7 +1755,7 @@ mod tests {
|
||||
|
||||
let camera = lightcraft_raw::spectral::find(Some("Canon"), "Canon EOS 600D").unwrap();
|
||||
let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 600D", Default::default());
|
||||
let (xy, t, look) = starting_colour_with(&mut raw, |_, _| None);
|
||||
let (xy, t, look) = starting_colour_with(&mut raw, |_, _| (None, false), |_, _| panic!("a colour JPEG never takes the black-and-white step"));
|
||||
assert!(look.is_none());
|
||||
assert_eq!(raw.color.illuminant, [17, 21]);
|
||||
assert_eq!(raw.color.forward_matrix, camera.forward_matrix.map(Some));
|
||||
@@ -1519,6 +1766,43 @@ mod tests {
|
||||
assert!(w.iter().all(|v| (v - 1.0).abs() < 1e-4), "{w:?}");
|
||||
}
|
||||
|
||||
/// After step 4 or 5: a photo whose JPEG gave no fit and is black and white gets its tone curve on top of the
|
||||
/// colour it has by then, the spectral matrices for a model in the table, the neutral fallback's otherwise
|
||||
/// (issue #577). The black-and-white fit sees that transform.
|
||||
#[test]
|
||||
fn a_black_and_white_jpeg_adds_its_tone_on_top_of_the_colour_it_gets_otherwise() {
|
||||
let toned = CameraLook {
|
||||
matrix: Mat3::IDENTITY,
|
||||
tone: dro_test_look(&std::array::from_fn(|i| 0.006 * 1.13f32.powi(i as i32)), &|x| x.sqrt()).tone,
|
||||
hue_sat: None,
|
||||
};
|
||||
let camera = lightcraft_raw::spectral::find(Some("Canon"), "Canon EOS 600D").unwrap();
|
||||
let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 600D", Default::default());
|
||||
let (xy, t, look) = starting_colour_with(
|
||||
&mut raw,
|
||||
|_, _| (None, true),
|
||||
|raw, t| {
|
||||
assert!(!t.matrix_is_fallback && raw.color.forward_matrix == camera.forward_matrix.map(Some), "the spectral matrices are in");
|
||||
Some(toned.clone())
|
||||
},
|
||||
);
|
||||
assert!(look.as_ref().is_some_and(|l| l.matrix == Mat3::IDENTITY && l.hue_sat.is_none()), "the tone curve on top");
|
||||
assert!(!t.matrix_is_fallback);
|
||||
assert_eq!(xy, lightcraft_raw::color::as_shot_white_xy(&raw));
|
||||
|
||||
let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 7D", Default::default());
|
||||
let (_, t, look) = starting_colour_with(
|
||||
&mut raw,
|
||||
|_, _| (None, true),
|
||||
|_, t| {
|
||||
assert!(t.matrix_is_fallback, "outside the table: the neutral fallback's colour");
|
||||
Some(toned.clone())
|
||||
},
|
||||
);
|
||||
assert!(look.is_some() && t.matrix_is_fallback);
|
||||
assert_eq!(raw.color, lightcraft_raw::ColorData::default());
|
||||
}
|
||||
|
||||
/// Step 5: a camera outside the table whose fit fails keeps the neutral fallback.
|
||||
#[test]
|
||||
fn a_camera_outside_the_table_keeps_the_fallback() {
|
||||
@@ -1559,7 +1843,31 @@ mod tests {
|
||||
assert_eq!(t.matrix_is_fallback, !own && !spectral, "{name}");
|
||||
let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
|
||||
assert_eq!(info.camera_tone.is_some(), fitted.is_some(), "{name}");
|
||||
assert_eq!(info.relative_wb, !own, "{name}");
|
||||
// white balance: relative only without matrices anywhere; a covered camera edits in Kelvin, through the
|
||||
// spectral matrices, whichever look it got (issue #730)
|
||||
assert_eq!(info.relative_wb, !own && !covered, "{name}");
|
||||
assert_eq!(info.legacy_relative_wb, !own && covered, "{name}");
|
||||
if covered && !own {
|
||||
assert!(
|
||||
(2000.0..=50000.0).contains(&info.as_shot_temp) && info.as_shot_temp != 6500.0 && info.as_shot_tint.abs() < 100.0,
|
||||
"{name}: {info:?}"
|
||||
);
|
||||
}
|
||||
// the same as the catalog decides from the probe (format and camera)
|
||||
let probe = crate::files::probe_bytes(name, &bytes).unwrap();
|
||||
let mut p = lightcraft_catalog::Photo::new(
|
||||
lightcraft_catalog::PhotoId(1),
|
||||
lightcraft_catalog::Source::File { path: name.into() },
|
||||
name,
|
||||
&probe.format,
|
||||
1,
|
||||
1,
|
||||
"",
|
||||
);
|
||||
p.kind = probe.kind;
|
||||
p.meta = probe.meta.clone();
|
||||
assert_eq!(p.relative_wb(), info.relative_wb, "{name}");
|
||||
assert_eq!(probe.as_shot_wb, Some((info.as_shot_temp, info.as_shot_tint)), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1635,7 +1943,7 @@ mod tests {
|
||||
assert_eq!(raw.format, RawFormat::Cr3);
|
||||
assert_eq!(raw.info().developed_size(), (6000, 4000));
|
||||
let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
|
||||
let look = fit_preview(&raw, &bytes, &transform);
|
||||
let look = fit_preview(&raw, &bytes, &transform).0;
|
||||
eprintln!("R100 guarded colour fit accepted: {}", look.is_some());
|
||||
assert!(look.is_some(), "this R100 corpus photo has enough matching colour after camera-framing alignment");
|
||||
let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
|
||||
@@ -1668,7 +1976,7 @@ mod tests {
|
||||
let raw = lightcraft_raw::decode(&bytes).unwrap();
|
||||
assert_eq!(raw.metadata.model.as_deref(), Some("ILCE-7CR"));
|
||||
let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
|
||||
let look = fit_preview(&raw, &bytes, &transform).expect("fit accepted");
|
||||
let look = fit_preview(&raw, &bytes, &transform).0.expect("fit accepted");
|
||||
let expected = bundled.matrix().mul(&transform.matrix.inverse().unwrap());
|
||||
for (row, want) in look.matrix.0.iter().zip(expected.0) {
|
||||
for (got, want) in row.iter().zip(want) {
|
||||
@@ -1914,7 +2222,7 @@ mod tests {
|
||||
let mut raw = lightcraft_raw::decode(&bytes).unwrap();
|
||||
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
|
||||
let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
|
||||
let look = fit_preview(&raw, &bytes, &transform).expect("a camera look");
|
||||
let look = fit_preview(&raw, &bytes, &transform).0.expect("a camera look");
|
||||
// the look fitted to the JPEG, as before this change
|
||||
let (sensor, reference, clipped) = proxies(&raw, &bytes, &transform, PROXY).unwrap();
|
||||
let profile = raw.metadata.model.as_deref().and_then(crate::camera_profiles::get);
|
||||
@@ -1988,15 +2296,24 @@ mod tests {
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
|
||||
.join("raw");
|
||||
for (name, accepted) in [("raf-fuji-xt2-865.raf", true), ("raf-fuji-gfx100s-4503.raf", true), ("raf-fuji-xt20-compressed.raf", true)] {
|
||||
// (the GFX100S shares the GFX 100's measured sensor, so its white balance is in Kelvin through those
|
||||
// matrices; the X-T2 and X-T20 have none and stay relative)
|
||||
for (name, accepted, kelvin) in
|
||||
[("raf-fuji-xt2-865.raf", true, false), ("raf-fuji-gfx100s-4503.raf", true, true), ("raf-fuji-xt20-compressed.raf", true, false)]
|
||||
{
|
||||
let Ok(bytes) = std::fs::read(dir.join(name)) else { continue };
|
||||
let header = crate::files::probe_bytes(name, &bytes).unwrap();
|
||||
assert_eq!(header.as_shot_wb, Some((6500.0, 0.0)), "{name}");
|
||||
let (_, small) = crate::files::load_bytes(&bytes, 400).unwrap();
|
||||
let (_, large) = crate::files::load_bytes(&bytes, 1200).unwrap();
|
||||
assert_eq!(small.camera_tone.is_some(), accepted, "{name}");
|
||||
assert_eq!(small.camera_tone, large.camera_tone, "{name}: resolution changed the fit");
|
||||
assert!(small.raw && small.relative_wb && small.as_shot_temp == 6500.0 && small.as_shot_tint == 0.0, "{name}");
|
||||
assert_eq!(header.as_shot_wb, Some((small.as_shot_temp, small.as_shot_tint)), "{name}");
|
||||
assert!(small.raw && small.relative_wb != kelvin && small.legacy_relative_wb == kelvin, "{name}: {small:?}");
|
||||
if kelvin {
|
||||
assert!(small.as_shot_temp != 6500.0 && (2000.0..50000.0).contains(&small.as_shot_temp), "{name}: {}", small.as_shot_temp);
|
||||
} else {
|
||||
assert!(small.as_shot_temp == 6500.0 && small.as_shot_tint == 0.0, "{name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2314,4 +2631,183 @@ mod tests {
|
||||
reference.data.iter_mut().zip(&sensor.data).for_each(|(r, s)| *r = [0.8 * luminance_2020(*s).sqrt(); 3]);
|
||||
assert!(fit_look(&sensor, &reference, &none, None).is_none(), "monochrome JPEG");
|
||||
}
|
||||
|
||||
/// A coloured scene whose camera JPEG is black and white: the camera's tone curve applied to the scene's
|
||||
/// luminance, grey in every pixel.
|
||||
fn black_and_white_scene() -> (Rgb32f, Rgb32f) {
|
||||
let (w, h) = (96usize, 64usize);
|
||||
let mut sensor = Rgb32f::new(w, h);
|
||||
for (i, p) in sensor.data.iter_mut().enumerate() {
|
||||
let ev = 0.01 + (i % 53) as f32 * 0.009;
|
||||
*p = [ev * (0.6 + (i % 11) as f32 * 0.08), ev, ev * (0.6 + (i % 13) as f32 * 0.07)];
|
||||
}
|
||||
let mut reference = sensor.clone();
|
||||
reference.map_in_place(|p| [1.0 - (-3.0 * luminance_2020(p)).exp(); 3]);
|
||||
(sensor, reference)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn black_and_white_jpegs_are_recognised() {
|
||||
let (sensor, mut reference) = black_and_white_scene();
|
||||
assert!(monochrome(&reference));
|
||||
assert!(!monochrome(&sensor), "a colour JPEG");
|
||||
// a few coloured pixels (JPEG noise) are allowed, one in a hundred is colour
|
||||
let n = reference.data.len();
|
||||
reference.data.iter_mut().take(n / 1000).for_each(|p| p[0] += 0.05);
|
||||
assert!(monochrome(&reference));
|
||||
reference.data.iter_mut().take(n / 100).for_each(|p| p[0] += 0.05);
|
||||
assert!(!monochrome(&reference));
|
||||
// a sepia-toned monochrome
|
||||
let (_, mut toned) = black_and_white_scene();
|
||||
toned.map_in_place(|p| [p[0] * 1.08, p[1], p[2] * 0.85]);
|
||||
assert!(!monochrome(&toned));
|
||||
assert!(!monochrome(&Rgb32f::new(8, 8)), "too small to tell");
|
||||
// the check reads pixels, not the picture style: a colour JPEG whose pixels are all grey within 0.002 counts
|
||||
let (_, mut faint) = black_and_white_scene();
|
||||
faint.map_in_place(|p| [p[0] + 0.001, p[1], p[2] - 0.0005]);
|
||||
assert!(monochrome(&faint));
|
||||
faint.map_in_place(|p| [p[0] + 0.003, p[1], p[2]]);
|
||||
assert!(!monochrome(&faint));
|
||||
// pixels that aren't numbers can't vouch for grey
|
||||
let (_, mut broken) = black_and_white_scene();
|
||||
broken.data.iter_mut().take(n / 100).for_each(|p| *p = [f32::NAN; 3]);
|
||||
assert!(!monochrome(&broken));
|
||||
}
|
||||
|
||||
/// Issue #577: a black-and-white JPEG gives no look (no colour to fit), but its tone curve renders the scene's
|
||||
/// brightness like the camera, with the colour left as it was and the chroma curve neutral.
|
||||
#[test]
|
||||
fn a_black_and_white_jpeg_gives_its_tone_curve() {
|
||||
let (sensor, reference) = black_and_white_scene();
|
||||
let none = vec![false; sensor.data.len()];
|
||||
assert!(fit_look(&sensor, &reference, &none, None).is_none(), "no colour to fit");
|
||||
let look = fit_tone_only(&sensor, &reference, None).expect("a tone curve");
|
||||
assert!(look.matrix == Mat3::IDENTITY && look.hue_sat.is_none(), "the colour is left as it was");
|
||||
assert!(look.tone.chroma().iter().all(|k| *k == 1.0), "the chroma curve stays neutral: {:?}", look.tone.chroma());
|
||||
let n = sensor.data.len() as f64;
|
||||
let mean = |tone: &ToneMap| sensor.data.iter().map(|p| luma(displayed(p.map(f64::from), tone))).sum::<f64>() / n;
|
||||
let target = reference.data.iter().map(|p| f64::from(luminance_2020(*p))).sum::<f64>() / n;
|
||||
let toned = mean(&ToneMap::camera(&look.tone, 0.0, 0.0, 0.0));
|
||||
assert!((toned / target - 1.0).abs() < 0.05, "{toned} vs {target}");
|
||||
assert!(mean(&ToneMap::new(0.0, 0.0, 0.0)) < target * 0.8, "the default curve is darker");
|
||||
// with a camera profile's colour, the look carries it
|
||||
let matrix = Mat3([[1.2, -0.15, -0.05], [-0.1, 1.15, -0.05], [-0.02, -0.1, 1.12]]);
|
||||
let look = fit_tone_only(&sensor, &reference, Some((matrix, None))).expect("a tone curve with the profile's colour");
|
||||
assert_eq!(look.matrix, matrix);
|
||||
}
|
||||
|
||||
/// A camera profile's colour can fail the checks where the photo's own colour passes, so [`monochrome_look`] tries
|
||||
/// again on the transform the photo has, as [`fit_look`] tries the photo's own colour after a profile's.
|
||||
#[test]
|
||||
fn a_rejected_profile_leaves_the_tone_curve_on_the_photos_own_colour() {
|
||||
// saturated colours, and a profile whose colour gets their luminance wrong (red and blue swapped)
|
||||
let hues = [[1.0, 0.15, 0.1], [0.1, 1.0, 0.15], [0.15, 0.1, 1.0], [1.0, 0.9, 0.1], [0.1, 0.9, 1.0], [1.0, 0.1, 0.9]];
|
||||
let mut sensor = Rgb32f::new(96, 64);
|
||||
for (i, p) in sensor.data.iter_mut().enumerate() {
|
||||
let ev = 0.02 + (i % 47) as f32 * 0.008;
|
||||
*p = hues[i % 6].map(|c| c * ev);
|
||||
}
|
||||
// a JPEG a little brighter than the default curve, so a curve has to fit closely to be clearly better
|
||||
let neutral = ToneMap::new(0.0, 0.0, 0.0);
|
||||
let mut reference = sensor.clone();
|
||||
reference.map_in_place(|p| [(1.15 * luma(displayed(p.map(f64::from), &neutral))) as f32; 3]);
|
||||
let swapped = Mat3([[0.0, 0.0, 1.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]]);
|
||||
assert!(fit_tone_only(&sensor, &reference, Some((swapped, None))).is_none(), "the profile's colour fails the checks");
|
||||
let own = fit_tone_only(&sensor, &reference, None);
|
||||
assert!(own.is_some(), "the photo's own colour passes");
|
||||
// and the step runs for a model with a bundled profile (the ILCE-7M4 also has spectral matrices)
|
||||
assert!(crate::camera_profiles::get("ILCE-7M4").is_some());
|
||||
let mut raw = raw_of(RawFormat::Arw, "SONY", "ILCE-7M4", Default::default());
|
||||
let (_, t, look) = starting_colour_with(
|
||||
&mut raw,
|
||||
|_, _| (None, true),
|
||||
|_, t| {
|
||||
assert!(!t.matrix_is_fallback, "on the spectral matrices");
|
||||
own.clone()
|
||||
},
|
||||
);
|
||||
assert!(look.is_some() && !t.matrix_is_fallback);
|
||||
}
|
||||
|
||||
/// [`monochrome_look`] on a synthetic raw file: an ILCE-7M4 (it has a bundled camera profile) whose camera JPEG,
|
||||
/// in a minimal TIFF, is black and white gets the tone curve, on the colour of the transform it is given; the
|
||||
/// same file with a tinted JPEG gets nothing from this step.
|
||||
#[test]
|
||||
fn monochrome_look_reads_the_files_black_and_white_jpeg() {
|
||||
use lightcraft_raw::{BlackLevel, ColorData, OpcodeLists, Orientation, RawData, Rect};
|
||||
let (w, h) = (192usize, 128usize);
|
||||
let level = |x: usize, y: usize| 0.01 + 0.5 * (x as f32 / w as f32) * (0.3 + 0.7 * y as f32 / h as f32);
|
||||
let raw = RawImage {
|
||||
format: RawFormat::Arw,
|
||||
width: w,
|
||||
height: h,
|
||||
cpp: 3,
|
||||
data: RawData::F32((0..w * h).flat_map(|i| [level(i % w, i / w); 3]).collect()),
|
||||
cfa: None,
|
||||
bits: 16,
|
||||
black: BlackLevel::uniform(0.0),
|
||||
white: vec![1.0],
|
||||
active_area: Rect::new(0, 0, w, h),
|
||||
crop: Rect::new(0, 0, w, h),
|
||||
orientation: Orientation::from_exif(1),
|
||||
color: ColorData::default(),
|
||||
wb_multipliers: Some([1.0; 3]),
|
||||
linearized: true,
|
||||
opcodes: OpcodeLists::default(),
|
||||
metadata: lightcraft_meta::Metadata { make: Some("SONY".into()), model: Some("ILCE-7M4".into()), ..Default::default() },
|
||||
};
|
||||
assert!(crate::camera_profiles::get("ILCE-7M4").is_some());
|
||||
// the camera's rendering: a bright curve on the scene's luminance, as a grey (or tinted) 8-bit sRGB JPEG
|
||||
let file = |tint: [f32; 3]| {
|
||||
let srgb = |v: f32| if v <= 0.003_130_8 { 12.92 * v } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 };
|
||||
let pixels: Vec<u8> = (0..w * h)
|
||||
.flat_map(|i| {
|
||||
let v = 1.0 - (-3.0 * level(i % w, i / w)).exp();
|
||||
tint.map(|t| (srgb(v * t).clamp(0.0, 1.0) * 255.0).round() as u8)
|
||||
})
|
||||
.collect();
|
||||
let image = lightcraft_codecs::EncodeImage::new(w as u32, h as u32, 3, lightcraft_codecs::Samples::U8(&pixels));
|
||||
let jpeg = lightcraft_codecs::encode_jpeg(&image, 95, lightcraft_codecs::ChromaSubsampling::S444, &Default::default()).unwrap();
|
||||
// II*, IFD0 at 8 with JPEGInterchangeFormat (0x201) and its length (0x202), the JPEG after it
|
||||
let mut tiff = b"II*\0\x08\0\0\0\x02\0".to_vec();
|
||||
for (tag, value) in [(0x0201u16, 38u32), (0x0202, jpeg.len() as u32)] {
|
||||
tiff.extend(tag.to_le_bytes());
|
||||
tiff.extend(4u16.to_le_bytes());
|
||||
tiff.extend(1u32.to_le_bytes());
|
||||
tiff.extend(value.to_le_bytes());
|
||||
}
|
||||
tiff.extend(0u32.to_le_bytes());
|
||||
assert_eq!(tiff.len(), 38);
|
||||
tiff.extend(jpeg);
|
||||
tiff
|
||||
};
|
||||
let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
|
||||
let look = monochrome_look(&raw, &file([1.0; 3]), &t).expect("a tone curve from the black-and-white JPEG");
|
||||
assert!(look.matrix == Mat3::IDENTITY && look.hue_sat.is_none(), "on the transform's colour");
|
||||
let curve = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
|
||||
let mid = curve.apply(0.25);
|
||||
assert!((mid - (1.0 - (-0.75f32).exp())).abs() < 0.05, "the camera's curve at 0.25: {mid}");
|
||||
assert!(monochrome_look(&raw, &file([1.0, 0.9, 0.8]), &t).is_none(), "a tinted JPEG is not black and white");
|
||||
}
|
||||
|
||||
/// The black-and-white fit has the partial look's checks: a JPEG of another picture, or one the default curve
|
||||
/// already renders, gives no look.
|
||||
#[test]
|
||||
fn a_black_and_white_jpeg_gives_a_tone_curve_only_when_clearly_better() {
|
||||
let (sensor, mut reference) = black_and_white_scene();
|
||||
let neutral = ToneMap::new(0.0, 0.0, 0.0);
|
||||
reference.data.iter_mut().zip(&sensor.data).for_each(|(r, s)| *r = [luma(displayed(s.map(f64::from), &neutral)) as f32; 3]);
|
||||
assert!(monochrome(&reference));
|
||||
assert!(fit_tone_only(&sensor, &reference, None).is_none(), "no clear gain over the default curve");
|
||||
for (i, p) in reference.data.iter_mut().enumerate() {
|
||||
*p = [0.05 + (i % 29) as f32 * 0.02; 3];
|
||||
}
|
||||
assert!(fit_tone_only(&sensor, &reference, None).is_none(), "another picture");
|
||||
// a flat grey JPEG ranks nothing, even when the scene's pixels are stored in brightness order (with ties
|
||||
// ranked in pixel order it looked perfectly related, and the curve flattened the photo to one grey)
|
||||
let mut sorted = sensor.clone();
|
||||
sorted.data.sort_by(|a, b| luminance_2020(*a).total_cmp(&luminance_2020(*b)));
|
||||
reference.data.iter_mut().for_each(|p| *p = [0.4; 3]);
|
||||
assert!(fit_tone_only(&sorted, &reference, None).is_none(), "a flat JPEG");
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,9 @@ fn browse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
v.sort();
|
||||
v
|
||||
};
|
||||
let report = import_with(s, &files, &ImportOptions { mode: ImportMode::Add, local: true, ..Default::default() })?;
|
||||
// looking at a folder never brings its deleted photos back (importing the files does)
|
||||
let opts = ImportOptions { mode: ImportMode::Add, local: true, on_deleted: crate::import::OnDeleted::Skip, ..Default::default() };
|
||||
let report = import_with(s, &files, &opts)?;
|
||||
stamp_browsed(s, &dir_s, &files);
|
||||
s.browse = Some(Browse { path: dir_s.clone(), subfolders });
|
||||
s.source = LibrarySource::Folder;
|
||||
@@ -105,7 +107,7 @@ impl Session {
|
||||
let smart = self.media.smart_dir.clone();
|
||||
let plan = self
|
||||
.catalog
|
||||
.forget_local_plan(&now, days, &|p| keep.contains(&p.id) || smart.as_ref().is_some_and(|d| d.join(crate::smart::file_name(p)).exists()));
|
||||
.forget_local_plan(&now, days, &|p| keep.contains(&p.id) || smart.as_ref().is_some_and(|d| crate::smart::file_path(d, p).exists()));
|
||||
if dry_run {
|
||||
return plan;
|
||||
}
|
||||
@@ -195,6 +197,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"path": to, "relinked": n}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"folder.create",
|
||||
"New Folder on Disk",
|
||||
[],
|
||||
None,
|
||||
"{parent, name} — create a folder on disk inside `parent` (refused if the name is taken); one undo step (undo removes it again while it is empty) → {path}",
|
||||
always,
|
||||
create_folder
|
||||
),
|
||||
cmd!(
|
||||
"photo.moveToFolder",
|
||||
"Move to Folder",
|
||||
[],
|
||||
None,
|
||||
"{folder, ids?} — move the photos' files (default: the selection) with their XMP sidecars into `folder` on disk and relink them; virtual copies follow their master; across volumes the file is copied, verified and only then removed; never overwrites (a name taken in `folder` refuses the whole move); missing/offline and Local photos are refused; one undo step (undo moves them back) → {moved, folder}",
|
||||
always,
|
||||
move_to_folder
|
||||
),
|
||||
cmd!(
|
||||
"library.browse",
|
||||
"Browse Folder",
|
||||
@@ -249,6 +269,127 @@ pub(crate) fn rename_folder_on_disk(from: &str, to: &str) -> std::result::Result
|
||||
std::fs::rename(src, dst).map_err(|e| format!("{from} → {to}: {e}"))
|
||||
}
|
||||
|
||||
/// Do an undo step's folder change on disk: rename `from` → `to`; with `to` empty, remove the
|
||||
/// empty folder `from`; with `from` empty, create the folder `to`. Never overwrites.
|
||||
pub(crate) fn folder_on_disk(from: &str, to: &str) -> std::result::Result<(), String> {
|
||||
match (from.is_empty(), to.is_empty()) {
|
||||
(false, false) => rename_folder_on_disk(from, to),
|
||||
(false, true) => remove_empty_folder(from),
|
||||
(true, false) => {
|
||||
if Path::new(to).exists() {
|
||||
return Err(format!("{to} already exists"));
|
||||
}
|
||||
std::fs::create_dir(to).map_err(|e| format!("{to}: {e}"))
|
||||
}
|
||||
(true, true) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove an empty folder; one with anything in it (hidden files too) is refused.
|
||||
fn remove_empty_folder(path: &str) -> std::result::Result<(), String> {
|
||||
let p = Path::new(path);
|
||||
if !p.is_dir() {
|
||||
return Err(format!("{path} is not a folder"));
|
||||
}
|
||||
let mut entries = std::fs::read_dir(p).map_err(|e| format!("{path}: {e}"))?;
|
||||
if entries.next().is_some() {
|
||||
return Err(format!("{path} is not empty"));
|
||||
}
|
||||
std::fs::remove_dir(p).map_err(|e| format!("{path}: {e}"))
|
||||
}
|
||||
|
||||
/// A folder path parameter without trailing separators (`None` when missing or blank).
|
||||
fn folder_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> {
|
||||
str_param(p, key).map(|x| x.trim_end_matches(['/', '\\'])).filter(|x| !x.is_empty())
|
||||
}
|
||||
|
||||
/// `folder.create`: a new folder on disk inside `parent`; undo removes it while it is empty.
|
||||
fn create_folder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "folder.create";
|
||||
let parent = folder_param(p, "parent").ok_or_else(|| bad(C, "missing parent"))?;
|
||||
let name = str_param(p, "name")
|
||||
.map(str::trim)
|
||||
.filter(|n| !n.is_empty() && !n.contains(['/', '\\']) && *n != "." && *n != "..")
|
||||
.ok_or_else(|| bad(C, "give a folder name (no slashes)"))?;
|
||||
if !Path::new(parent).is_dir() {
|
||||
return Err(bad(C, format!("{parent} is not a folder (offline, moved or deleted?)")));
|
||||
}
|
||||
let path = Path::new(parent).join(name).to_string_lossy().to_string();
|
||||
if Path::new(&path).exists() {
|
||||
return Err(bad(C, format!("{path} already exists: choose another name")));
|
||||
}
|
||||
folder_on_disk("", &path).map_err(|e| bad(C, e))?;
|
||||
let undo = crate::FolderMove { from: path.clone(), to: String::new() };
|
||||
if let Err(e) = s.commit_with_folder(&format!("Create Folder “{name}”"), Op::Batch { ops: vec![] }, undo) {
|
||||
let _ = remove_empty_folder(&path);
|
||||
return Err(e);
|
||||
}
|
||||
Ok(json!({"path": path}))
|
||||
}
|
||||
|
||||
/// `photo.moveToFolder`: move the photos' files (with their sidecars) into `folder` and relink
|
||||
/// them, as one undo step. Virtual copies follow their master's file. Never overwrites: a name
|
||||
/// already taken in `folder` refuses the whole move.
|
||||
fn move_to_folder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "photo.moveToFolder";
|
||||
let folder = folder_param(p, "folder").ok_or_else(|| bad(C, "missing folder"))?;
|
||||
let dir = Path::new(folder);
|
||||
if !dir.is_dir() {
|
||||
return Err(bad(C, format!("{folder} is not a folder (create it first with folder.create)")));
|
||||
}
|
||||
let ids = s.targets(p);
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "no photos to move: select some or give ids"));
|
||||
}
|
||||
let mut plans: Vec<crate::rename::RenamePlan> = Vec::new();
|
||||
let mut taken: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let mut clashes: Vec<String> = Vec::new();
|
||||
for id in ids {
|
||||
let ph = s.catalog.photo(id).ok_or_else(|| bad(C, format!("no photo {}", id.0)))?;
|
||||
if ph.local {
|
||||
return Err(bad(C, format!("{} is not in the library (Local): add it first", ph.file_name)));
|
||||
}
|
||||
let lightcraft_catalog::Source::File { path } = &ph.source else {
|
||||
return Err(bad(C, format!("{} has no file on disk", ph.file_name)));
|
||||
};
|
||||
if plans.iter().any(|pl| pl.from_path.as_deref() == Some(path.as_str())) {
|
||||
continue; // a virtual copy of a photo already moving
|
||||
}
|
||||
let src = Path::new(path);
|
||||
if !src.is_file() {
|
||||
return Err(bad(C, format!("{path} is missing or offline: find it first (Find Missing Photos)")));
|
||||
}
|
||||
let Some(file) = src.file_name() else { return Err(bad(C, format!("{path}: no file name"))) };
|
||||
if src.parent() == Some(dir) {
|
||||
continue; // already there
|
||||
}
|
||||
let to = dir.join(file);
|
||||
let to_s = to.to_string_lossy().to_string();
|
||||
if to.exists() || !taken.insert(to_s.to_lowercase()) {
|
||||
clashes.push(file.to_string_lossy().to_string());
|
||||
continue;
|
||||
}
|
||||
let name = ph.file_name.clone();
|
||||
plans.push(crate::rename::RenamePlan { id: id.0, from: name.clone(), to: name, from_path: Some(path.clone()), to_path: Some(to_s) });
|
||||
}
|
||||
if !clashes.is_empty() {
|
||||
let s_ = if clashes.len() == 1 { "" } else { "s" };
|
||||
return Err(bad(
|
||||
C,
|
||||
format!("{} already in {folder}: nothing was moved. Rename the photo{s_} first or choose another folder", clashes.join(", ")),
|
||||
));
|
||||
}
|
||||
let n = plans.len();
|
||||
if n == 0 {
|
||||
return Ok(json!({"moved": 0, "folder": folder}));
|
||||
}
|
||||
let plural = |k: usize| if k == 1 { "" } else { "s" };
|
||||
let _task = s.activity.start("move", &format!("Moving {n} photo{}", plural(n)), crate::activity::Cancel::No);
|
||||
let name = dir.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| folder.to_string());
|
||||
s.apply_file_plans(&crate::rename::RealFs, &plans, &|k| format!("Move {k} Photo{} to “{name}”", plural(k)))?;
|
||||
Ok(json!({"moved": n, "folder": folder}))
|
||||
}
|
||||
|
||||
/// After a folder moved from `from` to `to`: a browsed folder at or below it follows.
|
||||
pub(crate) fn follow_folder(s: &mut Session, from: &str, to: &str) {
|
||||
// a library folder chosen at or below it follows too, as a source or as a filter
|
||||
@@ -285,6 +426,9 @@ pub(crate) fn move_folder(s: &mut Session, from: &str, to: &str) -> std::result:
|
||||
if s.library.as_ref().is_some_and(|l| l.dir.starts_with(src) || src.starts_with(&l.dir)) {
|
||||
return Err("the library's own folders can't be moved here".into());
|
||||
}
|
||||
// Repairs retain their exact old paths, including those on surviving history entries.
|
||||
// Complete them before the directory moves; an unavailable repair leaves it in place.
|
||||
s.repair_sidecars_with(&crate::rename::RealFs).map_err(|e| e.to_string())?;
|
||||
rename_folder_on_disk(from, to)?;
|
||||
let mut ops: Vec<Op> = s
|
||||
.catalog
|
||||
|
||||
@@ -120,33 +120,67 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
|
||||
/// Edit in an external editor, the engine half: render the photo with its edits as a 16-bit
|
||||
/// TIFF next to the original (`<name>-Edit.tif`, never overwriting), add it to the library and
|
||||
/// stack it on top of the original. The host opens the file in the editor.
|
||||
/// TIFF (or PSD, or 8-bit) next to the original (`<name>-Edit.tif`, never overwriting), add it to
|
||||
/// the library and stack it on top of the original. The host opens the file in the editor.
|
||||
fn edit_external(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "photo.editExternal";
|
||||
let id = s.active().ok_or_else(|| bad(C, "no active photo"))?;
|
||||
let ph = s.catalog.photo(id).cloned().ok_or_else(|| bad(C, "no photo"))?;
|
||||
let dir = match &ph.source {
|
||||
Source::File { path } => Path::new(path).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default(),
|
||||
Source::Demo { .. } => match super::str_param(p, "dir") {
|
||||
Some(d) => d.to_string(),
|
||||
None => return Err(bad(C, "a generated demo photo has no folder: give `dir`")),
|
||||
},
|
||||
};
|
||||
let mut ids = s.targets(p);
|
||||
ids.dedup();
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "no active photo"));
|
||||
}
|
||||
if ids.len() > MAX_EDIT_COPIES {
|
||||
return Err(bad(C, format!("{} photos selected: edit at most {MAX_EDIT_COPIES} at a time", ids.len())));
|
||||
}
|
||||
let space = match super::str_param(p, "colorSpace").unwrap_or("adobeRgb") {
|
||||
"srgb" => lightcraft_pipeline::OutputSpace::Srgb,
|
||||
"displayP3" => lightcraft_pipeline::OutputSpace::DisplayP3,
|
||||
"prophoto" | "proPhoto" => lightcraft_pipeline::OutputSpace::ProPhoto,
|
||||
_ => lightcraft_pipeline::OutputSpace::AdobeRgb,
|
||||
};
|
||||
let format = match super::str_param(p, "format").unwrap_or("tiff") {
|
||||
"tiff" | "tif" => crate::export::ExportFormat::Tiff,
|
||||
"psd" => crate::export::ExportFormat::Psd,
|
||||
f => return Err(bad(C, format!("format {f:?}: use tiff or psd"))),
|
||||
};
|
||||
let bit_depth = match p.get("bitDepth").and_then(Value::as_u64).unwrap_or(16) {
|
||||
8 => 8,
|
||||
16 => 16,
|
||||
d => return Err(bad(C, format!("bitDepth {d}: use 8 or 16"))),
|
||||
};
|
||||
let opts = crate::export::ExportOptions {
|
||||
format: crate::export::ExportFormat::Tiff,
|
||||
bit_depth: Some(16),
|
||||
format,
|
||||
bit_depth: Some(bit_depth),
|
||||
color_space: space,
|
||||
naming: "{name}-Edit".into(),
|
||||
conflict: crate::export::Conflict::Unique,
|
||||
..Default::default()
|
||||
};
|
||||
let mut copies = Vec::new();
|
||||
for id in ids {
|
||||
copies.push(edit_copy(s, p, id, &opts)?);
|
||||
}
|
||||
let new: Vec<lightcraft_catalog::PhotoId> = copies.iter().filter_map(|c| c["id"].as_u64()).map(lightcraft_catalog::PhotoId).collect();
|
||||
s.selection = crate::Selection { active: new.first().copied(), ids: new };
|
||||
let first = copies.first().cloned().unwrap_or(Value::Null);
|
||||
Ok(json!({"path": first["path"], "id": first["id"], "original": first["original"], "copies": copies}))
|
||||
}
|
||||
|
||||
/// Most photos one Edit In renders (each becomes a full-size file on disk and a library photo).
|
||||
const MAX_EDIT_COPIES: usize = 50;
|
||||
|
||||
/// One photo's edit copy for [`edit_external`]: rendered with `opts` next to its original (a demo
|
||||
/// photo into `dir`), added to the library and stacked on top of the original, expanded.
|
||||
fn edit_copy(s: &mut Session, p: &Value, id: lightcraft_catalog::PhotoId, opts: &crate::export::ExportOptions) -> Result<Value> {
|
||||
const C: &str = "photo.editExternal";
|
||||
let ph = s.catalog.photo(id).cloned().ok_or_else(|| bad(C, format!("no photo {}", id.0)))?;
|
||||
let dir = match &ph.source {
|
||||
Source::File { path } => Path::new(path).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default(),
|
||||
Source::Demo { .. } => match super::str_param(p, "dir") {
|
||||
Some(d) => d.to_string(),
|
||||
None => return Err(bad(C, "a generated demo photo has no folder: give `dir`")),
|
||||
},
|
||||
};
|
||||
let mut written = Vec::new();
|
||||
let mut write = |path: &str, bytes: &[u8]| -> std::result::Result<(), String> {
|
||||
// durable: the TIFF becomes a library photo the external editor then changes
|
||||
@@ -155,13 +189,13 @@ fn edit_external(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(())
|
||||
};
|
||||
let to = crate::export::Destination { dir, exact: None };
|
||||
crate::export::export_batch(s, &[id], &opts, &to, &mut write, &|path| Path::new(path).exists()).map_err(|e| bad(C, e))?;
|
||||
let out = written.into_iter().find(|w| w.ends_with(".tif")).ok_or_else(|| bad(C, "nothing was written"))?;
|
||||
crate::export::export_batch(s, &[id], opts, &to, &mut write, &|path| Path::new(path).exists()).map_err(|e| bad(C, e))?;
|
||||
let ext = format!(".{}", opts.format.extension());
|
||||
let out = written.into_iter().find(|w| w.ends_with(&ext)).ok_or_else(|| bad(C, "nothing was written"))?;
|
||||
let r = s.execute("library.import", &json!({"paths": [out]}))?;
|
||||
let new = r["imported"].get(0).and_then(Value::as_u64).ok_or_else(|| bad(C, "the edit copy could not be added"))?;
|
||||
// stack: the edit on top of the original, expanded so both show
|
||||
let _ = s.execute("stack.group", &json!({"ids": [new, id.0], "top": new, "collapsed": false}));
|
||||
s.selection = crate::Selection::single(lightcraft_catalog::PhotoId(new));
|
||||
Ok(json!({"path": out, "id": new, "original": id.0}))
|
||||
}
|
||||
|
||||
@@ -229,6 +263,14 @@ pub(crate) fn lens_ops(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog:
|
||||
ops
|
||||
}
|
||||
|
||||
/// The op that gives a raw the as-shot white balance its file reads now (`info`, a fresh probe), or none when the
|
||||
/// catalog already has it or the photo is not a raw edited in Kelvin. A raw imported before its camera's matrices
|
||||
/// gave it a Kelvin scale keeps the relative scale's 6500 / 0 until a decode or Reload reads the file (issue #730).
|
||||
pub(crate) fn as_shot_wb_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::Photo, info: &crate::media::ProbeInfo) -> Option<Op> {
|
||||
let read = info.as_shot_wb.filter(|(t, tint)| t.is_finite() && tint.is_finite())?;
|
||||
(ph.develops_raw() && !ph.relative_wb() && ph.as_shot_wb != Some(read)).then_some(Op::SetAsShotWb { id, wb: Some(read) })
|
||||
}
|
||||
|
||||
/// Re-read photos whose files changed on disk (an external editor saved them): new size,
|
||||
/// dimensions and content hash, cached sources dropped. → {reloaded: [ids]}
|
||||
fn reload(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -248,14 +290,16 @@ fn reload(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// camera fields the catalog lacks (e.g. a raw imported before its format was read) are filled in;
|
||||
// nothing already set is overwritten
|
||||
let meta_ops = fill_missing_meta(id, ph, &info);
|
||||
let mut mine: Vec<Op> = content_op(id, ph, info.clone()).into_iter().chain(meta_ops).chain(lens_ops(id, ph, &info)).collect();
|
||||
let mut mine: Vec<Op> =
|
||||
content_op(id, ph, info.clone()).into_iter().chain(meta_ops).chain(lens_ops(id, ph, &info)).chain(as_shot_wb_op(id, ph, &info)).collect();
|
||||
if !mine.is_empty() {
|
||||
ops.append(&mut mine);
|
||||
reloaded.push(id);
|
||||
}
|
||||
// virtual copies share the file
|
||||
for c in s.catalog.photos().filter(|c| c.copy_of == Some(id)) {
|
||||
let theirs: Vec<Op> = content_op(c.id, c, info.clone()).into_iter().chain(lens_ops(c.id, c, &info)).collect();
|
||||
let theirs: Vec<Op> =
|
||||
content_op(c.id, c, info.clone()).into_iter().chain(lens_ops(c.id, c, &info)).chain(as_shot_wb_op(c.id, c, &info)).collect();
|
||||
if !theirs.is_empty() {
|
||||
ops.extend(theirs);
|
||||
reloaded.push(c.id);
|
||||
@@ -342,7 +386,7 @@ pub fn edit_specs() -> Vec<CommandSpec> {
|
||||
"Edit Copy for External Editor",
|
||||
[],
|
||||
None,
|
||||
"{colorSpace?: adobeRgb (default) | proPhoto | displayP3 | srgb, dir?} — render the active photo with its edits as a 16-bit TIFF `<name>-Edit.tif` next to it, add it stacked on the original and select it → {path, id, original} (the app then opens it in the external editor)",
|
||||
"{ids?, colorSpace?: adobeRgb (default) | proPhoto | displayP3 | srgb, format?: tiff (default) | psd, bitDepth?: 16 (default) | 8, dir?} — render each selected photo (or `ids`, at most 50) with its edits as `<name>-Edit.tif` (or `.psd`, flat, which PhotoCraft saves back to in place) next to it, add each stacked on its original and select the copies → {path, id, original (the first copy), copies: [{path, id, original}]} (the app then opens them in the external editor)",
|
||||
has_active,
|
||||
edit_external
|
||||
),
|
||||
@@ -381,6 +425,7 @@ mod tests {
|
||||
lightcraft_develop::EmbeddedLens {
|
||||
warp: Some(lightcraft_develop::EmbeddedWarp { planes: [[1.0, -0.1, 0.03, 0.0, 0.0, 0.0]; 3], ..Default::default() }),
|
||||
vignette: None,
|
||||
stages: Default::default(),
|
||||
}
|
||||
}
|
||||
fn old_rw2() -> lightcraft_catalog::Photo {
|
||||
|
||||
@@ -653,7 +653,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!(query "denoise.queue", "Denoise Photos", [], None, "{ids?: [photoId], scope?: selected | visible | withAmount, retry?: false} → {added, …status} — make the denoised pictures of raw photos that have none, in the background. `retry` tries photos that failed before", always, queue),
|
||||
cmd!(query "denoise.cancel", "Cancel Denoise", [], None, "{id?, all?} → {cancelled} — stop the photo being made and/or forget the ones waiting (all of them when `id` is omitted)", always, cancel),
|
||||
cmd!(query "denoise.status", "Denoise Status", [], None, "{id?} → {enabled, model, runtime, productsDir, queued, running: {photo, done, total, seconds} | null, ready, made, auto, runOn, device: {kind: gpu | cpu | pending | none, adapter?, reason?, cardMs?, cpuMs?, fellBack?}, cache: {files, bytes, limitBytes}, generation, photo?: {state: notApplicable | noModel | ready | queued | running | failed | idle, …}}", always, status),
|
||||
cmd!(query "denoise.status", "Denoise Status", [], None, "{id?} → {enabled, model, runtime, productsDir, queued, running: {photo, done, total, seconds} | null, ready, made, auto, runOn, device: {kind: gpu | cpu | pending | none, adapter?, reason?, cardMs?, cpuMs?, fellBack?, remote?: {address, state: pending | unavailable | used | idle | slower | given up, device?, remoteMs?, localMs?, reason?, failures?, lastError?}}, cache: {files, bytes, limitBytes}, generation, photo?: {state: notApplicable | noModel | ready | queued | running | failed | idle, …}}", always, status),
|
||||
cmd!(query "denoise.pump", "Denoise Pump", [], None, "{pace?: pause | light | normal | full} → {active, running, queued, ready, generation, downloading} — called every frame by the app: takes in finished work and keeps one photo going", always, pump),
|
||||
cmd!(query "denoise.clear", "Clear Denoise Cache", [], None, "{} → {deleted, bytes} — delete every cached denoised picture of this library (they are made again when wanted)", always, clear),
|
||||
]
|
||||
|
||||
@@ -37,6 +37,17 @@ fn crop_dims(s: &Session, id: PhotoId) -> (f64, f64) {
|
||||
if o.swaps_axes() { (h, w) } else { (w, h) }
|
||||
}
|
||||
|
||||
/// After a generic control write (`develop.set`, `develop.adjust`, …) that touched `crop.angle`:
|
||||
/// refit the crop as `crop.straighten` does, from the crop it had `before`, so the box never takes in
|
||||
/// canvas outside the photo (issue #742).
|
||||
fn refit_crop_angle(touched: bool, before: CropGeometry, d: &mut DevelopSettings, (w, h): (f64, f64)) {
|
||||
if touched {
|
||||
d.crop.geometry = before.with_angle(d.crop.geometry.angle, w, h);
|
||||
}
|
||||
}
|
||||
|
||||
const CROP_ANGLE: &str = "crop.angle";
|
||||
|
||||
/// `v` as a list of numbers; `None` unless it is an array of nothing but numbers.
|
||||
fn numbers(v: Option<&Value>) -> Option<Vec<f64>> {
|
||||
v?.as_array()?.iter().map(Value::as_f64).collect()
|
||||
@@ -146,7 +157,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Set Develop Values",
|
||||
[],
|
||||
None,
|
||||
"{control?: controlId, value?: number, values?: {controlId: number}, ids?: [photo]} — see develop.controls",
|
||||
"{control?: controlId, value?: number, values?: {controlId: number}, ids?: [photo]} — see develop.controls; `crop.angle` turns the crop as crop.straighten does (kept if it still fits, else the largest of its aspect inside the photo)",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let mut vals: Vec<(String, f64)> = Vec::new();
|
||||
@@ -167,10 +178,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
}
|
||||
let label = if vals.len() == 1 { controls::find(&vals[0].0).map(|c| c.label).unwrap_or("Edit").to_string() } else { "Edit".into() };
|
||||
let apply = |d: &mut DevelopSettings, info: &lightcraft_pipeline::SourceInfo| {
|
||||
if d.wb.mode == WbMode::AsShot && vals.iter().any(|(k, _)| k == "wb.temp" || k == "wb.tint") {
|
||||
d.wb.temp = info.as_shot_temp;
|
||||
d.wb.tint = info.as_shot_tint;
|
||||
let angle = vals.iter().any(|(k, _)| k == CROP_ANGLE);
|
||||
let apply = |d: &mut DevelopSettings, info: &lightcraft_pipeline::SourceInfo, dims: (f64, f64)| {
|
||||
let before = d.crop.geometry;
|
||||
if vals.iter().any(|(k, _)| k == "wb.temp" || k == "wb.tint") {
|
||||
lightcraft_pipeline::local::normalize_wb(d, info);
|
||||
if d.wb.mode == WbMode::AsShot {
|
||||
d.wb.temp = info.as_shot_temp;
|
||||
d.wb.tint = info.as_shot_tint;
|
||||
}
|
||||
}
|
||||
for (k, v) in &vals {
|
||||
controls::set(d, k, *v);
|
||||
@@ -178,6 +194,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
d.wb.mode = WbMode::Custom;
|
||||
}
|
||||
}
|
||||
refit_crop_angle(angle, before, d, dims);
|
||||
};
|
||||
if let Some(ids) = p.get("ids").and_then(Value::as_array) {
|
||||
let ids: Vec<PhotoId> = ids.iter().filter_map(Value::as_u64).map(PhotoId).collect();
|
||||
@@ -187,7 +204,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.filter_map(|(id, d)| {
|
||||
let mut d = (*d).clone();
|
||||
let info = s.source_info(id);
|
||||
apply(&mut d, &info);
|
||||
apply(&mut d, &info, crop_dims(s, id));
|
||||
s.develop_op(id, d, &label)
|
||||
})
|
||||
.collect();
|
||||
@@ -196,8 +213,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
let id = active(s, "develop.set")?;
|
||||
let info = s.source_info(id);
|
||||
let dims = crop_dims(s, id);
|
||||
edit(s, "develop.set", &label, |d| {
|
||||
apply(d, &info);
|
||||
apply(d, &info, dims);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -206,9 +224,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let c = str_param(p, "control").ok_or_else(|| bad("develop.adjust", "missing control"))?.to_string();
|
||||
let spec = controls::find(&c).ok_or_else(|| bad("develop.adjust", "unknown control"))?;
|
||||
let delta = f64_req(p, "delta", "develop.adjust")?;
|
||||
let info = s.source_info(active(s, "develop.adjust")?);
|
||||
let id = active(s, "develop.adjust")?;
|
||||
let (info, dims) = (s.source_info(id), crop_dims(s, id));
|
||||
edit(s, "develop.adjust", spec.label, |d| {
|
||||
let before = d.crop.geometry;
|
||||
if c == "wb.temp" || c == "wb.tint" {
|
||||
lightcraft_pipeline::local::normalize_wb(d, &info);
|
||||
if d.wb.mode == WbMode::AsShot {
|
||||
d.wb.temp = info.as_shot_temp;
|
||||
d.wb.tint = info.as_shot_tint;
|
||||
@@ -217,6 +238,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
let v = controls::get(d, &c).unwrap_or(spec.default);
|
||||
controls::set(d, &c, v + delta);
|
||||
refit_crop_angle(c == CROP_ANGLE, before, d, dims);
|
||||
Ok(())
|
||||
})
|
||||
}),
|
||||
@@ -239,11 +261,22 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let fresh = match look {
|
||||
Some(l) => (*l).clone(),
|
||||
None => {
|
||||
// the photo's built-in defaults (raws: capture sharpening and colour noise
|
||||
// reduction, embedded lens corrections), as at import, with the white balance
|
||||
// of its current as-shot reference
|
||||
let info = s.source_info(id);
|
||||
DevelopSettings {
|
||||
wb: lightcraft_develop::WhiteBalance { mode: WbMode::AsShot, temp: info.as_shot_temp, tint: info.as_shot_tint },
|
||||
..Default::default()
|
||||
}
|
||||
let defaults = s.catalog.photo(id).map(|p| p.camera_defaults()).unwrap_or_default();
|
||||
let mut fresh = DevelopSettings {
|
||||
wb: lightcraft_develop::WhiteBalance {
|
||||
mode: WbMode::AsShot,
|
||||
temp: info.as_shot_temp,
|
||||
tint: info.as_shot_tint,
|
||||
..Default::default()
|
||||
},
|
||||
..defaults
|
||||
};
|
||||
lightcraft_pipeline::local::normalize_wb(&mut fresh, &info);
|
||||
fresh
|
||||
}
|
||||
};
|
||||
// a fresh start renders with the current process (a section or slider reset doesn't change it)
|
||||
@@ -302,8 +335,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("develop.resetControl", "Reset Control", [], None, "{control}", has_active, |s, p| {
|
||||
let c = str_param(p, "control").ok_or_else(|| bad("develop.resetControl", "missing control"))?.to_string();
|
||||
let spec = controls::find(&c).ok_or_else(|| bad("develop.resetControl", "unknown control"))?;
|
||||
let dims = crop_dims(s, active(s, "develop.resetControl")?);
|
||||
edit(s, "develop.resetControl", spec.label, |d| {
|
||||
let before = d.crop.geometry;
|
||||
controls::set(d, &c, spec.default);
|
||||
refit_crop_angle(c == CROP_ANGLE, before, d, dims);
|
||||
Ok(())
|
||||
})
|
||||
}),
|
||||
@@ -377,7 +413,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
m => m
|
||||
.preset()
|
||||
.map(|(t, ti)| {
|
||||
// presets are relative to daylight for rendered files
|
||||
// presets are Kelvin on raws with that scale, relative to daylight (6500 K = as shot) elsewhere
|
||||
if info.raw && !info.relative_wb { (t, ti) } else { (6500.0 * t / 5500.0, ti) }
|
||||
})
|
||||
.unwrap_or((info.as_shot_temp, info.as_shot_tint)),
|
||||
@@ -387,6 +423,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
// The resolved temperature/tint is stored, so rendering never depends on the mode; the
|
||||
// mode is kept for the UI's WB dropdown.
|
||||
edit(s, "develop.wb", "White Balance", |d| {
|
||||
lightcraft_pipeline::local::normalize_wb(d, &info);
|
||||
d.wb.mode = mode;
|
||||
d.wb.temp = t.clamp(2000.0, 50000.0);
|
||||
d.wb.tint = tint.clamp(-150.0, 150.0);
|
||||
@@ -416,6 +453,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let patch = lightcraft_raster::Rgb32f::filled(4, 4, acc);
|
||||
let (t, tint) = lightcraft_pipeline::auto::auto_wb(&patch, &info);
|
||||
edit(s, "develop.wbPick", "White Balance", |d| {
|
||||
lightcraft_pipeline::local::normalize_wb(d, &info);
|
||||
d.wb.mode = WbMode::Custom;
|
||||
d.wb.temp = t;
|
||||
d.wb.tint = tint;
|
||||
@@ -489,10 +527,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
d.crop.geometry.rect = Rect::new(v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3]));
|
||||
}
|
||||
if let Some(a) = p.get("angle").and_then(Value::as_f64) {
|
||||
d.crop.geometry.angle = a.clamp(-45.0, 45.0);
|
||||
}
|
||||
d.crop.geometry = d.crop.geometry.constrained(w, h);
|
||||
let angle = p.get("angle").and_then(Value::as_f64).map(|a| a.clamp(-45.0, 45.0));
|
||||
d.crop.geometry = match (p.get("rect"), angle) {
|
||||
// a new angle alone turns the crop as Straighten does (issue #742)
|
||||
(None, Some(a)) => d.crop.geometry.with_angle(a, w, h),
|
||||
(_, a) => CropGeometry { angle: a.unwrap_or(d.crop.geometry.angle), ..d.crop.geometry }.constrained(w, h),
|
||||
};
|
||||
Ok(())
|
||||
})
|
||||
}),
|
||||
@@ -501,7 +541,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Drag Crop Handle",
|
||||
[],
|
||||
None,
|
||||
"{handle: topLeft|topRight|bottomRight|bottomLeft|top|right|bottom|left|move | 0..8, from: [x,y], to: [x,y] (normalized, straightened frame), start?: [x0,y0,x1,y1] (default: the current crop)} — drags a crop handle; stops at the image edge, keeps the opposite edges, honours the aspect lock",
|
||||
"{handle: topLeft|topRight|bottomRight|bottomLeft|top|right|bottom|left|move | 0..8, from: [x,y], to: [x,y] (normalized, straightened frame), start?: [x0,y0,x1,y1] (default: the current crop), keepRatio?: bool (default false)} — drags a crop handle; stops at the image edge, keeps the opposite edges, honours the aspect lock; keepRatio holds the frame's current proportions for this drag even with the lock off (the UI sends it while Shift is held)",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let id = active(s, "crop.drag")?;
|
||||
@@ -526,11 +566,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
_ => return Err(bad("crop.drag", "`start` needs 4 numbers")),
|
||||
};
|
||||
let keep_ratio = bool_or(p, "keepRatio", false);
|
||||
edit(s, "crop.drag", "Crop", |d| {
|
||||
let start = CropGeometry { rect: given.unwrap_or(d.crop.geometry.rect), angle: d.crop.geometry.angle };
|
||||
// A locked crop keeps the shape it has: the lock stores a width:height tuple whose
|
||||
// orientation is reinterpreted per image, so the frame itself is the truth.
|
||||
let ratio = d.crop.aspect.and(Some(start.rect_px(w, h).aspect())).filter(|a| a.is_finite() && *a > 0.0);
|
||||
// `keepRatio` (Shift held) asks for the same thing for one drag without touching the lock.
|
||||
let ratio = (d.crop.aspect.is_some() || keep_ratio).then(|| start.rect_px(w, h).aspect()).filter(|a| a.is_finite() && *a > 0.0);
|
||||
d.crop.geometry = drag_crop(start, handle, from, to, w, h, ratio);
|
||||
Ok(())
|
||||
})
|
||||
@@ -548,11 +590,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let (w, h) = crop_dims(s, id);
|
||||
let angle = f64_req(p, "angle", "crop.straighten")?.clamp(-45.0, 45.0);
|
||||
edit(s, "crop.straighten", "Straighten", |d| {
|
||||
let r = d.crop.geometry.rect_px(w, h);
|
||||
let fit = crop_fit_angle(w, h, angle, Some(r.width() / r.height()));
|
||||
// keep the current crop size if it still fits, else shrink
|
||||
let cand = CropGeometry { rect: d.crop.geometry.rect, angle };
|
||||
d.crop.geometry = if cand.is_within_image(w, h) { cand } else { fit };
|
||||
// keep the current crop if it still fits, else the largest of its aspect that does
|
||||
d.crop.geometry = d.crop.geometry.with_angle(angle, w, h);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -847,13 +886,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.filter_map(|id| s.develop_of(id).map(|d| (id, d)))
|
||||
.filter_map(|(id, d)| {
|
||||
let mut nd = (*d).clone();
|
||||
if nd.wb.mode == WbMode::AsShot && (ctl == "wb.temp" || ctl == "wb.tint") {
|
||||
if ctl == "wb.temp" || ctl == "wb.tint" {
|
||||
// each photo on its own scale: Kelvin (an old relative value converted first) or relative
|
||||
let info = s.source_info(id);
|
||||
nd.wb.temp = info.as_shot_temp;
|
||||
nd.wb.tint = info.as_shot_tint;
|
||||
lightcraft_pipeline::local::normalize_wb(&mut nd, &info);
|
||||
if nd.wb.mode == WbMode::AsShot {
|
||||
nd.wb.temp = info.as_shot_temp;
|
||||
nd.wb.tint = info.as_shot_tint;
|
||||
}
|
||||
}
|
||||
let before = nd.crop.geometry;
|
||||
let v = controls::get(&nd, &ctl).unwrap_or(spec.default);
|
||||
controls::set(&mut nd, &ctl, v + delta);
|
||||
refit_crop_angle(ctl == CROP_ANGLE, before, &mut nd, crop_dims(s, id));
|
||||
if ctl == "wb.temp" || ctl == "wb.tint" {
|
||||
nd.wb.mode = WbMode::Custom;
|
||||
}
|
||||
|
||||
@@ -78,8 +78,76 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(list(s))
|
||||
}
|
||||
|
||||
/// `export.addToPhotos`: there is a runner (the desktop app and lightcraft-cli on macOS).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn photos_enabled(s: &Session) -> std::result::Result<(), String> {
|
||||
match &s.apple_photos {
|
||||
Some(_) => Ok(()),
|
||||
None => Err(crate::apple_photos::unavailable().into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `export.addToPhotos {paths, album?, wait?}`: add files already on disk to Apple Photos, as a
|
||||
/// job: here (the default without a window: lightcraft-cli, the MCP server, scripts), or on a
|
||||
/// worker thread (the default in the desktop app, which answers control requests on its UI
|
||||
/// thread and must not wait minutes for Photos; see [`Session::apple_photos_background`]).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn add_to_photos(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "export.addToPhotos";
|
||||
let runner = s.apple_photos.clone().ok_or_else(|| bad(ID, crate::apple_photos::unavailable()))?;
|
||||
if let Some(k) = p.as_object().and_then(|o| o.keys().find(|k| !matches!(k.as_str(), "paths" | "album" | "wait"))) {
|
||||
return Err(bad(ID, format!("unknown parameter `{k}` (one of paths, album, wait)")));
|
||||
}
|
||||
let paths: Vec<String> = match p.get("paths") {
|
||||
Some(Value::Array(a)) => a
|
||||
.iter()
|
||||
.map(|v| v.as_str().map(str::to_string))
|
||||
.collect::<Option<_>>()
|
||||
.ok_or_else(|| bad(ID, "`paths` must be an array of file paths"))?,
|
||||
_ => return Err(bad(ID, "missing `paths` (an array of absolute file paths)")),
|
||||
};
|
||||
let album = match p.get("album") {
|
||||
None | Some(Value::Null) => "",
|
||||
Some(Value::String(a)) => a.as_str(),
|
||||
Some(_) => return Err(bad(ID, "`album` must be a string")),
|
||||
};
|
||||
// a window (UI thread) doesn't wait by default; a one-shot process must, or it would end
|
||||
// in the middle of the import
|
||||
let wait = match p.get("wait") {
|
||||
None | Some(Value::Null) => !s.apple_photos_background,
|
||||
// the desktop app answers on its window's thread, which never waits for Photos
|
||||
Some(Value::Bool(true)) if s.apple_photos_background => {
|
||||
return Err(bad(
|
||||
ID,
|
||||
"`wait: true` isn't available in the desktop app: the import runs in the background; its outcome comes from export.photosImports {job}",
|
||||
));
|
||||
}
|
||||
Some(Value::Bool(b)) => *b,
|
||||
Some(_) => return Err(bad(ID, "`wait` must be true or false")),
|
||||
};
|
||||
let job = crate::apple_photos::import_job(&runner, &s.apple_photos_imports, paths, album, !wait, Some(&s.activity)).map_err(|e| bad(ID, e))?;
|
||||
match job.outcome() {
|
||||
Some(crate::apple_photos::Outcome::Failed(e)) => Err(bad(ID, e)),
|
||||
_ => Ok(job.json()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `export.photosImports {job?}`: this session's Apple Photos imports, or one of them.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn photos_imports(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "export.photosImports";
|
||||
match p.get("job") {
|
||||
None | Some(Value::Null) => Ok(json!({"imports": s.apple_photos_imports.all().iter().map(|j| j.json()).collect::<Vec<_>>()})),
|
||||
Some(v) => {
|
||||
let id = v.as_u64().ok_or_else(|| bad(ID, "`job` must be an import number"))?;
|
||||
s.apple_photos_imports.get(id).map(|j| j.json()).ok_or_else(|| bad(ID, format!("no import {id} (see export.photosImports)")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
#[allow(unused_mut)]
|
||||
let mut v = vec![
|
||||
cmd!(query "export.contactSheet", "Export Contact Sheet PDF", [], None,
|
||||
"{path, ids?: photo ids (default selection), paper?: a4|letter, landscape?: bool, columns?: 1..8, rows?: 1..10, captions?: bool} → {path, pages, photos, bytes}; replaces an existing output, never an original",
|
||||
always, |s, p| {
|
||||
@@ -123,5 +191,28 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"path": path}))
|
||||
}
|
||||
),
|
||||
]
|
||||
];
|
||||
// Apple Events to Photos' import command (macOS); not in the web build. Not journaled: a
|
||||
// replay must not import again.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
v.push(cmd!(
|
||||
query "export.addToPhotos",
|
||||
"Add to Apple Photos",
|
||||
[],
|
||||
None,
|
||||
"{paths: [absolute file paths], album?: name (an album at the top level of Photos, made when missing; default none), wait?} → once Photos is done, {job, running: false, requested, album, imported, ids: [Photos media item ids], warning?}; with wait: false, at once {job, running: true, requested, album} and the outcome later from export.photosImports {job}. wait defaults to true without a window (lightcraft-cli, the MCP server) and to false in the desktop app, where wait: true is refused (its control requests are answered on the UI thread, which never waits for Photos). macOS: asks Photos to import the files (the first time, macOS asks the user to allow LightCraft to control Photos). One import runs at a time, and an export adding to Photos reserves it before writing: another is refused until it ends. app.export does this for the files it writes with `addToPhotos: true`",
|
||||
photos_enabled,
|
||||
add_to_photos
|
||||
));
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
v.push(cmd!(
|
||||
query "export.photosImports",
|
||||
"Apple Photos Imports",
|
||||
[],
|
||||
None,
|
||||
"{job?} → {imports: [{job, running, exporting?, requested, album, imported?, ids?, warning?, error?, skipped?}]} (this session's, oldest first), or the one job: how Add to Apple Photos is getting on (exporting: reserved by an export still writing its files)",
|
||||
always,
|
||||
photos_imports
|
||||
));
|
||||
v
|
||||
}
|
||||
@@ -464,6 +464,8 @@ fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if st.embedder.as_deref() == Some(id) {
|
||||
// another installed recogniser takes over, so recognition keeps working; with none left nothing is chosen
|
||||
st.embedder = installed_models(&dir).into_iter().find(|i| i.manifest.id != id && i.manifest.role == Role::Embedder).map(|i| i.manifest.id);
|
||||
// on with nothing to run is not a state (issue #678)
|
||||
st.enabled &= st.embedder.is_some();
|
||||
write_settings(&dir, &st)?;
|
||||
}
|
||||
Ok(json!({"removed": id, "embedder": st.embedder}))
|
||||
@@ -491,7 +493,15 @@ fn enable(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "faces.enable";
|
||||
let dir = models_dir(s, C)?;
|
||||
let mut st = read_settings(&dir);
|
||||
st.enabled = p.get("enabled").and_then(Value::as_bool).unwrap_or(!st.enabled);
|
||||
let on = p.get("enabled").and_then(Value::as_bool).unwrap_or(!st.enabled);
|
||||
// on means something only with a model to run (issue #678): a toggle that is on with nothing
|
||||
// chosen would sit there doing nothing, with no word about why
|
||||
let usable =
|
||||
st.embedder.as_deref().is_some_and(|id| installed_models(&dir).iter().any(|i| i.manifest.id == id && i.manifest.role == Role::Embedder));
|
||||
if on && !usable {
|
||||
return Err(bad(C, "choose a recognition model first (Settings ▸ Faces ▸ Use)"));
|
||||
}
|
||||
st.enabled = on;
|
||||
write_settings(&dir, &st)?;
|
||||
// the background scan notices at once, not within the second it trusts its last look
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
//! Reverse geocoding (issue #582): fill City / State / Country from a photo's GPS position, offline, with the
|
||||
//! GeoNames place names ([`lightcraft_geocode`]). The place names (CC-BY 4.0) are not shipped with the app: the user
|
||||
//! downloads them once (`metadata.downloadPlaceNames`, ~3 MB from download.geonames.org) into the settings folder;
|
||||
//! after that every lookup is local and no position ever leaves the machine.
|
||||
//!
|
||||
//! Sources: GeoNames dump readme (prose); own design.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use lightcraft_catalog::{Op, PhotoId};
|
||||
use lightcraft_geocode::{GEONAMES_ATTRIBUTION, GEONAMES_FILES, GeoNames, LatLon};
|
||||
|
||||
use super::{CommandSpec, always, bad, bool_or, cmd, has_selection, str_param};
|
||||
use crate::activity::Cancel;
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// Where the place names live: `dataset` (a folder) or `<settings>/geonames`.
|
||||
fn dataset_dir(p: &Value) -> Option<PathBuf> {
|
||||
str_param(p, "dataset").map(PathBuf::from).or_else(|| crate::config::config_dir().map(|d| d.join("geonames")))
|
||||
}
|
||||
|
||||
/// How a place-names download ended.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum DownloadOutcome {
|
||||
/// Done: this many places.
|
||||
Done(usize),
|
||||
/// Failed, in words the user can act on.
|
||||
Failed(String),
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DownloadShared {
|
||||
running: bool,
|
||||
last: Option<DownloadOutcome>,
|
||||
/// The last outcome, until the UI has shown it ([`PlaceNames::take_outcome`]).
|
||||
unreported: Option<DownloadOutcome>,
|
||||
}
|
||||
|
||||
/// The session's place names: the parsed gazetteer (kept while its file is unchanged) and the download state.
|
||||
#[derive(Default)]
|
||||
pub struct PlaceNames {
|
||||
shared: Arc<Mutex<DownloadShared>>,
|
||||
/// (cities file, its size, its modification time, the parsed names).
|
||||
cache: Option<(PathBuf, u64, Option<SystemTime>, Arc<GeoNames>)>,
|
||||
}
|
||||
|
||||
impl PlaceNames {
|
||||
fn lock(&self) -> std::sync::MutexGuard<'_, DownloadShared> {
|
||||
self.shared.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// A download that ended since the last call (the UI shows it once: an error stays readable as a toast).
|
||||
pub fn take_outcome(&self) -> Option<DownloadOutcome> {
|
||||
self.lock().unreported.take()
|
||||
}
|
||||
|
||||
pub fn downloading(&self) -> bool {
|
||||
self.lock().running
|
||||
}
|
||||
|
||||
/// The gazetteer in `dir`, parsed once and re-read only when the cities file's size or time changes.
|
||||
fn load(&mut self, dir: &Path) -> std::result::Result<Arc<GeoNames>, lightcraft_geocode::GeocodeError> {
|
||||
let file = dir.join(GEONAMES_FILES[0]);
|
||||
let meta = std::fs::metadata(&file).ok();
|
||||
let (len, mtime) = (meta.as_ref().map_or(0, |m| m.len()), meta.as_ref().and_then(|m| m.modified().ok()));
|
||||
if let Some((f, l, t, g)) = &self.cache
|
||||
&& meta.is_some()
|
||||
&& *f == file
|
||||
&& *l == len
|
||||
&& *t == mtime
|
||||
{
|
||||
return Ok(g.clone());
|
||||
}
|
||||
let g = Arc::new(GeoNames::load(dir)?);
|
||||
self.cache = Some((file, len, mtime, g.clone()));
|
||||
Ok(g)
|
||||
}
|
||||
}
|
||||
|
||||
fn status(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let dir = dataset_dir(p);
|
||||
let size = dir.as_ref().and_then(|d| std::fs::metadata(d.join(GEONAMES_FILES[0])).ok()).map(|m| m.len());
|
||||
let sh = s.place_names.lock();
|
||||
let (last, error) = match &sh.last {
|
||||
None => (Value::Null, Value::Null),
|
||||
Some(DownloadOutcome::Done(n)) => (json!({"places": n}), Value::Null),
|
||||
Some(DownloadOutcome::Failed(e)) => (json!("failed"), json!(e)),
|
||||
Some(DownloadOutcome::Cancelled) => (json!("cancelled"), Value::Null),
|
||||
};
|
||||
Ok(json!({
|
||||
"downloaded": size.is_some(), "bytes": size, "path": dir, "downloading": sh.running, "lastDownload": last, "lastError": error,
|
||||
"attribution": GEONAMES_ATTRIBUTION, "source": lightcraft_geocode::GEONAMES_BASE,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Which place fields to write: a field is filled when it is empty, or always with `overwrite`.
|
||||
fn fill(meta: &mut lightcraft_catalog::Meta, place: &lightcraft_geocode::Place, overwrite: bool) {
|
||||
for (field, value) in [
|
||||
(&mut meta.location, &place.sublocation),
|
||||
(&mut meta.city, &place.city),
|
||||
(&mut meta.state, &place.state),
|
||||
(&mut meta.country, &place.country),
|
||||
] {
|
||||
if !value.is_empty() && (overwrite || field.trim().is_empty()) {
|
||||
*field = value.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reverse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "metadata.reverseGeocode";
|
||||
let overwrite = bool_or(p, "overwrite", false);
|
||||
let ids = s.targets(p);
|
||||
let todo: Vec<(PhotoId, LatLon)> = ids
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
let (la, lo) = s.catalog.photo(*id)?.meta.gps?;
|
||||
let at = LatLon::new(la, lo);
|
||||
at.is_valid().then_some((*id, at))
|
||||
})
|
||||
.collect();
|
||||
if todo.is_empty() {
|
||||
return Err(bad(C, "none of these photos has a GPS position"));
|
||||
}
|
||||
let dir = dataset_dir(p).ok_or_else(|| bad(C, "no settings folder: give `dataset`"))?;
|
||||
let names = s.place_names.load(&dir).map_err(|e| bad(C, format!("{e}: download the place names first (Photo ▸ Download Place Names)")))?;
|
||||
// a large selection shows in the activity stack and can be stopped
|
||||
let task = (todo.len() > 1).then(|| s.activity.start("geocode", "Looking up places", Cancel::Yes));
|
||||
let mut ops = Vec::new();
|
||||
let mut found = Vec::new();
|
||||
for (n, (id, at)) in todo.iter().enumerate() {
|
||||
if let Some(t) = &task {
|
||||
if t.cancel_flag().is_some_and(|f| f.load(std::sync::atomic::Ordering::Relaxed)) {
|
||||
return Err(bad(C, "cancelled"));
|
||||
}
|
||||
t.progress(n as u64, todo.len() as u64);
|
||||
}
|
||||
let Some(place) = names.reverse(*at) else { continue };
|
||||
let Some(ph) = s.catalog.photo(*id) else { continue };
|
||||
let mut meta = ph.meta.clone();
|
||||
fill(&mut meta, &place, overwrite);
|
||||
if meta != ph.meta {
|
||||
ops.push(Op::SetMeta { id: *id, meta: Box::new(meta) });
|
||||
}
|
||||
found.push(json!({"id": id.0, "place": place}));
|
||||
}
|
||||
drop(task);
|
||||
let changed = ops.len();
|
||||
if !bool_or(p, "dryRun", false) && changed > 0 {
|
||||
s.commit("Reverse Geocode", Op::Batch { ops })?;
|
||||
}
|
||||
Ok(json!({"looked": todo.len(), "found": found.len(), "changed": changed, "photos": found, "attribution": GEONAMES_ATTRIBUTION}))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod download {
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
use lightcraft_geocode::{GEONAMES_BASE, GEONAMES_ZIP};
|
||||
|
||||
use super::*;
|
||||
use crate::activity::Unit;
|
||||
|
||||
const MAX_ZIP: u64 = 64 * 1024 * 1024;
|
||||
const MAX_TEXT: usize = 256 * 1024 * 1024;
|
||||
|
||||
fn get(name: &str, base: &str, dir: &Path, cancel: &AtomicBool, task: &crate::activity::TaskHandle) -> std::result::Result<(), String> {
|
||||
let _ = std::fs::remove_file(dir.join(name));
|
||||
let file = lightcraft_fetch::FileSpec { name, size: None, sha256: None, max: MAX_ZIP };
|
||||
lightcraft_fetch::download_url(&file, &format!("{base}{name}"), dir, &lightcraft_fetch::Options::default(), cancel, &mut |pr| {
|
||||
task.progress(pr.done, pr.total.max(pr.done))
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Fetch and unpack the place names into `dir`; the new files replace the old ones only when complete.
|
||||
pub(super) fn run(base: &str, dir: &Path, cancel: &AtomicBool, task: &crate::activity::TaskHandle) -> std::result::Result<usize, String> {
|
||||
let staging = dir.join(".download");
|
||||
std::fs::create_dir_all(&staging).map_err(|e| format!("could not create {}: {e}", staging.display()))?;
|
||||
get(GEONAMES_ZIP, base, &staging, cancel, task)?;
|
||||
let zip = std::fs::read(staging.join(GEONAMES_ZIP)).map_err(|e| e.to_string())?;
|
||||
let cities = lightcraft_geocode::unzip::extract(&zip, GEONAMES_FILES[0], MAX_TEXT).map_err(|e| e.to_string())?;
|
||||
let places = GeoNames::parse(&String::from_utf8_lossy(&cities), None, None).len();
|
||||
if places == 0 {
|
||||
return Err("the downloaded place list is empty".into());
|
||||
}
|
||||
let _ = std::fs::remove_file(staging.join(GEONAMES_ZIP));
|
||||
let write = |name: &str, data: &[u8]| std::fs::write(dir.join(name), data).map_err(|e| format!("could not save {name}: {e}"));
|
||||
for name in &GEONAMES_FILES[1..] {
|
||||
// optional: without them states stay blank and countries are ISO codes
|
||||
match get(name, base, &staging, cancel, task).and_then(|()| std::fs::read(staging.join(name)).map_err(|e| e.to_string())) {
|
||||
Ok(b) => write(name, &b)?,
|
||||
Err(e) => log::warn!("place names: {name}: {e}"),
|
||||
}
|
||||
let _ = std::fs::remove_file(staging.join(name));
|
||||
}
|
||||
write(GEONAMES_FILES[0], &cities)?;
|
||||
let _ = std::fs::remove_dir(&staging);
|
||||
Ok(places)
|
||||
}
|
||||
|
||||
/// A download base: https, or plain http to this machine's loopback address only (a test
|
||||
/// server), with nothing but a port or path after the host, so `http://127.0.0.1.example.com/`
|
||||
/// is refused.
|
||||
pub(super) fn base_allowed(base: &str) -> bool {
|
||||
base.starts_with("https://")
|
||||
|| base.strip_prefix("http://127.0.0.1").is_some_and(|rest| rest.is_empty() || rest.starts_with(':') || rest.starts_with('/'))
|
||||
}
|
||||
|
||||
/// `metadata.downloadPlaceNames`: starts a background download (shown in the activity stack, cancellable).
|
||||
pub(super) fn start(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "metadata.downloadPlaceNames";
|
||||
let dir = dataset_dir(p).ok_or_else(|| bad(C, "no settings folder: give `dataset`"))?;
|
||||
let base = str_param(p, "base").unwrap_or(GEONAMES_BASE).to_string();
|
||||
if !base_allowed(&base) {
|
||||
return Err(bad(C, "`base` must be an https:// address"));
|
||||
}
|
||||
let shared = s.place_names.shared.clone();
|
||||
{
|
||||
let mut sh = s.place_names.lock();
|
||||
if sh.running {
|
||||
return Err(bad(C, "the place names are already downloading"));
|
||||
}
|
||||
sh.running = true;
|
||||
}
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
let task = s.activity.start("download", "Downloading place names", Cancel::Flag(cancel.clone()));
|
||||
task.set_unit(Unit::Bytes);
|
||||
task.detail("GeoNames");
|
||||
let handle = task.handle();
|
||||
let thread_dir = dir.clone();
|
||||
let thread_shared = shared.clone();
|
||||
let spawned = std::thread::Builder::new().name("place-names-download".into()).spawn(move || {
|
||||
// the activity row goes when the thread ends, however it ends
|
||||
let _task = task;
|
||||
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&base, &thread_dir, &cancel, &handle)));
|
||||
let outcome = match outcome {
|
||||
Ok(Ok(n)) => DownloadOutcome::Done(n),
|
||||
_ if cancel.load(std::sync::atomic::Ordering::Relaxed) => DownloadOutcome::Cancelled,
|
||||
Ok(Err(e)) => DownloadOutcome::Failed(format!("Could not download the place names: {e}. Check the connection and try again.")),
|
||||
Err(_) => DownloadOutcome::Failed("The place names download stopped unexpectedly. Try again.".into()),
|
||||
};
|
||||
if let DownloadOutcome::Failed(e) = &outcome {
|
||||
log::warn!("{e}");
|
||||
}
|
||||
let mut sh = thread_shared.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
sh.running = false;
|
||||
sh.last = Some(outcome.clone());
|
||||
sh.unreported = Some(outcome);
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
shared.lock().unwrap_or_else(PoisonError::into_inner).running = false;
|
||||
return Err(bad(C, format!("could not start the download: {e}")));
|
||||
}
|
||||
Ok(json!({"started": true, "path": dir, "attribution": GEONAMES_ATTRIBUTION}))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
#[allow(unused_mut)]
|
||||
let mut v = vec![
|
||||
cmd!(
|
||||
"metadata.reverseGeocode",
|
||||
"Fill Location from GPS",
|
||||
["Photo"],
|
||||
None,
|
||||
"{ids?, overwrite?: bool (also replace place fields that already have text; default false: only empty ones are filled), dryRun?, dataset?: folder} → {looked, found, changed, photos: [{id, place: {sublocation, city, state, country, iso, distance}}], attribution} — fills City / State / Country (and Sublocation when known) from GPS with the offline GeoNames place names (download them first); one undo step",
|
||||
has_selection,
|
||||
reverse
|
||||
),
|
||||
cmd!(query "metadata.placeNamesStatus", "Place Names Status", [], None, "{dataset?: folder} → {downloaded, bytes, path, attribution, source}", always, status),
|
||||
];
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
v.push(cmd!(
|
||||
"metadata.downloadPlaceNames",
|
||||
"Download Place Names (GeoNames)",
|
||||
["Photo"],
|
||||
None,
|
||||
"{dataset?: folder} → {started, path, attribution} — downloads the GeoNames cities list (CC-BY 4.0, ~3 MB, from download.geonames.org) in the background for Fill Location from GPS; no photo data is sent",
|
||||
always,
|
||||
download::start
|
||||
));
|
||||
v
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const CITIES: &str = "2988507\tParis\tParis\t\t48.85341\t2.3488\tP\tPPLC\tFR\t\t11\t75\t\t\t2138551\t\t42\tEurope/Paris\t2024-01-01\n\
|
||||
2643743\tLondon\tLondon\t\t51.50853\t-0.12574\tP\tPPLC\tGB\t\tENG\t\t\t\t8961989\t\t25\tEurope/London\t2024-01-01\n";
|
||||
|
||||
fn dataset(tag: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("lightcraft-geocode-cmd-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join(GEONAMES_FILES[0]), CITIES).unwrap();
|
||||
std::fs::write(dir.join(GEONAMES_FILES[1]), "FR.11\tÎle-de-France\nGB.ENG\tEngland\n").unwrap();
|
||||
std::fs::write(dir.join(GEONAMES_FILES[2]), "#c\nFR\tFRA\t250\tFR\tFrance\nGB\tGBR\t826\tUK\tUnited Kingdom\n").unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn set_gps(s: &mut Session, id: PhotoId, gps: Option<(f64, f64)>, city: &str) {
|
||||
let mut meta = s.catalog.photo(id).unwrap().meta.clone();
|
||||
meta.gps = gps;
|
||||
meta.city = city.into();
|
||||
meta.state.clear();
|
||||
meta.country.clear();
|
||||
meta.location.clear();
|
||||
s.commit("test", Op::SetMeta { id, meta: Box::new(meta) }).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fills_empty_fields_keeps_typed_ones_and_undoes() {
|
||||
let mut s = Session::with_demo();
|
||||
let ids = s.visible_cloned();
|
||||
let dir = dataset("fill");
|
||||
set_gps(&mut s, ids[0], Some((48.86, 2.34)), "");
|
||||
set_gps(&mut s, ids[1], Some((51.5, -0.1)), "Westminster");
|
||||
set_gps(&mut s, ids[2], Some((f64::NAN, 0.0)), "");
|
||||
set_gps(&mut s, ids[3], Some((0.0, 0.0)), "");
|
||||
let r = s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0, ids[1].0, ids[2].0, ids[3].0], "dataset": dir})).unwrap();
|
||||
assert_eq!((r["looked"].as_u64(), r["found"].as_u64(), r["changed"].as_u64()), (Some(3), Some(2), Some(2)), "{r}");
|
||||
let m0 = s.catalog.photo(ids[0]).unwrap().meta.clone();
|
||||
assert_eq!((m0.city.as_str(), m0.state.as_str(), m0.country.as_str()), ("Paris", "Île-de-France", "France"));
|
||||
let m1 = s.catalog.photo(ids[1]).unwrap().meta.clone();
|
||||
assert_eq!((m1.city.as_str(), m1.state.as_str(), m1.country.as_str()), ("Westminster", "England", "United Kingdom"));
|
||||
assert!(s.activity.list().is_empty(), "the activity row ends with the command");
|
||||
// one undo step
|
||||
s.undo_step().unwrap();
|
||||
assert!(s.catalog.photo(ids[0]).unwrap().meta.city.is_empty());
|
||||
assert_eq!(s.catalog.photo(ids[1]).unwrap().meta.city, "Westminster");
|
||||
// overwrite replaces typed text
|
||||
s.execute("metadata.reverseGeocode", &json!({"ids": [ids[1].0], "dataset": dir, "overwrite": true})).unwrap();
|
||||
assert_eq!(s.catalog.photo(ids[1]).unwrap().meta.city, "London");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explains_missing_data_and_gps() {
|
||||
let mut s = Session::with_demo();
|
||||
let ids = s.visible_cloned();
|
||||
set_gps(&mut s, ids[0], None, "");
|
||||
let e = s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0]})).unwrap_err();
|
||||
assert!(e.to_string().contains("GPS"), "{e}");
|
||||
set_gps(&mut s, ids[0], Some((48.86, 2.34)), "");
|
||||
let missing = std::env::temp_dir().join(format!("lightcraft-geocode-none-{}", std::process::id()));
|
||||
let e = s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0], "dataset": missing})).unwrap_err();
|
||||
assert!(e.to_string().contains("download"), "{e}");
|
||||
let st = s.execute("metadata.placeNamesStatus", &json!({"dataset": missing})).unwrap();
|
||||
assert_eq!(st["downloaded"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_gazetteer_is_cached_until_its_file_changes() {
|
||||
let dir = dataset("cache");
|
||||
let mut names = PlaceNames::default();
|
||||
let a = names.load(&dir).unwrap();
|
||||
assert!(Arc::ptr_eq(&a, &names.load(&dir).unwrap()), "unchanged file: no re-read");
|
||||
// a different size invalidates it (a new download)
|
||||
std::fs::write(dir.join(GEONAMES_FILES[0]), "1\tRome\tRome\t\t41.9\t12.5\tP\tP\tIT\t\t07\n").unwrap();
|
||||
let b = names.load(&dir).unwrap();
|
||||
assert!(!Arc::ptr_eq(&a, &b));
|
||||
assert_eq!(b.reverse(LatLon::new(41.9, 12.5)).unwrap().city, "Rome");
|
||||
std::fs::remove_file(dir.join(GEONAMES_FILES[0])).unwrap();
|
||||
assert!(names.load(&dir).is_err(), "a deleted file is not served from the cache");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A local HTTP server; the first path segment picks the behaviour: `good` (the synthetic dump), `garbage` (a zip
|
||||
/// that isn't one), `short` (the body ends before its Content-Length), anything else 404.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn serve() -> String {
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = l.local_addr().unwrap().port();
|
||||
let zip = lightcraft_geocode::unzip::make(GEONAMES_FILES[0], CITIES.as_bytes());
|
||||
std::thread::spawn(move || {
|
||||
for conn in l.incoming() {
|
||||
let Ok(mut c) = conn else { continue };
|
||||
let mut r = BufReader::new(c.try_clone().unwrap());
|
||||
let mut line = String::new();
|
||||
let _ = r.read_line(&mut line);
|
||||
loop {
|
||||
let mut h = String::new();
|
||||
if r.read_line(&mut h).unwrap_or(0) == 0 || h.trim().is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let path = line.split(' ').nth(1).unwrap_or("").to_string();
|
||||
let (status, body, len): (&str, Vec<u8>, Option<usize>) = match (path.split('/').nth(1).unwrap_or(""), path.ends_with(".zip")) {
|
||||
("good", true) => ("200 OK", zip.clone(), None),
|
||||
("good", false) if path.contains("countryInfo") => ("200 OK", b"FR\tFRA\t250\tFR\tFrance\n".to_vec(), None),
|
||||
("garbage", true) => ("200 OK", b"<html>not a zip</html>".to_vec(), None),
|
||||
("short", true) => ("200 OK", zip[..zip.len() / 2].to_vec(), Some(zip.len())),
|
||||
_ => ("404 Not Found", b"nope".to_vec(), None),
|
||||
};
|
||||
let n = len.unwrap_or(body.len());
|
||||
let _ = c.write_all(format!("HTTP/1.1 {status}\r\nContent-Length: {n}\r\nConnection: close\r\n\r\n").as_bytes());
|
||||
let _ = c.write_all(&body);
|
||||
}
|
||||
});
|
||||
format!("http://127.0.0.1:{port}")
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn download(s: &mut Session, base: &str, dir: &Path) -> DownloadOutcome {
|
||||
s.execute("metadata.downloadPlaceNames", &json!({"dataset": dir, "base": base})).unwrap();
|
||||
assert!(s.place_names.downloading() || s.place_names.lock().unreported.is_some());
|
||||
let started = std::time::Instant::now();
|
||||
loop {
|
||||
if let Some(o) = s.place_names.take_outcome() {
|
||||
// the activity row ends with the thread
|
||||
while !s.activity.list().is_empty() && started.elapsed().as_secs() < 30 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
assert!(s.activity.list().is_empty());
|
||||
return o;
|
||||
}
|
||||
assert!(started.elapsed().as_secs() < 60, "the download never ended");
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn download_end_to_end_and_its_failures() {
|
||||
let mut s = Session::with_demo();
|
||||
let ids = s.visible_cloned();
|
||||
let base = serve();
|
||||
let dir = std::env::temp_dir().join(format!("lightcraft-geocode-dl-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert!(s.execute("metadata.downloadPlaceNames", &json!({"dataset": dir, "base": "http://example.com/"})).is_err());
|
||||
|
||||
for bad in ["missing", "garbage", "short"] {
|
||||
let o = download(&mut s, &format!("{base}/{bad}/"), &dir);
|
||||
let DownloadOutcome::Failed(e) = o else { panic!("{bad}: {o:?}") };
|
||||
assert!(e.contains("try again"), "{bad}: {e}");
|
||||
let st = s.execute("metadata.placeNamesStatus", &json!({"dataset": dir})).unwrap();
|
||||
assert_eq!((st["downloaded"].as_bool(), st["lastDownload"].as_str()), (Some(false), Some("failed")), "{bad}: {st}");
|
||||
assert_eq!(st["lastError"].as_str(), Some(e.as_str()));
|
||||
assert!(s.place_names.take_outcome().is_none(), "reported once");
|
||||
}
|
||||
|
||||
assert_eq!(download(&mut s, &format!("{base}/good/"), &dir), DownloadOutcome::Done(2));
|
||||
let st = s.execute("metadata.placeNamesStatus", &json!({"dataset": dir})).unwrap();
|
||||
assert_eq!((st["downloaded"].as_bool(), st["lastError"].is_null(), st["lastDownload"]["places"].as_u64()), (Some(true), true, Some(2)));
|
||||
set_gps(&mut s, ids[0], Some((48.86, 2.34)), "");
|
||||
s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0], "dataset": dir})).unwrap();
|
||||
let m = s.catalog.photo(ids[0]).unwrap().meta.clone();
|
||||
// admin1 was a 404 (optional): the state stays blank, the rest is filled
|
||||
assert_eq!((m.city.as_str(), m.state.as_str(), m.country.as_str()), ("Paris", "", "France"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_base_is_https_or_loopback_only() {
|
||||
assert!(download::base_allowed("https://download.geonames.org/export/dump/"));
|
||||
assert!(download::base_allowed("http://127.0.0.1:8080"));
|
||||
assert!(download::base_allowed("http://127.0.0.1/dump"));
|
||||
assert!(!download::base_allowed("http://127.0.0.1.example.com/"));
|
||||
assert!(!download::base_allowed("http://127.0.0.1@example.com/"));
|
||||
assert!(!download::base_allowed("http://download.geonames.org/"));
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
use lightcraft_catalog::{Album, AlbumId, ColorLabel, CopyrightStatus, Filter, Flag, GroupBy, Op, PhotoId, Sort, SortKey};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, bool_or, cmd, has_active, has_selection, ok, str_param};
|
||||
use super::{CommandSpec, always, bad, bool_or, cmd, has_active, has_library_selection, has_selection, ok, str_param};
|
||||
use crate::{LibrarySource, Result, Selection, Session};
|
||||
|
||||
fn select_after_deletion(s: &mut Session, before: &[PhotoId]) {
|
||||
@@ -82,7 +82,7 @@ pub fn import_params(s: &Session, p: &Value) -> Result<ImportRequest> {
|
||||
}
|
||||
let on_deleted = match str_param(p, "onDeleted") {
|
||||
Some(v) => {
|
||||
crate::import::OnDeleted::parse(v).ok_or_else(|| bad("library.import", format!("unknown onDeleted `{v}` (skip|restore|fresh)")))?
|
||||
crate::import::OnDeleted::parse(v).ok_or_else(|| bad("library.import", format!("unknown onDeleted `{v}` (restore|skip|fresh)")))?
|
||||
}
|
||||
None => Default::default(),
|
||||
};
|
||||
@@ -149,6 +149,24 @@ fn ids_param(p: &Value) -> Option<Vec<PhotoId>> {
|
||||
p.get("ids").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(PhotoId).collect())
|
||||
}
|
||||
|
||||
/// The targets an album can hold and how many were browsed (Local) photos, which album views
|
||||
/// never show (issue #684): those are skipped, and reported, instead of becoming invisible members.
|
||||
fn album_targets(s: &Session, targets: Vec<PhotoId>) -> (Vec<PhotoId>, usize) {
|
||||
let before = targets.len();
|
||||
let kept: Vec<PhotoId> = targets.into_iter().filter(|id| s.catalog.photo(*id).is_none_or(|p| !p.local)).collect();
|
||||
let skipped = before - kept.len();
|
||||
(kept, skipped)
|
||||
}
|
||||
|
||||
/// Album targets, or the error for a selection that was nothing but Local photos.
|
||||
fn album_targets_or_err(s: &Session, command: &str, targets: Vec<PhotoId>) -> Result<(Vec<PhotoId>, usize)> {
|
||||
let (kept, skipped) = album_targets(s, targets);
|
||||
if kept.is_empty() && skipped > 0 {
|
||||
return Err(bad(command, super::LOCAL_ONLY));
|
||||
}
|
||||
Ok((kept, skipped))
|
||||
}
|
||||
|
||||
/// Apply one op per target as a single undo step.
|
||||
fn for_targets(s: &mut Session, p: &Value, label: &str, f: impl Fn(PhotoId) -> Option<Op>) -> Result<Value> {
|
||||
let targets = s.targets(p);
|
||||
@@ -866,22 +884,31 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
),
|
||||
// ---- albums
|
||||
cmd!("album.create", "New Album", ["File"], None, "{name, parent?: folderId, folder?: bool, addSelected?: bool}", always, |s, p| {
|
||||
let name = str_param(p, "name").unwrap_or("Untitled Album").trim().to_string();
|
||||
if name.is_empty() {
|
||||
return Err(bad("album.create", "empty name"));
|
||||
cmd!(
|
||||
"album.create",
|
||||
"New Album",
|
||||
["File"],
|
||||
None,
|
||||
"{name, parent?: folderId, folder?: bool, addSelected?: bool} → {id, skipped} (skipped: Local photos of the selection, which albums can't hold)",
|
||||
always,
|
||||
|s, p| {
|
||||
let name = str_param(p, "name").unwrap_or("Untitled Album").trim().to_string();
|
||||
if name.is_empty() {
|
||||
return Err(bad("album.create", "empty name"));
|
||||
}
|
||||
let folder = bool_or(p, "folder", false);
|
||||
let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId);
|
||||
let (photos, skipped) =
|
||||
if !folder && bool_or(p, "addSelected", false) { album_targets(s, s.targets(&Value::Null)) } else { (vec![], 0) };
|
||||
let id = s.catalog.alloc_album_id();
|
||||
let cover = photos.first().copied();
|
||||
s.commit(
|
||||
if folder { "New Folder" } else { "New Album" },
|
||||
Op::AddAlbum { album: Album { id, name, parent, folder, photos, cover, smart: None, quick: false, order: None } },
|
||||
)?;
|
||||
Ok(json!({"id": id.0, "skipped": skipped}))
|
||||
}
|
||||
let folder = bool_or(p, "folder", false);
|
||||
let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId);
|
||||
let photos = if !folder && bool_or(p, "addSelected", false) { s.targets(&Value::Null) } else { vec![] };
|
||||
let id = s.catalog.alloc_album_id();
|
||||
let cover = photos.first().copied();
|
||||
s.commit(
|
||||
if folder { "New Folder" } else { "New Album" },
|
||||
Op::AddAlbum { album: Album { id, name, parent, folder, photos, cover, smart: None, quick: false, order: None } },
|
||||
)?;
|
||||
Ok(json!({"id": id.0}))
|
||||
}),
|
||||
),
|
||||
cmd!(
|
||||
"album.createSmart",
|
||||
"New Smart Album…",
|
||||
@@ -1103,34 +1130,44 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"changed": changed}))
|
||||
}
|
||||
),
|
||||
cmd!("album.addPhotos", "Add to Album", ["Photo"], None, "{id: albumId, ids?: [photoIds]} (default: selection)", has_selection, |s, p| {
|
||||
let id = album_param(p, "id", "album.addPhotos")?;
|
||||
let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null));
|
||||
let al = s.catalog.album(id).ok_or_else(|| bad("album.addPhotos", "no such album"))?;
|
||||
if al.is_smart() || al.folder {
|
||||
return Err(bad("album.addPhotos", "smart albums and folders can't hold photos"));
|
||||
}
|
||||
let mut photos = al.photos.clone();
|
||||
let before = photos.len();
|
||||
for t in targets {
|
||||
if !photos.contains(&t) {
|
||||
photos.push(t);
|
||||
cmd!(
|
||||
"album.addPhotos",
|
||||
"Add to Album",
|
||||
["Photo"],
|
||||
None,
|
||||
"{id: albumId, ids?: [photoIds]} (default: selection) → {added, skipped}; Local photos are skipped (albums hold library photos), only Local ones is an error",
|
||||
has_library_selection,
|
||||
|s, p| {
|
||||
let id = album_param(p, "id", "album.addPhotos")?;
|
||||
let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null));
|
||||
let al = s.catalog.album(id).ok_or_else(|| bad("album.addPhotos", "no such album"))?;
|
||||
if al.is_smart() || al.folder {
|
||||
return Err(bad("album.addPhotos", "smart albums and folders can't hold photos"));
|
||||
}
|
||||
let (targets, skipped) = album_targets_or_err(s, "album.addPhotos", targets)?;
|
||||
let mut photos = al.photos.clone();
|
||||
let before = photos.len();
|
||||
for t in targets {
|
||||
if !photos.contains(&t) {
|
||||
photos.push(t);
|
||||
}
|
||||
}
|
||||
let added = photos.len() - before;
|
||||
let cover = al.cover.or(photos.first().copied());
|
||||
s.commit("Add to Album", Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos }, Op::SetAlbumCover { id, cover }] })?;
|
||||
Ok(json!({"added": added, "skipped": skipped}))
|
||||
}
|
||||
let added = photos.len() - before;
|
||||
let cover = al.cover.or(photos.first().copied());
|
||||
s.commit("Add to Album", Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos }, Op::SetAlbumCover { id, cover }] })?;
|
||||
Ok(json!({"added": added}))
|
||||
}),
|
||||
),
|
||||
cmd!(
|
||||
"album.toggleTarget",
|
||||
"Add to Target Album",
|
||||
[],
|
||||
None,
|
||||
"{ids?} — B: adds the photos to the target album (the Quick Collection unless one is set), or removes them when they're all in it → {album, added}",
|
||||
has_selection,
|
||||
"{ids?} — B: adds the photos to the target album (the Quick Collection unless one is set), or removes them when they're all in it → {album, added, skipped}; Local photos are skipped",
|
||||
has_library_selection,
|
||||
|s, p| {
|
||||
let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null));
|
||||
let targets = album_targets_or_err(s, "album.toggleTarget", ids_param(p).unwrap_or_else(|| s.targets(&Value::Null)))?;
|
||||
let (targets, skipped) = targets;
|
||||
let target = s.target_album.filter(|a| s.catalog.album(*a).is_some_and(|al| !al.is_smart() && !al.folder));
|
||||
let id = match target.or_else(|| s.catalog.quick_collection()) {
|
||||
Some(id) => id,
|
||||
@@ -1154,7 +1191,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
if all_in { "Remove from Target Album" } else { "Add to Target Album" },
|
||||
Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos }, Op::SetAlbumCover { id, cover }] },
|
||||
)?;
|
||||
Ok(json!({"album": id.0, "name": name, "added": !all_in, "count": s.catalog.album_count(id)}))
|
||||
Ok(json!({"album": id.0, "name": name, "added": !all_in, "count": s.catalog.album_count(id), "skipped": skipped}))
|
||||
}
|
||||
),
|
||||
cmd!("album.setTarget", "Set as Target Album", [], None, "{id: albumId | null} (null = the Quick Collection)", always, |s, p| {
|
||||
@@ -1210,7 +1247,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Auto Import Now",
|
||||
[],
|
||||
None,
|
||||
"{listing?: [[path, size]] (the folder as listed by the caller, e.g. on a worker thread), start?: bool (false: return the `library.import` params as `import` instead of importing)} — add the watched folder's new photos (files the library doesn't have yet; partial / still-copying files wait for the next scan) → {imported, folder, import?}",
|
||||
"{listing?: [[path, size]] (the folder as listed by the caller, e.g. on a worker thread), start?: bool (false: return the `library.import` params as `import` instead of importing)} — add the watched folder's new photos (files the library doesn't have yet; partial / still-copying files wait for the next scan) → {imported, folder, warnings?: [{path, code, profile?, mode?, fields?}], import?}",
|
||||
always,
|
||||
|s, p| {
|
||||
let Some(folder) = s.import_defaults.auto_folder.clone() else { return Ok(json!({"imported": [], "folder": null})) };
|
||||
@@ -1246,7 +1283,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
return Ok(json!({"imported": [], "folder": folder}));
|
||||
}
|
||||
let mode = if s.import_defaults.auto_copy { "copy" } else { "add" };
|
||||
let mut params = json!({"paths": fresh, "mode": mode});
|
||||
// a photo deleted from the library doesn't come back on its own because its file
|
||||
// is still in the watched folder (a copy's source is not a known path)
|
||||
let mut params = json!({"paths": fresh, "mode": mode, "onDeleted": "skip"});
|
||||
if let Some(a) = s.import_defaults.auto_album.clone() {
|
||||
match s.catalog.albums().find(|al| al.name.eq_ignore_ascii_case(&a) && !al.folder && !al.is_smart()) {
|
||||
Some(al) => params["album"] = json!(al.id.0),
|
||||
@@ -1264,7 +1303,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let r = s.execute("library.import", ¶ms)?;
|
||||
// arriving photos don't take over the selection
|
||||
s.selection = sel;
|
||||
Ok(json!({"imported": r["imported"], "folder": folder}))
|
||||
let mut result = json!({"imported": r["imported"], "folder": folder});
|
||||
if let Some(warnings) = r.get("warnings") {
|
||||
result["warnings"] = warnings.clone();
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
),
|
||||
cmd!("album.removePhotos", "Remove from Album", [], None, "{id: albumId, ids?}", has_selection, |s, p| {
|
||||
@@ -1326,7 +1369,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Import Photos",
|
||||
["File"],
|
||||
Some("Cmd+Shift+I"),
|
||||
"{paths: [file or folder (recursive)], mode?: add|copy|move (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/; move = as copy, then each original and its XMP sidecars are removed from the source — only after the copy is verified (a hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate and unchecked files keep their sources; a taken name gets -1, -2…; undo removes the photos from the library but leaves the files at the destination), destination?: folder for copies / moves, organize?: date (YYYY/YYYY-MM-DD) | month (YYYY/YYYY-MM) | flat | a folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → 2026/20260114 (the template's `/` make the folders, each level expanded with the rename tokens and made a safe folder name: never outside the destination; must be relative, no `..`; a level with missing metadata is `unknown`) — dated by capture time, else the import time, rename?: file-name template for copies, original extension added (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each; blank = keep names), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG; copy only), local?: bool (browsing: the photos stay out of the library, like library.browse; not with move), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..], onDeleted?: skip|restore|fresh (a file that is in Recently Deleted: skip = leave it there and say so, restore = bring the photo back with its edits, fresh = delete the trashed record and import the file as new)} → {imported, duplicates: [{path, existing, reason, existingDeleted?}], restored?: [photoId], failed, moved?: [{from, to, sidecars?}], kept?: [{path, reason}] (move: sources left in place and why), album?}",
|
||||
"{paths: [file or folder (recursive)], mode?: add|copy|move (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/; move = as copy, then each original and its XMP sidecars are removed from the source — only after the copy is verified (a hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate and unchecked files keep their sources; a taken name gets -1, -2…; undo removes the photos from the library but leaves the files at the destination), destination?: folder for copies / moves, organize?: date (YYYY/YYYY-MM-DD) | month (YYYY/YYYY-MM) | flat | a folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → 2026/20260114 (the template's `/` make the folders, each level expanded with the rename tokens and made a safe folder name: never outside the destination; must be relative, no `..`; a level with missing metadata is `unknown`) — dated by capture time, else the import time, rename?: file-name template for copies, original extension added (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each; blank = keep names), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG; copy only), local?: bool (browsing: the photos stay out of the library, like library.browse; not with move), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..], onDeleted?: restore|skip|fresh (a file that is in Recently Deleted: restore (default) = bring the photo back with its edits — relinked to the file when its own is gone, skip = leave it there and say so, fresh = delete the trashed record and import the file as new)} → {imported, duplicates: [{path, existing, reason, existingDeleted?}], restored?: [photoId], failed, warnings?: [{path, code: cameraProfile|uprightGeometry|unmappedXmp, profile?, mode?: off|auto|level|vertical|full|guided|unknown, fields?}], moved?: [{from, to, sidecars?}], kept?: [{path, reason}] (move: sources left in place and why), album?}",
|
||||
always,
|
||||
|s, p| {
|
||||
let req = import_params(s, p)?;
|
||||
|
||||
@@ -34,22 +34,34 @@ pub fn register_all(s: &Session) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `.cube` files in `paths` (folders searched, zip bundles opened).
|
||||
fn cube_files(paths: &[String]) -> Vec<(String, String, Option<String>)> {
|
||||
/// `.cube` files in `paths` (folders searched, zip bundles opened), and the files that couldn't
|
||||
/// be read with the reason (`[name, reason]`).
|
||||
fn cube_files(paths: &[String]) -> (Vec<(String, String, Option<String>)>, Vec<Value>) {
|
||||
// (name, text, group)
|
||||
let mut out = Vec::new();
|
||||
let mut failed = Vec::new();
|
||||
let stem = |f: &Path| f.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
for p in paths {
|
||||
let path = Path::new(p);
|
||||
if path.is_dir() {
|
||||
let cube = |f: &Path| f.extension().is_some_and(|x| x.eq_ignore_ascii_case("cube"));
|
||||
for ep in crate::walk::files_in(path, None, crate::walk::Limits::default(), cube).files {
|
||||
if let Ok(t) = std::fs::read_to_string(&ep) {
|
||||
let group = ep.parent().and_then(|d| d.file_name()).map(|n| n.to_string_lossy().to_string());
|
||||
out.push((ep.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(), t, group));
|
||||
match std::fs::read_to_string(&ep) {
|
||||
Ok(t) => {
|
||||
let group = ep.parent().and_then(|d| d.file_name()).map(|n| n.to_string_lossy().to_string());
|
||||
out.push((stem(&ep), t, group));
|
||||
}
|
||||
Err(e) => failed.push(json!([stem(&ep), e.to_string()])),
|
||||
}
|
||||
}
|
||||
} else if path.extension().is_some_and(|x| x.eq_ignore_ascii_case("zip")) {
|
||||
let Ok(b) = std::fs::read(path) else { continue };
|
||||
let b = match std::fs::read(path) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
failed.push(json!([stem(path), e.to_string()]));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
for (inner, data) in crate::preset_import::read_zip(&b).unwrap_or_default() {
|
||||
if inner.to_ascii_lowercase().ends_with(".cube") {
|
||||
let ip = Path::new(&inner);
|
||||
@@ -65,11 +77,14 @@ fn cube_files(paths: &[String]) -> Vec<(String, String, Option<String>)> {
|
||||
));
|
||||
}
|
||||
}
|
||||
} else if let Ok(t) = std::fs::read_to_string(path) {
|
||||
out.push((path.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(), t, None));
|
||||
} else {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(t) => out.push((stem(path), t, None)),
|
||||
Err(e) => failed.push(json!([stem(path), e.to_string()])),
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
(out, failed)
|
||||
}
|
||||
|
||||
fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -83,8 +98,9 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(d) = &dir {
|
||||
std::fs::create_dir_all(d).map_err(|e| bad(C, format!("{}: {e}", d.display())))?;
|
||||
}
|
||||
let (mut imported, mut failed) = (Vec::new(), Vec::new());
|
||||
for (name, text, group) in cube_files(&paths) {
|
||||
let (files, mut failed) = cube_files(&paths);
|
||||
let mut imported = Vec::new();
|
||||
for (name, text, group) in files {
|
||||
let lut = match lightcraft_pipeline::lut::Lut3d::parse_cube(&text) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
|
||||
@@ -690,12 +690,69 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"spots": out, "added": found.len()}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"spot.findBlemishes",
|
||||
"Find Blemishes",
|
||||
[],
|
||||
None,
|
||||
"{sensitivity?: 0..100 (50), add?: bool (true), maxPerFace?: 1..20 (6)} — find small blemishes (darker or redder round spots on skin) on the faces of the active photo and add a heal spot on each (one undo step); needs the YuNet face detector (`faces.models.download {id: \"yunet-2023mar\"}`). Faces narrower than 160 px at 1600 px, and faces with more than 12 candidates (stubble, freckles, pores), get none → {faces, spots: [{x, y, size, face}], added}",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let c = "spot.findBlemishes";
|
||||
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
|
||||
let detector = super::face_detect::detector(s).map_err(|e| bad(c, e))?;
|
||||
// the uncropped photo, so positions are the spots' own coordinates
|
||||
let job = s.render_job(id, 1600, 1600, false, false).ok_or_else(|| bad(c, "no photo"))?;
|
||||
let img = job.run().rendered.map_err(|e| bad(c, e))?.image;
|
||||
let rgb: Vec<u8> = img.data.iter().flat_map(|px| [px[0], px[1], px[2]]).collect();
|
||||
let faces =
|
||||
detector.detect(&rgb, img.width, img.height, &lightcraft_faces::yunet::Options::default()).map_err(|e| bad(c, e.to_string()))?;
|
||||
let boxes: Vec<lightcraft_pipeline::blemish::FaceBox> = faces
|
||||
.iter()
|
||||
.map(|f| lightcraft_pipeline::blemish::FaceBox {
|
||||
x0: f64::from(f.x0),
|
||||
y0: f64::from(f.y0),
|
||||
x1: f64::from(f.x1),
|
||||
y1: f64::from(f.y1),
|
||||
landmarks: f.landmarks.map(|(x, y)| (f64::from(x), f64::from(y))),
|
||||
})
|
||||
.collect();
|
||||
let max_per_face = (f64_or(p, "maxPerFace", 6.0) as usize).clamp(1, 20);
|
||||
let mut found = lightcraft_pipeline::blemish::detect(&img, &boxes, f64_or(p, "sensitivity", 50.0) as f32);
|
||||
let mut per_face = std::collections::BTreeMap::new();
|
||||
found.retain(|b| {
|
||||
let n = per_face.entry(b.face).or_insert(0usize);
|
||||
*n += 1;
|
||||
*n <= max_per_face
|
||||
});
|
||||
let out: Vec<Value> = found.iter().map(|b| json!({"x": b.x, "y": b.y, "size": b.radius, "face": b.face})).collect();
|
||||
if !p.get("add").and_then(Value::as_bool).unwrap_or(true) || found.is_empty() {
|
||||
return Ok(json!({"faces": boxes.len(), "spots": out, "added": 0}));
|
||||
}
|
||||
let mut dd = (*s.develop_of(id).unwrap_or_default()).clone();
|
||||
let base = Spot::default();
|
||||
for b in &found {
|
||||
let mut spot = Spot {
|
||||
mode: SpotMode::Heal,
|
||||
points: vec![Point::new(b.x, b.y)],
|
||||
size: b.radius.clamp(SPOT_SIZE.0, SPOT_SIZE.1),
|
||||
feather: base.feather,
|
||||
opacity: 100.0,
|
||||
source_offset: None,
|
||||
};
|
||||
spot.source_offset = pick_source(s, id, &dd, &spot, None);
|
||||
dd.spots.push(spot);
|
||||
}
|
||||
s.set_develop(id, dd, "Find Blemishes")?;
|
||||
Ok(json!({"faces": boxes.len(), "spots": out, "added": found.len()}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"spot.add",
|
||||
"Add Remove Spot",
|
||||
[],
|
||||
None,
|
||||
"{mode?: remove|heal|clone, points: [[x,y],…], size?: fraction of long edge, feather?, opacity?, source?: [dx,dy] (default: the best match nearby)} — selects the new spot; returns {index}",
|
||||
"{mode?: remove|heal|clone|fill (content-aware), points: [[x,y],…], size?: fraction of long edge, feather?, opacity?, source?: [dx,dy] (default: the best match nearby)} — selects the new spot; returns {index}",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let mode: SpotMode =
|
||||
@@ -719,7 +776,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
};
|
||||
let id = s.active().ok_or_else(|| bad("spot.add", "no active photo"))?;
|
||||
let mut dd = (*s.develop_of(id).unwrap_or_default()).clone();
|
||||
if spot.source_offset.is_none() {
|
||||
if spot.source_offset.is_none() && spot.mode != SpotMode::Fill {
|
||||
// resolve the automatic source now: it gets a pin and stays put at every size
|
||||
spot.source_offset = pick_source(s, id, &dd, &spot, None);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ pub(crate) mod face_detect;
|
||||
mod face_models;
|
||||
mod face_recognize;
|
||||
pub mod filters;
|
||||
pub mod geocode;
|
||||
pub mod keywords;
|
||||
pub mod library;
|
||||
pub mod lut_profiles;
|
||||
@@ -100,6 +101,14 @@ pub fn has_active(s: &Session) -> std::result::Result<(), String> {
|
||||
pub fn has_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.selection.ids.is_empty() && s.selection.active.is_none() { Err("no photos selected".into()) } else { Ok(()) }
|
||||
}
|
||||
/// Albums hold library photos: a selection of only browsed (Local) photos has nothing an album
|
||||
/// can take (issue #684; Add to My Photos first).
|
||||
pub fn has_library_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
has_selection(s)?;
|
||||
if s.targets(&serde_json::Value::Null).iter().any(|id| s.catalog.photo(*id).is_none_or(|p| !p.local)) { Ok(()) } else { Err(LOCAL_ONLY.into()) }
|
||||
}
|
||||
/// Why an album command refused a Local-only selection.
|
||||
pub const LOCAL_ONLY: &str = "only Local photos: albums hold library photos (Add to My Photos first)";
|
||||
pub fn can_undo(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.undo.is_empty() { Err("nothing to undo".into()) } else { Ok(()) }
|
||||
}
|
||||
@@ -150,6 +159,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(missing::specs());
|
||||
v.extend(sync::specs());
|
||||
v.extend(metadata::specs());
|
||||
v.extend(geocode::specs());
|
||||
v.extend(filters::specs());
|
||||
v.extend(denoise::specs());
|
||||
v.extend(face_models::specs());
|
||||
|
||||
@@ -52,16 +52,17 @@ impl PreviewBuild {
|
||||
}
|
||||
}
|
||||
|
||||
/// The jobs that build `id`'s previews: the largest grid thumbnail, then the view render at
|
||||
/// `edge` (`None` = 1:1, the photo's full size).
|
||||
fn jobs_for(s: &mut Session, id: lightcraft_catalog::PhotoId, edge: Option<usize>) -> Vec<RenderJob> {
|
||||
/// The jobs that build `id`'s previews: the largest grid thumbnail and the view render at `edge`
|
||||
/// (`None` = 1:1, the photo's full size), the larger first.
|
||||
pub(crate) fn jobs_for(s: &mut Session, id: lightcraft_catalog::PhotoId, edge: Option<usize>) -> Vec<RenderJob> {
|
||||
let Some(p) = s.catalog.photo(id) else { return Vec::new() };
|
||||
let full = p.width.max(p.height) as usize;
|
||||
let e = edge.unwrap_or(full).min(full.max(1)).max(1);
|
||||
let mut v = Vec::new();
|
||||
v.extend(s.thumb_job(id, THUMB_SIZES[THUMB_SIZES.len() - 1]));
|
||||
v.extend(s.loupe_job(id, e, e, true));
|
||||
v
|
||||
let thumb = THUMB_SIZES[THUMB_SIZES.len() - 1];
|
||||
let (view, grid) = (s.loupe_job(id, e, e, true), s.thumb_job(id, thumb));
|
||||
// the larger render goes first: the one after it renders from the source it loaded (see
|
||||
// `run_photo`), which must not be smaller than what it needs
|
||||
if e >= thumb { view.into_iter().chain(grid).collect() } else { grid.into_iter().chain(view).collect() }
|
||||
}
|
||||
|
||||
/// The build's row in the activity stack (`what` as in [`PreviewBuild::what`]).
|
||||
@@ -76,17 +77,66 @@ fn task_for(s: &Session, state: &PreviewBuild) -> TaskGuard {
|
||||
task
|
||||
}
|
||||
|
||||
fn run_all(jobs: Vec<Vec<RenderJob>>, state: &PreviewBuild, task: &TaskGuard) {
|
||||
for photo_jobs in jobs {
|
||||
if state.cancel.load(Ordering::Relaxed) {
|
||||
break;
|
||||
/// Render one photo's jobs in order. The source the first job decodes serves the ones after it
|
||||
/// (the grid thumbnail is made from the view's source instead of decoding the file again).
|
||||
pub(crate) fn run_photo(jobs: Vec<RenderJob>) -> bool {
|
||||
let mut decoded: Option<crate::media::DecodedSource> = None;
|
||||
let mut ok = true;
|
||||
for mut j in jobs {
|
||||
if let Some(src) = &decoded
|
||||
&& !matches!(j.source, crate::media::SourceRef::Loaded(_))
|
||||
{
|
||||
j.source = crate::media::SourceRef::Loaded(Box::new(src.clone()));
|
||||
}
|
||||
let ok = photo_jobs.into_iter().all(|j| j.run().rendered.is_ok());
|
||||
if !ok {
|
||||
state.failed.fetch_add(1, Ordering::Relaxed);
|
||||
let r = j.run();
|
||||
ok &= r.rendered.is_ok();
|
||||
if decoded.is_none() {
|
||||
decoded = r.loaded;
|
||||
}
|
||||
let done = state.done.fetch_add(1, Ordering::Relaxed).saturating_add(1);
|
||||
task.progress(done as u64, state.total as u64);
|
||||
}
|
||||
ok
|
||||
}
|
||||
|
||||
/// How many photos render side by side: as many as an export batch for standard previews (each
|
||||
/// render spreads over the cores already; the others fill them while one decodes), two for 1:1
|
||||
/// previews (a full-size source each).
|
||||
fn lanes(n: usize, full: bool) -> usize {
|
||||
let l = crate::export::export_parallelism(n);
|
||||
if full { l.min(2) } else { l }
|
||||
}
|
||||
|
||||
fn run_all(jobs: Vec<Vec<RenderJob>>, state: &PreviewBuild, task: &TaskGuard, lanes: usize) {
|
||||
let total = jobs.len();
|
||||
let queue = std::sync::Mutex::new(jobs.into_iter());
|
||||
let work = || {
|
||||
loop {
|
||||
if state.cancel.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
let next = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner).next();
|
||||
let Some(photo_jobs) = next else { break };
|
||||
let ok = crate::guard::catch("building previews", || run_photo(photo_jobs)).unwrap_or(false);
|
||||
if !ok {
|
||||
state.failed.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
let done = state.done.fetch_add(1, Ordering::Relaxed).saturating_add(1);
|
||||
task.progress(done as u64, total as u64);
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
std::thread::scope(|sc| {
|
||||
for k in 1..lanes.max(1) {
|
||||
// a lane that can't start leaves its photos to the others (and to this thread)
|
||||
if std::thread::Builder::new().name(format!("previews-{k}")).spawn_scoped(sc, work).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
work();
|
||||
});
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = lanes;
|
||||
work();
|
||||
}
|
||||
state.finished.store(true, Ordering::Relaxed);
|
||||
}
|
||||
@@ -107,9 +157,10 @@ fn build(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let state = Arc::new(PreviewBuild { total: jobs.len(), ..Default::default() });
|
||||
s.preview_build = Some(state.clone());
|
||||
let task = task_for(s, &state);
|
||||
let lanes = lanes(jobs.len(), edge.is_none());
|
||||
let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false) || cfg!(target_arch = "wasm32");
|
||||
if wait {
|
||||
run_all(jobs, &state, &task);
|
||||
run_all(jobs, &state, &task, lanes);
|
||||
return Ok(state.json());
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -118,7 +169,7 @@ fn build(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = state.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("lc-build-previews".into())
|
||||
.spawn(move || run_all(jobs, &st, &task))
|
||||
.spawn(move || run_all(jobs, &st, &task, lanes))
|
||||
.map_err(|e| bad(C, format!("could not start: {e}")))?;
|
||||
}
|
||||
Ok(state.json())
|
||||
@@ -126,7 +177,7 @@ fn build(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// One photo's smart preview to build or discard: (photo, its file in the smart previews folder,
|
||||
/// how to load the original at preview size, whether the photo takes the per-file camera look —
|
||||
/// `camera_preview::file_local_look`, [`lightcraft_catalog::Photo::relative_wb`]: only those
|
||||
/// `camera_preview::file_local_look`, [`lightcraft_catalog::Photo::fitted_look`]: only those
|
||||
/// proxies go stale when the fit changes).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
type SmartJob = (lightcraft_catalog::PhotoId, std::path::PathBuf, Option<crate::media::SourceRef>, bool);
|
||||
@@ -192,6 +243,9 @@ fn smart_run(
|
||||
break;
|
||||
}
|
||||
let _one = SMART_WRITE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
// Read either the new shard or a pre-upgrade flat file, without making new work
|
||||
// depend on having the original online.
|
||||
let path = crate::smart::existing_file_path(dir, path);
|
||||
if discard {
|
||||
if std::fs::remove_file(&path).is_ok() {
|
||||
n.removed += 1;
|
||||
@@ -215,7 +269,14 @@ fn smart_run(
|
||||
.ok_or_else(|| "nothing to build from".to_string())
|
||||
.and_then(|s| s.load_source())
|
||||
.and_then(|src| crate::smart::encode(&src.image, src.info_or(Default::default()).camera_tone.as_ref()))
|
||||
.and_then(|b| lightcraft_catalog::safe_file::write_atomic(&path, &b).map_err(|e| format!("{}: {e}", path.display())));
|
||||
.and_then(|b| {
|
||||
let parent = path.parent().ok_or_else(|| format!("{}: no parent folder", path.display()))?;
|
||||
// Only create the leaf shard: do not recreate a missing external drive.
|
||||
if !parent.is_dir() {
|
||||
std::fs::create_dir(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
|
||||
}
|
||||
lightcraft_catalog::safe_file::write_atomic(&path, &b).map_err(|e| format!("{}: {e}", path.display()))
|
||||
});
|
||||
match r {
|
||||
Ok(()) => {
|
||||
n.built += 1;
|
||||
@@ -270,9 +331,9 @@ fn smart(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !matches!(ph.source, lightcraft_catalog::Source::File { .. }) {
|
||||
continue;
|
||||
}
|
||||
let path = dir.join(crate::smart::file_name(&ph));
|
||||
let path = crate::smart::sharded_file_path(&dir, &ph);
|
||||
let source = (!discard).then(|| s.media.source_ref(&ph, crate::media::SourceLevel::Preview));
|
||||
jobs.push((id, path, source, ph.relative_wb()));
|
||||
jobs.push((id, path, source, ph.fitted_look()));
|
||||
}
|
||||
let custom = s.smart_previews_dir.is_some();
|
||||
if background {
|
||||
@@ -314,9 +375,9 @@ pub(crate) fn refresh_stale_smart_previews(s: &mut Session) -> Option<std::threa
|
||||
return None;
|
||||
}
|
||||
let max_edge = crate::media::SourceLevel::Preview.max_edge();
|
||||
let photos: Vec<_> = s.catalog.photos().filter(|p| matches!(p.source, lightcraft_catalog::Source::File { .. }) && p.relative_wb()).collect();
|
||||
let photos: Vec<_> = s.catalog.photos().filter(|p| matches!(p.source, lightcraft_catalog::Source::File { .. }) && p.fitted_look()).collect();
|
||||
let jobs: Vec<SmartJob> =
|
||||
photos.iter().map(|p| (p.id, dir.join(crate::smart::file_name(p)), Some(s.media.origin_ref(&p.source, max_edge)), true)).collect();
|
||||
photos.iter().map(|p| (p.id, crate::smart::sharded_file_path(&dir, p), Some(s.media.origin_ref(&p.source, max_edge)), true)).collect();
|
||||
std::thread::Builder::new()
|
||||
.name("lc-smart-look".into())
|
||||
.spawn(move || refresh_stale(&dir, jobs))
|
||||
@@ -327,7 +388,11 @@ pub(crate) fn refresh_stale_smart_previews(s: &mut Session) -> Option<std::threa
|
||||
/// The body of [`refresh_stale_smart_previews`]: rebuild the stale proxies among `jobs`.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn refresh_stale(dir: &std::path::Path, jobs: Vec<SmartJob>) {
|
||||
let stale: Vec<SmartJob> = jobs.into_iter().filter(|(_, path, _, _)| crate::smart::is_stale(path)).collect();
|
||||
let stale: Vec<SmartJob> = jobs
|
||||
.into_iter()
|
||||
.map(|(id, path, source, look)| (id, crate::smart::existing_file_path(dir, path), source, look))
|
||||
.filter(|(_, path, _, _)| crate::smart::is_stale(path))
|
||||
.collect();
|
||||
if !stale.is_empty() {
|
||||
// `custom`: the folder is there already, and must not be recreated if it went away since
|
||||
match smart_run(dir, true, false, stale, None) {
|
||||
@@ -410,7 +475,7 @@ fn smart_location(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// Whether photo `id` has a smart preview.
|
||||
pub fn has_smart_preview(s: &Session, id: lightcraft_catalog::PhotoId) -> bool {
|
||||
match (&s.media.smart_dir, s.catalog.photo(id)) {
|
||||
(Some(dir), Some(p)) => crate::smart::is_valid(&dir.join(crate::smart::file_name(p))),
|
||||
(Some(dir), Some(p)) => crate::smart::is_valid(&crate::smart::file_path(dir, p)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -465,3 +530,37 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{jobs_for, run_photo};
|
||||
use crate::Session;
|
||||
use crate::media::SourceRef;
|
||||
|
||||
// Given a photo's preview jobs, the grid thumbnail is made from the source the view decoded, not
|
||||
// by reading the file again (here: a thumbnail job pointing at a file that is not there)
|
||||
#[test]
|
||||
fn preview_build_makes_the_thumbnail_from_the_views_source() {
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.active().unwrap();
|
||||
let mut jobs = jobs_for(&mut s, id, Some(512));
|
||||
assert_eq!(jobs.len(), 2, "view and thumbnail");
|
||||
assert!(jobs[0].view_cache.is_some(), "the view renders first");
|
||||
jobs[1].source = SourceRef::File { path: "/nonexistent/photo.arw".into(), max_edge: 512, loader: None, fallback: None, denoise: None };
|
||||
assert!(run_photo(jobs), "the thumbnail rendered from the view's decoded source");
|
||||
}
|
||||
|
||||
// Given many photos, a standard preview build renders them side by side and counts every one
|
||||
#[test]
|
||||
fn preview_build_of_many_photos_counts_each_once() {
|
||||
let mut s = Session::with_demo();
|
||||
let ids: Vec<u64> = s.catalog.photos().map(|p| p.id.0).collect();
|
||||
assert!(ids.len() > 4);
|
||||
let r = s.execute("library.buildPreviews", &json!({"size": "standard", "edge": 512, "ids": ids, "wait": true})).unwrap();
|
||||
assert_eq!(r["done"], json!(ids.len()), "{r}");
|
||||
assert_eq!(r["total"], json!(ids.len()));
|
||||
assert_eq!(r["failed"], json!(0));
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,12 @@ fn photo_arg(s: &Session, p: &Value, c: &str) -> crate::Result<PhotoId> {
|
||||
}
|
||||
}
|
||||
|
||||
// Catalog queries also run in the 32-bit web build. A narrowing cast wraps
|
||||
// an out-of-range offset to zero, returning the first page instead of no photos.
|
||||
fn pagination_usize(value: u64) -> usize {
|
||||
usize::try_from(value).unwrap_or(usize::MAX)
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "catalog.query", "Query Photos", [], None, "{filter?: Filter, sort?: Sort, offset?, limit?} — omit filter to list the current view", always, |s, p| {
|
||||
@@ -69,8 +75,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
} else {
|
||||
s.visible_cloned()
|
||||
};
|
||||
let off = p.get("offset").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let lim = p.get("limit").and_then(Value::as_u64).unwrap_or(200) as usize;
|
||||
let off = pagination_usize(p.get("offset").and_then(Value::as_u64).unwrap_or(0));
|
||||
let lim = pagination_usize(p.get("limit").and_then(Value::as_u64).unwrap_or(200));
|
||||
let items: Vec<Value> = ids.iter().skip(off).take(lim).filter_map(|id| s.catalog.photo(*id)).map(|p| photo_summary(p)).collect();
|
||||
Ok(json!({"total": ids.len(), "photos": items}))
|
||||
}),
|
||||
@@ -237,3 +243,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pagination_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn offsets_and_limits_saturate_instead_of_wrapping_on_wasm() {
|
||||
assert_eq!(pagination_usize(0), 0);
|
||||
assert_eq!(pagination_usize(200), 200);
|
||||
assert_eq!(pagination_usize(u64::MAX), usize::MAX);
|
||||
#[cfg(target_pointer_width = "32")]
|
||||
assert_eq!(pagination_usize(u64::from(u32::MAX) + 1), usize::MAX);
|
||||
}
|
||||
}
|
||||
@@ -35,11 +35,13 @@ fn read(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut ops = Vec::new();
|
||||
let mut read = Vec::new();
|
||||
let mut failed = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
for id in s.targets(p) {
|
||||
match s.read_sidecar_op(id) {
|
||||
Ok(Some((op, from))) => {
|
||||
match s.read_sidecar_op_with_warnings(id) {
|
||||
Ok(Some((op, from, mut photo_warnings))) => {
|
||||
ops.push(op);
|
||||
read.push(json!({"id": id.0, "from": from.display().to_string()}));
|
||||
warnings.append(&mut photo_warnings);
|
||||
}
|
||||
Ok(None) => failed.push(json!({"id": id.0, "error": "no XMP sidecar or embedded XMP"})),
|
||||
Err(e) => failed.push(json!({"id": id.0, "error": e.to_string()})),
|
||||
@@ -48,7 +50,11 @@ fn read(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !ops.is_empty() {
|
||||
s.commit("Read Metadata from File", Op::Batch { ops })?;
|
||||
}
|
||||
Ok(json!({"read": read, "failed": failed}))
|
||||
let mut result = json!({"read": read, "failed": failed});
|
||||
if !warnings.is_empty() {
|
||||
result["warnings"] = serde_json::to_value(warnings).unwrap_or_default();
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn prefs(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -84,7 +90,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Read Metadata from File",
|
||||
["Photo"],
|
||||
None,
|
||||
"{ids?} — reads each photo's XMP sidecar (or a raw/DNG's embedded XMP): metadata and develop settings, one undo step → {read, failed}",
|
||||
"{ids?} — reads each photo's XMP sidecar (or a raw/DNG's embedded XMP): metadata and develop settings, one undo step → {read, failed, warnings?: [{path, code, profile?, mode?, fields?}]}",
|
||||
has_selection,
|
||||
read
|
||||
),
|
||||
|
||||
+118
-10
@@ -3,24 +3,45 @@
|
||||
//!
|
||||
//! Hosts that have a file system (the desktop app, the CLI, the MCP server) share these defaults so a model
|
||||
//! installed from one is there for the others. Nothing here is applied automatically: a [`Session`] has no
|
||||
//! face- or denoise-models folder until a host asks for one ([`Session::with_default_face_models`],
|
||||
//! [`Session::with_default_denoise_models`]), so tests stay hermetic.
|
||||
//! face-, denoise- or SAM 3 model folder until a host asks for one ([`Session::with_default_face_models`],
|
||||
//! [`Session::with_default_denoise_models`], [`Session::with_default_sam3_model`]), so tests stay hermetic.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// LightCraft's per-user config folder (`…/LightCraft`), if the platform tells us where.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Platform {
|
||||
Mac,
|
||||
Windows,
|
||||
Other,
|
||||
}
|
||||
|
||||
/// LightCraft's settings folder. A nonempty `$LIGHTCRAFT_CONFIG_DIR` overrides the platform default.
|
||||
/// The override is the complete directory; no application-name suffix is appended. An explicitly
|
||||
/// empty value disables the settings directory instead of falling back to a personal profile.
|
||||
pub fn config_dir() -> Option<PathBuf> {
|
||||
if cfg!(target_os = "macos") {
|
||||
std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Application Support/LightCraft"))
|
||||
let platform = if cfg!(target_os = "macos") {
|
||||
Platform::Mac
|
||||
} else if cfg!(windows) {
|
||||
std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("LightCraft"))
|
||||
Platform::Windows
|
||||
} else {
|
||||
std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
|
||||
.map(|c| c.join("lightcraft"))
|
||||
Platform::Other
|
||||
};
|
||||
config_dir_with(platform, |name| std::env::var_os(name))
|
||||
}
|
||||
|
||||
fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
|
||||
if let Some(dir) = env("LIGHTCRAFT_CONFIG_DIR") {
|
||||
return (!dir.is_empty()).then(|| PathBuf::from(dir));
|
||||
}
|
||||
match platform {
|
||||
Platform::Mac => env("HOME").map(|h| PathBuf::from(h).join("Library/Application Support/LightCraft")),
|
||||
Platform::Windows => env("APPDATA").map(|a| PathBuf::from(a).join("LightCraft")),
|
||||
Platform::Other => {
|
||||
env("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| env("HOME").map(|h| PathBuf::from(h).join(".config"))).map(|c| c.join("lightcraft"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +58,25 @@ pub fn default_denoise_models_dir() -> Option<PathBuf> {
|
||||
.or_else(|| config_dir().map(|d| d.join("denoise-models")))
|
||||
}
|
||||
|
||||
/// Where the SAM 3 model is kept: `$LIGHTCRAFT_SAM3_DIR` if set, else `<config>/models/sam3`.
|
||||
pub fn default_sam3_dir() -> Option<PathBuf> {
|
||||
sam3_dir(std::env::var_os("LIGHTCRAFT_SAM3_DIR"), config_dir())
|
||||
}
|
||||
|
||||
/// [`default_sam3_dir`] for a given override and config folder: a non-empty override wins.
|
||||
fn sam3_dir(var: Option<std::ffi::OsString>, config: Option<PathBuf>) -> Option<PathBuf> {
|
||||
var.filter(|v| !v.is_empty()).map(PathBuf::from).or_else(|| config.map(|d| d.join("models").join("sam3")))
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Look for the SAM 3 model in the shared default folder ([`default_sam3_dir`]), and for the user's
|
||||
/// download mirrors in `<config>/models/sam3-mirrors.txt`.
|
||||
pub fn with_default_sam3_model(mut self) -> Self {
|
||||
self.segmenter.dir = default_sam3_dir();
|
||||
self.segmenter.mirrors_file = config_dir().map(|d| d.join("models").join("sam3-mirrors.txt"));
|
||||
self
|
||||
}
|
||||
|
||||
/// Keep face models in the shared default folder ([`default_face_models_dir`]).
|
||||
pub fn with_default_face_models(mut self) -> Self {
|
||||
self.face_models_dir = default_face_models_dir();
|
||||
@@ -56,3 +95,72 @@ impl Session {
|
||||
self.denoise.touch();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod config_tests {
|
||||
use super::*;
|
||||
|
||||
fn resolve(platform: Platform, vars: &[(&str, &str)]) -> Option<PathBuf> {
|
||||
config_dir_with(platform, |name| vars.iter().find_map(|(key, value)| (*key == name).then(|| OsString::from(value))))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_override_wins_without_reading_user_profile() {
|
||||
for platform in [Platform::Mac, Platform::Windows, Platform::Other] {
|
||||
let actual = config_dir_with(platform, |name| {
|
||||
assert_eq!(name, "LIGHTCRAFT_CONFIG_DIR");
|
||||
Some(OsString::from("isolated/settings"))
|
||||
});
|
||||
assert_eq!(actual, Some(PathBuf::from("isolated/settings")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_empty_override_disables_profile_without_reading_defaults() {
|
||||
for platform in [Platform::Mac, Platform::Windows, Platform::Other] {
|
||||
assert_eq!(
|
||||
config_dir_with(platform, |name| {
|
||||
assert_eq!(name, "LIGHTCRAFT_CONFIG_DIR");
|
||||
Some(OsString::new())
|
||||
}),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_unset_override_keeps_platform_defaults() {
|
||||
let vars = [("HOME", "/user"), ("APPDATA", "/roaming"), ("XDG_CONFIG_HOME", "/xdg")];
|
||||
assert_eq!(resolve(Platform::Mac, &vars), Some(PathBuf::from("/user/Library/Application Support/LightCraft")));
|
||||
assert_eq!(resolve(Platform::Windows, &vars), Some(PathBuf::from("/roaming/LightCraft")));
|
||||
assert_eq!(resolve(Platform::Other, &vars), Some(PathBuf::from("/xdg/lightcraft")));
|
||||
assert_eq!(resolve(Platform::Other, &[("HOME", "/user")]), Some(PathBuf::from("/user/.config/lightcraft")));
|
||||
for platform in [Platform::Mac, Platform::Windows, Platform::Other] {
|
||||
assert_eq!(resolve(platform, &[]), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn config_override_preserves_non_unicode_paths() {
|
||||
use std::os::unix::ffi::OsStringExt as _;
|
||||
let value = OsString::from_vec(b"isolated/\xff".to_vec());
|
||||
assert_eq!(config_dir_with(Platform::Mac, |_| Some(value.clone())), Some(PathBuf::from(value)));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A non-empty `LIGHTCRAFT_SAM3_DIR` wins; unset or empty falls back to `<config>/models/sam3` (issue #619).
|
||||
#[test]
|
||||
fn sam3_dir_prefers_the_override() {
|
||||
let config = PathBuf::from("cfg");
|
||||
let fallback = Some(config.join("models").join("sam3"));
|
||||
assert_eq!(sam3_dir(Some("elsewhere".into()), Some(config.clone())), Some(PathBuf::from("elsewhere")));
|
||||
assert_eq!(sam3_dir(None, Some(config.clone())), fallback);
|
||||
assert_eq!(sam3_dir(Some(std::ffi::OsString::new()), Some(config)), fallback);
|
||||
assert_eq!(sam3_dir(None, None), None);
|
||||
}
|
||||
}
|
||||
@@ -138,7 +138,8 @@ impl Prepared {
|
||||
size: 0.5,
|
||||
opacity: 1.0,
|
||||
anchor: Anchor::Center,
|
||||
inset: 0.0,
|
||||
inset_x: 0.0,
|
||||
inset_y: 0.0,
|
||||
color: [32; 3],
|
||||
shadow: false,
|
||||
..Default::default()
|
||||
@@ -305,7 +306,8 @@ mod tests {
|
||||
size: 0.5,
|
||||
opacity: 1.0,
|
||||
anchor: Anchor::Center,
|
||||
inset: 0.0,
|
||||
inset_x: 0.0,
|
||||
inset_y: 0.0,
|
||||
color: [32; 3],
|
||||
shadow: false,
|
||||
..Default::default()
|
||||
@@ -320,7 +322,8 @@ mod tests {
|
||||
size: 0.5,
|
||||
opacity: 1.0,
|
||||
anchor: Anchor::Center,
|
||||
inset: 0.0,
|
||||
inset_x: 0.0,
|
||||
inset_y: 0.0,
|
||||
color: [32; 3],
|
||||
shadow: false,
|
||||
..Default::default()
|
||||
|
||||
+117
-22
@@ -19,10 +19,10 @@ use serde_json::{Map, Value, json};
|
||||
pub type Props = BTreeMap<String, Vec<String>>;
|
||||
|
||||
/// What the mapped settings will develop. It decides how a `crs:` white balance is read: a raw
|
||||
/// with a measured illuminant takes `Temperature` as Kelvin; everything developed relative to its
|
||||
/// as-shot look (rendered files, and raws whose readers have no illuminant,
|
||||
/// [`lightcraft_catalog::relative_wb_format`]) needs a shift from the other app's as-shot white
|
||||
/// instead, because on that scale 6500 K / 0 means *as shot* (issue #510).
|
||||
/// with a Kelvin scale (colour matrices in the file, or measured for its camera) takes `Temperature`
|
||||
/// as Kelvin; everything developed relative to its as-shot look (rendered files, and raws without
|
||||
/// matrices anywhere, [`lightcraft_catalog::relative_wb_camera`]) needs a shift from the other app's
|
||||
/// as-shot white instead, because on that scale 6500 K / 0 means *as shot* (issue #510).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Target {
|
||||
/// A preset: raw or rendered unknown. Absolute Kelvin first, else the relative scale.
|
||||
@@ -30,8 +30,8 @@ pub enum Target {
|
||||
/// A rendered photo (or a raw shown from its embedded preview): `Incremental*` fields on our
|
||||
/// relative scale.
|
||||
Rendered,
|
||||
/// A raw developed with an absolute white balance (DNG and other files with a measured
|
||||
/// illuminant): Kelvin as written.
|
||||
/// A raw developed with an absolute white balance (DNG and other files with matrices, and raws
|
||||
/// whose camera has measured matrices): Kelvin as written.
|
||||
RawAbsolute,
|
||||
/// A raw developed relative to its as-shot look ([`lightcraft_catalog::Photo::relative_wb`]).
|
||||
RawRelative,
|
||||
@@ -39,11 +39,12 @@ pub enum Target {
|
||||
|
||||
impl Target {
|
||||
/// The target for a file of this media kind and format (its extension or Lightroom's
|
||||
/// `fileFormat` name); `preview_only` for a raw that can't be decoded yet.
|
||||
pub fn for_file(kind: lightcraft_catalog::MediaKind, format: &str, preview_only: bool) -> Target {
|
||||
/// `fileFormat` name) shot on `camera` (Exif make and model as the catalog keeps them, or
|
||||
/// empty when unknown); `preview_only` for a raw that can't be decoded yet.
|
||||
pub fn for_file(kind: lightcraft_catalog::MediaKind, format: &str, preview_only: bool, camera: &str) -> Target {
|
||||
if kind != lightcraft_catalog::MediaKind::Raw || preview_only {
|
||||
Target::Rendered
|
||||
} else if lightcraft_catalog::relative_wb_format(format) {
|
||||
} else if lightcraft_catalog::relative_wb_camera(format, camera) {
|
||||
Target::RawRelative
|
||||
} else {
|
||||
Target::RawAbsolute
|
||||
@@ -51,7 +52,7 @@ impl Target {
|
||||
}
|
||||
/// The target for a photo in the catalog.
|
||||
pub fn for_photo(p: &lightcraft_catalog::Photo) -> Target {
|
||||
Target::for_file(p.kind, &p.format, p.preview_only.is_some())
|
||||
Target::for_file(p.kind, &p.format, p.preview_only.is_some(), &p.meta.camera)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,6 +202,10 @@ pub fn to_partial(props: &Props, target: Target) -> Value {
|
||||
n(o, "Shadows2012", "light.shadows");
|
||||
n(o, "Whites2012", "light.whites");
|
||||
n(o, "Blacks2012", "light.blacks");
|
||||
// Lightroom's Light panel "HDR" button
|
||||
if let Some(on) = boolean(props, "crs:HDREditMode") {
|
||||
put(o, "hdr.enabled", json!(on));
|
||||
}
|
||||
// older process versions (most `.lrtemplate` presets): approximate the 2012 sliders from the
|
||||
// earlier ones, relative to their defaults (contrast 25, blacks 5, brightness 50)
|
||||
let has = |o: &Value, path: &str| path.split('.').try_fold(o, |v, k| v.get(k)).is_some();
|
||||
@@ -272,6 +277,11 @@ pub fn to_partial(props: &Props, target: Target) -> Value {
|
||||
if let Some(tint) = tint {
|
||||
put(o, "wb.tint", json!(tint));
|
||||
}
|
||||
// Kelvin as the other app wrote it: say so, so the value stays Kelvin on a raw with that scale (a
|
||||
// preset's Kelvin is read as the relative scale on the files that have only that, as before)
|
||||
if wb == abs && matches!(target, Target::RawAbsolute | Target::Any) {
|
||||
put(o, "wb.scale", json!("kelvin"));
|
||||
}
|
||||
}
|
||||
// a custom Kelvin with nothing to shift it from: read on the relative scale it would be a
|
||||
// large colour shift (ΔE 22 against Lightroom's export on the photos of issue #510), so
|
||||
@@ -462,18 +472,24 @@ pub fn to_partial_report(props: &Props, values: Option<&crate::crs_masks::Values
|
||||
read.extend(crate::crs_masks::CONTAINERS.iter().map(|c| c.to_string()));
|
||||
mask_skips.extend(skipped.into_iter().map(|k| format!("Mask: {k}")));
|
||||
}
|
||||
let (spots, skipped) = crate::crs_masks::spots(values, aspect);
|
||||
if !spots.is_empty() {
|
||||
put(&mut out, "spots", Value::Array(spots));
|
||||
}
|
||||
if values.keys().any(|k| crate::crs_masks::SPOT_CONTAINERS.contains(&k.as_str())) {
|
||||
read.extend(crate::crs_masks::SPOT_CONTAINERS.iter().map(|c| c.to_string()));
|
||||
mask_skips.extend(skipped.into_iter().map(|k| format!("Spot: {k}")));
|
||||
}
|
||||
}
|
||||
// fields that only switch a panel on/off or name things: not adjustments by themselves; an
|
||||
// HDR edit mode that is off, and Point Color slots that are all empty (-1)
|
||||
// fields that only switch a panel on/off or name things: not adjustments by themselves; and
|
||||
// Point Color slots that are all empty (-1)
|
||||
let value_is = |k: &str, f: &dyn Fn(&[String]) -> bool| props.get(&format!("crs:{k}")).is_some_and(|v| f(v));
|
||||
let off = |v: &[String]| v.iter().all(|s| s.trim() == "0");
|
||||
let empty_points = |v: &[String]| v.iter().all(|s| s.split(',').all(|n| n.trim().parse::<f64>().is_ok_and(|x| x == -1.0) || n.trim().is_empty()));
|
||||
let quiet = |k: &str| {
|
||||
k.starts_with("Enable")
|
||||
|| k.starts_with("ToneCurveName")
|
||||
|| k == "AutoTone"
|
||||
|| k == "AutoGrayscaleMix"
|
||||
|| (k == "HDREditMode" && value_is(k, &off))
|
||||
|| (k == "PointColors" && value_is(k, &empty_points))
|
||||
};
|
||||
let mut unmapped: Vec<String> = props
|
||||
@@ -489,6 +505,22 @@ pub fn to_partial_report(props: &Props, values: Option<&crate::crs_masks::Values
|
||||
(out, unmapped)
|
||||
}
|
||||
|
||||
/// The selected camera profile name from an XMP packet. The name is metadata only: it does not
|
||||
/// identify or carry the profile's rendering data.
|
||||
pub fn camera_profile_name(props: &Props) -> Option<&str> {
|
||||
first(props, "crs:CameraProfile")
|
||||
}
|
||||
|
||||
/// Whether an XMP packet contains a saved Lightroom Upright matrix. Lightroom's matrix uses a
|
||||
/// coordinate convention that LightCraft does not currently import.
|
||||
pub fn has_saved_upright_transform(props: &Props) -> bool {
|
||||
props.keys().any(|key| {
|
||||
key.strip_prefix("crs:").is_some_and(|name| {
|
||||
name.starts_with("UprightTransform_") && props.get(key).is_some_and(|values| values.iter().any(|v| !v.trim().is_empty()))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Read an XMP preset (`crs:` fields + `crs:Name` / `crs:Group`) into one of our presets.
|
||||
/// Returns `None` when the packet has no adjustments we understand.
|
||||
pub fn preset_from_xmp(xmp: &str, fallback_name: &str) -> Option<Preset> {
|
||||
@@ -593,6 +625,29 @@ mod tests {
|
||||
assert_eq!(partial, json!({"color": {"vibrance": 30.0}, "grain": {"amount": 10.0}}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_edit_mode_turns_hdr_editing_on_or_off() {
|
||||
let x = |v: &str| {
|
||||
format!(
|
||||
r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description
|
||||
xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:HDREditMode="{v}" crs:Exposure2012="+1.5"/></rdf:RDF>"#
|
||||
)
|
||||
};
|
||||
let on = apply_partial(&DevelopSettings::default(), &to_partial(&props(&x("1")), Target::RawAbsolute), 1.0);
|
||||
assert!(on.hdr.enabled);
|
||||
assert_eq!(on.light.exposure, 1.5);
|
||||
assert_eq!(on.hdr.max_ev, lightcraft_develop::Hdr::DEFAULT_MAX_EV, "the rest of the HDR settings keep their defaults");
|
||||
let true_form = apply_partial(&DevelopSettings::default(), &to_partial(&props(&x("True")), Target::RawAbsolute), 1.0);
|
||||
assert!(true_form.hdr.enabled);
|
||||
let mut hdr = DevelopSettings::default();
|
||||
hdr.hdr.enabled = true;
|
||||
assert!(!apply_partial(&hdr, &to_partial(&props(&x("0")), Target::RawAbsolute), 1.0).hdr.enabled, "0 turns it off");
|
||||
// without the field nothing about HDR changes
|
||||
let plain = r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description
|
||||
xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:Exposure2012="+1.5"/></rdf:RDF>"#;
|
||||
assert!(to_partial(&props(plain), Target::RawAbsolute).get("hdr").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn element_form_bw_and_incremental_wb() {
|
||||
let x = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
@@ -643,6 +698,10 @@ mod tests {
|
||||
assert_eq!(to_partial(&p, Target::Rendered)["wb"]["temp"], rel["wb"]["temp"]);
|
||||
assert_eq!(to_partial(&p, Target::RawAbsolute)["wb"]["temp"], 3578.0);
|
||||
assert_eq!(to_partial(&p, Target::Any)["wb"]["temp"], 3578.0);
|
||||
// Kelvin read as Kelvin says so, so it stays Kelvin on a raw with that scale; a shift does not
|
||||
assert_eq!(to_partial(&p, Target::RawAbsolute)["wb"]["scale"], "kelvin");
|
||||
assert_eq!(to_partial(&p, Target::Any)["wb"]["scale"], "kelvin");
|
||||
assert!(rel["wb"].get("scale").is_none() && to_partial(&p, Target::Rendered)["wb"].get("scale").is_none());
|
||||
// the as-shot fields are a reference, not an adjustment: nothing to report
|
||||
assert!(to_partial_report(&p, None, Target::RawRelative, 1.5).1.is_empty());
|
||||
// no tint next to the as-shot tint: the custom tint is taken as the shift itself
|
||||
@@ -674,14 +733,50 @@ mod tests {
|
||||
#[test]
|
||||
fn target_follows_the_file() {
|
||||
use lightcraft_catalog::MediaKind;
|
||||
assert_eq!(Target::for_file(MediaKind::Raw, "ARW", false), Target::RawRelative);
|
||||
assert_eq!(Target::for_file(MediaKind::Raw, "nef", false), Target::RawRelative);
|
||||
assert_eq!(Target::for_file(MediaKind::Raw, "ARW", false, ""), Target::RawRelative);
|
||||
assert_eq!(Target::for_file(MediaKind::Raw, "nef", false, "NIKON CORPORATION NIKON D7500"), Target::RawRelative);
|
||||
// Lightroom's `fileFormat` name for proprietary raws
|
||||
assert_eq!(Target::for_file(MediaKind::Raw, "RAW", false), Target::RawRelative);
|
||||
assert_eq!(Target::for_file(MediaKind::Raw, "DNG", false), Target::RawAbsolute);
|
||||
assert_eq!(Target::for_file(MediaKind::Raw, "RAW", false, "SONY ILCE-7M2"), Target::RawRelative);
|
||||
assert_eq!(Target::for_file(MediaKind::Raw, "DNG", false, ""), Target::RawAbsolute);
|
||||
// a raw shown from its embedded preview develops like a rendered file
|
||||
assert_eq!(Target::for_file(MediaKind::Raw, "ARW", true), Target::Rendered);
|
||||
assert_eq!(Target::for_file(MediaKind::Image, "JPG", false), Target::Rendered);
|
||||
assert_eq!(Target::for_file(MediaKind::Raw, "ARW", true, "SONY ILCE-7M3"), Target::Rendered);
|
||||
assert_eq!(Target::for_file(MediaKind::Image, "JPG", false, "SONY ILCE-7M3"), Target::Rendered);
|
||||
// a camera with measured matrices edits in Kelvin (issue #730), under any of its names
|
||||
assert_eq!(Target::for_file(MediaKind::Raw, "ARW", false, "SONY ILCE-7M3"), Target::RawAbsolute);
|
||||
assert_eq!(Target::for_file(MediaKind::Raw, "RAW", false, "ILCE-7M3"), Target::RawAbsolute);
|
||||
assert_eq!(Target::for_file(MediaKind::Raw, "NEF", false, "NIKON CORPORATION NIKON D850"), Target::RawAbsolute);
|
||||
assert_eq!(Target::for_file(MediaKind::Raw, "CR2", false, "Canon Canon EOS 5D Mark III"), Target::RawAbsolute);
|
||||
let mut p =
|
||||
lightcraft_catalog::Photo::new(lightcraft_catalog::PhotoId(1), lightcraft_catalog::Source::Demo { scene: 0 }, "a.arw", "ARW", 1, 1, "");
|
||||
p.kind = MediaKind::Raw;
|
||||
assert_eq!(Target::for_photo(&p), Target::RawRelative);
|
||||
p.meta.camera = "SONY ILCE-7M3".into();
|
||||
assert_eq!(Target::for_photo(&p), Target::RawAbsolute);
|
||||
}
|
||||
|
||||
/// Lightroom writes `crs:Temperature` as Kelvin for a raw whose camera LightCraft has matrices for: the value
|
||||
/// is read as written, marked Kelvin, and the as-shot reference is not needed (issue #730); the same packet on
|
||||
/// a raw without matrices still takes the #510 shift.
|
||||
#[test]
|
||||
fn kelvin_is_direct_on_raws_whose_camera_has_matrices() {
|
||||
use lightcraft_catalog::MediaKind;
|
||||
let x = r#"<rdf:Description xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
|
||||
crs:WhiteBalance="Custom" crs:Temperature="4650" crs:Tint="+3" crs:AsShotTemperature="5400" crs:AsShotTint="-1"/>"#;
|
||||
let p = props(x);
|
||||
let a7m3 = Target::for_file(MediaKind::Raw, "ARW", false, "SONY ILCE-7M3");
|
||||
assert_eq!(to_partial(&p, a7m3)["wb"], json!({"mode": "custom", "temp": 4650.0, "tint": 3.0, "scale": "kelvin"}));
|
||||
let a7m2 = Target::for_file(MediaKind::Raw, "ARW", false, "SONY ILCE-7M2");
|
||||
let shifted = to_partial(&p, a7m2);
|
||||
assert!(shifted["wb"]["temp"].as_f64().is_some_and(|t| t > 5000.0 && t < 6500.0), "{shifted}");
|
||||
assert_eq!(shifted["wb"]["tint"], 4.0);
|
||||
assert!(shifted["wb"].get("scale").is_none());
|
||||
// without the reference the Kelvin camera still reads it, the other stays As Shot
|
||||
let bare = props(&x.replace(r#" crs:AsShotTemperature="5400" crs:AsShotTint="-1""#, ""));
|
||||
assert_eq!(to_partial(&bare, a7m3)["wb"]["temp"], 4650.0);
|
||||
assert_eq!(to_partial(&bare, a7m2)["wb"], json!({"mode": "asShot"}));
|
||||
// a named preset carries no numbers either way
|
||||
let daylight = props(&x.replace(r#"crs:WhiteBalance="Custom""#, r#"crs:WhiteBalance="Daylight""#));
|
||||
assert_eq!(to_partial(&daylight, a7m3)["wb"], json!({"mode": "daylight"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -754,10 +849,10 @@ mod tests {
|
||||
fn preset_bookkeeping_is_not_reported_but_real_gaps_are() {
|
||||
let empty_points = "<crs:PointColors><rdf:Seq><rdf:li>-1.000000, -1.000000, -1.000000, -1.000000</rdf:li></rdf:Seq></crs:PointColors>";
|
||||
assert_eq!(unmapped(&preset_packet(r#"crs:HDREditMode="0""#, empty_points)), Vec::<String>::new());
|
||||
// an HDR edit, a used Point Color slot and an unknown adjustment still are
|
||||
// a used Point Color slot and an unknown adjustment still are (an HDR edit is carried over)
|
||||
let used_points = "<crs:PointColors><rdf:Seq><rdf:li>0.5, 0.2, 0.1, 10, 0, 0, 0, 0</rdf:li></rdf:Seq></crs:PointColors>";
|
||||
let got = unmapped(&preset_packet(r#"crs:HDREditMode="1" crs:FutureSlider="12""#, used_points));
|
||||
assert_eq!(got, ["FutureSlider", "HDREditMode", "PointColors"]);
|
||||
assert_eq!(got, ["FutureSlider", "PointColors"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -224,6 +224,120 @@ pub fn masks(values: &Values, aspect: f64) -> (Vec<Value>, Vec<String>) {
|
||||
(out, skipped)
|
||||
}
|
||||
|
||||
/// The containers spot removal is stored in: `RetouchAreas` (current, one struct per spot with a
|
||||
/// mask component) and `RetouchInfo` (older, one `key = value, …` line per spot; still written
|
||||
/// beside `RetouchAreas` for older readers and read on its own when that is all there is).
|
||||
pub const SPOT_CONTAINERS: [&str; 2] = ["crs:RetouchAreas", "crs:RetouchInfo"];
|
||||
|
||||
/// One `RetouchAreas` item → a [`Spot`](lightcraft_develop::Spot) as JSON. `aspect` = width /
|
||||
/// height of the target: horizontal sizes are stored as fractions of the width, ours of the long
|
||||
/// edge. Returns the component kind it can't carry over.
|
||||
fn spot(item: &XmpValue, aspect: f64) -> Result<Value, String> {
|
||||
let mode = match text(item, "crs:SpotType") {
|
||||
Some("clone") => "clone",
|
||||
_ => "heal",
|
||||
};
|
||||
let comps = item.field("crs:Masks").map(XmpValue::items).unwrap_or_default();
|
||||
let Some(m) = comps.first() else { return Err("spot without a mask".into()) };
|
||||
let width_to_long = aspect.min(1.0);
|
||||
let (points, size) = match text(m, "crs:What").unwrap_or("") {
|
||||
// a circle: centre + half-sizes in image fractions
|
||||
"Mask/Ellipse" => {
|
||||
let (Some(x), Some(y), Some(sx)) = (num(m, "crs:X"), num(m, "crs:Y"), num(m, "crs:SizeX")) else {
|
||||
return Err("ellipse spot".into());
|
||||
};
|
||||
(vec![pt(x, y)], sx * width_to_long)
|
||||
}
|
||||
// the shape exiftool's schema also allows for a spot
|
||||
"Mask/CircularGradient" => {
|
||||
let (Some(t), Some(l), Some(b), Some(r)) = (num(m, "crs:Top"), num(m, "crs:Left"), num(m, "crs:Bottom"), num(m, "crs:Right")) else {
|
||||
return Err("circular spot".into());
|
||||
};
|
||||
(vec![pt((l + r) / 2.0, (t + b) / 2.0)], (r - l).abs() / 2.0 * width_to_long)
|
||||
}
|
||||
// a brushed removal: the dabs are the path
|
||||
"Mask/Paint" => {
|
||||
let pts: Vec<Value> = m
|
||||
.field("crs:Dabs")
|
||||
.map(XmpValue::items)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter_map(XmpValue::text)
|
||||
.filter_map(|d| {
|
||||
let mut it = d.split_whitespace().skip(1).filter_map(|v| v.parse::<f64>().ok());
|
||||
Some(pt(it.next()?, it.next()?))
|
||||
})
|
||||
.collect();
|
||||
if pts.is_empty() {
|
||||
return Err("empty brushed spot".into());
|
||||
}
|
||||
(pts, num(m, "crs:Radius").unwrap_or(0.02))
|
||||
}
|
||||
other => return Err(if other.is_empty() { "unknown spot".into() } else { other.trim_start_matches("Mask/").to_string() }),
|
||||
};
|
||||
let size = size.clamp(0.001, 0.25);
|
||||
// the source is stored as its absolute position (`SourceX`, and `OffsetY` despite the name);
|
||||
// ours is the offset from the target; anything but an explicit source leaves it automatic
|
||||
let explicit = text(item, "crs:SourceState").is_none_or(|s| s == "sourceSetExplicitly");
|
||||
let source_offset = match (explicit, num(item, "crs:SourceX"), num(item, "crs:OffsetY"), points.first()) {
|
||||
(true, Some(sx), Some(sy), Some(p)) => {
|
||||
let (Some(x), Some(y)) = (p["x"].as_f64(), p["y"].as_f64()) else { return Err("spot centre".into()) };
|
||||
let (dx, dy) = (sx - x, sy - y);
|
||||
(dx.is_finite() && dy.is_finite() && (dx != 0.0 || dy != 0.0)).then(|| pt(dx, dy))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let feather = (num(item, "crs:Feather").unwrap_or(0.0) * 100.0).clamp(0.0, 100.0);
|
||||
let opacity = (num(item, "crs:Opacity").unwrap_or(1.0) * 100.0).clamp(0.0, 100.0);
|
||||
Ok(json!({"mode": mode, "points": points, "size": size, "feather": feather, "opacity": opacity, "source_offset": source_offset}))
|
||||
}
|
||||
|
||||
/// One `RetouchInfo` line (`centerX = 0.5, centerY = 0.5, radius = 0.01, sourceState = …,
|
||||
/// sourceX = …, sourceY = …, spotType = heal`) → a spot as JSON.
|
||||
fn legacy_spot(line: &str, aspect: f64) -> Option<Value> {
|
||||
let mut kv = BTreeMap::new();
|
||||
for part in line.split(',') {
|
||||
if let Some((k, v)) = part.split_once('=') {
|
||||
kv.insert(k.trim().to_string(), v.trim().to_string());
|
||||
}
|
||||
}
|
||||
let f = |k: &str| kv.get(k).and_then(|s| s.parse::<f64>().ok()).filter(|x| x.is_finite());
|
||||
let (x, y, r) = (f("centerX")?, f("centerY")?, f("radius")?);
|
||||
let mode = if kv.get("spotType").is_some_and(|s| s == "clone") { "clone" } else { "heal" };
|
||||
let explicit = kv.get("sourceState").is_none_or(|s| s == "sourceSetExplicitly");
|
||||
let source_offset = match (explicit, f("sourceX"), f("sourceY")) {
|
||||
(true, Some(sx), Some(sy)) if sx != x || sy != y => Some(pt(sx - x, sy - y)),
|
||||
_ => None,
|
||||
};
|
||||
let opacity = (f("opacity").unwrap_or(1.0) * 100.0).clamp(0.0, 100.0);
|
||||
Some(
|
||||
json!({"mode": mode, "points": [pt(x, y)], "size": (r * aspect.min(1.0)).clamp(0.001, 0.25), "feather": 0.0, "opacity": opacity, "source_offset": source_offset}),
|
||||
)
|
||||
}
|
||||
|
||||
/// Spot removal in `values` (JSON for `DevelopSettings::spots`), plus the kinds of spot that
|
||||
/// couldn't be carried over. `aspect` = width / height of the target (presets: assume 3:2).
|
||||
pub fn spots(values: &Values, aspect: f64) -> (Vec<Value>, Vec<String>) {
|
||||
let mut out = Vec::new();
|
||||
let mut skipped = Vec::new();
|
||||
if let Some(list) = values.get("crs:RetouchAreas") {
|
||||
for item in list.items() {
|
||||
match spot(item, aspect) {
|
||||
Ok(v) => out.push(v),
|
||||
Err(kind) => skipped.push(kind),
|
||||
}
|
||||
}
|
||||
}
|
||||
if out.is_empty()
|
||||
&& let Some(list) = values.get("crs:RetouchInfo")
|
||||
{
|
||||
out.extend(list.items().iter().filter_map(XmpValue::text).filter_map(|l| legacy_spot(l, aspect)));
|
||||
}
|
||||
skipped.sort();
|
||||
skipped.dedup();
|
||||
(out, skipped)
|
||||
}
|
||||
|
||||
/// A `.lrtemplate` Lua value as an XMP value (field names get the `crs:` prefix).
|
||||
pub fn from_lua(v: &crate::preset_import::Lua) -> XmpValue {
|
||||
use crate::preset_import::Lua;
|
||||
@@ -376,4 +490,91 @@ mod tests {
|
||||
assert!((s["rx"].as_f64().unwrap() - 0.3).abs() < 1e-9);
|
||||
assert!((s["ry"].as_f64().unwrap() - 0.3).abs() < 1e-9, "square photo: y radius = its box half-height");
|
||||
}
|
||||
|
||||
/// Spot removal in the shape Lightroom Classic writes it (values written for this test):
|
||||
/// a healed circle with an explicit source, a cloned circle with an automatic source, a
|
||||
/// brushed removal, a circular-gradient spot, one without a mask, and the legacy list.
|
||||
const SPOTS: &str = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:Exposure2012="0.1">
|
||||
<crs:RetouchInfo><rdf:Seq>
|
||||
<rdf:li>centerX = 0.4, centerY = 0.3, radius = 0.01, sourceState = sourceSetExplicitly, sourceX = 0.45, sourceY = 0.3, spotType = heal</rdf:li>
|
||||
</rdf:Seq></crs:RetouchInfo>
|
||||
<crs:RetouchAreas><rdf:Seq>
|
||||
<rdf:li><rdf:Description crs:SpotType="heal" crs:SourceState="sourceSetExplicitly" crs:Method="poisson" crs:SourceX="0.45" crs:OffsetY="0.3" crs:Opacity="1" crs:Feather="0" crs:Seed="1">
|
||||
<crs:Masks><rdf:Seq><rdf:li crs:What="Mask/Ellipse" crs:MaskActive="true" crs:MaskValue="1" crs:X="0.4" crs:Y="0.3" crs:SizeX="0.01" crs:SizeY="0.015"/></rdf:Seq></crs:Masks>
|
||||
</rdf:Description></rdf:li>
|
||||
<rdf:li><rdf:Description crs:SpotType="clone" crs:SourceState="sourceAutoComputed" crs:Opacity="0.5" crs:Feather="0.25">
|
||||
<crs:Masks><rdf:Seq><rdf:li crs:What="Mask/Ellipse" crs:X="0.7" crs:Y="0.6" crs:SizeX="0.02" crs:SizeY="0.02"/></rdf:Seq></crs:Masks>
|
||||
</rdf:Description></rdf:li>
|
||||
<rdf:li><rdf:Description crs:SpotType="heal" crs:SourceState="sourceSetExplicitly" crs:SourceX="0.2" crs:OffsetY="0.9">
|
||||
<crs:Masks><rdf:Seq><rdf:li crs:What="Mask/Paint" crs:Radius="0.03" crs:MaskValue="1">
|
||||
<crs:Dabs><rdf:Seq><rdf:li>d 0.1 0.8</rdf:li><rdf:li>d 0.12 0.82</rdf:li><rdf:li>d 0.14 0.84</rdf:li></rdf:Seq></crs:Dabs></rdf:li></rdf:Seq></crs:Masks>
|
||||
</rdf:Description></rdf:li>
|
||||
<rdf:li><rdf:Description crs:SpotType="heal" crs:SourceX="0.6" crs:OffsetY="0.5">
|
||||
<crs:Masks><rdf:Seq><rdf:li crs:What="Mask/CircularGradient" crs:Top="0.48" crs:Left="0.49" crs:Bottom="0.52" crs:Right="0.51"/></rdf:Seq></crs:Masks>
|
||||
</rdf:Description></rdf:li>
|
||||
<rdf:li><rdf:Description crs:SpotType="heal">
|
||||
<crs:Masks><rdf:Seq><rdf:li crs:What="Mask/Gradient" crs:FullX="0" crs:FullY="0" crs:ZeroX="1" crs:ZeroY="1"/></rdf:Seq></crs:Masks>
|
||||
</rdf:Description></rdf:li>
|
||||
</rdf:Seq></crs:RetouchAreas>
|
||||
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
|
||||
|
||||
#[test]
|
||||
fn retouch_areas_become_spots() {
|
||||
use lightcraft_develop::SpotMode;
|
||||
let d = lightcraft_meta::parse_xmp(SPOTS).unwrap();
|
||||
let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5);
|
||||
let s = DevelopSettings::default().merged(&partial).expect("valid settings");
|
||||
assert_eq!(s.light.exposure, 0.1, "global settings still map");
|
||||
assert_eq!(s.spots.len(), 4, "the gradient 'spot' is skipped: {:?}", s.spots);
|
||||
let heal = &s.spots[0];
|
||||
assert_eq!((heal.mode, heal.points.len(), heal.opacity, heal.feather), (SpotMode::Heal, 1, 100.0, 0.0));
|
||||
assert!((heal.points[0].x - 0.4).abs() < 1e-9 && (heal.points[0].y - 0.3).abs() < 1e-9);
|
||||
assert!((heal.size - 0.01).abs() < 1e-9, "a landscape target: width fractions are long-edge fractions");
|
||||
let src = heal.source_offset.expect("explicit source");
|
||||
assert!((src.x - 0.05).abs() < 1e-9 && src.y.abs() < 1e-9, "source stored absolute, ours an offset: {src:?}");
|
||||
let clone = &s.spots[1];
|
||||
assert_eq!((clone.mode, clone.opacity, clone.feather, clone.source_offset), (SpotMode::Clone, 50.0, 25.0, None));
|
||||
let brushed = &s.spots[2];
|
||||
assert_eq!((brushed.points.len(), brushed.size), (3, 0.03));
|
||||
let src = brushed.source_offset.expect("explicit source");
|
||||
assert!((src.x - 0.1).abs() < 1e-9 && (src.y - 0.1).abs() < 1e-9);
|
||||
let circ = &s.spots[3];
|
||||
assert!((circ.points[0].x - 0.5).abs() < 1e-9 && (circ.points[0].y - 0.5).abs() < 1e-9 && (circ.size - 0.01).abs() < 1e-9);
|
||||
assert_eq!(unmapped, vec!["Spot: Gradient".to_string()], "{unmapped:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portrait_target_scales_spot_sizes() {
|
||||
let d = lightcraft_meta::parse_xmp(SPOTS).unwrap();
|
||||
let (partial, _) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 2.0 / 3.0);
|
||||
let s = DevelopSettings::default().merged(&partial).unwrap();
|
||||
assert!((s.spots[0].size - 0.01 * 2.0 / 3.0).abs() < 1e-9, "a width fraction on a portrait target: {}", s.spots[0].size);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_retouch_info_is_read_on_its_own() {
|
||||
let packet = SPOTS.replace("crs:RetouchAreas", "crs:Ignored");
|
||||
let d = lightcraft_meta::parse_xmp(&packet).unwrap();
|
||||
let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5);
|
||||
let s = DevelopSettings::default().merged(&partial).unwrap();
|
||||
assert_eq!(s.spots.len(), 1, "{:?}", s.spots);
|
||||
assert_eq!(s.spots[0].mode, lightcraft_develop::SpotMode::Heal);
|
||||
assert!((s.spots[0].points[0].x - 0.4).abs() < 1e-9 && (s.spots[0].size - 0.01).abs() < 1e-9);
|
||||
assert!(s.spots[0].source_offset.is_some_and(|o| (o.x - 0.05).abs() < 1e-9));
|
||||
assert!(unmapped.iter().any(|u| u == "Ignored"), "{unmapped:?}");
|
||||
assert!(!unmapped.iter().any(|u| u == "RetouchInfo"), "{unmapped:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spots_render() {
|
||||
let d = lightcraft_meta::parse_xmp(SPOTS).unwrap();
|
||||
let (partial, _) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5);
|
||||
let s = DevelopSettings::default().merged(&partial).unwrap();
|
||||
let img = lightcraft_scenes::demo_library()[0].render(96, 64);
|
||||
let render = |d: &DevelopSettings| {
|
||||
lightcraft_pipeline::render(&img, &Default::default(), d, &lightcraft_pipeline::RenderRequest::fit(96, 64)).image.data
|
||||
};
|
||||
assert_ne!(render(&DevelopSettings { light: s.light, ..Default::default() }), render(&s), "the spots change the picture");
|
||||
}
|
||||
}
|
||||
+296
-15
@@ -157,6 +157,97 @@ struct Network {
|
||||
fallbacks: AtomicUsize,
|
||||
/// Tiles at once of the photo started last (0: none yet), to say where the work runs.
|
||||
last_threads: AtomicUsize,
|
||||
/// Another computer's graphics card (`LIGHTCRAFT_DENOISE_URL` + `LIGHTCRAFT_DENOISE_TOKEN`; see
|
||||
/// `docs/denoise.md` → Another computer's graphics card), greeted on first use: it takes tiles while it has a free
|
||||
/// slot, on top of this computer's runner.
|
||||
remote: Option<RemoteSide>,
|
||||
/// Whether this computer's share runs on the card (else the CPU), as chosen for the photo started last.
|
||||
local_card: AtomicBool,
|
||||
}
|
||||
|
||||
#[cfg(feature = "denoise")]
|
||||
struct RemoteSide {
|
||||
runner: lightcraft_denoise::remote::Remote,
|
||||
/// The greeting, and the server's time for the check tile there and back (ms; the faster of two runs).
|
||||
greeted: OnceLock<Result<(lightcraft_denoise::remote::ServerInfo, f64), String>>,
|
||||
/// Whether the photo started last shares tiles with the server, and if not because it is slower, the two times.
|
||||
share: AtomicBool,
|
||||
slower: Mutex<Option<(f64, f64)>>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "denoise")]
|
||||
impl RemoteSide {
|
||||
/// From the environment, for the model whose file has `sha256` and tiles of `tile` cells.
|
||||
fn from_env(sha256: &str, tile: usize) -> Option<RemoteSide> {
|
||||
let var = |k: &str| std::env::var(k).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty());
|
||||
let address = var("LIGHTCRAFT_DENOISE_URL")?;
|
||||
let token = var("LIGHTCRAFT_DENOISE_TOKEN").unwrap_or_default();
|
||||
Some(RemoteSide::new(lightcraft_denoise::remote::Remote::new(&address, &token, sha256, tile)))
|
||||
}
|
||||
|
||||
fn new(runner: lightcraft_denoise::remote::Remote) -> RemoteSide {
|
||||
RemoteSide { runner, greeted: OnceLock::new(), share: AtomicBool::new(false), slower: Mutex::new(None) }
|
||||
}
|
||||
|
||||
/// Greet the server and time a check tile there and back: a server behind a slow link can take seconds a tile, and
|
||||
/// handing it tiles a fast local card would finish sooner only makes the photo wait for it.
|
||||
fn greet(&self) -> Result<(lightcraft_denoise::remote::ServerInfo, f64), String> {
|
||||
let info = self.runner.hello().map_err(|e| e.to_string())?;
|
||||
let input = lightcraft_denoise::runtime::check_tile(info.tile);
|
||||
let mut best = f64::INFINITY;
|
||||
for _ in 0..2 {
|
||||
let started = web_time::Instant::now();
|
||||
match self.runner.try_run(&input) {
|
||||
Some(Ok(_)) => best = best.min(started.elapsed().as_secs_f64() * 1000.0),
|
||||
Some(Err(e)) => return Err(format!("the check tile failed: {e}")),
|
||||
None => return Err("no free slot for the check tile".into()),
|
||||
}
|
||||
}
|
||||
Ok((info, best))
|
||||
}
|
||||
|
||||
/// The server when it can take tiles now (greeting it the first time), else why not.
|
||||
fn usable(&self, start: bool) -> Result<&lightcraft_denoise::remote::Remote, String> {
|
||||
let greeted = if start { Some(self.greeted.get_or_init(|| self.greet())) } else { self.greeted.get() };
|
||||
match greeted {
|
||||
None => Err("not contacted yet".into()),
|
||||
Some(Err(why)) => Err(why.clone()),
|
||||
Some(Ok(_)) if !self.runner.healthy() => {
|
||||
Err(format!("it failed {} tiles ({})", self.runner.failures(), self.runner.last_error().unwrap_or_default()))
|
||||
}
|
||||
Some(Ok(_)) => Ok(&self.runner),
|
||||
}
|
||||
}
|
||||
|
||||
/// The server's time for a tile, once greeted.
|
||||
fn tile_ms(&self) -> Option<f64> {
|
||||
self.greeted.get().and_then(|g| g.as_ref().ok()).map(|g| g.1)
|
||||
}
|
||||
|
||||
fn describe(&self) -> Value {
|
||||
match self.greeted.get() {
|
||||
None => json!({"address": self.runner.address(), "state": "pending"}),
|
||||
Some(Err(why)) => json!({"address": self.runner.address(), "state": "unavailable", "reason": why}),
|
||||
Some(Ok((info, ms))) => {
|
||||
let slower = *self.slower.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let state = match () {
|
||||
_ if !self.runner.healthy() => "given up",
|
||||
_ if slower.is_some() => "slower",
|
||||
_ if self.share.load(Ordering::Relaxed) => "used",
|
||||
_ => "idle",
|
||||
};
|
||||
let mut v =
|
||||
json!({"address": info.address, "device": info.device, "state": state, "remoteMs": ms, "failures": self.runner.failures()});
|
||||
if let (Some((_, local)), Some(o)) = (slower, v.as_object_mut()) {
|
||||
o.insert("localMs".into(), json!(local));
|
||||
}
|
||||
if let (Some(e), Some(o)) = (self.runner.last_error(), v.as_object_mut()) {
|
||||
o.insert("lastError".into(), json!(e));
|
||||
}
|
||||
v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "denoise")]
|
||||
@@ -338,10 +429,19 @@ impl Network {
|
||||
}
|
||||
}
|
||||
|
||||
/// The card, with the CPU running any tile it gets wrong. Handed out only for photos that run on the card.
|
||||
/// A remote server's free slot first (when there is one), then the card when this photo runs on it, with the CPU
|
||||
/// running any tile either gets wrong. Handed out for photos that run on the card or have a server to share.
|
||||
#[cfg(feature = "denoise")]
|
||||
impl TileRunner for Network {
|
||||
fn run(&self, input: &[f32]) -> Result<Vec<f32>, RunError> {
|
||||
if let Some(Ok(remote)) = self.remote.as_ref().filter(|r| r.share.load(Ordering::Relaxed)).map(|r| r.usable(false))
|
||||
&& let Some(Ok(out)) = remote.try_run(input)
|
||||
{
|
||||
return Ok(out);
|
||||
}
|
||||
if !self.local_card.load(Ordering::Relaxed) {
|
||||
return self.cpu.run(input);
|
||||
}
|
||||
if let Ok(g) = self.card(false) {
|
||||
match g.runner.run(input) {
|
||||
Ok(out) if out.iter().all(|v| v.is_finite()) => return Ok(out),
|
||||
@@ -361,10 +461,37 @@ impl Model for Network {
|
||||
fn runner(&self, run_on: RunOn, threads: usize) -> (&dyn TileRunner, usize) {
|
||||
let threads = threads.max(1);
|
||||
self.last_threads.store(threads, Ordering::Relaxed);
|
||||
match self.choose(run_on, threads, true) {
|
||||
Ok(_) => (self, threads.min(GPU_PARALLEL)),
|
||||
Err(_) => (&self.cpu, threads),
|
||||
let (card, local) = match self.choose(run_on, threads, true) {
|
||||
Ok(_) => (true, threads.min(GPU_PARALLEL)),
|
||||
Err(_) => (false, threads),
|
||||
};
|
||||
self.local_card.store(card, Ordering::Relaxed);
|
||||
if let Some(r) = &self.remote {
|
||||
// this computer's time for a tile: the card's, or the CPU's with `threads` tiles sharing it (as
|
||||
// `card_is_faster` counts them); unknown when the card was never set up (then the server is used)
|
||||
let local_ms = match self.gpu.get() {
|
||||
Some(Ok(g)) if card => g.card_ms,
|
||||
Some(Ok(g)) => g.cpu_ms / (1.0 + 0.5 * threads.saturating_sub(1) as f64),
|
||||
_ => f64::INFINITY,
|
||||
};
|
||||
let share = match (r.usable(true), r.tile_ms()) {
|
||||
(Ok(_), Some(ms)) if ms < local_ms => {
|
||||
*r.slower.lock().unwrap_or_else(PoisonError::into_inner) = None;
|
||||
true
|
||||
}
|
||||
(Ok(_), Some(ms)) => {
|
||||
*r.slower.lock().unwrap_or_else(PoisonError::into_inner) = Some((ms, local_ms));
|
||||
false
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
r.share.store(share, Ordering::Relaxed);
|
||||
if let (true, Ok(remote)) = (share, r.usable(false)) {
|
||||
// the server's slots come on top of this computer's own tiles at once
|
||||
return (self, local + remote.slots());
|
||||
}
|
||||
}
|
||||
if card { (self, local) } else { (&self.cpu, local) }
|
||||
}
|
||||
|
||||
fn self_test(&self, run_on: RunOn) -> Result<Value, String> {
|
||||
@@ -403,6 +530,9 @@ impl Model for Network {
|
||||
o.insert("cpuMs".into(), json!(g.cpu_ms));
|
||||
o.insert("fellBack".into(), json!(self.fallbacks.load(Ordering::Relaxed)));
|
||||
}
|
||||
if let (Some(r), Some(o)) = (&self.remote, v.as_object_mut()) {
|
||||
o.insert("remote".into(), r.describe());
|
||||
}
|
||||
v
|
||||
}
|
||||
}
|
||||
@@ -412,15 +542,14 @@ pub(crate) fn default_loader() -> Loader {
|
||||
#[cfg(feature = "denoise")]
|
||||
{
|
||||
Arc::new(|path, manifest| {
|
||||
let cpu = lightcraft_denoise::runtime::CpuRunner::load(path, manifest).map_err(|e| e.to_string())?;
|
||||
Ok(Arc::new(Network {
|
||||
cpu,
|
||||
path: path.to_path_buf(),
|
||||
tile: manifest.tile as usize,
|
||||
gpu: OnceLock::new(),
|
||||
fallbacks: AtomicUsize::new(0),
|
||||
last_threads: AtomicUsize::new(0),
|
||||
}) as Arc<dyn Model>)
|
||||
// the server must run this very file: identified by the manifest's hash, else the file's
|
||||
let sha256 = match &manifest.sha256 {
|
||||
Some(h) => Some(h.clone()),
|
||||
None if std::env::var_os("LIGHTCRAFT_DENOISE_URL").is_some() => lightcraft_denoise::hash::sha256_file(path).ok(),
|
||||
None => None,
|
||||
};
|
||||
let remote = sha256.and_then(|h| RemoteSide::from_env(&h, manifest.tile as usize));
|
||||
Ok(Arc::new(network(path, manifest, remote)?) as Arc<dyn Model>)
|
||||
})
|
||||
}
|
||||
#[cfg(not(feature = "denoise"))]
|
||||
@@ -429,6 +558,69 @@ pub(crate) fn default_loader() -> Loader {
|
||||
}
|
||||
}
|
||||
|
||||
/// The model at `path` on this computer, sharing tiles with `remote` when there is one.
|
||||
#[cfg(feature = "denoise")]
|
||||
fn network(path: &Path, manifest: &DenoiserManifest, remote: Option<RemoteSide>) -> Result<Network, String> {
|
||||
let cpu = lightcraft_denoise::runtime::CpuRunner::load(path, manifest).map_err(|e| e.to_string())?;
|
||||
Ok(Network {
|
||||
remote,
|
||||
local_card: AtomicBool::new(true),
|
||||
cpu,
|
||||
path: path.to_path_buf(),
|
||||
tile: manifest.tile as usize,
|
||||
gpu: OnceLock::new(),
|
||||
fallbacks: AtomicUsize::new(0),
|
||||
last_threads: AtomicUsize::new(0),
|
||||
})
|
||||
}
|
||||
|
||||
/// A model ready to be served to other computers (`lightcraft-cli denoise-serve`).
|
||||
#[cfg(feature = "denoise")]
|
||||
pub struct Serving {
|
||||
pub model: lightcraft_denoise::remote::ModelId,
|
||||
pub tile: usize,
|
||||
/// Where its tiles run here: the graphics card's name, or why the CPU does.
|
||||
pub device: String,
|
||||
pub run: lightcraft_denoise::remote::RunTile,
|
||||
}
|
||||
|
||||
/// The installed model `id` (default: the one selected in Settings) in the models folder `dir` (default: this user's),
|
||||
/// set up to run on `run_on` as the app would: the card checked against the CPU first, the CPU when the card can't.
|
||||
#[cfg(feature = "denoise")]
|
||||
pub fn serving(dir: Option<&Path>, id: Option<&str>, run_on: RunOn) -> Result<Serving, String> {
|
||||
let dir = match dir {
|
||||
Some(d) => d.to_path_buf(),
|
||||
None => crate::config::default_denoise_models_dir().ok_or("no denoise models folder (set LIGHTCRAFT_DENOISE_MODELS)")?,
|
||||
};
|
||||
let want = match id {
|
||||
Some(i) => i.to_string(),
|
||||
None => read_settings(&dir).model.ok_or_else(|| {
|
||||
format!("no denoise model is selected in {} (install one in Settings ▸ AI Denoise, or name it with --model)", dir.display())
|
||||
})?,
|
||||
};
|
||||
let installed = installed_models(&dir)
|
||||
.into_iter()
|
||||
.find(|m| m.manifest.id == want)
|
||||
.ok_or_else(|| format!("no installed denoise model `{want}` in {}", dir.display()))?;
|
||||
let m = &installed.manifest;
|
||||
// the same identity a client derives (see `default_loader`)
|
||||
let sha256 = match &m.sha256 {
|
||||
Some(h) => h.clone(),
|
||||
None => lightcraft_denoise::hash::sha256_file(&installed.onnx).map_err(|e| format!("{}: {e}", installed.onnx.display()))?,
|
||||
};
|
||||
let net = Arc::new(network(&installed.onnx, m, None)?);
|
||||
let tile = m.tile as usize;
|
||||
// set the card up now (and say where the tiles run) rather than with the first client's tile
|
||||
let _ = net.runner(run_on, GPU_PARALLEL);
|
||||
let d = net.device(run_on);
|
||||
let device = match d["kind"].as_str() {
|
||||
Some("gpu") => format!("GPU: {}", d["adapter"].as_str().unwrap_or("?")),
|
||||
_ => format!("CPU ({})", d["reason"].as_str().unwrap_or("chosen")),
|
||||
};
|
||||
let run: lightcraft_denoise::remote::RunTile = Arc::new(move |input: &[f32]| net.runner(run_on, GPU_PARALLEL).0.run(input));
|
||||
Ok(Serving { model: lightcraft_denoise::remote::ModelId { id: m.id.clone(), version: m.version.clone(), sha256 }, tile, device, run })
|
||||
}
|
||||
|
||||
/// A model that is loaded on first use and kept, shared by the background job and the exports.
|
||||
pub(crate) type ModelSlot = Arc<Mutex<Option<Arc<dyn Model>>>>;
|
||||
|
||||
@@ -1008,16 +1200,18 @@ impl Session {
|
||||
}
|
||||
|
||||
/// Make the index of ready photos from the products on disk (a directory listing and a hash per photo: no file is
|
||||
/// opened). Pictures that appeared or vanished drop the decoded sources they affect.
|
||||
/// opened). Pictures that appeared or vanished drop the decoded sources they affect. The photo being made is its
|
||||
/// job's to report (see [`Self::denoise_check_photo`]).
|
||||
pub(crate) fn denoise_reindex(&mut self) {
|
||||
self.denoise.dirty = false;
|
||||
self.denoise.reindexed = Some((self.catalog.revision, web_time::Instant::now()));
|
||||
let running = self.denoise.running.as_ref().map(|r| r.photo);
|
||||
let mut now: HashMap<PhotoId, DenoiseSpec> = HashMap::new();
|
||||
if let (Some(dir), Some(active)) = (self.denoise_products_dir(), self.denoise.active.as_ref()) {
|
||||
let names: HashSet<String> =
|
||||
products_in(&dir).into_iter().filter_map(|(p, _, _)| p.file_stem().and_then(|s| s.to_str()).map(str::to_string)).collect();
|
||||
if !names.is_empty() {
|
||||
for p in self.catalog.photos().filter(|p| eligible(p)) {
|
||||
for p in self.catalog.photos().filter(|p| eligible(p) && running != Some(p.id)) {
|
||||
let key = product_key(&active.fingerprint, p);
|
||||
if names.contains(&key) {
|
||||
now.insert(p.id, DenoiseSpec { product: product_path(&dir, &key), key, make: None });
|
||||
@@ -1046,7 +1240,14 @@ impl Session {
|
||||
}
|
||||
|
||||
/// A photo whose file changed (its content key is not the one its index entry was made for) has no picture.
|
||||
///
|
||||
/// The photo whose picture is being made is left to its job: the worker saves the picture before it reports, so the
|
||||
/// file is on disk while the job still counts as running. Finding it here would make the photo Ready while the session
|
||||
/// is busy with it (#719); [`Self::denoise_poll`] makes it Ready in the pump that takes the job's result instead.
|
||||
fn denoise_check_photo(&mut self, id: PhotoId) {
|
||||
if self.denoise.running.as_ref().is_some_and(|r| r.photo == id) {
|
||||
return;
|
||||
}
|
||||
let Some(p) = self.catalog.photo(id).cloned() else { return };
|
||||
let Some((spec, _)) = self.denoise_spec(&p) else { return };
|
||||
match self.media.denoise.spec(id) {
|
||||
@@ -1399,6 +1600,12 @@ impl Session {
|
||||
self.denoise.made
|
||||
}
|
||||
|
||||
/// The running job's product path and key (tests stand in for the worker's save).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn denoise_running_product(&self) -> Option<(PathBuf, String)> {
|
||||
self.denoise.running.as_ref().map(|r| (r.product.clone(), r.key.clone()))
|
||||
}
|
||||
|
||||
/// What an export of photo `id` with `settings` needs for its Denoise amount: its denoised picture, made now when the
|
||||
/// queue has not got to it. `Ok(None)` when denoise does not apply (amount 0, not a Bayer raw), `Err` when it should
|
||||
/// have and cannot (no model chosen, nowhere to keep the picture, the model failing).
|
||||
@@ -1502,6 +1709,76 @@ mod gpu_tests {
|
||||
let _ = std::fs::remove_dir_all(path.parent().unwrap());
|
||||
}
|
||||
|
||||
/// A server on this computer running `cpu` for the model with `sha256`.
|
||||
fn server(cpu: lightcraft_denoise::runtime::CpuRunner, sha256: &str, tile: usize) -> String {
|
||||
use lightcraft_denoise::remote::{ModelId, ServerOptions, serve};
|
||||
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = l.local_addr().unwrap().to_string();
|
||||
let id = ModelId { id: "synthetic".into(), version: "1".into(), sha256: sha256.into() };
|
||||
let run: lightcraft_denoise::remote::RunTile = Arc::new(move |input: &[f32]| cpu.run(input));
|
||||
let opts = ServerOptions { token: "remote-test-token-0123".into(), ..Default::default() };
|
||||
std::thread::spawn(move || serve(l, id, tile, "CPU (test server)".into(), run, opts, Arc::new(|_| {})));
|
||||
addr
|
||||
}
|
||||
|
||||
fn with_remote(path: &Path, tile: u64, addr: &str, sha256: &str) -> Network {
|
||||
let manifest = DenoiserManifest {
|
||||
id: "synthetic".into(),
|
||||
name: "Synthetic".into(),
|
||||
version: "1".into(),
|
||||
licence: Default::default(),
|
||||
source: None,
|
||||
sha256: None,
|
||||
size_bytes: None,
|
||||
provenance: String::new(),
|
||||
domain: Domain::BayerToRgb,
|
||||
tile: tile as u32,
|
||||
overlap: 16,
|
||||
gain: Gain::MatchMean { nominal: 1.0, max_deviation: 0.05 },
|
||||
};
|
||||
let remote = RemoteSide::new(lightcraft_denoise::remote::Remote::new(addr, "remote-test-token-0123", sha256, tile as usize));
|
||||
network(path, &manifest, Some(remote)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_remote_server_shares_the_tiles_and_drops_out_when_it_cannot() {
|
||||
let (_, cpu, path) = model("remote", 64);
|
||||
let sha = lightcraft_denoise::hash::sha256_file(&path).unwrap();
|
||||
let (input, _) = lightcraft_denoise::runtime::test_tile(64);
|
||||
let want = cpu.run(&input).unwrap();
|
||||
// the same model on the server: its slots come on top of this computer's threads, answers within f16 rounding
|
||||
let addr = server(lightcraft_denoise::runtime::CpuRunner::load(&path, &cpu.manifest().clone()).unwrap(), &sha, 64);
|
||||
let net = with_remote(&path, 64, &addr, &sha);
|
||||
let (runner, at_once) = net.runner(RunOn::Cpu, 2);
|
||||
assert_eq!(at_once, 2 + 4, "four server slots on top of two local threads");
|
||||
assert!(worst(&want, &runner.run(&input).unwrap()) < 5e-3);
|
||||
let d = net.device(RunOn::Cpu);
|
||||
assert_eq!(d["remote"]["state"], "used", "{d}");
|
||||
assert_eq!(d["remote"]["device"], "CPU (test server)");
|
||||
// a local card faster than the server there and back (here a stand-in that takes no time): nothing is shared,
|
||||
// the photo never waits for the slower server, and the status says why
|
||||
let mut net = with_remote(&path, 64, &addr, &sha);
|
||||
net.gpu = OnceLock::from(Ok(GpuSide { runner: Box::new(cpu.clone()), adapter: "Fast card".into(), card_ms: 1e-6, cpu_ms: 1000.0 }));
|
||||
let (_, at_once) = net.runner(RunOn::Gpu, 8);
|
||||
assert_eq!(at_once, GPU_PARALLEL, "no server slots");
|
||||
let d = net.device(RunOn::Gpu);
|
||||
assert_eq!(d["remote"]["state"], "slower", "{d}");
|
||||
assert!(d["remote"]["remoteMs"].as_f64().is_some_and(|ms| ms > 0.0) && d["remote"]["localMs"].as_f64().is_some(), "{d}");
|
||||
// a server running another model is refused: everything runs here
|
||||
let other = server(lightcraft_denoise::runtime::CpuRunner::load(&path, &cpu.manifest().clone()).unwrap(), "0000", 64);
|
||||
let net = with_remote(&path, 64, &other, &sha);
|
||||
let (runner, at_once) = net.runner(RunOn::Cpu, 2);
|
||||
assert_eq!(at_once, 2);
|
||||
assert_eq!(runner.run(&input).unwrap(), want, "the CPU path, bit for bit");
|
||||
let d = net.device(RunOn::Cpu);
|
||||
assert!(d["remote"]["reason"].as_str().is_some_and(|r| r.contains("another model")), "{d}");
|
||||
// nobody listening: the same
|
||||
let net = with_remote(&path, 64, "127.0.0.1:1", &sha);
|
||||
assert_eq!(net.runner(RunOn::Cpu, 3).1, 3);
|
||||
assert_eq!(net.device(RunOn::Cpu)["remote"]["state"], "unavailable");
|
||||
let _ = std::fs::remove_dir_all(path.parent().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_the_processor_chosen_everything_runs_on_the_cpu() {
|
||||
let (model, cpu, path) = model("cpu", 64);
|
||||
@@ -1548,6 +1825,8 @@ mod gpu_tests {
|
||||
gpu: OnceLock::from(Ok(card)),
|
||||
fallbacks: AtomicUsize::new(0),
|
||||
last_threads: AtomicUsize::new(0),
|
||||
remote: None,
|
||||
local_card: AtomicBool::new(true),
|
||||
};
|
||||
let (input, _) = lightcraft_denoise::runtime::test_tile(64);
|
||||
let want = cpu.run(&input).unwrap();
|
||||
@@ -1576,6 +1855,8 @@ mod gpu_tests {
|
||||
gpu: OnceLock::from(Ok(slow)),
|
||||
fallbacks: AtomicUsize::new(0),
|
||||
last_threads: AtomicUsize::new(0),
|
||||
remote: None,
|
||||
local_card: AtomicBool::new(true),
|
||||
};
|
||||
assert_eq!(model.runner(RunOn::Auto, 16).1, 16, "sixteen threads beat it");
|
||||
let device = model.device(RunOn::Auto);
|
||||
|
||||
+602
-68
File diff suppressed because it is too large.
Load diff
+320
-24
@@ -44,12 +44,13 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option<String>) {
|
||||
(meta, m.capture_time.as_ref().map(|d| d.to_iso()))
|
||||
}
|
||||
|
||||
/// Lens corrections embedded in a raw's `OpcodeList3` (`WarpRectilinear`, `FixVignetteRadial`: a DNG's own, or the
|
||||
/// Lens corrections embedded in a raw's `OpcodeList3` (`WarpRectilinear`, `WarpRectilinear2`, `FixVignetteRadial`: a DNG's own, or the
|
||||
/// raw reader's equivalent of the camera's correction, e.g. Panasonic / Leica RW2 distortion), re-expressed for the
|
||||
/// default-cropped, EXIF-oriented image. These are the only "profile" corrections LightCraft applies.
|
||||
pub fn embedded_lens(raw: &lightcraft_raw::RawInfo) -> Option<lightcraft_develop::EmbeddedLens> {
|
||||
use lightcraft_develop::{EmbeddedLens, EmbeddedVignette, EmbeddedWarp};
|
||||
use lightcraft_develop::{EmbeddedLens, EmbeddedVignette, EmbeddedWarp, EmbeddedWarpStage, WARP_STAGES};
|
||||
use lightcraft_geom::Point;
|
||||
use lightcraft_raw::Opcode;
|
||||
let (aw, ah) = (raw.active_area.width as f64, raw.active_area.height as f64);
|
||||
if aw < 2.0 || ah < 2.0 {
|
||||
return None;
|
||||
@@ -69,21 +70,54 @@ pub fn embedded_lens(raw: &lightcraft_raw::RawInfo) -> Option<lightcraft_develop
|
||||
(Point::new((px + 0.5 - cx0) / cw, (py + 0.5 - cy0) / ch), m / long)
|
||||
};
|
||||
let mut lens = EmbeddedLens::default();
|
||||
for op in &raw.opcodes.list3 {
|
||||
// opcodes in list order, minus the ones the DNG 1.6 skip rule drops (a WarpRectilinear that follows an optional
|
||||
// WarpRectilinear2)
|
||||
let ops: Vec<&Opcode> = lightcraft_raw::opcodes::applied_ops(&raw.opcodes.list3).collect();
|
||||
// A DNG with a WarpRectilinear2 keeps every warp opcode as a chain of stages (each warps the one before's output);
|
||||
// without one the first WarpRectilinear is the lens warp, as it always was.
|
||||
let chain = ops.iter().any(|op| matches!(op, Opcode::WarpRectilinear2 { .. }));
|
||||
let mut stages = Vec::new();
|
||||
for op in ops {
|
||||
match op {
|
||||
lightcraft_raw::Opcode::WarpRectilinear { planes, center } if !planes.is_empty() && lens.warp.is_none() => {
|
||||
Opcode::WarpRectilinear2 { planes, center, reciprocal, .. } if !planes.is_empty() => {
|
||||
let (center, radius) = centre(*center);
|
||||
let p = |i: usize| planes[i.min(planes.len() - 1)];
|
||||
stages.push(EmbeddedWarpStage { planes: [p(0), p(1), p(2)], center, radius, reciprocal: *reciprocal });
|
||||
}
|
||||
Opcode::WarpRectilinear { planes, center } if chain && !planes.is_empty() => {
|
||||
// WarpRectilinear is the even-power case: kr0 + kr1 r² + kr2 r⁴ + kr3 r⁶, no radius limit
|
||||
let (center, radius) = centre(*center);
|
||||
let p = |i: usize| {
|
||||
let k = planes[i.min(planes.len() - 1)];
|
||||
let mut s = [0.0; lightcraft_raw::opcodes::WR2_LEN];
|
||||
s[0] = k[0];
|
||||
s[2] = k[1];
|
||||
s[4] = k[2];
|
||||
s[6] = k[3];
|
||||
s[15] = k[4];
|
||||
s[16] = k[5];
|
||||
s[18] = 1e6;
|
||||
s
|
||||
};
|
||||
stages.push(EmbeddedWarpStage { planes: [p(0), p(1), p(2)], center, radius, reciprocal: false });
|
||||
}
|
||||
Opcode::WarpRectilinear { planes, center } if !chain && !planes.is_empty() && lens.warp.is_none() => {
|
||||
let (center, radius) = centre(*center);
|
||||
let p = |i: usize| planes[i.min(planes.len() - 1)];
|
||||
lens.warp = Some(EmbeddedWarp { planes: [p(0), p(1), p(2)], center, radius });
|
||||
}
|
||||
lightcraft_raw::Opcode::FixVignetteRadial { k, center } if lens.vignette.is_none() => {
|
||||
Opcode::FixVignetteRadial { k, center } if lens.vignette.is_none() => {
|
||||
let (center, radius) = centre(*center);
|
||||
lens.vignette = Some(EmbeddedVignette { k: *k, center, radius });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if lens.warp.is_none() && lens.vignette.is_none() {
|
||||
// (more stages than a lens keeps: the first ones; real files carry two)
|
||||
for (slot, st) in lens.stages.iter_mut().zip(stages.into_iter().take(WARP_STAGES)) {
|
||||
*slot = Some(st);
|
||||
}
|
||||
if lens.warp.is_none() && lens.vignette.is_none() && !lens.has_stages() {
|
||||
return None;
|
||||
}
|
||||
Some(lightcraft_pipeline::optics::reorient_lens(&lens, raw.orientation, cw, ch))
|
||||
@@ -131,11 +165,9 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
|
||||
if raw.orientation.swaps_axes() {
|
||||
std::mem::swap(&mut w, &mut h);
|
||||
}
|
||||
let (t, tint) = xy_to_temp_tint(lightcraft_raw::color::as_shot_white_xy_of(&raw));
|
||||
// Vendor RGB multipliers do not identify an absolute illuminant without camera calibration.
|
||||
let relative = crate::camera_preview::file_local_look(raw.format) && !lightcraft_raw::color::has_matrix(&raw.color);
|
||||
let as_shot_wb = Some(if relative { (6500.0, 0.0) } else { (t.round(), tint.round()) });
|
||||
let embedded_lens = embedded_lens(&raw);
|
||||
let scale = WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata);
|
||||
let as_shot_wb = Some(scale.as_shot(lightcraft_raw::color::as_shot_white_xy_of(&raw)));
|
||||
let embedded_lens = crate::lens_profiles::for_raw(&raw, || m.clone());
|
||||
return Ok(ProbeInfo {
|
||||
embedded_lens,
|
||||
width: w,
|
||||
@@ -365,10 +397,11 @@ fn load_bytes_now(
|
||||
// Embedded lens corrections are applied by the pipeline ("Enable Profile Corrections"), not baked in
|
||||
// (removed before the camera look's binned sensor proxy too, which needs an empty `OpcodeList3`).
|
||||
let info = raw.info();
|
||||
let lens = embedded_lens(&info);
|
||||
let lens = crate::lens_profiles::for_raw(&info, || lightcraft_meta::extract(&bytes));
|
||||
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
|
||||
// Without colour matrices of its own, white balance is relative to the as-shot look (`Photo::relative_wb`).
|
||||
let own_matrix = lightcraft_raw::color::has_matrix(&raw.color);
|
||||
// Which scale white balance is edited on, and the as-shot white on it, from the file's own colour tags or
|
||||
// the camera's measured matrices (`Photo::relative_wb` decides the same from the catalog's camera)
|
||||
let scale = WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata);
|
||||
// the source's segmentation mattes (DNG semantic masks), read while the starting colour is fitted
|
||||
let ((xy, t, camera_look), mattes) = rayon::join(|| crate::camera_preview::starting_colour(&mut raw, &bytes), || dng_mattes(&bytes, &info));
|
||||
drop(bytes);
|
||||
@@ -435,17 +468,17 @@ fn load_bytes_now(
|
||||
eprintln!("[profile] raw source {}×{} (max {max_edge}, ms after decode): {}", img.width, img.height, parts.join(", "));
|
||||
}
|
||||
let twin = twin.map(|picture| finish(picture).0);
|
||||
let (temp, tint) = xy_to_temp_tint(xy);
|
||||
let relative = crate::camera_preview::file_local_look(raw.format) && !own_matrix;
|
||||
// White balance re-evaluates the file's own colour model (when it has one and no
|
||||
// file-local look matrix sits on top of it)
|
||||
let camera_color = (!t.matrix_is_fallback && camera_look.is_none()).then(|| {
|
||||
// White balance re-evaluates the file's own colour model (when it has one and no file-local look matrix sits
|
||||
// on top of it). A model's measured matrices never do: they only give the Kelvin scale its numbers, and the
|
||||
// picture is adapted from the as-shot white as every fitted look is (so settings from before that scale
|
||||
// existed render exactly as they did, `lightcraft_pipeline::local::legacy_white`).
|
||||
let camera_color = (scale.own_matrix && !t.matrix_is_fallback && camera_look.is_none()).then(|| {
|
||||
let tags = lightcraft_raw::ColorData { profile: Default::default(), ..raw.color.clone() };
|
||||
Arc::new(lightcraft_pipeline::CameraColor { tags, developed_for: xy })
|
||||
});
|
||||
let camera_tone = camera_look.as_ref().map(|p| p.tone).or_else(|| raw.color.profile.tone_curve.as_ref().and_then(dng_tone_curve));
|
||||
let local_tone = local_tone(&raw);
|
||||
let (temp, tint) = if relative { (6500.0, 0.0) } else { (temp.round(), tint.round()) };
|
||||
let (temp, tint) = scale.as_shot(xy);
|
||||
return Ok((
|
||||
img,
|
||||
twin,
|
||||
@@ -454,7 +487,8 @@ fn load_bytes_now(
|
||||
as_shot_temp: temp,
|
||||
as_shot_tint: tint,
|
||||
lens,
|
||||
relative_wb: relative,
|
||||
relative_wb: scale.relative,
|
||||
legacy_relative_wb: scale.legacy_relative,
|
||||
camera_color,
|
||||
camera_tone,
|
||||
mattes,
|
||||
@@ -469,6 +503,47 @@ fn load_bytes_now(
|
||||
Ok((img.into_oriented(Orientation::from_exif(d.orientation)), None, SourceInfo::default()))
|
||||
}
|
||||
|
||||
/// Which white-balance scale a raw is edited on, decided from the file alone, the way the catalog decides it from
|
||||
/// the photo's format and camera (`lightcraft_catalog::relative_wb_camera`; a test keeps the two in step).
|
||||
///
|
||||
/// A file with colour matrices of its own (DNG) has an absolute scale from its tags. A file without
|
||||
/// (`camera_preview::file_local_look`) has one when its camera's matrices are measured
|
||||
/// (`lightcraft_raw::spectral`): they turn its as-shot multipliers into Kelvin and tint, nothing else — the look
|
||||
/// stays the file's fit. Any other raw is edited relative to its as-shot look (6500 K / 0 means as shot).
|
||||
struct WbScaleOf {
|
||||
own_matrix: bool,
|
||||
relative: bool,
|
||||
/// Kelvin through the camera's measured matrices: edited on the relative scale before those were used.
|
||||
legacy_relative: bool,
|
||||
/// The as-shot white through the measured matrices, when that is where the scale comes from.
|
||||
spectral_white: Option<lightcraft_color::Xy>,
|
||||
}
|
||||
|
||||
impl WbScaleOf {
|
||||
fn new(
|
||||
format: lightcraft_raw::RawFormat,
|
||||
color: &lightcraft_raw::ColorData,
|
||||
wb_multipliers: Option<[f32; 3]>,
|
||||
meta: &lightcraft_meta::Metadata,
|
||||
) -> Self {
|
||||
let own_matrix = lightcraft_raw::color::has_matrix(color);
|
||||
let fitted = crate::camera_preview::file_local_look(format) && !own_matrix;
|
||||
let camera = fitted.then(|| meta.model.as_deref().and_then(|model| lightcraft_raw::spectral::find(meta.make.as_deref(), model))).flatten();
|
||||
let spectral_white = camera.and_then(|c| lightcraft_raw::spectral::as_shot_white_through(c, color, wb_multipliers));
|
||||
WbScaleOf { own_matrix, relative: fitted && camera.is_none(), legacy_relative: fitted && camera.is_some(), spectral_white }
|
||||
}
|
||||
/// The as-shot temperature and tint on the file's scale: the relative scale's reference, the white through
|
||||
/// the measured matrices, else `own` (the white the file's own tags give). Whole numbers, as the catalog
|
||||
/// keeps them. A covered camera whose file names no white (no multipliers) falls back to `own`, D65.
|
||||
fn as_shot(&self, own: lightcraft_color::Xy) -> (f64, f64) {
|
||||
if self.relative {
|
||||
return (6500.0, 0.0);
|
||||
}
|
||||
let (t, tint) = xy_to_temp_tint(self.spectral_white.unwrap_or(own));
|
||||
(t.round(), tint.round())
|
||||
}
|
||||
}
|
||||
|
||||
/// The raw's gain table map with where its developed picture (the default crop, oriented) sits in
|
||||
/// the active area: rendered when the photo's "Camera local tone mapping" option is on.
|
||||
fn local_tone(raw: &lightcraft_raw::RawImage) -> Option<Arc<lightcraft_pipeline::local_tone::LocalTone>> {
|
||||
@@ -605,17 +680,144 @@ fn fit_box(max_edge: usize) -> lightcraft_codecs::DecodeOptions {
|
||||
|
||||
/// Filesystem-backed embedded-preview hook (native).
|
||||
///
|
||||
/// Reads the whole file to find the preview, so it holds the memory gate for twice the file's size (the file and the
|
||||
/// decoded preview): background work (the face scan) waits for room, interactive work is counted and never waits.
|
||||
/// The quick path reads the file's header and its embedded preview and nothing else (see
|
||||
/// [`preview_window`]); a format whose preview the header cannot point at falls back to the
|
||||
/// whole-file path, which is what every format used to cost. The memory gate still counts the whole
|
||||
/// file, which is what the decode of one can hold.
|
||||
pub fn fs_preview_loader() -> PreviewLoader {
|
||||
Arc::new(|path: &str, max_edge: usize| {
|
||||
let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0);
|
||||
let gate = crate::memory::work_gate();
|
||||
let _permit = if crate::memory::is_background() { gate.acquire(len.saturating_mul(2)) } else { gate.acquire_urgent(len.saturating_mul(2)) };
|
||||
if let Some(image) = preview_window(path).ok().flatten().and_then(|(header, window, _)| quick_image_srgb(&header, &window, max_edge)) {
|
||||
return Some(image);
|
||||
}
|
||||
embedded_preview_srgb(&std::fs::read(path).ok()?, max_edge)
|
||||
})
|
||||
}
|
||||
|
||||
/// What [`preview_window`] read from a file, so a caller can see what a preview cost.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct PreviewRead {
|
||||
/// Bytes read.
|
||||
pub read: u64,
|
||||
/// The file's size.
|
||||
pub total: u64,
|
||||
/// Whether the preview came from a bounded read (`false`: the whole file was read, which is what
|
||||
/// every preview cost before the header path existed).
|
||||
pub header_path: bool,
|
||||
}
|
||||
|
||||
impl PreviewRead {
|
||||
/// The share of the file that was read, `0.0`..=`1.0`.
|
||||
pub fn fraction(self) -> f64 {
|
||||
if self.total == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
(self.read as f64 / self.total as f64).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// The file's header and its embedded preview, read without the rest of the file: `(header bytes,
|
||||
/// preview bytes, what was read)`. `None` when the header points at no preview this build can find,
|
||||
/// which is the caller's cue to read the file whole.
|
||||
///
|
||||
/// Why it exists: a 56 MB Fuji RAF keeps its JPEG in the first 5.6 MB (measured: `DSCF9492.RAF`),
|
||||
/// so reading the file whole to extract it costs ten times the bytes — and on a network share ten
|
||||
/// times the wait (measured on that file: 2.5 s local against 25.6 s over SMB). `embedded_preview`
|
||||
/// still needs the whole container, so the header comes back with the window; for a preview taken
|
||||
/// out of a container that carries no preview colour space of its own (Nikon's maker note) the
|
||||
/// whole-file fallback stays in charge.
|
||||
pub fn preview_window(path: &str) -> Result<Option<(Vec<u8>, Vec<u8>, PreviewRead)>, String> {
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
|
||||
let mut file = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
let total = file.metadata().map_err(|e| format!("{path}: {e}"))?.len();
|
||||
let mut prefix = read_prefix(&mut file, path, total)?;
|
||||
|
||||
// What the header itself declares, before the container reader is asked: it clamps a slice to
|
||||
// the buffer it was given, so a prefix that stops inside the declared preview yields a
|
||||
// self-consistently short range that looks trustworthy (measured on a real 56 MB DSCF9492.RAF:
|
||||
// a 4 KiB prefix reports 3948 bytes where the file holds 5.6 MB). Reading the declared extent
|
||||
// first costs nothing — it lives in the first bytes of every format covered here.
|
||||
if let Some(extent) = raf_preview_extent(&prefix)
|
||||
&& extent > prefix.len() as u64
|
||||
{
|
||||
prefix = read_prefix_at(&mut file, path, extent.min(total))?;
|
||||
}
|
||||
let Some(range) = lightcraft_raw::bounded_preview(&prefix) else { return Ok(None) };
|
||||
// A preview at the file's end may have been padded past it: clamp rather than fail.
|
||||
let end = range.offset.saturating_add(range.window_len).min(total);
|
||||
let len = usize::try_from(end.saturating_sub(range.offset)).map_err(|_| format!("{path}: preview too large"))?;
|
||||
// When the prefix already covers the range (the usual case: the prefix was grown to the declared
|
||||
// extent), take the window out of it. Reading those bytes twice would double a 5.6 MB preview.
|
||||
let prefix_len = prefix.len() as u64;
|
||||
let from_prefix = usize::try_from(range.offset).ok().and_then(|start| prefix.get(start..start.checked_add(len)?)).map(<[u8]>::to_vec);
|
||||
let (window, bytes_read) = match from_prefix {
|
||||
Some(window) => (window, prefix_len),
|
||||
None => {
|
||||
let mut window = vec![0u8; len];
|
||||
file.seek(SeekFrom::Start(range.offset)).map_err(|e| format!("{path}: {e}"))?;
|
||||
file.read_exact(&mut window).map_err(|e| format!("{path}: {e}"))?;
|
||||
(window, prefix_len + len as u64)
|
||||
}
|
||||
};
|
||||
|
||||
// A lying pointer must not cost the user their preview: hand the caller the whole-file fallback.
|
||||
if lightcraft_raw::embedded_preview_from_range(&window, range).is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
let read = PreviewRead { read: bytes_read, total, header_path: true };
|
||||
// On a network share the bytes are the wait, so say what this preview actually cost. A window
|
||||
// taken from the prefix cost nothing beyond the prefix itself.
|
||||
if lightcraft_pipeline::profiling() {
|
||||
eprintln!("[profile] preview {path}: read {} of {} bytes (header {}, window {})", read.read, total, prefix_len, window.len());
|
||||
}
|
||||
Ok(Some((prefix, window, read)))
|
||||
}
|
||||
|
||||
/// The file's first [`lightcraft_raw::PREVIEW_PREFIX`] bytes (or the whole file when it is shorter),
|
||||
/// which is what every format [`lightcraft_raw::bounded_preview`] covers keeps its pointer in.
|
||||
fn read_prefix(file: &mut std::fs::File, path: &str, total: u64) -> Result<Vec<u8>, String> {
|
||||
let len = lightcraft_raw::PREVIEW_PREFIX.min(usize::try_from(total).unwrap_or(usize::MAX));
|
||||
read_prefix_at(file, path, len as u64)
|
||||
}
|
||||
|
||||
/// The file's first `len` bytes, read from the start.
|
||||
fn read_prefix_at(file: &mut std::fs::File, path: &str, len: u64) -> Result<Vec<u8>, String> {
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
let len = usize::try_from(len).map_err(|_| format!("{path}: file too large"))?;
|
||||
let mut prefix = vec![0u8; len];
|
||||
file.seek(SeekFrom::Start(0)).map_err(|e| format!("{path}: {e}"))?;
|
||||
file.read_exact(&mut prefix).map_err(|e| format!("{path}: {e}"))?;
|
||||
Ok(prefix)
|
||||
}
|
||||
|
||||
/// The end of a Fuji RAF's preview block, from the two big-endian u32s every RAF header carries at
|
||||
/// 84 (offset) and 88 (length) — the same fields `lightcraft_raw`'s RAF reader uses, read here
|
||||
/// without the rest of the file so the prefix can be made big enough first.
|
||||
fn raf_preview_extent(prefix: &[u8]) -> Option<u64> {
|
||||
if !prefix.starts_with(b"FUJIFILMCCD-RAW") {
|
||||
return None;
|
||||
}
|
||||
let u32_at = |at: usize| prefix.get(at..at + 4).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]])).map(u64::from);
|
||||
let (offset, len) = (u32_at(84)?, u32_at(88)?);
|
||||
(offset > 0 && len > 0).then(|| offset.checked_add(len)).flatten()
|
||||
}
|
||||
|
||||
/// The display-ready preview of a raw whose embedded JPEG was already located: the counterpart of
|
||||
/// [`embedded_preview_srgb`] for [`preview_window`], which does not re-read the container.
|
||||
/// `None` when the bytes are not a decodable image.
|
||||
fn quick_image_srgb(header: &[u8], window: &[u8], max_edge: usize) -> Option<lightcraft_raster::Rgba8> {
|
||||
let mut d = lightcraft_codecs::decode(window, fit_box(max_edge)).ok()?;
|
||||
if d.image.width.max(d.image.height) > max_edge {
|
||||
d.image = fit(&d.image, max_edge, max_edge, Filter::Box);
|
||||
d.alpha = None;
|
||||
}
|
||||
// The JPEG's own EXIF orientation, else the raw's (from its header), as the whole-file path does
|
||||
Some(d.to_srgb8().oriented(preview_orientation(header, d.orientation)))
|
||||
}
|
||||
|
||||
/// The filesystem-backed [`PairLoader`]: the file is read and decoded once, and developed as usual and from the photo's
|
||||
/// denoised picture. It holds two pictures at once, so it counts for more at the memory gate than a plain load.
|
||||
pub fn fs_pair_loader() -> PairLoader {
|
||||
@@ -660,13 +862,18 @@ pub fn fs_hooks() -> (FileLoader, FileProbe) {
|
||||
}
|
||||
|
||||
impl crate::Session {
|
||||
/// Install the filesystem file hooks (desktop, CLI, MCP headless).
|
||||
/// Install the filesystem file hooks (desktop, CLI, MCP headless), and on macOS the Apple Photos
|
||||
/// runner ([`crate::apple_photos::osascript`]; installed, not run).
|
||||
pub fn with_fs(mut self) -> Self {
|
||||
let (l, p) = fs_hooks();
|
||||
self.media.file_loader = Some(l);
|
||||
self.media.file_probe = Some(p);
|
||||
self.media.preview_loader = Some(fs_preview_loader());
|
||||
self.media.denoise.loader = Some(fs_pair_loader());
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
self.apple_photos = Some(crate::apple_photos::osascript());
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -689,6 +896,95 @@ mod tests {
|
||||
use super::*;
|
||||
use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg};
|
||||
|
||||
/// Which white-balance scale a raw gets (issue #730): a file with matrices of its own is absolute from them; a
|
||||
/// fitted-look file is absolute through its camera's measured matrices, which only read the as-shot
|
||||
/// multipliers as Kelvin; any other raw stays relative (6500 / 0 = as shot). The catalog's decision from the
|
||||
/// format and camera string agrees.
|
||||
#[test]
|
||||
fn white_balance_scale_follows_the_files_matrices_then_the_cameras() {
|
||||
use lightcraft_raw::{ColorData, RawFormat};
|
||||
let meta = |make: &str, model: &str| lightcraft_meta::Metadata { make: Some(make.into()), model: Some(model.into()), ..Default::default() };
|
||||
let mul = Some([2.2f32, 1.0, 1.6]);
|
||||
let d65 = lightcraft_color::D65;
|
||||
// a covered camera: Kelvin through the spectral matrices, near the daylight these multipliers describe
|
||||
let a7m3 = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCE-7M3"));
|
||||
assert!(!a7m3.own_matrix && !a7m3.relative && a7m3.legacy_relative && a7m3.spectral_white.is_some());
|
||||
let (t, tint) = a7m3.as_shot(d65);
|
||||
assert!((3500.0..7000.0).contains(&t) && t != 6500.0 && tint.abs() < 30.0 && t.fract() == 0.0 && tint.fract() == 0.0, "{t} {tint}");
|
||||
assert!(!lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCE-7M3"));
|
||||
// the same camera without multipliers: the scale is Kelvin still, the white falls back to the tags' (D65)
|
||||
let bare = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), None, &meta("SONY", "ILCE-7M3"));
|
||||
assert!(bare.legacy_relative && bare.spectral_white.is_none());
|
||||
assert_eq!(bare.as_shot(d65), {
|
||||
let (t, tint) = xy_to_temp_tint(d65);
|
||||
(t.round(), tint.round())
|
||||
});
|
||||
// a camera without measured matrices: relative, whatever the multipliers
|
||||
let a7m2 = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCE-7M2"));
|
||||
assert!(a7m2.relative && !a7m2.legacy_relative && a7m2.spectral_white.is_none());
|
||||
assert_eq!(a7m2.as_shot(d65), (6500.0, 0.0));
|
||||
assert!(lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCE-7M2"));
|
||||
let unknown = WbScaleOf::new(RawFormat::Nef, &ColorData::default(), mul, &lightcraft_meta::Metadata::default());
|
||||
assert!(unknown.relative && unknown.as_shot(d65) == (6500.0, 0.0));
|
||||
// a file with matrices of its own (DNG): absolute from them, no matter whose camera
|
||||
let own = ColorData { illuminant: [21, 0], color_matrix: [Some(lightcraft_color::Mat3::IDENTITY), None], ..Default::default() };
|
||||
let dng = WbScaleOf::new(RawFormat::Dng, &own, mul, &meta("SONY", "ILCE-7M3"));
|
||||
assert!(dng.own_matrix && !dng.relative && !dng.legacy_relative && dng.spectral_white.is_none());
|
||||
let xy = lightcraft_color::cct::temp_tint_to_xy(4200.0, 12.0);
|
||||
assert_eq!(dng.as_shot(xy), (4200.0, 12.0));
|
||||
assert!(!lightcraft_catalog::relative_wb_camera("DNG", "SONY ILCE-7M3"));
|
||||
}
|
||||
|
||||
/// A RAF whose preview pointer is in its header, then a large raw tail the reader must not need.
|
||||
fn raf_with_embedded_jpeg(jpeg: &[u8], tail: usize) -> Vec<u8> {
|
||||
let head = 160;
|
||||
let mut f = vec![0u8; head];
|
||||
f.get_mut(..16).expect("head").copy_from_slice(b"FUJIFILMCCD-RAW ");
|
||||
f.get_mut(84..88).expect("jpeg offset").copy_from_slice(&(head as u32).to_be_bytes());
|
||||
f.get_mut(88..92).expect("jpeg length").copy_from_slice(&(jpeg.len() as u32).to_be_bytes());
|
||||
f.extend_from_slice(jpeg);
|
||||
f.extend(std::iter::repeat_n(0x33u8, tail));
|
||||
f
|
||||
}
|
||||
|
||||
/// A raw's preview is read from its header and its JPEG only. Every preview used to cost a
|
||||
/// whole-file read, which on a 56 MB RAF over a network share is ten times the bytes and ten
|
||||
/// times the wait (measured: 2.5 s local against 25.6 s over SMB, DSCF9492.RAF). The pixels the
|
||||
/// quick path produces must equal the whole-file path's, exactly.
|
||||
#[test]
|
||||
fn preview_loading_reads_only_the_header_and_the_jpeg() {
|
||||
let (w, h) = (48u32, 32u32);
|
||||
let rgb: Vec<u8> = (0..w * h * 3).map(|i| (i * 13 % 251) as u8).collect();
|
||||
let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb));
|
||||
let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap();
|
||||
let file = raf_with_embedded_jpeg(&jpeg, 200_000);
|
||||
let dir = crate::tests_xmp::temp_dir("preview-window");
|
||||
let path = dir.join("dscf0001.raf");
|
||||
std::fs::write(&path, &file).unwrap();
|
||||
let path = path.to_string_lossy().into_owned();
|
||||
let max_edge = 512;
|
||||
|
||||
let (header, window, read) = preview_window(&path).expect("the file reads").expect("it has a preview");
|
||||
assert_eq!(header.len(), lightcraft_raw::PREVIEW_PREFIX, "the header prefix is bounded");
|
||||
assert!(read.header_path, "the header path is what answered");
|
||||
assert_eq!(read.total, file.len() as u64);
|
||||
assert!(read.fraction() < 0.5, "the read is a fraction of the file: {read:?}");
|
||||
assert!(window.len() < file.len() / 2, "the raw tail must not be read: {} of {} bytes", window.len(), file.len());
|
||||
|
||||
// The quick path and the whole-file path agree byte for byte.
|
||||
let quick = quick_image_srgb(&header, &window, max_edge).expect("the window holds a decodable preview");
|
||||
let whole = embedded_preview_srgb(&file, max_edge).expect("the whole file decodes too");
|
||||
assert_eq!((quick.width, quick.height), (whole.width, whole.height), "same preview size");
|
||||
assert_eq!(quick.data, whole.data, "byte for byte");
|
||||
|
||||
// A file with no locatable preview declines (the caller then reads it whole), a missing file errors.
|
||||
let plain = dir.join("plain.jpg");
|
||||
std::fs::write(&plain, &jpeg).unwrap();
|
||||
assert!(preview_window(&plain.to_string_lossy()).expect("a plain file reads").is_none());
|
||||
assert!(preview_window(&dir.join("nope.raf").to_string_lossy()).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Issue #367: probes read headers, not pixels, and report what the decode-based probe did:
|
||||
/// oriented dimensions (EXIF orientation 6 swaps them), metadata, the error for a truncated
|
||||
/// file or one that isn't an image.
|
||||
|
||||
@@ -51,11 +51,22 @@ pub fn is_cjk_script(script: &str) -> bool {
|
||||
matches!(script, "Hans" | "Hant" | "Jpan" | "Kore" | "Hang" | "Hani" | "Bopo" | "Yiii" | "Nshu" | "Tang")
|
||||
}
|
||||
|
||||
/// The craft-fonts faces to fall back on for `script`, in preference order: the faces of that script
|
||||
/// first (for `style` when asked), then every other CJK face. One CJK face list serves every
|
||||
/// language, but Han characters are shared between them: a Chinese reader must get the Chinese
|
||||
/// forms and a Japanese reader the Japanese ones, so the active language's own script comes first.
|
||||
/// Empty without craft-fonts.
|
||||
/// The craft-fonts faces to fall back on for `script`, in preference order: the CJK faces, those of
|
||||
/// that script first (for `style` when asked), then the faces of every other non-Latin script
|
||||
/// (Thai, Arabic, ...), which file and folder names can contain whatever the interface language.
|
||||
/// One CJK face list serves every language, but Han characters are shared between them: a Chinese
|
||||
/// reader must get the Chinese forms and a Japanese reader the Japanese ones, so the active
|
||||
/// language's own script comes first. The other scripts share no characters, so their order only
|
||||
/// prefers `style`. Empty without craft-fonts.
|
||||
pub fn ui_fallback<'a>(fonts: &'a [CraftFont], script: &str, style: &str) -> Vec<&'a CraftFont> {
|
||||
let mut faces = cjk_fallback(fonts, script, style);
|
||||
let mut others: Vec<&CraftFont> = fonts.iter().filter(|font| !covers_cjk(font) && font.scripts.iter().any(|s| *s != "Latn")).collect();
|
||||
others.sort_by_key(|font| font.style != style);
|
||||
faces.extend(others);
|
||||
faces
|
||||
}
|
||||
|
||||
/// The CJK part of [`ui_fallback`], in the same preference order.
|
||||
pub fn cjk_fallback<'a>(fonts: &'a [CraftFont], script: &str, style: &str) -> Vec<&'a CraftFont> {
|
||||
let mut faces: Vec<&CraftFont> = fonts.iter().filter(|font| covers_cjk(font)).collect();
|
||||
faces.sort_by_key(|font| {
|
||||
@@ -92,6 +103,13 @@ mod tests {
|
||||
assert!(ab_glyph::FontRef::try_from_slice(f.bytes).is_ok(), "{} {} parses", f.family, f.style);
|
||||
}
|
||||
assert!(japanese(CRAFT_FONTS).next().is_some(), "craft-fonts carries Japanese faces");
|
||||
// Every script face that ships is a UI fallback, and has the glyphs of its script (#736).
|
||||
use ab_glyph::Font;
|
||||
for (script, text) in [("Thai", "ภาพถ่ายสวัสดี๑๒๓"), ("Arab", "صورة")] {
|
||||
let Some(face) = ui_fallback(CRAFT_FONTS, "Latn", "Regular").into_iter().find(|f| f.covers(script)) else { continue };
|
||||
let font = ab_glyph::FontRef::try_from_slice(face.bytes).expect("parses");
|
||||
assert!(text.chars().all(|c| font.glyph_id(c).0 != 0), "{} draws {script}", face.family);
|
||||
}
|
||||
}
|
||||
|
||||
/// Each language's own faces come first, so shared Han keeps the language's forms, and Latin
|
||||
@@ -112,4 +130,25 @@ mod tests {
|
||||
assert_eq!(cjk_fallback(FACES, "Hans", "Regular").len(), 3);
|
||||
assert_eq!(cjk_fallback(&[], "Hans", "Regular").len(), 0);
|
||||
}
|
||||
|
||||
/// Faces for other scripts (#736: Thai folder names showed boxes) follow the CJK faces in the
|
||||
/// UI fallback whatever the language; a Latin-only face is not a fallback (Inter covers Latin).
|
||||
#[test]
|
||||
fn other_scripts_follow_the_cjk_faces() {
|
||||
static FACES: &[CraftFont] = &[
|
||||
CraftFont { family: "Noto Sans Thai", style: "Regular", scripts: &["Thai", "Latn"], bytes: &[] },
|
||||
CraftFont { family: "Latin Only Sans", style: "Regular", scripts: &["Latn"], bytes: &[] },
|
||||
CraftFont { family: "Noto Sans CJK SC", style: "Regular", scripts: &["Hans", "Latn"], bytes: &[] },
|
||||
CraftFont { family: "Noto Sans Arabic", style: "Bold", scripts: &["Arab"], bytes: &[] },
|
||||
CraftFont { family: "Noto Sans Arabic", style: "Regular", scripts: &["Arab"], bytes: &[] },
|
||||
];
|
||||
let families = |script: &str, style: &str| ui_fallback(FACES, script, style).iter().map(|f| (f.family, f.style)).collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
families("Latn", "Regular"),
|
||||
[("Noto Sans CJK SC", "Regular"), ("Noto Sans Thai", "Regular"), ("Noto Sans Arabic", "Regular"), ("Noto Sans Arabic", "Bold")]
|
||||
);
|
||||
assert_eq!(families("Hans", "Bold")[0], ("Noto Sans CJK SC", "Regular"));
|
||||
assert_eq!(families("Hans", "Bold")[1], ("Noto Sans Arabic", "Bold"));
|
||||
assert!(ui_fallback(&[], "Thai", "Regular").is_empty());
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@
|
||||
//! 3. Probe the rest — dimensions, metadata, and the **content hash** of the bytes — in parallel
|
||||
//! on native targets.
|
||||
//! 4. Skip **duplicates by content** (same bytes already in the library, or twice in this batch).
|
||||
//! A duplicate (by path or content) of a photo in Recently Deleted is restored instead, with
|
||||
//! its edits, unless the import says otherwise ([`OnDeleted`]).
|
||||
//! 5. *Add* in place (the photo points at the original file), *copy* into the library's
|
||||
//! `Originals/YYYY/YYYY-MM-DD/` folder (names made unique) and point at the copy, or *move*
|
||||
//! there: placed like a copy (with its XMP sidecars), and the original removed only once its
|
||||
@@ -53,12 +55,19 @@ pub enum ImportMode {
|
||||
}
|
||||
|
||||
/// What an import does with a file the library already has in Recently Deleted.
|
||||
///
|
||||
/// Someone who imports such a file wants the photo in the library again, so the default brings
|
||||
/// it back (issues #298, #706: before, the import skipped it as "already in the library" while
|
||||
/// the grid showed it deleted, and the user was stuck). Restoring loses nothing — the edits,
|
||||
/// rating and albums come back with the photo and Reset Edits gives a clean start — where
|
||||
/// importing afresh would silently drop them.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum OnDeleted {
|
||||
/// Leave it there; the report says so ([`Duplicate::existing_deleted`]).
|
||||
#[default]
|
||||
Skip,
|
||||
/// Bring the trashed photo back, with its edits ([`ImportReport::restored`]).
|
||||
/// Bring the trashed photo back, with its edits ([`ImportReport::restored`]); a photo whose
|
||||
/// file is no longer where the library had it is relinked to the imported file.
|
||||
#[default]
|
||||
Restore,
|
||||
/// Delete the trashed record permanently and import the file as a new photo.
|
||||
Fresh,
|
||||
@@ -78,7 +87,7 @@ impl OnDeleted {
|
||||
/// What an import does besides adding the photos.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ImportOptions {
|
||||
/// A file that is in Recently Deleted: skip (default), restore, or import afresh.
|
||||
/// A file that is in Recently Deleted: restore (default), skip, or import afresh.
|
||||
pub on_deleted: OnDeleted,
|
||||
pub mode: ImportMode,
|
||||
/// Applied to every imported photo (one History entry).
|
||||
@@ -201,6 +210,48 @@ pub struct ImportReport {
|
||||
pub scanned: usize,
|
||||
/// Photos whose metadata/develop settings were read from an XMP sidecar (or embedded XMP).
|
||||
pub sidecars: usize,
|
||||
/// Per-photo diagnostics from foreign XMP develop settings that could not be fully carried over.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub warnings: Vec<ImportWarning>,
|
||||
}
|
||||
|
||||
/// A structured diagnostic from foreign XMP develop settings.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ImportWarningCode {
|
||||
/// A named camera profile was found but cannot be applied as Lightroom does.
|
||||
CameraProfile,
|
||||
/// Lightroom geometry data was found but its saved transform is not retained.
|
||||
UprightGeometry,
|
||||
/// One or more other XMP develop fields have no LightCraft mapping.
|
||||
UnmappedXmp,
|
||||
}
|
||||
|
||||
/// The saved Lightroom Upright mode associated with a foreign geometry transform.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ImportUprightMode {
|
||||
Off,
|
||||
Auto,
|
||||
Level,
|
||||
Vertical,
|
||||
Full,
|
||||
Guided,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// One actionable XMP import diagnostic. `path` is filled when its photo is committed.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ImportWarning {
|
||||
pub path: String,
|
||||
pub code: ImportWarningCode,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub profile: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mode: Option<ImportUprightMode>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub fields: Vec<String>,
|
||||
}
|
||||
|
||||
/// A file moved by an import.
|
||||
@@ -912,7 +963,7 @@ impl ImportJob {
|
||||
let packet = crate::sidecar::find_sidecar(&path, self.naming)
|
||||
.and_then(|f| std::fs::read_to_string(f).ok())
|
||||
.or_else(|| info.xmp.clone().filter(|_| raw));
|
||||
let target = crate::crs::Target::for_file(info.kind, &info.format, info.preview_only.is_some());
|
||||
let target = crate::crs::Target::for_file(info.kind, &info.format, info.preview_only.is_some(), &info.meta.camera);
|
||||
let sidecar = packet.and_then(|x| match crate::sidecar::parse_sidecar(&x, target) {
|
||||
Ok(sc) => Some(sc),
|
||||
Err(e) => {
|
||||
@@ -1170,6 +1221,16 @@ fn run_transfer(t: Transfer, file_bytes: Option<&crate::merge::ByteReader>) -> P
|
||||
PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed }))
|
||||
}
|
||||
|
||||
/// A trashed photo's file is not where the library had it (never on the web, where paths are
|
||||
/// storage keys, not files).
|
||||
fn trashed_file_is_gone(cat: &lightcraft_catalog::Catalog, id: PhotoId) -> bool {
|
||||
!cfg!(target_arch = "wasm32")
|
||||
&& match cat.photo(id).map(|p| &p.source) {
|
||||
Some(Source::File { path }) => !Path::new(path).exists(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The catalog half of an import: add the photos [`ImportJob::prepare`] readied (one undoable op;
|
||||
/// a Move's sources are removed once it is saved). On a failed commit a Move's placed files are
|
||||
/// taken back.
|
||||
@@ -1210,9 +1271,15 @@ pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepare
|
||||
});
|
||||
let trashed = existing.filter(|id| s.catalog.photo(*id).is_some_and(|p| p.deleted));
|
||||
match trashed {
|
||||
Some(id) if opts.on_deleted == OnDeleted::Restore => {
|
||||
// (browsing a folder in Local only looks: it never restores)
|
||||
Some(id) if opts.on_deleted == OnDeleted::Restore && !opts.local => {
|
||||
if !report.restored.contains(&id.0) {
|
||||
ops.push(Op::SetDeleted { id, deleted: false });
|
||||
// matched by content from another path, and the file the record
|
||||
// points at is gone (moved, renamed): the photo follows the file
|
||||
if reason == "content" && trashed_file_is_gone(&s.catalog, id) {
|
||||
ops.push(crate::cmd::missing::relink_op(id, &path));
|
||||
}
|
||||
report.restored.push(id.0);
|
||||
}
|
||||
}
|
||||
@@ -1249,6 +1316,10 @@ pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepare
|
||||
if let Some(sc) = &sidecar {
|
||||
crate::sidecar::merge_into(&mut p, sc, &now);
|
||||
report.sidecars += 1;
|
||||
report.warnings.extend(sc.warnings.iter().cloned().map(|mut warning| {
|
||||
warning.path = path.clone();
|
||||
warning
|
||||
}));
|
||||
}
|
||||
if let Some(mp) = opts.metadata_preset.as_ref().and_then(|n| s.metadata_presets.iter().find(|m| m.name.eq_ignore_ascii_case(n))) {
|
||||
crate::cmd::metadata::apply_to(&mut p.meta, &mp.fields);
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
//! Lens profile corrections from a lens database the user supplies ([`lightcraft_lensdb`]).
|
||||
//!
|
||||
//! When a raw carries no lens correction of its own (DNG opcodes, or a camera correction a raw reader converts,
|
||||
//! [`crate::files::embedded_lens`]), the lens named in its EXIF is looked up in a lensfun-format database: the
|
||||
//! directory in `LIGHTCRAFT_LENSFUN_DIR` (e.g. lensfun's `data/db`, CC-BY-SA, which is never bundled with the app).
|
||||
//! The profile is converted to the same [`lightcraft_develop::EmbeddedLens`] form, so "Enable Profile Corrections", its
|
||||
//! amount sliders, the GPU path and DNG export treat both alike. Without the variable nothing changes.
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use lightcraft_develop::EmbeddedLens;
|
||||
use lightcraft_lensdb::{LensDb, Shot};
|
||||
|
||||
/// The environment variable naming the lens database directory.
|
||||
pub const ENV: &str = "LIGHTCRAFT_LENSFUN_DIR";
|
||||
|
||||
/// The configured database, read once per process; `None` when none is configured or it holds no lenses.
|
||||
pub fn database() -> Option<&'static LensDb> {
|
||||
static DB: OnceLock<Option<LensDb>> = OnceLock::new();
|
||||
DB.get_or_init(|| {
|
||||
let dir = PathBuf::from(std::env::var_os(ENV)?);
|
||||
match LensDb::load_dir(&dir) {
|
||||
Ok((db, errors)) => {
|
||||
for (file, e) in errors {
|
||||
log::warn!("lens database: skipped {file}: {e}");
|
||||
}
|
||||
(!db.lenses.is_empty()).then_some(db)
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("{e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.as_ref()
|
||||
}
|
||||
|
||||
/// The database profile for a photo with metadata `m`, for its `w × h` (unoriented, default-cropped) image,
|
||||
/// re-expressed after `orientation`. `None` without a database, lens name, focal length or matching profile.
|
||||
pub fn lookup(db: &LensDb, m: &lightcraft_meta::Metadata, w: f64, h: f64, orientation: lightcraft_geom::Orientation) -> Option<EmbeddedLens> {
|
||||
let focal = m.focal_length.filter(|f| f.is_finite() && *f > 0.0)?;
|
||||
let camera = db.find_camera(m.make.as_deref().unwrap_or_default(), m.model.as_deref().unwrap_or_default());
|
||||
let lens = db.find_lens(m.lens_make.as_deref(), m.lens_model.as_deref()?, camera)?;
|
||||
// the crop factor: the camera's, else what the 35 mm equivalent focal length implies
|
||||
let crop =
|
||||
camera.map(|c| c.crop_factor).or_else(|| m.focal_length_35mm.map(|f35| f35 / focal).filter(|c| c.is_finite() && (0.1..=20.0).contains(c)));
|
||||
let shot = Shot { focal, aperture: m.f_number, distance: None, crop_factor: crop, width: w, height: h };
|
||||
let corr = lens.correction(&shot)?;
|
||||
Some(lightcraft_pipeline::optics::reorient_lens(&corr, orientation, w, h))
|
||||
}
|
||||
|
||||
/// [`crate::files::embedded_lens`], else the database profile ([`lookup`]) for a raw with metadata `meta`.
|
||||
pub fn for_raw(raw: &lightcraft_raw::RawInfo, meta: impl FnOnce() -> lightcraft_meta::Metadata) -> Option<EmbeddedLens> {
|
||||
crate::files::embedded_lens(raw).or_else(|| {
|
||||
let db = database()?;
|
||||
let c = raw.crop.clipped(raw.active_area.width, raw.active_area.height);
|
||||
let (w, h) = if c.width == 0 || c.height == 0 { (raw.active_area.width, raw.active_area.height) } else { (c.width, c.height) };
|
||||
lookup(db, &meta(), w as f64, h as f64, raw.orientation)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lightcraft_geom::Orientation;
|
||||
|
||||
const DB: &str = r#"<lensdatabase>
|
||||
<camera><maker>Testcam</maker><model>TC-1</model><mount>T</mount><cropfactor>1.5</cropfactor></camera>
|
||||
<lens><maker>Testcam</maker><model>Testcam 18-55mm f/3.5-5.6</model><mount>T</mount><cropfactor>1.5</cropfactor>
|
||||
<calibration>
|
||||
<distortion model="poly3" focal="18" k1="-0.03" />
|
||||
<vignetting model="pa" focal="18" aperture="3.5" distance="1000" k1="-0.4" k2="0" k3="0" />
|
||||
</calibration>
|
||||
</lens></lensdatabase>"#;
|
||||
|
||||
fn meta() -> lightcraft_meta::Metadata {
|
||||
lightcraft_meta::Metadata {
|
||||
make: Some("Testcam".into()),
|
||||
model: Some("TC-1".into()),
|
||||
lens_model: Some("18-55mm F3.5-5.6".into()),
|
||||
focal_length: Some(18.0),
|
||||
f_number: Some(3.5),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exif_lens_is_found_and_converted() {
|
||||
let db = LensDb::parse(DB).unwrap();
|
||||
let lens = lookup(&db, &meta(), 6000.0, 4000.0, Orientation::Normal).unwrap();
|
||||
assert!(lens.warp.is_some() && lens.vignette.is_some());
|
||||
let rotated = lookup(&db, &meta(), 6000.0, 4000.0, Orientation::Rotate90).unwrap();
|
||||
assert_eq!(rotated.warp.unwrap().center, lightcraft_geom::Point::new(0.5, 0.5));
|
||||
// no lens name, no focal length, an unknown lens: nothing
|
||||
assert!(lookup(&db, &lightcraft_meta::Metadata { lens_model: None, ..meta() }, 6000.0, 4000.0, Orientation::Normal).is_none());
|
||||
assert!(lookup(&db, &lightcraft_meta::Metadata { focal_length: None, ..meta() }, 6000.0, 4000.0, Orientation::Normal).is_none());
|
||||
let other = lightcraft_meta::Metadata { lens_model: Some("70-200mm F4".into()), ..meta() };
|
||||
assert!(lookup(&db, &other, 6000.0, 4000.0, Orientation::Normal).is_none());
|
||||
}
|
||||
|
||||
/// A synthetic grid recorded through the profile's barrel distortion comes out of the pipeline's geometry
|
||||
/// stage with straight lines.
|
||||
#[test]
|
||||
fn pipeline_straightens_a_distorted_grid() {
|
||||
let db = LensDb::parse(DB).unwrap();
|
||||
let profile = &db.lenses[0];
|
||||
let (w, h) = (600usize, 400usize);
|
||||
let lens = lookup(&db, &meta(), w as f64, h as f64, Orientation::Normal).unwrap();
|
||||
// record the grid: each sensor pixel sees the scene point whose distorted position it is
|
||||
let n = h as f64 / 2.0;
|
||||
let undistort = |x: f64, y: f64| {
|
||||
let (dx, dy) = ((x - w as f64 / 2.0) / n, (y - h as f64 / 2.0) / n);
|
||||
let rd = dx.hypot(dy);
|
||||
let (mut lo, mut hi) = (0.0, 3.0);
|
||||
for _ in 0..60 {
|
||||
let mid = (lo + hi) / 2.0;
|
||||
if mid * profile.distortion_ratio(18.0, mid) < rd {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
let s = if rd > 0.0 { lo / rd } else { 1.0 };
|
||||
(w as f64 / 2.0 + dx * s * n, h as f64 / 2.0 + dy * s * n)
|
||||
};
|
||||
// horizontal scene lines every 50 px (2 px wide, dark on light)
|
||||
let line = |v: f64| if v.rem_euclid(50.0) < 2.0 { 0.02f32 } else { 0.8 };
|
||||
let src = lightcraft_raster::Rgb32f::from_fn(w, h, |x, y| {
|
||||
let (_, sy) = undistort(x as f64 + 0.5, y as f64 + 0.5);
|
||||
[line(sy); 3]
|
||||
});
|
||||
// the dark row nearest y = 51 in each column, sub-pixel (centroid of darkness), as rendered
|
||||
let line_rows = |img: &lightcraft_raster::Rgba8| -> Vec<f64> {
|
||||
(20..w - 20)
|
||||
.step_by(10)
|
||||
.map(|x| {
|
||||
let (mut sw, mut swy) = (0.0, 0.0);
|
||||
for y in 35..70 {
|
||||
let d = 255.0 - img.get(x, y)[1] as f64;
|
||||
if d > 60.0 {
|
||||
sw += d;
|
||||
swy += d * (y as f64 + 0.5);
|
||||
}
|
||||
}
|
||||
swy / sw.max(1e-9)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let spread = |rows: &[f64]| rows.iter().cloned().fold(f64::MIN, f64::max) - rows.iter().cloned().fold(f64::MAX, f64::min);
|
||||
let req = lightcraft_pipeline::RenderRequest::fit(w, h);
|
||||
let mut s = lightcraft_develop::DevelopSettings::default();
|
||||
let info = lightcraft_pipeline::SourceInfo { lens: Some(lens), ..Default::default() };
|
||||
let before = spread(&line_rows(&lightcraft_pipeline::render(&src, &info, &s, &req).image));
|
||||
s.optics.lens_profile = true;
|
||||
let after = spread(&line_rows(&lightcraft_pipeline::render(&src, &info, &s, &req).image));
|
||||
assert!(before > 2.0, "the recorded line is curved: {before} px");
|
||||
// 8-bit output and a 2 px line: the centroid measurement itself is good to about a pixel
|
||||
assert!(after < 1.0 && after < before / 8.0, "corrected line bent by {after} px (uncorrected {before})");
|
||||
}
|
||||
}
|
||||
+146
-27
@@ -15,6 +15,8 @@
|
||||
extern crate lightcraft_denoise_core as lightcraft_denoise;
|
||||
|
||||
pub mod activity;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod apple_photos;
|
||||
pub mod availability;
|
||||
pub(crate) mod camera_preview;
|
||||
pub mod camera_profiles;
|
||||
@@ -38,9 +40,11 @@ pub mod fonts;
|
||||
pub mod guard;
|
||||
pub mod import;
|
||||
mod import_move;
|
||||
pub mod lens_profiles;
|
||||
pub mod library;
|
||||
mod lightroom_archive;
|
||||
pub mod lightroom_catalog;
|
||||
pub mod lightroom_folder;
|
||||
pub mod lightroom_job;
|
||||
mod lightroom_sqlite;
|
||||
pub mod logging;
|
||||
@@ -52,6 +56,7 @@ pub mod originals;
|
||||
pub mod preset_import;
|
||||
pub mod preset_luminar;
|
||||
pub mod presets;
|
||||
pub mod proof;
|
||||
pub mod rename;
|
||||
pub mod segment;
|
||||
pub mod sidecar;
|
||||
@@ -88,6 +93,11 @@ pub enum EngineError {
|
||||
/// Another process (the app, `lightcraft-cli`, another computer) has the library open.
|
||||
#[error("{0}")]
|
||||
LibraryInUse(String),
|
||||
/// The folder (or file) asked for is a Lightroom Classic catalog or part of one, not a
|
||||
/// LightCraft library (issue #891): nothing was created or opened. `catalog`: the `.lrcat`
|
||||
/// to import with `library.importLightroom`, when one was found.
|
||||
#[error("{} is a Lightroom Classic catalog, not a LightCraft library; import it into a LightCraft library with File ▸ Import Lightroom Catalog…{}", path.display(), catalog.as_ref().map_or(String::new(), |c| format!(" ({})", c.display())))]
|
||||
LightroomCatalog { path: std::path::PathBuf, catalog: Option<std::path::PathBuf> },
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
}
|
||||
@@ -100,8 +110,13 @@ pub struct UndoEntry {
|
||||
pub label: String,
|
||||
pub op: Op,
|
||||
/// A folder to rename on disk (`from` → `to`) before `op` is applied: Rename / Move Folder
|
||||
/// (whose `op` relinks the photos inside). Never overwrites; refused when `to` exists.
|
||||
/// (whose `op` relinks the photos inside). Never overwrites; refused when `to` exists. An
|
||||
/// empty `to` removes the (empty) folder `from`, an empty `from` creates `to` (Create Folder).
|
||||
pub folder: Option<FolderMove>,
|
||||
/// Inverses of logged path corrections from a partially failed history step.
|
||||
pub(crate) retry_inverse: Vec<Op>,
|
||||
/// Sidecars still separated from their photo after a failed move or rollback.
|
||||
pub(crate) retry_sidecars: Vec<rename::SidecarMove>,
|
||||
}
|
||||
|
||||
/// The one photo an undo step changes, when it changes exactly one through its edit, rating, flag,
|
||||
@@ -174,6 +189,8 @@ pub struct Session {
|
||||
/// its length can't tell how many steps a command just made; see [`Session::commits`]).
|
||||
commits: u64,
|
||||
pub redo: Vec<UndoEntry>,
|
||||
/// Exact filesystem repairs from discarded history; catalog inverses stay discarded.
|
||||
pub(crate) pending_sidecar_repairs: Vec<rename::SidecarMove>,
|
||||
pub interaction: Option<Interaction>,
|
||||
/// Set by a command whose change must not rewrite the photo's XMP sidecar even with auto-write on
|
||||
/// (a catalog-only edit of data the sidecar writer does not emit); consumed when the command ends.
|
||||
@@ -184,6 +201,8 @@ pub struct Session {
|
||||
pub face_models_dir: Option<std::path::PathBuf>,
|
||||
/// Face model downloads started this session (the staged files wait in `<face_models_dir>/.downloads`).
|
||||
pub face_downloads: face_download::Downloads,
|
||||
/// Reverse geocoding: the parsed place names and their download (`metadata.*PlaceNames`).
|
||||
pub place_names: cmd::geocode::PlaceNames,
|
||||
/// The user's own list of models to download (`catalog.json` in the models folder), as last read.
|
||||
pub(crate) face_catalog: lightcraft_faces::catalog::Catalog,
|
||||
/// The loaded recognition model and the face embeddings made with it.
|
||||
@@ -276,6 +295,20 @@ pub struct Session {
|
||||
/// Untouched Local records of folders not browsed for this many days are forgotten when the
|
||||
/// library opens (0 = never; persisted in prefs.json). See `cmd/browse.rs`.
|
||||
pub forget_local_days: u32,
|
||||
/// Adds exported files to Apple Photos ([`apple_photos`]): `/usr/bin/osascript`, installed by
|
||||
/// [`Session::with_fs`] on macOS; `None` elsewhere and in sessions without a file system. Tests
|
||||
/// put a fake here.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub apple_photos: Option<apple_photos::Runner>,
|
||||
/// This session's Apple Photos imports, running or finished (`export.photosImports`).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub apple_photos_imports: apple_photos::Imports,
|
||||
/// Apple Photos imports run on a worker by default (`export.addToPhotos` without `wait`,
|
||||
/// `app.export` without `background`): set by the desktop app, which answers control requests
|
||||
/// on its UI thread. Off (the default), they wait for Photos, so a one-shot process
|
||||
/// (lightcraft-cli, a script) never ends in the middle of one.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub apple_photos_background: bool,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
@@ -305,11 +338,13 @@ impl Session {
|
||||
undo: Vec::new(),
|
||||
commits: 0,
|
||||
redo: Vec::new(),
|
||||
pending_sidecar_repairs: Vec::new(),
|
||||
interaction: None,
|
||||
skip_auto_write: false,
|
||||
denoise: Default::default(),
|
||||
face_models_dir: None,
|
||||
face_downloads: Default::default(),
|
||||
place_names: Default::default(),
|
||||
face_catalog: Default::default(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
faces: Default::default(),
|
||||
@@ -355,6 +390,12 @@ impl Session {
|
||||
cache_mb: 0,
|
||||
smart_previews_dir: None,
|
||||
forget_local_days: lightcraft_catalog::DEFAULT_FORGET_DAYS,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
apple_photos: None,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
apple_photos_imports: Default::default(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
apple_photos_background: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,7 +450,7 @@ impl Session {
|
||||
if self.depth == 0 {
|
||||
let skip = std::mem::take(&mut self.skip_auto_write);
|
||||
if r.is_ok() && !skip && self.xmp.auto_write && self.interaction.is_none() && self.pending_log.len() > log_start {
|
||||
self.auto_write_sidecars(&self.pending_log[log_start..]);
|
||||
self.auto_write_sidecars_since(log_start);
|
||||
}
|
||||
}
|
||||
if self.depth == 0 && self.library.is_some() {
|
||||
@@ -438,17 +479,34 @@ impl Session {
|
||||
/// Apply an op as one undoable step.
|
||||
pub fn commit(&mut self, label: &str, op: Op) -> Result<()> {
|
||||
let fwd = op.clone();
|
||||
let inv = self.catalog.apply(op)?;
|
||||
let inv = self.apply_catalog(op)?;
|
||||
self.pending_log.push(fwd);
|
||||
self.undo.push(UndoEntry { label: label.to_string(), op: inv, folder: None });
|
||||
self.undo.push(UndoEntry { label: label.to_string(), op: inv, folder: None, retry_inverse: Vec::new(), retry_sidecars: Vec::new() });
|
||||
self.commits = self.commits.wrapping_add(1);
|
||||
if self.undo.len() > 1000 {
|
||||
self.undo.remove(0);
|
||||
let mut dropped = self.undo.remove(0);
|
||||
self.pending_sidecar_repairs.append(&mut dropped.retry_sidecars);
|
||||
}
|
||||
for mut dropped in self.redo.drain(..) {
|
||||
self.pending_sidecar_repairs.append(&mut dropped.retry_sidecars);
|
||||
}
|
||||
self.redo.clear();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_catalog(&mut self, op: Op) -> Result<Op> {
|
||||
let inv = self.catalog.apply(op.clone())?;
|
||||
let mut pending = vec![&op];
|
||||
while let Some(op) = pending.pop() {
|
||||
match op {
|
||||
Op::SetFile { id, .. } | Op::Relink { id, .. } | Op::SetContent { id, .. } | Op::RemovePhoto { id } => self.media.forget(*id),
|
||||
Op::AddPhoto { photo } => self.media.forget(photo.id),
|
||||
Op::Batch { ops } => pending.extend(ops),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(inv)
|
||||
}
|
||||
|
||||
/// [`Session::commit`] for an op that goes with a folder already renamed on disk; the undo
|
||||
/// step renames it back (`folder` is the undo direction: current place → old place).
|
||||
pub(crate) fn commit_with_folder(&mut self, label: &str, op: Op, folder: FolderMove) -> Result<()> {
|
||||
@@ -471,8 +529,8 @@ impl Session {
|
||||
}
|
||||
|
||||
/// Apply an op that is LightCraft's own bookkeeping rather than something the user did (faces found by the background
|
||||
/// scan): journaled like any op, but not an undo step, and it leaves the redo stack alone.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
/// scan, a raw's as-shot white read by a decode): journaled like any op, but not an undo step, and it leaves the
|
||||
/// redo stack alone.
|
||||
pub(crate) fn apply_system(&mut self, op: Op) -> Result<()> {
|
||||
let fwd = op.clone();
|
||||
self.catalog.apply(op)?;
|
||||
@@ -516,13 +574,20 @@ impl Session {
|
||||
if n < 2 || n > self.undo.len() {
|
||||
return;
|
||||
}
|
||||
// a step that moves a folder on disk stays on its own
|
||||
if self.undo[self.undo.len() - n..].iter().any(|e| e.folder.is_some()) {
|
||||
let Some(entries) = self.undo.get(self.undo.len() - n..) else { return };
|
||||
// Folder moves and partially applied file steps stay on their own.
|
||||
if entries.iter().any(|e| e.folder.is_some() || !e.retry_inverse.is_empty() || !e.retry_sidecars.is_empty()) {
|
||||
return;
|
||||
}
|
||||
let tail = self.undo.split_off(self.undo.len() - n);
|
||||
let ops = tail.into_iter().rev().map(|e| e.op).collect();
|
||||
self.undo.push(UndoEntry { label: label.to_string(), op: Op::Batch { ops }, folder: None });
|
||||
self.undo.push(UndoEntry {
|
||||
label: label.to_string(),
|
||||
op: Op::Batch { ops },
|
||||
folder: None,
|
||||
retry_inverse: Vec::new(),
|
||||
retry_sidecars: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Apply without recording undo (interactive previews).
|
||||
@@ -532,8 +597,12 @@ impl Session {
|
||||
}
|
||||
|
||||
pub fn undo_step(&mut self) -> Result<String> {
|
||||
let e = self.undo.pop().ok_or_else(|| EngineError::Other("nothing to undo".into()))?;
|
||||
let redo = match self.apply_with_files(&e.op, e.folder.as_ref()) {
|
||||
self.undo_step_with(&rename::RealFs)
|
||||
}
|
||||
|
||||
pub(crate) fn undo_step_with(&mut self, fs: &dyn rename::MoveFs) -> Result<String> {
|
||||
let mut e = self.undo.pop().ok_or_else(|| EngineError::Other("nothing to undo".into()))?;
|
||||
let redo = match self.apply_with_files(&mut e, fs) {
|
||||
Ok(r) => r,
|
||||
Err(err) => {
|
||||
self.undo.push(e);
|
||||
@@ -542,13 +611,23 @@ impl Session {
|
||||
};
|
||||
self.show_undone(&e.op);
|
||||
self.pending_log.push(e.op);
|
||||
self.redo.push(UndoEntry { label: e.label.clone(), op: redo, folder: e.folder.as_ref().map(FolderMove::reversed) });
|
||||
self.redo.push(UndoEntry {
|
||||
label: e.label.clone(),
|
||||
op: redo,
|
||||
folder: e.folder.as_ref().map(FolderMove::reversed),
|
||||
retry_inverse: Vec::new(),
|
||||
retry_sidecars: Vec::new(),
|
||||
});
|
||||
Ok(e.label)
|
||||
}
|
||||
|
||||
pub fn redo_step(&mut self) -> Result<String> {
|
||||
let e = self.redo.pop().ok_or_else(|| EngineError::Other("nothing to redo".into()))?;
|
||||
let undo = match self.apply_with_files(&e.op, e.folder.as_ref()) {
|
||||
self.redo_step_with(&rename::RealFs)
|
||||
}
|
||||
|
||||
pub(crate) fn redo_step_with(&mut self, fs: &dyn rename::MoveFs) -> Result<String> {
|
||||
let mut e = self.redo.pop().ok_or_else(|| EngineError::Other("nothing to redo".into()))?;
|
||||
let undo = match self.apply_with_files(&mut e, fs) {
|
||||
Ok(r) => r,
|
||||
Err(err) => {
|
||||
self.redo.push(e);
|
||||
@@ -557,7 +636,13 @@ impl Session {
|
||||
};
|
||||
self.show_undone(&e.op);
|
||||
self.pending_log.push(e.op);
|
||||
self.undo.push(UndoEntry { label: e.label.clone(), op: undo, folder: e.folder.as_ref().map(FolderMove::reversed) });
|
||||
self.undo.push(UndoEntry {
|
||||
label: e.label.clone(),
|
||||
op: undo,
|
||||
folder: e.folder.as_ref().map(FolderMove::reversed),
|
||||
retry_inverse: Vec::new(),
|
||||
retry_sidecars: Vec::new(),
|
||||
});
|
||||
Ok(e.label)
|
||||
}
|
||||
|
||||
@@ -583,37 +668,63 @@ impl Session {
|
||||
/// imply (all or nothing). Files that can't be moved back after a failure are reported and the
|
||||
/// library follows them (a logged change outside the undo history), so none goes missing; the
|
||||
/// folder is only moved back when no file was left behind at its new path.
|
||||
fn apply_with_files(&mut self, op: &Op, folder: Option<&FolderMove>) -> Result<Op> {
|
||||
let fs = rename::RealFs;
|
||||
fn apply_with_files(&mut self, entry: &mut UndoEntry, fs: &dyn rename::MoveFs) -> Result<Op> {
|
||||
// The entry is detached from self.undo/self.redo: include it before any global
|
||||
// repair can retire, then retry revalidates it before moving its own packets.
|
||||
rename::validate_sidecars(fs, &entry.retry_sidecars).map_err(|e| EngineError::Other(format!("can't move the files back: {e}")))?;
|
||||
// Repair discarded file steps before another path change can stale their exact paths.
|
||||
// Unrelated metadata history must remain usable while a sidecar is still unwritable.
|
||||
if entry.folder.is_some() || !entry.retry_sidecars.is_empty() || !self.file_moves(&entry.op).is_empty() {
|
||||
self.repair_sidecars_with(fs).map_err(|e| EngineError::Other(format!("can't move the files back: {e}")))?;
|
||||
}
|
||||
rename::retry_sidecars(fs, &mut entry.retry_sidecars).map_err(|e| EngineError::Other(format!("can't move the files back: {e}")))?;
|
||||
let op = &entry.op;
|
||||
let folder = entry.folder.as_ref();
|
||||
if let Some(f) = folder {
|
||||
cmd::browse::rename_folder_on_disk(&f.from, &f.to).map_err(|e| EngineError::Other(format!("can't move the folder back: {e}")))?;
|
||||
cmd::browse::folder_on_disk(&f.from, &f.to).map_err(|e| EngineError::Other(format!("can't move the folder back: {e}")))?;
|
||||
}
|
||||
let undo_folder = || {
|
||||
if let Some(f) = folder {
|
||||
let _ = cmd::browse::rename_folder_on_disk(&f.to, &f.from);
|
||||
let _ = cmd::browse::folder_on_disk(&f.to, &f.from);
|
||||
}
|
||||
};
|
||||
let moves = self.file_moves(op);
|
||||
if let Err(e) = rename::move_all(&fs, &moves) {
|
||||
if let Err(mut e) = rename::move_all(fs, &moves) {
|
||||
entry.retry_sidecars.append(&mut e.pending_sidecars);
|
||||
if e.stuck.is_empty() {
|
||||
undo_folder();
|
||||
}
|
||||
return Err(EngineError::Other(format!("can't move the files back: {}", self.follow_stuck(op, e, false))));
|
||||
let (message, mut inverses) = self.follow_stuck(op, e, false);
|
||||
// A later successful retry observes the corrected paths. Retain their old paths
|
||||
// now, so the opposite history direction still restores the entire original step.
|
||||
inverses.append(&mut entry.retry_inverse);
|
||||
entry.retry_inverse = inverses;
|
||||
return Err(EngineError::Other(format!("can't move the files back: {message}")));
|
||||
}
|
||||
match self.catalog.apply(op.clone()) {
|
||||
match self.apply_catalog(op.clone()) {
|
||||
Ok(inv) => {
|
||||
if let Some(f) = folder {
|
||||
cmd::browse::follow_folder(self, &f.from, &f.to);
|
||||
}
|
||||
Ok(inv)
|
||||
if entry.retry_inverse.is_empty() {
|
||||
Ok(inv)
|
||||
} else {
|
||||
let mut ops = match inv {
|
||||
Op::Batch { ops } => ops,
|
||||
op => vec![op],
|
||||
};
|
||||
ops.append(&mut entry.retry_inverse);
|
||||
Ok(Op::Batch { ops })
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let back: Vec<(String, String)> = moves.iter().rev().map(|(a, b)| (b.clone(), a.clone())).collect();
|
||||
if let Err(be) = rename::move_all(&fs, &back) {
|
||||
if let Err(mut be) = rename::move_all(fs, &back) {
|
||||
entry.retry_sidecars.append(&mut be.pending_sidecars);
|
||||
return Err(EngineError::Other(format!("{e}; the files could not all be moved back: {}", be.message)));
|
||||
}
|
||||
undo_folder();
|
||||
Err(e.into())
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -867,8 +978,12 @@ mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_album_order;
|
||||
#[cfg(test)]
|
||||
mod tests_albums_local;
|
||||
#[cfg(test)]
|
||||
mod tests_color;
|
||||
#[cfg(test)]
|
||||
mod tests_crop_angle;
|
||||
#[cfg(test)]
|
||||
mod tests_denoise;
|
||||
#[cfg(test)]
|
||||
mod tests_export;
|
||||
@@ -878,6 +993,8 @@ mod tests_face_models;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod tests_face_recognize;
|
||||
#[cfg(test)]
|
||||
mod tests_filesystem_history;
|
||||
#[cfg(test)]
|
||||
mod tests_folders;
|
||||
#[cfg(test)]
|
||||
mod tests_forget_local;
|
||||
@@ -892,6 +1009,8 @@ mod tests_libops;
|
||||
#[cfg(test)]
|
||||
mod tests_library;
|
||||
#[cfg(test)]
|
||||
mod tests_lightroom_folder;
|
||||
#[cfg(test)]
|
||||
mod tests_merge;
|
||||
#[cfg(test)]
|
||||
mod tests_organize;
|
||||
|
||||
@@ -32,6 +32,32 @@ use crate::{EngineError, LibrarySource, Result, Selection, Session};
|
||||
/// Library directory name inside the user's Pictures folder.
|
||||
pub const DEFAULT_NAME: &str = "LightCraft Library";
|
||||
|
||||
/// The library folder `path` names. A library is a folder; naming one of its own files
|
||||
/// (`catalog.snap`, `catalog.log`, `catalog.lock`) means the folder that holds it. Any other
|
||||
/// existing file is an error that says so, instead of the OS's "File exists" from creating the
|
||||
/// folder.
|
||||
pub fn resolve_dir(path: &Path) -> Result<PathBuf> {
|
||||
if !path.is_file() {
|
||||
return Ok(path.to_path_buf());
|
||||
}
|
||||
let own = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.is_some_and(|n| [lightcraft_catalog::journal::SNAPSHOT, lightcraft_catalog::journal::LOG, lightcraft_catalog::lock::LOCK].contains(&n));
|
||||
if !own {
|
||||
return Err(EngineError::Other(format!("{} is a file; a library is a folder", path.display())));
|
||||
}
|
||||
Ok(match path.parent().filter(|p| !p.as_os_str().is_empty()) {
|
||||
Some(dir) => dir.to_path_buf(),
|
||||
None => PathBuf::from("."),
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `dir` already holds a library (its catalog files).
|
||||
pub fn is_library(dir: &Path) -> bool {
|
||||
dir.join(lightcraft_catalog::journal::SNAPSHOT).is_file() || dir.join(lightcraft_catalog::journal::LOG).is_file()
|
||||
}
|
||||
|
||||
/// The default library location: `$LIGHTCRAFT_LIBRARY` if set, else `~/Pictures/LightCraft Library`
|
||||
/// (`%USERPROFILE%\Pictures\LightCraft Library` on Windows).
|
||||
pub fn default_dir() -> Option<PathBuf> {
|
||||
@@ -246,14 +272,49 @@ impl SettingsLoad {
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Check, without closing the library open now, that [`Session::open_library`] can be expected
|
||||
/// to open `dir`: not a Lightroom catalog, not some other file, a readable folder (or one not
|
||||
/// made yet), not locked by another program. Returns the library folder ([`resolve_dir`]).
|
||||
/// Nothing is created; a library folder that exists gets its lock taken and released again.
|
||||
/// Opening can still fail afterwards (a damaged catalog, a disk going away).
|
||||
pub fn check_library(&self, dir: impl AsRef<Path>) -> Result<PathBuf> {
|
||||
let dir = dir.as_ref().to_path_buf();
|
||||
if let Some(place) = crate::lightroom_folder::lightroom_catalog_at(&dir) {
|
||||
return Err(EngineError::LightroomCatalog { path: dir, catalog: place.catalog });
|
||||
}
|
||||
let dir = resolve_dir(&dir)?;
|
||||
if !dir.exists() {
|
||||
return Ok(dir);
|
||||
}
|
||||
if !dir.is_dir() {
|
||||
return Err(EngineError::Other(format!("{} is not a folder", dir.display())));
|
||||
}
|
||||
std::fs::read_dir(&dir).map_err(|e| EngineError::Other(format!("can't open library {}: {e}", dir.display())))?;
|
||||
// the library open in this session keeps its lock when reopened; any other must be free
|
||||
let current = self.library.as_ref().is_some_and(|l| l.on_disk && same_dir(&l.dir, &dir));
|
||||
if !current && is_library(&dir) {
|
||||
drop(LibraryLock::acquire(&dir, &program_name()).map_err(|e| EngineError::LibraryInUse(e.to_string()))?);
|
||||
}
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// Open (or create) the library at `dir` into this session, replacing its catalog. With
|
||||
/// `seed_demo`, a newly created library starts with the procedural demo photos.
|
||||
///
|
||||
/// The library is locked for this session ([`lightcraft_catalog::lock`]): if another process
|
||||
/// has it open, this fails with [`EngineError::LibraryInUse`] and nothing is read or changed.
|
||||
/// Reopening the library this session already has open keeps its lock.
|
||||
///
|
||||
/// A Lightroom Classic catalog folder, one of its `.lrdata` / `.lrcat-data` folders or an
|
||||
/// `.lrcat` file is refused with [`EngineError::LightroomCatalog`] before anything is
|
||||
/// created ([`crate::lightroom_folder`]); a LightCraft library always opens, whatever else
|
||||
/// its folder holds.
|
||||
pub fn open_library(&mut self, dir: impl AsRef<Path>, seed_demo: bool) -> Result<&LoadReport> {
|
||||
let dir = dir.as_ref().to_path_buf();
|
||||
if let Some(place) = crate::lightroom_folder::lightroom_catalog_at(&dir) {
|
||||
return Err(EngineError::LightroomCatalog { path: dir, catalog: place.catalog });
|
||||
}
|
||||
let dir = resolve_dir(&dir)?;
|
||||
let open = || FsStore::open(&dir).map_err(|e| EngineError::Other(format!("can't open library {}: {e}", dir.display())));
|
||||
let stores = LibraryStores { catalog: Box::new(open()?), files: Box::new(open()?), on_disk: true, dir: dir.clone() };
|
||||
// the library open in this session (same directory): hand its lock over
|
||||
@@ -312,6 +373,9 @@ impl Session {
|
||||
catalog.revision = LOADS.fetch_add(1, std::sync::atomic::Ordering::Relaxed).wrapping_add(1) << 32;
|
||||
self.catalog = catalog;
|
||||
self.library_generation = crate::LIBRARY_GEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
for entry in self.undo.iter_mut().chain(self.redo.iter_mut()) {
|
||||
self.pending_sidecar_repairs.append(&mut entry.retry_sidecars);
|
||||
}
|
||||
self.undo.clear();
|
||||
self.redo.clear();
|
||||
self.interaction = None;
|
||||
@@ -655,3 +719,63 @@ fn unlocked_warning(lock: Option<&LibraryLock>) -> Option<String> {
|
||||
.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod resolve_tests {
|
||||
use super::*;
|
||||
|
||||
/// `--library <dir>/catalog.snap` failed with the OS's "File exists" (creating a folder over
|
||||
/// the file); it opens the library that file belongs to.
|
||||
#[test]
|
||||
fn a_librarys_own_file_opens_its_folder_and_any_other_file_is_a_clear_error() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-resolve-lib-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&dir, false).unwrap();
|
||||
s.close_library().unwrap();
|
||||
drop(s);
|
||||
assert!(is_library(&dir));
|
||||
let snap = dir.join(lightcraft_catalog::journal::SNAPSHOT);
|
||||
assert_eq!(resolve_dir(&snap).unwrap(), dir);
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&snap, false).unwrap();
|
||||
assert_eq!(s.library.as_ref().map(|l| l.dir.clone()), Some(dir.clone()));
|
||||
drop(s);
|
||||
|
||||
let other = dir.join("notes.txt");
|
||||
std::fs::write(&other, b"x").unwrap();
|
||||
let e = Session::new().with_fs().open_library(&other, false).err().unwrap().to_string();
|
||||
assert!(e.contains("is a file"), "{e}");
|
||||
assert!(!e.contains("File exists"), "{e}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `check_library` refuses what `open_library` would, without touching the open library.
|
||||
#[test]
|
||||
fn check_library_refuses_locked_libraries_files_and_lightroom_catalogs() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-check-lib-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let (a, b) = (dir.join("a"), dir.join("b"));
|
||||
std::fs::create_dir_all(&b).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&b, false).unwrap();
|
||||
s.close_library().unwrap();
|
||||
s.open_library(&a, false).unwrap();
|
||||
assert_eq!(s.check_library(&a).unwrap(), a, "the open library keeps its lock");
|
||||
assert_eq!(s.check_library(&b).unwrap(), b);
|
||||
assert_eq!(s.check_library(dir.join("new")).unwrap(), dir.join("new"));
|
||||
assert!(!dir.join("new").exists(), "nothing created");
|
||||
let held = LibraryLock::acquire(&b, "other").unwrap();
|
||||
assert!(matches!(s.check_library(&b), Err(EngineError::LibraryInUse(_))));
|
||||
drop(held);
|
||||
assert!(s.check_library(&b).is_ok(), "the probe released its lock");
|
||||
std::fs::write(dir.join("notes.txt"), b"x").unwrap();
|
||||
assert!(s.check_library(dir.join("notes.txt")).is_err());
|
||||
std::fs::write(dir.join("Cat.lrcat"), b"x").unwrap();
|
||||
assert!(matches!(s.check_library(dir.join("Cat.lrcat")), Err(EngineError::LightroomCatalog { .. })));
|
||||
assert!(s.library.as_ref().is_some_and(|l| l.dir == a));
|
||||
drop(s);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
//! Lightroom, Python and a C SQLite runtime are not needed. Original paths stay in place.
|
||||
//! Raw settings are archived before mutation; mapped edits are approximations, not Adobe renders.
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||
@@ -336,6 +336,29 @@ fn committed_wal_len(main: &[u8], wal: &[u8]) -> Result<usize, String> {
|
||||
Ok(committed)
|
||||
}
|
||||
|
||||
/// Where a root folder is when its `absolutePath` isn't a folder here (the catalog came from
|
||||
/// another computer): `relativePathFromCatalog`, which Lightroom records for roots on the
|
||||
/// catalog's volume, resolved against the catalog's folder, if that is a folder.
|
||||
fn relocated_root(absolute: &str, relative: &str, catalog: &Path) -> Option<PathBuf> {
|
||||
if relative.is_empty() || !Path::new(relative).is_relative() || Path::new(absolute).is_dir() {
|
||||
return None;
|
||||
}
|
||||
let dir = std::path::absolute(catalog).ok()?.parent()?.join(relative);
|
||||
let mut out = PathBuf::new();
|
||||
for part in dir.components() {
|
||||
match part {
|
||||
Component::ParentDir => {
|
||||
if !out.pop() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Component::CurDir => {}
|
||||
part => out.push(part),
|
||||
}
|
||||
}
|
||||
out.is_dir().then_some(out)
|
||||
}
|
||||
|
||||
/// Inspect `.lrcat` plus committed WAL pages without modifying either source file.
|
||||
pub fn read(path: &Path) -> Result<CatalogImport, String> {
|
||||
let cancel = AtomicBool::new(false);
|
||||
@@ -428,6 +451,21 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize,
|
||||
}
|
||||
out
|
||||
};
|
||||
let mut root_paths = HashMap::new();
|
||||
let mut root_ids: Vec<_> = roots.keys().copied().collect();
|
||||
root_ids.sort_unstable();
|
||||
for (id, root) in root_ids.iter().filter_map(|id| Some((*id, roots.get(id)?))) {
|
||||
let absolute = text(root, "absolutePath");
|
||||
let resolved = match relocated_root(absolute, text(root, "relativePathFromCatalog"), path) {
|
||||
Some(found) => {
|
||||
let found = found.to_string_lossy().to_string();
|
||||
warnings.push(format!("root folder {}: {absolute} not found; using {found} (relative to the catalog)", text(root, "name")));
|
||||
found
|
||||
}
|
||||
None => absolute.to_string(),
|
||||
};
|
||||
root_paths.insert(id, resolved);
|
||||
}
|
||||
let mut history = group(optional_table("Adobe_libraryImageDevelopHistoryStep", &mut warnings)?);
|
||||
let mut snapshots = group(optional_table("Adobe_libraryImageDevelopSnapshot", &mut warnings)?);
|
||||
let mut out = CatalogImport {
|
||||
@@ -444,7 +482,7 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize,
|
||||
let file = files.get(&number(&image, "rootFile")).ok_or_else(|| format!("image {id}: missing file record"))?;
|
||||
let folder = folders.get(&number(file, "folder")).ok_or_else(|| format!("image {id}: missing folder record"))?;
|
||||
let root = roots.get(&number(folder, "rootFolder")).ok_or_else(|| format!("image {id}: missing root record"))?;
|
||||
let root_path = text(root, "absolutePath");
|
||||
let root_path = root_paths.get(&number(root, "id_local")).map_or("", String::as_str);
|
||||
let folder_path = text(folder, "pathFromRoot");
|
||||
let name = text(file, "idx_filename");
|
||||
if root_path.is_empty() || name.is_empty() {
|
||||
@@ -1259,19 +1297,9 @@ mod tests {
|
||||
}
|
||||
page[offset + 5..offset + 7].copy_from_slice(&(end as u16).to_be_bytes());
|
||||
}
|
||||
#[test]
|
||||
fn reads_native_sqlite_tables_without_lightroom_or_external_processes() {
|
||||
use SqlValue::{Integer as I, Null as N, Text as T};
|
||||
let tables = [
|
||||
("AgLibraryRootFolder", "id_local INTEGER PRIMARY KEY, absolutePath", vec![N, T("/catalog/".into())]),
|
||||
("AgLibraryFolder", "id_local INTEGER PRIMARY KEY, rootFolder, pathFromRoot", vec![N, I(1), T("Photos/".into())]),
|
||||
("AgLibraryFile", "id_local INTEGER PRIMARY KEY, folder, idx_filename", vec![N, I(1), T("test.ARW".into())]),
|
||||
(
|
||||
"Adobe_images",
|
||||
"id_local INTEGER PRIMARY KEY, id_global, rootFile, fileFormat, fileWidth, fileHeight, rating, pick",
|
||||
vec![N, T("fixture-image".into()), I(1), T("ARW".into()), I(20), I(10), I(5), I(1)],
|
||||
),
|
||||
];
|
||||
/// A one-row-per-table SQLite file: `(name, columns, row)` each.
|
||||
fn native_catalog(tables: &[(&str, &str, Vec<SqlValue>)]) -> Vec<u8> {
|
||||
use SqlValue::{Integer as I, Text as T};
|
||||
let mut bytes = vec![0u8; 5 * 2048];
|
||||
bytes[..16].copy_from_slice(b"SQLite format 3\0");
|
||||
bytes[16..18].copy_from_slice(&2048u16.to_be_bytes());
|
||||
@@ -1295,6 +1323,30 @@ mod tests {
|
||||
leaf(&mut bytes[(i + 1) * 2048..(i + 2) * 2048], 0, std::slice::from_ref(row));
|
||||
}
|
||||
leaf(&mut bytes[..2048], 100, &schema);
|
||||
bytes
|
||||
}
|
||||
|
||||
fn photo_tables(root: (&str, &str)) -> Vec<(&'static str, &'static str, Vec<SqlValue>)> {
|
||||
use SqlValue::{Integer as I, Null as N, Text as T};
|
||||
vec![
|
||||
(
|
||||
"AgLibraryRootFolder",
|
||||
"id_local INTEGER PRIMARY KEY, absolutePath, name, relativePathFromCatalog",
|
||||
vec![N, T(root.0.into()), T("Photos".into()), T(root.1.into())],
|
||||
),
|
||||
("AgLibraryFolder", "id_local INTEGER PRIMARY KEY, rootFolder, pathFromRoot", vec![N, I(1), T("Photos/".into())]),
|
||||
("AgLibraryFile", "id_local INTEGER PRIMARY KEY, folder, idx_filename", vec![N, I(1), T("test.ARW".into())]),
|
||||
(
|
||||
"Adobe_images",
|
||||
"id_local INTEGER PRIMARY KEY, id_global, rootFile, fileFormat, fileWidth, fileHeight, rating, pick",
|
||||
vec![N, T("fixture-image".into()), I(1), T("ARW".into()), I(20), I(10), I(5), I(1)],
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_native_sqlite_tables_without_lightroom_or_external_processes() {
|
||||
let bytes = native_catalog(&photo_tables(("/catalog/", "")));
|
||||
let path = std::env::temp_dir().join(format!("lightcraft-native-catalog-{}.lrcat", std::process::id()));
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
let catalog = read(&path).unwrap();
|
||||
@@ -1307,6 +1359,25 @@ mod tests {
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_root_missing_here_is_found_relative_to_the_catalog() {
|
||||
let dir = std::env::temp_dir().join(format!("lightcraft-relocated-catalog-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("Lightroom")).unwrap();
|
||||
std::fs::create_dir_all(dir.join("RAW")).unwrap();
|
||||
let path = dir.join("Lightroom").join("Lightroom.lrcat");
|
||||
std::fs::write(&path, native_catalog(&photo_tables(("C:/Users/me/Project/RAW/", "../RAW/")))).unwrap();
|
||||
let catalog = read(&path).unwrap();
|
||||
assert_eq!(Path::new(&catalog.photos[0].path), dir.join("RAW").join("Photos").join("test.ARW"));
|
||||
assert!(catalog.warnings.iter().any(|w| w.contains("relative to the catalog")), "{:?}", catalog.warnings);
|
||||
// a relative path that leads nowhere keeps the recorded one
|
||||
std::fs::write(&path, native_catalog(&photo_tables(("C:/Users/me/Project/RAW/", "../Elsewhere/")))).unwrap();
|
||||
let catalog = read(&path).unwrap();
|
||||
assert!(catalog.photos[0].path.starts_with("C:/Users/me/Project/RAW/"), "{}", catalog.photos[0].path);
|
||||
assert!(catalog.warnings.is_empty(), "{:?}", catalog.warnings);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_optional_table_warns_and_is_skipped() {
|
||||
let mut main = vec![0u8; 2 * 512];
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
//! Recognise a Lightroom Classic catalog where a LightCraft library was asked for (issue #891).
|
||||
//!
|
||||
//! A Lightroom catalog folder holds `Name.lrcat` (SQLite) next to `Name Previews.lrdata`,
|
||||
//! `Name Smart Previews.lrdata`, `Name Helper.lrdata`, `Name.lrcat-data`, the `-wal` / `-shm` /
|
||||
//! `.lock` files of an open catalog and `Backups/`. Opening any of these as a library would create
|
||||
//! an empty LightCraft library inside Lightroom's folder; [`Session::open_library`] refuses with
|
||||
//! [`EngineError::LightroomCatalog`] instead, and the app offers File ▸ Import Lightroom Catalog….
|
||||
//!
|
||||
//! What counts (only names are looked at; no catalog is opened or read):
|
||||
//! - a LightCraft library (`catalog.snap` or `catalog.log` in the folder) is never a Lightroom
|
||||
//! catalog, whatever else the folder holds: it opens as before;
|
||||
//! - a path named `*.lrcat` or one of its companions (`*.lrcat-wal`, `*.lrcat-shm`,
|
||||
//! `*.lrcat-journal`, `*.lrcat.lock`);
|
||||
//! - a folder named `*.lrdata` or `*.lrcat-data` (Lightroom's previews and data);
|
||||
//! - a folder holding a `*.lrcat` at its top level (the catalog folder itself), or `Backups/`
|
||||
//! next to one.
|
||||
//!
|
||||
//! The folder scan is bounded ([`SCAN_LIMIT`] entries, top level only) and every I/O error just
|
||||
//! means "not found": this never fails and never panics.
|
||||
//!
|
||||
//! [`Session::open_library`]: crate::Session::open_library
|
||||
//! [`EngineError::LightroomCatalog`]: crate::EngineError::LightroomCatalog
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The most directory entries looked at in one folder.
|
||||
pub const SCAN_LIMIT: usize = 10_000;
|
||||
|
||||
/// A Lightroom Classic catalog (or part of one) found where a library was asked for.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct LightroomPlace {
|
||||
/// The `.lrcat` file to import, when one was found.
|
||||
pub catalog: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Companion files of an open catalog, after the `.lrcat` (lower case).
|
||||
const COMPANIONS: [&str; 4] = ["-wal", "-shm", "-journal", ".lock"];
|
||||
/// Lightroom's own `.lrdata` folders: `Name<suffix>.lrdata` belongs to `Name.lrcat`.
|
||||
const LRDATA_SUFFIXES: [&str; 4] = [" Smart Previews", " Previews", " Helper", " Sync"];
|
||||
|
||||
/// `path`'s file name, lower-cased (lossy: only used to compare with ASCII suffixes).
|
||||
fn lower_name(path: &Path) -> Option<String> {
|
||||
path.file_name().map(|n| n.to_string_lossy().to_lowercase())
|
||||
}
|
||||
|
||||
/// `name` ends with `suffix`, ignoring ASCII case; returns the part before it (`None` for a
|
||||
/// non-UTF-8 name, or when the cut would not be on a character boundary).
|
||||
fn strip_suffix_nocase<'a>(name: &'a str, suffix: &str) -> Option<&'a str> {
|
||||
let cut = name.len().checked_sub(suffix.len())?;
|
||||
let tail = name.get(cut..)?;
|
||||
tail.eq_ignore_ascii_case(suffix).then(|| name.get(..cut)).flatten()
|
||||
}
|
||||
|
||||
/// `name` is `*.lrcat` (any case).
|
||||
fn is_lrcat_name(name: &str) -> bool {
|
||||
name.to_lowercase().ends_with(".lrcat")
|
||||
}
|
||||
|
||||
/// The first `*.lrcat` file (by name) at the top level of `dir`, looking at no more than
|
||||
/// [`SCAN_LIMIT`] entries. Unreadable folders and entries are skipped.
|
||||
pub fn find_lrcat(dir: &Path) -> Option<PathBuf> {
|
||||
let entries = std::fs::read_dir(dir).ok()?;
|
||||
let mut found: Option<PathBuf> = None;
|
||||
for entry in entries.take(SCAN_LIMIT).flatten() {
|
||||
let name = entry.file_name();
|
||||
let Some(name) = name.to_str() else { continue };
|
||||
if !is_lrcat_name(name) {
|
||||
continue;
|
||||
}
|
||||
let path = entry.path();
|
||||
// (follows a symlink; a folder named `*.lrcat` is not a catalog)
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
if found.as_ref().is_none_or(|f| path < *f) {
|
||||
found = Some(path);
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// `candidate` if it is a file, else the first catalog in `dir`.
|
||||
fn catalog_or_scan(candidate: Option<PathBuf>, dir: Option<&Path>) -> Option<PathBuf> {
|
||||
candidate.filter(|c| c.is_file()).or_else(|| dir.and_then(find_lrcat))
|
||||
}
|
||||
|
||||
/// Whether `path`, given as a library folder, is a Lightroom Classic catalog or part of one (see
|
||||
/// the module docs). `None`: not Lightroom's, open it as a library.
|
||||
pub fn lightroom_catalog_at(path: &Path) -> Option<LightroomPlace> {
|
||||
let is_dir = path.is_dir();
|
||||
if is_dir && crate::library::is_library(path) {
|
||||
return None;
|
||||
}
|
||||
let parent = path.parent().filter(|p| !p.as_os_str().is_empty());
|
||||
let name = path.file_name().and_then(|n| n.to_str());
|
||||
let lower = lower_name(path).unwrap_or_default();
|
||||
// the catalog file itself (a folder named `*.lrcat` is looked into below)
|
||||
if lower.ends_with(".lrcat") && !is_dir {
|
||||
return Some(LightroomPlace { catalog: path.is_file().then(|| path.to_path_buf()) });
|
||||
}
|
||||
// an open catalog's companion files: `Name.lrcat-wal` → `Name.lrcat`
|
||||
for c in COMPANIONS {
|
||||
if lower.ends_with(&format!(".lrcat{c}")) && !is_dir {
|
||||
let candidate = name.and_then(|n| strip_suffix_nocase(n, c)).and_then(|n| parent.map(|p| p.join(n)));
|
||||
return Some(LightroomPlace { catalog: catalog_or_scan(candidate, parent) });
|
||||
}
|
||||
}
|
||||
// `Name.lrcat-data` → `Name.lrcat`
|
||||
if let Some(stem) = name.and_then(|n| strip_suffix_nocase(n, "-data"))
|
||||
&& is_lrcat_name(stem)
|
||||
{
|
||||
let candidate = parent.map(|p| p.join(stem));
|
||||
return Some(LightroomPlace { catalog: catalog_or_scan(candidate, parent) });
|
||||
}
|
||||
// `Name Previews.lrdata` (and Smart Previews, Helper, Sync) → `Name.lrcat`
|
||||
if let Some(stem) = name.and_then(|n| strip_suffix_nocase(n, ".lrdata")) {
|
||||
let base = LRDATA_SUFFIXES.iter().find_map(|s| strip_suffix_nocase(stem, s)).unwrap_or(stem);
|
||||
let candidate = parent.map(|p| p.join(format!("{base}.lrcat")));
|
||||
return Some(LightroomPlace { catalog: catalog_or_scan(candidate, parent) });
|
||||
}
|
||||
if lower.ends_with(".lrdata") || lower.ends_with(".lrcat-data") {
|
||||
// (a non-UTF-8 name)
|
||||
return Some(LightroomPlace { catalog: parent.and_then(find_lrcat) });
|
||||
}
|
||||
if is_dir {
|
||||
// the catalog folder
|
||||
if let Some(catalog) = find_lrcat(path) {
|
||||
return Some(LightroomPlace { catalog: Some(catalog) });
|
||||
}
|
||||
// `Backups/` next to a catalog
|
||||
if lower == "backups"
|
||||
&& let Some(catalog) = parent.and_then(find_lrcat)
|
||||
{
|
||||
return Some(LightroomPlace { catalog: Some(catalog) });
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -141,7 +141,8 @@ pub struct LightroomJob {
|
||||
impl LightroomJob {
|
||||
/// Snapshot session state only. This constructor performs no filesystem work.
|
||||
pub fn new(s: &mut crate::Session, path: PathBuf, update_existing: bool) -> crate::Result<Self> {
|
||||
let opts = ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() };
|
||||
// a photo deleted here stays deleted: the catalog's record is matched, not restored
|
||||
let opts = ImportOptions { mode: ImportMode::Add, on_deleted: crate::import::OnDeleted::Skip, ..ImportOptions::default() };
|
||||
let import = ImportJob::new(s, opts)?;
|
||||
let archive_dir = s.library.as_ref().filter(|library| library.on_disk).map(|library| library.dir.join("Interop"));
|
||||
Ok(Self { path, update_existing, import, token: LightroomLibraryToken::capture(s), archive_dir })
|
||||
@@ -242,7 +243,7 @@ pub fn commit_prepared(s: &mut crate::Session, mut prepared: PreparedLightroom)
|
||||
|
||||
let undo0 = s.undo.len();
|
||||
prepared.normal.revalidate_add(s);
|
||||
let options = ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() };
|
||||
let options = ImportOptions { mode: ImportMode::Add, on_deleted: crate::import::OnDeleted::Skip, ..ImportOptions::default() };
|
||||
let now = prepared.now.clone();
|
||||
let files = crate::import::commit_prepared(s, &options, &now, prepared.normal)?;
|
||||
let applied = lightroom_catalog::apply_prepared(
|
||||
|
||||
+292
-28
@@ -19,7 +19,7 @@
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::sync::{Arc, Weak};
|
||||
|
||||
use lightcraft_catalog::{MediaKind, Photo, PhotoId, Source};
|
||||
use lightcraft_catalog::{MediaKind, Op, Photo, PhotoId, Source};
|
||||
use lightcraft_develop::DevelopSettings;
|
||||
use lightcraft_pipeline::{Quality, RenderRequest, Rendered, SourceInfo, StageCache};
|
||||
use lightcraft_preview::{Hash128, Hasher128, Lru, PreviewCache};
|
||||
@@ -62,7 +62,7 @@ impl SettingsHashes {
|
||||
}
|
||||
|
||||
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
|
||||
pub const RENDER_CACHE_VERSION: u64 = 42;
|
||||
pub const RENDER_CACHE_VERSION: u64 = 61;
|
||||
|
||||
/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache).
|
||||
pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512];
|
||||
@@ -309,8 +309,11 @@ pub struct MediaCache {
|
||||
thumbs: Lru<PhotoId, DecodedSource>,
|
||||
/// Decoded preview-level sources with their last use ([`lightcraft_preview::next_tick`]).
|
||||
previews: Vec<(PhotoId, DecodedSource, u64)>,
|
||||
/// The last full-resolution original (exports; one at a time: ~300 MB at 24 MP).
|
||||
full: Option<(PhotoId, DecodedSource, u64)>,
|
||||
/// The last full-resolution originals (exports, zoomed views; ~300 MB each at 24 MP), with
|
||||
/// their last use. Normally one; see [`Self::set_full_capacity`].
|
||||
full: Vec<(PhotoId, DecodedSource, u64)>,
|
||||
/// How many full-resolution originals to keep (LRU, at least one).
|
||||
full_capacity: usize,
|
||||
/// How many previews to keep (LRU).
|
||||
pub preview_capacity: usize,
|
||||
/// Bytes all decoded sources and rendered previews in memory may take together (the cache
|
||||
@@ -386,7 +389,8 @@ impl Default for MediaCache {
|
||||
settings_hashes: SettingsHashes::default(),
|
||||
thumbs: Lru::new(THUMB_SOURCE_BYTES.min(budget)),
|
||||
previews: Vec::new(),
|
||||
full: None,
|
||||
full: Vec::new(),
|
||||
full_capacity: 1,
|
||||
preview_capacity: 0,
|
||||
budget,
|
||||
file_loader: None,
|
||||
@@ -431,7 +435,23 @@ impl MediaCache {
|
||||
self.settings_hashes = SettingsHashes::default();
|
||||
self.thumbs = Lru::new(THUMB_SOURCE_BYTES.min(self.budget));
|
||||
self.previews.clear();
|
||||
self.full = None;
|
||||
self.full.clear();
|
||||
}
|
||||
|
||||
/// How many full-resolution originals to keep at once (at least one). Compare and Reference
|
||||
/// views zoomed to 1:1 cut windows from two photos' originals: with room for one, every window
|
||||
/// render of one side would evict the other side's original and decode its own again. Lowering
|
||||
/// it drops the least recently used originals at once.
|
||||
pub fn set_full_capacity(&mut self, n: usize) {
|
||||
self.full_capacity = n.max(1);
|
||||
self.trim_full();
|
||||
}
|
||||
|
||||
fn trim_full(&mut self) {
|
||||
while self.full.len() > self.full_capacity {
|
||||
let oldest = self.full.iter().enumerate().min_by_key(|(_, e)| e.2).map(|(i, _)| i).unwrap_or(0);
|
||||
self.full.remove(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes the caches may hold together.
|
||||
@@ -463,14 +483,14 @@ impl MediaCache {
|
||||
let candidates = [
|
||||
self.thumbs.oldest_tick().map(|t| (t, 0)),
|
||||
self.previews.iter().filter(|e| Some(e.2) != newest_preview).map(|e| e.2).min().map(|t| (t, 1)),
|
||||
self.full.as_ref().map(|e| (e.2, 2)),
|
||||
self.full.iter().map(|e| e.2).min().map(|t| (t, 2)),
|
||||
self.rendered.oldest_tick().map(|t| (t, 3)),
|
||||
];
|
||||
let Some((tick, which)) = candidates.into_iter().flatten().min() else { break };
|
||||
let freed = match which {
|
||||
0 => self.thumbs.pop_oldest(),
|
||||
1 => self.previews.iter().position(|e| e.2 == tick).map(|i| self.previews.remove(i).1.bytes()),
|
||||
2 => self.full.take().map(|e| e.1.bytes()),
|
||||
2 => self.full.iter().position(|e| e.2 == tick).map(|i| self.full.remove(i).1.bytes()),
|
||||
_ => self.rendered.evict_oldest(),
|
||||
};
|
||||
match freed {
|
||||
@@ -491,7 +511,7 @@ impl MediaCache {
|
||||
e.2 = lightcraft_preview::next_tick();
|
||||
e.1.clone()
|
||||
}),
|
||||
SourceLevel::Full => self.full.as_mut().filter(|e| e.0 == id).map(|e| {
|
||||
SourceLevel::Full => self.full.iter_mut().find(|e| e.0 == id).map(|e| {
|
||||
e.2 = lightcraft_preview::next_tick();
|
||||
e.1.clone()
|
||||
}),
|
||||
@@ -534,7 +554,11 @@ impl MediaCache {
|
||||
self.previews.remove(oldest);
|
||||
}
|
||||
}
|
||||
SourceLevel::Full => self.full = Some((id, img, tick)),
|
||||
SourceLevel::Full => {
|
||||
self.full.retain(|e| e.0 != id);
|
||||
self.full.push((id, img, tick));
|
||||
self.trim_full();
|
||||
}
|
||||
}
|
||||
self.enforce_budget();
|
||||
}
|
||||
@@ -543,9 +567,7 @@ impl MediaCache {
|
||||
pub fn forget(&mut self, id: PhotoId) {
|
||||
self.thumbs.remove(&id);
|
||||
self.previews.retain(|e| e.0 != id);
|
||||
if self.full.as_ref().is_some_and(|e| e.0 == id) {
|
||||
self.full = None;
|
||||
}
|
||||
self.full.retain(|e| e.0 != id);
|
||||
}
|
||||
|
||||
/// (decoded thumbnail sources, bytes).
|
||||
@@ -558,7 +580,7 @@ impl MediaCache {
|
||||
match level {
|
||||
SourceLevel::Thumb => self.thumbs.contains(&id),
|
||||
SourceLevel::Preview => self.previews.iter().any(|e| e.0 == id),
|
||||
SourceLevel::Full => self.full.as_ref().is_some_and(|e| e.0 == id),
|
||||
SourceLevel::Full => self.full.iter().any(|e| e.0 == id),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -566,7 +588,7 @@ impl MediaCache {
|
||||
pub fn usage(&self) -> (crate::memory::Usage, crate::memory::Usage, crate::memory::Usage) {
|
||||
use crate::memory::Usage;
|
||||
let previews = Usage::new(self.previews.len(), self.previews.iter().map(|e| e.1.bytes()).sum());
|
||||
let full = self.full.as_ref().map(|e| Usage::new(1, e.1.bytes())).unwrap_or_default();
|
||||
let full = Usage::new(self.full.len(), self.full.iter().map(|e| e.1.bytes()).sum());
|
||||
(Usage::new(self.thumbs.len(), self.thumbs.cost()), previews, full)
|
||||
}
|
||||
|
||||
@@ -613,7 +635,9 @@ impl MediaCache {
|
||||
// be offline is tried first and its smart preview is the render worker's fallback.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let (Source::File { path }, Some(dir)) = (&p.source, &self.smart_dir) {
|
||||
let sp = dir.join(crate::smart::file_name(p));
|
||||
let sharded = crate::smart::sharded_file_path(dir, p);
|
||||
let legacy = dir.join(crate::smart::file_name(p));
|
||||
let sp = if self.availability.exists(&sharded.to_string_lossy()) == Some(true) { sharded } else { legacy };
|
||||
// (a proxy cut short fails to load, like the offline original; the worker's fallback
|
||||
// in `SourceRef::load` only uses a proxy that passes `smart::is_valid`)
|
||||
if self.availability.is_offline(path) && self.availability.exists(&sp.to_string_lossy()) == Some(true) {
|
||||
@@ -685,6 +709,9 @@ pub struct RenderResult {
|
||||
pub level: SourceLevel,
|
||||
pub key: u64,
|
||||
pub rendered: Result<Rendered, String>,
|
||||
/// Set when this job's render was tried on the GPU and fell back to the CPU: why
|
||||
/// ([`lightcraft_gpu::Fallback::Failed`]). The result is the CPU render either way.
|
||||
pub gpu_fallback: Option<String>,
|
||||
/// A source that was loaded by this job (to be inserted into the cache).
|
||||
pub loaded: Option<DecodedSource>,
|
||||
/// Set for a [`QuickJob`]'s stand-in: where the image came from.
|
||||
@@ -733,6 +760,28 @@ impl RenderJob {
|
||||
self
|
||||
}
|
||||
|
||||
/// Render with some colour grading wheels (shadows, midtones, highlights) switched off for
|
||||
/// viewing (the wheel eyes in the Edit panel). The photo's settings are untouched; the result
|
||||
/// gets its own key and is never cached as the photo's preview or thumbnail.
|
||||
pub fn with_hidden_grading(mut self, hidden: [bool; 3]) -> Self {
|
||||
if !hidden.iter().any(|h| *h) {
|
||||
return self;
|
||||
}
|
||||
let mut s = (*self.settings).clone();
|
||||
let g = &mut s.grading;
|
||||
for (off, wheel) in hidden.into_iter().zip([&mut g.shadows, &mut g.midtones, &mut g.highlights]) {
|
||||
if off {
|
||||
*wheel = lightcraft_develop::Wheel::default();
|
||||
}
|
||||
}
|
||||
let bits = hidden.iter().enumerate().fold(0u64, |b, (i, h)| b | ((*h as u64) << i));
|
||||
self.key ^= s.hash64() ^ self.settings.hash64() ^ bits.wrapping_mul(0xd6e8_feb8_6659_fd93);
|
||||
self.settings = Arc::new(s);
|
||||
self.cache = None;
|
||||
self.view_cache = None;
|
||||
self
|
||||
}
|
||||
|
||||
/// Reuse `stages` across this view's renders.
|
||||
pub fn with_stages(mut self, stages: Arc<StageCache>) -> Self {
|
||||
self.stages = Some(stages);
|
||||
@@ -767,6 +816,7 @@ impl RenderJob {
|
||||
level: self.level,
|
||||
key: self.key,
|
||||
rendered: Ok(Rendered { image, histogram, deep: None }),
|
||||
gpu_fallback: None,
|
||||
loaded: None,
|
||||
quick: None,
|
||||
display: None,
|
||||
@@ -788,7 +838,7 @@ impl RenderJob {
|
||||
// Thumbnails (many small jobs side by side) stay on the CPU; views and exports use
|
||||
// the GPU when there is one.
|
||||
let gpu = self.cache.is_none();
|
||||
let mut rendered = develop(src, &info, &self.settings, &self.request, self.stages.as_deref(), gpu);
|
||||
let (mut rendered, gpu_fallback) = develop_reporting(src, &info, &self.settings, &self.request, self.stages.as_deref(), gpu);
|
||||
// for a display profile: the image goes to the display's values, the histogram
|
||||
// and the cached view preview stay sRGB
|
||||
let mut srgb = None;
|
||||
@@ -796,7 +846,8 @@ impl RenderJob {
|
||||
(Some(d), Some(_)) => {
|
||||
let keep = self.view_cache.is_some() && self.request.quality == Quality::Full;
|
||||
let (s, h) = crate::display::finish_render(&mut rendered.image, d, keep);
|
||||
if let Some(h) = h {
|
||||
// (an HDR edit's HDR histogram stays)
|
||||
if let Some(h) = h.filter(|_| rendered.histogram.hdr.is_none()) {
|
||||
rendered.histogram = h;
|
||||
}
|
||||
srgb = s;
|
||||
@@ -820,6 +871,7 @@ impl RenderJob {
|
||||
level: self.level,
|
||||
key: self.key,
|
||||
rendered: Ok(rendered),
|
||||
gpu_fallback,
|
||||
loaded: (!was_loaded).then_some(source),
|
||||
quick: None,
|
||||
display,
|
||||
@@ -832,6 +884,7 @@ impl RenderJob {
|
||||
level: self.level,
|
||||
key: self.key,
|
||||
rendered: Err(e),
|
||||
gpu_fallback: None,
|
||||
loaded: None,
|
||||
quick: None,
|
||||
display: None,
|
||||
@@ -844,19 +897,55 @@ impl RenderJob {
|
||||
/// the CPU, reusing `stages` either way. Both produce the same image within 1–3 LSB (see
|
||||
/// `docs/gpu-pipeline.md`); `LIGHTCRAFT_GPU=0` or [`lightcraft_gpu::set_enabled`] forces the CPU.
|
||||
pub fn develop(src: &Arc<Rgb32f>, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, stages: Option<&StageCache>, gpu: bool) -> Rendered {
|
||||
develop_reporting(src, info, s, req, stages, gpu).0
|
||||
}
|
||||
|
||||
/// [`develop`], also saying why the render fell back from the GPU to the CPU when it did (`None`:
|
||||
/// it rendered on the GPU, or the GPU was not tried). The reason is this render's own, not the
|
||||
/// process-wide [`lightcraft_gpu::last_fallback`].
|
||||
pub fn develop_reporting(
|
||||
src: &Arc<Rgb32f>,
|
||||
info: &SourceInfo,
|
||||
s: &DevelopSettings,
|
||||
req: &RenderRequest,
|
||||
stages: Option<&StageCache>,
|
||||
gpu: bool,
|
||||
) -> (Rendered, Option<String>) {
|
||||
let (mut rendered, fallback) = develop_image(src, info, s, req, stages, gpu);
|
||||
// an HDR edit's preview is its SDR rendition: its histogram shows the HDR range instead
|
||||
if let Some(h) = lightcraft_pipeline::hdr_histogram(src, info, s, req) {
|
||||
rendered.histogram = h;
|
||||
}
|
||||
(rendered, fallback)
|
||||
}
|
||||
|
||||
fn develop_image(
|
||||
src: &Arc<Rgb32f>,
|
||||
info: &SourceInfo,
|
||||
s: &DevelopSettings,
|
||||
req: &RenderRequest,
|
||||
stages: Option<&StageCache>,
|
||||
gpu: bool,
|
||||
) -> (Rendered, Option<String>) {
|
||||
// LUT profiles have no GPU stage: they render on the CPU
|
||||
if gpu
|
||||
let fallback = if gpu
|
||||
&& !lightcraft_pipeline::lut::is_lut_profile(&s.profile.id)
|
||||
&& !s.masks.iter().any(|m| m.visible && m.refine > 0.0)
|
||||
&& req.proof.is_none()
|
||||
&& let Some(r) = lightcraft_gpu::render(src, info, s, req, stages)
|
||||
{
|
||||
return r;
|
||||
}
|
||||
match stages {
|
||||
match lightcraft_gpu::try_render(src, info, s, req, stages) {
|
||||
Ok(r) => return (r, None),
|
||||
Err(lightcraft_gpu::Fallback::Failed(why)) => Some(why),
|
||||
Err(lightcraft_gpu::Fallback::NotTried) => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let rendered = match stages {
|
||||
Some(st) => lightcraft_pipeline::render_cached(src, info, s, req, st),
|
||||
None => lightcraft_pipeline::render(src, info, s, req),
|
||||
}
|
||||
};
|
||||
(rendered, fallback)
|
||||
}
|
||||
|
||||
/// Where a [`QuickJob`]'s stand-in image came from.
|
||||
@@ -897,6 +986,7 @@ impl QuickJob {
|
||||
level: SourceLevel::Thumb,
|
||||
key,
|
||||
rendered,
|
||||
gpu_fallback: None,
|
||||
loaded: None,
|
||||
quick,
|
||||
display: None,
|
||||
@@ -940,8 +1030,18 @@ pub fn source_info(p: &Photo) -> SourceInfo {
|
||||
// A raw shown from its embedded preview is a rendered (display-referred) JPEG: relative white
|
||||
// balance and the display tone curve, like any other rendered file.
|
||||
if p.develops_raw() {
|
||||
let (temp, tint) = if p.relative_wb() { (6500.0, 0.0) } else { p.as_shot_wb.unwrap_or((5500.0, 0.0)) };
|
||||
SourceInfo { raw: true, as_shot_temp: temp, as_shot_tint: tint, lens: p.embedded_lens, relative_wb: p.relative_wb(), ..Default::default() }
|
||||
let relative = p.relative_wb();
|
||||
let (temp, tint) = if relative { (6500.0, 0.0) } else { p.as_shot_wb.unwrap_or((5500.0, 0.0)) };
|
||||
SourceInfo {
|
||||
raw: true,
|
||||
as_shot_temp: temp,
|
||||
as_shot_tint: tint,
|
||||
lens: p.embedded_lens,
|
||||
relative_wb: relative,
|
||||
// (a fitted-look raw edited in Kelvin: its camera's matrices give the scale, see `files::WbScaleOf`)
|
||||
legacy_relative_wb: p.fitted_look() && !relative,
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
SourceInfo::default()
|
||||
}
|
||||
@@ -1212,6 +1312,23 @@ impl crate::Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `p`'s thumbnail can be the camera's own JPEG, so a frontend can skip the sensor
|
||||
/// decode behind it.
|
||||
///
|
||||
/// The grid shows this JPEG first in any case ([`QuickJob`]: "grid thumbnails of unedited raws
|
||||
/// likewise start from the embedded preview"), and then paid for a render of the raw to replace
|
||||
/// it. That render decodes the whole sensor — 50 of a 56 MB Fuji RAF's bytes — for a 512 px
|
||||
/// thumbnail the 4 416 × 2 944 embedded JPEG already covers, and on a share at 1.2 MB/s it is
|
||||
/// 45 s a photo: a 149-photo folder spent 8.3 GB of traffic on thumbnails, and the queue behind
|
||||
/// it delayed the render the user was waiting for.
|
||||
///
|
||||
/// Only unedited raws answer `true`: any edit must be rendered, and `has_import_look` is what
|
||||
/// says the photo still looks the way it did at import. A photo the user edits re-renders
|
||||
/// through the ordinary path, because its thumbnail key (settings hash) changes.
|
||||
pub fn embedded_thumb_is_final(&self, p: &Photo) -> bool {
|
||||
self.embedded_of(p).is_some()
|
||||
}
|
||||
|
||||
/// The camera's embedded preview of a raw file, for the face scan. Faces are read from the unedited picture, so the
|
||||
/// photo's edits do not matter, and the camera's own JPEG (oriented, display-ready) costs a fraction of decoding the
|
||||
/// raw. `None` for anything else, and where the host installed no loader.
|
||||
@@ -1254,15 +1371,38 @@ impl crate::Session {
|
||||
})
|
||||
}
|
||||
|
||||
/// Accept a finished job's loaded source into the cache.
|
||||
/// Accept a finished job's loaded source into the cache. What the decode learnt about the file that the catalog
|
||||
/// has wrong is corrected too ([`Self::learn_as_shot_wb`]).
|
||||
pub fn accept(&mut self, r: &RenderResult) {
|
||||
if let Some(src) = &r.loaded
|
||||
&& r.source_key.is_none_or(|key| self.catalog.photo(r.photo).is_some_and(|p| Hasher128::new().str(&content_key(p)).finish() == key))
|
||||
{
|
||||
if let Some(info) = &src.info {
|
||||
self.learn_as_shot_wb(r.photo, info);
|
||||
}
|
||||
self.media.insert_source(r.photo, r.level, src.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// Record the as-shot white balance a decode of photo `id`'s file read (`info`) when the catalog has another:
|
||||
/// a raw imported before its camera's matrices gave it a Kelvin scale keeps 6500 / 0 (the relative scale's
|
||||
/// reference) until its file is next decoded (issue #730). Bookkeeping, not an undo step; the look does not
|
||||
/// change (the pipeline reads the stored settings against the real as-shot white either way), only what As Shot
|
||||
/// says and what a smart preview renders with. Only for raws edited in Kelvin.
|
||||
fn learn_as_shot_wb(&mut self, id: PhotoId, info: &SourceInfo) {
|
||||
let Some(p) = self.catalog.photo(id) else { return };
|
||||
if !(info.raw && !info.relative_wb && p.develops_raw() && !p.relative_wb()) {
|
||||
return;
|
||||
}
|
||||
let read = (info.as_shot_temp, info.as_shot_tint);
|
||||
if !(read.0.is_finite() && read.1.is_finite()) || p.as_shot_wb == Some(read) {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = self.apply_system(Op::SetAsShotWb { id, wb: Some(read) }) {
|
||||
log::warn!("photo {}: as-shot white balance not recorded: {e}", id.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronous render (CLI, MCP, tests).
|
||||
pub fn render_now(&mut self, id: PhotoId, max_w: usize, max_h: usize) -> Result<Rendered, String> {
|
||||
let job = self.render_job(id, max_w, max_h, false, true).ok_or("no such photo")?;
|
||||
@@ -1322,6 +1462,9 @@ impl crate::Session {
|
||||
let p = self.catalog.photo(id).ok_or("no such photo")?.clone();
|
||||
let r = self.media.source_ref(&p, level).load_source()?;
|
||||
let image = r.image.clone();
|
||||
if let Some(info) = &r.info {
|
||||
self.learn_as_shot_wb(id, info);
|
||||
}
|
||||
self.media.insert_source(id, level, r);
|
||||
Ok(image)
|
||||
}
|
||||
@@ -1356,7 +1499,6 @@ pub type FileProbe = Arc<dyn Fn(&str) -> Result<ProbeInfo, String> + Send + Sync
|
||||
#[cfg(test)]
|
||||
mod thumbnail_hash_tests {
|
||||
use super::*;
|
||||
use lightcraft_catalog::Op;
|
||||
|
||||
fn session() -> crate::Session {
|
||||
let mut s = crate::Session::new();
|
||||
@@ -1466,6 +1608,23 @@ mod tests {
|
||||
assert_eq!(source.bytes(), reserved, "changing Amount replaces the reserved mix");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_grading_wheels_render_neutral_without_touching_the_photo() {
|
||||
let mut s = crate::Session::with_demo();
|
||||
let id = s.active().unwrap();
|
||||
s.execute("develop.set", &serde_json::json!({"values": {"grading.shadows.sat": 60, "grading.highlights.sat": 40}})).unwrap();
|
||||
let job = s.loupe_job(id, 64, 64, true).unwrap();
|
||||
let same = job.clone().with_hidden_grading([false; 3]);
|
||||
assert_eq!(same.key, job.key, "nothing hidden: the job is unchanged");
|
||||
assert!(same.view_cache.is_some());
|
||||
let hidden = job.clone().with_hidden_grading([true, false, false]);
|
||||
assert_eq!(hidden.settings.grading.shadows.sat, 0.0);
|
||||
assert_eq!(hidden.settings.grading.highlights.sat, 40.0);
|
||||
assert_ne!(hidden.key, job.key, "a hidden wheel gets its own result key");
|
||||
assert!(hidden.view_cache.is_none() && hidden.cache.is_none(), "never cached as the photo's preview");
|
||||
assert_eq!(s.catalog.photo(id).unwrap().develop.grading.shadows.sat, 60.0, "the edit is untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_info_survives_render_jobs_cache_and_eviction() {
|
||||
let tone = lightcraft_pipeline::tone::CameraTone::new(std::array::from_fn(|i| {
|
||||
@@ -1504,6 +1663,81 @@ mod tests {
|
||||
assert!(s.media.get_source(id, r.level).is_none());
|
||||
}
|
||||
|
||||
/// Issue #730: an ILCE-7M3 ARW from an older catalog (white balance relative to the as-shot look, 6500 / 0 as
|
||||
/// its as-shot white, a Custom value on that scale) renders pixel for pixel as it did once the camera's matrices
|
||||
/// give it a Kelvin scale; the decode teaches the catalog the real as-shot white (bookkeeping, not an undo step),
|
||||
/// and the panel then reads As Shot in Kelvin while the stored settings keep their look.
|
||||
#[test]
|
||||
fn old_relative_white_balance_on_a_kelvin_camera_keeps_its_look_and_learns_the_as_shot_white() {
|
||||
use lightcraft_develop::{WbMode, WbScale};
|
||||
let mut s = crate::Session::new();
|
||||
let id = s.catalog.alloc_photo_id();
|
||||
let mut p = Photo::new(id, Source::File { path: "/photos/DSC00001.ARW".into() }, "DSC00001.ARW", "ARW", 64, 64, "2026-01-01T00:00:00");
|
||||
p.kind = MediaKind::Raw;
|
||||
p.meta.camera = "SONY ILCE-7M3".into();
|
||||
p.as_shot_wb = Some((6500.0, 0.0));
|
||||
let mut d = lightcraft_develop::DevelopSettings::for_raw(6500.0, 0.0);
|
||||
(d.wb.mode, d.wb.temp, d.wb.tint) = (WbMode::Custom, 5800.0, 3.0);
|
||||
assert_eq!(d.wb.scale, WbScale::Legacy);
|
||||
p.develop = Arc::new(d.clone());
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
s.selection.ids = vec![id];
|
||||
s.selection.active = Some(id);
|
||||
// the catalog's view of the source before the file is read: Kelvin scale, as-shot white not yet known
|
||||
let header = s.source_info(id);
|
||||
assert!(header.raw && !header.relative_wb && header.legacy_relative_wb);
|
||||
assert_eq!((header.as_shot_temp, header.as_shot_tint), (6500.0, 0.0));
|
||||
// the decode: what `files::load_bytes` reports for this camera (the as-shot white through its matrices)
|
||||
let decoded = SourceInfo { raw: true, legacy_relative_wb: true, as_shot_temp: 4986.0, as_shot_tint: -2.0, ..Default::default() };
|
||||
let mut pixels = Rgb32f::new(64, 64);
|
||||
for (i, px) in pixels.data.iter_mut().enumerate() {
|
||||
let v = (i % 64) as f32 / 64.0;
|
||||
*px = [0.05 + 0.6 * v, 0.04 + 0.4 * v, 0.03 + 0.3 * v];
|
||||
}
|
||||
let mut job = s.render_job(id, 64, 64, false, true).unwrap();
|
||||
let (loaded_info, loaded_pixels) = (decoded.clone(), pixels.clone());
|
||||
job.source = SourceRef::File {
|
||||
path: "/photos/DSC00001.ARW".into(),
|
||||
max_edge: 64,
|
||||
loader: Some(Arc::new(move |_, _| Ok((loaded_pixels.clone(), loaded_info.clone())))),
|
||||
fallback: None,
|
||||
denoise: None,
|
||||
};
|
||||
let undo_before = s.undo.len();
|
||||
let request = job.request;
|
||||
let r = job.run();
|
||||
let new = r.rendered.as_ref().unwrap().image.data.clone();
|
||||
// the old pipeline: the same settings against the relative-scale source facts of an older build
|
||||
let old_info = SourceInfo { raw: true, relative_wb: true, as_shot_temp: 6500.0, as_shot_tint: 0.0, ..Default::default() };
|
||||
let old = lightcraft_pipeline::render(&pixels, &old_info, &d, &request).image.data;
|
||||
assert_eq!(new, old, "the look of an existing edit changed");
|
||||
assert_ne!(
|
||||
new,
|
||||
lightcraft_pipeline::render(&pixels, &decoded, &lightcraft_develop::DevelopSettings::for_raw(4986.0, -2.0), &request).image.data
|
||||
);
|
||||
// accepting the result records the as-shot white, journaled but not undoable, and the settings are untouched
|
||||
s.accept(&r);
|
||||
let p = s.catalog.photo(id).unwrap();
|
||||
assert_eq!(p.as_shot_wb, Some((4986.0, -2.0)));
|
||||
assert_eq!(*p.develop, d);
|
||||
assert_eq!(s.undo.len(), undo_before);
|
||||
assert!(s.pending_log.iter().any(|op| matches!(op, Op::SetAsShotWb { id: i, wb: Some((4986.0, -2.0)) } if *i == id)));
|
||||
assert!(!p.is_edited() || p.develop.wb.mode == WbMode::Custom);
|
||||
// the panel reads the white balance in effect in Kelvin: the value the relative edit renders as
|
||||
let info = s.source_info(id);
|
||||
assert_eq!((info.as_shot_temp, info.as_shot_tint), (4986.0, -2.0));
|
||||
let shown = lightcraft_pipeline::local::effective_wb(&info, &d);
|
||||
assert_eq!(shown, lightcraft_pipeline::local::legacy_to_kelvin(5800.0, 3.0, (4986.0, -2.0)));
|
||||
assert!(shown.0 < 4986.0 && shown.0 > 4000.0, "{shown:?}");
|
||||
// accepting again changes nothing more
|
||||
let log = s.pending_log.len();
|
||||
s.accept(&r);
|
||||
assert_eq!(s.pending_log.len(), log);
|
||||
// a catalog-only (proxy) render of the same settings before the decode gave the same pixels too
|
||||
let proxy = lightcraft_pipeline::render(&pixels, &header, &d, &request).image.data;
|
||||
assert_eq!(proxy, old, "a smart preview of an existing edit changed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn budget_evicts_least_recently_used_across_caches() {
|
||||
let img = |w: usize| Arc::new(Rgb32f::new(w, w));
|
||||
@@ -1529,6 +1763,36 @@ mod tests {
|
||||
assert_eq!(m.rendered.mem_usage().0, 0);
|
||||
}
|
||||
|
||||
// Given Compare zoomed to 1:1 (two photos' originals), both stay decoded; back to one view,
|
||||
// the least recently used goes, and the budget still evicts them like any source
|
||||
#[test]
|
||||
fn full_size_originals_are_kept_up_to_the_capacity() {
|
||||
let img = |w: usize| Arc::new(Rgb32f::new(w, w));
|
||||
let mut m = MediaCache::default();
|
||||
let mb = 1 << 20;
|
||||
m.set_budget(100 * mb);
|
||||
m.insert(PhotoId(1), SourceLevel::Full, img(1000));
|
||||
m.insert(PhotoId(2), SourceLevel::Full, img(1000));
|
||||
assert!(!m.has_source(PhotoId(1), SourceLevel::Full), "one original by default");
|
||||
m.set_full_capacity(2);
|
||||
m.insert(PhotoId(1), SourceLevel::Full, img(1000));
|
||||
assert!(m.has_source(PhotoId(1), SourceLevel::Full) && m.has_source(PhotoId(2), SourceLevel::Full));
|
||||
assert_eq!(m.usage().2.count, 2);
|
||||
// re-inserting a held photo replaces it instead of taking a second place
|
||||
m.insert(PhotoId(2), SourceLevel::Full, img(1000));
|
||||
assert_eq!(m.usage().2.count, 2);
|
||||
// 1 is the least recently used now
|
||||
m.set_full_capacity(1);
|
||||
assert!(!m.has_source(PhotoId(1), SourceLevel::Full) && m.has_source(PhotoId(2), SourceLevel::Full));
|
||||
m.set_full_capacity(0);
|
||||
assert_eq!(m.usage().2.count, 1, "never less than one");
|
||||
m.set_full_capacity(2);
|
||||
m.insert(PhotoId(3), SourceLevel::Full, img(1000));
|
||||
m.set_budget(15 * mb);
|
||||
assert_eq!(m.usage().2.count, 1, "the budget evicts the older original");
|
||||
assert!(m.has_source(PhotoId(3), SourceLevel::Full));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn work_gate_waits_for_room() {
|
||||
use crate::memory::WorkGate;
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
//! Soft proofing against an output-device ICC profile (a printer and paper): loads the profile,
|
||||
//! builds its proof table through the CMS and registers it with the pipeline, which a
|
||||
//! [`Proof`](lightcraft_pipeline::Proof) then names by id.
|
||||
|
||||
use lightcraft_codecs::display::MAX_PROFILE_BYTES;
|
||||
pub use lightcraft_codecs::proof::ProofIntent;
|
||||
|
||||
/// A device profile ready to proof against.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ProofProfile {
|
||||
/// Pass as [`Proof::profile`](lightcraft_pipeline::Proof::profile).
|
||||
pub id: u64,
|
||||
/// The profile's description, else the file name.
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Load the profile at `path` for proofing with `intent`, simulating paper and ink or not.
|
||||
pub fn load(path: &str, intent: ProofIntent, paper_ink: bool) -> Result<ProofProfile, String> {
|
||||
let path = path.trim();
|
||||
let meta = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
if meta.len() > MAX_PROFILE_BYTES as u64 {
|
||||
return Err(format!("{path}: too large for an ICC profile"));
|
||||
}
|
||||
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
let fallback = std::path::Path::new(path).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
from_bytes(&bytes, intent, paper_ink, &fallback).map_err(|e| format!("{path}: {e}"))
|
||||
}
|
||||
|
||||
/// [`load`] from the profile's bytes; `fallback_name` names a profile without a description.
|
||||
pub fn from_bytes(bytes: &[u8], intent: ProofIntent, paper_ink: bool, fallback_name: &str) -> Result<ProofProfile, String> {
|
||||
let table = lightcraft_codecs::proof::proof_table(bytes, intent, paper_ink)?;
|
||||
let lut = lightcraft_pipeline::ProofLut::new(table.n, table.data).ok_or("the profile's tables give no usable colours")?;
|
||||
let id = {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
(bytes, intent, paper_ink).hash(&mut h);
|
||||
h.finish()
|
||||
};
|
||||
let id = lightcraft_pipeline::register_proof_profile(id, lut);
|
||||
Ok(ProofProfile { id, name: table.description.unwrap_or_else(|| fallback_name.to_string()) })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lightcraft_codecs::space::NamedSpace;
|
||||
|
||||
#[test]
|
||||
fn profiles_register_by_content_and_options() {
|
||||
let p3 = lightcraft_codecs::icc::write_named(NamedSpace::DisplayP3);
|
||||
let a = from_bytes(&p3, ProofIntent::Perceptual, false, "x").unwrap();
|
||||
let b = from_bytes(&p3, ProofIntent::Perceptual, true, "x").unwrap();
|
||||
assert_ne!(a.id, b.id);
|
||||
assert_eq!(a, from_bytes(&p3, ProofIntent::Perceptual, false, "x").unwrap());
|
||||
assert!(lightcraft_pipeline::proof_profile(a.id).is_some());
|
||||
assert!(!a.name.is_empty());
|
||||
assert!(from_bytes(b"nope", ProofIntent::Relative, false, "x").is_err());
|
||||
assert!(load("/nonexistent/printer.icc", ProofIntent::Relative, false).is_err());
|
||||
assert_eq!(ProofIntent::parse("Relative"), Some(ProofIntent::Relative));
|
||||
assert_eq!(ProofIntent::parse("saturation"), None);
|
||||
}
|
||||
}
|
||||
+580
-60
@@ -319,6 +319,27 @@ pub(crate) trait MoveFs {
|
||||
/// Another photo file beside the stem-named sidecar `sidecar` that shares its stem (e.g.
|
||||
/// `IMG_1.JPG` for `IMG_1.xmp`), which still uses that sidecar.
|
||||
fn stem_sibling(&self, sidecar: &Path) -> Option<String>;
|
||||
/// Content observed in a packet, independent of its pathname or inode. Model volumes
|
||||
/// override this; disk implementations stream a bounded buffer without retaining the XML.
|
||||
fn packet_version(&self, path: &Path) -> std::io::Result<PacketVersion> {
|
||||
use std::io::Read;
|
||||
let mut file = std::fs::File::open(path)?;
|
||||
let mut hash = lightcraft_preview::Hasher128::new();
|
||||
let mut len = 0u64;
|
||||
let mut buffer = [0u8; 16 * 1024];
|
||||
loop {
|
||||
let n = match file.read(&mut buffer) {
|
||||
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
|
||||
result => result?,
|
||||
};
|
||||
if n == 0 {
|
||||
return Ok(PacketVersion { len, hash: hash.finish() });
|
||||
}
|
||||
let bytes = buffer.get(..n).ok_or_else(|| std::io::Error::other("invalid sidecar read length"))?;
|
||||
len = len.checked_add(n as u64).ok_or_else(|| std::io::Error::other("sidecar is too large"))?;
|
||||
hash.update(bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The real file system.
|
||||
@@ -370,7 +391,8 @@ impl MoveFs for RealFs {
|
||||
let _ = std::fs::remove_file(b);
|
||||
}),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Err(e),
|
||||
Err(_) => std::fs::rename(a, b),
|
||||
Err(_) => std::fs::rename(a, b)
|
||||
.or_else(|e| if e.kind() == std::io::ErrorKind::CrossesDevices { move_across_volumes(self, a, b) } else { Err(e) }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,6 +414,56 @@ impl MoveFs for RealFs {
|
||||
}
|
||||
}
|
||||
|
||||
/// Move `a` to `b` on another volume: copy (never replacing), compare the copy with the original
|
||||
/// byte for byte, and only then remove the original. A copy that doesn't match is removed.
|
||||
fn move_across_volumes(fs: &RealFs, a: &Path, b: &Path) -> std::io::Result<()> {
|
||||
fs.copy_no_replace(a, b)?;
|
||||
if !same_contents(a, b).unwrap_or(false) {
|
||||
let _ = std::fs::remove_file(b);
|
||||
return Err(std::io::Error::other(format!("the copy at {} did not match the original; the original was kept", b.display())));
|
||||
}
|
||||
std::fs::remove_file(a).inspect_err(|_| {
|
||||
// the original stays: drop the copy so the file isn't in two places
|
||||
let _ = std::fs::remove_file(b);
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether files `a` and `b` hold the same bytes: lengths first, then 1 MiB chunks at a time, so
|
||||
/// a large raw or video is never read into memory whole.
|
||||
fn same_contents(a: &Path, b: &Path) -> std::io::Result<bool> {
|
||||
const CHUNK: usize = 1 << 20;
|
||||
let (mut fa, mut fb) = (std::fs::File::open(a)?, std::fs::File::open(b)?);
|
||||
if fa.metadata()?.len() != fb.metadata()?.len() {
|
||||
return Ok(false);
|
||||
}
|
||||
let (mut ba, mut bb) = (vec![0u8; CHUNK], vec![0u8; CHUNK]);
|
||||
loop {
|
||||
let n = read_full(&mut fa, &mut ba)?;
|
||||
let m = read_full(&mut fb, &mut bb)?;
|
||||
if n != m || ba.get(..n) != bb.get(..m) {
|
||||
return Ok(false);
|
||||
}
|
||||
if n == 0 {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fill `buf` from `r` as far as the file goes; the number of bytes read (less than the buffer
|
||||
/// only at the end of the file).
|
||||
fn read_full(r: &mut impl std::io::Read, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
let mut filled = 0;
|
||||
while let Some(rest) = buf.get_mut(filled..).filter(|r| !r.is_empty()) {
|
||||
match r.read(rest) {
|
||||
Ok(0) => break,
|
||||
Ok(k) => filled = filled.saturating_add(k),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Ok(filled)
|
||||
}
|
||||
|
||||
/// `from` → `to` differs only in letter case and `to` is that very file (a case-insensitive
|
||||
/// volume): the rename must go through a temporary name.
|
||||
fn is_respelling(fs: &dyn MoveFs, from: &Path, to: &Path) -> bool {
|
||||
@@ -420,6 +492,178 @@ fn move_one(fs: &dyn MoveFs, a: &Path, b: &Path) -> std::io::Result<()> {
|
||||
})
|
||||
}
|
||||
|
||||
/// An unfinished sidecar operation. Keep its exact paths and any recorded copy requirement: the photo may
|
||||
/// already have followed its new path, so a retry cannot infer this work from the catalog.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct SidecarMove {
|
||||
pub from: PathBuf,
|
||||
pub to: PathBuf,
|
||||
copied: bool,
|
||||
/// Observed packets in the partial move, including the separated source. Absent planned
|
||||
/// destinations are never owned; a completed repair advances these observations.
|
||||
companions: Vec<RetainedPacket>,
|
||||
}
|
||||
|
||||
/// A content version, not an assertion that a pathname still names the same packet.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct PacketVersion {
|
||||
len: u64,
|
||||
hash: lightcraft_preview::Hash128,
|
||||
}
|
||||
|
||||
impl PacketVersion {
|
||||
fn of(bytes: &[u8]) -> Self {
|
||||
Self { len: bytes.len() as u64, hash: lightcraft_preview::hash_bytes(bytes) }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct RetainedPacket {
|
||||
path: PathBuf,
|
||||
// An unreadable observation remains untrusted even if a later read succeeds.
|
||||
version: Option<PacketVersion>,
|
||||
}
|
||||
|
||||
impl RetainedPacket {
|
||||
fn validate(&self, fs: &dyn MoveFs) -> std::result::Result<(), String> {
|
||||
if self.version.is_none() {
|
||||
return Err(format!("can't verify retained XMP {}: its content was unreadable when the partial move was recorded", self.path.display()));
|
||||
}
|
||||
let current = fs.packet_version(&self.path).map_err(|e| format!("can't verify retained XMP {}: {e}", self.path.display()))?;
|
||||
if self.version != Some(current) {
|
||||
return Err(format!(
|
||||
"retained XMP {} changed since the partial move; packet ownership must be resolved before retrying",
|
||||
self.path.display()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance only packets whose operation actually succeeded. A copy retains its original;
|
||||
/// a move retires vanished source aliases and adds the observed destination.
|
||||
fn advance_packets(packets: &mut Vec<RetainedPacket>, aliases: &[PathBuf], to: &Path, copied: bool, fs: &dyn MoveFs) {
|
||||
let Some(mut packet) = packets.iter().find(|p| aliases.contains(&p.path)).cloned() else { return };
|
||||
if !copied {
|
||||
packets.retain(|p| !aliases.contains(&p.path) || fs.is_file(&p.path));
|
||||
}
|
||||
packet.path = to.to_path_buf();
|
||||
if !packets.iter().any(|p| p.path == packet.path) {
|
||||
packets.push(packet);
|
||||
}
|
||||
}
|
||||
|
||||
fn packet_aliases(packets: &[RetainedPacket], fs: &dyn MoveFs, from: &Path) -> Vec<PathBuf> {
|
||||
packets.iter().filter(|p| p.path == from || fs.same_file(&p.path, from)).map(|p| p.path.clone()).collect()
|
||||
}
|
||||
|
||||
impl SidecarMove {
|
||||
fn validate_packets(&self, fs: &dyn MoveFs) -> std::result::Result<(), String> {
|
||||
for packet in &self.companions {
|
||||
packet.validate(fs)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate a queued retry and return the copy decision for this physical operation.
|
||||
fn validate_repair(&self, fs: &dyn MoveFs) -> std::result::Result<bool, String> {
|
||||
self.validate_packets(fs)?;
|
||||
let respelling = is_respelling(fs, &self.from, &self.to);
|
||||
// Batch rollback may have restored another photo using this source packet.
|
||||
let copied = self.copied || (!respelling && fs.stem_sibling(&self.from).is_some());
|
||||
if fs.exists(&self.to) && (copied || !respelling) {
|
||||
return Err(format!("{} already exists while repairing {} → {}", self.to.display(), self.from.display(), self.to.display()));
|
||||
}
|
||||
Ok(copied)
|
||||
}
|
||||
|
||||
fn needed(&self, fs: &dyn MoveFs) -> bool {
|
||||
fs.is_file(&self.from) && !(fs.exists(&self.to) && (self.copied || !is_respelling(fs, &self.from, &self.to)))
|
||||
}
|
||||
|
||||
fn touches(&self, fs: &dyn MoveFs, path: &Path) -> bool {
|
||||
let same_path = |endpoint: &Path| {
|
||||
endpoint == path
|
||||
|| fs.same_file(endpoint, path)
|
||||
|| (endpoint.file_name() == path.file_name()
|
||||
&& endpoint.parent().zip(path.parent()).is_some_and(|(a, b)| a == b || fs.same_file(a, b)))
|
||||
};
|
||||
same_path(&self.from) || same_path(&self.to)
|
||||
}
|
||||
|
||||
fn apply(&self, fs: &dyn MoveFs) -> std::io::Result<()> {
|
||||
if self.copied { fs.copy_no_replace(&self.from, &self.to) } else { move_one(fs, &self.from, &self.to) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete known unfinished sidecars before moving any more photos. Completed operations
|
||||
/// leave the queue; a failure retains that operation and the rest, without replacing a file.
|
||||
pub(crate) fn retry_sidecars(fs: &dyn MoveFs, pending: &mut Vec<SidecarMove>) -> std::result::Result<(), String> {
|
||||
validate_sidecars(fs, pending)?;
|
||||
while !pending.is_empty() {
|
||||
apply_repair(fs, pending, 0)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Preflight a detached history entry before global queues are allowed to retire. The
|
||||
/// caller can validate without mutating it; retry revalidates before each physical operation.
|
||||
pub(crate) fn validate_sidecars(fs: &dyn MoveFs, pending: &[SidecarMove]) -> std::result::Result<(), String> {
|
||||
for repair in pending {
|
||||
repair.validate_repair(fs)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Finish one operation and transfer its observed content to the remaining repairs. Do not
|
||||
/// infer ownership by reading an occupied destination or by retaining a planned pathname.
|
||||
fn apply_repair(fs: &dyn MoveFs, pending: &mut Vec<SidecarMove>, i: usize) -> std::result::Result<(), String> {
|
||||
let Some(mut repair) = pending.get(i).cloned() else { return Ok(()) };
|
||||
repair.copied = repair.validate_repair(fs)?;
|
||||
let aliases = packet_aliases(&repair.companions, fs, &repair.from);
|
||||
repair.apply(fs).map_err(|e| format!("rename {} → {}: {e}", repair.from.display(), repair.to.display()))?;
|
||||
for remaining in pending.iter_mut() {
|
||||
advance_packets(&mut remaining.companions, &aliases, &repair.to, repair.copied, fs);
|
||||
}
|
||||
pending.remove(i);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Repair only operations that affect a sidecar being saved. Unrelated unavailable originals
|
||||
/// must not block healthy metadata saves. Completed repairs retire; failures stay queued.
|
||||
pub(crate) fn retry_sidecars_for_paths(fs: &dyn MoveFs, pending: &mut Vec<SidecarMove>, paths: &[PathBuf]) -> std::result::Result<(), String> {
|
||||
for repair in pending.iter().filter(|m| paths.iter().any(|path| m.touches(fs, path))) {
|
||||
repair.validate_repair(fs)?;
|
||||
}
|
||||
let mut i = 0;
|
||||
while let Some(m) = pending.get(i) {
|
||||
if paths.iter().any(|path| m.touches(fs, path)) {
|
||||
// Path/alias equality only locates an observation; validation above proves its
|
||||
// content. A newly occupied, previously absent peer destination is a collision.
|
||||
if let Some(path) = paths.iter().find(|path| {
|
||||
fs.exists(path)
|
||||
&& !fs.same_file(path, &m.from)
|
||||
&& !fs.same_file(path, &m.to)
|
||||
&& !m.companions.iter().any(|known| fs.same_file(path, &known.path))
|
||||
}) {
|
||||
return Err(format!("{} already exists while repairing {} → {}", path.display(), m.from.display(), m.to.display()));
|
||||
}
|
||||
apply_repair(fs, pending, i)?;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every record came from an actual packet observed before this move. Successful physical
|
||||
/// operations have already advanced its path; missing planned destinations are not recorded.
|
||||
fn note_companions(pending: &mut [SidecarMove], packets: Vec<RetainedPacket>) {
|
||||
for repair in pending {
|
||||
repair.companions.clone_from(&packets);
|
||||
}
|
||||
}
|
||||
|
||||
/// Why [`move_file`] failed, and what it left behind.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MoveError {
|
||||
@@ -428,6 +672,7 @@ pub struct MoveError {
|
||||
/// The file itself ended up at the new path: it moved, a sidecar didn't, and moving the file
|
||||
/// back failed too. Its sidecars that did move stay with it.
|
||||
pub moved: bool,
|
||||
pub(crate) pending_sidecars: Vec<SidecarMove>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for MoveError {
|
||||
@@ -447,53 +692,82 @@ pub(crate) fn move_file_with(fs: &dyn MoveFs, from: &str, to: &str) -> std::resu
|
||||
if Path::new(from) == Path::new(to) {
|
||||
return Ok(());
|
||||
}
|
||||
let fail = |message: String| MoveError { message, moved: false };
|
||||
let fail = |message: String| MoveError { message, moved: false, pending_sidecars: Vec::new() };
|
||||
let (f, t) = (Path::new(from), Path::new(to));
|
||||
// `to` may be this very file spelled differently (case-insensitive volume); any other file
|
||||
// there — e.g. `img.jpg` next to `IMG.JPG` on a case-sensitive volume — is never replaced
|
||||
if fs.exists(t) && !is_respelling(fs, f, t) {
|
||||
return Err(fail(format!("{to} already exists")));
|
||||
}
|
||||
let mut packets: Vec<RetainedPacket> = sidecars(from)
|
||||
.into_iter()
|
||||
.map(|(p, _, _)| p)
|
||||
.filter(|p| fs.is_file(p))
|
||||
.map(|path| RetainedPacket { version: fs.packet_version(&path).ok(), path })
|
||||
.collect();
|
||||
move_one(fs, f, t).map_err(|e| fail(format!("rename {from}: {e}")))?;
|
||||
// sidecars moved (or copied: `true`)
|
||||
let mut done: Vec<(PathBuf, PathBuf, bool)> = Vec::new();
|
||||
for (sc, naming, upper) in sidecars(from) {
|
||||
if !fs.is_file(&sc) {
|
||||
continue;
|
||||
}
|
||||
let mut done: Vec<SidecarMove> = Vec::new();
|
||||
let mut remaining = sidecars(from).into_iter().filter(|(sc, _, _)| fs.is_file(sc)).map(|(sc, naming, upper)| {
|
||||
let mut dst = crate::sidecar::sidecar_path(to, naming);
|
||||
if upper {
|
||||
dst = dst.with_extension("XMP");
|
||||
}
|
||||
// a stem sidecar another file still uses (`IMG_1.xmp` of `IMG_1.CR3` and `IMG_1.JPG`)
|
||||
// is copied, not taken away from it (issue #92)
|
||||
let shared = naming == SidecarNaming::Stem && fs.stem_sibling(&sc).is_some();
|
||||
if fs.exists(&dst) && (shared || !is_respelling(fs, &sc, &dst)) {
|
||||
// A stem sidecar another photo still uses is copied, not taken away (issue #92).
|
||||
let copied = naming == SidecarNaming::Stem && fs.stem_sibling(&sc).is_some();
|
||||
SidecarMove { from: sc, to: dst, copied, companions: Vec::new() }
|
||||
});
|
||||
while let Some(m) = remaining.next() {
|
||||
if !m.needed(fs) {
|
||||
continue; // never overwrite: the old sidecar stays where it was
|
||||
}
|
||||
let r = if shared { fs.copy_no_replace(&sc, &dst) } else { move_one(fs, &sc, &dst) };
|
||||
if let Err(e) = r {
|
||||
let mut message = format!("rename {}: {e}", sc.display());
|
||||
// the file first: if it can't go back, the sidecars that moved stay with it
|
||||
let aliases = packet_aliases(&packets, fs, &m.from);
|
||||
if let Err(e) = m.apply(fs) {
|
||||
let mut message = format!("rename {}: {e}", m.from.display());
|
||||
// The file first: if it can't go back, sidecars that moved stay with it.
|
||||
if let Err(e) = move_one(fs, t, f) {
|
||||
message.push_str(&format!("; {from} could not be moved back ({e}): it is now {to}"));
|
||||
if !done.is_empty() {
|
||||
let names: Vec<String> = done.iter().map(|(_, b, _)| b.display().to_string()).collect();
|
||||
let names: Vec<String> = done.iter().map(|m| m.to.display().to_string()).collect();
|
||||
message.push_str(&format!(" with its sidecar {}", names.join(", ")));
|
||||
}
|
||||
return Err(MoveError { message, moved: true });
|
||||
let mut pending_sidecars = vec![m];
|
||||
for m in remaining.filter(|m| m.needed(fs)) {
|
||||
// The two case spellings can name one file. Keep one operation for
|
||||
// those aliases, while retaining distinct sidecars on sensitive volumes.
|
||||
let alias = pending_sidecars.iter().any(|p| {
|
||||
folded_path(&p.from.to_string_lossy()) == folded_path(&m.from.to_string_lossy())
|
||||
&& folded_path(&p.to.to_string_lossy()) == folded_path(&m.to.to_string_lossy())
|
||||
&& fs.same_file(&p.from, &m.from)
|
||||
});
|
||||
if !alias {
|
||||
pending_sidecars.push(m);
|
||||
}
|
||||
}
|
||||
note_companions(&mut pending_sidecars, packets);
|
||||
return Err(MoveError { message, moved: true, pending_sidecars });
|
||||
}
|
||||
for (a, b, copied) in done.iter().rev() {
|
||||
if *copied {
|
||||
// the original is still there: the copy just goes (a leftover copy loses nothing)
|
||||
let _ = fs.remove_file(b);
|
||||
} else if let Err(e) = move_one(fs, b, a) {
|
||||
message.push_str(&format!("; sidecar {} could not be moved back ({e}): it is now {}", a.display(), b.display()));
|
||||
let mut pending_sidecars = Vec::new();
|
||||
for m in done.iter().rev() {
|
||||
if m.copied {
|
||||
// The original is still there: a leftover copy loses nothing.
|
||||
if fs.remove_file(&m.to).is_ok() {
|
||||
packets.retain(|p| p.path != m.to);
|
||||
}
|
||||
} else {
|
||||
let aliases = packet_aliases(&packets, fs, &m.to);
|
||||
if let Err(e) = move_one(fs, &m.to, &m.from) {
|
||||
message.push_str(&format!("; sidecar {} could not be moved back ({e}): it is now {}", m.from.display(), m.to.display()));
|
||||
pending_sidecars.push(SidecarMove { from: m.to.clone(), to: m.from.clone(), copied: false, companions: Vec::new() });
|
||||
} else {
|
||||
advance_packets(&mut packets, &aliases, &m.from, false, fs);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Err(fail(message));
|
||||
note_companions(&mut pending_sidecars, packets);
|
||||
return Err(MoveError { message, moved: false, pending_sidecars });
|
||||
}
|
||||
done.push((sc, dst, shared));
|
||||
advance_packets(&mut packets, &aliases, &m.to, m.copied, fs);
|
||||
done.push(m);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -505,6 +779,8 @@ pub(crate) struct BatchMoveError {
|
||||
pub message: String,
|
||||
/// Moves that stayed done, (from, to): those files are at `to` now.
|
||||
pub stuck: Vec<(String, String)>,
|
||||
/// Sidecars still separated from the path where their photo ended up, in either direction.
|
||||
pub pending_sidecars: Vec<SidecarMove>,
|
||||
}
|
||||
|
||||
/// Move the files `moves` names, all or nothing: after a failure the earlier moves are undone.
|
||||
@@ -517,30 +793,41 @@ pub(crate) fn move_all(fs: &dyn MoveFs, moves: &[(String, String)]) -> std::resu
|
||||
continue;
|
||||
};
|
||||
let mut message = e.message;
|
||||
let mut pending_sidecars = e.pending_sidecars;
|
||||
let mut stuck = Vec::new();
|
||||
if e.moved {
|
||||
stuck.push(m.clone());
|
||||
}
|
||||
for d in done.iter().rev() {
|
||||
if let Err(e) = move_file_with(fs, &d.1, &d.0) {
|
||||
if let Err(mut e) = move_file_with(fs, &d.1, &d.0) {
|
||||
message.push_str(&format!("; {} could not be moved back to {}: {}", d.1, d.0, e.message));
|
||||
if !e.moved {
|
||||
stuck.push((*d).clone());
|
||||
}
|
||||
pending_sidecars.append(&mut e.pending_sidecars);
|
||||
}
|
||||
}
|
||||
return Err(BatchMoveError { message, stuck });
|
||||
return Err(BatchMoveError { message, stuck, pending_sidecars });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The `SetFile` ops in `op` (nested batches included) that point a photo at one of `to`.
|
||||
/// Final `SetFile` per photo (nested batches included), filtered by destination only afterward.
|
||||
fn set_file_ops_to(op: &Op, to: &HashSet<&str>, out: &mut Vec<Op>) {
|
||||
match op {
|
||||
Op::Batch { ops } => ops.iter().for_each(|o| set_file_ops_to(o, to, out)),
|
||||
Op::SetFile { source: Source::File { path }, .. } if to.contains(path.as_str()) => out.push(op.clone()),
|
||||
_ => {}
|
||||
let mut pending = vec![op];
|
||||
let mut order = Vec::new();
|
||||
let mut final_ops = HashMap::new();
|
||||
while let Some(op) = pending.pop() {
|
||||
match op {
|
||||
Op::Batch { ops } => pending.extend(ops.iter().rev()),
|
||||
Op::SetFile { id, .. } if final_ops.insert(*id, op).is_none() => order.push(*id),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out.extend(order.into_iter().filter_map(|id| {
|
||||
let op = final_ops.get(&id).copied()?;
|
||||
matches!(op, Op::SetFile { source: Source::File { path }, .. } if to.contains(path.as_str())).then(|| op.clone())
|
||||
}));
|
||||
}
|
||||
|
||||
/// The photos among `ids` a rename handles, in order: photos sharing one file (virtual copies)
|
||||
@@ -650,8 +937,69 @@ impl Session {
|
||||
self.apply_rename_with(&RealFs, plans)
|
||||
}
|
||||
|
||||
/// Complete retained repairs before a file or folder change can stale their exact paths.
|
||||
pub(crate) fn repair_sidecars_with(&mut self, fs: &dyn MoveFs) -> Result<()> {
|
||||
for repair in self.pending_sidecar_repairs.iter().chain(self.undo.iter().chain(&self.redo).flat_map(|e| &e.retry_sidecars)) {
|
||||
repair.validate_repair(fs).map_err(EngineError::Other)?;
|
||||
}
|
||||
retry_sidecars(fs, &mut self.pending_sidecar_repairs).map_err(EngineError::Other)?;
|
||||
for entry in self.undo.iter_mut().chain(self.redo.iter_mut()) {
|
||||
retry_sidecars(fs, &mut entry.retry_sidecars).map_err(EngineError::Other)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn repair_sidecars_for_paths(&mut self, paths: &[PathBuf], fs: &dyn MoveFs) -> Result<()> {
|
||||
self.validate_sidecar_packets_for_paths(paths, fs)?;
|
||||
retry_sidecars_for_paths(fs, &mut self.pending_sidecar_repairs, paths).map_err(EngineError::Other)?;
|
||||
for entry in self.undo.iter_mut().chain(self.redo.iter_mut()) {
|
||||
retry_sidecars_for_paths(fs, &mut entry.retry_sidecars, paths).map_err(EngineError::Other)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A healthy shared packet may be saved while another photo's repair is unavailable.
|
||||
/// Check its retained content too, without forcing that unrelated physical operation.
|
||||
pub(crate) fn validate_sidecar_packets_for_paths(&self, paths: &[PathBuf], fs: &dyn MoveFs) -> Result<()> {
|
||||
for repair in self.pending_sidecar_repairs.iter().chain(self.undo.iter().chain(&self.redo).flat_map(|e| &e.retry_sidecars)) {
|
||||
if paths.iter().any(|path| repair.touches(fs, path)) {
|
||||
repair.validate_repair(fs).map_err(EngineError::Other)?;
|
||||
} else {
|
||||
for packet in &repair.companions {
|
||||
if paths.iter().any(|path| fs.same_file(path, &packet.path)) {
|
||||
packet.validate(fs).map_err(EngineError::Other)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Only a successful application write updates a retained content version. Atomic writes
|
||||
/// replace inodes; aliases that now resolve to the written path observe the new content.
|
||||
pub(crate) fn note_sidecar_write(&mut self, path: &Path, bytes: &[u8]) {
|
||||
let version = PacketVersion::of(bytes);
|
||||
for repair in self.pending_sidecar_repairs.iter_mut().chain(self.undo.iter_mut().chain(&mut self.redo).flat_map(|e| &mut e.retry_sidecars)) {
|
||||
for packet in &mut repair.companions {
|
||||
if RealFs.same_file(path, &packet.path) {
|
||||
packet.version = Some(version);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Session::apply_rename`] on the file system `fs`.
|
||||
pub(crate) fn apply_rename_with(&mut self, fs: &dyn MoveFs, plans: &[RenamePlan]) -> Result<usize> {
|
||||
self.apply_file_plans(fs, plans, &|n| format!("Rename {n} Photo{}", if n == 1 { "" } else { "s" }))
|
||||
}
|
||||
|
||||
/// Carry out `plans` (renames or moves to another folder): move the files with their sidecars
|
||||
/// (all or nothing), then commit one undoable op, labelled `label(count)`, that points the
|
||||
/// photos (and their virtual copies) at the new paths. Undo moves the files back.
|
||||
pub(crate) fn apply_file_plans(&mut self, fs: &dyn MoveFs, plans: &[RenamePlan], label: &dyn Fn(usize) -> String) -> Result<usize> {
|
||||
// A partly completed rename can already have the requested photo name. Repair
|
||||
// its sidecars before treating that empty move list as a successful retry.
|
||||
self.repair_sidecars_with(fs)?;
|
||||
let mut ops = Vec::new();
|
||||
let by_path: HashMap<&str, &RenamePlan> = plans.iter().filter_map(|p| p.from_path.as_deref().map(|f| (f, p))).collect();
|
||||
for p in self.catalog.photos() {
|
||||
@@ -679,10 +1027,10 @@ impl Session {
|
||||
})
|
||||
.collect();
|
||||
if let Err(e) = move_all(fs, &moves) {
|
||||
return Err(EngineError::Other(self.follow_stuck(&op, e, true)));
|
||||
return Err(EngineError::Other(self.follow_stuck(&op, e, true).0));
|
||||
}
|
||||
if n > 0
|
||||
&& let Err(err) = self.commit(&format!("Rename {n} Photo{}", if n == 1 { "" } else { "s" }), op)
|
||||
&& let Err(err) = self.commit(&label(n), op)
|
||||
{
|
||||
let back: Vec<(String, String)> = moves.iter().rev().map(|(a, b)| (b.clone(), a.clone())).collect();
|
||||
if let Err(e) = move_all(fs, &back) {
|
||||
@@ -695,11 +1043,13 @@ impl Session {
|
||||
|
||||
/// After a failed batch of moves for `op`: point the photos whose files stayed moved at
|
||||
/// their new paths (an undoable "partly" rename when `undoable`, otherwise a plain logged
|
||||
/// change), so the library matches the disk. Returns the message for the user.
|
||||
pub(crate) fn follow_stuck(&mut self, op: &Op, e: BatchMoveError, undoable: bool) -> String {
|
||||
/// change), so the library matches the disk. Returns the message and, for a history retry,
|
||||
/// the correction's inverse file ops (so a retry doesn't lose the original paths).
|
||||
pub(crate) fn follow_stuck(&mut self, op: &Op, e: BatchMoveError, undoable: bool) -> (String, Vec<Op>) {
|
||||
let mut msg = e.message;
|
||||
if e.stuck.is_empty() {
|
||||
return msg;
|
||||
let pending_sidecars = e.pending_sidecars;
|
||||
if e.stuck.is_empty() && (!undoable || pending_sidecars.is_empty()) {
|
||||
return (msg, Vec::new());
|
||||
}
|
||||
let to: HashSet<&str> = e.stuck.iter().map(|m| m.1.as_str()).collect();
|
||||
let mut keep = Vec::new();
|
||||
@@ -708,56 +1058,97 @@ impl Session {
|
||||
let n = e.stuck.len();
|
||||
let s = if n == 1 { "" } else { "s" };
|
||||
let fix = Op::Batch { ops: keep };
|
||||
let mut inverses = Vec::new();
|
||||
let applied = if undoable {
|
||||
self.commit(&format!("Rename {n} Photo{s} (partly)"), fix)
|
||||
let label = if n == 0 { "Repair Rename Sidecars".to_string() } else { format!("Rename {n} Photo{s} (partly)") };
|
||||
self.commit(&label, fix).map(|()| {
|
||||
if let Some(entry) = self.undo.last_mut() {
|
||||
entry.retry_sidecars.extend(pending_sidecars);
|
||||
}
|
||||
})
|
||||
} else {
|
||||
match self.catalog.apply(fix.clone()) {
|
||||
Ok(_) => {
|
||||
match self.apply_catalog(fix.clone()) {
|
||||
Ok(inv) => {
|
||||
inverses = match inv {
|
||||
Op::Batch { ops } => ops,
|
||||
op => vec![op],
|
||||
};
|
||||
self.pending_log.push(fix);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
};
|
||||
match applied {
|
||||
Ok(()) => msg
|
||||
Ok(()) if n > 0 => msg
|
||||
.push_str(&format!(". {n} file{s} could not be moved back and kept the new name; the library now points there: {}", list.join(", "))),
|
||||
Ok(()) => {}
|
||||
Err(err) => msg.push_str(&format!(
|
||||
". {n} file{s} could not be moved back and the library could not follow ({err}); relink with Find Missing Photos: {}",
|
||||
list.join(", ")
|
||||
)),
|
||||
}
|
||||
msg
|
||||
(msg, inverses)
|
||||
}
|
||||
|
||||
/// File moves an undo/redo op implies: `SetFile` ops whose source path differs from the photo's
|
||||
/// current one.
|
||||
/// Physical moves to the final `SetFile` destination of each photo. A merged history
|
||||
/// batch can rename the same photo repeatedly; intermediate catalog paths need no file.
|
||||
pub(crate) fn file_moves(&self, op: &Op) -> Vec<(String, String)> {
|
||||
fn rec(s: &Session, op: &Op, v: &mut Vec<(String, String)>) {
|
||||
let mut pending = vec![op];
|
||||
let mut order = Vec::new();
|
||||
let mut targets = HashMap::new();
|
||||
while let Some(op) = pending.pop() {
|
||||
match op {
|
||||
Op::Batch { ops } => ops.iter().for_each(|o| rec(s, o, v)),
|
||||
Op::SetFile { id, source: Source::File { path: to }, .. } => {
|
||||
if let Some(Source::File { path: from }) = s.catalog.photo(*id).map(|p| &p.source)
|
||||
&& Path::new(from) != Path::new(to)
|
||||
&& !v.iter().any(|(f, _)| f == from)
|
||||
{
|
||||
v.push((from.clone(), to.clone()));
|
||||
}
|
||||
Op::Batch { ops } => pending.extend(ops.iter().rev()),
|
||||
Op::SetFile { id, source, .. } if targets.insert(*id, source).is_none() => {
|
||||
order.push(*id);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut v = Vec::new();
|
||||
rec(self, op, &mut v);
|
||||
v
|
||||
// Virtual copies share one physical move, keeping the original batch order.
|
||||
let mut seen = HashSet::new();
|
||||
order
|
||||
.into_iter()
|
||||
.filter_map(|id| {
|
||||
let Source::File { path: to } = targets.get(&id).copied()? else { return None };
|
||||
let Source::File { path: from } = &self.catalog.photo(id)?.source else { return None };
|
||||
(Path::new(from) != Path::new(to) && seen.insert(from)).then(|| (from.clone(), to.clone()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use std::cell::RefCell;
|
||||
|
||||
/// The cross-volume move's check compares files chunk by chunk: equal files across several
|
||||
/// chunks, a difference in the last chunk, different lengths, a missing file.
|
||||
#[test]
|
||||
fn same_contents_compares_in_chunks() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-same-contents-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let big: Vec<u8> = (0..(2usize << 20) + 123).map(|i| (i % 251) as u8).collect();
|
||||
let (a, b) = (dir.join("a"), dir.join("b"));
|
||||
std::fs::write(&a, &big).unwrap();
|
||||
std::fs::write(&b, &big).unwrap();
|
||||
assert!(same_contents(&a, &b).unwrap());
|
||||
let mut other = big.clone();
|
||||
*other.last_mut().unwrap() ^= 1;
|
||||
std::fs::write(&b, &other).unwrap();
|
||||
assert!(!same_contents(&a, &b).unwrap());
|
||||
std::fs::write(&b, &big[..big.len() - 1]).unwrap();
|
||||
assert!(!same_contents(&a, &b).unwrap());
|
||||
std::fs::write(&a, b"").unwrap();
|
||||
std::fs::write(&b, b"").unwrap();
|
||||
assert!(same_contents(&a, &b).unwrap());
|
||||
assert!(same_contents(&a, &dir.join("missing")).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A model volume: files by path with contents; case-insensitive (default APFS/NTFS) or
|
||||
/// case-sensitive (Linux, case-sensitive APFS). `fail` makes renames from a matching source fail.
|
||||
pub(crate) struct FakeFs {
|
||||
@@ -791,6 +1182,10 @@ mod tests {
|
||||
}
|
||||
|
||||
impl MoveFs for FakeFs {
|
||||
fn packet_version(&self, path: &Path) -> std::io::Result<PacketVersion> {
|
||||
let i = self.idx(path).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no such packet"))?;
|
||||
self.files.borrow().get(i).map(|(_, bytes)| PacketVersion::of(bytes)).ok_or_else(|| std::io::Error::other("packet disappeared"))
|
||||
}
|
||||
fn exists(&self, p: &Path) -> bool {
|
||||
self.idx(p).is_some()
|
||||
}
|
||||
@@ -812,6 +1207,9 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
|
||||
if self.fail.borrow().iter().any(|f| Self::norm(a).contains(f.as_str())) {
|
||||
return Err(std::io::Error::other("injected failure"));
|
||||
}
|
||||
if self.exists(b) {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "exists"));
|
||||
}
|
||||
@@ -841,6 +1239,101 @@ mod tests {
|
||||
Photo::new(PhotoId(id), Source::File { path }, &name, "JPG", 1, 1, "2026-01-01T00:00:00")
|
||||
}
|
||||
|
||||
/// Compare the unfinished operation and every observed packet after partial progress.
|
||||
/// Companion order is incidental; paths and recorded content versions are exact.
|
||||
pub(crate) fn assert_repair_packets(actual: &SidecarMove, unfinished: &SidecarMove, packets: &[(PathBuf, &[u8])]) {
|
||||
let mut expected = unfinished.clone();
|
||||
expected.companions =
|
||||
packets.iter().map(|(path, bytes)| RetainedPacket { path: path.clone(), version: Some(PacketVersion::of(bytes)) }).collect();
|
||||
let mut actual = actual.clone();
|
||||
actual.companions.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
expected.companions.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
assert_eq!(actual, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_moves_repair_retained_sidecars_before_changing_paths() {
|
||||
for command in ["folder.rename", "folder.move"] {
|
||||
for queue in ["pending", "undo", "redo"] {
|
||||
for (naming, copied) in [(SidecarNaming::Stem, false), (SidecarNaming::Full, false), (SidecarNaming::Stem, true)] {
|
||||
let root = crate::tests_xmp::temp_dir("folder-sidecar-repair");
|
||||
let from = root.join("Trip");
|
||||
std::fs::create_dir_all(&from).unwrap();
|
||||
let photo = from.join("b.jpg");
|
||||
std::fs::write(&photo, b"Photo bytes").unwrap();
|
||||
let old_sidecar = crate::sidecar::sidecar_path(&from.join("a.jpg").to_string_lossy(), naming);
|
||||
let sidecar = crate::sidecar::sidecar_path(&photo.to_string_lossy(), naming);
|
||||
std::fs::write(&old_sidecar, b"Retained edits").unwrap();
|
||||
// An ordinary occupied destination must block repair without replacing its bytes.
|
||||
std::fs::write(&sidecar, b"Other edits").unwrap();
|
||||
let mut s = Session::new();
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(file_photo(1, &photo.to_string_lossy())) }).unwrap();
|
||||
if copied {
|
||||
let sibling = from.join("a.raw");
|
||||
std::fs::write(&sibling, b"Shared original").unwrap();
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(file_photo(2, &sibling.to_string_lossy())) }).unwrap();
|
||||
}
|
||||
s.commit("Rating", Op::SetRating { id: PhotoId(1), rating: 3 }).unwrap();
|
||||
if queue == "redo" {
|
||||
s.undo_step().unwrap();
|
||||
}
|
||||
let repair = SidecarMove {
|
||||
from: old_sidecar.clone(),
|
||||
to: sidecar.clone(),
|
||||
copied,
|
||||
companions: vec![RetainedPacket { path: old_sidecar.clone(), version: Some(RealFs.packet_version(&old_sidecar).unwrap()) }],
|
||||
};
|
||||
match queue {
|
||||
"undo" => s.undo.last_mut().unwrap().retry_sidecars.push(repair),
|
||||
"redo" => s.redo.last_mut().unwrap().retry_sidecars.push(repair),
|
||||
_ => s.pending_sidecar_repairs.push(repair),
|
||||
}
|
||||
let params = if command == "folder.rename" {
|
||||
serde_json::json!({"path": from, "name": "Holiday"})
|
||||
} else {
|
||||
serde_json::json!({"path": from, "into": root.join("Archive")})
|
||||
};
|
||||
let to = if command == "folder.rename" { root.join("Holiday") } else { root.join("Archive/Trip") };
|
||||
let before = s.catalog.to_snapshot();
|
||||
let history = (s.undo.len(), s.redo.len());
|
||||
assert!(s.execute(command, ¶ms).is_err());
|
||||
assert!(from.is_dir() && !to.exists());
|
||||
assert_eq!(s.catalog.to_snapshot(), before);
|
||||
assert_eq!((s.undo.len(), s.redo.len()), history);
|
||||
assert_eq!(std::fs::read(&old_sidecar).unwrap(), b"Retained edits");
|
||||
assert_eq!(std::fs::read(&sidecar).unwrap(), b"Other edits");
|
||||
std::fs::remove_file(&sidecar).unwrap();
|
||||
s.execute(command, ¶ms).unwrap();
|
||||
assert!(s.pending_sidecar_repairs.is_empty());
|
||||
assert!(s.undo.iter().chain(&s.redo).all(|e| e.retry_sidecars.is_empty()));
|
||||
let moved_sidecar = to.join(sidecar.file_name().unwrap());
|
||||
assert_eq!(std::fs::read(&moved_sidecar).unwrap(), b"Retained edits");
|
||||
assert_eq!(to.join(old_sidecar.file_name().unwrap()).exists(), copied);
|
||||
for (step, current) in [("edit.undo", &from), ("edit.redo", &to)] {
|
||||
s.execute(step, &serde_json::json!({})).unwrap();
|
||||
assert_eq!(
|
||||
s.catalog.photo(PhotoId(1)).unwrap().source,
|
||||
Source::File { path: current.join("b.jpg").to_string_lossy().into_owned() }
|
||||
);
|
||||
assert_eq!(std::fs::read(current.join(sidecar.file_name().unwrap())).unwrap(), b"Retained edits");
|
||||
assert_eq!(current.join(old_sidecar.file_name().unwrap()).exists(), copied);
|
||||
}
|
||||
// Explicit IDs choose targets after the command's selection gate.
|
||||
s.execute("library.select", &serde_json::json!({"ids": [1], "active": 1})).unwrap();
|
||||
s.execute("photo.rename", &serde_json::json!({"ids": [1], "template": "Latest"})).unwrap();
|
||||
s.execute("edit.undo", &serde_json::json!({})).unwrap();
|
||||
assert!(to.join("b.jpg").is_file());
|
||||
assert_eq!(std::fs::read(&moved_sidecar).unwrap(), b"Retained edits");
|
||||
s.execute("edit.redo", &serde_json::json!({})).unwrap();
|
||||
assert!(to.join("Latest.jpg").is_file());
|
||||
let final_sidecar = crate::sidecar::sidecar_path(&to.join("Latest.jpg").to_string_lossy(), naming);
|
||||
assert_eq!(std::fs::read(final_sidecar).unwrap(), b"Retained edits");
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn mixed_separator_noop_rename_preserves_files_catalog_and_undo() {
|
||||
@@ -981,6 +1474,33 @@ mod tests {
|
||||
assert!(fs.listing().iter().all(|(p, _)| p.starts_with("/p/d.")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_sidecars_preserve_shared_copies_and_do_not_repeat_case_aliases() {
|
||||
// These are synthetic volume semantics; no host driver or OS runtime is involved.
|
||||
for ci in [false, true] {
|
||||
let fs = FakeFs::new(ci, &[("/p/a.CR3", "Raw"), ("/p/a.JPG", "Jpeg"), ("/p/a.XMP", "Shared edits"), ("/p/a.CR3.XMP", "Raw edits")]);
|
||||
fs.fail.borrow_mut().extend(["/p/a.XMP".into(), "/p/b.CR3".into()]);
|
||||
let mut error = move_file_with(&fs, "/p/a.CR3", "/p/b.CR3").unwrap_err();
|
||||
assert!(error.moved);
|
||||
assert_eq!(error.pending_sidecars.len(), 2);
|
||||
assert!(error.pending_sidecars[0].copied);
|
||||
assert!(!error.pending_sidecars[1].copied);
|
||||
fs.fail.borrow_mut().clear();
|
||||
retry_sidecars(&fs, &mut error.pending_sidecars).unwrap();
|
||||
assert!(error.pending_sidecars.is_empty());
|
||||
assert_eq!(
|
||||
fs.listing(),
|
||||
vec![
|
||||
("/p/a.JPG".into(), "Jpeg".into()),
|
||||
("/p/a.XMP".into(), "Shared edits".into()),
|
||||
("/p/b.CR3".into(), "Raw".into()),
|
||||
("/p/b.CR3.XMP".into(), "Raw edits".into()),
|
||||
("/p/b.XMP".into(), "Shared edits".into()),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue #92 (comment): renaming one of `IMG_1.CR3` + `IMG_1.JPG` copies their shared
|
||||
/// `IMG_1.xmp` instead of taking it away from the other; the last one renamed takes it.
|
||||
#[test]
|
||||
|
||||
@@ -104,6 +104,9 @@ pub struct SidecarData {
|
||||
/// `xmp:CreateDate` (first found). Used only when the file itself has no capture time.
|
||||
pub captured: Option<String>,
|
||||
pub develop: Option<DevelopPatch>,
|
||||
/// Actionable diagnostics for foreign develop settings that were not fully carried over.
|
||||
/// These are surfaced in the photo import report and never persisted in the catalog.
|
||||
pub warnings: Vec<crate::import::ImportWarning>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -165,7 +168,65 @@ pub fn parse_sidecar(xmp: &str, target: crate::crs::Target) -> std::result::Resu
|
||||
out.develop = match full {
|
||||
Some(s) => Some(DevelopPatch::Full(Box::new(s))),
|
||||
None if crate::crs::has_adjustments(&d.properties) => {
|
||||
Some(DevelopPatch::Partial(crate::crs::to_partial_report(&d.properties, Some(&d.values), target, crate::crs_masks::DEFAULT_ASPECT).0))
|
||||
let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), target, crate::crs_masks::DEFAULT_ASPECT);
|
||||
// Lightroom's default profiles are named by nearly every sidecar; our own starting look stands
|
||||
// in for them, so only another profile is worth a warning
|
||||
let profile = crate::crs::camera_profile_name(&d.properties)
|
||||
.filter(|name| !matches!(name.trim(), "Adobe Standard" | "Adobe Color"))
|
||||
.map(str::to_string);
|
||||
if let Some(name) = profile {
|
||||
out.warnings.push(crate::import::ImportWarning {
|
||||
path: String::new(),
|
||||
code: crate::import::ImportWarningCode::CameraProfile,
|
||||
profile: Some(name),
|
||||
mode: None,
|
||||
fields: Vec::new(),
|
||||
});
|
||||
}
|
||||
let upright = crate::crs::has_saved_upright_transform(&d.properties);
|
||||
if upright {
|
||||
let mode = d
|
||||
.properties
|
||||
.get("crs:PerspectiveUpright")
|
||||
.and_then(|values| values.first())
|
||||
.map(String::as_str)
|
||||
.map(|mode| match mode {
|
||||
"0" => crate::import::ImportUprightMode::Off,
|
||||
"1" => crate::import::ImportUprightMode::Auto,
|
||||
"2" => crate::import::ImportUprightMode::Level,
|
||||
"3" => crate::import::ImportUprightMode::Vertical,
|
||||
"4" => crate::import::ImportUprightMode::Full,
|
||||
"5" => crate::import::ImportUprightMode::Guided,
|
||||
_ => crate::import::ImportUprightMode::Unknown,
|
||||
})
|
||||
.unwrap_or(crate::import::ImportUprightMode::Unknown);
|
||||
out.warnings.push(crate::import::ImportWarning {
|
||||
path: String::new(),
|
||||
code: crate::import::ImportWarningCode::UprightGeometry,
|
||||
profile: None,
|
||||
mode: Some(mode),
|
||||
fields: Vec::new(),
|
||||
});
|
||||
}
|
||||
let other: Vec<&str> = unmapped
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.filter(|name| {
|
||||
*name != "CameraProfile"
|
||||
&& !name.starts_with("UprightTransform_")
|
||||
&& !matches!(*name, "UprightTransformCount" | "UprightCenterNormX" | "UprightCenterNormY" | "UprightVersion")
|
||||
})
|
||||
.collect();
|
||||
if !other.is_empty() {
|
||||
out.warnings.push(crate::import::ImportWarning {
|
||||
path: String::new(),
|
||||
code: crate::import::ImportWarningCode::UnmappedXmp,
|
||||
profile: None,
|
||||
mode: None,
|
||||
fields: other.into_iter().map(str::to_string).collect(),
|
||||
});
|
||||
}
|
||||
Some(DevelopPatch::Partial(partial))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
@@ -248,6 +309,12 @@ impl SidecarData {
|
||||
/// The sidecar packet for a photo; the colour label is written by its name in `cat` (custom
|
||||
/// label names included, as other editors do).
|
||||
pub fn sidecar_packet(p: &Photo, cat: &lightcraft_catalog::Catalog) -> String {
|
||||
sidecar_packet_with(p, cat, false)
|
||||
}
|
||||
|
||||
/// [`sidecar_packet`], without any location (GPS, sublocation, city, state, country) when
|
||||
/// `remove_location` is set (an export with "Remove location info").
|
||||
pub fn sidecar_packet_with(p: &Photo, cat: &lightcraft_catalog::Catalog, remove_location: bool) -> String {
|
||||
let nz = |s: &str| (!s.trim().is_empty()).then(|| s.to_string());
|
||||
let meta = lightcraft_meta::Metadata {
|
||||
software: Some("LightCraft".into()),
|
||||
@@ -271,6 +338,10 @@ pub fn sidecar_packet(p: &Photo, cat: &lightcraft_catalog::Catalog) -> String {
|
||||
gps: p.meta.gps.map(|(latitude, longitude)| lightcraft_meta::Gps { latitude, longitude, altitude: None }),
|
||||
..Default::default()
|
||||
};
|
||||
let mut meta = meta;
|
||||
if remove_location {
|
||||
(meta.sublocation, meta.city, meta.state, meta.country, meta.gps) = (None, None, None, None, None);
|
||||
}
|
||||
let settings = serde_json::to_string(&*p.develop).unwrap_or_default();
|
||||
let flag = match p.flag {
|
||||
Flag::None => "none",
|
||||
@@ -278,7 +349,7 @@ pub fn sidecar_packet(p: &Photo, cat: &lightcraft_catalog::Catalog) -> String {
|
||||
Flag::Reject => "reject",
|
||||
};
|
||||
let mut lc = vec![("settings", settings.as_str()), ("flag", flag)];
|
||||
if !p.meta.location.trim().is_empty() {
|
||||
if !remove_location && !p.meta.location.trim().is_empty() {
|
||||
lc.push(("location", p.meta.location.as_str()));
|
||||
}
|
||||
lightcraft_meta::write_xmp_lc(&meta, &lc)
|
||||
@@ -468,30 +539,60 @@ impl Session {
|
||||
}
|
||||
|
||||
/// Write the photo's XMP sidecar, merged into an existing one (see the module docs).
|
||||
pub fn save_sidecar(&self, id: PhotoId) -> Result<SidecarSaved> {
|
||||
pub fn save_sidecar(&mut self, id: PhotoId) -> Result<SidecarSaved> {
|
||||
self.save_sidecar_with(id, &StemOwners::of(&self.catalog))
|
||||
}
|
||||
|
||||
pub(crate) fn save_sidecar_with(&self, id: PhotoId, owners: &StemOwners) -> Result<SidecarSaved> {
|
||||
pub(crate) fn save_sidecar_with(&mut self, id: PhotoId, owners: &StemOwners) -> Result<SidecarSaved> {
|
||||
let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?;
|
||||
if p.copy_of.is_some() {
|
||||
return Err(EngineError::Other(format!("{} is a virtual copy: its settings live only in the library", p.file_name)));
|
||||
}
|
||||
let orig = file_path(p).ok_or_else(|| EngineError::Other(format!("{} is not a file on disk", p.file_name)))?;
|
||||
let path = sidecar_path(orig, owners.naming(p, self.xmp.naming));
|
||||
let naming = owners.naming(p, self.xmp.naming);
|
||||
let preferred = sidecar_path(orig, naming);
|
||||
let other = if naming == SidecarNaming::Stem { SidecarNaming::Full } else { SidecarNaming::Stem };
|
||||
let candidates: Vec<PathBuf> = [naming, other]
|
||||
.into_iter()
|
||||
.filter(|n| *n != SidecarNaming::Stem || owners.naming(p, SidecarNaming::Stem) == SidecarNaming::Stem)
|
||||
.flat_map(|n| {
|
||||
let path = sidecar_path(orig, n);
|
||||
[path.clone(), path.with_extension("XMP")]
|
||||
})
|
||||
.collect();
|
||||
// A separated packet is still the photo's metadata. Repair it before a fresh write
|
||||
// occupies its destination; a failure preserves the queue and both existing packets.
|
||||
self.repair_sidecars_for_paths(&candidates, &crate::rename::RealFs)
|
||||
.map_err(|e| EngineError::Other(format!("{}: unfinished XMP repair: {e}", preferred.display())))?;
|
||||
// Keep an existing packet's spelling/style so foreign properties survive this save and
|
||||
// subsequent Undo/Redo writes. Shared stems owned by another photo were excluded above.
|
||||
let path = candidates.into_iter().find(|path| path.is_file()).unwrap_or(preferred);
|
||||
self.validate_sidecar_packets_for_paths(std::slice::from_ref(&path), &crate::rename::RealFs)?;
|
||||
let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?;
|
||||
let err = |e: std::io::Error| EngineError::Other(format!("{}: {e}", path.display()));
|
||||
let (data, merged, backup) = sidecar_contents(&path, sidecar_packet(p, &self.catalog), &(self.clock)()).map_err(err)?;
|
||||
write_atomic(&path, data.as_bytes()).map_err(err)?;
|
||||
self.note_sidecar_write(&path, data.as_bytes());
|
||||
Ok(SidecarSaved { path, merged, backup })
|
||||
}
|
||||
|
||||
/// The op that applies a photo's sidecar (or embedded XMP) to the catalog, if there is one.
|
||||
pub fn read_sidecar_op(&self, id: PhotoId) -> Result<Option<(Op, PathBuf)>> {
|
||||
self.read_sidecar_op_with_warnings(id).map(|result| result.map(|(op, from, _)| (op, from)))
|
||||
}
|
||||
|
||||
/// Like [`Session::read_sidecar_op`], retaining transient diagnostics for the command report.
|
||||
pub fn read_sidecar_op_with_warnings(&self, id: PhotoId) -> Result<Option<(Op, PathBuf, Vec<crate::import::ImportWarning>)>> {
|
||||
let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?;
|
||||
let Some(orig) = file_path(p) else { return Ok(None) };
|
||||
let Some((packet, from)) = read_packet(orig, p.kind, self.sidecar_naming(id)) else { return Ok(None) };
|
||||
let sc = parse_sidecar(&packet, crate::crs::Target::for_photo(p)).map_err(|e| EngineError::Other(format!("{}: {e}", from.display())))?;
|
||||
Ok(Some((self.sidecar_op(id, sc)?, from)))
|
||||
let mut warnings = sc.warnings.clone();
|
||||
for warning in &mut warnings {
|
||||
warning.path = orig.to_string();
|
||||
}
|
||||
let op = self.sidecar_op(id, sc)?;
|
||||
Ok(Some((op, from, warnings)))
|
||||
}
|
||||
|
||||
/// The op that applies what a sidecar says (already read and parsed, e.g. on a worker
|
||||
@@ -517,9 +618,22 @@ impl Session {
|
||||
}
|
||||
|
||||
/// Auto-write: sidecars for photos changed by `ops` (errors are logged, not returned).
|
||||
pub(crate) fn auto_write_sidecars(&self, ops: &[Op]) {
|
||||
pub(crate) fn auto_write_sidecars(&mut self, ops: &[Op]) {
|
||||
let mut ids = Vec::new();
|
||||
ops.iter().for_each(|o| op_photos(o, &mut ids));
|
||||
self.auto_write_sidecar_ids(ids);
|
||||
}
|
||||
|
||||
/// Gather changed IDs before the saves mutate retained physical repair queues.
|
||||
pub(crate) fn auto_write_sidecars_since(&mut self, log_start: usize) {
|
||||
let mut ids = Vec::new();
|
||||
if let Some(ops) = self.pending_log.get(log_start..) {
|
||||
ops.iter().for_each(|o| op_photos(o, &mut ids));
|
||||
}
|
||||
self.auto_write_sidecar_ids(ids);
|
||||
}
|
||||
|
||||
fn auto_write_sidecar_ids(&mut self, ids: Vec<PhotoId>) {
|
||||
if ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -633,3 +747,15 @@ mod tests {
|
||||
assert_eq!(v, vec![PhotoId(1), PhotoId(3)]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sidecar/tests_repair_saves.rs"]
|
||||
mod tests_repair_saves;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sidecar/tests_repair_companions.rs"]
|
||||
mod tests_repair_companions;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sidecar/tests_repair_provenance.rs"]
|
||||
mod tests_repair_provenance;
|
||||
@@ -0,0 +1,287 @@
|
||||
//! Real Session partial moves, sidecar serialization/merging, AutoWrite and history.
|
||||
use super::*;
|
||||
use crate::rename::{MoveFs, RealFs};
|
||||
use lightcraft_catalog::{Op, Photo, Source};
|
||||
use serde_json::json;
|
||||
|
||||
struct Faults(Vec<PathBuf>);
|
||||
impl MoveFs for Faults {
|
||||
fn exists(&self, p: &Path) -> bool {
|
||||
RealFs.exists(p)
|
||||
}
|
||||
fn is_file(&self, p: &Path) -> bool {
|
||||
RealFs.is_file(p)
|
||||
}
|
||||
fn same_file(&self, a: &Path, b: &Path) -> bool {
|
||||
RealFs.same_file(a, b)
|
||||
}
|
||||
fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
|
||||
if self.0.iter().any(|p| p == a) {
|
||||
Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "injected partial move"))
|
||||
} else {
|
||||
RealFs.rename_no_replace(a, b)
|
||||
}
|
||||
}
|
||||
fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
|
||||
RealFs.copy_no_replace(a, b)
|
||||
}
|
||||
fn remove_file(&self, p: &Path) -> std::io::Result<()> {
|
||||
RealFs.remove_file(p)
|
||||
}
|
||||
fn stem_sibling(&self, p: &Path) -> Option<String> {
|
||||
RealFs.stem_sibling(p)
|
||||
}
|
||||
}
|
||||
fn packet(marker: &str) -> String {
|
||||
format!(
|
||||
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmlns:fixture="urn:lightcraft:companion-test" xmp:Rating="2" fixture:Marker="{marker}"/></rdf:RDF></x:xmpmeta>"#
|
||||
)
|
||||
}
|
||||
fn queues(s: &Session) -> Vec<crate::rename::SidecarMove> {
|
||||
s.pending_sidecar_repairs.iter().chain(s.undo.iter().chain(&s.redo).flat_map(|e| &e.retry_sidecars)).cloned().collect()
|
||||
}
|
||||
fn session(path: &Path) -> Session {
|
||||
let mut s = Session::new();
|
||||
let photo = Photo::new(
|
||||
PhotoId(1),
|
||||
Source::File { path: path.to_string_lossy().into_owned() },
|
||||
path.file_name().unwrap().to_str().unwrap(),
|
||||
"JPEG",
|
||||
4,
|
||||
3,
|
||||
"",
|
||||
);
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap();
|
||||
s.selection = crate::Selection::single(PhotoId(1));
|
||||
s
|
||||
}
|
||||
fn save(s: &mut Session) -> serde_json::Value {
|
||||
s.execute("photo.saveMetadataToFile", &json!({})).unwrap()
|
||||
}
|
||||
fn assert_fresh(path: &Path) {
|
||||
let xml = std::fs::read_to_string(path).unwrap();
|
||||
assert!(xml.contains("fixture:Marker="), "foreign property must survive real serialization/merge");
|
||||
let data = parse_sidecar(&xml, crate::crs::Target::Rendered).unwrap();
|
||||
assert_eq!(data.title.as_deref(), Some("Fresh"));
|
||||
assert!(data.keywords.unwrap().contains(&"café".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_move_companions_allow_save_and_autowrite_without_weakening_collisions() {
|
||||
for upper_stem in [false, true] {
|
||||
for upper_full in [false, true] {
|
||||
for naming in [SidecarNaming::Stem, SidecarNaming::Full] {
|
||||
for auto in [false, true] {
|
||||
let dir = crate::tests_xmp::temp_dir(&format!("r3-companions-{upper_stem}-{upper_full}-{naming:?}-{auto}"));
|
||||
let a = dir.join("a.jpg");
|
||||
let b = dir.join("Trip.jpg");
|
||||
std::fs::write(&a, b"physical fixture; Session rename does not decode").unwrap();
|
||||
let path = |photo: &Path, style, upper| {
|
||||
let path = sidecar_path(&photo.to_string_lossy(), style);
|
||||
if upper { path.with_extension("XMP") } else { path }
|
||||
};
|
||||
let stem_a = path(&a, SidecarNaming::Stem, upper_stem);
|
||||
let full_a = path(&a, SidecarNaming::Full, upper_full);
|
||||
let stem_b = path(&b, SidecarNaming::Stem, upper_stem);
|
||||
let full_b = path(&b, SidecarNaming::Full, upper_full);
|
||||
let stem_bytes = packet("stem-keep");
|
||||
let full_bytes = packet("full-keep");
|
||||
std::fs::write(&stem_a, &stem_bytes).unwrap();
|
||||
std::fs::write(&full_a, &full_bytes).unwrap();
|
||||
let mut s = session(&a);
|
||||
s.xmp.naming = naming;
|
||||
s.xmp.auto_write = auto;
|
||||
let fs = Faults(vec![full_a.clone(), b.clone()]);
|
||||
let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "Trip", 1);
|
||||
assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err());
|
||||
assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: b.to_string_lossy().into_owned() });
|
||||
let pending = queues(&s);
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(std::fs::read_to_string(&stem_b).unwrap(), stem_bytes);
|
||||
|
||||
// Missing packet and an unrelated packet at either case spelling remain protected.
|
||||
let held = dir.join("held.xmp");
|
||||
std::fs::rename(&full_a, &held).unwrap();
|
||||
s.execute("photo.setMeta", &json!({"title": "Fresh", "addKeywords": ["café"]})).unwrap();
|
||||
assert_eq!(save(&mut s)["failed"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(queues(&s), pending);
|
||||
assert_eq!(std::fs::read_to_string(&stem_b).unwrap(), stem_bytes);
|
||||
std::fs::rename(&held, &full_a).unwrap();
|
||||
for collision in [full_b.clone(), full_b.with_extension(if upper_full { "xmp" } else { "XMP" })] {
|
||||
std::fs::write(&collision, packet("unrelated-packet")).unwrap();
|
||||
let occupied = std::fs::read(&collision).unwrap();
|
||||
assert_eq!(save(&mut s)["failed"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(queues(&s), pending);
|
||||
assert_eq!(std::fs::read(&collision).unwrap(), occupied);
|
||||
assert_eq!(std::fs::read_to_string(&full_a).unwrap(), full_bytes);
|
||||
assert_eq!(std::fs::read_to_string(&stem_b).unwrap(), stem_bytes);
|
||||
std::fs::remove_file(collision).unwrap();
|
||||
}
|
||||
// AutoWrite is the first successful repair when enabled; explicit save otherwise.
|
||||
s.execute("photo.rate", &json!({"rating": 4})).unwrap();
|
||||
if !auto {
|
||||
assert_eq!(save(&mut s)["failed"].as_array().unwrap().len(), 0);
|
||||
}
|
||||
assert!(queues(&s).is_empty());
|
||||
assert!(!full_a.exists());
|
||||
let (written, untouched, expected) =
|
||||
if naming == SidecarNaming::Stem { (&stem_b, &full_b, &full_bytes) } else { (&full_b, &stem_b, &stem_bytes) };
|
||||
assert_fresh(written);
|
||||
assert_eq!(std::fs::read_to_string(untouched).unwrap(), *expected);
|
||||
assert_eq!(parse_sidecar(&std::fs::read_to_string(written).unwrap(), crate::crs::Target::Rendered).unwrap().rating, Some(4));
|
||||
for (command, rating) in [("edit.undo", 0), ("edit.redo", 4)] {
|
||||
s.execute(command, &json!({})).unwrap();
|
||||
if !auto {
|
||||
save(&mut s);
|
||||
}
|
||||
assert_fresh(written);
|
||||
assert_eq!(
|
||||
parse_sidecar(&std::fs::read_to_string(written).unwrap(), crate::crs::Target::Rendered).unwrap().rating,
|
||||
Some(rating)
|
||||
);
|
||||
assert_eq!(std::fs::read_to_string(untouched).unwrap(), *expected);
|
||||
}
|
||||
assert_eq!(s.xmp.naming, naming);
|
||||
assert_eq!(s.xmp.auto_write, auto);
|
||||
drop(s);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rollback_repair_and_shared_copy_keep_known_companions() {
|
||||
for rollback in [false, true] {
|
||||
for naming in [SidecarNaming::Stem, SidecarNaming::Full] {
|
||||
let dir = crate::tests_xmp::temp_dir(&format!("r3-companion-rollback-{rollback}-{naming:?}"));
|
||||
let a = dir.join("a.jpg");
|
||||
let b = dir.join("Trip.jpg");
|
||||
std::fs::write(&a, b"physical fixture").unwrap();
|
||||
let stem_a = sidecar_path(&a.to_string_lossy(), SidecarNaming::Stem);
|
||||
let full_a = sidecar_path(&a.to_string_lossy(), SidecarNaming::Full);
|
||||
let stem_b = sidecar_path(&b.to_string_lossy(), SidecarNaming::Stem);
|
||||
std::fs::write(&stem_a, packet("stem-keep")).unwrap();
|
||||
std::fs::write(&full_a, packet("full-keep")).unwrap();
|
||||
// When the photo cannot roll back, the stem is copied for the physical sibling.
|
||||
if !rollback {
|
||||
std::fs::write(dir.join("a.CR3"), b"physical stem sibling").unwrap();
|
||||
}
|
||||
let mut s = session(&a);
|
||||
let fs = Faults(vec![full_a.clone(), if rollback { stem_b.clone() } else { b.clone() }]);
|
||||
let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "Trip", 1);
|
||||
assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err());
|
||||
assert_eq!(queues(&s).len(), 1);
|
||||
let actual = if rollback { &a } else { &b };
|
||||
assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: actual.to_string_lossy().into_owned() });
|
||||
s.xmp.naming = naming;
|
||||
s.execute("photo.setMeta", &json!({"title": "Fresh", "addKeywords": ["café"]})).unwrap();
|
||||
let result = save(&mut s);
|
||||
assert!(result["failed"].as_array().unwrap().is_empty(), "{result}");
|
||||
assert!(queues(&s).is_empty());
|
||||
assert_fresh(&sidecar_path(&actual.to_string_lossy(), naming));
|
||||
if !rollback {
|
||||
assert_eq!(std::fs::read_to_string(&stem_a).unwrap(), packet("stem-keep"));
|
||||
}
|
||||
drop(s);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_later_save_recognizes_companions_after_an_earlier_repair_retires() {
|
||||
let dir = crate::tests_xmp::temp_dir("r3-successive-companions");
|
||||
let a = dir.join("a.jpg");
|
||||
let b = dir.join("Trip.jpg");
|
||||
let stem_a = sidecar_path(&a.to_string_lossy(), SidecarNaming::Stem);
|
||||
let full_a = sidecar_path(&a.to_string_lossy(), SidecarNaming::Full);
|
||||
let upper_a = full_a.with_extension("XMP");
|
||||
std::fs::write(&a, b"physical fixture").unwrap();
|
||||
std::fs::write(&stem_a, packet("stem-keep")).unwrap();
|
||||
std::fs::write(&full_a, packet("lower-keep")).unwrap();
|
||||
std::fs::write(&upper_a, packet("upper-keep")).unwrap();
|
||||
if RealFs.same_file(&full_a, &upper_a) {
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
return; // This case requires two physically distinct full packets.
|
||||
}
|
||||
let mut s = session(&a);
|
||||
s.xmp.naming = SidecarNaming::Full;
|
||||
let fs = Faults(vec![full_a.clone(), upper_a.clone(), b.clone()]);
|
||||
let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "Trip", 1);
|
||||
assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err());
|
||||
assert_eq!(queues(&s).len(), 2);
|
||||
assert!(s.repair_sidecars_with(&Faults(vec![full_a.clone()])).is_err());
|
||||
assert_eq!(queues(&s).len(), 1);
|
||||
let upper_b = sidecar_path(&b.to_string_lossy(), SidecarNaming::Full).with_extension("XMP");
|
||||
assert_eq!(std::fs::read_to_string(&upper_b).unwrap(), packet("upper-keep"));
|
||||
s.execute("photo.setMeta", &json!({"title": "Fresh", "addKeywords": ["café"]})).unwrap();
|
||||
assert!(save(&mut s)["failed"].as_array().unwrap().is_empty());
|
||||
assert!(queues(&s).is_empty());
|
||||
assert_fresh(&sidecar_path(&b.to_string_lossy(), SidecarNaming::Full));
|
||||
assert_eq!(std::fs::read_to_string(&upper_b).unwrap(), packet("upper-keep"));
|
||||
assert_eq!(std::fs::read_to_string(sidecar_path(&b.to_string_lossy(), SidecarNaming::Stem)).unwrap(), packet("stem-keep"));
|
||||
drop(s);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_stem_retry_preserves_shared_packet_after_batch_rollback() {
|
||||
let dir = crate::tests_xmp::temp_dir("queued-stem-batch-rollback");
|
||||
let a = dir.join("a.jpg");
|
||||
let b = dir.join("a.png");
|
||||
let first = dir.join("Trip_1.jpg");
|
||||
let second = dir.join("Trip_2.png");
|
||||
let shared = dir.join("a.xmp");
|
||||
let first_packet = dir.join("Trip_1.xmp");
|
||||
let second_packet = dir.join("Trip_2.xmp");
|
||||
let jpeg = b"first physical photo; rename does not decode".to_vec();
|
||||
let png = b"second physical photo; rename does not decode".to_vec();
|
||||
let bytes = packet("shared-after-batch-rollback").into_bytes();
|
||||
std::fs::write(&a, &jpeg).unwrap();
|
||||
std::fs::write(&b, &png).unwrap();
|
||||
std::fs::write(&shared, &bytes).unwrap();
|
||||
let mut s = session(&a);
|
||||
let photo = Photo::new(PhotoId(2), Source::File { path: b.to_string_lossy().into_owned() }, "a.png", "PNG", 4, 3, "");
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap();
|
||||
s.xmp.auto_write = false;
|
||||
|
||||
// Existing Faults refuses renames only: the first shared-packet copy must succeed.
|
||||
let faults = Faults(vec![shared.clone(), second.clone()]);
|
||||
let plans = s.plan_rename_with(&faults, &[PhotoId(1), PhotoId(2)], "Trip_{seq}", 1);
|
||||
assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&faults, &plans)?}))).is_err());
|
||||
assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: a.to_string_lossy().into_owned() });
|
||||
assert_eq!(s.catalog.photo(PhotoId(2)).unwrap().source, Source::File { path: second.to_string_lossy().into_owned() });
|
||||
assert_eq!(std::fs::read(&a).unwrap(), jpeg);
|
||||
assert_eq!(std::fs::read(&second).unwrap(), png);
|
||||
assert!(!b.exists() && !first.exists());
|
||||
assert_eq!(std::fs::read(&shared).unwrap(), bytes);
|
||||
assert_eq!(std::fs::read(&first_packet).unwrap(), bytes);
|
||||
assert!(!second_packet.exists());
|
||||
let pending = queues(&s);
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(pending[0].from, shared);
|
||||
assert_eq!(pending[0].to, second_packet);
|
||||
let catalog_before = s.catalog.to_snapshot();
|
||||
let history_before = (s.undo.len(), s.redo.len(), s.commits());
|
||||
|
||||
// Public command, real filesystem: its no-op path drains the naturally retained repair.
|
||||
let retry = s.execute("photo.rename", &json!({"ids": [2], "template": "{name}"}));
|
||||
let catalog_after = s.catalog.to_snapshot();
|
||||
let history_after = (s.undo.len(), s.redo.len(), s.commits());
|
||||
let repaired = queues(&s).is_empty();
|
||||
let old_paths_absent = !b.exists() && !first.exists();
|
||||
let files = [&a, &second, &first_packet, &second_packet].map(|p| std::fs::read(p).map_err(|e| e.kind()));
|
||||
let retained = std::fs::read(&shared).map_err(|e| e.kind());
|
||||
drop(s);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
assert!(!dir.exists(), "fixture cleanup must precede the qualifying assertion");
|
||||
|
||||
assert_eq!(retry.unwrap()["renamed"], 0);
|
||||
assert_eq!(catalog_after, catalog_before, "repair must preserve both catalog sources");
|
||||
assert_eq!(history_after, history_before);
|
||||
assert!(repaired && old_paths_absent);
|
||||
assert_eq!(files, [Ok(jpeg), Ok(png), Ok(bytes.clone()), Ok(bytes.clone())]);
|
||||
assert_eq!(retained, Ok(bytes), "queued repair took shared XMP from the photo restored by batch rollback");
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
//! Native files and genuine Session entry points: packet ownership survives partial renames.
|
||||
use super::*;
|
||||
use crate::rename::{MoveFs, RealFs};
|
||||
use lightcraft_catalog::{Op, Photo, Source};
|
||||
use serde_json::json;
|
||||
|
||||
struct Faults(Vec<PathBuf>);
|
||||
impl MoveFs for Faults {
|
||||
fn exists(&self, p: &Path) -> bool {
|
||||
RealFs.exists(p)
|
||||
}
|
||||
fn is_file(&self, p: &Path) -> bool {
|
||||
RealFs.is_file(p)
|
||||
}
|
||||
fn same_file(&self, a: &Path, b: &Path) -> bool {
|
||||
RealFs.same_file(a, b)
|
||||
}
|
||||
fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
|
||||
if self.0.iter().any(|p| p == a) {
|
||||
Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "injected provenance partial move"))
|
||||
} else {
|
||||
RealFs.rename_no_replace(a, b)
|
||||
}
|
||||
}
|
||||
fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
|
||||
RealFs.copy_no_replace(a, b)
|
||||
}
|
||||
fn remove_file(&self, p: &Path) -> std::io::Result<()> {
|
||||
RealFs.remove_file(p)
|
||||
}
|
||||
fn stem_sibling(&self, p: &Path) -> Option<String> {
|
||||
RealFs.stem_sibling(p)
|
||||
}
|
||||
}
|
||||
fn packet(marker: &str) -> String {
|
||||
format!(
|
||||
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmlns:fixture="urn:lightcraft:provenance-test" xmp:Rating="5" fixture:Marker="{marker}"/></rdf:RDF></x:xmpmeta>"#
|
||||
)
|
||||
}
|
||||
fn path(photo: &Path, style: SidecarNaming, upper: bool) -> PathBuf {
|
||||
let p = sidecar_path(&photo.to_string_lossy(), style);
|
||||
if upper { p.with_extension("XMP") } else { p }
|
||||
}
|
||||
fn queued(s: &Session) -> Vec<crate::rename::SidecarMove> {
|
||||
s.pending_sidecar_repairs.iter().chain(s.undo.iter().chain(&s.redo).flat_map(|e| &e.retry_sidecars)).cloned().collect()
|
||||
}
|
||||
fn session(photo: &Path) -> Session {
|
||||
let mut s = Session::new();
|
||||
let p = Photo::new(PhotoId(1), Source::File { path: photo.to_string_lossy().into_owned() }, "a.jpg", "JPEG", 4, 3, "");
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
s.selection = crate::Selection::single(PhotoId(1));
|
||||
s
|
||||
}
|
||||
fn save(s: &mut Session) -> serde_json::Value {
|
||||
s.execute("photo.saveMetadataToFile", &json!({})).unwrap()
|
||||
}
|
||||
struct Partial {
|
||||
s: Session,
|
||||
dir: PathBuf,
|
||||
a: PathBuf,
|
||||
b: PathBuf,
|
||||
companion: PathBuf,
|
||||
source: PathBuf,
|
||||
target: PathBuf,
|
||||
}
|
||||
fn partial(style: SidecarNaming, upper_stem: bool, upper_full: bool, shared: bool) -> Partial {
|
||||
// one folder per fixture: tests run in parallel, and each removes its folder at the end
|
||||
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let dir = crate::tests_xmp::temp_dir(&format!("sidecar-provenance-{n}"));
|
||||
let a = dir.join("a.jpg");
|
||||
let b = dir.join("Trip.jpg");
|
||||
std::fs::write(&a, b"tiny physical photo; rename does not decode").unwrap();
|
||||
let stem_a = path(&a, SidecarNaming::Stem, upper_stem);
|
||||
let full_a = path(&a, SidecarNaming::Full, upper_full);
|
||||
let stem_b = path(&b, SidecarNaming::Stem, upper_stem);
|
||||
let full_b = path(&b, SidecarNaming::Full, upper_full);
|
||||
std::fs::write(&stem_a, packet("observed-stem")).unwrap();
|
||||
std::fs::write(&full_a, packet("observed-full")).unwrap();
|
||||
if shared {
|
||||
std::fs::write(dir.join("a.CR3"), b"shared physical sibling").unwrap();
|
||||
}
|
||||
let mut s = session(&a);
|
||||
s.xmp.naming = style;
|
||||
let rollback = style == SidecarNaming::Full;
|
||||
let faults = Faults(vec![full_a.clone(), if rollback { stem_b.clone() } else { b.clone() }]);
|
||||
let plans = s.plan_rename_with(&faults, &[PhotoId(1)], "Trip", 1);
|
||||
assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&faults, &plans)?}))).is_err());
|
||||
assert_eq!(queued(&s).len(), 1);
|
||||
let (companion, source, target) = if rollback { (full_a, stem_b, stem_a) } else { (stem_b, full_a, full_b) };
|
||||
Partial { s, dir, a, b, companion, source, target }
|
||||
}
|
||||
fn replace(p: &Path, held: &Path, in_place: bool) -> (Vec<u8>, Vec<u8>) {
|
||||
let original = std::fs::read(p).unwrap();
|
||||
#[cfg(unix)]
|
||||
let before = {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
let m = std::fs::metadata(p).unwrap();
|
||||
(m.dev(), m.ino())
|
||||
};
|
||||
if in_place {
|
||||
std::fs::write(held, &original).unwrap();
|
||||
} else {
|
||||
std::fs::rename(p, held).unwrap();
|
||||
}
|
||||
let foreign = packet(&"f".repeat(original.len() - packet("").len())).into_bytes();
|
||||
assert_eq!(original.len(), foreign.len(), "content changes must be detected even at the same length");
|
||||
assert_ne!(original, foreign);
|
||||
assert!(parse_sidecar(std::str::from_utf8(&foreign).unwrap(), crate::crs::Target::Rendered).is_ok());
|
||||
std::fs::write(p, &foreign).unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
let m = std::fs::metadata(p).unwrap();
|
||||
assert_eq!(before == (m.dev(), m.ino()), in_place, "exercise replacement and unchanged-inode edits separately");
|
||||
}
|
||||
(original, foreign)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaced_and_in_place_companions_block_save_autowrite_retry_and_file_history() {
|
||||
for style in [SidecarNaming::Stem, SidecarNaming::Full] {
|
||||
for upper_stem in [false, true] {
|
||||
for upper_full in [false, true] {
|
||||
for in_place in [false, true] {
|
||||
let mut f = partial(style, upper_stem, upper_full, false);
|
||||
let held = f.dir.join("original-held.xmp");
|
||||
let (original, foreign) = replace(&f.companion, &held, in_place);
|
||||
let queue = queued(&f.s);
|
||||
let source = std::fs::read(&f.source).unwrap();
|
||||
let before = f.s.catalog.photo(PhotoId(1)).unwrap().source.clone();
|
||||
f.s.xmp.auto_write = true;
|
||||
f.s.execute("photo.setMeta", &json!({"title": "Fresh", "addKeywords": ["café"]})).unwrap();
|
||||
assert_eq!(queued(&f.s), queue, "AutoWrite must retain the exact repair");
|
||||
assert_eq!(std::fs::read(&f.companion).unwrap(), foreign);
|
||||
let saved = save(&mut f.s);
|
||||
assert!(saved["written"].as_array().unwrap().is_empty());
|
||||
assert!(saved["failed"][0]["error"].as_str().unwrap().contains("changed since"));
|
||||
assert_eq!(queued(&f.s), queue);
|
||||
assert_eq!(std::fs::read(&f.source).unwrap(), source);
|
||||
assert!(!f.target.exists());
|
||||
assert!(f.s.repair_sidecars_with(&RealFs).is_err());
|
||||
let plans = f.s.plan_rename(&[PhotoId(1)], if style == SidecarNaming::Stem { "Trip" } else { "a" }, 1);
|
||||
assert!(f.s.apply_rename(&plans).is_err());
|
||||
// Metadata history remains available while physical ownership is unresolved.
|
||||
f.s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(std::fs::read(&f.companion).unwrap(), foreign);
|
||||
assert!(f.s.undo_step().is_err(), "the real rename Undo must reject the foreign companion");
|
||||
assert_eq!(queued(&f.s), queue);
|
||||
assert_eq!(f.s.catalog.photo(PhotoId(1)).unwrap().source, before);
|
||||
assert_eq!(std::fs::read(&f.companion).unwrap(), foreign);
|
||||
assert_eq!(std::fs::read(&held).unwrap(), original);
|
||||
// Restoring the observed content resolves ownership and keeps the positive path.
|
||||
std::fs::write(&f.companion, &original).unwrap();
|
||||
assert!(save(&mut f.s)["failed"].as_array().unwrap().is_empty());
|
||||
assert!(queued(&f.s).is_empty());
|
||||
assert_eq!(std::fs::read(&f.target).unwrap(), source);
|
||||
drop(f.s);
|
||||
std::fs::remove_dir_all(f.dir).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_redo_and_discarded_or_pruned_history_keep_companion_provenance() {
|
||||
for owner in ["redo", "discarded", "pruned"] {
|
||||
let mut f = partial(SidecarNaming::Stem, false, false, false);
|
||||
if owner == "pruned" {
|
||||
for _ in 0..1000 {
|
||||
f.s.commit("Unrelated retention step", Op::Batch { ops: Vec::new() }).unwrap();
|
||||
}
|
||||
assert_eq!(f.s.pending_sidecar_repairs.len(), 1);
|
||||
} else {
|
||||
f.s.undo_step().unwrap();
|
||||
let full_a = path(&f.a, SidecarNaming::Full, false);
|
||||
assert!(f.s.redo_step_with(&Faults(vec![full_a, f.b.clone()])).is_err());
|
||||
assert_eq!(f.s.redo.iter().map(|e| e.retry_sidecars.len()).sum::<usize>(), 1);
|
||||
if owner == "discarded" {
|
||||
f.s.commit("Discard failed redo", Op::SetRating { id: PhotoId(1), rating: 3 }).unwrap();
|
||||
assert_eq!(f.s.pending_sidecar_repairs.len(), 1);
|
||||
assert!(f.s.redo.is_empty());
|
||||
}
|
||||
}
|
||||
let held = f.dir.join("original-held.xmp");
|
||||
let (original, foreign) = replace(&f.companion, &held, false);
|
||||
let queue = queued(&f.s);
|
||||
assert!(!save(&mut f.s)["failed"].as_array().unwrap().is_empty());
|
||||
assert!(f.s.repair_sidecars_with(&RealFs).is_err());
|
||||
if owner == "redo" {
|
||||
assert!(f.s.redo_step().is_err());
|
||||
}
|
||||
assert_eq!(queued(&f.s), queue);
|
||||
assert_eq!(std::fs::read(&f.companion).unwrap(), foreign);
|
||||
std::fs::write(&f.companion, &original).unwrap();
|
||||
assert!(save(&mut f.s)["failed"].as_array().unwrap().is_empty());
|
||||
assert!(queued(&f.s).is_empty());
|
||||
drop(f.s);
|
||||
std::fs::remove_dir_all(f.dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn two_repairs() -> Option<Partial> {
|
||||
let mut f = partial(SidecarNaming::Stem, false, false, false);
|
||||
// Resolve the initial fixture, then create a new genuine partial rename with two packets.
|
||||
f.s.undo_step().unwrap();
|
||||
let upper_a = path(&f.a, SidecarNaming::Full, true);
|
||||
std::fs::write(&upper_a, packet("observed-upper-full")).unwrap();
|
||||
let full_a = path(&f.a, SidecarNaming::Full, false);
|
||||
if RealFs.same_file(&upper_a, &full_a) {
|
||||
drop(f.s);
|
||||
std::fs::remove_dir_all(f.dir).unwrap();
|
||||
return None; // This case requires two physically distinct case spellings.
|
||||
}
|
||||
let fs = Faults(vec![upper_a, full_a.clone(), f.b.clone()]);
|
||||
let plans = f.s.plan_rename_with(&fs, &[PhotoId(1)], "Trip", 1);
|
||||
assert!(f.s.apply_rename_with(&fs, &plans).is_err());
|
||||
assert_eq!(queued(&f.s).len(), 2);
|
||||
f.source = full_a;
|
||||
Some(f)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_peer_destinations_are_not_owned_and_retirement_records_only_completed_packets() {
|
||||
let Some(mut f) = two_repairs() else { return };
|
||||
let future = path(&f.b, SidecarNaming::Full, false);
|
||||
let upper = path(&f.b, SidecarNaming::Full, true);
|
||||
let foreign = packet("foreign-at-never-created-destination").into_bytes();
|
||||
std::fs::write(&future, &foreign).unwrap();
|
||||
let queue = queued(&f.s);
|
||||
let original_companion = std::fs::read(&f.companion).unwrap();
|
||||
assert!(!save(&mut f.s)["failed"].as_array().unwrap().is_empty());
|
||||
assert!(f.s.repair_sidecars_with(&RealFs).is_err());
|
||||
assert_eq!(queued(&f.s), queue);
|
||||
assert!(!upper.exists(), "a foreign peer must be rejected before the first repair retires");
|
||||
assert_eq!(std::fs::read(&future).unwrap(), foreign);
|
||||
assert_eq!(std::fs::read(&f.companion).unwrap(), original_companion);
|
||||
std::fs::remove_file(&future).unwrap();
|
||||
assert!(f.s.repair_sidecars_with(&Faults(vec![f.source.clone()])).is_err());
|
||||
assert_eq!(queued(&f.s).len(), 1);
|
||||
let held = f.dir.join("retired-original-held.xmp");
|
||||
let (observed_upper, replaced) = replace(&upper, &held, false);
|
||||
let queue = queued(&f.s);
|
||||
assert!(!save(&mut f.s)["failed"].as_array().unwrap().is_empty());
|
||||
assert!(f.s.repair_sidecars_with(&RealFs).is_err());
|
||||
assert_eq!(queued(&f.s), queue);
|
||||
assert_eq!(std::fs::read(&upper).unwrap(), replaced);
|
||||
assert!(!future.exists());
|
||||
std::fs::write(&upper, &observed_upper).unwrap();
|
||||
assert!(save(&mut f.s)["failed"].as_array().unwrap().is_empty());
|
||||
assert!(queued(&f.s).is_empty());
|
||||
assert_eq!(std::fs::read(&upper).unwrap(), observed_upper);
|
||||
drop(f.s);
|
||||
std::fs::remove_dir_all(f.dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn own_atomic_shared_packet_writes_advance_retained_versions_and_failures_do_not() {
|
||||
let mut f = partial(SidecarNaming::Stem, false, false, true);
|
||||
let shared = path(&f.a, SidecarNaming::Stem, false);
|
||||
let shared_before = std::fs::read(&shared).unwrap();
|
||||
let copied_before = std::fs::read(&f.companion).unwrap();
|
||||
let raw = f.dir.join("a.CR3");
|
||||
let mut photo = Photo::new(PhotoId(2), Source::File { path: raw.to_string_lossy().into_owned() }, "a.CR3", "CR3", 4, 3, "");
|
||||
photo.kind = MediaKind::Raw;
|
||||
photo.meta.title = "Legitimate shared packet write".into();
|
||||
f.s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap();
|
||||
// A missing repair for the moved JPEG must not block a healthy save by its raw sibling.
|
||||
let held = f.dir.join("unavailable-source.xmp");
|
||||
std::fs::rename(&f.source, &held).unwrap();
|
||||
let before = queued(&f.s);
|
||||
{
|
||||
let _fault = lightcraft_catalog::safe_file::fail_writes_after(0);
|
||||
assert!(f.s.save_sidecar(PhotoId(2)).is_err());
|
||||
}
|
||||
assert_eq!(queued(&f.s), before, "failed atomic writes must not advance provenance");
|
||||
assert_eq!(std::fs::read(&shared).unwrap(), shared_before);
|
||||
f.s.save_sidecar(PhotoId(2)).unwrap();
|
||||
assert_ne!(std::fs::read(&shared).unwrap(), shared_before);
|
||||
assert_eq!(std::fs::read(&f.companion).unwrap(), copied_before);
|
||||
assert_eq!(queued(&f.s).len(), 1);
|
||||
// External edits are still refused after the application's own atomic replacement.
|
||||
let shared_written = std::fs::read(&shared).unwrap();
|
||||
std::fs::write(&shared, packet("foreign-after-our-write")).unwrap();
|
||||
assert!(f.s.save_sidecar(PhotoId(2)).is_err());
|
||||
assert_eq!(std::fs::read_to_string(&shared).unwrap(), packet("foreign-after-our-write"));
|
||||
std::fs::write(&shared, &shared_written).unwrap();
|
||||
f.s.save_sidecar(PhotoId(2)).unwrap();
|
||||
std::fs::rename(&held, &f.source).unwrap();
|
||||
assert!(save(&mut f.s)["failed"].as_array().unwrap().is_empty());
|
||||
assert!(queued(&f.s).is_empty(), "own shared writes must not stale the retained repair");
|
||||
assert_eq!(std::fs::read(&shared).unwrap(), shared_written);
|
||||
drop(f.s);
|
||||
std::fs::remove_dir_all(f.dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaced_copy_keeps_its_shared_original_and_repair_source_byte_identical() {
|
||||
let mut f = partial(SidecarNaming::Stem, true, true, true);
|
||||
let shared = path(&f.a, SidecarNaming::Stem, true);
|
||||
let shared_before = std::fs::read(&shared).unwrap();
|
||||
let source = std::fs::read(&f.source).unwrap();
|
||||
let held = f.dir.join("copied-original-held.xmp");
|
||||
let (original, foreign) = replace(&f.companion, &held, false);
|
||||
let queue = queued(&f.s);
|
||||
f.s.xmp.auto_write = true;
|
||||
f.s.execute("photo.setMeta", &json!({"title": "Fresh"})).unwrap();
|
||||
assert!(!save(&mut f.s)["failed"].as_array().unwrap().is_empty());
|
||||
assert!(f.s.repair_sidecars_with(&RealFs).is_err());
|
||||
assert_eq!(queued(&f.s), queue);
|
||||
assert_eq!(std::fs::read(&f.companion).unwrap(), foreign);
|
||||
assert_eq!(std::fs::read(&shared).unwrap(), shared_before);
|
||||
assert_eq!(std::fs::read(&f.source).unwrap(), source);
|
||||
std::fs::write(&f.companion, &original).unwrap();
|
||||
assert!(save(&mut f.s)["failed"].as_array().unwrap().is_empty());
|
||||
assert!(queued(&f.s).is_empty());
|
||||
assert_eq!(std::fs::read(&shared).unwrap(), shared_before);
|
||||
drop(f.s);
|
||||
std::fs::remove_dir_all(f.dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_separated_sources_are_not_moved_or_retired() {
|
||||
for style in [SidecarNaming::Stem, SidecarNaming::Full] {
|
||||
let mut f = partial(style, false, true, false);
|
||||
let held = f.dir.join("source-original-held.xmp");
|
||||
let (original, foreign) = replace(&f.source, &held, true);
|
||||
let queue = queued(&f.s);
|
||||
let companion = std::fs::read(&f.companion).unwrap();
|
||||
assert!(!save(&mut f.s)["failed"].as_array().unwrap().is_empty());
|
||||
assert!(f.s.repair_sidecars_with(&RealFs).is_err());
|
||||
assert_eq!(queued(&f.s), queue);
|
||||
assert_eq!(std::fs::read(&f.source).unwrap(), foreign);
|
||||
assert_eq!(std::fs::read(&f.companion).unwrap(), companion);
|
||||
assert!(!f.target.exists());
|
||||
std::fs::write(&f.source, &original).unwrap();
|
||||
assert!(save(&mut f.s)["failed"].as_array().unwrap().is_empty());
|
||||
assert!(queued(&f.s).is_empty());
|
||||
drop(f.s);
|
||||
std::fs::remove_dir_all(f.dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detached_history_entry_is_checked_before_unrelated_global_retirement() {
|
||||
// A private-method contract, not a UI reproduction: application reachability of two
|
||||
// simultaneous repair owners is unproven. Native move failures and follow_stuck seed
|
||||
// both records; the second owner's normal apply_rename preflight is deliberately bypassed.
|
||||
struct NativePartial {
|
||||
id: PhotoId,
|
||||
original: PathBuf,
|
||||
moved: PathBuf,
|
||||
stem: PathBuf,
|
||||
full: PathBuf,
|
||||
companion: PathBuf,
|
||||
target: PathBuf,
|
||||
}
|
||||
let root = crate::tests_xmp::temp_dir("detached-history-preflight-contract");
|
||||
let image = lightcraft_raster::Rgba8::from_fn(4, 3, |_, _| [40, 80, 120, 255]);
|
||||
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&image), &Default::default()).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.xmp.naming = SidecarNaming::Stem;
|
||||
let mut native = Vec::new();
|
||||
for (i, name) in ["first", "second"].into_iter().enumerate() {
|
||||
let dir = root.join(name);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let original = dir.join("a.png");
|
||||
let moved = dir.join("Trip.png");
|
||||
std::fs::write(&original, &png).unwrap();
|
||||
let stem = path(&original, SidecarNaming::Stem, false);
|
||||
let full = path(&original, SidecarNaming::Full, true);
|
||||
for (p, marker) in [(&stem, format!("{name}-stem")), (&full, format!("{name}-full"))] {
|
||||
std::fs::write(p, packet(&marker)).unwrap();
|
||||
assert_eq!(parse_sidecar(&std::fs::read_to_string(p).unwrap(), crate::crs::Target::Rendered).unwrap().rating, Some(5));
|
||||
}
|
||||
let id = PhotoId(i as u64 + 1);
|
||||
let photo = Photo::new(id, Source::File { path: original.to_string_lossy().into_owned() }, "a.png", "PNG", 4, 3, "");
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap();
|
||||
let fs = Faults(vec![full.clone(), moved.clone()]);
|
||||
if i == 0 {
|
||||
let plans = s.plan_rename_with(&fs, &[id], "Trip", 1);
|
||||
assert!(s.apply_rename_with(&fs, &plans).is_err());
|
||||
} else {
|
||||
// Register the genuine second partial move without draining the first repair.
|
||||
let moves = vec![(original.to_string_lossy().into_owned(), moved.to_string_lossy().into_owned())];
|
||||
let err = crate::rename::move_all(&fs, &moves).unwrap_err();
|
||||
assert_eq!(err.stuck, moves);
|
||||
assert_eq!(err.pending_sidecars.len(), 1);
|
||||
let op = Op::SetFile { id, source: Source::File { path: moved.to_string_lossy().into_owned() }, file_name: "Trip.png".into() };
|
||||
s.follow_stuck(&op, err, true);
|
||||
}
|
||||
assert_eq!(s.undo.len(), i + 1);
|
||||
assert_eq!(queued(&s).len(), i + 1);
|
||||
assert_eq!(s.catalog.photo(id).unwrap().source, Source::File { path: moved.to_string_lossy().into_owned() });
|
||||
let decoded = lightcraft_codecs::decode(&std::fs::read(&moved).unwrap(), Default::default()).unwrap();
|
||||
assert_eq!((decoded.width, decoded.height), (4, 3));
|
||||
native.push(NativePartial {
|
||||
id,
|
||||
original,
|
||||
companion: path(&moved, SidecarNaming::Stem, false),
|
||||
target: path(&moved, SidecarNaming::Full, true),
|
||||
moved,
|
||||
stem,
|
||||
full,
|
||||
});
|
||||
}
|
||||
let first = &native[0];
|
||||
let second = &native[1];
|
||||
let first_stem = std::fs::read(&first.companion).unwrap();
|
||||
let full_packets: Vec<_> = native.iter().map(|f| std::fs::read(&f.full).unwrap()).collect();
|
||||
let held = root.join("observed-second-companion.xmp");
|
||||
let (observed, foreign) = replace(&second.companion, &held, false);
|
||||
let mut entry = s.undo.pop().unwrap(); // Undo/Redo call this method with their entry detached.
|
||||
let retained = queued(&s);
|
||||
let detached = entry.retry_sidecars.clone();
|
||||
assert_eq!((retained.len(), detached.len()), (1, 1));
|
||||
let sources: Vec<_> = native.iter().map(|f| s.catalog.photo(f.id).unwrap().source.clone()).collect();
|
||||
let log_len = s.pending_log.len();
|
||||
let mut packets: Vec<_> = native.iter().flat_map(|f| [&f.moved, &f.full, &f.companion]).map(|p| (p.clone(), std::fs::read(p).unwrap())).collect();
|
||||
packets.push((held.clone(), std::fs::read(&held).unwrap()));
|
||||
assert!(!first.target.exists() && !second.target.exists());
|
||||
|
||||
let err = s.apply_with_files(&mut entry, &RealFs).unwrap_err();
|
||||
assert!(err.to_string().contains("changed since"), "{err}");
|
||||
assert_eq!(std::fs::read(&second.companion).unwrap(), foreign);
|
||||
println!(
|
||||
"detached history preflight contract: retained_before={}, retained_after={}, detached_before={}, detached_after={}, first_target_exists={}",
|
||||
retained.len(),
|
||||
queued(&s).len(),
|
||||
detached.len(),
|
||||
entry.retry_sidecars.len(),
|
||||
first.target.exists()
|
||||
);
|
||||
assert_eq!(queued(&s), retained, "reject the detached owner before any unrelated global repair retires");
|
||||
assert_eq!(entry.retry_sidecars, detached);
|
||||
assert_eq!(s.pending_log.len(), log_len);
|
||||
for (f, source) in native.iter().zip(&sources) {
|
||||
assert_eq!(&s.catalog.photo(f.id).unwrap().source, source);
|
||||
assert!(!f.original.exists() && !f.target.exists());
|
||||
}
|
||||
for (p, bytes) in &packets {
|
||||
assert_eq!(std::fs::read(p).unwrap(), *bytes, "rejection must preserve {} byte for byte", p.display());
|
||||
}
|
||||
|
||||
// Resolving the changed content permits the same real method to repair and undo the move.
|
||||
std::fs::write(&second.companion, &observed).unwrap();
|
||||
let inverse = s.apply_with_files(&mut entry, &RealFs).unwrap();
|
||||
assert!(queued(&s).is_empty() && entry.retry_sidecars.is_empty());
|
||||
assert_eq!(s.catalog.photo(first.id).unwrap().source, sources[0]);
|
||||
assert_eq!(s.catalog.photo(second.id).unwrap().source, Source::File { path: second.original.to_string_lossy().into_owned() });
|
||||
assert_eq!(s.file_moves(&inverse), vec![(second.original.to_string_lossy().into_owned(), second.moved.to_string_lossy().into_owned())]);
|
||||
assert_eq!(std::fs::read(&first.moved).unwrap(), png);
|
||||
assert_eq!(std::fs::read(&second.original).unwrap(), png);
|
||||
assert_eq!(std::fs::read(&first.companion).unwrap(), first_stem);
|
||||
assert_eq!(std::fs::read(&first.target).unwrap(), full_packets[0]);
|
||||
assert_eq!(std::fs::read(&second.stem).unwrap(), observed);
|
||||
assert_eq!(std::fs::read(&second.full).unwrap(), full_packets[1]);
|
||||
assert_eq!(std::fs::read(&held).unwrap(), observed);
|
||||
assert!(!first.original.exists() && !first.full.exists());
|
||||
assert!(!second.moved.exists() && !second.companion.exists() && !second.target.exists());
|
||||
drop(s);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
//! Real Session/serializer regressions for saves that overlap retained physical repairs.
|
||||
use super::*;
|
||||
use crate::rename::{MoveFs, RealFs};
|
||||
use lightcraft_catalog::{Op, Photo, Source};
|
||||
use serde_json::json;
|
||||
|
||||
struct PartialRename {
|
||||
sidecar: PathBuf,
|
||||
photo: PathBuf,
|
||||
}
|
||||
impl MoveFs for PartialRename {
|
||||
fn exists(&self, path: &Path) -> bool {
|
||||
RealFs.exists(path)
|
||||
}
|
||||
fn is_file(&self, path: &Path) -> bool {
|
||||
RealFs.is_file(path)
|
||||
}
|
||||
fn same_file(&self, a: &Path, b: &Path) -> bool {
|
||||
RealFs.same_file(a, b)
|
||||
}
|
||||
fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
|
||||
if a == self.sidecar || a == self.photo {
|
||||
Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "transient rename fault"))
|
||||
} else {
|
||||
RealFs.rename_no_replace(a, b)
|
||||
}
|
||||
}
|
||||
fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
|
||||
if a == self.sidecar {
|
||||
Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "transient copy fault"))
|
||||
} else {
|
||||
RealFs.copy_no_replace(a, b)
|
||||
}
|
||||
}
|
||||
fn remove_file(&self, path: &Path) -> std::io::Result<()> {
|
||||
RealFs.remove_file(path)
|
||||
}
|
||||
fn stem_sibling(&self, path: &Path) -> Option<String> {
|
||||
RealFs.stem_sibling(path)
|
||||
}
|
||||
}
|
||||
|
||||
const FOREIGN: &[u8] = br#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmlns:fixture="urn:lightcraft:repair-test" xmp:Rating="2" fixture:Marker="keep-me"/></rdf:RDF></x:xmpmeta>"#;
|
||||
|
||||
fn png(path: &Path) {
|
||||
let image = lightcraft_raster::Rgba8::from_fn(4, 3, |_, _| [40, 80, 120, 255]);
|
||||
let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&image), &Default::default()).unwrap();
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
}
|
||||
fn queued(s: &Session) -> Vec<crate::rename::SidecarMove> {
|
||||
s.pending_sidecar_repairs.iter().chain(s.undo.iter().chain(&s.redo).flat_map(|entry| entry.retry_sidecars.iter())).cloned().collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autowrite_repairs_partial_rename_before_merging_foreign_xmp_across_library_switch() {
|
||||
for naming in [SidecarNaming::Stem, SidecarNaming::Full] {
|
||||
for save_naming in [SidecarNaming::Stem, SidecarNaming::Full] {
|
||||
for upper in [false, true] {
|
||||
for switch in [false, true] {
|
||||
let root = crate::tests_xmp::temp_dir(&format!("repair-save-{naming:?}-{save_naming:?}-{upper}-{switch}"));
|
||||
let a = root.join("a.png");
|
||||
let b = root.join("Trip.png");
|
||||
png(&a);
|
||||
let mut old = sidecar_path(&a.to_string_lossy(), naming);
|
||||
if upper {
|
||||
old = old.with_extension("XMP");
|
||||
}
|
||||
std::fs::write(&old, FOREIGN).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(root.join("library-a"), false).unwrap();
|
||||
s.execute("library.import", &json!({"paths": [a]})).unwrap();
|
||||
let mut id = s.catalog.photos().next().unwrap().id;
|
||||
s.selection = crate::Selection::single(id);
|
||||
s.undo.clear();
|
||||
let fs = PartialRename { sidecar: old.clone(), photo: b.clone() };
|
||||
let plans = s.plan_rename_with(&fs, &[id], "Trip", 1);
|
||||
assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err());
|
||||
assert_eq!(queued(&s).len(), 1);
|
||||
assert_eq!(s.catalog.photo(id).unwrap().source, Source::File { path: b.to_string_lossy().into_owned() });
|
||||
let pending = queued(&s);
|
||||
let held = root.join("temporarily-unavailable.xmp");
|
||||
std::fs::rename(&old, &held).unwrap();
|
||||
if switch {
|
||||
s.close_library().unwrap();
|
||||
s.open_library(root.join("library-b"), false).unwrap();
|
||||
assert_eq!(s.pending_sidecar_repairs, pending);
|
||||
s.execute("library.import", &json!({"paths": [b]})).unwrap();
|
||||
id = s.catalog.photos().next().unwrap().id;
|
||||
s.selection = crate::Selection::single(id);
|
||||
}
|
||||
s.xmp.naming = save_naming;
|
||||
s.xmp.auto_write = true;
|
||||
s.execute("photo.rate", &json!({"rating": 4})).unwrap();
|
||||
let preferred = sidecar_path(&b.to_string_lossy(), save_naming);
|
||||
assert!(!preferred.exists() && !preferred.with_extension("XMP").exists());
|
||||
assert!(!pending[0].to.exists());
|
||||
assert_eq!(queued(&s), pending, "unavailable repair stays queued after AutoWrite");
|
||||
let saved = s.execute("photo.saveMetadataToFile", &json!({})).unwrap();
|
||||
assert!(saved["written"].as_array().unwrap().is_empty());
|
||||
assert!(saved["failed"][0]["error"].as_str().unwrap().contains("unfinished XMP repair"));
|
||||
assert_eq!(std::fs::read(&held).unwrap(), FOREIGN);
|
||||
assert_eq!(queued(&s), pending);
|
||||
|
||||
// A missing sidecar for another photo does not prevent this healthy save.
|
||||
let healthy = root.join("healthy.png");
|
||||
png(&healthy);
|
||||
let healthy_id = PhotoId(999);
|
||||
s.commit(
|
||||
"Healthy Photo",
|
||||
Op::AddPhoto {
|
||||
photo: Box::new(Photo::new(
|
||||
healthy_id,
|
||||
Source::File { path: healthy.to_string_lossy().into_owned() },
|
||||
"healthy.png",
|
||||
"PNG",
|
||||
4,
|
||||
3,
|
||||
"",
|
||||
)),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
s.execute("photo.setMeta", &json!({"ids": [healthy_id.0], "title": "Healthy save"})).unwrap();
|
||||
let packet = std::fs::read_to_string(sidecar_path(&healthy.to_string_lossy(), save_naming)).unwrap();
|
||||
assert_eq!(parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap().title.as_deref(), Some("Healthy save"));
|
||||
assert_eq!(queued(&s), pending);
|
||||
|
||||
// An existing destination is never overwritten and the original packet remains.
|
||||
std::fs::rename(&held, &old).unwrap();
|
||||
let destination = pending[0].to.clone();
|
||||
let collision = String::from_utf8(FOREIGN.to_vec()).unwrap().replace("Rating=\"2\"", "Rating=\"5\"").into_bytes();
|
||||
let collision_path = if upper { preferred.clone() } else { destination.clone() };
|
||||
std::fs::write(&collision_path, &collision).unwrap();
|
||||
s.execute("photo.setMeta", &json!({"ids": [id.0], "title": "Fresh title", "addKeywords": ["café"]})).unwrap();
|
||||
assert_eq!(std::fs::read(&old).unwrap(), FOREIGN);
|
||||
assert_eq!(std::fs::read(&collision_path).unwrap(), collision);
|
||||
assert_eq!(queued(&s), pending);
|
||||
s.selection = crate::Selection::single(id);
|
||||
assert_eq!(s.execute("photo.saveMetadataToFile", &json!({})).unwrap()["failed"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(std::fs::read(&collision_path).unwrap(), collision);
|
||||
std::fs::remove_file(&collision_path).unwrap(); // remove only this test's collision
|
||||
|
||||
// The next successful AutoWrite runs the real repair and production XMP merge.
|
||||
s.execute("photo.setMeta", &json!({"title": "Final title"})).unwrap();
|
||||
assert!(queued(&s).is_empty());
|
||||
assert!(!old.exists());
|
||||
let packet = std::fs::read_to_string(&destination).unwrap();
|
||||
assert!(packet.contains("fixture:Marker=\"keep-me\""));
|
||||
let parsed = parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap();
|
||||
assert_eq!(parsed.rating, Some(4));
|
||||
assert_eq!(parsed.title.as_deref(), Some("Final title"));
|
||||
assert!(parsed.keywords.as_ref().unwrap().contains(&"café".to_string()));
|
||||
if upper {
|
||||
assert!(!preferred.exists(), "merge uses the repaired uppercase packet");
|
||||
}
|
||||
for (step, title) in [("edit.undo", "Fresh title"), ("edit.redo", "Final title")] {
|
||||
s.execute(step, &json!({})).unwrap();
|
||||
let packet = std::fs::read_to_string(&destination).unwrap();
|
||||
assert!(packet.contains("fixture:Marker=\"keep-me\""));
|
||||
assert_eq!(parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap().title.as_deref(), Some(title));
|
||||
}
|
||||
drop(s);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unowned_shared_stem_repair_does_not_block_healthy_jpeg_full_sidecar_save() {
|
||||
let root = crate::tests_xmp::temp_dir("repair-save-shared-stem");
|
||||
let raw = root.join("old.CR3");
|
||||
let moved = root.join("Trip.CR3");
|
||||
let jpeg = root.join("Trip.jpg");
|
||||
std::fs::write(&raw, b"physical fixture; catalogued without decoding").unwrap();
|
||||
png(&jpeg);
|
||||
let old = sidecar_path(&raw.to_string_lossy(), SidecarNaming::Stem);
|
||||
std::fs::write(&old, FOREIGN).unwrap();
|
||||
let mut s = Session::new();
|
||||
let raw_id = PhotoId(1);
|
||||
let jpeg_id = PhotoId(2);
|
||||
let mut photo = Photo::new(raw_id, Source::File { path: raw.to_string_lossy().into_owned() }, "old.CR3", "CR3", 4, 3, "");
|
||||
photo.kind = MediaKind::Raw;
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap();
|
||||
s.catalog
|
||||
.apply(Op::AddPhoto {
|
||||
photo: Box::new(Photo::new(jpeg_id, Source::File { path: jpeg.to_string_lossy().into_owned() }, "Trip.jpg", "JPEG", 4, 3, "")),
|
||||
})
|
||||
.unwrap();
|
||||
let fs = PartialRename { sidecar: old.clone(), photo: moved };
|
||||
let plans = s.plan_rename_with(&fs, &[raw_id], "Trip", 1);
|
||||
assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err());
|
||||
let pending = queued(&s);
|
||||
assert_eq!(pending.len(), 1);
|
||||
let held = root.join("temporarily-unavailable.xmp");
|
||||
std::fs::rename(&old, &held).unwrap();
|
||||
assert_eq!(s.sidecar_naming(jpeg_id), SidecarNaming::Full);
|
||||
s.selection = crate::Selection::single(jpeg_id);
|
||||
s.xmp.auto_write = true;
|
||||
s.execute("photo.setMeta", &json!({"title": "Healthy JPEG"})).unwrap();
|
||||
let full = sidecar_path(&jpeg.to_string_lossy(), SidecarNaming::Full);
|
||||
let packet = std::fs::read_to_string(&full).unwrap();
|
||||
assert_eq!(parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap().title.as_deref(), Some("Healthy JPEG"));
|
||||
assert!(!pending[0].to.exists());
|
||||
assert_eq!(queued(&s), pending);
|
||||
assert_eq!(std::fs::read(&held).unwrap(), FOREIGN);
|
||||
drop(s);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
+111
-19
@@ -21,6 +21,33 @@ pub fn file_name(p: &Photo) -> String {
|
||||
format!("{:032x}.lcsp", h.0)
|
||||
}
|
||||
|
||||
/// The default sharded location of a proxy. A two-character hex prefix bounds each
|
||||
/// folder to about 1/256 of the library's smart previews.
|
||||
pub fn sharded_file_path(dir: &Path, p: &Photo) -> PathBuf {
|
||||
let name = file_name(p);
|
||||
match name.get(..2) {
|
||||
Some(shard) => dir.join(shard).join(&name),
|
||||
None => dir.join(&name),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read an existing proxy in either layout. New proxies use the sharded location; previews
|
||||
/// made by older releases still load and can be moved with smartPreviewsLocation.
|
||||
pub fn file_path(dir: &Path, p: &Photo) -> PathBuf {
|
||||
existing_file_path(dir, sharded_file_path(dir, p))
|
||||
}
|
||||
|
||||
/// Resolve a sharded path to an older flat file when present. Call only off the UI thread:
|
||||
/// checking a removable drive synchronously could stall drawing.
|
||||
pub fn existing_file_path(dir: &Path, sharded: PathBuf) -> PathBuf {
|
||||
if sharded.exists() {
|
||||
return sharded;
|
||||
}
|
||||
let Some(name) = sharded.file_name() else { return sharded };
|
||||
let legacy = dir.join(name);
|
||||
if legacy.exists() { legacy } else { sharded }
|
||||
}
|
||||
|
||||
/// The most header [`is_valid`] reads (the JSON line before the JPEG).
|
||||
const HEADER_MAX: u64 = 4096;
|
||||
|
||||
@@ -104,12 +131,45 @@ pub fn check_writable(dir: &Path) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The owned proxy files in both layouts. Do not follow symlinked directories or
|
||||
/// traverse unrelated folders inside a user-selected smart preview directory.
|
||||
fn files(dir: &Path) -> Vec<PathBuf> {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() };
|
||||
let mut found = Vec::new();
|
||||
for e in entries.flatten() {
|
||||
let Ok(kind) = e.file_type() else { continue };
|
||||
if kind.is_file() && e.path().extension().is_some_and(|ext| ext == "lcsp") {
|
||||
found.push(e.path()); // legacy layout
|
||||
} else if kind.is_dir() {
|
||||
let shard_name = e.file_name();
|
||||
let Some(shard) = shard_name.to_str() else { continue };
|
||||
if shard.len() != 2 || !shard.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) {
|
||||
continue;
|
||||
}
|
||||
let Ok(children) = std::fs::read_dir(e.path()) else { continue };
|
||||
for child in children.flatten() {
|
||||
let name = child.file_name();
|
||||
let Some(name) = name.to_str() else { continue };
|
||||
let Some(stem) = name.strip_suffix(".lcsp") else { continue };
|
||||
if stem.len() == 32
|
||||
&& stem.starts_with(shard)
|
||||
&& stem.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
|
||||
&& child.file_type().is_ok_and(|kind| kind.is_file())
|
||||
{
|
||||
found.push(child.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// The number of smart previews in `dir` and their total size in bytes.
|
||||
pub fn stats(dir: &Path) -> (usize, u64) {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return (0, 0) };
|
||||
rd.flatten()
|
||||
.filter(|e| e.path().extension().is_some_and(|x| x == "lcsp"))
|
||||
.fold((0, 0), |(n, b), e| (n + 1, b + e.metadata().map_or(0, |m| m.len())))
|
||||
files(dir).into_iter().fold((0usize, 0u64), |(count, bytes), path| {
|
||||
let size = std::fs::metadata(path).map_or(0, |m| m.len());
|
||||
(count.saturating_add(1), bytes.saturating_add(size))
|
||||
})
|
||||
}
|
||||
|
||||
/// What to do with the smart previews in the old folder when the location changes.
|
||||
@@ -140,24 +200,32 @@ pub fn migrate(from: &Path, to: &Path, what: Existing) -> (usize, Vec<String>) {
|
||||
if what == Existing::Leave {
|
||||
return (done, failed);
|
||||
}
|
||||
let Ok(rd) = std::fs::read_dir(from) else { return (done, failed) };
|
||||
for e in rd.flatten().filter(|e| e.path().extension().is_some_and(|x| x == "lcsp")) {
|
||||
let src = e.path();
|
||||
for src in files(from) {
|
||||
let Some(name) = src.file_name() else { continue };
|
||||
let dst = name
|
||||
.to_str()
|
||||
.and_then(|n| n.strip_suffix(".lcsp"))
|
||||
.filter(|stem| stem.len() == 32 && stem.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()))
|
||||
.map_or_else(|| to.join(name), |stem| to.join(&stem[..2]).join(name));
|
||||
let r = match what {
|
||||
Existing::Discard => std::fs::remove_file(&src),
|
||||
_ => {
|
||||
let dst = to.join(e.file_name());
|
||||
if is_valid(&dst) {
|
||||
// named by content: the complete copy already there is the same preview
|
||||
std::fs::remove_file(&src)
|
||||
} else {
|
||||
// across drives: copied atomically (never a partial proxy at the new place)
|
||||
std::fs::rename(&src, &dst).or_else(|_| {
|
||||
std::fs::read(&src)
|
||||
.and_then(|b| lightcraft_catalog::safe_file::write_atomic(&dst, &b))
|
||||
.and_then(|()| std::fs::remove_file(&src))
|
||||
})
|
||||
}
|
||||
let parent = dst.parent().unwrap_or(to);
|
||||
// Create only the shard, never missing parent directories on an unplugged drive.
|
||||
let prepare = if parent.is_dir() { Ok(()) } else { std::fs::create_dir(parent) };
|
||||
prepare.and_then(|()| {
|
||||
if is_valid(&dst) {
|
||||
// named by content: the complete copy already there is the same preview
|
||||
std::fs::remove_file(&src)
|
||||
} else {
|
||||
// across drives: copied atomically (never a partial proxy at the new place)
|
||||
std::fs::rename(&src, &dst).or_else(|_| {
|
||||
std::fs::read(&src)
|
||||
.and_then(|b| lightcraft_catalog::safe_file::write_atomic(&dst, &b))
|
||||
.and_then(|()| std::fs::remove_file(&src))
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
};
|
||||
match r {
|
||||
@@ -288,6 +356,30 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sharded_and_legacy_proxies_are_counted_and_moved() {
|
||||
let base = temp("shards");
|
||||
let from = base.join("from");
|
||||
let to = base.join("to");
|
||||
std::fs::create_dir_all(from.join("a1")).unwrap();
|
||||
std::fs::create_dir(&to).unwrap();
|
||||
let old = "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2.lcsp";
|
||||
let new = "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1.lcsp";
|
||||
std::fs::write(from.join(old), b"legacy").unwrap();
|
||||
std::fs::write(from.join("a1").join(new), b"sharded").unwrap();
|
||||
std::fs::write(from.join("a1").join("notes.txt"), b"user file").unwrap();
|
||||
std::fs::write(from.join("notes.lcsp.txt"), b"keep").unwrap();
|
||||
assert_eq!(stats(&from), (2, 13));
|
||||
let (moved, failed) = migrate(&from, &to, Existing::Move);
|
||||
assert_eq!((moved, failed), (2, Vec::<String>::new()));
|
||||
assert_eq!(stats(&from), (0, 0));
|
||||
assert_eq!(stats(&to), (2, 13));
|
||||
assert_eq!(std::fs::read(to.join("b2").join(old)).unwrap(), b"legacy");
|
||||
assert_eq!(std::fs::read(to.join("a1").join(new)).unwrap(), b"sharded");
|
||||
assert!(from.join("a1").join("notes.txt").exists());
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_proxies_are_invalid_and_never_kept_over_good_ones() {
|
||||
let base = temp("valid");
|
||||
|
||||
@@ -37,6 +37,33 @@ fn changing_one_wb_control_resolves_as_shot_without_stale_tint() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset Edits on a raw goes back to its import defaults, capture sharpening and colour noise
|
||||
/// reduction included (#763: reset left both at 0 while import sets 40 / 25); a rendered file
|
||||
/// keeps none.
|
||||
#[test]
|
||||
fn reset_edits_restores_a_raws_import_defaults() {
|
||||
use lightcraft_catalog::{Photo, PhotoId, Source};
|
||||
use lightcraft_develop::DevelopSettings;
|
||||
let mut s = demo();
|
||||
for (id, format, raw) in [(200u64, "NEF", true), (201, "JPG", false)] {
|
||||
let name = format!("p{id}.{format}");
|
||||
let mut p = Photo::new(PhotoId(id), Source::File { path: name.clone() }, &name, format, 16, 16, "");
|
||||
if raw {
|
||||
p.as_shot_wb = Some((5200.0, 4.0));
|
||||
p.develop = std::sync::Arc::new(DevelopSettings::for_raw(5200.0, 4.0));
|
||||
}
|
||||
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
}
|
||||
for (id, want) in [(200, (40.0, 25.0)), (201, (0.0, 0.0))] {
|
||||
s.execute("library.select", &json!({"ids": [id], "active": id})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "detail.sharpenAmount", "value": 90})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "detail.nrColor", "value": 70})).unwrap();
|
||||
s.execute("develop.reset", &json!({})).unwrap();
|
||||
let d = active_dev(&s);
|
||||
assert_eq!((d.detail.sharpen_amount, d.detail.nr_color), want, "photo {id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_library_loads() {
|
||||
let mut s = demo();
|
||||
@@ -530,6 +557,36 @@ fn crop_drag_keeps_a_locked_ratio_on_every_handle() {
|
||||
}
|
||||
}
|
||||
|
||||
// Feature: Shift while dragging keeps the current proportions (issue 614)
|
||||
#[test]
|
||||
fn crop_drag_keep_ratio_holds_the_shape_with_the_lock_off() {
|
||||
let mut s = demo();
|
||||
s.execute("crop.set", &json!({"rect": [0.1, 0.2, 0.7, 0.6]})).unwrap();
|
||||
assert!(active_dev(&s).crop.aspect.is_none(), "lock is off");
|
||||
let r = active_dev(&s).crop.geometry.rect;
|
||||
let want = r.aspect();
|
||||
for (handle, to) in [("left", [r.x0 + 0.1, 0.5]), ("top", [0.5, r.y0 + 0.05]), ("bottomRight", [1.3, 1.3]), ("right", [0.9, 0.5])] {
|
||||
s.execute("crop.drag", &json!({"handle": handle, "from": [0.5, 0.5], "to": to, "start": [r.x0, r.y0, r.x1, r.y1], "keepRatio": true}))
|
||||
.unwrap();
|
||||
let g = active_dev(&s).crop.geometry.rect;
|
||||
assert!((g.aspect() - want).abs() < 1e-6, "{handle}: {} vs {want}", g.aspect());
|
||||
assert!(active_dev(&s).crop.aspect.is_none(), "the lock toggle is untouched");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_drag_without_keep_ratio_and_lock_off_is_free() {
|
||||
let mut s = demo();
|
||||
s.execute("crop.set", &json!({"rect": [0.1, 0.2, 0.7, 0.6]})).unwrap();
|
||||
let r = active_dev(&s).crop.geometry.rect;
|
||||
let want = r.aspect();
|
||||
s.execute("crop.drag", &json!({"handle": "right", "from": [0.7, 0.4], "to": [0.9, 0.4], "start": [r.x0, r.y0, r.x1, r.y1]})).unwrap();
|
||||
assert!((active_dev(&s).crop.geometry.rect.aspect() - want).abs() > 1e-3, "ratio changes freely");
|
||||
s.execute("crop.drag", &json!({"handle": "right", "from": [0.7, 0.4], "to": [0.9, 0.4], "start": [r.x0, r.y0, r.x1, r.y1], "keepRatio": false}))
|
||||
.unwrap();
|
||||
assert!((active_dev(&s).crop.geometry.rect.aspect() - want).abs() > 1e-3, "keepRatio false is the same");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_drag_rejects_unknown_handles_and_bad_points() {
|
||||
let mut s = demo();
|
||||
@@ -1106,3 +1163,18 @@ fn undo_and_redo_show_the_photo_they_change() {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.selection, before, "a step over several photos keeps the selection");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_edits_render_with_their_hdr_histogram() {
|
||||
let mut s = demo();
|
||||
let id = s.active().unwrap();
|
||||
let plain = s.loupe_job(id, 200, 200, true).unwrap().run().rendered.unwrap();
|
||||
assert!(plain.histogram.hdr.is_none(), "SDR edits: the image's histogram");
|
||||
s.execute("develop.hdr", &json!({"enabled": true, "maxEv": 3})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "light.exposure", "value": 1.0})).unwrap();
|
||||
let r = s.loupe_job(id, 200, 200, true).unwrap().run().rendered.unwrap();
|
||||
let bins = r.histogram.hdr.expect("an HDR edit's histogram");
|
||||
assert_eq!(bins.stops, lightcraft_develop::Hdr::MAX_EV_LIMIT as f32, "a fixed scale");
|
||||
assert!(r.histogram.above_sdr() > 0.0);
|
||||
assert!(r.deep.is_none(), "the preview itself stays the 8-bit SDR rendition");
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//! Albums hold library photos (issue #684): browsed (Local) photos are skipped by
|
||||
//! `album.create {addSelected}`, `album.addPhotos` and `album.toggleTarget` (B), which report how
|
||||
//! many; a Local-only selection is refused and leaves Photo ▸ Add to Album off.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use lightcraft_catalog::{AlbumId, PhotoId};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Selection, Session};
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lc-albums-local-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn png(p: &Path, seed: u8) {
|
||||
let img = lightcraft_raster::Rgba8::from_fn(16, 12, |x, y| [(x * 9) as u8, (y * 11) as u8, seed, 255]);
|
||||
let b =
|
||||
crate::export::encode_image(&img, &crate::export::ExportOptions { format: crate::export::ExportFormat::Png, ..Default::default() }).unwrap();
|
||||
std::fs::write(p, b).unwrap();
|
||||
}
|
||||
|
||||
/// A session browsing a folder of two files, one of them added to My Photos: (session, local, library).
|
||||
fn browsed(tag: &str) -> (Session, PhotoId, PhotoId) {
|
||||
let dir = temp_dir(tag);
|
||||
png(&dir.join("a.png"), 1);
|
||||
png(&dir.join("b.png"), 2);
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.browse", &json!({"path": dir.to_string_lossy()})).unwrap();
|
||||
let mut ids: Vec<PhotoId> = s.catalog.photos().filter(|p| p.local).map(|p| p.id).collect();
|
||||
ids.sort();
|
||||
assert_eq!(ids.len(), 2, "both files are browsed");
|
||||
let (local, library) = (ids[0], ids[1]);
|
||||
s.execute("photo.addToLibrary", &json!({"ids": [library.0]})).unwrap();
|
||||
assert!(!s.catalog.photo(library).unwrap().local && s.catalog.photo(local).unwrap().local);
|
||||
(s, local, library)
|
||||
}
|
||||
|
||||
fn enabled(s: &Session, id: &str) -> bool {
|
||||
s.commands().into_iter().find(|c| c.id == id).unwrap_or_else(|| panic!("no command {id}")).enabled
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_local_only_selection_is_refused_and_leaves_add_to_album_off() {
|
||||
let (mut s, local, library) = browsed("refused");
|
||||
let album = AlbumId(s.execute("album.create", &json!({"name": "Trip", "addSelected": false})).unwrap()["id"].as_u64().unwrap());
|
||||
s.selection = Selection::single(local);
|
||||
assert!(!enabled(&s, "album.addPhotos"), "Photo ▸ Add to Album is off for a Local photo");
|
||||
for (command, params) in [
|
||||
("album.addPhotos", json!({"id": album.0})),
|
||||
("album.addPhotos", json!({"id": album.0, "ids": [local.0]})),
|
||||
("album.toggleTarget", json!({})),
|
||||
] {
|
||||
let e = s.execute(command, ¶ms).unwrap_err().to_string();
|
||||
assert!(e.contains("Local") && e.contains("Add to My Photos"), "{command}: {e}");
|
||||
}
|
||||
assert!(s.catalog.album(album).unwrap().photos.is_empty(), "nothing invisible was added");
|
||||
assert!(s.catalog.quick_collection().is_none(), "no Quick Collection was made for nothing");
|
||||
// the album is still created from a Local-only selection, just without it
|
||||
let r = s.execute("album.create", &json!({"name": "Empty", "addSelected": true})).unwrap();
|
||||
assert_eq!(r["skipped"], 1, "{r}");
|
||||
assert!(s.catalog.album(AlbumId(r["id"].as_u64().unwrap())).unwrap().photos.is_empty());
|
||||
// once in My Photos the same photo goes in
|
||||
s.execute("photo.addToLibrary", &json!({"ids": [local.0]})).unwrap();
|
||||
assert!(enabled(&s, "album.addPhotos"));
|
||||
let r = s.execute("album.addPhotos", &json!({"id": album.0})).unwrap();
|
||||
assert_eq!((r["added"].as_u64(), r["skipped"].as_u64()), (Some(1), Some(0)), "{r}");
|
||||
assert_eq!(s.catalog.album(album).unwrap().photos, vec![local]);
|
||||
let _ = library;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mixed_selection_skips_the_local_photos_and_says_how_many() {
|
||||
let (mut s, local, library) = browsed("mixed");
|
||||
s.selection = Selection { ids: vec![local, library], active: Some(library) };
|
||||
assert!(enabled(&s, "album.addPhotos"), "one library photo is enough");
|
||||
let r = s.execute("album.create", &json!({"name": "Trip", "addSelected": true})).unwrap();
|
||||
let album = AlbumId(r["id"].as_u64().unwrap());
|
||||
assert_eq!(r["skipped"], 1, "{r}");
|
||||
assert_eq!(s.catalog.album(album).unwrap().photos, vec![library]);
|
||||
assert_eq!(s.catalog.album(album).unwrap().cover, Some(library), "the cover is a photo the album shows");
|
||||
|
||||
let other = AlbumId(s.execute("album.create", &json!({"name": "Other", "addSelected": false})).unwrap()["id"].as_u64().unwrap());
|
||||
let r = s.execute("album.addPhotos", &json!({"id": other.0})).unwrap();
|
||||
assert_eq!((r["added"].as_u64(), r["skipped"].as_u64()), (Some(1), Some(1)), "{r}");
|
||||
assert_eq!(s.catalog.album(other).unwrap().photos, vec![library]);
|
||||
|
||||
let r = s.execute("album.toggleTarget", &json!({})).unwrap();
|
||||
assert_eq!((r["added"].as_bool(), r["skipped"].as_u64()), (Some(true), Some(1)), "{r}");
|
||||
let quick = s.catalog.quick_collection().expect("B made the Quick Collection");
|
||||
assert_eq!(s.catalog.album(quick).unwrap().photos, vec![library]);
|
||||
// B again: the library photo is the whole addable selection and it is in, so it comes out
|
||||
let r = s.execute("album.toggleTarget", &json!({})).unwrap();
|
||||
assert_eq!(r["added"], false, "{r}");
|
||||
assert!(s.catalog.album(quick).unwrap().photos.is_empty());
|
||||
}
|
||||
@@ -121,3 +121,102 @@ fn targeted_adjustment_on_curve_and_mixer() {
|
||||
assert!(o.values().all(|v| v.as_f64().unwrap() < 0.0));
|
||||
assert!(s.execute("develop.targeted", &json!({"target": "nope", "x": 0.5, "y": 0.5, "delta": 1})).is_err());
|
||||
}
|
||||
|
||||
/// Issue #730: on a raw whose camera has measured colour matrices the white-balance commands write Kelvin, mark
|
||||
/// the scale, resolve the presets to their real temperatures, and turn a value still on the old relative scale into
|
||||
/// the Custom Kelvin value that renders the same before applying an edit on top of it.
|
||||
#[test]
|
||||
fn white_balance_commands_write_kelvin_on_raws_whose_camera_has_matrices() {
|
||||
use lightcraft_catalog::{MediaKind, Op, Photo, Source};
|
||||
use lightcraft_develop::{DevelopSettings, WbMode, WbScale};
|
||||
let mut s = Session::new();
|
||||
let add = |s: &mut Session, name: &str, camera: &str, as_shot: (f64, f64)| {
|
||||
let id = s.catalog.alloc_photo_id();
|
||||
let mut p = Photo::new(id, Source::File { path: format!("/photos/{name}") }, name, "ARW", 64, 64, "2026-01-01T00:00:00");
|
||||
p.kind = MediaKind::Raw;
|
||||
p.meta.camera = camera.into();
|
||||
p.as_shot_wb = Some(as_shot);
|
||||
p.develop = std::sync::Arc::new(p.camera_defaults());
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
id
|
||||
};
|
||||
let kelvin = add(&mut s, "a.arw", "SONY ILCE-7M3", (4986.0, -2.0));
|
||||
let relative = add(&mut s, "b.arw", "SONY ILCE-7M2", (6500.0, 0.0));
|
||||
let dev = |s: &Session, id| (*s.develop_of(id).unwrap()).clone();
|
||||
assert_eq!((dev(&s, kelvin).wb.scale, dev(&s, relative).wb.scale), (WbScale::Kelvin, WbScale::Legacy));
|
||||
|
||||
// presets are real temperatures on the Kelvin raw, scaled around 6500 K = as shot on the other
|
||||
s.execute("library.select", &json!({"ids": [kelvin.0], "active": kelvin.0})).unwrap();
|
||||
let r = s.execute("develop.wb", &json!({"mode": "cloudy"})).unwrap();
|
||||
assert_eq!((r["temp"].as_f64(), r["tint"].as_f64()), (Some(6500.0), Some(10.0)));
|
||||
let d = dev(&s, kelvin);
|
||||
assert_eq!((d.wb.mode, d.wb.temp, d.wb.tint, d.wb.scale), (WbMode::Cloudy, 6500.0, 10.0, WbScale::Kelvin));
|
||||
s.execute("develop.wb", &json!({"mode": "tungsten"})).unwrap();
|
||||
assert_eq!(dev(&s, kelvin).wb.temp, 2850.0);
|
||||
s.execute("library.select", &json!({"ids": [relative.0], "active": relative.0})).unwrap();
|
||||
let r = s.execute("develop.wb", &json!({"mode": "cloudy"})).unwrap();
|
||||
assert!((r["temp"].as_f64().unwrap() - 6500.0 * 6500.0 / 5500.0).abs() < 1e-6, "{r}");
|
||||
assert_eq!(dev(&s, relative).wb.scale, WbScale::Legacy);
|
||||
|
||||
// a slider edit on the Kelvin raw is Kelvin; the stored value says so
|
||||
s.execute("library.select", &json!({"ids": [kelvin.0], "active": kelvin.0})).unwrap();
|
||||
s.execute("develop.wb", &json!({"mode": "asShot"})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "wb.temp", "value": 5200})).unwrap();
|
||||
let d = dev(&s, kelvin);
|
||||
assert_eq!((d.wb.mode, d.wb.temp, d.wb.tint, d.wb.scale), (WbMode::Custom, 5200.0, -2.0, WbScale::Kelvin));
|
||||
let info = s.source_info(kelvin);
|
||||
assert_eq!(lightcraft_engine_effective(&info, &d), (5200.0, -2.0));
|
||||
|
||||
// settings from an older catalog on the Kelvin raw: a relative Custom value (6500 = as shot) with no scale
|
||||
let mut old = DevelopSettings::for_raw(6500.0, 0.0);
|
||||
(old.wb.mode, old.wb.temp, old.wb.tint) = (WbMode::Custom, 5800.0, 3.0);
|
||||
s.set_develop(kelvin, old.clone(), "old").unwrap();
|
||||
let shown = lightcraft_engine_effective(&info, &old);
|
||||
let expect = lightcraft_pipeline::local::legacy_to_kelvin(5800.0, 3.0, (4986.0, -2.0));
|
||||
assert_eq!(shown, expect);
|
||||
assert!(shown.0 < 4986.0, "a warm relative shift is a lower Kelvin than as shot: {shown:?}");
|
||||
// nudging the tint converts the temperature to the Kelvin it rendered as, then applies the nudge
|
||||
s.execute("develop.adjust", &json!({"control": "wb.tint", "delta": 5})).unwrap();
|
||||
let d = dev(&s, kelvin);
|
||||
assert_eq!((d.wb.mode, d.wb.scale), (WbMode::Custom, WbScale::Kelvin));
|
||||
assert!((d.wb.temp - expect.0).abs() < 1e-6 && (d.wb.tint - (expect.1 + 5.0)).abs() < 1e-6, "{:?} vs {expect:?}", d.wb);
|
||||
// a relative preset value from an older catalog becomes a Custom value with its old look, not the preset's Kelvin
|
||||
let mut old = DevelopSettings::for_raw(6500.0, 0.0);
|
||||
(old.wb.mode, old.wb.temp, old.wb.tint) = (WbMode::Cloudy, 6500.0 * 6500.0 / 5500.0, 10.0);
|
||||
s.set_develop(kelvin, old.clone(), "old preset").unwrap();
|
||||
assert_eq!(lightcraft_engine_effective(&info, &old), lightcraft_pipeline::local::legacy_to_kelvin(old.wb.temp, 10.0, (4986.0, -2.0)));
|
||||
s.execute("develop.set", &json!({"control": "wb.tint", "value": 0})).unwrap();
|
||||
let d = dev(&s, kelvin);
|
||||
assert_eq!((d.wb.mode, d.wb.tint, d.wb.scale), (WbMode::Custom, 0.0, WbScale::Kelvin));
|
||||
assert!(d.wb.temp != 6500.0 && (d.wb.temp - lightcraft_pipeline::local::legacy_to_kelvin(old.wb.temp, 10.0, (4986.0, -2.0)).0).abs() < 1e-6);
|
||||
// a partial without a scale (an old preset, `develop.merge`) keeps the relative meaning on the Kelvin raw
|
||||
s.execute("develop.merge", &json!({"settings": {"wb": {"mode": "custom", "temp": 6500.0, "tint": 0.0}}})).unwrap();
|
||||
let d = dev(&s, kelvin);
|
||||
assert_eq!(d.wb.scale, WbScale::Legacy);
|
||||
assert_eq!(lightcraft_engine_effective(&info, &d), (4986.0, -2.0), "6500 / 0 without a scale is as shot");
|
||||
// Quick Develop on old settings: As Shot from an older catalog (no scale) nudges the Kelvin as-shot white, a
|
||||
// relative Custom value is converted to the Kelvin it rendered as first (never a Kelvin number read as relative)
|
||||
let mut old = DevelopSettings::for_raw(6500.0, 0.0);
|
||||
old.wb.mode = WbMode::AsShot;
|
||||
s.set_develop(kelvin, old, "old as shot").unwrap();
|
||||
s.execute("develop.quickAdjust", &json!({"control": "wb.temp", "delta": 100})).unwrap();
|
||||
let d = dev(&s, kelvin);
|
||||
assert_eq!((d.wb.mode, d.wb.temp, d.wb.tint, d.wb.scale), (WbMode::Custom, 5086.0, -2.0, WbScale::Kelvin));
|
||||
let mut old = DevelopSettings::for_raw(6500.0, 0.0);
|
||||
(old.wb.mode, old.wb.temp, old.wb.tint) = (WbMode::Custom, 5800.0, 3.0);
|
||||
s.set_develop(kelvin, old, "old custom").unwrap();
|
||||
s.execute("develop.quickAdjust", &json!({"control": "wb.tint", "delta": 5})).unwrap();
|
||||
let d = dev(&s, kelvin);
|
||||
assert_eq!((d.wb.mode, d.wb.scale), (WbMode::Custom, WbScale::Kelvin));
|
||||
assert!((d.wb.temp - expect.0).abs() < 1e-6 && (d.wb.tint - (expect.1 + 5.0)).abs() < 1e-6, "{:?} vs {expect:?}", d.wb);
|
||||
|
||||
// reset: As Shot, on the Kelvin scale
|
||||
s.execute("develop.reset", &json!({})).unwrap();
|
||||
let d = dev(&s, kelvin);
|
||||
assert_eq!((d.wb.mode, d.wb.scale), (WbMode::AsShot, WbScale::Kelvin));
|
||||
assert!(!s.catalog.photo(kelvin).unwrap().is_edited());
|
||||
}
|
||||
|
||||
fn lightcraft_engine_effective(info: &lightcraft_pipeline::SourceInfo, d: &lightcraft_develop::DevelopSettings) -> (f64, f64) {
|
||||
lightcraft_pipeline::local::effective_wb(info, d)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Every way of setting the crop angle keeps the crop inside the photo (issue #742): the generic
|
||||
//! control path (`develop.set` on `crop.angle`, as MCP, the control channel and `lightcraft-cli
|
||||
//! render --set` use it, plus `develop.adjust`, `develop.quickAdjust`, `develop.resetControl`) gives
|
||||
//! the same crop as `crop.straighten` (the rotate handle, the Straighten slider, the Angle field)
|
||||
//! and `crop.set {angle}`.
|
||||
|
||||
use lightcraft_geom::{CropGeometry, Rect};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn geometry(s: &Session) -> CropGeometry {
|
||||
s.develop_of(s.active().unwrap()).unwrap().crop.geometry
|
||||
}
|
||||
|
||||
fn dims(s: &Session) -> (f64, f64) {
|
||||
let p = s.catalog.photo(s.active().unwrap()).unwrap();
|
||||
let (w, h) = (p.width as f64, p.height as f64);
|
||||
if p.develop.orientation.swaps_axes() { (h, w) } else { (w, h) }
|
||||
}
|
||||
|
||||
/// Run `cmd` from the current state, return the crop it leaves, and undo it.
|
||||
fn try_cmd(s: &mut Session, cmd: &str, p: Value) -> CropGeometry {
|
||||
s.execute(cmd, &p).unwrap();
|
||||
let g = geometry(s);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
g
|
||||
}
|
||||
|
||||
fn close(a: CropGeometry, b: CropGeometry) -> bool {
|
||||
let r = |g: CropGeometry| [g.rect.x0, g.rect.y0, g.rect.x1, g.rect.y1, g.angle];
|
||||
r(a).iter().zip(r(b)).all(|(x, y)| (x - y).abs() < 1e-9)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_way_of_setting_the_angle_gives_the_straighten_crop() {
|
||||
let mut s = Session::with_demo();
|
||||
let (w, h) = dims(&s);
|
||||
for aspect in [json!("original"), json!("free"), json!("1x1"), json!("16x9"), json!("4x5")] {
|
||||
s.execute("crop.aspect", &json!({"aspect": aspect})).unwrap();
|
||||
let start = geometry(&s);
|
||||
for angle in [-45.0, -30.0, -7.5, 0.5, 2.0, 15.0, 33.3] {
|
||||
let want = try_cmd(&mut s, "crop.straighten", json!({"angle": angle}));
|
||||
assert!(want.is_within_image(w, h), "{aspect} {angle}°: straighten fits");
|
||||
assert_eq!(want.angle, angle);
|
||||
for (cmd, p) in [
|
||||
("develop.set", json!({"control": "crop.angle", "value": angle})),
|
||||
("develop.set", json!({"values": {"crop.angle": angle, "light.exposure": 0.3}})),
|
||||
("crop.set", json!({"angle": angle})),
|
||||
("develop.adjust", json!({"control": "crop.angle", "delta": angle - start.angle})),
|
||||
] {
|
||||
let got = try_cmd(&mut s, cmd, p.clone());
|
||||
assert!(close(got, want), "{aspect} {angle}°: {cmd} {p} gave {got:?}, crop.straighten {want:?}");
|
||||
}
|
||||
assert!(close(geometry(&s), start), "undo restored the crop");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crop_that_still_fits_is_kept() {
|
||||
let mut s = Session::with_demo();
|
||||
s.execute("crop.set", &json!({"rect": [0.3, 0.3, 0.7, 0.7]})).unwrap();
|
||||
for angle in [-4.0, 1.0, 3.0, 6.0] {
|
||||
s.execute("develop.set", &json!({"control": "crop.angle", "value": angle})).unwrap();
|
||||
let g = geometry(&s);
|
||||
assert_eq!(g.rect, Rect::new(0.3, 0.3, 0.7, 0.7), "no needless shrink at {angle}°");
|
||||
assert_eq!(g.angle, angle);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_develop_reset_and_batch_paths_fit_too() {
|
||||
let mut s = Session::with_demo();
|
||||
let (w, h) = dims(&s);
|
||||
let id = s.active().unwrap();
|
||||
let want = try_cmd(&mut s, "crop.straighten", json!({"angle": 12.0}));
|
||||
|
||||
let got = try_cmd(&mut s, "develop.quickAdjust", json!({"control": "crop.angle", "delta": 12.0, "ids": [id.0]}));
|
||||
assert!(close(got, want), "quickAdjust {got:?} vs {want:?}");
|
||||
let got = try_cmd(&mut s, "develop.set", json!({"control": "crop.angle", "value": 12.0, "ids": [id.0]}));
|
||||
assert!(close(got, want), "develop.set ids {got:?} vs {want:?}");
|
||||
|
||||
// back to 0°: the crop that fitted at 12° fits at 0° too and is kept
|
||||
s.execute("develop.set", &json!({"control": "crop.angle", "value": 12.0})).unwrap();
|
||||
s.execute("develop.resetControl", &json!({"control": "crop.angle"})).unwrap();
|
||||
let g = geometry(&s);
|
||||
assert_eq!((g.rect, g.angle), (want.rect, 0.0));
|
||||
assert!(g.is_within_image(w, h));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_straighten_slider_drag_fits_live_and_is_one_undo_step() {
|
||||
let mut s = Session::with_demo();
|
||||
let (w, h) = dims(&s);
|
||||
let start = geometry(&s);
|
||||
let undo = s.undo.len();
|
||||
s.execute("develop.beginInteraction", &json!({"label": "Straighten"})).unwrap();
|
||||
for angle in [1.0, 4.0, 9.0, 15.0] {
|
||||
s.execute("develop.set", &json!({"control": "crop.angle", "value": angle})).unwrap();
|
||||
assert!(geometry(&s).is_within_image(w, h), "fitted at every step ({angle}°)");
|
||||
}
|
||||
s.execute("develop.endInteraction", &json!({})).unwrap();
|
||||
assert_eq!(s.undo.len(), undo + 1, "the drag is one undo step");
|
||||
assert_eq!(geometry(&s).angle, 15.0);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(close(geometry(&s), start));
|
||||
}
|
||||
@@ -720,6 +720,52 @@ fn a_photo_waits_for_its_turn_unless_pictures_are_not_made_on_their_own_or_have_
|
||||
let _ = std::fs::remove_dir_all(&x.dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picture_that_appears_while_its_job_runs_is_ready_only_when_the_job_reports() {
|
||||
// #719: the worker saves the picture, then tears down its thread pool before it reports. A pump in that window
|
||||
// used to find the file and mark the photo Ready while the session still counted the job as running: Ready and
|
||||
// busy at once, and the activity row stayed up for a photo that said it was done.
|
||||
use std::sync::{Mutex, mpsc};
|
||||
use std::time::Duration;
|
||||
let mut x = setup("ready-while-running", true);
|
||||
let id = photo(&x.s);
|
||||
set_amount(&mut x.s, 80.0);
|
||||
// the job loads the model afresh and waits in the loader until it is released: a worker that is at work
|
||||
*x.s.denoise.active.as_ref().unwrap().model.lock().unwrap() = None;
|
||||
let (entered_tx, entered_rx) = mpsc::channel();
|
||||
let (release_tx, release_rx) = mpsc::channel::<()>();
|
||||
let release_rx = Mutex::new(release_rx);
|
||||
let runs = x.runs.clone();
|
||||
x.s.denoise.loader = Arc::new(move |_, _| {
|
||||
entered_tx.send(()).map_err(|e| e.to_string())?;
|
||||
release_rx.lock().unwrap().recv_timeout(Duration::from_secs(10)).map_err(|e| e.to_string())?;
|
||||
Ok(Arc::new(Dim { runs: runs.clone() }) as Arc<dyn Model>)
|
||||
});
|
||||
x.s.execute("denoise.pump", &json!({"pace": "full"})).unwrap();
|
||||
entered_rx.recv_timeout(Duration::from_secs(10)).unwrap();
|
||||
assert!(matches!(x.s.denoise_photo_state(id), PhotoState::Running { .. }));
|
||||
assert!(x.s.denoise_busy());
|
||||
// the picture lands on disk before the worker says so (as it does between its save and its report)
|
||||
let (product, key) = x.s.denoise_running_product().unwrap();
|
||||
let mut rgb = lightcraft_raster::Rgb32f::new(192, 160);
|
||||
rgb.data.fill([0.25, 0.25, 0.25]);
|
||||
lightcraft_denoise::product::write(&product, &rgb, &key).unwrap();
|
||||
assert!(lightcraft_denoise::product::is_current(&product, &key));
|
||||
x.s.denoise.touch(); // the index is also rebuilt from the folder in this window
|
||||
let r = x.s.execute("denoise.pump", &json!({"pace": "full"})).unwrap();
|
||||
assert!(matches!(x.s.denoise_photo_state(id), PhotoState::Running { .. }), "the job reports its own picture");
|
||||
assert_eq!(r["ready"], 0, "{r}");
|
||||
assert!(x.s.denoise_busy());
|
||||
// the worker reports: ready and idle in the same pump
|
||||
release_tx.send(()).unwrap();
|
||||
let r = pump_until_ready(&mut x.s, 1);
|
||||
assert_eq!(x.s.denoise_photo_state(id), PhotoState::Ready);
|
||||
assert_eq!(r["running"], Value::Null, "{r}");
|
||||
assert!(!x.s.denoise_busy());
|
||||
drop(x.s);
|
||||
let _ = std::fs::remove_dir_all(x.dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_denoise_switch_validates_arguments_and_accepts_explicit_targets_without_selection() {
|
||||
let mut x = setup("toggle", false);
|
||||
|
||||
@@ -177,7 +177,13 @@ fn prepared_exports_run_on_another_thread() {
|
||||
|
||||
/// Issue #78: a GPU render whose work never ran (a driver that drops a submission without an
|
||||
/// error) gave an all-black export. The export must come from the CPU instead — the same
|
||||
/// image — with the reason recorded.
|
||||
/// image — with the reason recorded on the export itself.
|
||||
///
|
||||
/// Issue #675: this test runs beside every other engine test, many of them rendering on the GPU.
|
||||
/// It asks only about its own export (`Exported::gpu_fallback`), never the process-wide
|
||||
/// `lightcraft_gpu::last_fallback` another thread's render overwrites, and the simulated failure
|
||||
/// switches the GPU off for this thread alone, so neither the fault nor `reset_failures` reaches
|
||||
/// the other tests.
|
||||
#[test]
|
||||
fn export_falls_back_to_the_cpu_when_gpu_work_is_lost() {
|
||||
let mut s = Session::with_demo();
|
||||
@@ -192,18 +198,26 @@ fn export_falls_back_to_the_cpu_when_gpu_work_is_lost() {
|
||||
let gpu = lightcraft_gpu::available();
|
||||
let healthy = mean(&export_photo(&mut s, id, &o, 1).unwrap().bytes);
|
||||
lightcraft_gpu::inject_fault(lightcraft_gpu::Fault::DropWork);
|
||||
let faulted = export_photo(&mut s, id, &o, 1).unwrap().bytes;
|
||||
let faulted = export_photo(&mut s, id, &o, 1).unwrap();
|
||||
if gpu {
|
||||
let why = lightcraft_gpu::last_fallback().unwrap_or_default();
|
||||
let why = faulted.gpu_fallback.clone().unwrap_or_default();
|
||||
assert!(why.contains("incomplete"), "{why}");
|
||||
// the GPU is off for the process now: this export renders on the CPU
|
||||
// the GPU is off for this thread now: the next export is not even tried on it
|
||||
assert!(!lightcraft_gpu::available());
|
||||
assert!(lightcraft_gpu::unavailable_reason().unwrap_or_default().contains("incomplete"), "{:?}", lightcraft_gpu::unavailable_reason());
|
||||
// the thread's own `inject_fault` stopped only its own GPU use: the others still have it
|
||||
let elsewhere = std::thread::spawn(|| (lightcraft_gpu::available(), lightcraft_gpu::unavailable_reason())).join().unwrap();
|
||||
assert_eq!(elsewhere, (true, None), "a simulated failure must not switch the GPU off for the other threads");
|
||||
} else {
|
||||
assert_eq!(faulted.gpu_fallback, None);
|
||||
}
|
||||
let cpu = export_photo(&mut s, id, &o, 1).unwrap().bytes;
|
||||
let cpu = export_photo(&mut s, id, &o, 1).unwrap();
|
||||
assert_eq!(cpu.gpu_fallback, None, "not tried on the GPU: no fallback to report");
|
||||
lightcraft_gpu::reset_failures();
|
||||
assert_eq!(lightcraft_gpu::available(), gpu, "reset_failures gives this thread the GPU back");
|
||||
let px = |b: &[u8]| lightcraft_codecs::decode(b, Default::default()).unwrap().image.data;
|
||||
assert!(px(&faulted) == px(&cpu), "the fallback is the CPU render");
|
||||
let m = mean(&faulted);
|
||||
assert!(px(&faulted.bytes) == px(&cpu.bytes), "the fallback is the CPU render");
|
||||
let m = mean(&faulted.bytes);
|
||||
assert!(m > 0.02, "not black: mean {m}");
|
||||
assert!((m - healthy).abs() < 0.01, "GPU {healthy} vs CPU {m}");
|
||||
}
|
||||
@@ -440,3 +454,408 @@ fn hdr_avif_export_is_pq_rec2020() {
|
||||
// a photo without an HDR edit exports as an ordinary (sRGB) AVIF
|
||||
assert!(nclx(&sdr.bytes).is_none_or(|c| c[3] != 16));
|
||||
}
|
||||
|
||||
/// "Remove location info" holds for the file-copying formats too: the sidecar and the DNG's
|
||||
/// packet carry no location, and an original that embeds GPS is refused rather than copied.
|
||||
#[test]
|
||||
fn remove_location_holds_for_original_and_dng_exports() {
|
||||
let dir = temp_dir("export-noloc");
|
||||
let gps =
|
||||
lightcraft_meta::Metadata { gps: Some(lightcraft_meta::Gps { latitude: 48.85, longitude: 2.35, altitude: None }), ..Default::default() };
|
||||
// a raw with no embedded GPS, located in the catalog
|
||||
let raw = dir.join("Raw.dng");
|
||||
std::fs::write(&raw, synthetic_dng_with(None, Default::default())).unwrap();
|
||||
// a JPEG with GPS in its EXIF
|
||||
let jpg = dir.join("Geo.jpg");
|
||||
let img = lightcraft_raster::Rgba8::from_fn(32, 24, |x, y| [(x * 8) as u8, (y * 10) as u8, 90, 255]);
|
||||
let exif = lightcraft_meta::write_exif(&gps);
|
||||
let meta = lightcraft_codecs::EncodeMeta { exif: Some(&exif), ..Default::default() };
|
||||
std::fs::write(&jpg, lightcraft_codecs::encode_jpeg(&lightcraft_codecs::EncodeImage::rgba8(&img), 90, Default::default(), &meta).unwrap())
|
||||
.unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.import", &json!({"paths": [raw.to_string_lossy(), jpg.to_string_lossy()]})).unwrap();
|
||||
let id = |s: &Session, n: &str| s.catalog.photos().find(|p| p.file_name == n).unwrap().id;
|
||||
let (rid, jid) = (id(&s, "Raw.dng"), id(&s, "Geo.jpg"));
|
||||
s.execute("photo.setMeta", &json!({"ids": [rid.0, jid.0], "gps": [48.85, 2.35], "city": "Paris"})).unwrap();
|
||||
let opts = |f: &str, remove: bool| ExportOptions::from_json(&json!({"format": f, "removeLocation": remove}));
|
||||
|
||||
// original raw: the sidecar keeps the location unless it is removed
|
||||
let side = |e: &crate::export::Exported| lightcraft_meta::parse_xmp(std::str::from_utf8(&e.sidecars[0].1).unwrap()).unwrap().metadata;
|
||||
let kept = side(&export_photo(&mut s, rid, &opts("original", false), 1).unwrap());
|
||||
assert!(kept.gps.is_some() && kept.city.is_some());
|
||||
let gone = side(&export_photo(&mut s, rid, &opts("original", true), 1).unwrap());
|
||||
assert!(gone.gps.is_none() && gone.city.is_none(), "{gone:?}");
|
||||
|
||||
// dng: the embedded packet
|
||||
let e = export_photo(&mut s, rid, &opts("dng", false), 1).unwrap();
|
||||
assert!(lightcraft_meta::extract(&e.bytes).gps.is_some());
|
||||
let e = export_photo(&mut s, rid, &opts("dng", true), 1).unwrap();
|
||||
let m = lightcraft_meta::extract(&e.bytes);
|
||||
assert!(m.gps.is_none() && m.city.is_none(), "{m:?}");
|
||||
|
||||
// original JPEG embedding GPS: copied as is normally, refused when the location must go
|
||||
let e = export_photo(&mut s, jid, &opts("original", false), 1).unwrap();
|
||||
assert!(lightcraft_meta::extract(&e.bytes).gps.is_some());
|
||||
let err = export_photo(&mut s, jid, &opts("original", true), 1).err().unwrap();
|
||||
assert!(err.contains("can't remove the location") && err.contains("export as JPEG"), "{err}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// An original raw whose own metadata holds GPS is refused under "Remove location info".
|
||||
#[test]
|
||||
fn remove_location_refuses_a_raw_that_embeds_gps() {
|
||||
let dir = temp_dir("export-noloc-raw");
|
||||
let gps = lightcraft_meta::Metadata { gps: Some(lightcraft_meta::Gps { latitude: 10.0, longitude: 20.0, altitude: None }), ..Default::default() };
|
||||
let raw = dir.join("Geo.dng");
|
||||
let bytes = synthetic_dng_with(Some(&lightcraft_meta::write_xmp(&gps, None)), Default::default());
|
||||
assert!(lightcraft_meta::extract(&bytes).gps.is_some(), "fixture embeds GPS");
|
||||
std::fs::write(&raw, bytes).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.import", &json!({"paths": [raw.to_string_lossy()]})).unwrap();
|
||||
let id = s.catalog.photos().next().unwrap().id;
|
||||
let err = export_photo(&mut s, id, &ExportOptions::from_json(&json!({"format": "original", "removeLocation": true})), 1).err().unwrap();
|
||||
assert!(err.contains("can't remove the location"), "{err}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_png_export_is_16_bit_pq_rec2020() {
|
||||
let mut s = Session::with_demo();
|
||||
let id = sunset(&mut s);
|
||||
let o = ExportOptions::from_json(&json!({"longEdge": 160, "format": "png", "hdr": true}));
|
||||
assert!(o.hdr_output());
|
||||
assert_eq!(o.effective_space(), lightcraft_pipeline::OutputSpace::Rec2020);
|
||||
// a photo without an HDR edit exports as an ordinary (sRGB, 8-bit) PNG
|
||||
let sdr = export_photo(&mut s, id, &o, 1).unwrap();
|
||||
assert!(!sdr.bytes.windows(4).any(|w| w == b"cICP"));
|
||||
assert_eq!(sdr.bytes[24], 8, "8-bit");
|
||||
s.execute("develop.hdr", &json!({"enabled": true, "maxEv": 2})).unwrap();
|
||||
let hdr = export_photo(&mut s, id, &o, 1).unwrap();
|
||||
let at = hdr.bytes.windows(4).position(|w| w == b"cICP").expect("cICP chunk");
|
||||
assert_eq!(&hdr.bytes[at + 4..at + 8], &[9, 16, 0, 1], "BT.2020, PQ, RGB, full range");
|
||||
assert_eq!(hdr.bytes[24], 16, "16-bit");
|
||||
// the highlights above SDR white are there: MaxCLL beyond the 203 cd/m² of SDR white
|
||||
let at = hdr.bytes.windows(4).position(|w| w == b"cLLI").expect("cLLI chunk");
|
||||
let max_cll = u32::from_be_bytes(hdr.bytes[at + 4..at + 8].try_into().unwrap()) as f32 / 10_000.0;
|
||||
assert!(max_cll > 250.0 && max_cll <= 4.0 * 203.0 + 1.0, "MaxCLL {max_cll}");
|
||||
// HDR output off: the HDR edit exports its SDR rendition
|
||||
let plain = export_photo(&mut s, id, &ExportOptions::from_json(&json!({"longEdge": 160, "format": "png"})), 1).unwrap();
|
||||
assert!(!plain.bytes.windows(4).any(|w| w == b"cICP"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_jxl_export_is_pq_rec2020() {
|
||||
let mut s = Session::with_demo();
|
||||
let id = sunset(&mut s);
|
||||
let o = ExportOptions::from_json(&json!({"longEdge": 160, "format": "jxl", "hdr": true, "quality": 90}));
|
||||
assert!(o.hdr_output());
|
||||
let sdr = export_photo(&mut s, id, &o, 1).unwrap();
|
||||
s.execute("develop.hdr", &json!({"enabled": true, "maxEv": 2})).unwrap();
|
||||
let hdr = export_photo(&mut s, id, &o, 1).unwrap();
|
||||
let encoding = |b: &[u8]| {
|
||||
let img = jxl_oxide::JxlImage::builder().read(std::io::Cursor::new(b)).unwrap();
|
||||
let m = &img.image_header().metadata;
|
||||
(format!("{:?}", m.colour_encoding), m.bit_depth.bits_per_sample())
|
||||
};
|
||||
let (c, bits) = encoding(&hdr.bytes);
|
||||
assert!(c.contains("Bt2100") && c.contains("Pq"), "{c}");
|
||||
assert_eq!(bits, 16);
|
||||
// a photo without an HDR edit exports as an ordinary SDR file
|
||||
let (c, _) = encoding(&sdr.bytes);
|
||||
assert!(!c.contains("Pq"), "{c}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jxl_export_writes_metadata() {
|
||||
let mut s = Session::with_demo();
|
||||
let id = sunset(&mut s);
|
||||
let o = ExportOptions::from_json(&json!({"longEdge": 120, "format": "jxl", "quality": 80}));
|
||||
let e = export_photo(&mut s, id, &o, 1).unwrap();
|
||||
let d = lightcraft_codecs::decode(&e.bytes, Default::default()).unwrap();
|
||||
assert_eq!(d.width.max(d.height), 120);
|
||||
assert!(d.xmp.as_deref().is_some_and(|x| x.contains("Golden horizon")), "title in the XMP box");
|
||||
assert!(d.exif.is_some(), "EXIF box");
|
||||
}
|
||||
|
||||
/// Issue #236: Add to Apple Photos after an export. A fake runner stands in for osascript: tests
|
||||
/// never start it, nor Photos.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod apple_photos {
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::disk_batch;
|
||||
use crate::Session;
|
||||
use crate::apple_photos::{Imports, MARKER, Output, Runner};
|
||||
use crate::export::{AfterExport, Destination, ExportOptions};
|
||||
use crate::tests_xmp::temp_dir;
|
||||
|
||||
type Calls = Arc<Mutex<Vec<Vec<String>>>>;
|
||||
|
||||
/// Records the arguments; Photos "imports" the first `keep` files (all: `usize::MAX`).
|
||||
fn fake(keep: usize) -> (Runner, Calls) {
|
||||
let calls: Calls = Arc::default();
|
||||
let c = calls.clone();
|
||||
let r: Runner = Arc::new(move |args: &[String], _| {
|
||||
c.lock().unwrap().push(args.to_vec());
|
||||
let files = args.len() - args.iter().position(|a| a == MARKER).unwrap() - 2;
|
||||
let ids: Vec<String> = (0..files.min(keep)).map(|i| format!("ID-{i}/L0/001")).collect();
|
||||
Ok(Output { status: Some(0), stdout: ids.join("\n") + "\n", stderr: String::new() })
|
||||
});
|
||||
(r, calls)
|
||||
}
|
||||
|
||||
/// The paths after the album: the files Photos was asked to import.
|
||||
fn imported_paths(args: &[String]) -> Vec<String> {
|
||||
args[args.iter().position(|a| a == MARKER).unwrap() + 2..].to_vec()
|
||||
}
|
||||
|
||||
fn export_two(s: &mut Session, tag: &str) -> Vec<Value> {
|
||||
let dir = temp_dir(tag);
|
||||
let ids: Vec<_> = s.visible().iter().copied().take(2).collect();
|
||||
let o = ExportOptions::from_json(&json!({"longEdge": 48, "naming": "{name}-{seq}"}));
|
||||
let to = Destination { dir: dir.to_string_lossy().into(), exact: None };
|
||||
let mut files = Vec::new();
|
||||
for id in ids {
|
||||
files.extend(disk_batch(s, id, &o, &to).unwrap());
|
||||
}
|
||||
files
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exported_files_go_to_photos_in_order() {
|
||||
let mut s = Session::with_demo();
|
||||
let files = export_two(&mut s, "photos-flow");
|
||||
let written: Vec<String> = files.iter().map(|f| f["path"].as_str().unwrap().to_string()).collect();
|
||||
assert_eq!(written.len(), 2);
|
||||
let (runner, calls) = fake(usize::MAX);
|
||||
let imports = Imports::default();
|
||||
let after = AfterExport::apple_photos(runner.clone(), " Trip \"2026\"\n ", &imports).unwrap();
|
||||
// a skipped file and a failed photo are not sent
|
||||
let mut results = files.clone();
|
||||
results.insert(1, json!({"skipped": "/elsewhere/x.jpg"}));
|
||||
results.push(json!({"photo": 9, "file": "y", "error": "no source"}));
|
||||
let out = after.run(&results, false).unwrap();
|
||||
assert_eq!(out["imported"], 2, "{out}");
|
||||
assert_eq!(out["requested"], 2);
|
||||
assert_eq!(out["ids"], json!(["ID-0/L0/001", "ID-1/L0/001"]));
|
||||
assert_eq!(out["album"], "Trip \"2026\"");
|
||||
assert!(out.get("warning").is_none());
|
||||
let args = calls.lock().unwrap()[0].clone();
|
||||
assert_eq!(imported_paths(&args), written, "the files written, in order, as whole arguments");
|
||||
assert!(written.iter().all(|p| std::path::Path::new(p).is_absolute()));
|
||||
assert_eq!(args[args.iter().position(|a| a == MARKER).unwrap() + 1], "Trip \"2026\"");
|
||||
// a cancelled export adds nothing
|
||||
let out = AfterExport::apple_photos(runner.clone(), "", &imports).unwrap().run(&files, true).unwrap();
|
||||
assert!(out["skipped"].as_str().unwrap().contains("cancelled"));
|
||||
assert_eq!(calls.lock().unwrap().len(), 1);
|
||||
// Photos took one of the two: said so, not an error
|
||||
let (runner, _) = fake(1);
|
||||
let out = AfterExport::apple_photos(runner, "", &Imports::default()).unwrap().run(&files, false).unwrap();
|
||||
assert_eq!((out["imported"].as_u64(), out["requested"].as_u64()), (Some(1), Some(2)));
|
||||
assert!(out["warning"].as_str().unwrap().contains("1 of 2"), "{out}");
|
||||
assert!(out["album"].is_null());
|
||||
// permission refused: the outcome says where to allow it; the files stay written
|
||||
let denied: Runner = Arc::new(|_: &[String], _| {
|
||||
Ok(Output {
|
||||
status: Some(1),
|
||||
stdout: String::new(),
|
||||
stderr: "execution error: Not authorized to send Apple events to Photos. (-1743)".into(),
|
||||
})
|
||||
});
|
||||
let out = AfterExport::apple_photos(denied, "", &Imports::default()).unwrap().run(&files, false).unwrap();
|
||||
assert!(out["error"].as_str().unwrap().contains("Privacy & Security › Automation"), "{out}");
|
||||
assert!(written.iter().all(|p| std::path::Path::new(p).is_file()));
|
||||
// nothing asked: nothing done
|
||||
assert!(AfterExport::default().is_none());
|
||||
assert_eq!(AfterExport::default().run(&files, false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn photos_is_refused_before_anything_is_written_when_it_cannot_be_reached() {
|
||||
let mut s = Session::with_demo();
|
||||
assert!(s.apple_photos.is_none(), "only with_fs installs the runner");
|
||||
let on = ExportOptions { add_to_photos: true, ..Default::default() };
|
||||
let e = s.after_export(&on).err().unwrap();
|
||||
if cfg!(target_os = "macos") {
|
||||
assert!(e.contains("isn't connected in this session"), "{e}");
|
||||
} else {
|
||||
assert_eq!(e, "Apple Photos is only available on macOS");
|
||||
}
|
||||
assert!(s.after_export(&ExportOptions::default()).unwrap().is_none());
|
||||
// with a runner: macOS adds, other platforms still refuse
|
||||
let (runner, calls) = fake(usize::MAX);
|
||||
s.apple_photos = Some(runner);
|
||||
let after = s.after_export(&on);
|
||||
assert_eq!(after.is_ok(), cfg!(target_os = "macos"), "{:?}", after.as_ref().err());
|
||||
match after {
|
||||
Ok(after) => {
|
||||
let files = export_two(&mut s, "photos-session");
|
||||
assert_eq!(after.run(&files, false).unwrap()["imported"], 2);
|
||||
assert_eq!(calls.lock().unwrap().len(), 1);
|
||||
}
|
||||
Err(e) => assert!(e.contains("only available on macOS"), "{e}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_add_to_photos_command_needs_a_runner_and_checks_its_input() {
|
||||
let mut s = Session::with_demo();
|
||||
let files = export_two(&mut s, "photos-cmd");
|
||||
let paths: Vec<String> = files.iter().map(|f| f["path"].as_str().unwrap().to_string()).collect();
|
||||
let info = |s: &Session| s.commands().into_iter().find(|c| c.id == "export.addToPhotos").unwrap();
|
||||
// no runner: listed, disabled, with the reason
|
||||
let i = info(&s);
|
||||
assert!(!i.enabled);
|
||||
let why = i.disabled_reason.unwrap();
|
||||
assert!(why.contains(if cfg!(target_os = "macos") { "isn't connected" } else { "only available on macOS" }), "{why}");
|
||||
let e = s.execute("export.addToPhotos", &json!({"paths": paths})).unwrap_err().to_string();
|
||||
assert!(e.contains("Apple Photos"), "{e}");
|
||||
let (runner, calls) = fake(usize::MAX);
|
||||
s.apple_photos = Some(runner);
|
||||
assert!(info(&s).enabled);
|
||||
// no window: it waits for Photos by default
|
||||
let r = s.execute("export.addToPhotos", &json!({"paths": paths, "album": "Läufe/日本 \\ \"q\""})).unwrap();
|
||||
assert_eq!((r["imported"].as_u64(), r["running"].as_bool(), r["job"].as_u64()), (Some(2), Some(false), Some(1)), "{r}");
|
||||
assert_eq!(r["album"], "Läufe/日本 \\ \"q\"");
|
||||
assert_eq!(imported_paths(&calls.lock().unwrap()[0]), paths);
|
||||
// bad input never reaches the runner (paths from the temp folder: absolute on every platform)
|
||||
let missing = std::env::temp_dir().join("lc-photos-nonexistent").join("a.jpg").to_string_lossy().into_owned();
|
||||
for (p, msg) in [
|
||||
(json!({}), "missing `paths`"),
|
||||
(json!({"paths": "a.jpg"}), "missing `paths`"),
|
||||
(json!({"paths": [1]}), "array of file paths"),
|
||||
(json!({"paths": []}), "no files"),
|
||||
(json!({"paths": ["relative.jpg"]}), "absolute"),
|
||||
(json!({"paths": [missing]}), "no such file"),
|
||||
(json!({"paths": paths, "album": 3}), "`album` must be a string"),
|
||||
(json!({"paths": paths, "wait": "yes"}), "`wait` must be true or false"),
|
||||
(json!({"paths": paths, "albm": "x"}), "unknown parameter `albm`"),
|
||||
] {
|
||||
let e = s.execute("export.addToPhotos", &p).unwrap_err().to_string();
|
||||
assert!(e.contains(msg), "{p}: {e}");
|
||||
}
|
||||
assert_eq!(calls.lock().unwrap().len(), 1);
|
||||
// not journaled: replaying actions must not import again
|
||||
assert!(!s.journal.iter().any(|(id, _)| id == "export.addToPhotos"));
|
||||
}
|
||||
|
||||
/// The desktop app answers control requests on its UI thread: `export.addToPhotos` must not
|
||||
/// wait there for Photos (minutes, or a permission prompt). It returns the running job; the
|
||||
/// outcome comes from `export.photosImports`; a second import, or an export asking for Photos,
|
||||
/// is refused until it ends, so a retry never adds the files twice.
|
||||
#[test]
|
||||
fn a_slow_photos_import_runs_in_the_background() {
|
||||
let mut s = Session::with_demo();
|
||||
let files = export_two(&mut s, "photos-slow");
|
||||
let paths: Vec<String> = files.iter().map(|f| f["path"].as_str().unwrap().to_string()).collect();
|
||||
let (go, wait) = std::sync::mpsc::channel::<()>();
|
||||
let wait = Mutex::new(wait);
|
||||
let runner: Runner = Arc::new(move |args: &[String], _| {
|
||||
// a regression (waiting on the caller's thread) fails after 20 s instead of hanging
|
||||
let _ = wait.lock().unwrap().recv_timeout(std::time::Duration::from_secs(20));
|
||||
let n = args.len() - args.iter().position(|a| a == MARKER).unwrap() - 2;
|
||||
Ok(Output { status: Some(0), stdout: (0..n).map(|i| format!("ID-{i}\n")).collect(), stderr: String::new() })
|
||||
});
|
||||
s.apple_photos = Some(runner);
|
||||
s.apple_photos_background = true; // as in the desktop app
|
||||
let t0 = std::time::Instant::now();
|
||||
let r = s.execute("export.addToPhotos", &json!({"paths": paths, "album": "Slow"})).unwrap();
|
||||
assert!(t0.elapsed() < std::time::Duration::from_secs(5), "answered without waiting for Photos");
|
||||
assert_eq!(r, json!({"job": 1, "running": true, "requested": 2, "album": "Slow"}));
|
||||
let status = |s: &mut Session| s.execute("export.photosImports", &json!({"job": 1})).unwrap();
|
||||
assert_eq!(status(&mut s)["running"], true);
|
||||
// a retry is refused, and says why
|
||||
let e = s.execute("export.addToPhotos", &json!({"paths": paths})).unwrap_err().to_string();
|
||||
assert!(e.contains("still adding 2 file(s) from an earlier request (import 1)"), "{e}");
|
||||
if cfg!(target_os = "macos") {
|
||||
let on = ExportOptions { add_to_photos: true, ..Default::default() };
|
||||
assert!(s.after_export(&on).err().unwrap().contains("still adding"), "refused before writing anything");
|
||||
}
|
||||
go.send(()).unwrap();
|
||||
while status(&mut s)["running"] == true {
|
||||
assert!(t0.elapsed() < std::time::Duration::from_secs(20), "the import finishes");
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
let done = status(&mut s);
|
||||
assert_eq!((done["imported"].as_u64(), done["album"].as_str()), (Some(2), Some("Slow")), "{done}");
|
||||
let all = s.execute("export.photosImports", &json!({})).unwrap();
|
||||
assert_eq!(all["imports"].as_array().map(Vec::len), Some(1));
|
||||
assert!(s.execute("export.photosImports", &json!({"job": 7})).unwrap_err().to_string().contains("no import 7"));
|
||||
// the window's thread never waits for Photos: `wait: true` is refused here, before any import
|
||||
let e = s.execute("export.addToPhotos", &json!({"paths": paths, "wait": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("isn't available in the desktop app"), "{e}");
|
||||
// finished: the next one may start
|
||||
go.send(()).unwrap();
|
||||
assert_eq!(s.execute("export.addToPhotos", &json!({"paths": paths})).unwrap()["job"], 2);
|
||||
}
|
||||
|
||||
/// Review of #236: an export adding to Photos reserves the import before it writes its files.
|
||||
/// While it renders, `export.addToPhotos` is refused, naming the export's job, instead of
|
||||
/// taking Photos and leaving the export to fail its Photos step after writing.
|
||||
#[test]
|
||||
fn an_export_holds_photos_from_before_it_writes() {
|
||||
let mut s = Session::with_demo();
|
||||
let (runner, calls) = fake(usize::MAX);
|
||||
s.apple_photos = Some(runner.clone());
|
||||
let elsewhere = export_two(&mut s, "photos-hold-other");
|
||||
let other: Vec<String> = elsewhere.iter().map(|f| f["path"].as_str().unwrap().to_string()).collect();
|
||||
// what Session::after_export does on macOS (it refuses other platforms first)
|
||||
let after = AfterExport::apple_photos(runner, "Held", &s.apple_photos_imports).unwrap();
|
||||
let job = s.execute("export.photosImports", &json!({"job": 1})).unwrap();
|
||||
assert_eq!((job["running"].as_bool(), job["exporting"].as_bool()), (Some(true), Some(true)), "{job}");
|
||||
let e = s.execute("export.addToPhotos", &json!({"paths": other})).unwrap_err().to_string();
|
||||
assert!(e.contains("An export is about to add its files to Apple Photos (import 1)"), "{e}");
|
||||
if cfg!(target_os = "macos") {
|
||||
let on = ExportOptions { add_to_photos: true, ..Default::default() };
|
||||
assert!(s.after_export(&on).err().unwrap().contains("(import 1)"), "a second export is refused before writing");
|
||||
}
|
||||
assert!(calls.lock().unwrap().is_empty());
|
||||
// the export writes its files and hands them over: imported under its own job
|
||||
let files = export_two(&mut s, "photos-hold");
|
||||
let out = after.run(&files, false).unwrap();
|
||||
assert_eq!((out["job"].as_u64(), out["imported"].as_u64(), out["album"].as_str()), (Some(1), Some(2), Some("Held")), "{out}");
|
||||
// then the slot is free
|
||||
assert_eq!(s.execute("export.addToPhotos", &json!({"paths": other})).unwrap()["job"], 2);
|
||||
// an export that fails (its AfterExport dropped) frees it too
|
||||
drop(AfterExport::apple_photos(fake(usize::MAX).0, "", &s.apple_photos_imports).unwrap());
|
||||
assert!(s.apple_photos_imports.running().is_none());
|
||||
assert_eq!(s.execute("export.addToPhotos", &json!({"paths": other})).unwrap()["job"], 4);
|
||||
}
|
||||
|
||||
/// Review of #236: a runner that panics while the command or an export waits for it is that
|
||||
/// import's error; the slot is freed, so the next import isn't refused as "still running"
|
||||
/// and a process ending doesn't wait for it.
|
||||
#[test]
|
||||
fn a_panicking_runner_never_holds_photos() {
|
||||
let mut s = Session::with_demo();
|
||||
let files = export_two(&mut s, "photos-panic");
|
||||
let paths: Vec<String> = files.iter().map(|f| f["path"].as_str().unwrap().to_string()).collect();
|
||||
s.apple_photos = Some(Arc::new(|_: &[String], _| panic!("runner exploded")));
|
||||
// the command (no window: it waits)
|
||||
let e = s.execute("export.addToPhotos", &json!({"paths": paths})).unwrap_err().to_string();
|
||||
assert!(e.contains("stopped unexpectedly"), "{e}");
|
||||
assert!(s.apple_photos_imports.running().is_none());
|
||||
assert_eq!(s.execute("export.photosImports", &json!({"job": 1})).unwrap()["running"], false);
|
||||
// an export's reservation
|
||||
let runner = s.apple_photos.clone().unwrap();
|
||||
let out = AfterExport::apple_photos(runner, "", &s.apple_photos_imports).unwrap().run(&files, false).unwrap();
|
||||
assert!(out["error"].as_str().unwrap().contains("stopped unexpectedly"), "{out}");
|
||||
assert!(s.apple_photos_imports.running().is_none());
|
||||
assert!(!s.wait_for_photos_imports(), "nothing left to wait for");
|
||||
// and the next import runs
|
||||
let (good, _) = fake(usize::MAX);
|
||||
s.apple_photos = Some(good);
|
||||
assert_eq!(s.execute("export.addToPhotos", &json!({"paths": paths})).unwrap()["imported"], 2);
|
||||
}
|
||||
|
||||
/// The desktop app, lightcraft-cli and the MCP server get the real runner from `with_fs`, on
|
||||
/// macOS only. (Installed, never run here.)
|
||||
#[test]
|
||||
fn with_fs_installs_the_runner_on_macos_only() {
|
||||
let s = Session::new().with_fs();
|
||||
assert_eq!(s.apple_photos.is_some(), cfg!(target_os = "macos"));
|
||||
assert_eq!(s.commands().into_iter().find(|c| c.id == "export.addToPhotos").unwrap().enabled, cfg!(target_os = "macos"));
|
||||
}
|
||||
}
|
||||
@@ -584,3 +584,32 @@ fn the_users_own_catalog_adds_models_with_the_same_download_and_install() {
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// Issue #678: on means something only with a model to run. Turning recognition on with no chosen
|
||||
/// (installed) recogniser is refused; installing one chooses it and switches recognition on, after
|
||||
/// which the switch works both ways; removing the last recogniser switches it off again.
|
||||
#[test]
|
||||
fn recognition_cannot_be_turned_on_without_a_chosen_model() {
|
||||
let d = temp("enable-needs-model");
|
||||
let mut s = session(&d);
|
||||
for p in [json!({"enabled": true}), json!({})] {
|
||||
let e = s.execute("faces.enable", &p).unwrap_err().to_string();
|
||||
assert!(e.contains("recognition model"), "{p}: {e}");
|
||||
}
|
||||
assert_eq!(s.execute("faces.enable", &json!({"enabled": false})).unwrap()["enabled"], false, "off is always allowed");
|
||||
assert_eq!(s.execute("faces.models.list", &json!({})).unwrap()["enabled"], false);
|
||||
let f = model_file(&d, "m.onnx", 64);
|
||||
let r = s.execute("faces.models.install", &json!({"path": f, "acknowledged": true})).unwrap();
|
||||
let id = r["installed"]["id"].as_str().unwrap().to_string();
|
||||
assert_eq!(r["active"]["enabled"], true, "installing chooses the model and switches recognition on: {r}");
|
||||
assert_eq!(s.execute("faces.enable", &json!({"enabled": false})).unwrap()["enabled"], false);
|
||||
assert_eq!(s.execute("faces.enable", &json!({"enabled": true})).unwrap()["enabled"], true);
|
||||
assert_eq!(s.execute("faces.enable", &json!({})).unwrap()["enabled"], false, "toggles when omitted");
|
||||
assert_eq!(s.execute("faces.enable", &json!({})).unwrap()["enabled"], true);
|
||||
// the last recogniser goes: nothing is chosen, so recognition is off and stays off
|
||||
assert_eq!(s.execute("faces.models.remove", &json!({"id": id})).unwrap()["embedder"], Value::Null);
|
||||
let l = s.execute("faces.models.list", &json!({})).unwrap();
|
||||
assert_eq!((l["enabled"].clone(), l["embedder"].clone()), (json!(false), Value::Null), "{l}");
|
||||
assert!(s.execute("faces.enable", &json!({"enabled": true})).is_err());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
//! Filesystem history and retained XMP repair regressions.
|
||||
|
||||
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Selection, Session};
|
||||
|
||||
fn select(s: &mut Session, ids: &[u64], active: u64) {
|
||||
s.execute("library.select", &json!({"ids": ids, "active": active})).unwrap();
|
||||
}
|
||||
|
||||
fn memory_library(store: &lightcraft_catalog::MemStore) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.open_library_in(
|
||||
crate::library::LibraryStores {
|
||||
dir: "history-regression".into(),
|
||||
catalog: Box::new(store.clone()),
|
||||
files: Box::new(lightcraft_catalog::MemStore::new()),
|
||||
on_disk: false,
|
||||
},
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn file_history_step(s: &mut Session, fs: &dyn crate::rename::MoveFs, redo: bool) -> crate::Result<serde_json::Value> {
|
||||
s.execute_fn(if redo { "edit.redo" } else { "edit.undo" }, |s| {
|
||||
let label = if redo { s.redo_step_with(fs)? } else { s.undo_step_with(fs)? };
|
||||
Ok(json!({"label": label}))
|
||||
})
|
||||
}
|
||||
|
||||
fn assert_file_catalog_and_journal(s: &Session, fs: &crate::rename::tests::FakeFs, store: &lightcraft_catalog::MemStore) {
|
||||
let files = fs.listing();
|
||||
for photo in s.catalog.photos() {
|
||||
let Source::File { path } = &photo.source else { panic!("synthetic file fixture") };
|
||||
assert!(files.iter().any(|(file, _)| file == path), "photo {} points to missing {path}: {files:?}", photo.id.0);
|
||||
assert_eq!(std::path::Path::new(path).file_name().unwrap().to_string_lossy(), photo.file_name.as_str());
|
||||
}
|
||||
let replayed = memory_library(store);
|
||||
assert_eq!(replayed.catalog.to_snapshot(), s.catalog.to_snapshot());
|
||||
}
|
||||
|
||||
fn sidecar_history_fixture(redo: bool) -> (Session, crate::rename::tests::FakeFs, lightcraft_catalog::MemStore) {
|
||||
let store = lightcraft_catalog::MemStore::new();
|
||||
let mut s = memory_library(&store);
|
||||
let fs = crate::rename::tests::FakeFs::new(
|
||||
false,
|
||||
&[
|
||||
("/p/a.jpg", "Photo"),
|
||||
("/p/a.XMP", "Upper stem edits"),
|
||||
("/p/a.xmp", "Lower stem edits"),
|
||||
("/p/a.jpg.XMP", "Upper full-name edits"),
|
||||
("/p/a.jpg.xmp", "Lower full-name edits"),
|
||||
("/p/other.jpg", "Other"),
|
||||
],
|
||||
);
|
||||
for (i, path) in ["/p/a.jpg", "/p/other.jpg"].iter().enumerate() {
|
||||
let name = std::path::Path::new(path).file_name().unwrap().to_string_lossy();
|
||||
let photo = Photo::new(PhotoId(i as u64 + 1), Source::File { path: (*path).into() }, &name, "JPEG", 16, 16, "");
|
||||
s.commit("Synthetic File", Op::AddPhoto { photo: Box::new(photo) }).unwrap();
|
||||
}
|
||||
s.persist().unwrap();
|
||||
s.undo.clear();
|
||||
select(&mut s, &[1], 1);
|
||||
let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "Trip", 1);
|
||||
s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).unwrap();
|
||||
if redo {
|
||||
file_history_step(&mut s, &fs, false).unwrap();
|
||||
}
|
||||
select(&mut s, &[2], 2);
|
||||
(s, fs, store)
|
||||
}
|
||||
|
||||
fn sidecar_history_entry(s: &Session, redo: bool) -> &crate::UndoEntry {
|
||||
if redo { s.redo.last().unwrap() } else { s.undo.last().unwrap() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merged_renames_coalesce_physical_moves_and_keep_retry_inverses_and_journal() {
|
||||
use crate::rename::tests::FakeFs;
|
||||
use crate::sidecar::{SidecarNaming, sidecar_path};
|
||||
|
||||
let store = lightcraft_catalog::MemStore::new();
|
||||
let mut s = memory_library(&store);
|
||||
let fs = FakeFs::new(false, &[("/p/a.jpg", "Photo"), ("/p/a.xmp", "Edits"), ("/p/other.jpg", "Other")]);
|
||||
for (id, path) in [(1, "/p/a.jpg"), (2, "/p/other.jpg")] {
|
||||
let name = std::path::Path::new(path).file_name().unwrap().to_string_lossy();
|
||||
let photo = Photo::new(PhotoId(id), Source::File { path: path.into() }, &name, "JPEG", 16, 16, "");
|
||||
s.commit("Synthetic File", Op::AddPhoto { photo: Box::new(photo) }).unwrap();
|
||||
}
|
||||
select(&mut s, &[1], 1);
|
||||
let copy = s.execute("photo.virtualCopy", &json!({})).unwrap()["ids"][0].as_u64().unwrap();
|
||||
s.undo.clear();
|
||||
let before = fs.listing();
|
||||
for name in ["middle", "final"] {
|
||||
let plans = s.plan_rename_with(&fs, &[PhotoId(1)], name, 1);
|
||||
s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).unwrap();
|
||||
assert_file_catalog_and_journal(&s, &fs, &store);
|
||||
}
|
||||
let renamed = fs.listing();
|
||||
s.merge_undo(2, "Rename Twice");
|
||||
assert_eq!(s.undo.len(), 1);
|
||||
assert_eq!(s.file_moves(&s.undo.last().unwrap().op), vec![("/p/final.jpg".into(), "/p/a.jpg".into())]);
|
||||
// First exercise the ordinary merged step, before any corrective paths exist.
|
||||
for redo in [false, true] {
|
||||
file_history_step(&mut s, &fs, redo).unwrap();
|
||||
assert_eq!(fs.listing(), if redo { renamed.clone() } else { before.clone() });
|
||||
assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, s.catalog.photo(PhotoId(copy)).unwrap().source);
|
||||
assert_file_catalog_and_journal(&s, &fs, &store);
|
||||
}
|
||||
for redo in [false, true, false] {
|
||||
select(&mut s, &[2], 2);
|
||||
let entry = if redo { s.redo.last().unwrap() } else { s.undo.last().unwrap() };
|
||||
let moves = s.file_moves(&entry.op);
|
||||
assert_eq!(moves.len(), 1, "virtual copies must share the final physical move");
|
||||
let (from, to) = &moves[0];
|
||||
let sidecar = sidecar_path(from, SidecarNaming::Stem).to_string_lossy().into_owned();
|
||||
*fs.fail.borrow_mut() = vec![sidecar, to.clone()];
|
||||
let history = (s.undo.len(), s.redo.len());
|
||||
assert!(file_history_step(&mut s, &fs, redo).is_err());
|
||||
assert_eq!((s.undo.len(), s.redo.len()), history);
|
||||
assert_eq!(s.selection, Selection::single(PhotoId(2)));
|
||||
assert_file_catalog_and_journal(&s, &fs, &store);
|
||||
fs.fail.borrow_mut().clear();
|
||||
file_history_step(&mut s, &fs, redo).unwrap();
|
||||
assert_eq!(fs.listing(), if redo { renamed.clone() } else { before.clone() });
|
||||
assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, s.catalog.photo(PhotoId(copy)).unwrap().source);
|
||||
assert_file_catalog_and_journal(&s, &fs, &store);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_file_history_failures_retry_without_losing_either_inverse_or_journal() {
|
||||
use crate::rename::tests::FakeFs;
|
||||
|
||||
for redo in [false, true] {
|
||||
let store = lightcraft_catalog::MemStore::new();
|
||||
let mut s = memory_library(&store);
|
||||
let fs = FakeFs::new(
|
||||
false,
|
||||
&[
|
||||
("/p/a.jpg", "A"),
|
||||
("/p/a.xmp", "A edits"),
|
||||
("/p/b.jpg", "B"),
|
||||
("/p/b.xmp", "B edits"),
|
||||
("/p/c.jpg", "C"),
|
||||
("/p/c.xmp", "C edits"),
|
||||
("/p/other.jpg", "Other"),
|
||||
],
|
||||
);
|
||||
let ops = ["/p/a.jpg", "/p/b.jpg", "/p/c.jpg", "/p/other.jpg"]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, path)| {
|
||||
let name = std::path::Path::new(path).file_name().unwrap().to_string_lossy();
|
||||
let photo = Photo::new(PhotoId(i as u64 + 1), Source::File { path: (*path).into() }, &name, "JPEG", 16, 16, "");
|
||||
Op::AddPhoto { photo: Box::new(photo) }
|
||||
})
|
||||
.collect();
|
||||
s.commit("Synthetic Files", Op::Batch { ops }).unwrap();
|
||||
// A virtual copy shares B's physical file and must follow every partial correction too.
|
||||
let mut copy = (**s.catalog.photo(PhotoId(2)).unwrap()).clone();
|
||||
copy.id = PhotoId(5);
|
||||
copy.copy_of = Some(PhotoId(2));
|
||||
s.commit("Synthetic Copy", Op::AddPhoto { photo: Box::new(copy) }).unwrap();
|
||||
s.persist().unwrap();
|
||||
s.undo.clear();
|
||||
select(&mut s, &[1, 2, 3], 1);
|
||||
let before = fs.listing();
|
||||
let plans = s.plan_rename_with(&fs, &[PhotoId(1), PhotoId(2), PhotoId(3)], "Trip-{seq}", 1);
|
||||
s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).unwrap();
|
||||
let renamed = fs.listing();
|
||||
if redo {
|
||||
file_history_step(&mut s, &fs, false).unwrap();
|
||||
}
|
||||
select(&mut s, &[4], 4);
|
||||
// Two bounded failures leave a different file moved on each attempt. A subsequent
|
||||
// failure with no moves must keep those corrections; a final retry round-trips them.
|
||||
for attempt in 0..2 {
|
||||
let entry = if redo { s.redo.last().unwrap() } else { s.undo.last().unwrap() };
|
||||
let moves = s.file_moves(&entry.op);
|
||||
assert_eq!(moves.len(), 3 - attempt);
|
||||
*fs.fail.borrow_mut() = vec![moves[1].0.clone(), moves[0].1.clone()];
|
||||
let history = (s.undo.len(), s.redo.len());
|
||||
let error = file_history_step(&mut s, &fs, redo).unwrap_err().to_string();
|
||||
assert!(error.contains("could not be moved back"), "{error}");
|
||||
assert_eq!((s.undo.len(), s.redo.len()), history);
|
||||
assert_eq!(s.selection, Selection::single(PhotoId(4)));
|
||||
assert_file_catalog_and_journal(&s, &fs, &store);
|
||||
}
|
||||
let entry = if redo { s.redo.last().unwrap() } else { s.undo.last().unwrap() };
|
||||
let moves = s.file_moves(&entry.op);
|
||||
assert_eq!(moves.len(), 1);
|
||||
*fs.fail.borrow_mut() = vec![moves[0].0.clone()];
|
||||
let unchanged = fs.listing();
|
||||
let history = (s.undo.len(), s.redo.len());
|
||||
assert!(file_history_step(&mut s, &fs, redo).is_err());
|
||||
assert_eq!(fs.listing(), unchanged);
|
||||
assert_eq!((s.undo.len(), s.redo.len()), history);
|
||||
assert_eq!(s.selection, Selection::single(PhotoId(4)));
|
||||
assert_file_catalog_and_journal(&s, &fs, &store);
|
||||
fs.fail.borrow_mut().clear();
|
||||
file_history_step(&mut s, &fs, redo).unwrap();
|
||||
assert_eq!(fs.listing(), if redo { renamed.clone() } else { before.clone() });
|
||||
assert_file_catalog_and_journal(&s, &fs, &store);
|
||||
file_history_step(&mut s, &fs, !redo).unwrap();
|
||||
assert_eq!(fs.listing(), if redo { before.clone() } else { renamed.clone() });
|
||||
assert_file_catalog_and_journal(&s, &fs, &store);
|
||||
file_history_step(&mut s, &fs, redo).unwrap();
|
||||
assert_eq!(fs.listing(), if redo { renamed } else { before });
|
||||
assert_file_catalog_and_journal(&s, &fs, &store);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_retries_both_sidecar_styles_after_following_a_photo_with_failed_rollback() {
|
||||
use crate::rename::MoveFs;
|
||||
use crate::sidecar::{SidecarNaming, sidecar_path};
|
||||
|
||||
for redo in [false, true] {
|
||||
for naming in [SidecarNaming::Stem, SidecarNaming::Full] {
|
||||
let (mut s, fs, store) = sidecar_history_fixture(redo);
|
||||
let moves = s.file_moves(&sidecar_history_entry(&s, redo).op);
|
||||
assert_eq!(moves.len(), 1);
|
||||
let (from, to) = &moves[0];
|
||||
let from = from.clone();
|
||||
let to = to.clone();
|
||||
let before = fs.listing();
|
||||
let failing = sidecar_path(&from, naming).with_extension("XMP");
|
||||
*fs.fail.borrow_mut() = vec![failing.to_string_lossy().into_owned(), to.clone()];
|
||||
let history = (s.undo.len(), s.redo.len());
|
||||
let error = file_history_step(&mut s, &fs, redo).unwrap_err().to_string();
|
||||
assert!(error.contains("could not be moved back"), "{error}");
|
||||
assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: to.clone() });
|
||||
assert!(s.file_moves(&sidecar_history_entry(&s, redo).op).is_empty(), "the photo already followed its destination");
|
||||
let expected_pending = if naming == SidecarNaming::Stem { 4 } else { 2 };
|
||||
assert_eq!(sidecar_history_entry(&s, redo).retry_sidecars.len(), expected_pending);
|
||||
assert_file_catalog_and_journal(&s, &fs, &store);
|
||||
|
||||
// Repeating the same XMP failure must not turn the empty photo move list into success.
|
||||
*fs.fail.borrow_mut() = vec![failing.to_string_lossy().into_owned()];
|
||||
let partial = fs.listing();
|
||||
let error = file_history_step(&mut s, &fs, redo).unwrap_err().to_string();
|
||||
assert!(error.contains("injected failure"), "{error}");
|
||||
assert_eq!(fs.listing(), partial);
|
||||
assert_eq!((s.undo.len(), s.redo.len()), history);
|
||||
assert_eq!(s.selection, Selection::single(PhotoId(2)));
|
||||
assert_eq!(sidecar_history_entry(&s, redo).retry_sidecars.len(), expected_pending);
|
||||
assert_file_catalog_and_journal(&s, &fs, &store);
|
||||
|
||||
// Make progress, then fail at the other full-name spelling. Completed sidecars
|
||||
// must not be retried (their old names no longer exist).
|
||||
let last = sidecar_history_entry(&s, redo).retry_sidecars.last().unwrap().clone();
|
||||
*fs.fail.borrow_mut() = vec![last.from.to_string_lossy().into_owned()];
|
||||
assert!(file_history_step(&mut s, &fs, redo).is_err());
|
||||
assert_eq!(sidecar_history_entry(&s, redo).retry_sidecars.len(), 1);
|
||||
assert_eq!((s.undo.len(), s.redo.len()), history);
|
||||
assert_eq!(s.selection, Selection::single(PhotoId(2)));
|
||||
assert_file_catalog_and_journal(&s, &fs, &store);
|
||||
|
||||
// A new destination collision stays an error and never replaces unrelated bytes.
|
||||
fs.fail.borrow_mut().clear();
|
||||
fs.files.borrow_mut().push((last.to.to_string_lossy().into_owned(), b"Unrelated edits".to_vec()));
|
||||
let occupied = fs.listing();
|
||||
assert!(file_history_step(&mut s, &fs, redo).is_err());
|
||||
assert_eq!(fs.listing(), occupied);
|
||||
assert_eq!((s.undo.len(), s.redo.len()), history);
|
||||
assert_eq!(sidecar_history_entry(&s, redo).retry_sidecars.len(), 1);
|
||||
assert_file_catalog_and_journal(&s, &fs, &store);
|
||||
fs.remove_file(&last.to).unwrap();
|
||||
|
||||
file_history_step(&mut s, &fs, redo).unwrap();
|
||||
let mut after = before
|
||||
.iter()
|
||||
.map(|(path, bytes)| (path.replace(from.trim_end_matches(".jpg"), to.trim_end_matches(".jpg")), bytes.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
after.sort();
|
||||
assert_eq!(fs.listing(), after);
|
||||
assert_file_catalog_and_journal(&s, &fs, &store);
|
||||
file_history_step(&mut s, &fs, !redo).unwrap();
|
||||
assert_eq!(fs.listing(), before);
|
||||
assert_file_catalog_and_journal(&s, &fs, &store);
|
||||
file_history_step(&mut s, &fs, redo).unwrap();
|
||||
assert_eq!(fs.listing(), after);
|
||||
assert_file_catalog_and_journal(&s, &fs, &store);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_repairs_a_sidecar_whose_rollback_failed_before_retrying_the_photo() {
|
||||
use crate::sidecar::{SidecarNaming, sidecar_path};
|
||||
|
||||
for redo in [false, true] {
|
||||
let (mut s, fs, store) = sidecar_history_fixture(redo);
|
||||
let moves = s.file_moves(&sidecar_history_entry(&s, redo).op);
|
||||
let (from, to) = &moves[0];
|
||||
let from = from.clone();
|
||||
let to = to.clone();
|
||||
let before = fs.listing();
|
||||
// The stem sidecars move first; full-name failure rolls the photo back, but the
|
||||
// first stem sidecar cannot return. The catalog has not changed this time.
|
||||
let failed_full = sidecar_path(&from, SidecarNaming::Full).with_extension("XMP");
|
||||
let stranded = sidecar_path(&to, SidecarNaming::Stem).with_extension("XMP");
|
||||
*fs.fail.borrow_mut() = vec![failed_full.to_string_lossy().into_owned(), stranded.to_string_lossy().into_owned()];
|
||||
let history = (s.undo.len(), s.redo.len());
|
||||
let error = file_history_step(&mut s, &fs, redo).unwrap_err().to_string();
|
||||
assert!(error.contains("sidecar") && error.contains("could not be moved back"), "{error}");
|
||||
let entry = sidecar_history_entry(&s, redo);
|
||||
assert_eq!(entry.retry_sidecars.len(), 1);
|
||||
assert!(entry.retry_inverse.is_empty());
|
||||
assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: from });
|
||||
assert_file_catalog_and_journal(&s, &fs, &store);
|
||||
let partial = fs.listing();
|
||||
*fs.fail.borrow_mut() = vec![stranded.to_string_lossy().into_owned()];
|
||||
assert!(file_history_step(&mut s, &fs, redo).is_err());
|
||||
assert_eq!(fs.listing(), partial);
|
||||
assert_eq!((s.undo.len(), s.redo.len()), history);
|
||||
assert_eq!(s.selection, Selection::single(PhotoId(2)));
|
||||
fs.fail.borrow_mut().clear();
|
||||
file_history_step(&mut s, &fs, redo).unwrap();
|
||||
assert_file_catalog_and_journal(&s, &fs, &store);
|
||||
file_history_step(&mut s, &fs, !redo).unwrap();
|
||||
assert_eq!(fs.listing(), before);
|
||||
assert_file_catalog_and_journal(&s, &fs, &store);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_partial_rename_keeps_sidecar_repairs_for_noop_retry_and_history() {
|
||||
use crate::rename::tests::FakeFs;
|
||||
let store = lightcraft_catalog::MemStore::new();
|
||||
let mut s = memory_library(&store);
|
||||
let fs = FakeFs::new(false, &[("/p/a.jpg", "Photo"), ("/p/a.xmp", "Stem edits"), ("/p/a.jpg.xmp", "Full edits")]);
|
||||
let photo = Photo::new(PhotoId(1), Source::File { path: "/p/a.jpg".into() }, "a.jpg", "JPEG", 16, 16, "");
|
||||
s.commit("Synthetic File", Op::AddPhoto { photo: Box::new(photo) }).unwrap();
|
||||
s.persist().unwrap();
|
||||
s.undo.clear();
|
||||
select(&mut s, &[1], 1);
|
||||
let before = fs.listing();
|
||||
let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "Trip", 1);
|
||||
*fs.fail.borrow_mut() = vec!["/p/a.xmp".into(), "/p/Trip.jpg".into()];
|
||||
assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err());
|
||||
assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: "/p/Trip.jpg".into() });
|
||||
assert_eq!(s.undo.len(), 1);
|
||||
assert_eq!(s.undo.last().unwrap().retry_sidecars.len(), 2);
|
||||
assert_file_catalog_and_journal(&s, &fs, &store);
|
||||
let retry = s.plan_rename_with(&fs, &[PhotoId(1)], "Trip", 1);
|
||||
assert_eq!(retry[0].from_path, retry[0].to_path, "the physical photo already has the requested name");
|
||||
*fs.fail.borrow_mut() = vec!["/p/a.xmp".into()];
|
||||
let partial = fs.listing();
|
||||
assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &retry)?}))).is_err());
|
||||
assert_eq!(fs.listing(), partial);
|
||||
assert_eq!(s.undo.last().unwrap().retry_sidecars.len(), 2, "a failed no-op retry must keep its repair queue");
|
||||
fs.fail.borrow_mut().clear();
|
||||
assert_eq!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &retry)?}))).unwrap()["renamed"], 0);
|
||||
assert!(s.undo.last().unwrap().retry_sidecars.is_empty());
|
||||
assert_eq!(s.undo.len(), 1, "a repaired no-op retry adds no empty rename step");
|
||||
let after = fs.listing();
|
||||
assert_eq!(
|
||||
after,
|
||||
vec![("/p/Trip.jpg".into(), "Photo".into()), ("/p/Trip.jpg.xmp".into(), "Full edits".into()), ("/p/Trip.xmp".into(), "Stem edits".into())]
|
||||
);
|
||||
assert_file_catalog_and_journal(&s, &fs, &store);
|
||||
file_history_step(&mut s, &fs, false).unwrap();
|
||||
assert_eq!(fs.listing(), before);
|
||||
file_history_step(&mut s, &fs, true).unwrap();
|
||||
assert_eq!(fs.listing(), after);
|
||||
assert_file_catalog_and_journal(&s, &fs, &store);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_sidecar_rollback_failure_keeps_a_repair_only_history_step() {
|
||||
use crate::rename::tests::FakeFs;
|
||||
let store = lightcraft_catalog::MemStore::new();
|
||||
let mut s = memory_library(&store);
|
||||
let fs = FakeFs::new(false, &[("/p/a.jpg", "Photo"), ("/p/a.XMP", "Stem edits"), ("/p/a.jpg.XMP", "Full edits")]);
|
||||
let photo = Photo::new(PhotoId(1), Source::File { path: "/p/a.jpg".into() }, "a.jpg", "JPEG", 16, 16, "");
|
||||
s.commit("Synthetic File", Op::AddPhoto { photo: Box::new(photo) }).unwrap();
|
||||
s.persist().unwrap();
|
||||
s.undo.clear();
|
||||
select(&mut s, &[1], 1);
|
||||
let before = fs.listing();
|
||||
let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "Trip", 1);
|
||||
*fs.fail.borrow_mut() = vec!["/p/a.jpg.XMP".into(), "/p/Trip.XMP".into()];
|
||||
assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err());
|
||||
assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: "/p/a.jpg".into() });
|
||||
assert_eq!(s.undo.len(), 1);
|
||||
assert_eq!(s.undo.last().unwrap().retry_sidecars.len(), 1);
|
||||
assert_file_catalog_and_journal(&s, &fs, &store);
|
||||
let partial = fs.listing();
|
||||
*fs.fail.borrow_mut() = vec!["/p/Trip.XMP".into()];
|
||||
assert!(file_history_step(&mut s, &fs, false).is_err());
|
||||
assert_eq!(fs.listing(), partial);
|
||||
assert_eq!(s.undo.last().unwrap().retry_sidecars.len(), 1);
|
||||
fs.fail.borrow_mut().clear();
|
||||
file_history_step(&mut s, &fs, false).unwrap();
|
||||
assert_eq!(fs.listing(), before);
|
||||
assert_file_catalog_and_journal(&s, &fs, &store);
|
||||
file_history_step(&mut s, &fs, true).unwrap();
|
||||
assert_eq!(fs.listing(), before, "redo of the repair-only step must not rename healthy files");
|
||||
assert_file_catalog_and_journal(&s, &fs, &store);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discarded_file_history_keeps_sidecar_repairs_without_stale_catalog_inverses() {
|
||||
use crate::rename::MoveFs;
|
||||
use crate::sidecar::{SidecarNaming, sidecar_path};
|
||||
|
||||
for redo in [false, true] {
|
||||
for naming in [SidecarNaming::Stem, SidecarNaming::Full] {
|
||||
let (mut s, fs, store) = sidecar_history_fixture(redo);
|
||||
let moves = s.file_moves(&sidecar_history_entry(&s, redo).op);
|
||||
let (from, to) = moves[0].clone();
|
||||
let failing = sidecar_path(&from, naming).with_extension("XMP");
|
||||
*fs.fail.borrow_mut() = vec![failing.to_string_lossy().into_owned(), to.clone()];
|
||||
assert!(file_history_step(&mut s, &fs, redo).is_err());
|
||||
let pending = sidecar_history_entry(&s, redo).retry_sidecars.clone();
|
||||
assert!(!sidecar_history_entry(&s, redo).retry_inverse.is_empty());
|
||||
// A new edit discards Redo. Enough edits also evict an unresolved Undo entry.
|
||||
for i in 0..if redo { 1 } else { 1000 } {
|
||||
s.execute("photo.rate", &json!({"ids": [2], "rating": i % 5 + 1})).unwrap();
|
||||
}
|
||||
assert!(s.redo.is_empty());
|
||||
assert_eq!(s.pending_sidecar_repairs, pending);
|
||||
assert!(s.undo.iter().all(|e| e.retry_inverse.is_empty() && e.retry_sidecars.is_empty()));
|
||||
let partial = fs.listing();
|
||||
// Metadata history remains usable without write access to the separated XMP.
|
||||
file_history_step(&mut s, &fs, false).unwrap();
|
||||
file_history_step(&mut s, &fs, true).unwrap();
|
||||
assert_eq!(s.pending_sidecar_repairs, pending);
|
||||
assert_eq!(fs.listing(), partial);
|
||||
assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: to.clone() });
|
||||
assert_file_catalog_and_journal(&s, &fs, &store);
|
||||
|
||||
let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "Next", 1);
|
||||
let history = (s.undo.len(), s.redo.len());
|
||||
assert!(s.apply_rename_with(&fs, &plans).is_err());
|
||||
assert_eq!(fs.listing(), partial);
|
||||
assert_eq!((s.undo.len(), s.redo.len()), history);
|
||||
assert_eq!(s.pending_sidecar_repairs, pending);
|
||||
// Retain only unfinished work after partial progress; never overwrite a collision.
|
||||
let last = pending.last().unwrap().clone();
|
||||
*fs.fail.borrow_mut() = vec![last.from.to_string_lossy().into_owned()];
|
||||
assert!(s.apply_rename_with(&fs, &plans).is_err());
|
||||
assert_eq!(s.pending_sidecar_repairs.len(), 1);
|
||||
crate::rename::tests::assert_repair_packets(
|
||||
&s.pending_sidecar_repairs[0],
|
||||
&last,
|
||||
&[
|
||||
(sidecar_path(&to, SidecarNaming::Stem).with_extension("XMP"), b"Upper stem edits"),
|
||||
(sidecar_path(&to, SidecarNaming::Stem), b"Lower stem edits"),
|
||||
(sidecar_path(&to, SidecarNaming::Full).with_extension("XMP"), b"Upper full-name edits"),
|
||||
(last.from.clone(), b"Lower full-name edits"),
|
||||
],
|
||||
);
|
||||
// Successful repairs advance companion paths while retaining their content versions.
|
||||
let last = s.pending_sidecar_repairs[0].clone();
|
||||
fs.fail.borrow_mut().clear();
|
||||
fs.files.borrow_mut().push((last.to.to_string_lossy().into_owned(), b"Other edits".to_vec()));
|
||||
let occupied = fs.listing();
|
||||
assert!(s.apply_rename_with(&fs, &plans).is_err());
|
||||
assert_eq!(fs.listing(), occupied);
|
||||
assert_eq!(s.pending_sidecar_repairs, vec![last.clone()]);
|
||||
fs.remove_file(&last.to).unwrap();
|
||||
|
||||
// A no-op Rename still repairs the detached sidecars, without a new undo step.
|
||||
let noop = s.plan_rename_with(&fs, &[PhotoId(1)], "{name}", 1);
|
||||
assert_eq!(s.apply_rename_with(&fs, &noop).unwrap(), 0);
|
||||
assert!(s.pending_sidecar_repairs.is_empty());
|
||||
assert_eq!((s.undo.len(), s.redo.len()), history);
|
||||
let repaired = fs.listing();
|
||||
assert_file_catalog_and_journal(&s, &fs, &store);
|
||||
s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).unwrap();
|
||||
let renamed = fs.listing();
|
||||
file_history_step(&mut s, &fs, false).unwrap();
|
||||
assert_eq!(fs.listing(), repaired, "new history restores the current name, not the discarded inverse");
|
||||
file_history_step(&mut s, &fs, true).unwrap();
|
||||
assert_eq!(fs.listing(), renamed);
|
||||
assert_file_catalog_and_journal(&s, &fs, &store);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merged_rename_failure_keeps_rolled_back_photo_identity() {
|
||||
use crate::rename::tests::FakeFs;
|
||||
|
||||
let store = lightcraft_catalog::MemStore::new();
|
||||
let mut s = memory_library(&store);
|
||||
let fs = FakeFs::new(false, &[("/p/a.jpg", "Photo 1"), ("/p/a.xmp", "Photo 1 edits"), ("/p/b.jpg", "Photo 2")]);
|
||||
for (id, path) in [(1, "/p/a.jpg"), (2, "/p/b.jpg")] {
|
||||
let name = std::path::Path::new(path).file_name().unwrap().to_string_lossy();
|
||||
let photo = Photo::new(PhotoId(id), Source::File { path: path.into() }, &name, "JPEG", 16, 16, "");
|
||||
s.commit("Synthetic File", Op::AddPhoto { photo: Box::new(photo) }).unwrap();
|
||||
}
|
||||
s.persist().unwrap();
|
||||
s.undo.clear();
|
||||
for (id, name) in [(1, "c"), (2, "a"), (2, "d")] {
|
||||
select(&mut s, &[id], id);
|
||||
let plans = s.plan_rename_with(&fs, &[PhotoId(id)], name, 1);
|
||||
s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
fs.listing(),
|
||||
vec![
|
||||
("/p/c.jpg".to_string(), "Photo 1".to_string()),
|
||||
("/p/c.xmp".to_string(), "Photo 1 edits".to_string()),
|
||||
("/p/d.jpg".to_string(), "Photo 2".to_string()),
|
||||
]
|
||||
);
|
||||
s.merge_undo(3, "Rename Three Times");
|
||||
assert_eq!((s.undo.len(), s.redo.len()), (1, 0));
|
||||
assert_eq!(s.file_moves(&s.undo.last().unwrap().op), vec![("/p/d.jpg".into(), "/p/b.jpg".into()), ("/p/c.jpg".into(), "/p/a.jpg".into())]);
|
||||
// The second photo move sticks; the preceding d -> b move must roll back to d.
|
||||
*fs.fail.borrow_mut() = vec!["/p/c.xmp".into(), "/p/a.jpg".into()];
|
||||
let error = file_history_step(&mut s, &fs, false).unwrap_err().to_string();
|
||||
assert!(error.contains("rename /p/c.xmp: injected failure"), "{error}");
|
||||
assert!(error.contains("/p/c.jpg could not be moved back"), "{error}");
|
||||
let files = fs.listing();
|
||||
assert_eq!(
|
||||
files,
|
||||
vec![
|
||||
("/p/a.jpg".to_string(), "Photo 1".to_string()),
|
||||
("/p/c.xmp".to_string(), "Photo 1 edits".to_string()),
|
||||
("/p/d.jpg".to_string(), "Photo 2".to_string()),
|
||||
]
|
||||
);
|
||||
assert_eq!((s.undo.len(), s.redo.len()), (1, 0));
|
||||
assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: "/p/a.jpg".into() });
|
||||
let photo2 = s.catalog.photo(PhotoId(2)).unwrap();
|
||||
assert_eq!(photo2.source, Source::File { path: "/p/d.jpg".into() }, "photo 2 rolled back physically and must still identify d.jpg");
|
||||
let Source::File { path } = &photo2.source else { panic!("file fixture") };
|
||||
assert_eq!(files.iter().find(|(file, _)| file == path).map(|(_, bytes)| bytes.as_str()), Some("Photo 2"));
|
||||
}
|
||||
Loaded 100 of 313 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user