Compare commits

...
27 Commits
Author SHA1 Message Date
dtourolle e22128c16b Release 0.18.2
Benchmarks / CPU and I/O (per commit) (push) Successful in 5m34s
Benchmarks / Frame budget (on demand) (push) Skipped
Traceability / Requirement traces (push) Successful in 1m0s
Build and test / Android (aarch64) (push) Successful in 30m28s
Build and test / android-image (push) Successful in 1s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / Desktop (Linux) (push) Successful in 47m53s
Build and test / windows-image (push) Successful in 1s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / Layer separation (push) Successful in 34s
Build and test / Windows (x86_64, cross) (push) Successful in 36m29s
Build and test / Publish the release (push) Successful in 1m10s
2026-09-27 08:23:04 -04:00
dtourolle 81ea9359bc Re-record the manual for 0.18.2
Every scene, recorded from this commit's desktop build. Since the last
recording (0.17.0) contrast flattens toward grey, a mask layer's
settings apply as offsets, and the film's settings are per-pixel, which
touch the develop, film, local and preset pictures; `--changed` could
not be trusted after the rebases, so nothing was left out.

Checked frame by frame: the film list still reaches Ilford HP5 Plus by
wheel, drag, scrollbar and keys (film_reach), and each picture shows
what its caption says. Settings shows version 0.18.1, the build's own
until the release commit bumps it.
2026-09-27 08:18:20 -04:00
dtourolle be37218696 Picture film inside a mask layer
The manual described film in a mask layer (6b99f67) with no picture of
it, and the paragraph named the control `Print exposure` where the
panel says `Print Exposure`.

A new scene, film_local, sets Kodak Ektar 100 on an upright alpine
frame — a negative, so it is printed and has a print exposure — then
adds a linear gradient, turns it by its rotate handle to fall from the
top, moves it onto the sky, shows the mask as its edge so the print
stays visible, and raises the layer's Print Exposure to burn the sky
in. Done Masking, then Before held. It puts the masks back to Tint
before undoing, as the other scenes expect.

The picture follows the film list's, under the paragraph it
illustrates. The bundled manual is regenerated to match.
2026-09-27 08:18:15 -04:00
dtourolle 5bff453d37 Add film in a mask and the hot-pixel repair to the README
The developing paragraph listed masks and film separately and said
nothing of photosites. It now says that a mask's sliders add to the
photograph's, the film's among them, so a sky can be burned in on the
print (0.18.1 and 6b99f67), and that hot and dead photosites are mended
before the demosaic with nothing to set (c50d96e).
2026-09-27 08:00:27 -04:00
dtourolle 61cd226719 Say that the runner's disk is the tighter budget
windows.md §7 weighed the Windows leg against the desktop leg in CPU
time only. What actually stopped a release was disk: the one 99 GB disk
is shared with the container images, v0.18.0's desktop run ended at
92 GB used, and v0.18.1's release build ran out, which is why that tag
has no release page. §7 now gives those figures, says what the desktop
leg deletes before its release build since 209ae361 and why that costs
the cache nothing, and names a second target's cache as the first thing
to check if a leg runs out again.
2026-09-27 08:00:27 -04:00
dtourolle 87d758bd81 Sweep outstanding.md for 0.18.2
Adds the release's line to the sweep notes at the head: FR-CULL-13's
write-path half and NFR-ARCH-1's executors, which now have entries, and
three things that closed without ever having had one — a mask layer's
film settings (FR-DEV-3f), hot and dead photosites repaired before the
demosaic (FR-RAW-3, with the defect-map reader still unwired), and the
tablet's scroll cue already described in §4a.
2026-09-27 08:00:19 -04:00
dtourolle 427a572aad Record where the named executors stand
NFR-ARCH-1's register note says what b1d1c472 and 7be1efff met and what
they left, but outstanding.md, which exists to show the distance between
the register and the binary, had no entry, and catalog.md §6 still said
interactive work runs "on the decode pool with the I/O pool behind it"
when there are no pools.

outstanding.md §4 gains the entry: named and guarded, not bounded — the
counts are a budget, the guard covers block_on only, the two mask
workers and the core crates' threads are outside the module. catalog.md
names the executors the thumbnail and metadata work starts on and says
the counts are not yet enforced.
2026-09-27 08:00:19 -04:00
dtourolle 01197ca37d Record FR-CULL-13's write-path half in outstanding.md
§2 still said FR-CULL-1 through -5 and -8 through -13 were built, while
the register's own status note (adbb9ac9) says only the write-path half
of -13 is met. The count now says -12, with -13 half built, and §2 gains
an entry for it: what verdicts.rs enumerates and holds to a reviewed
list, what fails the test, and the evidence that is not built — chips
for clipping, focus, burst membership and face counts, shown as absent
rather than zero, and a filter per signal.
2026-09-27 08:00:09 -04:00
dtourolle f9154ad12e Name the verdict check among the invariants the build defends
2cde2874 made `cargo test -p traceability` fail on any write of a
rating, flag, colour label or trash membership that is not on a
reviewed list, and CONTRIBUTING.md, which lists what CI will stop a
change for, did not mention it. It is now the third invariant beside
the ui-names-no-operation test and the operation schema: what it finds,
the kinds of reason a write can be listed with, that the workspace test
run is what runs it, and `traceability -- verdicts` to print the list.
2026-09-27 07:59:38 -04:00
dtourolle 1fdfb5990c Describe the film's tables as 0.18.2 bakes them
dr-film's README still described one 32³ lookup that took a negative
through the print and the paper, with the sliders' values baked into
it. Since 6b99f67 nothing a slider moves is baked: the curves are one
row per development time the datasheet measures and push interpolates
between them, a print is two lookups split at the paper's log exposure
with the enlarger's exposure added between them, and exposure, push,
print exposure and format reach the shader as uniforms. That is what
lets a mask layer hold film settings of its own.

The section now says so, and that the film's Exposure on the whole
photograph is the one setting that rebakes, because the enlarger's
filtration is solved against it.
2026-09-27 07:59:38 -04:00
dtourolle 7558053932 Say that a mask layer's settings add to the photograph's
fc54523 (0.18.1) stopped running a layer as a second chain after every
global operation and made its settings offsets applied at each
operation's own place, and nothing outside the code said so. The
manual still read as though a mask's slider were a setting of its own,
frame-budget.md described the masks as "a separate chain per layer",
and no document said how a layer is blended at all.

architecture.md §5.2 now says it: the offset, the blend by the layer's
weighted difference from the global result, what a photograph with no
layers composes to, and the film as the one operation whose settings
are averaged instead (6b99f67). FR-DEV-3 records the change as resolved
beside its local-adjustments bullet, frame-budget.md's note on what it
does not measure describes the cost as it now is, and the manual gives
the arithmetic in one sentence: -20 in a mask over -30 is -50 there.
The bundled manual is regenerated to match.
2026-09-27 07:59:29 -04:00
dtourolle 6bccc2db13 Say where hot and dead photosites are repaired
c50d96e added a repair pass ahead of the demosaic and no document said
so: FR-RAW-3's text names levels, the CFA and the colour matrices, and
architecture.md §5.2's stage diagram went from the upload straight to
black and white levels.

FR-RAW-3 now carries a status note: what counts as hot or dead, what
the photosite becomes, that Bayer and X-Trans share the pass, that
export and every other demosaicing path get it with no setting, the
test that holds it, and that the DNG defect map dr-decode can read is
still not used. §5.2 gains the stage and a paragraph on why it sits
before the demosaic, and its pointer to the raw histogram's tap names
the demosaic box rather than a row count the new stage would have made
wrong.
2026-09-27 07:59:00 -04:00
dtourolle 209ae36163 Free the desktop job's test binaries before its release build
Benchmarks / CPU and I/O (per commit) (push) Successful in 5m18s
Benchmarks / Frame budget (on demand) (push) Skipped
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Build and test / Desktop (Linux) (push) Successful in 47m36s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / windows-image (push) Successful in 1s
Build and test / Layer separation (push) Successful in 32s
Traceability / Requirement traces (push) Successful in 1m37s
Build and test / Android (aarch64) (push) Successful in 30m39s
Build and test / Windows (x86_64, cross) (push) Successful in 35m42s
Build and test / Publish the release (push) Skipped
The single CI runner has one 99 GB disk shared with its container images.
The desktop job reached 92 GB used (2.1 GB free) on v0.18.0's run, and on
v0.18.1's the release build died with "No space left on device", so that
tag has no release page. At rest the runner holds about 23 GB; the
restored target cache, the models and the dependency build bring a job to
about 78 GB before a test runs, and tests plus the release build take the
rest.

The test executables and examples in target/debug are the largest part
of that, are relinked whenever a source changes, and are not what the
cache exists for — the dependency rlibs are. Deleting them after the Test
step, before the release build, frees several GB at the moment the job
is fullest without costing the next run anything the cache would have
saved. The step prints the disk afterwards, beside the existing Disk
before/after lines.
2026-09-27 07:25:49 -04:00
dtourolle 8071f7101a Say that a tablet's scrollers show a position cue
The manual said the lists on a tablet scroll by flick alone, and
outstanding.md §4a that Android had no scrollbars by design. Both now
describe the cue from #69: a thin line while the view moves, gone once
it stops, that a flick starting on it passes through; and §4a notes
DR_SCROLL_CUE for looking at it on a desktop, and that it is not yet
checked on the tablet. The bundled manual is regenerated to match.
2026-09-27 07:22:09 -04:00
dtourolle 502023c0f4 Show a thin scroll cue on Android instead of no scrollbar at all
On Android the desktop scrollbar is off, so every scroller that has one
on a desktop (the develop column, the grid, the collections sidebar,
Settings, the help sheet and the film list) gave no sign of how long it
was or where the view was in it. That is how the black-and-white film
stocks came to look deleted when the list could not scroll.

ScrollBar now has a second mode, chosen in the one Scrolling global:
where bars are off and `cue` is on, it draws the thumb alone, 3 px wide
against the edge, while the viewport moves, and fades it 500 ms after
the last move. It has no TouchArea, so a flick that starts on it
scrolls the content. Rust sets cue on touch-first builds; a desktop
build shows it when DR_SCROLL_CUE is set, to look at it without a
device. Desktop is otherwise unchanged.

Test: on the testing backend's help sheet, a press on the cue moves
nothing, a drag starting on it carries the list with the finger, the
cue is drawn while the list moves (drag, fling, wheel) and not once it
is idle; with bars on there is no cue and the thumb still takes a drag.
2026-09-27 07:22:09 -04:00
dtourolle adbb9ac9e6 Tag the judgement dispatch R7 and record which half of FR-CULL-13 is met
apply_judgement and apply_label carry TRACES: R7, the burst
representative callback FR-CULL-13 with a note that choosing it writes
the grouping and no verdict. The register's status for FR-CULL-13 says
the write-path test is met and the evidence chips are outstanding.
Traceability matrix regenerated.
2026-09-27 07:20:41 -04:00
dtourolle 2cde287440 Hold every verdict write to a reviewed list of user actions
FR-CULL-13 says evidence never writes a rating, flag, label or trash
membership, and nothing enforced it. tools/traceability/src/verdicts.rs
parses the shipped code with syn and enumerates every write: calls to
the catalog setters and trash recorders, SQL that assigns those columns,
sidecar Amendment::Judgement, and fields named rating/flag/label. Each
site must be in ALLOWED with a reason, as Input (inside a Slint on_*
closure, checked structurally), Relay (its callers are checked in
turn), Carried (a verdict made elsewhere: sidecar and XMP pulls, sync
merge, catalog mirrored to file, duplicates consolidation) or
NotAVerdict. Unlisted sites and stale entries both fail
`cargo test -p traceability`; `traces verdicts` prints the list.

syn and proc-macro2 were already in the lockfile as proc-macro
dependencies; this adds the edges, no new crate and no version change.
2026-09-27 07:20:38 -04:00
dtourolle 6e68f1fb3d Move a resaved test file's mtime ahead, so the scan cannot miss it
`a_resaved_file_owes_a_reread_and_loses_its_stale_hash` failed once in a
full dr-catalog run (updated 0, expected 1) and passed three times alone.
The incremental scan tells a changed file by its mtime at whole-second
resolution, and the `resave` helper wrote and renamed the file within
the same second as the scan before it, so on a fast enough pass the
resave looked like no change at all.

The helper now sets the file's and its folder's mtime two seconds ahead,
which is what a real resave some time after a scan looks like. Only the
test helper changes; the scan's rule is right for real files.
2026-09-27 07:19:04 -04:00
dtourolle 8df3dda9aa Regenerate the traceability matrix for the executors module 2026-09-27 07:08:40 -04:00
dtourolle 3912bc0d1d Point architecture §7.1 at the executors module and record NFR-ARCH-1
§7.1 now says where its table lives in code, how the UI thread is
marked and guarded, and that the counts are a budget rather than a
bound until work is pooled by executor. The register's status note says
what is met — named, spawned through one module, block_on guarded and
tested — and what is not: pooling, a guard for synchronous file reads
and catalog queries, the two mask workers, and threads the core crates
start.
2026-09-27 07:08:37 -04:00
dtourolle b1d1c47261 Start every worker thread through the executors module
Thirty-nine spawn sites in dr-ui, and one in the Android entry point,
called std::thread::spawn or a Builder of their own, and most of the
threads they started were <unnamed> in a panic message or a profiler.
Each now calls executors::spawn with its executor and a role, so the thread is
named <executor>:<role> — net:sync, decode:thumbs, io:catalog-open —
and knows which executor it is on. The three that already set a name
(automation, import, prefetch) keep their name as the role.

Behaviour is unchanged: each job still gets a thread of its own when it
starts, and spawn panics where std::thread::spawn did.

The module's documentation now says how a job is assigned: by what it
spends its time on, so a sweep that fetches bytes and then decodes them
is Decode, and a sidecar write that touches the catalog is Network.

Left as they were: the segmentation and refine workers in masks_ui.rs,
which another change is reworking, and test-only threads.
2026-09-27 07:08:37 -04:00
dtourolle 7be1efff32 Name the executors and fail a block_on on the UI thread
architecture.md §7.1 stated five executors and their thread counts, and
no code named them. dr_ui::executors now does: the Executor enum with
each one's thread name and the count §7.1 gives with its reason, and
spawn, which starts a thread named <executor>:<role> and marks it with
the executor it belongs to. The counts are the stated budget, not yet a
bound: a job still gets a thread of its own when it starts.

run marks its own thread as the UI executor before it builds the
window. net_runtime::build now returns a NetRuntime whose block_on
asserts, in debug and test builds, that the caller is not that thread;
everything else derefs to the tokio runtime. The login, folder-list and
remote-folder workers built the same runtime by hand and now take it
from net_runtime, so their block_on is guarded too.

Tests: a block_on on a thread marked as the UI executor panics naming
the UI thread; the same call on a worker returns; a spawned thread
carries its name and executor.
2026-09-27 07:08:37 -04:00
dtourolle c50d96e949 Repair hot and dead photosites before the demosaic
A hot photosite went into the demosaic as it was read, and came out as a
coloured cross three pixels wide that nothing later could take back
out. Night and long exposures showed them; the defect-map reader added
for FR-RAW-3 was never wired in, and a CR2 carries no map anyway.

A pass over the mosaic now runs ahead of the demosaic, into a second
buffer. A photosite is hot when it reads more than twice every
same-colour photosite in its 5x5 window plus 2% of the range, and more
than twice each of its eight immediate neighbours of any colour. The
second half keeps stars and glints: real light reaches the sensor
through a lens and an anti-aliasing filter and lights a patch, so the
photosites beside it are lit too, where a hot photosite's are dark. It
is replaced by its brightest same-colour neighbour, which invents
nothing. Dead photosites are the mirror case, judged only where the
neighbourhood is above 5%, so shadow noise clipped at black is left
alone.

The colour of each photosite comes from a 6x6 sensor-anchored tile, so
Bayer and X-Trans share the pass. Export and every other path that
demosaics get it too, and there is no setting: the repair only fires
where a single photosite disagrees with everything around it.

Cost, warm, on a Canon 6D frame (RTX 3050): 91-99 ms to demosaic
before, 94-98 ms after; the extra pass is inside the run-to-run noise.

Tests render a frame with and without the defect and compare the
finished pixels. Without the repair a hot photosite showed by 230 and a
dead one by 168; with it neither shows, and a 3x3 highlight at white
survives.
2026-09-27 06:20:23 -04:00
dtourolle 6b99f67f47 Develop a mask layer's film on its own settings
A layer offered the film's sliders and they moved nothing: its copy of
the node was never given the stock, so it stayed inactive. Film now
works in a layer the way the other adjustments do, as offsets to the
photograph's settings, but blended as settings rather than as results,
since a film is a rendering and cross-fading two developments is not
what a region on a pushed film looks like.

- dr-film bakes no slider. Exposure is a gain in the shader; push
  interpolates the stock's measured processes, one curve row each; the
  print is split at the paper's log exposure, so print exposure is an
  addition between two lookups and exact at any setting. The enlarger
  stays balanced at the photograph's exposure.
- film_sim reads all four settings as uniforms, format one-hot over a
  grain count per format, so every uniform is linear in what it does.
- Operation::blends_settings lets the composer average each overlapping
  layer's uniforms with the global ones by mask weight, the global
  setting taking whatever weight the layers leave, and run the fragment
  once. Three layers at full weight give the mean of their settings.
- The stock picker is hidden on a layer. Only the photograph's exposure
  re-solves the print balance; push, print exposure and format need no
  rebake at all now.
2026-09-26 23:29:13 -04:00
dtourolle 48c5e74fa8 Release 0.18.1
Benchmarks / CPU and I/O (per commit) (push) Successful in 5m46s
Benchmarks / Frame budget (on demand) (push) Skipped
Traceability / Requirement traces (push) Successful in 49s
Build and test / Android (aarch64) (push) Successful in 17m27s
Build and test / android-image (push) Successful in 3s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / Desktop (Linux) (push) Failing after 25m32s
Build and test / windows-image (push) Successful in 1s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / Layer separation (push) Successful in 29s
Build and test / Windows (x86_64, cross) (push) Successful in 37m19s
Build and test / Publish the release (push) Skipped
2026-09-26 21:29:26 -04:00
dtourolle fc54523093 Apply a mask layer's settings as offsets to the global ones
A local adjustment ran as a second chain after every global operation,
then blended by the mask. So global contrast -30 with -20 on a face was
contrast -30, the rest of the chain, and contrast -20 again on the
result, rather than -50 where contrast runs. The two edits compounded in
ways neither slider showed; a flattening applied to an already
flattened picture is how the shadows of a night shot went magenta.

A layer's setting is now an offset from its default, added to the
global setting (clamped to the parameter's range; a moved switch or
choice replaces it) and run at that operation's own place in the chain.
At each operation the global fragment and each touching layer's
combined fragment read the same input colour, and the pixel moves by
each layer's weighted difference: c_g + sum w_i (c_i - c_g). At full
weight that is the combined setting exactly, at zero the global result
exactly, and no setting is applied twice. An offset that brings an
operation back to neutral emits an empty version, which undoes the
global setting inside the mask.

Blending the colours rather than the uniforms is deliberate: the tone
curve and colour mixer emit code only for the channels and bands that
are touched, so the global and combined versions of one operation need
not share a uniform set.

A photograph with no masks compiles to the same shader byte for byte.

Test: global -30 with a whole-frame layer at -20 renders within one
count of global -50.
2026-09-26 20:43:02 -04:00
dtourolle 8392cf772e Flatten contrast toward grey instead of scaling shadows by a ratio
Reducing contrast turned every black in a night photograph pink. The
fragment lifted each pixel's luminance to its target by multiplying the
colour by target/luma. For a pixel at 0.001 on the way to 0.09 that is
a gain of ninety, and in the deepest shadows the channels are sensor
noise: after white balance the red and blue noise sits above the green,
their multipliers being nearly twice its, so ninety times that noise is
magenta.

Flattening now mixes the colour toward middle grey, which gives the
same luminance and adds the lift as a neutral. A black goes to grey and
its noise stays the size it was.

The same fragment clamped luma/0.36 into the curve's 0..1 domain, which
scaled every tone above twice middle grey down to 0.36 in either
direction: contrast +10 took a 230 grey to 162. Those tones are now left
where they are, which is continuous with the curve's top (value 1,
slope 0).
2026-09-26 20:43:02 -04:00
109 changed files with 5729 additions and 778 deletions
+13
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+5 -2
View File
@@ -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.
+3 -1
View File
@@ -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.
+11
View File
@@ -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
View File
@@ -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
+443 -112
View File
@@ -21,14 +21,16 @@
//! curves and dyes, the viewing illuminant, the adaptation — all of it takes //! curves and dyes, the viewing illuminant, the adaptation — all of it takes
//! three numbers in and gives three numbers out. So it bakes into one small //! three numbers in and gives three numbers out. So it bakes into one small
//! 3D lookup, and the per-pixel cost is a matrix multiply, three curve taps //! 3D lookup, and the per-pixel cost is a matrix multiply, three curve taps
//! and one texture fetch. //! and one texture fetch. A print is two: the film's lookup ends at the
//! paper's log exposure, where the enlarger's exposure is an addition, and
//! the paper's curve and lookup take it from there — see [`Paper`].
//! //!
//! Splitting 2 from 3 rather than baking a single LUT over exposure is //! Splitting 2 from 3 rather than baking a single LUT over exposure is
//! deliberate and measured: the curve carries all of the sharp shape and the //! deliberate and measured: the curve carries all of the sharp shape and the
//! dye mixing is smooth, so putting the curve in the 3D LUT would force it //! dye mixing is smooth, so putting the curve in the 3D LUT would force it
//! three times larger for the same error. //! three times larger for the same error.
use crate::profile::Profile; use crate::profile::{Profile, CURVE_SAMPLES};
use crate::spectrum::{illuminant, Spectrum, Viewing}; use crate::spectrum::{illuminant, Spectrum, Viewing};
use crate::tables::{SPECTRUM, SRGB_BASIS}; use crate::tables::{SPECTRUM, SRGB_BASIS};
@@ -47,7 +49,19 @@ pub const MID_GREY: f32 = 0.184;
/// that on: the error is already under what the output can represent. /// that on: the error is already under what the output can represent.
pub const LUT_SIZE: usize = 32; pub const LUT_SIZE: usize = 32;
/// What to develop, and how. /// TRACES: FR-DEV-3f
/// The most development times a stock may measure: one curve row, and one
/// push station, each. Every stock shipped measures five; the ceiling is what
/// the shader's fixed uniform block can hold.
pub const MAX_CURVE_ROWS: usize = 8;
/// What to develop: the materials, and where the enlarger is balanced.
///
/// **Not how far, and not how bright.** Push, print exposure and camera
/// exposure are [`Settings`], evaluated per pixel against these tables, so
/// that a mask layer can hold its own and a pixel under it can take the
/// weighted average of everyone's (FR-DEV-3f). What is left here is what a
/// photograph has one of.
pub struct Recipe<'a> { pub struct Recipe<'a> {
/// The stock the picture was taken on. /// The stock the picture was taken on.
pub film: &'a Profile, pub film: &'a Profile,
@@ -55,17 +69,15 @@ pub struct Recipe<'a> {
/// what a reversal stock wants and what makes a negative come out orange /// what a reversal stock wants and what makes a negative come out orange
/// and inverted — that being what a negative actually looks like. /// and inverted — that being what a negative actually looks like.
pub print: Option<&'a Profile>, pub print: Option<&'a Profile>,
/// Camera exposure, in stops. /// The camera exposure the enlarger is balanced at, in stops. Ignored
pub exposure_ev: f32, /// without a `print`.
/// Enlarger exposure, in stops. Ignored without a `print`.
pub print_exposure_ev: f32,
/// TRACES: FR-DEV-3f
/// Development, in stops of push. Positive develops longer.
/// ///
/// Ignored by a stock measured at one process, of which there are many — /// The *photograph's* exposure, never a region's. An enlarger has one
/// see [`crate::profile::Profile::curves_at_push`], which returns the one /// filtration for the whole print: a negative exposed a stop brighter in
/// measured curve rather than inventing a pushed one. /// one corner prints a stop darker there, and that difference is the
pub push_stops: f32, /// picture — balancing it away per pixel would erase every local exposure
/// change a layer made.
pub exposure_ev: f32,
} }
impl<'a> Recipe<'a> { impl<'a> Recipe<'a> {
@@ -76,12 +88,27 @@ impl<'a> Recipe<'a> {
film, film,
print, print,
exposure_ev: 0.0, exposure_ev: 0.0,
print_exposure_ev: 0.0,
push_stops: 0.0,
} }
} }
} }
/// TRACES: FR-DEV-3f
/// What a pixel is developed with, against a [`Baked`] stock.
///
/// The shader's uniforms, as the CPU sees them: every field is linear in what
/// the tables are indexed by, which is what lets the composer blend several
/// layers' settings into one before the fragment runs.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct Settings {
/// Camera exposure, in stops: a gain on the scene.
pub exposure_ev: f32,
/// Development, in stops of push. Positive develops longer. Nothing for a
/// stock measured at one process, of which there are many.
pub push_stops: f32,
/// Enlarger exposure, in stops. Nothing without a print.
pub print_exposure_ev: f32,
}
/// A recipe reduced to three tables. /// A recipe reduced to three tables.
/// ///
/// Plain `f32` with a documented layout, and no notion of a texture: what to /// Plain `f32` with a documented layout, and no notion of a texture: what to
@@ -89,16 +116,31 @@ impl<'a> Recipe<'a> {
/// the whole model be tested on the CPU. /// the whole model be tested on the CPU.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Baked { pub struct Baked {
/// Linear sRGB to the three layers' log₁₀ exposure, before the log — row /// Linear sRGB to the three layers' exposure, before the log — row `l`,
/// `l`, column `c` is layer `l`'s response to sRGB channel `c`. /// column `c` is layer `l`'s response to sRGB channel `c`. At unit gain:
/// [`Settings::exposure_ev`] is applied per pixel.
pub exposure_matrix: [[f32; 3]; 3], pub exposure_matrix: [[f32; 3]; 3],
/// The characteristic curves, `CURVE_SAMPLES` samples per layer, uniform /// The characteristic curves: `curve_rows` rows of `CURVE_SAMPLES`
/// over `[curve_log_min, curve_log_max]`. /// samples, row after row, each uniform over
/// `[curve_log_min, curve_log_max]`.
///
/// Row `r` is the stock as measured at its `r`th development time, which
/// is push [`Self::push_stations`]`[r]`. The rows are the measurements
/// themselves rather than a resampling: between two, density is linear in
/// push (development is interpolated in log time, and push is log time),
/// so interpolating the rows by push reproduces
/// [`Profile::curves_at_push`] exactly. A stock measured at one process
/// has one row.
pub curves: Vec<[f32; 3]>, pub curves: Vec<[f32; 3]>,
pub curve_rows: usize,
/// The push each row was developed to, ascending, one per row.
pub push_stations: Vec<f32>,
pub curve_log_min: f32, pub curve_log_min: f32,
pub curve_log_max: f32, pub curve_log_max: f32,
/// Density to linear sRGB, `LUT_SIZE³` entries uniform over /// Film density to what comes next, `LUT_SIZE³` entries uniform over
/// `[0, density_max]` on each axis. /// `[0, density_max]` on each axis: linear sRGB when the film is viewed
/// directly, and the paper's log₁₀ exposure through it, per layer, when it
/// is printed.
/// ///
/// **The red axis varies fastest**, then green, then blue — that is, /// **The red axis varies fastest**, then green, then blue — that is,
/// `lut[(b * size + g) * size + r]`. Stated because it is not the order /// `lut[(b * size + g) * size + r]`. Stated because it is not the order
@@ -108,60 +150,142 @@ pub struct Baked {
/// picture with red and blue transposed, which looks like a plausible /// picture with red and blue transposed, which looks like a plausible
/// photograph of the wrong colour. /// photograph of the wrong colour.
pub lut: Vec<[f32; 3]>, pub lut: Vec<[f32; 3]>,
/// The paper, when there is one. See [`Paper`].
pub paper: Option<Paper>,
/// The deepest density any row develops to, so one lookup covers every
/// push.
pub density_max: f32, pub density_max: f32,
pub lut_size: usize, pub lut_size: usize,
} }
/// TRACES: FR-DEV-3f
/// The print half of a baked stock: enlarger to paper to viewing.
///
/// Split from the film's lookup at the paper's log exposure, for the reason
/// the film is split from its own curve. The enlarger's exposure is a shift
/// *in that log exposure*, the same stops on all three layers, so a print
/// exposure is an addition between the two lookups — exact at any value and
/// free per pixel. Baking it into one lookup instead needs a slice per
/// setting, and interpolating between slices misses by several code values,
/// because the paper's curve is the sharpest thing in the print.
#[derive(Debug, Clone)]
pub struct Paper {
/// The enlarger's filtration, per layer, in log₁₀ exposure: what makes a
/// mid-grey scene print neutral at the photograph's exposure. See
/// [`Recipe::exposure_ev`].
pub balance: [f32; 3],
/// The paper's characteristic curves, `CURVE_SAMPLES` samples uniform
/// over `[log_min, log_max]`.
pub curves: Vec<[f32; 3]>,
pub log_min: f32,
pub log_max: f32,
/// Paper density to linear sRGB, laid out as [`Baked::lut`] is, uniform
/// over `[0, density_max]`.
pub lut: Vec<[f32; 3]>,
pub density_max: f32,
}
/// Where `push` falls among the rows: the lower row and the fraction toward
/// the next. Clamped at both ends, as `curves_at_push` clamps to the first and
/// last measured process.
fn push_row(stations: &[f32], push: f32) -> (usize, f32) {
if stations.len() < 2 {
return (0, 0.0);
}
let last = stations.len() - 1;
let hi = stations
.iter()
.position(|p| *p >= push)
.unwrap_or(last)
.max(1);
let lo = hi - 1;
let f = (push - stations[lo]) / (stations[hi] - stations[lo]).max(1e-6);
(lo, f.clamp(0.0, 1.0))
}
impl Baked { impl Baked {
/// Look a colour up the way the shader will, for tests and for previews. /// Look a colour up the way the shader will, at the stock's own settings.
pub fn apply(&self, rgb: [f32; 3]) -> [f32; 3] { pub fn apply(&self, rgb: [f32; 3]) -> [f32; 3] {
self.apply_at(rgb, &Settings::default())
}
/// Look a colour up the way the shader will, for tests and for previews.
pub fn apply_at(&self, rgb: [f32; 3], settings: &Settings) -> [f32; 3] {
let gain = 2f32.powf(settings.exposure_ev);
let mut log_exposure = [0.0f32; 3]; let mut log_exposure = [0.0f32; 3];
for (l, slot) in log_exposure.iter_mut().enumerate() { for (l, slot) in log_exposure.iter_mut().enumerate() {
let m = self.exposure_matrix[l]; let m = self.exposure_matrix[l];
let e = m[0] * rgb[0] + m[1] * rgb[1] + m[2] * rgb[2]; let e = gain * (m[0] * rgb[0] + m[1] * rgb[1] + m[2] * rgb[2]);
*slot = (e.max(0.0) + 1e-10).log10(); *slot = (e.max(0.0) + 1e-10).log10();
} }
self.sample_lut(self.sample_curves(log_exposure)) let density = self.sample_curves(log_exposure, settings.push_stops);
let through = sample_cube(&self.lut, self.lut_size, density, self.density_max);
let Some(paper) = &self.paper else {
return through;
};
let shift = settings.print_exposure_ev * 2f32.log10();
let paper_log = [0, 1, 2].map(|l| through[l] + paper.balance[l] + shift);
let paper_density = sample_curve(&paper.curves, paper.log_min, paper.log_max, paper_log);
sample_cube(&paper.lut, self.lut_size, paper_density, paper.density_max)
} }
fn sample_curves(&self, log_exposure: [f32; 3]) -> [f32; 3] { fn sample_curves(&self, log_exposure: [f32; 3], push_stops: f32) -> [f32; 3] {
let last = self.curves.len() - 1; let (row, g) = push_row(&self.push_stations, push_stops);
let span = self.curve_log_max - self.curve_log_min; let lo = self.sample_curve_row(log_exposure, row);
let mut out = [0.0f32; 3]; if self.curve_rows < 2 {
for (c, slot) in out.iter_mut().enumerate() { return lo;
let t = ((log_exposure[c] - self.curve_log_min) / span).clamp(0.0, 1.0) * last as f32;
let i = (t.floor() as usize).min(last - 1);
let f = t - i as f32;
*slot = self.curves[i][c] * (1.0 - f) + self.curves[i + 1][c] * f;
} }
out let hi = self.sample_curve_row(log_exposure, row + 1);
[0, 1, 2].map(|c| lo[c] * (1.0 - g) + hi[c] * g)
} }
fn sample_lut(&self, density: [f32; 3]) -> [f32; 3] { fn sample_curve_row(&self, log_exposure: [f32; 3], row: usize) -> [f32; 3] {
let n = self.lut_size; let samples = self.curves.len() / self.curve_rows;
let mut base = [0usize; 3]; let curve = &self.curves[row * samples..(row + 1) * samples];
let mut frac = [0f32; 3]; sample_curve(curve, self.curve_log_min, self.curve_log_max, log_exposure)
for c in 0..3 { }
let t = (density[c] / self.density_max).clamp(0.0, 1.0) * (n - 1) as f32; }
base[c] = (t.floor() as usize).min(n - 2);
frac[c] = t - base[c] as f32; /// Three curves sampled uniformly over `[log_min, log_max]`, read at a log
} /// exposure per layer. Clamped at both ends, as the shader's is.
let mut out = [0.0f32; 3]; fn sample_curve(curve: &[[f32; 3]], log_min: f32, log_max: f32, at: [f32; 3]) -> [f32; 3] {
for dx in 0..2 { let last = curve.len() - 1;
for dy in 0..2 { let span = log_max - log_min;
for dz in 0..2 { let mut out = [0.0f32; 3];
let w = if dx == 0 { 1.0 - frac[0] } else { frac[0] } for (c, slot) in out.iter_mut().enumerate() {
* if dy == 0 { 1.0 - frac[1] } else { frac[1] } let t = ((at[c] - log_min) / span).clamp(0.0, 1.0) * last as f32;
* if dz == 0 { 1.0 - frac[2] } else { frac[2] }; let i = (t.floor() as usize).min(last - 1);
let e = self.lut[((base[2] + dz) * n + base[1] + dy) * n + base[0] + dx]; let f = t - i as f32;
for c in 0..3 { *slot = curve[i][c] * (1.0 - f) + curve[i + 1][c] * f;
out[c] += w * e[c]; }
} out
}
/// A cube of `n³` triples over `[0, max]` per axis, red fastest, read
/// trilinearly.
fn sample_cube(lut: &[[f32; 3]], n: usize, density: [f32; 3], max: f32) -> [f32; 3] {
let mut base = [0usize; 3];
let mut frac = [0f32; 3];
for c in 0..3 {
let t = (density[c] / max).clamp(0.0, 1.0) * (n - 1) as f32;
base[c] = (t.floor() as usize).min(n - 2);
frac[c] = t - base[c] as f32;
}
let mut out = [0.0f32; 3];
for dx in 0..2 {
for dy in 0..2 {
for dz in 0..2 {
let w = if dx == 0 { 1.0 - frac[0] } else { frac[0] }
* if dy == 0 { 1.0 - frac[1] } else { frac[1] }
* if dz == 0 { 1.0 - frac[2] } else { frac[2] };
let e = lut[((base[2] + dz) * n + base[1] + dy) * n + base[0] + dx];
for c in 0..3 {
out[c] += w * e[c];
} }
} }
} }
out
} }
out
} }
/// Linear sRGB to the three layers' exposure, mid-grey normalised. /// Linear sRGB to the three layers' exposure, mid-grey normalised.
@@ -204,12 +328,7 @@ pub fn exposure_matrix(film: &Profile) -> [[f32; 3]; 3] {
/// goes: the mask is a fixed density, so balancing mid-grey to neutral cancels /// goes: the mask is a fixed density, so balancing mid-grey to neutral cancels
/// it — which is why a printed negative looks like a photograph while a scanned /// it — which is why a printed negative looks like a photograph while a scanned
/// one looks orange. /// one looks orange.
fn print_balance( pub fn print_balance(film: &Profile, paper: &Profile, exposure_ev: f32) -> [f32; 3] {
film: &Profile,
paper: &Profile,
exposure_ev: f32,
print_exposure_ev: f32,
) -> [f32; 3] {
let matrix = exposure_matrix(film); let matrix = exposure_matrix(film);
let scene = MID_GREY * 2f32.powf(exposure_ev); let scene = MID_GREY * 2f32.powf(exposure_ev);
let mut log_exposure = [0.0f32; 3]; let mut log_exposure = [0.0f32; 3];
@@ -227,7 +346,7 @@ fn print_balance(
let mut offsets = [0.0f32; 3]; let mut offsets = [0.0f32; 3];
for (l, slot) in offsets.iter_mut().enumerate() { for (l, slot) in offsets.iter_mut().enumerate() {
*slot = target - (mid_raw[l] + 1e-10).log10() + print_exposure_ev * 2f32.log10(); *slot = target - (mid_raw[l] + 1e-10).log10();
} }
offsets offsets
} }
@@ -257,77 +376,117 @@ fn paper_exposure(film: &Profile, paper: &Profile, density: [f32; 3]) -> [f32; 3
/// Bake a recipe into the tables a shader runs. /// Bake a recipe into the tables a shader runs.
pub fn bake(recipe: &Recipe) -> Baked { pub fn bake(recipe: &Recipe) -> Baked {
let film = recipe.film; let film = recipe.film;
let mut matrix = exposure_matrix(film); // At unit gain. Camera exposure is a scalar on a linear quantity, so the
// Camera exposure rides in the matrix rather than in the shader: it is a // shader applies it for the price of one multiply — and has to, since a
// scalar on a linear quantity, and folding it in here costs nothing and // layer may hold its own.
// keeps the per-pixel work identical whether or not it has been moved. let matrix = exposure_matrix(film);
let gain = 2f32.powf(recipe.exposure_ev);
for row in &mut matrix {
for v in row.iter_mut() {
*v *= gain;
}
}
// TRACES: FR-DEV-3f // TRACES: FR-DEV-3f
// Developed to the requested push before anything else reads the curves: // Every measured process, not the one the slider is at: the shader
// the density ceiling, the print balance and the grain all depend on how // interpolates between rows per pixel, so a layer can push a region.
// far this film was taken, and a push that only reached one of them would // Resampled to one length because the rows share a texture.
// be a contrast change wearing a push's name. let measured = film.development_curves.len() >= 2
let curves = film.curves_at_push(recipe.push_stops); && film.development_times.len() == film.development_curves.len();
let density_max = curves let (curves, push_stations): (Vec<[f32; 3]>, Vec<f32>) = if measured {
.iter() let rows = film.development_curves.len().min(MAX_CURVE_ROWS);
.flat_map(|row| row.iter()) (
.fold(0.0f32, |a, &b| a.max(b)) film.development_curves[..rows]
.max(1e-3); .iter()
.flat_map(|c| resample(c))
let viewing = match recipe.print { .collect(),
Some(paper) => Viewing::new(&paper.viewing_illuminant), film.development_times[..rows]
None => Viewing::new(&film.viewing_illuminant), .iter()
.map(|t| 2.0 * (t / film.development_normal).log2())
.collect(),
)
} else {
(resample(&film.density_curves), vec![0.0])
}; };
let balance = recipe let curve_rows = push_stations.len();
.print // The ceiling of the deepest row, so one lookup covers every push.
.map(|paper| print_balance(film, paper, recipe.exposure_ev, recipe.print_exposure_ev)); let density_max = ceiling(&curves);
let n = LUT_SIZE; let n = LUT_SIZE;
let mut lut = Vec::with_capacity(n * n * n);
// Blue outermost and red innermost, so the red axis varies fastest. See // Blue outermost and red innermost, so the red axis varies fastest. See
// `Baked::lut`: this is the layout a 3D texture upload wants, and getting // `Baked::lut`: this is the layout a 3D texture upload wants, and getting
// it backwards transposes red and blue in the finished picture. // it backwards transposes red and blue in the finished picture.
for b in 0..n { let cube = |max: f32, f: &dyn Fn([f32; 3]) -> [f32; 3]| {
for g in 0..n { let mut out = Vec::with_capacity(n * n * n);
for r in 0..n { for b in 0..n {
let density = [ for g in 0..n {
density_max * r as f32 / (n - 1) as f32, for r in 0..n {
density_max * g as f32 / (n - 1) as f32, let step = max / (n - 1) as f32;
density_max * b as f32 / (n - 1) as f32, out.push(f([r as f32 * step, g as f32 * step, b as f32 * step]));
]; }
lut.push(match recipe.print.zip(balance) {
Some((paper, offsets)) => {
let raw = paper_exposure(film, paper, density);
let mut log_exposure = [0.0f32; 3];
for (l, slot) in log_exposure.iter_mut().enumerate() {
*slot = (raw[l] + 1e-10).log10() + offsets[l];
}
let paper_density = paper.density_at(log_exposure);
viewing.to_srgb(&paper.transmittance(paper_density))
}
None => viewing.to_srgb(&film.transmittance(density)),
});
} }
} }
} out
};
let (lut, paper) = match recipe.print {
None => {
let viewing = Viewing::new(&film.viewing_illuminant);
(
cube(density_max, &|d| viewing.to_srgb(&film.transmittance(d))),
None,
)
}
Some(paper) => {
let viewing = Viewing::new(&paper.viewing_illuminant);
let curves = resample(&paper.density_curves);
let paper_max = ceiling(&curves);
let lut = cube(density_max, &|d| {
paper_exposure(film, paper, d).map(|raw| (raw + 1e-10).log10())
});
let paper = Paper {
balance: print_balance(film, paper, recipe.exposure_ev),
log_min: paper.log_exposure_min,
log_max: paper.log_exposure_max,
lut: cube(paper_max, &|d| viewing.to_srgb(&paper.transmittance(d))),
density_max: paper_max,
curves,
};
(lut, Some(paper))
}
};
Baked { Baked {
exposure_matrix: matrix, exposure_matrix: matrix,
curves, curves,
curve_rows,
push_stations,
curve_log_min: film.log_exposure_min, curve_log_min: film.log_exposure_min,
curve_log_max: film.log_exposure_max, curve_log_max: film.log_exposure_max,
lut, lut,
paper,
density_max, density_max,
lut_size: n, lut_size: n,
} }
} }
/// A curve at `CURVE_SAMPLES`, uniform over the same domain it came in on.
fn resample(curve: &[[f32; 3]]) -> Vec<[f32; 3]> {
if curve.len() == CURVE_SAMPLES {
return curve.to_vec();
}
(0..CURVE_SAMPLES)
.map(|i| {
let at = i as f32 / (CURVE_SAMPLES - 1) as f32;
sample_curve(curve, 0.0, 1.0, [at; 3])
})
.collect()
}
/// The deepest density in a set of curves, floored so a lookup over it has
/// a width.
fn ceiling(curves: &[[f32; 3]]) -> f32 {
curves
.iter()
.flat_map(|row| row.iter())
.fold(0.0f32, |a, &b| a.max(b))
.max(1e-3)
}
fn mean(s: &Spectrum) -> f32 { fn mean(s: &Spectrum) -> f32 {
s.iter().sum::<f32>() / SPECTRUM as f32 s.iter().sum::<f32>() / SPECTRUM as f32
} }
@@ -467,6 +626,10 @@ mod tests {
#[test] #[test]
fn exposure_moves_the_print_the_way_it_moves_a_photograph() { fn exposure_moves_the_print_the_way_it_moves_a_photograph() {
// The photograph's exposure: the enlarger balanced at it, and the
// scene brighter by it. Mid-grey stays where the balance puts it —
// that is what the balance is for — so what a stop more does to a
// print is lift everything either side of it along the paper's curve.
let film = portra(); let film = portra();
let paper = endura(); let paper = endura();
let brighter = bake(&Recipe { let brighter = bake(&Recipe {
@@ -474,7 +637,155 @@ mod tests {
..Recipe::new(&film, Some(&paper)) ..Recipe::new(&film, Some(&paper))
}); });
let base = bake(&Recipe::new(&film, Some(&paper))); let base = bake(&Recipe::new(&film, Some(&paper)));
assert!(brighter.apply([MID_GREY; 3])[1] > base.apply([MID_GREY; 3])[1]); let one_stop = Settings {
exposure_ev: 1.0,
..Settings::default()
};
for v in [0.02f32, 0.6] {
assert!(
brighter.apply_at([v; 3], &one_stop)[1] > base.apply([v; 3])[1],
"{v} did not print brighter a stop up"
);
}
let (a, b) = (
brighter.apply_at([MID_GREY; 3], &one_stop)[1],
base.apply([MID_GREY; 3])[1],
);
assert!(
(a - b).abs() < 1.0 / 255.0,
"the balance let mid-grey move: {a} vs {b}"
);
}
#[test]
fn a_region_exposed_brighter_prints_brighter_than_the_enlarger_expects() {
// TRACES: FR-DEV-3f
// A layer's exposure is the scene's, not the enlarger's: the balance
// stays where the photograph put it, so the region prints lighter by
// more than the whole photograph would, which is what dodging at the
// camera is.
let film = portra();
let paper = endura();
let base = bake(&Recipe::new(&film, Some(&paper)));
let rebalanced = bake(&Recipe {
exposure_ev: 1.0,
..Recipe::new(&film, Some(&paper))
});
let one_stop = Settings {
exposure_ev: 1.0,
..Settings::default()
};
let local = base.apply_at([MID_GREY; 3], &one_stop)[1];
let global = rebalanced.apply_at([MID_GREY; 3], &one_stop)[1];
assert!(local > base.apply([MID_GREY; 3])[1], "not brighter at all");
assert!(
local > global,
"a region was rebalanced as though it were the whole print: {local} vs {global}"
);
}
#[test]
fn more_light_through_the_enlarger_darkens_the_print() {
// TRACES: FR-DEV-3f
// Paper is negative-working. Opening the enlarger a stop is burning
// in, and a slider that brightened would be the wrong way round for
// anyone who has printed.
let film = portra();
let paper = endura();
let baked = bake(&Recipe::new(&film, Some(&paper)));
let at = |stops: f32| {
baked.apply_at(
[MID_GREY; 3],
&Settings {
print_exposure_ev: stops,
..Settings::default()
},
)[1]
};
assert!(at(1.0) < at(0.0) && at(0.0) < at(-1.0));
}
#[test]
fn a_push_on_a_row_is_the_measured_curve() {
// TRACES: FR-DEV-3f
// The rows are the measured processes, so at a row the table must be
// that curve exactly, and between rows — density being linear in push
// there — it must be `curves_at_push` to rounding.
let film = profile(include_str!("../profiles/kodak_doublex.yaml"));
let baked = bake(&Recipe::new(&film, None));
assert_eq!(
baked.curve_rows, 5,
"Double-X measures five development times"
);
let span = film.log_exposure_max - film.log_exposure_min;
let mut worst = 0.0f32;
let stations = baked.push_stations.clone();
let mut pushes: Vec<(f32, bool)> = stations.iter().map(|p| (*p, true)).collect();
for k in 0..=16 {
pushes.push((-1.0 + 4.0 * k as f32 / 16.0, false));
}
for (push, on_row) in pushes {
let exact = film.curves_at_push(push);
for i in (0..exact.len()).step_by(7) {
let log = film.log_exposure_min + span * i as f32 / (exact.len() - 1) as f32;
let got = baked.sample_curves([log; 3], push);
for c in 0..3 {
let err = (got[c] - exact[i][c]).abs();
if on_row {
assert!(err < 1e-4, "push {push} is a row but misses it by {err}");
}
worst = worst.max(err);
}
}
}
assert!(worst < 1e-3, "between rows the density is off by {worst}");
}
#[test]
fn a_print_exposure_is_exact_at_any_setting() {
// TRACES: FR-DEV-3f
// The enlarger's exposure is added between the two lookups rather than
// baked into either, so no setting is nearer the tables than another.
// Compared against the chain evaluated spectrally, end to end, at
// settings chosen off every half and whole stop.
let film = portra();
let paper = endura();
let baked = bake(&Recipe::new(&film, Some(&paper)));
let offsets = print_balance(&film, &paper, 0.0);
let viewing = Viewing::new(&paper.viewing_illuminant);
let mut worst = 0.0f32;
for stops in [-2.3f32, -0.6, 0.0, 0.35, 1.7] {
for i in 0..14 {
let v = 0.004 * 2f32.powf(i as f32 * 0.6);
let rgb = [v, v * 0.8, v * 1.1];
let mut log_exposure = [0.0f32; 3];
for (l, slot) in log_exposure.iter_mut().enumerate() {
let m = baked.exposure_matrix[l];
*slot =
((m[0] * rgb[0] + m[1] * rgb[1] + m[2] * rgb[2]).max(0.0) + 1e-10).log10();
}
let raw = paper_exposure(&film, &paper, film.density_at(log_exposure));
let paper_log =
[0, 1, 2].map(|l| (raw[l] + 1e-10).log10() + offsets[l] + stops * 2f32.log10());
let exact = viewing.to_srgb(&paper.transmittance(paper.density_at(paper_log)));
let approx = baked.apply_at(
rgb,
&Settings {
print_exposure_ev: stops,
..Settings::default()
},
);
for c in 0..3 {
worst = worst.max((exact[c] - approx[c]).abs());
}
}
}
assert!(
worst < 1.0 / 255.0,
"the print misses the spectral chain by {worst}"
);
} }
#[test] #[test]
@@ -550,5 +861,25 @@ mod tests {
let baked = bake(&Recipe::new(&film, None)); let baked = bake(&Recipe::new(&film, None));
assert_eq!(baked.lut.len(), LUT_SIZE * LUT_SIZE * LUT_SIZE); assert_eq!(baked.lut.len(), LUT_SIZE * LUT_SIZE * LUT_SIZE);
assert_eq!(baked.curves.len(), CURVE_SAMPLES); assert_eq!(baked.curves.len(), CURVE_SAMPLES);
assert_eq!(baked.curve_rows, 1);
assert!(baked.paper.is_none());
// A print has a second lookup and a curve of its own; a development
// series a row per push. Neither is inferred from the other.
let negative = portra();
let paper = endura();
let printed = bake(&Recipe::new(&negative, Some(&paper)));
let print = printed
.paper
.as_ref()
.expect("a printed negative has a paper");
assert_eq!(print.lut.len(), LUT_SIZE.pow(3));
assert_eq!(print.curves.len(), CURVE_SAMPLES);
let pushable = profile(include_str!("../profiles/kodak_doublex.yaml"));
let rows = bake(&Recipe::new(&pushable, None));
assert_eq!(rows.curve_rows, pushable.development_times.len());
assert_eq!(rows.push_stations.len(), rows.curve_rows);
assert_eq!(rows.curves.len(), rows.curve_rows * CURVE_SAMPLES);
} }
} }
+4 -1
View File
@@ -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,
} }
+24 -4
View File
@@ -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
View File
@@ -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.
+185
View File
@@ -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);
}
+79
View File
@@ -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}"
);
}
+263 -6
View File
@@ -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");
}
+142
View File
@@ -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}");
}
+73
View File
@@ -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}"
);
}
+62 -28
View File
@@ -35,41 +35,62 @@ helpers: [luminance, apply_tone_gain]
define: define:
contrast_curve: | contrast_curve: |
// A symmetric S-curve on a 0..1 perceptual position. // The steepening S, on a 0..1 perceptual position.
// //
// `amount` above zero steepens, below zero flattens. The smoothstep form is // Blends toward a smoothstep, which has zero gradient at both ends, so the
// used for the steepening direction because it has zero gradient at both // curve cannot invert however hard it is pushed — the failure that makes
// ends, so the curve cannot invert however hard it is pushed — the failure // naive gain-about-a-pivot unusable past moderate settings. Only the
// that makes naive gain-about-a-pivot unusable past moderate settings. // positive direction comes here: flattening is not a curve at all (see
// the fragment).
fn contrast_curve(x: f32, amount: f32) -> f32 { fn contrast_curve(x: f32, amount: f32) -> f32 {
let clamped = clamp(x, 0.0, 1.0); let clamped = clamp(x, 0.0, 1.0);
if (amount >= 0.0) { let s = clamped * clamped * (3.0 - 2.0 * clamped);
// Blend toward a smoothstep, which is the S. return mix(clamped, s, amount);
let s = clamped * clamped * (3.0 - 2.0 * clamped);
return mix(clamped, s, amount);
}
// Flattening: pull toward the mid-point. At amount = -1 every tone
// collapses to 0.5, which is the meaningful limit of 'no contrast'.
return mix(clamped, 0.5, -amount);
} }
wgsl: | wgsl: |
let luma = luminance(c); if (amount < 0.0) {
if (luma > 0.0001) { // **Flattening mixes toward middle grey; it does not scale.**
// Work on luminance and rescale the colour by the ratio, rather than
// curving each channel independently. Per-channel contrast shifts hue
// wherever the channels differ — the classic symptom being skies going
// cyan as contrast rises.
// //
// MIDDLE_GREY is 0.18: the linear value the eye reads as mid-tone. The // Every tone moves the same fraction of the way to 0.18, which at -1
// curve operates on luma/(2*0.18) so that middle grey lands at the // collapses the picture to grey — the meaningful limit of 'no contrast'.
// curve's own 0.5 pivot. // In luminance this is exactly what the ratio form below would compute,
let pos = clamp(luma / 0.36, 0.0, 1.0); // but the ratio form reaches it by multiplying: a pixel at 0.001 has to
let curved = contrast_curve(pos, amount); // be lifted to 0.09, a gain of ninety, and in the deepest shadows the
// Not `target`: that is a WGSL reserved keyword, and using it produces a // channels are sensor noise, not a colour. After white balance the red
// parse error in generated code rather than anywhere a reader would look. // and blue noise sits above the green (their multipliers are nearly
let curved_luma = curved * 0.36; // twice its), so ninety times that noise is magenta — every black in the
c = apply_tone_gain(c, curved_luma / luma); // frame turned pink. Mixing adds the lift as a neutral, so a black goes
// to grey and its noise stays the size it was.
//
// The grey is (1, 1, 1) scaled, because this runs after white balance
// in the camera's space, where that is what neutral is.
c = mix(c, vec3<f32>(0.18), -amount);
} else {
let luma = luminance(c);
// Only up to twice middle grey, which is the curve's whole domain. Above
// it the curve's value is 1 and its slope 0, so leaving those tones
// alone is the continuous continuation — where scaling them to the
// curve's top, as this once did through a clamp, pinned every highlight
// in the photograph to 0.36 at the smallest touch of the slider.
if (luma > 0.0001 && luma < 0.36) {
// Work on luminance and rescale the colour by the ratio, rather than
// curving each channel independently. Per-channel contrast shifts hue
// wherever the channels differ — the classic symptom being skies going
// cyan as contrast rises. Safe here where it was not for flattening:
// the S only ever pulls a shadow down, so the gain is at most one
// below the pivot and noise is never amplified.
//
// MIDDLE_GREY is 0.18: the linear value the eye reads as mid-tone.
// The curve operates on luma/(2*0.18) so that middle grey lands at
// the curve's own 0.5 pivot.
let pos = luma / 0.36;
let curved = contrast_curve(pos, amount);
// Not `target`: that is a WGSL reserved keyword, and using it produces a
// parse error in generated code rather than anywhere a reader would look.
let curved_luma = curved * 0.36;
c = apply_tone_gain(c, curved_luma / luma);
}
} }
c = max(c, vec3<f32>(0.0)); c = max(c, vec3<f32>(0.0));
@@ -104,6 +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
+3 -1
View File
@@ -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,
}, },
+3 -1
View File
@@ -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,
}, },
+407 -89
View File
@@ -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,34 +2278,29 @@ pub(crate) fn compose_layers_revealing(stack: &MaskStack, reveal: Option<&Reveal
out.uniform_values.push(u.value); out.uniform_values.push(u.value);
} }
for h in op.helpers() { for h in dst.helpers() {
if !out.helpers.iter().any(|e| e.name == h.name) { if !out.helpers.iter().any(|e| e.name == h.name) {
out.helpers.push(*h); out.helpers.push(*h);
} }
} }
let mut fragment = op.wgsl_body(); let mut fragment = String::new();
for u in &op_uniforms { if !dst.blends_settings() {
fragment = crate::operation::rewrite_uniform( fragment = dst.wgsl_body();
&fragment, for u in &op_uniforms {
u.name, fragment = crate::operation::rewrite_uniform(
&format!("u.{op_prefix}_{}", u.name), &fragment,
); u.name,
&format!("u.{op_prefix}_{}", u.name),
);
}
} }
out.ops.push(LocalOp {
let _ = writeln!(out.body, " // ---- {id} ----"); op: id,
let _ = writeln!(out.body, " {{"); slot,
for line in fragment.lines() { fragment,
let _ = writeln!(out.body, " {line}"); });
}
let _ = writeln!(out.body, " }}");
} }
let _ = writeln!(out.body, " masked = c;");
let _ = writeln!(out.body, " }}");
let _ = writeln!(out.body, " c = mix(c, masked, m);");
let _ = writeln!(out.body, " }}");
let _ = writeln!(out.body, " }}");
} }
out out
@@ -2309,6 +2424,78 @@ mod tests {
layer layer
} }
/// A stock the shader can index, with values that are not a real one's.
fn film_fixture() -> crate::graph::Film {
use crate::ops::film_sim::{CURVE_SAMPLES, FORMAT_COUNT};
crate::graph::Film {
stock: "fixture".into(),
print: None,
tables: crate::ops::FilmTables {
exposure_matrix: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
curves: vec![[0.5; 3]; CURVE_SAMPLES],
push_stations: vec![0.0],
curve_log_min: -3.0,
curve_log_max: 1.0,
lut: vec![[0.5; 3]; 8],
density_max: 2.0,
lut_size: 2,
paper: None,
grain_particles: [[0.0; 3]; FORMAT_COUNT],
grain_density_max: [2.0; 3],
grain_uniformity: 1.0,
},
}
}
#[test]
fn a_layers_film_is_blended_as_settings_and_developed_once() {
// TRACES: FR-DEV-3f
// The film is a rendering: a layer's version of it run beside the
// global one and cross-faded would be the photograph developed twice.
// So the layer's uniforms are averaged into the global ones by weight
// and the fragment appears once.
use crate::ops::film_sim;
let mut graph = crate::EditGraph::default_chain();
graph.set_film(Some(film_fixture()));
let mut layer = MaskLayer::new("m1", regions(&[1]));
layer.set_param(film_sim::ID.0, film_sim::PRINT_EXPOSURE, 1.0);
assert!(layer.is_active(), "a film offset is an adjustment");
graph.masks_mut().push(layer);
let source = graph.compose().source;
assert!(source.contains("let set_w = mask_w0;"), "{source}");
assert!(source.contains("let set_g = max(1.0 - set_w, 0.0);"));
assert!(
source.contains("u.mask0_film_sim_pev"),
"the layer's setting is not read"
);
assert_eq!(
source.matches("let density = film_curve_pushed(").count(),
1,
"the film was developed more than once"
);
assert!(
!source.contains("let local_in"),
"the film was blended as a result"
);
}
#[test]
fn a_layers_film_without_a_stock_composes_to_nothing() {
// TRACES: FR-DEV-3f
// The offsets are kept — a stock chosen later brings them back — but
// with no film on the photograph there is nothing for them to offset.
use crate::ops::film_sim;
let mut graph = crate::EditGraph::default_chain();
let mut layer = MaskLayer::new("m1", regions(&[1]));
layer.set_param(film_sim::ID.0, film_sim::PUSH, 1.0);
graph.masks_mut().push(layer);
let source = graph.compose().source;
assert!(!source.contains("---- film_sim ----"), "{source}");
assert!(!source.contains("mask0_film_sim"));
}
#[test] #[test]
fn a_layer_with_no_adjustment_is_not_in_the_shader() { fn a_layer_with_no_adjustment_is_not_in_the_shader() {
let layer = MaskLayer::new("m1", regions(&[1])); let layer = MaskLayer::new("m1", regions(&[1]));
@@ -2317,7 +2504,10 @@ mod tests {
let mut stack = MaskStack::new(); let mut stack = MaskStack::new();
stack.push(layer); stack.push(layer);
assert!(stack.is_neutral()); assert!(stack.is_neutral());
assert_eq!(compose_layers_revealing(&stack, None).body, ""); assert_eq!(
compose_layers_revealing(&stack, None, &ops::chain()).weights,
""
);
} }
#[test] #[test]
@@ -2403,11 +2593,11 @@ mod tests {
stack.push(lit_layer("m1", 1.0)); stack.push(lit_layer("m1", 1.0));
stack.push(lit_layer("m2", -1.0)); stack.push(lit_layer("m2", -1.0));
let shader = compose_layers_revealing(&stack, None); let shader = compose_layers_revealing(&stack, None, &ops::chain());
assert!(shader.body.contains("sample_mask(uv_src, 0)")); assert!(shader.weights.contains("sample_mask(uv_src, 0)"));
assert!(shader.body.contains("sample_mask(uv_src, 1)")); assert!(shader.weights.contains("sample_mask(uv_src, 1)"));
assert!(shader.body.contains("u.mask0_opacity")); assert!(shader.weights.contains("u.mask0_opacity"));
assert!(shader.body.contains("u.mask1_opacity")); assert!(shader.weights.contains("u.mask1_opacity"));
} }
/// The slot a layer renders through must follow `active()`, not the raw /// The slot a layer renders through must follow `active()`, not the raw
@@ -2420,12 +2610,12 @@ mod tests {
stack.push(off); stack.push(off);
stack.push(lit_layer("m2", -1.0)); stack.push(lit_layer("m2", -1.0));
let shader = compose_layers_revealing(&stack, None); let shader = compose_layers_revealing(&stack, None, &ops::chain());
assert!( assert!(
shader.body.contains("sample_mask(uv_src, 0)"), shader.weights.contains("sample_mask(uv_src, 0)"),
"the one active layer must use slot 0, not slot 1" "the one active layer must use slot 0, not slot 1"
); );
assert!(!shader.body.contains("sample_mask(uv_src, 1)")); assert!(!shader.weights.contains("sample_mask(uv_src, 1)"));
} }
/// TRACES: FR-DEV-19c /// TRACES: FR-DEV-19c
@@ -2445,7 +2635,7 @@ mod tests {
stack.push(MaskLayer::new("m2", MaskSource::brush())); stack.push(MaskLayer::new("m2", MaskSource::brush()));
let reveal = Reveal::one("m2", RevealStyle::Alpha); let reveal = Reveal::one("m2", RevealStyle::Alpha);
let shader = compose_layers_revealing(&stack, Some(&reveal)); let shader = compose_layers_revealing(&stack, Some(&reveal), &ops::chain());
assert_eq!( assert_eq!(
stack.rendered_count(Some(&reveal)), stack.rendered_count(Some(&reveal)),
@@ -2483,7 +2673,7 @@ mod tests {
], ],
style: RevealStyle::Tint, style: RevealStyle::Tint,
}; };
let shader = compose_layers_revealing(&stack, Some(&reveal)); let shader = compose_layers_revealing(&stack, Some(&reveal), &ops::chain());
let sky = shader let sky = shader
.reveal .reveal
@@ -2509,7 +2699,9 @@ mod tests {
fn nothing_is_revealed_unless_it_was_asked_for() { fn nothing_is_revealed_unless_it_was_asked_for() {
let mut stack = MaskStack::new(); let mut stack = MaskStack::new();
stack.push(lit_layer("m1", 1.0)); stack.push(lit_layer("m1", 1.0));
assert!(compose_layers_revealing(&stack, None).reveal.is_empty()); assert!(compose_layers_revealing(&stack, None, &ops::chain())
.reveal
.is_empty());
} }
/// A reveal aimed at a layer that is not in the stack is not a slot, and /// A reveal aimed at a layer that is not in the stack is not a slot, and
@@ -2520,9 +2712,11 @@ mod tests {
stack.push(lit_layer("m1", 1.0)); stack.push(lit_layer("m1", 1.0));
let reveal = Reveal::one("gone", RevealStyle::Tint); let reveal = Reveal::one("gone", RevealStyle::Tint);
assert_eq!(stack.rendered_count(Some(&reveal)), 1); assert_eq!(stack.rendered_count(Some(&reveal)), 1);
assert!(compose_layers_revealing(&stack, Some(&reveal)) assert!(
.reveal compose_layers_revealing(&stack, Some(&reveal), &ops::chain())
.is_empty()); .reveal
.is_empty()
);
} }
#[test] #[test]
@@ -2531,7 +2725,7 @@ mod tests {
stack.push(lit_layer("m1", 1.0)); stack.push(lit_layer("m1", 1.0));
stack.push(lit_layer("m2", -1.0)); stack.push(lit_layer("m2", -1.0));
let shader = compose_layers_revealing(&stack, None); let shader = compose_layers_revealing(&stack, None, &ops::chain());
assert!(shader.uniform_fields.contains("mask0_exposure_")); assert!(shader.uniform_fields.contains("mask0_exposure_"));
assert!(shader.uniform_fields.contains("mask1_exposure_")); assert!(shader.uniform_fields.contains("mask1_exposure_"));
assert_eq!( assert_eq!(
@@ -2545,16 +2739,140 @@ mod tests {
); );
} }
/// The whole shader for `stack` over a global chain with `global`
/// applied to it.
fn composed_with(stack: &MaskStack, global: impl FnOnce(&mut [Box<dyn Operation>])) -> String {
let mut chain = ops::chain();
global(&mut chain);
crate::operation::compose_full(
&chain,
&crate::Framing::new(),
dr_types::ColourSpace::Srgb,
stack,
&crate::spot::SpotSet::new(),
&[],
)
.source
}
fn set(chain: &mut [Box<dyn Operation>], op: &str, param: &'static str, v: f32) {
chain
.iter_mut()
.find(|o| o.descriptor().id.0 == op)
.expect("op in chain")
.set_param(ParamId(param), v);
}
fn contrast_layer(v: f32) -> MaskLayer {
let mut layer = MaskLayer::new("m1", regions(&[1]));
layer.set_param("contrast", ParamId("contrast"), v);
layer
}
#[test] #[test]
fn the_inner_block_shadows_c_and_copies_back() { fn the_layer_version_shadows_c_and_blends_by_its_difference() {
let mut stack = MaskStack::new(); let mut stack = MaskStack::new();
stack.push(lit_layer("m1", 1.0)); stack.push(lit_layer("m1", 1.0));
let body = compose_layers_revealing(&stack, None).body; let src = composed_with(&stack, |_| {});
assert!(body.contains("var masked = c;")); assert!(src.contains("var c = local_in;"));
assert!(body.contains("var c = masked;")); assert!(src.contains("local_sum = local_sum + mask_w0 * (c - local_global);"));
assert!(body.contains("masked = c;")); assert!(src.contains("c = max(local_sum, vec3<f32>(0.0));"));
assert!(body.contains("c = mix(c, masked, m);")); }
/// **The bug this shape exists for.** A layer's contrast is added to the
/// global contrast, not run a second time on top of it.
#[test]
fn a_layer_setting_is_an_offset_to_the_global_one() {
let mut stack = MaskStack::new();
stack.push(contrast_layer(-20.0));
let mut chain = ops::chain();
set(&mut chain, "contrast", "contrast", -30.0);
let shader = compose_layers_revealing(&stack, None, &chain);
let at = shader
.uniform_fields
.lines()
.filter(|l| l.trim_start().starts_with("mask"))
.position(|l| l.contains("mask0_contrast_amount"))
.expect("the layer carries its own contrast");
assert_eq!(
shader.uniform_values[at], -0.5,
"global -30 and local -20 is -50 inside the mask"
);
}
/// Where the operation runs is where the layer's version of it runs —
/// between the global operations either side, not after all of them.
#[test]
fn a_layer_runs_at_its_operations_place_in_the_chain() {
let mut stack = MaskStack::new();
stack.push(contrast_layer(-20.0));
let src = composed_with(&stack, |c| set(c, "saturation", "saturation", 20.0));
let contrast = src.find("// ---- contrast ----").expect("contrast block");
let blend = src.find("mask_w0 * (c - local_global)").expect("blend");
let saturation = src
.find("// ---- saturation ----")
.expect("saturation block");
assert!(contrast < blend && blend < saturation);
}
/// **No setting is applied twice.** The layer's version of an operation
/// starts from the colour the operation was handed, not from the global
/// result, and reads only its own combined setting — so global −30 and
/// local −20 is one contrast of −50 inside the mask, never −30 and then
/// −50 again, and the operation does not run a second time after the
/// chain as it once did.
#[test]
fn a_setting_is_applied_once_not_stacked() {
let mut stack = MaskStack::new();
stack.push(contrast_layer(-20.0));
let src = composed_with(&stack, |c| set(c, "contrast", "contrast", -30.0));
assert_eq!(
src.matches("// ---- contrast ----").count(),
1,
"contrast runs at one place in the chain"
);
let version = &src[src.find("if (mask_w0 > 0.0)").expect("layer version")..];
let version = &version[..version.find("local_sum = local_sum").unwrap()];
assert!(
version.contains("var c = local_in;"),
"starts from the operation's input"
);
assert!(version.contains("u.mask0_contrast_amount"));
assert!(
!version.contains("u.contrast_amount"),
"the global setting is already inside the combined one"
);
}
/// An offset that cancels the global setting is not nothing: inside the
/// mask the operation is back at neutral, so the layer's version is empty
/// and the blend pulls toward the colour the operation was handed.
#[test]
fn an_offset_back_to_neutral_undoes_the_global_setting() {
let mut stack = MaskStack::new();
stack.push(contrast_layer(30.0));
let mut chain = ops::chain();
set(&mut chain, "contrast", "contrast", -30.0);
let shader = compose_layers_revealing(&stack, None, &chain);
let local: Vec<_> = shader.ops.iter().filter(|l| l.op == "contrast").collect();
assert_eq!(local.len(), 1);
assert!(local[0].fragment.is_empty());
}
/// A global chain with nothing moved still hands a layer's operation a
/// place to run: the global side of the blend is simply empty.
#[test]
fn an_operation_only_a_layer_moved_still_runs_in_its_place() {
let mut stack = MaskStack::new();
stack.push(contrast_layer(-20.0));
let src = composed_with(&stack, |_| {});
assert!(src.contains("// ---- contrast ----"));
assert!(!src.contains("(local only)"));
} }
#[test] #[test]
+222 -37
View File
@@ -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,48 +885,86 @@ 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() {
if !op_uniforms.is_empty() { // Each op's uniforms are prefixed, so two operations may both
let _ = writeln!(uniform_fields, " // {id}"); // declare a field called `amount` without colliding.
} op_uniforms = op.uniforms();
for u in &op_uniforms { if !op_uniforms.is_empty() {
let _ = writeln!(uniform_fields, " {prefix}_{}: f32,", u.name); let _ = writeln!(uniform_fields, " // {id}");
uniform_values.push(u.value); }
} for u in &op_uniforms {
let _ = writeln!(uniform_fields, " {prefix}_{}: f32,", u.name);
for h in op.helpers() { uniform_values.push(u.value);
if !helpers.iter().any(|existing| existing.name == h.name) {
helpers.push(*h);
} }
}
// Rewrite bare uniform names to their prefixed struct fields, so a for h in op.helpers() {
// fragment is written without knowing about any other operation. if !helpers.iter().any(|existing| existing.name == h.name) {
let mut fragment = op.wgsl_body(); helpers.push(*h);
for u in &op_uniforms { }
fragment = rewrite_uniform(&fragment, u.name, &format!("u.{prefix}_{}", u.name)); }
fragment = op.wgsl_body();
} }
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,
})); }));
+234 -91
View File
@@ -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,16 +438,40 @@ let density = film_curve(clamp((log_exposure - log_min) / (log_max - log_min),
// through whatever density resulted. Adding noise to the finished colour -- // through whatever density resulted. Adding noise to the finished colour --
// which is what an effect does -- tints the highlights wrong, because that // which is what an effect does -- tints the highlights wrong, because that
// noise never passes through the dye at all. // noise never passes through the dye at all.
let grained = film_grain(density, source_px, //
vec3<f32>(gn0, gn1, gn2), // The format's grain count, mixed by the one-hot weights: exactly one format's
// on the whole photograph, and the weighted average under overlapping layers.
let particles = fmt0 * vec3<f32>(gn00, gn01, gn02)
+ fmt1 * vec3<f32>(gn10, gn11, gn12)
+ fmt2 * vec3<f32>(gn20, gn21, gn22)
+ fmt3 * vec3<f32>(gn30, gn31, gn32)
+ fmt4 * vec3<f32>(gn40, gn41, gn42)
+ fmt5 * vec3<f32>(gn50, gn51, gn52);
let grained = film_grain(density, source_px, particles,
vec3<f32>(gd0, gd1, gd2), vec3<f32>(gd0, gd1, gd2),
grain_u); grain_u);
// Dye absorption, the print through the negative, the paper, the viewing // Dye absorption through to what comes next — all of it takes exactly three
// illuminant and the chromatic adaptation — all of which take exactly three // numbers in, which is why it fits in one lookup. Viewed directly, that is
// numbers in, which is why they fit in one lookup. // the picture; printed, it is the light the paper receives through the
c = film_lut(clamp(grained / density_max, vec3<f32>(0.0), vec3<f32>(1.0)), lut_size);" // negative, in log exposure.
.into() let through = film_lut(clamp(grained / density_max, vec3<f32>(0.0), vec3<f32>(1.0)),
lut_size, 0);
if (printed > 0.5) {
// The enlarger: its filtration, and then its exposure, the same stops on
// every layer — which is why print exposure is an addition here and not
// a table, and so exact at any setting.
let paper_log = through + vec3<f32>(pb0, pb1, pb2) + pev * 0.30103;
let paper_density = film_curve(
clamp((paper_log - plog_min) / (plog_max - plog_min), vec3<f32>(0.0), vec3<f32>(1.0)),
u32(rows),
);
c = film_lut(clamp(paper_density / pdmax, vec3<f32>(0.0), vec3<f32>(1.0)),
lut_size, i32(lut_size));
} else {
c = through;
}"
.into()
} }
fn helpers(&self) -> &'static [crate::operation::Helper] { fn helpers(&self) -> &'static [crate::operation::Helper] {
@@ -363,7 +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,
} }
+1 -1
View File
@@ -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};
+3 -1
View File
@@ -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,
}, },
+3 -1
View File
@@ -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,
}, },
+3 -1
View File
@@ -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,
}, },
+31 -1
View File
@@ -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
View File
@@ -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).
--- ---
+6 -3
View File
@@ -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
View File
@@ -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.
--- ---
+55
View File
@@ -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
View File
File diff suppressed because one or more lines are too long
+10
View File
@@ -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
View File
@@ -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
View File
@@ -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.
![What the model found in an urban scene: ground, architecture, sky, vegetation](media/local-categories.png) ![What the model found in an urban scene: ground, architecture, sky, vegetation](media/local-categories.png)
@@ -270,6 +275,15 @@ black-and-white stocks at its end.
![Opening the film list, scrolling it, choosing Velvia, then holding Before](media/film.gif) ![Opening the film list, scrolling it, choosing Velvia, then holding Before](media/film.gif)
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.
![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](media/film-local.gif)
### 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
View File
@@ -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.
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
View File
@@ -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
+59
View File
@@ -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
+2
View File
@@ -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
+1
View File
@@ -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.
/// ///
+36
View File
@@ -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
+8 -9
View File
@@ -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,15 +72,13 @@ 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()) for stream in listener.incoming().flatten() {
.spawn(move || { let weak = weak.clone();
for stream in listener.incoming().flatten() { executors::spawn(Executor::Io, "auto-conn", move || serve(stream, weak));
let weak = weak.clone(); }
std::thread::spawn(move || serve(stream, weak)); })
} .ok();
})
.ok();
} }
fn serve(stream: UnixStream, weak: slint::Weak<AppWindow>) { fn serve(stream: UnixStream, weak: slint::Weak<AppWindow>) {
+2 -1
View File
@@ -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) => {
+3 -2
View File
@@ -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) => {
+204 -76
View File
@@ -749,77 +749,103 @@ 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 .graph
// much film one pixel covers, and the grain model needs nothing else .param(
// to be correct at any zoom. dr_pipeline::ops::film_sim::ID,
// TRACES: FR-DEV-3f dr_pipeline::ops::film_sim::EXPOSURE,
// The frame this is being simulated on, against the pixels it is being )
// rendered to: together they are the enlargement, and the enlargement .unwrap_or(0.0);
// 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 { // Only the balance moves when the same stock and paper are chosen
film: profile, // again — which is what a moved exposure does — and it is three
print: paper, // numbers against a bake of two spectral lookups.
exposure_ev: self let same = self.graph.film().filter(|f| {
.graph f.stock == profile.stock && f.print.as_deref() == paper.map(|p| p.stock.as_str())
.param(
dr_pipeline::ops::film_sim::ID,
dr_pipeline::ops::film_sim::EXPOSURE,
)
.unwrap_or(0.0),
push_stops: self
.graph
.param(
dr_pipeline::ops::film_sim::ID,
dr_pipeline::ops::film_sim::PUSH,
)
.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,
exposure_matrix: baked.exposure_matrix,
curves: baked.curves,
curve_log_min: baked.curve_log_min,
curve_log_max: baked.curve_log_max,
lut: baked.lut,
density_max: baked.density_max,
lut_size: baked.lut_size,
grain_particles: grain.particles,
grain_density_max: grain.density_max,
grain_uniformity: grain.uniformity,
},
})); }));
} }
/// 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,
curves,
push_stations: baked.push_stations,
curve_log_min: baked.curve_log_min,
curve_log_max: baked.curve_log_max,
lut,
density_max: baked.density_max,
lut_size: baked.lut_size,
paper,
grain_particles: grains.map(|g| g.particles),
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.
pub fn film(&self) -> Option<(&str, bool)> { pub fn film(&self) -> Option<(&str, bool)> {
self.graph self.graph
@@ -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
+8
View File
@@ -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;
+6 -7
View File
@@ -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);
+3 -2
View File
@@ -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 });
}); });
+254
View File
@@ -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);
}
}
+5 -2
View File
@@ -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
} }
+4 -3
View File
@@ -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) => {
+7 -8
View File
@@ -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,14 +229,12 @@ 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()) if let Err(e) = run(request, &cancel, &tx) {
.spawn(move || { let _ = tx.send(Message::Failed(e));
if let Err(e) = run(request, &cancel, &tx) { }
let _ = tx.send(Message::Failed(e)); })
} .expect("spawning the import worker");
})
.expect("spawning the import worker");
rx rx
} }
+8 -21
View File
@@ -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
View File
@@ -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
+2 -1
View File
@@ -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 {
+3 -2
View File
@@ -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() {
+3 -2
View File
@@ -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,
+9 -8
View File
@@ -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,10 +846,10 @@ 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 }
} }
+2 -1
View File
@@ -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) => {
+3 -2
View File
@@ -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;
+28 -8
View File
@@ -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
+2 -1
View File
@@ -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) => {
+12 -2
View File
@@ -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