dtourolle 89c4ff1820 Store what the model found, so a reopened photograph keeps its masks
A subject or category layer was written to the sidecar as identity alone —
which run, which instance, which category — on the reasoning that the pixels
are reproducible by running the same model over the same image. They are, but
only by *running the model*, and nothing runs one except a photographer
pressing "find subjects". So on every path that did not already have a run in
memory the layer resolved to no coverage, `MaskPass::render` logged "has no
distance field; skipping", and the adjustment was silently absent:

  - reopening an edited photograph rendered it without its local adjustments,
    and then saved that state back on the way out;
  - a batch export from the grid could not have them at any point, because
    `render_from_library` opens a session, applies a version and renders, and
    there is no model anywhere on that path. Three hundred files written
    without the edits their photographer made, over a log warning.

Neither failure announced itself. The generated shader still emits the layer's
block and the empty placeholder multiplies it by zero, so the result is a
well-formed frame that is simply missing an edit — `mask_is_stale` already
named the state and called it "not stale, just unrenderable".

The coverage now travels in the file, as one `coverage = w h levels payload`
line at the end of the layer's block.

Two levels, and that is not a compromise. The model hands out a byte per pixel
but `Shaped::build` measures its distance field from `coverage >= 128` and
throws the shoulder away on the first line; everything soft about the rendered
edge comes afterwards from the layer's feather and falloff, which are read off
the distance. So one bit per pixel is not an approximation of what the model
said — it is exactly the part of it that reaches a pixel, and the stored mask
renders the identical frame. Storing all 256 levels would have stored 1.7 MB
of bilinear interpolation to reconstruct a predicate, and would not even have
compressed: a model mask is a bilinear upsample of a coarse grid, so almost no
two adjacent bytes are alike. Measured on a simulated sky and a simulated
figure at 1600x1067, against 1.71 MB raw: 4.0 kB and 6.5 kB at two levels,
46 kB and 76 kB at sixteen, 835 kB and 1.43 MB at all 256. The level count is
still written into the line, so a later build that finds a use for the
shoulder can write sixteen and this one will read them rather than misreading
a stream of lengths as pairs.

The coder is hand-rolled — run-length pairs in a base-64 varint — because
`dr-pipeline` links nothing, which is the property that lets the descriptor
and codegen logic be tested without a device. `flate2` would have been fewer
lines and a dependency in the one crate that has none.

Where it lives matters more than how it is coded. The raster sits on
`MaskLayer` beside the source, not inside `MaskSource::Subject`: the source is
*identity*, which is what makes it diff as a handful of numbers and merge per
field under FR-NC-9, and a raster in there would have given the merge a binary
blob to arbitrate. It takes no part in `MaskLayer`'s equality for the same
reason — a device that has run the model and one that has not hold the same
edit, and counting the difference would raise a conflict over a cache and let
`remote_wins` answer it by discarding the only copy of the pixels.

Encoding happens in `masks_for_storage`, on the save path, rather than in
`ensure_subject_fields` where every coverage already funnels through.
`ensure_subject_fields` runs on a drag — dilating a mask with a compound
morphology rebuilds the field every frame — and encoding a megapixel raster
per frame is the kind of work NFR-P5 exists to keep off a gesture. Saving
happens once, when the photograph stops being the open one, and already costs
a network round trip.

Version skew holds both ways. A file with no `coverage` line reads exactly as
it did before, which is a layer that needs the model run; an unreadable one
costs the pixels and not the layer, because the layer is the edit and the
raster is a cache of it. An old build reading a new file drops the key it does
not understand, which costs a model run and no work. And a payload that will
not compress is refused rather than truncated: a checkerboard would encode to
twice the raster it came from, so past 64 kB nothing is stored and the
behaviour falls back to what it was — half a mask would render as a mask that
is confidently wrong, which is the failure that tells nobody.
2026-08-30 21:28:54 +02:00
2026-08-30 16:52:05 +02:00
2026-08-26 10:08:51 +02:00
2026-08-30 16:52:05 +02:00
2026-08-30 16:52:05 +02:00

DarkRoom

A cross-platform, non-destructive RAW photo editor for Linux and Android.

Status: 0.9.0, and no longer a spike. A library opens, culls, develops and exports on both platforms, across eight tagged releases. What is not built is written down rather than merely absent — see docs/outstanding.md for the requirements that have no implementation and why, and docs/technical-debt.md for the compromises that were chosen.

Documentation

Document Contents
CONTRIBUTING.md How to land a first change without reading the rest
requirements.md What the software must do — 179 numbered requirements
architecture.md How it is built — crates, GPU pipeline, data model, sync
technical-debt.md Compromises taken deliberately, each with the condition that retires it
outstanding.md What is not built, and whether that is a decision or a gap
code-health.md What a contribution costs, per seam, measured
traceability.md Generated: which requirement is claimed by which file
faces.md Face detection and identity — the models, the licence problem, and what S14 measured

Building

Desktop:

cargo run -p darkroom-desktop

Android (containerised toolchain, see docker/android):

./docker/android/build.sh cargo ndk -t arm64-v8a build --release

Git LFS is required for the model weights, and the toolchain pins itself. CONTRIBUTING.md has the details and the four commands CI will run against what you send.

Current state

Working. A catalog over a local folder, a Nextcloud account, or a folder a sync client keeps in virtual-files mode — where a placeholder is treated as the photograph rather than as a one-byte file. A virtualised library grid with a capture-time timeline, ratings, labels, keywords, collections and a trash that survives a crash mid-operation. Card ingest. Face detection and identity, with the index syncing between devices. A develop pipeline of fifteen declared operations fused into a single compute dispatch, plus the neighbourhood operations that cannot be — clarity, texture, capture sharpening, noise reduction, lens correction, spectral film simulation. Crop, straighten, spot removal, gradient and subject-segmentation masks, named presets, and a generated panel that no operation in ui/ is allowed to name. Export to JPEG, PNG and 8- or 16-bit TIFF with resize and output sharpening.

The zero-copy display path works on desktop. The compute pass writes a texture that Slint composites directly, which is what ARCH §6.1 requires; the readback it forbids costs 96% of frame time at 4K, and

cargo run -p dr-gpu --example bench --features readback

still reproduces that measurement. The one exception is the Android develop view, which reads the frame back through the CPU because zero-copy there needs wgpu's Vulkan swapchain, and that tears a portrait window on a tablet whose panel is mounted landscape. It is debt, not a revision of the rule: the reasoning, the on-device measurements that forced it, and the three separate things any one of which would remove it are in technical-debt.md TD-1.

Not built. Plugins, compare and survey culling, focus peaking, burst grouping, AI denoise, tiled and progressive rendering, and most of the Android platform integration beyond running. The performance targets in §4.1 are unverified rather than unmet — the per-commit benchmark suite §8 requires does not exist, so nothing fails a build on a regression. docs/outstanding.md is the list, with the reasoning.

Licence

GPL-3.0-or-later.

S
Description
No description provided
Readme GPL-3.0
1 GiB
2026-10-07 11:27:59 +00:00
Languages
Rust 86.1%
Slint 10.3%
Python 1.1%
Shell 1%
WGSL 0.9%
Other 0.6%