Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -157,6 +157,19 @@ jobs:
|
|||||||
- name: Test
|
- name: Test
|
||||||
run: cargo test --workspace
|
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
|
- name: Build
|
||||||
run: cargo build --workspace --release
|
run: cargo build --workspace --release
|
||||||
|
|
||||||
|
|||||||
+14
-4
@@ -1,6 +1,6 @@
|
|||||||
# Contributing to DarkRoom
|
# 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
|
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
|
decided to work on this. This file is the other thing: how to get a first
|
||||||
change landed without reading any of it.
|
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
|
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.
|
enough to look like a hang. It is not one.
|
||||||
|
|
||||||
Android is a containerised toolchain and is not needed for most work; see
|
Android is a containerised toolchain and is not needed for most work; see
|
||||||
@@ -148,9 +148,9 @@ 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
|
it again. The pre-commit hook regenerates the matrix, the gesture book and the
|
||||||
page; CI runs all three checks.
|
page; CI runs all three checks.
|
||||||
|
|
||||||
## Two invariants the build defends
|
## Three invariants the build defends
|
||||||
|
|
||||||
Worth knowing before you trip one, because both failures name a requirement
|
Worth knowing before you trip one, because each failure names a requirement
|
||||||
rather than a line:
|
rather than a line:
|
||||||
|
|
||||||
- **No operation may be named in `ui/`** (FR-DEV-3a). Special-casing one
|
- **No operation may be named in `ui/`** (FR-DEV-3a). Special-casing one
|
||||||
@@ -162,6 +162,16 @@ rather than a line:
|
|||||||
`order:`, a filename disagreeing with its `id:`, a default outside its own
|
`order:`, a filename disagreeing with its `id:`, a default outside its own
|
||||||
range, an expression naming something that is not a parameter. Each error
|
range, an expression naming something that is not a parameter. Each error
|
||||||
names the key you got wrong and exits rather than panicking.
|
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
|
## Commit messages
|
||||||
|
|
||||||
|
|||||||
Generated
+183
-28
@@ -347,6 +347,28 @@ dependencies = [
|
|||||||
"libloading",
|
"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]]
|
[[package]]
|
||||||
name = "async-broadcast"
|
name = "async-broadcast"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
@@ -385,6 +407,17 @@ dependencies = [
|
|||||||
"slab",
|
"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]]
|
[[package]]
|
||||||
name = "async-io"
|
name = "async-io"
|
||||||
version = "2.6.0"
|
version = "2.6.0"
|
||||||
@@ -414,6 +447,17 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"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]]
|
[[package]]
|
||||||
name = "async-process"
|
name = "async-process"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@@ -1221,7 +1265,7 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darkroom-android"
|
name = "darkroom-android"
|
||||||
version = "0.16.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
@@ -1234,7 +1278,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darkroom-desktop"
|
name = "darkroom-desktop"
|
||||||
version = "0.16.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
@@ -1356,6 +1400,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
|
"block2 0.6.2",
|
||||||
|
"libc",
|
||||||
"objc2 0.6.4",
|
"objc2 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1408,7 +1454,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-bench"
|
name = "dr-bench"
|
||||||
version = "0.16.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"dr-catalog",
|
"dr-catalog",
|
||||||
@@ -1425,7 +1471,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-catalog"
|
name = "dr-catalog"
|
||||||
version = "0.16.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-face",
|
"dr-face",
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
@@ -1440,7 +1486,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-decode"
|
name = "dr-decode"
|
||||||
version = "0.16.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-types",
|
"dr-types",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
@@ -1454,7 +1500,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-export"
|
name = "dr-export"
|
||||||
version = "0.16.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-decode",
|
"dr-decode",
|
||||||
"dr-gpu",
|
"dr-gpu",
|
||||||
@@ -1473,7 +1519,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-face"
|
name = "dr-face"
|
||||||
version = "0.16.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-inference-engine",
|
"dr-inference-engine",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
@@ -1486,7 +1532,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-film"
|
name = "dr-film"
|
||||||
version = "0.16.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -1495,7 +1541,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-gpu"
|
name = "dr-gpu"
|
||||||
version = "0.16.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"dr-decode",
|
"dr-decode",
|
||||||
@@ -1513,7 +1559,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-inference-engine"
|
name = "dr-inference-engine"
|
||||||
version = "0.16.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"env_logger",
|
"env_logger",
|
||||||
"libloading",
|
"libloading",
|
||||||
@@ -1528,7 +1574,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-ingest"
|
name = "dr-ingest"
|
||||||
version = "0.16.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
"dr-types",
|
"dr-types",
|
||||||
@@ -1540,7 +1586,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-lens"
|
name = "dr-lens"
|
||||||
version = "0.16.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"lensfun",
|
"lensfun",
|
||||||
"log",
|
"log",
|
||||||
@@ -1548,7 +1594,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-pano"
|
name = "dr-pano"
|
||||||
version = "0.16.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-decode",
|
"dr-decode",
|
||||||
"dr-inference-engine",
|
"dr-inference-engine",
|
||||||
@@ -1562,7 +1608,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-pipeline"
|
name = "dr-pipeline"
|
||||||
version = "0.16.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-types",
|
"dr-types",
|
||||||
"log",
|
"log",
|
||||||
@@ -1571,7 +1617,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-plat"
|
name = "dr-plat"
|
||||||
version = "0.16.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android-native-keyring-store",
|
"android-native-keyring-store",
|
||||||
"dr-types",
|
"dr-types",
|
||||||
@@ -1587,7 +1633,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-preset-xmp"
|
name = "dr-preset-xmp"
|
||||||
version = "0.16.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-pipeline",
|
"dr-pipeline",
|
||||||
"log",
|
"log",
|
||||||
@@ -1597,7 +1643,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-segment"
|
name = "dr-segment"
|
||||||
version = "0.16.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-inference-engine",
|
"dr-inference-engine",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
@@ -1610,7 +1656,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-sync"
|
name = "dr-sync"
|
||||||
version = "0.16.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
@@ -1624,7 +1670,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-sync-folder"
|
name = "dr-sync-folder"
|
||||||
version = "0.16.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"dr-sync",
|
"dr-sync",
|
||||||
@@ -1636,7 +1682,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-sync-nextcloud"
|
name = "dr-sync-nextcloud"
|
||||||
version = "0.16.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"dr-decode",
|
"dr-decode",
|
||||||
@@ -1658,7 +1704,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-thumbs"
|
name = "dr-thumbs"
|
||||||
version = "0.16.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-types",
|
"dr-types",
|
||||||
"jpeg-encoder",
|
"jpeg-encoder",
|
||||||
@@ -1670,7 +1716,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-types"
|
name = "dr-types"
|
||||||
version = "0.16.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -1679,7 +1725,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-ui"
|
name = "dr-ui"
|
||||||
version = "0.16.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -1710,7 +1756,9 @@ dependencies = [
|
|||||||
"ndk-context",
|
"ndk-context",
|
||||||
"png",
|
"png",
|
||||||
"pollster",
|
"pollster",
|
||||||
|
"raw-window-handle",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
"rfd",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_norway",
|
"serde_norway",
|
||||||
@@ -1725,7 +1773,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-xmp"
|
name = "dr-xmp"
|
||||||
version = "0.16.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-types",
|
"dr-types",
|
||||||
"log",
|
"log",
|
||||||
@@ -5465,8 +5513,6 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "rawler"
|
name = "rawler"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "04f4cc35c23969a4a834e0b117c7da41ace812eb9053b5effc3fc5c77d114677"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"backtrace",
|
"backtrace",
|
||||||
"bitstream-io",
|
"bitstream-io",
|
||||||
@@ -5668,6 +5714,30 @@ dependencies = [
|
|||||||
"zune-jpeg 0.5.15",
|
"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]]
|
[[package]]
|
||||||
name = "rgb"
|
name = "rgb"
|
||||||
version = "0.8.53"
|
version = "0.8.53"
|
||||||
@@ -6286,6 +6356,7 @@ dependencies = [
|
|||||||
"num-traits",
|
"num-traits",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"pin-weak",
|
"pin-weak",
|
||||||
|
"raw-window-handle",
|
||||||
"slint-macros",
|
"slint-macros",
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
"vtable",
|
"vtable",
|
||||||
@@ -7036,12 +7107,14 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "traceability"
|
name = "traceability"
|
||||||
version = "0.16.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"proc-macro2",
|
||||||
"pulldown-cmark",
|
"pulldown-cmark",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7447,8 +7520,15 @@ dependencies = [
|
|||||||
"idna",
|
"idna",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urlencoding"
|
||||||
|
version = "2.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "usvg"
|
name = "usvg"
|
||||||
version = "0.47.0"
|
version = "0.47.0"
|
||||||
@@ -8169,6 +8249,15 @@ dependencies = [
|
|||||||
"windows-targets 0.52.6",
|
"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]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
@@ -8217,13 +8306,30 @@ dependencies = [
|
|||||||
"windows_aarch64_gnullvm 0.52.6",
|
"windows_aarch64_gnullvm 0.52.6",
|
||||||
"windows_aarch64_msvc 0.52.6",
|
"windows_aarch64_msvc 0.52.6",
|
||||||
"windows_i686_gnu 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_i686_msvc 0.52.6",
|
||||||
"windows_x86_64_gnu 0.52.6",
|
"windows_x86_64_gnu 0.52.6",
|
||||||
"windows_x86_64_gnullvm 0.52.6",
|
"windows_x86_64_gnullvm 0.52.6",
|
||||||
"windows_x86_64_msvc 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]]
|
[[package]]
|
||||||
name = "windows-threading"
|
name = "windows-threading"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -8251,6 +8357,12 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_msvc"
|
name = "windows_aarch64_msvc"
|
||||||
version = "0.42.2"
|
version = "0.42.2"
|
||||||
@@ -8269,6 +8381,12 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnu"
|
name = "windows_i686_gnu"
|
||||||
version = "0.42.2"
|
version = "0.42.2"
|
||||||
@@ -8287,12 +8405,24 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnullvm"
|
name = "windows_i686_gnullvm"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnullvm"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_msvc"
|
name = "windows_i686_msvc"
|
||||||
version = "0.42.2"
|
version = "0.42.2"
|
||||||
@@ -8311,6 +8441,12 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnu"
|
name = "windows_x86_64_gnu"
|
||||||
version = "0.42.2"
|
version = "0.42.2"
|
||||||
@@ -8329,6 +8465,12 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
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]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnullvm"
|
name = "windows_x86_64_gnullvm"
|
||||||
version = "0.42.2"
|
version = "0.42.2"
|
||||||
@@ -8347,6 +8489,12 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
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]]
|
[[package]]
|
||||||
name = "windows_x86_64_msvc"
|
name = "windows_x86_64_msvc"
|
||||||
version = "0.42.2"
|
version = "0.42.2"
|
||||||
@@ -8365,6 +8513,12 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
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]]
|
[[package]]
|
||||||
name = "winit"
|
name = "winit"
|
||||||
version = "0.30.13"
|
version = "0.30.13"
|
||||||
@@ -8830,6 +8984,7 @@ dependencies = [
|
|||||||
"endi",
|
"endi",
|
||||||
"enumflags2",
|
"enumflags2",
|
||||||
"serde",
|
"serde",
|
||||||
|
"url",
|
||||||
"winnow 1.0.4",
|
"winnow 1.0.4",
|
||||||
"zvariant_derive",
|
"zvariant_derive",
|
||||||
"zvariant_utils",
|
"zvariant_utils",
|
||||||
|
|||||||
+14
-6
@@ -32,7 +32,7 @@ members = [
|
|||||||
exclude = ["third_party"]
|
exclude = ["third_party"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.16.0"
|
version = "0.19.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.92"
|
rust-version = "1.92"
|
||||||
license = "GPL-3.0-or-later"
|
license = "GPL-3.0-or-later"
|
||||||
@@ -134,6 +134,12 @@ serde_json = "1"
|
|||||||
# Slint's Markdown parser, so this adds a dependency edge and no crate; only
|
# 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.
|
# the HTML writer is needed, not the command-line front end.
|
||||||
pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] }
|
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"
|
base64 = "0.23"
|
||||||
|
|
||||||
# Display-server clients, for FR-DSP-8's per-display profile acquisition.
|
# Display-server clients, for FR-DSP-8's per-display profile acquisition.
|
||||||
@@ -270,11 +276,13 @@ opt-level = 0
|
|||||||
lto = "thin"
|
lto = "thin"
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
|
|
||||||
# Two upstream crates carry a local patch so that the Android build can draw
|
# Three upstream crates carry a local patch: wgpu-hal and Slint's Skia
|
||||||
# with wgpu on a rotated display (technical-debt.md TD-1). Both are exact
|
# renderer so that the Android build can draw with wgpu on a rotated display
|
||||||
# copies of the version the lockfile already resolves, plus that patch;
|
# (technical-debt.md TD-1), and rawler so that a linear DNG wider than 16 700
|
||||||
# third_party/README.md says what was changed and how to carry it forward
|
# pixels decodes. Each is an exact copy of the version the lockfile already
|
||||||
# when Slint or wgpu moves.
|
# 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]
|
[patch.crates-io]
|
||||||
wgpu-hal = { path = "third_party/wgpu-hal-29.0.4" }
|
wgpu-hal = { path = "third_party/wgpu-hal-29.0.4" }
|
||||||
i-slint-renderer-skia = { path = "third_party/i-slint-renderer-skia-1.17.1" }
|
i-slint-renderer-skia = { path = "third_party/i-slint-renderer-skia-1.17.1" }
|
||||||
|
rawler = { path = "third_party/rawler-0.7.2" }
|
||||||
|
|||||||
@@ -15,29 +15,44 @@ is still missing.
|
|||||||
**A library.** Point it at a folder — on this machine, on a network mount,
|
**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
|
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
|
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
|
Nextcloud account directly; a photograph that is only on the server opens on
|
||||||
time with a timeline beside it, and filtered by rating, flag, colour label,
|
its thumbnail with the download's progress over it. The grid is virtualised,
|
||||||
person and whether the file is here. Ratings, colour labels, keywords,
|
ordered by capture time with a timeline beside it, and filtered by rating,
|
||||||
collections and a trash that survives a crash mid-operation. Card ingest.
|
flag, colour label, person and whether the file is here. Ratings, colour
|
||||||
Bursts fold. The same RAW catalogued twice — a dated folder and a backup
|
labels, keywords, collections and a trash that survives a crash
|
||||||
beside it — is found, proved the same, and folded onto one copy with the
|
mid-operation. Card ingest. Bursts fold. The same RAW catalogued twice — a
|
||||||
spares in the trash. Face detection and identity, with the index syncing
|
dated folder and a backup beside it — is found, proved the same, and folded
|
||||||
between devices.
|
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
|
**Developing.** Nineteen declared operations, those that read one pixel
|
||||||
dispatch, plus the neighbourhood work that cannot be: clarity, texture,
|
fused into a generated shader rather than run a pass each, plus the
|
||||||
capture sharpening, noise reduction, lens correction, spectral film
|
neighbourhood work that cannot be: clarity, texture, dehaze, capture
|
||||||
simulation. Crop, straighten and correct converging verticals, spot repair,
|
sharpening, noise reduction, lens correction. Every edit works on the scene
|
||||||
and local adjustments over masks the model draws — click a subject or a
|
as the camera recorded it — linear, highlights beyond white included — and
|
||||||
category, then paint, subtract a gradient or keep only where two selections
|
one `Tone Mapping` step, last, after sharpening and noise reduction, fits it
|
||||||
agree, grow or shrink the edge. Focus peaking and a raw histogram for judging
|
to the screen, with a contrast and a white point of its own; a spectral film
|
||||||
what is recoverable. Named presets; XMP sidecars other editors read.
|
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)
|
[](docs/manual/README.md#local-adjustments)
|
||||||
|
|
||||||
**Panoramas.** Select the frames, align, choose a projection, fill the
|
**Panoramas.** Select the frames, align, untick any frame to leave it out
|
||||||
ragged border rather than crop it, and the composite lands beside its
|
and the rest re-align at once, choose a projection, fill the ragged border
|
||||||
sources as a DNG, with a sidecar recording what it was merged from.
|
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)
|
[](docs/manual/README.md#merging-a-panorama)
|
||||||
|
|
||||||
@@ -49,8 +64,10 @@ binds it, and links them to the sections of the manual that show them — the
|
|||||||
manual ships with the application and opens offline.
|
manual ships with the application and opens offline.
|
||||||
|
|
||||||
**Export.** JPEG, PNG, AVIF, JPEG XL, 8- and 16-bit TIFF, with resize, output
|
**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
|
sharpening, a naming template and a colour space — into albums: named export
|
||||||
into the library.
|
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
|
**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
|
tablet; the interface is one layout, tuned for a wide viewport with touch
|
||||||
@@ -64,7 +81,7 @@ texture directly — no readback between the GPU and the screen.
|
|||||||
| Arch Linux | [`packaging/PKGBUILD`](packaging/PKGBUILD) — `makepkg -si` | Built from every release |
|
| 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 |
|
| 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/dev/windows.md)) | Verified under Wine only; unsigned |
|
| 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; choosing a library does not yet work in the sandbox |
|
| 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
|
Or build it. Git LFS is required for the model weights, and the toolchain
|
||||||
pins itself to 1.92.0:
|
pins itself to 1.92.0:
|
||||||
@@ -87,18 +104,19 @@ controls, its place in the chain and its tests.
|
|||||||
|
|
||||||
## Where it stands
|
## Where it stands
|
||||||
|
|
||||||
**0.16.0**, twenty-four tagged releases in. 191 numbered requirements in
|
**0.19.1**, thirty tagged releases in. 193 numbered requirements in
|
||||||
scope, 84% of them claimed by code and [traced to it](docs/dev/traceability.md);
|
scope, 85% of them claimed by code and [traced to it](docs/dev/traceability.md);
|
||||||
the rest are written down rather than merely absent.
|
the rest are written down rather than merely absent.
|
||||||
|
|
||||||
**Not built:** plugins (post-v1, [D12](docs/dev/requirements.md)), compare and
|
**Not built:** plugins (post-v1, [D12](docs/dev/requirements.md)), compare and
|
||||||
survey culling, AI denoise, tiled rendering, HDR merge and
|
survey culling, AI denoise, tiled rendering beyond the export of an oversized
|
||||||
focus stacking, importing a Lightroom or darktable catalog, translations
|
DNG, HDR merge and focus stacking, importing a Lightroom or darktable catalog,
|
||||||
beyond the launch screen, most of the Android platform integration beyond
|
translations beyond the launch screen, most of the Android platform
|
||||||
running, and the Flatpak's library chooser. The performance targets are half
|
integration beyond running, and a Flatpak actually built and run in its
|
||||||
verified: the per-commit benchmark suite §8 requires exists for everything
|
sandbox. The performance targets are half verified: the per-commit benchmark
|
||||||
that does not need a frame — the catalog, the scan, the thumbnails — and
|
suite §8 requires exists for everything that does not need a frame — the
|
||||||
not yet for the render path, so a regression there fails nothing.
|
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
|
[outstanding.md](docs/dev/outstanding.md) is the list, with the reasoning for
|
||||||
each.
|
each.
|
||||||
|
|
||||||
|
|||||||
@@ -158,6 +158,18 @@
|
|||||||
android:theme="@style/ManualTheme"
|
android:theme="@style/ManualTheme"
|
||||||
android:configChanges="orientation|keyboardHidden|screenSize|screenLayout|uiMode" />
|
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
|
<!-- FR-PLAT-AND-6, outbound. Android has refused file:// URIs
|
||||||
between apps since API 24 — handing one out raises
|
between apps since API 24 — handing one out raises
|
||||||
FileUriExposedException in *this* process — so an exported JPEG
|
FileUriExposedException in *this* process — so an exported JPEG
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -300,7 +300,9 @@ fn install_bundled_models(app: slint::android::AndroidApp) {
|
|||||||
// on the worker because `AAssetManager` is thread-safe by contract and
|
// on the worker because `AAssetManager` is thread-safe by contract and
|
||||||
// reading the pointer takes only the app's read lock, which `poll_events`
|
// reading the pointer takes only the app's read lock, which `poll_events`
|
||||||
// also only ever holds shared.
|
// 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.
|
/// The copy itself, on the worker [`install_bundled_models`] starts.
|
||||||
|
|||||||
@@ -1,14 +1,28 @@
|
|||||||
//! What the catalog's routine reads cost on a real library, off the GUI.
|
//! 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]
|
//! 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
|
//! Times `Catalog::open` — which every worker thread pays, including the
|
||||||
//! develop view's fetch of each original and each neighbour it prefetches —
|
//! 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
|
//! 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.
|
//! *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
|
//! 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.
|
//! 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::path::PathBuf;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
@@ -16,7 +30,15 @@ use std::time::{Duration, Instant};
|
|||||||
use dr_catalog::{keywords, rating, schema, Catalog};
|
use dr_catalog::{keywords, rating, schema, Catalog};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
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 {
|
let Some(path) = args.first().map(PathBuf::from) else {
|
||||||
eprintln!("usage: catalog_bench CATALOG.sqlite");
|
eprintln!("usage: catalog_bench CATALOG.sqlite");
|
||||||
std::process::exit(2);
|
std::process::exit(2);
|
||||||
@@ -29,6 +51,49 @@ fn main() {
|
|||||||
drop(Catalog::open(&path).unwrap());
|
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 catalog = Catalog::open(&path).unwrap();
|
||||||
let conn = catalog.connection();
|
let conn = catalog.connection();
|
||||||
time("schema::backfill (all steps)", 20, || {
|
time("schema::backfill (all steps)", 20, || {
|
||||||
@@ -44,6 +109,8 @@ fn main() {
|
|||||||
keywords::adopt_orphan_terms(conn).unwrap();
|
keywords::adopt_orphan_terms(conn).unwrap();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
interactive(conn);
|
||||||
|
|
||||||
// A sync pass: the upload snapshot, then a merge of the catalog with a
|
// 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.
|
// copy of itself — every row a match, which is the steady state.
|
||||||
let scratch = path.with_extension("bench-snapshot");
|
let scratch = path.with_extension("bench-snapshot");
|
||||||
@@ -65,6 +132,19 @@ fn main() {
|
|||||||
let _ = std::fs::remove_file(&scratch);
|
let _ = std::fs::remove_file(&scratch);
|
||||||
let _ = std::fs::remove_file(&remote);
|
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
|
// 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.
|
// directions in the steady state, where nothing is new either way.
|
||||||
if let Some(faces) = args.get(1).map(PathBuf::from) {
|
if let Some(faces) = args.get(1).map(PathBuf::from) {
|
||||||
@@ -84,6 +164,103 @@ fn main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
/// 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.
|
/// best CPU time — the figure to compare across runs on a busy machine.
|
||||||
fn time(label: &str, runs: usize, mut f: impl FnMut()) {
|
fn time(label: &str, runs: usize, mut f: impl FnMut()) {
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,516 @@
|
|||||||
|
//! 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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -1235,6 +1260,46 @@ mod tests {
|
|||||||
assert_eq!(visible(cat.connection()), vec![1, 2, 3, 4]);
|
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]
|
#[test]
|
||||||
fn a_library_with_no_bursts_hides_nothing() {
|
fn a_library_with_no_bursts_hides_nothing() {
|
||||||
// The predicate is in every grid query, so its cost and its effect on a
|
// 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
@@ -65,6 +65,16 @@ pub enum CatalogError {
|
|||||||
#[error("no such collection: {0}")]
|
#[error("no such collection: {0}")]
|
||||||
NoSuchCollection(u64),
|
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
|
/// A keyword the caller named is gone — deleted, or fused into another by a
|
||||||
/// merge while its id sat in a UI model.
|
/// merge while its id sat in a UI model.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -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
|
/// 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
|
/// 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(
|
pub fn merge_people(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
target: PersonId,
|
target: PersonId,
|
||||||
@@ -1039,7 +1040,53 @@ pub fn merge_people(
|
|||||||
return Ok(0);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
let tx = conn.unchecked_transaction()?;
|
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 —
|
// 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
|
// `face_person` is keyed by face. Where both hold the same face, the
|
||||||
// target's row wins and the source's is dropped.
|
// target's row wins and the source's is dropped.
|
||||||
@@ -1047,18 +1094,31 @@ pub fn merge_people(
|
|||||||
"DELETE FROM face_person
|
"DELETE FROM face_person
|
||||||
WHERE person_id = ?2
|
WHERE person_id = ?2
|
||||||
AND face_id IN (SELECT face_id FROM face_person WHERE person_id = ?1)",
|
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(
|
let moved = tx.execute(
|
||||||
"UPDATE face_person SET person_id = ?1 WHERE person_id = ?2",
|
"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(
|
tx.execute(
|
||||||
"UPDATE people SET merged_into = ?1, revision = revision + 1, modified = ?3
|
"INSERT OR IGNORE INTO face_person_rejected (face_id, person_id)
|
||||||
WHERE id = ?2",
|
SELECT face_id, ?1 FROM face_person_rejected WHERE person_id = ?2",
|
||||||
rusqlite::params![target.0 as i64, source.0 as i64, now_secs()],
|
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)
|
Ok(moved as u64)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2518,6 +2578,48 @@ mod tests {
|
|||||||
assert_eq!(people(&c).unwrap().len(), 1);
|
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]
|
#[test]
|
||||||
fn merging_does_not_duplicate_a_face_both_people_hold() {
|
fn merging_does_not_duplicate_a_face_both_people_hold() {
|
||||||
let c = db();
|
let c = db();
|
||||||
|
|||||||
@@ -29,7 +29,11 @@ pub enum JobKind {
|
|||||||
ScanFolder = 0,
|
ScanFolder = 0,
|
||||||
/// Promote an image from stat-only to full EXIF.
|
/// Promote an image from stat-only to full EXIF.
|
||||||
ExtractMetadata = 1,
|
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,
|
Thumbnail = 2,
|
||||||
/// A sidecar on disk is newer than what the catalog read.
|
/// A sidecar on disk is newer than what the catalog read.
|
||||||
ReadSidecar = 3,
|
ReadSidecar = 3,
|
||||||
@@ -69,6 +73,18 @@ impl JobKind {
|
|||||||
JobKind::DetectFaces,
|
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> {
|
fn from_i64(v: i64) -> Option<Self> {
|
||||||
Some(match v {
|
Some(match v {
|
||||||
0 => JobKind::ScanFolder,
|
0 => JobKind::ScanFolder,
|
||||||
@@ -399,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
|
/// 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
|
/// 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.
|
/// looked. Each one would be claimed, run, and failed five times.
|
||||||
///
|
///
|
||||||
/// Only kinds whose subject really is an image ([`JobKind::subject_is_image`])
|
/// Only kinds whose subject really is an image ([`JobKind::subject_is_image`])
|
||||||
@@ -431,6 +447,32 @@ pub fn reap_orphan_subjects(conn: &Connection) -> Result<usize, CatalogError> {
|
|||||||
Ok(n)
|
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.
|
/// How much is left, by state.
|
||||||
///
|
///
|
||||||
/// One query rather than a listing, because the caller is a progress line: a
|
/// 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
|
/// every assignment, and a query per keyword would be one statement per word
|
||||||
/// in the library.
|
/// in the library.
|
||||||
pub fn list(conn: &Connection) -> Result<Vec<Keyword>, CatalogError> {
|
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
|
// 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
|
// photograph, and reporting two is the kind of small lie that makes a
|
||||||
// user stop trusting the counts.
|
// user stop trusting the counts.
|
||||||
@@ -269,6 +270,27 @@ pub fn list(conn: &Connection) -> Result<Vec<Keyword>, CatalogError> {
|
|||||||
Ok(rows)
|
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.
|
/// 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
|
/// The bulk form is the *only* form, because keywording a selection is the
|
||||||
|
|||||||
@@ -36,10 +36,13 @@ use std::path::Path;
|
|||||||
use dr_types::{Availability, ImageId};
|
use dr_types::{Availability, ImageId};
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
|
|
||||||
|
pub mod albums;
|
||||||
|
mod backfilled;
|
||||||
pub mod bursts;
|
pub mod bursts;
|
||||||
pub mod cache;
|
pub mod cache;
|
||||||
pub mod collections;
|
pub mod collections;
|
||||||
pub mod dedup;
|
pub mod dedup;
|
||||||
|
pub mod dedup_people;
|
||||||
pub mod duplicates;
|
pub mod duplicates;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod face_shard;
|
pub mod face_shard;
|
||||||
@@ -57,6 +60,7 @@ pub mod sync;
|
|||||||
pub mod trash;
|
pub mod trash;
|
||||||
pub mod walk;
|
pub mod walk;
|
||||||
|
|
||||||
|
pub use albums::{Album, AlbumId, Place};
|
||||||
pub use cache::{Budget, Cache, DEFAULT_BUDGET_BYTES};
|
pub use cache::{Budget, Cache, DEFAULT_BUDGET_BYTES};
|
||||||
pub use collections::{Collection, CollectionKind, TreeRow};
|
pub use collections::{Collection, CollectionKind, TreeRow};
|
||||||
pub use dedup::{seen_by_content, seen_by_metadata, set_content_hash};
|
pub use dedup::{seen_by_content, seen_by_metadata, set_content_hash};
|
||||||
@@ -247,8 +251,18 @@ impl Catalog {
|
|||||||
// A migration adds a column; it cannot know what the value should be
|
// A migration adds a column; it cannot know what the value should be
|
||||||
// for rows that already existed. Backfilling on open is what stops
|
// for rows that already existed. Backfilling on open is what stops
|
||||||
// those rows being silently partial.
|
// 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 })
|
Ok(Catalog { conn })
|
||||||
}
|
}
|
||||||
|
|||||||
+760
-82
File diff suppressed because it is too large
Load Diff
+156
-17
@@ -449,19 +449,26 @@ pub fn toggled_label(
|
|||||||
/// TRACES: FR-CAT-5 | FR-CAT-6
|
/// TRACES: FR-CAT-5 | FR-CAT-6
|
||||||
/// How the library divides by colour label, for the filter chips' counts.
|
/// 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`. One
|
/// Index 0 is unlabelled and index `n` the label whose code is `n`. The
|
||||||
/// grouped statement — the same shape as [`rating_histogram`], and for the
|
/// same shape as [`rating_histogram`], and for the same reason the
|
||||||
/// same reason it LEFT JOINs: an image without a version row is unlabelled,
|
/// unlabelled slot is what is left of [`judged_rows`]: an image without a
|
||||||
/// not missing.
|
/// 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> {
|
pub fn label_histogram(conn: &Connection) -> Result<[usize; 6], CatalogError> {
|
||||||
let mut out = [0usize; 6];
|
let mut out = [0usize; 6];
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare_cached(
|
||||||
"SELECT coalesce(v.label, 0) AS l, count(*)
|
"SELECT label, count(*) FROM versions
|
||||||
FROM images i
|
WHERE is_default = 1 AND label IS NOT NULL
|
||||||
LEFT JOIN versions v ON v.image_id = i.id AND v.is_default = 1
|
GROUP BY label",
|
||||||
GROUP BY l",
|
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?;
|
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() {
|
for (code, count) in rows.flatten() {
|
||||||
// A code this build does not know counts as unlabelled, which is how
|
// A code this build does not know counts as unlabelled, which is how
|
||||||
// `label_from_code` reads it everywhere else.
|
// `label_from_code` reads it everywhere else.
|
||||||
@@ -471,7 +478,9 @@ pub fn label_histogram(conn: &Connection) -> Result<[usize; 6], CatalogError> {
|
|||||||
0
|
0
|
||||||
};
|
};
|
||||||
out[slot] += count as usize;
|
out[slot] += count as usize;
|
||||||
|
counted += count as usize;
|
||||||
}
|
}
|
||||||
|
out[0] += judged_rows(conn)?.saturating_sub(counted);
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -582,25 +591,61 @@ pub fn judgements(
|
|||||||
pub fn rating_histogram(conn: &Connection) -> Result<[usize; 6], CatalogError> {
|
pub fn rating_histogram(conn: &Connection) -> Result<[usize; 6], CatalogError> {
|
||||||
let mut out = [0usize; 6];
|
let mut out = [0usize; 6];
|
||||||
|
|
||||||
// LEFT JOIN, so an image whose version row is missing still counts as
|
// Only the rated rows are grouped; the unrated slot is what is left of
|
||||||
// unrated rather than vanishing from the totals. The histogram has to sum
|
// [`judged_rows`]. So an image whose version row is missing still counts
|
||||||
// to the library size or it is not believable.
|
// as unrated rather than vanishing from the totals -- the histogram has
|
||||||
let mut stmt = conn.prepare(
|
// to sum to the library size or it is not believable.
|
||||||
"SELECT coalesce(v.rating, 0) AS r, count(*)
|
//
|
||||||
FROM images i
|
// It was one `images LEFT JOIN versions ... GROUP BY` until 2026-09-26:
|
||||||
LEFT JOIN versions v ON v.image_id = i.id AND v.is_default = 1
|
// a probe of `versions_judgement` per image and a sort of every row, to
|
||||||
GROUP BY r",
|
// 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 rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?;
|
||||||
|
|
||||||
|
let mut counted = 0usize;
|
||||||
for (rating, count) in rows.flatten() {
|
for (rating, count) in rows.flatten() {
|
||||||
if let Some(slot) = out.get_mut(rating.clamp(0, MAX_RATING as i64) as usize) {
|
if let Some(slot) = out.get_mut(rating.clamp(0, MAX_RATING as i64) as usize) {
|
||||||
*slot += count as usize;
|
*slot += count as usize;
|
||||||
|
counted += count as usize;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
out[0] += judged_rows(conn)?.saturating_sub(counted);
|
||||||
Ok(out)
|
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)`.
|
/// How many images carry each flag: `(picks, rejects)`.
|
||||||
pub fn flag_counts(conn: &Connection) -> Result<(usize, usize), CatalogError> {
|
pub fn flag_counts(conn: &Connection) -> Result<(usize, usize), CatalogError> {
|
||||||
let picks: i64 = conn.query_row(
|
let picks: i64 = conn.query_row(
|
||||||
@@ -987,6 +1032,100 @@ mod tests {
|
|||||||
assert_eq!(h.iter().sum::<usize>(), 4);
|
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]
|
#[test]
|
||||||
fn flag_counts_separate_picks_from_rejects() {
|
fn flag_counts_separate_picks_from_rejects() {
|
||||||
let cat = with_images(5);
|
let cat = with_images(5);
|
||||||
|
|||||||
@@ -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
|
/// to move — a caller may be recovering from a file SQLite could not open
|
||||||
/// because it was never created.
|
/// because it was never created.
|
||||||
pub fn set_aside(catalog: &Path) -> Result<Option<PathBuf>, CatalogError> {
|
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 moved = if catalog.exists() {
|
||||||
let dest = with_suffix(catalog, DAMAGED_SUFFIX);
|
let dest = with_suffix(catalog, DAMAGED_SUFFIX);
|
||||||
// An earlier damaged copy is replaced rather than accumulating: two of
|
// An earlier damaged copy is replaced rather than accumulating: two of
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ pub enum Outcome {
|
|||||||
|
|
||||||
/// Something that can actually do the work a job describes.
|
/// 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
|
/// 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
|
/// `core/dr-catalog` (ARCH §4.1: calls go downward). So the queue lives here
|
||||||
/// and the handlers are supplied from above.
|
/// and the handlers are supplied from above.
|
||||||
@@ -187,17 +187,19 @@ pub struct Recovered {
|
|||||||
pub reclaimed: usize,
|
pub reclaimed: usize,
|
||||||
/// Jobs deleted because the photograph they name no longer exists.
|
/// Jobs deleted because the photograph they name no longer exists.
|
||||||
pub reaped: usize,
|
pub reaped: usize,
|
||||||
|
/// Jobs deleted because their kind is retired ([`JobKind::RETIRED`]).
|
||||||
|
pub retired: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Recovered {
|
impl Recovered {
|
||||||
pub fn did_anything(&self) -> bool {
|
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.
|
/// 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
|
/// - **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).
|
/// 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,
|
/// process down with it three times running should not be retried forever,
|
||||||
/// and the attempt counter is the only evidence of that we have.
|
/// 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
|
/// - **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.
|
/// 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.
|
/// before reaping removes whichever of them pointed at nothing.
|
||||||
///
|
///
|
||||||
/// **Call this exactly once per catalog, at startup.** It cannot distinguish a
|
/// **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,
|
/// 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.
|
/// because there is no owner column — the queue is durable, not distributed.
|
||||||
pub fn recover(conn: &Connection) -> Result<Recovered, CatalogError> {
|
pub fn recover(conn: &Connection) -> Result<Recovered, CatalogError> {
|
||||||
|
let retired = jobs::drop_retired(conn)?;
|
||||||
Ok(Recovered {
|
Ok(Recovered {
|
||||||
reclaimed: jobs::recover_orphaned(conn)?,
|
reclaimed: jobs::recover_orphaned(conn)?,
|
||||||
reaped: jobs::reap_orphan_subjects(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`,
|
// which is the window a durable queue exists to survive: no `complete`,
|
||||||
// no `fail`, just a row marked `Running` with nobody holding it.
|
// no `fail`, just a row marked `Running` with nobody holding it.
|
||||||
let c = db();
|
let c = db();
|
||||||
queued(&c, JobKind::Thumbnail, 1);
|
queued(&c, JobKind::ContentHash, 1);
|
||||||
|
|
||||||
// The dead process. It claimed the job and never came back.
|
// The dead process. It claimed the job and never came back.
|
||||||
let claimed = jobs::claim_next(&c, 0).unwrap().expect("claimable");
|
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
|
// A fresh runner, before it starts, finds the queue empty — the row is
|
||||||
// `Running` and no claim will touch it.
|
// `Running` and no claim will touch it.
|
||||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
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!(
|
assert_eq!(
|
||||||
runner.drain_all(0).unwrap().ran(),
|
runner.drain_all(0).unwrap().ran(),
|
||||||
0,
|
0,
|
||||||
@@ -766,7 +776,7 @@ mod tests {
|
|||||||
// the only evidence we keep across a death. Without this a poison-pill
|
// the only evidence we keep across a death. Without this a poison-pill
|
||||||
// job would be reclaimed and re-run forever.
|
// job would be reclaimed and re-run forever.
|
||||||
let c = db();
|
let c = db();
|
||||||
queued(&c, JobKind::Thumbnail, 1);
|
queued(&c, JobKind::ContentHash, 1);
|
||||||
|
|
||||||
for _ in 0..MAX_ATTEMPTS {
|
for _ in 0..MAX_ATTEMPTS {
|
||||||
jobs::claim_next(&c, 0).unwrap().expect("claimable");
|
jobs::claim_next(&c, 0).unwrap().expect("claimable");
|
||||||
@@ -782,13 +792,49 @@ mod tests {
|
|||||||
assert_eq!(state, JobState::Failed as i64);
|
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]
|
#[test]
|
||||||
fn recovery_drops_jobs_whose_photograph_is_gone() {
|
fn recovery_drops_jobs_whose_photograph_is_gone() {
|
||||||
// A culled library leaves thumbnail jobs for images deleted months
|
// A culled library leaves thumbnail jobs for images deleted months
|
||||||
// ago. Every one would be claimed, run and failed.
|
// ago. Every one would be claimed, run and failed.
|
||||||
let c = db();
|
let c = db();
|
||||||
queued(&c, JobKind::Thumbnail, 1);
|
queued(&c, JobKind::ContentHash, 1);
|
||||||
queued(&c, JobKind::Thumbnail, 2);
|
queued(&c, JobKind::ContentHash, 2);
|
||||||
c.execute("DELETE FROM images WHERE id = 2", []).unwrap();
|
c.execute("DELETE FROM images WHERE id = 2", []).unwrap();
|
||||||
|
|
||||||
let recovered = recover(&c).unwrap();
|
let recovered = recover(&c).unwrap();
|
||||||
@@ -804,7 +850,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn a_quiet_startup_recovers_nothing() {
|
fn a_quiet_startup_recovers_nothing() {
|
||||||
let c = db();
|
let c = db();
|
||||||
queued(&c, JobKind::Thumbnail, 1);
|
queued(&c, JobKind::ContentHash, 1);
|
||||||
assert_eq!(recover(&c).unwrap(), Recovered::default());
|
assert_eq!(recover(&c).unwrap(), Recovered::default());
|
||||||
assert!(!recover(&c).unwrap().did_anything());
|
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
|
/// silently partial — present, queryable, and wrong — which is worse than
|
||||||
/// missing, because nothing signals that they need attention.
|
/// missing, because nothing signals that they need attention.
|
||||||
///
|
///
|
||||||
/// Cheap enough to run on every open: each pass is one indexed UPDATE, and
|
/// Idempotent: re-running it is a no-op once the values are already right.
|
||||||
/// 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.
|
/// Returns how many rows each backfill touched, for logging.
|
||||||
pub fn backfill(conn: &Connection) -> Result<Vec<(&'static str, usize)>, CatalogError> {
|
pub fn backfill(conn: &Connection) -> Result<Vec<(&'static str, usize)>, CatalogError> {
|
||||||
|
|||||||
+493
-45
@@ -46,18 +46,210 @@ pub fn checkpoint(conn: &Connection) -> Result<(), CatalogError> {
|
|||||||
Ok(())
|
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
|
/// Built rather than copied. The snapshot is the *whole catalog* bar the
|
||||||
/// coherent even with writers active. Callers should still prefer a quiet
|
/// crops, uploaded on every sync and downloaded by every device. The crops are
|
||||||
/// moment — this competes with background jobs for the write lock.
|
/// 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> {
|
pub fn snapshot_for_upload(conn: &Connection, dest: &Path) -> Result<(), CatalogError> {
|
||||||
let out = copy_to(conn, dest)?;
|
// The source is read through a second connection, attached to the
|
||||||
strip_face_crops(&out)?;
|
// 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)?;
|
verify_snapshot(&out)?;
|
||||||
Ok(())
|
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
|
/// TRACES: NFR-R2
|
||||||
/// Refuse to hand over a snapshot that will not pass `quick_check`.
|
/// 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
|
/// Checkpoint, then copy the whole database to `dest`, and hand back the
|
||||||
/// connection to the copy.
|
/// connection to the copy.
|
||||||
///
|
///
|
||||||
/// Split out from [`snapshot_for_upload`] because [`crate::recovery`] wants
|
/// What [`crate::recovery`] takes its NFR-R2 backups with. A backup is the
|
||||||
/// 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
|
||||||
/// file the user may have to *live on*, so it keeps the face crops that an
|
/// [`snapshot_for_upload`] leaves out, and a byte-for-byte page copy is the
|
||||||
/// upload strips. Sharing the copy rather than reimplementing it is what keeps
|
/// right tool. Keeping it here beside the upload keeps the WAL discipline in
|
||||||
/// the WAL discipline in one place — a backup taken with `fs::copy` would be
|
/// one place: a backup taken with `fs::copy` would be the torn snapshot this
|
||||||
/// the torn snapshot this module's header exists to warn about.
|
/// module's header exists to warn about.
|
||||||
pub(crate) fn copy_to(conn: &Connection, dest: &Path) -> Result<Connection, CatalogError> {
|
pub(crate) fn copy_to(conn: &Connection, dest: &Path) -> Result<Connection, CatalogError> {
|
||||||
checkpoint(conn)?;
|
checkpoint(conn)?;
|
||||||
|
|
||||||
@@ -103,39 +295,6 @@ pub(crate) fn copy_to(conn: &Connection, dest: &Path) -> Result<Connection, Cata
|
|||||||
Ok(out)
|
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.
|
/// Whether a downloaded remote catalog is worth merging.
|
||||||
///
|
///
|
||||||
/// Cheap guard before attaching: a remote written by a newer build may contain
|
/// 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);
|
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
|
// Detach even if the merge failed, or the next attempt errors with
|
||||||
// "database remote_cat is already in use".
|
// "database remote_cat is already in use".
|
||||||
let detach = conn.execute(&format!("DETACH DATABASE {REMOTE_SCHEMA}"), []);
|
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}");
|
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
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,6 +466,92 @@ mod tests {
|
|||||||
assert_eq!(n, 2);
|
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]
|
#[test]
|
||||||
fn the_remote_can_be_merged_twice_without_attach_conflict() {
|
fn the_remote_can_be_merged_twice_without_attach_conflict() {
|
||||||
// Detach must happen even on the failure path, or the second attempt
|
// Detach must happen even on the failure path, or the second attempt
|
||||||
@@ -380,4 +644,188 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(kept, 1, "stripping the snapshot damaged the live catalog");
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-53
@@ -48,7 +48,6 @@ use dr_types::{Availability, FormatFilter, RootId, SourceRef};
|
|||||||
use rusqlite::{Connection, OptionalExtension};
|
use rusqlite::{Connection, OptionalExtension};
|
||||||
|
|
||||||
use crate::error::CatalogError;
|
use crate::error::CatalogError;
|
||||||
use crate::jobs::{self, JobKind, Priority};
|
|
||||||
use crate::query::availability_code;
|
use crate::query::availability_code;
|
||||||
use crate::scan::{
|
use crate::scan::{
|
||||||
classify_dir, classify_entry, DirAction, DirState, EntryAction, KnownFile, ScanOutcome,
|
classify_dir, classify_entry, DirAction, DirState, EntryAction, KnownFile, ScanOutcome,
|
||||||
@@ -336,7 +335,7 @@ pub fn scan_root(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
EntryAction::Insert => {
|
EntryAction::Insert => {
|
||||||
let id = insert_image(
|
insert_image(
|
||||||
&tx,
|
&tx,
|
||||||
root,
|
root,
|
||||||
folder_id,
|
folder_id,
|
||||||
@@ -345,11 +344,10 @@ pub fn scan_root(
|
|||||||
entry.meta.mtime,
|
entry.meta.mtime,
|
||||||
now,
|
now,
|
||||||
)?;
|
)?;
|
||||||
queue_reading_it(&tx, id)?;
|
|
||||||
report.inserted += 1;
|
report.inserted += 1;
|
||||||
}
|
}
|
||||||
EntryAction::Changed => {
|
EntryAction::Changed => {
|
||||||
let id = update_image(
|
update_image(
|
||||||
&tx,
|
&tx,
|
||||||
root,
|
root,
|
||||||
folder_id,
|
folder_id,
|
||||||
@@ -357,7 +355,6 @@ pub fn scan_root(
|
|||||||
entry.meta.size,
|
entry.meta.size,
|
||||||
entry.meta.mtime,
|
entry.meta.mtime,
|
||||||
)?;
|
)?;
|
||||||
queue_reading_it(&tx, id)?;
|
|
||||||
report.updated += 1;
|
report.updated += 1;
|
||||||
}
|
}
|
||||||
EntryAction::Ignored => unreachable!("returned above"),
|
EntryAction::Ignored => unreachable!("returned above"),
|
||||||
@@ -640,7 +637,7 @@ fn insert_image(
|
|||||||
size: u64,
|
size: u64,
|
||||||
mtime: i64,
|
mtime: i64,
|
||||||
now: i64,
|
now: i64,
|
||||||
) -> Result<i64, CatalogError> {
|
) -> Result<(), CatalogError> {
|
||||||
// `metadata_state = 1`: the scan knows the name, the size and the mtime,
|
// `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
|
// and has read no EXIF. Claiming otherwise would make a date filter
|
||||||
// silently wrong on a freshly scanned library.
|
// silently wrong on a freshly scanned library.
|
||||||
@@ -664,7 +661,7 @@ fn insert_image(
|
|||||||
now,
|
now,
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
image_id(conn, root, src)
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_image(
|
fn update_image(
|
||||||
@@ -674,7 +671,7 @@ fn update_image(
|
|||||||
src: &SourceRef,
|
src: &SourceRef,
|
||||||
size: u64,
|
size: u64,
|
||||||
mtime: i64,
|
mtime: i64,
|
||||||
) -> Result<i64, CatalogError> {
|
) -> Result<(), CatalogError> {
|
||||||
// The content hash is dropped, not recomputed: it described bytes that no
|
// 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
|
// 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
|
// 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(),
|
src.key(),
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
image_id(conn, root, src)
|
Ok(())
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TRACES: FR-CAT-9
|
/// TRACES: FR-CAT-9
|
||||||
@@ -831,6 +798,17 @@ mod tests {
|
|||||||
let tmp = target.with_extension("tmp");
|
let tmp = target.with_extension("tmp");
|
||||||
fs::write(&tmp, bytes).expect("write");
|
fs::write(&tmp, bytes).expect("write");
|
||||||
fs::rename(&tmp, &target).expect("rename");
|
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
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1096,7 +1074,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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");
|
let lib = Library::new("resaved");
|
||||||
lib.file("IMG.CR3", b"raw");
|
lib.file("IMG.CR3", b"raw");
|
||||||
lib.scan();
|
lib.scan();
|
||||||
@@ -1121,9 +1099,8 @@ mod tests {
|
|||||||
"a hash of bytes that no longer exist would match this image to the \
|
"a hash of bytes that no longer exist would match this image to the \
|
||||||
wrong file on reconnect"
|
wrong file on reconnect"
|
||||||
);
|
);
|
||||||
assert_eq!(lib.count("SELECT metadata_state FROM images"), 1);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
lib.count("SELECT count(*) FROM jobs WHERE kind = 1"),
|
lib.count("SELECT metadata_state FROM images"),
|
||||||
1,
|
1,
|
||||||
"EXIF must be re-read"
|
"EXIF must be re-read"
|
||||||
);
|
);
|
||||||
@@ -1137,7 +1114,11 @@ mod tests {
|
|||||||
let lib = Library::new("no-requeue");
|
let lib = Library::new("no-requeue");
|
||||||
lib.file("a.CR3", b"raw");
|
lib.file("a.CR3", b"raw");
|
||||||
lib.scan();
|
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");
|
lib.file("b.CR3", b"raw");
|
||||||
let r = lib.scan();
|
let r = lib.scan();
|
||||||
@@ -1145,9 +1126,9 @@ mod tests {
|
|||||||
assert_eq!(r.inserted, 1);
|
assert_eq!(r.inserted, 1);
|
||||||
assert_eq!(r.unchanged, 1);
|
assert_eq!(r.unchanged, 1);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
lib.count("SELECT count(*) FROM jobs"),
|
lib.count("SELECT count(*) FROM images WHERE metadata_state < 2"),
|
||||||
2,
|
1,
|
||||||
"EXIF and a thumbnail for the new image, and nothing for the old one"
|
"EXIF owed for the new image, and nothing for the old one"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1171,17 +1152,15 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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");
|
let lib = Library::new("queued");
|
||||||
lib.file("IMG.CR3", b"raw");
|
lib.file("IMG.CR3", b"raw");
|
||||||
lib.scan();
|
lib.scan();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(lib.count("SELECT count(*) FROM jobs"), 0);
|
||||||
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!(
|
assert_eq!(
|
||||||
lib.count("SELECT metadata_state FROM images"),
|
lib.count("SELECT metadata_state FROM images"),
|
||||||
1,
|
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,14 +16,12 @@
|
|||||||
//! second decoder can be put behind them without changing any of them
|
//! 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.
|
//! (FR-RAW-2). [`Rawler`] is the one that ships; [`default`] hands it out.
|
||||||
|
|
||||||
pub mod base_curve;
|
|
||||||
mod decoder;
|
mod decoder;
|
||||||
mod error;
|
mod error;
|
||||||
mod locate;
|
mod locate;
|
||||||
mod preview;
|
mod preview;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
|
|
||||||
pub use base_curve::BaseCurve;
|
|
||||||
pub use decoder::{default, Decoder, Rawler};
|
pub use decoder::{default, Decoder, Rawler};
|
||||||
pub use error::DecodeError;
|
pub use error::DecodeError;
|
||||||
pub use locate::{
|
pub use locate::{
|
||||||
@@ -125,19 +123,6 @@ pub struct RawImage {
|
|||||||
/// for the light the frame was shot under; see [`profile::CameraProfile`].
|
/// for the light the frame was shot under; see [`profile::CameraProfile`].
|
||||||
pub color_matrix: Option<[f32; 9]>,
|
pub color_matrix: Option<[f32; 9]>,
|
||||||
/// TRACES: FR-DEV-3e
|
/// 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.
|
/// The usable region of `data`, excluding masked and border photosites.
|
||||||
pub crop: CropRect,
|
pub crop: CropRect,
|
||||||
/// TRACES: FR-MRG-3
|
/// TRACES: FR-MRG-3
|
||||||
@@ -592,15 +577,6 @@ fn decode_unguarded(bytes: &[u8]) -> Result<RawImage, DecodeError> {
|
|||||||
profile.as_ref().map(|p| p.xyz_to_cam()).as_ref(),
|
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
|
// TRACES: FR-MRG-3
|
||||||
// A linear DNG — three samples per pixel, no colour filter array — is a
|
// A linear DNG — three samples per pixel, no colour filter array — is a
|
||||||
// composite this application wrote (or any other demosaiced DNG). It
|
// composite this application wrote (or any other demosaiced DNG). It
|
||||||
@@ -683,7 +659,6 @@ fn decode_unguarded(bytes: &[u8]) -> Result<RawImage, DecodeError> {
|
|||||||
.unwrap_or(u16::MAX),
|
.unwrap_or(u16::MAX),
|
||||||
wb_coeffs,
|
wb_coeffs,
|
||||||
color_matrix,
|
color_matrix,
|
||||||
base_curve,
|
|
||||||
samples_per_pixel,
|
samples_per_pixel,
|
||||||
profile,
|
profile,
|
||||||
make: image.camera.clean_make.clone(),
|
make: image.camera.clean_make.clone(),
|
||||||
|
|||||||
@@ -6,8 +6,10 @@
|
|||||||
//! colour needs two things the file cannot supply on its own: a **matrix**
|
//! 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
|
//! 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
|
//! **rendering** saying what to do with the resulting scene-referred values so
|
||||||
//! that a photograph looks like a photograph. This module supplies the first
|
//! that a photograph looks like a photograph. This module supplies the first.
|
||||||
//! and looks up the second ([`crate::base_curve`]).
|
//! 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
|
//! # What is extracted, and from where
|
||||||
//!
|
//!
|
||||||
@@ -65,8 +67,8 @@
|
|||||||
//! FR-DEV-3e defers full `.dcp` support — `HueSatDeltas` and
|
//! FR-DEV-3e defers full `.dcp` support — `HueSatDeltas` and
|
||||||
//! `ProfileLookTable` — and requires that they arrive as *additions* rather
|
//! `ProfileLookTable` — and requires that they arrive as *additions* rather
|
||||||
//! than as a pipeline reordering. They would: both are lookups applied to a
|
//! 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
|
//! colour at this matrix, before any edit reaches it, so they extend
|
||||||
//! extend [`CameraProfile`] with more calibration data and extend the shader's
|
//! [`CameraProfile`] with more calibration data and extend the shader's
|
||||||
//! camera-profile stage with more work. Nothing above would move.
|
//! camera-profile stage with more work. Nothing above would move.
|
||||||
|
|
||||||
use crate::{cam_to_srgb_from, invert3};
|
use crate::{cam_to_srgb_from, invert3};
|
||||||
|
|||||||
+31
-9
@@ -1,9 +1,8 @@
|
|||||||
# Film stocks
|
# Film stocks
|
||||||
|
|
||||||
One file per stock in [`profiles/`](profiles/). Adding a stock is adding a
|
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
|
file — no code change, no shader, no new operation — because under the GPLv3
|
||||||
`dr-decode`'s base curves work that way: under the GPLv3 a stock should be
|
a stock should be contributable without a release.
|
||||||
contributable without a release.
|
|
||||||
|
|
||||||
## What a profile is
|
## 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
|
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
|
approximation: the reconstructed scene spectrum is linear in the sRGB
|
||||||
triple, so the integral collapses into nine numbers.
|
triple, so the integral collapses into nine numbers.
|
||||||
2. **Three 1D curves**, log exposure to density, sampled at 256 points.
|
2. **Three 1D curves**, log exposure to density, sampled at 256 points — one
|
||||||
3. **One 32³ lookup**, density to linear sRGB — dye absorption, the print
|
row per development time the datasheet measures. Push picks between the
|
||||||
through the negative, the paper, the viewing illuminant and the chromatic
|
rows, and interpolating them is exact, because density is linear in push
|
||||||
adaptation, all of which take exactly three numbers in.
|
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.
|
The stock is the last thing that happens to the picture. It runs in the view
|
||||||
Splitting 2 from 3, rather than baking one LUT over exposure, is measured
|
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
|
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
|
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
|
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.
|
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
|
## Adding a stock
|
||||||
|
|
||||||
If spektrafilm has it, add its name to `STOCKS` in
|
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
|
//! 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
|
//! 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
|
//! 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
|
//! 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
|
//! 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
|
//! dye mixing is smooth, so putting the curve in the 3D LUT would force it
|
||||||
//! three times larger for the same error.
|
//! 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::spectrum::{illuminant, Spectrum, Viewing};
|
||||||
use crate::tables::{SPECTRUM, SRGB_BASIS};
|
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.
|
/// that on: the error is already under what the output can represent.
|
||||||
pub const LUT_SIZE: usize = 32;
|
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> {
|
pub struct Recipe<'a> {
|
||||||
/// The stock the picture was taken on.
|
/// The stock the picture was taken on.
|
||||||
pub film: &'a Profile,
|
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
|
/// what a reversal stock wants and what makes a negative come out orange
|
||||||
/// and inverted — that being what a negative actually looks like.
|
/// and inverted — that being what a negative actually looks like.
|
||||||
pub print: Option<&'a Profile>,
|
pub print: Option<&'a Profile>,
|
||||||
/// Camera exposure, in stops.
|
/// The camera exposure the enlarger is balanced at, in stops. Ignored
|
||||||
pub exposure_ev: f32,
|
/// without a `print`.
|
||||||
/// 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.
|
|
||||||
///
|
///
|
||||||
/// Ignored by a stock measured at one process, of which there are many —
|
/// The *photograph's* exposure, never a region's. An enlarger has one
|
||||||
/// see [`crate::profile::Profile::curves_at_push`], which returns the one
|
/// filtration for the whole print: a negative exposed a stop brighter in
|
||||||
/// measured curve rather than inventing a pushed one.
|
/// one corner prints a stop darker there, and that difference is the
|
||||||
pub push_stops: f32,
|
/// picture — balancing it away per pixel would erase every local exposure
|
||||||
|
/// change a layer made.
|
||||||
|
pub exposure_ev: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> Recipe<'a> {
|
impl<'a> Recipe<'a> {
|
||||||
@@ -76,12 +88,27 @@ impl<'a> Recipe<'a> {
|
|||||||
film,
|
film,
|
||||||
print,
|
print,
|
||||||
exposure_ev: 0.0,
|
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.
|
/// A recipe reduced to three tables.
|
||||||
///
|
///
|
||||||
/// Plain `f32` with a documented layout, and no notion of a texture: what to
|
/// 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.
|
/// the whole model be tested on the CPU.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Baked {
|
pub struct Baked {
|
||||||
/// Linear sRGB to the three layers' log₁₀ exposure, before the log — row
|
/// Linear sRGB to the three layers' exposure, before the log — row `l`,
|
||||||
/// `l`, column `c` is layer `l`'s response to sRGB channel `c`.
|
/// 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],
|
pub exposure_matrix: [[f32; 3]; 3],
|
||||||
/// The characteristic curves, `CURVE_SAMPLES` samples per layer, uniform
|
/// The characteristic curves: `curve_rows` rows of `CURVE_SAMPLES`
|
||||||
/// over `[curve_log_min, curve_log_max]`.
|
/// 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 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_min: f32,
|
||||||
pub curve_log_max: f32,
|
pub curve_log_max: f32,
|
||||||
/// Density to linear sRGB, `LUT_SIZE³` entries uniform over
|
/// Film density to what comes next, `LUT_SIZE³` entries uniform over
|
||||||
/// `[0, density_max]` on each axis.
|
/// `[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,
|
/// **The red axis varies fastest**, then green, then blue — that is,
|
||||||
/// `lut[(b * size + g) * size + r]`. Stated because it is not the order
|
/// `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
|
/// picture with red and blue transposed, which looks like a plausible
|
||||||
/// photograph of the wrong colour.
|
/// photograph of the wrong colour.
|
||||||
pub lut: Vec<[f32; 3]>,
|
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 density_max: f32,
|
||||||
pub lut_size: usize,
|
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 {
|
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] {
|
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];
|
let mut log_exposure = [0.0f32; 3];
|
||||||
for (l, slot) in log_exposure.iter_mut().enumerate() {
|
for (l, slot) in log_exposure.iter_mut().enumerate() {
|
||||||
let m = self.exposure_matrix[l];
|
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();
|
*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] {
|
fn sample_curves(&self, log_exposure: [f32; 3], push_stops: f32) -> [f32; 3] {
|
||||||
let last = self.curves.len() - 1;
|
let (row, g) = push_row(&self.push_stations, push_stops);
|
||||||
let span = self.curve_log_max - self.curve_log_min;
|
let lo = self.sample_curve_row(log_exposure, row);
|
||||||
let mut out = [0.0f32; 3];
|
if self.curve_rows < 2 {
|
||||||
for (c, slot) in out.iter_mut().enumerate() {
|
return lo;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
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] {
|
fn sample_curve_row(&self, log_exposure: [f32; 3], row: usize) -> [f32; 3] {
|
||||||
let n = self.lut_size;
|
let samples = self.curves.len() / self.curve_rows;
|
||||||
let mut base = [0usize; 3];
|
let curve = &self.curves[row * samples..(row + 1) * samples];
|
||||||
let mut frac = [0f32; 3];
|
sample_curve(curve, self.curve_log_min, self.curve_log_max, log_exposure)
|
||||||
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;
|
/// 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.
|
||||||
let mut out = [0.0f32; 3];
|
fn sample_curve(curve: &[[f32; 3]], log_min: f32, log_max: f32, at: [f32; 3]) -> [f32; 3] {
|
||||||
for dx in 0..2 {
|
let last = curve.len() - 1;
|
||||||
for dy in 0..2 {
|
let span = log_max - log_min;
|
||||||
for dz in 0..2 {
|
let mut out = [0.0f32; 3];
|
||||||
let w = if dx == 0 { 1.0 - frac[0] } else { frac[0] }
|
for (c, slot) in out.iter_mut().enumerate() {
|
||||||
* if dy == 0 { 1.0 - frac[1] } else { frac[1] }
|
let t = ((at[c] - log_min) / span).clamp(0.0, 1.0) * last as f32;
|
||||||
* if dz == 0 { 1.0 - frac[2] } else { frac[2] };
|
let i = (t.floor() as usize).min(last - 1);
|
||||||
let e = self.lut[((base[2] + dz) * n + base[1] + dy) * n + base[0] + dx];
|
let f = t - i as f32;
|
||||||
for c in 0..3 {
|
*slot = curve[i][c] * (1.0 - f) + curve[i + 1][c] * f;
|
||||||
out[c] += w * e[c];
|
}
|
||||||
}
|
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.
|
/// 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
|
/// 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
|
/// it — which is why a printed negative looks like a photograph while a scanned
|
||||||
/// one looks orange.
|
/// one looks orange.
|
||||||
fn print_balance(
|
pub fn print_balance(film: &Profile, paper: &Profile, exposure_ev: f32) -> [f32; 3] {
|
||||||
film: &Profile,
|
|
||||||
paper: &Profile,
|
|
||||||
exposure_ev: f32,
|
|
||||||
print_exposure_ev: f32,
|
|
||||||
) -> [f32; 3] {
|
|
||||||
let matrix = exposure_matrix(film);
|
let matrix = exposure_matrix(film);
|
||||||
let scene = MID_GREY * 2f32.powf(exposure_ev);
|
let scene = MID_GREY * 2f32.powf(exposure_ev);
|
||||||
let mut log_exposure = [0.0f32; 3];
|
let mut log_exposure = [0.0f32; 3];
|
||||||
@@ -227,7 +346,7 @@ fn print_balance(
|
|||||||
|
|
||||||
let mut offsets = [0.0f32; 3];
|
let mut offsets = [0.0f32; 3];
|
||||||
for (l, slot) in offsets.iter_mut().enumerate() {
|
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
|
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.
|
/// Bake a recipe into the tables a shader runs.
|
||||||
pub fn bake(recipe: &Recipe) -> Baked {
|
pub fn bake(recipe: &Recipe) -> Baked {
|
||||||
let film = recipe.film;
|
let film = recipe.film;
|
||||||
let mut matrix = exposure_matrix(film);
|
// At unit gain. Camera exposure is a scalar on a linear quantity, so the
|
||||||
// Camera exposure rides in the matrix rather than in the shader: it is a
|
// shader applies it for the price of one multiply — and has to, since a
|
||||||
// scalar on a linear quantity, and folding it in here costs nothing and
|
// layer may hold its own.
|
||||||
// keeps the per-pixel work identical whether or not it has been moved.
|
let matrix = exposure_matrix(film);
|
||||||
let gain = 2f32.powf(recipe.exposure_ev);
|
|
||||||
for row in &mut matrix {
|
|
||||||
for v in row.iter_mut() {
|
|
||||||
*v *= gain;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TRACES: FR-DEV-3f
|
// TRACES: FR-DEV-3f
|
||||||
// Developed to the requested push before anything else reads the curves:
|
// Every measured process, not the one the slider is at: the shader
|
||||||
// the density ceiling, the print balance and the grain all depend on how
|
// interpolates between rows per pixel, so a layer can push a region.
|
||||||
// far this film was taken, and a push that only reached one of them would
|
// Resampled to one length because the rows share a texture.
|
||||||
// be a contrast change wearing a push's name.
|
let measured = film.development_curves.len() >= 2
|
||||||
let curves = film.curves_at_push(recipe.push_stops);
|
&& film.development_times.len() == film.development_curves.len();
|
||||||
let density_max = curves
|
let (curves, push_stations): (Vec<[f32; 3]>, Vec<f32>) = if measured {
|
||||||
.iter()
|
let rows = film.development_curves.len().min(MAX_CURVE_ROWS);
|
||||||
.flat_map(|row| row.iter())
|
(
|
||||||
.fold(0.0f32, |a, &b| a.max(b))
|
film.development_curves[..rows]
|
||||||
.max(1e-3);
|
.iter()
|
||||||
|
.flat_map(|c| resample(c))
|
||||||
let viewing = match recipe.print {
|
.collect(),
|
||||||
Some(paper) => Viewing::new(&paper.viewing_illuminant),
|
film.development_times[..rows]
|
||||||
None => Viewing::new(&film.viewing_illuminant),
|
.iter()
|
||||||
|
.map(|t| 2.0 * (t / film.development_normal).log2())
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(resample(&film.density_curves), vec![0.0])
|
||||||
};
|
};
|
||||||
let balance = recipe
|
let curve_rows = push_stations.len();
|
||||||
.print
|
// The ceiling of the deepest row, so one lookup covers every push.
|
||||||
.map(|paper| print_balance(film, paper, recipe.exposure_ev, recipe.print_exposure_ev));
|
let density_max = ceiling(&curves);
|
||||||
|
|
||||||
let n = LUT_SIZE;
|
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
|
// 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
|
// `Baked::lut`: this is the layout a 3D texture upload wants, and getting
|
||||||
// it backwards transposes red and blue in the finished picture.
|
// it backwards transposes red and blue in the finished picture.
|
||||||
for b in 0..n {
|
let cube = |max: f32, f: &dyn Fn([f32; 3]) -> [f32; 3]| {
|
||||||
for g in 0..n {
|
let mut out = Vec::with_capacity(n * n * n);
|
||||||
for r in 0..n {
|
for b in 0..n {
|
||||||
let density = [
|
for g in 0..n {
|
||||||
density_max * r as f32 / (n - 1) as f32,
|
for r in 0..n {
|
||||||
density_max * g as f32 / (n - 1) as f32,
|
let step = max / (n - 1) as f32;
|
||||||
density_max * b as f32 / (n - 1) as f32,
|
out.push(f([r as f32 * step, g as f32 * step, b as f32 * step]));
|
||||||
];
|
}
|
||||||
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)),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
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 {
|
Baked {
|
||||||
exposure_matrix: matrix,
|
exposure_matrix: matrix,
|
||||||
curves,
|
curves,
|
||||||
|
curve_rows,
|
||||||
|
push_stations,
|
||||||
curve_log_min: film.log_exposure_min,
|
curve_log_min: film.log_exposure_min,
|
||||||
curve_log_max: film.log_exposure_max,
|
curve_log_max: film.log_exposure_max,
|
||||||
lut,
|
lut,
|
||||||
|
paper,
|
||||||
density_max,
|
density_max,
|
||||||
lut_size: n,
|
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 {
|
fn mean(s: &Spectrum) -> f32 {
|
||||||
s.iter().sum::<f32>() / SPECTRUM as f32
|
s.iter().sum::<f32>() / SPECTRUM as f32
|
||||||
}
|
}
|
||||||
@@ -467,6 +626,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn exposure_moves_the_print_the_way_it_moves_a_photograph() {
|
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 film = portra();
|
||||||
let paper = endura();
|
let paper = endura();
|
||||||
let brighter = bake(&Recipe {
|
let brighter = bake(&Recipe {
|
||||||
@@ -474,7 +637,155 @@ mod tests {
|
|||||||
..Recipe::new(&film, Some(&paper))
|
..Recipe::new(&film, Some(&paper))
|
||||||
});
|
});
|
||||||
let base = bake(&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]
|
#[test]
|
||||||
@@ -550,5 +861,25 @@ mod tests {
|
|||||||
let baked = bake(&Recipe::new(&film, None));
|
let baked = bake(&Recipe::new(&film, None));
|
||||||
assert_eq!(baked.lut.len(), LUT_SIZE * LUT_SIZE * LUT_SIZE);
|
assert_eq!(baked.lut.len(), LUT_SIZE * LUT_SIZE * LUT_SIZE);
|
||||||
assert_eq!(baked.curves.len(), CURVE_SAMPLES);
|
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
|
//! # Why it is data
|
||||||
//!
|
//!
|
||||||
//! The same argument `dr_decode::base_curve` makes for camera bodies, and for
|
//! Under the GPLv3 a stock should be contributable without a release (the
|
||||||
//! the same requirement: under the GPLv3 a stock should be contributable
|
//! argument the retired per-body base curves made for camera bodies, before
|
||||||
//! without a release. A profile is three tables and a handful of facts, all of
|
//! 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
|
//! 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.
|
//! a file — not a code change, not a shader, and not a new operation.
|
||||||
//!
|
//!
|
||||||
|
|||||||
@@ -680,15 +680,18 @@ fn film_tables() -> FilmTables {
|
|||||||
FilmTables {
|
FilmTables {
|
||||||
exposure_matrix: baked.exposure_matrix,
|
exposure_matrix: baked.exposure_matrix,
|
||||||
curves: baked.curves.clone(),
|
curves: baked.curves.clone(),
|
||||||
|
push_stations: baked.push_stations.clone(),
|
||||||
curve_log_min: baked.curve_log_min,
|
curve_log_min: baked.curve_log_min,
|
||||||
curve_log_max: baked.curve_log_max,
|
curve_log_max: baked.curve_log_max,
|
||||||
lut: baked.lut.clone(),
|
lut: baked.lut.clone(),
|
||||||
density_max: baked.density_max,
|
density_max: baked.density_max,
|
||||||
lut_size: baked.lut_size,
|
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
|
// 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
|
// not part of every edit, and the chain being measured here is "every
|
||||||
// operation active", not "every option of every operation".
|
// 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_density_max: [baked.density_max; 3],
|
||||||
grain_uniformity: 0.97,
|
grain_uniformity: 0.97,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,8 +182,7 @@ fn render_to(
|
|||||||
// and the example never has to know which it was handed.
|
// and the example never has to know which it was handed.
|
||||||
let shader = graph.compose_for(ColourSpace::Srgb);
|
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||||
let scale = graph.render_scale(image.size(), (w, h));
|
let scale = graph.render_scale(image.size(), (w, h));
|
||||||
let detail =
|
let detail = graph.compose_detail(scale.full_size(), scale.render_size());
|
||||||
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
|
|
||||||
let key = graph.invalidation().through(Affects::Colour);
|
let key = graph.invalidation().through(Affects::Colour);
|
||||||
adjust
|
adjust
|
||||||
.render_detailed(image, &shader, w, h, None, &detail, key)
|
.render_detailed(image, &shader, w, h, None, &detail, key)
|
||||||
|
|||||||
+199
-80
@@ -34,16 +34,6 @@ use crate::{DemosaicedImage, GpuContext, GpuError};
|
|||||||
/// reads them.
|
/// reads them.
|
||||||
const RESERVED_FIELDS: usize = dr_pipeline::RESERVED_UNIFORM_FIELDS;
|
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.
|
/// Runs composed operation chains against demosaiced images.
|
||||||
pub struct AdjustPass {
|
pub struct AdjustPass {
|
||||||
ctx: GpuContext,
|
ctx: GpuContext,
|
||||||
@@ -132,6 +122,8 @@ pub struct AdjustPass {
|
|||||||
colour_dispatches: usize,
|
colour_dispatches: usize,
|
||||||
/// Detail dispatches encoded.
|
/// Detail dispatches encoded.
|
||||||
detail_dispatches: usize,
|
detail_dispatches: usize,
|
||||||
|
/// View passes encoded — one per render with a detail stage (D19).
|
||||||
|
view_dispatches: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Target {
|
struct Target {
|
||||||
@@ -382,9 +374,23 @@ fn film_key(t: &dr_pipeline::ops::FilmTables) -> u64 {
|
|||||||
t.curve_log_max,
|
t.curve_log_max,
|
||||||
t.density_max,
|
t.density_max,
|
||||||
t.lut_size as f32,
|
t.lut_size as f32,
|
||||||
|
t.curves.len() as f32,
|
||||||
|
t.lut.len() as f32,
|
||||||
] {
|
] {
|
||||||
mix(v.to_bits());
|
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)) {
|
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));
|
mix(e[0].to_bits() ^ e[1].to_bits().rotate_left(11) ^ e[2].to_bits().rotate_left(22));
|
||||||
}
|
}
|
||||||
@@ -431,12 +437,16 @@ impl AdjustPass {
|
|||||||
return;
|
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(
|
let curves = self.upload_film(
|
||||||
"adjust-film-curves",
|
"adjust-film-curves",
|
||||||
wgpu::TextureDimension::D2,
|
wgpu::TextureDimension::D2,
|
||||||
wgpu::Extent3d {
|
wgpu::Extent3d {
|
||||||
width: t.curves.len() as u32,
|
width: samples,
|
||||||
height: 1,
|
height: t.curves.len() as u32 / samples,
|
||||||
depth_or_array_layers: 1,
|
depth_or_array_layers: 1,
|
||||||
},
|
},
|
||||||
&to_rgba(&t.curves),
|
&to_rgba(&t.curves),
|
||||||
@@ -448,7 +458,7 @@ impl AdjustPass {
|
|||||||
wgpu::Extent3d {
|
wgpu::Extent3d {
|
||||||
width: n,
|
width: n,
|
||||||
height: n,
|
height: n,
|
||||||
depth_or_array_layers: n,
|
depth_or_array_layers: t.lut.len() as u32 / (n * n),
|
||||||
},
|
},
|
||||||
&to_rgba(&t.lut),
|
&to_rgba(&t.lut),
|
||||||
);
|
);
|
||||||
@@ -645,6 +655,7 @@ impl AdjustPass {
|
|||||||
sample: SampleCache::new(ctx),
|
sample: SampleCache::new(ctx),
|
||||||
colour_dispatches: 0,
|
colour_dispatches: 0,
|
||||||
detail_dispatches: 0,
|
detail_dispatches: 0,
|
||||||
|
view_dispatches: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1040,13 +1051,14 @@ impl AdjustPass {
|
|||||||
/// Render one frame with a neighbourhood stage.
|
/// Render one frame with a neighbourhood stage.
|
||||||
///
|
///
|
||||||
/// `shader` and `detail` must be the two halves of **one** composition —
|
/// `shader` and `detail` must be the two halves of **one** composition —
|
||||||
/// `EditGraph::compose_for` and `EditGraph::compose_detail_for` on the same
|
/// `EditGraph::compose_for` and `EditGraph::compose_detail` on the same
|
||||||
/// graph, at the same output space. The fused pass stops at linear working
|
/// graph. The fused pass stops at linear working values when a detail
|
||||||
/// values when a detail stage exists and the last detail pass performs the
|
/// stage exists, and its view pass ([`ComposedShader::view`]) performs the
|
||||||
/// output transform, so a mismatched pair either encodes twice or not at
|
/// view transform and the output transform after the last detail pass
|
||||||
/// all.
|
/// (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
|
/// the honest thing to do rather than an optimisation: an edit with no
|
||||||
/// active sharpening *is* an ordinary edit, and it should cost exactly
|
/// active sharpening *is* an ordinary edit, and it should cost exactly
|
||||||
/// what one costs.
|
/// what one costs.
|
||||||
@@ -1086,17 +1098,25 @@ impl AdjustPass {
|
|||||||
detail: &ComposedDetail,
|
detail: &ComposedDetail,
|
||||||
colour_key: u64,
|
colour_key: u64,
|
||||||
) -> Result<&wgpu::Texture, GpuError> {
|
) -> 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);
|
return self.render_masked(source, shader, width, height, masks);
|
||||||
}
|
}
|
||||||
if shader.output_mode != OutputMode::LinearWorking {
|
let view = match (shader.output_mode, shader.view.as_deref()) {
|
||||||
return Err(GpuError::ShaderCompilation(
|
(OutputMode::LinearWorking, Some(view)) => view,
|
||||||
"this detail chain expects a fused pass composed to hand on \
|
_ => {
|
||||||
linear working values, but the shader given encodes its own \
|
return Err(GpuError::ShaderCompilation(
|
||||||
output; compose both halves from the same graph"
|
"this detail chain expects a fused pass composed to hand on \
|
||||||
.into(),
|
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));
|
let (width, height) = (width.max(1), height.max(1));
|
||||||
self.ensure_target(width, height);
|
self.ensure_target(width, height);
|
||||||
@@ -1109,6 +1129,7 @@ impl AdjustPass {
|
|||||||
// both want `&mut self`, and the second holds its borrow across the
|
// both want `&mut self`, and the second holds its borrow across the
|
||||||
// encode below.
|
// encode below.
|
||||||
self.pipeline(shader)?;
|
self.pipeline(shader)?;
|
||||||
|
self.pipeline(view)?;
|
||||||
let colour_view = self
|
let colour_view = self
|
||||||
.detail
|
.detail
|
||||||
.colour_target(detail.len(), width, height)
|
.colour_target(detail.len(), width, height)
|
||||||
@@ -1199,20 +1220,12 @@ impl AdjustPass {
|
|||||||
self.colour_dispatches += 1;
|
self.colour_dispatches += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// One encoder for the colour pass and every detail pass, submitted
|
// One encoder for the colour pass, every detail pass and the view pass,
|
||||||
// once — the shape `MaskPass::render` established. Submission order is
|
// submitted once — the shape `MaskPass::render` established.
|
||||||
// the whole of the synchronisation: each pass reads what the previous
|
// Submission order is the whole of the synchronisation: each pass
|
||||||
// one wrote, through the same queue.
|
// reads what the previous one wrote, through the same queue.
|
||||||
let target_view = self.targets[self.current]
|
let (ran, result) = match self.detail.encode(&mut enc, detail, width, height) {
|
||||||
.as_ref()
|
Ok(done) => done,
|
||||||
.expect("ensured above")
|
|
||||||
.view
|
|
||||||
.clone();
|
|
||||||
let ran = match self
|
|
||||||
.detail
|
|
||||||
.encode(&mut enc, detail, &target_view, width, height)
|
|
||||||
{
|
|
||||||
Ok(ran) => ran,
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Nothing is submitted, so a cache this frame was to write
|
// Nothing is submitted, so a cache this frame was to write
|
||||||
// holds nothing, and must not be read as though it did.
|
// holds nothing, and must not be read as though it did.
|
||||||
@@ -1222,6 +1235,86 @@ impl AdjustPass {
|
|||||||
return Err(e);
|
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 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.ctx.queue.submit(Some(enc.finish()));
|
||||||
self.detail_dispatches += ran;
|
self.detail_dispatches += ran;
|
||||||
self.colour_key = Some((key, width, height));
|
self.colour_key = Some((key, width, height));
|
||||||
@@ -1257,22 +1350,13 @@ impl AdjustPass {
|
|||||||
// runs. See `DemosaicedImage::is_non_linear`.
|
// runs. See `DemosaicedImage::is_non_linear`.
|
||||||
let non_linear = if source.is_non_linear() { 1.0 } else { 0.0 };
|
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]);
|
uniforms[12..16].copy_from_slice(&[wb[0], wb[1], wb[2], non_linear]);
|
||||||
// TRACES: FR-DEV-3e
|
// TRACES: FR-DSP-2 | NFR-RES-2
|
||||||
// The camera profile's base curve, packed the way the generated block
|
// Which part of the photograph the texture holds. The whole of it for
|
||||||
// declares it: four x, four y, then the fifth point and the flag. The
|
// every source that fits in one texture, which writes back exactly
|
||||||
// flag is what lets one compiled shader serve a profiled body and an
|
// what the composer put there.
|
||||||
// unprofiled one, so the pipeline cache is not split in two by which
|
let w = dr_pipeline::SOURCE_WINDOW_UNIFORM_OFFSET;
|
||||||
// camera took the frame.
|
uniforms[w..w + dr_pipeline::SOURCE_WINDOW_UNIFORM_FIELDS]
|
||||||
//
|
.copy_from_slice(&source.window_uniforms());
|
||||||
// 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]);
|
|
||||||
uniforms
|
uniforms
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1386,6 +1470,13 @@ impl AdjustPass {
|
|||||||
self.detail_dispatches
|
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
|
/// How many linear intermediates have been allocated. For tests: see
|
||||||
/// [`crate::MaskPass::allocations`] for the regression this catches.
|
/// [`crate::MaskPass::allocations`] for the regression this catches.
|
||||||
pub fn detail_allocations(&self) -> usize {
|
pub fn detail_allocations(&self) -> usize {
|
||||||
@@ -1429,9 +1520,9 @@ impl AdjustPass {
|
|||||||
/// one: the storage format is in the layout. The profile uniforms are
|
/// one: the storage format is in the layout. The profile uniforms are
|
||||||
/// filled neutral here rather than from the source, which is the whole
|
/// filled neutral here rather than from the source, which is the whole
|
||||||
/// point of the mode (`OutputMode::CameraLinear`): unit white balance,
|
/// point of the mode (`OutputMode::CameraLinear`): unit white balance,
|
||||||
/// identity matrix, base curve off. The non-linear flag is kept, so a
|
/// identity matrix, and no view transform composed. The non-linear flag
|
||||||
/// JPEG source is still linearised — camera space for a JPEG is the
|
/// is kept, so a JPEG source is still linearised — camera space for a
|
||||||
/// decoded values made linear, which is the best that exists.
|
/// 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
|
/// The texture stays on the device for a merge's warp to sample; see
|
||||||
/// [`Self::camera_texture`] and [`Self::read_camera_linear`].
|
/// [`Self::camera_texture`] and [`Self::read_camera_linear`].
|
||||||
@@ -1460,8 +1551,6 @@ impl AdjustPass {
|
|||||||
uniforms[4..8].copy_from_slice(&[0.0, 1.0, 0.0, 0.0]);
|
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[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]);
|
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
|
let params_buf = self
|
||||||
.ctx
|
.ctx
|
||||||
@@ -1689,7 +1778,7 @@ pub(crate) fn numbered(src: &str) -> String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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::ops::{colour_mixer, exposure, saturation};
|
||||||
use dr_pipeline::EditGraph;
|
use dr_pipeline::EditGraph;
|
||||||
// For `Operation::detail`, which is how `the_whole_chain_at_once_compiles`
|
// For `Operation::detail`, which is how `the_whole_chain_at_once_compiles`
|
||||||
@@ -1727,7 +1816,6 @@ mod tests {
|
|||||||
// Identity, so the test reasons about the operations alone
|
// Identity, so the test reasons about the operations alone
|
||||||
// rather than about a camera's colour response.
|
// 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]),
|
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,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
@@ -1931,7 +2019,6 @@ mod tests {
|
|||||||
white_level: 16383,
|
white_level: 16383,
|
||||||
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
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]),
|
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,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
@@ -2303,7 +2390,9 @@ mod tests {
|
|||||||
lut: vec![[0.5, 0.5, 0.5]; N * N * N],
|
lut: vec![[0.5, 0.5, 0.5]; N * N * N],
|
||||||
density_max: 3.0,
|
density_max: 3.0,
|
||||||
lut_size: N,
|
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_density_max: [3.0; 3],
|
||||||
grain_uniformity: 0.97,
|
grain_uniformity: 0.97,
|
||||||
}
|
}
|
||||||
@@ -2361,7 +2450,7 @@ mod tests {
|
|||||||
// find those operations in neither stage and fail for a reason that is
|
// find those operations in neither stage and fail for a reason that is
|
||||||
// not a defect. Shadows the smaller size deliberately.
|
// not a defect. Shadows the smaller size deliberately.
|
||||||
let (w, h) = g.output_size(512, 512);
|
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!(
|
assert!(
|
||||||
!detail.is_empty(),
|
!detail.is_empty(),
|
||||||
"the detail half composed nothing, so nothing of it was compiled"
|
"the detail half composed nothing, so nothing of it was compiled"
|
||||||
@@ -2381,7 +2470,20 @@ mod tests {
|
|||||||
let mut fused_blocks = 0;
|
let mut fused_blocks = 0;
|
||||||
for desc in g.descriptors() {
|
for desc in g.descriptors() {
|
||||||
let id = desc.id.0;
|
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
|
let neighbourhood = detail
|
||||||
.passes
|
.passes
|
||||||
.iter()
|
.iter()
|
||||||
@@ -2415,8 +2517,15 @@ mod tests {
|
|||||||
// because the two catch different faults: the XOR catches an operation
|
// 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 wrong stage, this catches a block in the shader that nothing
|
||||||
// in the chain asked for.
|
// 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!(
|
assert_eq!(
|
||||||
shader.source.matches("---- ").count(),
|
shader.source.matches("---- ").count() + view_blocks,
|
||||||
fused_blocks + warp_blocks + 1,
|
fused_blocks + warp_blocks + 1,
|
||||||
"the fused shader carries a block nothing in the chain asked for"
|
"the fused shader carries a block nothing in the chain asked for"
|
||||||
);
|
);
|
||||||
@@ -2518,7 +2627,6 @@ mod tests {
|
|||||||
white_level: 16383,
|
white_level: 16383,
|
||||||
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
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]),
|
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,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
@@ -2622,7 +2730,6 @@ mod tests {
|
|||||||
white_level: 16383,
|
white_level: 16383,
|
||||||
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
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]),
|
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,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
@@ -3207,11 +3314,16 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_jpeg_and_sensor_data_agree_on_the_same_scene_value() {
|
fn a_jpeg_and_sensor_data_differ_by_exactly_the_view_transform() {
|
||||||
// The two producers must be interchangeable. A mid-grey that is
|
// TRACES: FR-DEV-3j
|
||||||
// linearly 0.216 (sRGB 128) arriving as sensor data and as a JPEG
|
// The two producers must be interchangeable up to the rendering. A
|
||||||
// must render the same, or an edit would mean different things
|
// mid-grey that is linearly 0.216 (sRGB 128) arriving as sensor data
|
||||||
// depending on which decoder opened the file.
|
// 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 Some(ctx) = ctx() else { return };
|
||||||
let mut pass = AdjustPass::new(&ctx);
|
let mut pass = AdjustPass::new(&ctx);
|
||||||
let shader = EditGraph::default_chain().compose();
|
let shader = EditGraph::default_chain().compose();
|
||||||
@@ -3230,11 +3342,18 @@ mod tests {
|
|||||||
read_centre(&ctx, t)
|
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!(
|
assert!(
|
||||||
delta <= 3,
|
delta <= 3,
|
||||||
"the same scene value rendered {from_sensor:?} from sensor data \
|
"sensor data rendered {from_sensor:?}, expected about {expected}"
|
||||||
and {from_jpeg:?} from a JPEG"
|
);
|
||||||
|
let delta = (i32::from(from_jpeg[0]) - 128).abs();
|
||||||
|
assert!(
|
||||||
|
delta <= 3,
|
||||||
|
"a JPEG was rendered again: {from_jpeg:?} from sRGB 128"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+407
-44
@@ -10,7 +10,7 @@
|
|||||||
//! pass over this texture; it does not re-demosaic, which is what keeps the
|
//! pass over this texture; it does not re-demosaic, which is what keeps the
|
||||||
//! interaction budget (NFR-P9) reachable on a 24 MP file.
|
//! 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 wgpu::util::DeviceExt;
|
||||||
|
|
||||||
use crate::{GpuContext, GpuError};
|
use crate::{GpuContext, GpuError};
|
||||||
@@ -57,6 +57,32 @@ struct XTransParams {
|
|||||||
tile: [u32; 4],
|
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.
|
/// A demosaiced image living on the GPU.
|
||||||
///
|
///
|
||||||
/// RGBA16Float, scene-referred, camera colour space. This is the input every
|
/// RGBA16Float, scene-referred, camera colour space. This is the input every
|
||||||
@@ -83,23 +109,25 @@ pub struct DemosaicedImage {
|
|||||||
color_matrix: [f32; 9],
|
color_matrix: [f32; 9],
|
||||||
/// As-shot white balance, the neutral starting point for the WB control.
|
/// As-shot white balance, the neutral starting point for the WB control.
|
||||||
as_shot_wb: [f32; 3],
|
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.
|
/// Whether the texture holds gamma-encoded rather than linear values.
|
||||||
non_linear: bool,
|
non_linear: bool,
|
||||||
/// Which upload this is, unique for the life of the process. See
|
/// Which upload this is, unique for the life of the process. See
|
||||||
/// [`Self::id`].
|
/// [`Self::id`].
|
||||||
id: u64,
|
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`].
|
/// The next [`DemosaicedImage::id`].
|
||||||
fn next_image_id() -> u64 {
|
fn next_image_id() -> u64 {
|
||||||
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||||
@@ -117,10 +145,50 @@ impl DemosaicedImage {
|
|||||||
&self.view
|
&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) {
|
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)
|
(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.
|
/// 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
|
/// For a cache that has to know it is still looking at the same pixels
|
||||||
@@ -139,15 +207,6 @@ impl DemosaicedImage {
|
|||||||
self.color_matrix
|
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.
|
/// As-shot white balance multipliers, green-normalised.
|
||||||
///
|
///
|
||||||
/// The white balance control is expressed *relative* to these, so its
|
/// The white balance control is expressed *relative* to these, so its
|
||||||
@@ -261,13 +320,15 @@ impl DemosaicedImage {
|
|||||||
color_matrix: IDENTITY_3X3,
|
color_matrix: IDENTITY_3X3,
|
||||||
as_shot_wb: [1.0, 1.0, 1.0],
|
as_shot_wb: [1.0, 1.0, 1.0],
|
||||||
// **The identity, and this is the whole reason the field is here
|
// **The identity, and this is the whole reason the field is here
|
||||||
// rather than resolved further down.** A JPEG has already had its
|
// rather than resolved further down.** A JPEG has already been
|
||||||
// camera's base curve baked in by the camera; applying one again
|
// rendered by the camera; the view transform skips a source
|
||||||
// would render the rendering, crushing the shadows and flattening
|
// flagged non-linear, since rendering the rendering would crush
|
||||||
// the highlights of an image that was already finished.
|
// the shadows and flatten the highlights of an image that was
|
||||||
base_curve: BaseCurve::IDENTITY,
|
// already finished.
|
||||||
non_linear: true,
|
non_linear: true,
|
||||||
id: next_image_id(),
|
id: next_image_id(),
|
||||||
|
frame: (width, height),
|
||||||
|
window: WHOLE_FRAME,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -278,11 +339,46 @@ impl DemosaicedImage {
|
|||||||
/// what a merge writes. No demosaic; the samples are normalised by the
|
/// what a merge writes. No demosaic; the samples are normalised by the
|
||||||
/// file's black and white levels exactly as the demosaic kernel would
|
/// file's black and white levels exactly as the demosaic kernel would
|
||||||
/// normalise a photosite, and everything else — the matrix, the
|
/// 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
|
/// file, because the composite is developed as one photograph from the
|
||||||
/// body that took its sources.
|
/// body that took its sources.
|
||||||
pub fn from_linear_rgb16(ctx: &GpuContext, raw: &RawImage) -> Result<Self, GpuError> {
|
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));
|
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();
|
let limits = ctx.device.limits();
|
||||||
if width > limits.max_texture_dimension_2d || height > limits.max_texture_dimension_2d {
|
if width > limits.max_texture_dimension_2d || height > limits.max_texture_dimension_2d {
|
||||||
return Err(GpuError::TooLarge(format!(
|
return Err(GpuError::TooLarge(format!(
|
||||||
@@ -302,19 +398,49 @@ impl DemosaicedImage {
|
|||||||
}
|
}
|
||||||
let black = black_per_cell(raw);
|
let black = black_per_cell(raw);
|
||||||
let inv = inv_range_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);
|
// One output row per task: a 200-megapixel reduction is a second of
|
||||||
for y in 0..height as usize {
|
// one core, and the rows are independent.
|
||||||
let row = (raw.crop.y as usize + y) * stride + raw.crop.x as usize * 3;
|
let row_texels = width as usize * 4;
|
||||||
for x in 0..width as usize {
|
let mut half = vec![0u16; row_texels * height as usize];
|
||||||
let p = &raw.data[row + x * 3..row + x * 3 + 3];
|
let fill_row = |ty: usize, out: &mut [u16]| {
|
||||||
for c in 0..3 {
|
let sy0 = y0 as usize + ty * k as usize;
|
||||||
let v = (f32::from(p[c]) - black[c]) * inv[c];
|
let sy1 = (sy0 + k as usize).min((y0 + rh) as usize);
|
||||||
half.push(f32_to_f16_bits_unclamped(v));
|
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(
|
let texture = ctx.device.create_texture_with_data(
|
||||||
&ctx.queue,
|
&ctx.queue,
|
||||||
&wgpu::TextureDescriptor {
|
&wgpu::TextureDescriptor {
|
||||||
@@ -335,6 +461,17 @@ impl DemosaicedImage {
|
|||||||
bytemuck::cast_slice(&half),
|
bytemuck::cast_slice(&half),
|
||||||
);
|
);
|
||||||
let view = texture.create_view(&Default::default());
|
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 {
|
Ok(Self {
|
||||||
texture,
|
texture,
|
||||||
view,
|
view,
|
||||||
@@ -342,9 +479,10 @@ impl DemosaicedImage {
|
|||||||
height,
|
height,
|
||||||
color_matrix: raw.color_matrix.unwrap_or(IDENTITY_3X3),
|
color_matrix: raw.color_matrix.unwrap_or(IDENTITY_3X3),
|
||||||
as_shot_wb: [raw.wb_coeffs[0], raw.wb_coeffs[1], raw.wb_coeffs[2]],
|
as_shot_wb: [raw.wb_coeffs[0], raw.wb_coeffs[1], raw.wb_coeffs[2]],
|
||||||
base_curve: raw.base_curve,
|
|
||||||
non_linear: false,
|
non_linear: false,
|
||||||
id: next_image_id(),
|
id: next_image_id(),
|
||||||
|
frame,
|
||||||
|
window,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -437,6 +575,8 @@ pub struct Demosaicer {
|
|||||||
pipeline: wgpu::ComputePipeline,
|
pipeline: wgpu::ComputePipeline,
|
||||||
xtrans_pipeline: wgpu::ComputePipeline,
|
xtrans_pipeline: wgpu::ComputePipeline,
|
||||||
bind_group_layout: wgpu::BindGroupLayout,
|
bind_group_layout: wgpu::BindGroupLayout,
|
||||||
|
hot_pixel_pipeline: wgpu::ComputePipeline,
|
||||||
|
hot_pixel_layout: wgpu::BindGroupLayout,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Demosaicer {
|
impl Demosaicer {
|
||||||
@@ -527,11 +667,15 @@ impl Demosaicer {
|
|||||||
cache: None,
|
cache: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let (hot_pixel_pipeline, hot_pixel_layout) = hot_pixel_pipeline(ctx);
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
ctx: ctx.clone(),
|
ctx: ctx.clone(),
|
||||||
pipeline,
|
pipeline,
|
||||||
xtrans_pipeline,
|
xtrans_pipeline,
|
||||||
bind_group_layout,
|
bind_group_layout,
|
||||||
|
hot_pixel_pipeline,
|
||||||
|
hot_pixel_layout,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -558,8 +702,12 @@ impl Demosaicer {
|
|||||||
// the buffer outlive the `if` that chose them.
|
// the buffer outlive the `if` that chose them.
|
||||||
let bayer_params;
|
let bayer_params;
|
||||||
let xtrans_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() {
|
let (pipeline, params_bytes) = if raw.cfa_pattern.is_xtrans() {
|
||||||
xtrans_params = xtrans_params_for(raw, width, height);
|
xtrans_params = xtrans_params_for(raw, width, height);
|
||||||
|
xtrans_tile = Some(xtrans_params.tile);
|
||||||
(&self.xtrans_pipeline, bytemuck::bytes_of(&xtrans_params))
|
(&self.xtrans_pipeline, bytemuck::bytes_of(&xtrans_params))
|
||||||
} else {
|
} else {
|
||||||
let pattern = match raw.cfa_pattern {
|
let pattern = match raw.cfa_pattern {
|
||||||
@@ -598,6 +746,64 @@ impl Demosaicer {
|
|||||||
usage: wgpu::BufferUsages::STORAGE,
|
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
|
let params_buf = self
|
||||||
.ctx
|
.ctx
|
||||||
.device
|
.device
|
||||||
@@ -636,7 +842,7 @@ impl Demosaicer {
|
|||||||
entries: &[
|
entries: &[
|
||||||
wgpu::BindGroupEntry {
|
wgpu::BindGroupEntry {
|
||||||
binding: 0,
|
binding: 0,
|
||||||
resource: raw_buf.as_entire_binding(),
|
resource: repaired.as_entire_binding(),
|
||||||
},
|
},
|
||||||
wgpu::BindGroupEntry {
|
wgpu::BindGroupEntry {
|
||||||
binding: 1,
|
binding: 1,
|
||||||
@@ -655,6 +861,18 @@ impl Demosaicer {
|
|||||||
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||||
label: Some("demosaic-encoder"),
|
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 {
|
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||||
label: Some("demosaic-pass"),
|
label: Some("demosaic-pass"),
|
||||||
@@ -678,12 +896,13 @@ impl Demosaicer {
|
|||||||
// Whatever the profile database had for this body (FR-DEV-3e),
|
// Whatever the profile database had for this body (FR-DEV-3e),
|
||||||
// resolved at decode because that is the only place the make and
|
// resolved at decode because that is the only place the make and
|
||||||
// model are known.
|
// model are known.
|
||||||
base_curve: raw.base_curve,
|
|
||||||
// Sensor data is linear by construction — the demosaic shader
|
// Sensor data is linear by construction — the demosaic shader
|
||||||
// normalises against black and white levels and applies no
|
// normalises against black and white levels and applies no
|
||||||
// transfer function.
|
// transfer function.
|
||||||
non_linear: false,
|
non_linear: false,
|
||||||
id: next_image_id(),
|
id: next_image_id(),
|
||||||
|
frame: (width, height),
|
||||||
|
window: WHOLE_FRAME,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -960,6 +1179,136 @@ fn detect_xtrans_phase(raw: &RawImage) -> (u32, u32) {
|
|||||||
|
|
||||||
/// TRACES: FR-RAW-5
|
/// TRACES: FR-RAW-5
|
||||||
/// Everything the X-Trans shader needs about one image.
|
/// 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 {
|
fn xtrans_params_for(raw: &RawImage, width: u32, height: u32) -> XTransParams {
|
||||||
let (black, inv_range) = xtrans_levels(raw);
|
let (black, inv_range) = xtrans_levels(raw);
|
||||||
let wb = wb_gains(raw);
|
let wb = wb_gains(raw);
|
||||||
@@ -994,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]
|
#[test]
|
||||||
fn unclamped_half_keeps_shadows_signs_and_highlights() {
|
fn unclamped_half_keeps_shadows_signs_and_highlights() {
|
||||||
// A 14-bit LSB, normalised: subnormal in f16, and must not be zero.
|
// A 14-bit LSB, normalised: subnormal in f16, and must not be zero.
|
||||||
@@ -1030,7 +1397,6 @@ mod tests {
|
|||||||
white_level: white,
|
white_level: white,
|
||||||
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
||||||
color_matrix: None,
|
color_matrix: None,
|
||||||
base_curve: BaseCurve::IDENTITY,
|
|
||||||
samples_per_pixel: 1,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
@@ -1146,7 +1512,6 @@ mod tests {
|
|||||||
white_level: white,
|
white_level: white,
|
||||||
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
||||||
color_matrix: None,
|
color_matrix: None,
|
||||||
base_curve: BaseCurve::IDENTITY,
|
|
||||||
samples_per_pixel: 1,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
@@ -1443,7 +1808,6 @@ mod tests {
|
|||||||
white_level: 16383,
|
white_level: 16383,
|
||||||
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
||||||
color_matrix: None,
|
color_matrix: None,
|
||||||
base_curve: BaseCurve::IDENTITY,
|
|
||||||
samples_per_pixel: 1,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
@@ -1528,7 +1892,6 @@ mod tests {
|
|||||||
1.0,
|
1.0,
|
||||||
],
|
],
|
||||||
color_matrix: None,
|
color_matrix: None,
|
||||||
base_curve: BaseCurve::IDENTITY,
|
|
||||||
samples_per_pixel: 1,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
|
|||||||
+32
-46
@@ -43,12 +43,12 @@
|
|||||||
//! dispatch is skipped, and dragging a sharpening slider costs the detail
|
//! dispatch is skipped, and dragging a sharpening slider costs the detail
|
||||||
//! passes alone (FR-DEV-3d).
|
//! passes alone (FR-DEV-3d).
|
||||||
//!
|
//!
|
||||||
//! The remaining passes alternate between slots 1 and 2, and the last one
|
//! The passes alternate between slots 1 and 2, the last one included: since
|
||||||
//! writes the display texture directly rather than an intermediate — so a
|
//! D19 it hands its result to the adjust pass's **view pass**, which performs
|
||||||
//! chain of *N* passes costs *N* dispatches and not *N* + 1, and there is no
|
//! the view transform and the output transform after every kernel, so no
|
||||||
//! resolve pass to pay for. That leaves the allocation at `1 + min(N-1, 2)`
|
//! detail pass writes the display texture. A chain of *N* passes costs *N*
|
||||||
//! textures: one for a single-pass operation, two for a separable blur, three
|
//! dispatches plus that one, and the allocation is `1 + min(N, 2)` textures.
|
||||||
//! however long the chain gets after that.
|
//! An empty chain costs the view pass alone, reading slot 0.
|
||||||
//!
|
//!
|
||||||
//! # The reduced chain, and why a second one was needed
|
//! # 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.
|
/// intermediate against a fresh colour result and never be told.
|
||||||
pub(crate) struct DetailRunner {
|
pub(crate) struct DetailRunner {
|
||||||
ctx: GpuContext,
|
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,
|
to_linear: Layout,
|
||||||
/// Layout for the last pass, which writes the display texture.
|
|
||||||
to_output: Layout,
|
|
||||||
/// Compiled pipelines by pass structure hash.
|
/// Compiled pipelines by pass structure hash.
|
||||||
cache: HashMap<u64, wgpu::ComputePipeline>,
|
cache: HashMap<u64, wgpu::ComputePipeline>,
|
||||||
pool: Intermediates,
|
pool: Intermediates,
|
||||||
@@ -255,7 +254,6 @@ impl DetailRunner {
|
|||||||
Self {
|
Self {
|
||||||
ctx: ctx.clone(),
|
ctx: ctx.clone(),
|
||||||
to_linear: Layout::new(ctx, INTERMEDIATE_FORMAT, "detail-linear"),
|
to_linear: Layout::new(ctx, INTERMEDIATE_FORMAT, "detail-linear"),
|
||||||
to_output: Layout::new(ctx, crate::AdjustPass::FORMAT, "detail-output"),
|
|
||||||
cache: HashMap::new(),
|
cache: HashMap::new(),
|
||||||
pool: Intermediates::new(),
|
pool: Intermediates::new(),
|
||||||
reduced: Intermediates::new(),
|
reduced: Intermediates::new(),
|
||||||
@@ -274,27 +272,30 @@ impl DetailRunner {
|
|||||||
width: u32,
|
width: u32,
|
||||||
height: u32,
|
height: u32,
|
||||||
) -> &wgpu::TextureView {
|
) -> &wgpu::TextureView {
|
||||||
// One for the colour pass's result, then one per hand-off between
|
// One for the colour pass's result, then one per pass, capped at two
|
||||||
// detail passes, capped at two because a ping-pong needs no more: the
|
// because a ping-pong needs no more. The last pass writes an
|
||||||
// last pass writes the display texture rather than an intermediate.
|
// intermediate like the others since D19 — the view pass reads it —
|
||||||
let needed = 1 + passes.saturating_sub(1).min(2);
|
// 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.ensure(&self.ctx, needed, width, height);
|
||||||
&self.pool.slots[0].view
|
&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
|
/// The caller must already have run the fused colour pass into
|
||||||
/// [`Self::colour_target`] — or established that a previous frame's is
|
/// [`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(
|
pub(crate) fn encode(
|
||||||
&mut self,
|
&mut self,
|
||||||
encoder: &mut wgpu::CommandEncoder,
|
encoder: &mut wgpu::CommandEncoder,
|
||||||
chain: &ComposedDetail,
|
chain: &ComposedDetail,
|
||||||
output: &wgpu::TextureView,
|
|
||||||
width: u32,
|
width: u32,
|
||||||
height: u32,
|
height: u32,
|
||||||
) -> Result<usize, GpuError> {
|
) -> Result<(usize, wgpu::TextureView), GpuError> {
|
||||||
for pass in &chain.passes {
|
for pass in &chain.passes {
|
||||||
self.compile(pass)?;
|
self.compile(pass)?;
|
||||||
}
|
}
|
||||||
@@ -340,17 +341,15 @@ impl DetailRunner {
|
|||||||
for pass in chain.passes.iter() {
|
for pass in chain.passes.iter() {
|
||||||
let scaled = pass.output_scale > 1;
|
let scaled = pass.output_scale > 1;
|
||||||
|
|
||||||
// The last pass carries the output transform into the display
|
// The last pass hands the view pass its input, which is read at
|
||||||
// texture, which is the render size by definition. A scaled pass
|
// the render size by definition. A scaled pass there would leave
|
||||||
// there would bind a shader dispatching over a quarter-size grid
|
// the result in the reduced chain and the view pass would read the
|
||||||
// to a full-size target and write a quarter of the picture — a
|
// full-size slot before it — a wrong image rather than a
|
||||||
// wrong image rather than a validation failure, so it is caught
|
// validation failure, so it is caught here and named.
|
||||||
// here and named.
|
if scaled && std::ptr::eq(pass, chain.passes.last().expect("iterating")) {
|
||||||
if scaled && pass.writes_output {
|
|
||||||
return Err(GpuError::ShaderCompilation(format!(
|
return Err(GpuError::ShaderCompilation(format!(
|
||||||
"detail pass {} declares output_scale {} and is last in \
|
"detail pass {} declares output_scale {} and is last in \
|
||||||
the chain; the output transform is written at the render \
|
the chain; the view pass reads the render size",
|
||||||
size",
|
|
||||||
pass.label, pass.output_scale
|
pass.label, pass.output_scale
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
@@ -365,17 +364,14 @@ impl DetailRunner {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Read what the previous pass in *this pass's own chain* wrote;
|
// 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
|
// write the next slot of it. Alternating slots is what stops a pass reading the
|
||||||
// last pass. Alternating slots is what stops a pass reading the
|
|
||||||
// texture it is writing — on a compute pass that is not an error
|
// texture it is writing — on a compute pass that is not an error
|
||||||
// the driver reports, merely a picture that depends on scheduling.
|
// the driver reports, merely a picture that depends on scheduling.
|
||||||
let source = match (scaled, carried) {
|
let source = match (scaled, carried) {
|
||||||
(true, Some(slot)) => &self.reduced.slots[slot].view,
|
(true, Some(slot)) => &self.reduced.slots[slot].view,
|
||||||
_ => &self.pool.slots[full].view,
|
_ => &self.pool.slots[full].view,
|
||||||
};
|
};
|
||||||
let destination = if pass.writes_output {
|
let destination = if scaled {
|
||||||
output
|
|
||||||
} else if scaled {
|
|
||||||
&self.reduced.slots[reduced_writes % 2].view
|
&self.reduced.slots[reduced_writes % 2].view
|
||||||
} else {
|
} else {
|
||||||
&self.pool.slots[1 + (full_writes % 2)].view
|
&self.pool.slots[1 + (full_writes % 2)].view
|
||||||
@@ -387,11 +383,7 @@ impl DetailRunner {
|
|||||||
Some(slot) => &self.reduced.slots[slot].view,
|
Some(slot) => &self.reduced.slots[slot].view,
|
||||||
None => &self.no_reduced,
|
None => &self.no_reduced,
|
||||||
};
|
};
|
||||||
let layout = if pass.writes_output {
|
let layout = &self.to_linear;
|
||||||
&self.to_output
|
|
||||||
} else {
|
|
||||||
&self.to_linear
|
|
||||||
};
|
|
||||||
|
|
||||||
let params = self
|
let params = self
|
||||||
.ctx
|
.ctx
|
||||||
@@ -464,9 +456,7 @@ impl DetailRunner {
|
|||||||
compute.dispatch_workgroups(dispatch_w.div_ceil(8), dispatch_h.div_ceil(8), 1);
|
compute.dispatch_workgroups(dispatch_w.div_ceil(8), dispatch_h.div_ceil(8), 1);
|
||||||
drop(compute);
|
drop(compute);
|
||||||
|
|
||||||
if pass.writes_output {
|
if scaled {
|
||||||
// Nothing downstream to hand anything to.
|
|
||||||
} else if scaled {
|
|
||||||
carried = Some(reduced_writes % 2);
|
carried = Some(reduced_writes % 2);
|
||||||
reduced_writes += 1;
|
reduced_writes += 1;
|
||||||
} else {
|
} 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.
|
/// 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()),
|
source: wgpu::ShaderSource::Wgsl(pass.source.as_str().into()),
|
||||||
});
|
});
|
||||||
|
|
||||||
let layout = if pass.writes_output {
|
let layout = &self.to_linear;
|
||||||
&self.to_output
|
|
||||||
} else {
|
|
||||||
&self.to_linear
|
|
||||||
};
|
|
||||||
|
|
||||||
let pipeline = self
|
let pipeline = self
|
||||||
.ctx
|
.ctx
|
||||||
|
|||||||
@@ -928,7 +928,7 @@ impl MaskPass {
|
|||||||
// the only readers and they are skipped in that case.
|
// the only readers and they are skipped in that case.
|
||||||
let source_step = match source {
|
let source_step = match source {
|
||||||
Some(image) => {
|
Some(image) => {
|
||||||
let (sw, sh) = image.size();
|
let (sw, sh) = image.texture_size();
|
||||||
[
|
[
|
||||||
sw as f32 / width.max(1) as f32,
|
sw as f32 / width.max(1) as f32,
|
||||||
sh as f32 / height.max(1) as f32,
|
sh as f32 / height.max(1) as f32,
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ pub struct MergeOutput {
|
|||||||
pub chunk: (u32, u32),
|
pub chunk: (u32, u32),
|
||||||
/// Multiplies a normalised sample (1.0 = white) to the sensor's scale.
|
/// Multiplies a normalised sample (1.0 = white) to the sensor's scale.
|
||||||
pub sample_scale: f32,
|
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 {
|
impl MergeOutput {
|
||||||
@@ -109,7 +113,8 @@ struct WarpParams {
|
|||||||
tile_origin: [f32; 2],
|
tile_origin: [f32; 2],
|
||||||
tile_size: [u32; 2],
|
tile_size: [u32; 2],
|
||||||
feather: f32,
|
feather: f32,
|
||||||
_pad: f32,
|
clip_onset: f32,
|
||||||
|
balance: [f32; 4],
|
||||||
}
|
}
|
||||||
|
|
||||||
#[repr(C)]
|
#[repr(C)]
|
||||||
@@ -334,7 +339,13 @@ impl MergePass {
|
|||||||
tile_origin: [rect.0 as f32, rect.1 as f32],
|
tile_origin: [rect.0 as f32, rect.1 as f32],
|
||||||
tile_size: [rect.2, rect.3],
|
tile_size: [rect.2, rect.3],
|
||||||
feather: output.feather,
|
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,
|
||||||
|
],
|
||||||
};
|
};
|
||||||
self.accumulate(¶ms, tile);
|
self.accumulate(¶ms, tile);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,8 +28,8 @@
|
|||||||
//! than leaving the specification and the code silently disagreeing.
|
//! than leaving the specification and the code silently disagreeing.
|
||||||
//!
|
//!
|
||||||
//! That texture is the right one on the merits. It is camera-native: no white
|
//! 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,
|
//! balance has been applied, no camera matrix, no tone curve, no view
|
||||||
//! no output transform. It is normalised by the sensor's own black and white
|
//! 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
|
//! 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
|
//! *is* the headroom question, with no calibration to carry and no origin to
|
||||||
//! choose.
|
//! choose.
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ impl SegmentPass {
|
|||||||
source: &DemosaicedImage,
|
source: &DemosaicedImage,
|
||||||
opts: SegmentOptions,
|
opts: SegmentOptions,
|
||||||
) -> Result<Segmentation, GpuError> {
|
) -> 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 (width, height) = proxy_size(src_w, src_h, opts.max_edge);
|
||||||
let n = (width * height) as u64;
|
let n = (width * height) as u64;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -46,7 +46,10 @@ struct Params {
|
|||||||
tile_size: vec2<u32>,
|
tile_size: vec2<u32>,
|
||||||
// Pixels over which the weight ramps from the edge to full.
|
// Pixels over which the weight ramps from the edge to full.
|
||||||
feather: f32,
|
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>,
|
||||||
};
|
};
|
||||||
|
|
||||||
@group(0) @binding(0) var<uniform> p: Params;
|
@group(0) @binding(0) var<uniform> p: Params;
|
||||||
@@ -125,7 +128,19 @@ fn warp(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Colour is the alpha-weighted mean of the texels that exist.
|
// 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 wa = w * s.a;
|
||||||
let i = gid.y * p.chunk_size.x + gid.x;
|
let i = gid.y * p.chunk_size.x + gid.x;
|
||||||
acc[i] = acc[i] + vec4<f32>(rgb * wa, wa);
|
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
|
// 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
|
// 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
|
// 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
|
// exactly one place — the axis — and everything else here is deliberately the
|
||||||
// same construction, because the two reductions have the same shape and any
|
// same construction, because the two reductions have the same shape and any
|
||||||
|
|||||||
@@ -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> {
|
fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, out: u32) -> Vec<u8> {
|
||||||
let shader = graph.compose_for(ColourSpace::Srgb);
|
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||||
let scale = graph.render_scale(source.size(), (out, out));
|
let scale = graph.render_scale(source.size(), (out, out));
|
||||||
let detail =
|
let detail = graph.compose_detail(scale.full_size(), scale.render_size());
|
||||||
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
|
|
||||||
let key = graph.invalidation().through(Affects::Colour);
|
let key = graph.invalidation().through(Affects::Colour);
|
||||||
pass.render_detailed(source, &shader, out, out, None, &detail, key)
|
pass.render_detailed(source, &shader, out, out, None, &detail, key)
|
||||||
.expect("render");
|
.expect("render");
|
||||||
@@ -413,7 +412,7 @@ fn dragging_the_amount_recompiles_nothing_and_reallocates_nothing() {
|
|||||||
let pipelines = pass.cached_detail_pipelines();
|
let pipelines = pass.cached_detail_pipelines();
|
||||||
let allocations = pass.detail_allocations();
|
let allocations = pass.detail_allocations();
|
||||||
assert_eq!(pipelines, 2, "one per axis of the separable mask");
|
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.detail_dispatches(), 2);
|
||||||
assert_eq!(pass.colour_dispatches(), 1);
|
assert_eq!(pass.colour_dispatches(), 1);
|
||||||
|
|
||||||
@@ -453,13 +452,12 @@ fn dragging_the_amount_recompiles_nothing_and_reallocates_nothing() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_render_too_coarse_for_the_radius_still_reaches_the_screen() {
|
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
|
// Proved on a device rather than argued about. With the radius finer than
|
||||||
// device rather than argued about. With the radius finer than a render
|
// a render pixel the operation declines to sharpen — but it is still
|
||||||
// pixel the operation declines to sharpen — but it is still active, so the
|
// active, so the fused pass has already been composed to hand on
|
||||||
// fused pass has already been composed to hand on unclipped linear values,
|
// unclipped linear values, and something must still perform the output
|
||||||
// and something must still perform the output transform. An empty chain
|
// transform. Since D19 that is the view pass, whatever the chain holds:
|
||||||
// here would not be a soft preview: it would be a hard error out of
|
// the chain is empty and the frame is still whole.
|
||||||
// `render_detailed`, on the most ordinary develop view there is.
|
|
||||||
let Some(ctx) = ctx() else { return };
|
let Some(ctx) = ctx() else { return };
|
||||||
const SOURCE: u32 = 128;
|
const SOURCE: u32 = 128;
|
||||||
const RENDER: u32 = 32; // a quarter scale, as a fit view of a large frame
|
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 mut pass = AdjustPass::new(&ctx);
|
||||||
let sharp = render(&mut pass, &graph, &source, RENDER);
|
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
|
// And what reaches the screen is the unsharpened picture, not a black
|
||||||
// frame, a linear one, or a guess.
|
// 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")
|
DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A pass that sums the instance list into the red channel and writes the
|
/// A pass that sums the instance list into the red channel and writes a
|
||||||
/// output. Deliberately trivial: the value on screen is then a direct readout
|
/// linear intermediate, which the view pass then encodes (D19). Deliberately
|
||||||
/// of what arrived in the buffer.
|
/// 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 {
|
fn summing_pass(storage: Vec<[f32; 4]>, structure: u64) -> ComposedDetailPass {
|
||||||
let source = "
|
let source = "
|
||||||
@group(0) @binding(0) var source: texture_2d<f32>;
|
@group(0) @binding(0) var source: texture_2d<f32>;
|
||||||
struct Params { detail_base: vec4<f32> }
|
struct Params { detail_base: vec4<f32> }
|
||||||
@group(0) @binding(1) var<uniform> u: Params;
|
@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>>;
|
@group(0) @binding(3) var<storage, read> instances: array<vec4<f32>>;
|
||||||
|
|
||||||
@compute @workgroup_size(8, 8, 1)
|
@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) {
|
for (var i = 0u; i < n; i = i + 1u) {
|
||||||
total = total + instances[i].x * f32(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();
|
.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],
|
uniforms: vec![SIZE as f32, SIZE as f32, 1.0, 0.0],
|
||||||
storage,
|
storage,
|
||||||
radius: 0,
|
radius: 0,
|
||||||
writes_output: true,
|
|
||||||
// Any distinct number: the hash is a cache key, and these tests are
|
// Any distinct number: the hash is a cache key, and these tests are
|
||||||
// what decide whether two chains share a pipeline.
|
// what decide whether two chains share a pipeline.
|
||||||
structure_hash: structure,
|
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> {
|
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
|
// 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
|
// 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 pixels = render(&mut pass, &source, &chain);
|
||||||
let (red, green) = (pixels[0], pixels[1]);
|
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!(
|
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"
|
"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
|
/// 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);
|
let pixels = render(&mut pass, &source, &chain);
|
||||||
assert_eq!(pixels[0], 0, "the placeholder is zeroed");
|
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:
|
/// The property that makes placing the tenth spot as cheap as moving a slider:
|
||||||
|
|||||||
@@ -101,8 +101,7 @@ fn render(
|
|||||||
let _ = ctx;
|
let _ = ctx;
|
||||||
let shader = graph.compose_for(ColourSpace::Srgb);
|
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||||
let scale = graph.render_scale(source.size(), (out, out));
|
let scale = graph.render_scale(source.size(), (out, out));
|
||||||
let detail =
|
let detail = graph.compose_detail(scale.full_size(), scale.render_size());
|
||||||
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
|
|
||||||
let key = graph.invalidation().through(Affects::Colour);
|
let key = graph.invalidation().through(Affects::Colour);
|
||||||
pass.render_detailed(source, &shader, out, out, None, &detail, key)
|
pass.render_detailed(source, &shader, out, out, None, &detail, key)
|
||||||
.expect("render");
|
.expect("render");
|
||||||
@@ -301,7 +300,7 @@ fn dragging_a_slider_recompiles_nothing_and_reallocates_nothing() {
|
|||||||
let pipelines = pass.cached_detail_pipelines();
|
let pipelines = pass.cached_detail_pipelines();
|
||||||
let allocations = pass.detail_allocations();
|
let allocations = pass.detail_allocations();
|
||||||
assert_eq!(pipelines, 2, "one per pass of the separable blur");
|
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] {
|
for radius in [0.06, 0.07, 0.08, 0.09] {
|
||||||
graph.set_param(PROBE, RADIUS, radius);
|
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 shader = graph.compose_for(ColourSpace::Srgb);
|
||||||
let scale = graph.render_scale((SIZE, SIZE), (SIZE, SIZE));
|
let scale = graph.render_scale((SIZE, SIZE), (SIZE, SIZE));
|
||||||
let detail =
|
let detail = graph.compose_detail(scale.full_size(), scale.render_size());
|
||||||
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
|
|
||||||
assert!(detail.is_empty());
|
assert!(detail.is_empty());
|
||||||
|
|
||||||
pass.render_detailed(&source, &shader, SIZE, SIZE, None, &detail, 0)
|
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
|
//! model is checked against the reference, and the shader is checked against
|
||||||
//! the CPU model.
|
//! the CPU model.
|
||||||
|
|
||||||
use dr_decode::{BaseCurve, CfaPattern, CropRect, RawImage};
|
use dr_decode::{CfaPattern, CropRect, RawImage};
|
||||||
use dr_film::bake::{bake, Recipe};
|
use dr_film::bake::{bake, Recipe, Settings};
|
||||||
use dr_gpu::{AdjustPass, Demosaicer, GpuContext};
|
use dr_gpu::{AdjustPass, Demosaicer, GpuContext, LabelField, MaskPass};
|
||||||
use dr_pipeline::ops::FilmTables;
|
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;
|
use dr_pipeline::EditGraph;
|
||||||
|
|
||||||
const SIZE: u32 = 16;
|
const SIZE: u32 = 16;
|
||||||
@@ -45,7 +48,6 @@ fn flat_raw(level: u16) -> RawImage {
|
|||||||
// Off deliberately: a film replaces the camera's rendering, and
|
// Off deliberately: a film replaces the camera's rendering, and
|
||||||
// leaving a curve here would test the suppression rather than the
|
// leaving a curve here would test the suppression rather than the
|
||||||
// film. `dr-pipeline` asserts the suppression on the generated source.
|
// film. `dr-pipeline` asserts the suppression on the generated source.
|
||||||
base_curve: BaseCurve::IDENTITY,
|
|
||||||
samples_per_pixel: 1,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
make: String::new(),
|
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
|
/// split a grain that never left the CPU would look exactly like a passing
|
||||||
/// test suite.
|
/// test suite.
|
||||||
fn tables_with_grain(baked: &dr_film::Baked, particles: [f32; 3]) -> FilmTables {
|
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 {
|
FilmTables {
|
||||||
exposure_matrix: baked.exposure_matrix,
|
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_min: baked.curve_log_min,
|
||||||
curve_log_max: baked.curve_log_max,
|
curve_log_max: baked.curve_log_max,
|
||||||
lut: baked.lut.clone(),
|
lut,
|
||||||
density_max: baked.density_max,
|
density_max: baked.density_max,
|
||||||
lut_size: baked.lut_size,
|
lut_size: baked.lut_size,
|
||||||
grain_particles: particles,
|
paper,
|
||||||
|
grain_particles: [particles; FORMAT_COUNT],
|
||||||
grain_density_max: [baked.density_max; 3],
|
grain_density_max: [baked.density_max; 3],
|
||||||
grain_uniformity: 0.97,
|
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}"
|
"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");
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
}
|
||||||
@@ -1135,3 +1135,76 @@ fn two_shown_masks_are_drawn_each_in_its_own_colour() {
|
|||||||
"between them, alpha shows black: ({r}, {g}, {b})"
|
"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> {
|
fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, out: u32) -> Vec<u8> {
|
||||||
let shader = graph.compose_for(ColourSpace::Srgb);
|
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||||
let scale = graph.render_scale(source.size(), (out, out));
|
let scale = graph.render_scale(source.size(), (out, out));
|
||||||
let detail =
|
let detail = graph.compose_detail(scale.full_size(), scale.render_size());
|
||||||
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
|
|
||||||
let key = graph.invalidation().through(Affects::Colour);
|
let key = graph.invalidation().through(Affects::Colour);
|
||||||
pass.render_detailed(source, &shader, out, out, None, &detail, key)
|
pass.render_detailed(source, &shader, out, out, None, &detail, key)
|
||||||
.expect("render");
|
.expect("render");
|
||||||
@@ -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
|
// `render_masked`, and was rejected for handing a linear-working shader
|
||||||
// to the plain path — so texture alone on a thumbnail did not render.
|
// 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
|
// The seam was closed at the composition boundary, and closed again,
|
||||||
// composition boundary: `compose_detail` now emits a bodyless
|
// more simply, by D19: no detail pass encodes any more, the fused pass's
|
||||||
// `detail/resolve` pass in exactly this case, which reads only the pixel
|
// view pass performs the output transform whatever the chain holds, and
|
||||||
// it writes and performs the output transform the fused pass declined to
|
// so the empty chain is a whole render. That is the honest description of
|
||||||
// do. So the chain is no longer empty — it carries precisely the one pass
|
// "a two-pixel surface structure is not present in a 128-pixel
|
||||||
// that finishes the render and no kernel at all, which is the honest
|
// rendering".
|
||||||
// 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 scale = graph.render_scale(source.size(), (128, 128));
|
||||||
let composed =
|
let composed = graph.compose_detail(scale.full_size(), scale.render_size());
|
||||||
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
|
assert!(
|
||||||
assert_eq!(
|
composed.is_empty(),
|
||||||
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,
|
|
||||||
"texture claimed a kernel it cannot draw"
|
"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
|
// 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
|
// count says what the assertion above says: two passes, not four. Texture
|
||||||
|
|||||||
@@ -73,8 +73,7 @@ fn render_at(
|
|||||||
scale: RenderScale,
|
scale: RenderScale,
|
||||||
) -> Vec<u8> {
|
) -> Vec<u8> {
|
||||||
let shader = graph.compose_for(ColourSpace::Srgb);
|
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||||
let detail =
|
let detail = graph.compose_detail(scale.full_size(), scale.render_size());
|
||||||
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
|
|
||||||
let key = graph.invalidation().through(Affects::Colour);
|
let key = graph.invalidation().through(Affects::Colour);
|
||||||
pass.render_detailed(source, &shader, out.0, out.1, None, &detail, key)
|
pass.render_detailed(source, &shader, out.0, out.1, None, &detail, key)
|
||||||
.expect("render");
|
.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 shader = graph.compose_for(ColourSpace::Srgb);
|
||||||
let (w, h) = graph.output_size(source.size().0, source.size().1);
|
let (w, h) = graph.output_size(source.size().0, source.size().1);
|
||||||
let (w, h) = (w.min(out), h.min(out));
|
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);
|
let key = graph.invalidation().through(Affects::Colour);
|
||||||
pass.render_detailed(source, &shader, w, h, None, &detail, key)
|
pass.render_detailed(source, &shader, w, h, None, &detail, key)
|
||||||
.expect("render");
|
.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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
+142
-6
@@ -12,6 +12,11 @@
|
|||||||
//! best-connected frame; rotations chained along it.
|
//! best-connected frame; rotations chained along it.
|
||||||
//! 5. Bundle adjustment over every link's inliers (`bundle`).
|
//! 5. Bundle adjustment over every link's inliers (`bundle`).
|
||||||
//!
|
//!
|
||||||
|
//! Steps 1 and 2 are [`match_pairs`] and most of the time; 3 to 5 are
|
||||||
|
//! [`solve`], which takes a subset of the frames. Leaving a frame out is
|
||||||
|
//! then a solve over the pairs already measured — the same links, not a
|
||||||
|
//! fresh RANSAC whose seeds would move with the frames' positions.
|
||||||
|
//!
|
||||||
//! What it refuses to do is guess. A frame the tree does not reach is
|
//! What it refuses to do is guess. A frame the tree does not reach is
|
||||||
//! reported by index with the reason (FR-MRG-5) and left out of the
|
//! reported by index with the reason (FR-MRG-5) and left out of the
|
||||||
//! cameras; the caller decides whether a set with a hole is worth
|
//! cameras; the caller decides whether a set with a hole is worth
|
||||||
@@ -125,13 +130,45 @@ impl Alignment {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Align a set of frames from their features.
|
/// Every pair of a set measured: steps 1 and 2, the expensive part, kept
|
||||||
|
/// so that a solve over a subset reuses it.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct Pairs {
|
||||||
|
/// Each frame's long edge, for the focal length's clamp.
|
||||||
|
long_edges: Vec<f64>,
|
||||||
|
/// Pairs with enough matches to try a geometry, whether or not it held.
|
||||||
|
matched: Vec<(usize, usize)>,
|
||||||
|
links: Vec<Link>,
|
||||||
|
/// Every link's inliers, in pixels, centred.
|
||||||
|
observations: Vec<Observation>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Pairs {
|
||||||
|
/// How many frames were measured.
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.long_edges.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.long_edges.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Align a set of frames from their features: [`match_pairs`], then
|
||||||
|
/// [`solve`] over all of them.
|
||||||
///
|
///
|
||||||
/// Every `Features` must be in its own frame's pixel coordinates with the
|
/// Every `Features` must be in its own frame's pixel coordinates with the
|
||||||
/// image size filled in; points are centred on the image centre here. The
|
/// image size filled in; points are centred on the image centre here. The
|
||||||
/// frames must all come from the same lens at the same focal length, which
|
/// frames must all come from the same lens at the same focal length, which
|
||||||
/// is the panorama assumption and not checked — the caller has the EXIF.
|
/// is the panorama assumption and not checked — the caller has the EXIF.
|
||||||
pub fn align(frames: &[Features], opts: &AlignOptions) -> Result<Alignment, PanoError> {
|
pub fn align(frames: &[Features], opts: &AlignOptions) -> Result<Alignment, PanoError> {
|
||||||
|
let pairs = match_pairs(frames, opts)?;
|
||||||
|
solve(&pairs, &vec![true; frames.len()], opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Steps 1 and 2: every pair matched, and a robust homography for each
|
||||||
|
/// pair with enough matches.
|
||||||
|
pub fn match_pairs(frames: &[Features], opts: &AlignOptions) -> Result<Pairs, PanoError> {
|
||||||
let n = frames.len();
|
let n = frames.len();
|
||||||
if n < 2 {
|
if n < 2 {
|
||||||
return Err(PanoError::Input(
|
return Err(PanoError::Input(
|
||||||
@@ -156,7 +193,7 @@ pub fn align(frames: &[Features], opts: &AlignOptions) -> Result<Alignment, Pano
|
|||||||
// 1 + 2: every pair.
|
// 1 + 2: every pair.
|
||||||
let mut links = Vec::new();
|
let mut links = Vec::new();
|
||||||
let mut observations: Vec<Observation> = Vec::new();
|
let mut observations: Vec<Observation> = Vec::new();
|
||||||
let mut matched_any = vec![false; n];
|
let mut matched = Vec::new();
|
||||||
let t_match = std::time::Instant::now();
|
let t_match = std::time::Instant::now();
|
||||||
for i in 0..n {
|
for i in 0..n {
|
||||||
for j in i + 1..n {
|
for j in i + 1..n {
|
||||||
@@ -165,8 +202,7 @@ pub fn align(frames: &[Features], opts: &AlignOptions) -> Result<Alignment, Pano
|
|||||||
if matches.len() < 4 {
|
if matches.len() < 4 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
matched_any[i] = true;
|
matched.push((i, j));
|
||||||
matched_any[j] = true;
|
|
||||||
let pairs: Vec<((f64, f64), (f64, f64))> = matches
|
let pairs: Vec<((f64, f64), (f64, f64))> = matches
|
||||||
.iter()
|
.iter()
|
||||||
.map(|m| {
|
.map(|m| {
|
||||||
@@ -214,6 +250,69 @@ pub fn align(frames: &[Features], opts: &AlignOptions) -> Result<Alignment, Pano
|
|||||||
}
|
}
|
||||||
|
|
||||||
log::debug!("matching and pairwise geometry in {:?}", t_match.elapsed());
|
log::debug!("matching and pairwise geometry in {:?}", t_match.elapsed());
|
||||||
|
Ok(Pairs {
|
||||||
|
long_edges: frames
|
||||||
|
.iter()
|
||||||
|
.map(|f| f.width.max(f.height) as f64)
|
||||||
|
.collect(),
|
||||||
|
matched,
|
||||||
|
links,
|
||||||
|
observations,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Steps 3 to 5 over the frames `keep` marks, from pairs already measured.
|
||||||
|
///
|
||||||
|
/// The result is indexed by the kept frames in order: its frame `k` is the
|
||||||
|
/// `k`-th frame `keep` marks. Only pairs whose frames are both kept take
|
||||||
|
/// part, so a frame whose only overlap was with one left out is reported
|
||||||
|
/// as unaligned, as it would be had it never been measured with it.
|
||||||
|
pub fn solve(pairs: &Pairs, keep: &[bool], opts: &AlignOptions) -> Result<Alignment, PanoError> {
|
||||||
|
if keep.len() != pairs.len() {
|
||||||
|
return Err(PanoError::Input(format!(
|
||||||
|
"{} flags for {} frames",
|
||||||
|
keep.len(),
|
||||||
|
pairs.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
// Input index to the solve's.
|
||||||
|
let mut slot = vec![None; keep.len()];
|
||||||
|
let mut n = 0usize;
|
||||||
|
for (k, &kept) in keep.iter().enumerate() {
|
||||||
|
if kept {
|
||||||
|
slot[k] = Some(n);
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n < 2 {
|
||||||
|
return Err(PanoError::Input(
|
||||||
|
"a panorama needs at least two frames".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let both = |i: usize, j: usize| Some((slot[i]?, slot[j]?));
|
||||||
|
let mut matched_any = vec![false; n];
|
||||||
|
for &(i, j) in &pairs.matched {
|
||||||
|
if let Some((i, j)) = both(i, j) {
|
||||||
|
matched_any[i] = true;
|
||||||
|
matched_any[j] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let links: Vec<Link> = pairs
|
||||||
|
.links
|
||||||
|
.iter()
|
||||||
|
.filter_map(|l| {
|
||||||
|
let (i, j) = both(l.i, l.j)?;
|
||||||
|
Some(Link { i, j, ..l.clone() })
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let observations: Vec<Observation> = pairs
|
||||||
|
.observations
|
||||||
|
.iter()
|
||||||
|
.filter_map(|o| {
|
||||||
|
let (i, j) = both(o.i, o.j)?;
|
||||||
|
Some(Observation { i, j, ..*o })
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
// 3: the focal length.
|
// 3: the focal length.
|
||||||
let mut estimates: Vec<f64> = links
|
let mut estimates: Vec<f64> = links
|
||||||
@@ -221,9 +320,12 @@ pub fn align(frames: &[Features], opts: &AlignOptions) -> Result<Alignment, Pano
|
|||||||
.filter_map(|l| homography::focal_from_homography(&l.h))
|
.filter_map(|l| homography::focal_from_homography(&l.h))
|
||||||
.filter(|f| f.is_finite() && *f > 0.0)
|
.filter(|f| f.is_finite() && *f > 0.0)
|
||||||
.collect();
|
.collect();
|
||||||
let longest = frames
|
let longest = pairs
|
||||||
|
.long_edges
|
||||||
.iter()
|
.iter()
|
||||||
.map(|f| f.width.max(f.height) as f64)
|
.zip(keep)
|
||||||
|
.filter(|(_, &kept)| kept)
|
||||||
|
.map(|(&e, _)| e)
|
||||||
.fold(0.0, f64::max);
|
.fold(0.0, f64::max);
|
||||||
let focal = if !estimates.is_empty() {
|
let focal = if !estimates.is_empty() {
|
||||||
estimates.sort_by(f64::total_cmp);
|
estimates.sort_by(f64::total_cmp);
|
||||||
@@ -448,6 +550,40 @@ mod tests {
|
|||||||
assert!(out.rotations[..3].iter().all(Option::is_some));
|
assert!(out.rotations[..3].iter().all(Option::is_some));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_frame_left_out_is_solved_without_measuring_again() {
|
||||||
|
let (frames, truth) = synthetic_sweep(6, 0.3, 1400.0, 1024, 768);
|
||||||
|
let opts = AlignOptions::default();
|
||||||
|
let pairs = match_pairs(&frames, &opts).expect("measured");
|
||||||
|
// The first frame left out: five cameras, indexed as the kept
|
||||||
|
// frames, and the links among them only.
|
||||||
|
let keep = [false, true, true, true, true, true];
|
||||||
|
let out = solve(&pairs, &keep, &opts).expect("solved");
|
||||||
|
assert!(out.is_complete(), "unaligned: {:?}", out.unaligned);
|
||||||
|
assert_eq!(out.rotations.len(), 5);
|
||||||
|
assert_eq!(out.links.len(), 4 + 3, "links: {}", out.links.len());
|
||||||
|
let root = out
|
||||||
|
.rotations
|
||||||
|
.iter()
|
||||||
|
.position(|r| *r == Some(Mat3::IDENTITY))
|
||||||
|
.unwrap();
|
||||||
|
for k in 0..5 {
|
||||||
|
let rel_truth = truth.rotations[root + 1].transpose() * truth.rotations[k + 1];
|
||||||
|
let err = angle_between(rel_truth, out.rotations[k].unwrap());
|
||||||
|
assert!(err < 2e-3, "frame {k} off by {err} rad");
|
||||||
|
}
|
||||||
|
// A frame in the middle left out splits the sweep only if nothing
|
||||||
|
// spans the gap; at 0.3 rad steps its neighbours still overlap.
|
||||||
|
let keep = [true, true, false, true, true, true];
|
||||||
|
let out = solve(&pairs, &keep, &opts).expect("solved");
|
||||||
|
assert!(out.is_complete(), "unaligned: {:?}", out.unaligned);
|
||||||
|
// And the whole set solved from the pairs is `align`'s answer.
|
||||||
|
assert_eq!(
|
||||||
|
solve(&pairs, &[true; 6], &opts).expect("solved"),
|
||||||
|
align(&frames, &opts).expect("aligned")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn one_frame_is_refused() {
|
fn one_frame_is_refused() {
|
||||||
let (frames, _) = synthetic_sweep(1, 0.3, 1400.0, 640, 480);
|
let (frames, _) = synthetic_sweep(1, 0.3, 1400.0, 640, 480);
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ pub mod projection;
|
|||||||
#[cfg(feature = "xfeat")]
|
#[cfg(feature = "xfeat")]
|
||||||
pub mod xfeat;
|
pub mod xfeat;
|
||||||
|
|
||||||
pub use align::{align, AlignOptions, Alignment, Link, Unaligned};
|
pub use align::{align, match_pairs, solve, AlignOptions, Alignment, Link, Pairs, Unaligned};
|
||||||
pub use bundle::Cameras;
|
pub use bundle::Cameras;
|
||||||
pub use features::{Features, Keypoint};
|
pub use features::{Features, Keypoint};
|
||||||
pub use fill::{fill_border, Inpainter, Observer, Params as FillParams};
|
pub use fill::{fill_border, Inpainter, Observer, Params as FillParams};
|
||||||
|
|||||||
@@ -405,6 +405,7 @@ fn emit_node(out: &mut String, node: &Declaration) {
|
|||||||
active,
|
active,
|
||||||
tests,
|
tests,
|
||||||
presentation,
|
presentation,
|
||||||
|
camera_stage,
|
||||||
..
|
..
|
||||||
} = node;
|
} = node;
|
||||||
|
|
||||||
@@ -569,6 +570,15 @@ fn emit_node(out: &mut String, node: &Declaration) {
|
|||||||
" fn is_active(&self) -> bool {{\n {active_expr}\n }}\n"
|
" fn is_active(&self) -> bool {{\n {active_expr}\n }}\n"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Only a camera-stage node says anything: the trait's default is the
|
||||||
|
// scene, which is every other node (D19).
|
||||||
|
if *camera_stage {
|
||||||
|
out.push_str(
|
||||||
|
" fn stage(&self) -> crate::operation::Stage {\n \
|
||||||
|
crate::operation::Stage::Camera\n }\n\n",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
out,
|
out,
|
||||||
" fn wgsl_body(&self) -> String {{\n {}.into()\n }}\n",
|
" fn wgsl_body(&self) -> String {{\n {}.into()\n }}\n",
|
||||||
|
|||||||
@@ -228,9 +228,11 @@ interpolated points — master, red, green, blue — each reaching the shader on
|
|||||||
when it has been moved), `colour_mixer` (thirty-six faceted parameters from
|
when it has been moved), `colour_mixer` (thirty-six faceted parameters from
|
||||||
twelve computed hue bands), `film_sim` (a stock's measured tables, which are
|
twelve computed hue bands), `film_sim` (a stock's measured tables, which are
|
||||||
not parameters, and the one node that declares `Operation::renders` — see
|
not parameters, and the one node that declares `Operation::renders` — see
|
||||||
below), `capture_sharpen` (a separable convolution) and `noise_reduction` (a
|
below), `view_transform` (composed at its defaults, which a declaration cannot
|
||||||
kernel, and one that decides how many dispatches to emit at each resolution) —
|
say — see [What is not a node](#what-is-not-a-node-and-why)), and the five
|
||||||
the last two for the reason the next section gives. `vignetting` is
|
kernels — `capture_sharpen` (a separable convolution), `noise_reduction` (one
|
||||||
|
that decides how many dispatches to emit at each resolution), `clarity`,
|
||||||
|
`texture` and `dehaze` — for the reason the next section gives. `vignetting` is
|
||||||
hand-written too but is not in the develop chain — it carries lens-profile
|
hand-written too but is not in the develop chain — it carries lens-profile
|
||||||
coefficients that are not parameters.
|
coefficients that are not parameters.
|
||||||
|
|
||||||
@@ -240,18 +242,16 @@ coefficients that are not parameters.
|
|||||||
`Operation::renders`, and it is worth knowing why before writing a second one.
|
`Operation::renders`, and it is worth knowing why before writing a second one.
|
||||||
|
|
||||||
Every other node *adjusts* a picture. That one *makes* it: a film stock's
|
Every other node *adjusts* a picture. That one *makes* it: a film stock's
|
||||||
characteristic curve does the camera profile's base curve's job, from
|
characteristic curve does the view transform's job, from measurements rather
|
||||||
measurements rather than from a curve somebody drew. Running both renders the
|
than from a curve somebody chose. Running both renders the scene twice — the
|
||||||
scene twice — the camera's rendering, and then a film's rendering of *that* —
|
default rendering, and then a film's rendering of *that* — which looks like
|
||||||
which looks like neither and reads as a colour-management bug with no
|
neither and reads as a colour-management bug with no colour-management bug to
|
||||||
colour-management bug to find.
|
find.
|
||||||
|
|
||||||
So a node declaring `renders` takes camera RGB and hands back linear sRGB, and
|
So `film_sim` is in `Stage::View` beside `view_transform`, and while a stock
|
||||||
in exchange the composer emits neither the base curve nor the conversion out of
|
is loaded the composer emits it in the view transform's place, last, after the
|
||||||
camera space. Both halves move to the node, together: the base curve is defined
|
detail stage, and not the sigmoid (D19). It is handed working-space colour and
|
||||||
in camera RGB and the matrix is what leaves it, so a node replacing one has
|
hands back display-referred linear sRGB for the output transform. `distortion` and
|
||||||
necessarily replaced the other. `compose_full` keeps them as a single string
|
|
||||||
for exactly that reason — it is what makes getting half of it right impossible. `distortion` and
|
|
||||||
`aberration` are `Warp`s rather than operations: they rewrite coordinates
|
`aberration` are `Warp`s rather than operations: they rewrite coordinates
|
||||||
before sampling rather than transforming a colour after it.
|
before sampling rather than transforming a colour after it.
|
||||||
|
|
||||||
@@ -264,7 +264,8 @@ clarity, texture, dehaze and spot removal are all defined by what the
|
|||||||
of `c` at any price.
|
of `c` at any price.
|
||||||
|
|
||||||
They go in the **detail stage**, which runs after the fused pass, in linear
|
They go in the **detail stage**, which runs after the fused pass, in linear
|
||||||
light, at render resolution, before the output transform — see
|
light, at render resolution, before the view transform and the output
|
||||||
|
transform — see
|
||||||
[`../src/detail.rs`](../src/detail.rs) for why each of those is a decision
|
[`../src/detail.rs`](../src/detail.rs) for why each of those is a decision
|
||||||
rather than a convenience. A node of this kind:
|
rather than a convenience. A node of this kind:
|
||||||
|
|
||||||
@@ -294,41 +295,38 @@ in raw pixels is a different photograph on screen and in the exported file.
|
|||||||
|
|
||||||
## What is not a node, and why
|
## What is not a node, and why
|
||||||
|
|
||||||
Three things act on every pixel and are deliberately not in this directory:
|
Two things act on every pixel and are deliberately not in this directory: the
|
||||||
the as-shot white balance, the camera matrix, and the **base curve**
|
as-shot white balance and the camera matrix. They are emitted by
|
||||||
(FR-DEV-3e). They are emitted by [`../src/operation.rs`](../src/operation.rs)
|
[`../src/operation.rs`](../src/operation.rs) into the composed shader around the
|
||||||
into the composed shader's fixed preamble, around the block of nodes.
|
block of nodes. They are properties of the *file*, at the same standing as the
|
||||||
|
masked-photosite crop (FR-RAW-3) and the stored orientation (FR-DEV-3h): nobody
|
||||||
|
chose the sensor's green sensitivity, and reading the file correctly means
|
||||||
|
undoing it.
|
||||||
|
|
||||||
The test is not "does it transform a colour" — all three do. It is **whose
|
The **view transform** (FR-DEV-3j) *is* a node — `view_transform.yaml`, a
|
||||||
decision is it**. A node is something a photographer chose: it has parameters,
|
`rust:` one — and that is a change of mind worth knowing about. It replaced the
|
||||||
it moves off a neutral, it lands in the sidecar, it can be undone. These three
|
per-body base curve, which was kept out of this directory because it belonged
|
||||||
are properties of the *file*, at the same standing as the masked-photosite crop
|
to the camera: as a node it would have carried one body's rendering onto
|
||||||
(FR-RAW-3) and the stored orientation (FR-DEV-3h). Nobody chose the sensor's
|
another body's file through a shared sidecar. D19 retired the per-body curves,
|
||||||
green sensitivity or the body's rendering; they are what reading the file
|
and with them the argument. One view transform serves every body, so its
|
||||||
correctly means.
|
settings are a decision about the picture like any other. What is still
|
||||||
|
special about it is `Stage::View`: the composer emits it at the end of the
|
||||||
|
chain *whatever its state*, because a photograph with no view transform is a
|
||||||
|
scan and not a picture. Its neutral is its defaults, like every other node's,
|
||||||
|
so an untouched photograph writes nothing for it.
|
||||||
|
|
||||||
Making the base curve a node would have said the opposite in four places at
|
## Stages
|
||||||
once. It would have appeared in the develop panel as a control, so an
|
|
||||||
unprofiled body would show a slider that does nothing. Its values would have
|
|
||||||
gone into the sidecar, and sidecars are shared between devices and bodies
|
|
||||||
(FR-NC-9) — one camera's rendering would follow an edit onto another camera's
|
|
||||||
file. Its neutral would have had to be "the identity", so a profiled body would
|
|
||||||
open reporting itself modified. And there is no seam through which a node could
|
|
||||||
learn which camera took the frame: the profile arrives on the decoded image,
|
|
||||||
travels through `DemosaicedImage` beside the matrix it belongs with, and is
|
|
||||||
written into the uniform block by the same three lines in `dr-gpu` — which is
|
|
||||||
exactly the path the matrix already took, because it is exactly the same kind
|
|
||||||
of thing.
|
|
||||||
|
|
||||||
What it *does* share with the tone curve node is the spline. The composer asks
|
`stage: camera` puts a node in camera RGB, ahead of the camera matrix; the
|
||||||
`ToneCurve` for its `curve_span`/`curve_eval` helpers rather than emitting a
|
default, `stage: scene`, hands it working-space colour — linear sRGB
|
||||||
second copy, so a profile author placing a control point and a photographer
|
primaries, scene-referred and unbounded. White balance is the only camera
|
||||||
dragging one mean the same thing by it.
|
node, because its multipliers scale the sensor's own channels. Everything else
|
||||||
|
belongs in the scene, where a hue or a luminance weight means the same thing
|
||||||
The order still reads correctly from this directory: the base curve runs after
|
whichever body took the frame (D19). The composer emits the camera nodes, then
|
||||||
every node in the chain and before the conversion out of camera space. That is
|
the matrix, then the scene nodes, each group in `order:`, and the view
|
||||||
the same reasoning `exposure` records under `placement:` — corrections to
|
transform last. `stage: view` is not offered to a declaration: a node that
|
||||||
capture are only meaningful on linear values, so the rendering goes last.
|
maps into a display range is exactly what ARCH §6.14 forbids of everything
|
||||||
|
before the end, and the one that is allowed to is hand-written.
|
||||||
|
|
||||||
## Errors
|
## Errors
|
||||||
|
|
||||||
|
|||||||
@@ -35,41 +35,63 @@ helpers: [luminance, apply_tone_gain]
|
|||||||
|
|
||||||
define:
|
define:
|
||||||
contrast_curve: |
|
contrast_curve: |
|
||||||
// A symmetric S-curve on a 0..1 perceptual position.
|
// The steepening S, on a 0..1 perceptual position.
|
||||||
//
|
//
|
||||||
// `amount` above zero steepens, below zero flattens. The smoothstep form is
|
// Blends toward a smoothstep, which has zero gradient at both ends, so the
|
||||||
// used for the steepening direction because it has zero gradient at both
|
// curve cannot invert however hard it is pushed — the failure that makes
|
||||||
// ends, so the curve cannot invert however hard it is pushed — the failure
|
// naive gain-about-a-pivot unusable past moderate settings. Only the
|
||||||
// that makes naive gain-about-a-pivot unusable past moderate settings.
|
// positive direction comes here: flattening is not a curve at all (see
|
||||||
|
// the fragment).
|
||||||
fn contrast_curve(x: f32, amount: f32) -> f32 {
|
fn contrast_curve(x: f32, amount: f32) -> f32 {
|
||||||
let clamped = clamp(x, 0.0, 1.0);
|
let clamped = clamp(x, 0.0, 1.0);
|
||||||
if (amount >= 0.0) {
|
let s = clamped * clamped * (3.0 - 2.0 * clamped);
|
||||||
// Blend toward a smoothstep, which is the S.
|
return mix(clamped, s, amount);
|
||||||
let s = clamped * clamped * (3.0 - 2.0 * clamped);
|
|
||||||
return mix(clamped, s, amount);
|
|
||||||
}
|
|
||||||
// Flattening: pull toward the mid-point. At amount = -1 every tone
|
|
||||||
// collapses to 0.5, which is the meaningful limit of 'no contrast'.
|
|
||||||
return mix(clamped, 0.5, -amount);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
wgsl: |
|
wgsl: |
|
||||||
let luma = luminance(c);
|
if (amount < 0.0) {
|
||||||
if (luma > 0.0001) {
|
// **Flattening mixes toward middle grey; it does not scale.**
|
||||||
// Work on luminance and rescale the colour by the ratio, rather than
|
|
||||||
// curving each channel independently. Per-channel contrast shifts hue
|
|
||||||
// wherever the channels differ — the classic symptom being skies going
|
|
||||||
// cyan as contrast rises.
|
|
||||||
//
|
//
|
||||||
// MIDDLE_GREY is 0.18: the linear value the eye reads as mid-tone. The
|
// Every tone moves the same fraction of the way to 0.18, which at -1
|
||||||
// curve operates on luma/(2*0.18) so that middle grey lands at the
|
// collapses the picture to grey — the meaningful limit of 'no contrast'.
|
||||||
// curve's own 0.5 pivot.
|
// In luminance this is exactly what the ratio form below would compute,
|
||||||
let pos = clamp(luma / 0.36, 0.0, 1.0);
|
// but the ratio form reaches it by multiplying: a pixel at 0.001 has to
|
||||||
let curved = contrast_curve(pos, amount);
|
// be lifted to 0.09, a gain of ninety, and in the deepest shadows the
|
||||||
// Not `target`: that is a WGSL reserved keyword, and using it produces a
|
// channels are sensor noise, not a colour. After white balance the red
|
||||||
// parse error in generated code rather than anywhere a reader would look.
|
// and blue noise sits above the green (their multipliers are nearly
|
||||||
let curved_luma = curved * 0.36;
|
// twice its), so ninety times that noise is magenta — every black in the
|
||||||
c = apply_tone_gain(c, curved_luma / luma);
|
// frame turned pink. Mixing adds the lift as a neutral, so a black goes
|
||||||
|
// to grey and its noise stays the size it was.
|
||||||
|
//
|
||||||
|
// The grey is (1, 1, 1) scaled, because this runs after white balance
|
||||||
|
// and the camera matrix, which carries a balanced neutral to equal
|
||||||
|
// channels.
|
||||||
|
c = mix(c, vec3<f32>(0.18), -amount);
|
||||||
|
} else {
|
||||||
|
let luma = luminance(c);
|
||||||
|
// Only up to twice middle grey, which is the curve's whole domain. Above
|
||||||
|
// it the curve's value is 1 and its slope 0, so leaving those tones
|
||||||
|
// alone is the continuous continuation — where scaling them to the
|
||||||
|
// curve's top, as this once did through a clamp, pinned every highlight
|
||||||
|
// in the photograph to 0.36 at the smallest touch of the slider.
|
||||||
|
if (luma > 0.0001 && luma < 0.36) {
|
||||||
|
// Work on luminance and rescale the colour by the ratio, rather than
|
||||||
|
// curving each channel independently. Per-channel contrast shifts hue
|
||||||
|
// wherever the channels differ — the classic symptom being skies going
|
||||||
|
// cyan as contrast rises. Safe here where it was not for flattening:
|
||||||
|
// the S only ever pulls a shadow down, so the gain is at most one
|
||||||
|
// below the pivot and noise is never amplified.
|
||||||
|
//
|
||||||
|
// MIDDLE_GREY is 0.18: the linear value the eye reads as mid-tone.
|
||||||
|
// The curve operates on luma/(2*0.18) so that middle grey lands at
|
||||||
|
// the curve's own 0.5 pivot.
|
||||||
|
let pos = luma / 0.36;
|
||||||
|
let curved = contrast_curve(pos, amount);
|
||||||
|
// Not `target`: that is a WGSL reserved keyword, and using it produces a
|
||||||
|
// parse error in generated code rather than anywhere a reader would look.
|
||||||
|
let curved_luma = curved * 0.36;
|
||||||
|
c = apply_tone_gain(c, curved_luma / luma);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
c = max(c, vec3<f32>(0.0));
|
c = max(c, vec3<f32>(0.0));
|
||||||
|
|
||||||
@@ -104,6 +126,19 @@ tests:
|
|||||||
propagate through everything downstream.
|
propagate through everything downstream.
|
||||||
expect_wgsl: ["luma > 0.0001"]
|
expect_wgsl: ["luma > 0.0001"]
|
||||||
|
|
||||||
|
- name: flattening_mixes_toward_grey_rather_than_scaling
|
||||||
|
why: |
|
||||||
|
Lifting a shadow by a luminance ratio multiplies its noise by the same
|
||||||
|
ratio — ninety at the bottom of a night photograph — and after white
|
||||||
|
balance that noise is magenta. A mix adds the lift as a neutral.
|
||||||
|
expect_wgsl: ["mix(c, vec3<f32>(0.18), -amount)"]
|
||||||
|
|
||||||
|
- name: highlights_are_not_pinned_to_the_top_of_the_curve
|
||||||
|
why: |
|
||||||
|
The curve covers 0..0.36. A clamp into that range scaled every brighter
|
||||||
|
pixel down to 0.36; tones above it are left as they are.
|
||||||
|
expect_wgsl: ["luma < 0.36"]
|
||||||
|
|
||||||
- name: the_curve_cannot_invert
|
- name: the_curve_cannot_invert
|
||||||
why: |
|
why: |
|
||||||
A gain-about-a-pivot form produces a non-monotonic curve past moderate
|
A gain-about-a-pivot form produces a non-monotonic curve past moderate
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
id: film_sim
|
id: film_sim
|
||||||
order: 25
|
order: 190
|
||||||
# What this node is *about* is not written here, and cannot be: a `rust:` node
|
# What this node is *about* is not written here, and cannot be: a `rust:` node
|
||||||
# publishes its own descriptor, so `attributes:` in this file would be read,
|
# publishes its own descriptor, so `attributes:` in this file would be read,
|
||||||
# validated and then ignored. See `Attribute::Effect` on `FilmSim`'s descriptor
|
# validated and then ignored. See `Attribute::Effect` on `FilmSim`'s descriptor
|
||||||
@@ -11,15 +11,17 @@ why_rust: |
|
|||||||
characteristic curves and a density lookup — which are not parameters and
|
characteristic curves and a density lookup — which are not parameters and
|
||||||
which no `uniforms:` expression could produce. Its neutral is "no stock
|
which no `uniforms:` expression could produce. Its neutral is "no stock
|
||||||
loaded" rather than a set of values, and it is the one node that declares
|
loaded" rather than a set of values, and it is the one node that declares
|
||||||
`Operation::renders`, so the composer omits the camera profile's base curve
|
`Operation::renders`, so while a stock is loaded the composer emits it in
|
||||||
and the conversion out of camera space on its behalf.
|
the view transform's place instead of the default sigmoid.
|
||||||
|
|
||||||
placement: |
|
placement: |
|
||||||
After white balance and exposure, and before everything else.
|
Last, in the view transform's place (D19, FR-DEV-3j), after every other
|
||||||
|
operation and after the detail stage.
|
||||||
|
|
||||||
Those two are what the camera did — interpreting the sensor, and correcting
|
Before D19 it sat at order 25, after white balance and exposure, and every
|
||||||
the amount of light that reached it — and they are only meaningful on
|
decision below it acted on the film's output, as though the frame had been
|
||||||
scene-linear values, which is what a film has to be handed. Everything below
|
scanned and then worked on. That put a display-referred rendering in the
|
||||||
is a decision about the picture, and a decision about the picture belongs
|
middle of the chain, which is what D19 removes: every operation is now handed
|
||||||
after the film has rendered it, exactly as it does when you scan a frame and
|
the scene, and the film is the last thing that happens to the picture — an
|
||||||
then work on the scan.
|
edit is a decision about the exposure the negative receives. `Stage::View`
|
||||||
|
is what puts it there; this number only places it in the panel's order.
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
id: view_transform
|
||||||
|
order: 200
|
||||||
|
# A `rust:` node publishes its own descriptor; its attributes are on the type
|
||||||
|
# in `../src/ops/view_transform.rs`.
|
||||||
|
rust: ViewTransform
|
||||||
|
|
||||||
|
why_rust: |
|
||||||
|
It is composed at its defaults — a photograph with no view transform is a
|
||||||
|
scan, not a picture — which is `Stage::View`, and a declaration has no way to
|
||||||
|
say it. Its three uniforms are also the solution of two equations rather than
|
||||||
|
expressions over its parameters (`dr_pipeline::view::Sigmoid::new`).
|
||||||
|
|
||||||
|
placement: |
|
||||||
|
Last, after every scene operation and, when there is one, after the detail
|
||||||
|
stage (D19, FR-DEV-3j). It is the one stage allowed to map scene-linear colour
|
||||||
|
to a display range, so anything after it would be working on a rendering.
|
||||||
|
The order here only places it in the panel; the composer puts every
|
||||||
|
`Stage::View` node at the end whatever its number says.
|
||||||
@@ -20,6 +20,13 @@ placement: |
|
|||||||
First. It is a correction to how the scene was captured, and every tonal
|
First. It is a correction to how the scene was captured, and every tonal
|
||||||
operation after it should act on a correctly balanced image.
|
operation after it should act on a correctly balanced image.
|
||||||
|
|
||||||
|
# In camera RGB, ahead of the camera matrix, and the only node there (D19).
|
||||||
|
# Its multipliers scale the sensor's own channels — that is what the as-shot
|
||||||
|
# ones are, and what the picker solves for — and a matrix that mixes the
|
||||||
|
# channels, which is every body's, would turn the same numbers into a
|
||||||
|
# different correction once it had run.
|
||||||
|
stage: camera
|
||||||
|
|
||||||
params:
|
params:
|
||||||
temperature:
|
temperature:
|
||||||
label: param.temperature
|
label: param.temperature
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
drpl 1
|
||||||
|
|
||||||
|
# Black and white film: each preset is one measured stock, developed and — for a
|
||||||
|
# negative — printed on the paper its profile names, with every other
|
||||||
|
# control left where it was. The stock is the look; a grade on top is the
|
||||||
|
# photographer's to add.
|
||||||
|
#
|
||||||
|
# The measurements are spektrafilm's (Andrea Volpato, CC BY-SA 4.0), as
|
||||||
|
# converted in core/dr-film/profiles. A preset names a stock by id, so
|
||||||
|
# these lines are that attribution's reach: nothing of the data is here.
|
||||||
|
|
||||||
|
[preset Ilford Delta 100]
|
||||||
|
film = ilford_delta_100
|
||||||
|
film_print = kodak_2302
|
||||||
|
|
||||||
|
[preset Ilford Delta 400]
|
||||||
|
film = ilford_delta_400
|
||||||
|
film_print = kodak_2302
|
||||||
|
|
||||||
|
[preset Ilford FP4 Plus]
|
||||||
|
film = ilford_fp4_plus
|
||||||
|
film_print = kodak_2302
|
||||||
|
|
||||||
|
[preset Ilford HP5 Plus]
|
||||||
|
film = ilford_hp5_plus
|
||||||
|
film_print = kodak_2302
|
||||||
|
|
||||||
|
[preset Ilford Pan F Plus]
|
||||||
|
film = ilford_pan_f_plus
|
||||||
|
film_print = kodak_2302
|
||||||
|
|
||||||
|
[preset Kodak Double-X 5222]
|
||||||
|
film = kodak_doublex
|
||||||
|
film_print = kodak_2302
|
||||||
|
|
||||||
|
[preset Kodak Tri-X Reversal 7266]
|
||||||
|
film = kodak_trix
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
drpl 1
|
||||||
|
|
||||||
|
# Cinema film: each preset is one measured stock, developed and — for a
|
||||||
|
# negative — printed on the paper its profile names, with every other
|
||||||
|
# control left where it was. The stock is the look; a grade on top is the
|
||||||
|
# photographer's to add.
|
||||||
|
#
|
||||||
|
# The measurements are spektrafilm's (Andrea Volpato, CC BY-SA 4.0), as
|
||||||
|
# converted in core/dr-film/profiles. A preset names a stock by id, so
|
||||||
|
# these lines are that attribution's reach: nothing of the data is here.
|
||||||
|
|
||||||
|
[preset Kodak Verita 200D]
|
||||||
|
film = kodak_verita_200d
|
||||||
|
film_print = kodak_2383
|
||||||
|
|
||||||
|
[preset Kodak Vision3 200T]
|
||||||
|
film = kodak_vision3_200t
|
||||||
|
film_print = kodak_2383
|
||||||
|
|
||||||
|
[preset Kodak Vision3 250D]
|
||||||
|
film = kodak_vision3_250d
|
||||||
|
film_print = kodak_2383
|
||||||
|
|
||||||
|
[preset Kodak Vision3 500T]
|
||||||
|
film = kodak_vision3_500t
|
||||||
|
film_print = kodak_2383
|
||||||
|
|
||||||
|
[preset Kodak Vision3 50D]
|
||||||
|
film = kodak_vision3_50d
|
||||||
|
film_print = kodak_2383
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
drpl 1
|
||||||
|
|
||||||
|
# Colour film: each preset is one measured stock, developed and — for a
|
||||||
|
# negative — printed on the paper its profile names, with every other
|
||||||
|
# control left where it was. The stock is the look; a grade on top is the
|
||||||
|
# photographer's to add.
|
||||||
|
#
|
||||||
|
# The measurements are spektrafilm's (Andrea Volpato, CC BY-SA 4.0), as
|
||||||
|
# converted in core/dr-film/profiles. A preset names a stock by id, so
|
||||||
|
# these lines are that attribution's reach: nothing of the data is here.
|
||||||
|
|
||||||
|
[preset Fujifilm C200]
|
||||||
|
film = fujifilm_c200
|
||||||
|
film_print = fujifilm_crystal_archive_typeii
|
||||||
|
|
||||||
|
[preset Fujifilm Pro 400H]
|
||||||
|
film = fujifilm_pro_400h
|
||||||
|
film_print = fujifilm_crystal_archive_typeii
|
||||||
|
|
||||||
|
[preset Fujifilm Provia 100F]
|
||||||
|
film = fujifilm_provia_100f
|
||||||
|
|
||||||
|
[preset Fujifilm Velvia 100]
|
||||||
|
film = fujifilm_velvia_100
|
||||||
|
|
||||||
|
[preset Fujifilm X-Tra 400]
|
||||||
|
film = fujifilm_xtra_400
|
||||||
|
film_print = fujifilm_crystal_archive_typeii
|
||||||
|
|
||||||
|
[preset Kodak Ektachrome 100]
|
||||||
|
film = kodak_ektachrome_100
|
||||||
|
|
||||||
|
[preset Kodak Ektar 100]
|
||||||
|
film = kodak_ektar_100
|
||||||
|
film_print = kodak_portra_endura
|
||||||
|
|
||||||
|
[preset Kodak Gold 200]
|
||||||
|
film = kodak_gold_200
|
||||||
|
film_print = kodak_portra_endura
|
||||||
|
|
||||||
|
[preset Kodak Kodachrome 64]
|
||||||
|
film = kodak_kodachrome_64
|
||||||
|
|
||||||
|
[preset Kodak Portra 160]
|
||||||
|
film = kodak_portra_160
|
||||||
|
film_print = kodak_portra_endura
|
||||||
|
|
||||||
|
[preset Kodak Portra 400]
|
||||||
|
film = kodak_portra_400
|
||||||
|
film_print = kodak_portra_endura
|
||||||
|
|
||||||
|
[preset Kodak Portra 800]
|
||||||
|
film = kodak_portra_800
|
||||||
|
film_print = kodak_portra_endura
|
||||||
|
|
||||||
|
[preset Kodak Portra 800 pushed one stop]
|
||||||
|
film = kodak_portra_800_push1
|
||||||
|
film_print = kodak_portra_endura
|
||||||
|
|
||||||
|
[preset Kodak Portra 800 pushed two stops]
|
||||||
|
film = kodak_portra_800_push2
|
||||||
|
film_print = kodak_portra_endura
|
||||||
|
|
||||||
|
[preset Kodak Ultramax 400]
|
||||||
|
film = kodak_ultramax_400
|
||||||
|
film_print = kodak_portra_endura
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
drpl 1
|
||||||
|
|
||||||
|
# Essentials: small, general corrections written against this pipeline.
|
||||||
|
# These were the first-run starter set; they ship here now so that a
|
||||||
|
# new release can improve them without rewriting anybody's own presets.
|
||||||
|
# Deliberately mild — see bundled.rs.
|
||||||
|
|
||||||
|
[preset Crisp detail]
|
||||||
|
capture_sharpen.amount = 35
|
||||||
|
clarity.amount = 10
|
||||||
|
texture.amount = 20
|
||||||
|
|
||||||
|
[preset Lift the shadows]
|
||||||
|
blacks_whites.blacks = 12
|
||||||
|
contrast.contrast = -5
|
||||||
|
highlights_shadows.shadows = 40
|
||||||
|
|
||||||
|
[preset Muted]
|
||||||
|
contrast.contrast = -10
|
||||||
|
highlights_shadows.shadows = 12
|
||||||
|
saturation.saturation = -30
|
||||||
|
vibrance.vibrance = 10
|
||||||
|
|
||||||
|
[preset Punch]
|
||||||
|
blacks_whites.blacks = -8
|
||||||
|
clarity.amount = 12
|
||||||
|
contrast.contrast = 18
|
||||||
|
vibrance.vibrance = 18
|
||||||
|
|
||||||
|
[preset Recover the sky]
|
||||||
|
blacks_whites.whites = -10
|
||||||
|
highlights_shadows.highlights = -55
|
||||||
|
highlights_shadows.shadows = 35
|
||||||
|
|
||||||
|
[preset Soft portrait]
|
||||||
|
clarity.amount = -10
|
||||||
|
contrast.contrast = -8
|
||||||
|
highlights_shadows.highlights = -20
|
||||||
|
highlights_shadows.shadows = 15
|
||||||
|
saturation.saturation = -5
|
||||||
|
vibrance.vibrance = 10
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
drpl 1
|
||||||
|
|
||||||
|
# Skies: a bluer, deeper sky without touching the rest of the picture.
|
||||||
|
#
|
||||||
|
# Written against this pipeline, not derived from anybody's preset. Each
|
||||||
|
# works the colour mixer's azure and blue bands — the hues a clear sky
|
||||||
|
# occupies, 210° and 240° — darkening them and adding chroma, which is what
|
||||||
|
# a polarising filter does to a sky and why it reads as "more blue" rather
|
||||||
|
# than "more saturated". A grey sky has no hue for the bands to find, so on
|
||||||
|
# an overcast frame these do little, by construction: they cannot invent a
|
||||||
|
# sky, and a preset that tinted grey clouds blue would be one nobody trusted.
|
||||||
|
#
|
||||||
|
# Highlights come down with the sky in the stronger ones, because a darker
|
||||||
|
# blue beside a clipped white cloud looks like a mask edge.
|
||||||
|
|
||||||
|
[preset Blue sky]
|
||||||
|
colour_mixer.azure_lum = -20
|
||||||
|
colour_mixer.azure_sat = 25
|
||||||
|
colour_mixer.blue_lum = -15
|
||||||
|
colour_mixer.blue_sat = 20
|
||||||
|
highlights_shadows.highlights = -15
|
||||||
|
|
||||||
|
[preset Deep blue sky]
|
||||||
|
colour_mixer.azure_hue = 10
|
||||||
|
colour_mixer.azure_lum = -30
|
||||||
|
colour_mixer.azure_sat = 35
|
||||||
|
colour_mixer.blue_lum = -25
|
||||||
|
colour_mixer.blue_sat = 30
|
||||||
|
colour_mixer.cyan_sat = 10
|
||||||
|
highlights_shadows.highlights = -30
|
||||||
|
|
||||||
|
[preset Polariser]
|
||||||
|
colour_mixer.azure_hue = 10
|
||||||
|
colour_mixer.azure_lum = -35
|
||||||
|
colour_mixer.azure_sat = 40
|
||||||
|
colour_mixer.blue_lum = -30
|
||||||
|
colour_mixer.blue_sat = 35
|
||||||
|
colour_mixer.cyan_lum = -10
|
||||||
|
colour_mixer.cyan_sat = 15
|
||||||
|
dehaze.amount = 20
|
||||||
|
highlights_shadows.highlights = -35
|
||||||
|
vibrance.vibrance = 10
|
||||||
|
|
||||||
|
[preset Blue sky, golden land]
|
||||||
|
colour_mixer.azure_lum = -20
|
||||||
|
colour_mixer.azure_sat = 25
|
||||||
|
colour_mixer.blue_lum = -15
|
||||||
|
colour_mixer.blue_sat = 20
|
||||||
|
colour_mixer.orange_sat = 12
|
||||||
|
colour_mixer.yellow_hue = -10
|
||||||
|
colour_mixer.yellow_sat = 15
|
||||||
|
highlights_shadows.highlights = -20
|
||||||
@@ -0,0 +1,447 @@
|
|||||||
|
//! TRACES: FR-DEV-6
|
||||||
|
//! The presets that ship with the application.
|
||||||
|
//!
|
||||||
|
//! # Shipped, not seeded
|
||||||
|
//!
|
||||||
|
//! The first six used to be *copied* into the photographer's own library on
|
||||||
|
//! the first run and were theirs from then on. That was the right answer for
|
||||||
|
//! six, and it cannot grow: a copy is frozen at the version that made it, so a
|
||||||
|
//! better "Portra" in the next release would reach nobody who already had the
|
||||||
|
//! old one, and re-seeding would overwrite a preset someone had tuned. So the
|
||||||
|
//! shipped set is now read from the binary every time, never written to the
|
||||||
|
//! user's file, and changes when the application does.
|
||||||
|
//!
|
||||||
|
//! # Your copy wins, as long as it keeps the name
|
||||||
|
//!
|
||||||
|
//! Saving over a shipped preset's name makes the photographer's version the
|
||||||
|
//! one that name means, here and in every apply. It is still *that* preset —
|
||||||
|
//! listed where the shipped one was, marked as changed — and deleting it
|
||||||
|
//! reveals the shipped one again, which is what "revert" means to the person
|
||||||
|
//! pressing it. Renaming it cuts the link: it becomes one of their own, and
|
||||||
|
//! the shipped preset reappears beside it. The lookup is by name because the
|
||||||
|
//! name is what the photographer sees and chooses by; an id they never see
|
||||||
|
//! would link two presets they believe are different.
|
||||||
|
//!
|
||||||
|
//! # Looks, not whole edits
|
||||||
|
//!
|
||||||
|
//! Every shipped preset reaches only the operations it names
|
||||||
|
//! ([`Reach::Named`]): a look applied to a corrected photograph must keep the
|
||||||
|
//! correction. A photographer's own saved edits keep [`Reach::Whole`], which
|
||||||
|
//! is what saving an edit has always meant.
|
||||||
|
//!
|
||||||
|
//! # Why the data is text files
|
||||||
|
//!
|
||||||
|
//! The same format the user's library is written in, so a shipped preset can
|
||||||
|
//! be read, diffed and copied into one's own library by hand, and each file
|
||||||
|
//! can say in a comment where its looks came from — the attribution a licence
|
||||||
|
//! may require travels with the data it covers.
|
||||||
|
//!
|
||||||
|
//! # Why this lives in the core
|
||||||
|
//!
|
||||||
|
//! It names operations — "Punch" is a statement about contrast and clarity —
|
||||||
|
//! and nothing in `ui/` may (`ui_names_no_operation.rs`, ARCH §4.3a). The
|
||||||
|
//! frontend asks for the listing and applies what it is handed.
|
||||||
|
|
||||||
|
use crate::preset::{Preset, PresetLibrary, Reach};
|
||||||
|
|
||||||
|
/// One group of shipped presets, as the sheet lists it.
|
||||||
|
pub struct Section {
|
||||||
|
/// A stable identifier, for a frontend that remembers which sections a
|
||||||
|
/// photographer folded away. Never shown.
|
||||||
|
pub id: &'static str,
|
||||||
|
/// What the section is called on screen, as a category path: `/`
|
||||||
|
/// separates the levels, so `Film/Colour` is a folder inside `Film`. The
|
||||||
|
/// same spelling a photographer's own preset names use for theirs.
|
||||||
|
pub title: &'static str,
|
||||||
|
/// The presets in it, every one reaching only what it names.
|
||||||
|
pub presets: PresetLibrary,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The files, in the order the sheet lists them.
|
||||||
|
const SECTIONS: &[(&str, &str, &str)] = &[
|
||||||
|
(
|
||||||
|
"essentials",
|
||||||
|
"Essentials",
|
||||||
|
include_str!("../presets/essentials.drpl"),
|
||||||
|
),
|
||||||
|
("skies", "Skies", include_str!("../presets/skies.drpl")),
|
||||||
|
(
|
||||||
|
"colour_film",
|
||||||
|
"Film/Colour",
|
||||||
|
include_str!("../presets/colour_film.drpl"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"cinema_film",
|
||||||
|
"Film/Cinema",
|
||||||
|
include_str!("../presets/cinema_film.drpl"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"bw_film",
|
||||||
|
"Film/Black and white",
|
||||||
|
include_str!("../presets/bw_film.drpl"),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Every shipped section, parsed.
|
||||||
|
///
|
||||||
|
/// Parsed on each call rather than held: it is a few kilobytes read when the
|
||||||
|
/// preset sheet is drawn, and a static would be one more thing to keep
|
||||||
|
/// consistent with the files in a test. A file that fails to parse costs its
|
||||||
|
/// section, with a warning, rather than the sheet — and the tests below make
|
||||||
|
/// sure none does.
|
||||||
|
pub fn sections() -> Vec<Section> {
|
||||||
|
SECTIONS
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(id, title, text)| match PresetLibrary::parse(text) {
|
||||||
|
Ok(library) => Some(Section {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
presets: as_looks(library),
|
||||||
|
}),
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("shipped preset section {id} is unreadable ({e}); skipping");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark every preset in `library` as a look.
|
||||||
|
///
|
||||||
|
/// Here rather than as a `reach = named` line in every block of every file:
|
||||||
|
/// the rule is about where a preset came from, and a file that forgot the
|
||||||
|
/// line would ship a preset that wiped a photographer's corrections.
|
||||||
|
fn as_looks(library: PresetLibrary) -> PresetLibrary {
|
||||||
|
let mut looks = PresetLibrary::default();
|
||||||
|
for (name, preset) in library.iter() {
|
||||||
|
let _ = looks.insert(name, preset.clone().with_reach(Reach::Named));
|
||||||
|
}
|
||||||
|
looks
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a listed preset comes from.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Origin {
|
||||||
|
/// The photographer's own, with no shipped preset of that name.
|
||||||
|
Yours,
|
||||||
|
/// Shipped, and not overridden.
|
||||||
|
Shipped,
|
||||||
|
/// Shipped, and overridden by the photographer's copy under the same
|
||||||
|
/// name. The copy is what applies; deleting it reverts to the shipped one.
|
||||||
|
Changed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row of the preset sheet.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Listed {
|
||||||
|
pub name: String,
|
||||||
|
pub origin: Origin,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One group of rows: the photographer's own, then each shipped section.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ListedSection {
|
||||||
|
/// `None` for the photographer's own presets.
|
||||||
|
pub id: Option<&'static str>,
|
||||||
|
pub title: &'static str,
|
||||||
|
pub rows: Vec<Listed>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything the sheet lists, in order, with the photographer's copies
|
||||||
|
/// standing in for the shipped presets they override.
|
||||||
|
///
|
||||||
|
/// Their own presets come first, because a photographer reaches for their own
|
||||||
|
/// work more than for anybody's defaults, and a copy of a shipped preset is
|
||||||
|
/// listed in the shipped section rather than among their own — it is still
|
||||||
|
/// that preset, changed, and belongs where they would look for it.
|
||||||
|
pub fn listing(yours: &PresetLibrary) -> Vec<ListedSection> {
|
||||||
|
let shipped = sections();
|
||||||
|
let is_shipped = |name: &str| shipped.iter().any(|section| section.presets.contains(name));
|
||||||
|
|
||||||
|
let mut out = vec![ListedSection {
|
||||||
|
id: None,
|
||||||
|
title: "Yours",
|
||||||
|
rows: yours
|
||||||
|
.names()
|
||||||
|
.filter(|name| !is_shipped(name))
|
||||||
|
.map(|name| Listed {
|
||||||
|
name: name.to_string(),
|
||||||
|
origin: Origin::Yours,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}];
|
||||||
|
out.extend(shipped.iter().map(|section| {
|
||||||
|
ListedSection {
|
||||||
|
id: Some(section.id),
|
||||||
|
title: section.title,
|
||||||
|
rows: section
|
||||||
|
.presets
|
||||||
|
.names()
|
||||||
|
.map(|name| Listed {
|
||||||
|
name: name.to_string(),
|
||||||
|
origin: if yours.contains(name) {
|
||||||
|
Origin::Changed
|
||||||
|
} else {
|
||||||
|
Origin::Shipped
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The preset a name means: the photographer's if they have one, the shipped
|
||||||
|
/// one otherwise.
|
||||||
|
pub fn lookup(yours: &PresetLibrary, name: &str) -> Option<Preset> {
|
||||||
|
yours.get(name).cloned().or_else(|| {
|
||||||
|
sections()
|
||||||
|
.into_iter()
|
||||||
|
.find_map(|section| section.presets.get(name).cloned())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `name` is a shipped preset's.
|
||||||
|
pub fn is_shipped(name: &str) -> bool {
|
||||||
|
sections()
|
||||||
|
.iter()
|
||||||
|
.any(|section| section.presets.contains(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the copies a first run used to seed, where they are still exactly
|
||||||
|
/// as seeded. Returns how many went.
|
||||||
|
///
|
||||||
|
/// Those copies would otherwise all list as changed — overriding a shipped
|
||||||
|
/// preset with an identical one — and would freeze the six at their old
|
||||||
|
/// values forever. One that differs in any way was tuned by somebody and is
|
||||||
|
/// kept: it is theirs, and it now overrides the shipped one, which is the
|
||||||
|
/// rule above doing what it is for.
|
||||||
|
///
|
||||||
|
/// Compared on the parameters and the film and not on [`Reach`]: the seeded
|
||||||
|
/// copies were whole edits, the shipped ones are looks, and that difference
|
||||||
|
/// is the thing this migration exists to deliver.
|
||||||
|
pub fn forget_unchanged_copies(yours: &mut PresetLibrary) -> usize {
|
||||||
|
let shipped = sections();
|
||||||
|
let stale: Vec<String> = yours
|
||||||
|
.iter()
|
||||||
|
.filter(|(name, preset)| {
|
||||||
|
shipped.iter().any(|section| {
|
||||||
|
section
|
||||||
|
.presets
|
||||||
|
.get(name)
|
||||||
|
.is_some_and(|s| s.params() == preset.params() && s.film() == preset.film())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map(|(name, _)| name.to_string())
|
||||||
|
.collect();
|
||||||
|
for name in &stale {
|
||||||
|
yours.remove(name);
|
||||||
|
}
|
||||||
|
stale.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::{EditGraph, Scope};
|
||||||
|
|
||||||
|
fn all() -> Vec<(&'static str, String, Preset)> {
|
||||||
|
sections()
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|s| {
|
||||||
|
let id = s.id;
|
||||||
|
s.presets
|
||||||
|
.iter()
|
||||||
|
.map(|(n, p)| (id, n.to_string(), p.clone()))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_file_parses_and_every_line_is_understood() {
|
||||||
|
// A misspelt key would otherwise be kept as a line this build does
|
||||||
|
// not understand — preserved faithfully, and doing nothing.
|
||||||
|
assert_eq!(
|
||||||
|
sections().len(),
|
||||||
|
SECTIONS.len(),
|
||||||
|
"a section failed to parse"
|
||||||
|
);
|
||||||
|
for (id, _, text) in SECTIONS {
|
||||||
|
let library = PresetLibrary::parse(text).unwrap();
|
||||||
|
assert_eq!(library.unread_lines(), 0, "{id} has lines nobody reads");
|
||||||
|
assert!(!library.is_empty(), "{id} is empty");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_shipped_name_is_unique_across_sections() {
|
||||||
|
// A name is what the lookup and the override key on. Two shipped
|
||||||
|
// presets sharing one would make one of them unreachable.
|
||||||
|
let mut names: Vec<String> = all().into_iter().map(|(_, n, _)| n).collect();
|
||||||
|
let before = names.len();
|
||||||
|
names.sort();
|
||||||
|
names.dedup();
|
||||||
|
assert_eq!(names.len(), before);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_shipped_preset_is_a_look() {
|
||||||
|
for (id, name, preset) in all() {
|
||||||
|
assert_eq!(preset.reach(), Reach::Named, "{id}/{name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_shipped_preset_names_parameters_this_build_actually_has() {
|
||||||
|
// A renamed parameter must break the build rather than ship a preset
|
||||||
|
// that quietly does nothing.
|
||||||
|
let graph = EditGraph::default_chain();
|
||||||
|
let capabilities = graph.capabilities();
|
||||||
|
for (id, name, preset) in all() {
|
||||||
|
for (op, param) in preset.params().keys() {
|
||||||
|
let capability = capabilities
|
||||||
|
.iter()
|
||||||
|
.find(|c| c.id.0 == op)
|
||||||
|
.unwrap_or_else(|| panic!("{id}/{name}: no operation {op:?}"));
|
||||||
|
assert!(
|
||||||
|
capability.params.iter().any(|p| p.id.0 == param),
|
||||||
|
"{id}/{name}: operation {op:?} has no parameter {param:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_shipped_preset_changes_something() {
|
||||||
|
// A preset that applies to nothing teaches the photographer that the
|
||||||
|
// list does not work.
|
||||||
|
for (id, name, preset) in all() {
|
||||||
|
let mut graph = EditGraph::default_chain();
|
||||||
|
let rebake = preset.apply(&mut graph, Scope::adjustments());
|
||||||
|
assert!(
|
||||||
|
rebake.wanted().is_some() || Preset::capture(&graph) != Preset::default(),
|
||||||
|
"{id}/{name} left the graph at its defaults"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_shipped_preset_carries_a_crop() {
|
||||||
|
for (id, name, preset) in all() {
|
||||||
|
assert!(!preset.touches_framing(), "{id}/{name} carries framing");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_values_stay_inside_what_the_controls_accept() {
|
||||||
|
// Clamping happens on apply, so an out-of-range literal would be
|
||||||
|
// silently trimmed and the preset would not be the one written.
|
||||||
|
for (id, name, preset) in all() {
|
||||||
|
let mut graph = EditGraph::default_chain();
|
||||||
|
preset
|
||||||
|
.clone()
|
||||||
|
.with_film(None)
|
||||||
|
.apply(&mut graph, Scope::everything())
|
||||||
|
.expect_no_film();
|
||||||
|
for ((op, param), value) in preset.params() {
|
||||||
|
let (op_id, param_id) = crate::preset::resolve(&graph, op, param).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
graph.param(op_id, param_id),
|
||||||
|
Some(*value),
|
||||||
|
"{id}/{name}: {op}.{param} = {value} was clamped"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the listing and the override ------------------------------------
|
||||||
|
|
||||||
|
fn yours_with(names: &[(&str, Preset)]) -> PresetLibrary {
|
||||||
|
let mut lib = PresetLibrary::default();
|
||||||
|
for (n, p) in names {
|
||||||
|
lib.insert(n, p.clone()).unwrap();
|
||||||
|
}
|
||||||
|
lib
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_shipped() -> (String, Preset) {
|
||||||
|
let (_, name, preset) = all().into_iter().next().unwrap();
|
||||||
|
(name, preset)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn your_copy_under_a_shipped_name_is_what_that_name_applies() {
|
||||||
|
let (name, shipped) = first_shipped();
|
||||||
|
let mine = Preset::default();
|
||||||
|
let yours = yours_with(&[(&name, mine.clone())]);
|
||||||
|
|
||||||
|
assert_eq!(lookup(&yours, &name), Some(mine));
|
||||||
|
assert_eq!(lookup(&PresetLibrary::default(), &name), Some(shipped));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn your_copy_is_listed_in_the_shipped_section_as_changed() {
|
||||||
|
let (name, _) = first_shipped();
|
||||||
|
let yours = yours_with(&[(&name, Preset::default()), ("Mine", Preset::default())]);
|
||||||
|
let listing = listing(&yours);
|
||||||
|
|
||||||
|
assert_eq!(listing[0].id, None);
|
||||||
|
assert_eq!(
|
||||||
|
listing[0].rows,
|
||||||
|
vec![Listed {
|
||||||
|
name: "Mine".into(),
|
||||||
|
origin: Origin::Yours
|
||||||
|
}],
|
||||||
|
"an override must not be listed twice"
|
||||||
|
);
|
||||||
|
let row = listing[1..]
|
||||||
|
.iter()
|
||||||
|
.flat_map(|s| &s.rows)
|
||||||
|
.find(|r| r.name == name)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(row.origin, Origin::Changed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deleting_your_copy_reverts_to_the_shipped_one() {
|
||||||
|
let (name, shipped) = first_shipped();
|
||||||
|
let mut yours = yours_with(&[(&name, Preset::default())]);
|
||||||
|
yours.remove(&name);
|
||||||
|
assert_eq!(lookup(&yours, &name), Some(shipped));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renaming_your_copy_cuts_the_link() {
|
||||||
|
let (name, shipped) = first_shipped();
|
||||||
|
let mut yours = yours_with(&[(&name, Preset::default())]);
|
||||||
|
yours.rename(&name, "My version").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(lookup(&yours, &name), Some(shipped));
|
||||||
|
assert_eq!(lookup(&yours, "My version"), Some(Preset::default()));
|
||||||
|
assert_eq!(listing(&yours)[0].rows[0].name, "My version");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_old_seeded_copies_are_forgotten_and_tuned_ones_kept() {
|
||||||
|
// The six as a first run wrote them: the same parameters, as whole
|
||||||
|
// edits, because that is what a seeded copy was.
|
||||||
|
let essentials = sections().into_iter().next().unwrap().presets;
|
||||||
|
let mut yours = PresetLibrary::default();
|
||||||
|
for (name, preset) in essentials.iter() {
|
||||||
|
yours
|
||||||
|
.insert(name, preset.clone().with_reach(Reach::Whole))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let tuned = essentials.names().next().unwrap().to_string();
|
||||||
|
yours.insert(&tuned, Preset::default()).unwrap();
|
||||||
|
yours.insert("Mine", Preset::default()).unwrap();
|
||||||
|
|
||||||
|
let forgotten = forget_unchanged_copies(&mut yours);
|
||||||
|
|
||||||
|
assert_eq!(forgotten, essentials.len() - 1);
|
||||||
|
assert!(yours.contains(&tuned), "a tuned copy was thrown away");
|
||||||
|
assert!(yours.contains("Mine"));
|
||||||
|
assert_eq!(yours.len(), 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -271,6 +271,32 @@ pub struct Declaration {
|
|||||||
/// Boxed so the rare node that declares one does not widen every
|
/// Boxed so the rare node that declares one does not widen every
|
||||||
/// declaration by the size of a presentation it does not have.
|
/// declaration by the size of a presentation it does not have.
|
||||||
pub presentation: Option<Box<PresentationDef>>,
|
pub presentation: Option<Box<PresentationDef>>,
|
||||||
|
/// Whether the node runs in camera RGB, ahead of the camera matrix —
|
||||||
|
/// `stage: camera`. See [`read_stage`].
|
||||||
|
pub camera_stage: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3e | FR-DEV-2
|
||||||
|
/// Where in the chain a declared node's colour comes from: `stage: camera` or
|
||||||
|
/// `stage: scene`, the default.
|
||||||
|
///
|
||||||
|
/// Camera RGB is where white balance's multipliers are defined, and it is the
|
||||||
|
/// only thing that belongs there (D19): every other operation is handed
|
||||||
|
/// working-space colour, so that a hue or a luminance weight means the same
|
||||||
|
/// thing whichever body took the frame. `view` is not offered. The view
|
||||||
|
/// transform is hand-written, and a declared node that clipped into a display
|
||||||
|
/// range would be exactly what ARCH §6.14 forbids of every node before it.
|
||||||
|
fn read_stage(root: &Mapping) -> Result<bool, String> {
|
||||||
|
match root.get("stage") {
|
||||||
|
None => Ok(false),
|
||||||
|
Some(v) => match as_str(v, "stage")? {
|
||||||
|
"camera" => Ok(true),
|
||||||
|
"scene" => Ok(false),
|
||||||
|
other => Err(format!(
|
||||||
|
"unknown stage {other:?}; expected \"camera\" or \"scene\""
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Declaration {
|
impl Declaration {
|
||||||
@@ -512,6 +538,7 @@ pub fn read_node(text: &str, ctx: &str, shared: &BTreeSet<&str>) -> Result<Node,
|
|||||||
"define",
|
"define",
|
||||||
"label",
|
"label",
|
||||||
"attributes",
|
"attributes",
|
||||||
|
"stage",
|
||||||
] {
|
] {
|
||||||
if root.contains_key(key) {
|
if root.contains_key(key) {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
@@ -564,6 +591,7 @@ pub fn read_node(text: &str, ctx: &str, shared: &BTreeSet<&str>) -> Result<Node,
|
|||||||
.collect();
|
.collect();
|
||||||
let tests = read_tests(root, ¶ms, &uniform_names, &helper_names)?;
|
let tests = read_tests(root, ¶ms, &uniform_names, &helper_names)?;
|
||||||
let presentation = read_presentation(root, ¶m_names)?;
|
let presentation = read_presentation(root, ¶m_names)?;
|
||||||
|
let camera_stage = read_stage(root)?;
|
||||||
|
|
||||||
Ok(Node::Declared(Box::new(Declaration {
|
Ok(Node::Declared(Box::new(Declaration {
|
||||||
id,
|
id,
|
||||||
@@ -580,6 +608,7 @@ pub fn read_node(text: &str, ctx: &str, shared: &BTreeSet<&str>) -> Result<Node,
|
|||||||
active,
|
active,
|
||||||
tests,
|
tests,
|
||||||
presentation,
|
presentation,
|
||||||
|
camera_stage,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -107,6 +107,8 @@ pub struct DeclaredOp {
|
|||||||
helpers: Vec<Helper>,
|
helpers: Vec<Helper>,
|
||||||
presentation: Option<Presentation>,
|
presentation: Option<Presentation>,
|
||||||
order: i64,
|
order: i64,
|
||||||
|
/// See `decl::read_stage`.
|
||||||
|
camera_stage: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One uniform: the name the fragment reads it by, and how to compute it.
|
/// One uniform: the name the fragment reads it by, and how to compute it.
|
||||||
@@ -208,6 +210,7 @@ impl DeclaredOp {
|
|||||||
wgsl: declaration.wgsl_body(),
|
wgsl: declaration.wgsl_body(),
|
||||||
helpers,
|
helpers,
|
||||||
presentation: declaration.presentation.as_deref().map(presentation),
|
presentation: declaration.presentation.as_deref().map(presentation),
|
||||||
|
camera_stage: declaration.camera_stage,
|
||||||
order: declaration.order,
|
order: declaration.order,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -292,6 +295,14 @@ impl Operation for DeclaredOp {
|
|||||||
fn presentation(&self) -> Option<Presentation> {
|
fn presentation(&self) -> Option<Presentation> {
|
||||||
self.presentation.clone()
|
self.presentation.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn stage(&self) -> crate::operation::Stage {
|
||||||
|
if self.camera_stage {
|
||||||
|
crate::operation::Stage::Camera
|
||||||
|
} else {
|
||||||
|
crate::operation::Stage::Scene
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A declared parameter as the descriptor the panel reads.
|
/// A declared parameter as the descriptor the panel reads.
|
||||||
|
|||||||
@@ -659,10 +659,10 @@ impl Attribute {
|
|||||||
///
|
///
|
||||||
/// `Effect` after `Colour` is a look laid over a settled picture — and is
|
/// `Effect` after `Colour` is a look laid over a settled picture — and is
|
||||||
/// the one arguable slot. A spectral film simulation declares
|
/// the one arguable slot. A spectral film simulation declares
|
||||||
/// [`crate::Operation::renders`] and replaces the base curve, which is an
|
/// [`crate::Operation::renders`] and takes the view transform's place at
|
||||||
/// argument for treating it as foundational rather than final; an array of
|
/// the very end of the chain (D19), which is an argument for treating it as
|
||||||
/// six cannot say "last, except when it is first". The tension is recorded
|
/// the rendering rather than one effect among others; an array of six
|
||||||
/// here rather than settled.
|
/// cannot say that. The tension is recorded here rather than settled.
|
||||||
///
|
///
|
||||||
/// Both ends were wrong for as long as this list only fed a row of chips
|
/// Both ends were wrong for as long as this list only fed a row of chips
|
||||||
/// nobody reads in order. It stopped being harmless when the same list
|
/// nobody reads in order. It stopped being harmless when the same list
|
||||||
|
|||||||
+192
-252
@@ -24,9 +24,10 @@
|
|||||||
//! v
|
//! v
|
||||||
//! +------------------------------------------+
|
//! +------------------------------------------+
|
||||||
//! | the fused point-operation pass | one dispatch
|
//! | the fused point-operation pass | one dispatch
|
||||||
//! | white balance, exposure, tone, colour |
|
//! | white balance (camera RGB) |
|
||||||
//! | the mask layers |
|
|
||||||
//! | camera RGB -> linear sRGB |
|
//! | camera RGB -> linear sRGB |
|
||||||
|
//! | exposure, tone, colour |
|
||||||
|
//! | the mask layers |
|
||||||
//! +------------------------------------------+
|
//! +------------------------------------------+
|
||||||
//! | rgba16float, linear, **unclipped**, at render resolution
|
//! | rgba16float, linear, **unclipped**, at render resolution
|
||||||
//! v
|
//! v
|
||||||
@@ -34,7 +35,13 @@
|
|||||||
//! | the detail stage - this module | one dispatch per pass
|
//! | the detail stage - this module | one dispatch per pass
|
||||||
//! | sharpen, NR, clarity, texture, spots |
|
//! | sharpen, NR, clarity, texture, spots |
|
||||||
//! +------------------------------------------+
|
//! +------------------------------------------+
|
||||||
//! | the last pass applies the output transform
|
//! | rgba16float, still scene-linear and unclipped
|
||||||
|
//! v
|
||||||
|
//! +------------------------------------------+
|
||||||
|
//! | the view pass | one dispatch
|
||||||
|
//! | view transform, or the film stock |
|
||||||
|
//! | output transform, mask reveal |
|
||||||
|
//! +------------------------------------------+
|
||||||
//! v
|
//! v
|
||||||
//! rgba8unorm display or export texture
|
//! rgba8unorm display or export texture
|
||||||
//! ```
|
//! ```
|
||||||
@@ -52,14 +59,13 @@
|
|||||||
//! texture, clarity, spot removal and sharpen/NR sit below the tone curve and
|
//! texture, clarity, spot removal and sharpen/NR sit below the tone curve and
|
||||||
//! the colour mixer.
|
//! the colour mixer.
|
||||||
//!
|
//!
|
||||||
//! **In linear light, after the camera matrix.** The fused pass works in
|
//! **In linear light, after the camera matrix.** A detail pass wants a
|
||||||
//! *camera* space, because white balance and exposure are physically
|
//! luminance, and camera RGB has no luminance — the three channels are
|
||||||
//! meaningful there and nowhere else. A detail pass is the opposite case: it
|
|
||||||
//! wants a luminance, and camera RGB has no luminance — the three channels are
|
|
||||||
//! whatever the CFA's dyes passed, and weighting them 0.2126/0.7152/0.0722
|
//! whatever the CFA's dyes passed, and weighting them 0.2126/0.7152/0.0722
|
||||||
//! would be numerology. So the split is taken *after* the `cam_to_srgb`
|
//! would be numerology. Since D19 only white balance runs in camera RGB; the
|
||||||
//! multiply, where the working space is linear sRGB and a luminance is a
|
//! `cam_to_srgb` multiply follows it, so every point operation, and every
|
||||||
//! luminance.
|
//! detail pass after them, works in linear sRGB primaries, where a luminance
|
||||||
|
//! is a luminance.
|
||||||
//!
|
//!
|
||||||
//! **Before the output transform, and before the clip.** FR-DEV-2 allows
|
//! **Before the output transform, and before the clip.** FR-DEV-2 allows
|
||||||
//! exactly one quantisation, at the display or export stage. A detail pass
|
//! exactly one quantisation, at the display or export stage. A detail pass
|
||||||
@@ -70,9 +76,11 @@
|
|||||||
//! therefore `rgba16float` and holds linear values that have **not** been
|
//! therefore `rgba16float` and holds linear values that have **not** been
|
||||||
//! clamped to `0..=1`: a recovered highlight is still above one at this point,
|
//! clamped to `0..=1`: a recovered highlight is still above one at this point,
|
||||||
//! and clipping it before the sharpener sees it would put a hard edge exactly
|
//! and clipping it before the sharpener sees it would put a hard edge exactly
|
||||||
//! where the sharpener is most visible. The last detail pass performs the
|
//! where the sharpener is most visible. Every detail pass writes such an
|
||||||
//! primaries conversion, the clip and the encode, so the single quantisation
|
//! intermediate, the last one included, and the view pass after them — the
|
||||||
//! stays single.
|
//! view transform, then the output transform's primaries, clip and encode —
|
||||||
|
//! is the one place the scene is fitted to a display (D19, ARCH §6.14), so
|
||||||
|
//! the single quantisation stays single.
|
||||||
//!
|
//!
|
||||||
//! **After framing, at render resolution.** The alternative — running detail
|
//! **After framing, at render resolution.** The alternative — running detail
|
||||||
//! on the demosaiced source before the framing prologue — is superficially
|
//! on the demosaiced source before the framing prologue — is superficially
|
||||||
@@ -122,8 +130,6 @@
|
|||||||
|
|
||||||
use std::fmt::Write as _;
|
use std::fmt::Write as _;
|
||||||
|
|
||||||
use dr_types::ColourSpace;
|
|
||||||
|
|
||||||
use crate::operation::{Helper, Operation, Uniform};
|
use crate::operation::{Helper, Operation, Uniform};
|
||||||
|
|
||||||
/// Floats the generated detail uniform block always carries, before an
|
/// Floats the generated detail uniform block always carries, before an
|
||||||
@@ -199,6 +205,10 @@ pub const DETAIL_BASE_UNIFORM_FIELDS: usize = 4;
|
|||||||
pub struct RenderScale {
|
pub struct RenderScale {
|
||||||
render: (u32, u32),
|
render: (u32, u32),
|
||||||
full: (u32, u32),
|
full: (u32, u32),
|
||||||
|
/// The whole framed photograph at source resolution: `full` before the
|
||||||
|
/// zoom and the tile were folded in. What a frame fraction is a fraction
|
||||||
|
/// of — see [`Self::frame_fraction`].
|
||||||
|
frame: (u32, u32),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RenderScale {
|
impl RenderScale {
|
||||||
@@ -210,9 +220,27 @@ impl RenderScale {
|
|||||||
/// [`crate::EditGraph::render_scale`] works both out from the framing, and
|
/// [`crate::EditGraph::render_scale`] works both out from the framing, and
|
||||||
/// is what a caller should normally use.
|
/// is what a caller should normally use.
|
||||||
pub fn new(render: (u32, u32), full: (u32, u32)) -> Self {
|
pub fn new(render: (u32, u32), full: (u32, u32)) -> Self {
|
||||||
|
let full = (full.0.max(1), full.1.max(1));
|
||||||
Self {
|
Self {
|
||||||
render: (render.0.max(1), render.1.max(1)),
|
render: (render.0.max(1), render.1.max(1)),
|
||||||
full: (full.0.max(1), full.1.max(1)),
|
full,
|
||||||
|
frame: full,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DSP-1 | FR-DSP-2
|
||||||
|
/// The same scale, for a render that shows only part of a larger frame.
|
||||||
|
///
|
||||||
|
/// `frame` is the whole framed photograph at source resolution — the crop
|
||||||
|
/// folded in, the zoom and any tile not. A zoomed view and an export tile
|
||||||
|
/// both look at part of the frame, and a clarity radius is a fraction of
|
||||||
|
/// the *frame*, not of the part: measured against the part, zooming in
|
||||||
|
/// shrinks the halo to a fraction of what the file will get, and two
|
||||||
|
/// neighbouring tiles of an export would each draw their own.
|
||||||
|
pub fn within(self, frame: (u32, u32)) -> Self {
|
||||||
|
Self {
|
||||||
|
frame: (frame.0.max(1), frame.1.max(1)),
|
||||||
|
..self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,8 +289,19 @@ impl RenderScale {
|
|||||||
/// For the compositional family — clarity, texture, dehaze — and the same
|
/// For the compositional family — clarity, texture, dehaze — and the same
|
||||||
/// unit `dr-gpu`'s mask rasteriser already converts feathers in. An edit
|
/// unit `dr-gpu`'s mask rasteriser already converts feathers in. An edit
|
||||||
/// stored this way is resolution-independent by construction.
|
/// stored this way is resolution-independent by construction.
|
||||||
|
///
|
||||||
|
/// Measured against the whole frame ([`Self::within`]), scaled by the
|
||||||
|
/// render's own short edge over the viewed region's. When the render shows
|
||||||
|
/// the whole frame the two sizes cancel and this is `fraction` of the
|
||||||
|
/// render's short edge exactly.
|
||||||
pub fn frame_fraction(&self, fraction: f32) -> f32 {
|
pub fn frame_fraction(&self, fraction: f32) -> f32 {
|
||||||
fraction * self.render.0.min(self.render.1) as f32
|
let render = self.render.0.min(self.render.1) as f32;
|
||||||
|
let viewed = self.full.0.min(self.full.1) as f32;
|
||||||
|
let frame = self.frame.0.min(self.frame.1) as f32;
|
||||||
|
if self.frame == self.full {
|
||||||
|
return fraction * render;
|
||||||
|
}
|
||||||
|
fraction * render * (frame / viewed)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a radius stated in source pixels survives this render.
|
/// Whether a radius stated in source pixels survives this render.
|
||||||
@@ -491,15 +530,6 @@ pub struct ComposedDetailPass {
|
|||||||
pub radius: u32,
|
pub radius: u32,
|
||||||
/// See [`DetailPass::output_scale`].
|
/// See [`DetailPass::output_scale`].
|
||||||
pub output_scale: u32,
|
pub output_scale: u32,
|
||||||
/// Whether this pass writes the display/export texture rather than another
|
|
||||||
/// linear intermediate.
|
|
||||||
///
|
|
||||||
/// True for exactly the last pass in the chain, which carries the output
|
|
||||||
/// transform — the primaries conversion, the clip and the encode that the
|
|
||||||
/// fused pass performs when there is no detail stage at all. Folding them
|
|
||||||
/// into the last pass rather than adding a resolve dispatch keeps the cost
|
|
||||||
/// of the stage at one dispatch per pass, not one plus one.
|
|
||||||
pub writes_output: bool,
|
|
||||||
/// Identifies this pass's *structure*, for the pipeline cache. Covers the
|
/// Identifies this pass's *structure*, for the pipeline cache. Covers the
|
||||||
/// generated source, not the uniform values — so moving a slider uploads a
|
/// generated source, not the uniform values — so moving a slider uploads a
|
||||||
/// buffer and reuses the compiled pipeline, exactly as the fused pass does.
|
/// buffer and reuses the compiled pipeline, exactly as the fused pass does.
|
||||||
@@ -537,6 +567,26 @@ impl ComposedDetail {
|
|||||||
.max()
|
.max()
|
||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DSP-2
|
||||||
|
/// How far the whole chain reads from the pixel it finally writes, in
|
||||||
|
/// render pixels: the halo a tile has to be grown by so that its interior
|
||||||
|
/// renders exactly as the untiled frame does.
|
||||||
|
///
|
||||||
|
/// The **sum** of the passes' reaches, not the widest of them. The passes
|
||||||
|
/// run one after another, so a pixel of the last one depends on pixels of
|
||||||
|
/// the one before it `r` away, each of which depends on pixels a further
|
||||||
|
/// `r'` away. A separable blur's two halves each reach `r` along one axis
|
||||||
|
/// and the sum over-counts them by a factor of two; that is the price of a
|
||||||
|
/// bound that is always safe, and it is paid only by export tiles.
|
||||||
|
///
|
||||||
|
/// One pixel per pass on top, for the reduced grids' bilinear taps.
|
||||||
|
pub fn reach(&self) -> u32 {
|
||||||
|
self.passes
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.radius.saturating_mul(p.output_scale).saturating_add(1))
|
||||||
|
.fold(0u32, u32::saturating_add)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TRACES: FR-DEV-3 | FR-DSP-1
|
/// TRACES: FR-DEV-3 | FR-DSP-1
|
||||||
@@ -547,10 +597,11 @@ impl ComposedDetail {
|
|||||||
/// an edit with no sharpening produces an empty chain and `dr-gpu` runs the
|
/// an edit with no sharpening produces an empty chain and `dr-gpu` runs the
|
||||||
/// single dispatch it always did.
|
/// single dispatch it always did.
|
||||||
///
|
///
|
||||||
/// `output` is the space the **last** pass encodes into, and it is a parameter
|
/// No pass encodes. Every pass writes a linear intermediate, the last one
|
||||||
/// for the same reason it is a parameter to [`crate::compose_with_framing`]: a
|
/// included, and the fused pass's view pass ([`crate::ComposedShader::view`])
|
||||||
/// screen render and a Display P3 export are the same edit and different
|
/// reads the last and performs the view transform and the output transform
|
||||||
/// shaders, and neither is more authoritative than the other.
|
/// (D19). So the output space is not a parameter here: a screen render and a
|
||||||
|
/// Display P3 export share one detail stage.
|
||||||
///
|
///
|
||||||
/// # The generated uniform block
|
/// # The generated uniform block
|
||||||
///
|
///
|
||||||
@@ -562,12 +613,8 @@ impl ComposedDetail {
|
|||||||
/// is there because a two-pass operation emitting one body for both directions
|
/// is there because a two-pass operation emitting one body for both directions
|
||||||
/// is a reasonable thing to want, and would otherwise need a uniform of its
|
/// is a reasonable thing to want, and would otherwise need a uniform of its
|
||||||
/// own purely to say which half it is in.
|
/// own purely to say which half it is in.
|
||||||
pub fn compose_detail(
|
pub fn compose_detail(ops: &[Box<dyn Operation>], scale: RenderScale) -> ComposedDetail {
|
||||||
ops: &[Box<dyn Operation>],
|
compose_detail_with(ops, &[], scale)
|
||||||
scale: RenderScale,
|
|
||||||
output: ColourSpace,
|
|
||||||
) -> ComposedDetail {
|
|
||||||
compose_detail_with(ops, &[], scale, output)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TRACES: FR-DEV-8
|
/// TRACES: FR-DEV-8
|
||||||
@@ -592,7 +639,6 @@ pub fn compose_detail_with(
|
|||||||
ops: &[Box<dyn Operation>],
|
ops: &[Box<dyn Operation>],
|
||||||
spots: &[DetailPass],
|
spots: &[DetailPass],
|
||||||
scale: RenderScale,
|
scale: RenderScale,
|
||||||
output: ColourSpace,
|
|
||||||
) -> ComposedDetail {
|
) -> ComposedDetail {
|
||||||
// Every pass of every active detail operation, flattened, carrying the
|
// Every pass of every active detail operation, flattened, carrying the
|
||||||
// operation it came from for the uniform prefix and the helper set.
|
// operation it came from for the uniform prefix and the helper set.
|
||||||
@@ -624,45 +670,13 @@ pub fn compose_detail_with(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// An active detail operation that emitted nothing at this scale.
|
// An active detail operation may emit nothing at this scale — an
|
||||||
//
|
// acutance operation on a heavy proxy, whose one-source-pixel radius is a
|
||||||
// Legal, and the honest answer for an acutance operation on a heavy proxy
|
// third of a render pixel (see [`RenderScale`]). The chain is then empty
|
||||||
// — a one-source-pixel radius is a third of a render pixel there and no
|
// while the fused pass has stopped at linear working values, and that is
|
||||||
// kernel represents a third of a pixel (see [`RenderScale`]). But it opens
|
// fine: the fused pass's view pass reads the fused result directly and
|
||||||
// a hole between the two halves of the composition: [`compose_full`]
|
// performs the output transform. Before D19 the last detail pass encoded,
|
||||||
// decides to hand on linear working values from the *operations*, which it
|
// and this case needed a body-less resolve pass to do it.
|
||||||
// must, having no scale to consult, so the fused pass has already stopped
|
|
||||||
// short of the output transform. Returning an empty chain here would leave
|
|
||||||
// that transform undone and bind an `rgba16float` shader to an
|
|
||||||
// `rgba8unorm` target, which surfaces as a wgpu validation failure a long
|
|
||||||
// way from the cause.
|
|
||||||
//
|
|
||||||
// So the chain is never empty when the fused pass is expecting one: a
|
|
||||||
// single pass with no body, which reads the intermediate and performs the
|
|
||||||
// output transform the fused pass skipped. One dispatch, in the uncommon
|
|
||||||
// case where a photographer has a kernel switched on at a scale that
|
|
||||||
// cannot draw it — against the alternative of the preview failing outright
|
|
||||||
// or `compose_full` growing a resolution argument it has no other use for.
|
|
||||||
if planned.is_empty() && ops.iter().any(|o| o.is_active() && o.detail().is_some()) {
|
|
||||||
return ComposedDetail {
|
|
||||||
passes: vec![compose_one(
|
|
||||||
RESOLVE_ID,
|
|
||||||
&[],
|
|
||||||
&DetailPass {
|
|
||||||
output_scale: 1,
|
|
||||||
label: "resolve",
|
|
||||||
radius: 0,
|
|
||||||
wgsl: String::new(),
|
|
||||||
uniforms: Vec::new(),
|
|
||||||
storage: Vec::new(),
|
|
||||||
},
|
|
||||||
0,
|
|
||||||
scale,
|
|
||||||
output,
|
|
||||||
true,
|
|
||||||
)],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// TRACES: NFR-P5
|
// TRACES: NFR-P5
|
||||||
// A pass whose body is empty changes nothing but where the pixels are: it
|
// A pass whose body is empty changes nothing but where the pixels are: it
|
||||||
@@ -674,12 +688,10 @@ pub fn compose_detail_with(
|
|||||||
// 2560 x 1600 frame on the reference laptop with its clocks held down.
|
// 2560 x 1600 frame on the reference laptop with its clocks held down.
|
||||||
//
|
//
|
||||||
// Dropped here, where the chain is still a list, and only where dropping
|
// Dropped here, where the chain is still a list, and only where dropping
|
||||||
// it is exact:
|
// it is exact. The last pass is no exception since D19: it writes an
|
||||||
|
// `rgba16float` intermediate like the others, and the view pass reads
|
||||||
|
// whichever one the chain last wrote.
|
||||||
//
|
//
|
||||||
// - **Not the last pass.** The last pass performs the output transform on
|
|
||||||
// what it read from an `rgba16float` intermediate. Moving that transform
|
|
||||||
// onto the pass before would apply it to that pass's `f32` result
|
|
||||||
// instead, which is a different rounding of the same picture.
|
|
||||||
// - **Not after a reduced pass.** A full-resolution pass ends the reduced
|
// - **Not after a reduced pass.** A full-resolution pass ends the reduced
|
||||||
// chain (see `DetailRunner::encode`), so one that follows a scaled pass
|
// chain (see `DetailRunner::encode`), so one that follows a scaled pass
|
||||||
// is what stops the next operation reading the last one's base. None of
|
// is what stops the next operation reading the last one's base. None of
|
||||||
@@ -688,45 +700,29 @@ pub fn compose_detail_with(
|
|||||||
// Everywhere else the pass before and the pass after exchange the same
|
// Everywhere else the pass before and the pass after exchange the same
|
||||||
// `rgba16float` texels either way, `aux` included.
|
// `rgba16float` texels either way, `aux` included.
|
||||||
let mut kept: Vec<(&str, &[Helper], DetailPass, usize)> = Vec::with_capacity(planned.len());
|
let mut kept: Vec<(&str, &[Helper], DetailPass, usize)> = Vec::with_capacity(planned.len());
|
||||||
let total = planned.len();
|
for entry in planned {
|
||||||
for (position, entry) in planned.into_iter().enumerate() {
|
|
||||||
let after_full = kept.last().is_none_or(|(_, _, p, _)| p.output_scale <= 1);
|
let after_full = kept.last().is_none_or(|(_, _, p, _)| p.output_scale <= 1);
|
||||||
let droppable = position + 1 < total && after_full && entry.2.is_identity();
|
let droppable = after_full && entry.2.is_identity();
|
||||||
if !droppable {
|
if !droppable {
|
||||||
kept.push(entry);
|
kept.push(entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let planned = kept;
|
let planned = kept;
|
||||||
|
|
||||||
let last = planned.len().saturating_sub(1);
|
|
||||||
let passes = planned
|
let passes = planned
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.enumerate()
|
.map(|(id, helpers, pass, index)| compose_one(id, helpers, &pass, index, scale))
|
||||||
.map(|(position, (id, helpers, pass, index))| {
|
|
||||||
compose_one(id, helpers, &pass, index, scale, output, position == last)
|
|
||||||
})
|
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
ComposedDetail { passes }
|
ComposedDetail { passes }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The operation id the resolve pass is labelled with.
|
|
||||||
///
|
|
||||||
/// Not an operation: no `ops/*.yaml` declares it and nothing in the chain
|
|
||||||
/// answers to it. It exists so the generated label reads `detail/resolve`
|
|
||||||
/// rather than borrowing the id of whichever operation happened to fall
|
|
||||||
/// through, which would send a reader looking for a bug in that operation.
|
|
||||||
const RESOLVE_ID: &str = "detail";
|
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
fn compose_one(
|
fn compose_one(
|
||||||
id: &str,
|
id: &str,
|
||||||
helpers: &[Helper],
|
helpers: &[Helper],
|
||||||
pass: &DetailPass,
|
pass: &DetailPass,
|
||||||
index: usize,
|
index: usize,
|
||||||
scale: RenderScale,
|
scale: RenderScale,
|
||||||
output: ColourSpace,
|
|
||||||
writes_output: bool,
|
|
||||||
) -> ComposedDetailPass {
|
) -> ComposedDetailPass {
|
||||||
let prefix = format!("{}_{index}", crate::operation::sanitise(id));
|
let prefix = format!("{}_{index}", crate::operation::sanitise(id));
|
||||||
|
|
||||||
@@ -772,41 +768,13 @@ fn compose_one(
|
|||||||
let _ = writeln!(helper_src, "{}\n", h.source.trim_end());
|
let _ = writeln!(helper_src, "{}\n", h.source.trim_end());
|
||||||
}
|
}
|
||||||
|
|
||||||
// The storage format and the tail are the *only* difference between an
|
// Every pass writes another linear intermediate: no clip and no encode,
|
||||||
// intermediate pass and the final one. Everything above — the taps, the
|
// because the view pass after the last one still has to read real values
|
||||||
// uniforms, the body — is identical, which is what lets an operation write
|
// (D19). `aux` rides in alpha. A pass that never touches it hands on
|
||||||
// one kernel without knowing whether it happens to be last in the chain.
|
// whatever it was given, so the lane costs an operation that does not want
|
||||||
let (store_format, tail) = if writes_output {
|
// it exactly one copy of a value it already read.
|
||||||
(
|
let store_format = "rgba16float";
|
||||||
"rgba8unorm",
|
let tail = " textureStore(output, coord, vec4<f32>(c, aux));";
|
||||||
format!(
|
|
||||||
"{} // Clip to the output gamut and encode. The one quantisation\n\
|
|
||||||
\x20 // the pipeline performs (FR-DEV-2), and it is here rather than\n\
|
|
||||||
\x20 // in the fused pass because this is now the last thing to run.\n\
|
|
||||||
\x20 c = clamp(c, vec3<f32>(0.0), vec3<f32>(1.0));\n\
|
|
||||||
\x20 textureStore(output, coord, vec4<f32>(encode_output(c), 1.0));",
|
|
||||||
crate::operation::primaries_conversion(output)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
(
|
|
||||||
"rgba16float",
|
|
||||||
" // Another linear intermediate: no clip and no encode, because\n\
|
|
||||||
\x20 // the pass after this one still has to read real values.\n\
|
|
||||||
\x20 //\n\
|
|
||||||
\x20 // `aux` rides in alpha. A pass that never touches it hands on\n\
|
|
||||||
\x20 // whatever it was given, so the lane costs an operation that\n\
|
|
||||||
\x20 // does not want it exactly one copy of a value it already read.\n\
|
|
||||||
\x20 textureStore(output, coord, vec4<f32>(c, aux));"
|
|
||||||
.to_string(),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
let encode_fn = if writes_output {
|
|
||||||
crate::operation::encode_output_fn(output)
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
let label = format!("{id}/{}", pass.label);
|
let label = format!("{id}/{}", pass.label);
|
||||||
let indented = body
|
let indented = body
|
||||||
@@ -823,7 +791,7 @@ fn compose_one(
|
|||||||
// way back to a coordinate.
|
// way back to a coordinate.
|
||||||
//
|
//
|
||||||
// In: linear sRGB, scene-referred, **unclipped**, at render resolution.
|
// In: linear sRGB, scene-referred, **unclipped**, at render resolution.
|
||||||
// Out: {}
|
// Out: the same, for the next pass or for the view pass after the last.
|
||||||
|
|
||||||
struct Params {{
|
struct Params {{
|
||||||
{uniform_fields}}}
|
{uniform_fields}}}
|
||||||
@@ -907,7 +875,7 @@ fn reduced_at(coord: vec2<i32>) -> f32 {{
|
|||||||
return mix(mix(s00, s10, f.x), mix(s01, s11, f.x), f.y);
|
return mix(mix(s00, s10, f.x), mix(s01, s11, f.x), f.y);
|
||||||
}}
|
}}
|
||||||
|
|
||||||
{helper_src}{encode_fn}
|
{helper_src}
|
||||||
@compute @workgroup_size(8, 8, 1)
|
@compute @workgroup_size(8, 8, 1)
|
||||||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
|
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
|
||||||
let dims = textureDimensions(output);
|
let dims = textureDimensions(output);
|
||||||
@@ -936,12 +904,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
|
|||||||
|
|
||||||
{tail}
|
{tail}
|
||||||
}}
|
}}
|
||||||
",
|
"
|
||||||
if writes_output {
|
|
||||||
"display-encoded, in the output space."
|
|
||||||
} else {
|
|
||||||
"linear sRGB, for the next pass."
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let structure_hash = crate::operation::hash_source(&source);
|
let structure_hash = crate::operation::hash_source(&source);
|
||||||
@@ -956,7 +919,6 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
|
|||||||
// dispatch size and a declaration is data, which since FR-PLG-2 can
|
// dispatch size and a declaration is data, which since FR-PLG-2 can
|
||||||
// come from a file this build did not write.
|
// come from a file this build did not write.
|
||||||
output_scale: pass.output_scale.max(1),
|
output_scale: pass.output_scale.max(1),
|
||||||
writes_output,
|
|
||||||
structure_hash,
|
structure_hash,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1013,6 +975,21 @@ mod tests {
|
|||||||
assert!((export.frame_fraction(0.01) - 40.0).abs() < 0.5);
|
assert!((export.frame_fraction(0.01) - 40.0).abs() < 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_frame_fraction_does_not_shrink_with_the_zoom_or_the_tile() {
|
||||||
|
// TRACES: FR-DSP-1 | FR-DSP-2
|
||||||
|
// A 6000×4000 frame. At fit in a 1500×1000 panel, 1% of it is 10
|
||||||
|
// render pixels; zoomed to 1:1 on a 1500×1000 corner of it, the same
|
||||||
|
// 1% is 40 — the 40 the file gets — and an export tile of that corner
|
||||||
|
// must say 40 too, or each tile draws its own halo and the seams show.
|
||||||
|
let fit = RenderScale::new((1500, 1000), (6000, 4000));
|
||||||
|
assert!((fit.frame_fraction(0.01) - 10.0).abs() < 1e-3);
|
||||||
|
let zoomed = RenderScale::new((1500, 1000), (1500, 1000)).within((6000, 4000));
|
||||||
|
assert!((zoomed.frame_fraction(0.01) - 40.0).abs() < 1e-3);
|
||||||
|
let tile = RenderScale::full((1024, 1024)).within((6000, 4000));
|
||||||
|
assert!((tile.frame_fraction(0.01) - 40.0).abs() < 1e-3);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn zooming_to_one_to_one_makes_the_preview_exact() {
|
fn zooming_to_one_to_one_makes_the_preview_exact() {
|
||||||
// The reason there is no separate full-resolution preview path: the
|
// The reason there is no separate full-resolution preview path: the
|
||||||
@@ -1095,27 +1072,19 @@ mod tests {
|
|||||||
// dispatch. An unedited photograph must not pay for a sharpener it is
|
// dispatch. An unedited photograph must not pay for a sharpener it is
|
||||||
// not using.
|
// not using.
|
||||||
let ops = with_blur(0.0);
|
let ops = with_blur(0.0);
|
||||||
let composed = compose_detail(
|
let composed = compose_detail(&ops, RenderScale::full((512, 512)));
|
||||||
&ops,
|
|
||||||
RenderScale::full((512, 512)),
|
|
||||||
dr_types::ColourSpace::Srgb,
|
|
||||||
);
|
|
||||||
assert!(composed.is_empty());
|
assert!(composed.is_empty());
|
||||||
assert_eq!(fused(&ops).output_mode, OutputMode::Encoded);
|
assert_eq!(fused(&ops).output_mode, OutputMode::Encoded);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_separable_blur_becomes_two_passes_and_only_the_last_encodes() {
|
fn a_separable_blur_becomes_two_passes_and_neither_encodes() {
|
||||||
// The multi-pass case, which is the one the ping-pong exists for. The
|
// The multi-pass case, which is the one the ping-pong exists for. Both
|
||||||
// first pass writes a linear intermediate and the second writes the
|
// passes write linear intermediates, and the fused pass's view pass
|
||||||
// display texture — so the output transform happens exactly once, at
|
// reads the second and performs the view transform and the output
|
||||||
// the end, wherever the end happens to be.
|
// transform — so those happen exactly once, after every kernel (D19).
|
||||||
let ops = with_blur(0.05);
|
let ops = with_blur(0.05);
|
||||||
let composed = compose_detail(
|
let composed = compose_detail(&ops, RenderScale::full((512, 512)));
|
||||||
&ops,
|
|
||||||
RenderScale::full((512, 512)),
|
|
||||||
dr_types::ColourSpace::Srgb,
|
|
||||||
);
|
|
||||||
assert_eq!(composed.len(), 2);
|
assert_eq!(composed.len(), 2);
|
||||||
|
|
||||||
let first = &composed.passes[0];
|
let first = &composed.passes[0];
|
||||||
@@ -1123,13 +1092,16 @@ mod tests {
|
|||||||
assert_eq!(first.label, "detail_probe/horizontal");
|
assert_eq!(first.label, "detail_probe/horizontal");
|
||||||
assert_eq!(last.label, "detail_probe/vertical");
|
assert_eq!(last.label, "detail_probe/vertical");
|
||||||
|
|
||||||
assert!(!first.writes_output);
|
for pass in [first, last] {
|
||||||
assert!(first.source.contains("texture_storage_2d<rgba16float"));
|
assert!(pass.source.contains("texture_storage_2d<rgba16float"));
|
||||||
assert!(!first.source.contains("fn encode_output"));
|
assert!(!pass.source.contains("fn encode_output"));
|
||||||
|
assert!(!pass.source.contains("view_sigmoid"));
|
||||||
assert!(last.writes_output);
|
}
|
||||||
assert!(last.source.contains("texture_storage_2d<rgba8unorm"));
|
let view = fused(&ops)
|
||||||
assert!(last.source.contains("fn encode_output"));
|
.view
|
||||||
|
.expect("a view pass follows the detail stage");
|
||||||
|
assert!(view.source.contains("fn encode_output"));
|
||||||
|
assert!(view.source.contains("c = view_sigmoid("));
|
||||||
|
|
||||||
// Two passes of one operation are two shaders, so they must not share
|
// Two passes of one operation are two shaders, so they must not share
|
||||||
// a pipeline-cache entry — the classic way a second pass silently runs
|
// a pipeline-cache entry — the classic way a second pass silently runs
|
||||||
@@ -1143,11 +1115,7 @@ mod tests {
|
|||||||
// and the composer rewrites it to a prefixed struct field, so two
|
// and the composer rewrites it to a prefixed struct field, so two
|
||||||
// operations may both call a uniform `radius` and neither has to know.
|
// operations may both call a uniform `radius` and neither has to know.
|
||||||
let ops = with_blur(0.05);
|
let ops = with_blur(0.05);
|
||||||
let composed = compose_detail(
|
let composed = compose_detail(&ops, RenderScale::full((512, 512)));
|
||||||
&ops,
|
|
||||||
RenderScale::full((512, 512)),
|
|
||||||
dr_types::ColourSpace::Srgb,
|
|
||||||
);
|
|
||||||
let src = &composed.passes[0].source;
|
let src = &composed.passes[0].source;
|
||||||
assert!(src.contains("detail_probe_0_radius: f32,"));
|
assert!(src.contains("detail_probe_0_radius: f32,"));
|
||||||
assert!(src.contains("let r = i32(u.detail_probe_0_radius);"));
|
assert!(src.contains("let r = i32(u.detail_probe_0_radius);"));
|
||||||
@@ -1164,13 +1132,7 @@ mod tests {
|
|||||||
// outright by the WGSL uniform address space rules, and the failure
|
// outright by the WGSL uniform address space rules, and the failure
|
||||||
// arrives as a shader compilation error against generated source.
|
// arrives as a shader compilation error against generated source.
|
||||||
let ops = with_blur(0.05);
|
let ops = with_blur(0.05);
|
||||||
for pass in compose_detail(
|
for pass in compose_detail(&ops, RenderScale::full((512, 512))).passes {
|
||||||
&ops,
|
|
||||||
RenderScale::full((512, 512)),
|
|
||||||
dr_types::ColourSpace::Srgb,
|
|
||||||
)
|
|
||||||
.passes
|
|
||||||
{
|
|
||||||
assert_eq!(pass.uniforms.len() % 4, 0, "{}", pass.label);
|
assert_eq!(pass.uniforms.len() % 4, 0, "{}", pass.label);
|
||||||
assert!(pass.uniforms.iter().all(|v| v.is_finite()));
|
assert!(pass.uniforms.iter().all(|v| v.is_finite()));
|
||||||
// The base block is first and fixed, so a pass never addresses a
|
// The base block is first and fixed, so a pass never addresses a
|
||||||
@@ -1189,7 +1151,7 @@ mod tests {
|
|||||||
// the truth rather than zero.
|
// the truth rather than zero.
|
||||||
let ops = with_blur(0.05);
|
let ops = with_blur(0.05);
|
||||||
let scale = RenderScale::full((400, 400));
|
let scale = RenderScale::full((400, 400));
|
||||||
let composed = compose_detail(&ops, scale, dr_types::ColourSpace::Srgb);
|
let composed = compose_detail(&ops, scale);
|
||||||
let expected = BoxBlur::with_radius(0.05).kernel(scale);
|
let expected = BoxBlur::with_radius(0.05).kernel(scale);
|
||||||
assert_eq!(expected, 20, "5% of a 400px edge");
|
assert_eq!(expected, 20, "5% of a 400px edge");
|
||||||
assert_eq!(composed.radius(), expected);
|
assert_eq!(composed.radius(), expected);
|
||||||
@@ -1208,7 +1170,7 @@ mod tests {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|&(w, h)| {
|
.map(|&(w, h)| {
|
||||||
let scale = RenderScale::full((w, h));
|
let scale = RenderScale::full((w, h));
|
||||||
let composed = compose_detail(&ops, scale, dr_types::ColourSpace::Srgb);
|
let composed = compose_detail(&ops, scale);
|
||||||
composed.radius() as f32 / w.min(h) as f32
|
composed.radius() as f32 / w.min(h) as f32
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -1226,14 +1188,14 @@ mod tests {
|
|||||||
// cannot see each other. `compose_full` decides to hand on linear
|
// cannot see each other. `compose_full` decides to hand on linear
|
||||||
// working values from the *operations* — it has no resolution to
|
// working values from the *operations* — it has no resolution to
|
||||||
// consult — while this composer converts a radius and can legitimately
|
// consult — while this composer converts a radius and can legitimately
|
||||||
// decide there is nothing to draw at this size. An empty chain would
|
// decide there is nothing to draw at this size.
|
||||||
// then leave the output transform undone: the fused pass writes
|
|
||||||
// `rgba16float` and the frontend binds an `rgba8unorm` target to it.
|
|
||||||
//
|
//
|
||||||
// A photographer meets this by turning on capture sharpening or
|
// A photographer meets this by turning on capture sharpening or
|
||||||
// luminance noise reduction while the develop view is fitted to a
|
// luminance noise reduction while the develop view is fitted to a
|
||||||
// large file, which is the normal way to work, so it is not an edge
|
// large file, which is the normal way to work. Before D19 an empty
|
||||||
// case that can be left to fail.
|
// chain left the output transform undone and needed a resolve pass;
|
||||||
|
// now the view pass does the output transform whatever the chain
|
||||||
|
// holds.
|
||||||
let ops = with_blur(0.001);
|
let ops = with_blur(0.001);
|
||||||
let scale = RenderScale::full((400, 400));
|
let scale = RenderScale::full((400, 400));
|
||||||
assert!(ops.last().expect("the blur").is_active());
|
assert!(ops.last().expect("the blur").is_active());
|
||||||
@@ -1243,74 +1205,59 @@ mod tests {
|
|||||||
"the premise: a radius too small to draw emits no pass"
|
"the premise: a radius too small to draw emits no pass"
|
||||||
);
|
);
|
||||||
|
|
||||||
let composed = compose_detail(&ops, scale, dr_types::ColourSpace::Srgb);
|
// Empty, and that is fine since D19: nothing in the chain encodes, so
|
||||||
assert_eq!(composed.len(), 1, "the chain must not be empty here");
|
// there is no output transform for an empty chain to leave undone.
|
||||||
assert_eq!(composed.radius(), 0, "it reads only the pixel it writes");
|
// The fused pass stopped at linear values and its view pass reads
|
||||||
|
// them directly.
|
||||||
let resolve = &composed.passes[0];
|
let composed = compose_detail(&ops, scale);
|
||||||
assert_eq!(resolve.label, "detail/resolve");
|
assert!(composed.is_empty());
|
||||||
assert!(resolve.writes_output);
|
let fused = fused(&ops);
|
||||||
assert!(resolve.source.contains("texture_storage_2d<rgba8unorm"));
|
assert_eq!(fused.output_mode, OutputMode::LinearWorking);
|
||||||
assert!(resolve.source.contains("fn encode_output"));
|
let view = fused
|
||||||
// Exactly the fixed base block and no more: a pass with no body has
|
.view
|
||||||
// nothing of its own to upload, and the block still has to be a
|
.expect("the view pass performs the output transform");
|
||||||
// multiple of sixteen bytes.
|
assert_eq!(view.output_mode, OutputMode::Encoded);
|
||||||
assert_eq!(resolve.uniforms.len(), DETAIL_BASE_UNIFORM_FIELDS);
|
assert!(view.source.contains("fn encode_output"));
|
||||||
assert_eq!(resolve.uniforms.len() % 4, 0);
|
|
||||||
|
|
||||||
// And it really is a copy: the fused pass composed alongside it is the
|
|
||||||
// one that stopped short, so the two agree about who encodes.
|
|
||||||
assert_eq!(fused(&ops).output_mode, OutputMode::LinearWorking);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_pass_that_changes_nothing_is_dropped_where_that_is_exact() {
|
fn a_pass_that_changes_nothing_is_dropped_where_that_is_exact() {
|
||||||
// TRACES: NFR-P5
|
// TRACES: NFR-P5
|
||||||
// Capture sharpening at a scale too coarse to draw its radius emits a
|
// A pass with an empty body costs a render-sized read and write and
|
||||||
// pass with an empty body. Between two other passes it costs a
|
// changes no texel, so it goes — wherever it falls since D19, the last
|
||||||
// render-sized read and write and changes no texel, so it goes; as the
|
// position included, because the last pass writes an intermediate like
|
||||||
// last pass it performs the output transform on the intermediate, and
|
// every other and the view pass reads whichever the chain last wrote.
|
||||||
// moving that onto the pass before would round differently, so it
|
// Built by hand, and run as a repair so it goes first: no operation
|
||||||
// stays.
|
// emits one any more (capture sharpening at a scale too coarse to draw
|
||||||
use crate::ops::{capture_sharpen, CaptureSharpen, NoiseReduction};
|
// its radius used to, and now emits nothing).
|
||||||
let sharpen = || -> Box<dyn Operation> {
|
use crate::ops::NoiseReduction;
|
||||||
let mut op = CaptureSharpen::new();
|
|
||||||
op.set_param(capture_sharpen::AMOUNT, 60.0);
|
|
||||||
Box::new(op)
|
|
||||||
};
|
|
||||||
let chroma = || -> Box<dyn Operation> { Box::new(NoiseReduction::with_amounts(0.0, 60.0)) };
|
let chroma = || -> Box<dyn Operation> { Box::new(NoiseReduction::with_amounts(0.0, 60.0)) };
|
||||||
// A 24 MP frame fitted to a panel: a one-source-pixel radius is a
|
|
||||||
// quarter of a render pixel.
|
|
||||||
let scale = RenderScale::new((1500, 1000), (6000, 4000));
|
let scale = RenderScale::new((1500, 1000), (6000, 4000));
|
||||||
let unresolved = sharpen().detail().expect("a detail stage").passes(scale);
|
let nothing = DetailPass {
|
||||||
assert!(
|
output_scale: 1,
|
||||||
unresolved.len() == 1 && unresolved[0].is_identity(),
|
label: "nothing",
|
||||||
"the premise: sharpening at this scale is one pass that does nothing"
|
radius: 0,
|
||||||
);
|
wgsl: "// `c` already holds this pixel.".to_string(),
|
||||||
let labels = |ops: &[Box<dyn Operation>]| -> Vec<String> {
|
uniforms: Vec::new(),
|
||||||
compose_detail(ops, scale, dr_types::ColourSpace::Srgb)
|
storage: Vec::new(),
|
||||||
|
};
|
||||||
|
assert!(nothing.is_identity(), "the premise");
|
||||||
|
let labels: Vec<String> =
|
||||||
|
compose_detail_with(&[chroma()], std::slice::from_ref(¬hing), scale)
|
||||||
.passes
|
.passes
|
||||||
.iter()
|
.iter()
|
||||||
.map(|p| p.label.clone())
|
.map(|p| p.label.clone())
|
||||||
.collect()
|
.collect();
|
||||||
};
|
|
||||||
|
|
||||||
// First, ahead of the chroma passes: dropped.
|
|
||||||
let first = labels(&[sharpen(), chroma()]);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
first,
|
labels,
|
||||||
[
|
[
|
||||||
"noise_reduction/chroma-horizontal",
|
"noise_reduction/chroma-horizontal",
|
||||||
"noise_reduction/chroma-vertical"
|
"noise_reduction/chroma-vertical"
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
// Last, after them: kept, and it is the pass that encodes.
|
// Alone: dropped too, and the chain is empty — the view pass
|
||||||
let last = labels(&[chroma(), sharpen()]);
|
// finishes the frame.
|
||||||
assert_eq!(last.len(), 3);
|
assert!(compose_detail_with(&[], &[nothing], scale).is_empty());
|
||||||
assert_eq!(last[2], "capture_sharpen/unresolved");
|
|
||||||
// Alone: kept, because the fused pass stopped short and something has
|
|
||||||
// to finish the frame.
|
|
||||||
assert_eq!(labels(&[sharpen()]), ["capture_sharpen/unresolved"]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1325,11 +1272,7 @@ mod tests {
|
|||||||
// A pass that says nothing about `aux` hands on what it was given,
|
// A pass that says nothing about `aux` hands on what it was given,
|
||||||
// which is why the box blur below needs no knowledge of it.
|
// which is why the box blur below needs no knowledge of it.
|
||||||
let ops = with_blur(0.05);
|
let ops = with_blur(0.05);
|
||||||
let composed = compose_detail(
|
let composed = compose_detail(&ops, RenderScale::full((512, 512)));
|
||||||
&ops,
|
|
||||||
RenderScale::full((512, 512)),
|
|
||||||
dr_types::ColourSpace::Srgb,
|
|
||||||
);
|
|
||||||
|
|
||||||
for pass in &composed.passes {
|
for pass in &composed.passes {
|
||||||
assert!(
|
assert!(
|
||||||
@@ -1344,11 +1287,12 @@ mod tests {
|
|||||||
.contains("textureStore(output, coord, vec4<f32>(c, aux));"),
|
.contains("textureStore(output, coord, vec4<f32>(c, aux));"),
|
||||||
"an intermediate must carry the lane to the pass after it"
|
"an intermediate must carry the lane to the pass after it"
|
||||||
);
|
);
|
||||||
// The last pass writes the display texture, whose alpha is opacity and
|
// The last pass carries it too: since D19 it writes an intermediate
|
||||||
// not scratch space. Readable there, not written — which is the right
|
// for the view pass rather than the display texture, whose alpha is
|
||||||
// way round, because the combining pass is the one that reads it.
|
// opacity. The view pass reads only the colour.
|
||||||
assert!(composed.passes[1].writes_output);
|
assert!(composed.passes[1]
|
||||||
assert!(!composed.passes[1].source.contains("vec4<f32>(c, aux)"));
|
.source
|
||||||
|
.contains("textureStore(output, coord, vec4<f32>(c, aux));"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1357,11 +1301,7 @@ mod tests {
|
|||||||
// overwhelmingly common edit: no sharpening means no chain, which
|
// overwhelmingly common edit: no sharpening means no chain, which
|
||||||
// means `dr-gpu` runs the single fused dispatch it always did.
|
// means `dr-gpu` runs the single fused dispatch it always did.
|
||||||
let ops = crate::ops::chain();
|
let ops = crate::ops::chain();
|
||||||
let composed = compose_detail(
|
let composed = compose_detail(&ops, RenderScale::full((64, 64)));
|
||||||
&ops,
|
|
||||||
RenderScale::full((64, 64)),
|
|
||||||
dr_types::ColourSpace::Srgb,
|
|
||||||
);
|
|
||||||
assert!(composed.is_empty());
|
assert!(composed.is_empty());
|
||||||
assert_eq!(composed.radius(), 0);
|
assert_eq!(composed.radius(), 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1298,7 +1298,8 @@ impl Framing {
|
|||||||
// count active stages, and a neutral graph must generate none.
|
// count active stages, and a neutral graph must generate none.
|
||||||
if !self.is_active() {
|
if !self.is_active() {
|
||||||
return " // Source position, normalised and centred: the whole frame, unrotated.
|
return " // Source position, normalised and centred: the whole frame, unrotated.
|
||||||
let src_dims = textureDimensions(source);
|
let tex_dims = textureDimensions(source);
|
||||||
|
let src_dims = select(tex_dims, vec2<u32>(u.source_full.xy), u.source_full.x > 0.5);
|
||||||
let aspect = vec2<f32>(f32(src_dims.x) / f32(src_dims.y), 1.0);
|
let aspect = vec2<f32>(f32(src_dims.x) / f32(src_dims.y), 1.0);
|
||||||
let uv = (vec2<f32>(gid.xy) + vec2<f32>(0.5)) / vec2<f32>(dims);
|
let uv = (vec2<f32>(gid.xy) + vec2<f32>(0.5)) / vec2<f32>(dims);
|
||||||
var p = (uv - vec2<f32>(0.5)) * aspect;
|
var p = (uv - vec2<f32>(0.5)) * aspect;
|
||||||
@@ -1314,7 +1315,8 @@ impl Framing {
|
|||||||
// warp chain expects: the centre is (0, 0) and the radius is 1 at the
|
// warp chain expects: the centre is (0, 0) and the radius is 1 at the
|
||||||
// corner. Working here rather than in pixels is what makes the map
|
// corner. Working here rather than in pixels is what makes the map
|
||||||
// independent of the resolution being rendered at.
|
// independent of the resolution being rendered at.
|
||||||
let src_dims = textureDimensions(source);
|
let tex_dims = textureDimensions(source);
|
||||||
|
let src_dims = select(tex_dims, vec2<u32>(u.source_full.xy), u.source_full.x > 0.5);
|
||||||
let aspect = vec2<f32>(f32(src_dims.x) / f32(src_dims.y), 1.0);
|
let aspect = vec2<f32>(f32(src_dims.x) / f32(src_dims.y), 1.0);
|
||||||
var uv = (vec2<f32>(gid.xy) + vec2<f32>(0.5)) / vec2<f32>(dims);
|
var uv = (vec2<f32>(gid.xy) + vec2<f32>(0.5)) / vec2<f32>(dims);
|
||||||
",
|
",
|
||||||
|
|||||||
@@ -83,6 +83,13 @@ impl ParamCapability {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DSP-2
|
||||||
|
/// How far past the framing's own footprint [`EditGraph::source_region`]
|
||||||
|
/// reaches when a lens warp is active, as a fraction of the frame on each
|
||||||
|
/// side. Distortion profiles move a corner by a few per cent of the frame; a
|
||||||
|
/// window short of what the warp reads would render the missing strip black.
|
||||||
|
pub const WARP_MARGIN: f32 = 0.04;
|
||||||
|
|
||||||
/// An ordered pipeline of operations, plus how the result is framed.
|
/// An ordered pipeline of operations, plus how the result is framed.
|
||||||
pub struct EditGraph {
|
pub struct EditGraph {
|
||||||
ops: Vec<Box<dyn Operation>>,
|
ops: Vec<Box<dyn Operation>>,
|
||||||
@@ -292,6 +299,57 @@ impl EditGraph {
|
|||||||
self.framing.output_size(width, height)
|
self.framing.output_size(width, height)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DSP-2 | NFR-RES-2
|
||||||
|
/// The part of the source the visible region reads, as a rectangle in
|
||||||
|
/// normalised source coordinates, clamped to the frame.
|
||||||
|
///
|
||||||
|
/// For a photograph larger than one texture: a render of part of it —
|
||||||
|
/// the canvas zoomed in, one tile of an export — binds only this window
|
||||||
|
/// of the source (see `dr_pipeline::SOURCE_WINDOW_UNIFORM_FIELDS`).
|
||||||
|
///
|
||||||
|
/// The framing is walked on the CPU with [`Framing::source_at`], along
|
||||||
|
/// the border and across the interior, so a straightened or keystoned
|
||||||
|
/// view gets the box around the quadrilateral it actually reads. The lens
|
||||||
|
/// warps have no CPU mirror, so when one is active the box is widened
|
||||||
|
/// by [`WARP_MARGIN`] of the frame on each side: a distortion profile
|
||||||
|
/// moves a corner by a few per cent of the frame at most. `halo`, in
|
||||||
|
/// source pixels, is added on top — the detail stage's reach, which reads
|
||||||
|
/// beyond the pixels it writes.
|
||||||
|
pub fn source_region(&self, source: (u32, u32), halo: u32) -> crate::framing::CropRect {
|
||||||
|
const STEPS: usize = 16;
|
||||||
|
let (sw, sh) = (source.0.max(1), source.1.max(1));
|
||||||
|
let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
|
||||||
|
for j in 0..=STEPS {
|
||||||
|
for i in 0..=STEPS {
|
||||||
|
let out = (i as f32 / STEPS as f32, j as f32 / STEPS as f32);
|
||||||
|
let (x, y) = self.framing.source_at(out, sw, sh);
|
||||||
|
x0 = x0.min(x);
|
||||||
|
y0 = y0.min(y);
|
||||||
|
x1 = x1.max(x);
|
||||||
|
y1 = y1.max(y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let warp = if crate::lens::compose_warps(&self.warps).is_active() {
|
||||||
|
WARP_MARGIN
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
// Two pixels beyond the halo: the bilinear tap's second texel, and
|
||||||
|
// the rounding of the box to whole pixels by the caller.
|
||||||
|
let px = (halo as f32 + 2.0) / sw as f32;
|
||||||
|
let py = (halo as f32 + 2.0) / sh as f32;
|
||||||
|
let x0 = (x0 - warp - px).clamp(0.0, 1.0);
|
||||||
|
let y0 = (y0 - warp - py).clamp(0.0, 1.0);
|
||||||
|
let x1 = (x1 + warp + px).clamp(0.0, 1.0);
|
||||||
|
let y1 = (y1 + warp + py).clamp(0.0, 1.0);
|
||||||
|
crate::framing::CropRect {
|
||||||
|
x: x0,
|
||||||
|
y: y0,
|
||||||
|
width: (x1 - x0).max(0.0),
|
||||||
|
height: (y1 - y0).max(0.0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Descriptors for every operation, in order.
|
/// Descriptors for every operation, in order.
|
||||||
///
|
///
|
||||||
/// Operations only — framing is not one, and is reached through
|
/// Operations only — framing is not one, and is reached through
|
||||||
@@ -623,7 +681,9 @@ impl EditGraph {
|
|||||||
} = self;
|
} = self;
|
||||||
|
|
||||||
EditState {
|
EditState {
|
||||||
params: Preset::capture(self),
|
// Without the film: it has a field of its own below, and one
|
||||||
|
// edit must not have two places to disagree about its stock.
|
||||||
|
params: Preset::capture_params(self),
|
||||||
// A refcount bump. See `EditState::masks` for why that matters on
|
// A refcount bump. See `EditState::masks` for why that matters on
|
||||||
// a path called once a frame.
|
// a path called once a frame.
|
||||||
masks: Arc::clone(masks),
|
masks: Arc::clone(masks),
|
||||||
@@ -663,7 +723,11 @@ impl EditGraph {
|
|||||||
// At full scope. `Scope` is a question about what a paste carries
|
// At full scope. `Scope` is a question about what a paste carries
|
||||||
// *between* photographs; this is one photograph's own edit being put
|
// *between* photographs; this is one photograph's own edit being put
|
||||||
// back, so there is nothing to leave behind.
|
// back, so there is nothing to leave behind.
|
||||||
params.apply(self, Scope::everything());
|
//
|
||||||
|
// What the parameters say about the film is discarded: the stock is
|
||||||
|
// `film`'s to decide, below, and clearing it is what happens there
|
||||||
|
// either way.
|
||||||
|
let _ = params.apply(self, Scope::everything());
|
||||||
|
|
||||||
self.masks = Arc::clone(masks);
|
self.masks = Arc::clone(masks);
|
||||||
self.spots = spots.clone();
|
self.spots = spots.clone();
|
||||||
@@ -946,46 +1010,35 @@ impl EditGraph {
|
|||||||
((fw as f32 * view.width).round() as u32).max(1),
|
((fw as f32 * view.width).round() as u32).max(1),
|
||||||
((fh as f32 * view.height).round() as u32).max(1),
|
((fh as f32 * view.height).round() as u32).max(1),
|
||||||
);
|
);
|
||||||
crate::detail::RenderScale::new(render, full)
|
crate::detail::RenderScale::new(render, full).within((fw, fh))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TRACES: FR-DEV-3 | FR-DSP-1
|
/// TRACES: FR-DEV-3 | FR-DSP-1
|
||||||
/// Generate the detail stage for this edit at one resolution, to sRGB.
|
/// Generate the detail stage for this edit at one resolution.
|
||||||
///
|
///
|
||||||
/// Empty for every edit with no active neighbourhood operation, which is
|
/// Empty for every edit with no active neighbourhood operation, which is
|
||||||
/// almost all of them — and in that case [`Self::compose`] emits the
|
/// almost all of them — and in that case [`Self::compose`] emits the
|
||||||
/// single encoded dispatch it always has.
|
/// single encoded dispatch it always has.
|
||||||
pub fn compose_detail(
|
|
||||||
&self,
|
|
||||||
source: (u32, u32),
|
|
||||||
render: (u32, u32),
|
|
||||||
) -> crate::detail::ComposedDetail {
|
|
||||||
self.compose_detail_for(source, render, dr_types::ColourSpace::Srgb)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// TRACES: FR-EXP-2
|
|
||||||
/// The detail stage, encoded into a chosen output space.
|
|
||||||
///
|
///
|
||||||
/// The space belongs here as well as on [`Self::compose_for`] because when
|
/// No output space: since D19 no detail pass encodes. The fused pass's
|
||||||
/// a detail stage exists it is the *last* pass that performs the output
|
/// view pass reads what the last one wrote and performs the view transform
|
||||||
/// transform — the fused pass stops at linear working values. Composing
|
/// and the output transform, so it is [`Self::compose_for`] alone that
|
||||||
/// the two halves for different spaces would encode the edit twice, or
|
/// names the space.
|
||||||
/// not at all.
|
///
|
||||||
/// `source` is the demosaiced image's size and `render` the size being
|
/// `source` is the demosaiced image's size and `render` the size being
|
||||||
/// drawn. The scale is worked out here rather than handed in, because the
|
/// drawn. The scale is worked out here rather than handed in, because the
|
||||||
/// repairs need the *source* size as well — a spot is stored in normalised
|
/// repairs need the *source* size as well — a spot is stored in normalised
|
||||||
/// source coordinates and has to be put through the framing to find out
|
/// source coordinates and has to be put through the framing to find out
|
||||||
/// where it lands on this render, and a [`crate::detail::RenderScale`]
|
/// where it lands on this render, and a [`crate::detail::RenderScale`]
|
||||||
/// describes the region on screen rather than the photograph.
|
/// describes the region on screen rather than the photograph.
|
||||||
pub fn compose_detail_for(
|
pub fn compose_detail(
|
||||||
&self,
|
&self,
|
||||||
source: (u32, u32),
|
source: (u32, u32),
|
||||||
render: (u32, u32),
|
render: (u32, u32),
|
||||||
output: dr_types::ColourSpace,
|
|
||||||
) -> crate::detail::ComposedDetail {
|
) -> crate::detail::ComposedDetail {
|
||||||
let scale = self.render_scale(source, render);
|
let scale = self.render_scale(source, render);
|
||||||
let spots = self.spots.passes(&self.framing, source, scale);
|
let spots = self.spots.passes(&self.framing, source, scale);
|
||||||
crate::detail::compose_detail_with(&self.ops, &spots, scale, output)
|
crate::detail::compose_detail_with(&self.ops, &spots, scale)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TRACES: FR-DEV-3d
|
/// TRACES: FR-DEV-3d
|
||||||
@@ -1116,13 +1169,21 @@ mod tests {
|
|||||||
fn a_fresh_graph_is_neutral() {
|
fn a_fresh_graph_is_neutral() {
|
||||||
// Opening an unedited image must produce the image, not an
|
// Opening an unedited image must produce the image, not an
|
||||||
// interpretation of it.
|
// interpretation of it.
|
||||||
|
//
|
||||||
|
// One block, and it is the view transform: a view operation is
|
||||||
|
// composed at its defaults, because a photograph with no view
|
||||||
|
// transform is a scan rather than a picture (FR-DEV-3j). It is still
|
||||||
|
// neutral in the sense that matters here — nothing moved, nothing is
|
||||||
|
// written — and every adjustment is absent.
|
||||||
let g = EditGraph::default_chain();
|
let g = EditGraph::default_chain();
|
||||||
assert!(g.is_neutral());
|
assert!(g.is_neutral());
|
||||||
|
let source = g.compose().source;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
g.compose().source.matches("---- ").count(),
|
source.matches("---- ").count(),
|
||||||
0,
|
1,
|
||||||
"a neutral graph must generate no operation blocks"
|
"a neutral graph must generate no adjustment blocks"
|
||||||
);
|
);
|
||||||
|
assert!(source.contains("---- view_transform ----"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1201,13 +1262,15 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn only_active_operations_reach_the_shader() {
|
fn only_active_operations_reach_the_shader() {
|
||||||
// The composition property, end to end: two adjustments out of seven
|
// The composition property, end to end: two adjustments out of seven
|
||||||
// available must generate a shader doing exactly two things.
|
// available must generate a shader doing exactly two things — and
|
||||||
|
// the view transform, which every render has (FR-DEV-3j).
|
||||||
let mut g = EditGraph::default_chain();
|
let mut g = EditGraph::default_chain();
|
||||||
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
|
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
|
||||||
g.set_param(white_balance::ID, white_balance::TINT, 25.0);
|
g.set_param(white_balance::ID, white_balance::TINT, 25.0);
|
||||||
|
|
||||||
let shader = g.compose();
|
let shader = g.compose();
|
||||||
assert_eq!(shader.source.matches("---- ").count(), 2);
|
assert_eq!(shader.source.matches("---- ").count(), 3);
|
||||||
|
assert!(shader.source.contains("---- view_transform ----"));
|
||||||
assert!(shader.source.contains("---- exposure ----"));
|
assert!(shader.source.contains("---- exposure ----"));
|
||||||
assert!(shader.source.contains("---- white_balance ----"));
|
assert!(shader.source.contains("---- white_balance ----"));
|
||||||
assert!(!shader.source.contains("---- saturation ----"));
|
assert!(!shader.source.contains("---- saturation ----"));
|
||||||
|
|||||||
@@ -581,7 +581,9 @@ mod tests {
|
|||||||
lut: vec![[0.5, 0.5, 0.5]; 8],
|
lut: vec![[0.5, 0.5, 0.5]; 8],
|
||||||
density_max: 2.0,
|
density_max: 2.0,
|
||||||
lut_size: 2,
|
lut_size: 2,
|
||||||
grain_particles: [0.0; 3],
|
grain_particles: [[0.0; 3]; crate::ops::film_sim::FORMAT_COUNT],
|
||||||
|
push_stations: vec![0.0],
|
||||||
|
paper: None,
|
||||||
grain_density_max: [2.0; 3],
|
grain_density_max: [2.0; 3],
|
||||||
grain_uniformity: 1.0,
|
grain_uniformity: 1.0,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
//! single multiply and white balance a per-channel scale; on gamma-encoded
|
//! single multiply and white balance a per-channel scale; on gamma-encoded
|
||||||
//! data neither would be physically meaningful (ARCH §5.2).
|
//! data neither would be physically meaningful (ARCH §5.2).
|
||||||
|
|
||||||
|
pub mod bundled;
|
||||||
pub mod coverage;
|
pub mod coverage;
|
||||||
pub mod declared;
|
pub mod declared;
|
||||||
pub mod descriptor;
|
pub mod descriptor;
|
||||||
@@ -48,8 +49,9 @@ pub mod orphan;
|
|||||||
pub mod preset;
|
pub mod preset;
|
||||||
pub mod sidecar;
|
pub mod sidecar;
|
||||||
pub mod spot;
|
pub mod spot;
|
||||||
pub mod starter;
|
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
pub mod tiles;
|
||||||
|
pub mod view;
|
||||||
|
|
||||||
pub use coverage::Coverage;
|
pub use coverage::Coverage;
|
||||||
pub use declared::{Declaration, DeclaredOp};
|
pub use declared::{Declaration, DeclaredOp};
|
||||||
@@ -66,10 +68,10 @@ pub use history::{Edit, Entry as HistoryEntry, History, Step};
|
|||||||
pub use lens::{compose_warps, ComposedWarp, LensProfile, Tca, Warp};
|
pub use lens::{compose_warps, ComposedWarp, LensProfile, Tca, Warp};
|
||||||
pub use operation::{
|
pub use operation::{
|
||||||
compose, compose_with_framing, Affects, ComposedShader, Helper, Invalidation, Operation,
|
compose, compose_with_framing, Affects, ComposedShader, Helper, Invalidation, Operation,
|
||||||
OutputMode, Uniform, BASE_CURVE_POINTS, BASE_CURVE_UNIFORM_OFFSET, CLIP_ONSET,
|
OutputMode, Stage, Uniform, CLIP_ONSET, RESERVED_UNIFORM_FIELDS, SAMPLE_CACHE_UNIFORM_OFFSET,
|
||||||
RESERVED_UNIFORM_FIELDS, SAMPLE_CACHE_UNIFORM_OFFSET,
|
SOURCE_WINDOW_UNIFORM_FIELDS, SOURCE_WINDOW_UNIFORM_OFFSET, WHOLE_SOURCE_WINDOW,
|
||||||
};
|
};
|
||||||
pub use preset::{LibraryParseError, NameError, Preset, PresetLibrary, Scope};
|
pub use preset::{LibraryParseError, NameError, Preset, PresetLibrary, Reach, Scope};
|
||||||
pub use sidecar::{Sidecar, Version};
|
pub use sidecar::{Sidecar, Version};
|
||||||
pub use spot::{Spot, SpotMode, SpotSet};
|
pub use spot::{Spot, SpotMode, SpotSet};
|
||||||
pub use state::{EditState, FilmRebake, FilmRef};
|
pub use state::{EditState, FilmRebake, FilmRef};
|
||||||
@@ -132,7 +134,9 @@ mod tests {
|
|||||||
lut: vec![[0.5, 0.5, 0.5]; 32 * 32 * 32],
|
lut: vec![[0.5, 0.5, 0.5]; 32 * 32 * 32],
|
||||||
density_max: 3.0,
|
density_max: 3.0,
|
||||||
lut_size: 32,
|
lut_size: 32,
|
||||||
grain_particles: [0.0; 3],
|
grain_particles: [[0.0; 3]; crate::ops::film_sim::FORMAT_COUNT],
|
||||||
|
push_stations: vec![0.0],
|
||||||
|
paper: None,
|
||||||
grain_density_max: [3.0; 3],
|
grain_density_max: [3.0; 3],
|
||||||
grain_uniformity: 0.97,
|
grain_uniformity: 0.97,
|
||||||
},
|
},
|
||||||
@@ -167,7 +171,21 @@ mod tests {
|
|||||||
let mut fused_blocks = 0;
|
let mut fused_blocks = 0;
|
||||||
for desc in g.descriptors() {
|
for desc in g.descriptors() {
|
||||||
let id = desc.id.0;
|
let id = desc.id.0;
|
||||||
let point = shader.source.contains(&format!("---- {id} ----"));
|
// The film is loaded here, and a stock is a rendering: the view
|
||||||
|
// transform it replaces is correctly in neither stage (FR-DEV-3f,
|
||||||
|
// FR-DEV-3j).
|
||||||
|
if id == crate::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
|
let neighbourhood = detail
|
||||||
.passes
|
.passes
|
||||||
.iter()
|
.iter()
|
||||||
@@ -180,8 +198,12 @@ mod tests {
|
|||||||
);
|
);
|
||||||
fused_blocks += usize::from(point);
|
fused_blocks += usize::from(point);
|
||||||
}
|
}
|
||||||
|
let view_blocks = shader
|
||||||
|
.view
|
||||||
|
.as_ref()
|
||||||
|
.map_or(0, |v| v.source.matches("---- ").count());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
shader.source.matches("---- ").count(),
|
shader.source.matches("---- ").count() + view_blocks,
|
||||||
fused_blocks,
|
fused_blocks,
|
||||||
"the fused shader carries a block nothing in the chain asked for"
|
"the fused shader carries a block nothing in the chain asked for"
|
||||||
);
|
);
|
||||||
|
|||||||
+407
-89
@@ -73,7 +73,7 @@ use std::fmt::Write as _;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::coverage::Coverage;
|
use crate::coverage::Coverage;
|
||||||
use crate::descriptor::{Attribute, OpDescriptor, ParamId};
|
use crate::descriptor::{Attribute, OpDescriptor, ParamId, ParamKind};
|
||||||
use crate::operation::Operation;
|
use crate::operation::Operation;
|
||||||
use crate::ops;
|
use crate::ops;
|
||||||
|
|
||||||
@@ -1368,17 +1368,19 @@ pub struct MaskLayer {
|
|||||||
/// This layer's adjustments.
|
/// This layer's adjustments.
|
||||||
///
|
///
|
||||||
/// A full chain, the same one [`crate::EditGraph`] holds. That is the
|
/// A full chain, the same one [`crate::EditGraph`] holds. That is the
|
||||||
/// whole reason local adjustments need no per-operation support: the
|
/// whole reason local adjustments need no per-operation support: each
|
||||||
/// composer already knows how to turn a chain into WGSL, and a mask layer
|
/// setting here is an offset from its default, added to the global chain's
|
||||||
/// is a chain that happens to be multiplied by a mask afterwards.
|
/// setting and run where that operation runs, weighted by the mask (see
|
||||||
|
/// [`offset_onto`]).
|
||||||
pub ops: Vec<Box<dyn Operation>>,
|
pub ops: Vec<Box<dyn Operation>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The chain a mask layer holds: every point operation, and neither the
|
/// The chain a mask layer holds: every point operation, and neither the
|
||||||
/// neighbourhood ones nor the optical corrections.
|
/// neighbourhood ones nor the optical corrections.
|
||||||
///
|
///
|
||||||
/// A layer's adjustments are fused into the colour dispatch and multiplied by
|
/// A layer's adjustments are fused into the colour dispatch, each beside the
|
||||||
/// the mask afterwards, which is exactly why a layer needs no per-operation
|
/// global operation it offsets and weighted by the mask, which is exactly why
|
||||||
|
/// a layer needs no per-operation
|
||||||
/// support — the composer already knows how to turn a chain into WGSL. A
|
/// support — the composer already knows how to turn a chain into WGSL. A
|
||||||
/// neighbourhood operation cannot go through that path at all: it runs as its
|
/// neighbourhood operation cannot go through that path at all: it runs as its
|
||||||
/// own dispatch in [`crate::detail`], after the fused pass and after the masks
|
/// own dispatch in [`crate::detail`], after the fused pass and after the masks
|
||||||
@@ -1642,8 +1644,8 @@ impl MaskLayer {
|
|||||||
/// The operations in this layer's chain that reach the shader.
|
/// The operations in this layer's chain that reach the shader.
|
||||||
///
|
///
|
||||||
/// Neighbourhood operations are excluded, and not as an oversight. A
|
/// Neighbourhood operations are excluded, and not as an oversight. A
|
||||||
/// layer's chain is *fused into the point-operation pass* and multiplied
|
/// layer's chain is *fused into the point-operation pass*, weighted by the
|
||||||
/// by the mask afterwards; the detail stage runs once, over the whole
|
/// mask at each operation; the detail stage runs once, over the whole
|
||||||
/// frame, after that pass has finished (see [`crate::detail`]). There is
|
/// frame, after that pass has finished (see [`crate::detail`]). There is
|
||||||
/// nowhere in that arrangement for a sharpening confined to one mask to
|
/// nowhere in that arrangement for a sharpening confined to one mask to
|
||||||
/// happen, so a detail operation in a layer would contribute an empty
|
/// happen, so a detail operation in a layer would contribute an empty
|
||||||
@@ -1654,7 +1656,7 @@ impl MaskLayer {
|
|||||||
self.ops
|
self.ops
|
||||||
.iter()
|
.iter()
|
||||||
.map(|o| o.as_ref())
|
.map(|o| o.as_ref())
|
||||||
.filter(|o| o.is_active() && o.detail().is_none())
|
.filter(|o| moves(*o) && o.detail().is_none())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether any part of this mask belongs to a different segmentation.
|
/// Whether any part of this mask belongs to a different segmentation.
|
||||||
@@ -1975,7 +1977,9 @@ impl MaskStack {
|
|||||||
Some(self.layers.remove(i))
|
Some(self.layers.remove(i))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reorder, since later layers composite over earlier ones.
|
/// Reorder. Layers add their changes, so order no longer decides the
|
||||||
|
/// picture — but it is the order the panel lists them in and the order
|
||||||
|
/// their slots are assigned.
|
||||||
pub fn move_to(&mut self, id: &str, index: usize) {
|
pub fn move_to(&mut self, id: &str, index: usize) {
|
||||||
let Some(from) = self.layers.iter().position(|l| l.id == id) else {
|
let Some(from) = self.layers.iter().position(|l| l.id == id) else {
|
||||||
return;
|
return;
|
||||||
@@ -2041,25 +2045,104 @@ impl MaskStack {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One layer's contribution to the generated shader.
|
/// The layers' contribution to the generated shader.
|
||||||
|
///
|
||||||
|
/// Not a block of its own any more. A layer's adjustments are *offsets to the
|
||||||
|
/// global ones*, applied at each operation's own place in the chain, so what
|
||||||
|
/// this hands back is pieces the composer threads through its loop over the
|
||||||
|
/// global operations: the weights, sampled once before the first operation,
|
||||||
|
/// and one [`LocalOp`] per layer per operation the layer moved.
|
||||||
pub(crate) struct LayerShader {
|
pub(crate) struct LayerShader {
|
||||||
pub uniform_fields: String,
|
pub uniform_fields: String,
|
||||||
pub uniform_values: Vec<f32>,
|
pub uniform_values: Vec<f32>,
|
||||||
pub body: String,
|
/// Each layer's shaped mask, `mask_w{slot}`, sampled once ahead of the
|
||||||
|
/// operations that read it. Empty when no layer changes a pixel.
|
||||||
|
pub weights: String,
|
||||||
|
/// Every layer's version of every operation it moved, in layer order and
|
||||||
|
/// then chain order — see [`LocalOp`].
|
||||||
|
pub ops: Vec<LocalOp>,
|
||||||
pub helpers: Vec<crate::operation::Helper>,
|
pub helpers: Vec<crate::operation::Helper>,
|
||||||
/// TRACES: FR-DEV-19c
|
/// TRACES: FR-DEV-19c
|
||||||
/// The block that draws one layer's mask over the finished picture, empty
|
/// The block that draws one layer's mask over the finished picture, empty
|
||||||
/// when nothing is being revealed.
|
/// when nothing is being revealed.
|
||||||
///
|
///
|
||||||
/// Kept apart from `body` because it belongs at the other end of the
|
/// Kept apart from the rest because it belongs at the other end of the
|
||||||
/// shader. Everything in `body` runs on scene-referred colour in the
|
/// shader. Everything else runs on scene-referred colour in the working
|
||||||
/// working space, where a flat tint would then be pushed through the base
|
/// space, where a flat tint would then be pushed through the view
|
||||||
/// curve and the camera matrix and arrive as some other colour, and a
|
/// transform and arrive as some other colour, and a
|
||||||
/// white-on-black alpha would arrive as neither. This runs after the
|
/// white-on-black alpha would arrive as neither. This runs after the
|
||||||
/// output transform, so what is written is what is seen.
|
/// output transform, so what is written is what is seen.
|
||||||
pub reveal: String,
|
pub reveal: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One layer's version of one operation: the global settings with the layer's
|
||||||
|
/// offsets added, as a fragment reading this layer's own uniforms.
|
||||||
|
///
|
||||||
|
/// The composer runs it beside the global fragment on the same input colour,
|
||||||
|
/// and moves the pixel toward its result by the layer's weight — see
|
||||||
|
/// `operation::local_block`.
|
||||||
|
pub(crate) struct LocalOp {
|
||||||
|
pub op: &'static str,
|
||||||
|
pub slot: usize,
|
||||||
|
/// Empty when the offsets cancel the global setting back to neutral. That
|
||||||
|
/// is still an entry, because it still means something: inside the mask
|
||||||
|
/// this operation does nothing at all.
|
||||||
|
///
|
||||||
|
/// Empty, too, for an operation blended as settings: that entry stands
|
||||||
|
/// for this layer's uniforms, `mask{slot}_{op}_*`, which the composer
|
||||||
|
/// averages into the one fragment it runs.
|
||||||
|
pub fragment: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a layer's copy of `op` holds an adjustment.
|
||||||
|
///
|
||||||
|
/// An operation's own answer, except for one blended as settings
|
||||||
|
/// ([`Operation::blends_settings`]): that one is active by what it *holds* —
|
||||||
|
/// a film is active when a stock is loaded — and a layer never holds a stock,
|
||||||
|
/// only offsets to the photograph's. So a layer's film counts as moved when
|
||||||
|
/// its sliders are, which is the question being asked.
|
||||||
|
fn moves(op: &dyn Operation) -> bool {
|
||||||
|
op.is_active()
|
||||||
|
|| (op.blends_settings()
|
||||||
|
&& op
|
||||||
|
.descriptor()
|
||||||
|
.params
|
||||||
|
.iter()
|
||||||
|
.any(|p| op.param(p.id) != p.default))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A layer's settings for one operation, applied as offsets to the global
|
||||||
|
/// operation's.
|
||||||
|
///
|
||||||
|
/// **This is what a local adjustment means**, and the reason it is not a
|
||||||
|
/// second chain run over the finished picture. A photographer who sets
|
||||||
|
/// contrast −30 on the whole frame and −20 on a face means −50 on the face,
|
||||||
|
/// at the place contrast sits in the chain — not −30, then everything after
|
||||||
|
/// contrast, then −20 applied again to the result. Stacked that way the two
|
||||||
|
/// edits compound in ways neither slider shows, and a flattening applied to an
|
||||||
|
/// already-flattened picture is how a shadow's noise ended up magenta.
|
||||||
|
///
|
||||||
|
/// Per parameter: one the layer left at its default takes the global value; a
|
||||||
|
/// moved scalar adds its distance from default to the global value, clamped to
|
||||||
|
/// the parameter's range; a moved switch or choice replaces it, since there is
|
||||||
|
/// no such thing as half a variant.
|
||||||
|
fn offset_onto(dst: &mut dyn Operation, local: &dyn Operation, global: Option<&dyn Operation>) {
|
||||||
|
let desc = local.descriptor();
|
||||||
|
for p in &desc.params {
|
||||||
|
let here = local.param(p.id);
|
||||||
|
let base = global.map_or(p.default, |g| g.param(p.id));
|
||||||
|
let value = if here == p.default {
|
||||||
|
base
|
||||||
|
} else {
|
||||||
|
match p.kind {
|
||||||
|
ParamKind::Scalar { .. } => p.clamp(base + (here - p.default)),
|
||||||
|
ParamKind::Bool | ParamKind::Enum { .. } => here,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
dst.set_param(p.id, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Emit the WGSL for every layer that renders, and for the mask being looked
|
/// Emit the WGSL for every layer that renders, and for the mask being looked
|
||||||
/// at.
|
/// at.
|
||||||
///
|
///
|
||||||
@@ -2068,11 +2151,18 @@ pub(crate) struct LayerShader {
|
|||||||
/// an adjustment on it, which is why the two are one sequence and why every
|
/// an adjustment on it, which is why the two are one sequence and why every
|
||||||
/// other half of the pipeline has to be given the same `reveal` for the slots
|
/// other half of the pipeline has to be given the same `reveal` for the slots
|
||||||
/// to mean the same thing.
|
/// to mean the same thing.
|
||||||
pub(crate) fn compose_layers_revealing(stack: &MaskStack, reveal: Option<&Reveal>) -> LayerShader {
|
///
|
||||||
|
/// `global` is the chain the layers are offsets to.
|
||||||
|
pub(crate) fn compose_layers_revealing(
|
||||||
|
stack: &MaskStack,
|
||||||
|
reveal: Option<&Reveal>,
|
||||||
|
global: &[Box<dyn Operation>],
|
||||||
|
) -> LayerShader {
|
||||||
let mut out = LayerShader {
|
let mut out = LayerShader {
|
||||||
uniform_fields: String::new(),
|
uniform_fields: String::new(),
|
||||||
uniform_values: Vec::new(),
|
uniform_values: Vec::new(),
|
||||||
body: String::new(),
|
weights: String::new(),
|
||||||
|
ops: Vec::new(),
|
||||||
helpers: Vec::new(),
|
helpers: Vec::new(),
|
||||||
reveal: String::new(),
|
reveal: String::new(),
|
||||||
};
|
};
|
||||||
@@ -2104,13 +2194,14 @@ pub(crate) fn compose_layers_revealing(stack: &MaskStack, reveal: Option<&Reveal
|
|||||||
);
|
);
|
||||||
out.uniform_values.extend_from_slice(&layer.uniforms());
|
out.uniform_values.extend_from_slice(&layer.uniforms());
|
||||||
|
|
||||||
let _ = writeln!(
|
// A layer only being looked at moves no pixel, so it needs a slot for
|
||||||
out.body,
|
// the reveal and no weight.
|
||||||
"\n // ======== mask {slot}: {} ({}) ========",
|
if layer.active_ops().next().is_none() {
|
||||||
layer.display_name(),
|
continue;
|
||||||
layer.base().source.kind()
|
}
|
||||||
);
|
|
||||||
let _ = writeln!(out.body, " {{");
|
// The weight, once per pixel, ahead of every operation that reads it.
|
||||||
|
//
|
||||||
// **`uv_src`, not `gid.xy`.** The mask array is rasterised in *source*
|
// **`uv_src`, not `gid.xy`.** The mask array is rasterised in *source*
|
||||||
// space, and `uv_src` is the source position this output pixel came
|
// space, and `uv_src` is the source position this output pixel came
|
||||||
// from — after the crop, the zoom, the pan, the straightening and the
|
// from — after the crop, the zoom, the pan, the straightening and the
|
||||||
@@ -2123,33 +2214,62 @@ pub(crate) fn compose_layers_revealing(stack: &MaskStack, reveal: Option<&Reveal
|
|||||||
// place. A second copy here would be a second thing to keep in step
|
// place. A second copy here would be a second thing to keep in step
|
||||||
// with `Framing::wgsl_prologue`, and the failure would be a mask that
|
// with `Framing::wgsl_prologue`, and the failure would be a mask that
|
||||||
// is subtly wrong only when straightened.
|
// is subtly wrong only when straightened.
|
||||||
let _ = writeln!(out.body, " var m = sample_mask(uv_src, {slot});");
|
let w = format!("mask_w{slot}");
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
out.body,
|
out.weights,
|
||||||
" m = select(m, 1.0 - m, u.{prefix}_invert > 0.5);"
|
"\n // ======== mask {slot}: {} ({}) ========",
|
||||||
|
layer.display_name(),
|
||||||
|
layer.base().source.kind()
|
||||||
|
);
|
||||||
|
let _ = writeln!(out.weights, " var {w} = sample_mask(uv_src, {slot});");
|
||||||
|
let _ = writeln!(
|
||||||
|
out.weights,
|
||||||
|
" {w} = select({w}, 1.0 - {w}, u.{prefix}_invert > 0.5);"
|
||||||
);
|
);
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
out.body,
|
out.weights,
|
||||||
" m = clamp(m * u.{prefix}_opacity, 0.0, 1.0);"
|
" {w} = clamp({w} * u.{prefix}_opacity, 0.0, 1.0);"
|
||||||
);
|
);
|
||||||
// Skipping the work where the mask is empty is most of the point of a
|
|
||||||
// local adjustment: a mask covering a tenth of the frame should cost
|
|
||||||
// about a tenth of the shader. Safe as non-uniform control flow —
|
|
||||||
// nothing inside samples with derivatives or synchronises.
|
|
||||||
let _ = writeln!(out.body, " if (m > 0.0) {{");
|
|
||||||
// `masked` is the outer-scope carrier: op fragments write to a `c`
|
|
||||||
// they expect to own, so the inner block shadows `c` and copies the
|
|
||||||
// result back out. Assigning the outer `c` from inside is not possible
|
|
||||||
// precisely because it is shadowed.
|
|
||||||
let _ = writeln!(out.body, " var masked = c;");
|
|
||||||
let _ = writeln!(out.body, " {{");
|
|
||||||
let _ = writeln!(out.body, " var c = masked;");
|
|
||||||
|
|
||||||
for op in layer.active_ops() {
|
// A fresh chain to hold the combined settings: the layer's own ops
|
||||||
let id = op.descriptor().id.0;
|
// are its offsets and must stay that way.
|
||||||
|
let mut combined = layer_chain();
|
||||||
|
for (dst, local) in combined.iter_mut().zip(&layer.ops) {
|
||||||
|
let local = local.as_ref();
|
||||||
|
if !moves(local) || local.detail().is_some() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let id = local.descriptor().id.0;
|
||||||
|
let g = global
|
||||||
|
.iter()
|
||||||
|
.map(|o| o.as_ref())
|
||||||
|
.find(|o| o.descriptor().id.0 == id);
|
||||||
|
// The photograph's stock, lent to the layer's copy: a layer holds
|
||||||
|
// offsets to a film, never one of its own.
|
||||||
|
if let Some(g) = g {
|
||||||
|
dst.set_film_tables(g.film_tables());
|
||||||
|
}
|
||||||
|
offset_onto(dst.as_mut(), local, g);
|
||||||
|
|
||||||
|
if dst.blends_settings() {
|
||||||
|
// Its uniforms, and no fragment: the composer blends this
|
||||||
|
// layer's settings with the others' and runs the global
|
||||||
|
// fragment once. With no stock loaded there is nothing to
|
||||||
|
// blend, and the global side skips the operation too.
|
||||||
|
if !dst.is_active() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else if !dst.is_active() {
|
||||||
|
out.ops.push(LocalOp {
|
||||||
|
op: id,
|
||||||
|
slot,
|
||||||
|
fragment: String::new(),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let op_prefix = format!("{prefix}_{}", crate::operation::sanitise(id));
|
let op_prefix = format!("{prefix}_{}", crate::operation::sanitise(id));
|
||||||
|
let op_uniforms = dst.uniforms();
|
||||||
let op_uniforms = op.uniforms();
|
|
||||||
if !op_uniforms.is_empty() {
|
if !op_uniforms.is_empty() {
|
||||||
let _ = writeln!(out.uniform_fields, " // mask {slot}: {id}");
|
let _ = writeln!(out.uniform_fields, " // mask {slot}: {id}");
|
||||||
}
|
}
|
||||||
@@ -2158,34 +2278,29 @@ pub(crate) fn compose_layers_revealing(stack: &MaskStack, reveal: Option<&Reveal
|
|||||||
out.uniform_values.push(u.value);
|
out.uniform_values.push(u.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
for h in op.helpers() {
|
for h in dst.helpers() {
|
||||||
if !out.helpers.iter().any(|e| e.name == h.name) {
|
if !out.helpers.iter().any(|e| e.name == h.name) {
|
||||||
out.helpers.push(*h);
|
out.helpers.push(*h);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut fragment = op.wgsl_body();
|
let mut fragment = String::new();
|
||||||
for u in &op_uniforms {
|
if !dst.blends_settings() {
|
||||||
fragment = crate::operation::rewrite_uniform(
|
fragment = dst.wgsl_body();
|
||||||
&fragment,
|
for u in &op_uniforms {
|
||||||
u.name,
|
fragment = crate::operation::rewrite_uniform(
|
||||||
&format!("u.{op_prefix}_{}", u.name),
|
&fragment,
|
||||||
);
|
u.name,
|
||||||
|
&format!("u.{op_prefix}_{}", u.name),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
out.ops.push(LocalOp {
|
||||||
let _ = writeln!(out.body, " // ---- {id} ----");
|
op: id,
|
||||||
let _ = writeln!(out.body, " {{");
|
slot,
|
||||||
for line in fragment.lines() {
|
fragment,
|
||||||
let _ = writeln!(out.body, " {line}");
|
});
|
||||||
}
|
|
||||||
let _ = writeln!(out.body, " }}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = writeln!(out.body, " masked = c;");
|
|
||||||
let _ = writeln!(out.body, " }}");
|
|
||||||
let _ = writeln!(out.body, " c = mix(c, masked, m);");
|
|
||||||
let _ = writeln!(out.body, " }}");
|
|
||||||
let _ = writeln!(out.body, " }}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
out
|
out
|
||||||
@@ -2309,6 +2424,78 @@ mod tests {
|
|||||||
layer
|
layer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A stock the shader can index, with values that are not a real one's.
|
||||||
|
fn film_fixture() -> crate::graph::Film {
|
||||||
|
use crate::ops::film_sim::{CURVE_SAMPLES, FORMAT_COUNT};
|
||||||
|
crate::graph::Film {
|
||||||
|
stock: "fixture".into(),
|
||||||
|
print: None,
|
||||||
|
tables: crate::ops::FilmTables {
|
||||||
|
exposure_matrix: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
|
||||||
|
curves: vec![[0.5; 3]; CURVE_SAMPLES],
|
||||||
|
push_stations: vec![0.0],
|
||||||
|
curve_log_min: -3.0,
|
||||||
|
curve_log_max: 1.0,
|
||||||
|
lut: vec![[0.5; 3]; 8],
|
||||||
|
density_max: 2.0,
|
||||||
|
lut_size: 2,
|
||||||
|
paper: None,
|
||||||
|
grain_particles: [[0.0; 3]; FORMAT_COUNT],
|
||||||
|
grain_density_max: [2.0; 3],
|
||||||
|
grain_uniformity: 1.0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_layers_film_is_blended_as_settings_and_developed_once() {
|
||||||
|
// TRACES: FR-DEV-3f
|
||||||
|
// The film is a rendering: a layer's version of it run beside the
|
||||||
|
// global one and cross-faded would be the photograph developed twice.
|
||||||
|
// So the layer's uniforms are averaged into the global ones by weight
|
||||||
|
// and the fragment appears once.
|
||||||
|
use crate::ops::film_sim;
|
||||||
|
let mut graph = crate::EditGraph::default_chain();
|
||||||
|
graph.set_film(Some(film_fixture()));
|
||||||
|
let mut layer = MaskLayer::new("m1", regions(&[1]));
|
||||||
|
layer.set_param(film_sim::ID.0, film_sim::PRINT_EXPOSURE, 1.0);
|
||||||
|
assert!(layer.is_active(), "a film offset is an adjustment");
|
||||||
|
graph.masks_mut().push(layer);
|
||||||
|
|
||||||
|
let source = graph.compose().source;
|
||||||
|
assert!(source.contains("let set_w = mask_w0;"), "{source}");
|
||||||
|
assert!(source.contains("let set_g = max(1.0 - set_w, 0.0);"));
|
||||||
|
assert!(
|
||||||
|
source.contains("u.mask0_film_sim_pev"),
|
||||||
|
"the layer's setting is not read"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
source.matches("let density = film_curve_pushed(").count(),
|
||||||
|
1,
|
||||||
|
"the film was developed more than once"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!source.contains("let local_in"),
|
||||||
|
"the film was blended as a result"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_layers_film_without_a_stock_composes_to_nothing() {
|
||||||
|
// TRACES: FR-DEV-3f
|
||||||
|
// The offsets are kept — a stock chosen later brings them back — but
|
||||||
|
// with no film on the photograph there is nothing for them to offset.
|
||||||
|
use crate::ops::film_sim;
|
||||||
|
let mut graph = crate::EditGraph::default_chain();
|
||||||
|
let mut layer = MaskLayer::new("m1", regions(&[1]));
|
||||||
|
layer.set_param(film_sim::ID.0, film_sim::PUSH, 1.0);
|
||||||
|
graph.masks_mut().push(layer);
|
||||||
|
|
||||||
|
let source = graph.compose().source;
|
||||||
|
assert!(!source.contains("---- film_sim ----"), "{source}");
|
||||||
|
assert!(!source.contains("mask0_film_sim"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_layer_with_no_adjustment_is_not_in_the_shader() {
|
fn a_layer_with_no_adjustment_is_not_in_the_shader() {
|
||||||
let layer = MaskLayer::new("m1", regions(&[1]));
|
let layer = MaskLayer::new("m1", regions(&[1]));
|
||||||
@@ -2317,7 +2504,10 @@ mod tests {
|
|||||||
let mut stack = MaskStack::new();
|
let mut stack = MaskStack::new();
|
||||||
stack.push(layer);
|
stack.push(layer);
|
||||||
assert!(stack.is_neutral());
|
assert!(stack.is_neutral());
|
||||||
assert_eq!(compose_layers_revealing(&stack, None).body, "");
|
assert_eq!(
|
||||||
|
compose_layers_revealing(&stack, None, &ops::chain()).weights,
|
||||||
|
""
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2403,11 +2593,11 @@ mod tests {
|
|||||||
stack.push(lit_layer("m1", 1.0));
|
stack.push(lit_layer("m1", 1.0));
|
||||||
stack.push(lit_layer("m2", -1.0));
|
stack.push(lit_layer("m2", -1.0));
|
||||||
|
|
||||||
let shader = compose_layers_revealing(&stack, None);
|
let shader = compose_layers_revealing(&stack, None, &ops::chain());
|
||||||
assert!(shader.body.contains("sample_mask(uv_src, 0)"));
|
assert!(shader.weights.contains("sample_mask(uv_src, 0)"));
|
||||||
assert!(shader.body.contains("sample_mask(uv_src, 1)"));
|
assert!(shader.weights.contains("sample_mask(uv_src, 1)"));
|
||||||
assert!(shader.body.contains("u.mask0_opacity"));
|
assert!(shader.weights.contains("u.mask0_opacity"));
|
||||||
assert!(shader.body.contains("u.mask1_opacity"));
|
assert!(shader.weights.contains("u.mask1_opacity"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The slot a layer renders through must follow `active()`, not the raw
|
/// The slot a layer renders through must follow `active()`, not the raw
|
||||||
@@ -2420,12 +2610,12 @@ mod tests {
|
|||||||
stack.push(off);
|
stack.push(off);
|
||||||
stack.push(lit_layer("m2", -1.0));
|
stack.push(lit_layer("m2", -1.0));
|
||||||
|
|
||||||
let shader = compose_layers_revealing(&stack, None);
|
let shader = compose_layers_revealing(&stack, None, &ops::chain());
|
||||||
assert!(
|
assert!(
|
||||||
shader.body.contains("sample_mask(uv_src, 0)"),
|
shader.weights.contains("sample_mask(uv_src, 0)"),
|
||||||
"the one active layer must use slot 0, not slot 1"
|
"the one active layer must use slot 0, not slot 1"
|
||||||
);
|
);
|
||||||
assert!(!shader.body.contains("sample_mask(uv_src, 1)"));
|
assert!(!shader.weights.contains("sample_mask(uv_src, 1)"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TRACES: FR-DEV-19c
|
/// TRACES: FR-DEV-19c
|
||||||
@@ -2445,7 +2635,7 @@ mod tests {
|
|||||||
stack.push(MaskLayer::new("m2", MaskSource::brush()));
|
stack.push(MaskLayer::new("m2", MaskSource::brush()));
|
||||||
|
|
||||||
let reveal = Reveal::one("m2", RevealStyle::Alpha);
|
let reveal = Reveal::one("m2", RevealStyle::Alpha);
|
||||||
let shader = compose_layers_revealing(&stack, Some(&reveal));
|
let shader = compose_layers_revealing(&stack, Some(&reveal), &ops::chain());
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
stack.rendered_count(Some(&reveal)),
|
stack.rendered_count(Some(&reveal)),
|
||||||
@@ -2483,7 +2673,7 @@ mod tests {
|
|||||||
],
|
],
|
||||||
style: RevealStyle::Tint,
|
style: RevealStyle::Tint,
|
||||||
};
|
};
|
||||||
let shader = compose_layers_revealing(&stack, Some(&reveal));
|
let shader = compose_layers_revealing(&stack, Some(&reveal), &ops::chain());
|
||||||
|
|
||||||
let sky = shader
|
let sky = shader
|
||||||
.reveal
|
.reveal
|
||||||
@@ -2509,7 +2699,9 @@ mod tests {
|
|||||||
fn nothing_is_revealed_unless_it_was_asked_for() {
|
fn nothing_is_revealed_unless_it_was_asked_for() {
|
||||||
let mut stack = MaskStack::new();
|
let mut stack = MaskStack::new();
|
||||||
stack.push(lit_layer("m1", 1.0));
|
stack.push(lit_layer("m1", 1.0));
|
||||||
assert!(compose_layers_revealing(&stack, None).reveal.is_empty());
|
assert!(compose_layers_revealing(&stack, None, &ops::chain())
|
||||||
|
.reveal
|
||||||
|
.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A reveal aimed at a layer that is not in the stack is not a slot, and
|
/// A reveal aimed at a layer that is not in the stack is not a slot, and
|
||||||
@@ -2520,9 +2712,11 @@ mod tests {
|
|||||||
stack.push(lit_layer("m1", 1.0));
|
stack.push(lit_layer("m1", 1.0));
|
||||||
let reveal = Reveal::one("gone", RevealStyle::Tint);
|
let reveal = Reveal::one("gone", RevealStyle::Tint);
|
||||||
assert_eq!(stack.rendered_count(Some(&reveal)), 1);
|
assert_eq!(stack.rendered_count(Some(&reveal)), 1);
|
||||||
assert!(compose_layers_revealing(&stack, Some(&reveal))
|
assert!(
|
||||||
.reveal
|
compose_layers_revealing(&stack, Some(&reveal), &ops::chain())
|
||||||
.is_empty());
|
.reveal
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2531,7 +2725,7 @@ mod tests {
|
|||||||
stack.push(lit_layer("m1", 1.0));
|
stack.push(lit_layer("m1", 1.0));
|
||||||
stack.push(lit_layer("m2", -1.0));
|
stack.push(lit_layer("m2", -1.0));
|
||||||
|
|
||||||
let shader = compose_layers_revealing(&stack, None);
|
let shader = compose_layers_revealing(&stack, None, &ops::chain());
|
||||||
assert!(shader.uniform_fields.contains("mask0_exposure_"));
|
assert!(shader.uniform_fields.contains("mask0_exposure_"));
|
||||||
assert!(shader.uniform_fields.contains("mask1_exposure_"));
|
assert!(shader.uniform_fields.contains("mask1_exposure_"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -2545,16 +2739,140 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The whole shader for `stack` over a global chain with `global`
|
||||||
|
/// applied to it.
|
||||||
|
fn composed_with(stack: &MaskStack, global: impl FnOnce(&mut [Box<dyn Operation>])) -> String {
|
||||||
|
let mut chain = ops::chain();
|
||||||
|
global(&mut chain);
|
||||||
|
crate::operation::compose_full(
|
||||||
|
&chain,
|
||||||
|
&crate::Framing::new(),
|
||||||
|
dr_types::ColourSpace::Srgb,
|
||||||
|
stack,
|
||||||
|
&crate::spot::SpotSet::new(),
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.source
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set(chain: &mut [Box<dyn Operation>], op: &str, param: &'static str, v: f32) {
|
||||||
|
chain
|
||||||
|
.iter_mut()
|
||||||
|
.find(|o| o.descriptor().id.0 == op)
|
||||||
|
.expect("op in chain")
|
||||||
|
.set_param(ParamId(param), v);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn contrast_layer(v: f32) -> MaskLayer {
|
||||||
|
let mut layer = MaskLayer::new("m1", regions(&[1]));
|
||||||
|
layer.set_param("contrast", ParamId("contrast"), v);
|
||||||
|
layer
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_inner_block_shadows_c_and_copies_back() {
|
fn the_layer_version_shadows_c_and_blends_by_its_difference() {
|
||||||
let mut stack = MaskStack::new();
|
let mut stack = MaskStack::new();
|
||||||
stack.push(lit_layer("m1", 1.0));
|
stack.push(lit_layer("m1", 1.0));
|
||||||
let body = compose_layers_revealing(&stack, None).body;
|
let src = composed_with(&stack, |_| {});
|
||||||
|
|
||||||
assert!(body.contains("var masked = c;"));
|
assert!(src.contains("var c = local_in;"));
|
||||||
assert!(body.contains("var c = masked;"));
|
assert!(src.contains("local_sum = local_sum + mask_w0 * (c - local_global);"));
|
||||||
assert!(body.contains("masked = c;"));
|
assert!(src.contains("c = max(local_sum, vec3<f32>(0.0));"));
|
||||||
assert!(body.contains("c = mix(c, masked, m);"));
|
}
|
||||||
|
|
||||||
|
/// **The bug this shape exists for.** A layer's contrast is added to the
|
||||||
|
/// global contrast, not run a second time on top of it.
|
||||||
|
#[test]
|
||||||
|
fn a_layer_setting_is_an_offset_to_the_global_one() {
|
||||||
|
let mut stack = MaskStack::new();
|
||||||
|
stack.push(contrast_layer(-20.0));
|
||||||
|
let mut chain = ops::chain();
|
||||||
|
set(&mut chain, "contrast", "contrast", -30.0);
|
||||||
|
|
||||||
|
let shader = compose_layers_revealing(&stack, None, &chain);
|
||||||
|
let at = shader
|
||||||
|
.uniform_fields
|
||||||
|
.lines()
|
||||||
|
.filter(|l| l.trim_start().starts_with("mask"))
|
||||||
|
.position(|l| l.contains("mask0_contrast_amount"))
|
||||||
|
.expect("the layer carries its own contrast");
|
||||||
|
assert_eq!(
|
||||||
|
shader.uniform_values[at], -0.5,
|
||||||
|
"global -30 and local -20 is -50 inside the mask"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the operation runs is where the layer's version of it runs —
|
||||||
|
/// between the global operations either side, not after all of them.
|
||||||
|
#[test]
|
||||||
|
fn a_layer_runs_at_its_operations_place_in_the_chain() {
|
||||||
|
let mut stack = MaskStack::new();
|
||||||
|
stack.push(contrast_layer(-20.0));
|
||||||
|
let src = composed_with(&stack, |c| set(c, "saturation", "saturation", 20.0));
|
||||||
|
|
||||||
|
let contrast = src.find("// ---- contrast ----").expect("contrast block");
|
||||||
|
let blend = src.find("mask_w0 * (c - local_global)").expect("blend");
|
||||||
|
let saturation = src
|
||||||
|
.find("// ---- saturation ----")
|
||||||
|
.expect("saturation block");
|
||||||
|
assert!(contrast < blend && blend < saturation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **No setting is applied twice.** The layer's version of an operation
|
||||||
|
/// starts from the colour the operation was handed, not from the global
|
||||||
|
/// result, and reads only its own combined setting — so global −30 and
|
||||||
|
/// local −20 is one contrast of −50 inside the mask, never −30 and then
|
||||||
|
/// −50 again, and the operation does not run a second time after the
|
||||||
|
/// chain as it once did.
|
||||||
|
#[test]
|
||||||
|
fn a_setting_is_applied_once_not_stacked() {
|
||||||
|
let mut stack = MaskStack::new();
|
||||||
|
stack.push(contrast_layer(-20.0));
|
||||||
|
let src = composed_with(&stack, |c| set(c, "contrast", "contrast", -30.0));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
src.matches("// ---- contrast ----").count(),
|
||||||
|
1,
|
||||||
|
"contrast runs at one place in the chain"
|
||||||
|
);
|
||||||
|
let version = &src[src.find("if (mask_w0 > 0.0)").expect("layer version")..];
|
||||||
|
let version = &version[..version.find("local_sum = local_sum").unwrap()];
|
||||||
|
assert!(
|
||||||
|
version.contains("var c = local_in;"),
|
||||||
|
"starts from the operation's input"
|
||||||
|
);
|
||||||
|
assert!(version.contains("u.mask0_contrast_amount"));
|
||||||
|
assert!(
|
||||||
|
!version.contains("u.contrast_amount"),
|
||||||
|
"the global setting is already inside the combined one"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An offset that cancels the global setting is not nothing: inside the
|
||||||
|
/// mask the operation is back at neutral, so the layer's version is empty
|
||||||
|
/// and the blend pulls toward the colour the operation was handed.
|
||||||
|
#[test]
|
||||||
|
fn an_offset_back_to_neutral_undoes_the_global_setting() {
|
||||||
|
let mut stack = MaskStack::new();
|
||||||
|
stack.push(contrast_layer(30.0));
|
||||||
|
let mut chain = ops::chain();
|
||||||
|
set(&mut chain, "contrast", "contrast", -30.0);
|
||||||
|
|
||||||
|
let shader = compose_layers_revealing(&stack, None, &chain);
|
||||||
|
let local: Vec<_> = shader.ops.iter().filter(|l| l.op == "contrast").collect();
|
||||||
|
assert_eq!(local.len(), 1);
|
||||||
|
assert!(local[0].fragment.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A global chain with nothing moved still hands a layer's operation a
|
||||||
|
/// place to run: the global side of the blend is simply empty.
|
||||||
|
#[test]
|
||||||
|
fn an_operation_only_a_layer_moved_still_runs_in_its_place() {
|
||||||
|
let mut stack = MaskStack::new();
|
||||||
|
stack.push(contrast_layer(-20.0));
|
||||||
|
let src = composed_with(&stack, |_| {});
|
||||||
|
assert!(src.contains("// ---- contrast ----"));
|
||||||
|
assert!(!src.contains("(local only)"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -82,8 +82,8 @@ const FLOOR: f32 = 1e-4;
|
|||||||
/// Move the graph so that `sample` renders neutral.
|
/// Move the graph so that `sample` renders neutral.
|
||||||
///
|
///
|
||||||
/// `sample` is the linear triple the operation's own gains multiply — camera
|
/// `sample` is the linear triple the operation's own gains multiply — camera
|
||||||
/// RGB with the camera's as-shot balance on, *before* the body's base curve
|
/// RGB with the camera's as-shot balance on, *before* the camera matrix and
|
||||||
/// and matrix, and with the sampling operation at its defaults. Not the
|
/// the view transform, and with the sampling operation at its defaults. Not the
|
||||||
/// pixel on the screen: the matrix mixes the channels on the way there, so
|
/// pixel on the screen: the matrix mixes the channels on the way there, so
|
||||||
/// a colour read after it does not answer to these gains, and a solve over
|
/// a colour read after it does not answer to these gains, and a solve over
|
||||||
/// one lands somewhere no sample asked for. Returns whether the graph was
|
/// one lands somewhere no sample asked for. Returns whether the graph was
|
||||||
|
|||||||
+720
-352
File diff suppressed because it is too large
Load Diff
@@ -347,8 +347,15 @@ impl Operation for CaptureSharpen {
|
|||||||
|
|
||||||
impl DetailStage for CaptureSharpen {
|
impl DetailStage for CaptureSharpen {
|
||||||
fn passes(&self, scale: RenderScale) -> Vec<DetailPass> {
|
fn passes(&self, scale: RenderScale) -> Vec<DetailPass> {
|
||||||
|
// A radius finer than one pixel of this render: the detail it would
|
||||||
|
// act on is not in this texture — it was lost to the downscale before
|
||||||
|
// this stage ran (FR-DSP-1). Guessing at it would put sharpening on
|
||||||
|
// screen that the exported file will not contain, so there is no
|
||||||
|
// pass, and the interface is free to say `zoom to 1:1`. An empty
|
||||||
|
// chain is a whole render since D19: the fused pass's view pass
|
||||||
|
// performs the output transform whatever the chain holds.
|
||||||
if !self.resolves(scale) {
|
if !self.resolves(scale) {
|
||||||
return vec![nothing_to_sharpen()];
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
let extent = self.kernel(scale);
|
let extent = self.kernel(scale);
|
||||||
@@ -396,41 +403,6 @@ impl DetailStage for CaptureSharpen {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The pass emitted when the radius is finer than a render pixel.
|
|
||||||
///
|
|
||||||
/// One dispatch that changes nothing, rather than an empty chain, and the
|
|
||||||
/// difference is not stylistic. [`crate::operation::compose_full`] decides
|
|
||||||
/// from the *operations* — before any resolution is known — that an active
|
|
||||||
/// detail operation means the fused pass hands on unclipped linear values
|
|
||||||
/// instead of encoding its own output. If this returned no passes at all,
|
|
||||||
/// that decision would still stand and nothing downstream would ever perform
|
|
||||||
/// the output transform: `dr-gpu` would be handed a linear-working shader
|
|
||||||
/// with an empty chain and refuse it.
|
|
||||||
///
|
|
||||||
/// So the honest "nothing survives at this scale" still has to carry the
|
|
||||||
/// encode, and one pass that does only that is exactly the resolve step the
|
|
||||||
/// stage would otherwise need. It costs a single copy of a proxy-sized
|
|
||||||
/// texture, which is a rounding error against the dispatches around it.
|
|
||||||
fn nothing_to_sharpen() -> DetailPass {
|
|
||||||
DetailPass {
|
|
||||||
output_scale: 1,
|
|
||||||
label: "unresolved",
|
|
||||||
// Reads only the pixel it writes, so a tile needs no halo at all.
|
|
||||||
radius: 0,
|
|
||||||
// A convolution, not a list: nothing to bind at binding 3.
|
|
||||||
storage: Vec::new(),
|
|
||||||
uniforms: Vec::new(),
|
|
||||||
wgsl: "// The chosen radius is finer than one pixel of this render, so the detail
|
|
||||||
// it would act on is not in this texture — it was lost to the downscale
|
|
||||||
// before this stage ran (FR-DSP-1). Guessing at it would put sharpening on
|
|
||||||
// screen that the exported file will not contain, so this pass passes the
|
|
||||||
// colour through unchanged and the interface is free to say `zoom to 1:1`.
|
|
||||||
//
|
|
||||||
// `c` already holds this pixel; leaving it alone is the whole body."
|
|
||||||
.to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One axis of the separable unsharp mask.
|
/// One axis of the separable unsharp mask.
|
||||||
///
|
///
|
||||||
/// Emitted verbatim for both passes — see [`DetailStage::passes`] for why the
|
/// Emitted verbatim for both passes — see [`DetailStage::passes`] for why the
|
||||||
@@ -528,7 +500,6 @@ c = select(c, scaled, centre > 1e-5);"#;
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::EditGraph;
|
use crate::EditGraph;
|
||||||
use dr_types::ColourSpace;
|
|
||||||
|
|
||||||
/// The develop chain with the sharpener turned up.
|
/// The develop chain with the sharpener turned up.
|
||||||
///
|
///
|
||||||
@@ -549,7 +520,7 @@ mod tests {
|
|||||||
// The scale is what these tests vary, so it is rebuilt into the two
|
// The scale is what these tests vary, so it is rebuilt into the two
|
||||||
// sizes it stands for rather than handed over: a render of the full
|
// sizes it stands for rather than handed over: a render of the full
|
||||||
// frame at `render_size`, from a source of `full_size`.
|
// frame at `render_size`, from a source of `full_size`.
|
||||||
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb)
|
graph.compose_detail(scale.full_size(), scale.render_size())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -591,13 +562,11 @@ mod tests {
|
|||||||
assert_eq!(first.label, "capture_sharpen/horizontal");
|
assert_eq!(first.label, "capture_sharpen/horizontal");
|
||||||
assert_eq!(last.label, "capture_sharpen/vertical");
|
assert_eq!(last.label, "capture_sharpen/vertical");
|
||||||
|
|
||||||
assert!(!first.writes_output);
|
// Neither encodes: the view pass after the detail stage does (D19).
|
||||||
assert!(first.source.contains("texture_storage_2d<rgba16float"));
|
for pass in [first, last] {
|
||||||
assert!(!first.source.contains("fn encode_output"));
|
assert!(pass.source.contains("texture_storage_2d<rgba16float"));
|
||||||
|
assert!(!pass.source.contains("fn encode_output"));
|
||||||
assert!(last.writes_output);
|
}
|
||||||
assert!(last.source.contains("texture_storage_2d<rgba8unorm"));
|
|
||||||
assert!(last.source.contains("fn encode_output"));
|
|
||||||
|
|
||||||
// Two shaders, so two pipeline-cache entries. Sharing one would run
|
// Two shaders, so two pipeline-cache entries. Sharing one would run
|
||||||
// the horizontal pass's uniforms through the vertical pass's slots.
|
// the horizontal pass's uniforms through the vertical pass's slots.
|
||||||
@@ -723,20 +692,11 @@ mod tests {
|
|||||||
let proxy = RenderScale::new((1000, 1000), (4000, 4000));
|
let proxy = RenderScale::new((1000, 1000), (4000, 4000));
|
||||||
assert!(!proxy.resolves(1.0));
|
assert!(!proxy.resolves(1.0));
|
||||||
|
|
||||||
|
// Empty: since D19 nothing in the detail stage encodes, so a chain
|
||||||
|
// with nothing to draw is a whole render — the fused pass's view pass
|
||||||
|
// finishes it.
|
||||||
let composed = chain_at(&graph, proxy);
|
let composed = chain_at(&graph, proxy);
|
||||||
// Not empty, though. See `nothing_to_sharpen`: the fused pass has
|
assert!(composed.is_empty());
|
||||||
// already been composed to hand on linear values, so *something* must
|
|
||||||
// still perform the output transform.
|
|
||||||
assert_eq!(composed.len(), 1);
|
|
||||||
assert_eq!(composed.radius(), 0, "it reads no neighbours");
|
|
||||||
let pass = &composed.passes[0];
|
|
||||||
assert_eq!(pass.label, "capture_sharpen/unresolved");
|
|
||||||
assert!(pass.writes_output);
|
|
||||||
assert!(pass.source.contains("fn encode_output"));
|
|
||||||
assert!(
|
|
||||||
!pass.source.contains("for (var i ="),
|
|
||||||
"the pass-through must not walk a kernel it has decided not to run"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Zooming to 1:1 is what brings it back — the view rect shrinks while
|
// Zooming to 1:1 is what brings it back — the view rect shrinks while
|
||||||
// the render target keeps its size — so there is no separate
|
// the render target keeps its size — so there is no separate
|
||||||
|
|||||||
@@ -488,6 +488,36 @@ fn curve_eval(
|
|||||||
}",
|
}",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-2
|
||||||
|
/// The curve continued past its last point, for scene values above the
|
||||||
|
/// widget's axis.
|
||||||
|
const CURVE_EXTEND: Helper = Helper {
|
||||||
|
name: "curve_extend",
|
||||||
|
source: "\
|
||||||
|
// A five-point curve at `x`, continued past its last point along the slope of
|
||||||
|
// its last span.
|
||||||
|
//
|
||||||
|
// The widget draws a 0..1 axis, and scene-referred values do not stop at 1
|
||||||
|
// (D19): exposure and highlight recovery put them above it, and the view
|
||||||
|
// transform after every operation is what brings them down. Flat past the last
|
||||||
|
// point — which is what `curve_eval` gives, and what this curve did until
|
||||||
|
// D19 — made every one of them the same number, a hard clip in the middle of
|
||||||
|
// the chain. Continued along the last span instead, an identity curve stays
|
||||||
|
// the identity to any height, and a curve that lifts the highlights keeps
|
||||||
|
// lifting them. The slope is the last span's secant, which is also the
|
||||||
|
// tangent `curve_eval` gives the last point, so the join is smooth; monotone
|
||||||
|
// points make it non-negative.
|
||||||
|
fn curve_extend(
|
||||||
|
x0: f32, y0: f32, x1: f32, y1: f32, x2: f32, y2: f32,
|
||||||
|
x3: f32, y3: f32, x4: f32, y4: f32, x: f32,
|
||||||
|
) -> f32 {
|
||||||
|
if (x <= x4) {
|
||||||
|
return curve_eval(x0, y0, x1, y1, x2, y2, x3, y3, x4, y4, x);
|
||||||
|
}
|
||||||
|
return y4 + (x - x4) * max((y4 - y3) / (x4 - x3), 0.0);
|
||||||
|
}",
|
||||||
|
};
|
||||||
|
|
||||||
/// One colour component through its own curve.
|
/// One colour component through its own curve.
|
||||||
const CHANNEL_CURVE: Helper = Helper {
|
const CHANNEL_CURVE: Helper = Helper {
|
||||||
name: "channel_curve",
|
name: "channel_curve",
|
||||||
@@ -504,20 +534,21 @@ const CHANNEL_CURVE: Helper = Helper {
|
|||||||
// it changes the proportions between the components, which is what makes it
|
// it changes the proportions between the components, which is what makes it
|
||||||
// chromatic where the master is tonal.
|
// chromatic where the master is tonal.
|
||||||
//
|
//
|
||||||
// The clamp is the curve's promise rather than an oversight: its last point
|
// Above the axis the curve continues along its last span (`curve_extend`),
|
||||||
// *is* white, so a component arriving above the axis takes the value the curve
|
// exactly as the master's does, so a highlight is tinted the way every tone
|
||||||
// gives at 1. The master does the same to a luminance above 1, through the
|
// just below it is — a component that stopped at the curve's top instead
|
||||||
// gain it applies; a channel curve that instead let highlights past unchanged
|
// would put a coloured fringe along a blown edge, and flattened every
|
||||||
// would tint them differently from every tone below them, which reads as a
|
// scene-referred highlight into one value besides (D19). Below zero there is
|
||||||
// coloured fringe along a blown edge.
|
// no light to curve; the floor is the one clamp left, and it is at zero, not
|
||||||
|
// at one.
|
||||||
fn channel_curve(
|
fn channel_curve(
|
||||||
v: f32,
|
v: f32,
|
||||||
x0: f32, y0: f32, x1: f32, y1: f32, x2: f32, y2: f32,
|
x0: f32, y0: f32, x1: f32, y1: f32, x2: f32, y2: f32,
|
||||||
x3: f32, y3: f32, x4: f32, y4: f32,
|
x3: f32, y3: f32, x4: f32, y4: f32,
|
||||||
) -> f32 {
|
) -> f32 {
|
||||||
let encoded = pow(clamp(v, 0.0, 1.0), 1.0 / 2.2);
|
let encoded = pow(max(v, 0.0), 1.0 / 2.2);
|
||||||
let curved = curve_eval(x0, y0, x1, y1, x2, y2, x3, y3, x4, y4, encoded);
|
let curved = curve_extend(x0, y0, x1, y1, x2, y2, x3, y3, x4, y4, encoded);
|
||||||
return pow(clamp(curved, 0.0, 1.0), 2.2);
|
return pow(max(curved, 0.0), 2.2);
|
||||||
}",
|
}",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -535,10 +566,11 @@ static MASTER_HELPERS: &[Helper] = &[
|
|||||||
helpers::APPLY_TONE_GAIN,
|
helpers::APPLY_TONE_GAIN,
|
||||||
CURVE_SPAN,
|
CURVE_SPAN,
|
||||||
CURVE_EVAL,
|
CURVE_EVAL,
|
||||||
|
CURVE_EXTEND,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// The per-channel curves alone.
|
/// The per-channel curves alone.
|
||||||
static CHANNEL_HELPERS: &[Helper] = &[CURVE_SPAN, CURVE_EVAL, CHANNEL_CURVE];
|
static CHANNEL_HELPERS: &[Helper] = &[CURVE_SPAN, CURVE_EVAL, CURVE_EXTEND, CHANNEL_CURVE];
|
||||||
|
|
||||||
/// Both.
|
/// Both.
|
||||||
static ALL_HELPERS: &[Helper] = &[
|
static ALL_HELPERS: &[Helper] = &[
|
||||||
@@ -546,6 +578,7 @@ static ALL_HELPERS: &[Helper] = &[
|
|||||||
helpers::APPLY_TONE_GAIN,
|
helpers::APPLY_TONE_GAIN,
|
||||||
CURVE_SPAN,
|
CURVE_SPAN,
|
||||||
CURVE_EVAL,
|
CURVE_EVAL,
|
||||||
|
CURVE_EXTEND,
|
||||||
CHANNEL_CURVE,
|
CHANNEL_CURVE,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -553,16 +586,17 @@ static ALL_HELPERS: &[Helper] = &[
|
|||||||
const MASTER_BODY: &str = "\
|
const MASTER_BODY: &str = "\
|
||||||
let luma = luminance(c);
|
let luma = luminance(c);
|
||||||
if (luma > 0.0001) {
|
if (luma > 0.0001) {
|
||||||
// The curve is authored on a display-referred 0..1 axis, which is where
|
// The curve is authored on a 0..1 axis, which is where the widget's grid
|
||||||
// the eye reads tone and where the widget's grid lives. Scene-referred
|
// lives, with a 2.2 gamma so that a point placed at the middle of the
|
||||||
// luminance is unbounded, so it is encoded to that axis, curved, and
|
// grid means the middle of the visible range. Scene-referred luminance
|
||||||
// decoded back — otherwise a point placed at the middle of the grid
|
// does not stop at 1: above the axis the curve continues along its last
|
||||||
// would not correspond to the middle of the visible range.
|
// span (`curve_extend`) rather than clipping, because the view transform
|
||||||
let encoded = pow(clamp(luma, 0.0, 1.0), 1.0 / 2.2);
|
// after every operation is what brings a highlight down (D19).
|
||||||
|
let encoded = pow(luma, 1.0 / 2.2);
|
||||||
|
|
||||||
let curved = curve_eval(x0, y0, x1, y1, x2, y2, x3, y3, x4, y4, encoded);
|
let curved = curve_extend(x0, y0, x1, y1, x2, y2, x3, y3, x4, y4, encoded);
|
||||||
|
|
||||||
let decoded = pow(clamp(curved, 0.0, 1.0), 2.2);
|
let decoded = pow(max(curved, 0.0), 2.2);
|
||||||
// Applied as a ratio so hue is preserved, exactly as contrast does.
|
// Applied as a ratio so hue is preserved, exactly as contrast does.
|
||||||
c = apply_tone_gain(c, decoded / luma);
|
c = apply_tone_gain(c, decoded / luma);
|
||||||
}";
|
}";
|
||||||
@@ -1288,7 +1322,10 @@ mod tests {
|
|||||||
c.set_param(P2_Y, 0.7);
|
c.set_param(P2_Y, 0.7);
|
||||||
|
|
||||||
let body = c.wgsl_body();
|
let body = c.wgsl_body();
|
||||||
assert!(body.contains("curve_eval("), "the master curve is missing");
|
assert!(
|
||||||
|
body.contains("curve_extend("),
|
||||||
|
"the master curve is missing"
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!body.contains("channel_curve("),
|
!body.contains("channel_curve("),
|
||||||
"an untouched channel reached the shader:\n{body}"
|
"an untouched channel reached the shader:\n{body}"
|
||||||
|
|||||||
+116
-148
@@ -116,12 +116,34 @@
|
|||||||
//! mottling across smooth gradients. This decomposition samples nothing — it
|
//! mottling across smooth gradients. This decomposition samples nothing — it
|
||||||
//! evaluates the exact minimum over every pixel of the window, in two steps.
|
//! evaluates the exact minimum over every pixel of the window, in two steps.
|
||||||
//!
|
//!
|
||||||
|
//! # Why it is now two passes and not five
|
||||||
|
//!
|
||||||
|
//! The decomposition was run as four erosion passes — run and span along x,
|
||||||
|
//! then along y — and a fifth for the recovery. On the reference laptop the
|
||||||
|
//! taps turned out not to be what a pass costs: with the memory clock held at
|
||||||
|
//! 810 MHz by the power cap, a pass that only reads the render-sized
|
||||||
|
//! `rgba16float` intermediate and writes the other one costs about 4 ms at
|
||||||
|
//! 2560 x 1600, and dehaze's five came to 22 ms, of which the taps were about
|
||||||
|
//! 2. So each axis is now one pass that takes the minimum over the whole
|
||||||
|
//! window directly — 36 texture reads per pixel at that size instead of 12,
|
||||||
|
//! nearly all of them served by the cache — and the recovery rides in the y
|
||||||
|
//! pass, which already has the veil and this pixel's colour in hand. Two
|
||||||
|
//! passes: 9.2 ms.
|
||||||
|
//!
|
||||||
|
//! It is the same picture, bit for bit. A minimum is exact in any order, so
|
||||||
|
//! the minimum over the window's pixels is one value however it is grouped,
|
||||||
|
//! and the window is the one [`Split`] always covered, surplus pixel
|
||||||
|
//! included. The veil reaching the recovery was always exactly representable
|
||||||
|
//! in the `rgba16float` lane it crossed — a minimum of channel values that
|
||||||
|
//! were themselves read from `rgba16float` — so no rounding was lost by not
|
||||||
|
//! storing it between passes.
|
||||||
|
//!
|
||||||
//! It is also why this operation does not use the reduced chain that TD-4 gave
|
//! It is also why this operation does not use the reduced chain that TD-4 gave
|
||||||
//! clarity. The runner holds one reduced buffer, so every scaled pass in a
|
//! clarity. The runner holds one reduced buffer, so every scaled pass in a
|
||||||
//! chain must declare the same `output_scale`; clarity's steps down with the
|
//! chain must declare the same `output_scale`; clarity's steps down with the
|
||||||
//! viewport, so a second operation choosing its own would disagree with it at
|
//! viewport, so a second operation choosing its own would disagree with it at
|
||||||
//! some window sizes and not others. Four cheap full-resolution passes cost
|
//! some window sizes and not others. Two full-resolution passes cost less than
|
||||||
//! less than that coupling, and the decomposition is what makes them cheap.
|
//! that coupling.
|
||||||
//!
|
//!
|
||||||
//! # The artefact this does not fix
|
//! # The artefact this does not fix
|
||||||
//!
|
//!
|
||||||
@@ -275,22 +297,23 @@ impl Dehaze {
|
|||||||
///
|
///
|
||||||
/// See the module documentation: eroding by a contiguous run and then by a set
|
/// See the module documentation: eroding by a contiguous run and then by a set
|
||||||
/// of points spaced one run apart erodes by the sum of the two, which is the
|
/// of points spaced one run apart erodes by the sum of the two, which is the
|
||||||
/// whole window. This is the arithmetic of that split, in one place, because
|
/// whole window. The passes no longer run the two stages apart (see "Why it is
|
||||||
/// both axes need it and a second copy is a second chance to get the centring
|
/// now two passes"), but the window they read is still the one this split
|
||||||
/// wrong.
|
/// covers — [`Self::first`] and [`Self::width`] — surplus pixel included,
|
||||||
|
/// because that is the window every edit made so far was tuned against.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
pub struct Split {
|
pub struct Split {
|
||||||
/// Length of the contiguous run the first pass takes the minimum over.
|
/// Length of the contiguous run the first pass takes the minimum over.
|
||||||
pub run: u32,
|
pub run: u32,
|
||||||
/// How many runs the second pass chains together, spaced `run` apart.
|
/// How many runs the second pass chains together, spaced `run` apart.
|
||||||
pub span: u32,
|
pub span: u32,
|
||||||
/// What the second pass subtracts from its offsets to centre the window.
|
/// How far before the pixel being written the window starts.
|
||||||
///
|
///
|
||||||
/// The composite covers `run * span` pixels, which is at least the window
|
/// The composite covers `run * span` pixels, which is at least the window
|
||||||
/// asked for and can be one or two more; the surplus falls on the far side
|
/// asked for and can be one or two more; the surplus falls on the far side
|
||||||
/// rather than being trimmed, because trimming it would need a third pass
|
/// rather than being trimmed, because trimming it would have needed a
|
||||||
/// and a patch a pixel wider on one side is not a visible difference in a
|
/// third pass and a patch a pixel wider on one side is not a visible
|
||||||
/// field this smooth.
|
/// difference in a field this smooth.
|
||||||
pub shift: i32,
|
pub shift: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,14 +334,25 @@ impl Split {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The furthest the second pass reads, in pixels.
|
/// The window's first offset from the pixel being written: `-shift`.
|
||||||
|
pub fn first(&self) -> i32 {
|
||||||
|
-self.shift
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many pixels the window covers, `run * span` — the patch asked for
|
||||||
|
/// and the one or two surplus pixels on the far side the split leaves.
|
||||||
|
pub fn width(&self) -> u32 {
|
||||||
|
self.run * self.span
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The furthest the window reads from the pixel being written, in pixels.
|
||||||
///
|
///
|
||||||
/// Stated rather than assumed symmetric: the composite window is centred
|
/// Stated rather than assumed symmetric: the window is centred to within a
|
||||||
/// to within a pixel and not exactly, so the two directions can differ by
|
/// pixel and not exactly, so the two directions can differ by one. An
|
||||||
/// one. An understated radius is a seam at every tile boundary (ARCH
|
/// understated radius is a seam at every tile boundary (ARCH §5.3), which
|
||||||
/// §5.3), which is the kind of artefact that looks like a driver bug.
|
/// is the kind of artefact that looks like a driver bug.
|
||||||
pub fn reach(&self) -> u32 {
|
pub fn extent(&self) -> u32 {
|
||||||
let far = (self.span.saturating_sub(1) * self.run) as i32 - self.shift;
|
let far = self.width() as i32 - 1 - self.shift;
|
||||||
self.shift.max(far).max(0) as u32
|
self.shift.max(far).max(0) as u32
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -367,83 +401,50 @@ impl DetailStage for Dehaze {
|
|||||||
fn passes(&self, scale: RenderScale) -> Vec<DetailPass> {
|
fn passes(&self, scale: RenderScale) -> Vec<DetailPass> {
|
||||||
let split = Split::of(self.patch(scale));
|
let split = Split::of(self.patch(scale));
|
||||||
|
|
||||||
// The run stage's uniforms are the same on both axes, and so are the
|
// Both axes erode over the same window; only the offset expression
|
||||||
// span stage's. Only the offset expression differs, which is what
|
// differs, which is what `erode` takes as an argument — one filter
|
||||||
// `erode_run` and `erode_span` take as an argument — each filter
|
|
||||||
// written once, so the two axes cannot drift into being different
|
// written once, so the two axes cannot drift into being different
|
||||||
// filters.
|
// filters.
|
||||||
let run = vec![Uniform {
|
let window = vec![
|
||||||
name: "run",
|
|
||||||
value: split.run as f32,
|
|
||||||
}];
|
|
||||||
let span = vec![
|
|
||||||
Uniform {
|
Uniform {
|
||||||
name: "span",
|
name: "first",
|
||||||
value: split.span as f32,
|
value: split.first() as f32,
|
||||||
},
|
},
|
||||||
Uniform {
|
Uniform {
|
||||||
name: "stride",
|
name: "width",
|
||||||
value: split.run as f32,
|
value: split.width() as f32,
|
||||||
},
|
|
||||||
Uniform {
|
|
||||||
name: "shift",
|
|
||||||
value: split.shift as f32,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
let mut recovery = window.clone();
|
||||||
|
recovery.extend([
|
||||||
|
Uniform {
|
||||||
|
name: "omega",
|
||||||
|
value: self.omega(),
|
||||||
|
},
|
||||||
|
Uniform {
|
||||||
|
name: "min_transmission",
|
||||||
|
value: MIN_TRANSMISSION,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
vec![
|
vec![
|
||||||
DetailPass {
|
DetailPass {
|
||||||
output_scale: 1,
|
output_scale: 1,
|
||||||
label: "veil-run-x",
|
label: "veil-x",
|
||||||
// The run starts at this pixel and walks forward, so it reads
|
radius: split.extent(),
|
||||||
// `run - 1` beyond itself and nothing behind.
|
|
||||||
radius: split.run.saturating_sub(1),
|
|
||||||
storage: Vec::new(),
|
storage: Vec::new(),
|
||||||
uniforms: run.clone(),
|
uniforms: window,
|
||||||
wgsl: erode_run(Axis::X),
|
wgsl: erode(Axis::X),
|
||||||
},
|
},
|
||||||
DetailPass {
|
DetailPass {
|
||||||
output_scale: 1,
|
output_scale: 1,
|
||||||
label: "veil-span-x",
|
label: "veil-y-clear",
|
||||||
radius: split.reach(),
|
radius: split.extent(),
|
||||||
storage: Vec::new(),
|
storage: Vec::new(),
|
||||||
uniforms: span.clone(),
|
uniforms: recovery,
|
||||||
wgsl: erode_span(Axis::X),
|
// The erosion in a block of its own, so its locals do not
|
||||||
},
|
// collide with the recovery's.
|
||||||
DetailPass {
|
wgsl: format!("{{\n{}\n}}\n\n{CLEAR}", erode(Axis::Y)),
|
||||||
output_scale: 1,
|
|
||||||
label: "veil-run-y",
|
|
||||||
radius: split.run.saturating_sub(1),
|
|
||||||
storage: Vec::new(),
|
|
||||||
uniforms: run,
|
|
||||||
wgsl: erode_run(Axis::Y),
|
|
||||||
},
|
|
||||||
DetailPass {
|
|
||||||
output_scale: 1,
|
|
||||||
label: "veil-span-y",
|
|
||||||
radius: split.reach(),
|
|
||||||
storage: Vec::new(),
|
|
||||||
uniforms: span,
|
|
||||||
wgsl: erode_span(Axis::Y),
|
|
||||||
},
|
|
||||||
DetailPass {
|
|
||||||
output_scale: 1,
|
|
||||||
label: "clear",
|
|
||||||
// Reads only the pixel it writes: the veil arrived in the
|
|
||||||
// scratch lane four passes ago.
|
|
||||||
radius: 0,
|
|
||||||
storage: Vec::new(),
|
|
||||||
uniforms: vec![
|
|
||||||
Uniform {
|
|
||||||
name: "omega",
|
|
||||||
value: self.omega(),
|
|
||||||
},
|
|
||||||
Uniform {
|
|
||||||
name: "min_transmission",
|
|
||||||
value: MIN_TRANSMISSION,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
wgsl: CLEAR.to_string(),
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -466,33 +467,33 @@ impl Axis {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The first stage: the minimum over a contiguous run.
|
/// The minimum over the window along one axis.
|
||||||
///
|
///
|
||||||
/// Along x it reads the colour and reduces it to the dark channel; along y the
|
/// Along x it reads the colour and reduces it to the dark channel; along y the
|
||||||
/// dark channel is already in the scratch lane, so it reads that instead.
|
/// dark channel's x minimum is already in the scratch lane, so it reads that
|
||||||
/// Doing the channel minimum again on the second axis would be reducing a
|
/// instead. Doing the channel minimum again on the second axis would be
|
||||||
/// scalar and would quietly discard the x erosion.
|
/// reducing a scalar and would quietly discard the x erosion.
|
||||||
///
|
///
|
||||||
/// Neither stage touches `c`. The recovery needs the original colour *and* the
|
/// The x pass does not touch `c`. The recovery needs the original colour *and*
|
||||||
/// veil in the same place at the same time, and the ping-pong hands each pass
|
/// the veil in the same place at the same time, and the ping-pong hands each
|
||||||
/// only what the pass before it wrote — so the veil travels in `aux` and the
|
/// pass only what the pass before it wrote — so the veil travels in `aux` and
|
||||||
/// colour rides through untouched. See [`DetailPass::wgsl`].
|
/// the colour rides through untouched. See [`DetailPass::wgsl`].
|
||||||
fn erode_run(axis: Axis) -> String {
|
fn erode(axis: Axis) -> String {
|
||||||
let source = match axis {
|
let source = match axis {
|
||||||
Axis::X => "dark_channel(tap(coord, OFFSET))",
|
Axis::X => "dark_channel(tap(coord, OFFSET))",
|
||||||
Axis::Y => "tap_aux(coord, OFFSET)",
|
Axis::Y => "tap_aux(coord, OFFSET)",
|
||||||
};
|
};
|
||||||
let first = source.replace("OFFSET", &axis.offset("0"));
|
let head = source.replace("OFFSET", &axis.offset("o"));
|
||||||
let rest = source.replace("OFFSET", &axis.offset("i"));
|
let rest = source.replace("OFFSET", &axis.offset("o + i"));
|
||||||
|
|
||||||
format!(
|
format!(
|
||||||
"\
|
"\
|
||||||
// Half of the erosion's first stage: the minimum over `run` contiguous pixels,
|
// The minimum over every pixel of the window along this axis, from `first`
|
||||||
// walking forward from this one. The second stage chains these together, and
|
// for `width` pixels. A minimum is exact in any order, so this is the same
|
||||||
// the two structuring elements add up to the whole patch — which is why this
|
// value, bit for bit, as chaining a run and a span over the same pixels.
|
||||||
// one is not centred and does not need to be.
|
let o = i32(first);
|
||||||
let n = i32(run);
|
let n = i32(width);
|
||||||
var veil = {first};
|
var veil = {head};
|
||||||
for (var i = 1; i < n; i = i + 1) {{
|
for (var i = 1; i < n; i = i + 1) {{
|
||||||
veil = min(veil, {rest});
|
veil = min(veil, {rest});
|
||||||
}}
|
}}
|
||||||
@@ -500,38 +501,10 @@ aux = veil;"
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The second stage: the minimum over `span` points spaced `stride` apart.
|
|
||||||
///
|
|
||||||
/// Each of those points already holds the minimum over the run that starts
|
|
||||||
/// there, so this reads the whole window while touching `span` pixels of it.
|
|
||||||
/// `shift` is what centres the composite on the pixel being written; without
|
|
||||||
/// it the veil would be measured from a patch lying entirely to one side, and
|
|
||||||
/// the correction would appear to lag the picture by half a patch.
|
|
||||||
fn erode_span(axis: Axis) -> String {
|
|
||||||
let offset = axis.offset("j * s - o");
|
|
||||||
let first = axis.offset("-o");
|
|
||||||
|
|
||||||
format!(
|
|
||||||
"\
|
|
||||||
// The erosion's second stage. `span` taps, spaced a whole run apart, each
|
|
||||||
// standing for the run that begins at it — so the minimum over the patch costs
|
|
||||||
// `run + span` taps rather than the `run * span` pixels it covers, and it is
|
|
||||||
// the exact minimum over all of them rather than a sample of them.
|
|
||||||
let n = i32(span);
|
|
||||||
let s = i32(stride);
|
|
||||||
let o = i32(shift);
|
|
||||||
var veil = tap_aux(coord, {first});
|
|
||||||
for (var j = 1; j < n; j = j + 1) {{
|
|
||||||
veil = min(veil, tap_aux(coord, {offset}));
|
|
||||||
}}
|
|
||||||
aux = veil;"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The recovery: invert the scattering model with the transmission the erosion
|
/// The recovery: invert the scattering model with the transmission the erosion
|
||||||
/// implies.
|
/// implies.
|
||||||
const CLEAR: &str = "\
|
const CLEAR: &str = "\
|
||||||
// The veil the four erosion passes measured: the smallest channel anywhere in
|
// The veil the two erosions measured: the smallest channel anywhere in
|
||||||
// the patch around this pixel, which the dark-channel prior reads as the
|
// the patch around this pixel, which the dark-channel prior reads as the
|
||||||
// airlight that has been composited over the scene here.
|
// airlight that has been composited over the scene here.
|
||||||
//
|
//
|
||||||
@@ -568,20 +541,19 @@ c = (c - lifted) / t;";
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::detail::compose_detail;
|
use crate::detail::compose_detail;
|
||||||
use dr_types::ColourSpace;
|
|
||||||
|
|
||||||
fn ops(amount: f32) -> Vec<Box<dyn Operation>> {
|
fn ops(amount: f32) -> Vec<Box<dyn Operation>> {
|
||||||
vec![Box::new(Dehaze::with_amount(amount))]
|
vec![Box::new(Dehaze::with_amount(amount))]
|
||||||
}
|
}
|
||||||
|
|
||||||
fn composed(amount: f32, scale: RenderScale) -> crate::ComposedDetail {
|
fn composed(amount: f32, scale: RenderScale) -> crate::ComposedDetail {
|
||||||
compose_detail(&ops(amount), scale, ColourSpace::Srgb)
|
compose_detail(&ops(amount), scale)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dehaze_starts_neutral_and_costs_nothing() {
|
fn dehaze_starts_neutral_and_costs_nothing() {
|
||||||
// The rule the whole pipeline rests on. An unedited photograph must not
|
// The rule the whole pipeline rests on. An unedited photograph must not
|
||||||
// pay for a slider nobody has touched — and this one is five dispatches
|
// pay for a slider nobody has touched — and this one is two dispatches
|
||||||
// when it is on, so "nothing" here is a worthwhile amount of nothing.
|
// when it is on, so "nothing" here is a worthwhile amount of nothing.
|
||||||
assert!(!Dehaze::new().is_active());
|
assert!(!Dehaze::new().is_active());
|
||||||
assert!(composed(0.0, RenderScale::full((2000, 1500))).is_empty());
|
assert!(composed(0.0, RenderScale::full((2000, 1500))).is_empty());
|
||||||
@@ -623,7 +595,7 @@ mod tests {
|
|||||||
// develop view about it would read as a bug.
|
// develop view about it would read as a bug.
|
||||||
let tiny = RenderScale::full((48, 32));
|
let tiny = RenderScale::full((48, 32));
|
||||||
assert_eq!(Dehaze::with_amount(50.0).patch(tiny), 1);
|
assert_eq!(Dehaze::with_amount(50.0).patch(tiny), 1);
|
||||||
assert_eq!(composed(50.0, tiny).len(), 5);
|
assert_eq!(composed(50.0, tiny).len(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -648,7 +620,8 @@ mod tests {
|
|||||||
// Centred to within the pixel the odd surplus leaves over: the window
|
// Centred to within the pixel the odd surplus leaves over: the window
|
||||||
// covers [-31, 32] around the pixel being written.
|
// covers [-31, 32] around the pixel being written.
|
||||||
assert_eq!(split.shift, 31);
|
assert_eq!(split.shift, 31);
|
||||||
assert_eq!(split.reach(), 31);
|
assert_eq!((split.first(), split.width()), (-31, 64));
|
||||||
|
assert_eq!(split.extent(), 32);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -662,33 +635,27 @@ mod tests {
|
|||||||
assert_eq!(split.span, 2);
|
assert_eq!(split.span, 2);
|
||||||
assert!(split.run * split.span >= 3, "the window is not covered");
|
assert!(split.run * split.span >= 3, "the window is not covered");
|
||||||
assert_eq!(split.shift, 1);
|
assert_eq!(split.shift, 1);
|
||||||
assert_eq!(split.reach(), 1);
|
// [-1, 2]: the surplus pixel is on the far side.
|
||||||
|
assert_eq!((split.first(), split.width()), (-1, 4));
|
||||||
|
assert_eq!(split.extent(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_chain_is_four_erosions_and_a_recovery() {
|
fn the_chain_is_an_erosion_per_axis_the_second_carrying_the_recovery() {
|
||||||
// The shape of the operation, asserted where it is cheap to assert.
|
// The shape of the operation, asserted where it is cheap to assert.
|
||||||
// The erosions leave the colour alone and hand the veil forward in the
|
// The x erosion leaves the colour alone and hands the veil forward in
|
||||||
// scratch lane; only the last pass touches `c`, which is what makes an
|
// the scratch lane; only the y pass touches `c`, which is what makes an
|
||||||
// unsharp-mask-shaped operation expressible in a chain that hands each
|
// unsharp-mask-shaped operation expressible in a chain that hands each
|
||||||
// pass exactly one texture.
|
// pass exactly one texture. Two passes and not five: each extra pass
|
||||||
|
// is a render-sized read and write, which is what a pass costs (see
|
||||||
|
// "Why it is now two passes").
|
||||||
let composed = composed(60.0, RenderScale::full((2000, 1500)));
|
let composed = composed(60.0, RenderScale::full((2000, 1500)));
|
||||||
let labels: Vec<&str> = composed.passes.iter().map(|p| p.label.as_str()).collect();
|
let labels: Vec<&str> = composed.passes.iter().map(|p| p.label.as_str()).collect();
|
||||||
assert_eq!(
|
assert_eq!(labels, ["dehaze/veil-x", "dehaze/veil-y-clear"]);
|
||||||
labels,
|
|
||||||
[
|
|
||||||
"dehaze/veil-run-x",
|
|
||||||
"dehaze/veil-span-x",
|
|
||||||
"dehaze/veil-run-y",
|
|
||||||
"dehaze/veil-span-y",
|
|
||||||
"dehaze/clear",
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Only the last writes the display texture, so the output transform
|
// Both declare the whole window they read.
|
||||||
// happens exactly once (FR-DEV-2).
|
let split = Split::of(Dehaze::with_amount(60.0).patch(RenderScale::full((2000, 1500))));
|
||||||
assert!(composed.passes[..4].iter().all(|p| !p.writes_output));
|
assert!(composed.passes.iter().all(|p| p.radius == split.extent()));
|
||||||
assert!(composed.passes[4].writes_output);
|
|
||||||
|
|
||||||
// Nothing here uses the reduced chain — see the module documentation
|
// Nothing here uses the reduced chain — see the module documentation
|
||||||
// for why a second operation cannot pick its own `output_scale` while
|
// for why a second operation cannot pick its own `output_scale` while
|
||||||
@@ -730,7 +697,8 @@ mod tests {
|
|||||||
// nothing to say so.
|
// nothing to say so.
|
||||||
let composed = composed(60.0, RenderScale::full((2000, 1500)));
|
let composed = composed(60.0, RenderScale::full((2000, 1500)));
|
||||||
assert!(composed.passes[0].source.contains("dark_channel(tap(coord"));
|
assert!(composed.passes[0].source.contains("dark_channel(tap(coord"));
|
||||||
assert!(!composed.passes[2].source.contains("dark_channel(tap(coord"));
|
assert!(!composed.passes[1].source.contains("dark_channel(tap(coord"));
|
||||||
|
assert!(composed.passes[1].source.contains("tap_aux(coord"));
|
||||||
// The helper is still emitted for every pass of the operation, and it
|
// The helper is still emitted for every pass of the operation, and it
|
||||||
// must define the function it is named for or the shader fails to
|
// must define the function it is named for or the shader fails to
|
||||||
// compile a long way from here.
|
// compile a long way from here.
|
||||||
|
|||||||
@@ -1,30 +1,35 @@
|
|||||||
//! TRACES: FR-DEV-3f
|
//! TRACES: FR-DEV-3f
|
||||||
//! Film simulation — the stock renders the picture.
|
//! Film simulation — the stock renders the picture.
|
||||||
//!
|
//!
|
||||||
//! # Why this one replaces the base curve
|
//! # Why this one is the view transform
|
||||||
//!
|
//!
|
||||||
//! [`crate::ops`]' other nodes adjust a picture. This one *makes* it. The base
|
//! [`crate::ops`]' other nodes adjust a picture. This one *makes* it. The view
|
||||||
//! curve exists because sensor data is scene-referred and nothing anybody looks
|
//! transform exists because sensor data is scene-referred and nothing anybody
|
||||||
//! at is (FR-DEV-3e); a film stock's characteristic curve does the same job,
|
//! looks at is (FR-DEV-3j); a film stock's characteristic curve does the same
|
||||||
//! from measurements, with a toe and a shoulder that were coated onto acetate
|
//! job, from measurements, with a toe and a shoulder that were coated onto
|
||||||
//! rather than drawn. Running both renders the image twice — the camera's
|
//! acetate rather than drawn. Running both renders the image twice — the
|
||||||
//! JPEG-ish rendering, and then a film's rendering of that — which is not what
|
//! default rendering, and then a film's rendering of that — which is not what
|
||||||
//! either is for and looks like neither.
|
//! either is for and looks like neither.
|
||||||
//!
|
//!
|
||||||
//! So this node declares [`Operation::renders`], and the composer answers by
|
//! So this node is in [`Stage::View`] and declares [`Operation::renders`]: when
|
||||||
//! emitting neither the base curve nor the camera matrix. Both jobs move here:
|
//! a stock is loaded the composer puts it at the end of the chain in place of
|
||||||
//! the fragment takes camera RGB, converts it to linear sRGB itself with the
|
//! the default sigmoid (D19). It is handed working-space colour — linear sRGB
|
||||||
//! matrix already in the uniform block, and returns linear sRGB. That is a
|
//! primaries, scene-referred, after every other operation and after the detail
|
||||||
//! contract worth stating plainly, because a node that got half of it wrong
|
//! stage — and returns display-referred linear sRGB for the output transform.
|
||||||
//! would produce a picture that renders perfectly and is wrong everywhere.
|
//! Before D19 it ran at order 25, after exposure and before everything else,
|
||||||
|
//! and the operations below it acted on its output. They now act on the scene
|
||||||
|
//! it is shown: an edit is a decision about the exposure the negative
|
||||||
|
//! receives, and the film is the last thing that happens to the picture.
|
||||||
//!
|
//!
|
||||||
//! # Why the tables are not parameters
|
//! # Why the tables are not parameters
|
||||||
//!
|
//!
|
||||||
//! For the same reason [`crate::ops::vignetting`]'s coefficients are not: they
|
//! For the same reason [`crate::ops::vignetting`]'s coefficients are not: they
|
||||||
//! are measurements of a physical thing, not something a slider moves. The
|
//! are measurements of a physical thing, not something a slider moves. The
|
||||||
//! sliders here are exposure and print exposure, which are what a photographer
|
//! sliders here are exposure, push, print exposure and format, which are what
|
||||||
//! and a printer actually control. `dr-film` turns a stock plus those two
|
//! a photographer and a printer actually control. `dr-film` turns a stock into
|
||||||
//! numbers into [`FilmTables`]; this node knows only the layout.
|
//! [`FilmTables`] that hold none of them; the shader applies all four per
|
||||||
|
//! pixel, which is what lets a mask layer hold its own (see
|
||||||
|
//! [`Operation::blends_settings`]). This node knows only the layout.
|
||||||
//!
|
//!
|
||||||
//! Declared as a plain struct here rather than imported, so that dr-pipeline
|
//! Declared as a plain struct here rather than imported, so that dr-pipeline
|
||||||
//! keeps its no-dependency property (ARCH §6.5a) exactly as `vignetting` does
|
//! keeps its no-dependency property (ARCH §6.5a) exactly as `vignetting` does
|
||||||
@@ -32,7 +37,7 @@
|
|||||||
use std::sync::{Arc, LazyLock};
|
use std::sync::{Arc, LazyLock};
|
||||||
|
|
||||||
use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
|
use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
|
||||||
use crate::operation::{Operation, Uniform};
|
use crate::operation::{Operation, Stage, Uniform};
|
||||||
|
|
||||||
pub const ID: OpId = OpId("film_sim");
|
pub const ID: OpId = OpId("film_sim");
|
||||||
pub const EXPOSURE: ParamId = ParamId("exposure");
|
pub const EXPOSURE: ParamId = ParamId("exposure");
|
||||||
@@ -63,6 +68,15 @@ static FORMATS: [LocalizedKey; 6] = [
|
|||||||
/// take; [`FilmTables::is_well_formed`] is what stops the two drifting.
|
/// take; [`FilmTables::is_well_formed`] is what stops the two drifting.
|
||||||
pub const CURVE_SAMPLES: usize = 256;
|
pub const CURVE_SAMPLES: usize = 256;
|
||||||
|
|
||||||
|
/// The most development times a stock may measure — a curve row and a push
|
||||||
|
/// station each. Must agree with `dr_film::bake::MAX_CURVE_ROWS`, for the
|
||||||
|
/// reason [`CURVE_SAMPLES`] must; the uniform block holds this many stations.
|
||||||
|
pub const MAX_CURVE_ROWS: usize = 8;
|
||||||
|
|
||||||
|
/// How many frames [`FORMATS`] offers, and so how many grain counts a stock
|
||||||
|
/// carries.
|
||||||
|
pub const FORMAT_COUNT: usize = 6;
|
||||||
|
|
||||||
/// The uniform field names the fragment reads the exposure matrix from.
|
/// The uniform field names the fragment reads the exposure matrix from.
|
||||||
///
|
///
|
||||||
/// A table rather than a formatted string, because a `Uniform`'s name is
|
/// A table rather than a formatted string, because a `Uniform`'s name is
|
||||||
@@ -74,6 +88,24 @@ static MATRIX_FIELDS: [[&str; 3]; 3] = [
|
|||||||
["m20", "m21", "m22"],
|
["m20", "m21", "m22"],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Grains per pixel, per format and layer: `gn{format}{layer}`.
|
||||||
|
static GRAIN_FIELDS: [[&str; 3]; FORMAT_COUNT] = [
|
||||||
|
["gn00", "gn01", "gn02"],
|
||||||
|
["gn10", "gn11", "gn12"],
|
||||||
|
["gn20", "gn21", "gn22"],
|
||||||
|
["gn30", "gn31", "gn32"],
|
||||||
|
["gn40", "gn41", "gn42"],
|
||||||
|
["gn50", "gn51", "gn52"],
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The push each curve row was developed to, padded with the last.
|
||||||
|
static PUSH_FIELDS: [&str; MAX_CURVE_ROWS] =
|
||||||
|
["ps0", "ps1", "ps2", "ps3", "ps4", "ps5", "ps6", "ps7"];
|
||||||
|
|
||||||
|
/// Which format this is, one-hot. See [`FilmSim::uniforms`] for why a choice
|
||||||
|
/// reaches the shader as six weights rather than an index.
|
||||||
|
static FORMAT_FIELDS: [&str; FORMAT_COUNT] = ["fmt0", "fmt1", "fmt2", "fmt3", "fmt4", "fmt5"];
|
||||||
|
|
||||||
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||||
Arc::new(OpDescriptor {
|
Arc::new(OpDescriptor {
|
||||||
// Tone and colour both, and not `Effect`: a stock is not something applied
|
// Tone and colour both, and not `Effect`: a stock is not something applied
|
||||||
@@ -115,48 +147,86 @@ static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
|||||||
/// Layout is the contract between the two crates, so it is written down here
|
/// Layout is the contract between the two crates, so it is written down here
|
||||||
/// and checked rather than assumed:
|
/// and checked rather than assumed:
|
||||||
///
|
///
|
||||||
/// - `exposure_matrix[l][c]` — layer `l`'s response to linear sRGB channel `c`.
|
/// - `exposure_matrix[l][c]` — layer `l`'s response to linear sRGB channel
|
||||||
/// - `curves` — `CURVE_SAMPLES` density triples, uniform over
|
/// `c`, at unit gain: camera exposure is a per-pixel setting.
|
||||||
/// `[curve_log_min, curve_log_max]`.
|
/// - `curves` — one row of `CURVE_SAMPLES` density triples per
|
||||||
/// - `lut` — `lut_size³` linear sRGB triples, uniform over `[0, density_max]`
|
/// `push_stations` entry, uniform over `[curve_log_min, curve_log_max]`,
|
||||||
/// on each axis, with the **red axis varying fastest**: index
|
/// and then, when printed, one more row: the paper's, uniform over
|
||||||
/// `(b * size + g) * size + r`. That is the order a 3D texture upload
|
/// `[paper.log_min, paper.log_max]`.
|
||||||
/// expects, so the consumer hands the slice straight to the driver. Filling
|
/// - `lut` — `lut_size³` triples uniform over `[0, density_max]` on each
|
||||||
/// it the other way round transposes red and blue in the finished picture —
|
/// axis, with the **red axis varying fastest**: index
|
||||||
/// which is a plausible photograph of the wrong colour, and which the unit
|
/// `(b * size + g) * size + r`. Linear sRGB when the film is viewed
|
||||||
/// tests on both sides of this seam happily pass, because each side is
|
/// directly; the paper's log₁₀ exposure through the negative when it is
|
||||||
/// internally consistent. `dr-film` pins it; `dr-gpu`'s `film_sim` test
|
/// printed, followed by a second cube, paper density over
|
||||||
/// catches it end to end.
|
/// `[0, paper.density_max]` to linear sRGB. That is the order a 3D texture
|
||||||
|
/// upload expects with the cubes stacked in depth, so the consumer hands the
|
||||||
|
/// slice straight to the driver. Filling it the other way round transposes
|
||||||
|
/// red and blue in the finished picture — which is a plausible photograph
|
||||||
|
/// of the wrong colour, and which the unit tests on both sides of this seam
|
||||||
|
/// happily pass, because each side is internally consistent. `dr-film` pins
|
||||||
|
/// it; `dr-gpu`'s `film_sim` test catches it end to end.
|
||||||
|
///
|
||||||
|
/// Everything the sliders move — exposure, push, print exposure, format — is
|
||||||
|
/// absent. They are per-pixel settings the shader applies against these
|
||||||
|
/// tables, which is what lets a mask layer hold its own.
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct FilmTables {
|
pub struct FilmTables {
|
||||||
pub exposure_matrix: [[f32; 3]; 3],
|
pub exposure_matrix: [[f32; 3]; 3],
|
||||||
pub curves: Vec<[f32; 3]>,
|
pub curves: Vec<[f32; 3]>,
|
||||||
|
/// The push each film row was developed to, ascending: one entry for a
|
||||||
|
/// stock measured at a single process.
|
||||||
|
pub push_stations: Vec<f32>,
|
||||||
pub curve_log_min: f32,
|
pub curve_log_min: f32,
|
||||||
pub curve_log_max: f32,
|
pub curve_log_max: f32,
|
||||||
pub lut: Vec<[f32; 3]>,
|
pub lut: Vec<[f32; 3]>,
|
||||||
pub density_max: f32,
|
pub density_max: f32,
|
||||||
pub lut_size: usize,
|
pub lut_size: usize,
|
||||||
|
/// The print, for a negative printed on paper.
|
||||||
|
pub paper: Option<PaperTables>,
|
||||||
/// TRACES: FR-DEV-3f
|
/// TRACES: FR-DEV-3f
|
||||||
/// Grains in one pixel's patch of film, per layer, with the density
|
/// Grains in one pixel's patch of film, per format and then per layer,
|
||||||
/// ceiling and uniformity the variance is taken against. Zero particles
|
/// with the density ceiling and uniformity the variance is taken against.
|
||||||
/// means no grain, which is how the control is turned off.
|
/// Zero particles means no grain, which is how the control is turned off.
|
||||||
pub grain_particles: [f32; 3],
|
pub grain_particles: [[f32; 3]; FORMAT_COUNT],
|
||||||
pub grain_density_max: [f32; 3],
|
pub grain_density_max: [f32; 3],
|
||||||
pub grain_uniformity: f32,
|
pub grain_uniformity: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The print half of [`FilmTables`]: where the paper's row and cube are read.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct PaperTables {
|
||||||
|
/// The enlarger's filtration, per layer, in log₁₀ exposure.
|
||||||
|
pub balance: [f32; 3],
|
||||||
|
pub log_min: f32,
|
||||||
|
pub log_max: f32,
|
||||||
|
pub density_max: f32,
|
||||||
|
}
|
||||||
|
|
||||||
impl FilmTables {
|
impl FilmTables {
|
||||||
|
/// Film rows, not counting the paper's.
|
||||||
|
pub fn curve_rows(&self) -> usize {
|
||||||
|
self.push_stations.len()
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether these tables are the shape the shader will index them at.
|
/// Whether these tables are the shape the shader will index them at.
|
||||||
///
|
///
|
||||||
/// Checked on the way in, because the failure otherwise is a shader
|
/// Checked on the way in, because the failure otherwise is a shader
|
||||||
/// sampling past the end of a texture: undefined, silent, and different on
|
/// sampling past the end of a texture: undefined, silent, and different on
|
||||||
/// every driver.
|
/// every driver.
|
||||||
pub fn is_well_formed(&self) -> bool {
|
pub fn is_well_formed(&self) -> bool {
|
||||||
self.curves.len() == CURVE_SAMPLES
|
let rows = self.curve_rows();
|
||||||
|
let printed = usize::from(self.paper.is_some());
|
||||||
|
let paper_ok = self
|
||||||
|
.paper
|
||||||
|
.is_none_or(|p| p.density_max > 0.0 && p.log_max > p.log_min);
|
||||||
|
(1..=MAX_CURVE_ROWS).contains(&rows)
|
||||||
|
&& self.push_stations.windows(2).all(|w| w[0] < w[1])
|
||||||
|
&& self.curves.len() == CURVE_SAMPLES * (rows + printed)
|
||||||
&& self.lut_size >= 2
|
&& self.lut_size >= 2
|
||||||
&& self.lut.len() == self.lut_size.pow(3)
|
&& self.lut.len() == self.lut_size.pow(3) * (1 + printed)
|
||||||
&& self.density_max > 0.0
|
&& self.density_max > 0.0
|
||||||
&& self.curve_log_max > self.curve_log_min
|
&& self.curve_log_max > self.curve_log_min
|
||||||
|
&& paper_ok
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,69 +307,95 @@ impl Operation for FilmSim {
|
|||||||
self.tables.is_some()
|
self.tables.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This node renders; the camera's own rendering must not also run.
|
/// This node renders; the default view transform must not also run.
|
||||||
fn renders(&self) -> bool {
|
fn renders(&self) -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3f | FR-DEV-3j
|
||||||
|
/// The view transform's place, at the end of the chain (D19).
|
||||||
|
fn stage(&self) -> Stage {
|
||||||
|
Stage::View
|
||||||
|
}
|
||||||
|
|
||||||
fn set_film_tables(&mut self, tables: Option<&FilmTables>) {
|
fn set_film_tables(&mut self, tables: Option<&FilmTables>) {
|
||||||
self.set_tables(tables.cloned());
|
self.set_tables(tables.cloned());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn film_tables(&self) -> Option<&FilmTables> {
|
||||||
|
self.tables.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3f
|
||||||
|
/// A layer's film is its settings, not its own picture blended over the
|
||||||
|
/// global one.
|
||||||
|
///
|
||||||
|
/// Blending outputs would be a photograph developed twice and cross-faded;
|
||||||
|
/// a region on a pushed film is not that. Every uniform below is linear
|
||||||
|
/// in what it controls, so the composer can take each layer's weighted
|
||||||
|
/// average of them and develop the pixel once.
|
||||||
|
fn blends_settings(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every value here is linear in what the shader does with it, which is
|
||||||
|
/// what [`Self::blends_settings`] rests on. The format is the one that
|
||||||
|
/// needs arranging: an index averaged between layers is a format nobody
|
||||||
|
/// chose, so it goes out one-hot and the shader mixes the six grain
|
||||||
|
/// counts by it — two layers on 35 mm and 6x7 meet at the average grain.
|
||||||
fn uniforms(&self) -> Vec<Uniform> {
|
fn uniforms(&self) -> Vec<Uniform> {
|
||||||
let Some(t) = &self.tables else {
|
let Some(t) = &self.tables else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
let m = t.exposure_matrix;
|
let mut out = Vec::with_capacity(64);
|
||||||
// Exposure rides in the matrix on the CPU when the stock is baked, so
|
let mut push = |name: &'static str, value: f32| out.push(Uniform { name, value });
|
||||||
// what is left here is the *shader's* copy of the same nine numbers.
|
for (l, row) in t.exposure_matrix.iter().enumerate() {
|
||||||
// Spelled out one at a time because a uniform is a named `f32` in this
|
|
||||||
// pipeline and a matrix would be a second kind of thing for one caller.
|
|
||||||
let mut out = Vec::with_capacity(MATRIX_FIELDS.len() + 5);
|
|
||||||
for (l, row) in m.iter().enumerate() {
|
|
||||||
for (c, v) in row.iter().enumerate() {
|
for (c, v) in row.iter().enumerate() {
|
||||||
out.push(Uniform {
|
push(MATRIX_FIELDS[l][c], *v);
|
||||||
name: MATRIX_FIELDS[l][c],
|
|
||||||
value: *v,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (l, name) in ["gn0", "gn1", "gn2"].into_iter().enumerate() {
|
for (f, per_layer) in t.grain_particles.iter().enumerate() {
|
||||||
out.push(Uniform {
|
for (l, v) in per_layer.iter().enumerate() {
|
||||||
name,
|
push(GRAIN_FIELDS[f][l], *v);
|
||||||
value: t.grain_particles[l],
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
for (l, name) in ["gd0", "gd1", "gd2"].into_iter().enumerate() {
|
for (l, name) in ["gd0", "gd1", "gd2"].into_iter().enumerate() {
|
||||||
out.push(Uniform {
|
push(name, t.grain_density_max[l]);
|
||||||
name,
|
|
||||||
value: t.grain_density_max[l],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
out.push(Uniform {
|
push("grain_u", t.grain_uniformity);
|
||||||
name: "grain_u",
|
push("log_min", t.curve_log_min);
|
||||||
value: t.grain_uniformity,
|
push("log_max", t.curve_log_max);
|
||||||
});
|
push("density_max", t.density_max);
|
||||||
out.push(Uniform {
|
push("lut_size", t.lut_size as f32);
|
||||||
name: "log_min",
|
|
||||||
value: t.curve_log_min,
|
let last = *t.push_stations.last().unwrap_or(&0.0);
|
||||||
});
|
for (i, name) in PUSH_FIELDS.into_iter().enumerate() {
|
||||||
out.push(Uniform {
|
push(name, t.push_stations.get(i).copied().unwrap_or(last));
|
||||||
name: "log_max",
|
}
|
||||||
value: t.curve_log_max,
|
push("rows", t.curve_rows() as f32);
|
||||||
});
|
|
||||||
out.push(Uniform {
|
let paper = t.paper.unwrap_or(PaperTables {
|
||||||
name: "density_max",
|
balance: [0.0; 3],
|
||||||
value: t.density_max,
|
log_min: 0.0,
|
||||||
});
|
log_max: 1.0,
|
||||||
out.push(Uniform {
|
density_max: 1.0,
|
||||||
name: "lut_size",
|
|
||||||
value: t.lut_size as f32,
|
|
||||||
});
|
|
||||||
out.push(Uniform {
|
|
||||||
name: "print_exposure",
|
|
||||||
value: self.print_exposure,
|
|
||||||
});
|
});
|
||||||
|
push("printed", if t.paper.is_some() { 1.0 } else { 0.0 });
|
||||||
|
for (l, name) in ["pb0", "pb1", "pb2"].into_iter().enumerate() {
|
||||||
|
push(name, paper.balance[l]);
|
||||||
|
}
|
||||||
|
push("plog_min", paper.log_min);
|
||||||
|
push("plog_max", paper.log_max);
|
||||||
|
push("pdmax", paper.density_max);
|
||||||
|
|
||||||
|
// The sliders.
|
||||||
|
push("ev", self.exposure);
|
||||||
|
push("push", self.push);
|
||||||
|
push("pev", self.print_exposure);
|
||||||
|
let chosen = (self.format.max(0.0).round() as usize).min(FORMAT_COUNT - 1);
|
||||||
|
for (f, name) in FORMAT_FIELDS.into_iter().enumerate() {
|
||||||
|
push(name, if f == chosen { 1.0 } else { 0.0 });
|
||||||
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,20 +405,17 @@ impl Operation for FilmSim {
|
|||||||
// sampler binding, and adding one to interpolate two lookups would
|
// sampler binding, and adding one to interpolate two lookups would
|
||||||
// cost a binding in every shader whether or not a film is loaded.
|
// cost a binding in every shader whether or not a film is loaded.
|
||||||
"\
|
"\
|
||||||
// Camera RGB to linear sRGB. The film's exposure matrix is defined against
|
// Working-space colour, which is linear sRGB primaries — what the film's
|
||||||
// sRGB primaries, and this node has taken over the conversion the composer
|
// exposure matrix is defined against. The composer converted out of camera
|
||||||
// would otherwise have emitted at the end — see `Operation::renders`.
|
// RGB before any scene-stage operation ran (D19).
|
||||||
let scene = vec3<f32>(
|
let scene = c;
|
||||||
dot(u.cam_to_srgb_0.rgb, c),
|
|
||||||
dot(u.cam_to_srgb_1.rgb, c),
|
|
||||||
dot(u.cam_to_srgb_2.rgb, c),
|
|
||||||
);
|
|
||||||
|
|
||||||
// What each emulsion layer was exposed to. A matrix, exactly: the scene
|
// What each emulsion layer was exposed to. A matrix, exactly: the scene
|
||||||
// spectrum reconstructed from an sRGB triple is linear in that triple, so the
|
// spectrum reconstructed from an sRGB triple is linear in that triple, so the
|
||||||
// integral over wavelength collapsed into these nine numbers when the stock
|
// integral over wavelength collapsed into these nine numbers when the stock
|
||||||
// was baked.
|
// was baked. The camera's exposure is a gain on it, applied here rather than
|
||||||
let exposure = vec3<f32>(
|
// baked in so that a layer can hold its own.
|
||||||
|
let exposure = exp2(ev) * vec3<f32>(
|
||||||
dot(vec3<f32>(m00, m01, m02), scene),
|
dot(vec3<f32>(m00, m01, m02), scene),
|
||||||
dot(vec3<f32>(m10, m11, m12), scene),
|
dot(vec3<f32>(m10, m11, m12), scene),
|
||||||
dot(vec3<f32>(m20, m21, m22), scene),
|
dot(vec3<f32>(m20, m21, m22), scene),
|
||||||
@@ -331,12 +424,16 @@ let exposure = vec3<f32>(
|
|||||||
// the curve, and the toe is where it belongs.
|
// the curve, and the toe is where it belongs.
|
||||||
let log_exposure = log10(max(exposure, vec3<f32>(0.0)) + 1e-10);
|
let log_exposure = log10(max(exposure, vec3<f32>(0.0)) + 1e-10);
|
||||||
|
|
||||||
// The characteristic curve: what density each layer develops to. Clamped, not
|
// The characteristic curve: what density each layer develops to, at this
|
||||||
// extrapolated — past the shoulder a real emulsion stops responding, and
|
// pixel's push. Clamped, not extrapolated — past the shoulder a real emulsion
|
||||||
// extrapolating would turn a blown highlight into a colour cast that grows the
|
// stops responding, and extrapolating would turn a blown highlight into a
|
||||||
// more it is overexposed.
|
// colour cast that grows the more it is overexposed.
|
||||||
let density = film_curve(clamp((log_exposure - log_min) / (log_max - log_min),
|
let density = film_curve_pushed(
|
||||||
vec3<f32>(0.0), vec3<f32>(1.0)));
|
clamp((log_exposure - log_min) / (log_max - log_min), vec3<f32>(0.0), vec3<f32>(1.0)),
|
||||||
|
push,
|
||||||
|
array<f32, 8>(ps0, ps1, ps2, ps3, ps4, ps5, ps6, ps7),
|
||||||
|
u32(rows),
|
||||||
|
);
|
||||||
|
|
||||||
// TRACES: FR-DEV-3f
|
// TRACES: FR-DEV-3f
|
||||||
// Grain, on the density and before the dye.
|
// Grain, on the density and before the dye.
|
||||||
@@ -346,16 +443,40 @@ let density = film_curve(clamp((log_exposure - log_min) / (log_max - log_min),
|
|||||||
// through whatever density resulted. Adding noise to the finished colour --
|
// through whatever density resulted. Adding noise to the finished colour --
|
||||||
// which is what an effect does -- tints the highlights wrong, because that
|
// which is what an effect does -- tints the highlights wrong, because that
|
||||||
// noise never passes through the dye at all.
|
// noise never passes through the dye at all.
|
||||||
let grained = film_grain(density, source_px,
|
//
|
||||||
vec3<f32>(gn0, gn1, gn2),
|
// The format's grain count, mixed by the one-hot weights: exactly one format's
|
||||||
|
// on the whole photograph, and the weighted average under overlapping layers.
|
||||||
|
let particles = fmt0 * vec3<f32>(gn00, gn01, gn02)
|
||||||
|
+ fmt1 * vec3<f32>(gn10, gn11, gn12)
|
||||||
|
+ fmt2 * vec3<f32>(gn20, gn21, gn22)
|
||||||
|
+ fmt3 * vec3<f32>(gn30, gn31, gn32)
|
||||||
|
+ fmt4 * vec3<f32>(gn40, gn41, gn42)
|
||||||
|
+ fmt5 * vec3<f32>(gn50, gn51, gn52);
|
||||||
|
let grained = film_grain(density, source_px, particles,
|
||||||
vec3<f32>(gd0, gd1, gd2),
|
vec3<f32>(gd0, gd1, gd2),
|
||||||
grain_u);
|
grain_u);
|
||||||
|
|
||||||
// Dye absorption, the print through the negative, the paper, the viewing
|
// Dye absorption through to what comes next — all of it takes exactly three
|
||||||
// illuminant and the chromatic adaptation — all of which take exactly three
|
// numbers in, which is why it fits in one lookup. Viewed directly, that is
|
||||||
// numbers in, which is why they fit in one lookup.
|
// the picture; printed, it is the light the paper receives through the
|
||||||
c = film_lut(clamp(grained / density_max, vec3<f32>(0.0), vec3<f32>(1.0)), lut_size);"
|
// negative, in log exposure.
|
||||||
.into()
|
let through = film_lut(clamp(grained / density_max, vec3<f32>(0.0), vec3<f32>(1.0)),
|
||||||
|
lut_size, 0);
|
||||||
|
if (printed > 0.5) {
|
||||||
|
// The enlarger: its filtration, and then its exposure, the same stops on
|
||||||
|
// every layer — which is why print exposure is an addition here and not
|
||||||
|
// a table, and so exact at any setting.
|
||||||
|
let paper_log = through + vec3<f32>(pb0, pb1, pb2) + pev * 0.30103;
|
||||||
|
let paper_density = film_curve(
|
||||||
|
clamp((paper_log - plog_min) / (plog_max - plog_min), vec3<f32>(0.0), vec3<f32>(1.0)),
|
||||||
|
u32(rows),
|
||||||
|
);
|
||||||
|
c = film_lut(clamp(paper_density / pdmax, vec3<f32>(0.0), vec3<f32>(1.0)),
|
||||||
|
lut_size, i32(lut_size));
|
||||||
|
} else {
|
||||||
|
c = through;
|
||||||
|
}"
|
||||||
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn helpers(&self) -> &'static [crate::operation::Helper] {
|
fn helpers(&self) -> &'static [crate::operation::Helper] {
|
||||||
@@ -363,7 +484,7 @@ c = film_lut(clamp(grained / density_max, vec3<f32>(0.0), vec3<f32>(1.0)), lut_s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static HELPERS: [crate::operation::Helper; 5] = [
|
static HELPERS: [crate::operation::Helper; 6] = [
|
||||||
crate::operation::Helper {
|
crate::operation::Helper {
|
||||||
name: "film_hash",
|
name: "film_hash",
|
||||||
source: "\
|
source: "\
|
||||||
@@ -453,9 +574,9 @@ fn log10(v: vec3<f32>) -> vec3<f32> {
|
|||||||
crate::operation::Helper {
|
crate::operation::Helper {
|
||||||
name: "film_curve",
|
name: "film_curve",
|
||||||
source: "\
|
source: "\
|
||||||
// Three characteristic curves, sampled from a 256-wide texture and
|
// Three characteristic curves, one row of a 256-wide texture, interpolated by
|
||||||
// interpolated by hand. `t` is already normalised to the curve's domain.
|
// hand. `t` is already normalised to the curve's domain.
|
||||||
fn film_curve(t: vec3<f32>) -> vec3<f32> {
|
fn film_curve(t: vec3<f32>, row: u32) -> vec3<f32> {
|
||||||
let samples = u32(textureDimensions(film_curves).x);
|
let samples = u32(textureDimensions(film_curves).x);
|
||||||
let last = f32(samples - 1u);
|
let last = f32(samples - 1u);
|
||||||
var out = vec3<f32>(0.0);
|
var out = vec3<f32>(0.0);
|
||||||
@@ -463,20 +584,45 @@ fn film_curve(t: vec3<f32>) -> vec3<f32> {
|
|||||||
let x = t[ch] * last;
|
let x = t[ch] * last;
|
||||||
let i = min(u32(floor(x)), samples - 2u);
|
let i = min(u32(floor(x)), samples - 2u);
|
||||||
let f = x - f32(i);
|
let f = x - f32(i);
|
||||||
let a = textureLoad(film_curves, vec2<i32>(i32(i), 0), 0);
|
let a = textureLoad(film_curves, vec2<i32>(i32(i), i32(row)), 0);
|
||||||
let b = textureLoad(film_curves, vec2<i32>(i32(i) + 1, 0), 0);
|
let b = textureLoad(film_curves, vec2<i32>(i32(i) + 1, i32(row)), 0);
|
||||||
out[ch] = mix(a[ch], b[ch], f);
|
out[ch] = mix(a[ch], b[ch], f);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
|
}",
|
||||||
|
},
|
||||||
|
crate::operation::Helper {
|
||||||
|
name: "film_curve_pushed",
|
||||||
|
source: "\
|
||||||
|
// The curves at a push between two measured processes. Development is
|
||||||
|
// interpolated in log time and push *is* log time, so a straight line between
|
||||||
|
// the neighbouring rows is the stock's own interpolation, not an estimate of
|
||||||
|
// it. Clamped to the first and last process, as the stock is.
|
||||||
|
fn film_curve_pushed(t: vec3<f32>, push: f32, stations: array<f32, 8>, rows: u32) -> vec3<f32> {
|
||||||
|
if (rows < 2u) {
|
||||||
|
return film_curve(t, 0u);
|
||||||
|
}
|
||||||
|
var at = stations;
|
||||||
|
var hi = rows - 1u;
|
||||||
|
for (var i = 1u; i < rows; i = i + 1u) {
|
||||||
|
if (at[i] >= push) {
|
||||||
|
hi = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let lo = hi - 1u;
|
||||||
|
let f = clamp((push - at[lo]) / max(at[hi] - at[lo], 1e-6), 0.0, 1.0);
|
||||||
|
return mix(film_curve(t, lo), film_curve(t, hi), f);
|
||||||
}",
|
}",
|
||||||
},
|
},
|
||||||
crate::operation::Helper {
|
crate::operation::Helper {
|
||||||
name: "film_lut",
|
name: "film_lut",
|
||||||
source: "\
|
source: "\
|
||||||
// Trilinear interpolation of the density lookup, by hand for the same reason
|
// Trilinear interpolation of one cube of the lookup, by hand for the same
|
||||||
// the curve above is: there is no sampler bound, and the eight loads are
|
// reason the curve above is: there is no sampler bound, and the eight loads
|
||||||
// cache-neighbours.
|
// are cache-neighbours. `z0` is where the cube starts in depth: the film's at
|
||||||
fn film_lut(t: vec3<f32>, size: f32) -> vec3<f32> {
|
// zero, the paper's stacked after it.
|
||||||
|
fn film_lut(t: vec3<f32>, size: f32, z0: i32) -> vec3<f32> {
|
||||||
let n = i32(size);
|
let n = i32(size);
|
||||||
let x = t * (size - 1.0);
|
let x = t * (size - 1.0);
|
||||||
let base = min(vec3<i32>(floor(x)), vec3<i32>(n - 2));
|
let base = min(vec3<i32>(floor(x)), vec3<i32>(n - 2));
|
||||||
@@ -489,7 +635,7 @@ fn film_lut(t: vec3<f32>, size: f32) -> vec3<f32> {
|
|||||||
let wy = select(1.0 - f.y, f.y, dy == 1);
|
let wy = select(1.0 - f.y, f.y, dy == 1);
|
||||||
for (var dz = 0; dz < 2; dz = dz + 1) {
|
for (var dz = 0; dz < 2; dz = dz + 1) {
|
||||||
let wz = select(1.0 - f.z, f.z, dz == 1);
|
let wz = select(1.0 - f.z, f.z, dz == 1);
|
||||||
let p = base + vec3<i32>(dx, dy, dz);
|
let p = base + vec3<i32>(dx, dy, dz + z0);
|
||||||
out = out + wx * wy * wz
|
out = out + wx * wy * wz
|
||||||
* textureLoad(film_lut_texture, p, 0).rgb;
|
* textureLoad(film_lut_texture, p, 0).rgb;
|
||||||
}
|
}
|
||||||
@@ -513,7 +659,9 @@ mod tests {
|
|||||||
lut: vec![[0.5, 0.5, 0.5]; 32 * 32 * 32],
|
lut: vec![[0.5, 0.5, 0.5]; 32 * 32 * 32],
|
||||||
density_max: 3.0,
|
density_max: 3.0,
|
||||||
lut_size: 32,
|
lut_size: 32,
|
||||||
grain_particles: [0.0; 3],
|
grain_particles: [[0.0; 3]; FORMAT_COUNT],
|
||||||
|
push_stations: vec![0.0],
|
||||||
|
paper: None,
|
||||||
grain_density_max: [3.0; 3],
|
grain_density_max: [3.0; 3],
|
||||||
grain_uniformity: 0.97,
|
grain_uniformity: 0.97,
|
||||||
}
|
}
|
||||||
@@ -558,10 +706,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn it_declares_itself_a_rendering_transform() {
|
fn it_declares_itself_a_rendering_transform() {
|
||||||
// The whole reason the composer skips the base curve and the camera
|
// The whole reason the composer emits the stock in the view
|
||||||
// matrix. If this ever returned false the picture would be rendered
|
// transform's place rather than beside it. If this ever returned
|
||||||
// twice and converted twice, which looks like a colour management bug
|
// false the picture would be rendered twice, which looks like a
|
||||||
// a long way from here.
|
// colour management bug a long way from here.
|
||||||
assert!(FilmSim::new().renders());
|
assert!(FilmSim::new().renders());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -584,13 +732,15 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_fragment_converts_out_of_camera_space_itself() {
|
fn the_fragment_is_handed_working_space_colour() {
|
||||||
// It has to: it has taken over the conversion the composer would
|
// TRACES: FR-DEV-3f
|
||||||
// otherwise emit at the end.
|
// D19: the composer leaves camera space before any scene-stage
|
||||||
|
// operation, so a film converting again would apply the camera
|
||||||
|
// matrix twice.
|
||||||
let mut op = FilmSim::new();
|
let mut op = FilmSim::new();
|
||||||
op.set_tables(Some(tables()));
|
op.set_tables(Some(tables()));
|
||||||
let wgsl = op.wgsl_body();
|
let wgsl = op.wgsl_body();
|
||||||
assert!(wgsl.contains("cam_to_srgb_0"), "{wgsl}");
|
assert!(!wgsl.contains("cam_to_srgb"), "{wgsl}");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -878,7 +878,6 @@ c = c * exp2(stops);"
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::detail::compose_detail;
|
use crate::detail::compose_detail;
|
||||||
use dr_types::ColourSpace;
|
|
||||||
|
|
||||||
/// The two controls, as the graph would hold them.
|
/// The two controls, as the graph would hold them.
|
||||||
fn ops(clarity: f32, texture: f32) -> Vec<Box<dyn Operation>> {
|
fn ops(clarity: f32, texture: f32) -> Vec<Box<dyn Operation>> {
|
||||||
@@ -889,7 +888,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn composed(clarity: f32, texture: f32, scale: RenderScale) -> crate::ComposedDetail {
|
fn composed(clarity: f32, texture: f32, scale: RenderScale) -> crate::ComposedDetail {
|
||||||
compose_detail(&ops(clarity, texture), scale, ColourSpace::Srgb)
|
compose_detail(&ops(clarity, texture), scale)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ pub mod distortion;
|
|||||||
pub mod film_sim;
|
pub mod film_sim;
|
||||||
pub mod local_contrast;
|
pub mod local_contrast;
|
||||||
pub mod noise_reduction;
|
pub mod noise_reduction;
|
||||||
|
pub mod view_transform;
|
||||||
pub mod vignetting;
|
pub mod vignetting;
|
||||||
|
|
||||||
pub use aberration::Aberration;
|
pub use aberration::Aberration;
|
||||||
@@ -82,11 +83,12 @@ pub use colour_mixer::ColourMixer;
|
|||||||
pub use curve::ToneCurve;
|
pub use curve::ToneCurve;
|
||||||
pub use dehaze::Dehaze;
|
pub use dehaze::Dehaze;
|
||||||
pub use distortion::Distortion;
|
pub use distortion::Distortion;
|
||||||
pub use film_sim::{FilmSim, FilmTables};
|
pub use film_sim::{FilmSim, FilmTables, PaperTables};
|
||||||
// Clarity and texture are one implementation at two scales; see the module's
|
// Clarity and texture are one implementation at two scales; see the module's
|
||||||
// documentation for why that is two nodes and not one.
|
// documentation for why that is two nodes and not one.
|
||||||
pub use local_contrast::{Clarity, Texture};
|
pub use local_contrast::{Clarity, Texture};
|
||||||
pub use noise_reduction::NoiseReduction;
|
pub use noise_reduction::NoiseReduction;
|
||||||
|
pub use view_transform::ViewTransform;
|
||||||
pub use vignetting::Vignetting;
|
pub use vignetting::Vignetting;
|
||||||
|
|
||||||
// The declared nodes, plus `helpers` and `chain`. Generated into OUT_DIR by
|
// The declared nodes, plus `helpers` and `chain`. Generated into OUT_DIR by
|
||||||
|
|||||||
@@ -615,7 +615,6 @@ c = vec3<f32>(y0) + chroma_sum / weight_sum;";
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use dr_types::ColourSpace;
|
|
||||||
|
|
||||||
/// A 24 MP frame, and the panel a develop view might show it in.
|
/// A 24 MP frame, and the panel a develop view might show it in.
|
||||||
const FULL: (u32, u32) = (6000, 4000);
|
const FULL: (u32, u32) = (6000, 4000);
|
||||||
@@ -629,7 +628,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn compose(op: NoiseReduction, scale: RenderScale) -> crate::detail::ComposedDetail {
|
fn compose(op: NoiseReduction, scale: RenderScale) -> crate::detail::ComposedDetail {
|
||||||
crate::detail::compose_detail(&chain_with(op), scale, ColourSpace::Srgb)
|
crate::detail::compose_detail(&chain_with(op), scale)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -674,11 +673,6 @@ mod tests {
|
|||||||
// The luminance pass runs first, so the chroma guide is the denoised
|
// The luminance pass runs first, so the chroma guide is the denoised
|
||||||
// luminance rather than the raw one.
|
// luminance rather than the raw one.
|
||||||
assert_eq!(both.passes[0].label, "noise_reduction/luminance");
|
assert_eq!(both.passes[0].label, "noise_reduction/luminance");
|
||||||
// And only the last pass in the whole chain performs the output
|
|
||||||
// transform, whichever pass that happens to be.
|
|
||||||
assert!(!both.passes[0].writes_output);
|
|
||||||
assert!(!both.passes[1].writes_output);
|
|
||||||
assert!(both.passes[2].writes_output);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
//! TRACES: FR-DEV-3j
|
||||||
|
//! The view transform as an operation: the photographer's two numbers for
|
||||||
|
//! the curve [`crate::view`] defines.
|
||||||
|
//!
|
||||||
|
//! # Why it is a node now, when the base curve could not be
|
||||||
|
//!
|
||||||
|
//! The base curve was kept out of the chain for reasons that were all about
|
||||||
|
//! the *body*: it was looked up by camera model, so as a node it would have
|
||||||
|
//! carried one camera's rendering onto another camera's file through a shared
|
||||||
|
//! sidecar, shown a dead slider on an unprofiled body, and opened a profiled
|
||||||
|
//! one reporting itself modified. D19 removed the premise. There is one view
|
||||||
|
//! transform for every body, so its settings are a decision about the picture
|
||||||
|
//! like any other, and they belong in the sidecar, the history and a mask
|
||||||
|
//! layer.
|
||||||
|
//!
|
||||||
|
//! # Why it is composed at its defaults
|
||||||
|
//!
|
||||||
|
//! A neutral operation is normally left out of the shader, and "active" means
|
||||||
|
//! "moved from its defaults". Both stay true here — an untouched photograph
|
||||||
|
//! writes no view transform parameters, and `every_node_starts_neutral` still
|
||||||
|
//! holds — but the composer emits this node whatever its state, because a
|
||||||
|
//! photograph with no view transform is a scan, not a picture. See
|
||||||
|
//! [`crate::operation::Stage::View`].
|
||||||
|
|
||||||
|
use std::sync::{Arc, LazyLock};
|
||||||
|
|
||||||
|
use crate::descriptor::{
|
||||||
|
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit,
|
||||||
|
};
|
||||||
|
use crate::operation::{Helper, Operation, Stage, Uniform};
|
||||||
|
use crate::view::{Sigmoid, CONTRAST_RANGE, DEFAULT_CONTRAST, DEFAULT_WHITE, WHITE_RANGE};
|
||||||
|
|
||||||
|
pub const ID: OpId = OpId("view_transform");
|
||||||
|
pub const CONTRAST: ParamId = ParamId("contrast");
|
||||||
|
pub const WHITE: ParamId = ParamId("white");
|
||||||
|
|
||||||
|
static HELPERS: [Helper; 1] = [Helper {
|
||||||
|
name: "view_sigmoid",
|
||||||
|
source: crate::view::VIEW_SIGMOID_WGSL,
|
||||||
|
}];
|
||||||
|
|
||||||
|
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||||
|
Arc::new(OpDescriptor {
|
||||||
|
// Tone: it is the tone response of the whole picture, and the panel's
|
||||||
|
// Light group is where a photographer looks for the white point.
|
||||||
|
attributes: vec![Attribute::Tone],
|
||||||
|
id: ID,
|
||||||
|
label: LocalizedKey("op.view_transform"),
|
||||||
|
params: vec![
|
||||||
|
ParamDescriptor::scalar(
|
||||||
|
"contrast",
|
||||||
|
"param.view_transform.contrast",
|
||||||
|
CONTRAST_RANGE.0,
|
||||||
|
CONTRAST_RANGE.1,
|
||||||
|
DEFAULT_CONTRAST,
|
||||||
|
Unit::None,
|
||||||
|
Scale::Linear,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
ParamDescriptor::scalar(
|
||||||
|
"white",
|
||||||
|
"param.view_transform.white",
|
||||||
|
WHITE_RANGE.0,
|
||||||
|
WHITE_RANGE.1,
|
||||||
|
DEFAULT_WHITE,
|
||||||
|
Unit::Stops,
|
||||||
|
Scale::Linear,
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ViewTransform {
|
||||||
|
contrast: f32,
|
||||||
|
white: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ViewTransform {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
contrast: DEFAULT_CONTRAST,
|
||||||
|
white: DEFAULT_WHITE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ViewTransform {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The curve these settings solve to.
|
||||||
|
pub fn sigmoid(&self) -> Sigmoid {
|
||||||
|
Sigmoid::new(self.contrast, self.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Operation for ViewTransform {
|
||||||
|
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||||
|
DESCRIPTOR.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_param(&mut self, id: ParamId, value: f32) {
|
||||||
|
match id {
|
||||||
|
CONTRAST => self.contrast = value,
|
||||||
|
WHITE => self.white = value,
|
||||||
|
_ => log::warn!("view_transform: unknown parameter {id}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn param(&self, id: ParamId) -> f32 {
|
||||||
|
match id {
|
||||||
|
CONTRAST => self.contrast,
|
||||||
|
WHITE => self.white,
|
||||||
|
_ => 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_active(&self) -> bool {
|
||||||
|
self.contrast != DEFAULT_CONTRAST || self.white != DEFAULT_WHITE
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stage(&self) -> Stage {
|
||||||
|
Stage::View
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wgsl_body(&self) -> String {
|
||||||
|
"\
|
||||||
|
// Skipped for an already-rendered source: a JPEG is a display rendering
|
||||||
|
// already, and rendering it again would compress it twice.
|
||||||
|
if (!non_linear) {
|
||||||
|
c = view_sigmoid(c, slope, inv_k, peak);
|
||||||
|
}"
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn uniforms(&self) -> Vec<Uniform> {
|
||||||
|
let s = self.sigmoid();
|
||||||
|
vec![
|
||||||
|
Uniform {
|
||||||
|
name: "slope",
|
||||||
|
value: s.n,
|
||||||
|
},
|
||||||
|
Uniform {
|
||||||
|
name: "inv_k",
|
||||||
|
value: s.inv_k,
|
||||||
|
},
|
||||||
|
Uniform {
|
||||||
|
name: "peak",
|
||||||
|
value: s.w,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn helpers(&self) -> &[Helper] {
|
||||||
|
&HELPERS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn it_starts_neutral_and_says_so() {
|
||||||
|
// TRACES: FR-DEV-3j
|
||||||
|
// Neutral in the sense every other node is: nothing moved, so nothing
|
||||||
|
// is written. It is still composed — see the module documentation.
|
||||||
|
let op = ViewTransform::new();
|
||||||
|
assert!(!op.is_active());
|
||||||
|
assert_eq!(op.sigmoid(), Sigmoid::default_curve());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn moving_either_slider_makes_it_active() {
|
||||||
|
let mut op = ViewTransform::new();
|
||||||
|
op.set_param(WHITE, 6.0);
|
||||||
|
assert!(op.is_active());
|
||||||
|
let mut op = ViewTransform::new();
|
||||||
|
op.set_param(CONTRAST, 2.0);
|
||||||
|
assert!(op.is_active());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_uniforms_are_the_solved_curve() {
|
||||||
|
let mut op = ViewTransform::new();
|
||||||
|
op.set_param(CONTRAST, 2.0);
|
||||||
|
op.set_param(WHITE, 6.0);
|
||||||
|
let s = Sigmoid::new(2.0, 6.0);
|
||||||
|
let u = op.uniforms();
|
||||||
|
assert_eq!(
|
||||||
|
u.iter().map(|u| u.value).collect::<Vec<_>>(),
|
||||||
|
vec![s.n, s.inv_k, s.w]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_descriptor_defaults_are_the_curve_defaults() {
|
||||||
|
// The sidecar treats a value equal to the descriptor's default as
|
||||||
|
// unedited; the two disagreeing would make every photograph open
|
||||||
|
// reporting a view transform edit it never had.
|
||||||
|
let d = ViewTransform::new().descriptor();
|
||||||
|
assert_eq!(
|
||||||
|
d.param(CONTRAST).expect("contrast").default,
|
||||||
|
DEFAULT_CONTRAST
|
||||||
|
);
|
||||||
|
assert_eq!(d.param(WHITE).expect("white").default, DEFAULT_WHITE);
|
||||||
|
}
|
||||||
|
}
|
||||||
+439
-23
@@ -44,6 +44,7 @@ use std::fmt::Write as _;
|
|||||||
|
|
||||||
use crate::descriptor::{Attribute, OpId, ParamId};
|
use crate::descriptor::{Attribute, OpId, ParamId};
|
||||||
use crate::graph::EditGraph;
|
use crate::graph::EditGraph;
|
||||||
|
use crate::state::{FilmRebake, FilmRef};
|
||||||
|
|
||||||
/// Which parts of an edit a copy carries.
|
/// Which parts of an edit a copy carries.
|
||||||
///
|
///
|
||||||
@@ -173,6 +174,15 @@ impl Scope {
|
|||||||
self.bits == 0
|
self.bits == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether this scope carries the film — the stock as well as its sliders.
|
||||||
|
///
|
||||||
|
/// Asked of the film node's own classification rather than decided here,
|
||||||
|
/// so the stock travels under exactly the scopes its exposure slider
|
||||||
|
/// does. Splitting the two would put one stock's exposure on another.
|
||||||
|
pub fn carries_film(self) -> bool {
|
||||||
|
self.covers(crate::ops::film_sim::ID.0)
|
||||||
|
}
|
||||||
|
|
||||||
fn bit(attribute: Attribute) -> u8 {
|
fn bit(attribute: Attribute) -> u8 {
|
||||||
1 << Attribute::ALL
|
1 << Attribute::ALL
|
||||||
.iter()
|
.iter()
|
||||||
@@ -216,6 +226,17 @@ fn attributes_of(op: &str) -> Option<&'static [Attribute]> {
|
|||||||
TABLE.get(op).map(Vec::as_slice)
|
TABLE.get(op).map(Vec::as_slice)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How much of the target an applied preset replaces. See the module note.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum Reach {
|
||||||
|
/// Everything in scope: absence means default. A copy, a saved edit.
|
||||||
|
#[default]
|
||||||
|
Whole,
|
||||||
|
/// Only the operations the preset names, and the film if it names one.
|
||||||
|
/// Everything else in the target is left as it was. A look.
|
||||||
|
Named,
|
||||||
|
}
|
||||||
|
|
||||||
/// A set of non-default parameter values, ready to apply elsewhere.
|
/// A set of non-default parameter values, ready to apply elsewhere.
|
||||||
///
|
///
|
||||||
/// Ordered, so two captures of the same edit compare equal and a caller can
|
/// Ordered, so two captures of the same edit compare equal and a caller can
|
||||||
@@ -223,6 +244,10 @@ fn attributes_of(op: &str) -> Option<&'static [Attribute]> {
|
|||||||
#[derive(Debug, Clone, PartialEq, Default)]
|
#[derive(Debug, Clone, PartialEq, Default)]
|
||||||
pub struct Preset {
|
pub struct Preset {
|
||||||
params: BTreeMap<(String, String), f32>,
|
params: BTreeMap<(String, String), f32>,
|
||||||
|
reach: Reach,
|
||||||
|
/// TRACES: FR-DEV-3f
|
||||||
|
/// The stock this edit develops on, by id. See the module note.
|
||||||
|
film: Option<FilmRef>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Preset {
|
impl Preset {
|
||||||
@@ -237,6 +262,21 @@ impl Preset {
|
|||||||
/// have to be re-copied to change one's mind about framing, and a preset
|
/// have to be re-copied to change one's mind about framing, and a preset
|
||||||
/// that had already discarded the crop could never grow it back.
|
/// that had already discarded the crop could never grow it back.
|
||||||
pub fn capture(graph: &EditGraph) -> Self {
|
pub fn capture(graph: &EditGraph) -> Self {
|
||||||
|
Self {
|
||||||
|
film: graph.film().map(|f| FilmRef {
|
||||||
|
stock: f.stock.clone(),
|
||||||
|
print: f.print.clone(),
|
||||||
|
}),
|
||||||
|
..Self::capture_params(graph)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The parameters alone, without the film.
|
||||||
|
///
|
||||||
|
/// For [`EditGraph::state`], which carries the film in a field of its own
|
||||||
|
/// ([`crate::EditState::film`]). Capturing it twice would give one edit
|
||||||
|
/// two places to disagree about its stock.
|
||||||
|
pub(crate) fn capture_params(graph: &EditGraph) -> Self {
|
||||||
let mut params = BTreeMap::new();
|
let mut params = BTreeMap::new();
|
||||||
for cap in graph.capabilities() {
|
for cap in graph.capabilities() {
|
||||||
for p in &cap.params {
|
for p in &cap.params {
|
||||||
@@ -245,12 +285,59 @@ impl Preset {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Self { params }
|
Self::from_params(params)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build from an already-captured parameter map — a sidecar's, typically.
|
/// Build from an already-captured parameter map — a sidecar's, typically.
|
||||||
pub fn from_params(params: BTreeMap<(String, String), f32>) -> Self {
|
pub fn from_params(params: BTreeMap<(String, String), f32>) -> Self {
|
||||||
Self { params }
|
Self {
|
||||||
|
params,
|
||||||
|
reach: Reach::Whole,
|
||||||
|
film: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same preset, reaching as far as `reach` says.
|
||||||
|
pub fn with_reach(self, reach: Reach) -> Self {
|
||||||
|
Self { reach, ..self }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How much of the target this preset replaces.
|
||||||
|
pub fn reach(&self) -> Reach {
|
||||||
|
self.reach
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether applying this preset replaces `op` — before any scope is asked.
|
||||||
|
fn reaches(&self, op: &str) -> bool {
|
||||||
|
match self.reach {
|
||||||
|
Reach::Whole => true,
|
||||||
|
Reach::Named => {
|
||||||
|
self.params.keys().any(|(o, _)| o == op)
|
||||||
|
|| (self.film.is_some() && op == crate::ops::film_sim::ID.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same preset, developed on `film`.
|
||||||
|
pub fn with_film(self, film: Option<FilmRef>) -> Self {
|
||||||
|
Self { film, ..self }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stock this preset develops on, if it names one.
|
||||||
|
pub fn film(&self) -> Option<&FilmRef> {
|
||||||
|
self.film.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What applying at `scope` does to the target's film.
|
||||||
|
///
|
||||||
|
/// `None` when the scope leaves the film alone; `Some(None)` when it
|
||||||
|
/// develops the target without one. The same two levels the sidecar
|
||||||
|
/// writer takes, which is who asks: the batch path amends files rather
|
||||||
|
/// than graphs, and has to know whether the film line is being written at
|
||||||
|
/// all before it knows what to write.
|
||||||
|
pub fn film_for(&self, scope: Scope) -> Option<Option<&FilmRef>> {
|
||||||
|
(scope.carries_film() && self.reaches(crate::ops::film_sim::ID.0))
|
||||||
|
.then_some(self.film.as_ref())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The parameters, for a caller that stores them.
|
/// The parameters, for a caller that stores them.
|
||||||
@@ -270,7 +357,7 @@ impl Preset {
|
|||||||
/// answers is whether there is a clipboard to offer, which is why the UI
|
/// answers is whether there is a clipboard to offer, which is why the UI
|
||||||
/// asks it before enabling a paste.
|
/// asks it before enabling a paste.
|
||||||
pub fn is_empty(&self) -> bool {
|
pub fn is_empty(&self) -> bool {
|
||||||
self.params.is_empty()
|
self.params.is_empty() && self.film.is_none()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How many parameters were captured.
|
/// How many parameters were captured.
|
||||||
@@ -296,10 +383,14 @@ impl Preset {
|
|||||||
/// rather than parameters because thirty-six mixer sliders is a number
|
/// rather than parameters because thirty-six mixer sliders is a number
|
||||||
/// about the mixer's shape, not about how much was copied.
|
/// about the mixer's shape, not about how much was copied.
|
||||||
pub fn op_count(&self, scope: Scope) -> usize {
|
pub fn op_count(&self, scope: Scope) -> usize {
|
||||||
|
// A stock with every film slider at default is still the film node
|
||||||
|
// at work, and a preset holding nothing else is not "Neutral".
|
||||||
|
let film = self.film.is_some().then_some(crate::ops::film_sim::ID.0);
|
||||||
let mut ops: Vec<&str> = self
|
let mut ops: Vec<&str> = self
|
||||||
.params
|
.params
|
||||||
.keys()
|
.keys()
|
||||||
.map(|(op, _)| op.as_str())
|
.map(|(op, _)| op.as_str())
|
||||||
|
.chain(film)
|
||||||
.filter(|op| scope.covers(op))
|
.filter(|op| scope.covers(op))
|
||||||
.collect();
|
.collect();
|
||||||
ops.sort_unstable();
|
ops.sort_unstable();
|
||||||
@@ -319,7 +410,13 @@ impl Preset {
|
|||||||
/// to a fitted view mid-comparison, which reads as the paste having
|
/// to a fitted view mid-comparison, which reads as the paste having
|
||||||
/// navigated somewhere. This mirrors `DevelopSession::reset_framing`, and
|
/// navigated somewhere. This mirrors `DevelopSession::reset_framing`, and
|
||||||
/// lives here so every caller inherits it rather than each remembering.
|
/// lives here so every caller inherits it rather than each remembering.
|
||||||
pub fn apply(&self, graph: &mut EditGraph, scope: Scope) {
|
///
|
||||||
|
/// The **film** is replaced when the scope carries it, and handed back as
|
||||||
|
/// a [`FilmRebake`] because this crate cannot bake a stock — the same debt
|
||||||
|
/// [`EditGraph::set_state`] returns, for the same reason. It is cleared
|
||||||
|
/// even when the preset names the stock already on the graph: the tables
|
||||||
|
/// were baked from the film sliders this call has just replaced.
|
||||||
|
pub fn apply(&self, graph: &mut EditGraph, scope: Scope) -> FilmRebake {
|
||||||
let view = graph.framing().view();
|
let view = graph.framing().view();
|
||||||
|
|
||||||
// Clear the scope first, so absence means default (see the module
|
// Clear the scope first, so absence means default (see the module
|
||||||
@@ -328,7 +425,7 @@ impl Preset {
|
|||||||
let clears: Vec<(OpId, ParamId, f32)> = graph
|
let clears: Vec<(OpId, ParamId, f32)> = graph
|
||||||
.capabilities()
|
.capabilities()
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|cap| scope.covers(cap.id.0))
|
.filter(|cap| scope.covers(cap.id.0) && self.reaches(cap.id.0))
|
||||||
.flat_map(|cap| cap.params.iter().map(|p| (cap.id, p.id, p.default)))
|
.flat_map(|cap| cap.params.iter().map(|p| (cap.id, p.id, p.default)))
|
||||||
.collect();
|
.collect();
|
||||||
for (op, param, default) in clears {
|
for (op, param, default) in clears {
|
||||||
@@ -347,6 +444,17 @@ impl Preset {
|
|||||||
}
|
}
|
||||||
|
|
||||||
graph.framing_mut().set_view(view);
|
graph.framing_mut().set_view(view);
|
||||||
|
|
||||||
|
match self.film_for(scope) {
|
||||||
|
None => FilmRebake::NotNeeded,
|
||||||
|
Some(film) => {
|
||||||
|
graph.set_film(None);
|
||||||
|
match film {
|
||||||
|
None => FilmRebake::NotNeeded,
|
||||||
|
Some(film) => FilmRebake::Wanted(film.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply to a parameter map — the sidecar of an image that is not open.
|
/// Apply to a parameter map — the sidecar of an image that is not open.
|
||||||
@@ -361,8 +469,11 @@ impl Preset {
|
|||||||
/// Same replacement rule as [`Self::apply`]: the target's in-scope keys go,
|
/// Same replacement rule as [`Self::apply`]: the target's in-scope keys go,
|
||||||
/// the preset's arrive, and out-of-scope keys — the target's own crop, on
|
/// the preset's arrive, and out-of-scope keys — the target's own crop, on
|
||||||
/// the default scope — are left exactly as they were.
|
/// the default scope — are left exactly as they were.
|
||||||
|
///
|
||||||
|
/// A parameter map has no film in it, so the stock is not written here:
|
||||||
|
/// the caller asks [`Self::film_for`] and writes it beside the map.
|
||||||
pub fn amend(&self, target: &mut BTreeMap<(String, String), f32>, scope: Scope) {
|
pub fn amend(&self, target: &mut BTreeMap<(String, String), f32>, scope: Scope) {
|
||||||
target.retain(|(op, _), _| !scope.covers(op));
|
target.retain(|(op, _), _| !(scope.covers(op) && self.reaches(op)));
|
||||||
for ((op, param), value) in &self.params {
|
for ((op, param), value) in &self.params {
|
||||||
if scope.covers(op) {
|
if scope.covers(op) {
|
||||||
target.insert((op.clone(), param.clone()), *value);
|
target.insert((op.clone(), param.clone()), *value);
|
||||||
@@ -556,6 +667,14 @@ impl PresetLibrary {
|
|||||||
self.presets.is_empty()
|
self.presets.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How many lines were kept without being understood.
|
||||||
|
///
|
||||||
|
/// For a file this build wrote itself, or shipped, that should be none: a
|
||||||
|
/// misspelt key is preserved faithfully and does nothing.
|
||||||
|
pub fn unread_lines(&self) -> usize {
|
||||||
|
self.unknown.values().map(Vec::len).sum()
|
||||||
|
}
|
||||||
|
|
||||||
/// Serialise to the on-disk form.
|
/// Serialise to the on-disk form.
|
||||||
///
|
///
|
||||||
/// Deterministic, like the sidecar's: the same library always produces the
|
/// Deterministic, like the sidecar's: the same library always produces the
|
||||||
@@ -567,6 +686,22 @@ impl PresetLibrary {
|
|||||||
for ((op, param), value) in preset.params() {
|
for ((op, param), value) in preset.params() {
|
||||||
let _ = writeln!(out, "{op}.{param} = {}", format_value(*value));
|
let _ = writeln!(out, "{op}.{param} = {}", format_value(*value));
|
||||||
}
|
}
|
||||||
|
// TRACES: FR-DEV-3f
|
||||||
|
// Spelled as the sidecar spells them, so a block can still be
|
||||||
|
// pasted from one file into the other. A build that predates
|
||||||
|
// these lines reads them as lines it does not understand and
|
||||||
|
// writes them back untouched, which is the promise below.
|
||||||
|
// Only when it differs from the default, so every library written
|
||||||
|
// before looks existed still writes the same bytes.
|
||||||
|
if preset.reach() == Reach::Named {
|
||||||
|
let _ = writeln!(out, "reach = named");
|
||||||
|
}
|
||||||
|
if let Some(film) = preset.film() {
|
||||||
|
let _ = writeln!(out, "film = {}", film.stock);
|
||||||
|
if let Some(print) = &film.print {
|
||||||
|
let _ = writeln!(out, "film_print = {print}");
|
||||||
|
}
|
||||||
|
}
|
||||||
for line in self.unknown.get(name).into_iter().flatten() {
|
for line in self.unknown.get(name).into_iter().flatten() {
|
||||||
let _ = writeln!(out, "{line}");
|
let _ = writeln!(out, "{line}");
|
||||||
}
|
}
|
||||||
@@ -597,6 +732,8 @@ impl PresetLibrary {
|
|||||||
let mut library = Self::default();
|
let mut library = Self::default();
|
||||||
let mut current: Option<String> = None;
|
let mut current: Option<String> = None;
|
||||||
let mut params: BTreeMap<(String, String), f32> = BTreeMap::new();
|
let mut params: BTreeMap<(String, String), f32> = BTreeMap::new();
|
||||||
|
let mut film: Option<FilmRef> = None;
|
||||||
|
let mut reach = Reach::Whole;
|
||||||
|
|
||||||
for line in lines {
|
for line in lines {
|
||||||
let line = line.trim();
|
let line = line.trim();
|
||||||
@@ -609,9 +746,16 @@ impl PresetLibrary {
|
|||||||
.and_then(|l| l.strip_suffix(']'))
|
.and_then(|l| l.strip_suffix(']'))
|
||||||
{
|
{
|
||||||
if let Some(name) = current.take() {
|
if let Some(name) = current.take() {
|
||||||
library.presets.insert(name, Preset::from_params(params));
|
library.presets.insert(
|
||||||
params = BTreeMap::new();
|
name,
|
||||||
|
Preset::from_params(std::mem::take(&mut params))
|
||||||
|
.with_film(film.take())
|
||||||
|
.with_reach(reach),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
params.clear();
|
||||||
|
film = None;
|
||||||
|
reach = Reach::Whole;
|
||||||
// A name the writer should never have produced is dropped
|
// A name the writer should never have produced is dropped
|
||||||
// rather than taken: accepting it would mean writing a file
|
// rather than taken: accepting it would mean writing a file
|
||||||
// back out that no longer parses as this one.
|
// back out that no longer parses as this one.
|
||||||
@@ -631,6 +775,23 @@ impl PresetLibrary {
|
|||||||
};
|
};
|
||||||
|
|
||||||
match line.split_once('=') {
|
match line.split_once('=') {
|
||||||
|
// TRACES: FR-DEV-3f
|
||||||
|
// Not checked against the installed stocks: this crate does
|
||||||
|
// not link them, and a preset naming a stock this device
|
||||||
|
// lacks must survive being stored here. Whoever bakes it
|
||||||
|
// reports the miss — the sidecar's rule, for its reason.
|
||||||
|
// `get_or_insert_with` because a hand-edited block may name
|
||||||
|
// the paper first.
|
||||||
|
Some((key, value)) if key.trim() == "reach" && value.trim() == "named" => {
|
||||||
|
reach = Reach::Named;
|
||||||
|
}
|
||||||
|
Some((key, value)) if key.trim() == "film" && !value.trim().is_empty() => {
|
||||||
|
film.get_or_insert_with(FilmRef::default).stock = value.trim().to_string();
|
||||||
|
}
|
||||||
|
Some((key, value)) if key.trim() == "film_print" && !value.trim().is_empty() => {
|
||||||
|
film.get_or_insert_with(FilmRef::default).print =
|
||||||
|
Some(value.trim().to_string());
|
||||||
|
}
|
||||||
Some((key, value)) => {
|
Some((key, value)) => {
|
||||||
let key = key.trim();
|
let key = key.trim();
|
||||||
let value = value.trim();
|
let value = value.trim();
|
||||||
@@ -654,7 +815,21 @@ impl PresetLibrary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(name) = current {
|
if let Some(name) = current {
|
||||||
library.presets.insert(name, Preset::from_params(params));
|
library.presets.insert(
|
||||||
|
name,
|
||||||
|
Preset::from_params(params)
|
||||||
|
.with_film(film)
|
||||||
|
.with_reach(reach),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A paper with no film named beside it is a print of nothing. Dropped
|
||||||
|
// rather than kept, since baking it would have no stock to start from.
|
||||||
|
for preset in library.presets.values_mut() {
|
||||||
|
if preset.film.as_ref().is_some_and(|f| f.stock.is_empty()) {
|
||||||
|
log::warn!("preset library: a paper without a film; ignoring it");
|
||||||
|
preset.film = None;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A block whose every line was unreadable still produced a preset, and
|
// A block whose every line was unreadable still produced a preset, and
|
||||||
@@ -803,7 +978,9 @@ mod tests {
|
|||||||
};
|
};
|
||||||
target.set_crop(target_crop);
|
target.set_crop(target_crop);
|
||||||
|
|
||||||
preset.apply(&mut target, Scope::adjustments());
|
preset
|
||||||
|
.apply(&mut target, Scope::adjustments())
|
||||||
|
.expect_no_film();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
target.param(exposure::ID, exposure::EXPOSURE),
|
target.param(exposure::ID, exposure::EXPOSURE),
|
||||||
@@ -835,7 +1012,9 @@ mod tests {
|
|||||||
fn pasting_everything_carries_the_composition_too() {
|
fn pasting_everything_carries_the_composition_too() {
|
||||||
let preset = Preset::capture(&edited());
|
let preset = Preset::capture(&edited());
|
||||||
let mut target = EditGraph::default_chain();
|
let mut target = EditGraph::default_chain();
|
||||||
preset.apply(&mut target, Scope::everything());
|
preset
|
||||||
|
.apply(&mut target, Scope::everything())
|
||||||
|
.expect_no_film();
|
||||||
|
|
||||||
assert_eq!(target.param(framing::ID, framing::ANGLE), Some(-2.0));
|
assert_eq!(target.param(framing::ID, framing::ANGLE), Some(-2.0));
|
||||||
assert_eq!(target.param(framing::ID, framing::KEYSTONE_V), Some(40.0));
|
assert_eq!(target.param(framing::ID, framing::KEYSTONE_V), Some(40.0));
|
||||||
@@ -857,7 +1036,9 @@ mod tests {
|
|||||||
target.set_param(exposure::ID, exposure::EXPOSURE, 2.0);
|
target.set_param(exposure::ID, exposure::EXPOSURE, 2.0);
|
||||||
target.set_param(saturation::ID, saturation::SATURATION, -50.0);
|
target.set_param(saturation::ID, saturation::SATURATION, -50.0);
|
||||||
|
|
||||||
neutral.apply(&mut target, Scope::adjustments());
|
neutral
|
||||||
|
.apply(&mut target, Scope::adjustments())
|
||||||
|
.expect_no_film();
|
||||||
|
|
||||||
assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(0.0));
|
assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(0.0));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -876,7 +1057,9 @@ mod tests {
|
|||||||
let mut target = EditGraph::default_chain();
|
let mut target = EditGraph::default_chain();
|
||||||
target.set_param(framing::ID, framing::ANGLE, 3.5);
|
target.set_param(framing::ID, framing::ANGLE, 3.5);
|
||||||
|
|
||||||
neutral.apply(&mut target, Scope::adjustments());
|
neutral
|
||||||
|
.apply(&mut target, Scope::adjustments())
|
||||||
|
.expect_no_film();
|
||||||
assert_eq!(target.param(framing::ID, framing::ANGLE), Some(3.5));
|
assert_eq!(target.param(framing::ID, framing::ANGLE), Some(3.5));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -894,7 +1077,9 @@ mod tests {
|
|||||||
height: 0.25,
|
height: 0.25,
|
||||||
});
|
});
|
||||||
|
|
||||||
preset.apply(&mut target, Scope::everything());
|
preset
|
||||||
|
.apply(&mut target, Scope::everything())
|
||||||
|
.expect_no_film();
|
||||||
assert!(
|
assert!(
|
||||||
target.framing().is_zoomed(),
|
target.framing().is_zoomed(),
|
||||||
"the paste threw away the viewport: {:?}",
|
"the paste threw away the viewport: {:?}",
|
||||||
@@ -911,7 +1096,9 @@ mod tests {
|
|||||||
|
|
||||||
let mut sideways = EditGraph::default_chain();
|
let mut sideways = EditGraph::default_chain();
|
||||||
sideways.set_orientation(dr_types::Orientation::from_exif(6));
|
sideways.set_orientation(dr_types::Orientation::from_exif(6));
|
||||||
preset.apply(&mut sideways, Scope::everything());
|
preset
|
||||||
|
.apply(&mut sideways, Scope::everything())
|
||||||
|
.expect_no_film();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
sideways.framing().baseline(),
|
sideways.framing().baseline(),
|
||||||
@@ -928,7 +1115,9 @@ mod tests {
|
|||||||
params.insert(("exposure".to_string(), "exposure".to_string()), 1.25);
|
params.insert(("exposure".to_string(), "exposure".to_string()), 1.25);
|
||||||
|
|
||||||
let mut target = EditGraph::default_chain();
|
let mut target = EditGraph::default_chain();
|
||||||
Preset::from_params(params).apply(&mut target, Scope::adjustments());
|
Preset::from_params(params)
|
||||||
|
.apply(&mut target, Scope::adjustments())
|
||||||
|
.expect_no_film();
|
||||||
|
|
||||||
assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(1.25));
|
assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(1.25));
|
||||||
}
|
}
|
||||||
@@ -939,7 +1128,9 @@ mod tests {
|
|||||||
params.insert(("exposure".to_string(), "exposure".to_string()), 99.0);
|
params.insert(("exposure".to_string(), "exposure".to_string()), 99.0);
|
||||||
|
|
||||||
let mut target = EditGraph::default_chain();
|
let mut target = EditGraph::default_chain();
|
||||||
Preset::from_params(params).apply(&mut target, Scope::adjustments());
|
Preset::from_params(params)
|
||||||
|
.apply(&mut target, Scope::adjustments())
|
||||||
|
.expect_no_film();
|
||||||
assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(5.0));
|
assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(5.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1001,11 +1192,13 @@ mod tests {
|
|||||||
|
|
||||||
let mut target_map = Preset::capture(&target_graph).into_params();
|
let mut target_map = Preset::capture(&target_graph).into_params();
|
||||||
|
|
||||||
preset.apply(&mut target_graph, scope);
|
preset.apply(&mut target_graph, scope).expect_no_film();
|
||||||
preset.amend(&mut target_map, scope);
|
preset.amend(&mut target_map, scope);
|
||||||
|
|
||||||
let mut rebuilt = EditGraph::default_chain();
|
let mut rebuilt = EditGraph::default_chain();
|
||||||
Preset::from_params(target_map).apply(&mut rebuilt, Scope::everything());
|
Preset::from_params(target_map)
|
||||||
|
.apply(&mut rebuilt, Scope::everything())
|
||||||
|
.expect_no_film();
|
||||||
|
|
||||||
for cap in target_graph.capabilities() {
|
for cap in target_graph.capabilities() {
|
||||||
for p in &cap.params {
|
for p in &cap.params {
|
||||||
@@ -1030,7 +1223,9 @@ mod tests {
|
|||||||
let preset = Preset::capture(&source);
|
let preset = Preset::capture(&source);
|
||||||
|
|
||||||
let mut target = EditGraph::default_chain();
|
let mut target = EditGraph::default_chain();
|
||||||
preset.apply(&mut target, Scope::everything());
|
preset
|
||||||
|
.apply(&mut target, Scope::everything())
|
||||||
|
.expect_no_film();
|
||||||
|
|
||||||
for cap in source.capabilities() {
|
for cap in source.capabilities() {
|
||||||
for p in &cap.params {
|
for p in &cap.params {
|
||||||
@@ -1068,6 +1263,190 @@ mod tests {
|
|||||||
assert_eq!(excluded, vec![framing::ID.0]);
|
assert_eq!(excluded, vec![framing::ID.0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- the film ------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A graph developing on `stock`. The tables are invented; what is under
|
||||||
|
/// test is whether the *choice* travels.
|
||||||
|
fn on_film(stock: &str) -> EditGraph {
|
||||||
|
let mut g = edited();
|
||||||
|
g.set_film(Some(crate::graph::Film {
|
||||||
|
stock: stock.to_string(),
|
||||||
|
print: Some("kodak_portra_endura".to_string()),
|
||||||
|
tables: crate::ops::FilmTables {
|
||||||
|
exposure_matrix: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
|
||||||
|
curves: vec![[0.5, 0.5, 0.5]; crate::ops::film_sim::CURVE_SAMPLES],
|
||||||
|
curve_log_min: -3.0,
|
||||||
|
curve_log_max: 1.0,
|
||||||
|
lut: vec![[0.5, 0.5, 0.5]; 8],
|
||||||
|
density_max: 2.0,
|
||||||
|
lut_size: 2,
|
||||||
|
grain_particles: [[0.0; 3]; crate::ops::film_sim::FORMAT_COUNT],
|
||||||
|
push_stations: vec![0.0],
|
||||||
|
paper: None,
|
||||||
|
grain_density_max: [2.0; 3],
|
||||||
|
grain_uniformity: 1.0,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
g
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-6 | FR-DEV-3f
|
||||||
|
#[test]
|
||||||
|
fn a_copy_carries_the_stock_it_was_developed_on() {
|
||||||
|
let preset = Preset::capture(&on_film("kodak_portra_400"));
|
||||||
|
let film = preset.film().expect("the stock was captured");
|
||||||
|
assert_eq!(film.stock, "kodak_portra_400");
|
||||||
|
assert_eq!(film.print.as_deref(), Some("kodak_portra_endura"));
|
||||||
|
assert!(Preset::capture(&edited()).film().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-6 | FR-DEV-3f
|
||||||
|
/// The graph's own state keeps the film in one place only.
|
||||||
|
#[test]
|
||||||
|
fn an_edit_state_does_not_carry_the_film_twice() {
|
||||||
|
let state = on_film("kodak_portra_400").state();
|
||||||
|
assert!(state.film.is_some());
|
||||||
|
assert_eq!(state.params.film(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-6 | FR-DEV-3f
|
||||||
|
/// Applying a preset that names a stock asks for it to be baked, and
|
||||||
|
/// clears the tables it found — they came from the sliders it replaced.
|
||||||
|
#[test]
|
||||||
|
fn applying_a_film_preset_asks_for_its_stock() {
|
||||||
|
let preset = Preset::capture(&on_film("kodak_portra_400"));
|
||||||
|
let mut target = on_film("ilford_hp5");
|
||||||
|
|
||||||
|
let rebake = preset.apply(&mut target, Scope::adjustments());
|
||||||
|
assert_eq!(
|
||||||
|
rebake.wanted().map(|f| f.stock.as_str()),
|
||||||
|
Some("kodak_portra_400")
|
||||||
|
);
|
||||||
|
assert!(target.film().is_none(), "the old tables were left standing");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-6 | FR-DEV-3f
|
||||||
|
/// Replacement, as for every parameter: a preset with no stock, at a
|
||||||
|
/// scope that carries the film, develops the target without one.
|
||||||
|
#[test]
|
||||||
|
fn a_preset_without_a_film_clears_the_targets() {
|
||||||
|
let mut target = on_film("ilford_hp5");
|
||||||
|
Preset::capture(&edited())
|
||||||
|
.apply(&mut target, Scope::adjustments())
|
||||||
|
.expect_no_film();
|
||||||
|
assert!(target.film().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-6 | FR-DEV-3f
|
||||||
|
/// A scope that leaves the film node behind leaves the stock behind too,
|
||||||
|
/// so an exposure never lands on a stock it was not set for.
|
||||||
|
#[test]
|
||||||
|
fn a_scope_without_the_film_leaves_the_stock_alone() {
|
||||||
|
let preset = Preset::capture(&on_film("kodak_portra_400"));
|
||||||
|
let mut target = on_film("ilford_hp5");
|
||||||
|
|
||||||
|
let tone = Scope::of([Attribute::Tone]);
|
||||||
|
assert!(!tone.carries_film());
|
||||||
|
preset.apply(&mut target, tone).expect_no_film();
|
||||||
|
assert_eq!(target.film().map(|f| f.stock.as_str()), Some("ilford_hp5"));
|
||||||
|
assert_eq!(preset.film_for(tone), None);
|
||||||
|
assert!(preset.film_for(Scope::adjustments()).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-6 | FR-DEV-3f
|
||||||
|
#[test]
|
||||||
|
fn a_stock_alone_is_not_a_neutral_preset() {
|
||||||
|
let preset = Preset::default().with_film(Some(FilmRef {
|
||||||
|
stock: "kodak_portra_400".into(),
|
||||||
|
print: None,
|
||||||
|
}));
|
||||||
|
assert!(!preset.is_empty());
|
||||||
|
assert_eq!(preset.op_count(Scope::adjustments()), 1);
|
||||||
|
assert_eq!(preset.op_count(Scope::of([Attribute::Tone])), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- reach ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A look: a stock and a contrast, and nothing else.
|
||||||
|
fn look() -> Preset {
|
||||||
|
let mut params = BTreeMap::new();
|
||||||
|
params.insert(("contrast".to_string(), "contrast".to_string()), 20.0);
|
||||||
|
Preset::from_params(params)
|
||||||
|
.with_film(Some(FilmRef {
|
||||||
|
stock: "kodak_portra_400".into(),
|
||||||
|
print: None,
|
||||||
|
}))
|
||||||
|
.with_reach(Reach::Named)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-6
|
||||||
|
/// The reason `Reach` exists: a look applied over a corrected photograph
|
||||||
|
/// keeps the correction.
|
||||||
|
#[test]
|
||||||
|
fn a_look_leaves_what_it_does_not_name_alone() {
|
||||||
|
let mut target = EditGraph::default_chain();
|
||||||
|
target.set_param(exposure::ID, exposure::EXPOSURE, 1.25);
|
||||||
|
target.set_param(saturation::ID, saturation::SATURATION, -40.0);
|
||||||
|
|
||||||
|
let rebake = look().apply(&mut target, Scope::adjustments());
|
||||||
|
|
||||||
|
assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(1.25));
|
||||||
|
assert_eq!(
|
||||||
|
target.param(saturation::ID, saturation::SATURATION),
|
||||||
|
Some(-40.0)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rebake.wanted().map(|f| f.stock.as_str()),
|
||||||
|
Some("kodak_portra_400")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-6
|
||||||
|
/// A look without a film leaves the target's film alone, where a whole
|
||||||
|
/// edit without one would clear it.
|
||||||
|
#[test]
|
||||||
|
fn a_look_without_a_film_keeps_the_targets() {
|
||||||
|
let mut target = on_film("ilford_hp5");
|
||||||
|
let only_contrast = look().with_film(None);
|
||||||
|
assert_eq!(only_contrast.film_for(Scope::adjustments()), None);
|
||||||
|
only_contrast
|
||||||
|
.apply(&mut target, Scope::adjustments())
|
||||||
|
.expect_no_film();
|
||||||
|
assert_eq!(target.film().map(|f| f.stock.as_str()), Some("ilford_hp5"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-6
|
||||||
|
/// The batch path agrees: a look amends only the operations it names.
|
||||||
|
#[test]
|
||||||
|
fn a_look_amends_only_what_it_names() {
|
||||||
|
let mut target = BTreeMap::new();
|
||||||
|
target.insert(("exposure".to_string(), "exposure".to_string()), 1.25);
|
||||||
|
target.insert(("contrast".to_string(), "contrast".to_string()), -50.0);
|
||||||
|
look().amend(&mut target, Scope::adjustments());
|
||||||
|
assert_eq!(
|
||||||
|
target.get(&("exposure".to_string(), "exposure".to_string())),
|
||||||
|
Some(&1.25)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
target.get(&("contrast".to_string(), "contrast".to_string())),
|
||||||
|
Some(&20.0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-6
|
||||||
|
#[test]
|
||||||
|
fn a_look_survives_the_library_round_trip() {
|
||||||
|
let mut lib = named();
|
||||||
|
lib.insert("Look", look()).unwrap();
|
||||||
|
let text = lib.to_text();
|
||||||
|
assert!(text.contains("reach = named\n"), "{text}");
|
||||||
|
let back = PresetLibrary::parse(&text).unwrap();
|
||||||
|
assert_eq!(back, lib);
|
||||||
|
// And the default is not written, so an existing library's bytes are
|
||||||
|
// unchanged by this format ever having grown the line.
|
||||||
|
assert_eq!(named().to_text().matches("reach").count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Named presets
|
// Named presets
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -1147,6 +1526,37 @@ mod tests {
|
|||||||
assert!(out.contains("not_an_op.not_a_param = 0.25"), "{out}");
|
assert!(out.contains("not_an_op.not_a_param = 0.25"), "{out}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-6 | FR-DEV-3f
|
||||||
|
#[test]
|
||||||
|
fn a_film_survives_the_library_round_trip() {
|
||||||
|
let mut lib = named();
|
||||||
|
lib.insert("Portra", Preset::capture(&on_film("kodak_portra_400")))
|
||||||
|
.unwrap();
|
||||||
|
let text = lib.to_text();
|
||||||
|
assert!(text.contains("film = kodak_portra_400\n"), "{text}");
|
||||||
|
assert!(
|
||||||
|
text.contains("film_print = kodak_portra_endura\n"),
|
||||||
|
"{text}"
|
||||||
|
);
|
||||||
|
assert_eq!(PresetLibrary::parse(&text).unwrap(), lib);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-6 | FR-DEV-3f
|
||||||
|
/// The paper may come first in a hand-edited block, and a paper with no
|
||||||
|
/// film is dropped rather than baked from nothing.
|
||||||
|
#[test]
|
||||||
|
fn film_lines_read_in_either_order_and_a_lone_paper_is_dropped() {
|
||||||
|
let text = format!(
|
||||||
|
"drpl {LIBRARY_FORMAT_VERSION}\n\n[preset A]\nfilm_print = p\nfilm = s\n\n\
|
||||||
|
[preset B]\nfilm_print = p\nexposure.exposure = 1\n"
|
||||||
|
);
|
||||||
|
let lib = PresetLibrary::parse(&text).unwrap();
|
||||||
|
let a = lib.get("A").unwrap().film().unwrap();
|
||||||
|
assert_eq!((a.stock.as_str(), a.print.as_deref()), ("s", Some("p")));
|
||||||
|
assert_eq!(lib.get("B").unwrap().film(), None);
|
||||||
|
assert_eq!(lib.get("B").unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_file_from_a_newer_build_is_refused_rather_than_guessed_at() {
|
fn a_file_from_a_newer_build_is_refused_rather_than_guessed_at() {
|
||||||
let text = format!("drpl {}\n", LIBRARY_FORMAT_VERSION + 1);
|
let text = format!("drpl {}\n", LIBRARY_FORMAT_VERSION + 1);
|
||||||
@@ -1234,7 +1644,9 @@ mod tests {
|
|||||||
let preset = stored.get("Warm portrait").unwrap();
|
let preset = stored.get("Warm portrait").unwrap();
|
||||||
|
|
||||||
let mut target = EditGraph::default_chain();
|
let mut target = EditGraph::default_chain();
|
||||||
preset.apply(&mut target, Scope::adjustments());
|
preset
|
||||||
|
.apply(&mut target, Scope::adjustments())
|
||||||
|
.expect_no_film();
|
||||||
assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(0.75));
|
assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(0.75));
|
||||||
// The target keeps its own framing on the default scope.
|
// The target keeps its own framing on the default scope.
|
||||||
assert_eq!(target.param(framing::ID, framing::ANGLE), Some(0.0));
|
assert_eq!(target.param(framing::ID, framing::ANGLE), Some(0.0));
|
||||||
@@ -1268,7 +1680,9 @@ mod tests {
|
|||||||
fn a_hand_picked_scope_carries_only_the_kinds_it_names() {
|
fn a_hand_picked_scope_carries_only_the_kinds_it_names() {
|
||||||
let preset = Preset::capture(&edited());
|
let preset = Preset::capture(&edited());
|
||||||
let mut target = EditGraph::default_chain();
|
let mut target = EditGraph::default_chain();
|
||||||
preset.apply(&mut target, Scope::of([Attribute::Tone]));
|
preset
|
||||||
|
.apply(&mut target, Scope::of([Attribute::Tone]))
|
||||||
|
.expect_no_film();
|
||||||
|
|
||||||
// Tone was picked, so the exposure travelled.
|
// Tone was picked, so the exposure travelled.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1333,7 +1747,9 @@ mod tests {
|
|||||||
|
|
||||||
let mut target = EditGraph::default_chain();
|
let mut target = EditGraph::default_chain();
|
||||||
target.set_param(exposure::ID, exposure::EXPOSURE, -1.25);
|
target.set_param(exposure::ID, exposure::EXPOSURE, -1.25);
|
||||||
Preset::capture(&edited()).apply(&mut target, empty);
|
Preset::capture(&edited())
|
||||||
|
.apply(&mut target, empty)
|
||||||
|
.expect_no_film();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
target.param(exposure::ID, exposure::EXPOSURE),
|
target.param(exposure::ID, exposure::EXPOSURE),
|
||||||
Some(-1.25),
|
Some(-1.25),
|
||||||
|
|||||||
@@ -2023,7 +2023,9 @@ mod tests {
|
|||||||
lut: vec![[0.5, 0.5, 0.5]; 8],
|
lut: vec![[0.5, 0.5, 0.5]; 8],
|
||||||
density_max: 2.0,
|
density_max: 2.0,
|
||||||
lut_size: 2,
|
lut_size: 2,
|
||||||
grain_particles: [0.0; 3],
|
grain_particles: [[0.0; 3]; crate::ops::film_sim::FORMAT_COUNT],
|
||||||
|
push_stations: vec![0.0],
|
||||||
|
paper: None,
|
||||||
grain_density_max: [2.0; 3],
|
grain_density_max: [2.0; 3],
|
||||||
grain_uniformity: 1.0,
|
grain_uniformity: 1.0,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,220 +0,0 @@
|
|||||||
//! TRACES: FR-DEV-6
|
|
||||||
//! The presets a first run starts with.
|
|
||||||
//!
|
|
||||||
//! # Why any at all
|
|
||||||
//!
|
|
||||||
//! A preset sheet that opens on "No presets yet" teaches the photographer that
|
|
||||||
//! the feature is homework. These exist so the first thing the sheet does is
|
|
||||||
//! demonstrate what a preset *is* — and so that applying one to a selection,
|
|
||||||
//! which is the action worth discovering, is available before anybody has
|
|
||||||
//! saved anything.
|
|
||||||
//!
|
|
||||||
//! # Why these are ours and not Adobe's
|
|
||||||
//!
|
|
||||||
//! Lightroom ships a large bundled set, and importing one of *those* files is
|
|
||||||
//! what [`crate::preset_import`] is for — a photographer's own library,
|
|
||||||
//! carried across. Redistributing Adobe's inside this application would be
|
|
||||||
//! shipping their creative work under a licence that does not permit it, which
|
|
||||||
//! is a reason on its own; and their numbers are calibrated against their tone
|
|
||||||
//! curve rather than ours, so the look would not survive the trip even if the
|
|
||||||
//! licence allowed it.
|
|
||||||
//!
|
|
||||||
//! So these are written against this pipeline, in its units, and they are
|
|
||||||
//! deliberately mild. A starter preset is a starting point — a photographer
|
|
||||||
//! who wanted the full effect can push the sliders, where one who is handed a
|
|
||||||
//! caricature learns to distrust the list.
|
|
||||||
//!
|
|
||||||
//! # Why they live in the core rather than in the interface
|
|
||||||
//!
|
|
||||||
//! Because they name operations, and nothing in `ui/` may
|
|
||||||
//! (`ui_names_no_operation.rs`, ARCH §4.3a). That test is right to object: a
|
|
||||||
//! preset called "Punch" *is* a statement about contrast, clarity and
|
|
||||||
//! vibrance, which makes it a statement in the pipeline's vocabulary rather
|
|
||||||
//! than a fact about any interface. The frontend asks for the set and stores
|
|
||||||
//! it; it never learns what is in it.
|
|
||||||
//!
|
|
||||||
//! # Why they are seeded rather than merged
|
|
||||||
//!
|
|
||||||
//! Written once, on the first run that finds no library at all, and never
|
|
||||||
//! again. Re-adding them on every start would resurrect one the photographer
|
|
||||||
//! deleted on purpose, and updating them in place would silently rewrite an
|
|
||||||
//! edit they had adjusted and kept under the same name. After the first run
|
|
||||||
//! these are ordinary presets: renameable, editable, deletable, and gone for
|
|
||||||
//! good when deleted.
|
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
use crate::{Preset, PresetLibrary};
|
|
||||||
|
|
||||||
/// One starter preset: a name and the parameters that differ from default.
|
|
||||||
struct Starter {
|
|
||||||
name: &'static str,
|
|
||||||
params: &'static [(&'static str, &'static str, f32)],
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The set. Small on purpose — six a photographer might actually reach for
|
|
||||||
/// beats forty they have to scroll past.
|
|
||||||
const STARTERS: &[Starter] = &[
|
|
||||||
Starter {
|
|
||||||
name: "Punch",
|
|
||||||
params: &[
|
|
||||||
("contrast", "contrast", 18.0),
|
|
||||||
("clarity", "amount", 12.0),
|
|
||||||
("vibrance", "vibrance", 18.0),
|
|
||||||
("blacks_whites", "blacks", -8.0),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
Starter {
|
|
||||||
name: "Soft portrait",
|
|
||||||
params: &[
|
|
||||||
("contrast", "contrast", -8.0),
|
|
||||||
("highlights_shadows", "highlights", -20.0),
|
|
||||||
("highlights_shadows", "shadows", 15.0),
|
|
||||||
("clarity", "amount", -10.0),
|
|
||||||
("vibrance", "vibrance", 10.0),
|
|
||||||
("saturation", "saturation", -5.0),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
Starter {
|
|
||||||
name: "Recover the sky",
|
|
||||||
params: &[
|
|
||||||
// The most common single fix in landscape work: a bright sky and a
|
|
||||||
// dark foreground, both pulled back toward the middle.
|
|
||||||
("highlights_shadows", "highlights", -55.0),
|
|
||||||
("highlights_shadows", "shadows", 35.0),
|
|
||||||
("blacks_whites", "whites", -10.0),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
Starter {
|
|
||||||
name: "Lift the shadows",
|
|
||||||
params: &[
|
|
||||||
("highlights_shadows", "shadows", 40.0),
|
|
||||||
("blacks_whites", "blacks", 12.0),
|
|
||||||
("contrast", "contrast", -5.0),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
Starter {
|
|
||||||
name: "Crisp detail",
|
|
||||||
params: &[
|
|
||||||
("texture", "amount", 20.0),
|
|
||||||
("clarity", "amount", 10.0),
|
|
||||||
("capture_sharpen", "amount", 35.0),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
Starter {
|
|
||||||
name: "Muted",
|
|
||||||
params: &[
|
|
||||||
("saturation", "saturation", -30.0),
|
|
||||||
("vibrance", "vibrance", 10.0),
|
|
||||||
("contrast", "contrast", -10.0),
|
|
||||||
("highlights_shadows", "shadows", 12.0),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
/// The starter library, for a device that has never had one.
|
|
||||||
pub fn library() -> PresetLibrary {
|
|
||||||
let mut library = PresetLibrary::default();
|
|
||||||
for starter in STARTERS {
|
|
||||||
let params: BTreeMap<(String, String), f32> = starter
|
|
||||||
.params
|
|
||||||
.iter()
|
|
||||||
.map(|(op, param, value)| ((op.to_string(), param.to_string()), *value))
|
|
||||||
.collect();
|
|
||||||
// The name is a literal in this file, so a refusal would be a bug here
|
|
||||||
// rather than bad input — but it still must not take the whole set
|
|
||||||
// down, since the alternative to five presets is not six, it is none.
|
|
||||||
if let Err(e) = library.insert(starter.name, Preset::from_params(params)) {
|
|
||||||
log::warn!(
|
|
||||||
"starter preset {:?} is unusable ({e:?}); skipping",
|
|
||||||
starter.name
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
library
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::{EditGraph, Scope};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn every_starter_names_parameters_this_build_actually_has() {
|
|
||||||
// The same guard the importer's table has, for the same reason: a
|
|
||||||
// renamed parameter must break the build rather than ship a preset
|
|
||||||
// that quietly does nothing.
|
|
||||||
let graph = EditGraph::default_chain();
|
|
||||||
let capabilities = graph.capabilities();
|
|
||||||
for starter in STARTERS {
|
|
||||||
for (op, param, _) in starter.params {
|
|
||||||
let capability = capabilities
|
|
||||||
.iter()
|
|
||||||
.find(|c| c.id.0 == *op)
|
|
||||||
.unwrap_or_else(|| panic!("{:?}: no operation {op:?}", starter.name));
|
|
||||||
assert!(
|
|
||||||
capability.params.iter().any(|p| p.id.0 == *param),
|
|
||||||
"{:?}: operation {op:?} has no parameter {param:?}",
|
|
||||||
starter.name
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn every_starter_actually_changes_something() {
|
|
||||||
// A preset that applies to nothing is worse than one fewer preset: it
|
|
||||||
// teaches the photographer that the list does not work.
|
|
||||||
for (name, preset) in library().iter() {
|
|
||||||
assert!(!preset.is_empty(), "{name} carries nothing");
|
|
||||||
|
|
||||||
let mut graph = EditGraph::default_chain();
|
|
||||||
preset.apply(&mut graph, Scope::adjustments());
|
|
||||||
assert_ne!(
|
|
||||||
Preset::capture(&graph),
|
|
||||||
Preset::default(),
|
|
||||||
"{name} left the graph at its defaults"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn no_starter_carries_a_crop() {
|
|
||||||
// These are looks, not compositions. One that re-framed every image it
|
|
||||||
// was applied to would be the exact accident `Scope`'s default exists
|
|
||||||
// to prevent.
|
|
||||||
for (name, preset) in library().iter() {
|
|
||||||
assert!(!preset.touches_framing(), "{name} carries framing");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_names_are_distinct() {
|
|
||||||
assert_eq!(library().len(), STARTERS.len());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_values_stay_inside_what_the_controls_accept() {
|
|
||||||
// Clamping happens on apply, so an out-of-range literal here would be
|
|
||||||
// silently trimmed and the preset would not be the one written.
|
|
||||||
let graph = EditGraph::default_chain();
|
|
||||||
for starter in STARTERS {
|
|
||||||
for (op, param, value) in starter.params {
|
|
||||||
let mut applied = EditGraph::default_chain();
|
|
||||||
let capability = graph
|
|
||||||
.capabilities()
|
|
||||||
.into_iter()
|
|
||||||
.find(|c| c.id.0 == *op)
|
|
||||||
.unwrap();
|
|
||||||
let descriptor = capability.params.iter().find(|p| p.id.0 == *param).unwrap();
|
|
||||||
applied.set_param(capability.id, descriptor.id, *value);
|
|
||||||
assert_eq!(
|
|
||||||
applied.param(capability.id, descriptor.id),
|
|
||||||
Some(*value),
|
|
||||||
"{}: {op}.{param} = {value} was clamped",
|
|
||||||
starter.name
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -235,7 +235,9 @@ mod tests {
|
|||||||
lut: vec![[0.5, 0.5, 0.5]; 8],
|
lut: vec![[0.5, 0.5, 0.5]; 8],
|
||||||
density_max: 2.0,
|
density_max: 2.0,
|
||||||
lut_size: 2,
|
lut_size: 2,
|
||||||
grain_particles: [0.0; 3],
|
grain_particles: [[0.0; 3]; crate::ops::film_sim::FORMAT_COUNT],
|
||||||
|
push_stations: vec![0.0],
|
||||||
|
paper: None,
|
||||||
grain_density_max: [2.0; 3],
|
grain_density_max: [2.0; 3],
|
||||||
grain_uniformity: 1.0,
|
grain_uniformity: 1.0,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
//! TRACES: FR-DSP-2 | NFR-RES-2
|
||||||
|
//! Cutting a render too large for one texture into tiles.
|
||||||
|
//!
|
||||||
|
//! The interactive path is not tiled, and on the evidence should not be
|
||||||
|
//! (`docs/dev/frame-budget.md`, TD-4): one fused dispatch over a viewport is
|
||||||
|
//! inside the frame budget, and a halo per tile nearly doubles the taps of a
|
||||||
|
//! wide kernel. What does not fit is a *file*. A 22927×8966 panorama has no
|
||||||
|
//! render target on a device whose textures stop at 16384, so its export, and
|
||||||
|
//! nothing else, is drawn a tile at a time.
|
||||||
|
//!
|
||||||
|
//! A tile is two rectangles in pixels of the framed output: the one rendered,
|
||||||
|
//! grown by the detail stage's reach ([`crate::ComposedDetail::reach`]) so
|
||||||
|
//! every kernel near its edge reads the pixels it would read untiled, and the
|
||||||
|
//! one kept, which is the tile proper. The kept rectangles cover the frame
|
||||||
|
//! exactly once.
|
||||||
|
//!
|
||||||
|
//! The rendered rectangle's origin is aligned to [`TILE_ALIGN`]. The detail
|
||||||
|
//! stage computes clarity's base on a reduced grid, and a tile starting half
|
||||||
|
//! way through a reduced texel would reduce different pixels together than
|
||||||
|
//! the untiled frame does, which shows as a faint seam.
|
||||||
|
|
||||||
|
/// A multiple of every reduced grid the detail stage uses, so a tile's
|
||||||
|
/// grids line up with the untiled frame's.
|
||||||
|
pub const TILE_ALIGN: u32 = 16;
|
||||||
|
|
||||||
|
/// One tile of a render: what to draw, and which part of it to keep.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Tile {
|
||||||
|
/// `[x, y, width, height]` in output pixels: the tile grown by the halo,
|
||||||
|
/// clamped to the frame. This is what is rendered.
|
||||||
|
pub grown: [u32; 4],
|
||||||
|
/// `[x, y, width, height]` in output pixels: the tile proper, which lies
|
||||||
|
/// inside `grown`. This is what is kept.
|
||||||
|
pub keep: [u32; 4],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Tile {
|
||||||
|
/// The rendered rectangle as a view on the frame, the rectangle
|
||||||
|
/// [`crate::Framing::set_view`] takes.
|
||||||
|
pub fn view(&self, frame: (u32, u32)) -> crate::framing::CropRect {
|
||||||
|
let (fw, fh) = (frame.0.max(1) as f32, frame.1.max(1) as f32);
|
||||||
|
crate::framing::CropRect {
|
||||||
|
x: self.grown[0] as f32 / fw,
|
||||||
|
y: self.grown[1] as f32 / fh,
|
||||||
|
width: self.grown[2] as f32 / fw,
|
||||||
|
height: self.grown[3] as f32 / fh,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the kept rectangle starts inside the rendered one.
|
||||||
|
pub fn keep_offset(&self) -> (u32, u32) {
|
||||||
|
(self.keep[0] - self.grown[0], self.keep[1] - self.grown[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cut a `frame`-sized render into tiles no larger than `max_edge` once
|
||||||
|
/// grown by `halo` on every side.
|
||||||
|
///
|
||||||
|
/// Row-major, top to bottom, so a caller writing the file as it goes gets
|
||||||
|
/// its bands in order. A frame that fits whole is one tile with no halo.
|
||||||
|
/// `None` when the halo leaves no room for a tile at all — a spot heal
|
||||||
|
/// cloning from across a frame wider than the device can hold is the case,
|
||||||
|
/// and it has to be refused rather than drawn with a seam.
|
||||||
|
pub fn plan(frame: (u32, u32), max_edge: u32, halo: u32) -> Option<Vec<Tile>> {
|
||||||
|
let (fw, fh) = (frame.0.max(1), frame.1.max(1));
|
||||||
|
if fw <= max_edge && fh <= max_edge {
|
||||||
|
return Some(vec![Tile {
|
||||||
|
grown: [0, 0, fw, fh],
|
||||||
|
keep: [0, 0, fw, fh],
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
// The halo, rounded up so a grown origin lands on the grid; the tile
|
||||||
|
// proper a multiple of it for the same reason.
|
||||||
|
let halo = halo.div_ceil(TILE_ALIGN) * TILE_ALIGN;
|
||||||
|
let room = max_edge.checked_sub(2 * halo)?;
|
||||||
|
let step = room / TILE_ALIGN * TILE_ALIGN;
|
||||||
|
if step == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut y = 0;
|
||||||
|
while y < fh {
|
||||||
|
let kh = step.min(fh - y);
|
||||||
|
let mut x = 0;
|
||||||
|
while x < fw {
|
||||||
|
let kw = step.min(fw - x);
|
||||||
|
let gx = x.saturating_sub(halo);
|
||||||
|
let gy = y.saturating_sub(halo);
|
||||||
|
let gx1 = (x + kw + halo).min(fw);
|
||||||
|
let gy1 = (y + kh + halo).min(fh);
|
||||||
|
out.push(Tile {
|
||||||
|
grown: [gx, gy, gx1 - gx, gy1 - gy],
|
||||||
|
keep: [x, y, kw, kh],
|
||||||
|
});
|
||||||
|
x += kw;
|
||||||
|
}
|
||||||
|
y += kh;
|
||||||
|
}
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_frame_that_fits_is_one_tile_with_no_halo() {
|
||||||
|
let tiles = plan((6000, 4000), 8192, 200).unwrap();
|
||||||
|
assert_eq!(tiles.len(), 1);
|
||||||
|
assert_eq!(tiles[0].grown, [0, 0, 6000, 4000]);
|
||||||
|
assert_eq!(tiles[0].keep, tiles[0].grown);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_kept_rectangles_cover_the_frame_exactly_once() {
|
||||||
|
// The panorama that started this, against a 16384 device with a
|
||||||
|
// clarity-sized halo.
|
||||||
|
let frame = (22927, 8966);
|
||||||
|
let tiles = plan(frame, 16384, 230).unwrap();
|
||||||
|
let mut covered = vec![0u8; (frame.0 * frame.1) as usize];
|
||||||
|
for t in &tiles {
|
||||||
|
let [x, y, w, h] = t.keep;
|
||||||
|
for yy in y..y + h {
|
||||||
|
for xx in x..x + w {
|
||||||
|
covered[(yy * frame.0 + xx) as usize] += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(covered.iter().all(|&c| c == 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_tile_fits_the_device_and_holds_its_halo() {
|
||||||
|
let frame = (22927, 8966);
|
||||||
|
let (max, halo) = (8192, 300);
|
||||||
|
for t in plan(frame, max, halo).unwrap() {
|
||||||
|
let [gx, gy, gw, gh] = t.grown;
|
||||||
|
let [kx, ky, kw, kh] = t.keep;
|
||||||
|
assert!(gw <= max && gh <= max, "{t:?} does not fit");
|
||||||
|
assert_eq!(gx % TILE_ALIGN, 0, "{t:?} starts off the grid");
|
||||||
|
assert_eq!(gy % TILE_ALIGN, 0, "{t:?} starts off the grid");
|
||||||
|
// The halo is there on every side, or the frame ends first — in
|
||||||
|
// which case the untiled render stops at the same edge.
|
||||||
|
assert!(gx == 0 || kx - gx >= halo);
|
||||||
|
assert!(gy == 0 || ky - gy >= halo);
|
||||||
|
assert!(gx + gw == frame.0 || gx + gw - (kx + kw) >= halo);
|
||||||
|
assert!(gy + gh == frame.1 || gy + gh - (ky + kh) >= halo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_halo_wider_than_the_device_is_refused() {
|
||||||
|
assert_eq!(plan((40000, 100), 16384, 9000), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
//! TRACES: FR-DEV-3j | FR-DEV-2
|
||||||
|
//! The view transform — the one stage that maps scene-linear colour to a
|
||||||
|
//! display range (D19, ARCH §6.14).
|
||||||
|
//!
|
||||||
|
//! # What it is
|
||||||
|
//!
|
||||||
|
//! A log-logistic sigmoid, per channel:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! f(x) = w · r / (1 + r), r = (x / k)^n
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! `n` is the contrast — the slope in log-log terms, before the shoulder
|
||||||
|
//! bends it. `k` and `w` are solved from two conditions rather than set:
|
||||||
|
//! scene middle grey lands on display middle grey, and the scene white the
|
||||||
|
//! photographer chose lands on display white. So the curve has a toe, a
|
||||||
|
//! midtone slope and a shoulder that approaches `w` — a hair above 1.0 —
|
||||||
|
//! without ever reaching it. Everything the shoulder has not reached by the
|
||||||
|
//! white point is clipped by the output transform, which is the last moment
|
||||||
|
//! and the only place a clip belongs.
|
||||||
|
//!
|
||||||
|
//! # Why per channel, and why the middle channel is put back
|
||||||
|
//!
|
||||||
|
//! Per channel is what makes a bright saturated colour desaturate as it
|
||||||
|
//! approaches white — a blown sky rolls toward white rather than toward a
|
||||||
|
//! saturated corner of the gamut, which is what film and every camera JPEG
|
||||||
|
//! do. It also bends hue: the three channels sit at different places on the
|
||||||
|
//! curve, so their ratios change, and an orange flame drifts toward yellow.
|
||||||
|
//! So after the curve the middle channel is moved back to where it sat
|
||||||
|
//! *between the other two* before it — the same fraction of the way from the
|
||||||
|
//! smallest to the largest. The smallest and largest keep what the curve gave
|
||||||
|
//! them, which keeps the desaturation; the hue, which is decided by that
|
||||||
|
//! fraction, survives. It is the "preserve hue" step of darktable's sigmoid,
|
||||||
|
//! at full strength.
|
||||||
|
//!
|
||||||
|
//! # Why these defaults
|
||||||
|
//!
|
||||||
|
//! [`SCENE_GREY`] is where the retired default base curve put middle grey
|
||||||
|
//! (FR-DEV-3e): linear sensor data from a correctly exposed frame has it
|
||||||
|
//! near 13% of saturation, and a camera JPEG shows it at 18%. The contrast
|
||||||
|
//! and white defaults were chosen against that same retired curve: at 1.4 and
|
||||||
|
//! 4 stops the midtones stay within a quarter of a stop of it between scene
|
||||||
|
//! 0.03 and 1.0, while a highlight a stop past sensor saturation still rolls
|
||||||
|
//! into white rather than stopping dead at it. The upper midtones come out a
|
||||||
|
//! little darker than the curve had them, which is the price of that
|
||||||
|
//! headroom and what the white slider is for.
|
||||||
|
|
||||||
|
/// Scene-linear middle grey: where the retired default curve placed it.
|
||||||
|
pub const SCENE_GREY: f32 = 0.13;
|
||||||
|
|
||||||
|
/// Display-linear middle grey — what a camera JPEG shows a grey card as.
|
||||||
|
pub const DISPLAY_GREY: f32 = 0.18;
|
||||||
|
|
||||||
|
/// The default contrast, the sigmoid's log-log slope parameter `n`.
|
||||||
|
pub const DEFAULT_CONTRAST: f32 = 1.4;
|
||||||
|
|
||||||
|
/// The default white point, in stops above [`SCENE_GREY`].
|
||||||
|
pub const DEFAULT_WHITE: f32 = 4.0;
|
||||||
|
|
||||||
|
/// The contrast range a photographer is offered.
|
||||||
|
pub const CONTRAST_RANGE: (f32, f32) = (1.0, 3.0);
|
||||||
|
|
||||||
|
/// The white point range, in stops above middle grey.
|
||||||
|
///
|
||||||
|
/// The floor is not taste. The two conditions `k` and `w` are solved from
|
||||||
|
/// have a solution only while `2^(white · n)` exceeds `1 / DISPLAY_GREY`,
|
||||||
|
/// and at the lowest contrast that needs `white` above about 2.47 stops.
|
||||||
|
pub const WHITE_RANGE: (f32, f32) = (2.5, 10.0);
|
||||||
|
|
||||||
|
/// The curve's three numbers, solved from the photographer's two.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct Sigmoid {
|
||||||
|
/// Contrast: the exponent.
|
||||||
|
pub n: f32,
|
||||||
|
/// `1 / k`, so the shader multiplies rather than divides.
|
||||||
|
pub inv_k: f32,
|
||||||
|
/// The asymptote the shoulder approaches, a little above 1.0.
|
||||||
|
pub w: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Sigmoid {
|
||||||
|
/// Solve the curve for a contrast and a white point in stops.
|
||||||
|
///
|
||||||
|
/// Out-of-range inputs are clamped to [`CONTRAST_RANGE`] and
|
||||||
|
/// [`WHITE_RANGE`] rather than trusted: they arrive from a sidecar, which
|
||||||
|
/// may have been written by a build with other limits, and outside them
|
||||||
|
/// the solution below divides by something that is no longer positive.
|
||||||
|
///
|
||||||
|
/// With `r_g` the value of `r` at scene grey and `q = 2^(white · n)`, the
|
||||||
|
/// two conditions `f(grey) = display grey` and `f(grey · 2^white) = 1`
|
||||||
|
/// are `w·r_g/(1+r_g) = g` and `w·q·r_g/(1+q·r_g) = 1`. Dividing one by
|
||||||
|
/// the other eliminates `w` and leaves `r_g = (g·q − 1) / (q·(1 − g))`.
|
||||||
|
pub fn new(contrast: f32, white: f32) -> Self {
|
||||||
|
let n = contrast.clamp(CONTRAST_RANGE.0, CONTRAST_RANGE.1) as f64;
|
||||||
|
let white = white.clamp(WHITE_RANGE.0, WHITE_RANGE.1) as f64;
|
||||||
|
let g = f64::from(DISPLAY_GREY);
|
||||||
|
let q = (white * n).exp2();
|
||||||
|
let r_grey = (g * q - 1.0) / (q * (1.0 - g));
|
||||||
|
let w = g * (1.0 + r_grey) / r_grey;
|
||||||
|
// r = (x / k)^n, and r at scene grey is r_grey, so
|
||||||
|
// k = grey / r_grey^(1/n).
|
||||||
|
let k = f64::from(SCENE_GREY) / r_grey.powf(1.0 / n);
|
||||||
|
Self {
|
||||||
|
n: n as f32,
|
||||||
|
inv_k: (1.0 / k) as f32,
|
||||||
|
w: w as f32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The default curve.
|
||||||
|
pub fn default_curve() -> Self {
|
||||||
|
Self::new(DEFAULT_CONTRAST, DEFAULT_WHITE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One channel through the curve. The CPU reference the shader is
|
||||||
|
/// tested against.
|
||||||
|
pub fn channel(&self, x: f32) -> f32 {
|
||||||
|
let r = (x.max(0.0) * self.inv_k).powf(self.n);
|
||||||
|
self.w * r / (1.0 + r)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A colour through the curve, with the middle channel put back between
|
||||||
|
/// the other two. See the module documentation.
|
||||||
|
pub fn apply(&self, c: [f32; 3]) -> [f32; 3] {
|
||||||
|
let x = c.map(|v| v.max(0.0));
|
||||||
|
let y = x.map(|v| self.channel(v));
|
||||||
|
let lo = x[0].min(x[1]).min(x[2]);
|
||||||
|
let hi = x[0].max(x[1]).max(x[2]);
|
||||||
|
if hi - lo <= 1e-9 {
|
||||||
|
return y;
|
||||||
|
}
|
||||||
|
let (y_lo, y_hi) = (self.channel(lo), self.channel(hi));
|
||||||
|
x.map(|v| y_lo + (y_hi - y_lo) * (v - lo) / (hi - lo))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The WGSL twin of [`Sigmoid::apply`], as a helper function.
|
||||||
|
pub const VIEW_SIGMOID_WGSL: &str = "\
|
||||||
|
// The view transform (FR-DEV-3j): a log-logistic sigmoid per channel, then the
|
||||||
|
// middle channel put back between the other two so that the hue survives the
|
||||||
|
// shoulder. See `dr_pipeline::view` for the derivation and the defaults.
|
||||||
|
fn view_sigmoid(c: vec3<f32>, n: f32, inv_k: f32, w: f32) -> vec3<f32> {
|
||||||
|
// Negative components are colours outside the working primaries. They are
|
||||||
|
// floored here, at the last stage, which is the one place a gamut clip
|
||||||
|
// belongs.
|
||||||
|
let x = max(c, vec3<f32>(0.0));
|
||||||
|
let lo = min(x.r, min(x.g, x.b));
|
||||||
|
let hi = max(x.r, max(x.g, x.b));
|
||||||
|
let r_lo = pow(lo * inv_k, n);
|
||||||
|
let r_hi = pow(hi * inv_k, n);
|
||||||
|
let y_lo = w * r_lo / (1.0 + r_lo);
|
||||||
|
let y_hi = w * r_hi / (1.0 + r_hi);
|
||||||
|
// Each channel's place between the smallest and the largest. A neutral
|
||||||
|
// has no spread, and every channel then takes the one value there is.
|
||||||
|
let spread = hi - lo;
|
||||||
|
let t = select((x - vec3<f32>(lo)) / max(spread, 1e-9), vec3<f32>(0.0), spread <= 1e-9);
|
||||||
|
return vec3<f32>(y_lo) + (y_hi - y_lo) * t;
|
||||||
|
}
|
||||||
|
";
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The retired default base curve, for the acceptance comparison: five
|
||||||
|
/// points through the unit square, sampled here by straight lines between
|
||||||
|
/// them in log-log terms — close enough to the monotone spline that drew
|
||||||
|
/// it for a tolerance measured in quarters of a stop. Below its second
|
||||||
|
/// point it was a straight line from the origin.
|
||||||
|
fn retired_default(x: f32) -> f32 {
|
||||||
|
const P: [(f32, f32); 4] = [(0.04, 0.043), (0.13, 0.175), (0.45, 0.690), (1.0, 1.0)];
|
||||||
|
if x < 0.04 {
|
||||||
|
return x * (0.043 / 0.04);
|
||||||
|
}
|
||||||
|
let x = x.min(1.0);
|
||||||
|
let i = P.windows(2).position(|w| x <= w[1].0).unwrap_or(2);
|
||||||
|
let ((x0, y0), (x1, y1)) = (P[i], P[i + 1]);
|
||||||
|
let t = (x.ln() - x0.ln()) / (x1.ln() - x0.ln());
|
||||||
|
(y0.ln() + t * (y1.ln() - y0.ln())).exp()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn middle_grey_lands_on_display_grey() {
|
||||||
|
// TRACES: FR-DEV-3j
|
||||||
|
for (contrast, white) in [(1.0, 3.0), (1.4, 4.0), (2.5, 8.0), (3.0, 10.0)] {
|
||||||
|
let s = Sigmoid::new(contrast, white);
|
||||||
|
let got = s.channel(SCENE_GREY);
|
||||||
|
assert!(
|
||||||
|
(got - DISPLAY_GREY).abs() < 0.01,
|
||||||
|
"contrast {contrast}, white {white}: grey went to {got}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_white_point_reaches_display_white() {
|
||||||
|
// TRACES: FR-DEV-3j
|
||||||
|
// The whole meaning of the slider: the scene value it names is where
|
||||||
|
// the picture reaches white, and not before.
|
||||||
|
for (contrast, white) in [(1.0, 3.0), (1.4, 4.0), (2.5, 8.0)] {
|
||||||
|
let s = Sigmoid::new(contrast, white);
|
||||||
|
let at = SCENE_GREY * white.exp2();
|
||||||
|
assert!((s.channel(at) - 1.0).abs() < 1e-4, "{}", s.channel(at));
|
||||||
|
assert!(s.channel(at * 0.9) < 1.0);
|
||||||
|
assert!(s.w > 1.0, "the shoulder must approach a value above white");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_curve_is_monotone_and_keeps_going_past_one() {
|
||||||
|
// TRACES: FR-DEV-3j | FR-DEV-2
|
||||||
|
// What the base curve got wrong: it was flat past 1.0, so every
|
||||||
|
// recovered highlight left it as the same number.
|
||||||
|
let s = Sigmoid::default_curve();
|
||||||
|
let mut last = -1.0;
|
||||||
|
for i in 0..=2000 {
|
||||||
|
let x = i as f32 * 0.004;
|
||||||
|
let y = s.channel(x);
|
||||||
|
assert!(y > last || (x == 0.0 && y == 0.0), "not increasing at {x}");
|
||||||
|
last = y;
|
||||||
|
}
|
||||||
|
assert!(s.channel(2.0) > s.channel(1.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_default_stays_close_to_the_retired_curve() {
|
||||||
|
// TRACES: FR-DEV-3j | FR-DEV-3e
|
||||||
|
// D19's promise to every existing photograph: the midtones do not
|
||||||
|
// move by more than a third of a stop.
|
||||||
|
let s = Sigmoid::default_curve();
|
||||||
|
let mut x = 0.03_f32;
|
||||||
|
while x <= 1.0 {
|
||||||
|
let ev = (s.channel(x) / retired_default(x)).log2();
|
||||||
|
assert!(ev.abs() < 0.3, "at scene {x} the default moved {ev:+.2} EV");
|
||||||
|
x *= 1.1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_neutral_stays_neutral() {
|
||||||
|
// TRACES: FR-DEV-3j
|
||||||
|
let s = Sigmoid::default_curve();
|
||||||
|
for v in [0.0, 0.01, 0.13, 1.0, 7.0] {
|
||||||
|
let [r, g, b] = s.apply([v, v, v]);
|
||||||
|
assert_eq!(r, g);
|
||||||
|
assert_eq!(g, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_hue_survives_the_shoulder() {
|
||||||
|
// TRACES: FR-DEV-3j
|
||||||
|
// The middle channel's place between the other two is what decides
|
||||||
|
// the hue. Without the correction an orange at the shoulder drifts
|
||||||
|
// toward yellow as the red channel saturates first.
|
||||||
|
let s = Sigmoid::default_curve();
|
||||||
|
let orange = [2.0, 0.8, 0.1];
|
||||||
|
let out = s.apply(orange);
|
||||||
|
let before = (orange[1] - orange[2]) / (orange[0] - orange[2]);
|
||||||
|
let after = (out[1] - out[2]) / (out[0] - out[2]);
|
||||||
|
assert!((before - after).abs() < 1e-5, "{before} became {after}");
|
||||||
|
// And the extremes keep what the curve gave them — the desaturation
|
||||||
|
// toward white is the point of working per channel.
|
||||||
|
assert!((out[0] - s.channel(2.0)).abs() < 1e-6);
|
||||||
|
assert!((out[2] - s.channel(0.1)).abs() < 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn out_of_range_settings_are_clamped_not_trusted() {
|
||||||
|
// A sidecar from another build may carry anything, and outside the
|
||||||
|
// range the solution divides by a value that is no longer positive.
|
||||||
|
let s = Sigmoid::new(0.0, 0.0);
|
||||||
|
assert!(s.n.is_finite() && s.inv_k.is_finite() && s.w.is_finite());
|
||||||
|
assert_eq!(s, Sigmoid::new(CONTRAST_RANGE.0, WHITE_RANGE.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user