Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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
|
||||||
|
|
||||||
|
|||||||
+12
-2
@@ -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
+27
-25
@@ -1265,7 +1265,7 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darkroom-android"
|
name = "darkroom-android"
|
||||||
version = "0.18.0"
|
version = "0.18.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
@@ -1278,7 +1278,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darkroom-desktop"
|
name = "darkroom-desktop"
|
||||||
version = "0.18.0"
|
version = "0.18.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
@@ -1454,7 +1454,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-bench"
|
name = "dr-bench"
|
||||||
version = "0.18.0"
|
version = "0.18.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"dr-catalog",
|
"dr-catalog",
|
||||||
@@ -1471,7 +1471,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-catalog"
|
name = "dr-catalog"
|
||||||
version = "0.18.0"
|
version = "0.18.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-face",
|
"dr-face",
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
@@ -1486,7 +1486,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-decode"
|
name = "dr-decode"
|
||||||
version = "0.18.0"
|
version = "0.18.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-types",
|
"dr-types",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
@@ -1500,7 +1500,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-export"
|
name = "dr-export"
|
||||||
version = "0.18.0"
|
version = "0.18.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-decode",
|
"dr-decode",
|
||||||
"dr-gpu",
|
"dr-gpu",
|
||||||
@@ -1519,7 +1519,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-face"
|
name = "dr-face"
|
||||||
version = "0.18.0"
|
version = "0.18.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-inference-engine",
|
"dr-inference-engine",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
@@ -1532,7 +1532,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-film"
|
name = "dr-film"
|
||||||
version = "0.18.0"
|
version = "0.18.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -1541,7 +1541,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-gpu"
|
name = "dr-gpu"
|
||||||
version = "0.18.0"
|
version = "0.18.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"dr-decode",
|
"dr-decode",
|
||||||
@@ -1559,7 +1559,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-inference-engine"
|
name = "dr-inference-engine"
|
||||||
version = "0.18.0"
|
version = "0.18.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"env_logger",
|
"env_logger",
|
||||||
"libloading",
|
"libloading",
|
||||||
@@ -1574,7 +1574,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-ingest"
|
name = "dr-ingest"
|
||||||
version = "0.18.0"
|
version = "0.18.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
"dr-types",
|
"dr-types",
|
||||||
@@ -1586,7 +1586,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-lens"
|
name = "dr-lens"
|
||||||
version = "0.18.0"
|
version = "0.18.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"lensfun",
|
"lensfun",
|
||||||
"log",
|
"log",
|
||||||
@@ -1594,7 +1594,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-pano"
|
name = "dr-pano"
|
||||||
version = "0.18.0"
|
version = "0.18.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-decode",
|
"dr-decode",
|
||||||
"dr-inference-engine",
|
"dr-inference-engine",
|
||||||
@@ -1608,7 +1608,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-pipeline"
|
name = "dr-pipeline"
|
||||||
version = "0.18.0"
|
version = "0.18.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-types",
|
"dr-types",
|
||||||
"log",
|
"log",
|
||||||
@@ -1617,7 +1617,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-plat"
|
name = "dr-plat"
|
||||||
version = "0.18.0"
|
version = "0.18.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android-native-keyring-store",
|
"android-native-keyring-store",
|
||||||
"dr-types",
|
"dr-types",
|
||||||
@@ -1633,7 +1633,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-preset-xmp"
|
name = "dr-preset-xmp"
|
||||||
version = "0.18.0"
|
version = "0.18.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-pipeline",
|
"dr-pipeline",
|
||||||
"log",
|
"log",
|
||||||
@@ -1643,7 +1643,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-segment"
|
name = "dr-segment"
|
||||||
version = "0.18.0"
|
version = "0.18.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-inference-engine",
|
"dr-inference-engine",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
@@ -1656,7 +1656,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-sync"
|
name = "dr-sync"
|
||||||
version = "0.18.0"
|
version = "0.18.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
@@ -1670,7 +1670,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-sync-folder"
|
name = "dr-sync-folder"
|
||||||
version = "0.18.0"
|
version = "0.18.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"dr-sync",
|
"dr-sync",
|
||||||
@@ -1682,7 +1682,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-sync-nextcloud"
|
name = "dr-sync-nextcloud"
|
||||||
version = "0.18.0"
|
version = "0.18.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"dr-decode",
|
"dr-decode",
|
||||||
@@ -1704,7 +1704,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-thumbs"
|
name = "dr-thumbs"
|
||||||
version = "0.18.0"
|
version = "0.18.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-types",
|
"dr-types",
|
||||||
"jpeg-encoder",
|
"jpeg-encoder",
|
||||||
@@ -1716,7 +1716,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-types"
|
name = "dr-types"
|
||||||
version = "0.18.0"
|
version = "0.18.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -1725,7 +1725,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-ui"
|
name = "dr-ui"
|
||||||
version = "0.18.0"
|
version = "0.18.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -1773,7 +1773,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-xmp"
|
name = "dr-xmp"
|
||||||
version = "0.18.0"
|
version = "0.18.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-types",
|
"dr-types",
|
||||||
"log",
|
"log",
|
||||||
@@ -7109,12 +7109,14 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "traceability"
|
name = "traceability"
|
||||||
version = "0.18.0"
|
version = "0.18.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"proc-macro2",
|
||||||
"pulldown-cmark",
|
"pulldown-cmark",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
+7
-1
@@ -32,7 +32,7 @@ members = [
|
|||||||
exclude = ["third_party"]
|
exclude = ["third_party"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.18.0"
|
version = "0.18.2"
|
||||||
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.
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ capture sharpening, noise reduction, lens correction, spectral film
|
|||||||
simulation. Crop, straighten and correct converging verticals, spot repair,
|
simulation. Crop, straighten and correct converging verticals, spot repair,
|
||||||
and local adjustments over masks the model draws — click a subject or a
|
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
|
category, then paint, subtract a gradient or keep only where two selections
|
||||||
agree, grow or shrink the edge. Focus peaking and a raw histogram for judging
|
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, with a collection shipped in the application —
|
what is recoverable. Presets, with a collection shipped in the application —
|
||||||
everyday corrections, and a look for each measured colour, cinema and
|
everyday corrections, and a look for each measured colour, cinema and
|
||||||
black-and-white stock — and Lightroom presets imported as looks that leave a
|
black-and-white stock — and Lightroom presets imported as looks that leave a
|
||||||
@@ -93,7 +96,7 @@ controls, its place in the chain and its tests.
|
|||||||
|
|
||||||
## Where it stands
|
## Where it stands
|
||||||
|
|
||||||
**0.18.0**, twenty-six tagged releases in. 192 numbered requirements in
|
**0.18.2**, twenty-eight tagged releases in. 192 numbered requirements in
|
||||||
scope, 84% of them claimed by code and [traced to it](docs/dev/traceability.md);
|
scope, 84% 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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -798,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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+22
-6
@@ -41,18 +41,34 @@ 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.
|
Per pixel that is a matrix multiply, a handful of curve taps and one texture
|
||||||
Splitting 2 from 3, rather than baking one LUT over exposure, is measured
|
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
|
||||||
|
|||||||
+416
-85
@@ -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,41 +150,124 @@ 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);
|
||||||
|
if self.curve_rows < 2 {
|
||||||
|
return lo;
|
||||||
|
}
|
||||||
|
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_curve_row(&self, log_exposure: [f32; 3], row: usize) -> [f32; 3] {
|
||||||
|
let samples = self.curves.len() / self.curve_rows;
|
||||||
|
let curve = &self.curves[row * samples..(row + 1) * samples];
|
||||||
|
sample_curve(curve, self.curve_log_min, self.curve_log_max, log_exposure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Three curves sampled uniformly over `[log_min, log_max]`, read at a log
|
||||||
|
/// exposure per layer. Clamped at both ends, as the shader's is.
|
||||||
|
fn sample_curve(curve: &[[f32; 3]], log_min: f32, log_max: f32, at: [f32; 3]) -> [f32; 3] {
|
||||||
|
let last = curve.len() - 1;
|
||||||
|
let span = log_max - log_min;
|
||||||
let mut out = [0.0f32; 3];
|
let mut out = [0.0f32; 3];
|
||||||
for (c, slot) in out.iter_mut().enumerate() {
|
for (c, slot) in out.iter_mut().enumerate() {
|
||||||
let t = ((log_exposure[c] - self.curve_log_min) / span).clamp(0.0, 1.0) * last as f32;
|
let t = ((at[c] - log_min) / span).clamp(0.0, 1.0) * last as f32;
|
||||||
let i = (t.floor() as usize).min(last - 1);
|
let i = (t.floor() as usize).min(last - 1);
|
||||||
let f = t - i as f32;
|
let f = t - i as f32;
|
||||||
*slot = self.curves[i][c] * (1.0 - f) + self.curves[i + 1][c] * f;
|
*slot = curve[i][c] * (1.0 - f) + curve[i + 1][c] * f;
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sample_lut(&self, density: [f32; 3]) -> [f32; 3] {
|
/// A cube of `n³` triples over `[0, max]` per axis, red fastest, read
|
||||||
let n = self.lut_size;
|
/// trilinearly.
|
||||||
|
fn sample_cube(lut: &[[f32; 3]], n: usize, density: [f32; 3], max: f32) -> [f32; 3] {
|
||||||
let mut base = [0usize; 3];
|
let mut base = [0usize; 3];
|
||||||
let mut frac = [0f32; 3];
|
let mut frac = [0f32; 3];
|
||||||
for c in 0..3 {
|
for c in 0..3 {
|
||||||
let t = (density[c] / self.density_max).clamp(0.0, 1.0) * (n - 1) as f32;
|
let t = (density[c] / max).clamp(0.0, 1.0) * (n - 1) as f32;
|
||||||
base[c] = (t.floor() as usize).min(n - 2);
|
base[c] = (t.floor() as usize).min(n - 2);
|
||||||
frac[c] = t - base[c] as f32;
|
frac[c] = t - base[c] as f32;
|
||||||
}
|
}
|
||||||
@@ -153,7 +278,7 @@ impl Baked {
|
|||||||
let w = if dx == 0 { 1.0 - frac[0] } else { frac[0] }
|
let w = if dx == 0 { 1.0 - frac[0] } else { frac[0] }
|
||||||
* if dy == 0 { 1.0 - frac[1] } else { frac[1] }
|
* if dy == 0 { 1.0 - frac[1] } else { frac[1] }
|
||||||
* if dz == 0 { 1.0 - frac[2] } else { frac[2] };
|
* if dz == 0 { 1.0 - frac[2] } else { frac[2] };
|
||||||
let e = self.lut[((base[2] + dz) * n + base[1] + dy) * n + base[0] + dx];
|
let e = lut[((base[2] + dz) * n + base[1] + dy) * n + base[0] + dx];
|
||||||
for c in 0..3 {
|
for c in 0..3 {
|
||||||
out[c] += w * e[c];
|
out[c] += w * e[c];
|
||||||
}
|
}
|
||||||
@@ -162,7 +287,6 @@ impl Baked {
|
|||||||
}
|
}
|
||||||
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 {
|
||||||
|
let rows = film.development_curves.len().min(MAX_CURVE_ROWS);
|
||||||
|
(
|
||||||
|
film.development_curves[..rows]
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(|row| row.iter())
|
.flat_map(|c| resample(c))
|
||||||
.fold(0.0f32, |a, &b| a.max(b))
|
.collect(),
|
||||||
.max(1e-3);
|
film.development_times[..rows]
|
||||||
|
.iter()
|
||||||
let viewing = match recipe.print {
|
.map(|t| 2.0 * (t / film.development_normal).log2())
|
||||||
Some(paper) => Viewing::new(&paper.viewing_illuminant),
|
.collect(),
|
||||||
None => Viewing::new(&film.viewing_illuminant),
|
)
|
||||||
|
} 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.
|
||||||
|
let cube = |max: f32, f: &dyn Fn([f32; 3]) -> [f32; 3]| {
|
||||||
|
let mut out = Vec::with_capacity(n * n * n);
|
||||||
for b in 0..n {
|
for b in 0..n {
|
||||||
for g in 0..n {
|
for g in 0..n {
|
||||||
for r in 0..n {
|
for r in 0..n {
|
||||||
let density = [
|
let step = max / (n - 1) as f32;
|
||||||
density_max * r as f32 / (n - 1) as f32,
|
out.push(f([r as f32 * step, g as f32 * step, b as f32 * step]));
|
||||||
density_max * g as f32 / (n - 1) as f32,
|
|
||||||
density_max * b as f32 / (n - 1) as f32,
|
|
||||||
];
|
|
||||||
lut.push(match recipe.print.zip(balance) {
|
|
||||||
Some((paper, offsets)) => {
|
|
||||||
let raw = paper_exposure(film, paper, density);
|
|
||||||
let mut log_exposure = [0.0f32; 3];
|
|
||||||
for (l, slot) in log_exposure.iter_mut().enumerate() {
|
|
||||||
*slot = (raw[l] + 1e-10).log10() + offsets[l];
|
|
||||||
}
|
}
|
||||||
let paper_density = paper.density_at(log_exposure);
|
|
||||||
viewing.to_srgb(&paper.transmittance(paper_density))
|
|
||||||
}
|
}
|
||||||
None => viewing.to_srgb(&film.transmittance(density)),
|
}
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -382,9 +382,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 +445,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 +466,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),
|
||||||
);
|
);
|
||||||
@@ -2303,7 +2321,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,
|
||||||
}
|
}
|
||||||
|
|||||||
+255
-1
@@ -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
|
||||||
@@ -437,6 +463,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 +555,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 +590,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 +634,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 +730,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 +749,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"),
|
||||||
@@ -960,6 +1066,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 +1230,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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,9 +16,12 @@
|
|||||||
//! the CPU model.
|
//! the CPU model.
|
||||||
|
|
||||||
use dr_decode::{BaseCurve, CfaPattern, CropRect, RawImage};
|
use dr_decode::{BaseCurve, 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;
|
||||||
@@ -77,15 +80,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 +260,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,142 @@
|
|||||||
|
//! 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::{BaseCurve, 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]),
|
||||||
|
base_curve: BaseCurve::IDENTITY,
|
||||||
|
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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,42 +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) {
|
|
||||||
// Blend toward a smoothstep, which is the S.
|
|
||||||
let s = clamped * clamped * (3.0 - 2.0 * clamped);
|
let s = clamped * clamped * (3.0 - 2.0 * clamped);
|
||||||
return mix(clamped, s, amount);
|
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: |
|
||||||
|
if (amount < 0.0) {
|
||||||
|
// **Flattening mixes toward middle grey; it does not scale.**
|
||||||
|
//
|
||||||
|
// Every tone moves the same fraction of the way to 0.18, which at -1
|
||||||
|
// collapses the picture to grey — the meaningful limit of 'no contrast'.
|
||||||
|
// In luminance this is exactly what the ratio form below would compute,
|
||||||
|
// but the ratio form reaches it by multiplying: a pixel at 0.001 has to
|
||||||
|
// be lifted to 0.09, a gain of ninety, and in the deepest shadows the
|
||||||
|
// channels are sensor noise, not a colour. After white balance the red
|
||||||
|
// and blue noise sits above the green (their multipliers are nearly
|
||||||
|
// twice its), so ninety times that noise is magenta — every black in the
|
||||||
|
// 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
|
||||||
|
// in the camera's space, where that is what neutral is.
|
||||||
|
c = mix(c, vec3<f32>(0.18), -amount);
|
||||||
|
} else {
|
||||||
let luma = luminance(c);
|
let luma = luminance(c);
|
||||||
if (luma > 0.0001) {
|
// 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
|
// Work on luminance and rescale the colour by the ratio, rather than
|
||||||
// curving each channel independently. Per-channel contrast shifts hue
|
// curving each channel independently. Per-channel contrast shifts hue
|
||||||
// wherever the channels differ — the classic symptom being skies going
|
// wherever the channels differ — the classic symptom being skies going
|
||||||
// cyan as contrast rises.
|
// 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
|
// MIDDLE_GREY is 0.18: the linear value the eye reads as mid-tone.
|
||||||
// curve operates on luma/(2*0.18) so that middle grey lands at the
|
// The curve operates on luma/(2*0.18) so that middle grey lands at
|
||||||
// curve's own 0.5 pivot.
|
// the curve's own 0.5 pivot.
|
||||||
let pos = clamp(luma / 0.36, 0.0, 1.0);
|
let pos = luma / 0.36;
|
||||||
let curved = contrast_curve(pos, amount);
|
let curved = contrast_curve(pos, amount);
|
||||||
// Not `target`: that is a WGSL reserved keyword, and using it produces a
|
// 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.
|
// parse error in generated code rather than anywhere a reader would look.
|
||||||
let curved_luma = curved * 0.36;
|
let curved_luma = curved * 0.36;
|
||||||
c = apply_tone_gain(c, curved_luma / luma);
|
c = apply_tone_gain(c, curved_luma / luma);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
c = max(c, vec3<f32>(0.0));
|
c = max(c, vec3<f32>(0.0));
|
||||||
|
|
||||||
tests:
|
tests:
|
||||||
@@ -104,6 +125,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
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -132,7 +132,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,
|
||||||
},
|
},
|
||||||
|
|||||||
+399
-81
@@ -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 base curve
|
||||||
/// curve and the camera matrix and arrive as some other colour, and a
|
/// and the camera matrix 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,13 +2278,15 @@ 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();
|
||||||
|
if !dst.blends_settings() {
|
||||||
|
fragment = dst.wgsl_body();
|
||||||
for u in &op_uniforms {
|
for u in &op_uniforms {
|
||||||
fragment = crate::operation::rewrite_uniform(
|
fragment = crate::operation::rewrite_uniform(
|
||||||
&fragment,
|
&fragment,
|
||||||
@@ -2172,20 +2294,13 @@ pub(crate) fn compose_layers_revealing(stack: &MaskStack, reveal: Option<&Reveal
|
|||||||
&format!("u.{op_prefix}_{}", u.name),
|
&format!("u.{op_prefix}_{}", u.name),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = writeln!(out.body, " // ---- {id} ----");
|
|
||||||
let _ = writeln!(out.body, " {{");
|
|
||||||
for line in fragment.lines() {
|
|
||||||
let _ = writeln!(out.body, " {line}");
|
|
||||||
}
|
}
|
||||||
let _ = writeln!(out.body, " }}");
|
out.ops.push(LocalOp {
|
||||||
|
op: id,
|
||||||
|
slot,
|
||||||
|
fragment,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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!(
|
||||||
|
compose_layers_revealing(&stack, Some(&reveal), &ops::chain())
|
||||||
.reveal
|
.reveal
|
||||||
.is_empty());
|
.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]
|
||||||
|
|||||||
@@ -307,6 +307,32 @@ pub trait Operation: Send + Sync {
|
|||||||
/// shape it should take.
|
/// shape it should take.
|
||||||
fn set_film_tables(&mut self, _tables: Option<&crate::ops::film_sim::FilmTables>) {}
|
fn set_film_tables(&mut self, _tables: Option<&crate::ops::film_sim::FilmTables>) {}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3f
|
||||||
|
/// The stock's tables this operation holds, for a mask layer's copy of it.
|
||||||
|
///
|
||||||
|
/// The other half of [`Self::set_film_tables`]. A layer holds offsets,
|
||||||
|
/// never a stock of its own — the photograph is made on one film — so the
|
||||||
|
/// composer hands the global operation's tables to the layer's combined
|
||||||
|
/// copy through this pair.
|
||||||
|
fn film_tables(&self) -> Option<&crate::ops::film_sim::FilmTables> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3 | FR-DEV-3f
|
||||||
|
/// Whether a mask layer's version of this operation is blended as
|
||||||
|
/// *settings* rather than as a result.
|
||||||
|
///
|
||||||
|
/// Default `false`: each layer's version runs on the same input as the
|
||||||
|
/// global one and the results are blended by weight, which is right for an
|
||||||
|
/// adjustment. An operation that answers `true` has every uniform linear
|
||||||
|
/// in what it controls, so the composer can blend the uniforms instead —
|
||||||
|
/// the weighted average of every overlapping layer's settings, the global
|
||||||
|
/// setting taking whatever weight the layers leave — and run the fragment
|
||||||
|
/// once. See `local_settings_block`.
|
||||||
|
fn blends_settings(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
/// TRACES: FR-DEV-3e | FR-DEV-3f
|
/// TRACES: FR-DEV-3e | FR-DEV-3f
|
||||||
/// Whether this operation *is* the rendering, rather than an adjustment to
|
/// Whether this operation *is* the rendering, rather than an adjustment to
|
||||||
/// one.
|
/// one.
|
||||||
@@ -597,10 +623,11 @@ pub fn compose_with_framing(
|
|||||||
/// TRACES: FR-DEV-3
|
/// TRACES: FR-DEV-3
|
||||||
/// Compose the global chain, the framing, and the local adjustments.
|
/// Compose the global chain, the framing, and the local adjustments.
|
||||||
///
|
///
|
||||||
/// Mask layers are emitted **after** every global operation and before the
|
/// A mask layer's settings are **offsets to the global ones**, applied at each
|
||||||
/// conversion out of camera space, so a local exposure acts on the tones the
|
/// operation's own place in the chain: global contrast −30 and a face at −20
|
||||||
/// global chain settled on — which is what a photographer means by "and then
|
/// is contrast −50 on the face, run where contrast runs. They once ran as a
|
||||||
/// lift the shadows on her face".
|
/// second chain after every global operation, which compounded the two edits
|
||||||
|
/// in ways neither slider showed — see `mask::offset_onto`.
|
||||||
///
|
///
|
||||||
/// The fused-dispatch property survives: three global adjustments and two
|
/// The fused-dispatch property survives: three global adjustments and two
|
||||||
/// masked ones are still one shader, one read and one write. The masks
|
/// masked ones are still one shader, one read and one write. The masks
|
||||||
@@ -858,13 +885,36 @@ fn compose_inner(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for op in &active {
|
// TRACES: FR-DEV-3
|
||||||
|
// The local adjustments. Composed first because they are threaded through
|
||||||
|
// the loop below rather than appended after it: a layer's settings are
|
||||||
|
// offsets to the global ones, applied at each operation's own place in
|
||||||
|
// the chain (see `mask::offset_onto` for why). The weights are sampled
|
||||||
|
// here, once per pixel, ahead of every operation that reads them.
|
||||||
|
let layers = crate::mask::compose_layers_revealing(masks, reveal, ops);
|
||||||
|
body.push_str(&layers.weights);
|
||||||
|
|
||||||
|
// Every point operation that is active globally *or* in some layer. One
|
||||||
|
// that only a layer moved still runs here, at its place in the chain,
|
||||||
|
// with nothing on the global side of its blend.
|
||||||
|
for op in ops
|
||||||
|
.iter()
|
||||||
|
.map(|o| o.as_ref())
|
||||||
|
.filter(|o| o.detail().is_none())
|
||||||
|
{
|
||||||
let id = op.descriptor().id.0;
|
let id = op.descriptor().id.0;
|
||||||
|
let local: Vec<&crate::mask::LocalOp> = layers.ops.iter().filter(|l| l.op == id).collect();
|
||||||
|
if !op.is_active() && local.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let prefix = sanitise(id);
|
let prefix = sanitise(id);
|
||||||
|
|
||||||
// Each op's uniforms are prefixed, so two operations may both declare
|
let mut fragment = String::new();
|
||||||
// a field called `amount` without colliding.
|
let mut op_uniforms = Vec::new();
|
||||||
let op_uniforms = op.uniforms();
|
if op.is_active() {
|
||||||
|
// Each op's uniforms are prefixed, so two operations may both
|
||||||
|
// declare a field called `amount` without colliding.
|
||||||
|
op_uniforms = op.uniforms();
|
||||||
if !op_uniforms.is_empty() {
|
if !op_uniforms.is_empty() {
|
||||||
let _ = writeln!(uniform_fields, " // {id}");
|
let _ = writeln!(uniform_fields, " // {id}");
|
||||||
}
|
}
|
||||||
@@ -878,28 +928,43 @@ fn compose_inner(
|
|||||||
helpers.push(*h);
|
helpers.push(*h);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
fragment = op.wgsl_body();
|
||||||
// Rewrite bare uniform names to their prefixed struct fields, so a
|
|
||||||
// fragment is written without knowing about any other operation.
|
|
||||||
let mut fragment = op.wgsl_body();
|
|
||||||
for u in &op_uniforms {
|
|
||||||
fragment = rewrite_uniform(&fragment, u.name, &format!("u.{prefix}_{}", u.name));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = writeln!(body, "\n // ---- {id} ----");
|
let _ = writeln!(body, "\n // ---- {id} ----");
|
||||||
let _ = writeln!(body, " {{");
|
if op.blends_settings() && !local.is_empty() {
|
||||||
for line in fragment.lines() {
|
// TRACES: FR-DEV-3f
|
||||||
let _ = writeln!(body, " {line}");
|
body.push_str(&local_settings_block(
|
||||||
|
&fragment,
|
||||||
|
&prefix,
|
||||||
|
&op_uniforms,
|
||||||
|
&local,
|
||||||
|
));
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
let _ = writeln!(body, " }}");
|
// Rewrite bare uniform names to their prefixed struct fields, so a
|
||||||
|
// fragment is written without knowing about any other operation.
|
||||||
|
for u in &op_uniforms {
|
||||||
|
fragment = rewrite_uniform(&fragment, u.name, &format!("u.{prefix}_{}", u.name));
|
||||||
|
}
|
||||||
|
body.push_str(&local_block(&fragment, &local));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A layer's operation the global chain does not hold at all. Not a case
|
||||||
|
// any editor produces — both chains come from `ops::chain` — but a layer
|
||||||
|
// must not lose an edit because a caller composed a shorter chain.
|
||||||
|
let mut orphans: Vec<&'static str> = Vec::new();
|
||||||
|
for l in &layers.ops {
|
||||||
|
if !orphans.contains(&l.op) && !ops.iter().any(|o| o.descriptor().id.0 == l.op) {
|
||||||
|
orphans.push(l.op);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id in orphans {
|
||||||
|
let local: Vec<&crate::mask::LocalOp> = layers.ops.iter().filter(|l| l.op == id).collect();
|
||||||
|
let _ = writeln!(body, "\n // ---- {id} (local only) ----");
|
||||||
|
body.push_str(&local_block("", &local));
|
||||||
}
|
}
|
||||||
|
|
||||||
// The local adjustments, after every global one: a masked exposure should
|
|
||||||
// act on the tones the global chain arrived at, not on the ones it started
|
|
||||||
// from. Their uniforms follow the global ops' in the block for the same
|
|
||||||
// reason those follow framing's — slot order is emission order, and
|
|
||||||
// nothing addresses a slot by number.
|
|
||||||
let layers = crate::mask::compose_layers_revealing(masks, reveal);
|
|
||||||
// TRACES: FR-DEV-19c
|
// TRACES: FR-DEV-19c
|
||||||
// Held apart from the body, because it belongs after the output transform
|
// Held apart from the body, because it belongs after the output transform
|
||||||
// rather than among the operations — see `mask::LayerShader::reveal`.
|
// rather than among the operations — see `mask::LayerShader::reveal`.
|
||||||
@@ -908,7 +973,6 @@ fn compose_inner(
|
|||||||
let reveal_block = layers.reveal.clone();
|
let reveal_block = layers.reveal.clone();
|
||||||
uniform_fields.push_str(&layers.uniform_fields);
|
uniform_fields.push_str(&layers.uniform_fields);
|
||||||
uniform_values.extend_from_slice(&layers.uniform_values);
|
uniform_values.extend_from_slice(&layers.uniform_values);
|
||||||
body.push_str(&layers.body);
|
|
||||||
for h in &layers.helpers {
|
for h in &layers.helpers {
|
||||||
if !helpers.iter().any(|existing| existing.name == h.name) {
|
if !helpers.iter().any(|existing| existing.name == h.name) {
|
||||||
helpers.push(*h);
|
helpers.push(*h);
|
||||||
@@ -1255,6 +1319,125 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3f
|
||||||
|
/// One operation's block when its layers are blended as *settings*: every
|
||||||
|
/// uniform the weighted average of the global value and each overlapping
|
||||||
|
/// layer's, and then the fragment once.
|
||||||
|
///
|
||||||
|
/// The weights: each layer's mask weight `w_i`, and the global setting
|
||||||
|
/// whatever the layers leave, `w_g = max(0, 1 − Σ w_i)`. So
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// p = (w_g·p_g + Σ w_i·p_i) / (w_g + Σ w_i)
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// — one layer at weight `w` is `(1 − w)·p_g + w·p_1`, exactly the blend a
|
||||||
|
/// layer has always had, and three layers overlapping at full weight are the
|
||||||
|
/// plain mean of their three settings rather than one piled on another. A
|
||||||
|
/// layer's `p_i` is its combined setting (the global one plus its offset), so
|
||||||
|
/// the average is of what each layer *asks for*.
|
||||||
|
///
|
||||||
|
/// Only layers that move this operation take part. One that left it alone is
|
||||||
|
/// not voting for the global setting; it is not voting.
|
||||||
|
///
|
||||||
|
/// `fragment` is the operation's body with bare uniform names, as
|
||||||
|
/// `wgsl_body` wrote it: they are rewritten here to the blended values.
|
||||||
|
fn local_settings_block(
|
||||||
|
fragment: &str,
|
||||||
|
prefix: &str,
|
||||||
|
uniforms: &[Uniform],
|
||||||
|
local: &[&crate::mask::LocalOp],
|
||||||
|
) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
let _ = writeln!(out, " {{");
|
||||||
|
let weights: Vec<String> = local.iter().map(|l| format!("mask_w{}", l.slot)).collect();
|
||||||
|
let _ = writeln!(out, " let set_w = {};", weights.join(" + "));
|
||||||
|
let _ = writeln!(out, " let set_g = max(1.0 - set_w, 0.0);");
|
||||||
|
// Never zero: the global weight is one wherever no layer reaches.
|
||||||
|
let _ = writeln!(out, " let set_n = max(set_g + set_w, 1e-6);");
|
||||||
|
let mut body = fragment.to_string();
|
||||||
|
for u in uniforms {
|
||||||
|
let mut sum = format!("set_g * u.{prefix}_{}", u.name);
|
||||||
|
for l in local {
|
||||||
|
let _ = write!(
|
||||||
|
sum,
|
||||||
|
" + mask_w{slot} * u.mask{slot}_{prefix}_{name}",
|
||||||
|
slot = l.slot,
|
||||||
|
name = u.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let _ = writeln!(out, " let set_{} = ({sum}) / set_n;", u.name);
|
||||||
|
body = rewrite_uniform(&body, u.name, &format!("set_{}", u.name));
|
||||||
|
}
|
||||||
|
let _ = writeln!(out, " {{");
|
||||||
|
for line in body.lines() {
|
||||||
|
let _ = writeln!(out, " {line}");
|
||||||
|
}
|
||||||
|
let _ = writeln!(out, " }}");
|
||||||
|
let _ = writeln!(out, " }}");
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One operation's block: its global fragment, and each layer's version of it
|
||||||
|
/// blended in by that layer's weight.
|
||||||
|
///
|
||||||
|
/// Every version reads the same input — the colour as it arrived at this
|
||||||
|
/// operation — and the pixel moves from the global result by each layer's
|
||||||
|
/// difference from it: `c_g + Σ w_i (c_i − c_g)`. At full weight that is the
|
||||||
|
/// layer's combined setting exactly, at zero it is the global result exactly,
|
||||||
|
/// and two overlapping layers add their changes rather than one repainting
|
||||||
|
/// the other.
|
||||||
|
///
|
||||||
|
/// With no layer touching the operation this is the block the composer always
|
||||||
|
/// emitted, byte for byte: a photograph with no masks compiles to the shader
|
||||||
|
/// it did before layers were offsets.
|
||||||
|
fn local_block(global: &str, local: &[&crate::mask::LocalOp]) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
let _ = writeln!(out, " {{");
|
||||||
|
if local.is_empty() {
|
||||||
|
for line in global.lines() {
|
||||||
|
let _ = writeln!(out, " {line}");
|
||||||
|
}
|
||||||
|
let _ = writeln!(out, " }}");
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
let _ = writeln!(out, " let local_in = c;");
|
||||||
|
let _ = writeln!(out, " {{");
|
||||||
|
for line in global.lines() {
|
||||||
|
let _ = writeln!(out, " {line}");
|
||||||
|
}
|
||||||
|
let _ = writeln!(out, " }}");
|
||||||
|
let _ = writeln!(out, " let local_global = c;");
|
||||||
|
let _ = writeln!(out, " var local_sum = c;");
|
||||||
|
for l in local {
|
||||||
|
let w = format!("mask_w{}", l.slot);
|
||||||
|
// Skipping 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 extra work. Safe as non-uniform control flow — nothing
|
||||||
|
// inside samples with derivatives or synchronises.
|
||||||
|
let _ = writeln!(out, " if ({w} > 0.0) {{");
|
||||||
|
// Fragments write to a `c` they expect to own, so the layer's version
|
||||||
|
// gets one of its own, shadowing the outer one and starting from what
|
||||||
|
// this operation was handed.
|
||||||
|
let _ = writeln!(out, " var c = local_in;");
|
||||||
|
let _ = writeln!(out, " {{");
|
||||||
|
for line in l.fragment.lines() {
|
||||||
|
let _ = writeln!(out, " {line}");
|
||||||
|
}
|
||||||
|
let _ = writeln!(out, " }}");
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
" local_sum = local_sum + {w} * (c - local_global);"
|
||||||
|
);
|
||||||
|
let _ = writeln!(out, " }}");
|
||||||
|
}
|
||||||
|
// Two layers pulling the same way can overshoot below zero, and a
|
||||||
|
// negative component poisons every operation after this one.
|
||||||
|
let _ = writeln!(out, " c = max(local_sum, vec3<f32>(0.0));");
|
||||||
|
let _ = writeln!(out, " }}");
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// The WGSL converting linear sRGB into the output space's primaries.
|
/// The WGSL converting linear sRGB into the output space's primaries.
|
||||||
///
|
///
|
||||||
/// A constant matrix rather than a uniform: the space is chosen when the
|
/// A constant matrix rather than a uniform: the space is chosen when the
|
||||||
@@ -2070,7 +2253,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,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -22,9 +22,11 @@
|
|||||||
//!
|
//!
|
||||||
//! 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
|
||||||
@@ -63,6 +65,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 +85,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 +144,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,60 +313,80 @@ impl Operation for FilmSim {
|
|||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,8 +408,9 @@ let scene = vec3<f32>(
|
|||||||
// 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 +419,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,15 +438,39 @@ 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.
|
||||||
|
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()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,7 +479,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 +569,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 +579,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 +630,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 +654,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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ 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};
|
||||||
|
|||||||
@@ -1280,7 +1280,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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -347,6 +347,8 @@ RawImage (sensor data, CPU)
|
|||||||
│ upload
|
│ upload
|
||||||
▼
|
▼
|
||||||
┌─────────────────────┐
|
┌─────────────────────┐
|
||||||
|
│ hot/dead photosites │ repaired on the mosaic (FR-RAW-3)
|
||||||
|
├─────────────────────┤
|
||||||
│ black/white levels │ integer normalise
|
│ black/white levels │ integer normalise
|
||||||
├─────────────────────┤
|
├─────────────────────┤
|
||||||
│ demosaic │ Bayer or Markesteijn (X-Trans, FR-RAW-5)
|
│ demosaic │ Bayer or Markesteijn (X-Trans, FR-RAW-5)
|
||||||
@@ -379,8 +381,29 @@ RawImage (sensor data, CPU)
|
|||||||
|
|
||||||
Working precision is f16 in a linear wide-gamut space, quantising once at the output transform.
|
Working precision is f16 in a linear wide-gamut space, quantising once at the output transform.
|
||||||
|
|
||||||
|
**A hot or dead photosite is repaired before the demosaic, not after.** Past it, one photosite of
|
||||||
|
nonsense is a coloured cross three pixels wide that no later stage can tell from detail. The pass
|
||||||
|
(`shaders/hot_pixels.wgsl`, run by `Demosaicer::run` into a second buffer) replaces a photosite that
|
||||||
|
stands apart from every same-colour photosite in its 5×5 window *and* from each of its eight
|
||||||
|
immediate neighbours with the nearest value that neighbourhood vouches for. The second test is what
|
||||||
|
keeps a star: real light arrives through a lens and lights a patch, so its neighbours are lit too. A
|
||||||
|
6×6 sensor-anchored colour tile serves Bayer and X-Trans alike, every path that demosaics gets it,
|
||||||
|
and there is no setting.
|
||||||
|
|
||||||
|
**A mask layer runs inside this chain, not after it.** Its settings are offsets to the global ones
|
||||||
|
(`mask::offset_onto`), and each operation a layer touches is composed at the operation's own place:
|
||||||
|
the global fragment and each layer's combined fragment read the same input, and the pixel moves by
|
||||||
|
each layer's weighted difference, `c_g + Σ wᵢ(cᵢ − c_g)`. At full weight that is the combined
|
||||||
|
setting exactly and at zero the global result exactly, so global contrast −30 under a layer at −20
|
||||||
|
is contrast −50 where contrast runs, never −30 now and −20 again later — which is what the layer
|
||||||
|
chain did before 0.18.1, and how the shadows of a night shot went magenta. A photograph with no
|
||||||
|
layers composes to the same shader byte for byte. The film is the one exception, because it is a
|
||||||
|
rendering rather than an adjustment and cross-fading two developments is not what a region of a
|
||||||
|
pushed negative looks like: an operation that `blends_settings` has its uniforms averaged by mask
|
||||||
|
weight instead, and runs once (FR-DEV-3f).
|
||||||
|
|
||||||
There is a second reduction that does not hang off the bottom of this chain. The raw histogram
|
There is a second reduction that does not hang off the bottom of this chain. The raw histogram
|
||||||
(FR-CULL-3) taps the demosaiced scene-linear texture directly — the box four rows from the top —
|
(FR-CULL-3) taps the demosaiced scene-linear texture directly — the demosaic box's output —
|
||||||
because what it measures is the file rather than the render. See §5.5.
|
because what it measures is the file rather than the render. See §5.5.
|
||||||
|
|
||||||
### 5.3 Tiling and scheduling
|
### 5.3 Tiling and scheduling
|
||||||
@@ -593,6 +616,13 @@ does not silently become uploadable by existing.
|
|||||||
The UI executor never blocks — this is the mechanism behind R4 and NFR-P9, which the requirements
|
The UI executor never blocks — this is the mechanism behind R4 and NFR-P9, which the requirements
|
||||||
state as outcomes without saying how.
|
state as outcomes without saying how.
|
||||||
|
|
||||||
|
**In code:** `ui/dr-ui/src/executors.rs`. `Executor` names the five and states each count with its
|
||||||
|
reason (`Executor::threads`); `executors::spawn(executor, role, f)` starts a worker named
|
||||||
|
`<executor>:<role>` and marks it with its executor. `run` marks its own thread as the UI executor
|
||||||
|
before the window exists, and `executors::assert_not_ui` — called by `net_runtime`'s `block_on` —
|
||||||
|
fails a debug or test build that blocks there. The counts are not yet enforced: a job still gets a
|
||||||
|
thread of its own, and pooling by executor is where NFR-ARCH-2's priority classes will live.
|
||||||
|
|
||||||
### 7.2 Cancellation
|
### 7.2 Cancellation
|
||||||
|
|
||||||
Cooperative, with tokens threaded through every long operation. Observed within 100 ms
|
Cooperative, with tokens threaded through every long operation. Observed within 100 ms
|
||||||
|
|||||||
+5
-2
@@ -510,8 +510,11 @@ Failures increment `attempts` and set `not_before` to an exponential backoff. Af
|
|||||||
count the job is marked failed and attached to its image as a typed error (NFR-ARCH-4) — one
|
count the job is marked failed and attached to its image as a typed error (NFR-ARCH-4) — one
|
||||||
corrupt file does not stall the queue, and the user can see which files failed and why.
|
corrupt file does not stall the queue, and the user can see which files failed and why.
|
||||||
|
|
||||||
**A job runner never touches the UI executor**, and `Interactive` work runs on the decode pool with
|
**A job runner never touches the UI executor**, and `Interactive` work runs on the decode executor
|
||||||
the I/O pool behind it (ARCH §7.1).
|
with the I/O executor behind it (ARCH §7.1). Those are named rather than pooled today: thumbnails
|
||||||
|
on demand start as `decode:thumbs` and the sweeps as `decode:thumb-sweep` and `decode:metadata`,
|
||||||
|
through `dr_ui::executors::spawn` and each on a thread of its own, and the thread counts §7.1 gives
|
||||||
|
are a budget nothing yet enforces (NFR-ARCH-1).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -325,9 +325,12 @@ without measuring them:
|
|||||||
Stated because §7 of [display-and-extension.md](display-and-extension.md) asks
|
Stated because §7 of [display-and-extension.md](display-and-extension.md) asks
|
||||||
for it, and because each of these could move the numbers.
|
for it, and because each of these could move the numbers.
|
||||||
|
|
||||||
- **Local adjustments.** The mask stack is a separate chain per layer and is not
|
- **Local adjustments.** Not in any row above. Since 0.18.1 a layer is no longer
|
||||||
in any row above. `render_masked` takes them and the fused shader addresses
|
a separate chain after the global one: each operation a layer touches runs a
|
||||||
them per layer, so a heavily masked edit costs more than `all`.
|
second fragment at its own place in the chain, blended by the layer's mask
|
||||||
|
([architecture.md §5.2](architecture.md)), and `render_masked` binds the mask
|
||||||
|
array the fused shader samples. A heavily masked edit therefore costs more
|
||||||
|
than `all`, by roughly one fragment per touched operation per layer.
|
||||||
- **Spot repairs.** These add detail passes, and their cost is per spot.
|
- **Spot repairs.** These add detail passes, and their cost is per spot.
|
||||||
- **Lens corrections.** Not part of `EditGraph::default_chain` — they are built
|
- **Lens corrections.** Not part of `EditGraph::default_chain` — they are built
|
||||||
from a matched profile — so the `point` row does not include the warp chain.
|
from a matched profile — so the `point` row does not include the warp chain.
|
||||||
|
|||||||
+37
-4
@@ -38,6 +38,14 @@ work of 0.15.0 and 0.16.0 left open.
|
|||||||
in §5 is rewritten around what the Flatpak has still not shown; albums brought the first SAF code,
|
in §5 is rewritten around what the Flatpak has still not shown; albums brought the first SAF code,
|
||||||
which FR-PLAT-AND-1's entry now describes, along with why its new tag overstates it.
|
which FR-PLAT-AND-1's entry now describes, along with why its new tag overstates it.
|
||||||
|
|
||||||
|
**And for 0.18.2.** FR-CULL-13's write-path half is held by a test now, and §2 records the evidence
|
||||||
|
half it leaves; NFR-ARCH-1's executors are named and guarded but not pooled, recorded in §4. Three
|
||||||
|
things closed without having had an entry: a mask layer's film settings, which the panel offered
|
||||||
|
and which moved nothing until 0.18.2 (FR-DEV-3f); hot and dead photosites, repaired on the mosaic
|
||||||
|
before the demosaic, for Bayer and X-Trans alike and with no setting (FR-RAW-3; the defect-map
|
||||||
|
reader in `dr-decode` is still not wired in, and a CR2 carries no map for it to read); and the
|
||||||
|
tablet's scrollers, which §4a now describes.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Plugins — post-v1 since 2026-09-19
|
## 1. Plugins — post-v1 since 2026-09-19
|
||||||
@@ -91,7 +99,7 @@ somebody reads the matrix.
|
|||||||
## 2. Culling — the stated differentiator, half built
|
## 2. Culling — the stated differentiator, half built
|
||||||
|
|
||||||
[D11](requirements.md) names culling "the core differentiator". FR-CULL-1 through -5 and -8 through
|
[D11](requirements.md) names culling "the core differentiator". FR-CULL-1 through -5 and -8 through
|
||||||
-13 are built. Two are not.
|
-12 are built, and FR-CULL-13 is half built. Two are not built at all.
|
||||||
|
|
||||||
**FR-CULL-3 — Raw-truth overlays. Built, all three bullets.** Focus peaking is
|
**FR-CULL-3 — Raw-truth overlays. Built, all three bullets.** Focus peaking is
|
||||||
`core/dr-gpu/src/focus.rs` and `ui/dr-ui/src/peaking.rs`; the raw histogram and the raw clipping
|
`core/dr-gpu/src/focus.rs` and `ui/dr-ui/src/peaking.rs`; the raw histogram and the raw clipping
|
||||||
@@ -133,6 +141,17 @@ capture instant and size), `ui/dr-ui/src/duplicates.rs` proves each group the sa
|
|||||||
compares their edits, and a group is folded onto one survivor with the others trashed in one
|
compares their edits, and a group is folded onto one survivor with the others trashed in one
|
||||||
transaction, from the Duplicate originals review in the sidebar and in Settings.
|
transaction, from the Duplicate originals review in the sidebar and in Settings.
|
||||||
|
|
||||||
|
**FR-CULL-13 — Evidence, never verdicts. The write-path half.** Every write of a rating, flag,
|
||||||
|
colour label or trash membership in the shipped code is enumerated by
|
||||||
|
`tools/traceability/src/verdicts.rs`, which parses the tree with `syn` and holds each site to a
|
||||||
|
reviewed list with its reason: a key, click or tap, a function writing for callers that are checked
|
||||||
|
in turn, or a verdict carried from elsewhere (a sidecar or `.xmp` pull, the sync merge, the catalog
|
||||||
|
mirrored to a file, duplicates consolidation). An unlisted write, or a listed one that is gone,
|
||||||
|
fails `cargo test -p traceability`. What is not built is the evidence itself: chips for clipping,
|
||||||
|
focus, burst membership and face counts on the grid cell and in FR-CULL-4's mode, shown as absent
|
||||||
|
rather than zero, with a filter per signal. Eye state is the one signal shown today, on People's
|
||||||
|
face cells and as a library filter.
|
||||||
|
|
||||||
**FR-CULL-6 — Compare and survey.** Absent. No side-by-side view, no synchronised zoom or pan.
|
**FR-CULL-6 — Compare and survey.** Absent. No side-by-side view, no synchronised zoom or pan.
|
||||||
This is the one of the four with no adjacent machinery at all, and it is also the one that most
|
This is the one of the four with no adjacent machinery at all, and it is also the one that most
|
||||||
directly distinguishes culling from browsing.
|
directly distinguishes culling from browsing.
|
||||||
@@ -160,7 +179,7 @@ place.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. The render path — FR-DSP-2, FR-DSP-4, NFR-RES-2
|
## 4. The render path — FR-DSP-2, FR-DSP-4, NFR-RES-2, NFR-ARCH-1
|
||||||
|
|
||||||
**FR-DSP-2 — Tiled computation. Unbuilt, and under challenge.** [architecture.md §6.2](architecture.md)
|
**FR-DSP-2 — Tiled computation. Unbuilt, and under challenge.** [architecture.md §6.2](architecture.md)
|
||||||
calls for tiling "from day one" on the grounds that retrofitting it is a rewrite. It was not built,
|
calls for tiling "from day one" on the grounds that retrofitting it is a rewrite. It was not built,
|
||||||
@@ -199,6 +218,17 @@ and no spill. Spike S6 — a tiled pipeline on a
|
|||||||
mid-range Android device with an image larger than available GPU memory — is the one that would
|
mid-range Android device with an image larger than available GPU memory — is the one that would
|
||||||
settle both this and FR-DSP-2, and there is no evidence it has run.
|
settle both this and FR-DSP-2, and there is no evidence it has run.
|
||||||
|
|
||||||
|
**NFR-ARCH-1 — Named executors. Named and guarded, not bounded.** `dr_ui::executors` names the
|
||||||
|
five executors of [architecture.md §7.1](architecture.md) with their thread counts, every
|
||||||
|
long-lived worker in `dr-ui` and the Android entry point starts through its `spawn` as
|
||||||
|
`<executor>:<role>`, and `net_runtime`'s `block_on` fails a debug or test build on the UI thread.
|
||||||
|
The counts are a budget and not yet a limit: each job still gets a thread of its own, and a pool
|
||||||
|
sized from `Executor::threads` is where NFR-ARCH-2's priority classes would live. The guard covers
|
||||||
|
`block_on` only — not a synchronous file read or a catalog query on the UI thread, which the library
|
||||||
|
and People screens make on a click by design ([catalog.md §1](catalog.md)). The two mask workers in
|
||||||
|
`masks_ui.rs` still call `std::thread::spawn`, and the threads the core crates start are outside the
|
||||||
|
module.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4a. Develop, masks and the keyboard — what the 0.15.0 and 0.16.0 work left open
|
## 4a. Develop, masks and the keyboard — what the 0.15.0 and 0.16.0 work left open
|
||||||
@@ -226,8 +256,11 @@ decoder behind a trait (FR-RAW-2), and duplicate originals (FR-CAT-11a, §2 abov
|
|||||||
false alarm on the common path would teach the notice to be dismissed unread.
|
false alarm on the common path would teach the notice to be dismissed unread.
|
||||||
|
|
||||||
The desktop's scrollbars (the develop column, the grid, the sidebar, Settings, the film list and
|
The desktop's scrollbars (the develop column, the grid, the sidebar, Settings, the film list and
|
||||||
the help sheet) are drawn only where `dr_plat::is_touch_first()` is false; on Android the lists
|
the help sheet) are drawn only where `dr_plat::is_touch_first()` is false. On Android the same
|
||||||
still scroll by flick alone, which is deliberate rather than outstanding.
|
scrollers show instead a 3 px position cue (#69): `Scrolling.cue` in `widgets.slint`, the thumb
|
||||||
|
alone, drawn while the viewport moves and faded 500 ms after, with no touch target, so a flick that
|
||||||
|
starts on it scrolls the list (`tests/scroll_cue.rs`). A desktop build shows the cue when
|
||||||
|
`DR_SCROLL_CUE=1` is set, for checking it without a device. Not yet checked on the tablet.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -269,6 +269,17 @@ identification, and camera-native colour matrices. Demosaic quality shall be sel
|
|||||||
least a fast method for preview and a high-quality method for export (FR-EXP-9 requires export to
|
least a fast method for preview and a high-quality method for export (FR-EXP-9 requires export to
|
||||||
use the latter).
|
use the latter).
|
||||||
|
|
||||||
|
*Status (2026-09-27), defective photosites.* Hot and dead photosites are repaired on the mosaic,
|
||||||
|
before the demosaic, where each is still one wrong value rather than a coloured cross three pixels
|
||||||
|
wide (`core/dr-gpu/src/shaders/hot_pixels.wgsl`). A photosite is repaired only where it stands apart
|
||||||
|
from every same-colour photosite in its 5×5 window and from each of its eight immediate neighbours,
|
||||||
|
which leaves stars and glints alone, and it takes the value of its brightest (or, when dead,
|
||||||
|
darkest) same-colour neighbour, so nothing is invented. A 6×6 sensor-anchored colour tile serves
|
||||||
|
Bayer and X-Trans alike; export and every other path that demosaics get the repair, and there is no
|
||||||
|
setting. `core/dr-gpu/tests/hot_pixels.rs` renders a frame with and without a defect and compares
|
||||||
|
the finished pixels. The DNG defect map `dr_decode::defects` reads is not used: few files carry
|
||||||
|
one, and a CR2 none.
|
||||||
|
|
||||||
**FR-RAW-4 — Robustness.** A malformed or hostile RAW file shall not crash the application or
|
**FR-RAW-4 — Robustness.** A malformed or hostile RAW file shall not crash the application or
|
||||||
compromise the process. Decode failures are reported per-file and do not abort a batch.
|
compromise the process. Decode failures are reported per-file and do not abort a batch.
|
||||||
|
|
||||||
@@ -315,6 +326,13 @@ once, at the final export or display stage.
|
|||||||
- Crop, straighten, rotate, flip
|
- Crop, straighten, rotate, flip
|
||||||
- Local adjustments: linear gradient, radial gradient, and brush masks
|
- Local adjustments: linear gradient, radial gradient, and brush masks
|
||||||
|
|
||||||
|
*Resolved 2026-09-26:* a mask layer's settings are **offsets to the photograph's**, applied at each
|
||||||
|
operation's own place in the chain — global contrast −30 under a layer at −20 is −50 inside the
|
||||||
|
mask, applied once. A moved switch or choice replaces the global one, and an offset that brings an
|
||||||
|
operation back to neutral undoes the global setting inside the mask. Layers used to run as a second
|
||||||
|
chain after every global operation, which compounded the two edits in ways neither slider showed
|
||||||
|
([architecture.md §5.2](architecture.md); `core/dr-gpu/tests/local_adjustments.rs`).
|
||||||
|
|
||||||
**FR-DEV-3a — Self-describing operations.** Every processing operation shall declare its own
|
**FR-DEV-3a — Self-describing operations.** Every processing operation shall declare its own
|
||||||
parameters through a descriptor, so that adding an operation requires no changes to frontend code.
|
parameters through a descriptor, so that adding an operation requires no changes to frontend code.
|
||||||
An operation declares *what* its parameters are; the frontend decides *how* to present them.
|
An operation declares *what* its parameters are; the frontend decides *how* to present them.
|
||||||
@@ -434,6 +452,15 @@ reference implementation.
|
|||||||
parameter — `core/dr-pipeline/src/sidecar.rs` records why an index was rejected (installing a
|
parameter — `core/dr-pipeline/src/sidecar.rs` records why an index was rejected (installing a
|
||||||
profile would silently change which film every existing photograph was developed on).
|
profile would silently change which film every existing photograph was developed on).
|
||||||
|
|
||||||
|
*Resolved 2026-09-26:* the film's settings — exposure, push, print exposure, format — are
|
||||||
|
**per-pixel**, evaluated by the shader against tables that hold none of them, so a mask layer can
|
||||||
|
hold its own. A layer's settings are offsets to the photograph's, and where layers overlap a pixel
|
||||||
|
takes the weighted average of what each asks for, the photograph's setting taking whatever weight
|
||||||
|
the layers leave (`operation::local_settings_block`). The stock and its paper stay
|
||||||
|
photograph-wide: a layer has no picker. The print is split at the paper's log exposure, so print
|
||||||
|
exposure is an addition between two lookups and exact at any setting; push interpolates the
|
||||||
|
stock's measured processes. Before this a layer offered the film's sliders and they moved nothing.
|
||||||
|
|
||||||
**FR-DEV-3g — AI denoise.** Learned denoising operating in the raw domain, ideally jointly with
|
**FR-DEV-3g — AI denoise.** Learned denoising operating in the raw domain, ideally jointly with
|
||||||
demosaic.
|
demosaic.
|
||||||
|
|
||||||
@@ -1309,6 +1336,21 @@ only from an input event. The evidence for a frame is visible in FR-CULL-4's mod
|
|||||||
cell without opening it, and a filter on any one signal returns exactly the set whose chips show
|
cell without opening it, and a filter on any one signal returns exactly the set whose chips show
|
||||||
it.
|
it.
|
||||||
|
|
||||||
|
*Status (2026-09-26).* The write-path half is met; the evidence half is not. `cargo test -p
|
||||||
|
traceability` enumerates every write of a rating, flag, colour label or trash membership in the
|
||||||
|
shipped code — the catalog setters, any SQL that assigns those columns, the sidecar's judgement
|
||||||
|
amendment and the fields that carry one — and holds each to a hand-written list
|
||||||
|
(`tools/traceability/src/verdicts.rs`). Each listed write is a key, click or tap (a Slint `on_*`
|
||||||
|
callback, checked structurally), a function writing for its caller, whose callers are then checked
|
||||||
|
in turn, or a verdict carried from elsewhere: a sidecar or `.xmp` pull, the sync merge of two
|
||||||
|
devices' sidecars, the catalog mirrored out to a file, and duplicates consolidation, which moves
|
||||||
|
the copies' own verdicts onto the survivor and invents none. A new writer, including one in an
|
||||||
|
evidence producer, fails the test until it is listed with a reason; `traces verdicts` prints the
|
||||||
|
list. Choosing a burst's representative writes the grouping, not a verdict, and takes a press.
|
||||||
|
Outstanding: evidence chips (clipping, focus, burst membership, face counts) on the grid cell and in
|
||||||
|
FR-CULL-4's mode, shown as absent rather than zero, and a filter per signal. Eye state alone is
|
||||||
|
shown today, on People's face cells and as a library filter.
|
||||||
|
|
||||||
### 3.9.1 People
|
### 3.9.1 People
|
||||||
|
|
||||||
Face recognition was deferred in §7 through the 2026-08-08 calibration. It is undeferred here in a
|
Face recognition was deferred in §7 through the 2026-08-08 calibration. It is undeferred here in a
|
||||||
@@ -2145,6 +2187,19 @@ pool, I/O pool, network — with stated thread counts and the invariant that **n
|
|||||||
occurs on the UI executor**. This is the mechanism behind R4 and NFR-P9, which currently assert an
|
occurs on the UI executor**. This is the mechanism behind R4 and NFR-P9, which currently assert an
|
||||||
outcome with no stated means.
|
outcome with no stated means.
|
||||||
|
|
||||||
|
*Status (2026-09-26).* Named and guarded; not yet bounded. `dr_ui::executors` defines the five
|
||||||
|
executors with the thread counts of architecture.md §7.1 and the reason for each, and every
|
||||||
|
long-lived worker in `dr-ui` and the Android entry point is started through its `spawn`, which
|
||||||
|
names the thread `<executor>:<role>` (`net:sync`, `decode:thumbs`). `run` marks its thread as the
|
||||||
|
UI executor, and `net_runtime`'s `block_on` asserts in debug and test builds that it is not called
|
||||||
|
there; `executors`' tests show the panic on a thread marked as the UI one and the same call passing
|
||||||
|
on a worker. Outstanding: the counts are a stated budget, not a limit — each job still gets a
|
||||||
|
thread of its own, and a pool sized from `Executor::threads` is the change NFR-ARCH-2's priorities
|
||||||
|
need; the guard covers `block_on` only, not a synchronous file read or a catalog query on the UI
|
||||||
|
thread, several of which the library and People screens make on a click by design (catalog.md
|
||||||
|
§1); the two mask workers in `masks_ui.rs` still use `std::thread::spawn`; and threads the core
|
||||||
|
crates start (the inference engine's reaper and probe, already named) are outside the module.
|
||||||
|
|
||||||
**NFR-ARCH-2 — Scheduler priority.** The tiling scheduler assigns priority classes, with
|
**NFR-ARCH-2 — Scheduler priority.** The tiling scheduler assigns priority classes, with
|
||||||
visible-tile work **strictly preempting** background export and thumbnail work. Without this,
|
visible-tile work **strictly preempting** background export and thumbnail work. Without this,
|
||||||
NFR-P5's slider latency fails during a batch export — the common case, not an edge case.
|
NFR-P5's slider latency fails during a batch export — the common case, not an edge case.
|
||||||
|
|||||||
+104
-104
File diff suppressed because one or more lines are too long
@@ -411,6 +411,16 @@ tract, Slint's compiler, wgpu — so with the cargo cache warm it is minutes and
|
|||||||
better part of half an hour. Worth noting because the runner is one machine and the legs run in
|
better part of half an hour. Worth noting because the runner is one machine and the legs run in
|
||||||
parallel on it; if it starts starving the desktop leg, `needs: desktop` serialises them.
|
parallel on it; if it starts starving the desktop leg, `needs: desktop` serialises them.
|
||||||
|
|
||||||
|
**Disk is the tighter budget.** The runner has one 99 GB disk shared with its container images. At
|
||||||
|
rest it holds about 23 GB; the desktop leg's restored target cache, the models and the dependency
|
||||||
|
build bring it to about 78 GB before a test runs, and v0.18.0's run ended at 92 GB used. v0.18.1's
|
||||||
|
release build then died with "No space left on device", so that tag has no release page. Since
|
||||||
|
then the desktop leg deletes its test executables, `target/debug/examples` and
|
||||||
|
`target/debug/incremental` after the Test step and before the release build — they are relinked
|
||||||
|
whenever a source changes, and the cache exists for the dependency rlibs — and prints the disk
|
||||||
|
again beside its Disk before and after lines. A second target's cache on the same disk is the
|
||||||
|
first thing to look at if a leg runs out again.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Requirements
|
## 8. Requirements
|
||||||
|
|||||||
+1
-1
@@ -354,7 +354,7 @@ One key for "up one", innermost first: a question before the sheet under it, a s
|
|||||||
|
|
||||||
A list cut off at its edge looks, to a mouse, like a list that ends there — the film stocks past the tenth read as deleted. The bar says there is more and where the view is in it, and it is the one way to scroll that needs neither a wheel nor a drag on the content, which may be a row that would take the click.
|
A list cut off at its edge looks, to a mouse, like a list that ends there — the film stocks past the tenth read as deleted. The bar says there is more and where the view is in it, and it is the one way to scroll that needs neither a wheel nor a drag on the content, which may be a row that would take the click.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/widgets.slint:1131`</sub>
|
<sub>`ui/dr-ui/ui/widgets.slint:1138`</sub>
|
||||||
|
|
||||||
## Collections sidebar
|
## Collections sidebar
|
||||||
|
|
||||||
|
|||||||
+16
-2
@@ -71,7 +71,9 @@ Drag the timeline to scrub through years; Ctrl and the wheel resize the
|
|||||||
thumbnails. On a desktop a scrollbar beside the grid says how far through the
|
thumbnails. On a desktop a scrollbar beside the grid says how far through the
|
||||||
library the view is — drag its thumb, or click the track to move a page — and
|
library the view is — drag its thumb, or click the track to move a page — and
|
||||||
the sidebar, the develop column and Settings have one too whenever they run
|
the sidebar, the develop column and Settings have one too whenever they run
|
||||||
past the window. On a tablet they scroll by flick alone.
|
past the window. On a tablet they scroll by flick alone, and a thin line at
|
||||||
|
the right-hand edge shows where the view is while it moves, fading once it
|
||||||
|
stops; it is only a picture, and a flick that starts on it scrolls the list.
|
||||||
|
|
||||||
`Help` in the header, or `F1`, opens the controls and shortcuts: every key and
|
`Help` in the header, or `F1`, opens the controls and shortcuts: every key and
|
||||||
gesture, screen by screen, with `See it` beside those this page shows, and
|
gesture, screen by screen, with `See it` beside those this page shows, and
|
||||||
@@ -233,7 +235,10 @@ take the crop back or keep it.
|
|||||||
`Local` in the rail turns the column into a mask stack. `Find subjects` runs a
|
`Local` in the rail turns the column into a mask stack. `Find subjects` runs a
|
||||||
segmentation model over the photograph; what it recognises appears as a list
|
segmentation model over the photograph; what it recognises appears as a list
|
||||||
of categories with how much of the frame each covers. Click one and it is a
|
of categories with how much of the frame each covers. Click one and it is a
|
||||||
mask — then every slider below edits only that region.
|
mask — then every slider below edits only that region. A mask's slider adds to
|
||||||
|
the photograph's own rather than repeating it: contrast −20 in the mask over
|
||||||
|
−30 on the whole frame is −50 there, and +30 in the mask cancels the frame's
|
||||||
|
−30 inside it.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -270,6 +275,15 @@ black-and-white stocks at its end.
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
The film's sliders work on a mask as they do on the whole photograph, the way
|
||||||
|
a printer dodges and burns: in a layer, `Print Exposure` darkens or lightens
|
||||||
|
that region of the print, `Push` develops it further, and the stock stays the
|
||||||
|
one the photograph was made on. Where layers overlap, the photograph takes the
|
||||||
|
average of what they ask for. Below, a negative stock on an alpine frame, then
|
||||||
|
a gradient over the sky whose `Print Exposure` burns it in.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
### History, snapshots, presets
|
### History, snapshots, presets
|
||||||
|
|
||||||
Every change is a step; `Undo` and the History panel walk them. `Snapshot`
|
Every change is a step; `Undo` and the History panel walk them. `Snapshot`
|
||||||
|
|||||||
+14
-2
@@ -191,7 +191,9 @@ and the same keys work there.</p>
|
|||||||
thumbnails. On a desktop a scrollbar beside the grid says how far through the
|
thumbnails. On a desktop a scrollbar beside the grid says how far through the
|
||||||
library the view is — drag its thumb, or click the track to move a page — and
|
library the view is — drag its thumb, or click the track to move a page — and
|
||||||
the sidebar, the develop column and Settings have one too whenever they run
|
the sidebar, the develop column and Settings have one too whenever they run
|
||||||
past the window. On a tablet they scroll by flick alone.</p>
|
past the window. On a tablet they scroll by flick alone, and a thin line at
|
||||||
|
the right-hand edge shows where the view is while it moves, fading once it
|
||||||
|
stops; it is only a picture, and a flick that starts on it scrolls the list.</p>
|
||||||
<p><code>Help</code> in the header, or <code>F1</code>, opens the controls and shortcuts: every key and
|
<p><code>Help</code> in the header, or <code>F1</code>, opens the controls and shortcuts: every key and
|
||||||
gesture, screen by screen, with <code>See it</code> beside those this page shows, and
|
gesture, screen by screen, with <code>See it</code> beside those this page shows, and
|
||||||
<code>Manual</code> to open this page. In develop it is the <code>?</code> beside <code>Settings</code>.</p>
|
<code>Manual</code> to open this page. In develop it is the <code>?</code> beside <code>Settings</code>.</p>
|
||||||
@@ -304,7 +306,10 @@ take the crop back or keep it.</p>
|
|||||||
<p><code>Local</code> in the rail turns the column into a mask stack. <code>Find subjects</code> runs a
|
<p><code>Local</code> in the rail turns the column into a mask stack. <code>Find subjects</code> runs a
|
||||||
segmentation model over the photograph; what it recognises appears as a list
|
segmentation model over the photograph; what it recognises appears as a list
|
||||||
of categories with how much of the frame each covers. Click one and it is a
|
of categories with how much of the frame each covers. Click one and it is a
|
||||||
mask — then every slider below edits only that region.</p>
|
mask — then every slider below edits only that region. A mask's slider adds to
|
||||||
|
the photograph's own rather than repeating it: contrast −20 in the mask over
|
||||||
|
−30 on the whole frame is −50 there, and +30 in the mask cancels the frame's
|
||||||
|
−30 inside it.</p>
|
||||||
<figure><img loading="lazy" src="media/local-categories.png" alt="What the model found in an urban scene: ground, architecture, sky, vegetation"><figcaption>What the model found in an urban scene: ground, architecture, sky, vegetation</figcaption></figure>
|
<figure><img loading="lazy" src="media/local-categories.png" alt="What the model found in an urban scene: ground, architecture, sky, vegetation"><figcaption>What the model found in an urban scene: ground, architecture, sky, vegetation</figcaption></figure>
|
||||||
<figure><img loading="lazy" src="media/local-segment.png" alt="The sky chosen: tinted on the photograph, and the column now scoped to it"><figcaption>The sky chosen: tinted on the photograph, and the column now scoped to it</figcaption></figure>
|
<figure><img loading="lazy" src="media/local-segment.png" alt="The sky chosen: tinted on the photograph, and the column now scoped to it"><figcaption>The sky chosen: tinted on the photograph, and the column now scoped to it</figcaption></figure>
|
||||||
<p>A mask is a stack of parts. Paint into it, subtract a gradient from it, grow
|
<p>A mask is a stack of parts. Paint into it, subtract a gradient from it, grow
|
||||||
@@ -328,6 +333,13 @@ sensor does not. The list opens over the column and scrolls on its own — by
|
|||||||
wheel, drag or flick, or with <code>Up</code>, <code>Down</code> and <code>Enter</code> — down to the
|
wheel, drag or flick, or with <code>Up</code>, <code>Down</code> and <code>Enter</code> — down to the
|
||||||
black-and-white stocks at its end.</p>
|
black-and-white stocks at its end.</p>
|
||||||
<figure><img loading="lazy" src="media/film.gif" alt="Opening the film list, scrolling it, choosing Velvia, then holding Before"><figcaption>Opening the film list, scrolling it, choosing Velvia, then holding Before</figcaption></figure>
|
<figure><img loading="lazy" src="media/film.gif" alt="Opening the film list, scrolling it, choosing Velvia, then holding Before"><figcaption>Opening the film list, scrolling it, choosing Velvia, then holding Before</figcaption></figure>
|
||||||
|
<p>The film's sliders work on a mask as they do on the whole photograph, the way
|
||||||
|
a printer dodges and burns: in a layer, <code>Print Exposure</code> darkens or lightens
|
||||||
|
that region of the print, <code>Push</code> develops it further, and the stock stays the
|
||||||
|
one the photograph was made on. Where layers overlap, the photograph takes the
|
||||||
|
average of what they ask for. Below, a negative stock on an alpine frame, then
|
||||||
|
a gradient over the sky whose <code>Print Exposure</code> burns it in.</p>
|
||||||
|
<figure><img loading="lazy" src="media/film-local.gif" alt="Kodak Ektar 100 on the whole frame, a gradient turned to cover the sky, its Print Exposure raised to burn the sky in, then holding Before"><figcaption>Kodak Ektar 100 on the whole frame, a gradient turned to cover the sky, its Print Exposure raised to burn the sky in, then holding Before</figcaption></figure>
|
||||||
<h3 id="history-snapshots-presets">History, snapshots, presets</h3>
|
<h3 id="history-snapshots-presets">History, snapshots, presets</h3>
|
||||||
<p>Every change is a step; <code>Undo</code> and the History panel walk them. <code>Snapshot</code>
|
<p>Every change is a step; <code>Undo</code> and the History panel walk them. <code>Snapshot</code>
|
||||||
keeps the current state under a name. <code>Presets…</code> saves the settings to
|
keeps the current state under a name. <code>Presets…</code> saves the settings to
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
# makes `makepkg -si` in this directory install what you are actually working
|
# makes `makepkg -si` in this directory install what you are actually working
|
||||||
# on. Swap `source` for a tagged tarball when there is something to release.
|
# on. Swap `source` for a tagged tarball when there is something to release.
|
||||||
pkgname=darkroom
|
pkgname=darkroom
|
||||||
pkgver=0.18.0
|
pkgver=0.18.2
|
||||||
# Back to 1 with the version: a new pkgver is a new archive name, so there is
|
# Back to 1 with the version: a new pkgver is a new archive name, so there is
|
||||||
# nothing for makepkg to reuse and nothing for a release number to disambiguate.
|
# nothing for makepkg to reuse and nothing for a release number to disambiguate.
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ TEAPOT = '_MG_4918'
|
|||||||
PANO_FIRST, PANO_LAST = '_MG_8320', '_MG_8331'
|
PANO_FIRST, PANO_LAST = '_MG_8320', '_MG_8331'
|
||||||
NY_FIRST, NY_LAST = '_MG_8393', '_MG_8397'
|
NY_FIRST, NY_LAST = '_MG_8393', '_MG_8397'
|
||||||
ALPS_ROW = ['_MG_8322', '_MG_8328'] # the second row of the alps, first and last
|
ALPS_ROW = ['_MG_8322', '_MG_8328'] # the second row of the alps, first and last
|
||||||
|
ALPS_SKY = '_MG_8330' # an upright alpine frame, its top third sky and cloud
|
||||||
TOWER = '_MG_8693' # towers shot looking up: verticals that converge
|
TOWER = '_MG_8693' # towers shot looking up: verticals that converge
|
||||||
ROAD = '_MG_8672' # a road under a sky, with marks on it: masks and repair
|
ROAD = '_MG_8672' # a road under a sky, with marks on it: masks and repair
|
||||||
ROAD_MARK = (0.305, 0.794) # a dark mark on its tarmac
|
ROAD_MARK = (0.305, 0.794) # a dark mark on its tarmac
|
||||||
@@ -1005,6 +1006,64 @@ def film():
|
|||||||
undo_all()
|
undo_all()
|
||||||
|
|
||||||
|
|
||||||
|
FILM_LOCAL = 'Kodak Ektar 100' # a negative, so it is printed and has a print exposure
|
||||||
|
|
||||||
|
|
||||||
|
def choose_film(stock):
|
||||||
|
"""`stock` chosen from the film list, which is open at its top."""
|
||||||
|
px, py, _, _ = film_list_origin()
|
||||||
|
vx0, vy0, vx1, vy1 = dr.rect(f'{stock}@Text')
|
||||||
|
dr.click(px + (vx0 + vx1) // 2, py + (vy0 + vy1) // 2)
|
||||||
|
pause(3)
|
||||||
|
|
||||||
|
|
||||||
|
def gradient_over_top(to_fy):
|
||||||
|
"""The linear gradient just added, turned to fall from the top of the
|
||||||
|
frame and its middle moved to `to_fy`. A new one is laid across the
|
||||||
|
middle facing right, its rotate handle below the centre: that handle,
|
||||||
|
dragged round to the right, turns it a quarter to face up."""
|
||||||
|
cx, cy = dr.photo(0.5, 0.5)
|
||||||
|
handles = [(int(h['x'] + h['w'] / 2), int(h['y'] + h['h'] / 2))
|
||||||
|
for h in dr.matches('id:GradientHandles::drag')]
|
||||||
|
rx, ry = max(handles, key=lambda p: p[1])
|
||||||
|
dr.drag(rx, ry, cx + (ry - cy), cy, 25)
|
||||||
|
pause(1.0)
|
||||||
|
dr.drag(cx, cy, *dr.photo(0.5, to_fy), 25)
|
||||||
|
pause(1.2)
|
||||||
|
|
||||||
|
|
||||||
|
@scene(media=['film-local.gif'], sources=MASK_SRC + ['core/dr-film/src/**', 'core/dr-pipeline/src/ops/film_sim.rs'])
|
||||||
|
def film_local():
|
||||||
|
"""A negative stock on the whole photograph, then a gradient over the
|
||||||
|
sky whose Print Exposure burns it in, as a printer would under the
|
||||||
|
enlarger, the mask drawn as its edge so the print shows through; Done
|
||||||
|
Masking, then Before held."""
|
||||||
|
at_develop(ALPS_SKY)
|
||||||
|
in_column('Film@Button')
|
||||||
|
film_list_open()
|
||||||
|
choose_film(FILM_LOCAL)
|
||||||
|
tool('Local')
|
||||||
|
rec('film-local')
|
||||||
|
pause(0.6)
|
||||||
|
dr.click(*in_column('Linear@Button'))
|
||||||
|
pause(1.5)
|
||||||
|
gradient_over_top(0.36)
|
||||||
|
dr.click(*in_column('Edge@RadioButton')) # an outline, so the tint hides nothing
|
||||||
|
pause(1.2)
|
||||||
|
slide('Print Exposure', 28, 30)
|
||||||
|
pause(2.0)
|
||||||
|
dr.click_on('Done Masking@Button')
|
||||||
|
pause(2.0)
|
||||||
|
hold_before(1.6)
|
||||||
|
pause(1.0)
|
||||||
|
cut()
|
||||||
|
tool('Local') # masks shown tinted again, as the other scenes
|
||||||
|
dr.click(*in_column('Tint@RadioButton')) # expect; offered while one exists
|
||||||
|
pause(0.6)
|
||||||
|
tool('Photo')
|
||||||
|
undo_all()
|
||||||
|
|
||||||
|
|
||||||
FILM_LAST = 'Ilford HP5 Plus' # the last stock `DevelopSession::film_choices` lists
|
FILM_LAST = 'Ilford HP5 Plus' # the last stock `DevelopSession::film_choices` lists
|
||||||
FILM_ROWS = 28 # None and the 27 camera stocks
|
FILM_ROWS = 28 # None and the 27 camera stocks
|
||||||
FILM_ROW, FILM_LIST_MAX = 32, 320
|
FILM_ROW, FILM_LIST_MAX = 32, 320
|
||||||
|
|||||||
@@ -19,3 +19,5 @@ anyhow.workspace = true
|
|||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
pulldown-cmark.workspace = true
|
pulldown-cmark.workspace = true
|
||||||
|
syn.workspace = true
|
||||||
|
proc-macro2.workspace = true
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ pub mod chord;
|
|||||||
pub mod gestures;
|
pub mod gestures;
|
||||||
pub mod keymap;
|
pub mod keymap;
|
||||||
pub mod manual;
|
pub mod manual;
|
||||||
|
pub mod verdicts;
|
||||||
|
|
||||||
/// Requirement ID prefixes that participate in coverage.
|
/// Requirement ID prefixes that participate in coverage.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
//! traces gestures-check # gate: non-zero exit if either has drifted
|
//! traces gestures-check # gate: non-zero exit if either has drifted
|
||||||
//! traces manual # write docs/manual/index.html from its README
|
//! traces manual # write docs/manual/index.html from its README
|
||||||
//! traces manual-check # gate: non-zero exit if the page has drifted
|
//! traces manual-check # gate: non-zero exit if the page has drifted
|
||||||
|
//! traces verdicts # list every verdict write; non-zero exit on an unlisted one
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! The gesture half scans a different tag out of the same files — see
|
//! The gesture half scans a different tag out of the same files — see
|
||||||
@@ -79,6 +80,9 @@ fn main() -> Result<()> {
|
|||||||
if mode.starts_with("manual") {
|
if mode.starts_with("manual") {
|
||||||
return run_manual(&base, mode == "manual-check");
|
return run_manual(&base, mode == "manual-check");
|
||||||
}
|
}
|
||||||
|
if mode == "verdicts" {
|
||||||
|
return run_verdicts(&base);
|
||||||
|
}
|
||||||
|
|
||||||
let req_path = base.join("docs/dev/requirements.md");
|
let req_path = base.join("docs/dev/requirements.md");
|
||||||
let markdown = std::fs::read_to_string(&req_path)
|
let markdown = std::fs::read_to_string(&req_path)
|
||||||
@@ -248,6 +252,38 @@ fn run_gestures(base: &Path, check: bool) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// List every verdict write with the reason it is allowed, and fail on any
|
||||||
|
/// that has none — the same check `cargo test -p traceability` runs, in a
|
||||||
|
/// form a reviewer can read ([`traceability::verdicts`]).
|
||||||
|
fn run_verdicts(base: &Path) -> Result<()> {
|
||||||
|
let files = verdicts::sources(base);
|
||||||
|
let report = verdicts::check(&files, verdicts::ALLOWED);
|
||||||
|
println!("files scanned {}", files.len());
|
||||||
|
println!("writes found {}", report.sites.len());
|
||||||
|
for s in &report.sites {
|
||||||
|
let kind = verdicts::ALLOWED
|
||||||
|
.iter()
|
||||||
|
.find(|a| a.file == s.file && a.within == s.within && a.writes == s.writes)
|
||||||
|
.map(|a| format!("{:?}", a.kind))
|
||||||
|
.unwrap_or_else(|| "UNLISTED".into());
|
||||||
|
println!(
|
||||||
|
" {kind:<11} {}:{} {} {} {}",
|
||||||
|
s.file, s.line, s.within, s.writes, s.detail
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !report.problems.is_empty() {
|
||||||
|
for p in &report.problems {
|
||||||
|
println!(" {p}");
|
||||||
|
}
|
||||||
|
bail!(
|
||||||
|
"{} verdict write problem(s) (FR-CULL-13)",
|
||||||
|
report.problems.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!("\nverdict gate: PASS");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Render the manual to its page, or check the committed page is that render.
|
/// Render the manual to its page, or check the committed page is that render.
|
||||||
fn run_manual(base: &Path, check: bool) -> Result<()> {
|
fn run_manual(base: &Path, check: bool) -> Result<()> {
|
||||||
let source = std::fs::read_to_string(base.join(MANUAL_SOURCE))
|
let source = std::fs::read_to_string(base.join(MANUAL_SOURCE))
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -45,6 +45,7 @@
|
|||||||
//!
|
//!
|
||||||
//! Waiting is the client's job (poll `locate`), which keeps this stateless.
|
//! Waiting is the client's job (poll `locate`), which keeps this stateless.
|
||||||
|
|
||||||
|
use crate::executors::{self, Executor};
|
||||||
use std::io::{BufRead, BufReader, Write};
|
use std::io::{BufRead, BufReader, Write};
|
||||||
use std::os::unix::net::{UnixListener, UnixStream};
|
use std::os::unix::net::{UnixListener, UnixStream};
|
||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
@@ -71,12 +72,10 @@ pub(crate) fn attach(window: &AppWindow) {
|
|||||||
};
|
};
|
||||||
log::info!("automation: listening on {path:?}");
|
log::info!("automation: listening on {path:?}");
|
||||||
let weak = window.as_weak();
|
let weak = window.as_weak();
|
||||||
std::thread::Builder::new()
|
executors::try_spawn(Executor::Io, "automation", move || {
|
||||||
.name("automation".into())
|
|
||||||
.spawn(move || {
|
|
||||||
for stream in listener.incoming().flatten() {
|
for stream in listener.incoming().flatten() {
|
||||||
let weak = weak.clone();
|
let weak = weak.clone();
|
||||||
std::thread::spawn(move || serve(stream, weak));
|
executors::spawn(Executor::Io, "auto-conn", move || serve(stream, weak));
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
//! abandoned half way leaves the signatures it did compute — they are permanent
|
//! abandoned half way leaves the signatures it did compute — they are permanent
|
||||||
//! and correct — and the previous grouping intact.
|
//! and correct — and the previous grouping intact.
|
||||||
|
|
||||||
|
use crate::executors::{self, Executor};
|
||||||
use std::cell::{Cell, RefCell};
|
use std::cell::{Cell, RefCell};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@@ -82,7 +83,7 @@ pub enum BurstMessage {
|
|||||||
pub fn spawn_grouping(catalog_path: PathBuf, thumbs_dir: PathBuf) -> Receiver<BurstMessage> {
|
pub fn spawn_grouping(catalog_path: PathBuf, thumbs_dir: PathBuf) -> Receiver<BurstMessage> {
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
executors::spawn(Executor::Decode, "bursts", move || {
|
||||||
let catalog = match Catalog::open(&catalog_path) {
|
let catalog = match Catalog::open(&catalog_path) {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
//! share boundary the account cannot write to. The scanner excludes it by the
|
//! share boundary the account cannot write to. The scanner excludes it by the
|
||||||
//! same mechanism that excludes the trash.
|
//! same mechanism that excludes the trash.
|
||||||
|
|
||||||
|
use crate::executors::{self, Executor};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use dr_sync::{Connection, RemoteBackend, RemoteError, RemoteId, RemotePath};
|
use dr_sync::{Connection, RemoteBackend, RemoteError, RemoteId, RemotePath};
|
||||||
@@ -132,7 +133,7 @@ pub fn spawn_sync(
|
|||||||
) -> std::sync::mpsc::Receiver<SyncMessage> {
|
) -> std::sync::mpsc::Receiver<SyncMessage> {
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
executors::spawn(Executor::Network, "sync", move || {
|
||||||
// A current-thread runtime here is what made the library scan itself
|
// A current-thread runtime here is what made the library scan itself
|
||||||
// rather than adopt the shards the server already had; see net_runtime.
|
// rather than adopt the shards the server already had; see net_runtime.
|
||||||
let rt = match crate::net_runtime::build() {
|
let rt = match crate::net_runtime::build() {
|
||||||
@@ -1018,7 +1019,7 @@ pub fn spawn_place_fetch(
|
|||||||
) -> std::sync::mpsc::Receiver<Result<Option<dr_types::Place>, String>> {
|
) -> std::sync::mpsc::Receiver<Result<Option<dr_types::Place>, String>> {
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
executors::spawn(Executor::Network, "places", move || {
|
||||||
let rt = match crate::net_runtime::build() {
|
let rt = match crate::net_runtime::build() {
|
||||||
Ok(rt) => rt,
|
Ok(rt) => rt,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
+196
-68
@@ -749,75 +749,101 @@ impl DevelopSession {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
// TRACES: FR-DEV-3f
|
// TRACES: FR-DEV-3f
|
||||||
// Grain, at the scale this photograph is being sampled at.
|
// The photograph's exposure, which is where the enlarger is balanced.
|
||||||
//
|
// Nothing else a slider moves is baked: push, print exposure and the
|
||||||
// A digital frame has no film format, so simulating one means choosing
|
// format are per-pixel settings the shader applies, so that a mask
|
||||||
// what it *would have been* — 35 mm, because that is the format every
|
// layer can hold its own.
|
||||||
// published granularity figure and every intuition about how grainy a
|
let exposure_ev = self
|
||||||
// stock looks comes from. The sensor's width in pixels then says how
|
|
||||||
// much film one pixel covers, and the grain model needs nothing else
|
|
||||||
// to be correct at any zoom.
|
|
||||||
// TRACES: FR-DEV-3f
|
|
||||||
// The frame this is being simulated on, against the pixels it is being
|
|
||||||
// rendered to: together they are the enlargement, and the enlargement
|
|
||||||
// is what decides how grainy the result looks. A crystal is a fixed
|
|
||||||
// size in micrometres — the same emulsion on a sheet averages far more
|
|
||||||
// of them into each pixel than it does on 35 mm.
|
|
||||||
let format = dr_film::Format::from_index(
|
|
||||||
self.graph
|
|
||||||
.param(
|
|
||||||
dr_pipeline::ops::film_sim::ID,
|
|
||||||
dr_pipeline::ops::film_sim::FORMAT,
|
|
||||||
)
|
|
||||||
.unwrap_or(0.0)
|
|
||||||
.max(0.0) as usize,
|
|
||||||
);
|
|
||||||
let (source_width, _) = self.demosaiced.size();
|
|
||||||
let pixel_size_um = format.width_um() / source_width.max(1) as f32;
|
|
||||||
let grain = dr_film::Grain::for_pixel_size(profile, pixel_size_um);
|
|
||||||
|
|
||||||
let baked = dr_film::bake(&dr_film::Recipe {
|
|
||||||
film: profile,
|
|
||||||
print: paper,
|
|
||||||
exposure_ev: self
|
|
||||||
.graph
|
.graph
|
||||||
.param(
|
.param(
|
||||||
dr_pipeline::ops::film_sim::ID,
|
dr_pipeline::ops::film_sim::ID,
|
||||||
dr_pipeline::ops::film_sim::EXPOSURE,
|
dr_pipeline::ops::film_sim::EXPOSURE,
|
||||||
)
|
)
|
||||||
.unwrap_or(0.0),
|
.unwrap_or(0.0);
|
||||||
push_stops: self
|
|
||||||
.graph
|
// Only the balance moves when the same stock and paper are chosen
|
||||||
.param(
|
// again — which is what a moved exposure does — and it is three
|
||||||
dr_pipeline::ops::film_sim::ID,
|
// numbers against a bake of two spectral lookups.
|
||||||
dr_pipeline::ops::film_sim::PUSH,
|
let same = self.graph.film().filter(|f| {
|
||||||
)
|
f.stock == profile.stock && f.print.as_deref() == paper.map(|p| p.stock.as_str())
|
||||||
.unwrap_or(0.0),
|
|
||||||
print_exposure_ev: self
|
|
||||||
.graph
|
|
||||||
.param(
|
|
||||||
dr_pipeline::ops::film_sim::ID,
|
|
||||||
dr_pipeline::ops::film_sim::PRINT_EXPOSURE,
|
|
||||||
)
|
|
||||||
.unwrap_or(0.0),
|
|
||||||
});
|
});
|
||||||
|
let tables = match same {
|
||||||
|
Some(f) => {
|
||||||
|
let mut tables = f.tables.clone();
|
||||||
|
if let (Some(held), Some(paper)) = (tables.paper.as_mut(), paper) {
|
||||||
|
held.balance = dr_film::bake::print_balance(profile, paper, exposure_ev);
|
||||||
|
}
|
||||||
|
tables
|
||||||
|
}
|
||||||
|
None => self.bake_film(profile, paper, exposure_ev),
|
||||||
|
};
|
||||||
|
|
||||||
self.set_film(Some(dr_pipeline::graph::Film {
|
self.set_film(Some(dr_pipeline::graph::Film {
|
||||||
stock: profile.stock.clone(),
|
stock: profile.stock.clone(),
|
||||||
print: paper.map(|p| p.stock.clone()),
|
print: paper.map(|p| p.stock.clone()),
|
||||||
tables: dr_pipeline::ops::FilmTables {
|
tables,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3f
|
||||||
|
/// A stock and its paper as the shader binds them.
|
||||||
|
///
|
||||||
|
/// The paper, when there is one, rides behind the film: its curve as one
|
||||||
|
/// more row, its lookup stacked after the film's. See `FilmTables`.
|
||||||
|
fn bake_film(
|
||||||
|
&self,
|
||||||
|
profile: &dr_film::Profile,
|
||||||
|
paper: Option<&dr_film::Profile>,
|
||||||
|
exposure_ev: f32,
|
||||||
|
) -> dr_pipeline::ops::FilmTables {
|
||||||
|
// TRACES: FR-DEV-3f
|
||||||
|
// Grain, at the scale this photograph is being sampled at, on every
|
||||||
|
// frame it could be simulated on.
|
||||||
|
//
|
||||||
|
// A digital frame has no film format, so simulating one means choosing
|
||||||
|
// what it *would have been*. A crystal is a fixed size in micrometres,
|
||||||
|
// so the sensor's width in pixels against the frame's width says how
|
||||||
|
// much film one pixel covers — and the same emulsion on a sheet
|
||||||
|
// averages far more of them into each pixel than it does on 35 mm.
|
||||||
|
// All six are baked because the format is a per-pixel setting: a
|
||||||
|
// layer may be on another one.
|
||||||
|
let (source_width, _) = self.demosaiced.size();
|
||||||
|
let grains = dr_film::Format::ALL.map(|format| {
|
||||||
|
let pixel_size_um = format.width_um() / source_width.max(1) as f32;
|
||||||
|
dr_film::Grain::for_pixel_size(profile, pixel_size_um)
|
||||||
|
});
|
||||||
|
|
||||||
|
let baked = dr_film::bake(&dr_film::Recipe {
|
||||||
|
film: profile,
|
||||||
|
print: paper,
|
||||||
|
exposure_ev,
|
||||||
|
});
|
||||||
|
let mut curves = baked.curves;
|
||||||
|
let mut lut = baked.lut;
|
||||||
|
let paper = baked.paper.map(|p| {
|
||||||
|
curves.extend_from_slice(&p.curves);
|
||||||
|
lut.extend_from_slice(&p.lut);
|
||||||
|
dr_pipeline::ops::PaperTables {
|
||||||
|
balance: p.balance,
|
||||||
|
log_min: p.log_min,
|
||||||
|
log_max: p.log_max,
|
||||||
|
density_max: p.density_max,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
dr_pipeline::ops::FilmTables {
|
||||||
exposure_matrix: baked.exposure_matrix,
|
exposure_matrix: baked.exposure_matrix,
|
||||||
curves: baked.curves,
|
curves,
|
||||||
|
push_stations: baked.push_stations,
|
||||||
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,
|
lut,
|
||||||
density_max: baked.density_max,
|
density_max: baked.density_max,
|
||||||
lut_size: baked.lut_size,
|
lut_size: baked.lut_size,
|
||||||
grain_particles: grain.particles,
|
paper,
|
||||||
grain_density_max: grain.density_max,
|
grain_particles: grains.map(|g| g.particles),
|
||||||
grain_uniformity: grain.uniformity,
|
grain_density_max: grains[0].density_max,
|
||||||
},
|
grain_uniformity: grains[0].uniformity,
|
||||||
}));
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The stock and paper currently chosen, by id.
|
/// The stock and paper currently chosen, by id.
|
||||||
@@ -827,33 +853,51 @@ impl DevelopSession {
|
|||||||
.map(|f| (f.stock.as_str(), f.print.is_some()))
|
.map(|f| (f.stock.as_str(), f.print.is_some()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-bake if `op_index` names the film, and do nothing otherwise.
|
/// TRACES: FR-DEV-3f
|
||||||
|
/// Re-balance the enlarger if `op_index`/`param_index` name the
|
||||||
|
/// photograph's film exposure, and do nothing otherwise.
|
||||||
///
|
///
|
||||||
/// `op_index` counts over [`Self::scoped_capabilities`] — the same list
|
/// The one film slider the tables depend on: the print balance is solved
|
||||||
/// [`Self::lookup`] resolves a slider through — so that is the only list to
|
/// against the photograph's exposure, as an enlarger's filtration is set
|
||||||
/// ask. An earlier version also indexed `rows()`, which is one entry per
|
/// for the negative in front of it. Everything else the film's sliders
|
||||||
/// *parameter* and filtered by the active tab: past its end the check
|
/// move — push, print exposure, format, and exposure on a mask layer — is
|
||||||
|
/// a per-pixel setting the shader reads, and needs nothing from here.
|
||||||
|
///
|
||||||
|
/// Both indices count over [`Self::scoped_capabilities`] — the same list
|
||||||
|
/// [`Self::lookup`] resolves a slider through — so that is the only list
|
||||||
|
/// to ask. An earlier version also indexed `rows()`, which is one entry
|
||||||
|
/// per *parameter* and filtered by the active tab: past its end the check
|
||||||
/// short-circuited, the tables were never rebuilt, and the film's own
|
/// short-circuited, the tables were never rebuilt, and the film's own
|
||||||
/// sliders moved nothing at all.
|
/// sliders moved nothing at all.
|
||||||
///
|
///
|
||||||
/// The test is here rather than at the call site so the callback in
|
/// The test is here rather than at the call site so the callback in
|
||||||
/// `lib.rs` goes on naming no operation, which is the rule the whole panel
|
/// `lib.rs` goes on naming no operation, which is the rule the whole panel
|
||||||
/// is built on (ARCH §4.3a).
|
/// is built on (ARCH §4.3a).
|
||||||
pub fn rebake_film_if_affected(&mut self, op_index: i32) {
|
pub fn rebake_film_if_affected(&mut self, op_index: i32, param_index: i32) {
|
||||||
let is_film = usize::try_from(op_index)
|
if self.active_layer().is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let caps = self.scoped_capabilities();
|
||||||
|
let Some(op) = usize::try_from(op_index).ok().and_then(|i| caps.get(i)) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let param = usize::try_from(param_index)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|i| self.scoped_capabilities().get(i).map(|c| c.id))
|
.and_then(|i| op.params.get(i))
|
||||||
.is_some_and(|id| id == dr_pipeline::ops::film_sim::ID);
|
.map(|p| p.id);
|
||||||
if is_film {
|
if op.id == dr_pipeline::ops::film_sim::ID
|
||||||
|
&& param == Some(dr_pipeline::ops::film_sim::EXPOSURE)
|
||||||
|
&& self.graph.film().is_some_and(|f| f.print.is_some())
|
||||||
|
{
|
||||||
self.rebake_film();
|
self.rebake_film();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The stock, the paper, and how far it was developed.
|
/// The stock and the paper again, at the photograph's current exposure.
|
||||||
///
|
///
|
||||||
/// Push rides with the other two through every path that re-bakes, because
|
/// Only the enlarger's balance changes when they are the same stock and
|
||||||
/// it is the same kind of fact: a decision about the material rather than
|
/// paper, so this is cheap on the path a slider takes; see
|
||||||
/// an adjustment to the picture it produced.
|
/// [`Self::choose_film`].
|
||||||
pub fn rebake_film(&mut self) {
|
pub fn rebake_film(&mut self) {
|
||||||
if let Some((stock, print)) = self.film().map(|(s, p)| (s.to_string(), p)) {
|
if let Some((stock, print)) = self.film().map(|(s, p)| (s.to_string(), p)) {
|
||||||
self.choose_film(Some(&stock), print);
|
self.choose_film(Some(&stock), print);
|
||||||
@@ -1249,6 +1293,90 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3f
|
||||||
|
/// A mask layer offers the film's settings and not the stock.
|
||||||
|
///
|
||||||
|
/// The layer holds offsets to the photograph's film, which the shader
|
||||||
|
/// blends per pixel, so its sliders belong on it. The stock does not: it
|
||||||
|
/// is what the whole photograph was made on, and a picker in the layer's
|
||||||
|
/// panel would change it for every pixel.
|
||||||
|
#[test]
|
||||||
|
fn a_mask_layer_offers_film_settings_but_not_a_stock() {
|
||||||
|
let Some(ctx) = headless() else { return };
|
||||||
|
let (mut session, _) = grey_session(&ctx);
|
||||||
|
session.set_active_tab(-1);
|
||||||
|
assert!(
|
||||||
|
session.film_in_group(),
|
||||||
|
"the photograph has a stock to pick"
|
||||||
|
);
|
||||||
|
|
||||||
|
let id = session.add_gradient_mask(true).expect("radial");
|
||||||
|
session.set_active_mask(Some(&id));
|
||||||
|
assert!(!session.film_in_group(), "a layer has no stock of its own");
|
||||||
|
assert!(
|
||||||
|
session
|
||||||
|
.scoped_capabilities()
|
||||||
|
.iter()
|
||||||
|
.any(|c| c.id == dr_pipeline::ops::film_sim::ID),
|
||||||
|
"but it has the film's settings"
|
||||||
|
);
|
||||||
|
|
||||||
|
session.set_active_mask(None);
|
||||||
|
assert!(
|
||||||
|
session.film_in_group(),
|
||||||
|
"and the stock returns with the photograph"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3f
|
||||||
|
/// Only the photograph's exposure touches the tables, and only the
|
||||||
|
/// balance in them.
|
||||||
|
///
|
||||||
|
/// Everything else a film slider moves is a per-pixel setting. Re-baking
|
||||||
|
/// for those would cost two spectral lookups per tick of a slider for
|
||||||
|
/// nothing, and re-baking for a layer's exposure would rebalance the whole
|
||||||
|
/// print around one region.
|
||||||
|
#[test]
|
||||||
|
fn a_film_slider_rebalances_only_where_the_balance_depends_on_it() {
|
||||||
|
use dr_pipeline::ops::film_sim;
|
||||||
|
let Some(ctx) = headless() else { return };
|
||||||
|
let (mut session, _) = grey_session(&ctx);
|
||||||
|
session.set_active_tab(-1);
|
||||||
|
session.choose_film(Some("kodak_portra_400"), true);
|
||||||
|
let balance = |s: &DevelopSession| {
|
||||||
|
s.graph
|
||||||
|
.film()
|
||||||
|
.and_then(|f| f.tables.paper)
|
||||||
|
.map(|p| p.balance)
|
||||||
|
};
|
||||||
|
let before = balance(&session).expect("a printed negative has a balance");
|
||||||
|
|
||||||
|
let caps = session.scoped_capabilities();
|
||||||
|
let op = caps
|
||||||
|
.iter()
|
||||||
|
.position(|c| c.id == film_sim::ID)
|
||||||
|
.expect("film");
|
||||||
|
let param = |id| caps[op].params.iter().position(|p| p.id == id).unwrap() as i32;
|
||||||
|
|
||||||
|
session.graph.set_param(film_sim::ID, film_sim::PUSH, 1.0);
|
||||||
|
session.rebake_film_if_affected(op as i32, param(film_sim::PUSH));
|
||||||
|
assert_eq!(
|
||||||
|
balance(&session),
|
||||||
|
Some(before),
|
||||||
|
"push is not the enlarger's business"
|
||||||
|
);
|
||||||
|
|
||||||
|
session
|
||||||
|
.graph
|
||||||
|
.set_param(film_sim::ID, film_sim::EXPOSURE, 1.0);
|
||||||
|
session.rebake_film_if_affected(op as i32, param(film_sim::EXPOSURE));
|
||||||
|
assert_ne!(
|
||||||
|
balance(&session),
|
||||||
|
Some(before),
|
||||||
|
"the balance follows the exposure"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// A frame black on the left half and white on the right, at `size`
|
/// A frame black on the left half and white on the right, at `size`
|
||||||
/// square. Both ends of the histogram are occupied and both clipping
|
/// square. Both ends of the histogram are occupied and both clipping
|
||||||
/// counters are non-zero, and cropping to one half leaves exactly one of
|
/// counters are non-zero, and cropping to one half leaves exactly one of
|
||||||
|
|||||||
@@ -90,7 +90,15 @@ impl DevelopSession {
|
|||||||
/// the operation — what is this control *about* — and the panel is not
|
/// the operation — what is this control *about* — and the panel is not
|
||||||
/// allowed to know. It asks the descriptor, so a stock that were ever
|
/// allowed to know. It asks the descriptor, so a stock that were ever
|
||||||
/// re-declared as something other than an effect would move on its own.
|
/// re-declared as something other than an effect would move on its own.
|
||||||
|
///
|
||||||
|
/// Never on a mask layer. A layer holds the film's *settings* — a region
|
||||||
|
/// pushed further, or burned in under the enlarger — but the stock is what
|
||||||
|
/// the whole photograph was made on, and a picker in a layer's panel would
|
||||||
|
/// change it for every pixel while looking as though it changed some.
|
||||||
pub fn film_in_group(&self) -> bool {
|
pub fn film_in_group(&self) -> bool {
|
||||||
|
if self.active_layer().is_some() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
let Some(active) = self.active_tab else {
|
let Some(active) = self.active_tab else {
|
||||||
// "All" shows everything, the stock included.
|
// "All" shows everything, the stock included.
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -445,12 +445,11 @@ fn wire_adjustments(window: &AppWindow, w: &DevelopWiring) {
|
|||||||
if let Some(s) = session.borrow_mut().as_mut() {
|
if let Some(s) = session.borrow_mut().as_mut() {
|
||||||
s.set_param(op, param, value);
|
s.set_param(op, param, value);
|
||||||
// TRACES: FR-DEV-3f
|
// TRACES: FR-DEV-3f
|
||||||
// The film's own exposures ride *inside* the baked tables
|
// The print balance is solved against the photograph's
|
||||||
// rather than arriving as uniforms, because the print balance
|
// exposure — an enlarger's filtration depends on how the
|
||||||
// is solved against them — an enlarger's filtration depends on
|
// negative was exposed — so moving it re-solves the
|
||||||
// how the negative was exposed. So moving one has to rebuild
|
// balance, which no other slider in the panel does.
|
||||||
// the lookup, which no other slider in the panel does.
|
s.rebake_film_if_affected(op, param);
|
||||||
s.rebake_film_if_affected(op);
|
|
||||||
}
|
}
|
||||||
sync_rows(&w, &rows, &session);
|
sync_rows(&w, &rows, &session);
|
||||||
redraw(&w);
|
redraw(&w);
|
||||||
@@ -506,7 +505,7 @@ fn wire_adjustments(window: &AppWindow, w: &DevelopWiring) {
|
|||||||
(row.value + direction.signum() as f32 * step).clamp(row.minimum, row.maximum);
|
(row.value + direction.signum() as f32 * step).clamp(row.minimum, row.maximum);
|
||||||
if let Some(s) = session.borrow_mut().as_mut() {
|
if let Some(s) = session.borrow_mut().as_mut() {
|
||||||
s.set_param(op, param, value);
|
s.set_param(op, param, value);
|
||||||
s.rebake_film_if_affected(op);
|
s.rebake_film_if_affected(op, param);
|
||||||
}
|
}
|
||||||
sync_rows(&w, &rows, &session);
|
sync_rows(&w, &rows, &session);
|
||||||
redraw(&w);
|
redraw(&w);
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
//! is a function of the image id, so the next review finds each such file
|
//! is a function of the image id, so the next review finds each such file
|
||||||
//! there, and consolidating again treats it as already moved.
|
//! there, and consolidating again treats it as already moved.
|
||||||
|
|
||||||
|
use crate::executors::{self, Executor};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::mpsc::{Receiver, Sender};
|
use std::sync::mpsc::{Receiver, Sender};
|
||||||
@@ -492,7 +493,7 @@ pub fn spawn_check(
|
|||||||
stop: Arc<AtomicBool>,
|
stop: Arc<AtomicBool>,
|
||||||
) -> Receiver<DupMessage> {
|
) -> Receiver<DupMessage> {
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
std::thread::spawn(move || {
|
executors::spawn(Executor::Network, "dupes", move || {
|
||||||
let stopped = run_check(&conn, &catalog_path, cache_dir, groups, &stop, &tx).err();
|
let stopped = run_check(&conn, &catalog_path, cache_dir, groups, &stop, &tx).err();
|
||||||
let _ = tx.send(DupMessage::Finished { stopped });
|
let _ = tx.send(DupMessage::Finished { stopped });
|
||||||
});
|
});
|
||||||
@@ -758,7 +759,7 @@ pub fn spawn_consolidate(
|
|||||||
stop: Arc<AtomicBool>,
|
stop: Arc<AtomicBool>,
|
||||||
) -> Receiver<DupMessage> {
|
) -> Receiver<DupMessage> {
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
std::thread::spawn(move || {
|
executors::spawn(Executor::Network, "consolidate", move || {
|
||||||
let stopped = run_consolidate(&conn, &catalog_path, cache_dir, plans, &stop, &tx).err();
|
let stopped = run_consolidate(&conn, &catalog_path, cache_dir, plans, &stop, &tx).err();
|
||||||
let _ = tx.send(DupMessage::Finished { stopped });
|
let _ = tx.send(DupMessage::Finished { stopped });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
//! TRACES: NFR-ARCH-1 | R4
|
||||||
|
//! The executors: the one place that names them, states their thread counts,
|
||||||
|
//! and starts the threads that belong to them.
|
||||||
|
//!
|
||||||
|
//! `architecture.md §7.1` is the design; this is where the code says the same
|
||||||
|
//! thing. There are five, and every long-lived thread the app starts belongs
|
||||||
|
//! to one of them:
|
||||||
|
//!
|
||||||
|
//! | Executor | Threads | Work |
|
||||||
|
//! |---|---|---|
|
||||||
|
//! | [`Executor::Ui`] | 1 | Slint's event loop. Never blocks. |
|
||||||
|
//! | [`Executor::GpuSubmit`] | 1 | Command encoding and queue submission |
|
||||||
|
//! | [`Executor::Decode`] | cores − 2 | RAW decode, previews, and the CPU passes over them |
|
||||||
|
//! | [`Executor::Io`] | 4 | Catalog, sidecar, cache, filesystem |
|
||||||
|
//! | [`Executor::Network`] | 2 | Nextcloud and other remote transfer |
|
||||||
|
//!
|
||||||
|
//! # What this module does and does not enforce
|
||||||
|
//!
|
||||||
|
//! It **names**: [`spawn`] gives every thread `<executor>:<role>` — `net:sync`,
|
||||||
|
//! `decode:thumbs` — which is what a panic message, `top -H` and a profiler
|
||||||
|
//! show. Before it, most workers were `<unnamed>`, and a panic on one said
|
||||||
|
//! nothing about which of thirty jobs it was.
|
||||||
|
//!
|
||||||
|
//! It **marks**: each thread knows which executor it belongs to
|
||||||
|
//! ([`current`]), and the UI thread is marked where the event loop starts
|
||||||
|
//! ([`mark_ui_thread`], called at the top of `run`). [`assert_not_ui`] is the
|
||||||
|
//! guard every blocking helper calls; see there.
|
||||||
|
//!
|
||||||
|
//! It does **not yet bound** the counts. A job still gets a thread of its own
|
||||||
|
//! when it starts, as it did before this module existed, so the counts in the
|
||||||
|
//! table are the budget the design states rather than a limit the code holds
|
||||||
|
//! — [`Executor::threads`] is what a pool will be sized from when one exists.
|
||||||
|
//! Pooling, and the priority between executors that NFR-ARCH-2 asks for, is a
|
||||||
|
//! change of behaviour; naming them first is what makes that change visible.
|
||||||
|
//!
|
||||||
|
//! # Which executor a job belongs to
|
||||||
|
//!
|
||||||
|
//! By what it spends its time on. One that decodes images or runs a CPU pass
|
||||||
|
//! over their pixels or embeddings — the thumbnail and metadata sweeps, face
|
||||||
|
//! indexing, grouping, repairs — is [`Executor::Decode`], even when it fetches
|
||||||
|
//! the bytes first. One that drives the GPU — batch export, a merge — is
|
||||||
|
//! [`Executor::GpuSubmit`]. One whose work is moving files or metadata to or
|
||||||
|
//! from a server — sync, sidecars, fetches, trash, login — is
|
||||||
|
//! [`Executor::Network`], even when it touches the catalog on the way. The
|
||||||
|
//! rest — opening the catalog, a backup, a local import, relaying a channel to
|
||||||
|
//! the log — is [`Executor::Io`].
|
||||||
|
|
||||||
|
use std::cell::Cell;
|
||||||
|
use std::thread::JoinHandle;
|
||||||
|
|
||||||
|
/// One of the app's executors. See the module documentation for the table.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Executor {
|
||||||
|
/// The Slint event loop. One thread, and it never blocks (NFR-P9).
|
||||||
|
Ui,
|
||||||
|
/// Command encoding and queue submission.
|
||||||
|
GpuSubmit,
|
||||||
|
/// RAW decode, preview extraction, and CPU-bound passes over the result.
|
||||||
|
Decode,
|
||||||
|
/// Catalog, sidecars, caches, the filesystem.
|
||||||
|
Io,
|
||||||
|
/// Transfer to and from a remote library.
|
||||||
|
Network,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Executor {
|
||||||
|
/// Every executor, in the order §7.1 lists them.
|
||||||
|
pub const ALL: [Executor; 5] = [
|
||||||
|
Executor::Ui,
|
||||||
|
Executor::GpuSubmit,
|
||||||
|
Executor::Decode,
|
||||||
|
Executor::Io,
|
||||||
|
Executor::Network,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The prefix of every thread name on this executor.
|
||||||
|
///
|
||||||
|
/// Short because Linux keeps fifteen bytes of a thread name, and the role
|
||||||
|
/// after the colon is the part worth reading.
|
||||||
|
pub const fn name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Executor::Ui => "ui",
|
||||||
|
Executor::GpuSubmit => "gpu",
|
||||||
|
Executor::Decode => "decode",
|
||||||
|
Executor::Io => "io",
|
||||||
|
Executor::Network => "net",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The thread count §7.1 states for this executor, on this machine.
|
||||||
|
///
|
||||||
|
/// - **UI, 1**: Slint has one event loop, on the thread that created the
|
||||||
|
/// window.
|
||||||
|
/// - **GPU submit, 1**: one queue; more submitters only contend for it.
|
||||||
|
/// - **Decode, cores − 2**: CPU-bound, so as many as there are cores,
|
||||||
|
/// less one for the UI and one for the GPU submitter, so neither is
|
||||||
|
/// starved by a sweep. Never fewer than one.
|
||||||
|
/// - **I/O, 4**: enough to overlap a catalog write with file reads; more
|
||||||
|
/// only queue on the same disk.
|
||||||
|
/// - **Network, 2**: one transfer and one metadata request in flight; a
|
||||||
|
/// WebDAV server serialises most of the rest per account.
|
||||||
|
pub fn threads(self) -> usize {
|
||||||
|
match self {
|
||||||
|
Executor::Ui | Executor::GpuSubmit => 1,
|
||||||
|
Executor::Decode => std::thread::available_parallelism()
|
||||||
|
.map_or(1, |n| n.get())
|
||||||
|
.saturating_sub(2)
|
||||||
|
.max(1),
|
||||||
|
Executor::Io => 4,
|
||||||
|
Executor::Network => 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
/// Which executor this thread belongs to; `None` for threads this module
|
||||||
|
/// did not start — the test harness, a library's own workers.
|
||||||
|
static CURRENT: Cell<Option<Executor>> = const { Cell::new(None) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The executor the calling thread belongs to, if it was started here or is
|
||||||
|
/// the marked UI thread.
|
||||||
|
pub fn current() -> Option<Executor> {
|
||||||
|
CURRENT.with(Cell::get)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark the calling thread as the UI executor.
|
||||||
|
///
|
||||||
|
/// Called once, at the top of `run`, on the thread that goes on to create the
|
||||||
|
/// window and enter Slint's event loop — on desktop the main thread, on
|
||||||
|
/// Android the one `android_main` runs on.
|
||||||
|
pub fn mark_ui_thread() {
|
||||||
|
CURRENT.with(|c| c.set(Some(Executor::Ui)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: NFR-ARCH-1 | NFR-P9
|
||||||
|
/// Fail loudly, in debug and test builds, if the calling thread is the UI
|
||||||
|
/// thread. `what` names the blocking call, for the message.
|
||||||
|
///
|
||||||
|
/// Every blocking helper calls this before it blocks: a `block_on` on the UI
|
||||||
|
/// thread is a frozen window for as long as the future takes, which for a
|
||||||
|
/// network call is unbounded. A debug assertion rather than a check in release
|
||||||
|
/// because the fault is in the code, not the input — a release build that met
|
||||||
|
/// it would still freeze rather than crash, which is no worse than before, and
|
||||||
|
/// every test and debug run that reaches the call finds it.
|
||||||
|
#[track_caller]
|
||||||
|
pub fn assert_not_ui(what: &str) {
|
||||||
|
debug_assert!(
|
||||||
|
current() != Some(Executor::Ui),
|
||||||
|
"{what} on the UI thread: it would block the event loop (NFR-ARCH-1). \
|
||||||
|
Move it onto a worker with executors::spawn."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a thread on `executor`, named `<executor>:<role>`.
|
||||||
|
///
|
||||||
|
/// A drop-in for `std::thread::spawn`, and like it panics if the OS will not
|
||||||
|
/// give a thread. `role` is what the job is — `scan`, `sync`, `thumbs` — and
|
||||||
|
/// should be short: the name is cut to fifteen bytes where the OS keeps it.
|
||||||
|
pub fn spawn<F, T>(executor: Executor, role: &str, f: F) -> JoinHandle<T>
|
||||||
|
where
|
||||||
|
F: FnOnce() -> T + Send + 'static,
|
||||||
|
T: Send + 'static,
|
||||||
|
{
|
||||||
|
try_spawn(executor, role, f)
|
||||||
|
.unwrap_or_else(|e| panic!("spawning {}:{role}: {e}", executor.name()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`spawn`], for a caller that can carry on without the thread.
|
||||||
|
pub fn try_spawn<F, T>(executor: Executor, role: &str, f: F) -> std::io::Result<JoinHandle<T>>
|
||||||
|
where
|
||||||
|
F: FnOnce() -> T + Send + 'static,
|
||||||
|
T: Send + 'static,
|
||||||
|
{
|
||||||
|
debug_assert!(
|
||||||
|
executor != Executor::Ui,
|
||||||
|
"the UI executor is the event loop's thread; it is marked, not spawned"
|
||||||
|
);
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name(format!("{}:{role}", executor.name()))
|
||||||
|
.spawn(move || {
|
||||||
|
CURRENT.with(|c| c.set(Some(executor)));
|
||||||
|
f()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// TRACES: NFR-ARCH-1
|
||||||
|
/// Every executor has a name and at least one thread, and the fixed
|
||||||
|
/// counts are the ones §7.1 states.
|
||||||
|
#[test]
|
||||||
|
fn the_executors_and_their_counts_are_the_ones_the_design_states() {
|
||||||
|
let names: Vec<_> = Executor::ALL.iter().map(|e| e.name()).collect();
|
||||||
|
assert_eq!(names, ["ui", "gpu", "decode", "io", "net"]);
|
||||||
|
assert!(Executor::ALL.iter().all(|e| e.threads() >= 1));
|
||||||
|
assert_eq!(Executor::Ui.threads(), 1);
|
||||||
|
assert_eq!(Executor::GpuSubmit.threads(), 1);
|
||||||
|
assert_eq!(Executor::Io.threads(), 4);
|
||||||
|
assert_eq!(Executor::Network.threads(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: NFR-ARCH-1
|
||||||
|
/// A spawned thread carries its executor's name and knows which executor
|
||||||
|
/// it is on; the spawning thread is unaffected.
|
||||||
|
#[test]
|
||||||
|
fn a_spawned_thread_is_named_and_marked() {
|
||||||
|
let (name, on) = spawn(Executor::Network, "sync", || {
|
||||||
|
(std::thread::current().name().map(str::to_owned), current())
|
||||||
|
})
|
||||||
|
.join()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(name.as_deref(), Some("net:sync"));
|
||||||
|
assert_eq!(on, Some(Executor::Network));
|
||||||
|
assert_ne!(current(), Some(Executor::Network));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: NFR-ARCH-1 | NFR-P9
|
||||||
|
/// A `block_on` on the thread marked as the UI executor panics, and says
|
||||||
|
/// why. On a thread of its own, because the mark is thread-local and with
|
||||||
|
/// `--test-threads=1` every test runs on the harness's main thread.
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
#[test]
|
||||||
|
fn block_on_the_ui_thread_panics() {
|
||||||
|
let outcome = std::thread::spawn(|| {
|
||||||
|
mark_ui_thread();
|
||||||
|
let rt = crate::net_runtime::build().expect("runtime");
|
||||||
|
rt.block_on(async { 1 })
|
||||||
|
})
|
||||||
|
.join();
|
||||||
|
let payload = outcome.expect_err("a block_on on the UI thread must panic");
|
||||||
|
let message = payload
|
||||||
|
.downcast_ref::<String>()
|
||||||
|
.cloned()
|
||||||
|
.or_else(|| payload.downcast_ref::<&str>().map(|s| s.to_string()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
assert!(message.contains("UI thread"), "message: {message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: NFR-ARCH-1
|
||||||
|
/// The same `block_on` on a worker is what the network runtime is for.
|
||||||
|
#[test]
|
||||||
|
fn block_on_a_worker_is_allowed() {
|
||||||
|
let answer = spawn(Executor::Network, "test", || {
|
||||||
|
let rt = crate::net_runtime::build().expect("runtime");
|
||||||
|
rt.block_on(async { 42 })
|
||||||
|
})
|
||||||
|
.join()
|
||||||
|
.expect("a worker may block");
|
||||||
|
assert_eq!(answer, 42);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,7 @@
|
|||||||
//! same file from the grid does — subject, both times, to what the settings
|
//! same file from the grid does — subject, both times, to what the settings
|
||||||
//! allow (FR-EXP-8).
|
//! allow (FR-EXP-8).
|
||||||
|
|
||||||
|
use crate::executors::{self, Executor};
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
@@ -414,7 +415,7 @@ pub fn spawn_upload(
|
|||||||
) -> std::sync::mpsc::Receiver<UploadMessage> {
|
) -> std::sync::mpsc::Receiver<UploadMessage> {
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
executors::spawn(Executor::Network, "upload", move || {
|
||||||
let rt = match crate::net_runtime::build() {
|
let rt = match crate::net_runtime::build() {
|
||||||
Ok(rt) => rt,
|
Ok(rt) => rt,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -665,7 +666,9 @@ pub enum BatchMessage {
|
|||||||
/// Export a selection on a thread of its own.
|
/// Export a selection on a thread of its own.
|
||||||
pub fn spawn_batch(request: BatchRequest, cancel: Cancel) -> Receiver<BatchMessage> {
|
pub fn spawn_batch(request: BatchRequest, cancel: Cancel) -> Receiver<BatchMessage> {
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
std::thread::spawn(move || run(request, &cancel, &tx));
|
executors::spawn(Executor::GpuSubmit, "export", move || {
|
||||||
|
run(request, &cancel, &tx)
|
||||||
|
});
|
||||||
rx
|
rx
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
//! on every one. It therefore runs debounced, when detection has been idle and
|
//! on every one. It therefore runs debounced, when detection has been idle and
|
||||||
//! the face count has moved materially (catalog.md §10.2).
|
//! the face count has moved materially (catalog.md §10.2).
|
||||||
|
|
||||||
|
use crate::executors::{self, Executor};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::mpsc::{Receiver, Sender};
|
use std::sync::mpsc::{Receiver, Sender};
|
||||||
|
|
||||||
@@ -816,7 +817,7 @@ pub fn spawn_store_face_sweep(
|
|||||||
) -> Receiver<FaceSweepMessage> {
|
) -> Receiver<FaceSweepMessage> {
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
executors::spawn(Executor::Decode, "faces", move || {
|
||||||
let finish_empty = |tx: &Sender<FaceSweepMessage>| {
|
let finish_empty = |tx: &Sender<FaceSweepMessage>| {
|
||||||
let _ = tx.send(FaceSweepMessage::Finished {
|
let _ = tx.send(FaceSweepMessage::Finished {
|
||||||
images: 0,
|
images: 0,
|
||||||
@@ -1348,7 +1349,7 @@ pub fn spawn_grouping_preview(
|
|||||||
) -> Receiver<PreviewMessage> {
|
) -> Receiver<PreviewMessage> {
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
executors::spawn(Executor::Decode, "grouping", move || {
|
||||||
let msg = match Catalog::open(&catalog_path) {
|
let msg = match Catalog::open(&catalog_path) {
|
||||||
Ok(catalog) => match preview_grouping(&catalog, &model_id, &grouping) {
|
Ok(catalog) => match preview_grouping(&catalog, &model_id, &grouping) {
|
||||||
Ok(p) => PreviewMessage::Ready(p),
|
Ok(p) => PreviewMessage::Ready(p),
|
||||||
@@ -1397,7 +1398,7 @@ pub fn spawn_recluster(
|
|||||||
) -> Receiver<ReclusterMessage> {
|
) -> Receiver<ReclusterMessage> {
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
executors::spawn(Executor::Decode, "recluster", move || {
|
||||||
let catalog = match Catalog::open(&catalog_path) {
|
let catalog = match Catalog::open(&catalog_path) {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
@@ -48,6 +48,7 @@
|
|||||||
//! record afterwards is each file's digest (`dr_catalog::dedup`), which the
|
//! record afterwards is each file's digest (`dr_catalog::dedup`), which the
|
||||||
//! scan cannot know because it never reads a whole file.
|
//! scan cannot know because it never reads a whole file.
|
||||||
|
|
||||||
|
use crate::executors::{self, Executor};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::mpsc::{Receiver, Sender};
|
use std::sync::mpsc::{Receiver, Sender};
|
||||||
@@ -228,9 +229,7 @@ pub struct Outcome {
|
|||||||
/// does, and the caller holds it.
|
/// does, and the caller holds it.
|
||||||
pub fn spawn(request: Request, cancel: Cancel) -> Receiver<Message> {
|
pub fn spawn(request: Request, cancel: Cancel) -> Receiver<Message> {
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
std::thread::Builder::new()
|
executors::try_spawn(Executor::Io, "import", move || {
|
||||||
.name("import".into())
|
|
||||||
.spawn(move || {
|
|
||||||
if let Err(e) = run(request, &cancel, &tx) {
|
if let Err(e) = run(request, &cancel, &tx) {
|
||||||
let _ = tx.send(Message::Failed(e));
|
let _ = tx.send(Message::Failed(e));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
//! the develop window's wiring. The model holds the state machine and is
|
//! the develop window's wiring. The model holds the state machine and is
|
||||||
//! tested headless; this module only moves values across the boundary.
|
//! tested headless; this module only moves values across the boundary.
|
||||||
|
|
||||||
|
use crate::executors::{self, Executor};
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
@@ -464,7 +465,7 @@ fn spawn_login(weak: slint::Weak<AppWindow>, ctl: Rc<LaunchController>, server:
|
|||||||
// the thread boundary.
|
// the thread boundary.
|
||||||
let (tx, rx) = std::sync::mpsc::channel::<LoginMessage>();
|
let (tx, rx) = std::sync::mpsc::channel::<LoginMessage>();
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
executors::spawn(Executor::Network, "login", move || {
|
||||||
// A panic anywhere below would unwind the thread, drop `tx`, and leave
|
// A panic anywhere below would unwind the thread, drop `tx`, and leave
|
||||||
// the UI with nothing but a closed channel — which it can only report
|
// the UI with nothing but a closed channel — which it can only report
|
||||||
// as "failed unexpectedly", losing the one piece of information that
|
// as "failed unexpectedly", losing the one piece of information that
|
||||||
@@ -491,12 +492,7 @@ fn spawn_login(weak: slint::Weak<AppWindow>, ctl: Rc<LaunchController>, server:
|
|||||||
// Only IO and time are enabled; `enable_all()` would also start the
|
// Only IO and time are enabled; `enable_all()` would also start the
|
||||||
// signal driver, which wants process-wide signal handling that an
|
// signal driver, which wants process-wide signal handling that an
|
||||||
// Android app's runtime already owns.
|
// Android app's runtime already owns.
|
||||||
let rt = match tokio::runtime::Builder::new_multi_thread()
|
let rt = match crate::net_runtime::build() {
|
||||||
.worker_threads(1)
|
|
||||||
.enable_io()
|
|
||||||
.enable_time()
|
|
||||||
.build()
|
|
||||||
{
|
|
||||||
Ok(rt) => rt,
|
Ok(rt) => rt,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = tx.send(LoginMessage::Failed(format!("tokio runtime: {e}")));
|
let _ = tx.send(LoginMessage::Failed(format!("tokio runtime: {e}")));
|
||||||
@@ -538,17 +534,12 @@ fn spawn_direct_login(
|
|||||||
) {
|
) {
|
||||||
let (tx, rx) = std::sync::mpsc::channel::<LoginMessage>();
|
let (tx, rx) = std::sync::mpsc::channel::<LoginMessage>();
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
executors::spawn(Executor::Network, "login", move || {
|
||||||
let panic_tx = tx.clone();
|
let panic_tx = tx.clone();
|
||||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
|
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
|
||||||
// Multi-thread for the reason the browser flow is: a current-thread
|
// Multi-thread for the reason the browser flow is: a current-thread
|
||||||
// runtime left reqwest's future unpolled on Android.
|
// runtime left reqwest's future unpolled on Android.
|
||||||
let rt = match tokio::runtime::Builder::new_multi_thread()
|
let rt = match crate::net_runtime::build() {
|
||||||
.worker_threads(1)
|
|
||||||
.enable_io()
|
|
||||||
.enable_time()
|
|
||||||
.build()
|
|
||||||
{
|
|
||||||
Ok(rt) => rt,
|
Ok(rt) => rt,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = tx.send(LoginMessage::Failed(format!("tokio runtime: {e}")));
|
let _ = tx.send(LoginMessage::Failed(format!("tokio runtime: {e}")));
|
||||||
@@ -604,7 +595,7 @@ fn spawn_direct_login(
|
|||||||
/// The body of the login flow, split out so the worker above can wrap it in
|
/// The body of the login flow, split out so the worker above can wrap it in
|
||||||
/// `catch_unwind` without a deeply nested closure.
|
/// `catch_unwind` without a deeply nested closure.
|
||||||
fn run_login_flow(
|
fn run_login_flow(
|
||||||
rt: tokio::runtime::Runtime,
|
rt: crate::net_runtime::NetRuntime,
|
||||||
tx: std::sync::mpsc::Sender<LoginMessage>,
|
tx: std::sync::mpsc::Sender<LoginMessage>,
|
||||||
server: String,
|
server: String,
|
||||||
step: &dyn Fn(&str),
|
step: &dyn Fn(&str),
|
||||||
@@ -777,16 +768,12 @@ fn spawn_folder_list(weak: slint::Weak<AppWindow>, ctl: Rc<LaunchController>, pa
|
|||||||
|
|
||||||
let (tx, rx) = std::sync::mpsc::channel::<Result<Vec<String>, String>>();
|
let (tx, rx) = std::sync::mpsc::channel::<Result<Vec<String>, String>>();
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
executors::spawn(Executor::Network, "folders", move || {
|
||||||
// Multi-thread for the same reason as the login worker: a
|
// Multi-thread for the same reason as the login worker: a
|
||||||
// current-thread runtime left reqwest's connection future unpolled on
|
// current-thread runtime left reqwest's connection future unpolled on
|
||||||
// Android, so the await never resolved and the thread stopped without
|
// Android, so the await never resolved and the thread stopped without
|
||||||
// failing or returning.
|
// failing or returning.
|
||||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
let rt = crate::net_runtime::build();
|
||||||
.worker_threads(1)
|
|
||||||
.enable_io()
|
|
||||||
.enable_time()
|
|
||||||
.build();
|
|
||||||
let Ok(rt) = rt else {
|
let Ok(rt) = rt else {
|
||||||
let _ = tx.send(Err("runtime".into()));
|
let _ = tx.send(Err("runtime".into()));
|
||||||
return;
|
return;
|
||||||
|
|||||||
+6
-1
@@ -33,6 +33,7 @@ mod develop_ui;
|
|||||||
mod display_ui;
|
mod display_ui;
|
||||||
mod duplicates;
|
mod duplicates;
|
||||||
mod duplicates_ui;
|
mod duplicates_ui;
|
||||||
|
pub mod executors;
|
||||||
mod export;
|
mod export;
|
||||||
pub mod faces;
|
pub mod faces;
|
||||||
mod folder_dialog;
|
mod folder_dialog;
|
||||||
@@ -746,7 +747,7 @@ fn drain_outbox(library: &Rc<library_ui::LibraryController>) {
|
|||||||
|
|
||||||
let root = conn.account.root.clone();
|
let root = conn.account.root.clone();
|
||||||
let rx = export::spawn_upload(conn, root, outbox);
|
let rx = export::spawn_upload(conn, root, outbox);
|
||||||
std::thread::spawn(move || {
|
executors::spawn(executors::Executor::Io, "upload-log", move || {
|
||||||
while let Ok(msg) = rx.recv() {
|
while let Ok(msg) = rx.recv() {
|
||||||
match msg {
|
match msg {
|
||||||
export::UploadMessage::Status(s) => log::info!("export: {s}"),
|
export::UploadMessage::Status(s) => log::info!("export: {s}"),
|
||||||
@@ -1142,6 +1143,10 @@ fn shared_gpu() -> Option<dr_gpu::GpuContext> {
|
|||||||
/// TRACES: M-13 | M-14
|
/// TRACES: M-13 | M-14
|
||||||
/// Build and run the viewer.
|
/// Build and run the viewer.
|
||||||
pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||||
|
// This thread creates the window and enters Slint's event loop: it is the
|
||||||
|
// UI executor, and every blocking helper asserts it is not (NFR-ARCH-1).
|
||||||
|
executors::mark_ui_thread();
|
||||||
|
|
||||||
// Mutable because the browsing list has two sources: the command line at
|
// Mutable because the browsing list has two sources: the command line at
|
||||||
// startup, and whatever the library grid is showing when a cell is
|
// startup, and whatever the library grid is showing when a cell is
|
||||||
// clicked. Opening from the grid replaces this so next/previous walk the
|
// clicked. Opening from the grid replaces this so next/previous walk the
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
//! Walking a remote library into the catalog, and pulling other devices'
|
//! Walking a remote library into the catalog, and pulling other devices'
|
||||||
//! judgements out of the sidecars the walk finds along the way.
|
//! judgements out of the sidecars the walk finds along the way.
|
||||||
|
|
||||||
|
use crate::executors::{self, Executor};
|
||||||
use dr_catalog::Catalog;
|
use dr_catalog::Catalog;
|
||||||
use dr_sync::{Connection, RemoteBackend, RemoteId, RemotePath};
|
use dr_sync::{Connection, RemoteBackend, RemoteId, RemotePath};
|
||||||
use dr_types::FormatFilter;
|
use dr_types::FormatFilter;
|
||||||
@@ -77,7 +78,7 @@ pub fn spawn_scan(
|
|||||||
) -> Receiver<ScanMessage> {
|
) -> Receiver<ScanMessage> {
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
executors::spawn(Executor::Network, "scan", move || {
|
||||||
let started = std::time::Instant::now();
|
let started = std::time::Instant::now();
|
||||||
if let Err(e) = run_scan(&tx, conn, root, filter, catalog_path, started) {
|
if let Err(e) = run_scan(&tx, conn, root, filter, catalog_path, started) {
|
||||||
let _ = tx.send(ScanMessage::Failed {
|
let _ = tx.send(ScanMessage::Failed {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
//! Writing local edits out to the catalog's sidecar outbox, and draining
|
//! Writing local edits out to the catalog's sidecar outbox, and draining
|
||||||
//! that outbox to the remote once a connection is available.
|
//! that outbox to the remote once a connection is available.
|
||||||
|
|
||||||
|
use crate::executors::{self, Executor};
|
||||||
use crate::sidecar_cache::SidecarCache;
|
use crate::sidecar_cache::SidecarCache;
|
||||||
use dr_sync::{Connection, RemoteBackend, RemoteError, RemoteId, RemotePath};
|
use dr_sync::{Connection, RemoteBackend, RemoteError, RemoteId, RemotePath};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@@ -138,7 +139,7 @@ pub fn spawn_sidecar_writes(
|
|||||||
) -> Receiver<SidecarMessage> {
|
) -> Receiver<SidecarMessage> {
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
executors::spawn(Executor::Network, "sc-write", move || {
|
||||||
let cache = SidecarCache::open(cache_dir);
|
let cache = SidecarCache::open(cache_dir);
|
||||||
|
|
||||||
// The runtime and the backend are only needed to *upload*. Offline,
|
// The runtime and the backend are only needed to *upload*. Offline,
|
||||||
@@ -451,7 +452,7 @@ pub(super) async fn write_one_sidecar_online(
|
|||||||
pub fn spawn_outbox_drain(conn: Connection, cache_dir: PathBuf) -> Receiver<SidecarMessage> {
|
pub fn spawn_outbox_drain(conn: Connection, cache_dir: PathBuf) -> Receiver<SidecarMessage> {
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
executors::spawn(Executor::Network, "outbox", move || {
|
||||||
let cache = SidecarCache::open(cache_dir);
|
let cache = SidecarCache::open(cache_dir);
|
||||||
let queued = cache.pending();
|
let queued = cache.pending();
|
||||||
if queued.is_empty() {
|
if queued.is_empty() {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
//! The background sweeps: metadata extraction and thumbnail generation
|
//! The background sweeps: metadata extraction and thumbnail generation
|
||||||
//! for whatever the catalog still owes, a chunk at a time.
|
//! for whatever the catalog still owes, a chunk at a time.
|
||||||
|
|
||||||
|
use crate::executors::{self, Executor};
|
||||||
use dr_catalog::Catalog;
|
use dr_catalog::Catalog;
|
||||||
use dr_sync::{Connection, RemoteBackend, RemoteId, RemotePath};
|
use dr_sync::{Connection, RemoteBackend, RemoteId, RemotePath};
|
||||||
use dr_thumbs::ThumbStore;
|
use dr_thumbs::ThumbStore;
|
||||||
@@ -280,7 +281,7 @@ pub fn spawn_sweep(conn: Connection, catalog_path: PathBuf) -> Receiver<SweepMes
|
|||||||
// The one place this job names a decoder; everything below takes it.
|
// The one place this job names a decoder; everything below takes it.
|
||||||
let decoder = dr_decode::default();
|
let decoder = dr_decode::default();
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
executors::spawn(Executor::Decode, "metadata", move || {
|
||||||
let catalog = match Catalog::open(&catalog_path) {
|
let catalog = match Catalog::open(&catalog_path) {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -602,7 +603,7 @@ pub fn spawn_thumbnail_sweep(
|
|||||||
// The one place this job names a decoder; everything below takes it.
|
// The one place this job names a decoder; everything below takes it.
|
||||||
let decoder = dr_decode::default();
|
let decoder = dr_decode::default();
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
executors::spawn(Executor::Decode, "thumb-sweep", move || {
|
||||||
let finish_empty = |tx: &Sender<ThumbSweepMessage>| {
|
let finish_empty = |tx: &Sender<ThumbSweepMessage>| {
|
||||||
let _ = tx.send(ThumbSweepMessage::Finished {
|
let _ = tx.send(ThumbSweepMessage::Finished {
|
||||||
stored: 0,
|
stored: 0,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
//! cache, dehydration, and the prefetcher that keeps the grid ahead of
|
//! cache, dehydration, and the prefetcher that keeps the grid ahead of
|
||||||
//! scrolling.
|
//! scrolling.
|
||||||
|
|
||||||
|
use crate::executors::{self, Executor};
|
||||||
use crate::sidecar_cache::SidecarCache;
|
use crate::sidecar_cache::SidecarCache;
|
||||||
use dr_catalog::Catalog;
|
use dr_catalog::Catalog;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -136,7 +137,7 @@ pub fn spawn_pin_fetch(
|
|||||||
) -> Receiver<PinMessage> {
|
) -> Receiver<PinMessage> {
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
executors::spawn(Executor::Network, "pin", move || {
|
||||||
let (store, catalog) = match (
|
let (store, catalog) = match (
|
||||||
dr_catalog::Cache::open(&cache_dir, budget),
|
dr_catalog::Cache::open(&cache_dir, budget),
|
||||||
Catalog::open(&catalog_path),
|
Catalog::open(&catalog_path),
|
||||||
@@ -324,7 +325,7 @@ pub fn spawn_dehydrate(
|
|||||||
) -> Receiver<usize> {
|
) -> Receiver<usize> {
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
executors::spawn(Executor::Network, "dehydrate", move || {
|
||||||
let Ok(catalog) = Catalog::open(&catalog_path) else {
|
let Ok(catalog) = Catalog::open(&catalog_path) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -442,7 +443,7 @@ pub fn spawn_sidecar_fetch(
|
|||||||
) -> Receiver<Option<dr_pipeline::Sidecar>> {
|
) -> Receiver<Option<dr_pipeline::Sidecar>> {
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
executors::spawn(Executor::Network, "sidecars", move || {
|
||||||
let cache = SidecarCache::open(cache_dir);
|
let cache = SidecarCache::open(cache_dir);
|
||||||
let path_str = sidecar_path(&image_path);
|
let path_str = sidecar_path(&image_path);
|
||||||
|
|
||||||
@@ -565,7 +566,7 @@ pub fn spawn_full_fetch(
|
|||||||
cache: Option<CacheContext>,
|
cache: Option<CacheContext>,
|
||||||
) -> Receiver<Result<Vec<u8>, FetchFailure>> {
|
) -> Receiver<Result<Vec<u8>, FetchFailure>> {
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
std::thread::spawn(move || {
|
executors::spawn(Executor::Network, "fetch", move || {
|
||||||
let _ = tx.send(fetch_original(conn, &path, cache.as_ref()));
|
let _ = tx.send(fetch_original(conn, &path, cache.as_ref()));
|
||||||
});
|
});
|
||||||
rx
|
rx
|
||||||
@@ -845,9 +846,9 @@ impl Prefetcher {
|
|||||||
let shared = std::sync::Arc::new(PrefetchShared::default());
|
let shared = std::sync::Arc::new(PrefetchShared::default());
|
||||||
let (tx, events) = std::sync::mpsc::channel();
|
let (tx, events) = std::sync::mpsc::channel();
|
||||||
let worker = shared.clone();
|
let worker = shared.clone();
|
||||||
std::thread::Builder::new()
|
executors::try_spawn(Executor::Network, "prefetch", move || {
|
||||||
.name("prefetch".into())
|
serve_prefetches(&worker, &tx)
|
||||||
.spawn(move || serve_prefetches(&worker, &tx))
|
})
|
||||||
.expect("spawning the prefetch worker");
|
.expect("spawning the prefetch worker");
|
||||||
Self { shared, events }
|
Self { shared, events }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
//! Generating thumbnails locally from a decoded preview or original, and
|
//! Generating thumbnails locally from a decoded preview or original, and
|
||||||
//! the metadata that comes along for the ride.
|
//! the metadata that comes along for the ride.
|
||||||
|
|
||||||
|
use crate::executors::{self, Executor};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use dr_catalog::Catalog;
|
use dr_catalog::Catalog;
|
||||||
use dr_sync::{Connection, RemoteBackend, RemoteId, RemotePath};
|
use dr_sync::{Connection, RemoteBackend, RemoteId, RemotePath};
|
||||||
@@ -101,7 +102,7 @@ pub fn spawn_thumbnails(
|
|||||||
// The one place this job names a decoder; everything below takes it.
|
// The one place this job names a decoder; everything below takes it.
|
||||||
let decoder = dr_decode::default();
|
let decoder = dr_decode::default();
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
executors::spawn(Executor::Decode, "thumbs", move || {
|
||||||
let mut store = match ThumbStore::open(&store_dir) {
|
let mut store = match ThumbStore::open(&store_dir) {
|
||||||
Ok(s) => Some(s),
|
Ok(s) => Some(s),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
//! Pushing local judgements out to XMP sidecars, and reloading sidecars a
|
//! Pushing local judgements out to XMP sidecars, and reloading sidecars a
|
||||||
//! person chose to trust by hand.
|
//! person chose to trust by hand.
|
||||||
|
|
||||||
|
use crate::executors::{self, Executor};
|
||||||
use dr_catalog::Catalog;
|
use dr_catalog::Catalog;
|
||||||
use dr_sync::{Connection, RemoteBackend, RemoteId, RemotePath};
|
use dr_sync::{Connection, RemoteBackend, RemoteId, RemotePath};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@@ -32,7 +33,7 @@ pub struct XmpWrite {
|
|||||||
/// name. Reported once at the end, as the judgement writes are.
|
/// name. Reported once at the end, as the judgement writes are.
|
||||||
pub fn spawn_xmp_writes(conn: Connection, writes: Vec<XmpWrite>) -> Receiver<XmpMessage> {
|
pub fn spawn_xmp_writes(conn: Connection, writes: Vec<XmpWrite>) -> Receiver<XmpMessage> {
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
std::thread::spawn(move || {
|
executors::spawn(Executor::Network, "xmp-write", move || {
|
||||||
let mut written = 0usize;
|
let mut written = 0usize;
|
||||||
let mut failed = 0usize;
|
let mut failed = 0usize;
|
||||||
let mut last_error = None;
|
let mut last_error = None;
|
||||||
@@ -140,7 +141,7 @@ pub fn spawn_xmp_reload(
|
|||||||
paths: Vec<String>,
|
paths: Vec<String>,
|
||||||
) -> Receiver<XmpMessage> {
|
) -> Receiver<XmpMessage> {
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
std::thread::spawn(move || {
|
executors::spawn(Executor::Network, "xmp-reload", move || {
|
||||||
let mut written = 0usize;
|
let mut written = 0usize;
|
||||||
let mut failed = 0usize;
|
let mut failed = 0usize;
|
||||||
let mut last_error = None;
|
let mut last_error = None;
|
||||||
|
|||||||
@@ -189,6 +189,16 @@ fn report_position(window: &AppWindow, ctl: &Rc<LibraryController>, row: usize)
|
|||||||
window.set_total(window.global::<Library>().get_library_total());
|
window.set_total(window.global::<Library>().get_library_total());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether scrollers show the touch cue rather than the desktop bar.
|
||||||
|
///
|
||||||
|
/// A touch-first build always does. A desktop build does when
|
||||||
|
/// `DR_SCROLL_CUE` is set to anything but empty or `0` — not a setting, but
|
||||||
|
/// the way to look at the tablet's cue with a mouse: a developer checking it,
|
||||||
|
/// or a screenshot of it, without an APK and a device.
|
||||||
|
fn scroll_cue(touch_first: bool, env: Option<std::ffi::OsString>) -> bool {
|
||||||
|
touch_first || env.is_some_and(|v| !v.is_empty() && v != "0")
|
||||||
|
}
|
||||||
|
|
||||||
/// Connect the grid's callbacks.
|
/// Connect the grid's callbacks.
|
||||||
pub fn wire<F>(
|
pub fn wire<F>(
|
||||||
window: &AppWindow,
|
window: &AppWindow,
|
||||||
@@ -229,14 +239,15 @@ pub fn wire<F>(
|
|||||||
.global::<Library>()
|
.global::<Library>()
|
||||||
.set_library_touched(cfg!(target_os = "android"));
|
.set_library_touched(cfg!(target_os = "android"));
|
||||||
|
|
||||||
// TRACES: FR-UI-1
|
// TRACES: FR-UI-1 | FR-UI-2
|
||||||
// Scrollbars for a pointer, none for a finger: the same question, asked
|
// Scrollbars for a pointer, the thin cue for a finger: the same question,
|
||||||
// of the same function, that puts the develop groups in the rail. Set
|
// asked of the same function, that puts the develop groups in the rail.
|
||||||
// here, once, because every scroller that draws a bar reads it and none
|
// Set here, once, because every scroller that draws a bar reads it and
|
||||||
// of them can change it.
|
// none of them can change it.
|
||||||
window
|
let cue = scroll_cue(dr_plat::is_touch_first(), std::env::var_os("DR_SCROLL_CUE"));
|
||||||
.global::<crate::Scrolling>()
|
let scrolling = window.global::<crate::Scrolling>();
|
||||||
.set_bars(!dr_plat::is_touch_first());
|
scrolling.set_bars(!cue);
|
||||||
|
scrolling.set_cue(cue);
|
||||||
|
|
||||||
// TRACES: FR-UI-4
|
// TRACES: FR-UI-4
|
||||||
// The gesture reference. Pushed once, here, rather than on demand: the
|
// The gesture reference. Pushed once, here, rather than on demand: the
|
||||||
@@ -815,6 +826,15 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::library_ui::window::{locate_open, window_for, window_start};
|
use crate::library_ui::window::{locate_open, window_for, window_start};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_cue_is_the_tablets_and_a_desktop_opts_in() {
|
||||||
|
assert!(scroll_cue(true, None), "a touch-first build always cues");
|
||||||
|
assert!(!scroll_cue(false, None), "a desktop keeps its bars");
|
||||||
|
assert!(scroll_cue(false, Some("1".into())));
|
||||||
|
assert!(!scroll_cue(false, Some("0".into())), "0 is off");
|
||||||
|
assert!(!scroll_cue(false, Some("".into())), "empty is off");
|
||||||
|
}
|
||||||
|
|
||||||
/// A library larger than one loaded window, and the window over it as
|
/// A library larger than one loaded window, and the window over it as
|
||||||
/// `load_window` leaves it: offset clamped so the window stays full, and
|
/// `load_window` leaves it: offset clamped so the window stays full, and
|
||||||
/// holding the photographs from there on. A photograph's id is its
|
/// holding the photographs from there on. A photograph's id is its
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
//! (`offline`) it hands off to on a failure. See `docs/dev/code-health.md`
|
//! (`offline`) it hands off to on a failure. See `docs/dev/code-health.md`
|
||||||
//! CH-1.
|
//! CH-1.
|
||||||
|
|
||||||
|
use crate::executors::{self, Executor};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::sync::mpsc::Receiver;
|
use std::sync::mpsc::Receiver;
|
||||||
@@ -271,7 +272,7 @@ enum CatalogOpen {
|
|||||||
fn spawn_catalog_open(path: PathBuf) -> Receiver<CatalogOpen> {
|
fn spawn_catalog_open(path: PathBuf) -> Receiver<CatalogOpen> {
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
executors::spawn(Executor::Io, "catalog-open", move || {
|
||||||
let started = std::time::Instant::now();
|
let started = std::time::Instant::now();
|
||||||
let message = match Catalog::open_verified(&path) {
|
let message = match Catalog::open_verified(&path) {
|
||||||
Ok(cat) => {
|
Ok(cat) => {
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ impl LabelGesture {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TRACES: FR-CAT-5 | FR-CAT-13
|
/// TRACES: FR-CAT-5 | FR-CAT-13 | R7
|
||||||
/// Label a set of images: catalog first, in one transaction, then the grid,
|
/// Label a set of images: catalog first, in one transaction, then the grid,
|
||||||
/// the chips and both sidecars — the order and the reasons of
|
/// the chips and both sidecars — the order and the reasons of
|
||||||
/// [`apply_judgement`].
|
/// [`apply_judgement`].
|
||||||
@@ -376,8 +376,15 @@ fn keyword_summary(word: &str, changed: usize, selected: usize, assigning: bool)
|
|||||||
format!("{verb} “{word}” {preposition} {changed} of {selected}")
|
format!("{verb} “{word}” {preposition} {changed} of {selected}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: R7 | FR-CULL-13
|
||||||
/// Apply a judgement to a set of images: catalog first, then sidecars.
|
/// Apply a judgement to a set of images: catalog first, then sidecars.
|
||||||
///
|
///
|
||||||
|
/// The judgement dispatch: every rating and flag a person gives passes
|
||||||
|
/// through here, from an `on_*` callback below, and nothing else calls it.
|
||||||
|
/// `tools/traceability/src/verdicts.rs` holds that to a list — a call from
|
||||||
|
/// anywhere else, and in particular from anything that computes evidence
|
||||||
|
/// about a frame, fails `cargo test -p traceability`.
|
||||||
|
///
|
||||||
/// # Order matters
|
/// # Order matters
|
||||||
///
|
///
|
||||||
/// The catalog is written **synchronously and first**, so the star appears
|
/// The catalog is written **synchronously and first**, so the star appears
|
||||||
@@ -1075,10 +1082,13 @@ pub(super) fn wire_ratings_and_flags(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TRACES: FR-CULL-5 | FR-CULL-13
|
||||||
// Name the frame an open burst folds to (FR-CULL-5). The default is the
|
// Name the frame an open burst folds to (FR-CULL-5). The default is the
|
||||||
// earliest of them, chosen because it is a fact about the clock and not a
|
// earliest of them, chosen because it is a fact about the clock and not a
|
||||||
// judgement about the photograph; this is the photographer, who is the only
|
// judgement about the photograph; this is the photographer, who is the only
|
||||||
// one who knows which of the twelve is the keeper, saying otherwise.
|
// one who knows which of the twelve is the keeper, saying otherwise. The
|
||||||
|
// choice is a grouping, not a verdict: `bursts::choose` moves the badge
|
||||||
|
// and writes no rating, flag or label (FR-CULL-13).
|
||||||
//
|
//
|
||||||
// A repaint of the badges rather than a reload, which is what separates it
|
// A repaint of the badges rather than a reload, which is what separates it
|
||||||
// from folding a burst up: the grid's query returns the same rows either
|
// from folding a burst up: the grid's query returns the same rows either
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user