Compare commits
290
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5c93ae795 | ||
|
|
379dd1afcc | ||
|
|
825c5af20a | ||
|
|
23a2f13b46 | ||
|
|
2c947430e6 | ||
|
|
36556729f1 | ||
|
|
6f33517b35 | ||
|
|
1d7115437b | ||
|
|
caae65c78d | ||
|
|
fcccc2c2e0 | ||
|
|
1bc04870c3 | ||
|
|
4da2ec39b3 | ||
|
|
9558b77759 | ||
|
|
5ae742816d | ||
|
|
8d66fa9be5 | ||
|
|
34630ff752 | ||
|
|
3b7d7129ff | ||
|
|
6050a8e703 | ||
|
|
ae4e1a0f07 | ||
|
|
ce5b7d72e3 | ||
|
|
98a67393d9 | ||
|
|
37136f7377 | ||
|
|
e2e2181469 | ||
|
|
ad27369cdc | ||
|
|
883b4aca10 | ||
|
|
8102234e68 | ||
|
|
757133d2a8 | ||
|
|
5e863fa718 | ||
|
|
e600dae3df | ||
|
|
330ece0abe | ||
|
|
4bd8d86c00 | ||
|
|
6c749b469d | ||
|
|
affdaecaee | ||
|
|
31bcc3a462 | ||
|
|
89b464db0c | ||
|
|
87b2599740 | ||
|
|
885864b6a0 | ||
|
|
0007fa459f | ||
|
|
1eab723c90 | ||
|
|
e601bd806c | ||
|
|
cfc1fea25a | ||
|
|
72aa7e98bf | ||
|
|
77a1925bac | ||
|
|
0d9feb0556 | ||
|
|
0682d05f95 | ||
|
|
657f8f19ff | ||
|
|
c07f81edcb | ||
|
|
92afaebd34 | ||
|
|
37a6d99dc4 | ||
|
|
db7b84795c | ||
|
|
e8898a5c38 | ||
|
|
621a6b8313 | ||
|
|
e98b1def98 | ||
|
|
f52c4cb8b6 | ||
|
|
e22128c16b | ||
|
|
81ea9359bc | ||
|
|
be37218696 | ||
|
|
5bff453d37 | ||
|
|
61cd226719 | ||
|
|
87d758bd81 | ||
|
|
427a572aad | ||
|
|
01197ca37d | ||
|
|
f9154ad12e | ||
|
|
1fdfb5990c | ||
|
|
7558053932 | ||
|
|
6bccc2db13 | ||
|
|
209ae36163 | ||
|
|
8071f7101a | ||
|
|
502023c0f4 | ||
|
|
adbb9ac9e6 | ||
|
|
2cde287440 | ||
|
|
6e68f1fb3d | ||
|
|
8df3dda9aa | ||
|
|
3912bc0d1d | ||
|
|
b1d1c47261 | ||
|
|
7be1efff32 | ||
|
|
c50d96e949 | ||
|
|
6b99f67f47 | ||
|
|
48c5e74fa8 | ||
|
|
fc54523093 | ||
|
|
8392cf772e | ||
|
|
515d4eb59e | ||
|
|
b2f3936a53 | ||
|
|
ec7a8c07ee | ||
|
|
78df4211b0 | ||
|
|
c78b798cf0 | ||
|
|
6450f54199 | ||
|
|
1479e45637 | ||
|
|
b5b30e3750 | ||
|
|
884b681c21 | ||
|
|
23abfd1827 | ||
|
|
94ea2569ee | ||
|
|
7a56d16df1 | ||
|
|
5baaf9bac2 | ||
|
|
7428f6f845 | ||
|
|
9f95d23ff4 | ||
|
|
1b8d0e740f | ||
|
|
d2b99bb8c1 | ||
|
|
f39b88b005 | ||
|
|
2f5f2041ab | ||
|
|
0f7ea741d8 | ||
|
|
dee509c6ef | ||
|
|
caaae11d98 | ||
|
|
e57c5b8182 | ||
|
|
02ddce8d80 | ||
|
|
faf52f6dbd | ||
|
|
ffdd640170 | ||
|
|
2226d543f9 | ||
|
|
bee5c5866f | ||
|
|
9b580c3720 | ||
|
|
1abb18d972 | ||
|
|
92d4b23bed | ||
|
|
7cbcacc02e | ||
|
|
2eb06b1064 | ||
|
|
6683c14b40 | ||
|
|
94542371f6 | ||
|
|
715fcf8512 | ||
|
|
49b7bc2f9d | ||
|
|
a59f14c797 | ||
|
|
e82190fdf2 | ||
|
|
a7b090cf36 | ||
|
|
90c0695c05 | ||
|
|
c6d4c1ba62 | ||
|
|
ce6705be89 | ||
|
|
ae0281fedd | ||
|
|
981022ab1d | ||
|
|
87badb6f99 | ||
|
|
d537e4a965 | ||
|
|
fe6e523443 | ||
|
|
73059f2656 | ||
|
|
81118728f4 | ||
|
|
408f189019 | ||
|
|
fabc1c5b56 | ||
|
|
441f6f1404 | ||
|
|
b3dbf4a039 | ||
|
|
6e67ef4467 | ||
|
|
5fcd3752d7 | ||
|
|
5da28584a4 | ||
|
|
8a1d9c8642 | ||
|
|
c3b10ed372 | ||
|
|
4bec01eaf1 | ||
|
|
3b97195b37 | ||
|
|
5794f8c6ea | ||
|
|
b1d36b9143 | ||
|
|
048d48f532 | ||
|
|
35d0696b66 | ||
|
|
9e099a07ab | ||
|
|
a76e3bdd02 | ||
|
|
0103200fc5 | ||
|
|
7c838691e9 | ||
|
|
6979b1a2b8 | ||
|
|
9253a16fed | ||
|
|
9b1f74e6d5 | ||
|
|
e31990550f | ||
|
|
79c051e8a6 | ||
|
|
ea44aba110 | ||
|
|
cc905fa2ed | ||
|
|
f7df276295 | ||
|
|
005dc3a835 | ||
|
|
825015a58e | ||
|
|
2ef971bf37 | ||
|
|
e957d483fc | ||
|
|
80938b0527 | ||
|
|
6640ce0ca8 | ||
|
|
7c9a4ee358 | ||
|
|
54aee50539 | ||
|
|
220e9af222 | ||
|
|
8d4ecb75c1 | ||
|
|
3c2eacbf3f | ||
|
|
d430ec9528 | ||
|
|
1dc7b45cfe | ||
|
|
284fc4a456 | ||
|
|
9de37bed81 | ||
|
|
380cfda695 | ||
|
|
d9f259656a | ||
|
|
19dd3257e3 | ||
|
|
a030bfd239 | ||
|
|
ce72fe49a0 | ||
|
|
4583048595 | ||
|
|
32b2a5e317 | ||
|
|
9e786532a1 | ||
|
|
1f26e1e627 | ||
|
|
2fd1b0b8ce | ||
|
|
9cff677392 | ||
|
|
454375243c | ||
|
|
c559d31dad | ||
|
|
058286750b | ||
|
|
548caa733c | ||
|
|
cf84cec96f | ||
|
|
f0e7b8e11c | ||
|
|
90c7cb65d3 | ||
|
|
70583b9b2f | ||
|
|
f426bb903a | ||
|
|
c41f99ea52 | ||
|
|
a6ea6ba83f | ||
|
|
b480de5bff | ||
|
|
a8043e6827 | ||
|
|
4b4c9e2e6d | ||
|
|
dc9da52651 | ||
|
|
dc1add9dbb | ||
|
|
b5ae5c2be1 | ||
|
|
aa2a88f655 | ||
|
|
f8737e3fda | ||
|
|
d489a34190 | ||
|
|
9d1e31ffbb | ||
|
|
150e53e878 | ||
|
|
7591738c73 | ||
|
|
352e59498b | ||
|
|
d8f26fb5cd | ||
|
|
10216355c1 | ||
|
|
d8e031888e | ||
|
|
114d979397 | ||
|
|
5a500118ae | ||
|
|
ff89a4fa21 | ||
|
|
04495afbfd | ||
|
|
96f1d5c896 | ||
|
|
f1db919b9d | ||
|
|
46f5b95828 | ||
|
|
d748527a4c | ||
|
|
89859d39d1 | ||
|
|
ade627a5d0 | ||
|
|
4642c77e18 | ||
|
|
7d0870c3fb | ||
|
|
c3d1f83b96 | ||
|
|
fc0ea8824d | ||
|
|
9772785f81 | ||
|
|
733a033274 | ||
|
|
414094bd38 | ||
|
|
d8fb382ce9 | ||
|
|
e8f68a92f8 | ||
|
|
8f3df7b68d | ||
|
|
5f0b7ac799 | ||
|
|
8cdad3863d | ||
|
|
229def0afc | ||
|
|
5569a066ff | ||
|
|
ea31791388 | ||
|
|
adade27de4 | ||
|
|
a3f3e188e1 | ||
|
|
031315bdb6 | ||
|
|
00c028c8c8 | ||
|
|
bddf3250c5 | ||
|
|
41486bd59b | ||
|
|
d6d27fb062 | ||
|
|
317a2f40bd | ||
|
|
949fe40d5b | ||
|
|
2be80d4203 | ||
|
|
9ebaa15099 | ||
|
|
aee355fada | ||
|
|
7fa3176f88 | ||
|
|
af162dd010 | ||
|
|
f5300a9f43 | ||
|
|
b19d470189 | ||
|
|
bfadd9c409 | ||
|
|
050b2a3914 | ||
|
|
e166b64ee9 | ||
|
|
a773ad5c27 | ||
|
|
1e472fd251 | ||
|
|
6bf67cefc4 | ||
|
|
00e2fe6aaf | ||
|
|
402dcdc24c | ||
|
|
94b39410bc | ||
|
|
2014c80e62 | ||
|
|
fbfa891296 | ||
|
|
aee62dc7f2 | ||
|
|
84fade99ec | ||
|
|
3bfa73d1e1 | ||
|
|
4616cb0a23 | ||
|
|
cc73ea3153 | ||
|
|
f6ff5eabd9 | ||
|
|
14ed1dc410 | ||
|
|
38819da222 | ||
|
|
d6e9c7dc94 | ||
|
|
9c8f21b754 | ||
|
|
bd7d75522d | ||
|
|
4ef1b74f2f | ||
|
|
e86edef47c | ||
|
|
0aff5e6c8c | ||
|
|
5458314083 | ||
|
|
cb7b9d71c4 | ||
|
|
beb7a5eac0 | ||
|
|
262ed2553c | ||
|
|
8d08ffd7b7 | ||
|
|
8540518022 | ||
|
|
b952f5976a | ||
|
|
a1d511fd4b | ||
|
|
050c2c9d16 | ||
|
|
bd3b993b90 | ||
|
|
59605f9fbb | ||
|
|
04949741c1 | ||
|
|
6b1aac477d |
+5
-2
@@ -23,6 +23,9 @@ fixtures/** filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
# The manual's pictures live in LFS for the same reason the models do: a
|
||||
# screenshot or a GIF changes wholesale when the interface it shows changes,
|
||||
# and every re-recording would otherwise stay in every clone for good. CI's
|
||||
# pulls exclude the directory; nothing built or tested reads it.
|
||||
# and every re-recording would otherwise stay in every clone for good. The
|
||||
# desktop and benchmark legs exclude the directory, since nothing they build
|
||||
# or test reads it; the Android and Windows legs fetch it, because the APK
|
||||
# and the installer carry the manual (docs/manual/index.html) with its
|
||||
# pictures, and their packagers refuse a pointer.
|
||||
docs/manual/media/** filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: Benchmarks
|
||||
|
||||
# The suite docs/requirements.md §8 has been promising since it was written:
|
||||
# The suite docs/dev/requirements.md §8 has been promising since it was written:
|
||||
# "an automated benchmark suite against a synthetic 50k catalog, run per-commit
|
||||
# … A regression beyond stated tolerance fails the build."
|
||||
#
|
||||
@@ -29,7 +29,7 @@ name: Benchmarks
|
||||
# every commit to establish, every time, that this runner has no GPU. It
|
||||
# runs on demand (Actions → Run workflow) so that a runner that *does*
|
||||
# have one can be pointed at it, and the numbers it produces belong in
|
||||
# docs/frame-budget.md by hand, as they already are.
|
||||
# docs/dev/frame-budget.md by hand, as they already are.
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -183,7 +183,7 @@ jobs:
|
||||
- name: Frame budget (FR-DSP-3)
|
||||
run: cargo test --release -p dr-gpu --test frame_budget -- --nocapture
|
||||
|
||||
# The instrument behind docs/frame-budget.md. It exits non-zero with no
|
||||
# The instrument behind docs/dev/frame-budget.md. It exits non-zero with no
|
||||
# adapter, which is right for a tool a person runs deliberately and wrong
|
||||
# for a job that usually has none — hence continue-on-error. Its table is
|
||||
# in the log for whoever asked for this run; the committed numbers are
|
||||
|
||||
@@ -7,6 +7,10 @@ name: Build and test
|
||||
on:
|
||||
push:
|
||||
branches: [main, master, develop]
|
||||
# A release tag builds again and publishes what it built (the `release`
|
||||
# job at the end). The master push of the same commit has usually filled
|
||||
# the caches, so the second run is the warm one.
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
branches: [main, master, develop]
|
||||
|
||||
@@ -96,7 +100,9 @@ jobs:
|
||||
| while read -r key; do git config --local --unset-all "$key"; done || true
|
||||
git config --local lfs.url \
|
||||
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
|
||||
git lfs pull --exclude="fixtures/**,docs/manual/media/**"
|
||||
# The manual's pictures too: the APK carries the manual, and
|
||||
# assemble-apk.sh refuses a pointer where a picture should be.
|
||||
git lfs pull --exclude="fixtures/**"
|
||||
ls -lR models/
|
||||
|
||||
- name: Cache cargo
|
||||
@@ -151,9 +157,32 @@ jobs:
|
||||
- name: Test
|
||||
run: cargo test --workspace
|
||||
|
||||
# The runner has one 99 GB disk shared with its images, and this job
|
||||
# ended at 92 GB (2.1 GB free) on v0.18.0's run; v0.18.1's release
|
||||
# build then died with "No space left on device". The test executables
|
||||
# and examples in target/debug are the largest things in it, are never
|
||||
# reused (a changed source relinks them) and are not what the cache is
|
||||
# for — the dependency rlibs are — so they go before the release build
|
||||
# rather than competing with it.
|
||||
- name: Free the test binaries before the release build
|
||||
run: |
|
||||
find target/debug/deps -maxdepth 1 -type f -executable -delete
|
||||
rm -rf target/debug/examples target/debug/incremental
|
||||
df -h /workspace 2>/dev/null || df -h .
|
||||
|
||||
- name: Build
|
||||
run: cargo build --workspace --release
|
||||
|
||||
# Only on a release tag: the binary is 150 MB and nothing but the
|
||||
# release job wants it.
|
||||
- name: Upload the desktop binary
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: darkroom-desktop-x86_64-linux
|
||||
path: target/release/darkroom-desktop
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Disk after
|
||||
if: always()
|
||||
run: df -h /workspace 2>/dev/null || df -h .
|
||||
@@ -213,7 +242,9 @@ jobs:
|
||||
| while read -r key; do git config --local --unset-all "$key"; done || true
|
||||
git config --local lfs.url \
|
||||
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
|
||||
git lfs pull --exclude="fixtures/**,docs/manual/media/**"
|
||||
# The manual's pictures too: the APK carries the manual, and
|
||||
# assemble-apk.sh refuses a pointer where a picture should be.
|
||||
git lfs pull --exclude="fixtures/**"
|
||||
ls -lR models/
|
||||
|
||||
- name: Cache cargo
|
||||
@@ -322,7 +353,7 @@ jobs:
|
||||
env:
|
||||
CARGO_TARGET_DIR: target-android
|
||||
# Absent secrets mean a debug signature, which is what a fork or a
|
||||
# branch build should get. Set all three (see docs/android-signing.md)
|
||||
# branch build should get. Set all three (see docs/dev/android-signing.md)
|
||||
# and the same job produces a release-signed APK instead.
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
KEYSTORE_PASS: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
@@ -370,7 +401,7 @@ jobs:
|
||||
|
||||
# TRACES: FR-PLAT-WIN-3
|
||||
# The Windows executable and its installer, cross-built from Linux
|
||||
# (docs/windows.md §7). No Windows machine anywhere in this job: what it
|
||||
# (docs/dev/windows.md §7). No Windows machine anywhere in this job: what it
|
||||
# can prove is that the binary links, is a Windows executable with no
|
||||
# MinGW runtime imports, starts under Wine, and that the installer installs
|
||||
# and uninstalls under Wine. What it cannot prove — a Vulkan device, a
|
||||
@@ -406,7 +437,9 @@ jobs:
|
||||
| while read -r key; do git config --local --unset-all "$key"; done || true
|
||||
git config --local lfs.url \
|
||||
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
|
||||
git lfs pull --exclude="fixtures/**,docs/manual/media/**"
|
||||
# The manual's pictures too: the installer carries the manual, and
|
||||
# package.sh refuses a pointer where a picture should be.
|
||||
git lfs pull --exclude="fixtures/**"
|
||||
ls -l models/face models/scene
|
||||
|
||||
- name: Cache cargo
|
||||
@@ -454,7 +487,17 @@ jobs:
|
||||
wine "$SETUP" /S 2>/dev/null
|
||||
INST=$(echo "$HOME"/.wine/drive_c/users/*/AppData/Local/Programs/DarkRoom)
|
||||
ls "$INST"
|
||||
[ "$(ls "$INST/models" | wc -l)" = 7 ] || { echo "FAIL: expected 7 model files"; exit 1; }
|
||||
# As many files as package.sh stages: everything but the READMEs in
|
||||
# the directories it copies. A literal here went stale the first
|
||||
# time a model was added.
|
||||
WANT=$(find models/face models/scene models/inpaint -maxdepth 1 -type f ! -name README.md | wc -l)
|
||||
GOT=$(ls "$INST/models" | wc -l)
|
||||
[ "$GOT" = "$WANT" ] || { echo "FAIL: expected $WANT model files, installed $GOT"; exit 1; }
|
||||
# The manual, and every picture it shows, counted the same way.
|
||||
[ -f "$INST/manual/index.html" ] || { echo "FAIL: no manual installed"; exit 1; }
|
||||
WANT=$(ls docs/manual/media | wc -l)
|
||||
GOT=$(ls "$INST/manual/media" | wc -l)
|
||||
[ "$GOT" = "$WANT" ] || { echo "FAIL: expected $WANT manual pictures, installed $GOT"; exit 1; }
|
||||
wine reg query 'HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\DarkRoom' 2>/dev/null \
|
||||
| grep -q DisplayVersion || { echo "FAIL: no uninstall registry key"; exit 1; }
|
||||
wine "$INST/darkroom.exe" --version 2>/dev/null | grep -q '^darkroom-desktop ' \
|
||||
@@ -514,3 +557,47 @@ jobs:
|
||||
fi
|
||||
done
|
||||
exit $FAILED
|
||||
|
||||
# A v* tag becomes a Gitea Release carrying the three builds and their
|
||||
# SHA256SUMS, titled and described by the tag's message. Until this job
|
||||
# existed every release was made by hand, and most tags never got one.
|
||||
#
|
||||
# It needs all three platform jobs, so a tag whose tests fail publishes
|
||||
# nothing; re-run the failed job and this one follows. The work is
|
||||
# tools/publish-release.sh, which is also how a release is finished by hand.
|
||||
release:
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs: [desktop, android, windows]
|
||||
runs-on: linux/amd64
|
||||
name: Publish the release
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Fetch the builds
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
path: dist
|
||||
|
||||
# Named for the download page, with the version in each name the way
|
||||
# the hand-made releases had them. The installer already carries its
|
||||
# version from package.sh.
|
||||
- name: Publish
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN || github.token }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -e
|
||||
V="${TAG#v}"
|
||||
ls -lR dist
|
||||
mkdir -p out
|
||||
cp dist/darkroom-arm64-v8a-apk/darkroom.apk "out/darkroom-${V}-arm64-v8a.apk"
|
||||
cp dist/darkroom-desktop-x86_64-linux/darkroom-desktop "out/darkroom-desktop-${V}-x86_64-linux"
|
||||
chmod +x "out/darkroom-desktop-${V}-x86_64-linux"
|
||||
cp dist/darkroom-windows-x86_64-setup/DarkRoom-${V}-x86_64-setup.exe out/
|
||||
bash tools/publish-release.sh "$TAG" out/*
|
||||
|
||||
@@ -7,7 +7,7 @@ name: Traceability
|
||||
# fail its own threshold. Two rules follow, and the extractor's own tests
|
||||
# enforce both:
|
||||
#
|
||||
# 1. Denominators are parsed from docs/requirements.md at run time.
|
||||
# 1. Denominators are parsed from docs/dev/requirements.md at run time.
|
||||
# 2. Coverage is |traced ∩ defined| / |defined|, never a raw traced count.
|
||||
#
|
||||
# This job is static analysis of source comments plus markdown parsing, so it
|
||||
@@ -67,6 +67,12 @@ jobs:
|
||||
# threshold: zero requirements parsed, zero files scanned, a ratio above
|
||||
# 100%, or any orphan tag all fail the build. A misconfigured run must not
|
||||
# report a plausible-looking 0%.
|
||||
# Every picture the manual shows is made by a scene in
|
||||
# tools/manual/scenes.py, and every picture a scene makes is shown.
|
||||
# Two files read; no app, no display.
|
||||
- name: Manual pictures have scenes
|
||||
run: tools/manual/record.sh --check
|
||||
|
||||
- name: Traceability gate
|
||||
run: cargo run -q -p traceability -- check
|
||||
|
||||
@@ -74,11 +80,11 @@ jobs:
|
||||
run: |
|
||||
set -e
|
||||
cargo run -q -p traceability -- report
|
||||
if ! git diff --quiet docs/traceability.md; then
|
||||
if ! git diff --quiet docs/dev/traceability.md; then
|
||||
echo ""
|
||||
echo "docs/traceability.md is out of date."
|
||||
echo "docs/dev/traceability.md is out of date."
|
||||
echo "Run: cargo run -p traceability -- report"
|
||||
git diff --stat docs/traceability.md
|
||||
git diff --stat docs/dev/traceability.md
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -91,10 +97,19 @@ jobs:
|
||||
# they will conclude the application is broken rather than the page.
|
||||
#
|
||||
# This also fails on a malformed tag, so a typo costs a gesture its
|
||||
# desktop half loudly rather than silently.
|
||||
# desktop half loudly rather than silently — and on a key a Slint
|
||||
# handler binds that no tag names, or a key a tag names that no handler
|
||||
# binds (tools/traceability/src/keymap.rs).
|
||||
- name: Regenerate the gesture vocabulary and check it is committed
|
||||
run: cargo run -q -p traceability -- gestures-check
|
||||
|
||||
# The manual's page, which the packages carry and the help sheet links
|
||||
# into. Blocking for the gesture book's reason: it is shown to the user,
|
||||
# and a page that disagrees with the README is a manual describing an
|
||||
# application that no longer exists.
|
||||
- name: Regenerate the manual page and check it is committed
|
||||
run: cargo run -q -p traceability -- manual-check
|
||||
|
||||
# Advisory, not blocking: not every file implements a requirement, and a
|
||||
# tag on every function is noise that rots faster than it helps. Tag the
|
||||
# unit that decides.
|
||||
@@ -127,4 +142,4 @@ jobs:
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: head -30 docs/traceability.md || true
|
||||
run: head -30 docs/dev/traceability.md || true
|
||||
|
||||
+18
-4
@@ -26,7 +26,7 @@ fi
|
||||
# The artefacts are generated from the tree, so regenerating them because one
|
||||
# was itself edited would be circular.
|
||||
case "$(tr -d '[:space:]' <<< "${staged}")" in
|
||||
docs/traceability.md | docs/gestures.md | ui/dr-ui/src/gesture_book.rs)
|
||||
docs/dev/traceability.md | docs/gestures.md | ui/dr-ui/src/gesture_book.rs | docs/manual/index.html)
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
@@ -41,9 +41,9 @@ if ! cargo run -q -p traceability -- report >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! git diff --quiet -- docs/traceability.md; then
|
||||
git add docs/traceability.md
|
||||
echo "pre-commit: regenerated docs/traceability.md and staged it"
|
||||
if ! git diff --quiet -- docs/dev/traceability.md; then
|
||||
git add docs/dev/traceability.md
|
||||
echo "pre-commit: regenerated docs/dev/traceability.md and staged it"
|
||||
fi
|
||||
|
||||
# The gesture vocabulary, same discipline.
|
||||
@@ -65,3 +65,17 @@ for f in docs/gestures.md ui/dr-ui/src/gesture_book.rs; do
|
||||
echo "pre-commit: regenerated ${f} and staged it"
|
||||
fi
|
||||
done
|
||||
|
||||
# The manual's page, when its source is part of the commit. Rendered from
|
||||
# nothing but the README, so there is no reason to pay for it otherwise.
|
||||
if grep -qx 'docs/manual/README.md' <<< "${staged}"; then
|
||||
if ! out="$(cargo run -q -p traceability -- manual 2>&1)"; then
|
||||
echo "pre-commit: the manual would not render" >&2
|
||||
echo "${out}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! git diff --quiet -- docs/manual/index.html; then
|
||||
git add docs/manual/index.html
|
||||
echo "pre-commit: regenerated docs/manual/index.html and staged it"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
Notes for anyone — person or agent — changing this code. They record what
|
||||
went wrong once and what the fix looked like, so the same shape is not
|
||||
written again. Requirements live in `docs/requirements.md`; this file is
|
||||
written again. Requirements live in `docs/dev/requirements.md`; this file is
|
||||
about habits, not features.
|
||||
|
||||
## Catalog reads: work is proportional to what changed, never to library size
|
||||
|
||||
`docs/catalog.md §1` states the rule. These are the ways it was broken on
|
||||
`docs/dev/catalog.md §1` states the rule. These are the ways it was broken on
|
||||
the Identity screen, found when every confirm click cost half a second on a
|
||||
24k-image library (2026-09-19), and what each fix looked like.
|
||||
|
||||
@@ -31,6 +31,30 @@ over a result set — including `deep_count` per sidebar row, which is fine
|
||||
at sidebar scale and would not be at grid scale. Aggregate in one
|
||||
statement and look up in memory.
|
||||
|
||||
**SQL text in a loop is a prepare in a loop.** rusqlite's `execute` and
|
||||
`query_row` compile their statement on every call. A loop that calls them
|
||||
per row pays a prepare per row even when each query is a primary-key seek:
|
||||
the merge of a synced catalog prepared four statements for each of 13,000
|
||||
incoming faces (450 ms of a pass that changed nothing), `persist` four per
|
||||
photograph a scan listed (1.5 s for a first scan), the shard sync one per
|
||||
image each way. Hoist the statement, use `prepare_cached`, or — better, when
|
||||
the loop asks the same table about every row — read that table once into a
|
||||
map. And do not rewrite a row with what it already holds: an upsert of
|
||||
identical values still dirties the page.
|
||||
|
||||
**A `LIKE` is case-insensitive, and no index here serves that.**
|
||||
`source_ref LIKE 'stem.%'` read every name of the root per sidecar a pull
|
||||
took in. When the check that decides is exact, spell the prefix as a range
|
||||
(`>= 'stem.' AND < 'stem/'`, `/` being the byte after `.`), which the
|
||||
`(root_id, source_ref)` key answers with a seek.
|
||||
|
||||
**`Catalog::open` is not free, and every worker thread calls it.** The
|
||||
backfill runs on every open, and the develop view opens a catalog to fetch
|
||||
each original and again for each neighbour it prefetches. Keep each
|
||||
backfill step's no-op case to a read of the small side — the unpaired
|
||||
JPEGs, not every RAW; the distinct keywords, not every assignment — and
|
||||
measure an open with `catalog_bench` after adding one.
|
||||
|
||||
**Filter and aggregate in SQL, and aggregate the small side first.**
|
||||
`faces::people` read 19,000 rows, grouped, sorted them by name, and the
|
||||
screen threw 17,000 away (empty unnamed groups). `people_in_use` filters in
|
||||
@@ -124,6 +148,15 @@ other builds are running on the machine — the wall clock doubles under
|
||||
load, the CPU figure does not. Keep the binary from before the change and
|
||||
run both back to back rather than trusting numbers taken an hour apart.
|
||||
|
||||
`cargo run --release -p dr-catalog --example catalog_bench -- CATALOG
|
||||
[FACES_DIR]` does the same for opening the catalog (the backfill step by
|
||||
step), the upload snapshot, a merge, and the face shard export and import;
|
||||
`persist_bench`, an ignored test in `dr-ui`'s scan module, replays a scan's
|
||||
`persist` and a sidecar pull (`DR_BENCH_CATALOG=copy.sqlite cargo test
|
||||
--release -p dr-ui --lib persist_bench -- --ignored --nocapture
|
||||
--test-threads=1`). Both take copies; hand `catalog_bench` a copy of the
|
||||
face store directory too.
|
||||
|
||||
Reference figures from the 2026-09-19 fixes, largest person (754 faces),
|
||||
24k images, 19k faces, before → after. What one click read: `load_people`
|
||||
22 ms → 12 ms, `load_faces` 316 ms → 2.4 ms, `audit` 190 ms → not run
|
||||
|
||||
+43
-15
@@ -1,6 +1,6 @@
|
||||
# Contributing to DarkRoom
|
||||
|
||||
There is a lot of documentation here — 14 documents and 177 numbered
|
||||
There is a lot of documentation here — twenty-odd documents and 192 numbered
|
||||
requirements — and almost all of it is written for someone who has already
|
||||
decided to work on this. This file is the other thing: how to get a first
|
||||
change landed without reading any of it.
|
||||
@@ -62,7 +62,7 @@ sudo apt-get install pkg-config libfontconfig1-dev libxkbcommon-dev
|
||||
cargo run -p darkroom-desktop
|
||||
```
|
||||
|
||||
The first build resolves 826 crates and takes a while — on a laptop, long
|
||||
The first build resolves some 850 crates and takes a while — on a laptop, long
|
||||
enough to look like a hang. It is not one.
|
||||
|
||||
Android is a containerised toolchain and is not needed for most work; see
|
||||
@@ -90,18 +90,18 @@ break it by accident:
|
||||
cargo run --release -p dr-bench -- check
|
||||
```
|
||||
|
||||
That is the benchmark suite (`docs/requirements.md` §8), which builds a
|
||||
That is the benchmark suite (`docs/dev/requirements.md` §8), which builds a
|
||||
synthetic 50,000-image catalog and fails the build if a performance target is
|
||||
missed or a measurement has drifted past its tolerance. It runs on every push in
|
||||
its own workflow. [`docs/benchmarks.md`](docs/benchmarks.md) says what it
|
||||
its own workflow. [`docs/dev/benchmarks.md`](docs/dev/benchmarks.md) says what it
|
||||
measures, what it deliberately does not, and how to read a failure. If you have
|
||||
touched the catalog, the decoder, the thumbnail store or the exporter, run it
|
||||
before you send.
|
||||
|
||||
## Requirements and traceability
|
||||
|
||||
[`requirements.md`](docs/requirements.md) is the register of record.
|
||||
[`traceability.md`](docs/traceability.md) is generated from `TRACES:` tags in
|
||||
[`requirements.md`](docs/dev/requirements.md) is the register of record.
|
||||
[`traceability.md`](docs/dev/traceability.md) is generated from `TRACES:` tags in
|
||||
the source and must never be hand-edited:
|
||||
|
||||
```rust
|
||||
@@ -124,15 +124,33 @@ Note that it tracks line numbers, so a change that only moves code still moves
|
||||
the matrix. Never regenerate it with a stale prebuilt binary.
|
||||
|
||||
**One convention that the tooling cannot enforce.** A tag proves that a tag
|
||||
exists, not that the code under it does the thing — `docs/code-health.md`
|
||||
exists, not that the code under it does the thing — `docs/dev/code-health.md`
|
||||
CH-4 has the details, and two requirements currently read as covered on the
|
||||
strength of plumbing a future feature would use. So: **close a requirement
|
||||
with a test that would fail if the behaviour were removed.** Coverage that
|
||||
moves slowly and means something beats coverage that moves quickly.
|
||||
|
||||
## Two invariants the build defends
|
||||
**Keys and gestures are held the same way.** A key handler in Slint compares
|
||||
one canonical chord, `Keys.chord(event) == "Ctrl+Z"`, under a `// KEYMAP:`
|
||||
comment naming its section of the gesture book, and every key it binds must be
|
||||
named by a `GESTURE:` block beside it. `cargo run -p traceability -- gestures`
|
||||
regenerates [`docs/gestures.md`](docs/gestures.md) and the in-app help sheet
|
||||
from those blocks; `-- gestures-check` fails when a handler binds a key no tag
|
||||
names, or a tag names a key no handler binds. A `manual:` field in a block
|
||||
links the gesture to a section of the manual, and a heading that is not there
|
||||
fails the scan.
|
||||
|
||||
Worth knowing before you trip one, because both failures name a requirement
|
||||
**The manual is checked too.** `docs/manual/index.html` is rendered from
|
||||
`docs/manual/README.md` by `-- manual` and `-- manual-check` fails when they
|
||||
differ; `tools/manual/record.sh --check` fails when the manual shows a picture
|
||||
no scene in `tools/manual/scenes.py` makes. If you change what a pictured
|
||||
screen looks like, [`tools/manual`](tools/manual/README.md) says how to record
|
||||
it again. The pre-commit hook regenerates the matrix, the gesture book and the
|
||||
page; CI runs all three checks.
|
||||
|
||||
## Three invariants the build defends
|
||||
|
||||
Worth knowing before you trip one, because each failure names a requirement
|
||||
rather than a line:
|
||||
|
||||
- **No operation may be named in `ui/`** (FR-DEV-3a). Special-casing one
|
||||
@@ -144,6 +162,16 @@ rather than a line:
|
||||
`order:`, a filename disagreeing with its `id:`, a default outside its own
|
||||
range, an expression naming something that is not a parameter. Each error
|
||||
names the key you got wrong and exits rather than panicking.
|
||||
- **No verdict is written without a user action** (FR-CULL-13). A rating,
|
||||
flag, colour label or trash membership is the photographer's to set, never a
|
||||
signal's. `tools/traceability/src/verdicts.rs` finds every write of one in
|
||||
the shipped code — the catalog setters, SQL that assigns those columns, the
|
||||
sidecar's judgement amendment — and holds each to a reviewed list with its
|
||||
reason: inside a Slint `on_*` callback, writing for callers that are checked
|
||||
in turn, or carrying a verdict made elsewhere, such as a sidecar pull or the
|
||||
sync merge. A new write fails `cargo test` (the `traceability` crate's tests,
|
||||
part of the workspace run) until it is listed, and so does a listed one that
|
||||
has gone; `cargo run -p traceability -- verdicts` prints the list.
|
||||
|
||||
## Commit messages
|
||||
|
||||
@@ -163,12 +191,12 @@ One commit per change. If you fixed two things, that is two commits.
|
||||
| Document | Read it when |
|
||||
|---|---|
|
||||
| [`core/dr-pipeline/ops/README.md`](core/dr-pipeline/ops/README.md) | Adding or changing a develop operation — start here regardless |
|
||||
| [`docs/architecture.md`](docs/architecture.md) | Anything touching the render path, catalog or sync |
|
||||
| [`docs/code-health.md`](docs/code-health.md) | Deciding what to work on; grades each seam by what it costs |
|
||||
| [`docs/benchmarks.md`](docs/benchmarks.md) | A change that could plausibly cost time or memory |
|
||||
| [`docs/technical-debt.md`](docs/technical-debt.md) | Something looks wrong — check it was not chosen |
|
||||
| [`docs/distribution.md`](docs/distribution.md) | Packaging a build, or adding a permission to one |
|
||||
| [`docs/requirements.md`](docs/requirements.md) | Reference, not reading |
|
||||
| [`docs/dev/architecture.md`](docs/dev/architecture.md) | Anything touching the render path, catalog or sync |
|
||||
| [`docs/dev/code-health.md`](docs/dev/code-health.md) | Deciding what to work on; grades each seam by what it costs |
|
||||
| [`docs/dev/benchmarks.md`](docs/dev/benchmarks.md) | A change that could plausibly cost time or memory |
|
||||
| [`docs/dev/technical-debt.md`](docs/dev/technical-debt.md) | Something looks wrong — check it was not chosen |
|
||||
| [`docs/dev/distribution.md`](docs/dev/distribution.md) | Packaging a build, or adding a permission to one |
|
||||
| [`docs/dev/requirements.md`](docs/dev/requirements.md) | Reference, not reading |
|
||||
|
||||
`technical-debt.md` is the one to check before "fixing" anything surprising.
|
||||
It records compromises that were deliberate, each with the reasoning and a
|
||||
|
||||
Generated
+199
-32
@@ -347,6 +347,28 @@ dependencies = [
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ashpd"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39"
|
||||
dependencies = [
|
||||
"async-fs",
|
||||
"async-net",
|
||||
"enumflags2",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"rand 0.9.5",
|
||||
"raw-window-handle",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"url",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
@@ -385,6 +407,17 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-fs"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5"
|
||||
dependencies = [
|
||||
"async-lock",
|
||||
"blocking",
|
||||
"futures-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-io"
|
||||
version = "2.6.0"
|
||||
@@ -414,6 +447,17 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-net"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7"
|
||||
dependencies = [
|
||||
"async-io",
|
||||
"blocking",
|
||||
"futures-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-process"
|
||||
version = "2.5.0"
|
||||
@@ -1221,7 +1265,7 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
|
||||
|
||||
[[package]]
|
||||
name = "darkroom-android"
|
||||
version = "0.13.6"
|
||||
version = "0.19.4"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"dr-plat",
|
||||
@@ -1234,7 +1278,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "darkroom-desktop"
|
||||
version = "0.13.6"
|
||||
version = "0.19.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dr-plat",
|
||||
@@ -1356,6 +1400,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"block2 0.6.2",
|
||||
"libc",
|
||||
"objc2 0.6.4",
|
||||
]
|
||||
|
||||
@@ -1408,7 +1454,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
||||
|
||||
[[package]]
|
||||
name = "dr-bench"
|
||||
version = "0.13.6"
|
||||
version = "0.19.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dr-catalog",
|
||||
@@ -1425,7 +1471,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-catalog"
|
||||
version = "0.13.6"
|
||||
version = "0.19.4"
|
||||
dependencies = [
|
||||
"dr-face",
|
||||
"dr-plat",
|
||||
@@ -1440,7 +1486,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-decode"
|
||||
version = "0.13.6"
|
||||
version = "0.19.4"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"env_logger",
|
||||
@@ -1454,7 +1500,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-export"
|
||||
version = "0.13.6"
|
||||
version = "0.19.4"
|
||||
dependencies = [
|
||||
"dr-decode",
|
||||
"dr-gpu",
|
||||
@@ -1473,7 +1519,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-face"
|
||||
version = "0.13.6"
|
||||
version = "0.19.4"
|
||||
dependencies = [
|
||||
"dr-inference-engine",
|
||||
"env_logger",
|
||||
@@ -1486,7 +1532,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-film"
|
||||
version = "0.13.6"
|
||||
version = "0.19.4"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -1495,7 +1541,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-gpu"
|
||||
version = "0.13.6"
|
||||
version = "0.19.4"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"dr-decode",
|
||||
@@ -1513,7 +1559,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-inference-engine"
|
||||
version = "0.13.6"
|
||||
version = "0.19.4"
|
||||
dependencies = [
|
||||
"env_logger",
|
||||
"libloading",
|
||||
@@ -1528,7 +1574,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-ingest"
|
||||
version = "0.13.6"
|
||||
version = "0.19.4"
|
||||
dependencies = [
|
||||
"dr-plat",
|
||||
"dr-types",
|
||||
@@ -1540,7 +1586,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-lens"
|
||||
version = "0.13.6"
|
||||
version = "0.19.4"
|
||||
dependencies = [
|
||||
"lensfun",
|
||||
"log",
|
||||
@@ -1548,7 +1594,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-pano"
|
||||
version = "0.13.6"
|
||||
version = "0.19.4"
|
||||
dependencies = [
|
||||
"dr-decode",
|
||||
"dr-inference-engine",
|
||||
@@ -1562,7 +1608,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-pipeline"
|
||||
version = "0.13.6"
|
||||
version = "0.19.4"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"log",
|
||||
@@ -1571,7 +1617,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-plat"
|
||||
version = "0.13.6"
|
||||
version = "0.19.4"
|
||||
dependencies = [
|
||||
"android-native-keyring-store",
|
||||
"dr-types",
|
||||
@@ -1587,7 +1633,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-preset-xmp"
|
||||
version = "0.13.6"
|
||||
version = "0.19.4"
|
||||
dependencies = [
|
||||
"dr-pipeline",
|
||||
"log",
|
||||
@@ -1597,7 +1643,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-segment"
|
||||
version = "0.13.6"
|
||||
version = "0.19.4"
|
||||
dependencies = [
|
||||
"dr-inference-engine",
|
||||
"env_logger",
|
||||
@@ -1610,7 +1656,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-sync"
|
||||
version = "0.13.6"
|
||||
version = "0.19.4"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dr-plat",
|
||||
@@ -1624,7 +1670,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-sync-folder"
|
||||
version = "0.13.6"
|
||||
version = "0.19.4"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dr-sync",
|
||||
@@ -1636,7 +1682,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-sync-nextcloud"
|
||||
version = "0.13.6"
|
||||
version = "0.19.4"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dr-decode",
|
||||
@@ -1658,7 +1704,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-thumbs"
|
||||
version = "0.13.6"
|
||||
version = "0.19.4"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"jpeg-encoder",
|
||||
@@ -1670,7 +1716,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-types"
|
||||
version = "0.13.6"
|
||||
version = "0.19.4"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1679,7 +1725,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-ui"
|
||||
version = "0.13.6"
|
||||
version = "0.19.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -1704,25 +1750,30 @@ dependencies = [
|
||||
"dr-types",
|
||||
"dr-xmp",
|
||||
"env_logger",
|
||||
"i-slint-backend-testing",
|
||||
"jni 0.22.4",
|
||||
"log",
|
||||
"ndk-context",
|
||||
"png",
|
||||
"pollster",
|
||||
"raw-window-handle",
|
||||
"reqwest",
|
||||
"rfd",
|
||||
"rusqlite",
|
||||
"serde_json",
|
||||
"serde_norway",
|
||||
"sha2",
|
||||
"slint",
|
||||
"slint-build",
|
||||
"thiserror 2.0.20",
|
||||
"tokio",
|
||||
"url",
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dr-xmp"
|
||||
version = "0.13.6"
|
||||
version = "0.19.4"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"log",
|
||||
@@ -2780,6 +2831,18 @@ dependencies = [
|
||||
"i-slint-renderer-skia",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "i-slint-backend-testing"
|
||||
version = "1.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521e901e3d47ab829c0ef500c63155776208707cd93259e6a7803ed627fa2786"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"i-slint-common",
|
||||
"i-slint-core",
|
||||
"vtable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "i-slint-backend-winit"
|
||||
version = "1.17.1"
|
||||
@@ -2960,8 +3023,6 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "i-slint-renderer-skia"
|
||||
version = "1.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b6eed7f3f0a9a3d3ca6e8b9d4ca233371d989351fdb2a7ab88ec368b99e7b57"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"bytemuck",
|
||||
@@ -5452,8 +5513,6 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "rawler"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04f4cc35c23969a4a834e0b117c7da41ace812eb9053b5effc3fc5c77d114677"
|
||||
dependencies = [
|
||||
"backtrace",
|
||||
"bitstream-io",
|
||||
@@ -5655,6 +5714,30 @@ dependencies = [
|
||||
"zune-jpeg 0.5.15",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfd"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
|
||||
dependencies = [
|
||||
"ashpd",
|
||||
"block2 0.6.2",
|
||||
"dispatch2",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation 0.3.2",
|
||||
"pollster",
|
||||
"raw-window-handle",
|
||||
"urlencoding",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rgb"
|
||||
version = "0.8.53"
|
||||
@@ -6273,6 +6356,7 @@ dependencies = [
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"pin-weak",
|
||||
"raw-window-handle",
|
||||
"slint-macros",
|
||||
"unicode-segmentation",
|
||||
"vtable",
|
||||
@@ -7023,11 +7107,14 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||
|
||||
[[package]]
|
||||
name = "traceability"
|
||||
version = "0.13.6"
|
||||
version = "0.19.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"proc-macro2",
|
||||
"pulldown-cmark",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7433,8 +7520,15 @@ dependencies = [
|
||||
"idna",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urlencoding"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||
|
||||
[[package]]
|
||||
name = "usvg"
|
||||
version = "0.47.0"
|
||||
@@ -7923,8 +8017,6 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "wgpu-hal"
|
||||
version = "29.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97ace1c17727311c22a46e4e3faf56ea6de81af99dcc839bdfb54857b94d448d"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"arrayvec",
|
||||
@@ -8157,6 +8249,15 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
||||
dependencies = [
|
||||
"windows-targets 0.53.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
@@ -8205,13 +8306,30 @@ dependencies = [
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 0.52.6",
|
||||
"windows_i686_gnu 0.52.6",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_gnullvm 0.52.6",
|
||||
"windows_i686_msvc 0.52.6",
|
||||
"windows_x86_64_gnu 0.52.6",
|
||||
"windows_x86_64_gnullvm 0.52.6",
|
||||
"windows_x86_64_msvc 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.53.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows_aarch64_gnullvm 0.53.1",
|
||||
"windows_aarch64_msvc 0.53.1",
|
||||
"windows_i686_gnu 0.53.1",
|
||||
"windows_i686_gnullvm 0.53.1",
|
||||
"windows_i686_msvc 0.53.1",
|
||||
"windows_x86_64_gnu 0.53.1",
|
||||
"windows_x86_64_gnullvm 0.53.1",
|
||||
"windows_x86_64_msvc 0.53.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-threading"
|
||||
version = "0.2.1"
|
||||
@@ -8239,6 +8357,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.42.2"
|
||||
@@ -8257,6 +8381,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.42.2"
|
||||
@@ -8275,12 +8405,24 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.42.2"
|
||||
@@ -8299,6 +8441,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.42.2"
|
||||
@@ -8317,6 +8465,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.42.2"
|
||||
@@ -8335,6 +8489,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.42.2"
|
||||
@@ -8353,6 +8513,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||
|
||||
[[package]]
|
||||
name = "winit"
|
||||
version = "0.30.13"
|
||||
@@ -8818,6 +8984,7 @@ dependencies = [
|
||||
"endi",
|
||||
"enumflags2",
|
||||
"serde",
|
||||
"url",
|
||||
"winnow 1.0.4",
|
||||
"zvariant_derive",
|
||||
"zvariant_utils",
|
||||
|
||||
+26
-2
@@ -27,9 +27,12 @@ members = [
|
||||
"tools/bench",
|
||||
"tools/traceability",
|
||||
]
|
||||
# Patched copies of upstream crates, not our code: see third_party/README.md.
|
||||
# Excluded so `--workspace` does not test, lint or format them as ours.
|
||||
exclude = ["third_party"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.13.6"
|
||||
version = "0.19.4"
|
||||
edition = "2021"
|
||||
rust-version = "1.92"
|
||||
license = "GPL-3.0-or-later"
|
||||
@@ -48,7 +51,7 @@ dr-export = { path = "core/dr-export" }
|
||||
dr-face = { path = "core/dr-face", default-features = false }
|
||||
dr-film = { path = "core/dr-film" }
|
||||
# `tract` on by default so a test binary can open a session with nothing
|
||||
# installed; the apps add `native` to look for a runtime file (docs/inference.md §3).
|
||||
# installed; the apps add `native` to look for a runtime file (docs/dev/inference.md §3).
|
||||
dr-inference-engine = { path = "core/dr-inference-engine" }
|
||||
dr-ingest = { path = "core/dr-ingest" }
|
||||
dr-gpu = { path = "core/dr-gpu" }
|
||||
@@ -127,6 +130,16 @@ url = "2.5"
|
||||
async-trait = "0.1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
# The manual's HTML rendering (tools/traceability). Already in the tree as
|
||||
# Slint's Markdown parser, so this adds a dependency edge and no crate; only
|
||||
# the HTML writer is needed, not the command-line front end.
|
||||
pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] }
|
||||
# The verdict-writer check (tools/traceability, FR-CULL-13) reads Rust as Rust:
|
||||
# a text scan cannot tell a call from a comment, a test module from shipped
|
||||
# code, or which callback closure a call sits in. Both already in the tree as
|
||||
# every proc macro's parser; `span-locations` gives a problem its line.
|
||||
syn = { version = "2", default-features = false, features = ["full", "parsing", "visit", "printing"] }
|
||||
proc-macro2 = { version = "1", default-features = false, features = ["span-locations"] }
|
||||
base64 = "0.23"
|
||||
|
||||
# Display-server clients, for FR-DSP-8's per-display profile acquisition.
|
||||
@@ -262,3 +275,14 @@ opt-level = 0
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
|
||||
# Three upstream crates carry a local patch: wgpu-hal and Slint's Skia
|
||||
# renderer so that the Android build can draw with wgpu on a rotated display
|
||||
# (technical-debt.md TD-1), and rawler so that a linear DNG wider than 16 700
|
||||
# pixels decodes. Each is an exact copy of the version the lockfile already
|
||||
# resolves, plus its patch; third_party/README.md says what was changed and
|
||||
# how to carry it forward when Slint, wgpu or rawler moves.
|
||||
[patch.crates-io]
|
||||
wgpu-hal = { path = "third_party/wgpu-hal-29.0.4" }
|
||||
i-slint-renderer-skia = { path = "third_party/i-slint-renderer-skia-1.17.1" }
|
||||
rawler = { path = "third_party/rawler-0.7.2" }
|
||||
|
||||
@@ -15,35 +15,63 @@ is still missing.
|
||||
**A library.** Point it at a folder — on this machine, on a network mount,
|
||||
or one a Nextcloud client keeps in virtual-files mode, where a placeholder
|
||||
is treated as the photograph rather than as a one-byte file — or at a
|
||||
Nextcloud account directly. The grid is virtualised, ordered by capture
|
||||
time with a timeline beside it, and filtered by rating, flag, person and
|
||||
whether the file is here. Ratings, keywords, collections and a
|
||||
trash that survives a crash mid-operation. Card ingest. Bursts fold. Face
|
||||
detection and identity, with the index syncing between devices.
|
||||
Nextcloud account directly; a photograph that is only on the server opens on
|
||||
its thumbnail with the download's progress over it. The grid is virtualised,
|
||||
ordered by capture time with a timeline beside it, and filtered by rating,
|
||||
flag, colour label, person and whether the file is here. Ratings, colour
|
||||
labels, keywords, collections and a trash that survives a crash
|
||||
mid-operation. Card ingest. Bursts fold. The same RAW catalogued twice — a
|
||||
dated folder and a backup beside it — is found, proved the same, and folded
|
||||
onto one copy with the spares in the trash. Face detection and identity,
|
||||
with the index syncing between devices.
|
||||
|
||||
**Developing.** Eighteen declared operations fused into one compute
|
||||
dispatch, plus the neighbourhood work that cannot be: clarity, texture,
|
||||
capture sharpening, noise reduction, lens correction, spectral film
|
||||
simulation. Crop and straighten, spot repair, and local adjustments over
|
||||
masks the model draws — click a subject or a category, then paint, subtract
|
||||
a gradient, grow or shrink the edge. Focus peaking and a raw histogram for
|
||||
judging what is recoverable. Named presets; XMP sidecars other editors read.
|
||||
**Developing.** Nineteen declared operations, those that read one pixel
|
||||
fused into a generated shader rather than run a pass each, plus the
|
||||
neighbourhood work that cannot be: clarity, texture, dehaze, capture
|
||||
sharpening, noise reduction, lens correction. Every edit works on the scene
|
||||
as the camera recorded it — linear, highlights beyond white included — and
|
||||
one `Tone Mapping` step, last, after sharpening and noise reduction, fits it
|
||||
to the screen, with a contrast and a white point of its own; a spectral film
|
||||
stock takes its place when one is chosen. Crop, straighten and correct
|
||||
converging verticals, spot repair, and local adjustments over masks the
|
||||
model draws — click a subject or a category, then paint, subtract a gradient
|
||||
or keep only where two selections agree, grow or shrink the edge. A mask's
|
||||
sliders add to the photograph's, the film's among them, so a sky can be
|
||||
burned in on the print as a darkroom printer would. Hot and dead photosites
|
||||
are mended before the demosaic, with nothing to set. Focus peaking and a raw
|
||||
histogram for judging what is recoverable. Presets, a click away in a menu
|
||||
at the foot of the tool rail, with a collection shipped in the application —
|
||||
everyday corrections, and a look for each measured colour, cinema and
|
||||
black-and-white stock — and Lightroom presets imported as looks that leave a
|
||||
photograph's own corrections alone. XMP sidecars other editors read. A
|
||||
linear DNG larger than one GPU texture — a stitched panorama twenty thousand
|
||||
pixels wide — opens, develops and exports at full size.
|
||||
|
||||
[](docs/manual/README.md#local-adjustments)
|
||||
|
||||
**Panoramas.** Select the frames, align, choose a projection, fill the
|
||||
ragged border rather than crop it, and the composite lands beside its
|
||||
sources as a DNG, with a sidecar recording what it was merged from.
|
||||
**Panoramas.** Select the frames, align, untick any frame to leave it out
|
||||
and the rest re-align at once, choose a projection, fill the ragged border
|
||||
rather than crop it, and the composite lands beside its sources as a DNG,
|
||||
with a sidecar recording what it was merged from.
|
||||
|
||||
[](docs/manual/README.md#merging-a-panorama)
|
||||
|
||||
**From the keyboard, and with its manual.** Rating, flagging and labelling
|
||||
have keys in the grid and in develop, as do zoom, undo and stepping through a
|
||||
shoot in develop, and none of them is keyboard-only. The help sheet (`F1`, or
|
||||
`?` in develop) lists every key and gesture, generated from the code that
|
||||
binds it, and links them to the sections of the manual that show them — the
|
||||
manual ships with the application and opens offline.
|
||||
|
||||
**Export.** JPEG, PNG, AVIF, JPEG XL, 8- and 16-bit TIFF, with resize, output
|
||||
sharpening, a naming template and a colour space — to a folder here or back
|
||||
into the library.
|
||||
sharpening, a naming template and a colour space — into albums: named export
|
||||
folders on this machine or on the server, never inside the library, which
|
||||
remember the photograph behind each file and sync between devices as
|
||||
collections do.
|
||||
|
||||
**On both platforms.** The same core runs on a desktop and a 12-inch
|
||||
tablet; the interface is one layout, tuned for a wide viewport with touch
|
||||
targets throughout. On desktop the develop view draws the compute pass's
|
||||
targets throughout. On both, the develop view draws the compute pass's
|
||||
texture directly — no readback between the GPU and the screen.
|
||||
|
||||
## Getting it
|
||||
@@ -52,54 +80,146 @@ texture directly — no readback between the GPU and the screen.
|
||||
|---|---|---|
|
||||
| Arch Linux | [`packaging/PKGBUILD`](packaging/PKGBUILD) — `makepkg -si` | Built from every release |
|
||||
| Android | The APK from each CI run, or `./docker/android/package.sh --install` | Runs on a tablet; F-Droid not yet submitted |
|
||||
| Windows | `DarkRoom-<version>-x86_64-setup.exe`, cross-built by CI ([windows.md](docs/windows.md)) | Verified under Wine only; unsigned |
|
||||
| Flatpak | [`packaging/flatpak/`](packaging/flatpak/) | Manifest in tree; choosing a library does not yet work in the sandbox |
|
||||
| Windows | `DarkRoom-<version>-x86_64-setup.exe`, cross-built by CI ([windows.md](docs/dev/windows.md)) | Verified under Wine only; unsigned |
|
||||
| Flatpak | [`packaging/flatpak/`](packaging/flatpak/) | Manifest in tree; folders are chosen through the portal, but no Flatpak has been built to prove it |
|
||||
|
||||
Or build it. Git LFS is required for the model weights, and the toolchain
|
||||
pins itself to 1.92.0:
|
||||
## Building from source
|
||||
|
||||
**Before anything.** Git LFS holds the model weights and the manual's
|
||||
pictures; a clone without it has ~130-byte pointers in their place, and every
|
||||
packager below refuses to ship one. The Rust toolchain pins itself to 1.92.0
|
||||
through `rust-toolchain.toml`, so rustup is all you install. Slint needs a few
|
||||
system headers, and the app needs a Vulkan driver at runtime:
|
||||
|
||||
```bash
|
||||
git clone https://gitea.tourolle.paris/dtourolle/DarkRoom.git && cd DarkRoom
|
||||
git lfs install && git lfs pull
|
||||
|
||||
# Debian / Ubuntu
|
||||
sudo apt-get install pkg-config libfontconfig1-dev libxkbcommon-dev libvulkan1
|
||||
# Arch
|
||||
sudo pacman -S --needed pkgconf fontconfig libxkbcommon vulkan-icd-loader
|
||||
```
|
||||
|
||||
**To try it** from the checkout, without installing anything:
|
||||
|
||||
```bash
|
||||
cargo run --release -p darkroom-desktop
|
||||
```
|
||||
|
||||
Android, through the containerised toolchain ([docker/android](docker/android/README.md)):
|
||||
This is for development. The binary under `target/` finds no face, scene or
|
||||
panorama-fill models, and a release build does not find the manual either:
|
||||
it looks for all of them in the system data directories an install creates
|
||||
(`$XDG_DATA_DIRS/darkroom`, by default `/usr/local/share/darkroom` and
|
||||
`/usr/share/darkroom`), never in the checkout. Those features show as
|
||||
unavailable until it is installed.
|
||||
|
||||
### Linux: build and install
|
||||
|
||||
**On Arch**, build a package from the checkout and install it with pacman,
|
||||
so it can be upgraded and removed like any other:
|
||||
|
||||
```bash
|
||||
./docker/android/build.sh cargo ndk -t arm64-v8a build --release
|
||||
cd packaging && makepkg -si
|
||||
```
|
||||
|
||||
[CONTRIBUTING.md](CONTRIBUTING.md) has the system packages, the four
|
||||
**Elsewhere**, build the release binary and install it under `/usr/local`
|
||||
by hand. These are the same files, in the same places, as the Arch package
|
||||
([`packaging/PKGBUILD`](packaging/PKGBUILD)'s `package()` is the reference):
|
||||
|
||||
```bash
|
||||
cargo build --release --locked -p darkroom-desktop
|
||||
# -> target/release/darkroom-desktop
|
||||
|
||||
P=/usr/local
|
||||
sudo install -Dm755 target/release/darkroom-desktop $P/bin/darkroom-desktop
|
||||
|
||||
# The models: faces and eye state, scene categories, panorama border fill
|
||||
sudo install -d $P/share/darkroom/models
|
||||
sudo install -m644 models/face/*.onnx models/scene/* models/inpaint/*.onnx \
|
||||
$P/share/darkroom/models/
|
||||
|
||||
# The offline manual the Help menu opens
|
||||
sudo install -Dm644 docs/manual/index.html $P/share/darkroom/manual/index.html
|
||||
sudo install -Dm644 -t $P/share/darkroom/manual/media docs/manual/media/*
|
||||
|
||||
# Launcher entry, icon and software-centre description
|
||||
sudo install -Dm644 packaging/paris.tourolle.darkroom.desktop \
|
||||
$P/share/applications/paris.tourolle.darkroom.desktop
|
||||
sudo install -Dm644 ui/dr-ui/ui/app-icon.png \
|
||||
$P/share/icons/hicolor/256x256/apps/paris.tourolle.darkroom.png
|
||||
sudo install -Dm644 packaging/paris.tourolle.darkroom.metainfo.xml \
|
||||
$P/share/metainfo/paris.tourolle.darkroom.metainfo.xml
|
||||
```
|
||||
|
||||
Then run `darkroom-desktop`, or open it from the application menu. To
|
||||
uninstall, remove those files and `/usr/local/share/darkroom`. Your catalog,
|
||||
settings and thumbnails live in `darkroom/` under your own XDG data, config
|
||||
and cache directories (`~/.local/share`, `~/.config`, `~/.cache`) and are
|
||||
not touched by either.
|
||||
|
||||
Optional at runtime: `gnome-keyring` or `kwallet` to remember Nextcloud
|
||||
credentials, and an ONNX Runtime in `/usr/lib` (CPU, or ROCm on an AMD GPU) to
|
||||
run the models on every core rather than on the built-in engine.
|
||||
|
||||
### Windows: build the installer
|
||||
|
||||
The `.exe` is cross-built from Linux in a container (podman or docker), with
|
||||
no Windows machine involved. Two steps — the executable, then the NSIS
|
||||
installer that carries it with its models and manual:
|
||||
|
||||
```bash
|
||||
./docker/windows/build.sh cargo build --release --target x86_64-pc-windows-gnu -p darkroom-desktop
|
||||
./docker/windows/build.sh docker/windows/package.sh
|
||||
```
|
||||
|
||||
Both land in the container's cache on the host, `~/.cache/darkroom-windows/target/`:
|
||||
the bare executable under `x86_64-pc-windows-gnu/release/darkroom-desktop.exe`,
|
||||
the installer under `installer/DarkRoom-<version>-x86_64-setup.exe`.
|
||||
Copy that to the Windows machine and run it — it installs per user, needs no
|
||||
administrator rights, and adds an uninstaller. Run on its own, the bare
|
||||
`.exe` looks for `models\` and `manual\` beside itself, so use the installer.
|
||||
[docker/windows](docker/windows/README.md) has the details.
|
||||
|
||||
### Android: build the APK
|
||||
|
||||
Also containerised ([docker/android](docker/android/README.md)). This
|
||||
builds, packages and debug-signs the APK, and with `--install` puts it on a
|
||||
device connected over adb:
|
||||
|
||||
```bash
|
||||
./docker/android/package.sh --install
|
||||
```
|
||||
|
||||
A debug-signed APK cannot replace one installed from a release; uninstall
|
||||
that first.
|
||||
|
||||
[CONTRIBUTING.md](CONTRIBUTING.md) has the four
|
||||
commands CI runs against what you send, and the shortest useful
|
||||
contribution — a develop operation is one YAML file, and it arrives with its
|
||||
controls, its place in the chain and its tests.
|
||||
|
||||
## Where it stands
|
||||
|
||||
**0.13.6**, twenty tagged releases in. 188 numbered requirements in
|
||||
scope, 82% of them claimed by code and [traced to it](docs/traceability.md);
|
||||
**0.19.4**, thirty-three tagged releases in. 193 numbered requirements in
|
||||
scope, 85% of them claimed by code and [traced to it](docs/dev/traceability.md);
|
||||
the rest are written down rather than merely absent.
|
||||
|
||||
**Not built:** plugins (post-v1, [D12](docs/requirements.md)), compare and
|
||||
survey culling, AI denoise, tiled and progressive rendering, HDR merge and
|
||||
focus stacking, most of the Android platform integration beyond running,
|
||||
and the Flatpak's library chooser. The performance targets are half
|
||||
verified: the per-commit benchmark suite §8 requires exists for everything
|
||||
that does not need a frame — the catalog, the scan, the thumbnails — and
|
||||
not yet for the render path, so a regression there fails nothing.
|
||||
[outstanding.md](docs/outstanding.md) is the list, with the reasoning for
|
||||
**Not built:** plugins (post-v1, [D12](docs/dev/requirements.md)), compare and
|
||||
survey culling, AI denoise, tiled rendering beyond the export of an oversized
|
||||
DNG, HDR merge and focus stacking, importing a Lightroom or darktable catalog,
|
||||
translations beyond the launch screen, most of the Android platform
|
||||
integration beyond running, and a Flatpak actually built and run in its
|
||||
sandbox. The performance targets are half verified: the per-commit benchmark
|
||||
suite §8 requires exists for everything that does not need a frame — the
|
||||
catalog, the scan, the thumbnails — and not yet for the render path, so a
|
||||
regression there fails nothing.
|
||||
[outstanding.md](docs/dev/outstanding.md) is the list, with the reasoning for
|
||||
each.
|
||||
|
||||
**The one deliberate compromise worth knowing about before reading
|
||||
anything else:** the Android develop view reads its frame back through the
|
||||
CPU, because zero-copy there needs wgpu's Vulkan swapchain and that tears a
|
||||
portrait window on a tablet whose panel is mounted landscape. It is debt,
|
||||
not a revision of the rule — [technical-debt.md TD-1](docs/technical-debt.md)
|
||||
has the measurements and the three things any one of which would remove it.
|
||||
|
||||
## Documentation
|
||||
|
||||
For someone using it:
|
||||
[docs/README.md](docs/README.md) is the index. The short version, for someone using it:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
@@ -111,21 +231,21 @@ For someone changing it:
|
||||
| | |
|
||||
|---|---|
|
||||
| [CONTRIBUTING.md](CONTRIBUTING.md) | How to land a first change without reading the rest |
|
||||
| [requirements.md](docs/requirements.md) | What the software must do — the numbered register, and the decisions |
|
||||
| [architecture.md](docs/architecture.md) | How it is built — crates, the GPU pipeline, the data model, sync |
|
||||
| [technical-debt.md](docs/technical-debt.md) | Compromises taken deliberately, each with the condition that retires it |
|
||||
| [outstanding.md](docs/outstanding.md) | What is not built, and whether that is a decision or a gap |
|
||||
| [code-health.md](docs/code-health.md) | What a contribution costs, per seam, measured |
|
||||
| [traceability.md](docs/traceability.md) | Generated: which requirement is claimed by which file |
|
||||
| [requirements.md](docs/dev/requirements.md) | What the software must do — the numbered register, and the decisions |
|
||||
| [architecture.md](docs/dev/architecture.md) | How it is built — crates, the GPU pipeline, the data model, sync |
|
||||
| [technical-debt.md](docs/dev/technical-debt.md) | Compromises taken deliberately, each with the condition that retires it |
|
||||
| [outstanding.md](docs/dev/outstanding.md) | What is not built, and whether that is a decision or a gap |
|
||||
| [code-health.md](docs/dev/code-health.md) | What a contribution costs, per seam, measured |
|
||||
| [traceability.md](docs/dev/traceability.md) | Generated: which requirement is claimed by which file |
|
||||
|
||||
Designs, one per subsystem:
|
||||
[segmentation](docs/segmentation.md) and [mask editing](docs/mask-editing.md) ·
|
||||
[spot removal](docs/spot-removal.md) · [panorama](docs/panorama.md) ·
|
||||
[faces](docs/faces.md) · [inference](docs/inference.md) ·
|
||||
[storage and sync](docs/storage.md) · [catalog](docs/catalog.md) ·
|
||||
[display and extension](docs/display-and-extension.md) ·
|
||||
[navigation](docs/ui-navigation.md) · [distribution](docs/distribution.md) ·
|
||||
[windows](docs/windows.md) · [benchmarks](docs/benchmarks.md).
|
||||
[segmentation](docs/dev/segmentation.md) and [mask editing](docs/dev/mask-editing.md) ·
|
||||
[spot removal](docs/dev/spot-removal.md) · [panorama](docs/dev/panorama.md) ·
|
||||
[faces](docs/dev/faces.md) · [inference](docs/dev/inference.md) ·
|
||||
[storage and sync](docs/dev/storage.md) · [catalog](docs/dev/catalog.md) ·
|
||||
[display and extension](docs/dev/display-and-extension.md) ·
|
||||
[navigation](docs/dev/ui-navigation.md) · [distribution](docs/dev/distribution.md) ·
|
||||
[windows](docs/dev/windows.md) · [benchmarks](docs/dev/benchmarks.md).
|
||||
|
||||
## Licence
|
||||
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
|
||||
Deliberately minimal: this packages the viewer for on-device testing (spike
|
||||
S2 needs Adreno and Mali hardware, which no emulator represents). Nothing
|
||||
here is a distribution manifest yet. Only network access is declared: file
|
||||
access needs no manifest permission because the library grid reads through
|
||||
SAF, which grants per-tree at runtime (ARCH §6.9).
|
||||
here is a distribution manifest yet. The library grid needs no storage
|
||||
permission, because it reads through SAF, which grants per-tree at runtime
|
||||
(ARCH §6.9); the one storage permission declared is for importing from a
|
||||
camera card, which is read by path.
|
||||
|
||||
Minimal is not the same as empty, and the entries below that are not the
|
||||
activity are the difference. A manifest is the only place a component can be
|
||||
@@ -21,13 +22,29 @@
|
||||
WebDAV listing, thumbnail and image fetches. Without it Android refuses
|
||||
socket creation outright, and the failure is invisible — no panic to
|
||||
catch, no log line, just a worker thread that stops. Storage is the
|
||||
separate case that genuinely needs no permission here, because SAF
|
||||
grants per-tree at runtime (ARCH §6.9). -->
|
||||
separate case: the library and album folders need no permission
|
||||
here, because SAF grants per-tree at runtime (ARCH §6.9). -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!-- Read before deciding whether a sync may run: FR-NC-6 gates background
|
||||
work on unmetered-and-charging, which means knowing the network type. -->
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<!-- FR-CAT-10: importing from a camera card. The importer reads the card
|
||||
as files, and "all files access" is what makes an SD card or a USB
|
||||
card reader readable by path on API 30 and up (see Cards.java). It is
|
||||
granted on a system settings page, not a dialog; the import page
|
||||
sends the user there when it is missing. READ_EXTERNAL_STORAGE is the
|
||||
same thing for API 28 and 29, and means nothing above them; on 29 it
|
||||
reads by path only with requestLegacyExternalStorage, which is why
|
||||
<application> carries that flag.
|
||||
|
||||
Google Play limits MANAGE_EXTERNAL_STORAGE to a short list of app
|
||||
kinds. DarkRoom is not distributed through Play. -->
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="29" />
|
||||
|
||||
<!-- Vulkan 1.1 is what wgpu needs; the API 28 floor is where support is
|
||||
dependable (NFR-COMPAT-1). Marked required so an unsupported device
|
||||
fails at install rather than at first frame. -->
|
||||
@@ -53,6 +70,7 @@
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:hasCode="true"
|
||||
android:allowBackup="false"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:supportsRtl="true">
|
||||
|
||||
<!-- NativeActivity rather than a Kotlin Activity: android-activity's
|
||||
@@ -141,6 +159,35 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- The manual (dr_ui::manual): a WebView over the copy the APK
|
||||
carries in assets/manual. See ManualActivity.java for why it is
|
||||
not the browser.
|
||||
|
||||
Not exported: nothing outside this app has a reason to start it,
|
||||
and dr_ui starts it by class name, which needs no intent filter.
|
||||
Its own task entry is not wanted either — it is a page over the
|
||||
app, and Back returns to the photograph it was opened from.
|
||||
configChanges so a rotation reflows the page rather than
|
||||
reloading it at the top. -->
|
||||
<activity
|
||||
android:name="paris.tourolle.darkroom.ManualActivity"
|
||||
android:exported="false"
|
||||
android:label="DarkRoom manual"
|
||||
android:theme="@style/ManualTheme"
|
||||
android:configChanges="orientation|keyboardHidden|screenSize|screenLayout|uiMode" />
|
||||
|
||||
<!-- FR-EXP-10: the system's folder picker, for an album's folder on
|
||||
this device. NativeActivity's onActivityResult is not ours, so
|
||||
this activity exists only to ask and hand the answer back (see
|
||||
FolderPicker.java). Translucent and without a title so nothing
|
||||
of it shows but the system chooser; not exported, and started by
|
||||
class name from dr_ui::saf. -->
|
||||
<activity
|
||||
android:name="paris.tourolle.darkroom.FolderPicker"
|
||||
android:exported="false"
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar"
|
||||
android:configChanges="orientation|keyboardHidden|screenSize|screenLayout|uiMode" />
|
||||
|
||||
<!-- FR-PLAT-AND-6, outbound. Android has refused file:// URIs
|
||||
between apps since API 24 — handing one out raises
|
||||
FileUriExposedException in *this* process — so an exported JPEG
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package paris.tourolle.darkroom;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Environment;
|
||||
import android.os.storage.StorageManager;
|
||||
import android.os.storage.StorageVolume;
|
||||
import android.provider.Settings;
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Finding a camera card, and the permission that makes it readable (FR-CAT-10).
|
||||
*
|
||||
* <p>An import reads the card as files: the survey walks it, the probe reads
|
||||
* each header and the copy streams each original, all through the same
|
||||
* {@code std::fs} code the desktop uses. Android hands out such paths —
|
||||
* {@code /storage/9C33-6BBD/DCIM} — to an app holding "all files access"
|
||||
* ({@code MANAGE_EXTERNAL_STORAGE}, API 30), which covers the root of an SD
|
||||
* card and of a USB card reader. Below API 30 the same paths are readable
|
||||
* with {@code READ_EXTERNAL_STORAGE}.
|
||||
*
|
||||
* <p>Not the folder picker {@link FolderPicker} uses for albums. A tree
|
||||
* granted through SAF is {@code content://} URIs, not paths, and since API 30
|
||||
* the picker refuses the root of a card outright; reading a card through it
|
||||
* would mean a second storage implementation under the importer, where this
|
||||
* needs none.
|
||||
*
|
||||
* <p>Google Play restricts this permission to file managers and the like.
|
||||
* DarkRoom is not distributed through Play, so the restriction does not
|
||||
* apply; it would need revisiting if that changed.
|
||||
*/
|
||||
public final class Cards {
|
||||
private static final String TAG = "DarkRoom";
|
||||
|
||||
private Cards() {
|
||||
}
|
||||
|
||||
/** Whether this app may read a card's files by path. */
|
||||
public static boolean hasAccess(Context context) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
return Environment.isExternalStorageManager();
|
||||
}
|
||||
return context.checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
== PackageManager.PERMISSION_GRANTED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the system page where the user grants it.
|
||||
*
|
||||
* <p>A settings page rather than a permission dialog because there is no
|
||||
* dialog for this one on API 30 and up: the user flips "Allow access to
|
||||
* manage all files" for this app. Below 30 the context is the application
|
||||
* context, which cannot raise a runtime permission request (that needs an
|
||||
* Activity's result), so the app's own settings page is the route there
|
||||
* too. Either way the app learns of the grant by asking
|
||||
* {@link #hasAccess} again.
|
||||
*/
|
||||
public static void requestAccess(Context context) {
|
||||
Uri self = Uri.parse("package:" + context.getPackageName());
|
||||
Intent intent;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
intent = new Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, self);
|
||||
} else {
|
||||
intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, self);
|
||||
}
|
||||
// The context is not an Activity; see FolderPicker.start.
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
try {
|
||||
context.startActivity(intent);
|
||||
} catch (RuntimeException e) {
|
||||
// Some builds ship without the per-app page; the list of every
|
||||
// app holding the permission is the fallback that always exists.
|
||||
Log.w(TAG, "no per-app all-files page; opening the list", e);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
Intent list = new Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION);
|
||||
list.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
context.startActivity(list);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every mounted volume other than the device's own storage.
|
||||
*
|
||||
* <p>One string per volume, {@code path \t description \t removable},
|
||||
* where removable is {@code 1} or {@code 0}: the reason {@link Intents}
|
||||
* gives for keeping the JNI surface to strings. The primary volume is left
|
||||
* out — it is the device's internal storage, never a card — and so is
|
||||
* anything not mounted, which is a card being ejected or one the system
|
||||
* could not read.
|
||||
*/
|
||||
public static String[] volumes(Context context) {
|
||||
List<String> out = new ArrayList<String>();
|
||||
StorageManager manager = (StorageManager) context.getSystemService(Context.STORAGE_SERVICE);
|
||||
if (manager == null) {
|
||||
return new String[0];
|
||||
}
|
||||
for (StorageVolume volume : manager.getStorageVolumes()) {
|
||||
if (volume.isPrimary()) {
|
||||
continue;
|
||||
}
|
||||
String state = volume.getState();
|
||||
if (!Environment.MEDIA_MOUNTED.equals(state)
|
||||
&& !Environment.MEDIA_MOUNTED_READ_ONLY.equals(state)) {
|
||||
continue;
|
||||
}
|
||||
String path = path(volume);
|
||||
if (path == null) {
|
||||
Log.w(TAG, "a mounted volume with no path: " + volume);
|
||||
continue;
|
||||
}
|
||||
String description = volume.getDescription(context);
|
||||
if (description == null) {
|
||||
description = new File(path).getName();
|
||||
}
|
||||
out.add(path + "\t" + description.replace('\t', ' ') + "\t"
|
||||
+ (volume.isRemovable() ? "1" : "0"));
|
||||
}
|
||||
return out.toArray(new String[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the volume is mounted.
|
||||
*
|
||||
* <p>{@code getDirectory} is API 30. Below it the same answer is the
|
||||
* hidden {@code getPath}, which every release from 24 to 29 has, reached by
|
||||
* reflection because android.jar does not declare it.
|
||||
*/
|
||||
private static String path(StorageVolume volume) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
File dir = volume.getDirectory();
|
||||
return dir == null ? null : dir.getPath();
|
||||
}
|
||||
try {
|
||||
Object path = StorageVolume.class.getMethod("getPath").invoke(volume);
|
||||
return path == null ? null : path.toString();
|
||||
} catch (ReflectiveOperationException e) {
|
||||
Log.w(TAG, "StorageVolume.getPath", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package paris.tourolle.darkroom;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
|
||||
/**
|
||||
* The system's folder picker, for an album's folder on this device (FR-EXP-10).
|
||||
*
|
||||
* <h2>Why an activity of its own</h2>
|
||||
*
|
||||
* <p>{@code ACTION_OPEN_DOCUMENT_TREE} answers through
|
||||
* {@code onActivityResult}, and the main activity is {@code NativeActivity},
|
||||
* whose result callback is not ours to override. So this one exists only to
|
||||
* ask: it starts the picker, takes the answer, and finishes — no layout, a
|
||||
* translucent theme, nothing on screen but the system's own chooser, which has
|
||||
* its own "New folder".
|
||||
*
|
||||
* <p>The answer is left in a static for Rust to poll ({@link #poll}), rather
|
||||
* than called back into native code: a callback would need a registered
|
||||
* native method and a thread to deliver on, and a poll from the Slint timer
|
||||
* that is already running is one static call.
|
||||
*
|
||||
* <h2>The grant</h2>
|
||||
*
|
||||
* <p>A tree URI is usable only while its permission is held, and a plain
|
||||
* result grants it until the process dies. {@code takePersistableUriPermission}
|
||||
* keeps it across restarts — an album's folder is chosen once and exported to
|
||||
* for months.
|
||||
*/
|
||||
public final class FolderPicker extends Activity {
|
||||
private static final String TAG = "DarkRoom";
|
||||
private static final int REQUEST = 0x5AF;
|
||||
|
||||
/** The last answer: a tree URI, "" for a cancel, null while none has come. */
|
||||
private static volatile String answer = null;
|
||||
|
||||
/**
|
||||
* Start asking. Clears any answer left from before.
|
||||
*
|
||||
* <p>Takes a {@code Context} rather than an {@code Activity}, because what
|
||||
* native code holds (ndk_context's handle) is the application context,
|
||||
* and starting an activity from one that is not an activity needs
|
||||
* {@code FLAG_ACTIVITY_NEW_TASK} — without it the call throws. The picker
|
||||
* shares the app's task affinity, so it still opens over the app and Back
|
||||
* still returns to it.
|
||||
*/
|
||||
public static void start(Context from) {
|
||||
answer = null;
|
||||
Intent intent = new Intent(from, FolderPicker.class);
|
||||
if (!(from instanceof Activity)) {
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
}
|
||||
from.startActivity(intent);
|
||||
}
|
||||
|
||||
/**
|
||||
* The answer, once: a tree URI, "" if the user backed out, or null while
|
||||
* the picker is still open. Reading it clears it, so a second poll after a
|
||||
* cancel does not see the cancel again.
|
||||
*/
|
||||
public static String poll() {
|
||||
String a = answer;
|
||||
if (a != null) {
|
||||
answer = null;
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle state) {
|
||||
super.onCreate(state);
|
||||
// Recreated after a rotation with the picker already up: asking again
|
||||
// would stack a second chooser over the first.
|
||||
if (state != null) {
|
||||
return;
|
||||
}
|
||||
Intent pick = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
|
||||
pick.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
| Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION);
|
||||
try {
|
||||
startActivityForResult(pick, REQUEST);
|
||||
} catch (ActivityNotFoundException e) {
|
||||
Log.w(TAG, "no folder picker on this device", e);
|
||||
answer = "";
|
||||
finish();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onActivityResult(int request, int result, Intent data) {
|
||||
if (request != REQUEST) {
|
||||
return;
|
||||
}
|
||||
Uri tree = (result == RESULT_OK && data != null) ? data.getData() : null;
|
||||
if (tree == null) {
|
||||
answer = "";
|
||||
} else {
|
||||
try {
|
||||
getContentResolver().takePersistableUriPermission(tree,
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
|
||||
} catch (SecurityException e) {
|
||||
// Still usable this session; said in the log so a folder that
|
||||
// stops working after a restart has an explanation.
|
||||
Log.w(TAG, "the folder grant could not be kept: " + tree, e);
|
||||
}
|
||||
answer = tree.toString();
|
||||
}
|
||||
finish();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package paris.tourolle.darkroom;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.webkit.WebResourceRequest;
|
||||
import android.webkit.WebSettings;
|
||||
import android.webkit.WebView;
|
||||
import android.webkit.WebViewClient;
|
||||
|
||||
/**
|
||||
* The manual that ships in the APK, shown in a WebView.
|
||||
*
|
||||
* <h2>Why an activity of our own rather than the browser</h2>
|
||||
*
|
||||
* <p>The desktop hands the manual to the system browser. Android leaves no
|
||||
* way to do the same: the page is an asset inside the APK, which is not a
|
||||
* file; an unpacked copy in app-private storage is a file no browser may
|
||||
* read; a {@code file:} URI handed to another app is refused since API 24;
|
||||
* and a {@code content:} URI serves the page but leaves the browser to fetch
|
||||
* every picture by a relative URL against the provider, which browsers do not
|
||||
* reliably do. A WebView reads {@code file:///android_asset/} straight from
|
||||
* the APK, pictures and section anchor included, and nothing is unpacked.
|
||||
*
|
||||
* <h2>What it is not</h2>
|
||||
*
|
||||
* <p>A browser. JavaScript stays off (the page has none), and a link that
|
||||
* leaves the manual — the design documents are on the forge — goes to the
|
||||
* user's browser rather than opening inside this view, so the only thing ever
|
||||
* shown here is the page the APK carries.
|
||||
*
|
||||
* <p>Started by {@code dr_ui::manual} with {@code Intent.setClassName}, so the
|
||||
* name here and there must agree; a test in lib.rs checks the manifest
|
||||
* declares it.
|
||||
*/
|
||||
public final class ManualActivity extends Activity {
|
||||
/** The section to open at, a heading's anchor. Absent opens the top. */
|
||||
public static final String EXTRA_ANCHOR = "anchor";
|
||||
|
||||
private static final String PAGE = "file:///android_asset/manual/index.html";
|
||||
|
||||
private WebView web;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle saved) {
|
||||
super.onCreate(saved);
|
||||
setTitle("DarkRoom manual");
|
||||
|
||||
web = new WebView(this);
|
||||
WebSettings settings = web.getSettings();
|
||||
settings.setJavaScriptEnabled(false);
|
||||
// Pinch to zoom into a screenshot, which is 1600 pixels wide and drawn
|
||||
// at the width of a phone.
|
||||
settings.setBuiltInZoomControls(true);
|
||||
settings.setDisplayZoomControls(false);
|
||||
web.setWebViewClient(new WebViewClient() {
|
||||
@Override
|
||||
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
|
||||
Uri uri = request.getUrl();
|
||||
if ("file".equals(uri.getScheme())) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
startActivity(new Intent(Intent.ACTION_VIEW, uri));
|
||||
} catch (ActivityNotFoundException e) {
|
||||
// No browser on the device: the link does nothing, which
|
||||
// is all it could do.
|
||||
}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
setContentView(web);
|
||||
|
||||
if (saved != null) {
|
||||
web.restoreState(saved);
|
||||
} else {
|
||||
String anchor = getIntent().getStringExtra(EXTRA_ANCHOR);
|
||||
web.loadUrl(anchor == null || anchor.isEmpty() ? PAGE : PAGE + "#" + anchor);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onSaveInstanceState(Bundle out) {
|
||||
super.onSaveInstanceState(out);
|
||||
web.saveState(out);
|
||||
}
|
||||
|
||||
/** Back walks back through the sections visited, then leaves. */
|
||||
@Override
|
||||
public void onBackPressed() {
|
||||
if (web.canGoBack()) {
|
||||
web.goBack();
|
||||
} else {
|
||||
super.onBackPressed();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
web.destroy();
|
||||
super.onDestroy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package paris.tourolle.darkroom;
|
||||
|
||||
import android.content.ContentResolver;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.provider.DocumentsContract;
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
|
||||
/**
|
||||
* Writing an export into a folder the user granted through
|
||||
* {@link FolderPicker} — the Storage Access Framework, which is the only way
|
||||
* this app reaches a folder on the device (FR-PLAT-AND-1).
|
||||
*
|
||||
* <p>A tree URI is not a path: a child is found by listing the folder and
|
||||
* matching its display name, and created through the provider, which may
|
||||
* rename it on a collision. So the name that was actually written is handed
|
||||
* back, and the album records that one.
|
||||
*
|
||||
* <p>Two static calls, strings and a byte array in, a string out, for the
|
||||
* reason {@link Intents} gives: every call here would be a signature typed as
|
||||
* a string on the Rust side, and the fewer of those the better.
|
||||
*/
|
||||
public final class Saf {
|
||||
private static final String TAG = "DarkRoom";
|
||||
|
||||
private Saf() {
|
||||
}
|
||||
|
||||
/** Whether {@code name} already exists in the folder. False on any error. */
|
||||
public static boolean exists(Context context, String tree, String name) {
|
||||
try {
|
||||
return find(context.getContentResolver(), Uri.parse(tree), name) != null;
|
||||
} catch (RuntimeException e) {
|
||||
Log.w(TAG, "checking " + name + " in " + tree, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write {@code bytes} as {@code name} in the folder, replacing a file of
|
||||
* that name when {@code replace} is set.
|
||||
*
|
||||
* @return the name the file has in the folder — the provider may have
|
||||
* added " (1)" — or null on failure, with the reason in the log.
|
||||
*/
|
||||
public static String write(Context context, String tree, String name, String mime,
|
||||
byte[] bytes, boolean replace) {
|
||||
ContentResolver resolver = context.getContentResolver();
|
||||
Uri treeUri = Uri.parse(tree);
|
||||
try {
|
||||
Uri target = replace ? find(resolver, treeUri, name) : null;
|
||||
if (target == null) {
|
||||
Uri folder = DocumentsContract.buildDocumentUriUsingTree(treeUri,
|
||||
DocumentsContract.getTreeDocumentId(treeUri));
|
||||
target = DocumentsContract.createDocument(resolver, folder, mime, name);
|
||||
}
|
||||
if (target == null) {
|
||||
Log.w(TAG, "the folder refused to create " + name + " in " + tree);
|
||||
return null;
|
||||
}
|
||||
// "wt": truncate. A replacement shorter than what it replaces
|
||||
// must not keep the old file's tail.
|
||||
try (OutputStream out = resolver.openOutputStream(target, "wt")) {
|
||||
if (out == null) {
|
||||
Log.w(TAG, "no stream for " + target);
|
||||
return null;
|
||||
}
|
||||
out.write(bytes);
|
||||
}
|
||||
String written = displayName(resolver, target);
|
||||
return written != null ? written : name;
|
||||
} catch (IOException | RuntimeException e) {
|
||||
Log.w(TAG, "writing " + name + " to " + tree, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The document for {@code name} directly in the tree's folder, or null. */
|
||||
private static Uri find(ContentResolver resolver, Uri tree, String name) {
|
||||
String folderId = DocumentsContract.getTreeDocumentId(tree);
|
||||
Uri children = DocumentsContract.buildChildDocumentsUriUsingTree(tree, folderId);
|
||||
String[] columns = {
|
||||
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
|
||||
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
|
||||
};
|
||||
try (Cursor c = resolver.query(children, columns, null, null, null)) {
|
||||
if (c == null) {
|
||||
return null;
|
||||
}
|
||||
while (c.moveToNext()) {
|
||||
if (name.equals(c.getString(1))) {
|
||||
return DocumentsContract.buildDocumentUriUsingTree(tree, c.getString(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String displayName(ContentResolver resolver, Uri document) {
|
||||
String[] columns = {DocumentsContract.Document.COLUMN_DISPLAY_NAME};
|
||||
try (Cursor c = resolver.query(document, columns, null, null, null)) {
|
||||
if (c != null && c.moveToFirst()) {
|
||||
return c.getString(0);
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
Log.w(TAG, "reading the name of " + document, e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Day or night as the system is; see values/themes.xml. -->
|
||||
<resources>
|
||||
<style name="ManualTheme" parent="@android:style/Theme.DeviceDefault.DayNight" />
|
||||
</resources>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
The manual's theme (ManualActivity). Light below API 29, which has no
|
||||
day-night theme in the platform; values-v29 follows the system from there.
|
||||
The WebView takes prefers-color-scheme from whether this theme is light, and
|
||||
the manual's stylesheet takes its colours from that.
|
||||
-->
|
||||
<resources>
|
||||
<style name="ManualTheme" parent="@android:style/Theme.DeviceDefault.Light" />
|
||||
</resources>
|
||||
@@ -240,7 +240,7 @@ fn android_main(app: slint::android::AndroidApp) {
|
||||
///
|
||||
/// **Face weights are absent from the repository by design.** The InsightFace
|
||||
/// grant is research-only and incompatible with this project's licence
|
||||
/// (docs/faces.md §2), so a desktop user fetches them, runs
|
||||
/// (docs/dev/faces.md §2), so a desktop user fetches them, runs
|
||||
/// `tools/fix-face-model-shapes.sh` over them, and drops the result in. A build
|
||||
/// that carries none is the ordinary case and face indexing simply stays off.
|
||||
///
|
||||
@@ -300,7 +300,9 @@ fn install_bundled_models(app: slint::android::AndroidApp) {
|
||||
// on the worker because `AAssetManager` is thread-safe by contract and
|
||||
// reading the pointer takes only the app's read lock, which `poll_events`
|
||||
// also only ever holds shared.
|
||||
std::thread::spawn(move || unpack_bundled_models(&app));
|
||||
dr_ui::executors::spawn(dr_ui::executors::Executor::Io, "models", move || {
|
||||
unpack_bundled_models(&app)
|
||||
});
|
||||
}
|
||||
|
||||
/// The copy itself, on the worker [`install_bundled_models`] starts.
|
||||
@@ -321,17 +323,17 @@ fn unpack_bundled_models(app: &slint::android::AndroidApp) {
|
||||
// before it reports the tab available.
|
||||
//
|
||||
// Three detectors, because which one runs is a setting
|
||||
// (`FaceDetector`, docs/faces.md §12.3) and a tablet has no other way to
|
||||
// (`FaceDetector`, docs/dev/faces.md §12.3) and a tablet has no other way to
|
||||
// obtain the one it was not shipped with. Twenty megabytes of APK for
|
||||
// the choice; the embedder is the same for all three.
|
||||
//
|
||||
// Then the three eye-state models (docs/faces.md §17): landmarks, open
|
||||
// Then the three eye-state models (docs/dev/faces.md §17): landmarks, open
|
||||
// or closed, sunglasses. The app indexes without them; with them the
|
||||
// eyes-open filter has something to read, and a tablet has no other way
|
||||
// to get them either.
|
||||
//
|
||||
// The int8 forms beside the three detectors are what the Hexagon runs
|
||||
// (docs/inference.md §5); the engine loads the sibling when the probe
|
||||
// (docs/dev/inference.md §5); the engine loads the sibling when the probe
|
||||
// chose that rung and ignores it otherwise.
|
||||
const BUNDLED: [(&std::ffi::CStr, &str); 14] = [
|
||||
(c"models/scrfd_500m_640.onnx", "scrfd_500m_640.onnx"),
|
||||
@@ -420,7 +422,7 @@ fn unpack_bundled_models(app: &slint::android::AndroidApp) {
|
||||
// on a first launch they were not on disk until this line. The runtime
|
||||
// is in the APK's native library directory beside `libdarkroom.so`,
|
||||
// which is also where Qualcomm's DSP loader has to be pointed for the
|
||||
// Hexagon skel (docs/inference.md §3, §8).
|
||||
// Hexagon skel (docs/dev/inference.md §3, §8).
|
||||
dr_ui::inference::init(native_library_dir().into_iter().collect());
|
||||
}
|
||||
|
||||
@@ -581,6 +583,37 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `dr_ui::manual` starts the manual by class name. A name the manifest
|
||||
/// does not declare is an `ActivityNotFoundException` on the device and a
|
||||
/// Manual button that does nothing, so the three spellings — dr_ui's, the
|
||||
/// manifest's and the Java file's — are checked to be one.
|
||||
#[test]
|
||||
fn the_manual_activity_dr_ui_starts_is_declared() {
|
||||
let manifest = manifest();
|
||||
let wanted = dr_ui::manual::ANDROID_ACTIVITY;
|
||||
let element = manifest
|
||||
.split("<activity")
|
||||
.skip(1)
|
||||
.find(|a| attribute(a, "android:name").as_deref() == Some(wanted))
|
||||
.unwrap_or_else(|| panic!("the manifest declares no activity {wanted}"));
|
||||
assert_eq!(
|
||||
attribute(element, "android:exported").as_deref(),
|
||||
Some("false"),
|
||||
"the manual activity has no reason to be startable by another app"
|
||||
);
|
||||
let java = include_str!("../android/java/paris/tourolle/darkroom/ManualActivity.java");
|
||||
let (package, class) = wanted.rsplit_once('.').expect("unqualified class name");
|
||||
assert!(java.contains(&format!("package {package};")));
|
||||
assert!(java.contains(&format!("class {class} ")));
|
||||
assert!(
|
||||
java.contains(&format!(
|
||||
"EXTRA_ANCHOR = \"{}\"",
|
||||
dr_ui::manual::ANDROID_EXTRA_ANCHOR
|
||||
)),
|
||||
"ManualActivity reads the section from a different extra than dr_ui writes"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_provider_hands_out_one_file_at_a_time_and_nothing_by_itself() {
|
||||
let manifest = manifest();
|
||||
|
||||
@@ -25,3 +25,6 @@ winresource = "0.1"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# The manual's recording hook (dr-ui's `automation`); tools/manual/record.sh
|
||||
# builds with it, nothing else does.
|
||||
automation = ["dr-ui/automation"]
|
||||
|
||||
@@ -21,7 +21,7 @@ fn main() -> anyhow::Result<()> {
|
||||
// build made on a machine that cannot run the application — the Linux CI
|
||||
// producing the Windows binary, checked under Wine — has an exit that
|
||||
// proves the executable starts without opening a window or touching the
|
||||
// user's directories (docs/windows.md §6).
|
||||
// user's directories (docs/dev/windows.md §6).
|
||||
if std::env::args().nth(1).as_deref() == Some("--version") {
|
||||
println!("darkroom-desktop {}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(());
|
||||
@@ -63,7 +63,7 @@ fn main() -> anyhow::Result<()> {
|
||||
|
||||
// Before the window: the probe runs on its own thread and the first
|
||||
// frame does not wait for it, but the models a background job asks for
|
||||
// should already know where the runtime is (docs/inference.md §4).
|
||||
// should already know where the runtime is (docs/dev/inference.md §4).
|
||||
dr_ui::inference::init(runtime_dirs());
|
||||
|
||||
dr_ui::run(paths)?;
|
||||
@@ -78,7 +78,7 @@ fn main() -> anyhow::Result<()> {
|
||||
|
||||
/// Where a desktop package may have put `libonnxruntime`, most specific
|
||||
/// first. None of these existing is the tract build, which is a complete
|
||||
/// application and not an error (docs/inference.md §3).
|
||||
/// application and not an error (docs/dev/inference.md §3).
|
||||
///
|
||||
/// `DARKROOM_ORT_DIR` is for a developer pointing at a runtime that is not
|
||||
/// installed — the wheel's `capi` directory, say. Then beside the executable
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
//! What the catalog's routine reads cost on a real library, off the GUI.
|
||||
//!
|
||||
//! cargo run --release -p dr-catalog --example catalog_bench -- CATALOG.sqlite [FACES_DIR] [--remote PEER.sqlite]
|
||||
//!
|
||||
//! Times `Catalog::open` — which every worker thread pays, including the
|
||||
//! develop view's fetch of each original and each neighbour it prefetches —
|
||||
//! and the backfill that runs inside it, step by step. Run it against a
|
||||
//! *copy* of a real catalog: opening migrates and backfills, which write.
|
||||
//!
|
||||
//! Then what the library screen reads on every keystroke and scroll: the
|
||||
//! filter chips' counts, the keyword panel, and the grid's total. Two of
|
||||
//! those live in `dr-ui` (`library::local_original_count` and the grid
|
||||
//! count), whose `library` module is private; their SQL is spelled here as
|
||||
//! it is spelled there, and has to be kept in step by hand.
|
||||
//!
|
||||
//! `--remote` also times a merge with another device's catalog — the
|
||||
//! server snapshot — which is the pass where the two disagree: faces one
|
||||
//! side found and the other did not, boxes that moved. The merge with a copy
|
||||
//! of itself matches every face by its box and never reaches that work. The
|
||||
//! first of its runs writes what the peer brought; the rest are the steady
|
||||
//! state, so compare two builds from two fresh copies of one catalog.
|
||||
//!
|
||||
//! The figures are for reading side by side before and after a change; they
|
||||
//! are not a gate. Compare the `cpu` column when the machine is busy. The
|
||||
//! answers are printed too, so two builds can be checked for agreeing.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use dr_catalog::{keywords, name_dates, rating, schema, Catalog};
|
||||
|
||||
fn main() {
|
||||
let mut args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let peer = args.iter().position(|a| a == "--remote").map(|at| {
|
||||
let path = args.get(at + 1).map(PathBuf::from).unwrap_or_else(|| {
|
||||
eprintln!("--remote needs a catalog");
|
||||
std::process::exit(2);
|
||||
});
|
||||
args.drain(at..at + 2);
|
||||
path
|
||||
});
|
||||
let Some(path) = args.first().map(PathBuf::from) else {
|
||||
eprintln!("usage: catalog_bench CATALOG.sqlite");
|
||||
std::process::exit(2);
|
||||
};
|
||||
|
||||
// Once untimed, so a migration or a first backfill is not in the figures.
|
||||
drop(Catalog::open(&path).expect("catalog"));
|
||||
|
||||
time("Catalog::open", 20, || {
|
||||
drop(Catalog::open(&path).unwrap());
|
||||
});
|
||||
|
||||
// One develop landing, in the two shapes the app has had. Five opens is
|
||||
// what `fetch_original` and a `holds_original` per prefetched neighbour
|
||||
// cost when each asked on a connection of its own; two is the fetch plus
|
||||
// one connection the prefetch worker keeps for its batch's row checks.
|
||||
// Five images from the library, against an empty cache: the question is
|
||||
// asked the same way whatever the answer.
|
||||
let images: Vec<dr_types::ImageId> = {
|
||||
let c = Catalog::open(&path).unwrap();
|
||||
let mut stmt = c
|
||||
.connection()
|
||||
.prepare("SELECT id FROM images ORDER BY id LIMIT 5 OFFSET 1000")
|
||||
.unwrap();
|
||||
let ids = stmt
|
||||
.query_map([], |r| r.get::<_, i64>(0))
|
||||
.unwrap()
|
||||
.map(|id| dr_types::ImageId(id.unwrap() as u64))
|
||||
.collect();
|
||||
ids
|
||||
};
|
||||
let cache_dir = path.with_extension("bench-cache");
|
||||
let budget = dr_catalog::Budget::default();
|
||||
time("landing: 5 opens (fetch + 4 row checks)", 20, || {
|
||||
let store = dr_catalog::Cache::open(&cache_dir, budget).unwrap();
|
||||
let c = Catalog::open(&path).unwrap();
|
||||
let _ = store.load(c.connection(), images[0], 0).unwrap();
|
||||
for &image in &images[1..] {
|
||||
let store = dr_catalog::Cache::open(&cache_dir, budget).unwrap();
|
||||
let c = Catalog::open(&path).unwrap();
|
||||
let _ = store.holds_original(c.connection(), image);
|
||||
}
|
||||
});
|
||||
time("landing: 2 opens (fetch + held row checks)", 20, || {
|
||||
let store = dr_catalog::Cache::open(&cache_dir, budget).unwrap();
|
||||
let c = Catalog::open(&path).unwrap();
|
||||
let _ = store.load(c.connection(), images[0], 0).unwrap();
|
||||
let held = Catalog::open(&path).unwrap();
|
||||
for &image in &images[1..] {
|
||||
let store = dr_catalog::Cache::open(&cache_dir, budget).unwrap();
|
||||
let _ = store.holds_original(held.connection(), image);
|
||||
}
|
||||
});
|
||||
let _ = std::fs::remove_dir_all(&cache_dir);
|
||||
|
||||
let catalog = Catalog::open(&path).unwrap();
|
||||
let conn = catalog.connection();
|
||||
time("schema::backfill (all steps)", 20, || {
|
||||
schema::backfill(conn).unwrap();
|
||||
});
|
||||
time(" rating::ensure_default_versions", 20, || {
|
||||
rating::ensure_default_versions(conn).unwrap();
|
||||
});
|
||||
time(" rating::align_default_version_uuids", 20, || {
|
||||
rating::align_default_version_uuids(conn).unwrap();
|
||||
});
|
||||
time(" keywords::adopt_orphan_terms", 20, || {
|
||||
keywords::adopt_orphan_terms(conn).unwrap();
|
||||
});
|
||||
time(" name_dates::fill", 20, || {
|
||||
name_dates::fill(conn, None).unwrap();
|
||||
});
|
||||
|
||||
interactive(conn);
|
||||
|
||||
// A sync pass: the upload snapshot, then a merge of the catalog with a
|
||||
// copy of itself — every row a match, which is the steady state.
|
||||
let scratch = path.with_extension("bench-snapshot");
|
||||
time("snapshot_for_upload", 3, || {
|
||||
let _ = std::fs::remove_file(&scratch);
|
||||
catalog.snapshot_for_upload(&scratch).unwrap();
|
||||
});
|
||||
println!(
|
||||
" snapshot size {:.1} MB",
|
||||
std::fs::metadata(&scratch).map(|m| m.len()).unwrap_or(0) as f64 / 1e6
|
||||
);
|
||||
let remote = path.with_extension("bench-remote");
|
||||
let _ = std::fs::remove_file(&remote);
|
||||
conn.execute("VACUUM INTO ?1", [remote.to_string_lossy().as_ref()])
|
||||
.unwrap();
|
||||
time("merge_remote_catalog (self)", 5, || {
|
||||
catalog.merge_remote_catalog(&remote).unwrap();
|
||||
});
|
||||
let _ = std::fs::remove_file(&scratch);
|
||||
let _ = std::fs::remove_file(&remote);
|
||||
|
||||
if let Some(peer) = &peer {
|
||||
// A copy, so nothing the merge does to its input reaches the file
|
||||
// the caller named.
|
||||
std::fs::copy(peer, &remote).unwrap();
|
||||
let mut first = None;
|
||||
time("merge_remote_catalog (--remote)", 5, || {
|
||||
let report = catalog.merge_remote_catalog(&remote).unwrap();
|
||||
first.get_or_insert(report);
|
||||
});
|
||||
println!(" first pass: {first:?}");
|
||||
let _ = std::fs::remove_file(&remote);
|
||||
}
|
||||
|
||||
// The face half of a sync pass, against a copy of the face store: both
|
||||
// directions in the steady state, where nothing is new either way.
|
||||
if let Some(faces) = args.get(1).map(PathBuf::from) {
|
||||
let model = "scrfd_10g+w600k_mbf";
|
||||
let mut store = dr_catalog::FaceShardStore::open(&faces).unwrap();
|
||||
println!(
|
||||
" first export sent {}, first import adopted {}",
|
||||
dr_catalog::face_shard::export_to_shards(conn, &mut store, model).unwrap(),
|
||||
dr_catalog::face_shard::import_from_shards(conn, &store, model).unwrap()
|
||||
);
|
||||
time("face_shard::export_to_shards (steady)", 5, || {
|
||||
dr_catalog::face_shard::export_to_shards(conn, &mut store, model).unwrap();
|
||||
});
|
||||
time("face_shard::import_from_shards (steady)", 5, || {
|
||||
dr_catalog::face_shard::import_from_shards(conn, &store, model).unwrap();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// What one click in the library reads: a rating or label keystroke
|
||||
/// refreshes the chips, a selection change redraws the keyword panel, and
|
||||
/// every scroll reload counts the grid.
|
||||
fn interactive(conn: &rusqlite::Connection) {
|
||||
println!(
|
||||
" rating_histogram {:?}, label_histogram {:?}, local originals {}, grid {} / rated {}",
|
||||
rating::rating_histogram(conn).unwrap(),
|
||||
rating::label_histogram(conn).unwrap(),
|
||||
local_original_count(conn),
|
||||
grid_count(conn, ""),
|
||||
grid_count(conn, RATED_AT_LEAST_ONE),
|
||||
);
|
||||
let words = keywords::list(conn).unwrap();
|
||||
println!(
|
||||
" keywords::list {} terms, digest {:016x}",
|
||||
words.len(),
|
||||
digest(&format!("{words:?}"))
|
||||
);
|
||||
// A selection the size of a grid window, from the start of the library.
|
||||
let selection: Vec<dr_types::ImageId> = conn
|
||||
.prepare("SELECT id FROM images ORDER BY id LIMIT 120")
|
||||
.unwrap()
|
||||
.query_map([], |r| Ok(dr_types::ImageId(r.get::<_, i64>(0)? as u64)))
|
||||
.unwrap()
|
||||
.collect::<Result<_, _>>()
|
||||
.unwrap();
|
||||
println!(
|
||||
" keywords::for_images digest {:016x}",
|
||||
digest(&format!(
|
||||
"{:?}",
|
||||
keywords::for_images(conn, &selection).unwrap()
|
||||
))
|
||||
);
|
||||
|
||||
time("rating::rating_histogram", 50, || {
|
||||
rating::rating_histogram(conn).unwrap();
|
||||
});
|
||||
time("library::local_original_count", 50, || {
|
||||
local_original_count(conn);
|
||||
});
|
||||
time("rating::label_histogram", 50, || {
|
||||
rating::label_histogram(conn).unwrap();
|
||||
});
|
||||
time("keywords::list", 50, || {
|
||||
keywords::list(conn).unwrap();
|
||||
});
|
||||
time("keywords::for_images (120)", 50, || {
|
||||
keywords::for_images(conn, &selection).unwrap();
|
||||
});
|
||||
time("grid count", 50, || {
|
||||
grid_count(conn, "");
|
||||
});
|
||||
time("grid count, rated >= 1", 50, || {
|
||||
grid_count(conn, RATED_AT_LEAST_ONE);
|
||||
});
|
||||
}
|
||||
|
||||
/// `dr_ui::library::local_original_count`, spelled as it is there.
|
||||
fn local_original_count(conn: &rusqlite::Connection) -> i64 {
|
||||
conn.query_row(
|
||||
"SELECT count(*) FROM images i
|
||||
WHERE i.shadowed_by IS NULL AND i.trashed_at IS NULL
|
||||
AND i.id IN (SELECT ic.image_id FROM image_cache ic
|
||||
WHERE ic.tier_actual >= 2)",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// `RatingFilter::sql` for one star and up.
|
||||
const RATED_AT_LEAST_ONE: &str = " AND coalesce((SELECT dv.rating FROM versions dv
|
||||
WHERE dv.image_id = i.id AND dv.is_default = 1
|
||||
LIMIT 1), 0) >= 1";
|
||||
|
||||
/// `dr_ui::library::total_images_filtered`, spelled as it is there.
|
||||
fn grid_count(conn: &rusqlite::Connection, rated: &str) -> i64 {
|
||||
let visible = "i.shadowed_by IS NULL AND i.trashed_at IS NULL";
|
||||
let hidden = dr_catalog::bursts::collapsed_away_frames("i");
|
||||
conn.query_row(
|
||||
&format!(
|
||||
"SELECT (SELECT count(*) FROM images i WHERE {visible}{rated})
|
||||
- (SELECT count(*) FROM {hidden} AND {visible}{rated})"
|
||||
),
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// FNV-1a, to print a long answer as something two runs can compare.
|
||||
fn digest(s: &str) -> u64 {
|
||||
s.bytes().fold(0xcbf29ce484222325, |h, b| {
|
||||
(h ^ u64::from(b)).wrapping_mul(0x100000001b3)
|
||||
})
|
||||
}
|
||||
|
||||
/// Run `f` a few times and print the best wall-clock, the median, and the
|
||||
/// best CPU time — the figure to compare across runs on a busy machine.
|
||||
fn time(label: &str, runs: usize, mut f: impl FnMut()) {
|
||||
let mut wall: Vec<Duration> = Vec::with_capacity(runs);
|
||||
let mut cpu: Vec<Duration> = Vec::with_capacity(runs);
|
||||
for _ in 0..runs {
|
||||
let c = cpu_now();
|
||||
let t = Instant::now();
|
||||
f();
|
||||
wall.push(t.elapsed());
|
||||
cpu.push(cpu_now().saturating_sub(c));
|
||||
}
|
||||
wall.sort();
|
||||
cpu.sort();
|
||||
println!(
|
||||
"{label:42} best {:8.2} ms median {:8.2} ms cpu {:8.2} ms",
|
||||
wall[0].as_secs_f64() * 1e3,
|
||||
wall[runs / 2].as_secs_f64() * 1e3,
|
||||
cpu[0].as_secs_f64() * 1e3
|
||||
);
|
||||
}
|
||||
|
||||
/// This thread's time on a CPU so far, from `/proc/self/schedstat`; zero where
|
||||
/// the file is missing, which only makes the CPU column useless.
|
||||
fn cpu_now() -> Duration {
|
||||
std::fs::read_to_string("/proc/self/schedstat")
|
||||
.ok()
|
||||
.and_then(|s| s.split_whitespace().next()?.parse::<u64>().ok())
|
||||
.map(Duration::from_nanos)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Run the people and face deduplication (#78) on a copy of a real catalog.
|
||||
//!
|
||||
//! cargo run --release -p dr-catalog --example dedup_people -- COPY.sqlite [--peer PEER_COPY.sqlite]
|
||||
//!
|
||||
//! It writes: run it against a *copy* (`sqlite3 catalog.sqlite ".backup
|
||||
//! copy.sqlite"`), never the library's own file. Prints the live people and
|
||||
//! faces before and after, what the first run merged and kept apart, and
|
||||
//! how long the first and a second run took -- the second is the cost the
|
||||
//! job adds to every sync once a catalog is clean.
|
||||
//!
|
||||
//! `--peer` then plays a sync round trip with another device's catalog (a
|
||||
//! copy of the server snapshot, which it also writes): the peer merges this
|
||||
//! one as the previous release would, with no job after it, then this one
|
||||
//! merges the peer back through `sync::merge_remote`, twice. The named
|
||||
//! people each side lists are printed after each step; they should agree.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
|
||||
use dr_catalog::{dedup_people, merge, schema, sync};
|
||||
use rusqlite::Connection;
|
||||
|
||||
fn open(path: &std::path::Path) -> Connection {
|
||||
let conn = Connection::open(path).expect("open the catalog copy");
|
||||
schema::configure(&conn).expect("configure");
|
||||
schema::migrate(&conn).expect("migrate");
|
||||
conn
|
||||
}
|
||||
|
||||
/// The named people a device lists, as `name (uuid prefix)`, sorted.
|
||||
fn named(conn: &Connection) -> Vec<String> {
|
||||
let mut v: Vec<String> = conn
|
||||
.prepare(
|
||||
"SELECT name, substr(uuid, 1, 8) FROM people
|
||||
WHERE merged_into IS NULL AND trim(name) <> ''",
|
||||
)
|
||||
.unwrap()
|
||||
.query_map([], |r| {
|
||||
Ok(format!(
|
||||
"{} ({})",
|
||||
r.get::<_, String>(0)?,
|
||||
r.get::<_, String>(1)?
|
||||
))
|
||||
})
|
||||
.unwrap()
|
||||
.collect::<Result<_, _>>()
|
||||
.unwrap();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let mut args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let peer = args.iter().position(|a| a == "--peer").map(|at| {
|
||||
let p = PathBuf::from(&args[at + 1]);
|
||||
args.drain(at..at + 2);
|
||||
p
|
||||
});
|
||||
let Some(path) = args.first().map(PathBuf::from) else {
|
||||
eprintln!("usage: dedup_people COPY.sqlite [--peer PEER_COPY.sqlite]");
|
||||
std::process::exit(2);
|
||||
};
|
||||
let conn = open(&path);
|
||||
|
||||
let counts = |label: &str| {
|
||||
let q = |sql: &str| -> i64 { conn.query_row(sql, [], |r| r.get(0)).unwrap() };
|
||||
println!(
|
||||
"{label}: {} people listed ({} named), {} redirects, {} faces, {} confirmed",
|
||||
q("SELECT COUNT(*) FROM people WHERE merged_into IS NULL"),
|
||||
q("SELECT COUNT(*) FROM people WHERE merged_into IS NULL AND trim(name) <> ''"),
|
||||
q("SELECT COUNT(*) FROM people WHERE merged_into IS NOT NULL"),
|
||||
q("SELECT COUNT(*) FROM faces"),
|
||||
q("SELECT COUNT(*) FROM face_person WHERE confirmed = 1"),
|
||||
);
|
||||
};
|
||||
|
||||
counts("before");
|
||||
for pass in ["first", "second", "third"] {
|
||||
let started = Instant::now();
|
||||
let report = dedup_people::run(&conn).expect("dedup");
|
||||
let took = started.elapsed();
|
||||
println!("{pass} run: {took:?}, changed: {}", report.changed());
|
||||
if pass == "first" {
|
||||
println!(" merged: {:?}", report.merged);
|
||||
for k in &report.kept_apart {
|
||||
println!(
|
||||
" kept apart: {:?} ({}) from {}: {:?}",
|
||||
k.name, k.uuid, k.survivor, k.why
|
||||
);
|
||||
}
|
||||
println!(
|
||||
" redirects followed {}, cycles broken {}, faces fused {}, faces confirmed apart {}",
|
||||
report.redirects_followed,
|
||||
report.cycles_broken,
|
||||
report.faces_fused,
|
||||
report.faces_confirmed_apart
|
||||
);
|
||||
}
|
||||
}
|
||||
counts("after");
|
||||
|
||||
let Some(peer_path) = peer else { return };
|
||||
let peer = open(&peer_path);
|
||||
let show = |step: &str| {
|
||||
let (ours, theirs) = (named(&conn), named(&peer));
|
||||
println!(
|
||||
"{step}: this device lists {} named, the peer {}; {}",
|
||||
ours.len(),
|
||||
theirs.len(),
|
||||
if ours == theirs {
|
||||
"the same".to_string()
|
||||
} else {
|
||||
format!("differ:\n here {ours:?}\n peer {theirs:?}")
|
||||
}
|
||||
);
|
||||
};
|
||||
show("before the round trip");
|
||||
for round in 1..=2 {
|
||||
peer.execute(
|
||||
"ATTACH DATABASE ?1 AS remote_cat",
|
||||
[path.to_string_lossy().as_ref()],
|
||||
)
|
||||
.unwrap();
|
||||
let theirs = merge::merge_all(&peer).expect("the peer's merge");
|
||||
peer.execute("DETACH DATABASE remote_cat", []).unwrap();
|
||||
println!(
|
||||
"round {round}: the peer took {} people updated, {} inserted",
|
||||
theirs.people_updated, theirs.people_inserted
|
||||
);
|
||||
show(&format!("round {round}, after the peer's merge"));
|
||||
let started = Instant::now();
|
||||
let ours = sync::merge_remote(&conn, &peer_path).expect("our merge");
|
||||
println!(
|
||||
"round {round}: merge_remote with the job took {:?}; {} people updated, {} inserted",
|
||||
started.elapsed(),
|
||||
ours.people_updated,
|
||||
ours.people_inserted
|
||||
);
|
||||
show(&format!("round {round}, after ours"));
|
||||
}
|
||||
}
|
||||
@@ -315,7 +315,7 @@ fn full_library(
|
||||
|
||||
// The three phases, separately, because "a regroup takes n seconds" does
|
||||
// not tell anyone which half to optimise — and the answer differs between
|
||||
// a desktop and a tablet (docs/faces.md §9).
|
||||
// a desktop and a tablet (docs/dev/faces.md §9).
|
||||
{
|
||||
let dim = candidates.first().map(|c| c.embedding.len()).unwrap_or(0);
|
||||
let flat: Vec<f32> = candidates
|
||||
|
||||
@@ -0,0 +1,580 @@
|
||||
//! TRACES: FR-EXP-10 | FR-EXP-6 | FR-CAT-7
|
||||
//! Albums: named export folders, and which photographs went into each.
|
||||
//!
|
||||
//! An album is where finished pictures go — a folder of JPEGs somebody else
|
||||
//! looks at — as opposed to a collection, which is a set of originals the
|
||||
//! photographer works on. The folder holds only the exported files. What the
|
||||
//! catalog adds is the link back: each export is recorded against the image
|
||||
//! it was rendered from, so opening an album in the library shows the RAWs
|
||||
//! behind its JPEGs, and re-exporting after an edit is one selection away.
|
||||
//!
|
||||
//! # Where the folder is, and why that is two tables
|
||||
//!
|
||||
//! An album's folder is either on the library's server or on this device.
|
||||
//!
|
||||
//! A **server folder** is one path on the account, the same from every device
|
||||
//! signed in to it, so it lives on the album row and syncs with it.
|
||||
//!
|
||||
//! A **local folder** — a filesystem path on a desktop, a Storage Access
|
||||
//! Framework tree on Android — means nothing on any other device. It lives in
|
||||
//! `album_folders`, which the merge never reads and the upload snapshot drops
|
||||
//! ([`crate::sync::snapshot_for_upload`]). An album made on the desktop with a
|
||||
//! local folder therefore reaches the tablet as an album with no folder there
|
||||
//! yet, which is true, and which the tablet can fix by choosing one.
|
||||
//!
|
||||
//! # Created on first use, not by a migration
|
||||
//!
|
||||
//! A new schema version makes every older build refuse this catalog's
|
||||
//! snapshot at sync (`crate::sync::remote_is_mergeable`), so the tablet would
|
||||
//! stop merging collections, keywords and people until it was updated — for
|
||||
//! a feature it does not have. The tables are created by [`ensure_tables`]
|
||||
//! instead, the way `dedup_probes` is; an older build that meets them ignores
|
||||
//! them, and its merge keeps working.
|
||||
//!
|
||||
//! # Sync
|
||||
//!
|
||||
//! Albums merge by uuid and revision with tombstones, and their exports as a
|
||||
//! set union keyed on the image's server file id — the rules
|
||||
//! [`crate::merge`] applies to collections, for the same reasons.
|
||||
|
||||
use rusqlite::{Connection, OptionalExtension};
|
||||
|
||||
use dr_types::ImageId;
|
||||
|
||||
use crate::error::CatalogError;
|
||||
|
||||
/// Identifies an album within one catalog. Local, like every integer id here;
|
||||
/// the uuid is what crosses devices.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct AlbumId(pub u64);
|
||||
|
||||
/// Where an album's files go.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Place {
|
||||
/// A folder on the library's server, relative to the account root, with no
|
||||
/// leading slash. The same on every device.
|
||||
Server(String),
|
||||
/// A folder on this device: a filesystem path, or on Android a SAF tree
|
||||
/// URI. Never synced.
|
||||
Local(String),
|
||||
}
|
||||
|
||||
/// One album, as the sidebar and the export sheet show it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Album {
|
||||
pub id: AlbumId,
|
||||
pub uuid: String,
|
||||
pub name: String,
|
||||
/// Where exports go from this device, or `None` for an album whose folder
|
||||
/// is local to another device and has not been chosen here.
|
||||
pub place: Option<Place>,
|
||||
/// Distinct photographs exported into it — what the grid shows when the
|
||||
/// album is opened.
|
||||
pub sources: usize,
|
||||
}
|
||||
|
||||
/// Create the album tables if this catalog does not have them yet.
|
||||
///
|
||||
/// Cheap when they exist: `IF NOT EXISTS` is answered from the schema, and
|
||||
/// every function below calls this first so no caller has to remember to.
|
||||
pub fn ensure_tables(conn: &Connection) -> Result<(), CatalogError> {
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS albums (
|
||||
id INTEGER PRIMARY KEY,
|
||||
-- The merge identity; the integer id is local.
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
-- A folder on the server, relative to the account root. NULL for
|
||||
-- an album whose folder is local to some device.
|
||||
server_path TEXT,
|
||||
created INTEGER NOT NULL,
|
||||
revision INTEGER NOT NULL DEFAULT 1,
|
||||
modified INTEGER NOT NULL,
|
||||
deleted INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
-- One row per file written into an album. Keyed on the file, not the
|
||||
-- image: a photograph exported twice — two crops, or once before an
|
||||
-- edit and once after — is two files in the folder and two rows here.
|
||||
CREATE TABLE IF NOT EXISTS album_exports (
|
||||
album_id INTEGER NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
|
||||
file_name TEXT NOT NULL,
|
||||
image_id INTEGER NOT NULL REFERENCES images(id) ON DELETE CASCADE,
|
||||
exported_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (album_id, file_name)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS album_exports_image ON album_exports(image_id);
|
||||
-- This device's folder for an album. Never merged, never uploaded.
|
||||
CREATE TABLE IF NOT EXISTS album_folders (
|
||||
album_id INTEGER PRIMARY KEY REFERENCES albums(id) ON DELETE CASCADE,
|
||||
folder TEXT NOT NULL
|
||||
);",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Make an album.
|
||||
///
|
||||
/// The name is trimmed and must not be empty; two albums may share one, as
|
||||
/// two collections may, because the uuid is the identity and refusing a
|
||||
/// duplicate name here would refuse it on one device and not another.
|
||||
pub fn create(conn: &Connection, name: &str, place: &Place) -> Result<AlbumId, CatalogError> {
|
||||
ensure_tables(conn)?;
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return Err(CatalogError::EmptyName);
|
||||
}
|
||||
let now = now_secs();
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute(
|
||||
"INSERT INTO albums(uuid, name, server_path, created, revision, modified)
|
||||
VALUES (?1, ?2, ?3, ?4, 1, ?4)",
|
||||
rusqlite::params![
|
||||
crate::collections::new_uuid(),
|
||||
name,
|
||||
server_path(place),
|
||||
now
|
||||
],
|
||||
)?;
|
||||
let id = AlbumId(tx.last_insert_rowid() as u64);
|
||||
if let Place::Local(folder) = place {
|
||||
tx.execute(
|
||||
"INSERT INTO album_folders(album_id, folder) VALUES (?1, ?2)",
|
||||
rusqlite::params![id.0 as i64, folder],
|
||||
)?;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Rename an album. The folder keeps its name: the album is what the
|
||||
/// photographer calls it, the folder is what is already out there.
|
||||
pub fn rename(conn: &Connection, id: AlbumId, name: &str) -> Result<(), CatalogError> {
|
||||
ensure_tables(conn)?;
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return Err(CatalogError::EmptyName);
|
||||
}
|
||||
let n = conn.execute(
|
||||
"UPDATE albums SET name = ?2, revision = revision + 1, modified = ?3
|
||||
WHERE id = ?1 AND deleted = 0",
|
||||
rusqlite::params![id.0 as i64, name, now_secs()],
|
||||
)?;
|
||||
if n == 0 {
|
||||
return Err(CatalogError::NoSuchAlbum(id.0));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Point an album at a different folder, from this device.
|
||||
///
|
||||
/// A server folder replaces the synced path, and bumps the revision so the
|
||||
/// move reaches every device. A local folder is recorded for this device
|
||||
/// only; it also clears a server path, because an album goes to one place and
|
||||
/// the photographer has just said which.
|
||||
pub fn set_place(conn: &Connection, id: AlbumId, place: &Place) -> Result<(), CatalogError> {
|
||||
ensure_tables(conn)?;
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
let n = tx.execute(
|
||||
"UPDATE albums SET server_path = ?2, revision = revision + 1, modified = ?3
|
||||
WHERE id = ?1 AND deleted = 0",
|
||||
rusqlite::params![id.0 as i64, server_path(place), now_secs()],
|
||||
)?;
|
||||
if n == 0 {
|
||||
return Err(CatalogError::NoSuchAlbum(id.0));
|
||||
}
|
||||
match place {
|
||||
Place::Local(folder) => tx.execute(
|
||||
"INSERT INTO album_folders(album_id, folder) VALUES (?1, ?2)
|
||||
ON CONFLICT(album_id) DO UPDATE SET folder = excluded.folder",
|
||||
rusqlite::params![id.0 as i64, folder],
|
||||
)?,
|
||||
Place::Server(_) => tx.execute(
|
||||
"DELETE FROM album_folders WHERE album_id = ?1",
|
||||
[id.0 as i64],
|
||||
)?,
|
||||
};
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete an album, leaving a tombstone. The files in its folder are not
|
||||
/// touched: they are finished work somebody may already have been sent a
|
||||
/// link to, and the album was only ever this catalog's note of them.
|
||||
pub fn delete(conn: &Connection, id: AlbumId) -> Result<(), CatalogError> {
|
||||
ensure_tables(conn)?;
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
let n = tx.execute(
|
||||
"UPDATE albums SET deleted = 1, revision = revision + 1, modified = ?2
|
||||
WHERE id = ?1 AND deleted = 0",
|
||||
rusqlite::params![id.0 as i64, now_secs()],
|
||||
)?;
|
||||
if n == 0 {
|
||||
return Err(CatalogError::NoSuchAlbum(id.0));
|
||||
}
|
||||
tx.execute(
|
||||
"DELETE FROM album_exports WHERE album_id = ?1",
|
||||
[id.0 as i64],
|
||||
)?;
|
||||
tx.execute(
|
||||
"DELETE FROM album_folders WHERE album_id = ?1",
|
||||
[id.0 as i64],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every live album, by name, with how many photographs each holds.
|
||||
///
|
||||
/// One statement: the counts are aggregated from `album_exports` first and
|
||||
/// joined to the (few) albums, not counted per row.
|
||||
pub fn list(conn: &Connection) -> Result<Vec<Album>, CatalogError> {
|
||||
ensure_tables(conn)?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT a.id, a.uuid, a.name, a.server_path, f.folder, coalesce(e.n, 0)
|
||||
FROM albums a
|
||||
LEFT JOIN album_folders f ON f.album_id = a.id
|
||||
LEFT JOIN (SELECT album_id, count(DISTINCT image_id) AS n
|
||||
FROM album_exports GROUP BY album_id) e
|
||||
ON e.album_id = a.id
|
||||
WHERE a.deleted = 0
|
||||
ORDER BY a.name COLLATE NOCASE, a.id",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map([], album_from_row)?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// One album, or `None` if it is gone.
|
||||
pub fn get(conn: &Connection, id: AlbumId) -> Result<Option<Album>, CatalogError> {
|
||||
ensure_tables(conn)?;
|
||||
Ok(conn
|
||||
.query_row(
|
||||
"SELECT a.id, a.uuid, a.name, a.server_path, f.folder,
|
||||
(SELECT count(DISTINCT image_id) FROM album_exports WHERE album_id = a.id)
|
||||
FROM albums a
|
||||
LEFT JOIN album_folders f ON f.album_id = a.id
|
||||
WHERE a.id = ?1 AND a.deleted = 0",
|
||||
[id.0 as i64],
|
||||
album_from_row,
|
||||
)
|
||||
.optional()?)
|
||||
}
|
||||
|
||||
/// The album with this uuid, if this catalog holds it live.
|
||||
pub fn id_for_uuid(conn: &Connection, uuid: &str) -> Result<Option<AlbumId>, CatalogError> {
|
||||
ensure_tables(conn)?;
|
||||
Ok(conn
|
||||
.query_row(
|
||||
"SELECT id FROM albums WHERE uuid = ?1 AND deleted = 0",
|
||||
[uuid],
|
||||
|r| r.get::<_, i64>(0),
|
||||
)
|
||||
.optional()?
|
||||
.map(|id| AlbumId(id as u64)))
|
||||
}
|
||||
|
||||
/// Record the files one export wrote into an album, and which image each
|
||||
/// came from. One transaction for the batch, however many files it placed.
|
||||
///
|
||||
/// A file name already recorded is re-pointed at the image that wrote it
|
||||
/// last: an export that overwrote `IMG_0001.jpg` replaced the picture in the
|
||||
/// folder, and the link must say what is there now.
|
||||
pub fn record_exports(
|
||||
conn: &Connection,
|
||||
id: AlbumId,
|
||||
files: &[(ImageId, String)],
|
||||
) -> Result<(), CatalogError> {
|
||||
ensure_tables(conn)?;
|
||||
if files.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
let now = now_secs();
|
||||
{
|
||||
let mut insert = tx.prepare(
|
||||
"INSERT INTO album_exports(album_id, file_name, image_id, exported_at)
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(album_id, file_name) DO UPDATE SET
|
||||
image_id = excluded.image_id, exported_at = excluded.exported_at",
|
||||
)?;
|
||||
for (image, name) in files {
|
||||
insert.execute(rusqlite::params![id.0 as i64, name, image.0 as i64, now])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every file name an album records, for an export choosing a name to know
|
||||
/// what it would land on.
|
||||
///
|
||||
/// A server album cannot be asked while the export is queued offline, and
|
||||
/// the names this app put there are the ones a second export of the same
|
||||
/// photographs will collide with. One read of the album's rows, not one per
|
||||
/// candidate name.
|
||||
pub fn file_names(
|
||||
conn: &Connection,
|
||||
id: AlbumId,
|
||||
) -> Result<std::collections::HashSet<String>, CatalogError> {
|
||||
ensure_tables(conn)?;
|
||||
let mut stmt = conn.prepare("SELECT file_name FROM album_exports WHERE album_id = ?1")?;
|
||||
let rows = stmt
|
||||
.query_map([id.0 as i64], |r| r.get(0))?
|
||||
.collect::<Result<_, _>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// A file the upload had to give another name: the server held one by the
|
||||
/// name the export recorded, put there by something this catalog never
|
||||
/// saw. The album row follows the file to the name it was given.
|
||||
///
|
||||
/// By the album's server folder, because that is all an outbox entry knows.
|
||||
/// `folder` is spelled as [`Place::Server`] spells it, without slashes at
|
||||
/// either end.
|
||||
pub fn rename_export(
|
||||
conn: &Connection,
|
||||
folder: &str,
|
||||
from: &str,
|
||||
to: &str,
|
||||
) -> Result<(), CatalogError> {
|
||||
ensure_tables(conn)?;
|
||||
conn.execute(
|
||||
"UPDATE OR REPLACE album_exports SET file_name = ?3
|
||||
WHERE file_name = ?2
|
||||
AND album_id IN (SELECT id FROM albums WHERE server_path = ?1 AND deleted = 0)",
|
||||
rusqlite::params![folder.trim_matches('/'), from, to],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The photographs behind an album's files, most recently exported first —
|
||||
/// what the grid shows when the album is opened.
|
||||
pub fn sources(conn: &Connection, id: AlbumId) -> Result<Vec<ImageId>, CatalogError> {
|
||||
ensure_tables(conn)?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT image_id FROM album_exports
|
||||
WHERE album_id = ?1
|
||||
GROUP BY image_id
|
||||
ORDER BY max(exported_at) DESC, image_id",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map([id.0 as i64], |r| r.get::<_, i64>(0))?
|
||||
.map(|r| r.map(|i| ImageId(i as u64)))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// The names of the files an image left in an album — the "which JPEG is
|
||||
/// this" half of the link.
|
||||
pub fn files_of(
|
||||
conn: &Connection,
|
||||
id: AlbumId,
|
||||
image: ImageId,
|
||||
) -> Result<Vec<String>, CatalogError> {
|
||||
ensure_tables(conn)?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT file_name FROM album_exports
|
||||
WHERE album_id = ?1 AND image_id = ?2
|
||||
ORDER BY exported_at DESC, file_name",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params![id.0 as i64, image.0 as i64], |r| r.get(0))?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
fn album_from_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<Album> {
|
||||
let server: Option<String> = r.get(3)?;
|
||||
let local: Option<String> = r.get(4)?;
|
||||
Ok(Album {
|
||||
id: AlbumId(r.get::<_, i64>(0)? as u64),
|
||||
uuid: r.get(1)?,
|
||||
name: r.get(2)?,
|
||||
// A server path wins: `set_place` clears the local folder when it
|
||||
// sets one, so both being present means a merge brought a server
|
||||
// path in over a local choice — and the newer revision decided that.
|
||||
place: server.map(Place::Server).or(local.map(Place::Local)),
|
||||
sources: r.get::<_, i64>(5)? as usize,
|
||||
})
|
||||
}
|
||||
|
||||
fn server_path(place: &Place) -> Option<&str> {
|
||||
match place {
|
||||
Place::Server(p) => Some(p.trim_matches('/')),
|
||||
Place::Local(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn now_secs() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A catalog, and the connection to it. The `Catalog` has to outlive the
|
||||
/// connection it hands out, so tests hold both.
|
||||
fn catalog() -> crate::Catalog {
|
||||
let cat = crate::Catalog::in_memory().unwrap();
|
||||
cat.connection()
|
||||
.execute(
|
||||
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
cat
|
||||
}
|
||||
|
||||
fn image(conn: &Connection, path: &str) -> ImageId {
|
||||
conn.execute(
|
||||
"INSERT INTO images(root_id, source_ref, added_at) VALUES (1, ?1, 0)",
|
||||
[path],
|
||||
)
|
||||
.unwrap();
|
||||
ImageId(conn.last_insert_rowid() as u64)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_album_lists_with_its_place_and_no_photographs() {
|
||||
let cat = catalog();
|
||||
let conn = cat.connection();
|
||||
let web = create(conn, " Web ", &Place::Server("Shared/Web/".into())).unwrap();
|
||||
let print = create(conn, "Print", &Place::Local("/mnt/print".into())).unwrap();
|
||||
|
||||
let all = list(conn).unwrap();
|
||||
assert_eq!(all.len(), 2);
|
||||
assert_eq!(all[0].id, print, "sorted by name");
|
||||
assert_eq!(all[0].place, Some(Place::Local("/mnt/print".into())));
|
||||
assert_eq!(all[1].id, web);
|
||||
assert_eq!(all[1].name, "Web", "trimmed");
|
||||
assert_eq!(all[1].place, Some(Place::Server("Shared/Web".into())));
|
||||
assert_eq!(all[1].sources, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_name_is_refused() {
|
||||
let cat = catalog();
|
||||
let conn = cat.connection();
|
||||
assert!(matches!(
|
||||
create(conn, " ", &Place::Local("/x".into())),
|
||||
Err(CatalogError::EmptyName)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_link_files_back_to_their_images() {
|
||||
let cat = catalog();
|
||||
let conn = cat.connection();
|
||||
let album = create(conn, "Web", &Place::Local("/out".into())).unwrap();
|
||||
let a = image(conn, "a.cr3");
|
||||
let b = image(conn, "b.cr3");
|
||||
|
||||
record_exports(
|
||||
conn,
|
||||
album,
|
||||
&[
|
||||
(a, "a.jpg".into()),
|
||||
(a, "a (1).jpg".into()),
|
||||
(b, "b.jpg".into()),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let got = get(conn, album).unwrap().unwrap();
|
||||
assert_eq!(got.sources, 2, "two photographs, three files");
|
||||
let mut s = sources(conn, album).unwrap();
|
||||
s.sort();
|
||||
assert_eq!(s, vec![a, b]);
|
||||
assert_eq!(files_of(conn, album, a).unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_overwritten_file_points_at_what_wrote_it_last() {
|
||||
let cat = catalog();
|
||||
let conn = cat.connection();
|
||||
let album = create(conn, "Web", &Place::Local("/out".into())).unwrap();
|
||||
let a = image(conn, "a.cr3");
|
||||
let b = image(conn, "b.cr3");
|
||||
record_exports(conn, album, &[(a, "x.jpg".into())]).unwrap();
|
||||
record_exports(conn, album, &[(b, "x.jpg".into())]).unwrap();
|
||||
assert_eq!(sources(conn, album).unwrap(), vec![b]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_renamed_upload_moves_the_row_of_the_server_album_only() {
|
||||
let cat = catalog();
|
||||
let conn = cat.connection();
|
||||
let web = create(conn, "Web", &Place::Server("Albums/Web".into())).unwrap();
|
||||
let other = create(conn, "Other", &Place::Server("Albums/Other".into())).unwrap();
|
||||
let a = image(conn, "a.cr3");
|
||||
record_exports(conn, web, &[(a, "a.jpg".into())]).unwrap();
|
||||
record_exports(conn, other, &[(a, "a.jpg".into())]).unwrap();
|
||||
|
||||
rename_export(conn, "/Albums/Web", "a.jpg", "a-1.jpg").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
file_names(conn, web).unwrap(),
|
||||
["a-1.jpg".to_string()].into()
|
||||
);
|
||||
assert_eq!(
|
||||
file_names(conn, other).unwrap(),
|
||||
["a.jpg".to_string()].into()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_to_the_server_forgets_the_local_folder() {
|
||||
let cat = catalog();
|
||||
let conn = cat.connection();
|
||||
let album = create(conn, "Web", &Place::Local("/out".into())).unwrap();
|
||||
set_place(conn, album, &Place::Server("Web".into())).unwrap();
|
||||
assert_eq!(
|
||||
get(conn, album).unwrap().unwrap().place,
|
||||
Some(Place::Server("Web".into()))
|
||||
);
|
||||
set_place(conn, album, &Place::Local("/again".into())).unwrap();
|
||||
assert_eq!(
|
||||
get(conn, album).unwrap().unwrap().place,
|
||||
Some(Place::Local("/again".into()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_deleted_album_is_gone_and_its_uuid_no_longer_resolves() {
|
||||
let cat = catalog();
|
||||
let conn = cat.connection();
|
||||
let album = create(conn, "Web", &Place::Local("/out".into())).unwrap();
|
||||
let uuid = get(conn, album).unwrap().unwrap().uuid;
|
||||
let a = image(conn, "a.cr3");
|
||||
record_exports(conn, album, &[(a, "a.jpg".into())]).unwrap();
|
||||
|
||||
delete(conn, album).unwrap();
|
||||
assert!(list(conn).unwrap().is_empty());
|
||||
assert_eq!(id_for_uuid(conn, &uuid).unwrap(), None);
|
||||
assert!(matches!(
|
||||
rename(conn, album, "Again"),
|
||||
Err(CatalogError::NoSuchAlbum(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rename_bumps_the_revision_the_merge_compares() {
|
||||
let cat = catalog();
|
||||
let conn = cat.connection();
|
||||
let album = create(conn, "Web", &Place::Local("/out".into())).unwrap();
|
||||
rename(conn, album, "Website").unwrap();
|
||||
let rev: i64 = conn
|
||||
.query_row(
|
||||
"SELECT revision FROM albums WHERE id = ?1",
|
||||
[album.0 as i64],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(rev, 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
//! TRACES: NFR-P9
|
||||
//! Which catalog files this process has already backfilled, and as of what.
|
||||
//!
|
||||
//! # Why this exists
|
||||
//!
|
||||
//! [`crate::schema::backfill`] used to run inside every [`crate::Catalog::open`],
|
||||
//! and every worker thread opens its own connection. Landing on a photograph
|
||||
//! in develop opened the catalog five times — the fetch of the original and a
|
||||
//! cache check per prefetched neighbour — and each open paid the whole
|
||||
//! backfill: an anti-join of every image against its versions, a pass over
|
||||
//! every default version's uuid, the unpaired JPEGs and the keyword
|
||||
//! vocabulary. On the reference library that was ~12 ms an open and ~60 ms of
|
||||
//! CPU a landing, spent confirming that nothing had changed since the open
|
||||
//! before.
|
||||
//!
|
||||
//! # What makes skipping it safe
|
||||
//!
|
||||
//! Everything the backfill repairs is a row some write *added*: an image
|
||||
//! inserted by a scan or an import has no default version and may be the RAW
|
||||
//! beside an unpaired JPEG; a version merged or restored from an older build
|
||||
//! may carry a minted uuid; a keyword assignment merged from a remote may name
|
||||
//! a word with no term. So the question "is any work owed?" is answered by
|
||||
//! whether those tables have gained rows since the last backfill, and that is
|
||||
//! a read of each table's last row — the last page of its b-tree — rather than
|
||||
//! a scan.
|
||||
//!
|
||||
//! # Why the last row, and not only its id
|
||||
//!
|
||||
//! None of these tables is `AUTOINCREMENT`, so SQLite hands out the largest
|
||||
//! rowid plus one, and an id freed by deleting the newest row is handed out
|
||||
//! again. That is an ordinary sequence, not a contrived one: emptying the
|
||||
//! trash of the newest photograph and then scanning a new one, or a local
|
||||
//! folder's walk removing a renamed file's row and inserting the new name in
|
||||
//! the same pass. `max(id)` does not move, and neither does `count(*)`. And
|
||||
//! the row that took the id is exactly one that needs the backfill, because
|
||||
//! neither scan creates default versions — `persist` and the walk insert the
|
||||
//! image and leave the version, the pairing and the keyword terms to the next
|
||||
//! open. Skipped, it would go without them until the app restarted: a rating
|
||||
//! or a keyword with nowhere to land, a JPEG beside its RAW shown twice.
|
||||
//!
|
||||
//! So the stamp carries the last row's content as well as its id: the newest
|
||||
//! image's path, when it was added, and **whether it has a version**; the
|
||||
//! newest version's image; the newest assignment's word and version. Whether
|
||||
//! the newest image has a version is the part that cannot be fooled: once the
|
||||
//! backfill has run, every image has one, and a row that has just taken a
|
||||
//! freed id has none, so the two stamps differ whatever the path and the time
|
||||
//! say. The others make the newest version or assignment a different row
|
||||
//! whenever a different one took its id; one that is the same content at the
|
||||
//! same id is the same row as far as the backfill is concerned.
|
||||
//!
|
||||
//! The [`Stamp`] is those, the schema version, and the file's identity.
|
||||
//! An open whose stamp matches the one recorded at the last backfill of the
|
||||
//! same path skips it; anything else runs it. That covers the cases that must
|
||||
//! run it:
|
||||
//!
|
||||
//! - **The first open in a process.** Nothing is recorded yet.
|
||||
//! - **A migration.** `user_version` is in the stamp, and [`crate::Catalog::open`]
|
||||
//! also runs the backfill unconditionally whenever `migrate` moved the
|
||||
//! schema, because that is what the backfill was written for.
|
||||
//! - **A pulled catalog.** The merge inserts assignments, which moves the
|
||||
//! stamp; and [`crate::sync::merge_remote`] [`forget`]s the path as well, so
|
||||
//! the next open backfills even when every incoming row collided.
|
||||
//! - **A file replaced underneath the path** — a restore from backup, a
|
||||
//! rebuild, a catalog copied in. On unix the device and inode are in the
|
||||
//! stamp, and a replacement is a new inode; [`crate::recovery::set_aside`],
|
||||
//! the first step of both a restore and a rebuild, forgets the path too.
|
||||
//! - **Another process writing.** The stamp is read from the file, not from
|
||||
//! anything this process did, so a scan in a second instance moves it just
|
||||
//! the same.
|
||||
//!
|
||||
//! # Why the stamp is taken before the backfill
|
||||
//!
|
||||
//! The backfill adds versions and terms itself, so a stamp read afterwards
|
||||
//! would describe its own writes. Read afterwards it could also describe an
|
||||
//! image another connection inserted between the backfill's read and the
|
||||
//! stamp's — and record that image as covered when it was not. Read before,
|
||||
//! the worst case is the reverse: the backfill's own inserts move the stamp,
|
||||
//! and the next open runs one more backfill that finds nothing. That costs one
|
||||
//! redundant pass after a backfill that did real work, and never misses a row.
|
||||
//!
|
||||
//! # What it does not see
|
||||
//!
|
||||
//! An `UPDATE` that creates work without adding a row. None of this build's
|
||||
//! writers does: a scan's move of a file is a new `source_ref` and so a new
|
||||
//! image, and uuids are only rewritten by the backfill itself. Should one
|
||||
//! appear, the cost is that its repair waits for the next insert or the next
|
||||
//! start of the app — which is exactly where the backfill ran before it ran on
|
||||
//! every open.
|
||||
//!
|
||||
//! Kept in memory rather than in the catalog on purpose: a row in the file
|
||||
//! would travel in the sync snapshot and would need a table an older build
|
||||
//! does not have, and a flag that another device's catalog carried in would
|
||||
//! say nothing about this one.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::error::CatalogError;
|
||||
|
||||
/// What a catalog looked like, as far as the backfill cares.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct Stamp {
|
||||
/// Device and inode, so a file swapped in under the same name is a new
|
||||
/// catalog. `None` where the platform has no such thing.
|
||||
file: Option<(u64, u64)>,
|
||||
user_version: i64,
|
||||
/// The newest image: id, path, when added, and whether it has a version.
|
||||
last_image: Option<String>,
|
||||
/// The newest version: id and the image it belongs to.
|
||||
last_version: Option<String>,
|
||||
/// The newest keyword assignment: rowid, version and word.
|
||||
last_keyword: Option<String>,
|
||||
}
|
||||
|
||||
/// The stamp recorded at the last backfill, per catalog file.
|
||||
fn done() -> &'static Mutex<HashMap<PathBuf, Stamp>> {
|
||||
static DONE: OnceLock<Mutex<HashMap<PathBuf, Stamp>>> = OnceLock::new();
|
||||
DONE.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
/// One name per file, whichever spelling of its path the caller used.
|
||||
fn key(path: &Path) -> PathBuf {
|
||||
std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
|
||||
}
|
||||
|
||||
/// Read the stamp of the catalog behind `conn`, which was opened from `path`.
|
||||
///
|
||||
/// One statement: the last row of each of three tables, each found by
|
||||
/// descending its rowid b-tree to the last page, plus one probe of
|
||||
/// `versions_image` for the newest image — and a `stat` of the file.
|
||||
pub(crate) fn stamp(conn: &Connection, path: &Path) -> Result<Stamp, CatalogError> {
|
||||
let (user_version, last_image, last_version, last_keyword) = conn.query_row(
|
||||
"SELECT (SELECT user_version FROM pragma_user_version),
|
||||
(SELECT printf('%d|%d|%d|%s', i.id, i.added_at,
|
||||
EXISTS (SELECT 1 FROM versions v WHERE v.image_id = i.id),
|
||||
i.source_ref)
|
||||
FROM images i ORDER BY i.id DESC LIMIT 1),
|
||||
(SELECT printf('%d|%d', id, image_id)
|
||||
FROM versions ORDER BY id DESC LIMIT 1),
|
||||
(SELECT printf('%d|%d|%s', rowid, version_id, keyword)
|
||||
FROM keywords ORDER BY rowid DESC LIMIT 1)",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
|
||||
)?;
|
||||
Ok(Stamp {
|
||||
file: file_identity(path),
|
||||
user_version,
|
||||
last_image,
|
||||
last_version,
|
||||
last_keyword,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn file_identity(path: &Path) -> Option<(u64, u64)> {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
std::fs::metadata(path).ok().map(|m| (m.dev(), m.ino()))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn file_identity(_path: &Path) -> Option<(u64, u64)> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether the catalog at `path` was last backfilled at exactly `stamp`.
|
||||
pub(crate) fn is_current(path: &Path, stamp: &Stamp) -> bool {
|
||||
done()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.get(&key(path))
|
||||
== Some(stamp)
|
||||
}
|
||||
|
||||
/// Record that the catalog at `path` has been backfilled as of `stamp`.
|
||||
pub(crate) fn record(path: &Path, stamp: Stamp) {
|
||||
done()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.insert(key(path), stamp);
|
||||
}
|
||||
|
||||
/// Make the next open of `path` backfill, whatever its stamp says.
|
||||
///
|
||||
/// For the writers that know they have changed the catalog wholesale — a
|
||||
/// merge of a pulled catalog, a restore from backup — so their correctness
|
||||
/// does not rest on the stamp happening to move.
|
||||
pub(crate) fn forget(path: &Path) {
|
||||
done()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.remove(&key(path));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::rating::derived_version_uuid;
|
||||
use crate::Catalog;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// A catalog file of its own, holding one image the server has named,
|
||||
/// backfilled and settled.
|
||||
///
|
||||
/// Opened three times on the way: to create it; after the image went in,
|
||||
/// which gives the image its default version; and once more, because that
|
||||
/// version moved the stamp and the next open runs the one redundant pass
|
||||
/// the module header describes. After that the stamp stands still.
|
||||
fn catalog(tag: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"dr-backfilled-{tag}-{}-{:?}",
|
||||
std::process::id(),
|
||||
std::thread::current().id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("catalog.sqlite");
|
||||
{
|
||||
let cat = Catalog::open(&path).unwrap();
|
||||
let c = cat.connection();
|
||||
c.execute_batch(
|
||||
"INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'Photos');
|
||||
INSERT INTO images(id, root_id, source_ref, added_at)
|
||||
VALUES (1, 1, 'Photos/a.CR3', 0);
|
||||
INSERT INTO remote(image_id, file_id) VALUES (1, 77);",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(uuid(&path, 1), Some(derived_version_uuid(77)));
|
||||
assert_eq!(uuid(&path, 1), Some(derived_version_uuid(77)));
|
||||
path
|
||||
}
|
||||
|
||||
/// The default version's uuid for `image`, read through an ordinary open.
|
||||
fn uuid(path: &std::path::Path, image: i64) -> Option<String> {
|
||||
let cat = Catalog::open(path).unwrap();
|
||||
cat.connection()
|
||||
.query_row(
|
||||
"SELECT uuid FROM versions WHERE image_id = ?1 AND is_default = 1",
|
||||
[image],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Put the one row back into the state the backfill repairs, with an
|
||||
/// `UPDATE` — which moves none of the stamp's maxima, so only the stamp's
|
||||
/// other parts or an explicit `forget` can bring the backfill back.
|
||||
fn unalign(path: &std::path::Path) {
|
||||
rusqlite::Connection::open(path)
|
||||
.unwrap()
|
||||
.execute("UPDATE versions SET uuid = 'minted' WHERE image_id = 1", [])
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unchanged_catalog_is_not_backfilled_again() {
|
||||
let path = catalog("unchanged");
|
||||
unalign(&path);
|
||||
assert_eq!(
|
||||
uuid(&path, 1).as_deref(),
|
||||
Some("minted"),
|
||||
"nothing was added since the last backfill, so the open skipped it"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_a_scan_added_is_backfilled_on_the_next_open() {
|
||||
let path = catalog("scanned");
|
||||
rusqlite::Connection::open(&path)
|
||||
.unwrap()
|
||||
.execute(
|
||||
"INSERT INTO images(id, root_id, source_ref, added_at)
|
||||
VALUES (2, 1, 'Photos/b.CR3', 0)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(uuid(&path, 2).is_some(), "the new image got its version");
|
||||
}
|
||||
|
||||
/// The id of a deleted newest row is handed out again, so `max(id)` is
|
||||
/// the same before and after — the sequence emptying the trash and then
|
||||
/// scanning makes. The image that took the id still needs its version.
|
||||
///
|
||||
/// A virtual copy on the older image holds the newest version id, so
|
||||
/// the deletion does not move `max(versions.id)` either: nothing the old
|
||||
/// stamp read changes, which is the case that went unrepaired.
|
||||
#[test]
|
||||
fn an_image_that_reuses_a_deleted_id_is_backfilled_on_the_next_open() {
|
||||
let path = catalog("reused");
|
||||
rusqlite::Connection::open(&path)
|
||||
.unwrap()
|
||||
.execute(
|
||||
"INSERT INTO images(id, root_id, source_ref, added_at)
|
||||
VALUES (2, 1, 'Photos/b.CR3', 0)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(uuid(&path, 2).is_some());
|
||||
rusqlite::Connection::open(&path)
|
||||
.unwrap()
|
||||
.execute(
|
||||
"INSERT INTO versions(image_id, uuid, name, is_default)
|
||||
VALUES (1, 'copy', 'Crop', 0)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
// Settle: one open backfills after the new version, one more runs
|
||||
// the redundant pass and records the stamp that stands.
|
||||
assert!(uuid(&path, 2).is_some());
|
||||
assert!(uuid(&path, 2).is_some());
|
||||
|
||||
let c = rusqlite::Connection::open(&path).unwrap();
|
||||
let before: (i64, i64) = c
|
||||
.query_row(
|
||||
"SELECT (SELECT max(id) FROM images), (SELECT max(id) FROM versions)",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
c.execute("DELETE FROM images WHERE id = 2", []).unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO images(root_id, source_ref, added_at)
|
||||
VALUES (1, 'Photos/c.CR3', 0)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let after: (i64, i64) = c
|
||||
.query_row(
|
||||
"SELECT (SELECT max(id) FROM images), (SELECT max(id) FROM versions)",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(before, after, "SQLite handed the freed id out again");
|
||||
drop(c);
|
||||
assert!(uuid(&path, 2).is_some(), "the new image got its version");
|
||||
}
|
||||
|
||||
/// The same, with the same file coming back at the same id in the same
|
||||
/// second: path and time match, and only the missing version tells.
|
||||
#[test]
|
||||
fn the_same_file_back_at_the_same_id_is_backfilled_on_the_next_open() {
|
||||
let path = catalog("returned");
|
||||
let c = rusqlite::Connection::open(&path).unwrap();
|
||||
// As above: a newer version on another image keeps the deletion
|
||||
// from moving `max(versions.id)`.
|
||||
c.execute_batch(
|
||||
"INSERT INTO images(id, root_id, source_ref, added_at)
|
||||
VALUES (0, 1, 'Photos/0.CR3', 0);
|
||||
INSERT INTO versions(image_id, uuid, name, is_default)
|
||||
VALUES (0, 'copy', 'Crop', 0);",
|
||||
)
|
||||
.unwrap();
|
||||
drop(c);
|
||||
assert!(uuid(&path, 1).is_some());
|
||||
assert!(uuid(&path, 1).is_some());
|
||||
let c = rusqlite::Connection::open(&path).unwrap();
|
||||
c.execute_batch(
|
||||
"DELETE FROM images WHERE id = 1;
|
||||
INSERT INTO images(id, root_id, source_ref, added_at)
|
||||
VALUES (1, 1, 'Photos/a.CR3', 0);
|
||||
INSERT INTO remote(image_id, file_id) VALUES (1, 77);",
|
||||
)
|
||||
.unwrap();
|
||||
drop(c);
|
||||
assert_eq!(uuid(&path, 1), Some(derived_version_uuid(77)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_open_after_a_migration_backfills() {
|
||||
let path = catalog("migrated");
|
||||
unalign(&path);
|
||||
rusqlite::Connection::open(&path)
|
||||
.unwrap()
|
||||
.pragma_update(None, "user_version", crate::schema::SCHEMA_VERSION - 1)
|
||||
.unwrap();
|
||||
assert_eq!(uuid(&path, 1), Some(derived_version_uuid(77)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_open_after_a_pulled_catalog_backfills() {
|
||||
let path = catalog("pulled");
|
||||
// The remote is this catalog as it stands, so every row the merge
|
||||
// offers collides and nothing in the stamp moves: only the merge
|
||||
// saying so can make the next open backfill.
|
||||
let remote = path.with_file_name("remote.sqlite");
|
||||
rusqlite::Connection::open(&path)
|
||||
.unwrap()
|
||||
.execute("VACUUM INTO ?1", [remote.to_string_lossy().as_ref()])
|
||||
.unwrap();
|
||||
unalign(&path);
|
||||
assert_eq!(uuid(&path, 1).as_deref(), Some("minted"));
|
||||
|
||||
Catalog::open(&path)
|
||||
.unwrap()
|
||||
.merge_remote_catalog(&remote)
|
||||
.unwrap();
|
||||
assert_eq!(uuid(&path, 1), Some(derived_version_uuid(77)));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_catalog_replaced_under_the_same_name_backfills() {
|
||||
let path = catalog("replaced");
|
||||
unalign(&path);
|
||||
assert_eq!(uuid(&path, 1).as_deref(), Some("minted"));
|
||||
// Every connection is closed, so the WAL is folded in and the main
|
||||
// file is the whole catalog. A copy renamed over it is the same rows
|
||||
// in a new file — which is what a restore or a copied-in catalog is.
|
||||
let copy = path.with_file_name("copy.sqlite");
|
||||
std::fs::copy(&path, ©).unwrap();
|
||||
std::fs::rename(©, &path).unwrap();
|
||||
assert_eq!(uuid(&path, 1), Some(derived_version_uuid(77)));
|
||||
}
|
||||
}
|
||||
@@ -82,7 +82,7 @@
|
||||
//! Grouping has no natural `subject_id`: it is a property of a *run* of frames,
|
||||
//! so a per-image job would rebuild the world once per photograph. It is
|
||||
//! therefore a debounced library-level pass, for exactly the reasons
|
||||
//! docs/catalog.md §10.2 gives for face clustering, and [`regroup`] is the whole
|
||||
//! docs/dev/catalog.md §10.2 gives for face clustering, and [`regroup`] is the whole
|
||||
//! of it — one ordered walk, no per-pair comparison beyond adjacent frames.
|
||||
//!
|
||||
//! # Grouping is not hiding
|
||||
@@ -722,6 +722,31 @@ pub fn not_collapsed_away(image: &str) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// SQL for the rows [`not_collapsed_away`] drops, as a `FROM ... WHERE`
|
||||
/// joining each such frame to its image under the alias `image`.
|
||||
///
|
||||
/// For counting. A count that applies [`not_collapsed_away`] to every row
|
||||
/// pays two primary-key probes per image to find the handful a collapsed
|
||||
/// burst hides; counting everything and subtracting what this lists walks
|
||||
/// only `burst_members`, which is empty on a library without bursts. The
|
||||
/// caller appends its own conditions on `image` with `AND`, the same ones
|
||||
/// it counted the whole with, so the subtraction takes away only rows the
|
||||
/// whole included. `image_id` is `burst_members`' key, so no image is
|
||||
/// listed twice.
|
||||
///
|
||||
/// The two must describe the same rows: change one, change both, and
|
||||
/// `the_collapsed_frames_are_what_the_predicate_drops` will say if they drift.
|
||||
///
|
||||
/// Never interpolate anything user-supplied as `image`.
|
||||
pub fn collapsed_away_frames(image: &str) -> String {
|
||||
format!(
|
||||
"burst_members bm CROSS JOIN images {image} ON {image}.id = bm.image_id
|
||||
WHERE bm.representative = 0
|
||||
AND NOT EXISTS (SELECT 1 FROM burst_expanded be
|
||||
WHERE be.burst_id = bm.burst_id)"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1235,6 +1260,46 @@ mod tests {
|
||||
assert_eq!(visible(cat.connection()), vec![1, 2, 3, 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_collapsed_frames_are_what_the_predicate_drops() {
|
||||
// `collapsed_away_frames` is `not_collapsed_away` turned inside out
|
||||
// for counting; the two must name the same rows, open or closed.
|
||||
let cat = seeded(&[
|
||||
(1, 1000, Some(0xFF00)),
|
||||
(2, 1001, Some(0xFF00)),
|
||||
(3, 1002, Some(0xFF00)),
|
||||
(4, 9000, Some(0xAA00)),
|
||||
(5, 9001, Some(0xAA00)),
|
||||
(6, 20000, Some(0xFF00)),
|
||||
]);
|
||||
regroup(cat.connection(), Rules::default()).unwrap();
|
||||
let ids = |sql: String| -> Vec<i64> {
|
||||
let c = cat.connection();
|
||||
let mut stmt = c.prepare(&sql).unwrap();
|
||||
let rows = stmt.query_map([], |r| r.get::<_, i64>(0)).unwrap();
|
||||
rows.collect::<Result<Vec<_>, _>>().unwrap()
|
||||
};
|
||||
let dropped = || {
|
||||
ids(format!(
|
||||
"SELECT id FROM images i WHERE NOT {} ORDER BY id",
|
||||
not_collapsed_away("i")
|
||||
))
|
||||
};
|
||||
let listed = || {
|
||||
ids(format!(
|
||||
"SELECT i.id FROM {} ORDER BY i.id",
|
||||
collapsed_away_frames("i")
|
||||
))
|
||||
};
|
||||
|
||||
assert_eq!(listed(), dropped());
|
||||
set_expanded(cat.connection(), ImageId(1), false).unwrap();
|
||||
assert_eq!(dropped(), vec![2, 3]);
|
||||
assert_eq!(listed(), dropped());
|
||||
set_expanded(cat.connection(), ImageId(4), false).unwrap();
|
||||
assert_eq!(listed(), dropped());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_library_with_no_bursts_hides_nothing() {
|
||||
// The predicate is in every grid query, so its cost and its effect on a
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -65,6 +65,16 @@ pub enum CatalogError {
|
||||
#[error("no such collection: {0}")]
|
||||
NoSuchCollection(u64),
|
||||
|
||||
/// An album the caller named is gone — deleted here, or by a merge while
|
||||
/// its id sat in a UI model.
|
||||
#[error("no such album: {0}")]
|
||||
NoSuchAlbum(u64),
|
||||
|
||||
/// A name that is empty once trimmed. Refused rather than stored, because
|
||||
/// a row with no name is one the sidebar cannot draw and nobody can pick.
|
||||
#[error("a name is required")]
|
||||
EmptyName,
|
||||
|
||||
/// A keyword the caller named is gone — deleted, or fused into another by a
|
||||
/// merge while its id sat in a UI model.
|
||||
///
|
||||
@@ -96,6 +106,13 @@ pub enum CatalogError {
|
||||
|
||||
#[error("io: {0}")]
|
||||
Io(String),
|
||||
|
||||
/// TRACES: FR-CAT-11a
|
||||
/// A duplicate group planned earlier no longer holds: a copy was trashed,
|
||||
/// rescanned or changed since the review was drawn. The group is left
|
||||
/// untouched rather than consolidated on a stale plan.
|
||||
#[error("no longer a duplicate: {0}")]
|
||||
StaleDuplicate(String),
|
||||
}
|
||||
|
||||
impl From<rusqlite::Error> for CatalogError {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! Face data as sealed shards, so a second device does not re-index the library.
|
||||
//!
|
||||
//! Indexing a 23,500-image library is on the order of two hours of CPU
|
||||
//! (docs/faces.md §12.2). It is also **byte-identical on every device**: the
|
||||
//! (docs/dev/faces.md §12.2). It is also **byte-identical on every device**: the
|
||||
//! same model over the same proxy produces the same embedding. Paying for it
|
||||
//! once per account rather than once per device is the whole point of this
|
||||
//! module, and it is the same bargain the thumbnail store already makes.
|
||||
@@ -30,6 +30,7 @@
|
||||
//! identity every client agrees on (FR-NC-5), and it survives a server-side
|
||||
//! move, so a shard written before a reorganisation still applies after it.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use rusqlite::{Connection, OptionalExtension};
|
||||
@@ -155,6 +156,45 @@ impl FaceShardStore {
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// [`indexed_at`](Self::indexed_at) for every entry at once.
|
||||
///
|
||||
/// What a pass over the whole library asks instead of one lookup per image:
|
||||
/// the export compares every marker in the catalog with this, and a lookup
|
||||
/// each was 19,000 statements prepared and run on every sync pass that had
|
||||
/// nothing to send.
|
||||
fn all_indexed_at(&self) -> Result<HashMap<(u64, String), Option<i64>>, CatalogError> {
|
||||
let mut q = self
|
||||
.index
|
||||
.prepare("SELECT file_id, model_id, indexed_at FROM entries")?;
|
||||
let rows = q.query_map([], |r| {
|
||||
Ok((
|
||||
(r.get::<_, i64>(0)? as u64, r.get::<_, String>(1)?),
|
||||
r.get::<_, Option<i64>>(2)?,
|
||||
))
|
||||
})?;
|
||||
Ok(rows.collect::<Result<_, _>>()?)
|
||||
}
|
||||
|
||||
/// [`held_model`](Self::held_model) for every file at once: one statement,
|
||||
/// ordered exactly as that one is, keeping the first row per file.
|
||||
fn all_held_models(&self, model_id: &str) -> Result<HashMap<u64, String>, CatalogError> {
|
||||
let mut q = self.index.prepare(&format!(
|
||||
"SELECT file_id, model_id FROM entries
|
||||
WHERE {} = ?1
|
||||
ORDER BY file_id, indexed_at DESC NULLS LAST, model_id",
|
||||
crate::faces::embedder_sql("model_id")
|
||||
))?;
|
||||
let rows = q.query_map([crate::faces::embedder_of(model_id)], |r| {
|
||||
Ok((r.get::<_, i64>(0)? as u64, r.get::<_, String>(1)?))
|
||||
})?;
|
||||
let mut out = HashMap::new();
|
||||
for row in rows {
|
||||
let (file, model) = row?;
|
||||
out.entry(file).or_insert(model);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The pipeline this store holds an image under, among those sharing
|
||||
/// `model_id`'s embedder — the most recently indexed where a peer has
|
||||
/// sent more than one.
|
||||
@@ -784,6 +824,18 @@ pub fn export_to_shards_reporting(
|
||||
/// enough that the reporting is lost in the write it accompanies.
|
||||
const REPORT_EVERY: usize = 25;
|
||||
|
||||
// What the store holds, read once. A put below rewrites only its own
|
||||
// file's entries -- its generation, and siblings it supersedes -- so a
|
||||
// file already written in this pass is asked of the store again and every
|
||||
// other answer is the one a lookup would have given.
|
||||
//
|
||||
// An index that cannot be read answers as each lookup did: nothing held.
|
||||
let held = store.all_indexed_at().unwrap_or_else(|e| {
|
||||
log::debug!("reading the shard index: {e}");
|
||||
HashMap::new()
|
||||
});
|
||||
let mut written: HashSet<u64> = HashSet::new();
|
||||
|
||||
let total = rows.len();
|
||||
let mut exported = 0;
|
||||
for (seen, (file_id, image_id, edge, indexed_at, model_id)) in rows.into_iter().enumerate() {
|
||||
@@ -800,10 +852,14 @@ pub fn export_to_shards_reporting(
|
||||
//
|
||||
// The comparison is against when the *catalog* indexed it, so a
|
||||
// re-index is visible and an unchanged image still costs nothing.
|
||||
if store
|
||||
.indexed_at(file_id as u64, model_id)
|
||||
.is_some_and(|was| was >= indexed_at)
|
||||
{
|
||||
let was = if written.contains(&(file_id as u64)) {
|
||||
store.indexed_at(file_id as u64, model_id)
|
||||
} else {
|
||||
held.get(&(file_id as u64, model_id.to_string()))
|
||||
.copied()
|
||||
.flatten()
|
||||
};
|
||||
if was.is_some_and(|was| was >= indexed_at) {
|
||||
continue;
|
||||
}
|
||||
let mut fq = conn.prepare(
|
||||
@@ -840,6 +896,7 @@ pub fn export_to_shards_reporting(
|
||||
&faces,
|
||||
Some(indexed_at),
|
||||
)?;
|
||||
written.insert(file_id as u64);
|
||||
exported += 1;
|
||||
}
|
||||
progress(total, total);
|
||||
@@ -902,6 +959,18 @@ pub fn import_from_shards(
|
||||
/// the lock, waits a fraction of a second and not the whole import.
|
||||
const CHUNK: usize = 100;
|
||||
|
||||
// Every file's held pipeline, read once rather than asked per candidate --
|
||||
// 23,000 prepared lookups on every pass, nearly all of them for images
|
||||
// this device already holds. Nothing below changes which pipeline the
|
||||
// store holds a file under (`set_indexed_at` touches only a file already
|
||||
// decided), and each candidate is a different file, so these are the
|
||||
// answers the lookups gave.
|
||||
// An index that cannot be read answers as each lookup did: nothing held.
|
||||
let held_models = store.all_held_models(model_id).unwrap_or_else(|e| {
|
||||
log::debug!("reading the shard index: {e}");
|
||||
HashMap::new()
|
||||
});
|
||||
|
||||
let mut adopted = 0;
|
||||
let mut tx = conn.unchecked_transaction()?;
|
||||
let mut in_chunk = 0;
|
||||
@@ -911,7 +980,7 @@ pub fn import_from_shards(
|
||||
tx = conn.unchecked_transaction()?;
|
||||
in_chunk = 0;
|
||||
}
|
||||
let Some(held) = store.held_model(file_id as u64, model_id) else {
|
||||
let Some(held) = held_models.get(&(file_id as u64)).cloned() else {
|
||||
continue;
|
||||
};
|
||||
if let Some(local) = local {
|
||||
|
||||
+277
-11
@@ -1,7 +1,7 @@
|
||||
//! TRACES: FR-CULL-8 | FR-CULL-9 | FR-CULL-10 | FR-CULL-11 | FR-CULL-12 | NFR-SEC-5
|
||||
//! People and faces: what was detected, who it is, and who said so.
|
||||
//!
|
||||
//! The storage half of docs/faces.md. `dr-face` finds faces and turns them into
|
||||
//! The storage half of docs/dev/faces.md. `dr-face` finds faces and turns them into
|
||||
//! 512 numbers; this module is where those numbers acquire an identity, and
|
||||
//! where the user's corrections outrank the model's guesses.
|
||||
//!
|
||||
@@ -110,7 +110,7 @@ pub struct DetectedFace {
|
||||
/// Raw rather than unit length, so the length ([`Self::quality`]) is in
|
||||
/// the blob and not only beside it. Readers re-normalise on load.
|
||||
pub embedding: Vec<u8>,
|
||||
/// Source pixels across the aligned crop (docs/faces.md §7).
|
||||
/// Source pixels across the aligned crop (docs/dev/faces.md §7).
|
||||
pub crop_px: f32,
|
||||
/// Length of the raw embedding before normalisation — the model's own
|
||||
/// reading of how recognisable the crop was, and the gate on whether
|
||||
@@ -211,7 +211,7 @@ pub use dr_face::Calibration;
|
||||
/// the same face in the same photograph, for carrying an identity across a
|
||||
/// re-detection.
|
||||
///
|
||||
/// Set at the reference library's P≈0.95 line (docs/faces.md §9's table:
|
||||
/// Set at the reference library's P≈0.95 line (docs/dev/faces.md §9's table:
|
||||
/// 0.449), which is far above anything two different people in one frame
|
||||
/// reach and below what one face re-embedded from a better crop of itself
|
||||
/// does. The number is only ever asked about *overlapping* boxes on *one*
|
||||
@@ -1029,7 +1029,8 @@ fn people_where(conn: &Connection, in_use: bool) -> Result<Vec<Person>, CatalogE
|
||||
///
|
||||
/// Confirmations survive the move: a face the user confirmed as the source
|
||||
/// person is now a confirmed face of the target, which is what the user meant
|
||||
/// by saying they are the same person.
|
||||
/// by saying they are the same person. So do rejections — see
|
||||
/// [`merge_people_within`].
|
||||
pub fn merge_people(
|
||||
conn: &Connection,
|
||||
target: PersonId,
|
||||
@@ -1039,7 +1040,53 @@ pub fn merge_people(
|
||||
return Ok(0);
|
||||
}
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
let moved = merge_people_within(&tx, target, source)?;
|
||||
tx.commit()?;
|
||||
Ok(moved)
|
||||
}
|
||||
|
||||
/// [`merge_people`] inside a transaction the caller holds, so a job that
|
||||
/// merges several pairs commits once (`crate::dedup_people`).
|
||||
///
|
||||
/// **Rejections move with the faces.** "This face is not Annie" is a
|
||||
/// judgement about the person, and once Annie is Anna it is one about Anna.
|
||||
/// Left on the redirect it binds nothing, and the next grouping pass
|
||||
/// suggests the face the user pushed away to the person it now belongs to. Where the
|
||||
/// two halves disagree about one face — confirmed as one, rejected as the
|
||||
/// other — the confirmation stands, which is the rule [`confirm`] applies
|
||||
/// to one face; and a moved rejection takes a suggestion of the same face
|
||||
/// with it, the rule [`reject`] applies.
|
||||
pub(crate) fn merge_people_within(
|
||||
tx: &Connection,
|
||||
target: PersonId,
|
||||
source: PersonId,
|
||||
) -> Result<u64, CatalogError> {
|
||||
if target == source {
|
||||
return Ok(0);
|
||||
}
|
||||
let moved = move_judgements(tx, target, source)?;
|
||||
tx.execute(
|
||||
"UPDATE people SET merged_into = ?1, revision = revision + 1, modified = ?3
|
||||
WHERE id = ?2",
|
||||
rusqlite::params![target.0 as i64, source.0 as i64, now_secs()],
|
||||
)?;
|
||||
Ok(moved)
|
||||
}
|
||||
|
||||
/// The half of [`merge_people_within`] that moves faces and rejections,
|
||||
/// without touching either person's row.
|
||||
///
|
||||
/// Also what follows a redirect that arrived by sync
|
||||
/// (`crate::dedup_people`): the other device merged the people, and this
|
||||
/// one still holds judgements on the person merged away. Bumping the
|
||||
/// person's revision there would be an edit of this device's own, sent back
|
||||
/// on every pass, so the row is left as the merge wrote it.
|
||||
pub(crate) fn move_judgements(
|
||||
tx: &Connection,
|
||||
target: PersonId,
|
||||
source: PersonId,
|
||||
) -> Result<u64, CatalogError> {
|
||||
let (t, s) = (target.0 as i64, source.0 as i64);
|
||||
// A face already assigned to the target must not gain a second row —
|
||||
// `face_person` is keyed by face. Where both hold the same face, the
|
||||
// target's row wins and the source's is dropped.
|
||||
@@ -1047,18 +1094,31 @@ pub fn merge_people(
|
||||
"DELETE FROM face_person
|
||||
WHERE person_id = ?2
|
||||
AND face_id IN (SELECT face_id FROM face_person WHERE person_id = ?1)",
|
||||
rusqlite::params![target.0 as i64, source.0 as i64],
|
||||
rusqlite::params![t, s],
|
||||
)?;
|
||||
let moved = tx.execute(
|
||||
"UPDATE face_person SET person_id = ?1 WHERE person_id = ?2",
|
||||
rusqlite::params![target.0 as i64, source.0 as i64],
|
||||
rusqlite::params![t, s],
|
||||
)?;
|
||||
tx.execute(
|
||||
"UPDATE people SET merged_into = ?1, revision = revision + 1, modified = ?3
|
||||
WHERE id = ?2",
|
||||
rusqlite::params![target.0 as i64, source.0 as i64, now_secs()],
|
||||
"INSERT OR IGNORE INTO face_person_rejected (face_id, person_id)
|
||||
SELECT face_id, ?1 FROM face_person_rejected WHERE person_id = ?2",
|
||||
rusqlite::params![t, s],
|
||||
)?;
|
||||
tx.execute("DELETE FROM face_person_rejected WHERE person_id = ?1", [s])?;
|
||||
tx.execute(
|
||||
"DELETE FROM face_person_rejected
|
||||
WHERE person_id = ?1
|
||||
AND face_id IN (SELECT face_id FROM face_person
|
||||
WHERE person_id = ?1 AND confirmed = 1)",
|
||||
[t],
|
||||
)?;
|
||||
tx.execute(
|
||||
"DELETE FROM face_person
|
||||
WHERE person_id = ?1 AND confirmed = 0
|
||||
AND face_id IN (SELECT face_id FROM face_person_rejected WHERE person_id = ?1)",
|
||||
[t],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(moved as u64)
|
||||
}
|
||||
|
||||
@@ -1599,6 +1659,170 @@ fn iou(a: (f32, f32, f32, f32), b: (f32, f32, f32, f32)) -> f32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-11a | FR-CULL-10
|
||||
/// What [`carry_onto_copy`] did with one byte-identical copy's faces.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct FaceCarry {
|
||||
/// Faces moved onto the survivor outright, because it had none.
|
||||
pub moved: usize,
|
||||
/// Names and suggestions put on a survivor's face that lacked one.
|
||||
pub named: usize,
|
||||
/// Rejections added to a survivor's face.
|
||||
pub rejections: usize,
|
||||
/// Faces named one person on the copy and another on the survivor. The
|
||||
/// survivor's name is kept; the copy keeps its own, in the trash.
|
||||
pub conflicts: usize,
|
||||
/// Named faces on the copy that match nothing on the survivor. Left with
|
||||
/// the copy rather than mixed into another pipeline's faces.
|
||||
pub unmatched_named: usize,
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-11a | FR-CULL-10
|
||||
/// Bring what one copy of a photograph knows about its faces onto another
|
||||
/// copy of the same bytes, inside the caller's transaction.
|
||||
///
|
||||
/// Faces are per image, so two copies of one file indexed separately hold
|
||||
/// two sets of the same boxes, and a name confirmed on one is invisible on
|
||||
/// the other. The rule is the one [`record_detections_within`] keeps: an
|
||||
/// image holds one pipeline's faces at a time, and the user's judgements
|
||||
/// are what must survive.
|
||||
///
|
||||
/// - **The survivor has no faces at all.** The copy's faces and its run
|
||||
/// markers move over wholesale — nothing is duplicated, and the survivor
|
||||
/// is spared a detection pass it would only repeat. Its own markers go
|
||||
/// first, because a marker saying "examined, nothing found" over an image
|
||||
/// that now holds faces is the V12 state.
|
||||
/// - **The survivor has faces.** Each of the copy's faces is paired with the
|
||||
/// survivor face its box overlaps most (IoU above one half — the bytes are
|
||||
/// the same, so the boxes coincide). A name or suggestion is carried onto
|
||||
/// a survivor face that has none, a confirmation outranks a suggestion,
|
||||
/// and two different confirmed names are a conflict the survivor wins.
|
||||
/// Rejections are unioned. The copy's faces stay where they are, with the
|
||||
/// copy.
|
||||
pub fn carry_onto_copy(
|
||||
tx: &Connection,
|
||||
from: ImageId,
|
||||
to: ImageId,
|
||||
) -> Result<FaceCarry, CatalogError> {
|
||||
let mut out = FaceCarry::default();
|
||||
let boxes = |image: ImageId| -> Result<Vec<CopyFace>, CatalogError> {
|
||||
let mut q = tx.prepare(
|
||||
"SELECT f.id, f.x, f.y, f.w, f.h, fp.person_id, fp.probability, fp.confirmed
|
||||
FROM faces f
|
||||
LEFT JOIN face_person fp ON fp.face_id = f.id
|
||||
WHERE f.image_id = ?1
|
||||
ORDER BY f.id",
|
||||
)?;
|
||||
let rows = q.query_map([image.0 as i64], |r| {
|
||||
let person: Option<i64> = r.get(5)?;
|
||||
Ok(CopyFace {
|
||||
id: r.get(0)?,
|
||||
rect: (
|
||||
r.get::<_, f64>(1)? as f32,
|
||||
r.get::<_, f64>(2)? as f32,
|
||||
r.get::<_, f64>(3)? as f32,
|
||||
r.get::<_, f64>(4)? as f32,
|
||||
),
|
||||
assignment: match person {
|
||||
Some(p) => Some((p, r.get::<_, f64>(6)?, r.get::<_, i64>(7)? != 0)),
|
||||
None => None,
|
||||
},
|
||||
})
|
||||
})?;
|
||||
Ok(rows.collect::<Result<Vec<_>, _>>()?)
|
||||
};
|
||||
|
||||
let theirs = boxes(from)?;
|
||||
if theirs.is_empty() {
|
||||
return Ok(out);
|
||||
}
|
||||
let ours = boxes(to)?;
|
||||
|
||||
if ours.is_empty() {
|
||||
tx.execute("DELETE FROM face_index WHERE image_id = ?1", [to.0 as i64])?;
|
||||
tx.execute(
|
||||
"UPDATE face_index SET image_id = ?2 WHERE image_id = ?1",
|
||||
rusqlite::params![from.0 as i64, to.0 as i64],
|
||||
)?;
|
||||
out.moved = tx.execute(
|
||||
"UPDATE faces SET image_id = ?2 WHERE image_id = ?1",
|
||||
rusqlite::params![from.0 as i64, to.0 as i64],
|
||||
)?;
|
||||
return Ok(out);
|
||||
}
|
||||
|
||||
let mut taken = vec![false; ours.len()];
|
||||
for face in &theirs {
|
||||
let best = ours
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| !taken[*i])
|
||||
.map(|(i, o)| (i, iou(face.rect, o.rect)))
|
||||
.filter(|(_, overlap)| *overlap > 0.5)
|
||||
.max_by(|a, b| a.1.total_cmp(&b.1));
|
||||
let Some((at, _)) = best else {
|
||||
if face.assignment.is_some_and(|(_, _, confirmed)| confirmed) {
|
||||
out.unmatched_named += 1;
|
||||
}
|
||||
continue;
|
||||
};
|
||||
taken[at] = true;
|
||||
let target = &ours[at];
|
||||
|
||||
out.rejections += tx.execute(
|
||||
"INSERT OR IGNORE INTO face_person_rejected (face_id, person_id)
|
||||
SELECT ?2, person_id FROM face_person_rejected WHERE face_id = ?1",
|
||||
rusqlite::params![face.id, target.id],
|
||||
)?;
|
||||
|
||||
let Some((person, probability, confirmed)) = face.assignment else {
|
||||
continue;
|
||||
};
|
||||
let carry = match target.assignment {
|
||||
None => true,
|
||||
// The same person: only a confirmation upgrades a suggestion.
|
||||
Some((p, _, theirs_confirmed)) if p == person => confirmed && !theirs_confirmed,
|
||||
// Another person, only suggested there: the user's word wins.
|
||||
Some((_, _, false)) => confirmed,
|
||||
// Another person, confirmed there: the survivor keeps its name.
|
||||
Some((_, _, true)) => {
|
||||
if confirmed {
|
||||
out.conflicts += 1;
|
||||
}
|
||||
false
|
||||
}
|
||||
};
|
||||
if carry {
|
||||
tx.execute(
|
||||
"INSERT INTO face_person (face_id, person_id, probability, confirmed)
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(face_id) DO UPDATE SET
|
||||
person_id = excluded.person_id,
|
||||
probability = excluded.probability,
|
||||
confirmed = excluded.confirmed",
|
||||
rusqlite::params![target.id, person, probability, confirmed],
|
||||
)?;
|
||||
// A name the user gave outranks a rejection of the same pair made
|
||||
// on the survivor — the same order `confirm` applies.
|
||||
if confirmed {
|
||||
tx.execute(
|
||||
"DELETE FROM face_person_rejected WHERE face_id = ?1 AND person_id = ?2",
|
||||
rusqlite::params![target.id, person],
|
||||
)?;
|
||||
}
|
||||
out.named += 1;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// One face as [`carry_onto_copy`] pairs it.
|
||||
struct CopyFace {
|
||||
id: i64,
|
||||
rect: (f32, f32, f32, f32),
|
||||
assignment: Option<(i64, f64, bool)>,
|
||||
}
|
||||
|
||||
pub(crate) fn now_secs() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@@ -2354,6 +2578,48 @@ mod tests {
|
||||
assert_eq!(people(&c).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
/// A rejection left on the redirect bound nothing: the next grouping
|
||||
/// pass suggested the face to the merged person, whom the user had told
|
||||
/// it was somebody else.
|
||||
#[test]
|
||||
fn merging_moves_the_rejections_too() {
|
||||
let c = db();
|
||||
let ids: Vec<FaceId> = (1..=3)
|
||||
.map(|n| {
|
||||
let img = image(&c, n);
|
||||
record_detections(&c, img, "w600k_mbf", 1024, &[face(n as u8)]).unwrap()[0]
|
||||
})
|
||||
.collect();
|
||||
let anna = create_person(&c, "Anna").unwrap();
|
||||
let annie = create_person(&c, "Annie").unwrap();
|
||||
// Rejected as Annie, and nothing said about Anna.
|
||||
reject(&c, ids[0], annie).unwrap();
|
||||
// Rejected as Annie, suggested as Anna: the rejection now covers it.
|
||||
suggest(&c, ids[1], anna, 0.8).unwrap();
|
||||
reject(&c, ids[1], annie).unwrap();
|
||||
// Rejected as Annie, confirmed as Anna: the confirmation stands.
|
||||
confirm(&c, ids[2], anna).unwrap();
|
||||
reject(&c, ids[2], annie).unwrap();
|
||||
|
||||
merge_people(&c, anna, annie).unwrap();
|
||||
|
||||
let rejected: Vec<(i64, i64)> = c
|
||||
.prepare("SELECT face_id, person_id FROM face_person_rejected ORDER BY face_id")
|
||||
.unwrap()
|
||||
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
|
||||
.unwrap()
|
||||
.collect::<Result<_, _>>()
|
||||
.unwrap();
|
||||
let anna_id = anna.0 as i64;
|
||||
assert_eq!(
|
||||
rejected,
|
||||
[(ids[0].0 as i64, anna_id), (ids[1].0 as i64, anna_id)]
|
||||
);
|
||||
assert_eq!(for_image(&c, ImageId(2)).unwrap()[0].person, None);
|
||||
let kept = &for_image(&c, ImageId(3)).unwrap()[0];
|
||||
assert_eq!((kept.person, kept.confirmed), (Some(anna), true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merging_does_not_duplicate_a_face_both_people_hold() {
|
||||
let c = db();
|
||||
@@ -2486,7 +2752,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// The reference implementation's fitted MBF curve puts the P=0.5 boundary
|
||||
/// at cosine 0.267 (docs/faces.md §1). Our own first end-to-end run scored
|
||||
/// at cosine 0.267 (docs/dev/faces.md §1). Our own first end-to-end run scored
|
||||
/// 0.596 between distinct photographs of one person and 0.05 between
|
||||
/// different people, so those two must land either side.
|
||||
#[test]
|
||||
|
||||
@@ -29,7 +29,11 @@ pub enum JobKind {
|
||||
ScanFolder = 0,
|
||||
/// Promote an image from stat-only to full EXIF.
|
||||
ExtractMetadata = 1,
|
||||
/// Build or rebuild a thumbnail.
|
||||
/// Build or rebuild a thumbnail. **Retired** — see [`JobKind::RETIRED`].
|
||||
///
|
||||
/// Kept so the number stays taken: a catalog written by 0.16.0 or earlier
|
||||
/// holds rows of kind 2, and reusing it would hand them to whatever took
|
||||
/// its place.
|
||||
Thumbnail = 2,
|
||||
/// A sidecar on disk is newer than what the catalog read.
|
||||
ReadSidecar = 3,
|
||||
@@ -69,6 +73,18 @@ impl JobKind {
|
||||
JobKind::DetectFaces,
|
||||
];
|
||||
|
||||
/// Kinds that are no longer queued by anything, whose rows are deleted on
|
||||
/// sight by [`drop_retired`].
|
||||
///
|
||||
/// `Thumbnail` is here because thumbnails are owed by the store, not by
|
||||
/// the queue. The grid's worker and the thumbnail sweep both find their
|
||||
/// work by asking `ThumbStore` what it lacks, and the store is shared
|
||||
/// between devices, so it is the only thing that can say another device
|
||||
/// already made one. Up to 0.16.0 every scan enqueued a job per
|
||||
/// photograph anyway and no handler ever claimed one: the reference
|
||||
/// catalog held 23,582 of them (#73; catalog.md §6.1).
|
||||
pub const RETIRED: [JobKind; 1] = [JobKind::Thumbnail];
|
||||
|
||||
fn from_i64(v: i64) -> Option<Self> {
|
||||
Some(match v {
|
||||
0 => JobKind::ScanFolder,
|
||||
@@ -185,7 +201,8 @@ pub fn enqueue(
|
||||
priority: Priority,
|
||||
payload: Option<&str>,
|
||||
) -> Result<(), CatalogError> {
|
||||
conn.execute(
|
||||
// Cached: a scan enqueues one per photograph it lists.
|
||||
conn.prepare_cached(
|
||||
"INSERT INTO jobs(kind, subject_id, priority, state, payload)
|
||||
VALUES (?1, ?2, ?3, 0, ?4)
|
||||
ON CONFLICT(kind, subject_id) DO UPDATE SET
|
||||
@@ -195,8 +212,13 @@ pub fn enqueue(
|
||||
state = CASE WHEN jobs.state = 2 THEN 0 ELSE jobs.state END,
|
||||
attempts = CASE WHEN jobs.state = 2 THEN 0 ELSE jobs.attempts END,
|
||||
not_before = CASE WHEN jobs.state = 2 THEN 0 ELSE jobs.not_before END",
|
||||
rusqlite::params![kind as i64, subject_id, priority as i64, payload],
|
||||
)?;
|
||||
)?
|
||||
.execute(rusqlite::params![
|
||||
kind as i64,
|
||||
subject_id,
|
||||
priority as i64,
|
||||
payload
|
||||
])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -393,7 +415,7 @@ pub fn recover_orphaned(conn: &Connection) -> Result<usize, CatalogError> {
|
||||
///
|
||||
/// Coalescing keeps the table one row per unit of work, but nothing shrinks it
|
||||
/// when the work stops existing: a library that has been culled carries a
|
||||
/// thumbnail job for every photograph deleted since the last time anything
|
||||
/// job for every photograph deleted since the last time anything
|
||||
/// looked. Each one would be claimed, run, and failed five times.
|
||||
///
|
||||
/// Only kinds whose subject really is an image ([`JobKind::subject_is_image`])
|
||||
@@ -425,6 +447,32 @@ pub fn reap_orphan_subjects(conn: &Connection) -> Result<usize, CatalogError> {
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Delete every row of a [`JobKind::RETIRED`] kind.
|
||||
///
|
||||
/// Not a migration, deliberately. A schema bump makes an older build refuse
|
||||
/// the synced catalog snapshot, and a device still on 0.16.0 would lose the
|
||||
/// catalog to save a megabyte. So this runs where the queue is readied —
|
||||
/// [`crate::runner::recover`], at every open — and has to be cheap when there
|
||||
/// is nothing to do: `kind` leads the `UNIQUE(kind, subject_id)` index, so an
|
||||
/// empty answer is one index probe, not a table scan.
|
||||
///
|
||||
/// Every open rather than once, because once is not enough: an older build
|
||||
/// opening the same catalog enqueues them again on its next scan.
|
||||
///
|
||||
/// Rows in any state go. Nothing claims these kinds, so none can be running,
|
||||
/// and a failed one would be a report about work nobody was going to do.
|
||||
pub fn drop_retired(conn: &Connection) -> Result<usize, CatalogError> {
|
||||
let kinds: Vec<i64> = JobKind::RETIRED.iter().map(|k| *k as i64).collect();
|
||||
let placeholders = std::iter::repeat_n("?", kinds.len())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let n = conn.execute(
|
||||
&format!("DELETE FROM jobs WHERE kind IN ({placeholders})"),
|
||||
rusqlite::params_from_iter(kinds.iter()),
|
||||
)?;
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// How much is left, by state.
|
||||
///
|
||||
/// One query rather than a listing, because the caller is a progress line: a
|
||||
|
||||
@@ -244,7 +244,8 @@ pub fn delete(conn: &Connection, id: KeywordId) -> Result<usize, CatalogError> {
|
||||
/// every assignment, and a query per keyword would be one statement per word
|
||||
/// in the library.
|
||||
pub fn list(conn: &Connection) -> Result<Vec<Keyword>, CatalogError> {
|
||||
let mut stmt = conn.prepare(
|
||||
ensure_term_index(conn);
|
||||
let mut stmt = conn.prepare_cached(
|
||||
// DISTINCT image, not row: a word on two versions of one frame is one
|
||||
// photograph, and reporting two is the kind of small lie that makes a
|
||||
// user stop trusting the counts.
|
||||
@@ -269,6 +270,27 @@ pub fn list(conn: &Connection) -> Result<Vec<Keyword>, CatalogError> {
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// The index [`list`]'s per-word count is served from: `keywords_term`
|
||||
/// with the version beside the word, so the count reads no `keywords` row.
|
||||
///
|
||||
/// `keywords_term` alone gave the row id, and each of the 10,800 assignments
|
||||
/// on the reference library cost a probe of the table for its version --
|
||||
/// 4 ms of the keyword panel's redraw, on every selection change.
|
||||
///
|
||||
/// Created on first use rather than by a migration, for the reason
|
||||
/// `duplicates::ensure_probe_table` gives: a new schema version makes every
|
||||
/// older build refuse this catalog's snapshot at sync, and an older build
|
||||
/// that meets an extra index ignores it. Once it exists, the statement is a
|
||||
/// lookup in the schema (microseconds). A failure to create it is logged and
|
||||
/// the list read without it: the index is a speed-up, never an answer.
|
||||
fn ensure_term_index(conn: &Connection) {
|
||||
if let Err(e) = conn.execute_batch(
|
||||
"CREATE INDEX IF NOT EXISTS keywords_term_version ON keywords(keyword, version_id);",
|
||||
) {
|
||||
log::warn!("keywords: could not create keywords_term_version: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Assign a keyword to images, creating the keyword if it is new.
|
||||
///
|
||||
/// The bulk form is the *only* form, because keywording a selection is the
|
||||
@@ -491,7 +513,12 @@ pub fn adopt_orphan_terms(conn: &Connection) -> Result<usize, CatalogError> {
|
||||
// quietly readmitted to the vocabulary; it stays visible as an
|
||||
// orphan in [`for_images`] instead, which is a state someone can
|
||||
// see and act on rather than one that silently undoes a deletion.
|
||||
"SELECT DISTINCT k.keyword FROM keywords k
|
||||
//
|
||||
// The distinct words first, then the check: the vocabulary has no
|
||||
// index a tombstone-inclusive lookup can use, so checking once per
|
||||
// assignment scanned it 10,000 times on every open. Once per word
|
||||
// is a few dozen scans of a few dozen rows.
|
||||
"SELECT k.keyword FROM (SELECT DISTINCT keyword FROM keywords) k
|
||||
WHERE NOT EXISTS (SELECT 1 FROM keyword_terms t
|
||||
WHERE t.name = k.keyword)",
|
||||
)?;
|
||||
|
||||
@@ -36,16 +36,21 @@ use std::path::Path;
|
||||
use dr_types::{Availability, ImageId};
|
||||
use rusqlite::Connection;
|
||||
|
||||
pub mod albums;
|
||||
mod backfilled;
|
||||
pub mod bursts;
|
||||
pub mod cache;
|
||||
pub mod collections;
|
||||
pub mod dedup;
|
||||
pub mod dedup_people;
|
||||
pub mod duplicates;
|
||||
pub mod error;
|
||||
pub mod face_shard;
|
||||
pub mod faces;
|
||||
pub mod jobs;
|
||||
pub mod keywords;
|
||||
pub mod merge;
|
||||
pub mod name_dates;
|
||||
pub mod query;
|
||||
pub mod rating;
|
||||
pub mod recovery;
|
||||
@@ -56,6 +61,7 @@ pub mod sync;
|
||||
pub mod trash;
|
||||
pub mod walk;
|
||||
|
||||
pub use albums::{Album, AlbumId, Place};
|
||||
pub use cache::{Budget, Cache, DEFAULT_BUDGET_BYTES};
|
||||
pub use collections::{Collection, CollectionKind, TreeRow};
|
||||
pub use dedup::{seen_by_content, seen_by_metadata, set_content_hash};
|
||||
@@ -246,8 +252,18 @@ impl Catalog {
|
||||
// A migration adds a column; it cannot know what the value should be
|
||||
// for rows that already existed. Backfilling on open is what stops
|
||||
// those rows being silently partial.
|
||||
for (what, n) in schema::backfill(&conn)? {
|
||||
log::info!("backfilled {what} for {n} row(s) (schema was v{from})");
|
||||
//
|
||||
// Once per catalog state rather than once per open (NFR-P9): every
|
||||
// worker thread opens its own connection, and a develop landing made
|
||||
// five, each paying the whole backfill to confirm nothing had changed.
|
||||
// [`backfilled`] says what "changed" means and why it is enough. A
|
||||
// migration always backfills, stamp or no stamp.
|
||||
let stamp = backfilled::stamp(&conn, path)?;
|
||||
if from < schema::SCHEMA_VERSION || !backfilled::is_current(path, &stamp) {
|
||||
for (what, n) in schema::backfill(&conn)? {
|
||||
log::info!("backfilled {what} for {n} row(s) (schema was v{from})");
|
||||
}
|
||||
backfilled::record(path, stamp);
|
||||
}
|
||||
Ok(Catalog { conn })
|
||||
}
|
||||
|
||||
+793
-106
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,387 @@
|
||||
//! TRACES: FR-CAT-5
|
||||
//! A capture time read from the file's name, for an image whose header has
|
||||
//! none.
|
||||
//!
|
||||
//! # Why
|
||||
//!
|
||||
//! A photograph with no EXIF date sorts after everything else, so it is lost
|
||||
//! at the end of the grid and absent from the timeline. The files that end up
|
||||
//! there are rarely without a date — they are without *EXIF*: WhatsApp strips
|
||||
//! every tag and names the file `WhatsApp Image 2023-06-15 at 07.00.42.jpeg`,
|
||||
//! a Windows Phone wrote `WP_20140922_14_16_27_Pro.jpg`, a phone camera
|
||||
//! `IMG_20190812_153012.jpg`, and darktable's import renames to
|
||||
//! `20230629_0001.jpeg`. On the reference library 250 of 274 undated images
|
||||
//! carried their date in the name or in the folder above it.
|
||||
//!
|
||||
//! # What is accepted
|
||||
//!
|
||||
//! A date is `YYYYMMDD` as a whole run of digits, or `YYYY`, `MM` and `DD`
|
||||
//! joined by `-`, `_` or `.`. A time may follow it — `HHMMSS` as one run (or
|
||||
//! nine digits, milliseconds appended), or three two-digit runs joined by
|
||||
//! `-`, `_`, `.` or `:` — after `_`, `-`, `.`, `T`, a space or ` at `.
|
||||
//! Anything else after the date leaves it at midnight: `_0059` in
|
||||
//! `20230628_0059` is a sequence number, not 00:59, and reading it as a time
|
||||
//! would invent one.
|
||||
//!
|
||||
//! The name is tried first and then each folder above it, innermost first —
|
||||
//! `2016/2016-11-11/IMG_7910.jpg` is dated by its folder. A bare year folder
|
||||
//! is not a date: putting a photograph at 1 January is a wrong answer, and an
|
||||
//! undated one at least says it does not know.
|
||||
//!
|
||||
//! The reading is wall-clock time with no zone, stored as EXIF's is
|
||||
//! (`dr_decode::parse_exif_datetime`), and EXIF always wins: this only fills
|
||||
//! rows whose `captured_at` is still empty.
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::CatalogError;
|
||||
|
||||
/// The capture time a path's name states, as wall-clock Unix seconds.
|
||||
pub fn date_from_path(source_ref: &str) -> Option<i64> {
|
||||
let mut parts = source_ref.rsplit(['/', '\\']);
|
||||
let name = parts.next()?;
|
||||
let stem = name.rsplit_once('.').map_or(name, |(stem, _)| stem);
|
||||
date_in(stem).or_else(|| parts.find_map(date_in))
|
||||
}
|
||||
|
||||
/// Date every examined, undated image whose name states one.
|
||||
///
|
||||
/// `only` limits the pass to the images just examined — what the sweep hands
|
||||
/// in — and `None` visits every undated image, which is the backfill's case.
|
||||
/// Both read the undated side alone (`images_captured` answers
|
||||
/// `captured_at IS NULL` with a seek), never the library.
|
||||
///
|
||||
/// Returns how many images were dated.
|
||||
pub fn fill(conn: &Connection, only: Option<&[i64]>) -> Result<usize, CatalogError> {
|
||||
let rows: Vec<(i64, String)> = match only {
|
||||
None => {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, source_ref FROM images
|
||||
WHERE captured_at IS NULL AND metadata_state >= 2",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
|
||||
.collect::<Result<_, _>>()?;
|
||||
rows
|
||||
}
|
||||
Some(ids) => {
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"SELECT source_ref FROM images
|
||||
WHERE id = ?1 AND captured_at IS NULL AND metadata_state >= 2",
|
||||
)?;
|
||||
let mut rows = Vec::new();
|
||||
for &id in ids {
|
||||
let mut q = stmt.query([id])?;
|
||||
if let Some(r) = q.next()? {
|
||||
rows.push((id, r.get(0)?));
|
||||
}
|
||||
}
|
||||
rows
|
||||
}
|
||||
};
|
||||
|
||||
let dated: Vec<(i64, i64)> = rows
|
||||
.iter()
|
||||
.filter_map(|(id, path)| date_from_path(path).map(|at| (*id, at)))
|
||||
.collect();
|
||||
if dated.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// A savepoint rather than a transaction, so a caller already inside one
|
||||
// can still call this: the backfill's 250 rows are one commit, not 250.
|
||||
conn.execute_batch("SAVEPOINT name_dates")?;
|
||||
let written = (|| {
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"UPDATE images SET captured_at = ?2 WHERE id = ?1 AND captured_at IS NULL",
|
||||
)?;
|
||||
let mut n = 0;
|
||||
for (id, at) in &dated {
|
||||
n += stmt.execute(rusqlite::params![id, at])?;
|
||||
}
|
||||
Ok::<_, CatalogError>(n)
|
||||
})();
|
||||
match written {
|
||||
Ok(n) => {
|
||||
conn.execute_batch("RELEASE name_dates")?;
|
||||
Ok(n)
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = conn.execute_batch("ROLLBACK TO name_dates; RELEASE name_dates");
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The first date, with its time if one follows, in one name component.
|
||||
fn date_in(s: &str) -> Option<i64> {
|
||||
let b = s.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
// Only at the start of a run of digits: a date inside a longer number
|
||||
// is a coincidence, not a date.
|
||||
if b[i].is_ascii_digit() && (i == 0 || !b[i - 1].is_ascii_digit()) {
|
||||
if let Some(at) = date_at(b, i) {
|
||||
return Some(at);
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// A date starting at `i`, and the time after it if there is one.
|
||||
fn date_at(b: &[u8], i: usize) -> Option<i64> {
|
||||
let run = digits(b, i);
|
||||
let ((y, mo, d), after) = match run.len() {
|
||||
// YYYYMMDD, or YYYYMMDDHHMMSS written as one number.
|
||||
8 | 14 => ((num(&run[..4]), num(&run[4..6]), num(&run[6..8])), i + 8),
|
||||
4 => {
|
||||
let sep = |at: usize| matches!(b.get(at), Some(b'-' | b'_' | b'.'));
|
||||
let mo_at = i + 4 + 1;
|
||||
let d_at = mo_at + 2 + 1;
|
||||
if !(sep(i + 4) && digits(b, mo_at).len() == 2 && sep(mo_at + 2))
|
||||
|| digits(b, d_at).len() != 2
|
||||
{
|
||||
return None;
|
||||
}
|
||||
(
|
||||
(num(run), num(&b[mo_at..mo_at + 2]), num(&b[d_at..d_at + 2])),
|
||||
d_at + 2,
|
||||
)
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
let day = civil_days(y, mo, d)?;
|
||||
|
||||
let time = if run.len() == 14 {
|
||||
hms(num(&run[8..10]), num(&run[10..12]), num(&run[12..14]))
|
||||
} else {
|
||||
time_at(b, after)
|
||||
};
|
||||
Some(day * 86_400 + time.unwrap_or(0))
|
||||
}
|
||||
|
||||
/// The time following a date that ends at `i`, as seconds into the day.
|
||||
fn time_at(b: &[u8], i: usize) -> Option<i64> {
|
||||
let rest = &b[i..];
|
||||
let start = if rest.starts_with(b" at ") {
|
||||
i + 4
|
||||
} else if matches!(rest.first(), Some(b'_' | b'-' | b'.' | b'T' | b' ')) {
|
||||
i + 1
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let run = digits(b, start);
|
||||
match run.len() {
|
||||
// HHMMSS, or with milliseconds appended (Pixel's PXL_…_123456789).
|
||||
6 | 9 => hms(num(&run[..2]), num(&run[2..4]), num(&run[4..6])),
|
||||
2 => {
|
||||
let sep = |at: usize| matches!(b.get(at), Some(b'-' | b'_' | b'.' | b':'));
|
||||
let (m_at, s_at) = (start + 3, start + 6);
|
||||
if !(sep(start + 2) && digits(b, m_at).len() == 2 && sep(m_at + 2))
|
||||
|| digits(b, s_at).len() != 2
|
||||
{
|
||||
return None;
|
||||
}
|
||||
hms(num(run), num(&b[m_at..m_at + 2]), num(&b[s_at..s_at + 2]))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The run of ASCII digits starting at `i`.
|
||||
fn digits(b: &[u8], i: usize) -> &[u8] {
|
||||
let rest = b.get(i..).unwrap_or(&[]);
|
||||
let n = rest.iter().take_while(|c| c.is_ascii_digit()).count();
|
||||
&rest[..n]
|
||||
}
|
||||
|
||||
fn num(d: &[u8]) -> i64 {
|
||||
d.iter().fold(0, |n, c| n * 10 + i64::from(c - b'0'))
|
||||
}
|
||||
|
||||
fn hms(h: i64, m: i64, s: i64) -> Option<i64> {
|
||||
((0..24).contains(&h) && (0..60).contains(&m) && (0..61).contains(&s))
|
||||
.then_some(h * 3_600 + m * 60 + s)
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 for a valid civil date, `None` for anything else.
|
||||
///
|
||||
/// The year range is EXIF's (`parse_exif_datetime`): wide enough for scanned
|
||||
/// film, narrow enough that a counter such as `12345678` is not a date.
|
||||
fn civil_days(y: i64, mo: i64, d: i64) -> Option<i64> {
|
||||
let leap = y % 4 == 0 && (y % 100 != 0 || y % 400 == 0);
|
||||
let month_len = match mo {
|
||||
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
|
||||
4 | 6 | 9 | 11 => 30,
|
||||
2 if leap => 29,
|
||||
2 => 28,
|
||||
_ => return None,
|
||||
};
|
||||
if !(1900..=2200).contains(&y) || !(1..=month_len).contains(&d) {
|
||||
return None;
|
||||
}
|
||||
let y_adj = if mo <= 2 { y - 1 } else { y };
|
||||
let era = y_adj.div_euclid(400);
|
||||
let yoe = y_adj - era * 400;
|
||||
let mp = (mo + 9) % 12;
|
||||
let doy = (153 * mp + 2) / 5 + d - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
Some(era * 146_097 + doe - 719_468)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Wall-clock seconds for a date and time, the expected side of each case.
|
||||
fn at(y: i64, mo: i64, d: i64, h: i64, mi: i64, s: i64) -> Option<i64> {
|
||||
Some(civil_days(y, mo, d).unwrap() * 86_400 + h * 3_600 + mi * 60 + s)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_names_in_the_reference_library_are_read() {
|
||||
// Every shape here is a file that sat undated at the end of the grid.
|
||||
for (path, want) in [
|
||||
(
|
||||
"PhotosRaw/alps trip/alps whatsapp/WhatsApp Image 2023-06-15 at 07.00.42.jpeg",
|
||||
at(2023, 6, 15, 7, 0, 42),
|
||||
),
|
||||
(
|
||||
"PhotosRaw/alps trip/alps whatsapp/WhatsApp Image 2023-06-17 at 12.45.52 (1).jpeg",
|
||||
at(2023, 6, 17, 12, 45, 52),
|
||||
),
|
||||
(
|
||||
"PhotosRaw/WP_20140922_14_16_27_Pro.jpg",
|
||||
at(2014, 9, 22, 14, 16, 27),
|
||||
),
|
||||
// A sequence number after the date is not a time.
|
||||
(
|
||||
"PhotosRaw/Darktable/20230629_no_name/20230629_0001.jpeg",
|
||||
at(2023, 6, 29, 0, 0, 0),
|
||||
),
|
||||
("PhotosRaw/20230628_0059.jpg", at(2023, 6, 28, 0, 0, 0)),
|
||||
(
|
||||
"PhotosRaw/backdrops/IMG_20130625_0021.jpg",
|
||||
at(2013, 6, 25, 0, 0, 0),
|
||||
),
|
||||
(
|
||||
"PhotosRaw/alps trip/20230628_0641 - 20230628_0661.jpg",
|
||||
at(2023, 6, 28, 0, 0, 0),
|
||||
),
|
||||
] {
|
||||
assert_eq!(date_from_path(path), want, "{path}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn common_camera_and_app_names_are_read() {
|
||||
for (path, want) in [
|
||||
("IMG_20190812_153012.jpg", at(2019, 8, 12, 15, 30, 12)),
|
||||
("PXL_20210101_123456789.jpg", at(2021, 1, 1, 12, 34, 56)),
|
||||
(
|
||||
"Screenshot_2021-03-04-12-30-45.png",
|
||||
at(2021, 3, 4, 12, 30, 45),
|
||||
),
|
||||
(
|
||||
"Screenshot from 2021-03-04 12-30-45.png",
|
||||
at(2021, 3, 4, 12, 30, 45),
|
||||
),
|
||||
("IMG-20210304-WA0001.jpg", at(2021, 3, 4, 0, 0, 0)),
|
||||
("20210304143012.jpg", at(2021, 3, 4, 14, 30, 12)),
|
||||
("2019.12.25 party.jpg", at(2019, 12, 25, 0, 0, 0)),
|
||||
("signal-2022-01-02-101112.jpg", at(2022, 1, 2, 10, 11, 12)),
|
||||
("2022-01-02T10:11:12.jpg", at(2022, 1, 2, 10, 11, 12)),
|
||||
] {
|
||||
assert_eq!(date_from_path(path), want, "{path}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_dates_a_name_that_does_not() {
|
||||
assert_eq!(
|
||||
date_from_path("PhotosRaw/2016/2016-11-11/IMG_7910.jpg"),
|
||||
at(2016, 11, 11, 0, 0, 0)
|
||||
);
|
||||
// The innermost folder that states a date wins.
|
||||
assert_eq!(
|
||||
date_from_path("2016-01-01 trip/2016-01-03/_MG_1.jpg"),
|
||||
at(2016, 1, 3, 0, 0, 0)
|
||||
);
|
||||
// The name beats its folder.
|
||||
assert_eq!(
|
||||
date_from_path("2016-11-11/IMG_20161112_080000.jpg"),
|
||||
at(2016, 11, 12, 8, 0, 0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers_that_are_not_dates_are_left_alone() {
|
||||
for path in [
|
||||
"PhotosRaw/_MG_9002.jpg",
|
||||
"PhotosRaw/scanning/fau_2.jpg",
|
||||
// A year folder is not a day.
|
||||
"PhotosRaw/2016/_MG_1.jpg",
|
||||
"IMG_1999.jpg",
|
||||
"DSC_12345678.jpg", // month 56
|
||||
"20230230_0001.jpg", // 30 February
|
||||
"120230615.jpg", // the date is inside a longer number
|
||||
"1612345678901.jpg", // a millisecond epoch, not a civil date
|
||||
"2023-6-15.jpg", // a one-digit month is too loose to trust
|
||||
] {
|
||||
assert_eq!(date_from_path(path), None, "{path}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_time_that_cannot_be_is_dropped_and_the_date_kept() {
|
||||
assert_eq!(
|
||||
date_from_path("20230615_256199.jpg"),
|
||||
at(2023, 6, 15, 0, 0, 0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_dates_only_examined_undated_rows_and_never_overrides_exif() {
|
||||
let c = Connection::open_in_memory().unwrap();
|
||||
crate::schema::migrate(&c).unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'lib')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
// (id, name, captured_at, metadata_state)
|
||||
for (id, name, captured, state) in [
|
||||
(1i64, "IMG_20190812_153012.jpg", None, 2i64),
|
||||
// EXIF already answered; the name disagrees and loses.
|
||||
(2, "IMG_20190812_153012b.jpg", Some(42i64), 2),
|
||||
// Not yet examined: EXIF may still come, so the name waits.
|
||||
(3, "IMG_20190813_000000.jpg", None, 1),
|
||||
(4, "_MG_9002.jpg", None, 2),
|
||||
] {
|
||||
c.execute(
|
||||
"INSERT INTO images(id, root_id, source_ref, captured_at, metadata_state, added_at)
|
||||
VALUES (?1, 1, ?2, ?3, ?4, 0)",
|
||||
rusqlite::params![id, name, captured, state],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let captured = |id: i64| -> Option<i64> {
|
||||
c.query_row("SELECT captured_at FROM images WHERE id = ?1", [id], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
assert_eq!(fill(&c, Some(&[2, 3, 4])).unwrap(), 0);
|
||||
assert_eq!(fill(&c, None).unwrap(), 1);
|
||||
assert_eq!(captured(1), at(2019, 8, 12, 15, 30, 12));
|
||||
assert_eq!(captured(2), Some(42));
|
||||
assert_eq!(captured(3), None);
|
||||
assert_eq!(captured(4), None);
|
||||
// Nothing left to do is a no-op, not a rewrite.
|
||||
assert_eq!(fill(&c, None).unwrap(), 0);
|
||||
}
|
||||
}
|
||||
+318
-22
@@ -49,6 +49,12 @@ pub struct Judgement {
|
||||
/// 0..=5. Zero means *unrated*, which is a state in its own right.
|
||||
pub rating: u8,
|
||||
pub flag: FlagState,
|
||||
/// TRACES: FR-CAT-5
|
||||
/// The colour label, or `None`. Not part of [`Judgement::is_judged`]:
|
||||
/// a label sorts photographs into piles of the photographer's own
|
||||
/// meaning — "to print", "send to Anna" — and says nothing about whether
|
||||
/// a frame has been culled, which is the question "unjudged" asks.
|
||||
pub label: Option<ColourLabel>,
|
||||
}
|
||||
|
||||
impl Judgement {
|
||||
@@ -267,14 +273,6 @@ pub fn align_default_version_uuids(conn: &Connection) -> Result<usize, CatalogEr
|
||||
Ok(moved)
|
||||
}
|
||||
|
||||
/// The default version's row id for an image, creating one if it has none.
|
||||
///
|
||||
/// Every write path goes through this rather than assuming a version exists.
|
||||
/// An image can arrive without one in two ways that are not worth trying to
|
||||
/// prevent: a row inserted by a build predating this module, and a scan whose
|
||||
/// version pass was interrupted between the image insert and the commit.
|
||||
/// Failing a rating because of either would be the wrong answer — the user
|
||||
/// pressed a key and expects a star.
|
||||
/// TRACES: FR-CAT-13
|
||||
/// How `versions.label` encodes a colour label, and back.
|
||||
///
|
||||
@@ -304,6 +302,14 @@ pub fn label_from_code(code: Option<i64>) -> Option<ColourLabel> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The default version's row id for an image, creating one if it has none.
|
||||
///
|
||||
/// Every write path goes through this rather than assuming a version exists.
|
||||
/// An image can arrive without one in two ways that are not worth trying to
|
||||
/// prevent: a row inserted by a build predating this module, and a scan whose
|
||||
/// version pass was interrupted between the image insert and the commit.
|
||||
/// Failing a rating because of either would be the wrong answer — the user
|
||||
/// pressed a key and expects a star.
|
||||
pub fn default_version_id(conn: &Connection, image: ImageId) -> Result<i64, CatalogError> {
|
||||
let existing: Option<i64> = conn
|
||||
.query_row(
|
||||
@@ -387,6 +393,97 @@ pub fn set_flag_many(
|
||||
apply_many(conn, images, |conn, id| set_flag(conn, id, flag))
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-5
|
||||
/// Set or clear the colour label for one image.
|
||||
pub fn set_label(
|
||||
conn: &Connection,
|
||||
image: ImageId,
|
||||
label: Option<ColourLabel>,
|
||||
) -> Result<(), CatalogError> {
|
||||
let version = default_version_id(conn, image)?;
|
||||
conn.execute(
|
||||
"UPDATE versions SET label = ?2 WHERE id = ?1",
|
||||
rusqlite::params![version, label.map(label_code)],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-5
|
||||
/// Set or clear a label on many images in one transaction — one keystroke
|
||||
/// over a selection is one commit, as for [`set_rating_many`].
|
||||
pub fn set_label_many(
|
||||
conn: &Connection,
|
||||
images: &[ImageId],
|
||||
label: Option<ColourLabel>,
|
||||
) -> Result<usize, CatalogError> {
|
||||
apply_many(conn, images, |conn, id| set_label(conn, id, label))
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-5
|
||||
/// What a label key does to a set of images: Lightroom's toggle.
|
||||
///
|
||||
/// Pressing the key for the label every one of them already carries takes it
|
||||
/// off; otherwise every one of them gets it. Decided over the whole set
|
||||
/// rather than per image, so a selection that was half red comes out all red
|
||||
/// rather than inverted — the photographer pressed "red", and a key that
|
||||
/// turned half of them red and the other half plain would be two answers to
|
||||
/// one question.
|
||||
pub fn toggled_label(
|
||||
current: impl IntoIterator<Item = Option<ColourLabel>>,
|
||||
pressed: ColourLabel,
|
||||
) -> Option<ColourLabel> {
|
||||
let mut any = false;
|
||||
for label in current {
|
||||
any = true;
|
||||
if label != Some(pressed) {
|
||||
return Some(pressed);
|
||||
}
|
||||
}
|
||||
if any {
|
||||
None
|
||||
} else {
|
||||
Some(pressed)
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-5 | FR-CAT-6
|
||||
/// How the library divides by colour label, for the filter chips' counts.
|
||||
///
|
||||
/// Index 0 is unlabelled and index `n` the label whose code is `n`. The
|
||||
/// same shape as [`rating_histogram`], and for the same reason the
|
||||
/// unlabelled slot is what is left of [`judged_rows`]: an image without a
|
||||
/// version row is unlabelled, not missing.
|
||||
///
|
||||
/// Only labelled rows are grouped. The join this replaced (2026-09-26)
|
||||
/// probed `versions_judgement` per image and then read each version's row
|
||||
/// for `label`, which the index does not carry -- 10 ms on the reference
|
||||
/// library, on every label keystroke, to find that none of 23,500 images
|
||||
/// had one. This walks the default versions in the index's order, which
|
||||
/// is close to the table's, and groups the few that are labelled.
|
||||
pub fn label_histogram(conn: &Connection) -> Result<[usize; 6], CatalogError> {
|
||||
let mut out = [0usize; 6];
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"SELECT label, count(*) FROM versions
|
||||
WHERE is_default = 1 AND label IS NOT NULL
|
||||
GROUP BY label",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?;
|
||||
let mut counted = 0usize;
|
||||
for (code, count) in rows.flatten() {
|
||||
// A code this build does not know counts as unlabelled, which is how
|
||||
// `label_from_code` reads it everywhere else.
|
||||
let slot = if label_from_code(Some(code)).is_some() {
|
||||
code as usize
|
||||
} else {
|
||||
0
|
||||
};
|
||||
out[slot] += count as usize;
|
||||
counted += count as usize;
|
||||
}
|
||||
out[0] += judged_rows(conn)?.saturating_sub(counted);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Shared bulk wrapper, so the two axes cannot drift in their commit
|
||||
/// behaviour — a partially-committed rating and a fully-committed flag from
|
||||
/// the same keystroke would be hard to explain and harder to notice.
|
||||
@@ -411,21 +508,22 @@ fn apply_many(
|
||||
/// An image with no version reads as unrated and unflagged rather than as an
|
||||
/// error: that is exactly what it is.
|
||||
pub fn judgement(conn: &Connection, image: ImageId) -> Result<Judgement, CatalogError> {
|
||||
let row: Option<(i64, i64)> = conn
|
||||
let row: Option<(i64, i64, Option<i64>)> = conn
|
||||
.query_row(
|
||||
"SELECT rating, flag FROM versions
|
||||
"SELECT rating, flag, label FROM versions
|
||||
WHERE image_id = ?1
|
||||
ORDER BY is_default DESC, id ASC
|
||||
LIMIT 1",
|
||||
[image.0 as i64],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||
)
|
||||
.optional()?;
|
||||
|
||||
Ok(match row {
|
||||
Some((rating, flag)) => Judgement {
|
||||
Some((rating, flag, label)) => Judgement {
|
||||
rating: rating.clamp(0, MAX_RATING as i64) as u8,
|
||||
flag: flag_from_code(flag),
|
||||
label: label_from_code(label),
|
||||
},
|
||||
None => Judgement::default(),
|
||||
})
|
||||
@@ -452,7 +550,7 @@ pub fn judgements(
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let sql = format!(
|
||||
"SELECT image_id, rating, flag FROM versions
|
||||
"SELECT image_id, rating, flag, label FROM versions
|
||||
WHERE image_id IN ({placeholders}) AND is_default = 1"
|
||||
);
|
||||
|
||||
@@ -467,15 +565,17 @@ pub fn judgements(
|
||||
r.get::<_, i64>(0)?,
|
||||
r.get::<_, i64>(1)?,
|
||||
r.get::<_, i64>(2)?,
|
||||
r.get::<_, Option<i64>>(3)?,
|
||||
))
|
||||
})?;
|
||||
|
||||
for (image, rating, flag) in rows.flatten() {
|
||||
for (image, rating, flag, label) in rows.flatten() {
|
||||
out.insert(
|
||||
ImageId(image as u64),
|
||||
Judgement {
|
||||
rating: rating.clamp(0, MAX_RATING as i64) as u8,
|
||||
flag: flag_from_code(flag),
|
||||
label: label_from_code(label),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -491,25 +591,61 @@ pub fn judgements(
|
||||
pub fn rating_histogram(conn: &Connection) -> Result<[usize; 6], CatalogError> {
|
||||
let mut out = [0usize; 6];
|
||||
|
||||
// LEFT JOIN, so an image whose version row is missing still counts as
|
||||
// unrated rather than vanishing from the totals. The histogram has to sum
|
||||
// to the library size or it is not believable.
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT coalesce(v.rating, 0) AS r, count(*)
|
||||
FROM images i
|
||||
LEFT JOIN versions v ON v.image_id = i.id AND v.is_default = 1
|
||||
GROUP BY r",
|
||||
// Only the rated rows are grouped; the unrated slot is what is left of
|
||||
// [`judged_rows`]. So an image whose version row is missing still counts
|
||||
// as unrated rather than vanishing from the totals -- the histogram has
|
||||
// to sum to the library size or it is not believable.
|
||||
//
|
||||
// It was one `images LEFT JOIN versions ... GROUP BY` until 2026-09-26:
|
||||
// a probe of `versions_judgement` per image and a sort of every row, to
|
||||
// put 22,000 of 23,500 in slot zero. 9 ms on every star keystroke on the
|
||||
// reference library; this is a pass over the index that sorts only the
|
||||
// rated few, and [`judged_rows`] is three index-only counts.
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"SELECT rating, count(*) FROM versions
|
||||
WHERE is_default = 1 AND rating != 0
|
||||
GROUP BY rating",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?;
|
||||
|
||||
let mut counted = 0usize;
|
||||
for (rating, count) in rows.flatten() {
|
||||
if let Some(slot) = out.get_mut(rating.clamp(0, MAX_RATING as i64) as usize) {
|
||||
*slot += count as usize;
|
||||
counted += count as usize;
|
||||
}
|
||||
}
|
||||
out[0] += judged_rows(conn)?.saturating_sub(counted);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// How many rows `images LEFT JOIN versions ON ... AND is_default = 1` has:
|
||||
/// one per image with no default version, and one per default version for
|
||||
/// the rest. The total both histograms divide up, and the unjudged slot is
|
||||
/// what is left of it once the judged rows are counted.
|
||||
///
|
||||
/// Spelled as three counts rather than as that join because the join probes
|
||||
/// `versions_judgement` once per image, where each count here is one pass
|
||||
/// over an index without reading a row: the library size, the default
|
||||
/// versions, and the images holding one. An image with two default versions
|
||||
/// -- nothing prevents it -- is two rows of the join and one image of the
|
||||
/// third count, so it adds one here exactly as it did there. A version
|
||||
/// always belongs to an image; `foreign_keys` is on and deletes cascade.
|
||||
///
|
||||
/// `count(DISTINCT image_id)` alone in its statement: that is what lets
|
||||
/// SQLite read the distinct values off the index's order instead of
|
||||
/// building a temporary b-tree of them.
|
||||
fn judged_rows(conn: &Connection) -> Result<usize, CatalogError> {
|
||||
let n: i64 = conn
|
||||
.prepare_cached(
|
||||
"SELECT (SELECT count(*) FROM images)
|
||||
+ (SELECT count(*) FROM versions WHERE is_default = 1)
|
||||
- (SELECT count(DISTINCT image_id) FROM versions WHERE is_default = 1)",
|
||||
)?
|
||||
.query_row([], |r| r.get(0))?;
|
||||
Ok(n.max(0) as usize)
|
||||
}
|
||||
|
||||
/// How many images carry each flag: `(picks, rejects)`.
|
||||
pub fn flag_counts(conn: &Connection) -> Result<(usize, usize), CatalogError> {
|
||||
let picks: i64 = conn.query_row(
|
||||
@@ -686,6 +822,72 @@ mod tests {
|
||||
assert_eq!(distinct, 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_label_round_trips_and_clears() {
|
||||
// TRACES: FR-CAT-5
|
||||
let cat = with_images(1);
|
||||
let id = ids(&cat)[0];
|
||||
set_label(cat.connection(), id, Some(ColourLabel::Green)).unwrap();
|
||||
assert_eq!(
|
||||
judgement(cat.connection(), id).unwrap().label,
|
||||
Some(ColourLabel::Green)
|
||||
);
|
||||
set_label(cat.connection(), id, None).unwrap();
|
||||
assert_eq!(judgement(cat.connection(), id).unwrap().label, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_label_is_not_a_judgement() {
|
||||
// "Unjudged" is the cull's resume point; a label is a pile of the
|
||||
// photographer's own, and labelling a frame must not hide it there.
|
||||
let cat = with_images(1);
|
||||
let id = ids(&cat)[0];
|
||||
set_label(cat.connection(), id, Some(ColourLabel::Red)).unwrap();
|
||||
assert!(!judgement(cat.connection(), id).unwrap().is_judged());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labelling_a_selection_is_one_commit_and_reaches_every_image() {
|
||||
// TRACES: FR-CAT-5
|
||||
let cat = with_images(4);
|
||||
let all = ids(&cat);
|
||||
assert_eq!(
|
||||
set_label_many(cat.connection(), &all, Some(ColourLabel::Blue)).unwrap(),
|
||||
4
|
||||
);
|
||||
let found = judgements(cat.connection(), &all).unwrap();
|
||||
assert!(all
|
||||
.iter()
|
||||
.all(|id| found[id].label == Some(ColourLabel::Blue)));
|
||||
assert_eq!(
|
||||
label_histogram(cat.connection()).unwrap(),
|
||||
[0, 0, 0, 0, 4, 0]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_label_key_toggles_only_when_every_image_already_has_it() {
|
||||
// TRACES: FR-CAT-5
|
||||
use ColourLabel::*;
|
||||
assert_eq!(toggled_label([Some(Red), Some(Red)], Red), None);
|
||||
assert_eq!(toggled_label([Some(Red), None], Red), Some(Red));
|
||||
assert_eq!(toggled_label([Some(Blue)], Red), Some(Red));
|
||||
assert_eq!(toggled_label([], Red), Some(Red));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_label_histogram_sums_to_the_library() {
|
||||
// TRACES: FR-CAT-6
|
||||
// Images without a version row count as unlabelled rather than
|
||||
// vanishing, as the rating histogram's do.
|
||||
let cat = with_images(3);
|
||||
let first = ids(&cat)[0];
|
||||
set_label(cat.connection(), first, Some(ColourLabel::Purple)).unwrap();
|
||||
let h = label_histogram(cat.connection()).unwrap();
|
||||
assert_eq!(h, [2, 0, 0, 0, 0, 1]);
|
||||
assert_eq!(h.iter().sum::<usize>(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rating_round_trips() {
|
||||
let cat = with_images(1);
|
||||
@@ -830,6 +1032,100 @@ mod tests {
|
||||
assert_eq!(h.iter().sum::<usize>(), 4);
|
||||
}
|
||||
|
||||
/// The rows of the join the histograms used to be spelled as, grouped the
|
||||
/// way `rating_histogram` groups them. What the counts must still agree
|
||||
/// with, in the states nothing in the schema prevents.
|
||||
fn by_join(cat: &Catalog, column: &str) -> Vec<(i64, i64)> {
|
||||
cat.connection()
|
||||
.prepare(&format!(
|
||||
"SELECT coalesce(v.{column}, 0) AS c, count(*)
|
||||
FROM images i
|
||||
LEFT JOIN versions v ON v.image_id = i.id AND v.is_default = 1
|
||||
GROUP BY c ORDER BY c"
|
||||
))
|
||||
.unwrap()
|
||||
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
|
||||
.unwrap()
|
||||
.map(Result::unwrap)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A library in every awkward state at once: an image with no version,
|
||||
/// one with only a virtual copy, one with two default versions, and
|
||||
/// values out of range on both axes.
|
||||
fn awkward() -> Catalog {
|
||||
let cat = with_images(8);
|
||||
ensure_default_versions(cat.connection()).unwrap();
|
||||
let all = ids(&cat);
|
||||
let c = cat.connection();
|
||||
set_rating(c, all[0], 5).unwrap();
|
||||
set_rating(c, all[1], 2).unwrap();
|
||||
set_label(c, all[1], Some(ColourLabel::Blue)).unwrap();
|
||||
c.execute(
|
||||
"DELETE FROM versions WHERE image_id = ?1",
|
||||
[all[2].0 as i64],
|
||||
)
|
||||
.unwrap();
|
||||
c.execute(
|
||||
"UPDATE versions SET is_default = 0 WHERE image_id = ?1",
|
||||
[all[3].0 as i64],
|
||||
)
|
||||
.unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO versions(image_id, uuid, name, is_default, rating, label)
|
||||
VALUES (?1, 'second-default', 'Copy', 1, 4, 3)",
|
||||
[all[4].0 as i64],
|
||||
)
|
||||
.unwrap();
|
||||
c.execute(
|
||||
"UPDATE versions SET rating = -1, label = 9 WHERE image_id = ?1",
|
||||
[all[5].0 as i64],
|
||||
)
|
||||
.unwrap();
|
||||
c.execute(
|
||||
"UPDATE versions SET rating = 7, label = 0 WHERE image_id = ?1",
|
||||
[all[6].0 as i64],
|
||||
)
|
||||
.unwrap();
|
||||
cat
|
||||
}
|
||||
|
||||
/// Fold the join's rows into slots the way the old code did.
|
||||
fn folded(rows: &[(i64, i64)], slot: impl Fn(i64) -> usize) -> [usize; 6] {
|
||||
let mut out = [0usize; 6];
|
||||
for &(code, n) in rows {
|
||||
out[slot(code)] += n as usize;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_rating_histogram_agrees_with_the_join_it_replaced() {
|
||||
let cat = awkward();
|
||||
let expected = folded(&by_join(&cat, "rating"), |r| {
|
||||
r.clamp(0, MAX_RATING as i64) as usize
|
||||
});
|
||||
assert_eq!(rating_histogram(cat.connection()).unwrap(), expected);
|
||||
// Nine rows for eight images: the doubled default counts twice, as
|
||||
// it always has.
|
||||
assert_eq!(expected.iter().sum::<usize>(), 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_label_histogram_agrees_with_the_join_it_replaced() {
|
||||
let cat = awkward();
|
||||
let expected = folded(&by_join(&cat, "label"), |code| {
|
||||
if label_from_code(Some(code)).is_some() {
|
||||
code as usize
|
||||
} else {
|
||||
0
|
||||
}
|
||||
});
|
||||
assert_eq!(label_histogram(cat.connection()).unwrap(), expected);
|
||||
assert_eq!(expected[3], 1, "the second default's label is counted");
|
||||
assert_eq!(expected.iter().sum::<usize>(), 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flag_counts_separate_picks_from_rejects() {
|
||||
let cat = with_images(5);
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
//! # The one thing a rebuild does not recover
|
||||
//!
|
||||
//! **Collections.** A manual collection is a set of images the user assembled
|
||||
//! by hand and nothing in the filesystem records it (`docs/catalog.md` §8.1) —
|
||||
//! by hand and nothing in the filesystem records it (`docs/dev/catalog.md` §8.1) —
|
||||
//! which is the whole reason the catalog file itself syncs. So the two offers
|
||||
//! are not interchangeable, and the interface must not present them as if they
|
||||
//! were: a restore keeps the user's collections, a rebuild does not.
|
||||
@@ -322,6 +322,10 @@ pub fn restore(catalog: &Path, backup: &Path) -> Result<(), CatalogError> {
|
||||
/// to move — a caller may be recovering from a file SQLite could not open
|
||||
/// because it was never created.
|
||||
pub fn set_aside(catalog: &Path) -> Result<Option<PathBuf>, CatalogError> {
|
||||
// Whatever takes this name next — a rebuild or a restored backup — is not
|
||||
// the file this process last backfilled. Forgotten while the path still
|
||||
// resolves, so it is the same key the open recorded.
|
||||
crate::backfilled::forget(catalog);
|
||||
let moved = if catalog.exists() {
|
||||
let dest = with_suffix(catalog, DAMAGED_SUFFIX);
|
||||
// An earlier damaged copy is replaced rather than accumulating: two of
|
||||
@@ -570,7 +574,7 @@ mod tests {
|
||||
// The first NFR-R6 branch, asserted on the thing that distinguishes it
|
||||
// from the second: a collection exists nowhere but the catalog, so it
|
||||
// is the evidence that the *contents* came back and not merely a
|
||||
// readable file (docs/catalog.md §8.1).
|
||||
// readable file (docs/dev/catalog.md §8.1).
|
||||
let dir = tempdir("restore");
|
||||
let path = dir.join("catalog.sqlite");
|
||||
fixture(&path, 500);
|
||||
|
||||
@@ -77,7 +77,7 @@ pub enum Outcome {
|
||||
|
||||
/// Something that can actually do the work a job describes.
|
||||
///
|
||||
/// The catalog knows what needs doing and nothing about how — a thumbnail
|
||||
/// The catalog knows what needs doing and nothing about how — face detection
|
||||
/// needs a decoder, a fetch needs a network stack, and neither belongs under
|
||||
/// `core/dr-catalog` (ARCH §4.1: calls go downward). So the queue lives here
|
||||
/// and the handlers are supplied from above.
|
||||
@@ -187,17 +187,19 @@ pub struct Recovered {
|
||||
pub reclaimed: usize,
|
||||
/// Jobs deleted because the photograph they name no longer exists.
|
||||
pub reaped: usize,
|
||||
/// Jobs deleted because their kind is retired ([`JobKind::RETIRED`]).
|
||||
pub retired: usize,
|
||||
}
|
||||
|
||||
impl Recovered {
|
||||
pub fn did_anything(&self) -> bool {
|
||||
self.reclaimed > 0 || self.reaped > 0
|
||||
self.reclaimed > 0 || self.reaped > 0 || self.retired > 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Ready the queue for a fresh run, before any worker touches it.
|
||||
///
|
||||
/// Two distinct cleanups, and both are startup-only:
|
||||
/// Three distinct cleanups, and all are startup-only:
|
||||
///
|
||||
/// - **Reclaim.** A `Running` row has no owner; the process that claimed it is
|
||||
/// gone. On Android that is a routine morning, not a crash (FR-PLAT-AND-3).
|
||||
@@ -205,19 +207,27 @@ impl Recovered {
|
||||
/// process down with it three times running should not be retried forever,
|
||||
/// and the attempt counter is the only evidence of that we have.
|
||||
/// - **Reap.** Jobs naming an image the catalog no longer has. A library that
|
||||
/// has been culled leaves thumbnail jobs for photographs that were deleted
|
||||
/// has been culled leaves jobs for photographs that were deleted
|
||||
/// months ago, and every one of them would be claimed, run and failed.
|
||||
/// - **Retire.** Rows of a kind nothing enqueues or claims any more
|
||||
/// ([`jobs::drop_retired`]). Here rather than in a migration so that no
|
||||
/// schema bump locks an older device out of the synced catalog, and every
|
||||
/// time rather than once because an older build sharing the catalog will
|
||||
/// queue them again.
|
||||
///
|
||||
/// Reclaim runs first so its count is the honest number of interrupted jobs,
|
||||
/// Retiring runs first, so the other two never touch rows about to go.
|
||||
/// Reclaim runs next so its count is the honest number of interrupted jobs,
|
||||
/// before reaping removes whichever of them pointed at nothing.
|
||||
///
|
||||
/// **Call this exactly once per catalog, at startup.** It cannot distinguish a
|
||||
/// job a dead process was holding from one a live runner is holding right now,
|
||||
/// because there is no owner column — the queue is durable, not distributed.
|
||||
pub fn recover(conn: &Connection) -> Result<Recovered, CatalogError> {
|
||||
let retired = jobs::drop_retired(conn)?;
|
||||
Ok(Recovered {
|
||||
reclaimed: jobs::recover_orphaned(conn)?,
|
||||
reaped: jobs::reap_orphan_subjects(conn)?,
|
||||
retired,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -729,7 +739,7 @@ mod tests {
|
||||
// which is the window a durable queue exists to survive: no `complete`,
|
||||
// no `fail`, just a row marked `Running` with nobody holding it.
|
||||
let c = db();
|
||||
queued(&c, JobKind::Thumbnail, 1);
|
||||
queued(&c, JobKind::ContentHash, 1);
|
||||
|
||||
// The dead process. It claimed the job and never came back.
|
||||
let claimed = jobs::claim_next(&c, 0).unwrap().expect("claimable");
|
||||
@@ -738,7 +748,7 @@ mod tests {
|
||||
// A fresh runner, before it starts, finds the queue empty — the row is
|
||||
// `Running` and no claim will touch it.
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
let mut runner = Runner::new(&c).with(recording(vec![JobKind::Thumbnail], seen.clone()));
|
||||
let mut runner = Runner::new(&c).with(recording(vec![JobKind::ContentHash], seen.clone()));
|
||||
assert_eq!(
|
||||
runner.drain_all(0).unwrap().ran(),
|
||||
0,
|
||||
@@ -766,7 +776,7 @@ mod tests {
|
||||
// the only evidence we keep across a death. Without this a poison-pill
|
||||
// job would be reclaimed and re-run forever.
|
||||
let c = db();
|
||||
queued(&c, JobKind::Thumbnail, 1);
|
||||
queued(&c, JobKind::ContentHash, 1);
|
||||
|
||||
for _ in 0..MAX_ATTEMPTS {
|
||||
jobs::claim_next(&c, 0).unwrap().expect("claimable");
|
||||
@@ -782,13 +792,49 @@ mod tests {
|
||||
assert_eq!(state, JobState::Failed as i64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_drops_retired_kinds_every_time_and_nothing_else() {
|
||||
// What 0.16.0 left behind: a thumbnail job per photograph that nothing
|
||||
// would ever claim, beside live work that must survive.
|
||||
let c = db();
|
||||
queued(&c, JobKind::Thumbnail, 1);
|
||||
queued(&c, JobKind::Thumbnail, 2);
|
||||
enqueue(
|
||||
&c,
|
||||
JobKind::DetectFaces,
|
||||
Some(1),
|
||||
Priority::Background,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let first = recover(&c).unwrap();
|
||||
assert_eq!(first.retired, 2);
|
||||
assert!(first.did_anything());
|
||||
|
||||
let kinds: Vec<i64> = c
|
||||
.prepare("SELECT kind FROM jobs")
|
||||
.unwrap()
|
||||
.query_map([], |r| r.get(0))
|
||||
.unwrap()
|
||||
.map(Result::unwrap)
|
||||
.collect();
|
||||
assert_eq!(kinds, vec![JobKind::DetectFaces as i64]);
|
||||
|
||||
// An older build opening the same catalog queues them again on its
|
||||
// next scan. The next open by this one clears them again.
|
||||
enqueue(&c, JobKind::Thumbnail, Some(1), Priority::Background, None).unwrap();
|
||||
assert_eq!(recover(&c).unwrap().retired, 1);
|
||||
assert_eq!(recover(&c).unwrap(), Recovered::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_drops_jobs_whose_photograph_is_gone() {
|
||||
// A culled library leaves thumbnail jobs for images deleted months
|
||||
// ago. Every one would be claimed, run and failed.
|
||||
let c = db();
|
||||
queued(&c, JobKind::Thumbnail, 1);
|
||||
queued(&c, JobKind::Thumbnail, 2);
|
||||
queued(&c, JobKind::ContentHash, 1);
|
||||
queued(&c, JobKind::ContentHash, 2);
|
||||
c.execute("DELETE FROM images WHERE id = 2", []).unwrap();
|
||||
|
||||
let recovered = recover(&c).unwrap();
|
||||
@@ -804,7 +850,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_quiet_startup_recovers_nothing() {
|
||||
let c = db();
|
||||
queued(&c, JobKind::Thumbnail, 1);
|
||||
queued(&c, JobKind::ContentHash, 1);
|
||||
assert_eq!(recover(&c).unwrap(), Recovered::default());
|
||||
assert!(!recover(&c).unwrap().did_anything());
|
||||
}
|
||||
|
||||
@@ -301,8 +301,11 @@ pub const EYE_COLUMNS: [&str; 7] = [
|
||||
/// silently partial — present, queryable, and wrong — which is worse than
|
||||
/// missing, because nothing signals that they need attention.
|
||||
///
|
||||
/// Cheap enough to run on every open: each pass is one indexed UPDATE, and
|
||||
/// re-running it is a no-op once the values are already right.
|
||||
/// Idempotent: re-running it is a no-op once the values are already right.
|
||||
/// [`crate::Catalog::open`] runs it once per catalog state rather than on
|
||||
/// every open — each pass scans a whole table, and together they were most of
|
||||
/// what an open cost — and `backfilled` in this crate says what counts as a
|
||||
/// new state.
|
||||
///
|
||||
/// Returns how many rows each backfill touched, for logging.
|
||||
pub fn backfill(conn: &Connection) -> Result<Vec<(&'static str, usize)>, CatalogError> {
|
||||
@@ -353,6 +356,15 @@ pub fn backfill(conn: &Connection) -> Result<Vec<(&'static str, usize)>, Catalog
|
||||
out.push(("keyword_terms", n));
|
||||
}
|
||||
|
||||
// TRACES: FR-CAT-5
|
||||
// A date from the file's name for every examined image EXIF left undated.
|
||||
// The sweep does this as it examines each image; this is for the images
|
||||
// examined by a build that did not, and reads the undated side alone.
|
||||
let n = crate::name_dates::fill(conn, None)?;
|
||||
if n > 0 {
|
||||
out.push(("dates_from_names", n));
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -494,15 +506,51 @@ fn rewrite_for_attached(sql: &str, schema_name: &str) -> String {
|
||||
fn pair_raw_and_jpeg(conn: &Connection) -> Result<usize, CatalogError> {
|
||||
use std::collections::HashMap;
|
||||
|
||||
// (folder, lowercase stem) -> RAW id, built in one pass over the RAWs.
|
||||
// The small side first: the JPEGs not yet paired. On a settled library
|
||||
// these are the ones with no RAW beside them -- 1,900 of 24,000 on the
|
||||
// reference library -- and this runs on every open, including the ones
|
||||
// the develop view makes for each photograph it fetches. Reading every
|
||||
// RAW to find the handful that share a folder with one of them was most
|
||||
// of what opening the catalog cost.
|
||||
let jpegs: Vec<(i64, Option<i64>, String)> = {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, folder_id, source_ref FROM images
|
||||
WHERE lower(format) IN ('jpg','jpeg') AND shadowed_by IS NULL",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, i64>(0)?,
|
||||
r.get::<_, Option<i64>>(1)?,
|
||||
r.get::<_, String>(2)?,
|
||||
))
|
||||
})?;
|
||||
rows.filter_map(Result::ok).collect()
|
||||
};
|
||||
if jpegs.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// (folder, lowercase stem) -> RAW id, over the folders those JPEGs are in
|
||||
// and no others: a pair is same-folder by definition. Ordered by id so
|
||||
// that where two RAWs share a stem the later one wins, as it did when this
|
||||
// read every RAW in table order.
|
||||
let mut folders: Vec<i64> = jpegs.iter().filter_map(|(_, f, _)| *f).collect();
|
||||
folders.sort_unstable();
|
||||
folders.dedup();
|
||||
let unfiled = jpegs.iter().any(|(_, f, _)| f.is_none());
|
||||
let folders = serde_json::to_string(&folders).unwrap_or_else(|_| "[]".to_string());
|
||||
|
||||
let mut raws: HashMap<(Option<i64>, String), i64> = HashMap::new();
|
||||
{
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, folder_id, source_ref FROM images
|
||||
WHERE lower(format) IN
|
||||
('cr2','cr3','nef','arw','raf','rw2','orf','dng')",
|
||||
('cr2','cr3','nef','arw','raf','rw2','orf','dng')
|
||||
AND (folder_id IN (SELECT value FROM json_each(?1))
|
||||
OR (?2 AND folder_id IS NULL))
|
||||
ORDER BY id",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
let rows = stmt.query_map(rusqlite::params![folders, unfiled], |r| {
|
||||
Ok((
|
||||
r.get::<_, i64>(0)?,
|
||||
r.get::<_, Option<i64>>(1)?,
|
||||
@@ -518,25 +566,16 @@ fn pair_raw_and_jpeg(conn: &Connection) -> Result<usize, CatalogError> {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let pairs: Vec<(i64, i64)> = {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, folder_id, source_ref FROM images
|
||||
WHERE lower(format) IN ('jpg','jpeg') AND shadowed_by IS NULL",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, i64>(0)?,
|
||||
r.get::<_, Option<i64>>(1)?,
|
||||
r.get::<_, String>(2)?,
|
||||
))
|
||||
})?;
|
||||
rows.filter_map(|row| {
|
||||
let (id, folder, path) = row.ok()?;
|
||||
let raw = raws.get(&(folder, stem_of(&path).to_ascii_lowercase()))?;
|
||||
Some((id, *raw))
|
||||
let pairs: Vec<(i64, i64)> = jpegs
|
||||
.iter()
|
||||
.filter_map(|(id, folder, path)| {
|
||||
let raw = raws.get(&(*folder, stem_of(path).to_ascii_lowercase()))?;
|
||||
Some((*id, *raw))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
.collect();
|
||||
if pairs.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
for (jpeg, raw) in &pairs {
|
||||
@@ -1009,7 +1048,7 @@ CREATE INDEX face_index_model ON face_index(model_id);
|
||||
|
||||
const V8: &str = r#"
|
||||
-- TRACES: FR-CULL-8 | FR-CULL-9 | FR-CULL-10 | FR-CULL-11 | FR-CULL-12 | NFR-SEC-5
|
||||
-- People and faces (docs/faces.md, docs/catalog.md §10).
|
||||
-- People and faces (docs/dev/faces.md, docs/dev/catalog.md §10).
|
||||
--
|
||||
-- Everything here is **derived data** except one column. Faces, landmarks,
|
||||
-- embeddings, cluster assignments and suggestions are all reproducible by
|
||||
@@ -1046,7 +1085,7 @@ CREATE TABLE faces (
|
||||
landmarks BLOB NOT NULL, -- 5 x (x, y) f32, normalised likewise
|
||||
detector_confidence REAL NOT NULL,
|
||||
embedding BLOB NOT NULL, -- 512 x f16; unit length until V14, raw since
|
||||
-- Source pixels across the aligned 112x112 crop (docs/faces.md §7).
|
||||
-- Source pixels across the aligned 112x112 crop (docs/dev/faces.md §7).
|
||||
--
|
||||
-- Not cosmetic: it is the honest quality signal for the UI, a feature in
|
||||
-- the §8 calibration -- FR-CULL-9 names face size as an axis along which an
|
||||
@@ -1582,6 +1621,36 @@ mod tests {
|
||||
assert_eq!(backfilled(&c, "shadowed_by"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pair_is_found_among_other_folders_and_unfiled_images() {
|
||||
// The RAWs are read only from the folders an unpaired JPEG is in,
|
||||
// plus the unfiled ones when an unfiled JPEG is waiting: each JPEG
|
||||
// must still find its own sibling, and only its own.
|
||||
let c = with_root();
|
||||
let raw_a = image(&c, Some(1), "a/IMG_7.CR2", "cr2");
|
||||
image(&c, Some(2), "b/IMG_7.CR2", "cr2");
|
||||
image(&c, Some(2), "b/IMG_8.CR2", "cr2");
|
||||
let raw_unfiled = image(&c, None, "IMG_9.DNG", "dng");
|
||||
let jpeg_a = image(&c, Some(1), "a/IMG_7.JPG", "jpg");
|
||||
let jpeg_unfiled = image(&c, None, "IMG_9.jpg", "jpg");
|
||||
image(&c, Some(1), "a/IMG_9.jpg", "jpg");
|
||||
|
||||
assert_eq!(backfilled(&c, "shadowed_by"), 2);
|
||||
let of = |id: i64| -> Option<i64> {
|
||||
c.query_row("SELECT shadowed_by FROM images WHERE id = ?1", [id], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(of(jpeg_a), Some(raw_a));
|
||||
assert_eq!(of(jpeg_unfiled), Some(raw_unfiled));
|
||||
assert_eq!(
|
||||
backfilled(&c, "shadowed_by"),
|
||||
0,
|
||||
"settled on the second pass"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_raw_is_never_shadowed_by_a_jpeg() {
|
||||
// The relationship is one-way: the RAW is the photograph.
|
||||
|
||||
+493
-45
@@ -46,18 +46,210 @@ pub fn checkpoint(conn: &Connection) -> Result<(), CatalogError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write a consistent snapshot of the catalog to `dest`, ready to upload.
|
||||
/// Write a consistent snapshot of the catalog to `dest`, ready to upload,
|
||||
/// without the face crops.
|
||||
///
|
||||
/// Uses the backup API rather than a filesystem copy so the snapshot is
|
||||
/// coherent even with writers active. Callers should still prefer a quiet
|
||||
/// moment — this competes with background jobs for the write lock.
|
||||
/// Built rather than copied. The snapshot is the *whole catalog* bar the
|
||||
/// crops, uploaded on every sync and downloaded by every device. The crops are
|
||||
/// most of the file (96 MB of a 158 MB reference catalog), and copying them in
|
||||
/// only to delete them was most of the cost. A backup-API copy followed by
|
||||
/// `UPDATE faces SET crop = NULL` and `VACUUM` wrote the file roughly three
|
||||
/// times over to produce 50 MB (#71). So this creates the schema in an empty
|
||||
/// file and copies every table into it with `crop` left NULL. That is one
|
||||
/// pass, with nothing written that is not uploaded.
|
||||
///
|
||||
/// Consistency comes from doing the whole copy inside one transaction on the
|
||||
/// snapshot's connection, which holds a single read snapshot of the source for
|
||||
/// its duration. A writer committing meanwhile lands in the source's WAL and is
|
||||
/// simply not seen, the same serialisation the backup API gave.
|
||||
///
|
||||
/// Crops are not lost by this: they travel in the face shards
|
||||
/// ([`crate::face_shard::export_to_shards`]), which are written once and
|
||||
/// downloaded once. Nothing reads a crop out of a merged remote catalog. The
|
||||
/// merge reads a remote face's box and model to match it to a local one, and
|
||||
/// no more. So leaving them out costs a receiving device nothing it would
|
||||
/// otherwise have had. A device never adopts a downloaded catalog as its own,
|
||||
/// so a fresh one gets its crops from the shards too.
|
||||
///
|
||||
/// The result must stay what every earlier build already merges: same schema,
|
||||
/// same `user_version`, same page size and the same WAL flag in the header.
|
||||
/// The `the_snapshot_*` tests pin those.
|
||||
pub fn snapshot_for_upload(conn: &Connection, dest: &Path) -> Result<(), CatalogError> {
|
||||
let out = copy_to(conn, dest)?;
|
||||
strip_face_crops(&out)?;
|
||||
// The source is read through a second connection, attached to the
|
||||
// snapshot's, so it needs to be a file. Every catalog is one.
|
||||
let source = conn
|
||||
.path()
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.ok_or_else(|| CatalogError::Io("the catalog to snapshot has no file".into()))?;
|
||||
|
||||
// Not needed for consistency, since the read transaction below sees the
|
||||
// WAL, but it keeps the live WAL from growing across syncs, as before.
|
||||
checkpoint(conn)?;
|
||||
|
||||
// A leftover from a pass that died mid-build would otherwise be built on.
|
||||
for stale in [
|
||||
dest.to_path_buf(),
|
||||
sidecar_of(dest, "-wal"),
|
||||
sidecar_of(dest, "-journal"),
|
||||
sidecar_of(dest, "-shm"),
|
||||
] {
|
||||
match std::fs::remove_file(&stale) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(CatalogError::Io(format!("{}: {e}", stale.display()))),
|
||||
}
|
||||
}
|
||||
|
||||
let out = Connection::open(dest)?;
|
||||
build_snapshot(conn, &source, &out)?;
|
||||
// This device's local album folders are paths and SAF grants nobody
|
||||
// else can use; the merge never reads them, and the snapshot is what
|
||||
// a fresh device would otherwise adopt whole.
|
||||
out.execute_batch("DROP TABLE IF EXISTS album_folders")?;
|
||||
verify_snapshot(&out)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `catalog.sqlite` + `-wal` → `catalog.sqlite-wal`.
|
||||
fn sidecar_of(path: &Path, suffix: &str) -> PathBuf {
|
||||
let mut s = path.as_os_str().to_owned();
|
||||
s.push(suffix);
|
||||
PathBuf::from(s)
|
||||
}
|
||||
|
||||
/// Schema name the source catalog is attached under while a snapshot is built.
|
||||
const SOURCE_SCHEMA: &str = "snap_src";
|
||||
|
||||
/// The body of [`snapshot_for_upload`]: fill the empty database `out` from
|
||||
/// the catalog at `source`.
|
||||
fn build_snapshot(conn: &Connection, source: &Path, out: &Connection) -> Result<(), CatalogError> {
|
||||
// Settings that only take on an empty file, copied from the source so the
|
||||
// result is the file a backup would have been.
|
||||
let page_size: i64 = conn.query_row("PRAGMA main.page_size", [], |r| r.get(0))?;
|
||||
let auto_vacuum: i64 = conn.query_row("PRAGMA main.auto_vacuum", [], |r| r.get(0))?;
|
||||
out.pragma_update(None, "page_size", page_size)?;
|
||||
out.pragma_update(None, "auto_vacuum", auto_vacuum)?;
|
||||
// A scratch file, rebuilt whole on every pass and checked before upload:
|
||||
// durability during the build buys nothing. MEMORY rather than OFF keeps
|
||||
// ROLLBACK defined on the failure path.
|
||||
out.pragma_update(None, "journal_mode", "MEMORY")?;
|
||||
out.pragma_update(None, "synchronous", "OFF")?;
|
||||
// The rows were checked when they were written, and the copy has them
|
||||
// all by the end. The bundled SQLite turns foreign keys on by default,
|
||||
// and with them a multi-row INSERT into `images` scans `images` for
|
||||
// children of every row it adds (`shadowed_by` refers to the same table
|
||||
// and has no index): 1.2 s of a 1.7 s snapshot on 24k images.
|
||||
out.pragma_update(None, "foreign_keys", false)?;
|
||||
|
||||
// Bound as a parameter, so a path containing a quote cannot break out.
|
||||
out.execute(
|
||||
&format!("ATTACH DATABASE ?1 AS {SOURCE_SCHEMA}"),
|
||||
[source.to_string_lossy().as_ref()],
|
||||
)?;
|
||||
let result = copy_schema_and_rows(out);
|
||||
if let Err(e) = out.execute(&format!("DETACH DATABASE {SOURCE_SCHEMA}"), []) {
|
||||
log::warn!("failed to detach the catalog from its snapshot: {e}");
|
||||
}
|
||||
result?;
|
||||
|
||||
// Last, and outside any transaction, which is the only place it can be
|
||||
// set: the header says WAL, as every snapshot uploaded so far has.
|
||||
out.pragma_update(None, "journal_mode", "WAL")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_schema_and_rows(out: &Connection) -> Result<(), CatalogError> {
|
||||
let tx = out.unchecked_transaction()?;
|
||||
|
||||
// The first read of the source opens its read snapshot. Everything from
|
||||
// here, schema included, is as of that one moment.
|
||||
let objects: Vec<(String, String, String)> = {
|
||||
let mut stmt = tx.prepare(&format!(
|
||||
"SELECT type, name, sql FROM {SOURCE_SCHEMA}.sqlite_master
|
||||
WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\'
|
||||
ORDER BY rowid"
|
||||
))?;
|
||||
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?;
|
||||
rows.collect::<Result<_, _>>()?
|
||||
};
|
||||
let user_version: i64 =
|
||||
tx.query_row(&format!("PRAGMA {SOURCE_SCHEMA}.user_version"), [], |r| {
|
||||
r.get(0)
|
||||
})?;
|
||||
let application_id: i64 =
|
||||
tx.query_row(&format!("PRAGMA {SOURCE_SCHEMA}.application_id"), [], |r| {
|
||||
r.get(0)
|
||||
})?;
|
||||
|
||||
// Tables and their rows first, then indexes, triggers and views, so that
|
||||
// an index is built once over the data rather than maintained per row,
|
||||
// and no trigger fires on the copy. Foreign keys are off on this
|
||||
// connection, so the order tables are filled in does not matter.
|
||||
for (_, name, sql) in objects.iter().filter(|(k, _, _)| k == "table") {
|
||||
// Verbatim: an unqualified CREATE lands in `main`, the snapshot.
|
||||
tx.execute_batch(sql)?;
|
||||
let columns: Vec<String> = {
|
||||
let mut stmt = tx.prepare("SELECT name FROM pragma_table_info(?1, 'main')")?;
|
||||
let rows = stmt.query_map([name], |r| r.get::<_, String>(0))?;
|
||||
rows.collect::<Result<_, _>>()?
|
||||
};
|
||||
let select = columns
|
||||
.iter()
|
||||
.map(|c| {
|
||||
if name == "faces" && c == "crop" {
|
||||
"NULL".to_string()
|
||||
} else {
|
||||
quote_ident(c)
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let insert = columns
|
||||
.iter()
|
||||
.map(|c| quote_ident(c))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let table = quote_ident(name);
|
||||
tx.execute(
|
||||
&format!(
|
||||
"INSERT INTO main.{table} ({insert})
|
||||
SELECT {select} FROM {SOURCE_SCHEMA}.{table}"
|
||||
),
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
// AUTOINCREMENT's counters live in a table the filter above skips; the
|
||||
// CREATE of such a table makes an empty one here.
|
||||
let has_sequence: bool = tx.query_row(
|
||||
&format!(
|
||||
"SELECT EXISTS(SELECT 1 FROM {SOURCE_SCHEMA}.sqlite_master
|
||||
WHERE name = 'sqlite_sequence')"
|
||||
),
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
if has_sequence {
|
||||
tx.execute_batch(&format!(
|
||||
"DELETE FROM main.sqlite_sequence;
|
||||
INSERT INTO main.sqlite_sequence SELECT * FROM {SOURCE_SCHEMA}.sqlite_sequence;"
|
||||
))?;
|
||||
}
|
||||
for (_, _, sql) in objects.iter().filter(|(k, _, _)| k != "table") {
|
||||
tx.execute_batch(sql)?;
|
||||
}
|
||||
|
||||
tx.pragma_update(None, "user_version", user_version)?;
|
||||
tx.pragma_update(None, "application_id", application_id)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `name` as an SQL identifier, whatever it contains.
|
||||
fn quote_ident(name: &str) -> String {
|
||||
format!("\"{}\"", name.replace('"', "\"\""))
|
||||
}
|
||||
|
||||
/// TRACES: NFR-R2
|
||||
/// Refuse to hand over a snapshot that will not pass `quick_check`.
|
||||
///
|
||||
@@ -82,12 +274,12 @@ fn verify_snapshot(snapshot: &Connection) -> Result<(), CatalogError> {
|
||||
/// Checkpoint, then copy the whole database to `dest`, and hand back the
|
||||
/// connection to the copy.
|
||||
///
|
||||
/// Split out from [`snapshot_for_upload`] because [`crate::recovery`] wants
|
||||
/// exactly this and none of what follows it there: an NFR-R2 backup is the
|
||||
/// file the user may have to *live on*, so it keeps the face crops that an
|
||||
/// upload strips. Sharing the copy rather than reimplementing it is what keeps
|
||||
/// the WAL discipline in one place — a backup taken with `fs::copy` would be
|
||||
/// the torn snapshot this module's header exists to warn about.
|
||||
/// What [`crate::recovery`] takes its NFR-R2 backups with. A backup is the
|
||||
/// file the user may have to *live on*, so it keeps the face crops that
|
||||
/// [`snapshot_for_upload`] leaves out, and a byte-for-byte page copy is the
|
||||
/// right tool. Keeping it here beside the upload keeps the WAL discipline in
|
||||
/// one place: a backup taken with `fs::copy` would be the torn snapshot this
|
||||
/// module's header exists to warn about.
|
||||
pub(crate) fn copy_to(conn: &Connection, dest: &Path) -> Result<Connection, CatalogError> {
|
||||
checkpoint(conn)?;
|
||||
|
||||
@@ -103,39 +295,6 @@ pub(crate) fn copy_to(conn: &Connection, dest: &Path) -> Result<Connection, Cata
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Drop the stored face crops from a snapshot before it is uploaded.
|
||||
///
|
||||
/// The snapshot is the *whole catalog*, uploaded on every sync and downloaded
|
||||
/// by every device. Face crops are a few KB each and a fully indexed library
|
||||
/// holds tens of thousands of them, so leaving them in would put tens of MB on
|
||||
/// every round trip — the exact cost `face_shard`'s 25 MB cap exists to bound,
|
||||
/// and the reason the bulk per-face data lives in shards in the first place.
|
||||
///
|
||||
/// Crops are not lost by this: they travel in the face shards
|
||||
/// ([`crate::face_shard::export_to_shards`]), which are written once and
|
||||
/// downloaded once. Nothing reads a crop out of a merged remote catalog —
|
||||
/// [`merge_all`] touches collections and keywords only — so removing them here
|
||||
/// costs a receiving device nothing it would otherwise have had.
|
||||
///
|
||||
/// `VACUUM` afterwards because SQLite does not return freed pages to the file
|
||||
/// on its own, and an upload sized by the file rather than by its contents
|
||||
/// would keep paying for bytes that are no longer there.
|
||||
fn strip_face_crops(snapshot: &Connection) -> Result<(), CatalogError> {
|
||||
// A catalog older than the crop column is a legitimate input here — a
|
||||
// snapshot taken mid-migration, or a test fixture built from an earlier
|
||||
// schema — so an absent column is nothing to fail over.
|
||||
let has_crop = snapshot
|
||||
.prepare("SELECT crop FROM faces LIMIT 1")
|
||||
.map(|_| true)
|
||||
.unwrap_or(false);
|
||||
if !has_crop {
|
||||
return Ok(());
|
||||
}
|
||||
snapshot.execute("UPDATE faces SET crop = NULL WHERE crop IS NOT NULL", [])?;
|
||||
snapshot.execute_batch("VACUUM")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether a downloaded remote catalog is worth merging.
|
||||
///
|
||||
/// Cheap guard before attaching: a remote written by a newer build may contain
|
||||
@@ -171,6 +330,13 @@ pub fn merge_remote(conn: &Connection, remote: &Path) -> Result<MergeReport, Cat
|
||||
|
||||
let result = merge::merge_all(conn);
|
||||
|
||||
// The merge brings in rows the backfill exists for — assignments whose
|
||||
// word this device has no term for, from a remote older than v6 — so the
|
||||
// next open must run it, whether or not the stamp happened to move.
|
||||
if let Some(path) = conn.path().filter(|p| !p.is_empty()) {
|
||||
crate::backfilled::forget(Path::new(path));
|
||||
}
|
||||
|
||||
// Detach even if the merge failed, or the next attempt errors with
|
||||
// "database remote_cat is already in use".
|
||||
let detach = conn.execute(&format!("DETACH DATABASE {REMOTE_SCHEMA}"), []);
|
||||
@@ -178,6 +344,18 @@ pub fn merge_remote(conn: &Connection, remote: &Path) -> Result<MergeReport, Cat
|
||||
log::warn!("failed to detach remote catalog: {e}");
|
||||
}
|
||||
|
||||
// After every merge, because a merge is where two devices' people meet:
|
||||
// the same name typed on each, or a redirect one of them made. Its own
|
||||
// transaction, and a failure is logged rather than returned -- what the
|
||||
// merge took is committed and valid whether or not the duplicates were
|
||||
// folded, and the next pass tries again. Runs on the sync worker, never
|
||||
// the UI thread, and costs ~10 ms when there is nothing to do.
|
||||
if result.is_ok() {
|
||||
if let Err(e) = crate::dedup_people::run(conn) {
|
||||
log::warn!("dedup after the catalog merge: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
@@ -288,6 +466,92 @@ mod tests {
|
||||
assert_eq!(n, 2);
|
||||
}
|
||||
|
||||
/// One image, known to the server by `file_id`, in a catalog.
|
||||
fn with_image(c: &Connection, file_id: i64) -> dr_types::ImageId {
|
||||
c.execute(
|
||||
"INSERT OR IGNORE INTO roots(id, kind, label) VALUES (1, 'remote', 'lib')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO images(root_id, source_ref, added_at) VALUES (1, ?1, 0)",
|
||||
[format!("IMG_{file_id}.CR3")],
|
||||
)
|
||||
.unwrap();
|
||||
let id = c.last_insert_rowid();
|
||||
c.execute(
|
||||
"INSERT INTO remote(image_id, file_id) VALUES (?1, ?2)",
|
||||
[id, file_id],
|
||||
)
|
||||
.unwrap();
|
||||
dr_types::ImageId(id as u64)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_album_and_its_exports_reach_another_device_but_its_folder_does_not() {
|
||||
use crate::albums::{self, Place};
|
||||
let dir = tempdir();
|
||||
let remote_path = dir.join("remote.sqlite");
|
||||
let snap = dir.join("snap.sqlite");
|
||||
{
|
||||
// The desktop: two albums, one on the server and one on its own
|
||||
// disk, each with an export of the same photograph.
|
||||
let r = seeded(&dir.join("desktop.sqlite"));
|
||||
let img = with_image(&r, 4242);
|
||||
let web = albums::create(&r, "Web", &Place::Server("Shared/Web".into())).unwrap();
|
||||
let print = albums::create(&r, "Print", &Place::Local("/mnt/print".into())).unwrap();
|
||||
albums::record_exports(&r, web, &[(img, "IMG_4242.jpg".into())]).unwrap();
|
||||
albums::record_exports(&r, print, &[(img, "IMG_4242.tif".into())]).unwrap();
|
||||
snapshot_for_upload(&r, &snap).unwrap();
|
||||
std::fs::rename(&snap, &remote_path).unwrap();
|
||||
}
|
||||
|
||||
// The tablet knows the same file under its own image id.
|
||||
let local = seeded(&dir.join("tablet.sqlite"));
|
||||
with_image(&local, 1);
|
||||
let img = with_image(&local, 4242);
|
||||
|
||||
let report = merge_remote(&local, &remote_path).unwrap();
|
||||
assert_eq!(report.albums_taken, 2);
|
||||
assert_eq!(report.album_exports_added, 2);
|
||||
assert!(report.local_changed());
|
||||
|
||||
let all = albums::list(&local).unwrap();
|
||||
let print = all.iter().find(|a| a.name == "Print").unwrap();
|
||||
let web = all.iter().find(|a| a.name == "Web").unwrap();
|
||||
assert_eq!(print.place, None, "the desktop's disk is not the tablet's");
|
||||
assert_eq!(web.place, Some(Place::Server("Shared/Web".into())));
|
||||
assert_eq!(albums::sources(&local, web.id).unwrap(), vec![img]);
|
||||
|
||||
// Nothing changed on either side, so a second pass takes nothing.
|
||||
let again = merge_remote(&local, &remote_path).unwrap();
|
||||
assert_eq!(again.albums_taken, 0);
|
||||
assert_eq!(again.album_exports_added, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_device_that_never_made_an_album_still_uploads_this_ones() {
|
||||
use crate::albums::{self, Place};
|
||||
let dir = tempdir();
|
||||
let remote_path = dir.join("remote.sqlite");
|
||||
{
|
||||
// A snapshot from a build that predates albums altogether.
|
||||
let r = seeded(&remote_path);
|
||||
checkpoint(&r).unwrap();
|
||||
}
|
||||
let local = seeded(&dir.join("local.sqlite"));
|
||||
albums::create(&local, "Web", &Place::Server("Web".into())).unwrap();
|
||||
|
||||
let report = merge_remote(&local, &remote_path).unwrap();
|
||||
assert_eq!(report.albums_taken, 0);
|
||||
// Its albums table is absent, so nothing was compared — and the local
|
||||
// album has still to reach the server.
|
||||
assert!(
|
||||
albums::list(&local).unwrap().len() == 1,
|
||||
"the local album survives a merge with a catalog that has none"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_remote_can_be_merged_twice_without_attach_conflict() {
|
||||
// Detach must happen even on the failure path, or the second attempt
|
||||
@@ -380,4 +644,188 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(kept, 1, "stripping the snapshot damaged the live catalog");
|
||||
}
|
||||
/// Device-side setup for the snapshot tests: an image both devices know by
|
||||
/// its cross-device file id, and one face on it carrying `crop`.
|
||||
fn with_a_face(c: &Connection, face_id: i64, x: f64, crop: &[u8]) {
|
||||
c.execute(
|
||||
"INSERT OR IGNORE INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO images(id, root_id, source_ref, added_at) VALUES (1, 1, 'a.CR3', 0)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
c.execute("INSERT INTO remote(image_id, file_id) VALUES (1, 5000)", [])
|
||||
.unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO faces
|
||||
(id, image_id, x, y, w, h, landmarks, detector_confidence, embedding,
|
||||
crop_px, model_id, detected_at, crop)
|
||||
VALUES (?1, 1, ?2, 0.2, 0.2, 0.2, X'00', 0.9, X'00', 150.0, 'w600k_mbf', 0, ?3)",
|
||||
rusqlite::params![face_id, x, crop],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn crop_of(c: &Connection, face_id: i64) -> Option<Vec<u8>> {
|
||||
c.query_row("SELECT crop FROM faces WHERE id = ?1", [face_id], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The snapshot is built table by table rather than copied, so what has to
|
||||
/// hold is that it is still the same database bar the crops: every table,
|
||||
/// index and row, and the header fields an older build checks before it
|
||||
/// will merge (`user_version`) or open it the way it always has (the WAL
|
||||
/// flag and page size a backup-API copy carried).
|
||||
#[test]
|
||||
fn the_snapshot_is_the_catalog_bar_the_crops() {
|
||||
let dir = tempdir();
|
||||
let live = dir.join("catalog.sqlite");
|
||||
let snap = dir.join("snap.sqlite");
|
||||
let c = seeded(&live);
|
||||
with_a_face(&c, 7, 0.3, &[7u8; 4096]);
|
||||
c.execute(
|
||||
"INSERT INTO collections(uuid, name, kind, created, revision, modified)
|
||||
VALUES ('u1', 'Iceland', 0, 0, 1, 1)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
// Created on first use rather than by a migration: the copy must not
|
||||
// depend on the migrations knowing every table.
|
||||
crate::duplicates::ensure_probe_table(&c).unwrap();
|
||||
|
||||
snapshot_for_upload(&c, &snap).unwrap();
|
||||
let out = Connection::open(&snap).unwrap();
|
||||
|
||||
let objects = |conn: &Connection| -> Vec<(String, String)> {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT type, name FROM sqlite_master ORDER BY type, name")
|
||||
.unwrap();
|
||||
let rows = stmt
|
||||
.query_map([], |r| Ok((r.get(0).unwrap(), r.get(1).unwrap())))
|
||||
.unwrap();
|
||||
rows.map(Result::unwrap).collect()
|
||||
};
|
||||
assert_eq!(objects(&out), objects(&c), "the snapshot's schema differs");
|
||||
|
||||
for (kind, table) in objects(&c) {
|
||||
if kind != "table" {
|
||||
continue;
|
||||
}
|
||||
let count = |conn: &Connection| -> i64 {
|
||||
conn.query_row(&format!("SELECT COUNT(*) FROM \"{table}\""), [], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(count(&out), count(&c), "rows differ in {table}");
|
||||
}
|
||||
|
||||
for pragma in [
|
||||
"user_version",
|
||||
"application_id",
|
||||
"page_size",
|
||||
"journal_mode",
|
||||
] {
|
||||
let read = |conn: &Connection| -> String {
|
||||
conn.query_row(&format!("PRAGMA {pragma}"), [], |r| {
|
||||
r.get::<_, rusqlite::types::Value>(0)
|
||||
})
|
||||
.map(|v| format!("{v:?}"))
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(read(&out), read(&c), "{pragma} differs");
|
||||
}
|
||||
drop(out);
|
||||
// Bytes 18 and 19 of the header are 2 for a WAL database, which is
|
||||
// what every snapshot uploaded before this one said.
|
||||
let header = std::fs::read(&snap).unwrap();
|
||||
assert_eq!(&header[18..20], &[2, 2], "the snapshot is not WAL-flagged");
|
||||
|
||||
// The face is there; its pixels are not.
|
||||
let out = Connection::open(&snap).unwrap();
|
||||
assert_eq!(crop_of(&out, 7), None);
|
||||
}
|
||||
|
||||
/// A pass that died mid-build leaves a file behind; the next one must
|
||||
/// build afresh rather than on top of it.
|
||||
#[test]
|
||||
fn a_leftover_snapshot_is_replaced_not_built_on() {
|
||||
let dir = tempdir();
|
||||
let live = dir.join("catalog.sqlite");
|
||||
let snap = dir.join("snap.sqlite");
|
||||
let c = seeded(&live);
|
||||
|
||||
std::fs::write(&snap, b"not a database").unwrap();
|
||||
snapshot_for_upload(&c, &snap).unwrap();
|
||||
// And twice over a good one, which is the steady state.
|
||||
snapshot_for_upload(&c, &snap).unwrap();
|
||||
|
||||
let out = Connection::open(&snap).unwrap();
|
||||
let v: i64 = out
|
||||
.query_row("PRAGMA user_version", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(v, schema::SCHEMA_VERSION);
|
||||
}
|
||||
|
||||
/// The merge side of a crop-less snapshot: the other device's names still
|
||||
/// cross over — the match is by box, not by pixels — and this device's
|
||||
/// own crop is left exactly as it was, never replaced by the snapshot's
|
||||
/// NULL.
|
||||
#[test]
|
||||
fn merging_a_crop_less_snapshot_keeps_local_crops_and_takes_the_names() {
|
||||
let dir = tempdir();
|
||||
let snap = dir.join("snap.sqlite");
|
||||
|
||||
let desktop = seeded(&dir.join("desktop.sqlite"));
|
||||
with_a_face(&desktop, 42, 0.31, &[1u8; 3000]);
|
||||
desktop
|
||||
.execute(
|
||||
"INSERT INTO people(id, uuid, name, ignored, created, revision, modified)
|
||||
VALUES (3, 'u-anna', 'Anna', 0, 0, 1, 1)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
desktop
|
||||
.execute(
|
||||
"INSERT INTO face_person(face_id, person_id, probability, confirmed)
|
||||
VALUES (42, 3, 0.9, 1)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
snapshot_for_upload(&desktop, &snap).unwrap();
|
||||
|
||||
// The tablet found the same face itself, under its own row id, and has
|
||||
// its own crop of it — from its own detection or from the shards.
|
||||
let tablet = seeded(&dir.join("tablet.sqlite"));
|
||||
let mine = vec![9u8; 2500];
|
||||
with_a_face(&tablet, 7, 0.30, &mine);
|
||||
|
||||
let report = merge_remote(&tablet, &snap).unwrap();
|
||||
assert_eq!(report.people_inserted, 1);
|
||||
assert_eq!(report.faces_assigned, 1);
|
||||
let named: (String, bool) = tablet
|
||||
.query_row(
|
||||
"SELECT p.name, fp.confirmed
|
||||
FROM face_person fp JOIN people p ON p.id = fp.person_id
|
||||
WHERE fp.face_id = 7",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(named, ("Anna".to_string(), true));
|
||||
assert_eq!(
|
||||
crop_of(&tablet, 7),
|
||||
Some(mine),
|
||||
"the merge touched a local crop"
|
||||
);
|
||||
|
||||
// Idempotent over a crop-less remote too.
|
||||
let again = merge_remote(&tablet, &snap).unwrap();
|
||||
assert!(!again.local_changed());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,28 +129,40 @@ pub fn record_trashed(
|
||||
return Ok(0);
|
||||
}
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
let mut n = 0;
|
||||
|
||||
{
|
||||
let mut stmt = tx.prepare(
|
||||
"UPDATE images
|
||||
SET trashed_from = CASE
|
||||
WHEN trashed_at IS NULL THEN source_ref
|
||||
ELSE trashed_from
|
||||
END,
|
||||
source_ref = ?2,
|
||||
trashed_at = coalesce(trashed_at, ?3)
|
||||
WHERE id = ?1",
|
||||
)?;
|
||||
for (image, path) in moved {
|
||||
n += stmt.execute(rusqlite::params![image.0 as i64, path, now])?;
|
||||
}
|
||||
}
|
||||
|
||||
let n = record_trashed_within(&tx, moved, now)?;
|
||||
tx.commit()?;
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// [`record_trashed`] inside a transaction the caller owns.
|
||||
///
|
||||
/// For a caller whose trash is one half of a larger write that must land
|
||||
/// whole or not at all — consolidating duplicates (`crate::duplicates`)
|
||||
/// merges a copy's judgements onto the survivor and trashes the copy in one
|
||||
/// commit. `unchecked_transaction` cannot nest, so this is offered here
|
||||
/// rather than wrapped from above.
|
||||
pub fn record_trashed_within(
|
||||
tx: &Connection,
|
||||
moved: &[(ImageId, String)],
|
||||
now: i64,
|
||||
) -> Result<usize, CatalogError> {
|
||||
let mut n = 0;
|
||||
let mut stmt = tx.prepare(
|
||||
"UPDATE images
|
||||
SET trashed_from = CASE
|
||||
WHEN trashed_at IS NULL THEN source_ref
|
||||
ELSE trashed_from
|
||||
END,
|
||||
source_ref = ?2,
|
||||
trashed_at = coalesce(trashed_at, ?3)
|
||||
WHERE id = ?1",
|
||||
)?;
|
||||
for (image, path) in moved {
|
||||
n += stmt.execute(rusqlite::params![image.0 as i64, path, now])?;
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Record that images have been moved back out of the trash.
|
||||
///
|
||||
/// Call after the move succeeds, for the same reason as [`record_trashed`].
|
||||
|
||||
+32
-53
@@ -48,7 +48,6 @@ use dr_types::{Availability, FormatFilter, RootId, SourceRef};
|
||||
use rusqlite::{Connection, OptionalExtension};
|
||||
|
||||
use crate::error::CatalogError;
|
||||
use crate::jobs::{self, JobKind, Priority};
|
||||
use crate::query::availability_code;
|
||||
use crate::scan::{
|
||||
classify_dir, classify_entry, DirAction, DirState, EntryAction, KnownFile, ScanOutcome,
|
||||
@@ -336,7 +335,7 @@ pub fn scan_root(
|
||||
}
|
||||
}
|
||||
EntryAction::Insert => {
|
||||
let id = insert_image(
|
||||
insert_image(
|
||||
&tx,
|
||||
root,
|
||||
folder_id,
|
||||
@@ -345,11 +344,10 @@ pub fn scan_root(
|
||||
entry.meta.mtime,
|
||||
now,
|
||||
)?;
|
||||
queue_reading_it(&tx, id)?;
|
||||
report.inserted += 1;
|
||||
}
|
||||
EntryAction::Changed => {
|
||||
let id = update_image(
|
||||
update_image(
|
||||
&tx,
|
||||
root,
|
||||
folder_id,
|
||||
@@ -357,7 +355,6 @@ pub fn scan_root(
|
||||
entry.meta.size,
|
||||
entry.meta.mtime,
|
||||
)?;
|
||||
queue_reading_it(&tx, id)?;
|
||||
report.updated += 1;
|
||||
}
|
||||
EntryAction::Ignored => unreachable!("returned above"),
|
||||
@@ -640,7 +637,7 @@ fn insert_image(
|
||||
size: u64,
|
||||
mtime: i64,
|
||||
now: i64,
|
||||
) -> Result<i64, CatalogError> {
|
||||
) -> Result<(), CatalogError> {
|
||||
// `metadata_state = 1`: the scan knows the name, the size and the mtime,
|
||||
// and has read no EXIF. Claiming otherwise would make a date filter
|
||||
// silently wrong on a freshly scanned library.
|
||||
@@ -664,7 +661,7 @@ fn insert_image(
|
||||
now,
|
||||
],
|
||||
)?;
|
||||
image_id(conn, root, src)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update_image(
|
||||
@@ -674,7 +671,7 @@ fn update_image(
|
||||
src: &SourceRef,
|
||||
size: u64,
|
||||
mtime: i64,
|
||||
) -> Result<i64, CatalogError> {
|
||||
) -> Result<(), CatalogError> {
|
||||
// The content hash is dropped, not recomputed: it described bytes that no
|
||||
// longer exist, and leaving it would let reconnect-by-hash match this image
|
||||
// to a file it is no longer a copy of. `metadata_state` goes back to 1 for
|
||||
@@ -692,37 +689,7 @@ fn update_image(
|
||||
src.key(),
|
||||
],
|
||||
)?;
|
||||
image_id(conn, root, src)
|
||||
}
|
||||
|
||||
fn image_id(conn: &Connection, root: RootId, src: &SourceRef) -> Result<i64, CatalogError> {
|
||||
Ok(conn.query_row(
|
||||
"SELECT id FROM images WHERE root_id = ?1 AND source_ref = ?2",
|
||||
rusqlite::params![root.0 as i64, src.key()],
|
||||
|r| r.get(0),
|
||||
)?)
|
||||
}
|
||||
|
||||
/// Queue the work that turns a stat-only row into a usable grid cell.
|
||||
///
|
||||
/// Enqueued inside the scan's transaction, so a folder's rows and the jobs that
|
||||
/// finish them land together — a crash between the two would otherwise leave
|
||||
/// images no worker was ever told about.
|
||||
fn queue_reading_it(conn: &Connection, image_id: i64) -> Result<(), CatalogError> {
|
||||
jobs::enqueue(
|
||||
conn,
|
||||
JobKind::ExtractMetadata,
|
||||
Some(image_id),
|
||||
Priority::Background,
|
||||
None,
|
||||
)?;
|
||||
jobs::enqueue(
|
||||
conn,
|
||||
JobKind::Thumbnail,
|
||||
Some(image_id),
|
||||
Priority::Background,
|
||||
None,
|
||||
)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-9
|
||||
@@ -831,6 +798,17 @@ mod tests {
|
||||
let tmp = target.with_extension("tmp");
|
||||
fs::write(&tmp, bytes).expect("write");
|
||||
fs::rename(&tmp, &target).expect("rename");
|
||||
// A scan tells a changed file by its mtime, at whole-second
|
||||
// resolution; a resave landing in the same second as the scan
|
||||
// before it looks unchanged, and the test fails when the machine
|
||||
// is fast enough. Two seconds ahead, on the file and its folder,
|
||||
// is what a real resave some time later would look like.
|
||||
let later = std::time::SystemTime::now() + std::time::Duration::from_secs(2);
|
||||
for p in [target.as_path(), target.parent().expect("parent")] {
|
||||
fs::File::open(p)
|
||||
.and_then(|f| f.set_modified(later))
|
||||
.expect("set mtime");
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
@@ -1096,7 +1074,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_resaved_file_is_queued_for_rereading_and_loses_its_stale_hash() {
|
||||
fn a_resaved_file_owes_a_reread_and_loses_its_stale_hash() {
|
||||
let lib = Library::new("resaved");
|
||||
lib.file("IMG.CR3", b"raw");
|
||||
lib.scan();
|
||||
@@ -1121,9 +1099,8 @@ mod tests {
|
||||
"a hash of bytes that no longer exist would match this image to the \
|
||||
wrong file on reconnect"
|
||||
);
|
||||
assert_eq!(lib.count("SELECT metadata_state FROM images"), 1);
|
||||
assert_eq!(
|
||||
lib.count("SELECT count(*) FROM jobs WHERE kind = 1"),
|
||||
lib.count("SELECT metadata_state FROM images"),
|
||||
1,
|
||||
"EXIF must be re-read"
|
||||
);
|
||||
@@ -1137,7 +1114,11 @@ mod tests {
|
||||
let lib = Library::new("no-requeue");
|
||||
lib.file("a.CR3", b"raw");
|
||||
lib.scan();
|
||||
lib.conn().execute("DELETE FROM jobs", []).unwrap();
|
||||
// As if the metadata sweep had read it: what is owed is recorded in
|
||||
// `metadata_state`, and the thumbnail store answers for itself.
|
||||
lib.conn()
|
||||
.execute("UPDATE images SET metadata_state = 2", [])
|
||||
.unwrap();
|
||||
|
||||
lib.file("b.CR3", b"raw");
|
||||
let r = lib.scan();
|
||||
@@ -1145,9 +1126,9 @@ mod tests {
|
||||
assert_eq!(r.inserted, 1);
|
||||
assert_eq!(r.unchanged, 1);
|
||||
assert_eq!(
|
||||
lib.count("SELECT count(*) FROM jobs"),
|
||||
2,
|
||||
"EXIF and a thumbnail for the new image, and nothing for the old one"
|
||||
lib.count("SELECT count(*) FROM images WHERE metadata_state < 2"),
|
||||
1,
|
||||
"EXIF owed for the new image, and nothing for the old one"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1171,17 +1152,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_image_is_queued_for_a_thumbnail_and_for_exif() {
|
||||
fn a_new_image_owes_its_exif_and_queues_nothing() {
|
||||
// The sweeps find their work from `metadata_state` and the thumbnail
|
||||
// store. A queued job would be a second record of the same debt, and
|
||||
// no handler claims one (#73).
|
||||
let lib = Library::new("queued");
|
||||
lib.file("IMG.CR3", b"raw");
|
||||
lib.scan();
|
||||
|
||||
assert_eq!(
|
||||
lib.count("SELECT count(*) FROM jobs WHERE kind = 2"),
|
||||
1,
|
||||
"no thumbnail job means an empty grid cell forever"
|
||||
);
|
||||
assert_eq!(lib.count("SELECT count(*) FROM jobs WHERE kind = 1"), 1);
|
||||
assert_eq!(lib.count("SELECT count(*) FROM jobs"), 0);
|
||||
assert_eq!(
|
||||
lib.count("SELECT metadata_state FROM images"),
|
||||
1,
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
// TRACES: FR-PLAT-AND-3
|
||||
//! No job kind is enqueued without something that claims it.
|
||||
//!
|
||||
//! The queue coalesces, so a producer with no consumer does not fail — it
|
||||
//! just leaves a row per subject for ever. That is how the reference catalog
|
||||
//! came to hold 23,582 `Thumbnail` jobs, one per photograph, re-coalesced on
|
||||
//! every scan, with no handler for the kind anywhere in the tree (#73). Nothing
|
||||
//! at runtime notices: the rows are cheap one at a time and invisible in the
|
||||
//! interface. So the pairing is checked here, over the source, instead.
|
||||
//!
|
||||
//! ## What counts
|
||||
//!
|
||||
//! In shipping code under `core/`, `ui/`, `apps/` and `platform/` — every
|
||||
//! `src/` tree, with `#[cfg(test)]` items dropped:
|
||||
//!
|
||||
//! - **Enqueued**: the `JobKind::X` named in the arguments of a call to
|
||||
//! `enqueue(`. An enqueue whose kind is not spelled there — passed in a
|
||||
//! variable — is refused outright, because this scan could not say what it
|
||||
//! queues.
|
||||
//! - **Claimed**: the `JobKind::X` in the body of a `fn kinds(` (what a
|
||||
//! `JobHandler` declares, and all a `Runner` claims), or named in a call to
|
||||
//! `claim_next_matching(`. A call to `claim_next(` claims every kind.
|
||||
//! `JobKind::ALL` in either place means every kind.
|
||||
//!
|
||||
//! Tests and examples are left out on purpose: a unit test of the queue's
|
||||
//! mechanics enqueues and claims whatever it likes, and proves nothing about
|
||||
//! the app.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Every `.rs` file under each `src/` of each crate in `group`.
|
||||
fn crate_sources(group: &Path, out: &mut Vec<PathBuf>) {
|
||||
let Ok(crates) = fs::read_dir(group) else {
|
||||
return;
|
||||
};
|
||||
for krate in crates {
|
||||
let src = krate.expect("read dir entry").path().join("src");
|
||||
if src.is_dir() {
|
||||
rust_files(&src, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rust_files(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
for entry in fs::read_dir(dir).unwrap_or_else(|e| panic!("cannot read {}: {e}", dir.display()))
|
||||
{
|
||||
let path = entry.expect("read dir entry").path();
|
||||
if path.is_dir() {
|
||||
rust_files(&path, out);
|
||||
} else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Blank out string literals and line comments, so neither a brace nor a
|
||||
/// `JobKind::` inside prose is read as code.
|
||||
fn strip_literals_and_comments(line: &str) -> String {
|
||||
let mut out = String::with_capacity(line.len());
|
||||
let mut chars = line.chars().peekable();
|
||||
let mut in_string = false;
|
||||
while let Some(c) = chars.next() {
|
||||
if in_string {
|
||||
match c {
|
||||
'\\' => {
|
||||
chars.next();
|
||||
}
|
||||
'"' => in_string = false,
|
||||
_ => {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match c {
|
||||
'"' => in_string = true,
|
||||
'/' if chars.peek() == Some(&'/') => break,
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The shipping code of a file, comments and strings blanked, with every
|
||||
/// `#[cfg(test)]` item dropped. The attribute must be the whole line, so a
|
||||
/// doc comment mentioning it is not mistaken for one.
|
||||
fn shipping_code(text: &str) -> String {
|
||||
let lines: Vec<String> = text.lines().map(strip_literals_and_comments).collect();
|
||||
let mut out = String::new();
|
||||
let mut i = 0;
|
||||
while i < lines.len() {
|
||||
if lines[i].trim() != "#[cfg(test)]" {
|
||||
out.push_str(&lines[i]);
|
||||
out.push('\n');
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let (mut j, mut depth, mut opened) = (i + 1, 0i32, false);
|
||||
while j < lines.len() {
|
||||
depth += lines[j].matches('{').count() as i32;
|
||||
depth -= lines[j].matches('}').count() as i32;
|
||||
opened |= lines[j].contains('{');
|
||||
if (opened && depth <= 0) || (!opened && lines[j].contains(';')) {
|
||||
break;
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
i = j + 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The text from `start` (just past an opening delimiter) to its matching
|
||||
/// close.
|
||||
fn balanced(code: &str, start: usize, open: char, close: char) -> &str {
|
||||
let mut depth = 1;
|
||||
for (i, c) in code[start..].char_indices() {
|
||||
if c == open {
|
||||
depth += 1;
|
||||
} else if c == close {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
return &code[start..start + i];
|
||||
}
|
||||
}
|
||||
}
|
||||
&code[start..]
|
||||
}
|
||||
|
||||
/// Each call of `name(` in `code` that is a call rather than the function's
|
||||
/// own definition or a longer name ending in it, as its argument text.
|
||||
fn calls<'a>(code: &'a str, name: &str) -> Vec<&'a str> {
|
||||
let needle = format!("{name}(");
|
||||
let mut found = Vec::new();
|
||||
for (at, _) in code.match_indices(&needle) {
|
||||
let before = &code[..at];
|
||||
let prev = before.chars().next_back();
|
||||
if prev.is_some_and(|c| c.is_alphanumeric() || c == '_') {
|
||||
continue;
|
||||
}
|
||||
if before.trim_end().ends_with("fn") {
|
||||
continue;
|
||||
}
|
||||
found.push(balanced(code, at + needle.len(), '(', ')'));
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// Bodies of every `fn kinds(` that has one — a trait declaration ending in
|
||||
/// `;` has none.
|
||||
fn kinds_bodies(code: &str) -> Vec<&str> {
|
||||
let mut found = Vec::new();
|
||||
for (at, _) in code.match_indices("fn kinds(") {
|
||||
let rest = &code[at..];
|
||||
let (Some(brace), semi) = (rest.find('{'), rest.find(';')) else {
|
||||
continue;
|
||||
};
|
||||
if semi.is_some_and(|s| s < brace) {
|
||||
continue;
|
||||
}
|
||||
found.push(balanced(code, at + brace + 1, '{', '}'));
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// The `X` of each `JobKind::X` in `text`.
|
||||
fn kinds_named(text: &str) -> Vec<String> {
|
||||
text.match_indices("JobKind::")
|
||||
.map(|(at, m)| {
|
||||
text[at + m.len()..]
|
||||
.chars()
|
||||
.take_while(|c| c.is_alphanumeric() || *c == '_')
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
struct Ledger {
|
||||
/// Kind → where it is enqueued.
|
||||
enqueued: Vec<(String, String)>,
|
||||
/// Enqueue calls whose kind could not be read.
|
||||
unreadable: Vec<String>,
|
||||
claimed: BTreeSet<String>,
|
||||
claims_everything: bool,
|
||||
}
|
||||
|
||||
fn read(files: &[(String, String)]) -> Ledger {
|
||||
let mut ledger = Ledger::default();
|
||||
for (name, text) in files {
|
||||
let code = shipping_code(text);
|
||||
|
||||
for args in calls(&code, "enqueue") {
|
||||
let kinds = kinds_named(args);
|
||||
if kinds.is_empty() {
|
||||
ledger
|
||||
.unreadable
|
||||
.push(format!("{name}: enqueue({})", args.trim()));
|
||||
}
|
||||
for k in kinds {
|
||||
ledger.enqueued.push((k, name.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
let claimed = kinds_bodies(&code)
|
||||
.into_iter()
|
||||
.chain(calls(&code, "claim_next_matching"));
|
||||
for text in claimed {
|
||||
for k in kinds_named(text) {
|
||||
if k == "ALL" {
|
||||
ledger.claims_everything = true;
|
||||
} else {
|
||||
ledger.claimed.insert(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !calls(&code, "claim_next").is_empty() {
|
||||
ledger.claims_everything = true;
|
||||
}
|
||||
}
|
||||
ledger
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_kind_enqueued_is_claimed_by_something() {
|
||||
let repo = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.and_then(Path::parent)
|
||||
.expect("core/dr-catalog has a grandparent");
|
||||
|
||||
let mut paths = Vec::new();
|
||||
for group in ["core", "ui", "apps", "platform"] {
|
||||
crate_sources(&repo.join(group), &mut paths);
|
||||
}
|
||||
let files: Vec<(String, String)> = paths
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let text = fs::read_to_string(p)
|
||||
.unwrap_or_else(|e| panic!("cannot read {}: {e}", p.display()));
|
||||
let name = p.strip_prefix(repo).unwrap_or(p).display().to_string();
|
||||
(name, text)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// A scan over nothing passes for the wrong reason. The queue's own file
|
||||
// and the scan that used to feed it must both have been read, and the
|
||||
// queue's definitions found in them.
|
||||
for must in [
|
||||
"core/dr-catalog/src/jobs.rs",
|
||||
"core/dr-catalog/src/runner.rs",
|
||||
"ui/dr-ui/src/library/scan.rs",
|
||||
] {
|
||||
assert!(
|
||||
files.iter().any(|(n, _)| n == must),
|
||||
"{must} was not scanned — the source walk is wrong, not the code"
|
||||
);
|
||||
}
|
||||
let jobs = &files
|
||||
.iter()
|
||||
.find(|(n, _)| n == "core/dr-catalog/src/jobs.rs")
|
||||
.unwrap()
|
||||
.1;
|
||||
assert!(shipping_code(jobs).contains("pub fn enqueue("));
|
||||
|
||||
let ledger = read(&files);
|
||||
|
||||
assert!(
|
||||
ledger.unreadable.is_empty(),
|
||||
"\n\nThese enqueue calls do not name their JobKind, so this test cannot \
|
||||
check that anything claims it. Spell the kind at the call:\n {}\n",
|
||||
ledger.unreadable.join("\n ")
|
||||
);
|
||||
|
||||
if ledger.claims_everything {
|
||||
return;
|
||||
}
|
||||
let orphans: Vec<String> = ledger
|
||||
.enqueued
|
||||
.iter()
|
||||
.filter(|(k, _)| !ledger.claimed.contains(k))
|
||||
.map(|(k, at)| format!("JobKind::{k}, enqueued in {at}"))
|
||||
.collect();
|
||||
assert!(
|
||||
orphans.is_empty(),
|
||||
"\n\nEnqueued, and claimed by nothing (claimed: {:?}):\n {}\n\n\
|
||||
A kind nobody claims is a row per subject that stays for ever — the \
|
||||
queue coalesces, so it never fails, it only grows (#73). Register a \
|
||||
JobHandler for the kind, or stop enqueueing it and add it to \
|
||||
JobKind::RETIRED so the rows already queued are dropped.\n",
|
||||
ledger.claimed,
|
||||
orphans.join("\n ")
|
||||
);
|
||||
}
|
||||
|
||||
/// The reader itself, on code whose answer is known — so a parsing bug shows
|
||||
/// up as this failing, rather than the real check quietly finding nothing.
|
||||
#[test]
|
||||
fn the_reader_sees_producers_and_consumers() {
|
||||
let producer = r#"
|
||||
use dr_catalog::jobs;
|
||||
fn persist(tx: &Connection, id: i64) {
|
||||
// jobs::enqueue(tx, JobKind::ContentHash, ...) in a comment is not a call
|
||||
let _ = dr_catalog::jobs::enqueue(
|
||||
tx,
|
||||
JobKind::Thumbnail,
|
||||
Some(id),
|
||||
Priority::Background,
|
||||
None,
|
||||
);
|
||||
jobs::enqueue(tx, kind, Some(id), Priority::Background, None)?;
|
||||
}
|
||||
pub fn enqueue(conn: &Connection, kind: JobKind) {}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn t() { enqueue(&c, JobKind::FetchOriginal, None, P, None); }
|
||||
}
|
||||
"#;
|
||||
let consumer = r#"
|
||||
impl JobHandler for Faces {
|
||||
fn kinds(&self) -> &[JobKind] {
|
||||
&[JobKind::DetectFaces]
|
||||
}
|
||||
fn run(&mut self) {}
|
||||
}
|
||||
trait JobHandler { fn kinds(&self) -> &[JobKind]; }
|
||||
fn pull(c: &Connection) { claim_next_matching(c, 0, &[JobKind::FetchPreview]); }
|
||||
"#;
|
||||
let ledger = read(&[
|
||||
("producer.rs".into(), producer.into()),
|
||||
("consumer.rs".into(), consumer.into()),
|
||||
]);
|
||||
|
||||
let enqueued: Vec<&str> = ledger.enqueued.iter().map(|(k, _)| k.as_str()).collect();
|
||||
assert_eq!(enqueued, vec!["Thumbnail"]);
|
||||
assert_eq!(ledger.unreadable.len(), 1, "{:?}", ledger.unreadable);
|
||||
assert_eq!(
|
||||
ledger.claimed,
|
||||
BTreeSet::from(["DetectFaces".to_string(), "FetchPreview".to_string()])
|
||||
);
|
||||
assert!(!ledger.claims_everything);
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
# DarkRoom camera base curves (FR-DEV-3e).
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# Adding a body is editing this file. It is not a code change.
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# The copy you are reading is compiled into the binary as a floor. At startup
|
||||
# `dr_decode::base_curve::load` also looks for `base_curves.yaml` in:
|
||||
#
|
||||
# 1. $DARKROOM_PROFILES/ (set it while you are tuning)
|
||||
# 2. $XDG_DATA_HOME/darkroom/profiles/
|
||||
# or $HOME/.local/share/darkroom/profiles/
|
||||
#
|
||||
# and uses the first one it finds *whose `version:` is higher than this one's*.
|
||||
# So: bump `version`, drop the file in that directory, restart. A body added
|
||||
# this afternoon renders correctly this afternoon, with no release and no
|
||||
# rebuild — which is what the requirement asks for, and what makes these
|
||||
# contributable under the GPL.
|
||||
#
|
||||
# The version check runs both ways on purpose. A file older than the built-in
|
||||
# copy is ignored with a log line, so upgrading DarkRoom cannot silently lose
|
||||
# curves to a pack somebody downloaded a year ago.
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# What the numbers mean
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Five `[x, y]` control points on a monotone spline (Fritsch-Carlson, the same
|
||||
# one the tone curve widget draws). Both axes are **linear**:
|
||||
#
|
||||
# x scene-referred camera RGB after white balance, 1.0 = sensor saturation
|
||||
# y display-referred linear; the sRGB transfer function is applied later,
|
||||
# at the end of the shader, so do not pre-apply a gamma here
|
||||
#
|
||||
# The identity is y = x, and it is what an unrecognised body gets if `default:`
|
||||
# is removed. It is also the wrong answer for almost every photograph: linear
|
||||
# scene data has middle grey at about 13% and a camera JPEG puts it near 18%,
|
||||
# so an uncurved render is roughly half a stop dark through the midtones and
|
||||
# has no highlight rolloff at all.
|
||||
#
|
||||
# A curve that works has three parts, and it is worth naming them because they
|
||||
# are what you are actually tuning:
|
||||
#
|
||||
# the toe the first span, slope near or below 1. Deep shadows stay
|
||||
# deep. Lift it and blacks go milky; crush it and shadow
|
||||
# detail the sensor recorded disappears.
|
||||
# the midtones the middle spans, slope well above 1. This is the contrast
|
||||
# and the brightness people read as "the camera's look".
|
||||
# the shoulder the last span, slope well below 1. Highlights compress
|
||||
# toward white instead of arriving there and clipping. It is
|
||||
# the difference between a rolled-off sky and a white hole.
|
||||
#
|
||||
# Two invariants are enforced in code and tested, so a mistake here fails the
|
||||
# build rather than the photograph: x must strictly increase, y must not
|
||||
# decrease, and everything must lie inside the unit square.
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# Honesty about these values
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# These are hand-tuned shapes, not measurements. They encode what every camera
|
||||
# JPEG rendering has in common — the toe/midtone/shoulder structure above —
|
||||
# plus each maker's well-known house differences: Canon's gentler shoulder and
|
||||
# warmer-reading midtones, Nikon's slightly higher midtone contrast, Sony's
|
||||
# flatter and more conservative default, Fujifilm's markedly contrastier
|
||||
# Provia-derived rendering.
|
||||
#
|
||||
# FR-DEV-3e's acceptance criterion is subjective comparison against each body's
|
||||
# own JPEG, and meeting it properly needs a frame from that body in front of
|
||||
# you. Where that has not been done, the entry is still much closer to right
|
||||
# than the identity — which is the bar these have to clear, and do.
|
||||
|
||||
version: 1
|
||||
|
||||
# The rendering for a body with no entry of its own.
|
||||
#
|
||||
# **Deliberately not the identity.** The failure this requirement exists to fix
|
||||
# is the flat render, and a conservative curve is far closer to right for every
|
||||
# body than no curve is for any of them. It is gentler than the per-body
|
||||
# entries below — a shallower midtone and an earlier, softer shoulder — because
|
||||
# it has to be safe on a sensor nobody has looked at, and the cost of being too
|
||||
# tame is a photograph that wants a little contrast rather than one that has
|
||||
# lost its highlights.
|
||||
default:
|
||||
points:
|
||||
- [0.00, 0.000]
|
||||
- [0.04, 0.043]
|
||||
- [0.13, 0.175]
|
||||
- [0.45, 0.690]
|
||||
- [1.00, 1.000]
|
||||
|
||||
bodies:
|
||||
# Canon. A soft toe and a long, gradual shoulder — the reason Canon files
|
||||
# are described as forgiving in highlights and a little low in contrast
|
||||
# straight out of camera.
|
||||
- make: Canon
|
||||
model: EOS 6D
|
||||
points:
|
||||
- [0.00, 0.000]
|
||||
- [0.04, 0.045]
|
||||
- [0.13, 0.190]
|
||||
- [0.45, 0.720]
|
||||
- [1.00, 1.000]
|
||||
|
||||
- make: Canon
|
||||
model: EOS R6
|
||||
points:
|
||||
- [0.00, 0.000]
|
||||
- [0.04, 0.044]
|
||||
- [0.13, 0.195]
|
||||
- [0.45, 0.730]
|
||||
- [1.00, 1.000]
|
||||
|
||||
# Nikon. A slightly deeper toe and more midtone slope than Canon, which is
|
||||
# the "punchier out of camera" difference people describe between the two.
|
||||
- make: Nikon
|
||||
model: Z 6
|
||||
points:
|
||||
- [0.00, 0.000]
|
||||
- [0.04, 0.038]
|
||||
- [0.13, 0.200]
|
||||
- [0.46, 0.750]
|
||||
- [1.00, 1.000]
|
||||
|
||||
- make: Nikon
|
||||
model: D750
|
||||
points:
|
||||
- [0.00, 0.000]
|
||||
- [0.04, 0.039]
|
||||
- [0.13, 0.198]
|
||||
- [0.46, 0.745]
|
||||
- [1.00, 1.000]
|
||||
|
||||
# Sony. The flattest default of the four, and intentionally so — Sony's own
|
||||
# rendering leaves more headroom than it uses, which is why Sony files are
|
||||
# the ones people describe as needing the most work.
|
||||
- make: Sony
|
||||
model: ILCE-7M3
|
||||
points:
|
||||
- [0.00, 0.000]
|
||||
- [0.04, 0.048]
|
||||
- [0.13, 0.185]
|
||||
- [0.44, 0.700]
|
||||
- [1.00, 1.000]
|
||||
|
||||
# Fujifilm. Provia, the default film simulation: a firm toe, the steepest
|
||||
# midtones here, and a hard shoulder. It is the most distinctive rendering of
|
||||
# the four and the one where a flat render looks most obviously wrong.
|
||||
#
|
||||
# This entry does *not* read the in-RAF film simulation tag — that is
|
||||
# FR-DEV-3f, and until it lands every Fujifilm file gets the Provia shape
|
||||
# whatever the camera was set to.
|
||||
- make: Fujifilm
|
||||
model: X-T3
|
||||
points:
|
||||
- [0.00, 0.000]
|
||||
- [0.045, 0.040]
|
||||
- [0.14, 0.215]
|
||||
- [0.47, 0.775]
|
||||
- [1.00, 1.000]
|
||||
@@ -1,752 +0,0 @@
|
||||
//! TRACES: FR-DEV-3e
|
||||
//! Base curves — the per-body rendering that turns a correct exposure into a
|
||||
//! photograph.
|
||||
//!
|
||||
//! # What this is for
|
||||
//!
|
||||
//! A camera matrix gets the *colours* right and leaves the picture flat. Sensor
|
||||
//! data is scene-referred and very nearly linear; a print, a screen and a
|
||||
//! camera's own JPEG are none of those things. Rendering linear data straight
|
||||
//! out is the dcraw default, and FR-DEV-3e names it precisely: "the flat,
|
||||
//! poor-skin-tone rendering characteristic of dcraw defaults, which is the
|
||||
//! documented reason people abandon darktable in the first hour."
|
||||
//!
|
||||
//! The fix is a tone curve applied as part of *reading* the file rather than as
|
||||
//! an edit — a toe, a steep midtone, and a shoulder that rolls highlights off
|
||||
//! instead of clipping them. Every raw converter has one. Adobe calls it the
|
||||
//! camera profile's tone curve, darktable calls it the base curve, and the name
|
||||
//! here follows darktable's because the placement does too: it runs in camera
|
||||
//! RGB, after white balance and the user's adjustments, immediately before the
|
||||
//! conversion out to a working space.
|
||||
//!
|
||||
//! # Why it is not an edit
|
||||
//!
|
||||
//! It never reaches the sidecar and there is no slider for it, for the same
|
||||
//! reason the EXIF orientation is not an edit (FR-DEV-3h): it is a property of
|
||||
//! the body that took the frame, not of what anyone decided about the frame.
|
||||
//! Sidecars are shared between devices and bodies (FR-NC-9), and one camera's
|
||||
//! rendering must not follow an edit onto another camera's file.
|
||||
//!
|
||||
//! # Why it is data
|
||||
//!
|
||||
//! FR-DEV-3e requires the profile database to be "versioned independently of
|
||||
//! the app binary so bodies and curves can be added without a release — and,
|
||||
//! under D8's GPLv3, contributed by users". So the curves live in
|
||||
//! `profiles/base_curves.yaml`, a file that is compiled in as a floor and
|
||||
//! *overridden* by a copy on disk carrying a higher `version:`. Adding a body
|
||||
//! is adding ten numbers to a YAML file; shipping that body to users is
|
||||
//! publishing the file. Neither is a code change and neither needs a release.
|
||||
//!
|
||||
//! See [`load`] for the search path and [`Curves::body`] for the matching.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// How many control points a base curve has.
|
||||
///
|
||||
/// Five, which is not a coincidence: it is what the tone curve widget uses
|
||||
/// (`dr_pipeline::ops::curve::POINTS`), so the shader evaluates a profile's
|
||||
/// curve and a photographer's curve through exactly the same spline. A profile
|
||||
/// author and a photographer dragging a point mean the same thing by it, and
|
||||
/// the generated shader carries one implementation rather than two that could
|
||||
/// disagree.
|
||||
pub const POINTS: usize = 5;
|
||||
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// A base curve: five points on a monotone spline through the unit square.
|
||||
///
|
||||
/// `xs` is scene-linear camera RGB, normalised so that 1.0 is the sensor's
|
||||
/// saturation point. `ys` is display-referred linear — *not* gamma-encoded,
|
||||
/// because the sRGB transfer function is applied at the very end of the
|
||||
/// generated shader and applying it twice would wash the image out.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct BaseCurve {
|
||||
pub xs: [f32; POINTS],
|
||||
pub ys: [f32; POINTS],
|
||||
}
|
||||
|
||||
impl BaseCurve {
|
||||
/// The curve that does nothing — the identity diagonal.
|
||||
///
|
||||
/// What an unrecognised body gets if the database carries no default, and
|
||||
/// what a JPEG gets always: an already-rendered image must not be rendered
|
||||
/// a second time.
|
||||
pub const IDENTITY: Self = Self {
|
||||
xs: [0.0, 0.25, 0.5, 0.75, 1.0],
|
||||
ys: [0.0, 0.25, 0.5, 0.75, 1.0],
|
||||
};
|
||||
|
||||
/// Whether this curve would leave the image alone.
|
||||
///
|
||||
/// The shader is told to skip the stage entirely when it would, so an
|
||||
/// unprofiled body costs a branch that is uniform across the dispatch
|
||||
/// rather than a spline evaluation per channel per pixel.
|
||||
pub fn is_identity(&self) -> bool {
|
||||
self.xs
|
||||
.iter()
|
||||
.zip(self.ys.iter())
|
||||
.all(|(x, y)| (x - y).abs() < 1e-6)
|
||||
}
|
||||
|
||||
/// Build from raw pairs, rejecting anything that is not a curve.
|
||||
///
|
||||
/// A profile file is data a user may have edited, so this is the boundary
|
||||
/// where "ten numbers" becomes "a curve": the x coordinates must increase,
|
||||
/// the y coordinates must not decrease, and both must lie in the unit
|
||||
/// square. A non-monotone x sends the spline's span search backwards and
|
||||
/// divides by a negative width; a decreasing y inverts tones locally,
|
||||
/// which reads as a dark halo through smooth gradients rather than as a
|
||||
/// bad profile.
|
||||
///
|
||||
/// Endpoints are not forced to (0,0) and (1,1). A curve that lifts black
|
||||
/// slightly, or that places the shoulder below white, is a legitimate
|
||||
/// rendering choice and several bodies make it.
|
||||
pub fn from_points(points: &[[f32; 2]]) -> Option<Self> {
|
||||
if points.len() != POINTS {
|
||||
return None;
|
||||
}
|
||||
let mut xs = [0.0f32; POINTS];
|
||||
let mut ys = [0.0f32; POINTS];
|
||||
for (i, p) in points.iter().enumerate() {
|
||||
if !p[0].is_finite() || !p[1].is_finite() {
|
||||
return None;
|
||||
}
|
||||
if !(0.0..=1.0).contains(&p[0]) || !(0.0..=1.0).contains(&p[1]) {
|
||||
return None;
|
||||
}
|
||||
xs[i] = p[0];
|
||||
ys[i] = p[1];
|
||||
}
|
||||
for i in 1..POINTS {
|
||||
// Strictly increasing in x — the spline divides by the span width.
|
||||
if xs[i] <= xs[i - 1] {
|
||||
return None;
|
||||
}
|
||||
// Non-decreasing in y. Flat is allowed: a curve that holds a
|
||||
// highlight range at white is clipping deliberately.
|
||||
if ys[i] < ys[i - 1] {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(Self { xs, ys })
|
||||
}
|
||||
}
|
||||
|
||||
/// One body's entry in the database.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct BodyCurve {
|
||||
/// The manufacturer, as the file writes it — "Canon", "NIKON CORPORATION".
|
||||
pub make: String,
|
||||
/// The model, as the file writes it — "EOS 6D", "ILCE-7M3".
|
||||
pub model: String,
|
||||
pub curve: BaseCurve,
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// The base curve database.
|
||||
///
|
||||
/// Versioned as a whole rather than per body, because that is the unit a user
|
||||
/// downloads and the unit that has to beat the built-in copy. See [`load`].
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Curves {
|
||||
version: u32,
|
||||
default: Option<BaseCurve>,
|
||||
bodies: Vec<BodyCurve>,
|
||||
}
|
||||
|
||||
impl Curves {
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// The curve to render a frame from this body with.
|
||||
///
|
||||
/// Falls back, in order, to the database's `default:` and then to the
|
||||
/// identity. **The default is deliberately not the identity**: an
|
||||
/// unrecognised body rendered flat is the failure this requirement exists
|
||||
/// to prevent, and a gentle, conservative curve is much closer to right for
|
||||
/// every body than no curve is for any of them. A body with its own entry
|
||||
/// gets that instead.
|
||||
///
|
||||
/// # What "this body" has to survive
|
||||
///
|
||||
/// The same camera names itself three ways depending on which program last
|
||||
/// touched the file. A native NEF says make "NIKON CORPORATION", model
|
||||
/// "NIKON Z 6"; rawler's own database cleans that to "Nikon" and "Z 6"; an
|
||||
/// Adobe-converted DNG keeps the uncleaned pair. A database that had to
|
||||
/// spell every variant would go stale the first time a maker changed its
|
||||
/// mind about its own name, so the matching does the folding instead:
|
||||
///
|
||||
/// - Case, punctuation and runs of whitespace are flattened, so
|
||||
/// "ILCE-7M3", "ILCE 7M3" and "ilce-7m3" are one body.
|
||||
/// - The make is compared on its **first word only**. Every maker's
|
||||
/// trailing corporate boilerplate — "CORPORATION", "IMAGING CORP" — is
|
||||
/// noise, and no two camera manufacturers share a first word.
|
||||
/// - The model is tried both as written and with a leading copy of the
|
||||
/// make removed, which is what lets one "Canon"/"EOS 6D" entry cover
|
||||
/// "Canon EOS 6D" as well.
|
||||
pub fn body(&self, make: &str, model: &str) -> BaseCurve {
|
||||
let (make, model) = (make_key(make), normalise(model));
|
||||
// The model with a leading copy of the maker's name removed.
|
||||
let bare = model.strip_prefix(&format!("{make} ")).unwrap_or(&model);
|
||||
|
||||
self.bodies
|
||||
.iter()
|
||||
.find(|b| {
|
||||
let entry_model = normalise(&b.model);
|
||||
make_key(&b.make) == make && (entry_model == model || entry_model == bare)
|
||||
})
|
||||
.map(|b| b.curve)
|
||||
.or(self.default)
|
||||
.unwrap_or(BaseCurve::IDENTITY)
|
||||
}
|
||||
|
||||
/// The database version. Higher wins; see [`load`].
|
||||
pub fn version(&self) -> u32 {
|
||||
self.version
|
||||
}
|
||||
|
||||
/// How many bodies have their own curve, excluding the default.
|
||||
pub fn len(&self) -> usize {
|
||||
self.bodies.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.bodies.is_empty()
|
||||
}
|
||||
|
||||
/// Parse a database from YAML.
|
||||
///
|
||||
/// Entries that are not curves are dropped with a warning rather than
|
||||
/// failing the parse. A user-contributed file with one bad body should
|
||||
/// cost that body's rendering, not every body's — and the alternative is an
|
||||
/// application that will not open a photograph because somebody typed a
|
||||
/// comma.
|
||||
pub fn parse(yaml: &str) -> Result<Self, String> {
|
||||
let file: File = serde_norway::from_str(yaml).map_err(|e| e.to_string())?;
|
||||
|
||||
let default = file.default.and_then(|d| {
|
||||
BaseCurve::from_points(&d.points).or_else(|| {
|
||||
log::warn!("base curves: the default entry is not a monotone curve; ignoring it");
|
||||
None
|
||||
})
|
||||
});
|
||||
|
||||
let bodies = file
|
||||
.bodies
|
||||
.into_iter()
|
||||
.filter_map(|b| match BaseCurve::from_points(&b.points) {
|
||||
Some(curve) => Some(BodyCurve {
|
||||
make: b.make,
|
||||
model: b.model,
|
||||
curve,
|
||||
}),
|
||||
None => {
|
||||
log::warn!(
|
||||
"base curves: {} {} is not a monotone curve; ignoring it",
|
||||
b.make,
|
||||
b.model
|
||||
);
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Self {
|
||||
version: file.version,
|
||||
default,
|
||||
bodies,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The copy that ships inside the binary.
|
||||
///
|
||||
/// A floor, not the answer: [`load`] prefers a newer file on disk. Compiled in
|
||||
/// so that a fresh install with no profile directory — and every Android build,
|
||||
/// where there is no such directory to speak of — still renders properly.
|
||||
const BUILT_IN: &str = include_str!("../profiles/base_curves.yaml");
|
||||
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// The base curve database, loaded once.
|
||||
///
|
||||
/// # The search path, and why it is a version comparison
|
||||
///
|
||||
/// 1. `$DARKROOM_PROFILES`, a directory, when set. The escape hatch: a profile
|
||||
/// author iterating on a curve points this at their working copy and does
|
||||
/// not have to install anything.
|
||||
/// 2. `$XDG_DATA_HOME/darkroom/profiles/`, else `$HOME/.local/share/darkroom/profiles/`.
|
||||
/// The same base directory the catalog uses, chosen there for the same
|
||||
/// reason — it is data, not cache, and must survive a storage sweep.
|
||||
/// 3. The copy compiled into the binary.
|
||||
///
|
||||
/// The first file that parses *and carries a higher `version:` than the
|
||||
/// built-in copy* wins. The version check is the whole mechanism the
|
||||
/// requirement asks for, and it runs in both directions:
|
||||
///
|
||||
/// - A downloaded pack at version 7 supersedes a binary shipping version 3, so
|
||||
/// a body added after the release renders correctly with no release.
|
||||
/// - A stale pack at version 2 does **not** supersede a binary shipping version
|
||||
/// 3, so upgrading the application cannot silently lose curves to a file
|
||||
/// somebody downloaded a year ago and forgot.
|
||||
///
|
||||
/// Failures are warnings, never errors. A malformed profile file must cost the
|
||||
/// user their curves, not their photographs.
|
||||
pub fn load() -> &'static Curves {
|
||||
static LOADED: OnceLock<Curves> = OnceLock::new();
|
||||
LOADED.get_or_init(|| {
|
||||
let built_in = Curves::parse(BUILT_IN).unwrap_or_else(|e| {
|
||||
// Unreachable in a build that ran its tests — `the_shipped_database_parses`
|
||||
// asserts exactly this — but a panic here would mean an
|
||||
// application that cannot open a photograph because of a typo in a
|
||||
// data file, which is never the right trade.
|
||||
log::error!("base curves: the built-in database does not parse: {e}");
|
||||
Curves {
|
||||
version: 0,
|
||||
default: None,
|
||||
bodies: Vec::new(),
|
||||
}
|
||||
});
|
||||
|
||||
choose(built_in, &search_path())
|
||||
})
|
||||
}
|
||||
|
||||
/// The version comparison, separated from where the directories come from.
|
||||
///
|
||||
/// Split out so it can be tested against real files in a real directory
|
||||
/// without the process-wide `OnceLock` and the environment `load` reads. The
|
||||
/// rule this implements is the whole of what FR-DEV-3e asks for, so it is
|
||||
/// worth being able to state it as a test rather than as a comment.
|
||||
fn choose(built_in: Curves, dirs: &[PathBuf]) -> Curves {
|
||||
for dir in dirs {
|
||||
let path = dir.join("base_curves.yaml");
|
||||
let Ok(text) = std::fs::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
match Curves::parse(&text) {
|
||||
Ok(external) if external.version > built_in.version => {
|
||||
log::info!(
|
||||
"base curves: using {} (version {}, {} bodies) over the built-in version {}",
|
||||
path.display(),
|
||||
external.version,
|
||||
external.len(),
|
||||
built_in.version
|
||||
);
|
||||
return external;
|
||||
}
|
||||
Ok(external) => log::info!(
|
||||
"base curves: ignoring {} at version {}; the built-in database is version {}",
|
||||
path.display(),
|
||||
external.version,
|
||||
built_in.version
|
||||
),
|
||||
Err(e) => log::warn!("base curves: {} does not parse: {e}", path.display()),
|
||||
}
|
||||
}
|
||||
built_in
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// The curve for a body, from the loaded database.
|
||||
///
|
||||
/// The one call site the decoder needs; everything above is reachable for
|
||||
/// tests and for a future profile editor.
|
||||
pub fn for_body(make: &str, model: &str) -> BaseCurve {
|
||||
load().body(make, model)
|
||||
}
|
||||
|
||||
/// Directories that may hold a `base_curves.yaml`, most specific first.
|
||||
fn search_path() -> Vec<PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
if let Some(explicit) = std::env::var_os("DARKROOM_PROFILES") {
|
||||
dirs.push(PathBuf::from(explicit));
|
||||
}
|
||||
// The same resolution `dr_ui::library::catalog_path` uses, and for the
|
||||
// same reason: this is data a user may have installed, not a cache. It is
|
||||
// duplicated rather than shared because `dr-decode` sits far below the UI
|
||||
// and must not acquire a dependency on it to find a directory.
|
||||
let base = std::env::var_os("XDG_DATA_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| Path::new(&h).join(".local/share")));
|
||||
if let Some(base) = base {
|
||||
dirs.push(base.join("darkroom").join("profiles"));
|
||||
}
|
||||
dirs
|
||||
}
|
||||
|
||||
/// A manufacturer's first word, folded.
|
||||
///
|
||||
/// "NIKON CORPORATION", "Nikon" and "nikon" all become `NIKON`. The corporate
|
||||
/// suffixes are not information — they appear or not depending on whether the
|
||||
/// file went through a DNG converter — and no two camera manufacturers share a
|
||||
/// first word, so nothing is lost by dropping them.
|
||||
fn make_key(s: &str) -> String {
|
||||
normalise(s)
|
||||
.split(' ')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Fold a make or model into something two files can agree on.
|
||||
///
|
||||
/// Upper-cased, with every run of non-alphanumeric characters collapsed to one
|
||||
/// space and the ends trimmed, so that "ILCE-7M3", "ILCE 7M3" and "ilce-7m3"
|
||||
/// become one.
|
||||
fn normalise(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut pending_space = false;
|
||||
for c in s.chars() {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
if pending_space && !out.is_empty() {
|
||||
out.push(' ');
|
||||
}
|
||||
pending_space = false;
|
||||
out.push(c.to_ascii_uppercase());
|
||||
} else {
|
||||
pending_space = true;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ---- The on-disk shape, kept apart from the in-memory one ----------------
|
||||
//
|
||||
// Deliberately separate types. The file is data a user edits and is allowed to
|
||||
// be wrong; `Curves` is a parsed database whose every entry is known to be a
|
||||
// monotone curve. Deriving `Deserialize` on `BaseCurve` directly would delete
|
||||
// that boundary and let an unchecked five-point array reach the shader.
|
||||
//
|
||||
// Unknown fields are **accepted**, which is not laziness. The database is
|
||||
// versioned independently of the binary and moves in both directions: a pack
|
||||
// published after this release may carry keys this build has never heard of —
|
||||
// a hue twist, a look table (FR-DEV-3f) — and it must still deliver its curves
|
||||
// to an older DarkRoom rather than failing to parse and leaving every body
|
||||
// flat. `deny_unknown_fields` would trade that for a diagnostic nobody needs.
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct File {
|
||||
version: u32,
|
||||
#[serde(default)]
|
||||
default: Option<Entry>,
|
||||
#[serde(default)]
|
||||
bodies: Vec<BodyEntry>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Entry {
|
||||
points: Vec<[f32; 2]>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct BodyEntry {
|
||||
make: String,
|
||||
model: String,
|
||||
points: Vec<[f32; 2]>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_shipped_database_parses_and_carries_a_default() {
|
||||
// The one test that must never be allowed to fail quietly: `load`
|
||||
// degrades to an empty database rather than panicking, so without this
|
||||
// a typo in the YAML would ship as "every photograph renders flat"
|
||||
// rather than as a build failure.
|
||||
let curves = Curves::parse(BUILT_IN).expect("the shipped database parses");
|
||||
assert!(curves.version() >= 1);
|
||||
assert!(!curves.is_empty(), "the database ships bodies");
|
||||
assert!(
|
||||
!curves.body("Nobody", "Nothing").is_identity(),
|
||||
"an unknown body must still get the default rendering"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_shipped_curve_lifts_the_midtones_and_rolls_the_highlights() {
|
||||
// What makes a base curve a base curve rather than a decoration. If a
|
||||
// shipped curve failed either half it would be a worse rendering than
|
||||
// the flat one it replaced, which is the one outcome forbidden.
|
||||
let curves = Curves::parse(BUILT_IN).expect("parses");
|
||||
let all = curves
|
||||
.bodies
|
||||
.iter()
|
||||
.map(|b| (format!("{} {}", b.make, b.model), b.curve))
|
||||
.chain(curves.default.map(|c| ("default".to_string(), c)));
|
||||
|
||||
for (name, curve) in all {
|
||||
// The midtone point sits above the diagonal: a linear midtone is
|
||||
// roughly a stop and a half darker than any camera renders it.
|
||||
let mid = 2;
|
||||
assert!(
|
||||
curve.ys[mid] > curve.xs[mid],
|
||||
"{name} does not lift its midtones ({} -> {})",
|
||||
curve.xs[mid],
|
||||
curve.ys[mid]
|
||||
);
|
||||
// And the last span is shallower than the one before it, which is
|
||||
// what a shoulder *is*. Without one the curve clips highlights
|
||||
// harder than the linear rendering did.
|
||||
let slope =
|
||||
|i: usize| (curve.ys[i + 1] - curve.ys[i]) / (curve.xs[i + 1] - curve.xs[i]);
|
||||
assert!(
|
||||
slope(POINTS - 2) < slope(POINTS - 3),
|
||||
"{name} has no highlight shoulder"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_curve_that_is_not_monotone_is_refused() {
|
||||
// The profile file is user-editable, so this is a real boundary and
|
||||
// not a formality. A decreasing y inverts tones locally and shows up
|
||||
// as a dark halo in a gradient, which reads as a rendering fault
|
||||
// rather than as a bad profile.
|
||||
assert_eq!(
|
||||
BaseCurve::from_points(&[[0.0, 0.0], [0.25, 0.4], [0.5, 0.3], [0.75, 0.8], [1.0, 1.0]]),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_curve_whose_x_does_not_advance_is_refused() {
|
||||
// The spline divides by the span width; a repeated x is a division by
|
||||
// zero in the shader, which is a NaN pixel rather than an error.
|
||||
assert_eq!(
|
||||
BaseCurve::from_points(&[
|
||||
[0.0, 0.0],
|
||||
[0.25, 0.3],
|
||||
[0.25, 0.5],
|
||||
[0.75, 0.8],
|
||||
[1.0, 1.0]
|
||||
]),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_curve_of_the_wrong_length_is_refused() {
|
||||
assert_eq!(BaseCurve::from_points(&[[0.0, 0.0], [1.0, 1.0]]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_outside_the_unit_square_are_refused() {
|
||||
// The shader clamps its output at the very end anyway, but a control
|
||||
// point above 1.0 would put the shoulder outside the range the curve
|
||||
// is defined over and silently flatten everything below it.
|
||||
assert_eq!(
|
||||
BaseCurve::from_points(&[[0.0, 0.0], [0.25, 0.3], [0.5, 1.4], [0.75, 1.5], [1.0, 1.6]]),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_body_with_its_own_entry_beats_the_default() {
|
||||
let curves = Curves::parse(
|
||||
"version: 2
|
||||
default:
|
||||
points: [[0.0, 0.0], [0.25, 0.3], [0.5, 0.6], [0.75, 0.85], [1.0, 1.0]]
|
||||
bodies:
|
||||
- make: Canon
|
||||
model: EOS 6D
|
||||
points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
|
||||
",
|
||||
)
|
||||
.expect("parses");
|
||||
|
||||
assert_eq!(curves.body("Canon", "EOS 6D").ys[1], 0.35);
|
||||
assert_eq!(curves.body("Canon", "EOS 5D").ys[1], 0.30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_make_may_be_repeated_in_the_model() {
|
||||
// Canon writes "Canon" as the make and "Canon EOS 6D" as the model;
|
||||
// rawler's cleaned strings drop the repetition and both reach here.
|
||||
// One entry has to cover both or half the files on a card miss.
|
||||
let curves = Curves::parse(
|
||||
"version: 1
|
||||
bodies:
|
||||
- make: Canon
|
||||
model: EOS 6D
|
||||
points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
|
||||
",
|
||||
)
|
||||
.expect("parses");
|
||||
|
||||
assert_eq!(curves.body("Canon", "Canon EOS 6D").ys[1], 0.35);
|
||||
assert_eq!(curves.body("Canon", "EOS 6D").ys[1], 0.35);
|
||||
assert_eq!(curves.body("CANON", "eos 6d").ys[1], 0.35);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_corporate_suffix_does_not_hide_a_body() {
|
||||
// The same Z 6 arrives as "Nikon"/"Z 6" from rawler's camera database
|
||||
// and as "NIKON CORPORATION"/"NIKON Z 6" from a DNG converted out of
|
||||
// the same file. Both must find the entry, or converting a file to
|
||||
// DNG would silently change how it renders.
|
||||
let curves = Curves::parse(
|
||||
"version: 1
|
||||
bodies:
|
||||
- make: Nikon
|
||||
model: Z 6
|
||||
points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
|
||||
",
|
||||
)
|
||||
.expect("parses");
|
||||
|
||||
assert_eq!(curves.body("Nikon", "Z 6").ys[1], 0.35);
|
||||
assert_eq!(curves.body("NIKON CORPORATION", "NIKON Z 6").ys[1], 0.35);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn punctuation_and_spacing_do_not_decide_whether_a_body_is_known() {
|
||||
let curves = Curves::parse(
|
||||
"version: 1
|
||||
bodies:
|
||||
- make: Sony
|
||||
model: ILCE-7M3
|
||||
points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
|
||||
",
|
||||
)
|
||||
.expect("parses");
|
||||
|
||||
assert_eq!(curves.body("SONY", "ILCE 7M3").ys[1], 0.35);
|
||||
assert_eq!(curves.body("sony", "ilce-7m3").ys[1], 0.35);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_bad_entry_does_not_cost_the_rest() {
|
||||
// A user-contributed file with one typo should cost that body's
|
||||
// rendering, not every body's.
|
||||
let curves = Curves::parse(
|
||||
"version: 1
|
||||
bodies:
|
||||
- make: Broken
|
||||
model: Body
|
||||
points: [[0.0, 0.0], [0.25, 0.9], [0.5, 0.1], [0.75, 0.9], [1.0, 1.0]]
|
||||
- make: Canon
|
||||
model: EOS 6D
|
||||
points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
|
||||
",
|
||||
)
|
||||
.expect("parses");
|
||||
|
||||
assert_eq!(curves.len(), 1);
|
||||
assert_eq!(curves.body("Canon", "EOS 6D").ys[1], 0.35);
|
||||
assert!(curves.body("Broken", "Body").is_identity());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pack_from_the_future_still_delivers_its_curves() {
|
||||
// The database is versioned independently of the binary, so a pack
|
||||
// published after this build may carry keys this build has never heard
|
||||
// of. It must still hand over the curves it does understand — failing
|
||||
// the parse would leave every body flat, which is the exact failure
|
||||
// FR-DEV-3e exists to prevent, delivered by the mechanism meant to
|
||||
// prevent it.
|
||||
let curves = Curves::parse(
|
||||
"version: 9
|
||||
look_table: ambitious
|
||||
bodies:
|
||||
- make: Canon
|
||||
model: EOS 6D
|
||||
hue_twist: [1, 2, 3]
|
||||
points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
|
||||
",
|
||||
)
|
||||
.expect("an unfamiliar key must not fail the parse");
|
||||
|
||||
assert_eq!(curves.version(), 9);
|
||||
assert_eq!(curves.body("Canon", "EOS 6D").ys[1], 0.35);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_body_with_no_default_gets_the_identity() {
|
||||
// Graceful fallback, stated as a property: never worse than a flat
|
||||
// render, and never a curve tuned for somebody else's sensor when the
|
||||
// database declines to offer one.
|
||||
let curves = Curves::parse("version: 1\nbodies: []\n").expect("parses");
|
||||
assert!(curves.body("Nobody", "Nothing").is_identity());
|
||||
}
|
||||
|
||||
/// A directory holding one `base_curves.yaml`, unique to the caller.
|
||||
fn a_pack_dir(name: &str, yaml: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("darkroom-base-curves-{name}"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("a writable temp directory");
|
||||
std::fs::write(dir.join("base_curves.yaml"), yaml).expect("write");
|
||||
dir
|
||||
}
|
||||
|
||||
const A_CANON_ENTRY: &str = "bodies:
|
||||
- make: Canon
|
||||
model: EOS 6D
|
||||
points: [[0.0, 0.0], [0.25, 0.42], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
|
||||
";
|
||||
|
||||
#[test]
|
||||
fn a_newer_pack_on_disk_supersedes_the_built_in_database() {
|
||||
// **This is the requirement.** FR-DEV-3e asks for a profile database
|
||||
// versioned independently of the app binary "so bodies and curves can
|
||||
// be added without a release". A file with a higher version, dropped
|
||||
// in the profile directory, is what that means in practice.
|
||||
let built_in = Curves::parse(BUILT_IN).expect("parses");
|
||||
let newer = format!("version: {}\n{A_CANON_ENTRY}", built_in.version() + 1);
|
||||
let dir = a_pack_dir("newer", &newer);
|
||||
|
||||
let chosen = choose(built_in.clone(), &[dir]);
|
||||
assert_eq!(chosen.version(), built_in.version() + 1);
|
||||
assert_eq!(chosen.body("Canon", "EOS 6D").ys[1], 0.42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_pack_does_not_survive_an_upgrade() {
|
||||
// The other direction, and the one that protects the user. Somebody
|
||||
// downloads a pack, a release later ships better curves for the same
|
||||
// bodies, and the forgotten file must not quietly hold the application
|
||||
// back at last year's rendering.
|
||||
let built_in = Curves::parse(BUILT_IN).expect("parses");
|
||||
let stale = format!("version: {}\n{A_CANON_ENTRY}", built_in.version());
|
||||
let dir = a_pack_dir("stale", &stale);
|
||||
|
||||
let chosen = choose(built_in.clone(), &[dir]);
|
||||
assert_eq!(chosen.version(), built_in.version());
|
||||
assert_ne!(
|
||||
chosen.body("Canon", "EOS 6D").ys[1],
|
||||
0.42,
|
||||
"an equal version must not displace the built-in database"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_pack_costs_the_curves_and_not_the_photographs() {
|
||||
// A malformed profile file must degrade to the built-in database, not
|
||||
// to an error. The user came here to look at a photograph.
|
||||
let built_in = Curves::parse(BUILT_IN).expect("parses");
|
||||
let dir = a_pack_dir("broken", "version: [this is not a number\n");
|
||||
|
||||
let chosen = choose(built_in.clone(), &[dir]);
|
||||
assert_eq!(chosen.version(), built_in.version());
|
||||
assert_eq!(chosen.len(), built_in.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_directory_with_no_pack_in_it_is_simply_skipped() {
|
||||
// The ordinary case on every machine: the search path exists, the file
|
||||
// does not. It must not be a warning, an error, or a slow path.
|
||||
let built_in = Curves::parse(BUILT_IN).expect("parses");
|
||||
let missing = std::env::temp_dir().join("darkroom-base-curves-nothing-here");
|
||||
let _ = std::fs::remove_dir_all(&missing);
|
||||
|
||||
assert_eq!(choose(built_in.clone(), &[missing]), built_in);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_identity_is_recognised_as_doing_nothing() {
|
||||
assert!(BaseCurve::IDENTITY.is_identity());
|
||||
assert!(!Curves::parse(BUILT_IN)
|
||||
.expect("parses")
|
||||
.body("Canon", "EOS 6D")
|
||||
.is_identity());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! TRACES: FR-RAW-2
|
||||
//! The seam a second decoder plugs into.
|
||||
//!
|
||||
//! D2 keeps LibRaw as the fallback for bodies rawler does not cover. Adding
|
||||
//! it later should be a new `impl Decoder`, not an edit to every caller that
|
||||
//! reads a header, cuts a thumbnail or opens a photograph for export — which
|
||||
//! is what the free functions alone would have made it. So the callers take a
|
||||
//! `&dyn Decoder`, and only the places that start a job name [`default`].
|
||||
//!
|
||||
//! Bytes in, always. Nothing here takes a path or a `SourceRef`: resolving a
|
||||
//! file to bytes is `Storage`'s job at the caller, so the same decoder serves a
|
||||
//! local file, an Android document and a range fetched from Nextcloud. The
|
||||
//! decoder's part in that is to say how much of a file it needs
|
||||
//! ([`Decoder::header_bytes`]) and where its preview sits
|
||||
//! ([`Decoder::locate_preview`]); the storage layer fetches exactly that.
|
||||
//!
|
||||
//! What stays a free function is what is not a decoder's to vary: recognising
|
||||
//! a JPEG ([`crate::probe`]), decoding one ([`crate::decode_jpeg`]) and
|
||||
//! checking one is whole ([`crate::is_complete_jpeg`]). A second RAW decoder
|
||||
//! would not read a JPEG differently.
|
||||
|
||||
use dr_types::Orientation;
|
||||
|
||||
use crate::{DecodeError, Metadata, Preview, PreviewLocation, PreviewSize, RawImage};
|
||||
|
||||
/// TRACES: FR-RAW-2
|
||||
/// A RAW decoder, over bytes.
|
||||
///
|
||||
/// Object-safe so a caller can hold `&dyn Decoder` without becoming generic,
|
||||
/// `Send + Sync` because the callers that need one most — the thumbnail
|
||||
/// lanes, the export worker — run off the UI thread, and `Debug` so a job
|
||||
/// description that carries one can still be printed.
|
||||
pub trait Decoder: Send + Sync + std::fmt::Debug {
|
||||
/// How much of the start of a file [`Self::metadata`] and
|
||||
/// [`Self::locate_preview`] need. A caller reading over a network fetches
|
||||
/// this range and no more.
|
||||
fn header_bytes(&self) -> u64;
|
||||
|
||||
/// Capture metadata, from a header or a whole file, without touching
|
||||
/// sensor data.
|
||||
fn metadata(&self, bytes: &[u8]) -> Result<Metadata, DecodeError>;
|
||||
|
||||
/// How the stored pixels are turned, from a header. `None` where the file
|
||||
/// does not say, which callers take as upright.
|
||||
fn orientation(&self, header: &[u8]) -> Option<Orientation>;
|
||||
|
||||
/// Where the embedded preview best suited to a thumbnail sits in the file,
|
||||
/// from its header, so a remote caller can fetch that range alone.
|
||||
fn locate_preview(&self, header: &[u8], file_len: u64) -> Option<PreviewLocation>;
|
||||
|
||||
/// The embedded preview at the size asked for, falling through the ladder
|
||||
/// to the next size where the file lacks it.
|
||||
fn preview(&self, bytes: &[u8], size: PreviewSize) -> Result<Preview, DecodeError>;
|
||||
|
||||
/// Sensor data, for develop and export. The expensive path.
|
||||
fn decode(&self, bytes: &[u8]) -> Result<RawImage, DecodeError>;
|
||||
}
|
||||
|
||||
/// TRACES: FR-RAW-2
|
||||
/// The decoder the application ships: rawler for sensor data and the
|
||||
/// previews it knows, DarkRoom's own container walk for headers and ranges.
|
||||
///
|
||||
/// Its methods are the crate's free functions, unchanged. They stay public
|
||||
/// for the tools and examples that read one file and have no caller to keep
|
||||
/// decoder-agnostic.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct Rawler;
|
||||
|
||||
impl Decoder for Rawler {
|
||||
fn header_bytes(&self) -> u64 {
|
||||
crate::HEADER_BYTES
|
||||
}
|
||||
|
||||
fn metadata(&self, bytes: &[u8]) -> Result<Metadata, DecodeError> {
|
||||
crate::metadata(bytes)
|
||||
}
|
||||
|
||||
fn orientation(&self, header: &[u8]) -> Option<Orientation> {
|
||||
crate::orientation(header)
|
||||
}
|
||||
|
||||
fn locate_preview(&self, header: &[u8], file_len: u64) -> Option<PreviewLocation> {
|
||||
crate::locate_preview(header, file_len)
|
||||
}
|
||||
|
||||
fn preview(&self, bytes: &[u8], size: PreviewSize) -> Result<Preview, DecodeError> {
|
||||
crate::extract_preview(bytes, size)
|
||||
}
|
||||
|
||||
fn decode(&self, bytes: &[u8]) -> Result<RawImage, DecodeError> {
|
||||
crate::decode(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-RAW-2
|
||||
/// The decoder a job uses unless it was handed another.
|
||||
///
|
||||
/// Named by the places that start work — a thread, a UI handler — and by
|
||||
/// nothing below them. Returning `&'static dyn Decoder` rather than `Rawler`
|
||||
/// is the point: a caller that only has this cannot reach past the trait.
|
||||
pub fn default() -> &'static dyn Decoder {
|
||||
static RAWLER: Rawler = Rawler;
|
||||
&RAWLER
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The default is the shipped decoder, reached through the trait: same
|
||||
/// header budget, and the same answer to bytes neither can read.
|
||||
#[test]
|
||||
fn the_default_is_rawler_behind_the_trait() {
|
||||
let d = default();
|
||||
assert_eq!(d.header_bytes(), crate::HEADER_BYTES);
|
||||
let junk = [0u8; 64];
|
||||
assert_eq!(d.metadata(&junk).is_err(), crate::metadata(&junk).is_err());
|
||||
assert!(d.decode(&junk).is_err());
|
||||
assert_eq!(d.orientation(&junk), crate::orientation(&junk));
|
||||
}
|
||||
}
|
||||
@@ -11,14 +11,18 @@
|
||||
//!
|
||||
//! Fusing them would force a full decode where a header read suffices, which
|
||||
//! is exactly why Lightroom stalls ~2 s per image during culling.
|
||||
//!
|
||||
//! Callers reach these through the [`Decoder`] trait rather than by name, so a
|
||||
//! second decoder can be put behind them without changing any of them
|
||||
//! (FR-RAW-2). [`Rawler`] is the one that ships; [`default`] hands it out.
|
||||
|
||||
pub mod base_curve;
|
||||
mod decoder;
|
||||
mod error;
|
||||
mod locate;
|
||||
mod preview;
|
||||
pub mod profile;
|
||||
|
||||
pub use base_curve::BaseCurve;
|
||||
pub use decoder::{default, Decoder, Rawler};
|
||||
pub use error::DecodeError;
|
||||
pub use locate::{
|
||||
defects, is_complete_jpeg, jpeg_metadata, locate_preview, tiff_metadata, BadLine, BadPixel,
|
||||
@@ -119,19 +123,6 @@ pub struct RawImage {
|
||||
/// for the light the frame was shot under; see [`profile::CameraProfile`].
|
||||
pub color_matrix: Option<[f32; 9]>,
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// The per-body rendering curve, the other half of the camera profile.
|
||||
///
|
||||
/// The matrix above decides what the colours *are*; this decides what the
|
||||
/// picture looks like. Carried on the decoded image rather than looked up
|
||||
/// downstream because this is the only point in the system that knows
|
||||
/// which body took the frame, and because it is not an edit: it belongs to
|
||||
/// the file in the same way the masked-photosite crop does, and must never
|
||||
/// reach a sidecar (FR-NC-9).
|
||||
///
|
||||
/// [`BaseCurve::IDENTITY`] for an unknown body with no default in the
|
||||
/// database, which renders exactly as this decoder did before profiles
|
||||
/// existed.
|
||||
pub base_curve: BaseCurve,
|
||||
/// The usable region of `data`, excluding masked and border photosites.
|
||||
pub crop: CropRect,
|
||||
/// TRACES: FR-MRG-3
|
||||
@@ -586,15 +577,6 @@ fn decode_unguarded(bytes: &[u8]) -> Result<RawImage, DecodeError> {
|
||||
profile.as_ref().map(|p| p.xyz_to_cam()).as_ref(),
|
||||
);
|
||||
|
||||
// The rendering half of the profile (FR-DEV-3e). rawler's cleaned strings
|
||||
// are preferred where it has them — they are what the shipped database is
|
||||
// written against — and the matching folds the variants either way, so a
|
||||
// DNG naming the same body differently still finds its curve.
|
||||
let base_curve = base_curve::for_body(
|
||||
image.camera.clean_make.as_str(),
|
||||
image.camera.clean_model.as_str(),
|
||||
);
|
||||
|
||||
// TRACES: FR-MRG-3
|
||||
// A linear DNG — three samples per pixel, no colour filter array — is a
|
||||
// composite this application wrote (or any other demosaiced DNG). It
|
||||
@@ -677,7 +659,6 @@ fn decode_unguarded(bytes: &[u8]) -> Result<RawImage, DecodeError> {
|
||||
.unwrap_or(u16::MAX),
|
||||
wb_coeffs,
|
||||
color_matrix,
|
||||
base_curve,
|
||||
samples_per_pixel,
|
||||
profile,
|
||||
make: image.camera.clean_make.clone(),
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
//! colour needs two things the file cannot supply on its own: a **matrix**
|
||||
//! saying how this sensor's three responses relate to the CIE observer, and a
|
||||
//! **rendering** saying what to do with the resulting scene-referred values so
|
||||
//! that a photograph looks like a photograph. This module supplies the first
|
||||
//! and looks up the second ([`crate::base_curve`]).
|
||||
//! that a photograph looks like a photograph. This module supplies the first.
|
||||
//! The second is not the body's: since D19 it is the pipeline's view
|
||||
//! transform (FR-DEV-3j), one for every camera, and the per-body base curves
|
||||
//! that used to be looked up here are retired.
|
||||
//!
|
||||
//! # What is extracted, and from where
|
||||
//!
|
||||
@@ -65,8 +67,8 @@
|
||||
//! FR-DEV-3e defers full `.dcp` support — `HueSatDeltas` and
|
||||
//! `ProfileLookTable` — and requires that they arrive as *additions* rather
|
||||
//! than as a pipeline reordering. They would: both are lookups applied to a
|
||||
//! colour after this matrix and before, or alongside, the base curve, so they
|
||||
//! extend [`CameraProfile`] with more calibration data and extend the shader's
|
||||
//! colour at this matrix, before any edit reaches it, so they extend
|
||||
//! [`CameraProfile`] with more calibration data and extend the shader's
|
||||
//! camera-profile stage with more work. Nothing above would move.
|
||||
|
||||
use crate::{cam_to_srgb_from, invert3};
|
||||
|
||||
@@ -11,7 +11,7 @@ log.workspace = true
|
||||
|
||||
# Inference. `ort` is the API; **what runs it is `dr-inference-engine`'s
|
||||
# business** — tract, or an ONNX Runtime the app found on disk, on whichever
|
||||
# provider the device has (docs/inference.md). This crate never names either.
|
||||
# provider the device has (docs/dev/inference.md). This crate never names either.
|
||||
ort = { workspace = true, optional = true }
|
||||
dr-inference-engine = { workspace = true, optional = true }
|
||||
ndarray = { workspace = true, optional = true }
|
||||
@@ -37,7 +37,7 @@ required-features = ["inference"]
|
||||
|
||||
[features]
|
||||
# Nothing on by default, and in particular **no `embedded-model`**: the weights
|
||||
# are not a build input and never become one (docs/faces.md §2.2). A feature
|
||||
# are not a build input and never become one (docs/dev/faces.md §2.2). A feature
|
||||
# flag that *could* embed them is a flag someone eventually sets in a packaging
|
||||
# script, and the InsightFace grant does not survive that.
|
||||
default = []
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Detect the faces in a JPEG and read each one's eyes (docs/faces.md §17).
|
||||
//! Detect the faces in a JPEG and read each one's eyes (docs/dev/faces.md §17).
|
||||
//!
|
||||
//! The thing worth looking at is whether the eye boxes land on eyes and
|
||||
//! whether soft ones are refused — so with `--dump DIR` the crops the
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! DET.onnx EMB.onnx photo.jpg [photo.jpg ...]
|
||||
//!
|
||||
//! The models must have had their input dims frozen first; see
|
||||
//! `tools/fix-face-model-shapes.sh` and docs/faces.md §12 M1.
|
||||
//! `tools/fix-face-model-shapes.sh` and docs/dev/faces.md §12 M1.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! M1 (docs/faces.md §12) — will tract load these graphs at all?
|
||||
//! M1 (docs/dev/faces.md §12) — will tract load these graphs at all?
|
||||
//!
|
||||
//! The one measurement everything else in the face subsystem is conditional
|
||||
//! on. `det_500m.onnx` has a dynamic H/W input, which is exactly what tract
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//!
|
||||
//! # What it is for
|
||||
//!
|
||||
//! docs/faces.md §9 has the desktop numbers and the question they leave open:
|
||||
//! docs/dev/faces.md §9 has the desktop numbers and the question they leave open:
|
||||
//! a GPU GEMM is worth roughly 1.5× of a regroup on a twenty-core desktop,
|
||||
//! because the scan is under a third of the pass there. On a tablet the CPU is
|
||||
//! several times slower and the GPU is not, so the same optimisation is worth
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Five-point face alignment (docs/faces.md §5).
|
||||
//! Five-point face alignment (docs/dev/faces.md §5).
|
||||
//!
|
||||
//! ArcFace embeddings are trained on faces warped to a canonical 112×112
|
||||
//! arrangement. Feeding the model a plain bounding-box crop *works* — it
|
||||
@@ -208,7 +208,7 @@ impl Similarity {
|
||||
///
|
||||
/// # Why least squares and not RANSAC
|
||||
///
|
||||
/// The reference C++ implementation (docs/faces.md §1.1) fits this with
|
||||
/// The reference C++ implementation (docs/dev/faces.md §1.1) fits this with
|
||||
/// OpenCV's `estimateAffinePartial2D` under RANSAC. RANSAC over five points is
|
||||
/// a strange fit: the minimal sample for a similarity is two, so it can discard
|
||||
/// landmarks it judges outliers and solve from a subset — and on a profile face
|
||||
@@ -427,7 +427,7 @@ fn sample_window(
|
||||
// ── eyes ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Width of an eye crop as the classifier reads it, in pixels. Fixed by the
|
||||
/// OCEC input (`docs/faces.md` §17): 40 wide, 24 high.
|
||||
/// OCEC input (`docs/dev/faces.md` §17): 40 wide, 24 high.
|
||||
pub const EYE_PATCH_WIDTH: usize = 40;
|
||||
/// Height of an eye crop as the classifier reads it, in pixels.
|
||||
pub const EYE_PATCH_HEIGHT: usize = 24;
|
||||
@@ -438,7 +438,7 @@ pub const EYE_PATCH_HEIGHT: usize = 24;
|
||||
/// The classifier was trained on a whole-body detector's *eye* boxes — tight
|
||||
/// round the palpebral fissure — and measured on 25 open-eyed faces from the
|
||||
/// reference library, a tight box is what it wants: 22 of 25 read open at
|
||||
/// 0 and 0.1, 18 at 0.4, 14 at 0.6 (docs/faces.md §17.2). A tenth, so a
|
||||
/// 0 and 0.1, 18 at 0.4, 14 at 0.6 (docs/dev/faces.md §17.2). A tenth, so a
|
||||
/// contour landing a pixel short of the lashes still holds them.
|
||||
pub const EYE_BOX_MARGIN: f32 = 0.1;
|
||||
|
||||
@@ -571,7 +571,7 @@ pub const SUNGLASSES_EDGE: usize = 48;
|
||||
/// clear glasses, at 0.68. Erring towards "sunglasses" is the safe direction
|
||||
/// for what this feeds: a face called sunglasses is left alone by the
|
||||
/// eyes-open filter, where a pair of sunglasses missed hands the eye
|
||||
/// classifier a lens to guess at (docs/faces.md §17).
|
||||
/// classifier a lens to guess at (docs/dev/faces.md §17).
|
||||
pub const SUNGLASSES_WINDOWS: [(f32, f32, f32, f32); 2] =
|
||||
[(0.0, 0.0, 112.0, 112.0), (-5.0, -14.0, 122.0, 122.0)];
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Cosine to probability (docs/faces.md §8, FR-CULL-9).
|
||||
//! Cosine to probability (docs/dev/faces.md §8, FR-CULL-9).
|
||||
//!
|
||||
//! FR-CULL-9 is a hard requirement rather than an implementation detail: no
|
||||
//! code path may threshold a bare cosine, every threshold in the subsystem is
|
||||
@@ -28,7 +28,7 @@
|
||||
//! calibration to the belief it was supposed to test — and that is the whole
|
||||
//! of the alternative.
|
||||
//!
|
||||
//! docs/faces.md §8.1 names one more that would cost no labelling at all: two
|
||||
//! docs/dev/faces.md §8.1 names one more that would cost no labelling at all: two
|
||||
//! faces in adjacent frames of one burst are near-certainly the same person,
|
||||
//! and FR-CULL-5's grouping is sitting there. Nothing draws on it. This crate
|
||||
//! cannot see a catalog, let alone the bursts in one — it is handed cosines by
|
||||
@@ -83,7 +83,7 @@ pub struct Calibration {
|
||||
}
|
||||
|
||||
impl Default for Calibration {
|
||||
/// The reference implementation's fitted MBF curve (docs/faces.md §1):
|
||||
/// The reference implementation's fitted MBF curve (docs/dev/faces.md §1):
|
||||
/// steepness 16.2, P=0.5 at cosine 0.267.
|
||||
///
|
||||
/// **`valid` is false**, and that is the point. It is a documented
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! TRACES: FR-CULL-8a
|
||||
//! The two small classifiers behind a face's eye state (docs/faces.md §17).
|
||||
//! The two small classifiers behind a face's eye state (docs/dev/faces.md §17).
|
||||
//!
|
||||
//! **OCEC** — *open closed eyes classification*, Hyodo 2025 — reads one
|
||||
//! 40×24 eye and answers P(open). **SGC** — *sunglasses classification*,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Grouping faces into people (docs/faces.md §9, FR-CULL-10).
|
||||
//! Grouping faces into people (docs/dev/faces.md §9, FR-CULL-10).
|
||||
//!
|
||||
//! Model-free: this is arithmetic over embeddings, and it is where the
|
||||
//! subsystem's accuracy actually lives, so it is testable with no weights on
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! SCRFD face detection (docs/faces.md §4).
|
||||
//! SCRFD face detection (docs/dev/faces.md §4).
|
||||
//!
|
||||
//! One forward pass produces a box, a confidence and **five landmarks** per
|
||||
//! face — the landmarks being the reason for this detector rather than a
|
||||
@@ -138,7 +138,7 @@ impl Detection {
|
||||
pub struct Detector {
|
||||
session: Model,
|
||||
/// f32 or int8 — the int8 form finds a different set of faces and is a
|
||||
/// different detector in `model_id` (docs/inference.md §7).
|
||||
/// different detector in `model_id` (docs/dev/inference.md §7).
|
||||
form: Form,
|
||||
/// Feature-map count: 3 for strides {8,16,32}, 4 for {8,16,32,64}.
|
||||
///
|
||||
@@ -346,7 +346,7 @@ fn iou(a: &(f32, f32, f32, f32), b: &(f32, f32, f32, f32)) -> f32 {
|
||||
/// How the image is fitted into the graph's fixed square input.
|
||||
///
|
||||
/// The forward and inverse mappings live in one struct on purpose:
|
||||
/// docs/faces.md §4.1 notes that what matters is not *where* the padding goes
|
||||
/// docs/dev/faces.md §4.1 notes that what matters is not *where* the padding goes
|
||||
/// but that the two agree. A mismatch offsets every box and landmark by the
|
||||
/// padding, producing detections that look plausible and embeddings that
|
||||
/// quietly cluster badly three stages later.
|
||||
@@ -372,7 +372,7 @@ impl Letterbox {
|
||||
///
|
||||
/// `(x·255 − 127.5) / 128` — note `/128`, not `/127.5`. The reference
|
||||
/// implementation this is ported from uses `/128` for both models, and
|
||||
/// every measured number in docs/faces.md §1 came from it.
|
||||
/// every measured number in docs/dev/faces.md §1 came from it.
|
||||
///
|
||||
/// Padding is grey, matching the reference's `114`: the value the network
|
||||
/// reads least as an edge, where black would draw a hard border across the
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! ArcFace / MobileFaceNet inference (docs/faces.md §6).
|
||||
//! ArcFace / MobileFaceNet inference (docs/dev/faces.md §6).
|
||||
//!
|
||||
//! Takes an aligned crop and returns 512 L2-normalised floats. The alignment is
|
||||
//! not optional and cannot be skipped by accident: [`Embedder::embed`] takes an
|
||||
@@ -66,7 +66,7 @@ impl Embedder {
|
||||
|
||||
pub fn from_bytes(bytes: &[u8], model: ModelId) -> Result<Self, FaceError> {
|
||||
// Always the f32 form: an embedding must compare across devices
|
||||
// (docs/inference.md §7), and the engine pins this role to it.
|
||||
// (docs/dev/inference.md §7), and the engine pins this role to it.
|
||||
let loaded = dr_inference_engine::open(Role::Embedder, Form::F32, bytes)?;
|
||||
let acquired = loaded.acquire()?;
|
||||
let session = acquired.lock();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! What an embedder produces, and how it is stored (docs/faces.md §6).
|
||||
//! What an embedder produces, and how it is stored (docs/dev/faces.md §6).
|
||||
//!
|
||||
//! Deliberately **model-free**: the vector, its identity, its comparison and
|
||||
//! its storage encoding are arithmetic, and `calibrate` and `cluster` are built
|
||||
@@ -273,7 +273,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The claim docs/faces.md §6 makes about the storage format: the f16
|
||||
/// The claim docs/dev/faces.md §6 makes about the storage format: the f16
|
||||
/// round-trip costs ~1e-3 of cosine, three orders below the separation
|
||||
/// between a match and a non-match.
|
||||
#[test]
|
||||
|
||||
@@ -67,14 +67,14 @@ pub const SUNGLASSES_THRESHOLD: f32 = 0.5;
|
||||
/// The classifier was trained on eyes down to about a dozen pixels wide
|
||||
/// (its reference footage averaged 15–21); below that the 40-pixel patch is
|
||||
/// an interpolation of nothing, and the answer is noise that reads as
|
||||
/// "closed". docs/faces.md §17.3 has the measurement behind the number.
|
||||
/// "closed". docs/dev/faces.md §17.3 has the measurement behind the number.
|
||||
pub const MIN_EYE_PX: f32 = 12.0;
|
||||
|
||||
/// Least [`Eye::sharpness`] for the eye to be read.
|
||||
///
|
||||
/// The same measure as the face's `min_sharpness`, over the eye patch, and
|
||||
/// chosen the same way: the value under which the open-eyed faces of the
|
||||
/// reference sample were being called closed. docs/faces.md §17.3.
|
||||
/// reference sample were being called closed. docs/dev/faces.md §17.3.
|
||||
pub const MIN_EYE_SHARPNESS: f32 = 0.02;
|
||||
|
||||
/// An eye narrower than this fraction of its partner is the far eye of a
|
||||
@@ -82,7 +82,7 @@ pub const MIN_EYE_SHARPNESS: f32 = 0.02;
|
||||
///
|
||||
/// A landmark model's contour for a hidden eye collapses towards the nose.
|
||||
/// Measured on twenty native renders of the reference library
|
||||
/// (docs/faces.md §17.4): profiles put the far eye at 0.02–0.43 of the near
|
||||
/// (docs/dev/faces.md §17.4): profiles put the far eye at 0.02–0.43 of the near
|
||||
/// one, two three-quarter faces whose far eye read closed sat at 0.54, and
|
||||
/// every face looking at the camera — winks included, since a shut eye's
|
||||
/// box keeps its width — sat at 0.78 or more. 0.6 splits the gap.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! TRACES: FR-CULL-8a
|
||||
//! Dense facial landmarks — InsightFace's `2d106det` (docs/faces.md §17.2).
|
||||
//! Dense facial landmarks — InsightFace's `2d106det` (docs/dev/faces.md §17.2).
|
||||
//!
|
||||
//! SCRFD's five points place a face; they do not place an eye. Its eye
|
||||
//! point is loose enough that a window centred on it left the eye in a
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Faces and identity (S14, docs/faces.md).
|
||||
//! Faces and identity (S14, docs/dev/faces.md).
|
||||
//!
|
||||
//! Two models, run over the native render, producing per face a box, five
|
||||
//! landmarks, a confidence and a 512-d embedding (FR-CULL-8) — and then the
|
||||
@@ -21,9 +21,9 @@
|
||||
//! for a packaging script to switch on. The application obtains a model at
|
||||
//! runtime; this crate takes bytes and never fetches anything.
|
||||
//!
|
||||
//! docs/faces.md §2 is the full reading, including what would have to change
|
||||
//! docs/dev/faces.md §2 is the full reading, including what would have to change
|
||||
//! for that to stop being true. The eye-state models are the exception: MIT,
|
||||
//! weights and all, and shipped in `models/face/` (docs/faces.md §17).
|
||||
//! weights and all, and shipped in `models/face/` (docs/dev/faces.md §17).
|
||||
//!
|
||||
//! # Why the runtime is split behind a feature
|
||||
//!
|
||||
@@ -55,7 +55,7 @@ pub mod references;
|
||||
/// Smallest long edge a face crop may be sampled from.
|
||||
///
|
||||
/// **A floor on the crop source, not on the detector input.** The distinction
|
||||
/// is the whole of FR-CULL-8 and `docs/faces.md` §7: detection letterboxes
|
||||
/// is the whole of FR-CULL-8 and `docs/dev/faces.md` §7: detection letterboxes
|
||||
/// every buffer into 640×640, so its input resolution decides nothing, while
|
||||
/// [`warp`] samples the 112×112 the embedder sees and so converts source
|
||||
/// resolution directly into embedding quality. FR-CULL-8 requires that crop to
|
||||
|
||||
@@ -115,7 +115,7 @@ pub struct Faces<'a> {
|
||||
/// Source pixels across the aligned crop, for the calibration's size term.
|
||||
pub crop_px: &'a [f32],
|
||||
/// Which photograph each face came from. Two faces in one frame are not
|
||||
/// the same person, so those pairs are never returned (docs/faces.md §9).
|
||||
/// the same person, so those pairs are never returned (docs/dev/faces.md §9).
|
||||
pub images: &'a [u64],
|
||||
/// Which faces may be compared *against* — the gallery
|
||||
/// ([`crate::embedding::MIN_GALLERY_QUALITY`]).
|
||||
|
||||
+31
-9
@@ -1,9 +1,8 @@
|
||||
# Film stocks
|
||||
|
||||
One file per stock in [`profiles/`](profiles/). Adding a stock is adding a
|
||||
file — no code change, no shader, no new operation — for the same reason
|
||||
`dr-decode`'s base curves work that way: under the GPLv3 a stock should be
|
||||
contributable without a release.
|
||||
file — no code change, no shader, no new operation — because under the GPLv3
|
||||
a stock should be contributable without a release.
|
||||
|
||||
## What a profile is
|
||||
|
||||
@@ -41,18 +40,41 @@ matters — see [`src/bake.rs`](src/bake.rs) for the argument:
|
||||
1. **A 3×3 matrix**, linear sRGB to the three layers' exposure. Exact, not an
|
||||
approximation: the reconstructed scene spectrum is linear in the sRGB
|
||||
triple, so the integral collapses into nine numbers.
|
||||
2. **Three 1D curves**, log exposure to density, sampled at 256 points.
|
||||
3. **One 32³ lookup**, density to linear sRGB — dye absorption, the print
|
||||
through the negative, the paper, the viewing illuminant and the chromatic
|
||||
adaptation, all of which take exactly three numbers in.
|
||||
2. **Three 1D curves**, log exposure to density, sampled at 256 points — one
|
||||
row per development time the datasheet measures. Push picks between the
|
||||
rows, and interpolating them is exact, because density is linear in push
|
||||
between two measured processes.
|
||||
3. **One 32³ lookup**, density to linear sRGB — dye absorption, the viewing
|
||||
illuminant and the chromatic adaptation, all of which take exactly three
|
||||
numbers in. A printed negative is two: the film's cube ends at the paper's
|
||||
log exposure through the negative, the enlarger's exposure is added there,
|
||||
and the paper's own curve row and cube take it to linear sRGB.
|
||||
|
||||
Per pixel that is a matrix multiply, three curve taps and one texture fetch.
|
||||
Splitting 2 from 3, rather than baking one LUT over exposure, is measured
|
||||
The stock is the last thing that happens to the picture. It runs in the view
|
||||
transform's place (D19): handed linear sRGB, scene-referred, after every other
|
||||
adjustment and after sharpening and noise reduction, and handing back the
|
||||
rendering the output transform encodes. So every other slider decides the
|
||||
exposure the negative receives, and the default tone mapping is not applied
|
||||
on top.
|
||||
|
||||
Per pixel that is a matrix multiply, a handful of curve taps and one texture
|
||||
fetch — two for a print. Splitting 2 from 3, rather than baking one LUT over exposure, is measured
|
||||
rather than assumed: the curve carries all the sharp shape and the dye mixing
|
||||
is smooth, so folding the curve into the 3D lookup would need it three times
|
||||
larger for the same error. At 32³ the worst interpolation error is about 0.003
|
||||
in linear sRGB, below one 8-bit code value, and there is a test that says so.
|
||||
|
||||
**No slider is baked.** Camera exposure is a gain before the matrix, push
|
||||
chooses between curve rows, print exposure is the addition between the two
|
||||
cubes, and format sets the grain; each reaches the shader as a uniform that is
|
||||
linear in what it does. That is what lets a mask layer hold its own film
|
||||
settings, and a pixel under several layers take the weighted average of them.
|
||||
Only the enlarger's filtration is solved at bake time, against the
|
||||
photograph's exposure — an enlarger has one filtration for the whole print —
|
||||
so the film's Exposure, set on the whole photograph, is the one slider that
|
||||
rebakes. The stock and its
|
||||
paper are the photograph's; a layer has no picker.
|
||||
|
||||
## Adding a stock
|
||||
|
||||
If spektrafilm has it, add its name to `STOCKS` in
|
||||
|
||||
+443
-112
@@ -21,14 +21,16 @@
|
||||
//! curves and dyes, the viewing illuminant, the adaptation — all of it takes
|
||||
//! three numbers in and gives three numbers out. So it bakes into one small
|
||||
//! 3D lookup, and the per-pixel cost is a matrix multiply, three curve taps
|
||||
//! and one texture fetch.
|
||||
//! and one texture fetch. A print is two: the film's lookup ends at the
|
||||
//! paper's log exposure, where the enlarger's exposure is an addition, and
|
||||
//! the paper's curve and lookup take it from there — see [`Paper`].
|
||||
//!
|
||||
//! Splitting 2 from 3 rather than baking a single LUT over exposure is
|
||||
//! deliberate and measured: the curve carries all of the sharp shape and the
|
||||
//! dye mixing is smooth, so putting the curve in the 3D LUT would force it
|
||||
//! three times larger for the same error.
|
||||
|
||||
use crate::profile::Profile;
|
||||
use crate::profile::{Profile, CURVE_SAMPLES};
|
||||
use crate::spectrum::{illuminant, Spectrum, Viewing};
|
||||
use crate::tables::{SPECTRUM, SRGB_BASIS};
|
||||
|
||||
@@ -47,7 +49,19 @@ pub const MID_GREY: f32 = 0.184;
|
||||
/// that on: the error is already under what the output can represent.
|
||||
pub const LUT_SIZE: usize = 32;
|
||||
|
||||
/// What to develop, and how.
|
||||
/// TRACES: FR-DEV-3f
|
||||
/// The most development times a stock may measure: one curve row, and one
|
||||
/// push station, each. Every stock shipped measures five; the ceiling is what
|
||||
/// the shader's fixed uniform block can hold.
|
||||
pub const MAX_CURVE_ROWS: usize = 8;
|
||||
|
||||
/// What to develop: the materials, and where the enlarger is balanced.
|
||||
///
|
||||
/// **Not how far, and not how bright.** Push, print exposure and camera
|
||||
/// exposure are [`Settings`], evaluated per pixel against these tables, so
|
||||
/// that a mask layer can hold its own and a pixel under it can take the
|
||||
/// weighted average of everyone's (FR-DEV-3f). What is left here is what a
|
||||
/// photograph has one of.
|
||||
pub struct Recipe<'a> {
|
||||
/// The stock the picture was taken on.
|
||||
pub film: &'a Profile,
|
||||
@@ -55,17 +69,15 @@ pub struct Recipe<'a> {
|
||||
/// what a reversal stock wants and what makes a negative come out orange
|
||||
/// and inverted — that being what a negative actually looks like.
|
||||
pub print: Option<&'a Profile>,
|
||||
/// Camera exposure, in stops.
|
||||
pub exposure_ev: f32,
|
||||
/// Enlarger exposure, in stops. Ignored without a `print`.
|
||||
pub print_exposure_ev: f32,
|
||||
/// TRACES: FR-DEV-3f
|
||||
/// Development, in stops of push. Positive develops longer.
|
||||
/// The camera exposure the enlarger is balanced at, in stops. Ignored
|
||||
/// without a `print`.
|
||||
///
|
||||
/// Ignored by a stock measured at one process, of which there are many —
|
||||
/// see [`crate::profile::Profile::curves_at_push`], which returns the one
|
||||
/// measured curve rather than inventing a pushed one.
|
||||
pub push_stops: f32,
|
||||
/// The *photograph's* exposure, never a region's. An enlarger has one
|
||||
/// filtration for the whole print: a negative exposed a stop brighter in
|
||||
/// one corner prints a stop darker there, and that difference is the
|
||||
/// picture — balancing it away per pixel would erase every local exposure
|
||||
/// change a layer made.
|
||||
pub exposure_ev: f32,
|
||||
}
|
||||
|
||||
impl<'a> Recipe<'a> {
|
||||
@@ -76,12 +88,27 @@ impl<'a> Recipe<'a> {
|
||||
film,
|
||||
print,
|
||||
exposure_ev: 0.0,
|
||||
print_exposure_ev: 0.0,
|
||||
push_stops: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3f
|
||||
/// What a pixel is developed with, against a [`Baked`] stock.
|
||||
///
|
||||
/// The shader's uniforms, as the CPU sees them: every field is linear in what
|
||||
/// the tables are indexed by, which is what lets the composer blend several
|
||||
/// layers' settings into one before the fragment runs.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||||
pub struct Settings {
|
||||
/// Camera exposure, in stops: a gain on the scene.
|
||||
pub exposure_ev: f32,
|
||||
/// Development, in stops of push. Positive develops longer. Nothing for a
|
||||
/// stock measured at one process, of which there are many.
|
||||
pub push_stops: f32,
|
||||
/// Enlarger exposure, in stops. Nothing without a print.
|
||||
pub print_exposure_ev: f32,
|
||||
}
|
||||
|
||||
/// A recipe reduced to three tables.
|
||||
///
|
||||
/// Plain `f32` with a documented layout, and no notion of a texture: what to
|
||||
@@ -89,16 +116,31 @@ impl<'a> Recipe<'a> {
|
||||
/// the whole model be tested on the CPU.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Baked {
|
||||
/// Linear sRGB to the three layers' log₁₀ exposure, before the log — row
|
||||
/// `l`, column `c` is layer `l`'s response to sRGB channel `c`.
|
||||
/// Linear sRGB to the three layers' exposure, before the log — row `l`,
|
||||
/// column `c` is layer `l`'s response to sRGB channel `c`. At unit gain:
|
||||
/// [`Settings::exposure_ev`] is applied per pixel.
|
||||
pub exposure_matrix: [[f32; 3]; 3],
|
||||
/// The characteristic curves, `CURVE_SAMPLES` samples per layer, uniform
|
||||
/// over `[curve_log_min, curve_log_max]`.
|
||||
/// The characteristic curves: `curve_rows` rows of `CURVE_SAMPLES`
|
||||
/// samples, row after row, each uniform over
|
||||
/// `[curve_log_min, curve_log_max]`.
|
||||
///
|
||||
/// Row `r` is the stock as measured at its `r`th development time, which
|
||||
/// is push [`Self::push_stations`]`[r]`. The rows are the measurements
|
||||
/// themselves rather than a resampling: between two, density is linear in
|
||||
/// push (development is interpolated in log time, and push is log time),
|
||||
/// so interpolating the rows by push reproduces
|
||||
/// [`Profile::curves_at_push`] exactly. A stock measured at one process
|
||||
/// has one row.
|
||||
pub curves: Vec<[f32; 3]>,
|
||||
pub curve_rows: usize,
|
||||
/// The push each row was developed to, ascending, one per row.
|
||||
pub push_stations: Vec<f32>,
|
||||
pub curve_log_min: f32,
|
||||
pub curve_log_max: f32,
|
||||
/// Density to linear sRGB, `LUT_SIZE³` entries uniform over
|
||||
/// `[0, density_max]` on each axis.
|
||||
/// Film density to what comes next, `LUT_SIZE³` entries uniform over
|
||||
/// `[0, density_max]` on each axis: linear sRGB when the film is viewed
|
||||
/// directly, and the paper's log₁₀ exposure through it, per layer, when it
|
||||
/// is printed.
|
||||
///
|
||||
/// **The red axis varies fastest**, then green, then blue — that is,
|
||||
/// `lut[(b * size + g) * size + r]`. Stated because it is not the order
|
||||
@@ -108,60 +150,142 @@ pub struct Baked {
|
||||
/// picture with red and blue transposed, which looks like a plausible
|
||||
/// photograph of the wrong colour.
|
||||
pub lut: Vec<[f32; 3]>,
|
||||
/// The paper, when there is one. See [`Paper`].
|
||||
pub paper: Option<Paper>,
|
||||
/// The deepest density any row develops to, so one lookup covers every
|
||||
/// push.
|
||||
pub density_max: f32,
|
||||
pub lut_size: usize,
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3f
|
||||
/// The print half of a baked stock: enlarger to paper to viewing.
|
||||
///
|
||||
/// Split from the film's lookup at the paper's log exposure, for the reason
|
||||
/// the film is split from its own curve. The enlarger's exposure is a shift
|
||||
/// *in that log exposure*, the same stops on all three layers, so a print
|
||||
/// exposure is an addition between the two lookups — exact at any value and
|
||||
/// free per pixel. Baking it into one lookup instead needs a slice per
|
||||
/// setting, and interpolating between slices misses by several code values,
|
||||
/// because the paper's curve is the sharpest thing in the print.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Paper {
|
||||
/// The enlarger's filtration, per layer, in log₁₀ exposure: what makes a
|
||||
/// mid-grey scene print neutral at the photograph's exposure. See
|
||||
/// [`Recipe::exposure_ev`].
|
||||
pub balance: [f32; 3],
|
||||
/// The paper's characteristic curves, `CURVE_SAMPLES` samples uniform
|
||||
/// over `[log_min, log_max]`.
|
||||
pub curves: Vec<[f32; 3]>,
|
||||
pub log_min: f32,
|
||||
pub log_max: f32,
|
||||
/// Paper density to linear sRGB, laid out as [`Baked::lut`] is, uniform
|
||||
/// over `[0, density_max]`.
|
||||
pub lut: Vec<[f32; 3]>,
|
||||
pub density_max: f32,
|
||||
}
|
||||
|
||||
/// Where `push` falls among the rows: the lower row and the fraction toward
|
||||
/// the next. Clamped at both ends, as `curves_at_push` clamps to the first and
|
||||
/// last measured process.
|
||||
fn push_row(stations: &[f32], push: f32) -> (usize, f32) {
|
||||
if stations.len() < 2 {
|
||||
return (0, 0.0);
|
||||
}
|
||||
let last = stations.len() - 1;
|
||||
let hi = stations
|
||||
.iter()
|
||||
.position(|p| *p >= push)
|
||||
.unwrap_or(last)
|
||||
.max(1);
|
||||
let lo = hi - 1;
|
||||
let f = (push - stations[lo]) / (stations[hi] - stations[lo]).max(1e-6);
|
||||
(lo, f.clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
impl Baked {
|
||||
/// Look a colour up the way the shader will, for tests and for previews.
|
||||
/// Look a colour up the way the shader will, at the stock's own settings.
|
||||
pub fn apply(&self, rgb: [f32; 3]) -> [f32; 3] {
|
||||
self.apply_at(rgb, &Settings::default())
|
||||
}
|
||||
|
||||
/// Look a colour up the way the shader will, for tests and for previews.
|
||||
pub fn apply_at(&self, rgb: [f32; 3], settings: &Settings) -> [f32; 3] {
|
||||
let gain = 2f32.powf(settings.exposure_ev);
|
||||
let mut log_exposure = [0.0f32; 3];
|
||||
for (l, slot) in log_exposure.iter_mut().enumerate() {
|
||||
let m = self.exposure_matrix[l];
|
||||
let e = m[0] * rgb[0] + m[1] * rgb[1] + m[2] * rgb[2];
|
||||
let e = gain * (m[0] * rgb[0] + m[1] * rgb[1] + m[2] * rgb[2]);
|
||||
*slot = (e.max(0.0) + 1e-10).log10();
|
||||
}
|
||||
self.sample_lut(self.sample_curves(log_exposure))
|
||||
let density = self.sample_curves(log_exposure, settings.push_stops);
|
||||
let through = sample_cube(&self.lut, self.lut_size, density, self.density_max);
|
||||
let Some(paper) = &self.paper else {
|
||||
return through;
|
||||
};
|
||||
let shift = settings.print_exposure_ev * 2f32.log10();
|
||||
let paper_log = [0, 1, 2].map(|l| through[l] + paper.balance[l] + shift);
|
||||
let paper_density = sample_curve(&paper.curves, paper.log_min, paper.log_max, paper_log);
|
||||
sample_cube(&paper.lut, self.lut_size, paper_density, paper.density_max)
|
||||
}
|
||||
|
||||
fn sample_curves(&self, log_exposure: [f32; 3]) -> [f32; 3] {
|
||||
let last = self.curves.len() - 1;
|
||||
let span = self.curve_log_max - self.curve_log_min;
|
||||
let mut out = [0.0f32; 3];
|
||||
for (c, slot) in out.iter_mut().enumerate() {
|
||||
let t = ((log_exposure[c] - self.curve_log_min) / span).clamp(0.0, 1.0) * last as f32;
|
||||
let i = (t.floor() as usize).min(last - 1);
|
||||
let f = t - i as f32;
|
||||
*slot = self.curves[i][c] * (1.0 - f) + self.curves[i + 1][c] * f;
|
||||
fn sample_curves(&self, log_exposure: [f32; 3], push_stops: f32) -> [f32; 3] {
|
||||
let (row, g) = push_row(&self.push_stations, push_stops);
|
||||
let lo = self.sample_curve_row(log_exposure, row);
|
||||
if self.curve_rows < 2 {
|
||||
return lo;
|
||||
}
|
||||
out
|
||||
let hi = self.sample_curve_row(log_exposure, row + 1);
|
||||
[0, 1, 2].map(|c| lo[c] * (1.0 - g) + hi[c] * g)
|
||||
}
|
||||
|
||||
fn sample_lut(&self, density: [f32; 3]) -> [f32; 3] {
|
||||
let n = self.lut_size;
|
||||
let mut base = [0usize; 3];
|
||||
let mut frac = [0f32; 3];
|
||||
for c in 0..3 {
|
||||
let t = (density[c] / self.density_max).clamp(0.0, 1.0) * (n - 1) as f32;
|
||||
base[c] = (t.floor() as usize).min(n - 2);
|
||||
frac[c] = t - base[c] as f32;
|
||||
}
|
||||
let mut out = [0.0f32; 3];
|
||||
for dx in 0..2 {
|
||||
for dy in 0..2 {
|
||||
for dz in 0..2 {
|
||||
let w = if dx == 0 { 1.0 - frac[0] } else { frac[0] }
|
||||
* if dy == 0 { 1.0 - frac[1] } else { frac[1] }
|
||||
* if dz == 0 { 1.0 - frac[2] } else { frac[2] };
|
||||
let e = self.lut[((base[2] + dz) * n + base[1] + dy) * n + base[0] + dx];
|
||||
for c in 0..3 {
|
||||
out[c] += w * e[c];
|
||||
}
|
||||
fn sample_curve_row(&self, log_exposure: [f32; 3], row: usize) -> [f32; 3] {
|
||||
let samples = self.curves.len() / self.curve_rows;
|
||||
let curve = &self.curves[row * samples..(row + 1) * samples];
|
||||
sample_curve(curve, self.curve_log_min, self.curve_log_max, log_exposure)
|
||||
}
|
||||
}
|
||||
|
||||
/// Three curves sampled uniformly over `[log_min, log_max]`, read at a log
|
||||
/// exposure per layer. Clamped at both ends, as the shader's is.
|
||||
fn sample_curve(curve: &[[f32; 3]], log_min: f32, log_max: f32, at: [f32; 3]) -> [f32; 3] {
|
||||
let last = curve.len() - 1;
|
||||
let span = log_max - log_min;
|
||||
let mut out = [0.0f32; 3];
|
||||
for (c, slot) in out.iter_mut().enumerate() {
|
||||
let t = ((at[c] - log_min) / span).clamp(0.0, 1.0) * last as f32;
|
||||
let i = (t.floor() as usize).min(last - 1);
|
||||
let f = t - i as f32;
|
||||
*slot = curve[i][c] * (1.0 - f) + curve[i + 1][c] * f;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A cube of `n³` triples over `[0, max]` per axis, red fastest, read
|
||||
/// trilinearly.
|
||||
fn sample_cube(lut: &[[f32; 3]], n: usize, density: [f32; 3], max: f32) -> [f32; 3] {
|
||||
let mut base = [0usize; 3];
|
||||
let mut frac = [0f32; 3];
|
||||
for c in 0..3 {
|
||||
let t = (density[c] / max).clamp(0.0, 1.0) * (n - 1) as f32;
|
||||
base[c] = (t.floor() as usize).min(n - 2);
|
||||
frac[c] = t - base[c] as f32;
|
||||
}
|
||||
let mut out = [0.0f32; 3];
|
||||
for dx in 0..2 {
|
||||
for dy in 0..2 {
|
||||
for dz in 0..2 {
|
||||
let w = if dx == 0 { 1.0 - frac[0] } else { frac[0] }
|
||||
* if dy == 0 { 1.0 - frac[1] } else { frac[1] }
|
||||
* if dz == 0 { 1.0 - frac[2] } else { frac[2] };
|
||||
let e = lut[((base[2] + dz) * n + base[1] + dy) * n + base[0] + dx];
|
||||
for c in 0..3 {
|
||||
out[c] += w * e[c];
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Linear sRGB to the three layers' exposure, mid-grey normalised.
|
||||
@@ -204,12 +328,7 @@ pub fn exposure_matrix(film: &Profile) -> [[f32; 3]; 3] {
|
||||
/// goes: the mask is a fixed density, so balancing mid-grey to neutral cancels
|
||||
/// it — which is why a printed negative looks like a photograph while a scanned
|
||||
/// one looks orange.
|
||||
fn print_balance(
|
||||
film: &Profile,
|
||||
paper: &Profile,
|
||||
exposure_ev: f32,
|
||||
print_exposure_ev: f32,
|
||||
) -> [f32; 3] {
|
||||
pub fn print_balance(film: &Profile, paper: &Profile, exposure_ev: f32) -> [f32; 3] {
|
||||
let matrix = exposure_matrix(film);
|
||||
let scene = MID_GREY * 2f32.powf(exposure_ev);
|
||||
let mut log_exposure = [0.0f32; 3];
|
||||
@@ -227,7 +346,7 @@ fn print_balance(
|
||||
|
||||
let mut offsets = [0.0f32; 3];
|
||||
for (l, slot) in offsets.iter_mut().enumerate() {
|
||||
*slot = target - (mid_raw[l] + 1e-10).log10() + print_exposure_ev * 2f32.log10();
|
||||
*slot = target - (mid_raw[l] + 1e-10).log10();
|
||||
}
|
||||
offsets
|
||||
}
|
||||
@@ -257,77 +376,117 @@ fn paper_exposure(film: &Profile, paper: &Profile, density: [f32; 3]) -> [f32; 3
|
||||
/// Bake a recipe into the tables a shader runs.
|
||||
pub fn bake(recipe: &Recipe) -> Baked {
|
||||
let film = recipe.film;
|
||||
let mut matrix = exposure_matrix(film);
|
||||
// Camera exposure rides in the matrix rather than in the shader: it is a
|
||||
// scalar on a linear quantity, and folding it in here costs nothing and
|
||||
// keeps the per-pixel work identical whether or not it has been moved.
|
||||
let gain = 2f32.powf(recipe.exposure_ev);
|
||||
for row in &mut matrix {
|
||||
for v in row.iter_mut() {
|
||||
*v *= gain;
|
||||
}
|
||||
}
|
||||
// At unit gain. Camera exposure is a scalar on a linear quantity, so the
|
||||
// shader applies it for the price of one multiply — and has to, since a
|
||||
// layer may hold its own.
|
||||
let matrix = exposure_matrix(film);
|
||||
|
||||
// TRACES: FR-DEV-3f
|
||||
// Developed to the requested push before anything else reads the curves:
|
||||
// the density ceiling, the print balance and the grain all depend on how
|
||||
// far this film was taken, and a push that only reached one of them would
|
||||
// be a contrast change wearing a push's name.
|
||||
let curves = film.curves_at_push(recipe.push_stops);
|
||||
let density_max = curves
|
||||
.iter()
|
||||
.flat_map(|row| row.iter())
|
||||
.fold(0.0f32, |a, &b| a.max(b))
|
||||
.max(1e-3);
|
||||
|
||||
let viewing = match recipe.print {
|
||||
Some(paper) => Viewing::new(&paper.viewing_illuminant),
|
||||
None => Viewing::new(&film.viewing_illuminant),
|
||||
// Every measured process, not the one the slider is at: the shader
|
||||
// interpolates between rows per pixel, so a layer can push a region.
|
||||
// Resampled to one length because the rows share a texture.
|
||||
let measured = film.development_curves.len() >= 2
|
||||
&& film.development_times.len() == film.development_curves.len();
|
||||
let (curves, push_stations): (Vec<[f32; 3]>, Vec<f32>) = if measured {
|
||||
let rows = film.development_curves.len().min(MAX_CURVE_ROWS);
|
||||
(
|
||||
film.development_curves[..rows]
|
||||
.iter()
|
||||
.flat_map(|c| resample(c))
|
||||
.collect(),
|
||||
film.development_times[..rows]
|
||||
.iter()
|
||||
.map(|t| 2.0 * (t / film.development_normal).log2())
|
||||
.collect(),
|
||||
)
|
||||
} else {
|
||||
(resample(&film.density_curves), vec![0.0])
|
||||
};
|
||||
let balance = recipe
|
||||
.print
|
||||
.map(|paper| print_balance(film, paper, recipe.exposure_ev, recipe.print_exposure_ev));
|
||||
let curve_rows = push_stations.len();
|
||||
// The ceiling of the deepest row, so one lookup covers every push.
|
||||
let density_max = ceiling(&curves);
|
||||
|
||||
let n = LUT_SIZE;
|
||||
let mut lut = Vec::with_capacity(n * n * n);
|
||||
// Blue outermost and red innermost, so the red axis varies fastest. See
|
||||
// `Baked::lut`: this is the layout a 3D texture upload wants, and getting
|
||||
// it backwards transposes red and blue in the finished picture.
|
||||
for b in 0..n {
|
||||
for g in 0..n {
|
||||
for r in 0..n {
|
||||
let density = [
|
||||
density_max * r as f32 / (n - 1) as f32,
|
||||
density_max * g as f32 / (n - 1) as f32,
|
||||
density_max * b as f32 / (n - 1) as f32,
|
||||
];
|
||||
lut.push(match recipe.print.zip(balance) {
|
||||
Some((paper, offsets)) => {
|
||||
let raw = paper_exposure(film, paper, density);
|
||||
let mut log_exposure = [0.0f32; 3];
|
||||
for (l, slot) in log_exposure.iter_mut().enumerate() {
|
||||
*slot = (raw[l] + 1e-10).log10() + offsets[l];
|
||||
}
|
||||
let paper_density = paper.density_at(log_exposure);
|
||||
viewing.to_srgb(&paper.transmittance(paper_density))
|
||||
}
|
||||
None => viewing.to_srgb(&film.transmittance(density)),
|
||||
});
|
||||
let cube = |max: f32, f: &dyn Fn([f32; 3]) -> [f32; 3]| {
|
||||
let mut out = Vec::with_capacity(n * n * n);
|
||||
for b in 0..n {
|
||||
for g in 0..n {
|
||||
for r in 0..n {
|
||||
let step = max / (n - 1) as f32;
|
||||
out.push(f([r as f32 * step, g as f32 * step, b as f32 * step]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
};
|
||||
|
||||
let (lut, paper) = match recipe.print {
|
||||
None => {
|
||||
let viewing = Viewing::new(&film.viewing_illuminant);
|
||||
(
|
||||
cube(density_max, &|d| viewing.to_srgb(&film.transmittance(d))),
|
||||
None,
|
||||
)
|
||||
}
|
||||
Some(paper) => {
|
||||
let viewing = Viewing::new(&paper.viewing_illuminant);
|
||||
let curves = resample(&paper.density_curves);
|
||||
let paper_max = ceiling(&curves);
|
||||
let lut = cube(density_max, &|d| {
|
||||
paper_exposure(film, paper, d).map(|raw| (raw + 1e-10).log10())
|
||||
});
|
||||
let paper = Paper {
|
||||
balance: print_balance(film, paper, recipe.exposure_ev),
|
||||
log_min: paper.log_exposure_min,
|
||||
log_max: paper.log_exposure_max,
|
||||
lut: cube(paper_max, &|d| viewing.to_srgb(&paper.transmittance(d))),
|
||||
density_max: paper_max,
|
||||
curves,
|
||||
};
|
||||
(lut, Some(paper))
|
||||
}
|
||||
};
|
||||
|
||||
Baked {
|
||||
exposure_matrix: matrix,
|
||||
curves,
|
||||
curve_rows,
|
||||
push_stations,
|
||||
curve_log_min: film.log_exposure_min,
|
||||
curve_log_max: film.log_exposure_max,
|
||||
lut,
|
||||
paper,
|
||||
density_max,
|
||||
lut_size: n,
|
||||
}
|
||||
}
|
||||
|
||||
/// A curve at `CURVE_SAMPLES`, uniform over the same domain it came in on.
|
||||
fn resample(curve: &[[f32; 3]]) -> Vec<[f32; 3]> {
|
||||
if curve.len() == CURVE_SAMPLES {
|
||||
return curve.to_vec();
|
||||
}
|
||||
(0..CURVE_SAMPLES)
|
||||
.map(|i| {
|
||||
let at = i as f32 / (CURVE_SAMPLES - 1) as f32;
|
||||
sample_curve(curve, 0.0, 1.0, [at; 3])
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The deepest density in a set of curves, floored so a lookup over it has
|
||||
/// a width.
|
||||
fn ceiling(curves: &[[f32; 3]]) -> f32 {
|
||||
curves
|
||||
.iter()
|
||||
.flat_map(|row| row.iter())
|
||||
.fold(0.0f32, |a, &b| a.max(b))
|
||||
.max(1e-3)
|
||||
}
|
||||
|
||||
fn mean(s: &Spectrum) -> f32 {
|
||||
s.iter().sum::<f32>() / SPECTRUM as f32
|
||||
}
|
||||
@@ -467,6 +626,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn exposure_moves_the_print_the_way_it_moves_a_photograph() {
|
||||
// The photograph's exposure: the enlarger balanced at it, and the
|
||||
// scene brighter by it. Mid-grey stays where the balance puts it —
|
||||
// that is what the balance is for — so what a stop more does to a
|
||||
// print is lift everything either side of it along the paper's curve.
|
||||
let film = portra();
|
||||
let paper = endura();
|
||||
let brighter = bake(&Recipe {
|
||||
@@ -474,7 +637,155 @@ mod tests {
|
||||
..Recipe::new(&film, Some(&paper))
|
||||
});
|
||||
let base = bake(&Recipe::new(&film, Some(&paper)));
|
||||
assert!(brighter.apply([MID_GREY; 3])[1] > base.apply([MID_GREY; 3])[1]);
|
||||
let one_stop = Settings {
|
||||
exposure_ev: 1.0,
|
||||
..Settings::default()
|
||||
};
|
||||
for v in [0.02f32, 0.6] {
|
||||
assert!(
|
||||
brighter.apply_at([v; 3], &one_stop)[1] > base.apply([v; 3])[1],
|
||||
"{v} did not print brighter a stop up"
|
||||
);
|
||||
}
|
||||
let (a, b) = (
|
||||
brighter.apply_at([MID_GREY; 3], &one_stop)[1],
|
||||
base.apply([MID_GREY; 3])[1],
|
||||
);
|
||||
assert!(
|
||||
(a - b).abs() < 1.0 / 255.0,
|
||||
"the balance let mid-grey move: {a} vs {b}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_region_exposed_brighter_prints_brighter_than_the_enlarger_expects() {
|
||||
// TRACES: FR-DEV-3f
|
||||
// A layer's exposure is the scene's, not the enlarger's: the balance
|
||||
// stays where the photograph put it, so the region prints lighter by
|
||||
// more than the whole photograph would, which is what dodging at the
|
||||
// camera is.
|
||||
let film = portra();
|
||||
let paper = endura();
|
||||
let base = bake(&Recipe::new(&film, Some(&paper)));
|
||||
let rebalanced = bake(&Recipe {
|
||||
exposure_ev: 1.0,
|
||||
..Recipe::new(&film, Some(&paper))
|
||||
});
|
||||
let one_stop = Settings {
|
||||
exposure_ev: 1.0,
|
||||
..Settings::default()
|
||||
};
|
||||
let local = base.apply_at([MID_GREY; 3], &one_stop)[1];
|
||||
let global = rebalanced.apply_at([MID_GREY; 3], &one_stop)[1];
|
||||
assert!(local > base.apply([MID_GREY; 3])[1], "not brighter at all");
|
||||
assert!(
|
||||
local > global,
|
||||
"a region was rebalanced as though it were the whole print: {local} vs {global}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn more_light_through_the_enlarger_darkens_the_print() {
|
||||
// TRACES: FR-DEV-3f
|
||||
// Paper is negative-working. Opening the enlarger a stop is burning
|
||||
// in, and a slider that brightened would be the wrong way round for
|
||||
// anyone who has printed.
|
||||
let film = portra();
|
||||
let paper = endura();
|
||||
let baked = bake(&Recipe::new(&film, Some(&paper)));
|
||||
let at = |stops: f32| {
|
||||
baked.apply_at(
|
||||
[MID_GREY; 3],
|
||||
&Settings {
|
||||
print_exposure_ev: stops,
|
||||
..Settings::default()
|
||||
},
|
||||
)[1]
|
||||
};
|
||||
assert!(at(1.0) < at(0.0) && at(0.0) < at(-1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_push_on_a_row_is_the_measured_curve() {
|
||||
// TRACES: FR-DEV-3f
|
||||
// The rows are the measured processes, so at a row the table must be
|
||||
// that curve exactly, and between rows — density being linear in push
|
||||
// there — it must be `curves_at_push` to rounding.
|
||||
let film = profile(include_str!("../profiles/kodak_doublex.yaml"));
|
||||
let baked = bake(&Recipe::new(&film, None));
|
||||
assert_eq!(
|
||||
baked.curve_rows, 5,
|
||||
"Double-X measures five development times"
|
||||
);
|
||||
|
||||
let span = film.log_exposure_max - film.log_exposure_min;
|
||||
let mut worst = 0.0f32;
|
||||
let stations = baked.push_stations.clone();
|
||||
let mut pushes: Vec<(f32, bool)> = stations.iter().map(|p| (*p, true)).collect();
|
||||
for k in 0..=16 {
|
||||
pushes.push((-1.0 + 4.0 * k as f32 / 16.0, false));
|
||||
}
|
||||
for (push, on_row) in pushes {
|
||||
let exact = film.curves_at_push(push);
|
||||
for i in (0..exact.len()).step_by(7) {
|
||||
let log = film.log_exposure_min + span * i as f32 / (exact.len() - 1) as f32;
|
||||
let got = baked.sample_curves([log; 3], push);
|
||||
for c in 0..3 {
|
||||
let err = (got[c] - exact[i][c]).abs();
|
||||
if on_row {
|
||||
assert!(err < 1e-4, "push {push} is a row but misses it by {err}");
|
||||
}
|
||||
worst = worst.max(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(worst < 1e-3, "between rows the density is off by {worst}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_print_exposure_is_exact_at_any_setting() {
|
||||
// TRACES: FR-DEV-3f
|
||||
// The enlarger's exposure is added between the two lookups rather than
|
||||
// baked into either, so no setting is nearer the tables than another.
|
||||
// Compared against the chain evaluated spectrally, end to end, at
|
||||
// settings chosen off every half and whole stop.
|
||||
let film = portra();
|
||||
let paper = endura();
|
||||
let baked = bake(&Recipe::new(&film, Some(&paper)));
|
||||
let offsets = print_balance(&film, &paper, 0.0);
|
||||
let viewing = Viewing::new(&paper.viewing_illuminant);
|
||||
|
||||
let mut worst = 0.0f32;
|
||||
for stops in [-2.3f32, -0.6, 0.0, 0.35, 1.7] {
|
||||
for i in 0..14 {
|
||||
let v = 0.004 * 2f32.powf(i as f32 * 0.6);
|
||||
let rgb = [v, v * 0.8, v * 1.1];
|
||||
let mut log_exposure = [0.0f32; 3];
|
||||
for (l, slot) in log_exposure.iter_mut().enumerate() {
|
||||
let m = baked.exposure_matrix[l];
|
||||
*slot =
|
||||
((m[0] * rgb[0] + m[1] * rgb[1] + m[2] * rgb[2]).max(0.0) + 1e-10).log10();
|
||||
}
|
||||
let raw = paper_exposure(&film, &paper, film.density_at(log_exposure));
|
||||
let paper_log =
|
||||
[0, 1, 2].map(|l| (raw[l] + 1e-10).log10() + offsets[l] + stops * 2f32.log10());
|
||||
let exact = viewing.to_srgb(&paper.transmittance(paper.density_at(paper_log)));
|
||||
let approx = baked.apply_at(
|
||||
rgb,
|
||||
&Settings {
|
||||
print_exposure_ev: stops,
|
||||
..Settings::default()
|
||||
},
|
||||
);
|
||||
for c in 0..3 {
|
||||
worst = worst.max((exact[c] - approx[c]).abs());
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
worst < 1.0 / 255.0,
|
||||
"the print misses the spectral chain by {worst}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -550,5 +861,25 @@ mod tests {
|
||||
let baked = bake(&Recipe::new(&film, None));
|
||||
assert_eq!(baked.lut.len(), LUT_SIZE * LUT_SIZE * LUT_SIZE);
|
||||
assert_eq!(baked.curves.len(), CURVE_SAMPLES);
|
||||
assert_eq!(baked.curve_rows, 1);
|
||||
assert!(baked.paper.is_none());
|
||||
|
||||
// A print has a second lookup and a curve of its own; a development
|
||||
// series a row per push. Neither is inferred from the other.
|
||||
let negative = portra();
|
||||
let paper = endura();
|
||||
let printed = bake(&Recipe::new(&negative, Some(&paper)));
|
||||
let print = printed
|
||||
.paper
|
||||
.as_ref()
|
||||
.expect("a printed negative has a paper");
|
||||
assert_eq!(print.lut.len(), LUT_SIZE.pow(3));
|
||||
assert_eq!(print.curves.len(), CURVE_SAMPLES);
|
||||
|
||||
let pushable = profile(include_str!("../profiles/kodak_doublex.yaml"));
|
||||
let rows = bake(&Recipe::new(&pushable, None));
|
||||
assert_eq!(rows.curve_rows, pushable.development_times.len());
|
||||
assert_eq!(rows.push_stations.len(), rows.curve_rows);
|
||||
assert_eq!(rows.curves.len(), rows.curve_rows * CURVE_SAMPLES);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
//!
|
||||
//! # Why it is data
|
||||
//!
|
||||
//! The same argument `dr_decode::base_curve` makes for camera bodies, and for
|
||||
//! the same requirement: under the GPLv3 a stock should be contributable
|
||||
//! without a release. A profile is three tables and a handful of facts, all of
|
||||
//! Under the GPLv3 a stock should be contributable without a release (the
|
||||
//! argument the retired per-body base curves made for camera bodies, before
|
||||
//! D19). A profile is three tables and a handful of facts, all of
|
||||
//! them published in the manufacturer's datasheet, so adding a stock is adding
|
||||
//! a file — not a code change, not a shader, and not a new operation.
|
||||
//!
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! What a frame actually costs — the measurement FR-DSP-2 is waiting on.
|
||||
//!
|
||||
//! `docs/display-and-extension.md` §2 argues that tiled computation predates
|
||||
//! `docs/dev/display-and-extension.md` §2 argues that tiled computation predates
|
||||
//! the fused-shader design and may not need to exist: the composer folds every
|
||||
//! active operation into **one dispatch over a viewport-sized target**, so the
|
||||
//! problem tiles were invented to solve may already be solved. That argument
|
||||
@@ -28,7 +28,7 @@
|
||||
//! the per-frame CPU half is dominated by shader-source assembly, which is
|
||||
//! string formatting and is several times slower unoptimised.
|
||||
//!
|
||||
//! The committed numbers live in `docs/frame-budget.md`. Rerun this and diff
|
||||
//! The committed numbers live in `docs/dev/frame-budget.md`. Rerun this and diff
|
||||
//! that file; a regression should be a diff rather than somebody's memory.
|
||||
//!
|
||||
//! # Why the 99th percentile and not the mean
|
||||
@@ -680,15 +680,18 @@ fn film_tables() -> FilmTables {
|
||||
FilmTables {
|
||||
exposure_matrix: baked.exposure_matrix,
|
||||
curves: baked.curves.clone(),
|
||||
push_stations: baked.push_stations.clone(),
|
||||
curve_log_min: baked.curve_log_min,
|
||||
curve_log_max: baked.curve_log_max,
|
||||
lut: baked.lut.clone(),
|
||||
density_max: baked.density_max,
|
||||
lut_size: baked.lut_size,
|
||||
// Viewed directly: `STOCK` is baked without a paper above.
|
||||
paper: None,
|
||||
// Grain off. It is a per-pixel hash and would be measured; it is also
|
||||
// not part of every edit, and the chain being measured here is "every
|
||||
// operation active", not "every option of every operation".
|
||||
grain_particles: [0.0; 3],
|
||||
grain_particles: [[0.0; 3]; dr_pipeline::ops::film_sim::FORMAT_COUNT],
|
||||
grain_density_max: [baked.density_max; 3],
|
||||
grain_uniformity: 0.97,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Segment an image and write the granularity ladder as false-coloured PPMs.
|
||||
//!
|
||||
//! The whole point of S15 step 2 (docs/segmentation.md §11): look at the
|
||||
//! The whole point of S15 step 2 (docs/dev/segmentation.md §11): look at the
|
||||
//! ladder and decide whether clicking through it would land on the things a
|
||||
//! person means. No amount of design settles that — the pictures do.
|
||||
//!
|
||||
|
||||
@@ -182,8 +182,7 @@ fn render_to(
|
||||
// and the example never has to know which it was handed.
|
||||
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||
let scale = graph.render_scale(image.size(), (w, h));
|
||||
let detail =
|
||||
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
|
||||
let detail = graph.compose_detail(scale.full_size(), scale.render_size());
|
||||
let key = graph.invalidation().through(Affects::Colour);
|
||||
adjust
|
||||
.render_detailed(image, &shader, w, h, None, &detail, key)
|
||||
|
||||
+652
-76
@@ -34,16 +34,6 @@ use crate::{DemosaicedImage, GpuContext, GpuError};
|
||||
/// reads them.
|
||||
const RESERVED_FIELDS: usize = dr_pipeline::RESERVED_UNIFORM_FIELDS;
|
||||
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// The two crates must agree on how many points a base curve has.
|
||||
///
|
||||
/// `dr-decode` reads them from the profile database and `dr-pipeline` declares
|
||||
/// the uniform slots; this file is the only place the two meet, and it packs
|
||||
/// them by index. A disagreement would not fail to compile — it would upload a
|
||||
/// curve with a point missing or a stale float in it, which renders as a
|
||||
/// plausible-looking wrong tone response. Cheaper to catch here, at build time.
|
||||
const _: () = assert!(dr_decode::base_curve::POINTS == dr_pipeline::BASE_CURVE_POINTS);
|
||||
|
||||
/// Runs composed operation chains against demosaiced images.
|
||||
pub struct AdjustPass {
|
||||
ctx: GpuContext,
|
||||
@@ -124,11 +114,16 @@ pub struct AdjustPass {
|
||||
/// Cleared by any render that does not write it, so a stale intermediate
|
||||
/// cannot survive a change of image and be handed to a later detail chain.
|
||||
colour_key: Option<(u64, u32, u32)>,
|
||||
/// The source texels the fused pass read on an earlier frame, kept so a
|
||||
/// slider drag at fit reads them contiguously. See [`SampleCache`].
|
||||
sample: SampleCache,
|
||||
/// Fused dispatches actually encoded. Exposed so a test can see the reuse
|
||||
/// above happening rather than take it on trust.
|
||||
colour_dispatches: usize,
|
||||
/// Detail dispatches encoded.
|
||||
detail_dispatches: usize,
|
||||
/// View passes encoded — one per render with a detail stage (D19).
|
||||
view_dispatches: usize,
|
||||
}
|
||||
|
||||
struct Target {
|
||||
@@ -147,6 +142,205 @@ struct Target {
|
||||
const FILM_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float;
|
||||
|
||||
/// TRACES: FR-DEV-3f
|
||||
/// What a cached sample is valid for: the composer's
|
||||
/// [`ComposedShader::sample_key`], the source image, and the render size.
|
||||
type SampleKey = (u64, u64, u32, u32);
|
||||
|
||||
/// The largest render the sample cache is kept for, in pixels.
|
||||
///
|
||||
/// 4K and a little over, which is every develop view there is. An export
|
||||
/// renders a whole sensor once and gains nothing from a cache it will not
|
||||
/// read again; without a ceiling, two exports of the same frame in a row
|
||||
/// would park a full-resolution copy of it on the device.
|
||||
const SAMPLE_CACHE_MAX_PIXELS: u64 = 3840 * 2400;
|
||||
|
||||
/// How the fused dispatch gets its source colour this frame.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum SampleUse {
|
||||
/// From the source, as it always did.
|
||||
Direct,
|
||||
/// From the source, and stored in the cache for the frames after.
|
||||
Write,
|
||||
/// From the cache.
|
||||
Read,
|
||||
}
|
||||
|
||||
/// TRACES: NFR-P5
|
||||
/// The fused pass's gather from the source, remembered across frames.
|
||||
///
|
||||
/// At fit, each output pixel of the fused pass reads one texel of a source
|
||||
/// three or four times its width, on a stride, and the memory system fetches
|
||||
/// the neighbours it skips along with it. On a 60 MP source that gather was
|
||||
/// most of the fused pass: 10.9 ms of a 2560 x 1600 frame against 3.8 ms for
|
||||
/// the same work reading contiguously (RTX 3050, clocks held down). But which
|
||||
/// texel a pixel reads depends on the framing and nothing else, and a slider
|
||||
/// drag does not move the framing. So the shader writes what it gathered to a
|
||||
/// render-sized texture on one frame and reads it back contiguously on every
|
||||
/// frame after, until the framing, the image or the size changes.
|
||||
///
|
||||
/// Bit-for-bit the same picture: the source is `rgba16float` and so is the
|
||||
/// cache, so the stored texel is the texel. Only the whole-texel sampling path
|
||||
/// takes part — [`ComposedShader::sample_key`] is `None` when the sample is
|
||||
/// interpolated.
|
||||
///
|
||||
/// **Written on the second frame with a key, not the first.** A drag of the
|
||||
/// crop or of a zoom changes the key on every frame, and a cache written then
|
||||
/// is never read — it would add a write per frame to exactly the gestures that
|
||||
/// can least afford one. Waiting for the key to repeat once costs a slider drag
|
||||
/// one uncached frame and costs a crop drag nothing.
|
||||
struct SampleCache {
|
||||
/// The cache itself, `rgba16float`, sampled and written as storage.
|
||||
target: Option<Target>,
|
||||
/// What `target` holds, once a dispatch has written it.
|
||||
holds: Option<SampleKey>,
|
||||
/// The key the previous fused dispatch had, cached or not.
|
||||
last: Option<SampleKey>,
|
||||
/// What the most recent plan decided. Read by the tests, which have no
|
||||
/// other way to tell a cached frame from an uncached one — the point being
|
||||
/// that the pictures are identical.
|
||||
last_use: SampleUse,
|
||||
/// Bound at `@binding(6)` when the cache is not being read.
|
||||
no_sampled: wgpu::TextureView,
|
||||
/// Bound at `@binding(7)` when the cache is not being written.
|
||||
no_sample_out: wgpu::TextureView,
|
||||
}
|
||||
|
||||
impl SampleCache {
|
||||
const FORMAT: wgpu::TextureFormat = DemosaicedImage::FORMAT;
|
||||
|
||||
fn new(ctx: &GpuContext) -> Self {
|
||||
let placeholder = |label, usage| {
|
||||
ctx.device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: Self::FORMAT,
|
||||
usage,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default())
|
||||
};
|
||||
Self {
|
||||
target: None,
|
||||
holds: None,
|
||||
last: None,
|
||||
last_use: SampleUse::Direct,
|
||||
no_sampled: placeholder("adjust-no-sampled", wgpu::TextureUsages::TEXTURE_BINDING),
|
||||
no_sample_out: placeholder(
|
||||
"adjust-no-sample-out",
|
||||
wgpu::TextureUsages::STORAGE_BINDING,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Decide how this dispatch samples, on the assumption that it will be
|
||||
/// submitted — call it after anything that can still fail.
|
||||
fn plan(
|
||||
&mut self,
|
||||
ctx: &GpuContext,
|
||||
source: &DemosaicedImage,
|
||||
shader: &ComposedShader,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> SampleUse {
|
||||
self.last_use = self.decide(ctx, source, shader, width, height);
|
||||
self.last_use
|
||||
}
|
||||
|
||||
fn decide(
|
||||
&mut self,
|
||||
ctx: &GpuContext,
|
||||
source: &DemosaicedImage,
|
||||
shader: &ComposedShader,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> SampleUse {
|
||||
let key = shader
|
||||
.sample_key
|
||||
.filter(|_| u64::from(width) * u64::from(height) <= SAMPLE_CACHE_MAX_PIXELS)
|
||||
.map(|k| (k, source.id(), width, height));
|
||||
let last = std::mem::replace(&mut self.last, key);
|
||||
let Some(key) = key else {
|
||||
return SampleUse::Direct;
|
||||
};
|
||||
if self.holds == Some(key) {
|
||||
return SampleUse::Read;
|
||||
}
|
||||
if last != Some(key) {
|
||||
return SampleUse::Direct;
|
||||
}
|
||||
|
||||
if !self
|
||||
.target
|
||||
.as_ref()
|
||||
.is_some_and(|t| t.width == width && t.height == height)
|
||||
{
|
||||
let texture = ctx.device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("adjust-sample-cache"),
|
||||
size: wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: Self::FORMAT,
|
||||
usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = texture.create_view(&Default::default());
|
||||
self.target = Some(Target {
|
||||
texture,
|
||||
view,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}
|
||||
// Marked as held now: the dispatch that writes it is submitted before
|
||||
// any that could read it, and one queue orders the two.
|
||||
self.holds = Some(key);
|
||||
SampleUse::Write
|
||||
}
|
||||
|
||||
/// Write the flags for `usage` into a fused uniform block.
|
||||
fn flag(usage: SampleUse, uniforms: &mut [f32]) {
|
||||
let o = dr_pipeline::SAMPLE_CACHE_UNIFORM_OFFSET;
|
||||
uniforms[o] = if usage == SampleUse::Read { 1.0 } else { 0.0 };
|
||||
uniforms[o + 1] = if usage == SampleUse::Write { 1.0 } else { 0.0 };
|
||||
}
|
||||
|
||||
/// The views for `@binding(6)` and `@binding(7)`.
|
||||
fn views(&self, usage: SampleUse) -> (wgpu::TextureView, wgpu::TextureView) {
|
||||
let cache = || {
|
||||
self.target
|
||||
.as_ref()
|
||||
.expect("planned with a target")
|
||||
.view
|
||||
.clone()
|
||||
};
|
||||
match usage {
|
||||
SampleUse::Direct => (self.no_sampled.clone(), self.no_sample_out.clone()),
|
||||
SampleUse::Write => (self.no_sampled.clone(), cache()),
|
||||
SampleUse::Read => (cache(), self.no_sample_out.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget everything; the next render starts over.
|
||||
fn release(&mut self) {
|
||||
self.target = None;
|
||||
self.holds = None;
|
||||
self.last = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// A baked film stock, resident on the GPU.
|
||||
struct FilmTextures {
|
||||
curves: wgpu::TextureView,
|
||||
@@ -180,9 +374,23 @@ fn film_key(t: &dr_pipeline::ops::FilmTables) -> u64 {
|
||||
t.curve_log_max,
|
||||
t.density_max,
|
||||
t.lut_size as f32,
|
||||
t.curves.len() as f32,
|
||||
t.lut.len() as f32,
|
||||
] {
|
||||
mix(v.to_bits());
|
||||
}
|
||||
for v in &t.push_stations {
|
||||
mix(v.to_bits());
|
||||
}
|
||||
// The paper's balance is left out on purpose: it reaches the shader as
|
||||
// uniforms, not texels, and it is what moves when the photograph's
|
||||
// exposure does — keying on it would re-upload a megabyte per tick of a
|
||||
// slider that changes three floats.
|
||||
if let Some(p) = &t.paper {
|
||||
for v in [p.log_min, p.log_max, p.density_max] {
|
||||
mix(v.to_bits());
|
||||
}
|
||||
}
|
||||
for e in t.lut.iter().step_by(8).chain(t.curves.iter().step_by(8)) {
|
||||
mix(e[0].to_bits() ^ e[1].to_bits().rotate_left(11) ^ e[2].to_bits().rotate_left(22));
|
||||
}
|
||||
@@ -229,12 +437,16 @@ impl AdjustPass {
|
||||
return;
|
||||
}
|
||||
|
||||
// One row per curve — the film's at each measured push, then the
|
||||
// paper's — and one cube per stage stacked in depth. The shader reads
|
||||
// the layout from `FilmTables`' uniforms, not from these sizes.
|
||||
let samples = dr_pipeline::ops::film_sim::CURVE_SAMPLES as u32;
|
||||
let curves = self.upload_film(
|
||||
"adjust-film-curves",
|
||||
wgpu::TextureDimension::D2,
|
||||
wgpu::Extent3d {
|
||||
width: t.curves.len() as u32,
|
||||
height: 1,
|
||||
width: samples,
|
||||
height: t.curves.len() as u32 / samples,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
&to_rgba(&t.curves),
|
||||
@@ -246,7 +458,7 @@ impl AdjustPass {
|
||||
wgpu::Extent3d {
|
||||
width: n,
|
||||
height: n,
|
||||
depth_or_array_layers: n,
|
||||
depth_or_array_layers: t.lut.len() as u32 / (n * n),
|
||||
},
|
||||
&to_rgba(&t.lut),
|
||||
);
|
||||
@@ -440,8 +652,10 @@ impl AdjustPass {
|
||||
camera_pipeline_layout,
|
||||
camera_target: None,
|
||||
colour_key: None,
|
||||
sample: SampleCache::new(ctx),
|
||||
colour_dispatches: 0,
|
||||
detail_dispatches: 0,
|
||||
view_dispatches: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -537,6 +751,30 @@ impl AdjustPass {
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
// The sample cache, read and written. Present in every
|
||||
// layout for the reason the masks are, and bound to
|
||||
// placeholders whenever the flags leave it alone. See
|
||||
// `SampleCache`.
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 6,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: false },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 7,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::StorageTexture {
|
||||
access: wgpu::StorageTextureAccess::WriteOnly,
|
||||
format: SampleCache::FORMAT,
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
@@ -716,7 +954,15 @@ impl AdjustPass {
|
||||
let (width, height) = (width.max(1), height.max(1));
|
||||
self.ensure_target(width, height);
|
||||
|
||||
let uniforms = Self::fused_uniforms(source, shader);
|
||||
// Compile first: `pipeline` takes &mut self, and the sample cache's
|
||||
// plan below assumes the dispatch it plans for is submitted, so nothing
|
||||
// after it may fail.
|
||||
let _ = self.pipeline(shader)?;
|
||||
|
||||
let mut uniforms = Self::fused_uniforms(source, shader);
|
||||
let sampling = self.sample.plan(&self.ctx, source, shader, width, height);
|
||||
SampleCache::flag(sampling, &mut uniforms);
|
||||
let (sampled, sample_out) = self.sample.views(sampling);
|
||||
|
||||
let params_buf = self
|
||||
.ctx
|
||||
@@ -727,8 +973,6 @@ impl AdjustPass {
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
|
||||
// Borrow order: compile first, since `pipeline` takes &mut self.
|
||||
let _ = self.pipeline(shader)?;
|
||||
let pipeline = self
|
||||
.cache
|
||||
.get(&shader.structure_hash)
|
||||
@@ -768,6 +1012,14 @@ impl AdjustPass {
|
||||
binding: 5,
|
||||
resource: wgpu::BindingResource::TextureView(self.film_lut_view()),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 6,
|
||||
resource: wgpu::BindingResource::TextureView(&sampled),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 7,
|
||||
resource: wgpu::BindingResource::TextureView(&sample_out),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -799,13 +1051,14 @@ impl AdjustPass {
|
||||
/// Render one frame with a neighbourhood stage.
|
||||
///
|
||||
/// `shader` and `detail` must be the two halves of **one** composition —
|
||||
/// `EditGraph::compose_for` and `EditGraph::compose_detail_for` on the same
|
||||
/// graph, at the same output space. The fused pass stops at linear working
|
||||
/// values when a detail stage exists and the last detail pass performs the
|
||||
/// output transform, so a mismatched pair either encodes twice or not at
|
||||
/// all.
|
||||
/// `EditGraph::compose_for` and `EditGraph::compose_detail` on the same
|
||||
/// graph. The fused pass stops at linear working values when a detail
|
||||
/// stage exists, and its view pass ([`ComposedShader::view`]) performs the
|
||||
/// view transform and the output transform after the last detail pass
|
||||
/// (D19), so a mismatched pair either encodes twice or not at all.
|
||||
///
|
||||
/// An empty `detail` falls through to [`Self::render_masked`], which is
|
||||
/// An encoded shader with an empty `detail` falls through to
|
||||
/// [`Self::render_masked`], which is
|
||||
/// the honest thing to do rather than an optimisation: an edit with no
|
||||
/// active sharpening *is* an ordinary edit, and it should cost exactly
|
||||
/// what one costs.
|
||||
@@ -845,17 +1098,25 @@ impl AdjustPass {
|
||||
detail: &ComposedDetail,
|
||||
colour_key: u64,
|
||||
) -> Result<&wgpu::Texture, GpuError> {
|
||||
if detail.is_empty() {
|
||||
// An edit with no detail stage encodes in the fused pass, and is an
|
||||
// ordinary render. Decided from the shader rather than from the chain:
|
||||
// an active kernel too fine for this render emits no pass, and its
|
||||
// fused pass has still stopped at linear values for the view pass.
|
||||
if shader.output_mode == OutputMode::Encoded && detail.is_empty() {
|
||||
return self.render_masked(source, shader, width, height, masks);
|
||||
}
|
||||
if shader.output_mode != OutputMode::LinearWorking {
|
||||
return Err(GpuError::ShaderCompilation(
|
||||
"this detail chain expects a fused pass composed to hand on \
|
||||
linear working values, but the shader given encodes its own \
|
||||
output; compose both halves from the same graph"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
let view = match (shader.output_mode, shader.view.as_deref()) {
|
||||
(OutputMode::LinearWorking, Some(view)) => view,
|
||||
_ => {
|
||||
return Err(GpuError::ShaderCompilation(
|
||||
"this detail chain expects a fused pass composed to hand on \
|
||||
linear working values, with its view pass, but the shader \
|
||||
given encodes its own output; compose both halves from the \
|
||||
same graph"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let (width, height) = (width.max(1), height.max(1));
|
||||
self.ensure_target(width, height);
|
||||
@@ -868,6 +1129,7 @@ impl AdjustPass {
|
||||
// both want `&mut self`, and the second holds its borrow across the
|
||||
// encode below.
|
||||
self.pipeline(shader)?;
|
||||
self.pipeline(view)?;
|
||||
let colour_view = self
|
||||
.detail
|
||||
.colour_target(detail.len(), width, height)
|
||||
@@ -885,7 +1147,12 @@ impl AdjustPass {
|
||||
label: Some("adjust-detail-encoder"),
|
||||
});
|
||||
|
||||
let mut sampling = SampleUse::Direct;
|
||||
if !reuse {
|
||||
let mut uniforms = uniforms;
|
||||
sampling = self.sample.plan(&self.ctx, source, shader, width, height);
|
||||
SampleCache::flag(sampling, &mut uniforms);
|
||||
let (sampled, sample_out) = self.sample.views(sampling);
|
||||
let params_buf =
|
||||
self.ctx
|
||||
.device
|
||||
@@ -927,6 +1194,14 @@ impl AdjustPass {
|
||||
binding: 5,
|
||||
resource: wgpu::BindingResource::TextureView(&film_lut),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 6,
|
||||
resource: wgpu::BindingResource::TextureView(&sampled),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 7,
|
||||
resource: wgpu::BindingResource::TextureView(&sample_out),
|
||||
},
|
||||
],
|
||||
});
|
||||
let pipeline = self
|
||||
@@ -945,18 +1220,101 @@ impl AdjustPass {
|
||||
self.colour_dispatches += 1;
|
||||
}
|
||||
|
||||
// One encoder for the colour pass and every detail pass, submitted
|
||||
// once — the shape `MaskPass::render` established. Submission order is
|
||||
// the whole of the synchronisation: each pass reads what the previous
|
||||
// one wrote, through the same queue.
|
||||
// One encoder for the colour pass, every detail pass and the view pass,
|
||||
// submitted once — the shape `MaskPass::render` established.
|
||||
// Submission order is the whole of the synchronisation: each pass
|
||||
// reads what the previous one wrote, through the same queue.
|
||||
let (ran, result) = match self.detail.encode(&mut enc, detail, width, height) {
|
||||
Ok(done) => done,
|
||||
Err(e) => {
|
||||
// Nothing is submitted, so a cache this frame was to write
|
||||
// holds nothing, and must not be read as though it did.
|
||||
if sampling == SampleUse::Write {
|
||||
self.sample.release();
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
// TRACES: FR-DEV-3j
|
||||
// The view pass: the view transform and the output transform, after
|
||||
// every kernel (D19). Its own uniform block, filled from the source
|
||||
// like the fused pass's — it reads the non-linear flag and the film
|
||||
// settings there — with the sample cache off, because the colour it
|
||||
// reads is the detail stage's result, bound where the cache would be.
|
||||
let view_uniforms = Self::fused_uniforms(source, view);
|
||||
let view_params = self
|
||||
.ctx
|
||||
.device
|
||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("adjust-view-params"),
|
||||
contents: bytemuck::cast_slice(&view_uniforms),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
let (_, no_sample_out) = self.sample.views(SampleUse::Direct);
|
||||
let target_view = self.targets[self.current]
|
||||
.as_ref()
|
||||
.expect("ensured above")
|
||||
.view
|
||||
.clone();
|
||||
let ran = self
|
||||
.detail
|
||||
.encode(&mut enc, detail, &target_view, width, height)?;
|
||||
let view_bind_group = self
|
||||
.ctx
|
||||
.device
|
||||
.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("adjust-view-bg"),
|
||||
layout: &self.bind_group_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(source.view()),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: view_params.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::TextureView(&target_view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::TextureView(
|
||||
masks.map_or(&self.empty_masks, |m| m.view()),
|
||||
),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: wgpu::BindingResource::TextureView(&film_curves),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 5,
|
||||
resource: wgpu::BindingResource::TextureView(&film_lut),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 6,
|
||||
resource: wgpu::BindingResource::TextureView(&result),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 7,
|
||||
resource: wgpu::BindingResource::TextureView(&no_sample_out),
|
||||
},
|
||||
],
|
||||
});
|
||||
{
|
||||
let pipeline = self
|
||||
.cache
|
||||
.get(&view.structure_hash)
|
||||
.expect("compiled above");
|
||||
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("adjust-view-pass"),
|
||||
timestamp_writes: None,
|
||||
});
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_bind_group(0, &view_bind_group, &[]);
|
||||
pass.dispatch_workgroups(width.div_ceil(8), height.div_ceil(8), 1);
|
||||
}
|
||||
self.view_dispatches += 1;
|
||||
|
||||
self.ctx.queue.submit(Some(enc.finish()));
|
||||
self.detail_dispatches += ran;
|
||||
self.colour_key = Some((key, width, height));
|
||||
@@ -992,22 +1350,13 @@ impl AdjustPass {
|
||||
// runs. See `DemosaicedImage::is_non_linear`.
|
||||
let non_linear = if source.is_non_linear() { 1.0 } else { 0.0 };
|
||||
uniforms[12..16].copy_from_slice(&[wb[0], wb[1], wb[2], non_linear]);
|
||||
// TRACES: FR-DEV-3e
|
||||
// The camera profile's base curve, packed the way the generated block
|
||||
// declares it: four x, four y, then the fifth point and the flag. The
|
||||
// flag is what lets one compiled shader serve a profiled body and an
|
||||
// unprofiled one, so the pipeline cache is not split in two by which
|
||||
// camera took the frame.
|
||||
//
|
||||
// Written here rather than at the call site so that *both* callers —
|
||||
// the plain render and the masked one — carry the profile. Filling it
|
||||
// at one of them was how the two halves of this merge each had it.
|
||||
let curve = source.base_curve();
|
||||
let on = if curve.is_identity() { 0.0 } else { 1.0 };
|
||||
let b = dr_pipeline::BASE_CURVE_UNIFORM_OFFSET;
|
||||
uniforms[b..b + 4].copy_from_slice(&curve.xs[0..4]);
|
||||
uniforms[b + 4..b + 8].copy_from_slice(&curve.ys[0..4]);
|
||||
uniforms[b + 8..b + 12].copy_from_slice(&[curve.xs[4], curve.ys[4], on, 0.0]);
|
||||
// TRACES: FR-DSP-2 | NFR-RES-2
|
||||
// Which part of the photograph the texture holds. The whole of it for
|
||||
// every source that fits in one texture, which writes back exactly
|
||||
// what the composer put there.
|
||||
let w = dr_pipeline::SOURCE_WINDOW_UNIFORM_OFFSET;
|
||||
uniforms[w..w + dr_pipeline::SOURCE_WINDOW_UNIFORM_FIELDS]
|
||||
.copy_from_slice(&source.window_uniforms());
|
||||
uniforms
|
||||
}
|
||||
|
||||
@@ -1090,6 +1439,7 @@ impl AdjustPass {
|
||||
self.detail.release_caches();
|
||||
self.targets = [None, None];
|
||||
self.colour_key = None;
|
||||
self.sample.release();
|
||||
}
|
||||
|
||||
/// How many distinct pipelines are compiled. Exposed for tests asserting
|
||||
@@ -1120,6 +1470,13 @@ impl AdjustPass {
|
||||
self.detail_dispatches
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3j
|
||||
/// View passes encoded since this pass was created: one for every render
|
||||
/// that had a detail stage, since the view transform follows it.
|
||||
pub fn view_dispatches(&self) -> usize {
|
||||
self.view_dispatches
|
||||
}
|
||||
|
||||
/// How many linear intermediates have been allocated. For tests: see
|
||||
/// [`crate::MaskPass::allocations`] for the regression this catches.
|
||||
pub fn detail_allocations(&self) -> usize {
|
||||
@@ -1163,9 +1520,9 @@ impl AdjustPass {
|
||||
/// one: the storage format is in the layout. The profile uniforms are
|
||||
/// filled neutral here rather than from the source, which is the whole
|
||||
/// point of the mode (`OutputMode::CameraLinear`): unit white balance,
|
||||
/// identity matrix, base curve off. The non-linear flag is kept, so a
|
||||
/// JPEG source is still linearised — camera space for a JPEG is the
|
||||
/// decoded values made linear, which is the best that exists.
|
||||
/// identity matrix, and no view transform composed. The non-linear flag
|
||||
/// is kept, so a JPEG source is still linearised — camera space for a
|
||||
/// JPEG is the decoded values made linear, which is the best that exists.
|
||||
///
|
||||
/// The texture stays on the device for a merge's warp to sample; see
|
||||
/// [`Self::camera_texture`] and [`Self::read_camera_linear`].
|
||||
@@ -1194,8 +1551,6 @@ impl AdjustPass {
|
||||
uniforms[4..8].copy_from_slice(&[0.0, 1.0, 0.0, 0.0]);
|
||||
uniforms[8..12].copy_from_slice(&[0.0, 0.0, 1.0, 0.0]);
|
||||
uniforms[12..16].copy_from_slice(&[1.0, 1.0, 1.0, non_linear]);
|
||||
let b = dr_pipeline::BASE_CURVE_UNIFORM_OFFSET;
|
||||
uniforms[b + 10] = 0.0;
|
||||
|
||||
let params_buf = self
|
||||
.ctx
|
||||
@@ -1244,6 +1599,16 @@ impl AdjustPass {
|
||||
binding: 5,
|
||||
resource: wgpu::BindingResource::TextureView(self.film_lut_view()),
|
||||
},
|
||||
// The sample cache is the display's; a camera-space tap
|
||||
// reads its source directly (its flags are zero).
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 6,
|
||||
resource: wgpu::BindingResource::TextureView(&self.sample.no_sampled),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 7,
|
||||
resource: wgpu::BindingResource::TextureView(&self.sample.no_sample_out),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1413,7 +1778,7 @@ pub(crate) fn numbered(src: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use dr_decode::{BaseCurve, CfaPattern, CropRect, RawImage};
|
||||
use dr_decode::{CfaPattern, CropRect, RawImage};
|
||||
use dr_pipeline::ops::{colour_mixer, exposure, saturation};
|
||||
use dr_pipeline::EditGraph;
|
||||
// For `Operation::detail`, which is how `the_whole_chain_at_once_compiles`
|
||||
@@ -1451,7 +1816,6 @@ mod tests {
|
||||
// Identity, so the test reasons about the operations alone
|
||||
// rather than about a camera's colour response.
|
||||
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
||||
base_curve: BaseCurve::IDENTITY,
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
make: String::new(),
|
||||
@@ -1655,7 +2019,6 @@ mod tests {
|
||||
white_level: 16383,
|
||||
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
||||
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
||||
base_curve: BaseCurve::IDENTITY,
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
make: String::new(),
|
||||
@@ -1898,6 +2261,62 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-20
|
||||
#[test]
|
||||
fn a_keystone_reshapes_the_frame_without_exposing_a_corner() {
|
||||
// The shader half of perspective correction, end to end. A top-bright
|
||||
// frame with a full vertical keystone spreads its top across the
|
||||
// output, so the bright half reaches further down than the middle;
|
||||
// and since the frame is mapped onto a trapezoid *inside* the source,
|
||||
// no corner is left without a pixel behind it.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
let img = split_image(&ctx, true);
|
||||
|
||||
let plain = EditGraph::default_chain().compose();
|
||||
let tex = pass.render(&img, &plain, 32, 32).expect("render");
|
||||
let below_middle = read_pixel(&ctx, tex, 16, 19)[0];
|
||||
assert!(
|
||||
below_middle < 90,
|
||||
"unkeyed, row 19 is the dark half: {below_middle}"
|
||||
);
|
||||
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_param(
|
||||
dr_pipeline::framing::ID,
|
||||
dr_pipeline::framing::KEYSTONE_V,
|
||||
100.0,
|
||||
);
|
||||
g.set_param(
|
||||
dr_pipeline::framing::ID,
|
||||
dr_pipeline::framing::KEYSTONE_H,
|
||||
100.0,
|
||||
);
|
||||
let shader = g.compose();
|
||||
let tex = pass.render(&img, &shader, 32, 32).expect("render");
|
||||
|
||||
for (x, y) in [(0, 0), (31, 0), (0, 31), (31, 31)] {
|
||||
assert_ne!(
|
||||
read_pixel(&ctx, tex, x, y),
|
||||
[0, 0, 0, 255],
|
||||
"corner ({x},{y}) has no source pixel behind it"
|
||||
);
|
||||
}
|
||||
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_param(
|
||||
dr_pipeline::framing::ID,
|
||||
dr_pipeline::framing::KEYSTONE_V,
|
||||
100.0,
|
||||
);
|
||||
let tex = pass.render(&img, &g.compose(), 32, 32).expect("render");
|
||||
let keyed = read_pixel(&ctx, tex, 16, 19)[0];
|
||||
assert!(
|
||||
keyed > 128,
|
||||
"the spread top half must reach row 19: {keyed}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_the_crop_does_not_recompile() {
|
||||
// The cache contract for framing, which is what makes an interactive
|
||||
@@ -1971,7 +2390,9 @@ mod tests {
|
||||
lut: vec![[0.5, 0.5, 0.5]; N * N * N],
|
||||
density_max: 3.0,
|
||||
lut_size: N,
|
||||
grain_particles: [0.0; 3],
|
||||
push_stations: vec![0.0],
|
||||
paper: None,
|
||||
grain_particles: [[0.0; 3]; dr_pipeline::ops::film_sim::FORMAT_COUNT],
|
||||
grain_density_max: [3.0; 3],
|
||||
grain_uniformity: 0.97,
|
||||
}
|
||||
@@ -2029,7 +2450,7 @@ mod tests {
|
||||
// find those operations in neither stage and fail for a reason that is
|
||||
// not a defect. Shadows the smaller size deliberately.
|
||||
let (w, h) = g.output_size(512, 512);
|
||||
let detail = g.compose_detail_for((512, 512), (w, h), dr_types::ColourSpace::Srgb);
|
||||
let detail = g.compose_detail((512, 512), (w, h));
|
||||
assert!(
|
||||
!detail.is_empty(),
|
||||
"the detail half composed nothing, so nothing of it was compiled"
|
||||
@@ -2049,7 +2470,20 @@ mod tests {
|
||||
let mut fused_blocks = 0;
|
||||
for desc in g.descriptors() {
|
||||
let id = desc.id.0;
|
||||
let point = shader.source.contains(&format!("---- {id} ----"));
|
||||
// A stock is loaded here, and a stock is a rendering: the view
|
||||
// transform it replaces is correctly in neither half (FR-DEV-3j).
|
||||
if id == dr_pipeline::ops::view_transform::ID.0 {
|
||||
assert!(!shader.source.contains("---- view_transform ----"));
|
||||
continue;
|
||||
}
|
||||
// A view operation is in the view pass when a detail stage
|
||||
// follows, which it does here (D19).
|
||||
let block = format!("---- {id} ----");
|
||||
let point = shader.source.contains(&block)
|
||||
|| shader
|
||||
.view
|
||||
.as_ref()
|
||||
.is_some_and(|v| v.source.contains(&block));
|
||||
let neighbourhood = detail
|
||||
.passes
|
||||
.iter()
|
||||
@@ -2083,8 +2517,15 @@ mod tests {
|
||||
// because the two catch different faults: the XOR catches an operation
|
||||
// in the wrong stage, this catches a block in the shader that nothing
|
||||
// in the chain asked for.
|
||||
//
|
||||
// The view pass repeats the prologue — framing and the warps — for
|
||||
// the positions it publishes, so only its operation blocks count.
|
||||
let view_blocks = shader
|
||||
.view
|
||||
.as_ref()
|
||||
.map_or(0, |v| v.source.matches("---- ").count() - (warp_blocks + 1));
|
||||
assert_eq!(
|
||||
shader.source.matches("---- ").count(),
|
||||
shader.source.matches("---- ").count() + view_blocks,
|
||||
fused_blocks + warp_blocks + 1,
|
||||
"the fused shader carries a block nothing in the chain asked for"
|
||||
);
|
||||
@@ -2186,7 +2627,6 @@ mod tests {
|
||||
white_level: 16383,
|
||||
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
||||
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
||||
base_curve: BaseCurve::IDENTITY,
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
make: String::new(),
|
||||
@@ -2290,7 +2730,6 @@ mod tests {
|
||||
white_level: 16383,
|
||||
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
||||
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
||||
base_curve: BaseCurve::IDENTITY,
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
make: String::new(),
|
||||
@@ -2568,6 +3007,131 @@ mod tests {
|
||||
|
||||
/// A flat RGBA8 image on the JPEG path — already gamma-encoded, as a
|
||||
/// decoded JPEG is.
|
||||
/// A frame with a different value at every pixel, several times the size
|
||||
/// of the renders below, so that a fit view reads it on a stride and a
|
||||
/// texel read from the wrong place cannot go unnoticed.
|
||||
fn busy_image(ctx: &GpuContext) -> DemosaicedImage {
|
||||
let (w, h) = (97u32, 61u32);
|
||||
let mut data = Vec::with_capacity((w * h * 4) as usize);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let n = (x.wrapping_mul(2_654_435_761) ^ y.wrapping_mul(1_640_531_527)) >> 7;
|
||||
data.extend_from_slice(&[n as u8, (n >> 8) as u8, (x * 2 + y) as u8, 255]);
|
||||
}
|
||||
}
|
||||
DemosaicedImage::from_rgba8(ctx, &data, w, h).expect("upload")
|
||||
}
|
||||
|
||||
/// Render `g` with a pass that has never seen it, which reads the source
|
||||
/// directly by construction: the reference a cached frame must equal.
|
||||
fn fresh(ctx: &GpuContext, img: &DemosaicedImage, g: &EditGraph) -> Vec<u8> {
|
||||
let mut pass = AdjustPass::new(ctx);
|
||||
let detail = g.compose_detail(img.size(), (23, 15));
|
||||
pass.render_detailed(img, &g.compose(), 23, 15, None, &detail, 1)
|
||||
.expect("render");
|
||||
assert_eq!(pass.sample.last_use, SampleUse::Direct);
|
||||
pass.export_pixels().expect("read").0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cached_sample_is_the_same_picture() {
|
||||
// TRACES: NFR-P5
|
||||
// The sample cache's whole claim: a frame that reads the source through
|
||||
// it is bit-for-bit the frame that reads the source directly. Walked
|
||||
// through each state — direct, writing, reading, reading after a
|
||||
// slider moved, and direct again once the framing moves — against a
|
||||
// fresh pass each time.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let img = busy_image(&ctx);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
let mut g = EditGraph::default_chain();
|
||||
|
||||
let frame = |pass: &mut AdjustPass, g: &EditGraph, key: u64| {
|
||||
let detail = g.compose_detail(img.size(), (23, 15));
|
||||
pass.render_detailed(&img, &g.compose(), 23, 15, None, &detail, key)
|
||||
.expect("render");
|
||||
(pass.sample.last_use, pass.export_pixels().expect("read").0)
|
||||
};
|
||||
|
||||
for (i, (expected, value)) in [
|
||||
(SampleUse::Direct, 0.3),
|
||||
(SampleUse::Write, 0.4),
|
||||
(SampleUse::Read, 0.5),
|
||||
(SampleUse::Read, -0.7),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
g.set_param(exposure::ID, exposure::EXPOSURE, value);
|
||||
let (used, pixels) = frame(&mut pass, &g, i as u64);
|
||||
assert_eq!(used, expected, "frame {i}");
|
||||
assert_eq!(pixels, fresh(&ctx, &img, &g), "frame {i} ({used:?})");
|
||||
}
|
||||
|
||||
// A neighbourhood operation: the fused pass writes the linear
|
||||
// intermediate instead, through the same sampling.
|
||||
g.set_param(
|
||||
dr_pipeline::ops::noise_reduction::ID,
|
||||
dr_pipeline::ops::noise_reduction::CHROMA,
|
||||
60.0,
|
||||
);
|
||||
for i in 10..13 {
|
||||
g.set_param(exposure::ID, exposure::EXPOSURE, i as f32 * 0.01);
|
||||
let (used, pixels) = frame(&mut pass, &g, i);
|
||||
assert_eq!(used, SampleUse::Read, "detail frame {i}");
|
||||
assert_eq!(pixels, fresh(&ctx, &img, &g), "detail frame {i}");
|
||||
}
|
||||
|
||||
// The framing moves: what was cached is for the old framing.
|
||||
g.set_param(dr_pipeline::framing::ID, dr_pipeline::framing::CROP_W, 0.6);
|
||||
let (used, pixels) = frame(&mut pass, &g, 20);
|
||||
assert_eq!(used, SampleUse::Direct, "a new framing reads directly");
|
||||
assert_eq!(pixels, fresh(&ctx, &img, &g));
|
||||
let (used, _) = frame(&mut pass, &g, 21);
|
||||
assert_eq!(used, SampleUse::Write, "and caches once it holds still");
|
||||
let (used, pixels) = frame(&mut pass, &g, 22);
|
||||
assert_eq!(used, SampleUse::Read);
|
||||
assert_eq!(pixels, fresh(&ctx, &img, &g));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cache_is_not_read_for_another_image_or_size() {
|
||||
// The key is the composer's half plus the two things only this side
|
||||
// knows. A second photograph with the same edit and the same framing
|
||||
// must not be shown the first one's texels.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let (a, b) = (busy_image(&ctx), grey_image(&ctx, 8000));
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
let shader = EditGraph::default_chain().compose();
|
||||
for _ in 0..3 {
|
||||
pass.render(&a, &shader, 23, 15).expect("render");
|
||||
}
|
||||
assert_eq!(pass.sample.last_use, SampleUse::Read);
|
||||
|
||||
pass.render(&b, &shader, 23, 15).expect("render");
|
||||
assert_eq!(pass.sample.last_use, SampleUse::Direct, "another image");
|
||||
pass.render(&b, &shader, 23, 15).expect("render");
|
||||
pass.render(&b, &shader, 24, 15).expect("render");
|
||||
assert_eq!(pass.sample.last_use, SampleUse::Direct, "another size");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_straightened_frame_samples_directly() {
|
||||
// Interpolated: the sample is a blend of four texels, which the cache's
|
||||
// format could not hold exactly, so the composer offers no key.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let img = busy_image(&ctx);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_param(dr_pipeline::framing::ID, dr_pipeline::framing::ANGLE, 3.0);
|
||||
let shader = g.compose();
|
||||
assert!(shader.sample_key.is_none());
|
||||
for _ in 0..3 {
|
||||
pass.render(&img, &shader, 23, 15).expect("render");
|
||||
assert_eq!(pass.sample.last_use, SampleUse::Direct);
|
||||
}
|
||||
}
|
||||
|
||||
fn jpeg_image(ctx: &GpuContext, rgb: [u8; 3]) -> DemosaicedImage {
|
||||
let size = 16u32;
|
||||
let mut data = Vec::with_capacity((size * size) as usize * 4);
|
||||
@@ -2750,11 +3314,16 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_jpeg_and_sensor_data_agree_on_the_same_scene_value() {
|
||||
// The two producers must be interchangeable. A mid-grey that is
|
||||
// linearly 0.216 (sRGB 128) arriving as sensor data and as a JPEG
|
||||
// must render the same, or an edit would mean different things
|
||||
// depending on which decoder opened the file.
|
||||
fn a_jpeg_and_sensor_data_differ_by_exactly_the_view_transform() {
|
||||
// TRACES: FR-DEV-3j
|
||||
// The two producers must be interchangeable up to the rendering. A
|
||||
// mid-grey that is linearly 0.216 (sRGB 128) arriving as sensor data
|
||||
// is scene-referred and goes through the view transform; arriving as
|
||||
// a JPEG it is already a rendering and must come out as it went in.
|
||||
// Before D19 the fixture's identity base curve made both unrendered
|
||||
// and this asserted they matched; what it guards is unchanged — the
|
||||
// linearisation of each agrees — but the rendering between them is
|
||||
// now always there for sensor data.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
let shader = EditGraph::default_chain().compose();
|
||||
@@ -2773,11 +3342,18 @@ mod tests {
|
||||
read_centre(&ctx, t)
|
||||
};
|
||||
|
||||
let delta = (i32::from(from_sensor[0]) - i32::from(from_jpeg[0])).abs();
|
||||
let scene = 3537.0 / 16383.0;
|
||||
let viewed = dr_pipeline::view::Sigmoid::default_curve().channel(scene);
|
||||
let expected = (dr_types::Transfer::Srgb.encode(viewed) * 255.0).round() as i32;
|
||||
let delta = (i32::from(from_sensor[0]) - expected).abs();
|
||||
assert!(
|
||||
delta <= 3,
|
||||
"the same scene value rendered {from_sensor:?} from sensor data \
|
||||
and {from_jpeg:?} from a JPEG"
|
||||
"sensor data rendered {from_sensor:?}, expected about {expected}"
|
||||
);
|
||||
let delta = (i32::from(from_jpeg[0]) - 128).abs();
|
||||
assert!(
|
||||
delta <= 3,
|
||||
"a JPEG was rendered again: {from_jpeg:?} from sRGB 128"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+431
-44
@@ -10,7 +10,7 @@
|
||||
//! pass over this texture; it does not re-demosaic, which is what keeps the
|
||||
//! interaction budget (NFR-P9) reachable on a 24 MP file.
|
||||
|
||||
use dr_decode::{BaseCurve, CfaPattern, RawImage};
|
||||
use dr_decode::{CfaPattern, RawImage};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
use crate::{GpuContext, GpuError};
|
||||
@@ -57,6 +57,32 @@ struct XTransParams {
|
||||
tile: [u32; 4],
|
||||
}
|
||||
|
||||
/// Uniform block for the hot-pixel repair. Layout must match
|
||||
/// `hot_pixels.wgsl`.
|
||||
///
|
||||
/// One block for both colour filter arrays: the repair asks only "which
|
||||
/// photosites share this one's colour", and a 6×6 tile answers that for a
|
||||
/// Bayer cell as well as for X-Trans.
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct HotPixelParams {
|
||||
crop_x: u32,
|
||||
crop_y: u32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
stride: u32,
|
||||
words: u32,
|
||||
row_invocations: u32,
|
||||
samples: u32,
|
||||
black: [f32; 4],
|
||||
inv_range: [f32; 4],
|
||||
tile: [u32; 4],
|
||||
}
|
||||
|
||||
/// The repair's workgroup width. Must match `@workgroup_size` in
|
||||
/// `hot_pixels.wgsl`.
|
||||
const HOT_PIXEL_GROUP: u32 = 64;
|
||||
|
||||
/// A demosaiced image living on the GPU.
|
||||
///
|
||||
/// RGBA16Float, scene-referred, camera colour space. This is the input every
|
||||
@@ -83,18 +109,29 @@ pub struct DemosaicedImage {
|
||||
color_matrix: [f32; 9],
|
||||
/// As-shot white balance, the neutral starting point for the WB control.
|
||||
as_shot_wb: [f32; 3],
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// The camera profile's rendering curve, carried through for the adjust
|
||||
/// pass exactly as `color_matrix` is.
|
||||
///
|
||||
/// It rides on the image rather than on the edit graph because it is not
|
||||
/// an edit: it belongs to the body that took the frame, the way the
|
||||
/// masked-photosite crop and the EXIF orientation do, and a sidecar shared
|
||||
/// between two bodies must never carry one body's rendering onto the
|
||||
/// other's file (FR-NC-9).
|
||||
base_curve: BaseCurve,
|
||||
/// Whether the texture holds gamma-encoded rather than linear values.
|
||||
non_linear: bool,
|
||||
/// Which upload this is, unique for the life of the process. See
|
||||
/// [`Self::id`].
|
||||
id: u64,
|
||||
/// TRACES: FR-DSP-2 | NFR-RES-2
|
||||
/// The whole frame's size in pixels — what [`Self::size`] reports.
|
||||
/// The texture's own size when it holds the whole frame at full
|
||||
/// resolution, which is every photograph that fits in one.
|
||||
frame: (u32, u32),
|
||||
/// Which part of the frame the texture holds, as origin and extent in
|
||||
/// normalised frame coordinates. `[0, 0, 1, 1]` for the whole frame,
|
||||
/// reduced or not. See [`Self::window_uniforms`].
|
||||
window: [f32; 4],
|
||||
}
|
||||
|
||||
/// The window of a texture that holds the whole frame.
|
||||
const WHOLE_FRAME: [f32; 4] = [0.0, 0.0, 1.0, 1.0];
|
||||
|
||||
/// The next [`DemosaicedImage::id`].
|
||||
fn next_image_id() -> u64 {
|
||||
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
impl DemosaicedImage {
|
||||
@@ -108,25 +145,68 @@ impl DemosaicedImage {
|
||||
&self.view
|
||||
}
|
||||
|
||||
/// The size of the photograph this stands for, in its own pixels.
|
||||
///
|
||||
/// **Not necessarily the texture's.** For a photograph larger than one
|
||||
/// texture this is a reduced copy of it or a window cut from it, and
|
||||
/// everything that sizes a render, a crop or a kernel has to go on
|
||||
/// measuring the photograph. What indexes the texture's texels asks
|
||||
/// [`Self::texture_size`] instead.
|
||||
pub fn size(&self) -> (u32, u32) {
|
||||
self.frame
|
||||
}
|
||||
|
||||
/// The texture's own size in texels.
|
||||
pub fn texture_size(&self) -> (u32, u32) {
|
||||
(self.width, self.height)
|
||||
}
|
||||
|
||||
/// TRACES: FR-DSP-2 | NFR-RES-2
|
||||
/// The source window uniforms the fused shader reads, in the order
|
||||
/// `dr_pipeline::SOURCE_WINDOW_UNIFORM_FIELDS` declares them.
|
||||
///
|
||||
/// The second `vec4` is zero for a texture that holds the whole frame at
|
||||
/// full resolution, so the shader measures the texture itself exactly as
|
||||
/// it did before windows existed.
|
||||
pub fn window_uniforms(&self) -> [f32; dr_pipeline::SOURCE_WINDOW_UNIFORM_FIELDS] {
|
||||
let [x, y, w, h] = self.window;
|
||||
let (fw, fh) = if self.is_whole() {
|
||||
(0.0, 0.0)
|
||||
} else {
|
||||
(self.frame.0 as f32, self.frame.1 as f32)
|
||||
};
|
||||
[x, y, w, h, fw, fh, 0.0, 0.0]
|
||||
}
|
||||
|
||||
/// Whether the texture is the whole frame at full resolution.
|
||||
pub fn is_whole(&self) -> bool {
|
||||
self.window == WHOLE_FRAME && self.frame == (self.width, self.height)
|
||||
}
|
||||
|
||||
/// The window this texture holds, as origin and extent in normalised
|
||||
/// frame coordinates.
|
||||
pub fn window(&self) -> [f32; 4] {
|
||||
self.window
|
||||
}
|
||||
|
||||
/// Which texture this is, as a number that is never reused.
|
||||
///
|
||||
/// For a cache that has to know it is still looking at the same pixels
|
||||
/// (`AdjustPass`'s sample cache) without holding the texture alive to find
|
||||
/// out: keeping a handle would keep half a gigabyte of a closed photograph
|
||||
/// on the device, and comparing addresses would mistake a new upload for an
|
||||
/// old one the moment the allocator reused the slot. A texture here is
|
||||
/// never written after it is built, so the same id is the same pixels.
|
||||
pub(crate) fn id(&self) -> u64 {
|
||||
self.id
|
||||
}
|
||||
|
||||
/// Camera RGB → linear sRGB, row-major. Identity where the body is
|
||||
/// uncalibrated, so the image renders uncalibrated rather than black.
|
||||
pub fn color_matrix(&self) -> [f32; 9] {
|
||||
self.color_matrix
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// The camera profile's base curve, as five `(x, y)` points.
|
||||
///
|
||||
/// [`BaseCurve::IDENTITY`] where the body is unprofiled or the source was
|
||||
/// never raw, in which case the adjust pass skips the stage entirely.
|
||||
pub fn base_curve(&self) -> BaseCurve {
|
||||
self.base_curve
|
||||
}
|
||||
|
||||
/// As-shot white balance multipliers, green-normalised.
|
||||
///
|
||||
/// The white balance control is expressed *relative* to these, so its
|
||||
@@ -240,12 +320,15 @@ impl DemosaicedImage {
|
||||
color_matrix: IDENTITY_3X3,
|
||||
as_shot_wb: [1.0, 1.0, 1.0],
|
||||
// **The identity, and this is the whole reason the field is here
|
||||
// rather than resolved further down.** A JPEG has already had its
|
||||
// camera's base curve baked in by the camera; applying one again
|
||||
// would render the rendering, crushing the shadows and flattening
|
||||
// the highlights of an image that was already finished.
|
||||
base_curve: BaseCurve::IDENTITY,
|
||||
// rather than resolved further down.** A JPEG has already been
|
||||
// rendered by the camera; the view transform skips a source
|
||||
// flagged non-linear, since rendering the rendering would crush
|
||||
// the shadows and flatten the highlights of an image that was
|
||||
// already finished.
|
||||
non_linear: true,
|
||||
id: next_image_id(),
|
||||
frame: (width, height),
|
||||
window: WHOLE_FRAME,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -256,11 +339,46 @@ impl DemosaicedImage {
|
||||
/// what a merge writes. No demosaic; the samples are normalised by the
|
||||
/// file's black and white levels exactly as the demosaic kernel would
|
||||
/// normalise a photosite, and everything else — the matrix, the
|
||||
/// balance, the body's base curve — is carried through as for a CFA
|
||||
/// balance, the view transform — is carried through as for a CFA
|
||||
/// file, because the composite is developed as one photograph from the
|
||||
/// body that took its sources.
|
||||
pub fn from_linear_rgb16(ctx: &GpuContext, raw: &RawImage) -> Result<Self, GpuError> {
|
||||
let (width, height) = (raw.crop.width.max(1), raw.crop.height.max(1));
|
||||
Self::linear_rgb16_window(ctx, raw, [0, 0, width, height], 1)
|
||||
}
|
||||
|
||||
/// TRACES: FR-DSP-2 | NFR-RES-2
|
||||
/// Part of a linear DNG, or a reduced copy of it, for a photograph too
|
||||
/// large to hold in one texture.
|
||||
///
|
||||
/// `region` is `[x, y, width, height]` in pixels of the frame (the
|
||||
/// file's crop), clamped to it. `reduce` averages `reduce × reduce`
|
||||
/// blocks into one texel — a box filter, which is what a reduced copy
|
||||
/// that is only ever displayed smaller than itself needs, and which keeps
|
||||
/// the samples in scene-linear light where an average means something.
|
||||
///
|
||||
/// The texture then knows where it sits ([`Self::window`]) and how large
|
||||
/// the photograph is ([`Self::size`]), and the fused shader maps each
|
||||
/// output pixel's position in the *photograph* into it. So a crop, a
|
||||
/// rotation or a mask drawn on the reduced copy lands on the same pixels
|
||||
/// of a full-resolution window, and an export in tiles is the same
|
||||
/// picture as one that fitted.
|
||||
///
|
||||
/// Refused only if the result itself does not fit the device.
|
||||
pub fn linear_rgb16_window(
|
||||
ctx: &GpuContext,
|
||||
raw: &RawImage,
|
||||
region: [u32; 4],
|
||||
reduce: u32,
|
||||
) -> Result<Self, GpuError> {
|
||||
let frame = (raw.crop.width.max(1), raw.crop.height.max(1));
|
||||
let k = reduce.max(1);
|
||||
let x0 = region[0].min(frame.0 - 1);
|
||||
let y0 = region[1].min(frame.1 - 1);
|
||||
let rw = region[2].clamp(1, frame.0 - x0);
|
||||
let rh = region[3].clamp(1, frame.1 - y0);
|
||||
let (width, height) = (rw.div_ceil(k), rh.div_ceil(k));
|
||||
|
||||
let limits = ctx.device.limits();
|
||||
if width > limits.max_texture_dimension_2d || height > limits.max_texture_dimension_2d {
|
||||
return Err(GpuError::TooLarge(format!(
|
||||
@@ -280,19 +398,49 @@ impl DemosaicedImage {
|
||||
}
|
||||
let black = black_per_cell(raw);
|
||||
let inv = inv_range_per_cell(raw);
|
||||
// Per channel rather than per CFA cell: R, G, B are the first three.
|
||||
let mut half: Vec<u16> = Vec::with_capacity((width * height * 4) as usize);
|
||||
for y in 0..height as usize {
|
||||
let row = (raw.crop.y as usize + y) * stride + raw.crop.x as usize * 3;
|
||||
for x in 0..width as usize {
|
||||
let p = &raw.data[row + x * 3..row + x * 3 + 3];
|
||||
for c in 0..3 {
|
||||
let v = (f32::from(p[c]) - black[c]) * inv[c];
|
||||
half.push(f32_to_f16_bits_unclamped(v));
|
||||
|
||||
// One output row per task: a 200-megapixel reduction is a second of
|
||||
// one core, and the rows are independent.
|
||||
let row_texels = width as usize * 4;
|
||||
let mut half = vec![0u16; row_texels * height as usize];
|
||||
let fill_row = |ty: usize, out: &mut [u16]| {
|
||||
let sy0 = y0 as usize + ty * k as usize;
|
||||
let sy1 = (sy0 + k as usize).min((y0 + rh) as usize);
|
||||
for tx in 0..width as usize {
|
||||
let sx0 = x0 as usize + tx * k as usize;
|
||||
let sx1 = (sx0 + k as usize).min((x0 + rw) as usize);
|
||||
let mut acc = [0f32; 3];
|
||||
for sy in sy0..sy1 {
|
||||
let row = (raw.crop.y as usize + sy) * stride + raw.crop.x as usize * 3;
|
||||
for sx in sx0..sx1 {
|
||||
let p = &raw.data[row + sx * 3..row + sx * 3 + 3];
|
||||
for c in 0..3 {
|
||||
acc[c] += f32::from(p[c]);
|
||||
}
|
||||
}
|
||||
}
|
||||
half.push(f32_to_f16_bits(1.0));
|
||||
let n = ((sy1 - sy0) * (sx1 - sx0)).max(1) as f32;
|
||||
let texel = &mut out[tx * 4..tx * 4 + 4];
|
||||
for c in 0..3 {
|
||||
let v = (acc[c] / n - black[c]) * inv[c];
|
||||
texel[c] = f32_to_f16_bits_unclamped(v);
|
||||
}
|
||||
texel[3] = f32_to_f16_bits(1.0);
|
||||
}
|
||||
}
|
||||
};
|
||||
let threads = std::thread::available_parallelism().map_or(1, |n| n.get());
|
||||
let rows_per = (height as usize).div_ceil(threads).max(1);
|
||||
std::thread::scope(|scope| {
|
||||
for (chunk, rows) in half.chunks_mut(rows_per * row_texels).enumerate() {
|
||||
let fill_row = &fill_row;
|
||||
scope.spawn(move || {
|
||||
for (i, out) in rows.chunks_mut(row_texels).enumerate() {
|
||||
fill_row(chunk * rows_per + i, out);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let texture = ctx.device.create_texture_with_data(
|
||||
&ctx.queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
@@ -313,6 +461,17 @@ impl DemosaicedImage {
|
||||
bytemuck::cast_slice(&half),
|
||||
);
|
||||
let view = texture.create_view(&Default::default());
|
||||
// The extent is the texels' own, `width × k`, not the region's: the
|
||||
// last block of a reduction may run past the frame's edge, and
|
||||
// stretching it to fit would put every texel slightly off the
|
||||
// pixels it averaged. The shader's bounds test is on the frame, so
|
||||
// nothing past the edge is ever read.
|
||||
let window = [
|
||||
x0 as f32 / frame.0 as f32,
|
||||
y0 as f32 / frame.1 as f32,
|
||||
(width * k) as f32 / frame.0 as f32,
|
||||
(height * k) as f32 / frame.1 as f32,
|
||||
];
|
||||
Ok(Self {
|
||||
texture,
|
||||
view,
|
||||
@@ -320,8 +479,10 @@ impl DemosaicedImage {
|
||||
height,
|
||||
color_matrix: raw.color_matrix.unwrap_or(IDENTITY_3X3),
|
||||
as_shot_wb: [raw.wb_coeffs[0], raw.wb_coeffs[1], raw.wb_coeffs[2]],
|
||||
base_curve: raw.base_curve,
|
||||
non_linear: false,
|
||||
id: next_image_id(),
|
||||
frame,
|
||||
window,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -414,6 +575,8 @@ pub struct Demosaicer {
|
||||
pipeline: wgpu::ComputePipeline,
|
||||
xtrans_pipeline: wgpu::ComputePipeline,
|
||||
bind_group_layout: wgpu::BindGroupLayout,
|
||||
hot_pixel_pipeline: wgpu::ComputePipeline,
|
||||
hot_pixel_layout: wgpu::BindGroupLayout,
|
||||
}
|
||||
|
||||
impl Demosaicer {
|
||||
@@ -504,11 +667,15 @@ impl Demosaicer {
|
||||
cache: None,
|
||||
});
|
||||
|
||||
let (hot_pixel_pipeline, hot_pixel_layout) = hot_pixel_pipeline(ctx);
|
||||
|
||||
Ok(Self {
|
||||
ctx: ctx.clone(),
|
||||
pipeline,
|
||||
xtrans_pipeline,
|
||||
bind_group_layout,
|
||||
hot_pixel_pipeline,
|
||||
hot_pixel_layout,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -535,8 +702,12 @@ impl Demosaicer {
|
||||
// the buffer outlive the `if` that chose them.
|
||||
let bayer_params;
|
||||
let xtrans_params;
|
||||
// Kept for the hot-pixel repair: finding the X-Trans phase reads the
|
||||
// whole frame on the CPU, and once per photograph is enough.
|
||||
let mut xtrans_tile = None;
|
||||
let (pipeline, params_bytes) = if raw.cfa_pattern.is_xtrans() {
|
||||
xtrans_params = xtrans_params_for(raw, width, height);
|
||||
xtrans_tile = Some(xtrans_params.tile);
|
||||
(&self.xtrans_pipeline, bytemuck::bytes_of(&xtrans_params))
|
||||
} else {
|
||||
let pattern = match raw.cfa_pattern {
|
||||
@@ -575,6 +746,64 @@ impl Demosaicer {
|
||||
usage: wgpu::BufferUsages::STORAGE,
|
||||
});
|
||||
|
||||
// TRACES: FR-RAW-3
|
||||
// The mosaic the demosaic actually reads: the readout with its hot and
|
||||
// dead photosites repaired. A second buffer rather than in place,
|
||||
// because every photosite's verdict reads its neighbours' originals.
|
||||
let repaired = self.ctx.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("raw-repaired"),
|
||||
size: raw_buf.size(),
|
||||
usage: wgpu::BufferUsages::STORAGE,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let words = packed.len() as u32;
|
||||
let groups = words.div_ceil(HOT_PIXEL_GROUP).max(1);
|
||||
// A 24 MP readout is 190,000 workgroups, past the 65,535 one
|
||||
// dispatch dimension may hold, so the grid folds into rows.
|
||||
let groups_x = groups.min(
|
||||
self.ctx
|
||||
.device
|
||||
.limits()
|
||||
.max_compute_workgroups_per_dimension,
|
||||
);
|
||||
let groups_y = groups.div_ceil(groups_x);
|
||||
let hot_params = hot_pixel_params(
|
||||
raw,
|
||||
(width, height),
|
||||
words,
|
||||
groups_x * HOT_PIXEL_GROUP,
|
||||
xtrans_tile,
|
||||
);
|
||||
let hot_params_buf =
|
||||
self.ctx
|
||||
.device
|
||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("hot-pixel-params"),
|
||||
contents: bytemuck::bytes_of(&hot_params),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
let hot_bind_group = self
|
||||
.ctx
|
||||
.device
|
||||
.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("hot-pixel-bg"),
|
||||
layout: &self.hot_pixel_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: raw_buf.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: hot_params_buf.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: repaired.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let params_buf = self
|
||||
.ctx
|
||||
.device
|
||||
@@ -613,7 +842,7 @@ impl Demosaicer {
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: raw_buf.as_entire_binding(),
|
||||
resource: repaired.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
@@ -632,6 +861,18 @@ impl Demosaicer {
|
||||
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||
label: Some("demosaic-encoder"),
|
||||
});
|
||||
// Two passes in one submission. wgpu orders a storage write in one
|
||||
// pass before a read of the same buffer in the next, so the demosaic
|
||||
// sees every repair.
|
||||
{
|
||||
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("hot-pixel-pass"),
|
||||
timestamp_writes: None,
|
||||
});
|
||||
pass.set_pipeline(&self.hot_pixel_pipeline);
|
||||
pass.set_bind_group(0, &hot_bind_group, &[]);
|
||||
pass.dispatch_workgroups(groups_x, groups_y, 1);
|
||||
}
|
||||
{
|
||||
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("demosaic-pass"),
|
||||
@@ -655,11 +896,13 @@ impl Demosaicer {
|
||||
// Whatever the profile database had for this body (FR-DEV-3e),
|
||||
// resolved at decode because that is the only place the make and
|
||||
// model are known.
|
||||
base_curve: raw.base_curve,
|
||||
// Sensor data is linear by construction — the demosaic shader
|
||||
// normalises against black and white levels and applies no
|
||||
// transfer function.
|
||||
non_linear: false,
|
||||
id: next_image_id(),
|
||||
frame: (width, height),
|
||||
window: WHOLE_FRAME,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -936,6 +1179,136 @@ fn detect_xtrans_phase(raw: &RawImage) -> (u32, u32) {
|
||||
|
||||
/// TRACES: FR-RAW-5
|
||||
/// Everything the X-Trans shader needs about one image.
|
||||
/// TRACES: FR-RAW-3
|
||||
/// The hot-pixel repair's pipeline and its three bindings: the readout, the
|
||||
/// uniform block, and the repaired copy it writes.
|
||||
fn hot_pixel_pipeline(ctx: &GpuContext) -> (wgpu::ComputePipeline, wgpu::BindGroupLayout) {
|
||||
let shader = ctx
|
||||
.device
|
||||
.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("hot-pixels"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/hot_pixels.wgsl").into()),
|
||||
});
|
||||
let storage = |binding, read_only| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Storage { read_only },
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
};
|
||||
let layout = ctx
|
||||
.device
|
||||
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("hot-pixel-bgl"),
|
||||
entries: &[
|
||||
storage(0, true),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
storage(2, false),
|
||||
],
|
||||
});
|
||||
let pipeline_layout = ctx
|
||||
.device
|
||||
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("hot-pixel-layout"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let pipeline = ctx
|
||||
.device
|
||||
.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("hot-pixel-pipeline"),
|
||||
layout: Some(&pipeline_layout),
|
||||
module: &shader,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
(pipeline, layout)
|
||||
}
|
||||
|
||||
/// The colour of each position of a Bayer cell, row-major, for the pattern
|
||||
/// the decoder reported: 0=R, 1=G, 2=B. `None` for anything that is not a
|
||||
/// 2×2 pattern.
|
||||
fn bayer_cell(pattern: CfaPattern) -> Option<[u32; 4]> {
|
||||
match pattern {
|
||||
CfaPattern::Rggb => Some([0, 1, 1, 2]),
|
||||
CfaPattern::Bggr => Some([2, 1, 1, 0]),
|
||||
CfaPattern::Grbg => Some([1, 0, 2, 1]),
|
||||
CfaPattern::Gbrg => Some([1, 2, 0, 1]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A Bayer cell as the 6×6 sensor-anchored tile the repair indexes.
|
||||
///
|
||||
/// The decoder's pattern is phased for the *crop* origin, and the tile is
|
||||
/// indexed by sensor coordinate, so each position is shifted by the crop.
|
||||
/// Six is even, so a column's parity modulo 6 is its parity outright and the
|
||||
/// cell repeats cleanly.
|
||||
fn pack_bayer_tile(cell: [u32; 4], crop_x: u32, crop_y: u32) -> [u32; 4] {
|
||||
let mut out = [0u32; 4];
|
||||
for row in 0..6u32 {
|
||||
for col in 0..6u32 {
|
||||
let i = (((row + crop_y) & 1) * 2 + ((col + crop_x) & 1)) as usize;
|
||||
out[(row >> 1) as usize] |= cell[i] << ((row & 1) * 12 + col * 2);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The repair's uniforms for one readout.
|
||||
///
|
||||
/// `xtrans_tile` is the tile the X-Trans demosaic was given, when it was one;
|
||||
/// anything else must be a Bayer pattern, which `run` has already checked.
|
||||
fn hot_pixel_params(
|
||||
raw: &RawImage,
|
||||
(width, height): (u32, u32),
|
||||
words: u32,
|
||||
row_invocations: u32,
|
||||
xtrans_tile: Option<[u32; 4]>,
|
||||
) -> HotPixelParams {
|
||||
let (black, inv_range, tile) = match (xtrans_tile, bayer_cell(raw.cfa_pattern)) {
|
||||
(Some(tile), _) => {
|
||||
let (black, inv_range) = xtrans_levels(raw);
|
||||
([black; 4], [inv_range; 4], tile)
|
||||
}
|
||||
(None, Some(cell)) => (
|
||||
black_per_cell(raw),
|
||||
inv_range_per_cell(raw),
|
||||
pack_bayer_tile(cell, raw.crop.x, raw.crop.y),
|
||||
),
|
||||
// Not reached from `run`, which refuses any other pattern before
|
||||
// this. A zero tile judges every photosite against all of its
|
||||
// neighbours, which is right for a sensor with no colour filter.
|
||||
(None, None) => (black_per_cell(raw), inv_range_per_cell(raw), [0; 4]),
|
||||
};
|
||||
HotPixelParams {
|
||||
crop_x: raw.crop.x,
|
||||
crop_y: raw.crop.y,
|
||||
width,
|
||||
height,
|
||||
stride: raw.width,
|
||||
words,
|
||||
row_invocations,
|
||||
samples: raw.data.len() as u32,
|
||||
black,
|
||||
inv_range,
|
||||
tile,
|
||||
}
|
||||
}
|
||||
|
||||
fn xtrans_params_for(raw: &RawImage, width: u32, height: u32) -> XTransParams {
|
||||
let (black, inv_range) = xtrans_levels(raw);
|
||||
let wb = wb_gains(raw);
|
||||
@@ -970,6 +1343,24 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The repair's tile is indexed by sensor coordinate, the decoder's
|
||||
/// pattern by crop coordinate. A crop at an odd origin must shift one
|
||||
/// into the other, or the repair compares red with green.
|
||||
#[test]
|
||||
fn the_bayer_tile_is_anchored_to_the_sensor_not_the_crop() {
|
||||
let cell = bayer_cell(CfaPattern::Rggb).unwrap();
|
||||
let colour = |tile: [u32; 4], x: u32, y: u32| {
|
||||
(tile[((y % 6) >> 1) as usize] >> (((y % 6) & 1) * 12 + (x % 6) * 2)) & 3
|
||||
};
|
||||
for (cx, cy) in [(0, 0), (1, 0), (0, 1), (1, 1), (7, 4)] {
|
||||
let tile = pack_bayer_tile(cell, cx, cy);
|
||||
// Red is the crop's first photosite, wherever the crop starts.
|
||||
assert_eq!(colour(tile, cx, cy), 0, "crop at ({cx}, {cy})");
|
||||
assert_eq!(colour(tile, cx + 1, cy + 1), 2, "crop at ({cx}, {cy})");
|
||||
assert_eq!(colour(tile, cx + 1, cy), 1, "crop at ({cx}, {cy})");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unclamped_half_keeps_shadows_signs_and_highlights() {
|
||||
// A 14-bit LSB, normalised: subnormal in f16, and must not be zero.
|
||||
@@ -1006,7 +1397,6 @@ mod tests {
|
||||
white_level: white,
|
||||
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
||||
color_matrix: None,
|
||||
base_curve: BaseCurve::IDENTITY,
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
make: String::new(),
|
||||
@@ -1122,7 +1512,6 @@ mod tests {
|
||||
white_level: white,
|
||||
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
||||
color_matrix: None,
|
||||
base_curve: BaseCurve::IDENTITY,
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
make: String::new(),
|
||||
@@ -1419,7 +1808,6 @@ mod tests {
|
||||
white_level: 16383,
|
||||
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
||||
color_matrix: None,
|
||||
base_curve: BaseCurve::IDENTITY,
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
make: String::new(),
|
||||
@@ -1504,7 +1892,6 @@ mod tests {
|
||||
1.0,
|
||||
],
|
||||
color_matrix: None,
|
||||
base_curve: BaseCurve::IDENTITY,
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
make: String::new(),
|
||||
|
||||
+32
-46
@@ -43,12 +43,12 @@
|
||||
//! dispatch is skipped, and dragging a sharpening slider costs the detail
|
||||
//! passes alone (FR-DEV-3d).
|
||||
//!
|
||||
//! The remaining passes alternate between slots 1 and 2, and the last one
|
||||
//! writes the display texture directly rather than an intermediate — so a
|
||||
//! chain of *N* passes costs *N* dispatches and not *N* + 1, and there is no
|
||||
//! resolve pass to pay for. That leaves the allocation at `1 + min(N-1, 2)`
|
||||
//! textures: one for a single-pass operation, two for a separable blur, three
|
||||
//! however long the chain gets after that.
|
||||
//! The passes alternate between slots 1 and 2, the last one included: since
|
||||
//! D19 it hands its result to the adjust pass's **view pass**, which performs
|
||||
//! the view transform and the output transform after every kernel, so no
|
||||
//! detail pass writes the display texture. A chain of *N* passes costs *N*
|
||||
//! dispatches plus that one, and the allocation is `1 + min(N, 2)` textures.
|
||||
//! An empty chain costs the view pass alone, reading slot 0.
|
||||
//!
|
||||
//! # The reduced chain, and why a second one was needed
|
||||
//!
|
||||
@@ -186,10 +186,9 @@ impl Intermediates {
|
||||
/// intermediate against a fresh colour result and never be told.
|
||||
pub(crate) struct DetailRunner {
|
||||
ctx: GpuContext,
|
||||
/// Layout for a pass writing another linear intermediate.
|
||||
/// Layout for every pass: each writes a linear intermediate, the last
|
||||
/// one included, and the adjust pass's view pass reads the last (D19).
|
||||
to_linear: Layout,
|
||||
/// Layout for the last pass, which writes the display texture.
|
||||
to_output: Layout,
|
||||
/// Compiled pipelines by pass structure hash.
|
||||
cache: HashMap<u64, wgpu::ComputePipeline>,
|
||||
pool: Intermediates,
|
||||
@@ -255,7 +254,6 @@ impl DetailRunner {
|
||||
Self {
|
||||
ctx: ctx.clone(),
|
||||
to_linear: Layout::new(ctx, INTERMEDIATE_FORMAT, "detail-linear"),
|
||||
to_output: Layout::new(ctx, crate::AdjustPass::FORMAT, "detail-output"),
|
||||
cache: HashMap::new(),
|
||||
pool: Intermediates::new(),
|
||||
reduced: Intermediates::new(),
|
||||
@@ -274,27 +272,30 @@ impl DetailRunner {
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> &wgpu::TextureView {
|
||||
// One for the colour pass's result, then one per hand-off between
|
||||
// detail passes, capped at two because a ping-pong needs no more: the
|
||||
// last pass writes the display texture rather than an intermediate.
|
||||
let needed = 1 + passes.saturating_sub(1).min(2);
|
||||
// One for the colour pass's result, then one per pass, capped at two
|
||||
// because a ping-pong needs no more. The last pass writes an
|
||||
// intermediate like the others since D19 — the view pass reads it —
|
||||
// so a one-pass chain needs two slots where it used to need one.
|
||||
let needed = 1 + passes.min(2);
|
||||
self.pool.ensure(&self.ctx, needed, width, height);
|
||||
&self.pool.slots[0].view
|
||||
}
|
||||
|
||||
/// Encode every pass of `chain`, the last one writing `output`.
|
||||
/// Encode every pass of `chain`, and return how many ran and the view
|
||||
/// the last one wrote — slot 0, the colour pass's own result, for an
|
||||
/// empty chain.
|
||||
///
|
||||
/// The caller must already have run the fused colour pass into
|
||||
/// [`Self::colour_target`] — or established that a previous frame's is
|
||||
/// still valid, which is the whole point of keeping slot 0.
|
||||
/// still valid, which is the whole point of keeping slot 0 — and reads the
|
||||
/// returned view in the view pass that finishes the render (D19).
|
||||
pub(crate) fn encode(
|
||||
&mut self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
chain: &ComposedDetail,
|
||||
output: &wgpu::TextureView,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<usize, GpuError> {
|
||||
) -> Result<(usize, wgpu::TextureView), GpuError> {
|
||||
for pass in &chain.passes {
|
||||
self.compile(pass)?;
|
||||
}
|
||||
@@ -340,17 +341,15 @@ impl DetailRunner {
|
||||
for pass in chain.passes.iter() {
|
||||
let scaled = pass.output_scale > 1;
|
||||
|
||||
// The last pass carries the output transform into the display
|
||||
// texture, which is the render size by definition. A scaled pass
|
||||
// there would bind a shader dispatching over a quarter-size grid
|
||||
// to a full-size target and write a quarter of the picture — a
|
||||
// wrong image rather than a validation failure, so it is caught
|
||||
// here and named.
|
||||
if scaled && pass.writes_output {
|
||||
// The last pass hands the view pass its input, which is read at
|
||||
// the render size by definition. A scaled pass there would leave
|
||||
// the result in the reduced chain and the view pass would read the
|
||||
// full-size slot before it — a wrong image rather than a
|
||||
// validation failure, so it is caught here and named.
|
||||
if scaled && std::ptr::eq(pass, chain.passes.last().expect("iterating")) {
|
||||
return Err(GpuError::ShaderCompilation(format!(
|
||||
"detail pass {} declares output_scale {} and is last in \
|
||||
the chain; the output transform is written at the render \
|
||||
size",
|
||||
the chain; the view pass reads the render size",
|
||||
pass.label, pass.output_scale
|
||||
)));
|
||||
}
|
||||
@@ -365,17 +364,14 @@ impl DetailRunner {
|
||||
};
|
||||
|
||||
// Read what the previous pass in *this pass's own chain* wrote;
|
||||
// write the next slot of it, or the display texture if this is the
|
||||
// last pass. Alternating slots is what stops a pass reading the
|
||||
// write the next slot of it. Alternating slots is what stops a pass reading the
|
||||
// texture it is writing — on a compute pass that is not an error
|
||||
// the driver reports, merely a picture that depends on scheduling.
|
||||
let source = match (scaled, carried) {
|
||||
(true, Some(slot)) => &self.reduced.slots[slot].view,
|
||||
_ => &self.pool.slots[full].view,
|
||||
};
|
||||
let destination = if pass.writes_output {
|
||||
output
|
||||
} else if scaled {
|
||||
let destination = if scaled {
|
||||
&self.reduced.slots[reduced_writes % 2].view
|
||||
} else {
|
||||
&self.pool.slots[1 + (full_writes % 2)].view
|
||||
@@ -387,11 +383,7 @@ impl DetailRunner {
|
||||
Some(slot) => &self.reduced.slots[slot].view,
|
||||
None => &self.no_reduced,
|
||||
};
|
||||
let layout = if pass.writes_output {
|
||||
&self.to_output
|
||||
} else {
|
||||
&self.to_linear
|
||||
};
|
||||
let layout = &self.to_linear;
|
||||
|
||||
let params = self
|
||||
.ctx
|
||||
@@ -464,9 +456,7 @@ impl DetailRunner {
|
||||
compute.dispatch_workgroups(dispatch_w.div_ceil(8), dispatch_h.div_ceil(8), 1);
|
||||
drop(compute);
|
||||
|
||||
if pass.writes_output {
|
||||
// Nothing downstream to hand anything to.
|
||||
} else if scaled {
|
||||
if scaled {
|
||||
carried = Some(reduced_writes % 2);
|
||||
reduced_writes += 1;
|
||||
} else {
|
||||
@@ -480,7 +470,7 @@ impl DetailRunner {
|
||||
}
|
||||
}
|
||||
|
||||
Ok(chain.passes.len())
|
||||
Ok((chain.passes.len(), self.pool.slots[full].view.clone()))
|
||||
}
|
||||
|
||||
/// Compile one pass, or leave the cached pipeline in place.
|
||||
@@ -508,11 +498,7 @@ impl DetailRunner {
|
||||
source: wgpu::ShaderSource::Wgsl(pass.source.as_str().into()),
|
||||
});
|
||||
|
||||
let layout = if pass.writes_output {
|
||||
&self.to_output
|
||||
} else {
|
||||
&self.to_linear
|
||||
};
|
||||
let layout = &self.to_linear;
|
||||
|
||||
let pipeline = self
|
||||
.ctx
|
||||
|
||||
@@ -470,7 +470,7 @@ impl FocusPeakPass {
|
||||
// TEXTURE_BINDING to be sampled by the compositor.
|
||||
// RENDER_ATTACHMENT is not used by anything here and is required
|
||||
// anyway: Slint rejects an imported texture without it. COPY_SRC
|
||||
// is for `read_overlay` and its two callers.
|
||||
// is for `read_overlay` and the tests that call it.
|
||||
usage: wgpu::TextureUsages::STORAGE_BINDING
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
@@ -490,18 +490,11 @@ impl FocusPeakPass {
|
||||
/// TRACES: AC-8
|
||||
/// Copy the overlay to the CPU, as RGBA8 rows with no padding.
|
||||
///
|
||||
/// **Two callers, and neither is the desktop display path.** The tests
|
||||
/// below are one: an overlay is a claim about which pixels are sharp, and
|
||||
/// there is no way to check that claim without looking at the pixels. The
|
||||
/// other is the Android develop view, which reads the *frame* back for the
|
||||
/// reasons `technical-debt.md` TD-1 records — wgpu's Android swapchain
|
||||
/// tears a portrait window, so Slint is not drawing with wgpu there and no
|
||||
/// texture can be handed over. An overlay that stayed on the device on a
|
||||
/// platform where the picture underneath it does not would simply never be
|
||||
/// seen.
|
||||
///
|
||||
/// On desktop nothing calls this, and ARCH §6.1 holds on the path that
|
||||
/// matters: the overlay reaches the compositor as a texture.
|
||||
/// **The tests below are the only caller, and never the display path.** An
|
||||
/// overlay is a claim about which pixels are sharp, and there is no way to
|
||||
/// check that claim without looking at the pixels. On screen, on desktop
|
||||
/// and Android alike, the overlay reaches the compositor as a texture and
|
||||
/// ARCH §6.1 holds. (Android read it back here until TD-1 was paid off.)
|
||||
pub fn read_overlay(&self) -> Result<(Vec<u8>, u32, u32), GpuError> {
|
||||
let Some(layer) = self.layers[self.current].as_ref() else {
|
||||
return Err(GpuError::Readback("no overlay has been rendered".into()));
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! module doc said for eight releases that it held no pipeline and no masks.
|
||||
//! It holds both now, plus demosaic, detail, segmentation masks, two
|
||||
//! histograms and focus peaking. The zero-copy claim is still the one that
|
||||
//! matters, and TD-1 records the one platform where it does not hold.
|
||||
//! matters, and since TD-1 was paid off it holds on Android too.
|
||||
//!
|
||||
//! Deliberately free of UI dependencies (ARCH §6.5a). The texture is handed
|
||||
//! out as a `wgpu::Texture`; who composites it is not this crate's concern.
|
||||
|
||||
+14
-1
@@ -395,6 +395,7 @@ pub struct MaskPass {
|
||||
combine_layout: wgpu::BindGroupLayout,
|
||||
combine_union: wgpu::RenderPipeline,
|
||||
combine_subtract: wgpu::RenderPipeline,
|
||||
combine_intersect: wgpu::RenderPipeline,
|
||||
/// Where a part is drawn before it is joined.
|
||||
///
|
||||
/// One texture for the whole stack rather than one per layer, because
|
||||
@@ -651,6 +652,16 @@ impl MaskPass {
|
||||
"mask-combine-subtract",
|
||||
blend_state(wgpu::BlendFactor::Zero, wgpu::BlendFactor::OneMinusSrc),
|
||||
);
|
||||
// TRACES: FR-DEV-19a
|
||||
// `dst * src`: what the mask had, kept only in proportion to how much
|
||||
// of it this part also covers. The same three vertices and the same
|
||||
// scratch, so a third set operation is a third blend state and
|
||||
// nothing more — which is what `Join::apply` states on the CPU and
|
||||
// `the_joins_match_their_definition` holds this to.
|
||||
let combine_intersect = combine(
|
||||
"mask-combine-intersect",
|
||||
blend_state(wgpu::BlendFactor::Zero, wgpu::BlendFactor::Src),
|
||||
);
|
||||
|
||||
// The same, with the deposit thrown away: coverage is only ever taken
|
||||
// off what earlier strokes on this layer put down. There is no negative
|
||||
@@ -681,6 +692,7 @@ impl MaskPass {
|
||||
combine_layout,
|
||||
combine_union,
|
||||
combine_subtract,
|
||||
combine_intersect,
|
||||
scratch: None,
|
||||
array: None,
|
||||
allocations: 0,
|
||||
@@ -916,7 +928,7 @@ impl MaskPass {
|
||||
// the only readers and they are skipped in that case.
|
||||
let source_step = match source {
|
||||
Some(image) => {
|
||||
let (sw, sh) = image.size();
|
||||
let (sw, sh) = image.texture_size();
|
||||
[
|
||||
sw as f32 / width.max(1) as f32,
|
||||
sh as f32 / height.max(1) as f32,
|
||||
@@ -1376,6 +1388,7 @@ impl MaskPass {
|
||||
pass.set_pipeline(match join {
|
||||
Join::Union => &self.combine_union,
|
||||
Join::Subtract => &self.combine_subtract,
|
||||
Join::Intersect => &self.combine_intersect,
|
||||
});
|
||||
pass.set_bind_group(0, &bind_group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
|
||||
+122
-10
@@ -30,18 +30,27 @@
|
||||
//! are the caller's to provide and cache — `source` is asked for frame `k`
|
||||
//! as it is needed, and a caller short of memory may demosaic on demand.
|
||||
//!
|
||||
//! # The blend
|
||||
//!
|
||||
//! With a seam map (`dr_pano::seam`), a frame's weight at a pixel is its
|
||||
//! share of the map about that pixel — whole on its own side of a seam,
|
||||
//! nothing on the other, and a ramp across a window `seam_blend` pixels
|
||||
//! wide that follows the seam. Without one, or where the map has nothing
|
||||
//! to say, the weight is the distance to the frame's edge over `feather`,
|
||||
//! which hides exposure steps and does not hide parallax: the average draws
|
||||
//! anything the frames disagree on twice.
|
||||
//!
|
||||
//! # What is not here yet
|
||||
//!
|
||||
//! A feathered blend, not seams and a Laplacian pyramid: the weight is the
|
||||
//! distance to the frame's edge, which hides exposure steps and small
|
||||
//! misalignments and does not hide parallax. Gain is a scalar per frame
|
||||
//! the caller supplies. Both are panorama.md §10's step 5, after the path
|
||||
//! writes a file end to end.
|
||||
//! A Laplacian pyramid, which would let the seam's blend be narrow for
|
||||
//! detail and wide for exposure at once. Gain is a scalar per frame the
|
||||
//! caller supplies.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use dr_pano::bundle::Cameras;
|
||||
use dr_pano::projection::{Bounds, Projection};
|
||||
use dr_pano::seam::SeamMap;
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
use crate::readback::await_mapping;
|
||||
@@ -58,7 +67,7 @@ pub struct MergeFrame {
|
||||
}
|
||||
|
||||
/// The output the merge produces.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct MergeOutput {
|
||||
pub projection: Projection,
|
||||
/// The projection's scale in output pixels: the cylinder's radius, the
|
||||
@@ -69,10 +78,19 @@ pub struct MergeOutput {
|
||||
pub bounds: Bounds,
|
||||
/// Pixels over which a frame's weight ramps up from its edge.
|
||||
pub feather: f32,
|
||||
/// Which frame each part of the output is taken from, laid out at the
|
||||
/// proxies' scale; `None` for the feathered average everywhere.
|
||||
pub seams: Option<Arc<SeamMap>>,
|
||||
/// The width, in output pixels, of the blend across a seam.
|
||||
pub seam_blend: f32,
|
||||
/// Chunk size: the unit of GPU work and of memory.
|
||||
pub chunk: (u32, u32),
|
||||
/// Multiplies a normalised sample (1.0 = white) to the sensor's scale.
|
||||
pub sample_scale: f32,
|
||||
/// The white balance the composite will be developed with — the
|
||||
/// inverse of its `AsShotNeutral` — so that a blown sample can be
|
||||
/// written as the camera value that balance calls grey.
|
||||
pub balance: [f32; 3],
|
||||
}
|
||||
|
||||
impl MergeOutput {
|
||||
@@ -109,7 +127,14 @@ struct WarpParams {
|
||||
tile_origin: [f32; 2],
|
||||
tile_size: [u32; 2],
|
||||
feather: f32,
|
||||
_pad: f32,
|
||||
clip_onset: f32,
|
||||
balance: [f32; 4],
|
||||
seam_origin: [f32; 2],
|
||||
seam_size: [u32; 2],
|
||||
seam_px: f32,
|
||||
seam_radius: f32,
|
||||
frame_index: u32,
|
||||
seam_on: u32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
@@ -177,6 +202,16 @@ impl MergePass {
|
||||
count: None,
|
||||
},
|
||||
storage(2, false),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 3,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Uint,
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
let resolve_layout =
|
||||
@@ -274,6 +309,32 @@ impl MergePass {
|
||||
let mut band_cov = vec![false; (out_w * ch) as usize];
|
||||
let mut chunk_px: Vec<u32> = Vec::new();
|
||||
|
||||
// The seam map, once for the whole output, and where it sits in
|
||||
// this output's coordinates. A one-texel stand-in when there is
|
||||
// none, because the binding is not optional.
|
||||
let (seam_tex, seam_origin, seam_px, seam_radius, seam_size) = match &output.seams {
|
||||
Some(m) => {
|
||||
let ((ou, ov), px) = m.at_scale(output.scale);
|
||||
let radius = m.blend_radius(output.scale, f64::from(output.seam_blend));
|
||||
(
|
||||
self.label_texture(m.width as u32, m.height as u32, &m.labels),
|
||||
[ou as f32, ov as f32],
|
||||
px as f32,
|
||||
radius as f32,
|
||||
[m.width as u32, m.height as u32],
|
||||
)
|
||||
}
|
||||
None => (
|
||||
self.label_texture(1, 1, &[dr_pano::seam::NONE]),
|
||||
[0.0; 2],
|
||||
1.0,
|
||||
1.0,
|
||||
[1, 1],
|
||||
),
|
||||
};
|
||||
let seam_view = seam_tex.create_view(&Default::default());
|
||||
let seam_on = u32::from(output.seams.is_some());
|
||||
|
||||
let mut y = 0u32;
|
||||
while y < out_h {
|
||||
let rows = ch.min(out_h - y);
|
||||
@@ -334,9 +395,21 @@ impl MergePass {
|
||||
tile_origin: [rect.0 as f32, rect.1 as f32],
|
||||
tile_size: [rect.2, rect.3],
|
||||
feather: output.feather,
|
||||
_pad: 0.0,
|
||||
clip_onset: dr_pipeline::CLIP_ONSET,
|
||||
balance: [
|
||||
output.balance[0].max(1e-3),
|
||||
output.balance[1].max(1e-3),
|
||||
output.balance[2].max(1e-3),
|
||||
0.0,
|
||||
],
|
||||
seam_origin,
|
||||
seam_size,
|
||||
seam_px,
|
||||
seam_radius,
|
||||
frame_index: k as u32,
|
||||
seam_on,
|
||||
};
|
||||
self.accumulate(¶ms, tile);
|
||||
self.accumulate(¶ms, tile, &seam_view);
|
||||
}
|
||||
|
||||
self.resolve_chunk((cols, rows), output.sample_scale, &mut chunk_px)?;
|
||||
@@ -374,7 +447,42 @@ impl MergePass {
|
||||
self.ctx.queue.submit(Some(enc.finish()));
|
||||
}
|
||||
|
||||
fn accumulate(&mut self, params: &WarpParams, tile: &wgpu::Texture) {
|
||||
/// The seam map's labels as an `r8uint` texture.
|
||||
fn label_texture(&self, width: u32, height: u32, labels: &[u8]) -> wgpu::Texture {
|
||||
let size = wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: 1,
|
||||
};
|
||||
let tex = self.ctx.device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("merge-seams"),
|
||||
size,
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::R8Uint,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
self.ctx.queue.write_texture(
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture: &tex,
|
||||
mip_level: 0,
|
||||
origin: wgpu::Origin3d::ZERO,
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
labels,
|
||||
wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(width),
|
||||
rows_per_image: Some(height),
|
||||
},
|
||||
size,
|
||||
);
|
||||
tex
|
||||
}
|
||||
|
||||
fn accumulate(&mut self, params: &WarpParams, tile: &wgpu::Texture, seams: &wgpu::TextureView) {
|
||||
let chunk = (params.chunk_size[0], params.chunk_size[1]);
|
||||
let uniforms = self
|
||||
.ctx
|
||||
@@ -406,6 +514,10 @@ impl MergePass {
|
||||
binding: 2,
|
||||
resource: acc.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::TextureView(seams),
|
||||
},
|
||||
],
|
||||
});
|
||||
let mut enc = self.ctx.device.create_command_encoder(&Default::default());
|
||||
|
||||
@@ -28,8 +28,8 @@
|
||||
//! than leaving the specification and the code silently disagreeing.
|
||||
//!
|
||||
//! That texture is the right one on the merits. It is camera-native: no white
|
||||
//! balance has been applied, no camera matrix, no base curve, no tone curve,
|
||||
//! no output transform. It is normalised by the sensor's own black and white
|
||||
//! balance has been applied, no camera matrix, no tone curve, no view
|
||||
//! transform, no output transform. It is normalised by the sensor's own black and white
|
||||
//! levels, so 1.0 is saturation by construction and the distribution below it
|
||||
//! *is* the headroom question, with no calibration to carry and no origin to
|
||||
//! choose.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Watershed segmentation — arm A's GPU half (S15, docs/segmentation.md).
|
||||
//! Watershed segmentation — arm A's GPU half (S15, docs/dev/segmentation.md).
|
||||
//!
|
||||
//! Runs the five passes in `shaders/watershed.wgsl` over a demosaiced image
|
||||
//! and leaves a basin label per pixel on the GPU. The hierarchy built from
|
||||
@@ -20,7 +20,7 @@
|
||||
//! one AC-8 forbids is per frame in the render loop, and sharing a switch
|
||||
//! would force a build wanting local masking to unlock the other.
|
||||
//!
|
||||
//! It is still a real cost and still unfinished. F3 in docs/segmentation.md
|
||||
//! It is still a real cost and still unfinished. F3 in docs/dev/segmentation.md
|
||||
//! §12 stands: the adjacency accumulation belongs GPU-side with atomics, and
|
||||
//! until it moves there every segmentation pays a full-resolution transfer.
|
||||
//! Read the feature name as a description of a known gap rather than as
|
||||
@@ -36,7 +36,7 @@ pub struct SegmentOptions {
|
||||
/// Longest proxy edge. The segmentation runs here, not at sensor
|
||||
/// resolution: a 24 MP watershed costs 12× the memory to place boundaries
|
||||
/// a person cannot see, and the boundary refinement that matters at 1:1
|
||||
/// is a separate stage (docs/segmentation.md §4).
|
||||
/// is a separate stage (docs/dev/segmentation.md §4).
|
||||
pub max_edge: u32,
|
||||
/// Pre-smoothing radius in proxy pixels. The caller's to raise with ISO —
|
||||
/// this is the single knob that decides whether a noisy file segments
|
||||
@@ -69,7 +69,7 @@ impl Default for SegmentOptions {
|
||||
w_chroma: 0.5,
|
||||
// **Zero: the pass is off.** It is implemented, dispatched
|
||||
// correctly and measurably changes nothing — see the ignored test
|
||||
// below and §12 of docs/segmentation.md. Until that is understood,
|
||||
// below and §12 of docs/dev/segmentation.md. Until that is understood,
|
||||
// running it would buy 64 dispatches per segmentation and no
|
||||
// improvement, so the default declines to pay.
|
||||
plateau_iterations: 0,
|
||||
@@ -208,7 +208,7 @@ impl SegmentPass {
|
||||
source: &DemosaicedImage,
|
||||
opts: SegmentOptions,
|
||||
) -> Result<Segmentation, GpuError> {
|
||||
let (src_w, src_h) = source.size();
|
||||
let (src_w, src_h) = source.texture_size();
|
||||
let (width, height) = proxy_size(src_w, src_h, opts.max_edge);
|
||||
let n = (width * height) as u64;
|
||||
|
||||
@@ -486,7 +486,7 @@ impl Segmentation {
|
||||
/// a region graph of a few thousand nodes that every later interaction
|
||||
/// reads from the CPU anyway.
|
||||
///
|
||||
/// What it is *not* is finished. F3 in docs/segmentation.md §12 stands:
|
||||
/// What it is *not* is finished. F3 in docs/dev/segmentation.md §12 stands:
|
||||
/// the adjacency accumulation belongs on the GPU with atomics, and until
|
||||
/// it moves there a segmentation costs one full-resolution transfer of the
|
||||
/// label and gradient buffers. That is a real cost on a phone and the
|
||||
@@ -724,7 +724,7 @@ mod tests {
|
||||
px
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "the plateau pass is a measured no-op; see docs/segmentation.md §12"]
|
||||
#[ignore = "the plateau pass is a measured no-op; see docs/dev/segmentation.md §12"]
|
||||
fn lower_completion_drains_a_plateau_instead_of_shattering_it() {
|
||||
// F1, asserted rather than eyeballed, and asserted at the level where
|
||||
// it matters.
|
||||
@@ -741,7 +741,7 @@ mod tests {
|
||||
// with no exit anywhere — cannot be drained by a distance that has
|
||||
// nowhere to descend to, and collapsing it fully would need connected
|
||||
// component labelling rather than a local rule. It is not worth it:
|
||||
// see docs/segmentation.md §12.
|
||||
// see docs/dev/segmentation.md §12.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let (w, h) = (96u32, 96u32);
|
||||
let src = DemosaicedImage::from_rgba8(&ctx, &ramp(w, h), w, h).expect("source");
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
// Hot and dead photosite repair, on the raw mosaic, before demosaic.
|
||||
//
|
||||
// A hot photosite reads far above anything the light put there — a leaky
|
||||
// well, lit by its own dark current on a long or high-ISO exposure. Left in,
|
||||
// the demosaic spreads it into its neighbours' interpolated channels and it
|
||||
// becomes a coloured cross, three pixels wide, that no later stage can take
|
||||
// back out: by then it is five pixels of plausible colour rather than one
|
||||
// photosite of nonsense. So it is repaired here, where it is still one value.
|
||||
//
|
||||
// **What counts as hot.** A photosite far above *every* photosite of its own
|
||||
// colour in its 5x5 window, and also far above every one of its eight
|
||||
// immediate neighbours whatever their colour. The second half is what keeps a
|
||||
// star or a glint: real light arrives through a lens and an anti-aliasing
|
||||
// filter, so even the sharpest point lands on a patch of photosites, and the
|
||||
// ones beside it are lit too. A hot photosite's neighbours are as dark as the
|
||||
// rest of the frame. Dead photosites are the mirror image and are handled the
|
||||
// same way.
|
||||
//
|
||||
// **What it becomes.** The brightest (for a hot photosite) or darkest (for a
|
||||
// dead one) same-colour neighbour — the value nearest to what it read that
|
||||
// the neighbourhood can vouch for. An average would soften the one case this
|
||||
// gets wrong, a real highlight that happened to pass both tests; a clamp to
|
||||
// the neighbourhood's range cannot invent anything.
|
||||
//
|
||||
// Written for either colour filter array: the colour of a photosite comes from
|
||||
// a 6x6 tile anchored to the sensor, which holds the X-Trans pattern as it is
|
||||
// and a Bayer 2x2 cell repeated nine times.
|
||||
|
||||
struct HotPixelParams {
|
||||
// The cropped area, in sensor photosites. Only photosites inside it are
|
||||
// judged, and only photosites inside it are asked as neighbours — the
|
||||
// masked border sits at black and would make everything look hot.
|
||||
crop_x: u32,
|
||||
crop_y: u32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
// Row stride of the readout, in samples, and the number of u32 words.
|
||||
stride: u32,
|
||||
words: u32,
|
||||
// How many invocations one row of the dispatch grid holds, so a frame
|
||||
// wider than a dispatch dimension can be addressed as two.
|
||||
row_invocations: u32,
|
||||
// Samples in the readout. One less than twice `words` when the count is
|
||||
// odd, and the padding half of the last word is never judged.
|
||||
samples: u32,
|
||||
// Per-position black levels and reciprocal ranges, indexed by the
|
||||
// photosite's parity within the *crop*: (y&1)*2 + (x&1) counted from its
|
||||
// origin, as the demosaic counts them.
|
||||
black: vec4<f32>,
|
||||
inv_range: vec4<f32>,
|
||||
// The 6x6 colour tile, two bits per photosite, indexed by sensor
|
||||
// coordinate modulo 6: word k holds row 2k in its low 12 bits and row
|
||||
// 2k+1 in the next 12. The fourth word is padding.
|
||||
tile: vec4<u32>,
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var<storage, read> raw: array<u32>;
|
||||
@group(0) @binding(1) var<uniform> params: HotPixelParams;
|
||||
@group(0) @binding(2) var<storage, read_write> repaired: array<u32>;
|
||||
|
||||
// How far above its brightest neighbour a photosite must read to be hot, as a
|
||||
// ratio and a margin in normalised units. Twice the neighbourhood and two
|
||||
// percent of the range above it: far enough that shot noise in a lit area
|
||||
// never qualifies, near enough that a hot photosite in a night sky — reading
|
||||
// a third of the range over a sky at one percent — always does.
|
||||
const HOT_RATIO: f32 = 2.0;
|
||||
const HOT_MARGIN: f32 = 0.02;
|
||||
// A dead photosite reads under half its darkest neighbour, and only counts
|
||||
// where that neighbour is at least this bright: in the shadows, a photosite
|
||||
// at zero is noise that clipped at the black point, not a defect.
|
||||
const DEAD_RATIO: f32 = 0.5;
|
||||
const DEAD_FLOOR: f32 = 0.05;
|
||||
|
||||
fn value_at(index: u32) -> u32 {
|
||||
let word = raw[index >> 1u];
|
||||
return select(word & 0xFFFFu, word >> 16u, (index & 1u) == 1u);
|
||||
}
|
||||
|
||||
fn colour_at(sx: u32, sy: u32) -> u32 {
|
||||
let row = sy % 6u;
|
||||
let col = sx % 6u;
|
||||
let word = params.tile[row >> 1u];
|
||||
return (word >> ((row & 1u) * 12u + col * 2u)) & 3u;
|
||||
}
|
||||
|
||||
// A raw value against its own black level and range. Compared rather than
|
||||
// stored, so it is left unclamped at the top: a hot photosite above white is
|
||||
// still more above white than its neighbours are.
|
||||
fn level(sx: u32, sy: u32, v: u32) -> f32 {
|
||||
let cell = ((sy - params.crop_y) & 1u) * 2u + ((sx - params.crop_x) & 1u);
|
||||
return max(f32(v) - params.black[cell], 0.0) * params.inv_range[cell];
|
||||
}
|
||||
|
||||
// The value to store for the photosite at `index`.
|
||||
fn repair(index: u32) -> u32 {
|
||||
let v = value_at(index);
|
||||
let sx = index % params.stride;
|
||||
let sy = index / params.stride;
|
||||
if (sx < params.crop_x || sy < params.crop_y
|
||||
|| sx >= params.crop_x + params.width || sy >= params.crop_y + params.height) {
|
||||
return v;
|
||||
}
|
||||
|
||||
let centre = level(sx, sy, v);
|
||||
let colour = colour_at(sx, sy);
|
||||
|
||||
var same_hi = -1.0;
|
||||
var same_lo = 1.0e9;
|
||||
var same_hi_raw = v;
|
||||
var same_lo_raw = v;
|
||||
var same_count = 0u;
|
||||
var adjacent_hi = 0.0;
|
||||
var adjacent_lo = 1.0e9;
|
||||
|
||||
for (var dy = -2; dy <= 2; dy++) {
|
||||
for (var dx = -2; dx <= 2; dx++) {
|
||||
if (dx == 0 && dy == 0) {
|
||||
continue;
|
||||
}
|
||||
let nx = i32(sx) + dx;
|
||||
let ny = i32(sy) + dy;
|
||||
if (nx < i32(params.crop_x) || ny < i32(params.crop_y)
|
||||
|| nx >= i32(params.crop_x + params.width)
|
||||
|| ny >= i32(params.crop_y + params.height)) {
|
||||
continue;
|
||||
}
|
||||
let nsx = u32(nx);
|
||||
let nsy = u32(ny);
|
||||
let nv = value_at(nsy * params.stride + nsx);
|
||||
let n = level(nsx, nsy, nv);
|
||||
|
||||
if (abs(dx) <= 1 && abs(dy) <= 1) {
|
||||
adjacent_hi = max(adjacent_hi, n);
|
||||
adjacent_lo = min(adjacent_lo, n);
|
||||
}
|
||||
if (colour_at(nsx, nsy) == colour) {
|
||||
same_count += 1u;
|
||||
if (n > same_hi) {
|
||||
same_hi = n;
|
||||
same_hi_raw = nv;
|
||||
}
|
||||
if (n < same_lo) {
|
||||
same_lo = n;
|
||||
same_lo_raw = nv;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A corner of the crop can leave a photosite with a single same-colour
|
||||
// neighbour, and one witness is not a neighbourhood.
|
||||
if (same_count < 2u) {
|
||||
return v;
|
||||
}
|
||||
|
||||
let hot_line_same = same_hi * HOT_RATIO + HOT_MARGIN;
|
||||
let hot_line_adjacent = adjacent_hi * HOT_RATIO + HOT_MARGIN;
|
||||
if (centre > hot_line_same && centre > hot_line_adjacent) {
|
||||
return same_hi_raw;
|
||||
}
|
||||
if (same_lo >= DEAD_FLOOR && centre < same_lo * DEAD_RATIO
|
||||
&& centre < adjacent_lo * DEAD_RATIO) {
|
||||
return same_lo_raw;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
// One invocation per u32 word: two photosites, packed as the demosaic reads
|
||||
// them. A word may straddle two rows when the stride is odd, which `repair`
|
||||
// does not mind — it addresses by sample index.
|
||||
@compute @workgroup_size(64, 1, 1)
|
||||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let word = gid.y * params.row_invocations + gid.x;
|
||||
if (word >= params.words) {
|
||||
return;
|
||||
}
|
||||
let lo = repair(word * 2u);
|
||||
// The padding half of an odd-length readout is copied, not judged: it is
|
||||
// not a photosite, and the demosaic never addresses it.
|
||||
var hi = raw[word] >> 16u;
|
||||
if (word * 2u + 1u < params.samples) {
|
||||
hi = repair(word * 2u + 1u);
|
||||
}
|
||||
repaired[word] = (lo & 0xFFFFu) | (hi << 16u);
|
||||
}
|
||||
@@ -5,8 +5,9 @@
|
||||
// pixel it asks which direction that pixel looks along, turns the
|
||||
// direction into the frame's camera, projects it to a source pixel, and
|
||||
// if that pixel is inside the tile that was rendered for this chunk,
|
||||
// samples it and adds it — weighted by its distance from the frame's edge
|
||||
// — into the accumulator. `resolve` runs once per chunk after every frame
|
||||
// samples it and adds it — weighted by the frame's share of the seam map
|
||||
// there, or by its distance from the frame's edge where there is no map —
|
||||
// into the accumulator. `resolve` runs once per chunk after every frame
|
||||
// has been added: divides the sums by the weights and packs the result as
|
||||
// sixteen-bit samples at the sensor's scale (FR-MRG-3).
|
||||
//
|
||||
@@ -46,13 +47,87 @@ struct Params {
|
||||
tile_size: vec2<u32>,
|
||||
// Pixels over which the weight ramps from the edge to full.
|
||||
feather: f32,
|
||||
_pad: f32,
|
||||
// Where a sample starts to count as blown (`CLIP_ONSET`), and the
|
||||
// white balance the composite will be developed with.
|
||||
clip_onset: f32,
|
||||
balance: vec4<f32>,
|
||||
// The seam map (`dr_pano::seam`): where its texel (0, 0)'s corner sits
|
||||
// in this output's centred coordinates, its size, output pixels per
|
||||
// texel, the blend's radius in texels, which frame this dispatch is,
|
||||
// and whether there is a map at all.
|
||||
seam_origin: vec2<f32>,
|
||||
seam_size: vec2<u32>,
|
||||
seam_px: f32,
|
||||
seam_radius: f32,
|
||||
frame_index: u32,
|
||||
seam_on: u32,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> p: Params;
|
||||
@group(0) @binding(1) var tile: texture_2d<f32>;
|
||||
// rgb·w summed, then w: four floats per chunk pixel.
|
||||
@group(0) @binding(2) var<storage, read_write> acc: array<vec4<f32>>;
|
||||
// One frame index per texel, 255 for none.
|
||||
@group(0) @binding(3) var seams: texture_2d<u32>;
|
||||
|
||||
const NO_FRAME: u32 = 255u;
|
||||
|
||||
fn label(i: i32, j: i32) -> u32 {
|
||||
if (i < 0 || j < 0 || i >= i32(p.seam_size.x) || j >= i32(p.seam_size.y)) {
|
||||
return NO_FRAME;
|
||||
}
|
||||
return textureLoad(seams, vec2<i32>(i, j), 0).r;
|
||||
}
|
||||
|
||||
// This frame's share of the seam map about output point (u, v): the
|
||||
// tent-weighted fraction of the texels within the radius that it owns, and
|
||||
// the weight of the texels owned by anyone (zero where the map has nothing
|
||||
// to say). `SeamMap::share` verbatim.
|
||||
fn seam_share(u: f32, v: f32) -> vec2<f32> {
|
||||
let x = (u - p.seam_origin.x) / p.seam_px - 0.5;
|
||||
let y = (v - p.seam_origin.y) / p.seam_px - 0.5;
|
||||
let r = max(p.seam_radius, 1.0);
|
||||
let x0 = i32(ceil(x - r));
|
||||
let x1 = i32(floor(x + r));
|
||||
let y0 = i32(ceil(y - r));
|
||||
let y1 = i32(floor(y + r));
|
||||
// Most pixels are nowhere near a seam: if the window's corners, edge
|
||||
// midpoints and centre agree, so does the window. A seam crossing it
|
||||
// has to cross its border, between two of those.
|
||||
let xm = i32(round(x));
|
||||
let ym = i32(round(y));
|
||||
let c = label(xm, ym);
|
||||
if (label(x0, y0) == c && label(x1, y0) == c && label(x0, y1) == c && label(x1, y1) == c
|
||||
&& label(xm, y0) == c && label(xm, y1) == c && label(x0, ym) == c && label(x1, ym) == c) {
|
||||
if (c == NO_FRAME) {
|
||||
return vec2<f32>(0.0, 0.0);
|
||||
}
|
||||
return vec2<f32>(select(0.0, 1.0, c == p.frame_index), 1.0);
|
||||
}
|
||||
var mine = 0.0;
|
||||
var owned = 0.0;
|
||||
for (var j = y0; j <= y1; j = j + 1) {
|
||||
let wy = 1.0 - abs(y - f32(j)) / r;
|
||||
if (wy <= 0.0) {
|
||||
continue;
|
||||
}
|
||||
for (var i = x0; i <= x1; i = i + 1) {
|
||||
let wx = 1.0 - abs(x - f32(i)) / r;
|
||||
let l = label(i, j);
|
||||
if (wx <= 0.0 || l == NO_FRAME) {
|
||||
continue;
|
||||
}
|
||||
owned = owned + wx * wy;
|
||||
if (l == p.frame_index) {
|
||||
mine = mine + wx * wy;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (owned <= 0.0) {
|
||||
return vec2<f32>(0.0, 0.0);
|
||||
}
|
||||
return vec2<f32>(mine / owned, 1.0);
|
||||
}
|
||||
|
||||
fn to_direction(u: f32, v: f32) -> vec3<f32> {
|
||||
let s = p.proj_scale;
|
||||
@@ -95,7 +170,17 @@ fn warp(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
if (edge <= 0.0) {
|
||||
return;
|
||||
}
|
||||
let w = clamp(edge / max(p.feather, 1.0), 0.0, 1.0);
|
||||
var w = clamp(edge / max(p.feather, 1.0), 0.0, 1.0);
|
||||
// With seams, the share of the map scales it. The small floor keeps
|
||||
// the feather underneath as the answer wherever no frame that reaches
|
||||
// this pixel owns it — the map is coarser than the output, so at the
|
||||
// frames' outer edges it can name a frame that falls just short.
|
||||
if (p.seam_on != 0u) {
|
||||
let s = seam_share(u, v);
|
||||
if (s.y > 0.0) {
|
||||
w = w * (s.x + 1e-4);
|
||||
}
|
||||
}
|
||||
// Into the tile.
|
||||
let tx = sx - p.tile_origin.x;
|
||||
let ty = sy - p.tile_origin.y;
|
||||
@@ -125,7 +210,19 @@ fn warp(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
return;
|
||||
}
|
||||
// Colour is the alpha-weighted mean of the texels that exist.
|
||||
let rgb = s.rgb / s.a * p.gain;
|
||||
let cam = s.rgb / s.a;
|
||||
// **A blown sample is written as grey, before the gain.** A clipped
|
||||
// photosite arrives as (1, 1, 1), which is not a colour: balanced, it
|
||||
// is magenta, and the develop's highlight desaturation only rescues it
|
||||
// while it is still at the white level. A gain below one moved it off
|
||||
// that level, and a feather mixed it into a neighbour's real sky, so
|
||||
// the composite's blown clouds came out pink. Written instead as the
|
||||
// camera value the balance maps to grey — the develop pipeline's own
|
||||
// neutral, the brightest balanced channel — it survives both.
|
||||
let clipped = smoothstep(p.clip_onset, 1.0, max(cam.r, max(cam.g, cam.b)));
|
||||
let balanced = cam * p.balance.rgb;
|
||||
let grey = vec3<f32>(max(balanced.r, max(balanced.g, balanced.b))) / p.balance.rgb;
|
||||
let rgb = mix(cam, grey, clipped) * p.gain;
|
||||
let wa = w * s.a;
|
||||
let i = gid.y * p.chunk_size.x + gid.x;
|
||||
acc[i] = acc[i] + vec4<f32>(rgb * wa, wa);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//
|
||||
// The shader beside this one, `histogram.wgsl`, counts the frame the display
|
||||
// is about to show: an 8-bit code value, after white balance, the camera
|
||||
// matrix, the base curve, the tone curve and the output transform. This one
|
||||
// matrix, the tone curve, the view transform and the output transform. This one
|
||||
// counts the texture the demosaic wrote, before any of that. The two differ in
|
||||
// exactly one place — the axis — and everything else here is deliberately the
|
||||
// same construction, because the two reductions have the same shape and any
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Watershed segmentation — the passes behind arm A of S15 (docs/segmentation.md).
|
||||
// Watershed segmentation — the passes behind arm A of S15 (docs/dev/segmentation.md).
|
||||
//
|
||||
// Seven entry points forming one chain:
|
||||
//
|
||||
@@ -197,7 +197,7 @@ fn gradient(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
// lowest-indexed neighbour, which is up and to the left. Each pixel therefore
|
||||
// walks diagonally until it falls off the plateau, and one flat region becomes
|
||||
// a fan of diagonal chains rather than one basin — visible as hatching across
|
||||
// what should be a single area (docs/segmentation.md §12, F1).
|
||||
// what should be a single area (docs/dev/segmentation.md §12, F1).
|
||||
//
|
||||
// The fix is the standard lower-completion: give each plateau pixel its
|
||||
// geodesic distance to the nearest pixel that *does* have a lower neighbour,
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
//! TRACES: FR-DEV-3e
|
||||
//! The camera profile's base curve, end to end on a device.
|
||||
//!
|
||||
//! The unit tests either side of this one check halves. `dr-decode` asserts
|
||||
//! that the shipped database parses and that every curve in it lifts its
|
||||
//! midtones; `dr-pipeline` asserts that the generated WGSL evaluates a curve
|
||||
//! in the right place. Neither would notice if the two agreed with each other
|
||||
//! and both were wrong — a curve packed into the wrong uniform slots, or a
|
||||
//! flag read from the wrong component, satisfies both and renders nothing.
|
||||
//!
|
||||
//! So this renders real pixels twice, once with a profiled body's curve and
|
||||
//! once with the identity, and asserts the difference is the one a base curve
|
||||
//! is for: midtones lifted, black still black, white still white.
|
||||
|
||||
use dr_decode::{BaseCurve, CfaPattern, CropRect, RawImage};
|
||||
use dr_gpu::{AdjustPass, Demosaicer, GpuContext};
|
||||
use dr_pipeline::EditGraph;
|
||||
|
||||
const SIZE: u32 = 16;
|
||||
|
||||
fn ctx() -> Option<GpuContext> {
|
||||
pollster::block_on(GpuContext::new_headless()).ok()
|
||||
}
|
||||
|
||||
/// A flat RGGB frame at `level` out of 65535, carrying `curve`.
|
||||
///
|
||||
/// Every photosite the same value, so the demosaic result is a uniform grey
|
||||
/// and the only thing that can move a pixel is the curve. The colour matrix is
|
||||
/// the identity and the balance is neutral for the same reason: this test is
|
||||
/// about one stage, and a real body's matrix would make every assertion below
|
||||
/// a statement about that body instead.
|
||||
fn flat_raw(level: u16, curve: BaseCurve) -> RawImage {
|
||||
RawImage {
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
data: vec![level; (SIZE * SIZE) as usize],
|
||||
cfa_pattern: CfaPattern::Rggb,
|
||||
black_level: [0; 4],
|
||||
white_level: u16::MAX,
|
||||
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
||||
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
||||
base_curve: curve,
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: CropRect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a neutral edit over a flat frame and return the centre pixel's red.
|
||||
///
|
||||
/// The centre rather than a corner: a demosaic has to invent its edges, and
|
||||
/// the interpolated border of a 16×16 frame is not where anyone should be
|
||||
/// reading a tone off.
|
||||
fn rendered_level(ctx: &GpuContext, level: u16, curve: BaseCurve) -> u8 {
|
||||
let raw = flat_raw(level, curve);
|
||||
let source = Demosaicer::new(ctx)
|
||||
.expect("demosaicer")
|
||||
.run(&raw)
|
||||
.expect("demosaic");
|
||||
let shader = EditGraph::default_chain().compose();
|
||||
let mut adjust = AdjustPass::new(ctx);
|
||||
adjust.render(&source, &shader, SIZE, SIZE).expect("render");
|
||||
let (pixels, _, _) = adjust.export_pixels().expect("readback");
|
||||
let centre = ((SIZE / 2) * SIZE + SIZE / 2) * 4;
|
||||
pixels[centre as usize]
|
||||
}
|
||||
|
||||
/// The Canon EOS 6D's curve, from the shipped profile database.
|
||||
///
|
||||
/// Looked up by name rather than written out, so this also asserts the thing
|
||||
/// no other test can: that a curve travels from the YAML, through the body
|
||||
/// match, onto the decoded image and into the uniform block that the shader
|
||||
/// actually reads.
|
||||
fn six_d() -> BaseCurve {
|
||||
let curve = dr_decode::base_curve::for_body("Canon", "EOS 6D");
|
||||
assert!(
|
||||
!curve.is_identity(),
|
||||
"the shipped database must have a curve for the EOS 6D"
|
||||
);
|
||||
curve
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_profiled_body_renders_brighter_midtones_than_a_flat_one() {
|
||||
// **The whole requirement, in one assertion.** A linear midtone renders
|
||||
// roughly half a stop dark, which is the flat, lifeless look FR-DEV-3e
|
||||
// exists to get away from. If the curve did not reach the shader — wrong
|
||||
// slot, wrong flag, wrong stage — this is the only test that would fail.
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
return;
|
||||
};
|
||||
|
||||
// 13% of full scale: roughly where a camera places middle grey, leaving
|
||||
// about two and a half stops of highlight headroom above it.
|
||||
let level = (0.13 * 65535.0) as u16;
|
||||
let flat = rendered_level(&ctx, level, BaseCurve::IDENTITY);
|
||||
let profiled = rendered_level(&ctx, level, six_d());
|
||||
|
||||
assert!(
|
||||
profiled > flat + 8,
|
||||
"the profile lifted middle grey from {flat} only to {profiled}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_curve_leaves_black_black_and_white_white() {
|
||||
// A base curve renders the range between the endpoints; it must not move
|
||||
// the endpoints themselves. A curve that lifted black would put a grey
|
||||
// veil over every night photograph, and one that pulled white down would
|
||||
// make a correctly exposed frame look underexposed.
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
return;
|
||||
};
|
||||
|
||||
let curve = six_d();
|
||||
assert_eq!(rendered_level(&ctx, 0, curve), 0, "black moved");
|
||||
assert_eq!(rendered_level(&ctx, u16::MAX, curve), 255, "white moved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unprofiled_body_renders_exactly_as_it_did_before_profiles_existed() {
|
||||
// The graceful fallback, asserted as a number rather than as a promise.
|
||||
// With no curve the pipeline must still be a pass-through: black level
|
||||
// out, white level in, sRGB encoding on the way to the screen and nothing
|
||||
// else. "Never worse than today" is the one property this change was not
|
||||
// allowed to trade away, and the way it would break is silently — a flag
|
||||
// read from the wrong component would apply a curve nobody asked for.
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
return;
|
||||
};
|
||||
|
||||
for level in [0u16, 4_000, 8_520, 32_768, 60_000, u16::MAX] {
|
||||
let scene = f32::from(level) / f32::from(u16::MAX);
|
||||
let expected = (dr_types::Transfer::Srgb.encode(scene) * 255.0).round() as i32;
|
||||
let got = i32::from(rendered_level(&ctx, level, BaseCurve::IDENTITY));
|
||||
// Two 8-bit steps: the texture holding the demosaiced frame is
|
||||
// `Rgba16Float`, so a value round-trips through eleven mantissa bits
|
||||
// before it is encoded. That is well under one step at any level, and
|
||||
// the tolerance is for the rounding either side of it rather than for
|
||||
// the transform being approximate.
|
||||
assert!(
|
||||
(got - expected).abs() <= 2,
|
||||
"raw {level} rendered as {got}, expected about {expected}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_curve_is_monotone_through_the_whole_range() {
|
||||
// The property the spline's tangent limiting exists to guarantee, checked
|
||||
// where it actually matters: on the device, through the real uniform
|
||||
// packing. A curve that dipped anywhere would put a dark band across a
|
||||
// smooth gradient — a sky, most visibly — and it would read as a
|
||||
// rendering fault rather than as a bad profile.
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
return;
|
||||
};
|
||||
|
||||
let curve = six_d();
|
||||
let mut previous = 0u8;
|
||||
for step in 0..=16u32 {
|
||||
let level = (step * 65535 / 16) as u16;
|
||||
let value = rendered_level(&ctx, level, curve);
|
||||
assert!(
|
||||
value >= previous,
|
||||
"the curve fell from {previous} to {value} at raw level {level}"
|
||||
);
|
||||
previous = value;
|
||||
}
|
||||
}
|
||||
@@ -87,8 +87,7 @@ fn sharpened(amount: f32, radius: f32, threshold: f32) -> EditGraph {
|
||||
fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, out: u32) -> Vec<u8> {
|
||||
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||
let scale = graph.render_scale(source.size(), (out, out));
|
||||
let detail =
|
||||
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
|
||||
let detail = graph.compose_detail(scale.full_size(), scale.render_size());
|
||||
let key = graph.invalidation().through(Affects::Colour);
|
||||
pass.render_detailed(source, &shader, out, out, None, &detail, key)
|
||||
.expect("render");
|
||||
@@ -413,7 +412,7 @@ fn dragging_the_amount_recompiles_nothing_and_reallocates_nothing() {
|
||||
let pipelines = pass.cached_detail_pipelines();
|
||||
let allocations = pass.detail_allocations();
|
||||
assert_eq!(pipelines, 2, "one per axis of the separable mask");
|
||||
assert_eq!(allocations, 2, "the colour result, and one hand-off");
|
||||
assert_eq!(allocations, 3, "the colour result, and the ping-pong pair");
|
||||
assert_eq!(pass.detail_dispatches(), 2);
|
||||
assert_eq!(pass.colour_dispatches(), 1);
|
||||
|
||||
@@ -453,13 +452,12 @@ fn dragging_the_amount_recompiles_nothing_and_reallocates_nothing() {
|
||||
|
||||
#[test]
|
||||
fn a_render_too_coarse_for_the_radius_still_reaches_the_screen() {
|
||||
// The failure mode that the pass-through exists to prevent, proved on a
|
||||
// device rather than argued about. With the radius finer than a render
|
||||
// pixel the operation declines to sharpen — but it is still active, so the
|
||||
// fused pass has already been composed to hand on unclipped linear values,
|
||||
// and something must still perform the output transform. An empty chain
|
||||
// here would not be a soft preview: it would be a hard error out of
|
||||
// `render_detailed`, on the most ordinary develop view there is.
|
||||
// Proved on a device rather than argued about. With the radius finer than
|
||||
// a render pixel the operation declines to sharpen — but it is still
|
||||
// active, so the fused pass has already been composed to hand on
|
||||
// unclipped linear values, and something must still perform the output
|
||||
// transform. Since D19 that is the view pass, whatever the chain holds:
|
||||
// the chain is empty and the frame is still whole.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
const SOURCE: u32 = 128;
|
||||
const RENDER: u32 = 32; // a quarter scale, as a fit view of a large frame
|
||||
@@ -471,7 +469,16 @@ fn a_render_too_coarse_for_the_radius_still_reaches_the_screen() {
|
||||
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
let sharp = render(&mut pass, &graph, &source, RENDER);
|
||||
assert_eq!(pass.detail_dispatches(), 1, "one pass, and it only encodes");
|
||||
assert_eq!(
|
||||
pass.detail_dispatches(),
|
||||
0,
|
||||
"nothing to sharpen at this scale"
|
||||
);
|
||||
assert_eq!(
|
||||
pass.view_dispatches(),
|
||||
1,
|
||||
"and the view pass finishes the frame"
|
||||
);
|
||||
|
||||
// And what reaches the screen is the unsharpened picture, not a black
|
||||
// frame, a linear one, or a guess.
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Contrast, on a device, at the two ends of the tonal range.
|
||||
//!
|
||||
//! The descriptor tests check that the fragment says the right words; these
|
||||
//! check what those words do to a pixel. Both failures here passed every
|
||||
//! descriptor test for months, because each is a property of the arithmetic
|
||||
//! at the extremes rather than of the shape of the code.
|
||||
|
||||
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext};
|
||||
use dr_pipeline::descriptor::ParamId;
|
||||
use dr_pipeline::operation::compose;
|
||||
use dr_pipeline::ops;
|
||||
|
||||
const SIZE: u32 = 8;
|
||||
|
||||
fn ctx() -> Option<GpuContext> {
|
||||
pollster::block_on(GpuContext::new_headless()).ok()
|
||||
}
|
||||
|
||||
/// A flat frame of one sRGB colour, contrast set to `amount`, rendered and
|
||||
/// read back as the colour of one pixel.
|
||||
fn render(ctx: &GpuContext, rgb: [u8; 3], amount: f32) -> [u8; 3] {
|
||||
let data: Vec<u8> = (0..SIZE * SIZE)
|
||||
.flat_map(|_| [rgb[0], rgb[1], rgb[2], 255])
|
||||
.collect();
|
||||
let source = DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload");
|
||||
|
||||
let mut chain = ops::chain();
|
||||
let op = chain
|
||||
.iter_mut()
|
||||
.find(|o| o.descriptor().id.0 == "contrast")
|
||||
.expect("contrast is in the chain");
|
||||
op.set_param(ParamId("contrast"), amount);
|
||||
let shader = compose(&chain);
|
||||
|
||||
let mut adjust = AdjustPass::new(ctx);
|
||||
adjust.render(&source, &shader, SIZE, SIZE).expect("render");
|
||||
let pixels = adjust.export_pixels().expect("readback").0;
|
||||
[pixels[0], pixels[1], pixels[2]]
|
||||
}
|
||||
|
||||
/// **The pink-blacks bug.** A near-black pixel whose red and blue sit a count
|
||||
/// above its green — what white-balanced sensor noise in a night shadow looks
|
||||
/// like — must come out grey when contrast is reduced, not magenta.
|
||||
///
|
||||
/// The ratio form lifted it by a gain of well over a hundred, and a hundred
|
||||
/// times a one-count cast is a saturated colour.
|
||||
#[test]
|
||||
fn reducing_contrast_lifts_a_black_to_grey_not_to_magenta() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no GPU adapter; skipping");
|
||||
return;
|
||||
};
|
||||
let [r, g, b] = render(&ctx, [4, 1, 4], -50.0);
|
||||
let spread = r.max(g).max(b) - r.min(g).min(b);
|
||||
assert!(
|
||||
g > 40,
|
||||
"a black at half contrast should be lifted toward grey, got ({r}, {g}, {b})"
|
||||
);
|
||||
assert!(
|
||||
spread <= 6,
|
||||
"the lift must be neutral: ({r}, {g}, {b}) has a cast of {spread}"
|
||||
);
|
||||
}
|
||||
|
||||
/// **The pinned highlights.** A light tone, above twice middle grey, must not
|
||||
/// be pulled down to the top of the curve by the smallest positive contrast.
|
||||
#[test]
|
||||
fn a_little_contrast_leaves_a_highlight_where_it_was() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no GPU adapter; skipping");
|
||||
return;
|
||||
};
|
||||
let before = render(&ctx, [230, 230, 230], 0.0)[1];
|
||||
let after = render(&ctx, [230, 230, 230], 10.0)[1];
|
||||
assert!(
|
||||
after >= before.saturating_sub(2),
|
||||
"contrast +10 took a highlight from {before} to {after}"
|
||||
);
|
||||
}
|
||||
@@ -38,15 +38,17 @@ fn grey(ctx: &GpuContext) -> DemosaicedImage {
|
||||
DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload")
|
||||
}
|
||||
|
||||
/// A pass that sums the instance list into the red channel and writes the
|
||||
/// output. Deliberately trivial: the value on screen is then a direct readout
|
||||
/// of what arrived in the buffer.
|
||||
/// A pass that sums the instance list into the red channel and writes a
|
||||
/// linear intermediate, which the view pass then encodes (D19). Deliberately
|
||||
/// trivial: the value on screen is then a direct readout of what arrived in the
|
||||
/// buffer, through the sRGB encode — the source is an 8-bit upload, so the
|
||||
/// view transform is skipped for it and the encode is the only thing between.
|
||||
fn summing_pass(storage: Vec<[f32; 4]>, structure: u64) -> ComposedDetailPass {
|
||||
let source = "
|
||||
@group(0) @binding(0) var source: texture_2d<f32>;
|
||||
struct Params { detail_base: vec4<f32> }
|
||||
@group(0) @binding(1) var<uniform> u: Params;
|
||||
@group(0) @binding(2) var output: texture_storage_2d<rgba8unorm, write>;
|
||||
@group(0) @binding(2) var output: texture_storage_2d<rgba16float, write>;
|
||||
@group(0) @binding(3) var<storage, read> instances: array<vec4<f32>>;
|
||||
|
||||
@compute @workgroup_size(8, 8, 1)
|
||||
@@ -61,7 +63,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
for (var i = 0u; i < n; i = i + 1u) {
|
||||
total = total + instances[i].x * f32(i + 1u);
|
||||
}
|
||||
textureStore(output, vec2<i32>(gid.xy), vec4<f32>(total, f32(n) / 255.0, 0.0, 1.0));
|
||||
textureStore(output, vec2<i32>(gid.xy), vec4<f32>(total, f32(n) * 0.1, 0.0, 1.0));
|
||||
}
|
||||
"
|
||||
.to_string();
|
||||
@@ -73,13 +75,17 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
uniforms: vec![SIZE as f32, SIZE as f32, 1.0, 0.0],
|
||||
storage,
|
||||
radius: 0,
|
||||
writes_output: true,
|
||||
// Any distinct number: the hash is a cache key, and these tests are
|
||||
// what decide whether two chains share a pipeline.
|
||||
structure_hash: structure,
|
||||
}
|
||||
}
|
||||
|
||||
/// A linear value as the view pass leaves it in the 8-bit output.
|
||||
fn encoded(linear: f32) -> u8 {
|
||||
(dr_types::Transfer::Srgb.encode(linear) * 255.0).round() as u8
|
||||
}
|
||||
|
||||
fn render(pass: &mut AdjustPass, source: &DemosaicedImage, chain: &ComposedDetail) -> Vec<u8> {
|
||||
// The fused half has to be composed knowing a detail stage follows it, or
|
||||
// it encodes its own output and the chain would quantise twice — a mismatch
|
||||
@@ -118,12 +124,15 @@ fn a_pass_reads_the_list_it_was_given() {
|
||||
let pixels = render(&mut pass, &source, &chain);
|
||||
let (red, green) = (pixels[0], pixels[1]);
|
||||
|
||||
// 0.05·1 + 0.1·2 = 0.25, written straight to an rgba8 target.
|
||||
// 0.05·1 + 0.1·2 = 0.25.
|
||||
assert!(
|
||||
red.abs_diff((0.25 * 255.0) as u8) <= 1,
|
||||
red.abs_diff(encoded(0.25)) <= 1,
|
||||
"the shader summed {red}, not the list it was handed"
|
||||
);
|
||||
assert_eq!(green, 2, "arrayLength saw both entries");
|
||||
assert!(
|
||||
green.abs_diff(encoded(0.2)) <= 1,
|
||||
"arrayLength saw both entries"
|
||||
);
|
||||
}
|
||||
|
||||
/// A convolution declares no list and must still run: it is bound to the
|
||||
@@ -142,7 +151,10 @@ fn a_pass_with_no_list_still_runs() {
|
||||
|
||||
let pixels = render(&mut pass, &source, &chain);
|
||||
assert_eq!(pixels[0], 0, "the placeholder is zeroed");
|
||||
assert_eq!(pixels[1], 1, "and is exactly one element long");
|
||||
assert!(
|
||||
pixels[1].abs_diff(encoded(0.1)) <= 1,
|
||||
"and is exactly one element long"
|
||||
);
|
||||
}
|
||||
|
||||
/// The property that makes placing the tenth spot as cheap as moving a slider:
|
||||
|
||||
@@ -101,8 +101,7 @@ fn render(
|
||||
let _ = ctx;
|
||||
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||
let scale = graph.render_scale(source.size(), (out, out));
|
||||
let detail =
|
||||
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
|
||||
let detail = graph.compose_detail(scale.full_size(), scale.render_size());
|
||||
let key = graph.invalidation().through(Affects::Colour);
|
||||
pass.render_detailed(source, &shader, out, out, None, &detail, key)
|
||||
.expect("render");
|
||||
@@ -301,7 +300,7 @@ fn dragging_a_slider_recompiles_nothing_and_reallocates_nothing() {
|
||||
let pipelines = pass.cached_detail_pipelines();
|
||||
let allocations = pass.detail_allocations();
|
||||
assert_eq!(pipelines, 2, "one per pass of the separable blur");
|
||||
assert_eq!(allocations, 2, "the colour result, and one hand-off");
|
||||
assert_eq!(allocations, 3, "the colour result, and the ping-pong pair");
|
||||
|
||||
for radius in [0.06, 0.07, 0.08, 0.09] {
|
||||
graph.set_param(PROBE, RADIUS, radius);
|
||||
@@ -403,8 +402,7 @@ fn an_empty_chain_falls_through_to_the_ordinary_render() {
|
||||
|
||||
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||
let scale = graph.render_scale((SIZE, SIZE), (SIZE, SIZE));
|
||||
let detail =
|
||||
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
|
||||
let detail = graph.compose_detail(scale.full_size(), scale.render_size());
|
||||
assert!(detail.is_empty());
|
||||
|
||||
pass.render_detailed(&source, &shader, SIZE, SIZE, None, &detail, 0)
|
||||
|
||||
@@ -15,10 +15,13 @@
|
||||
//! model is checked against the reference, and the shader is checked against
|
||||
//! the CPU model.
|
||||
|
||||
use dr_decode::{BaseCurve, CfaPattern, CropRect, RawImage};
|
||||
use dr_film::bake::{bake, Recipe};
|
||||
use dr_gpu::{AdjustPass, Demosaicer, GpuContext};
|
||||
use dr_pipeline::ops::FilmTables;
|
||||
use dr_decode::{CfaPattern, CropRect, RawImage};
|
||||
use dr_film::bake::{bake, Recipe, Settings};
|
||||
use dr_gpu::{AdjustPass, Demosaicer, GpuContext, LabelField, MaskPass};
|
||||
use dr_pipeline::mask::{MaskLayer, MaskSource};
|
||||
use dr_pipeline::ops::film_sim;
|
||||
use dr_pipeline::ops::film_sim::FORMAT_COUNT;
|
||||
use dr_pipeline::ops::{FilmTables, PaperTables};
|
||||
use dr_pipeline::EditGraph;
|
||||
|
||||
const SIZE: u32 = 16;
|
||||
@@ -45,7 +48,6 @@ fn flat_raw(level: u16) -> RawImage {
|
||||
// Off deliberately: a film replaces the camera's rendering, and
|
||||
// leaving a curve here would test the suppression rather than the
|
||||
// film. `dr-pipeline` asserts the suppression on the generated source.
|
||||
base_curve: BaseCurve::IDENTITY,
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
make: String::new(),
|
||||
@@ -77,15 +79,31 @@ fn tables(baked: &dr_film::Baked) -> FilmTables {
|
||||
/// split a grain that never left the CPU would look exactly like a passing
|
||||
/// test suite.
|
||||
fn tables_with_grain(baked: &dr_film::Baked, particles: [f32; 3]) -> FilmTables {
|
||||
// The paper, when there is one, rides behind the film: its curve as one
|
||||
// more row, its cube stacked after the film's.
|
||||
let mut curves = baked.curves.clone();
|
||||
let mut lut = baked.lut.clone();
|
||||
let paper = baked.paper.as_ref().map(|p| {
|
||||
curves.extend_from_slice(&p.curves);
|
||||
lut.extend_from_slice(&p.lut);
|
||||
PaperTables {
|
||||
balance: p.balance,
|
||||
log_min: p.log_min,
|
||||
log_max: p.log_max,
|
||||
density_max: p.density_max,
|
||||
}
|
||||
});
|
||||
FilmTables {
|
||||
exposure_matrix: baked.exposure_matrix,
|
||||
curves: baked.curves.clone(),
|
||||
curves,
|
||||
push_stations: baked.push_stations.clone(),
|
||||
curve_log_min: baked.curve_log_min,
|
||||
curve_log_max: baked.curve_log_max,
|
||||
lut: baked.lut.clone(),
|
||||
lut,
|
||||
density_max: baked.density_max,
|
||||
lut_size: baked.lut_size,
|
||||
grain_particles: particles,
|
||||
paper,
|
||||
grain_particles: [particles; FORMAT_COUNT],
|
||||
grain_density_max: [baked.density_max; 3],
|
||||
grain_uniformity: 0.97,
|
||||
}
|
||||
@@ -241,3 +259,241 @@ fn grain_reaches_the_shader_and_scales_with_the_pixel() {
|
||||
"grain never reached the shader: the coarsest setting moved the pixel by {coarse_err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The same render, with the film's sliders set and mask layers laid over it.
|
||||
///
|
||||
/// Every layer is a `Regions` mask over a field splitting the frame down the
|
||||
/// middle: region 0, the left half, at full weight, and the right half
|
||||
/// untouched. Returns the left and right centre pixels, linear.
|
||||
fn rendered_split(
|
||||
ctx: &GpuContext,
|
||||
level: u16,
|
||||
tables: FilmTables,
|
||||
global: Settings,
|
||||
layers: Vec<MaskLayer>,
|
||||
) -> ([f32; 3], [f32; 3]) {
|
||||
let source = Demosaicer::new(ctx)
|
||||
.expect("demosaicer")
|
||||
.run(&flat_raw(level))
|
||||
.expect("demosaic");
|
||||
|
||||
let mut graph = EditGraph::default_chain();
|
||||
graph.set_film(Some(dr_pipeline::graph::Film {
|
||||
stock: "under_test".to_string(),
|
||||
print: None,
|
||||
tables: tables.clone(),
|
||||
}));
|
||||
graph.set_param(film_sim::ID, film_sim::EXPOSURE, global.exposure_ev);
|
||||
graph.set_param(film_sim::ID, film_sim::PUSH, global.push_stops);
|
||||
graph.set_param(
|
||||
film_sim::ID,
|
||||
film_sim::PRINT_EXPOSURE,
|
||||
global.print_exposure_ev,
|
||||
);
|
||||
for layer in layers {
|
||||
graph.masks_mut().push(layer);
|
||||
}
|
||||
let shader = graph.compose();
|
||||
|
||||
let labels: Vec<u32> = (0..SIZE * SIZE)
|
||||
.map(|i| u32::from(i % SIZE >= SIZE / 2))
|
||||
.collect();
|
||||
let field = LabelField::upload(ctx, &labels, SIZE, SIZE, 2).expect("label upload");
|
||||
let mut masks = MaskPass::new(ctx).expect("mask pass");
|
||||
let array = masks
|
||||
.render(graph.masks(), Some(&field), None, None, SIZE, SIZE)
|
||||
.expect("rasterise");
|
||||
|
||||
let mut adjust = AdjustPass::new(ctx);
|
||||
adjust.set_film(Some(&tables));
|
||||
adjust
|
||||
.render_masked(&source, &shader, SIZE, SIZE, Some(array))
|
||||
.expect("render");
|
||||
let (pixels, _, _) = adjust.export_pixels().expect("readback");
|
||||
let at = |x: u32| {
|
||||
let c = (((SIZE / 2) * SIZE + x) * 4) as usize;
|
||||
[0, 1, 2].map(|i| srgb_to_linear(f32::from(pixels[c + i]) / 255.0))
|
||||
};
|
||||
(at(SIZE / 4), at(3 * SIZE / 4))
|
||||
}
|
||||
|
||||
/// A layer over the left half holding these film offsets.
|
||||
fn left_half(id: &str, offsets: &[(dr_pipeline::descriptor::ParamId, f32)]) -> MaskLayer {
|
||||
let mut layer = MaskLayer::new(
|
||||
id,
|
||||
MaskSource::Regions {
|
||||
signature: 1,
|
||||
level: 2,
|
||||
ids: vec![0],
|
||||
},
|
||||
);
|
||||
for (param, v) in offsets {
|
||||
layer.set_param(film_sim::ID.0, *param, *v);
|
||||
}
|
||||
layer
|
||||
}
|
||||
|
||||
fn assert_close(got: [f32; 3], want: [f32; 3], what: &str) {
|
||||
for c in 0..3 {
|
||||
assert!(
|
||||
(got[c] - want[c]).abs() < 0.02,
|
||||
"{what}, channel {c}: GPU gave {got:?}, the model says {want:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_print_renders_on_the_gpu_the_way_it_does_on_the_cpu_at_any_setting() {
|
||||
// TRACES: FR-DEV-3f
|
||||
// The print path — the film's lookup into the paper's log exposure, the
|
||||
// enlarger added between, the paper's curve and its own lookup — is read
|
||||
// from the same two textures as the film, at offsets. Every one of those
|
||||
// offsets is a way to render a plausible print of the wrong thing.
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no GPU adapter; skipping");
|
||||
return;
|
||||
};
|
||||
let film = dr_film::find("kodak_portra_400").expect("stock");
|
||||
let paper = dr_film::default_print(film).expect("paper");
|
||||
let baked = bake(&Recipe::new(film, Some(paper)));
|
||||
|
||||
for settings in [
|
||||
Settings::default(),
|
||||
Settings {
|
||||
print_exposure_ev: -1.3,
|
||||
..Settings::default()
|
||||
},
|
||||
Settings {
|
||||
exposure_ev: 0.4,
|
||||
print_exposure_ev: 0.8,
|
||||
..Settings::default()
|
||||
},
|
||||
] {
|
||||
for level in [6_000u16, 20_000] {
|
||||
let input = f32::from(level) / f32::from(u16::MAX);
|
||||
let (got, _) = rendered_split(&ctx, level, tables(&baked), settings, Vec::new());
|
||||
assert_close(
|
||||
got,
|
||||
baked.apply_at([input; 3], &settings),
|
||||
&format!("{settings:?} at {level}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_push_between_two_measured_processes_renders_as_the_model_does() {
|
||||
// TRACES: FR-DEV-3f
|
||||
// Double-X measures five processes; a push between two is a mix of two
|
||||
// rows of the curve texture, found by searching the stations uniform.
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no GPU adapter; skipping");
|
||||
return;
|
||||
};
|
||||
let film = dr_film::find("kodak_doublex").expect("stock");
|
||||
let baked = bake(&Recipe::new(film, None));
|
||||
assert!(baked.curve_rows > 2, "Double-X has a development series");
|
||||
|
||||
for push in [-0.8f32, 0.4, 1.3, 2.9] {
|
||||
let settings = Settings {
|
||||
push_stops: push,
|
||||
..Settings::default()
|
||||
};
|
||||
let level = 12_000u16;
|
||||
let input = f32::from(level) / f32::from(u16::MAX);
|
||||
let (got, _) = rendered_split(&ctx, level, tables(&baked), settings, Vec::new());
|
||||
assert_close(
|
||||
got,
|
||||
baked.apply_at([input; 3], &settings),
|
||||
&format!("push {push}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_develops_its_region_on_its_own_settings() {
|
||||
// TRACES: FR-DEV-3f
|
||||
// Offsets to the photograph's: print exposure +1 on a photograph at +0.5
|
||||
// is +1.5 under the layer, and the rest of the print is untouched. Before
|
||||
// film was blended as settings the layer's sliders moved and nothing
|
||||
// happened, because the layer's copy of the node had no stock.
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no GPU adapter; skipping");
|
||||
return;
|
||||
};
|
||||
let film = dr_film::find("kodak_portra_400").expect("stock");
|
||||
let paper = dr_film::default_print(film).expect("paper");
|
||||
let baked = bake(&Recipe::new(film, Some(paper)));
|
||||
let level = 12_000u16;
|
||||
let input = f32::from(level) / f32::from(u16::MAX);
|
||||
|
||||
let global = Settings {
|
||||
print_exposure_ev: 0.5,
|
||||
..Settings::default()
|
||||
};
|
||||
let layer = left_half(
|
||||
"burn",
|
||||
&[(film_sim::PRINT_EXPOSURE, 1.0), (film_sim::EXPOSURE, -0.5)],
|
||||
);
|
||||
let (left, right) = rendered_split(&ctx, level, tables(&baked), global, vec![layer]);
|
||||
|
||||
let under = Settings {
|
||||
exposure_ev: -0.5,
|
||||
print_exposure_ev: 1.5,
|
||||
..Settings::default()
|
||||
};
|
||||
assert_close(left, baked.apply_at([input; 3], &under), "under the layer");
|
||||
assert_close(right, baked.apply_at([input; 3], &global), "outside it");
|
||||
assert!(
|
||||
left[1] < right[1] - 0.01,
|
||||
"the burn did not darken: {left:?} vs {right:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_layers_take_the_average_of_their_settings() {
|
||||
// TRACES: FR-DEV-3f
|
||||
// Three layers over the same pixels at full weight: the plain mean of what
|
||||
// each asks for, and the global setting has no weight left. Summed, the
|
||||
// offsets would be -2 stops of print exposure and +1 of exposure; the mean
|
||||
// is (-1, -1, 0) / 3 and (0, 0, +1) / 3.
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no GPU adapter; skipping");
|
||||
return;
|
||||
};
|
||||
let film = dr_film::find("kodak_portra_400").expect("stock");
|
||||
let paper = dr_film::default_print(film).expect("paper");
|
||||
let baked = bake(&Recipe::new(film, Some(paper)));
|
||||
let level = 12_000u16;
|
||||
let input = f32::from(level) / f32::from(u16::MAX);
|
||||
|
||||
let layers = vec![
|
||||
left_half("a", &[(film_sim::PRINT_EXPOSURE, -1.0)]),
|
||||
left_half("b", &[(film_sim::PRINT_EXPOSURE, -1.0)]),
|
||||
left_half("c", &[(film_sim::EXPOSURE, 1.0)]),
|
||||
];
|
||||
let (left, right) = rendered_split(&ctx, level, tables(&baked), Settings::default(), layers);
|
||||
|
||||
let mean = Settings {
|
||||
exposure_ev: 1.0 / 3.0,
|
||||
print_exposure_ev: -2.0 / 3.0,
|
||||
..Settings::default()
|
||||
};
|
||||
let summed = Settings {
|
||||
exposure_ev: 1.0,
|
||||
print_exposure_ev: -2.0,
|
||||
..Settings::default()
|
||||
};
|
||||
let (m, s) = (
|
||||
baked.apply_at([input; 3], &mean)[1],
|
||||
baked.apply_at([input; 3], &summed)[1],
|
||||
);
|
||||
// Three times the tolerance the GPU is held to below, or a sum could pass
|
||||
// for a mean.
|
||||
assert!(
|
||||
(m - s).abs() > 0.06,
|
||||
"the mean and the sum render alike ({m} vs {s}), so this proves nothing"
|
||||
);
|
||||
assert_close(left, baked.apply_at([input; 3], &mean), "under all three");
|
||||
assert_close(right, baked.apply([input; 3]), "outside them");
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
//!
|
||||
//! FR-DSP-3 says a slider updates the visible region within one frame budget at
|
||||
//! proxy resolution. Until this file existed nothing checked it, which made it
|
||||
//! a wish — `docs/display-and-extension.md` §3 is blunt about that, and §7 is
|
||||
//! a wish — `docs/dev/display-and-extension.md` §3 is blunt about that, and §7 is
|
||||
//! blunt about what tagging an unchecked requirement does to the coverage
|
||||
//! figure.
|
||||
//!
|
||||
//! The measurements this guards are in [`docs/frame-budget.md`], produced by
|
||||
//! The measurements this guards are in [`docs/dev/frame-budget.md`], produced by
|
||||
//! `examples/frame_budget.rs`. This file is the part of them that has to keep
|
||||
//! being true: it renders the **whole point-operation chain** through the real
|
||||
//! `render_detailed` for a hundred frames, moving a slider between each, and
|
||||
@@ -17,7 +17,7 @@
|
||||
//! **The neighbourhood stage is deliberately not in the asserted chain.** It is
|
||||
//! over the budget today — clarity alone is 34 ms at 4K, because its kernel is
|
||||
//! a fraction of the frame and reaches a 52-pixel radius there — and
|
||||
//! `docs/frame-budget.md` records that, names the fix (a base computed at
|
||||
//! `docs/dev/frame-budget.md` records that, names the fix (a base computed at
|
||||
//! reduced resolution) and does not pretend otherwise. Asserting a budget the
|
||||
//! code does not meet would produce a red suite that everyone learns to ignore;
|
||||
//! asserting it on a chain that quietly excluded the expensive stage *without
|
||||
@@ -81,7 +81,7 @@ const SOURCE: (u32, u32) = (6000, 4000);
|
||||
/// The viewport the budget is asserted at: a 16:10 desktop display.
|
||||
///
|
||||
/// Not 4K, and the reason is worth stating. At 4K the fused chain still passes
|
||||
/// with room to spare (4.5 ms of GPU; see `docs/frame-budget.md`), but a test
|
||||
/// with room to spare (4.5 ms of GPU; see `docs/dev/frame-budget.md`), but a test
|
||||
/// that renders 8.3 M pixels a hundred times twice over is four seconds of
|
||||
/// suite time to re-establish a conclusion 4.1 M pixels already establishes.
|
||||
const VIEWPORT: (u32, u32) = (2560, 1600);
|
||||
@@ -166,7 +166,7 @@ impl Run {
|
||||
judged <= BUDGET_MS,
|
||||
"{case} at {}x{}: p99 of {FRAMES} frames was {judged:.2} ms, over the \
|
||||
{BUDGET_MS:.0} ms budget (cpu {:.2} ms, gpu {:.2} ms, total {:.2} ms). \
|
||||
FR-DSP-3 is what this violates; docs/frame-budget.md holds the \
|
||||
FR-DSP-3 is what this violates; docs/dev/frame-budget.md holds the \
|
||||
numbers it used to be.",
|
||||
viewport.0,
|
||||
viewport.1,
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
//! TRACES: FR-RAW-3
|
||||
//! Hot and dead photosite repair, end to end on a device.
|
||||
//!
|
||||
//! Each test renders a frame twice — once with a defect, once without — and
|
||||
//! compares the finished pixels. That is the only comparison that means
|
||||
//! anything: the repair happens on the mosaic, and what a photographer would
|
||||
//! see of a defect it missed is the coloured cross the demosaic makes of it.
|
||||
|
||||
use dr_decode::{CfaPattern, CropRect, RawImage};
|
||||
use dr_gpu::{AdjustPass, Demosaicer, GpuContext};
|
||||
use dr_pipeline::EditGraph;
|
||||
|
||||
const SIZE: u32 = 36;
|
||||
const WHITE: u16 = 4095;
|
||||
|
||||
fn ctx() -> Option<GpuContext> {
|
||||
pollster::block_on(GpuContext::new_headless()).ok()
|
||||
}
|
||||
|
||||
/// A flat frame at `level`, with `set` applied to its photosites.
|
||||
fn frame(pattern: CfaPattern, level: u16, set: &[(u32, u32, u16)]) -> RawImage {
|
||||
let mut data = vec![level; (SIZE * SIZE) as usize];
|
||||
for &(x, y, v) in set {
|
||||
data[(y * SIZE + x) as usize] = v;
|
||||
}
|
||||
RawImage {
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
data,
|
||||
cfa_pattern: pattern,
|
||||
black_level: [0; 4],
|
||||
white_level: WHITE,
|
||||
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
||||
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: CropRect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn render(ctx: &GpuContext, raw: &RawImage) -> Vec<u8> {
|
||||
let source = Demosaicer::new(ctx)
|
||||
.expect("demosaicer")
|
||||
.run(raw)
|
||||
.expect("demosaic");
|
||||
let shader = EditGraph::default_chain().compose();
|
||||
let mut adjust = AdjustPass::new(ctx);
|
||||
adjust.render(&source, &shader, SIZE, SIZE).expect("render");
|
||||
adjust.export_pixels().expect("readback").0
|
||||
}
|
||||
|
||||
/// The largest channel difference between two renders.
|
||||
fn worst(a: &[u8], b: &[u8]) -> u8 {
|
||||
a.iter().zip(b).map(|(x, y)| x.abs_diff(*y)).max().unwrap()
|
||||
}
|
||||
|
||||
const MIDDLE: u32 = SIZE / 2;
|
||||
|
||||
/// **The feature.** A photosite at white in a dark frame — a hot pixel in a
|
||||
/// night sky — leaves no trace in the rendered picture.
|
||||
#[test]
|
||||
fn a_hot_photosite_in_a_dark_frame_is_invisible() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no GPU adapter; skipping");
|
||||
return;
|
||||
};
|
||||
let clean = render(&ctx, &frame(CfaPattern::Rggb, 40, &[]));
|
||||
for (x, y) in [
|
||||
(MIDDLE, MIDDLE),
|
||||
(MIDDLE + 1, MIDDLE),
|
||||
(MIDDLE + 1, MIDDLE + 1),
|
||||
] {
|
||||
let hot = render(&ctx, &frame(CfaPattern::Rggb, 40, &[(x, y, WHITE)]));
|
||||
let diff = worst(&clean, &hot);
|
||||
assert!(
|
||||
diff <= 1,
|
||||
"a hot photosite at ({x}, {y}) still shows, by {diff}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The same for one stuck dark in a lit area.
|
||||
#[test]
|
||||
fn a_dead_photosite_in_a_lit_frame_is_invisible() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no GPU adapter; skipping");
|
||||
return;
|
||||
};
|
||||
let clean = render(&ctx, &frame(CfaPattern::Rggb, 1600, &[]));
|
||||
let dead = render(&ctx, &frame(CfaPattern::Rggb, 1600, &[(MIDDLE, MIDDLE, 0)]));
|
||||
let diff = worst(&clean, &dead);
|
||||
assert!(diff <= 1, "a dead photosite still shows, by {diff}");
|
||||
}
|
||||
|
||||
/// **What it must not eat.** A point of real light lands on a patch of
|
||||
/// photosites, not one — so a 3×3 highlight survives, even at its brightest.
|
||||
#[test]
|
||||
fn a_small_real_highlight_survives() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no GPU adapter; skipping");
|
||||
return;
|
||||
};
|
||||
let mut star = Vec::new();
|
||||
for dy in 0..3 {
|
||||
for dx in 0..3 {
|
||||
star.push((MIDDLE - 1 + dx, MIDDLE - 1 + dy, WHITE));
|
||||
}
|
||||
}
|
||||
let clean = render(&ctx, &frame(CfaPattern::Rggb, 40, &[]));
|
||||
let lit = render(&ctx, &frame(CfaPattern::Rggb, 40, &star));
|
||||
let at = ((MIDDLE * SIZE + MIDDLE) * 4 + 1) as usize;
|
||||
assert!(
|
||||
lit[at] > clean[at] + 100,
|
||||
"the highlight was repaired away: {} against a background of {}",
|
||||
lit[at],
|
||||
clean[at]
|
||||
);
|
||||
}
|
||||
|
||||
/// The Fujifilm path goes through the same repair, with its own tile.
|
||||
#[test]
|
||||
fn a_hot_photosite_on_x_trans_is_invisible() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no GPU adapter; skipping");
|
||||
return;
|
||||
};
|
||||
let clean = render(&ctx, &frame(CfaPattern::XTrans, 40, &[]));
|
||||
let hot = render(
|
||||
&ctx,
|
||||
&frame(CfaPattern::XTrans, 40, &[(MIDDLE, MIDDLE, WHITE)]),
|
||||
);
|
||||
let diff = worst(&clean, &hot);
|
||||
assert!(diff <= 1, "a hot X-Trans photosite still shows, by {diff}");
|
||||
}
|
||||
@@ -790,6 +790,119 @@ fn the_order_parts_are_joined_in_is_the_mask() {
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-19a
|
||||
/// The truth table `docs/dev/mask-editing.md` §13 asks for: one base, one
|
||||
/// part that half-covers it, joined each of the three ways. The base is the
|
||||
/// left half of the frame and the part a dab in the middle, so the four
|
||||
/// quarters of the table are four pixels.
|
||||
#[test]
|
||||
fn a_part_unioned_subtracted_and_intersected_gives_the_three_fields() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let field = split_field(&ctx);
|
||||
let joined = |join: Join| {
|
||||
let mut layer = brighten(MaskSource::Regions {
|
||||
signature: 1,
|
||||
level: 2,
|
||||
ids: vec![0],
|
||||
});
|
||||
assert!(layer.push_part(MaskPart::painted("p2", join)));
|
||||
paint(&mut layer, 1, false, &[(0.5, 0.5)]);
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(layer);
|
||||
render(&ctx, &stack, Some(&field))
|
||||
};
|
||||
|
||||
// (base, part): left outside the dab, left inside, right inside, right
|
||||
// outside.
|
||||
let cells = [(4, 16), (14, 16), (18, 16), (27, 16)];
|
||||
let lit = |pixels: &[u8]| cells.map(|(x, y)| luma_at(pixels, x, y) > 200);
|
||||
|
||||
assert_eq!(
|
||||
lit(&joined(Join::Union)),
|
||||
[true, true, true, false],
|
||||
"union: either"
|
||||
);
|
||||
assert_eq!(
|
||||
lit(&joined(Join::Subtract)),
|
||||
[true, false, false, false],
|
||||
"subtract: the base without the dab"
|
||||
);
|
||||
assert_eq!(
|
||||
lit(&joined(Join::Intersect)),
|
||||
[false, true, false, false],
|
||||
"intersect: only where both are"
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-19a
|
||||
/// Intersection on soft coverage is the product `Join::apply` defines, on
|
||||
/// either side of the join: a gradient intersected with a region it fills is
|
||||
/// the gradient there and nothing elsewhere, and a region intersected with a
|
||||
/// gradient is the gradient wherever the region is.
|
||||
#[test]
|
||||
fn the_joins_match_their_definition() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let field = split_field(&ctx);
|
||||
let ramp = || MaskSource::Linear {
|
||||
centre: (0.5, 0.5),
|
||||
angle: 0.0,
|
||||
width: 1.0,
|
||||
};
|
||||
let right_half = || MaskSource::Regions {
|
||||
signature: 1,
|
||||
level: 2,
|
||||
ids: vec![1],
|
||||
};
|
||||
let draw = |layer: MaskLayer| {
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(layer);
|
||||
render(&ctx, &stack, Some(&field))
|
||||
};
|
||||
|
||||
let alone = draw(brighten(ramp()));
|
||||
|
||||
let mut ramp_then_region = brighten(ramp());
|
||||
assert!(ramp_then_region.push_part(MaskPart::new("p2", Join::Intersect, right_half())));
|
||||
let ramp_then_region = draw(ramp_then_region);
|
||||
|
||||
let mut region_then_ramp = brighten(whole_frame());
|
||||
assert!(region_then_ramp.push_part(MaskPart::new("p2", Join::Intersect, ramp())));
|
||||
let region_then_ramp = draw(region_then_ramp);
|
||||
|
||||
for x in 0..SIZE {
|
||||
let y = SIZE / 2;
|
||||
let want = luma_at(&alone, x, y);
|
||||
// dst · 1 = dst on the right; dst · 0 = 0 on the left. Pixels
|
||||
// within two of the seam are left out: the region's own edge
|
||||
// is soft there, so neither side of the table is 0 or 1.
|
||||
let got = luma_at(&ramp_then_region, x, y);
|
||||
if x.abs_diff(SIZE / 2) <= 2 {
|
||||
// The seam.
|
||||
} else if x > SIZE / 2 {
|
||||
assert!(
|
||||
got.abs_diff(want) <= 1,
|
||||
"x={x}: the ramp survives where the region is ({got} vs {want})"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(got, 128, "x={x}: and nothing survives where it is not");
|
||||
}
|
||||
// 1 · src = src everywhere.
|
||||
let got = luma_at(®ion_then_ramp, x, y);
|
||||
assert!(
|
||||
got.abs_diff(want) <= 1,
|
||||
"x={x}: a full base intersected with the ramp is the ramp ({got} vs {want})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- seeing the mask (FR-DEV-19c) ------------------------------------------
|
||||
|
||||
/// A radial that covers the middle of the frame and nothing near the corners.
|
||||
@@ -1022,3 +1135,76 @@ fn two_shown_masks_are_drawn_each_in_its_own_colour() {
|
||||
"between them, alpha shows black: ({r}, {g}, {b})"
|
||||
);
|
||||
}
|
||||
|
||||
/// Render a mid-grey-and-shadows frame through `chain` and `stack`.
|
||||
fn render_chain(
|
||||
ctx: &GpuContext,
|
||||
chain: &[Box<dyn dr_pipeline::operation::Operation>],
|
||||
stack: &MaskStack,
|
||||
field: Option<&LabelField>,
|
||||
) -> Vec<u8> {
|
||||
// A ramp, so both ends of the tonal range are in the comparison.
|
||||
let data: Vec<u8> = (0..SIZE * SIZE)
|
||||
.flat_map(|i| {
|
||||
let v = ((i % SIZE) * 255 / (SIZE - 1)) as u8;
|
||||
[v, v / 2, v, 255]
|
||||
})
|
||||
.collect();
|
||||
let source = DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload");
|
||||
let shader = compose_full(
|
||||
chain,
|
||||
&Framing::new(),
|
||||
ColourSpace::Srgb,
|
||||
stack,
|
||||
&SpotSet::new(),
|
||||
&[],
|
||||
);
|
||||
let mut masks = MaskPass::new(ctx).expect("mask pass");
|
||||
let array = masks
|
||||
.render(stack, field, None, None, SIZE, SIZE)
|
||||
.expect("rasterise");
|
||||
let mut adjust = AdjustPass::new(ctx);
|
||||
adjust
|
||||
.render_masked(&source, &shader, SIZE, SIZE, Some(array))
|
||||
.expect("render");
|
||||
adjust.export_pixels().expect("readback").0
|
||||
}
|
||||
|
||||
fn contrast_chain(v: f32) -> Vec<Box<dyn dr_pipeline::operation::Operation>> {
|
||||
let mut chain = ops::chain();
|
||||
chain
|
||||
.iter_mut()
|
||||
.find(|o| o.descriptor().id.0 == "contrast")
|
||||
.expect("contrast")
|
||||
.set_param(ParamId("contrast"), v);
|
||||
chain
|
||||
}
|
||||
|
||||
/// A layer's setting is an offset to the global one, applied once: global
|
||||
/// −30 with a whole-frame layer at −20 is exactly global −50 — not −30 and
|
||||
/// then −20 again on the result, which is what a layer used to do.
|
||||
#[test]
|
||||
fn a_whole_frame_layer_adds_its_setting_to_the_global_one() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no GPU adapter; skipping");
|
||||
return;
|
||||
};
|
||||
let mut layer = MaskLayer::new("m1", whole_frame());
|
||||
layer.set_param("contrast", ParamId("contrast"), -20.0);
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(layer);
|
||||
|
||||
let field = split_field(&ctx);
|
||||
let offset = render_chain(&ctx, &contrast_chain(-30.0), &stack, Some(&field));
|
||||
let direct = render_chain(&ctx, &contrast_chain(-50.0), &MaskStack::new(), None);
|
||||
let worst = offset
|
||||
.iter()
|
||||
.zip(&direct)
|
||||
.map(|(a, b)| a.abs_diff(*b))
|
||||
.max()
|
||||
.unwrap();
|
||||
assert!(
|
||||
worst <= 1,
|
||||
"layer offset differs from the summed setting by {worst}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -109,8 +109,7 @@ fn row_rgb(pixels: &[u8], size: u32, y: u32) -> Vec<[u8; 3]> {
|
||||
fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, out: u32) -> Vec<u8> {
|
||||
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||
let scale = graph.render_scale(source.size(), (out, out));
|
||||
let detail =
|
||||
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
|
||||
let detail = graph.compose_detail(scale.full_size(), scale.render_size());
|
||||
let key = graph.invalidation().through(Affects::Colour);
|
||||
pass.render_detailed(source, &shader, out, out, None, &detail, key)
|
||||
.expect("render");
|
||||
@@ -326,7 +325,7 @@ fn a_proxy_and_an_export_agree_about_the_effect() {
|
||||
|
||||
#[test]
|
||||
fn crossing_the_reduction_threshold_does_not_change_the_picture() {
|
||||
// TRACES: FR-DSP-3 — `docs/technical-debt.md` TD-4, held in pixels.
|
||||
// TRACES: FR-DSP-3 — `docs/dev/technical-debt.md` TD-4, held in pixels.
|
||||
//
|
||||
// Clarity's base is computed on a reduced grid, and how reduced depends on
|
||||
// the viewport: `LocalContrast::reduction` steps 4 -> 2 -> 1 as sigma
|
||||
@@ -683,28 +682,21 @@ fn texture_contributes_nothing_where_its_scale_does_not_exist() {
|
||||
// `render_masked`, and was rejected for handing a linear-working shader
|
||||
// to the plain path — so texture alone on a thumbnail did not render.
|
||||
//
|
||||
// The seam was closed where that note said it would have to be, at the
|
||||
// composition boundary: `compose_detail` now emits a bodyless
|
||||
// `detail/resolve` pass in exactly this case, which reads only the pixel
|
||||
// it writes and performs the output transform the fused pass declined to
|
||||
// do. So the chain is no longer empty — it carries precisely the one pass
|
||||
// that finishes the render and no kernel at all, which is the honest
|
||||
// description of "a two-pixel surface structure is not present in a
|
||||
// 128-pixel rendering".
|
||||
// The seam was closed at the composition boundary, and closed again,
|
||||
// more simply, by D19: no detail pass encodes any more, the fused pass's
|
||||
// view pass performs the output transform whatever the chain holds, and
|
||||
// so the empty chain is a whole render. That is the honest description of
|
||||
// "a two-pixel surface structure is not present in a 128-pixel
|
||||
// rendering".
|
||||
let scale = graph.render_scale(source.size(), (128, 128));
|
||||
let composed =
|
||||
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
|
||||
assert_eq!(
|
||||
composed.len(),
|
||||
1,
|
||||
"the chain must carry the resolve pass and nothing else"
|
||||
);
|
||||
assert_eq!(composed.passes[0].label, "detail/resolve");
|
||||
assert_eq!(
|
||||
composed.radius(),
|
||||
0,
|
||||
let composed = graph.compose_detail(scale.full_size(), scale.render_size());
|
||||
assert!(
|
||||
composed.is_empty(),
|
||||
"texture claimed a kernel it cannot draw"
|
||||
);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
render(&mut pass, &graph, &source, 128);
|
||||
assert_eq!(pass.view_dispatches(), 1, "the view pass still finishes it");
|
||||
|
||||
// With clarity on as well the edit is renderable again, and the dispatch
|
||||
// count says what the assertion above says: two passes, not four. Texture
|
||||
|
||||
@@ -73,8 +73,7 @@ fn render_at(
|
||||
scale: RenderScale,
|
||||
) -> Vec<u8> {
|
||||
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||
let detail =
|
||||
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
|
||||
let detail = graph.compose_detail(scale.full_size(), scale.render_size());
|
||||
let key = graph.invalidation().through(Affects::Colour);
|
||||
pass.render_detailed(source, &shader, out.0, out.1, None, &detail, key)
|
||||
.expect("render");
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
//! TRACES: FR-DEV-2 | FR-DEV-3j
|
||||
//! Scene-referred until the view transform (D19, ARCH §6.14), on a device.
|
||||
//!
|
||||
//! The rule is about every operation between the camera matrix and the view
|
||||
//! transform, so this runs each of them over a ramp that reaches sixteen
|
||||
//! times sensor saturation and asserts the two things a clip or an early
|
||||
//! encode would break: the output still increases with the input, and values
|
||||
//! above 1.0 still differ from one another.
|
||||
//!
|
||||
//! A clip above 1.0 cannot be seen through an 8-bit display encode on its
|
||||
//! own, so each operation is wrapped: a gain of sixteen ahead of it puts the
|
||||
//! ramp into the range the rule is about, a gain of one sixty-fourth after it
|
||||
//! brings the result back under 1.0 — with two stops to spare, for the
|
||||
//! operations that brighten — and an identity in the view transform's
|
||||
//! place stops the sigmoid compressing what is being measured. A fragment
|
||||
//! that clamps, or encodes and decodes through a clamped range, flattens the
|
||||
//! top of the ramp, and the last few steps come out equal.
|
||||
//!
|
||||
//! The view stage and the detail stage are excluded. The view transform and
|
||||
//! film simulation clip into a display range because that is their job, and
|
||||
//! a neighbourhood operation is a pass of its own that a flat frame cannot
|
||||
//! exercise.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use dr_decode::{CfaPattern, CropRect, RawImage};
|
||||
use dr_gpu::{AdjustPass, Demosaicer, GpuContext};
|
||||
use dr_pipeline::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamId, ParamKind};
|
||||
use dr_pipeline::operation::{Operation, Stage, Uniform};
|
||||
|
||||
const SIZE: u32 = 16;
|
||||
|
||||
fn ctx() -> Option<GpuContext> {
|
||||
pollster::block_on(GpuContext::new_headless()).ok()
|
||||
}
|
||||
|
||||
/// A gain, as a scene-stage operation, or an identity in the view stage.
|
||||
struct Probe {
|
||||
id: &'static str,
|
||||
gain: f32,
|
||||
stage: Stage,
|
||||
}
|
||||
|
||||
impl Operation for Probe {
|
||||
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||
Arc::new(OpDescriptor {
|
||||
id: OpId(self.id),
|
||||
label: LocalizedKey(self.id),
|
||||
params: Vec::new(),
|
||||
attributes: vec![Attribute::Tone],
|
||||
})
|
||||
}
|
||||
fn set_param(&mut self, _: ParamId, _: f32) {}
|
||||
fn param(&self, _: ParamId) -> f32 {
|
||||
0.0
|
||||
}
|
||||
fn is_active(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn stage(&self) -> Stage {
|
||||
self.stage
|
||||
}
|
||||
/// The identity view claims the view transform's place: while it is
|
||||
/// active the composer emits it rather than the sigmoid.
|
||||
fn renders(&self) -> bool {
|
||||
self.stage == Stage::View
|
||||
}
|
||||
fn wgsl_body(&self) -> String {
|
||||
"c = c * gain;".into()
|
||||
}
|
||||
fn uniforms(&self) -> Vec<Uniform> {
|
||||
vec![Uniform {
|
||||
name: "gain",
|
||||
value: self.gain,
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
fn probe(id: &'static str, gain: f32, stage: Stage) -> Box<dyn Operation> {
|
||||
Box::new(Probe { id, gain, stage })
|
||||
}
|
||||
|
||||
/// A flat frame at `level` of sensor saturation, identity matrix, neutral
|
||||
/// balance.
|
||||
fn flat(ctx: &GpuContext, level: f32) -> dr_gpu::DemosaicedImage {
|
||||
let raw = RawImage {
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
data: vec![(level * f32::from(u16::MAX)).round() as u16; (SIZE * SIZE) as usize],
|
||||
cfa_pattern: CfaPattern::Rggb,
|
||||
black_level: [0; 4],
|
||||
white_level: u16::MAX,
|
||||
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
||||
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: CropRect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
},
|
||||
};
|
||||
Demosaicer::new(ctx)
|
||||
.expect("demosaicer")
|
||||
.run(&raw)
|
||||
.expect("demosaic")
|
||||
}
|
||||
|
||||
/// Every parameter moved off its default, a third of the way toward its
|
||||
/// maximum — or toward its minimum where the default is the maximum.
|
||||
///
|
||||
/// The tone curve is the exception, because its neutral is a relationship:
|
||||
/// its parameters are point coordinates, and moving every x and y the same
|
||||
/// fraction leaves the points on the diagonal. It gets a lifted midpoint on
|
||||
/// the master and on the red curve instead — the two helpers that clamped.
|
||||
fn non_neutral(op: &mut dyn Operation) {
|
||||
use dr_pipeline::ops::curve::{coordinate, Axis, Channel};
|
||||
if op.descriptor().id == dr_pipeline::ops::curve::ID {
|
||||
op.set_param(coordinate(Channel::Master, 2, Axis::Y), 0.65);
|
||||
op.set_param(coordinate(Channel::Red, 2, Axis::Y), 0.6);
|
||||
return;
|
||||
}
|
||||
for p in &op.descriptor().params {
|
||||
if let ParamKind::Scalar { min, max, .. } = p.kind {
|
||||
let toward = if p.default < max { max } else { min };
|
||||
op.set_param(p.id, p.default + (toward - p.default) / 3.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The ramp, as scene values after the sixteenfold gain: 0.4 to 16.
|
||||
///
|
||||
/// Kept below 1.0 at the sensor, and away from its last 1.5%, because the
|
||||
/// prologue's highlight desaturation fades a photosite toward neutral there —
|
||||
/// a sensor fact, not an operation's, and flat grey is neutral already.
|
||||
const LEVELS: [f32; 8] = [0.025, 0.05, 0.1, 0.2, 0.4, 0.6, 0.8, 0.95];
|
||||
|
||||
#[test]
|
||||
fn scene_referred_until_the_view() {
|
||||
// TRACES: FR-DEV-2 | FR-DEV-3j
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
return;
|
||||
};
|
||||
let sources: Vec<_> = LEVELS.iter().map(|&l| flat(&ctx, l)).collect();
|
||||
let mut adjust = AdjustPass::new(&ctx);
|
||||
|
||||
let mut checked = 0;
|
||||
for mut op in dr_pipeline::ops::chain() {
|
||||
if op.detail().is_some() || op.stage() == Stage::View {
|
||||
continue;
|
||||
}
|
||||
let id = op.descriptor().id.0;
|
||||
non_neutral(op.as_mut());
|
||||
assert!(op.is_active(), "{id}: the edit above left it neutral");
|
||||
let ops = vec![
|
||||
probe("probe_up", 16.0, Stage::Scene),
|
||||
op,
|
||||
probe("probe_down", 1.0 / 64.0, Stage::Scene),
|
||||
probe("probe_view", 1.0, Stage::View),
|
||||
];
|
||||
let shader = dr_pipeline::compose(&ops);
|
||||
assert!(
|
||||
!shader.source.contains("view_sigmoid"),
|
||||
"the identity must take the view transform's place"
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
for source in &sources {
|
||||
adjust.render(source, &shader, SIZE, SIZE).expect("render");
|
||||
let (pixels, _, _) = adjust.export_pixels().expect("readback");
|
||||
let centre = (((SIZE / 2) * SIZE + SIZE / 2) * 4) as usize;
|
||||
out.push([pixels[centre], pixels[centre + 1], pixels[centre + 2]]);
|
||||
}
|
||||
|
||||
for channel in 0..3 {
|
||||
let ramp: Vec<u8> = out.iter().map(|p| p[channel]).collect();
|
||||
assert!(
|
||||
ramp.windows(2).all(|w| w[1] >= w[0]),
|
||||
"{id} is not monotone in channel {channel}: {ramp:?}"
|
||||
);
|
||||
// The top three levels are scene 9.6, 12.8 and 15.2: all far
|
||||
// above 1.0, and a clip anywhere below them makes them equal.
|
||||
let top = &ramp[LEVELS.len() - 3..];
|
||||
assert!(
|
||||
top[0] < top[1] && top[1] < top[2],
|
||||
"{id} flattens values above 1.0 in channel {channel}: {ramp:?}"
|
||||
);
|
||||
}
|
||||
checked += 1;
|
||||
}
|
||||
assert!(checked >= 10, "only {checked} operations were checked");
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
//! TRACES: FR-DSP-2 | NFR-RES-2
|
||||
//! A photograph larger than one texture, developed from windows of it.
|
||||
//!
|
||||
//! The claim under test is that the window is invisible: a frame rendered a
|
||||
//! tile at a time, each tile from only the part of the source it reads, is the
|
||||
//! frame rendered whole. `dr-pipeline` can check the plan — the tiles cover
|
||||
//! the frame once, each is grown by the reach — but not that the shader's
|
||||
//! mapping into a window lands on the texel the whole texture would have
|
||||
//! given, which only a device answers.
|
||||
//!
|
||||
//! The frames here are small and the "device limit" is a number passed in,
|
||||
//! so the tiling is exercised on any adapter, including one whose real limit
|
||||
//! a test image could never approach.
|
||||
|
||||
use dr_decode::{CfaPattern, CropRect, RawImage};
|
||||
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext};
|
||||
use dr_pipeline::descriptor::{OpId, ParamId};
|
||||
use dr_pipeline::framing::ANGLE;
|
||||
use dr_pipeline::{tiles, Affects, EditGraph};
|
||||
use dr_types::ColourSpace;
|
||||
|
||||
fn ctx() -> Option<GpuContext> {
|
||||
match pollster::block_on(GpuContext::new_headless()) {
|
||||
Ok(c) => Some(c),
|
||||
Err(e) => {
|
||||
eprintln!("skipping: no GPU adapter ({e})");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A linear RGB frame with detail at every scale: a slow gradient for the
|
||||
/// tone controls and a hash for the kernels, so a tile that read one pixel
|
||||
/// off would show.
|
||||
fn linear_frame(w: u32, h: u32, noise: bool) -> RawImage {
|
||||
let mut data = Vec::with_capacity((w * h * 3) as usize);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let base = 4000.0 + 30000.0 * (x as f32 / w as f32) + 12000.0 * (y as f32 / h as f32);
|
||||
let hash = if noise {
|
||||
((x.wrapping_mul(73_856_093) ^ y.wrapping_mul(19_349_663)) % 8000) as f32
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
for c in 0..3 {
|
||||
data.push((base * (0.7 + 0.15 * c as f32) + hash) as u16);
|
||||
}
|
||||
}
|
||||
}
|
||||
RawImage {
|
||||
width: w,
|
||||
height: h,
|
||||
data,
|
||||
cfa_pattern: CfaPattern::Unknown,
|
||||
black_level: [512; 4],
|
||||
white_level: 65535,
|
||||
wb_coeffs: [2.0, 1.0, 1.5, 1.0],
|
||||
color_matrix: Some([1.6, -0.5, -0.1, -0.2, 1.4, -0.2, 0.0, -0.4, 1.4]),
|
||||
samples_per_pixel: 3,
|
||||
profile: None,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: CropRect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: w,
|
||||
height: h,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Render `graph` over `source` at `size` and read it back.
|
||||
fn render(
|
||||
pass: &mut AdjustPass,
|
||||
graph: &EditGraph,
|
||||
source: &DemosaicedImage,
|
||||
size: (u32, u32),
|
||||
) -> Vec<u8> {
|
||||
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||
let detail = graph.compose_detail(source.size(), size);
|
||||
let key = graph.invalidation().through(Affects::Colour);
|
||||
pass.render_detailed(source, &shader, size.0, size.1, None, &detail, key)
|
||||
.expect("render");
|
||||
pass.export_pixels().expect("readback").0
|
||||
}
|
||||
|
||||
/// The frame at full resolution, a tile at a time, each from its own window.
|
||||
fn render_tiled(
|
||||
ctx: &GpuContext,
|
||||
pass: &mut AdjustPass,
|
||||
graph: &mut EditGraph,
|
||||
raw: &RawImage,
|
||||
max_edge: u32,
|
||||
) -> (Vec<u8>, usize) {
|
||||
let frame = (raw.crop.width, raw.crop.height);
|
||||
let out = graph.output_size(frame.0, frame.1);
|
||||
let reach = graph.compose_detail(frame, out).reach();
|
||||
let plan = tiles::plan(out, max_edge, reach).expect("a plan");
|
||||
let mut pixels = vec![0u8; (out.0 * out.1 * 4) as usize];
|
||||
for t in &plan {
|
||||
graph.framing_mut().set_view(t.view(out));
|
||||
let r = graph.source_region(frame, 0);
|
||||
let x0 = (r.x * frame.0 as f32).floor() as u32;
|
||||
let y0 = (r.y * frame.1 as f32).floor() as u32;
|
||||
let x1 = ((r.x + r.width) * frame.0 as f32).ceil() as u32;
|
||||
let y1 = ((r.y + r.height) * frame.1 as f32).ceil() as u32;
|
||||
let window = DemosaicedImage::linear_rgb16_window(ctx, raw, [x0, y0, x1 - x0, y1 - y0], 1)
|
||||
.expect("window");
|
||||
assert_eq!(window.size(), frame, "a window measures the frame");
|
||||
let tile = render(pass, graph, &window, (t.grown[2], t.grown[3]));
|
||||
let (ox, oy) = t.keep_offset();
|
||||
for row in 0..t.keep[3] {
|
||||
let src = (((oy + row) * t.grown[2] + ox) * 4) as usize;
|
||||
let dst = (((t.keep[1] + row) * out.0 + t.keep[0]) * 4) as usize;
|
||||
let n = (t.keep[2] * 4) as usize;
|
||||
pixels[dst..dst + n].copy_from_slice(&tile[src..src + n]);
|
||||
}
|
||||
}
|
||||
graph
|
||||
.framing_mut()
|
||||
.set_view(dr_pipeline::CropRect::default());
|
||||
(pixels, plan.len())
|
||||
}
|
||||
|
||||
fn largest_difference(a: &[u8], b: &[u8]) -> u8 {
|
||||
a.iter()
|
||||
.zip(b)
|
||||
.map(|(x, y)| x.abs_diff(*y))
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiles_of_windows_are_the_whole_frame() {
|
||||
// Point operations only, unrotated: every output pixel is an exact load
|
||||
// of one source texel, so the tiled frame has to be the whole one to
|
||||
// the bit.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let raw = linear_frame(200, 120, true);
|
||||
let mut graph = EditGraph::default_chain();
|
||||
graph.set_param(OpId("exposure"), ParamId("exposure"), 0.7);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
|
||||
let whole = DemosaicedImage::from_linear_rgb16(&ctx, &raw).unwrap();
|
||||
assert!(whole.is_whole());
|
||||
let reference = render(&mut pass, &graph, &whole, (200, 120));
|
||||
let (tiled, n) = render_tiled(&ctx, &mut pass, &mut graph, &raw, 64);
|
||||
assert!(n > 4, "the frame should have been cut, got {n} tile(s)");
|
||||
assert_eq!(largest_difference(&reference, &tiled), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_straightened_frame_with_clarity_tiles_without_seams() {
|
||||
// The hard case: a free angle samples between texels, and clarity reads
|
||||
// a wide neighbourhood on a reduced grid. The halo and the grid
|
||||
// alignment are what keep the tiles' edges out of the picture; a code
|
||||
// value of rounding is all that may differ.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let raw = linear_frame(320, 208, true);
|
||||
let mut graph = EditGraph::default_chain();
|
||||
graph.set_param(OpId("clarity"), ParamId("amount"), 60.0);
|
||||
graph.framing_mut().set_param(ANGLE, 3.0);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
|
||||
let whole = DemosaicedImage::from_linear_rgb16(&ctx, &raw).unwrap();
|
||||
let out = graph.output_size(320, 208);
|
||||
let reference = render(&mut pass, &graph, &whole, out);
|
||||
let (tiled, n) = render_tiled(&ctx, &mut pass, &mut graph, &raw, 160);
|
||||
assert!(n > 1, "the frame should have been cut, got {n} tile(s)");
|
||||
let worst = largest_difference(&reference, &tiled);
|
||||
assert!(
|
||||
worst <= 1,
|
||||
"tiles differ from the whole frame by {worst} code values"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reduced_copy_stands_for_the_whole_frame() {
|
||||
// The canvas at fit renders from a copy reduced to fit the device. It
|
||||
// must measure the photograph, not itself, or a crop drawn on it lands
|
||||
// somewhere else in the export; and rendered small it must look like the
|
||||
// full frame rendered small.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let raw = linear_frame(400, 240, false);
|
||||
let mut graph = EditGraph::default_chain();
|
||||
graph.set_crop(dr_pipeline::CropRect {
|
||||
x: 0.25,
|
||||
y: 0.1,
|
||||
width: 0.5,
|
||||
height: 0.6,
|
||||
});
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
|
||||
let whole = DemosaicedImage::from_linear_rgb16(&ctx, &raw).unwrap();
|
||||
let reduced = DemosaicedImage::linear_rgb16_window(&ctx, &raw, [0, 0, 400, 240], 3).unwrap();
|
||||
assert_eq!(reduced.size(), (400, 240));
|
||||
assert_eq!(reduced.texture_size(), (134, 80));
|
||||
assert!(!reduced.is_whole());
|
||||
|
||||
let size = (50, 36);
|
||||
let a = render(&mut pass, &graph, &whole, size);
|
||||
let b = render(&mut pass, &graph, &reduced, size);
|
||||
let worst = largest_difference(&a, &b);
|
||||
assert!(
|
||||
worst <= 3,
|
||||
"the reduced copy renders {worst} code values away"
|
||||
);
|
||||
}
|
||||
@@ -72,7 +72,7 @@ fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, ou
|
||||
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||
let (w, h) = graph.output_size(source.size().0, source.size().1);
|
||||
let (w, h) = (w.min(out), h.min(out));
|
||||
let detail = graph.compose_detail_for(source.size(), (w, h), ColourSpace::Srgb);
|
||||
let detail = graph.compose_detail(source.size(), (w, h));
|
||||
let key = graph.invalidation().through(Affects::Colour);
|
||||
pass.render_detailed(source, &shader, w, h, None, &detail, key)
|
||||
.expect("render");
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
//! TRACES: FR-DEV-3j | FR-DEV-2
|
||||
//! The view transform, end to end on a device.
|
||||
//!
|
||||
//! `dr-pipeline` checks the curve on the CPU and that the composer emits it in
|
||||
//! the right place. Neither would notice a shader that disagreed with the CPU
|
||||
//! reference, or a clamp somewhere upstream that made two highlights the same
|
||||
//! number before the curve ever saw them — which is exactly what the retired
|
||||
//! base curve did, and why D19 exists. So this renders real pixels.
|
||||
|
||||
use dr_decode::{CfaPattern, CropRect, RawImage};
|
||||
use dr_gpu::{AdjustPass, Demosaicer, GpuContext};
|
||||
use dr_pipeline::view::Sigmoid;
|
||||
use dr_pipeline::EditGraph;
|
||||
|
||||
const SIZE: u32 = 16;
|
||||
|
||||
fn ctx() -> Option<GpuContext> {
|
||||
pollster::block_on(GpuContext::new_headless()).ok()
|
||||
}
|
||||
|
||||
/// A flat RGGB frame at `level` out of 65535, with an identity matrix and a
|
||||
/// neutral balance, so the only things that move a pixel are the edit and the
|
||||
/// view transform.
|
||||
fn flat_raw(level: u16) -> RawImage {
|
||||
RawImage {
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
data: vec![level; (SIZE * SIZE) as usize],
|
||||
cfa_pattern: CfaPattern::Rggb,
|
||||
black_level: [0; 4],
|
||||
white_level: u16::MAX,
|
||||
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
||||
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: CropRect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Render `graph` over a flat frame and return the centre pixel's red.
|
||||
///
|
||||
/// The centre rather than a corner: a demosaic has to invent its edges.
|
||||
fn rendered(ctx: &GpuContext, level: u16, graph: &EditGraph) -> u8 {
|
||||
let source = Demosaicer::new(ctx)
|
||||
.expect("demosaicer")
|
||||
.run(&flat_raw(level))
|
||||
.expect("demosaic");
|
||||
let shader = graph.compose();
|
||||
let mut adjust = AdjustPass::new(ctx);
|
||||
adjust.render(&source, &shader, SIZE, SIZE).expect("render");
|
||||
let (pixels, _, _) = adjust.export_pixels().expect("readback");
|
||||
let centre = ((SIZE / 2) * SIZE + SIZE / 2) * 4;
|
||||
pixels[centre as usize]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shader_agrees_with_the_cpu_reference() {
|
||||
// TRACES: FR-DEV-3j
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
return;
|
||||
};
|
||||
let curve = Sigmoid::default_curve();
|
||||
let graph = EditGraph::default_chain();
|
||||
for level in [0u16, 500, 4_000, 8_520, 32_768, 60_000, u16::MAX] {
|
||||
let scene = f32::from(level) / f32::from(u16::MAX);
|
||||
let display = curve.channel(scene).min(1.0);
|
||||
let expected = (dr_types::Transfer::Srgb.encode(display) * 255.0).round() as i32;
|
||||
let got = i32::from(rendered(&ctx, level, &graph));
|
||||
// Two 8-bit steps, for the `Rgba16Float` intermediate and the
|
||||
// rounding either side of the encode.
|
||||
assert!(
|
||||
(got - expected).abs() <= 2,
|
||||
"raw {level} rendered as {got}, expected about {expected}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlights_above_one_stay_distinct() {
|
||||
// TRACES: FR-DEV-2 | FR-DEV-3j
|
||||
// The failure D19 names first. Two stops of exposure put these two
|
||||
// frames at 1.0 and 1.5 of sensor saturation. The base curve was flat
|
||||
// past 1.0, so both rendered as the same white; the view transform's
|
||||
// shoulder still separates them.
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
return;
|
||||
};
|
||||
let mut graph = EditGraph::default_chain();
|
||||
graph.set_param(
|
||||
dr_pipeline::ops::exposure::ID,
|
||||
dr_pipeline::ops::exposure::EXPOSURE,
|
||||
2.0,
|
||||
);
|
||||
let lower = rendered(&ctx, u16::MAX / 4, &graph);
|
||||
let upper = rendered(&ctx, (u16::MAX / 8) * 3, &graph);
|
||||
assert!(
|
||||
upper > lower,
|
||||
"scene 1.0 rendered {lower} and scene 1.5 rendered {upper}"
|
||||
);
|
||||
assert!(upper < 255, "scene 1.5 is below the default white point");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_rendering_is_monotone_through_the_whole_range() {
|
||||
// TRACES: FR-DEV-3j
|
||||
// A dip anywhere puts a dark band across a smooth gradient — a sky, most
|
||||
// visibly.
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
return;
|
||||
};
|
||||
let graph = EditGraph::default_chain();
|
||||
let mut last = 0u8;
|
||||
for step in 0..=32u32 {
|
||||
let level = (step * u32::from(u16::MAX) / 32) as u16;
|
||||
let got = rendered(&ctx, level, &graph);
|
||||
assert!(got >= last, "raw {level} rendered {got}, below {last}");
|
||||
last = got;
|
||||
}
|
||||
}
|
||||
|
||||
/// Render `graph` over a flat frame through `render_detailed`, the path every
|
||||
/// frontend takes, and return the centre pixel's red.
|
||||
fn rendered_detailed(ctx: &GpuContext, level: u16, graph: &EditGraph) -> (u8, AdjustPass) {
|
||||
let source = Demosaicer::new(ctx)
|
||||
.expect("demosaicer")
|
||||
.run(&flat_raw(level))
|
||||
.expect("demosaic");
|
||||
let shader = graph.compose_for(dr_types::ColourSpace::Srgb);
|
||||
let detail = graph.compose_detail(source.size(), (SIZE, SIZE));
|
||||
let key = graph.invalidation().through(dr_pipeline::Affects::Colour);
|
||||
let mut adjust = AdjustPass::new(ctx);
|
||||
adjust
|
||||
.render_detailed(&source, &shader, SIZE, SIZE, None, &detail, key)
|
||||
.expect("render");
|
||||
let (pixels, _, _) = adjust.export_pixels().expect("readback");
|
||||
let centre = ((SIZE / 2) * SIZE + SIZE / 2) * 4;
|
||||
(pixels[centre as usize], adjust)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_detail_stage_renders_through_the_view_pass_unchanged() {
|
||||
// TRACES: FR-DEV-3j | FR-DEV-2
|
||||
// With a detail stage the view transform is a dispatch of its own after
|
||||
// it (D19). Sharpening a flat field changes nothing, so the same frame
|
||||
// with and without it must render the same: the view pass read the detail
|
||||
// stage's result, applied the view transform once, and encoded once.
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
return;
|
||||
};
|
||||
let plain = rendered(&ctx, 8_520, &EditGraph::default_chain());
|
||||
|
||||
let mut sharpened = EditGraph::default_chain();
|
||||
let id = dr_pipeline::ops::capture_sharpen::ID;
|
||||
sharpened.set_param(id, dr_pipeline::ops::capture_sharpen::AMOUNT, 100.0);
|
||||
sharpened.set_param(id, dr_pipeline::ops::capture_sharpen::RADIUS, 1.0);
|
||||
let (detailed, pass) = rendered_detailed(&ctx, 8_520, &sharpened);
|
||||
|
||||
assert!(
|
||||
pass.detail_dispatches() > 0,
|
||||
"the premise: a detail stage ran"
|
||||
);
|
||||
assert_eq!(pass.view_dispatches(), 1);
|
||||
assert!(
|
||||
detailed.abs_diff(plain) <= 1,
|
||||
"with a detail stage {detailed}, without {plain}"
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
//! code path — the zoom is the full-resolution path — which is why the
|
||||
//! requirement has been satisfied for some time without anyone tagging it.
|
||||
//!
|
||||
//! `docs/display-and-extension.md` §7 is the reason this file exists rather
|
||||
//! `docs/dev/display-and-extension.md` §7 is the reason this file exists rather
|
||||
//! than a tag on `framing.rs`: a requirement counts as covered when a `TRACES`
|
||||
//! comment names it, and nothing checks that the code under the tag does the
|
||||
//! thing. `FR-DEV-8` is tagged against plumbing a future operation would use.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user