Compare commits

...
12 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
50 changed files with 309 additions and 120 deletions
+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
+25 -25
View File
@@ -1265,7 +1265,7 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]] [[package]]
name = "darkroom-android" name = "darkroom-android"
version = "0.18.1" 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.1" 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.1" 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.1" 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.1" 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.1" 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.1" 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.1" 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.1" 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.1" 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.1" 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.1" 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.1" 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.1" 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.1" 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.1" 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.1" 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.1" 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.1" 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.1" 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.1" 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.1" 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.1" 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.1" version = "0.18.2"
dependencies = [ dependencies = [
"dr-types", "dr-types",
"log", "log",
@@ -7109,7 +7109,7 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]] [[package]]
name = "traceability" name = "traceability"
version = "0.18.1" version = "0.18.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"proc-macro2", "proc-macro2",
+1 -1
View File
@@ -32,7 +32,7 @@ members = [
exclude = ["third_party"] exclude = ["third_party"]
[workspace.package] [workspace.package]
version = "0.18.1" 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"
+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.1**, twenty-seven 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.
+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
+24 -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
+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.
+32 -2
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
+18
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.
+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
+10 -4
View File
@@ -235,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,13 +273,16 @@ sensor does not. The list opens over the column and scrolls on its own — by
wheel, drag or flick, or with `Up`, `Down` and `Enter` — down to the wheel, drag or flick, or with `Up`, `Down` and `Enter` — down to the
black-and-white stocks at its end. black-and-white stocks at its end.
![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 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 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 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 one the photograph was made on. Where layers overlap, the photograph takes the
average of what they ask for. 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.
![Opening the film list, scrolling it, choosing Velvia, then holding Before](media/film.gif) ![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
+9 -4
View File
@@ -306,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
@@ -329,12 +332,14 @@ named stock; below it, the print exposure and push controls a film has and a
sensor does not. The list opens over the column and scrolls on its own — by 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>
<p>The film's sliders work on a mask as they do on the whole photograph, the way <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 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 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 one the photograph was made on. Where layers overlap, the photograph takes the
average of what they ask for.</p> average of what they ask for. Below, a negative stock on an alpine frame, then
<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> 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.1 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