Compare commits

..
211 Commits
Author SHA1 Message Date
dtourolleandClaude Opus 5 ce666c768e Let a binary name the release it came from
Build and test / Desktop (Linux) (push) Failing after 28s
Build and test / Layer separation (push) Successful in 27s
Traceability / Requirement traces (push) Successful in 25s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
Build and test / Android (aarch64) (push) Failing after 9m49s
The workspace version has read 0.1.0 since before v0.2.0 was tagged, so every
binary built from this tree has reported itself two releases stale. The cost
lands on whoever reads a bug report quoting it: the version names a commit
from before either release, and they go looking in the wrong place.

Sets the workspace to 0.3.0. The tag stays the release identity; this only
makes the tree agree with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 20:54:34 +02:00
dtourolle 56dd3187f1 Merge integration into wip/ingest
Second pass, against the detail-stage and thumbnail work that has landed since
the first. Resolved and verified here rather than in the shared merge worktree,
so what goes back is a fast-forward.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

# Conflicts:
#	docs/traceability.md
2026-08-22 19:44:42 +02:00
dtourolle 42f1f55fd3 Regenerate the traceability matrix
The detail stage and its four kernels, the camera profiles, per-channel tone
curves, keywords and export metadata all landed since the last regeneration.
2026-08-22 19:37:56 +02:00
dtourolle 36e03258d8 Let texture on a thumbnail render, now that the seam it named is closed
`texture_contributes_nothing_where_its_scale_does_not_exist` asserted that the
detail chain composed *nothing* when texture's kernel rounded away, and its
comment recorded that empty chain as a gap: the fused pass had already decided
to hand on linear working values, so an empty chain left the output transform
undone and the render was rejected. It said fixing it meant composing both
halves together, at the composition boundary rather than in that file.

Noise reduction closed it there in the same round, by emitting a bodyless
`detail/resolve` pass for exactly this case. So the assertion was describing a
defect that no longer exists, and failing because the defect was fixed.

Now asserts the property it was always about — texture contributes no kernel,
`radius() == 0` — while the chain carries the one pass that finishes the
render. Two agents working in parallel each saw one half of this; it is only
visible with both merged.
2026-08-22 19:36:34 +02:00
dtourolleandClaude Opus 5 d91f1ec277 Thumbnail the whole library on request, and send the shards up
The grid fetches a preview only for cells that are actually browsed, which is
the right posture over a link that must not be saturated to show one screen
(FR-NC-3). The cost is that the thumbnail store ends up holding the fraction of
the library someone happened to scroll past — and that store is the one derived
thing worth syncing, since a second device that downloads the shards gets a
full grid without touching a single RAW. So the complete set is worth an hour
of range fetches paid once, deliberately, on a machine that can afford it.

That is what this adds: a pass over every visible image with an `oc:fileid`,
launched from the settings page and reported in the activity register like
every other background job. The work list is what the store lacks rather than a
flag in the catalog, so it is resumable by construction and safe to press
twice.

Lane-parallel like the metadata sweep, but it does what that sweep declined to.
The store is `&mut` and cannot cross lanes, which is why dating the library
skips thumbnails entirely; here the lanes fetch, decode and *encode*, and only
the ~20 KB result crosses back to the one thread that owns the store and writes
the chunk. The parallelism is real and the single-writer rule is not bent.

Grid class only. The large class is ~860 MB of shards against ~200 MB on the
reference library, paid by every device that syncs them; a photograph looked at
closely still gets its large thumbnail from the interactive path.

Dates come free — the header a preview needs is the header EXIF lives in — and
the pass ends by pushing the shards to the server. A filled store that never
leaves this device would be most of the cost for none of the point.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 19:34:59 +02:00
dtourolle ec4283e37f Finish reconciling the whole-chain tests the three kernels each rewrote
Sharpening, noise reduction and clarity were written in parallel and each
rewrote the same two tests, which had counted one fused block per operation —
true only while every operation was a point function.

Kept the exclusive-or formulation: each operation must reach exactly one of
the two stages. A count cannot tell "moved to the detail stage" from
"vanished from both", and that ambiguity is what broke these tests three
times over.

The merge left two fragments of the versions it replaced — a loop over a set
that no longer exists, and the tail of an assertion whose head was gone.
The loop is not restored: `point ^ neighbourhood` already asserts per
operation what it checked over the set. The assertion is, because it catches
a different fault from the exclusive-or — a block in the shader that nothing
in the chain asked for, rather than an operation in the wrong stage.
2026-08-22 19:34:24 +02:00
dtourolle 35b126449b Order the detail stage so the repair runs before the enhancements
Capture sharpening and noise reduction were written in parallel and both
claimed `order: 110`; the codegen refuses that, which is the guard working —
two nodes at one order is an ambiguous pipeline and operation order changes
the result.

Resolved in noise reduction's favour, for the reason its own `placement:`
block already gives: denoising is a repair and everything else in this stage
is an enhancement. Sharpening or adding clarity to a noisy frame amplifies the
grain along with the detail, and no later pass can separate them again. So the
detail stage now runs noise reduction, capture sharpening, clarity, texture,
and the other three shift up a slot to keep the multiple-of-ten convention the
rest of the chain uses.

Noise reduction was also missing the required `attributes:` key. The order
collision aborted the build before the attribute check could report it, so it
arrived looking like one fault and was two.
2026-08-22 19:32:59 +02:00
dtourolle 5d15b753f7 Regenerate the traceability matrix
Five branches merged since the last regeneration. Generated file, so the only
correct version is the one produced once, here, from the merged source.
2026-08-22 19:31:22 +02:00
dtourolle d800af049b Merge branch 'worktree-agent-acd27f9b2974c67eb' into integration
# Conflicts:
#	core/dr-gpu/src/adjust.rs
#	core/dr-pipeline/src/detail.rs
#	core/dr-pipeline/src/ops/mod.rs
2026-08-22 19:31:15 +02:00
dtourolle 5852e14a5c Merge branch 'worktree-agent-a75c901968abfa183' into integration
# Conflicts:
#	core/dr-gpu/src/adjust.rs
#	core/dr-pipeline/ops/README.md
#	core/dr-pipeline/src/lib.rs
#	core/dr-pipeline/src/ops/mod.rs
#	ui/dr-ui/src/develop.rs
2026-08-22 19:29:39 +02:00
dtourolle d92cfcbd8c Merge branch 'worktree-agent-a86b971be6ee42cf1' into integration 2026-08-22 19:27:18 +02:00
dtourolle 66eb5312c5 Merge branch 'worktree-agent-a1c8fdfa4258709aa' into integration 2026-08-22 19:24:11 +02:00
dtourolle 7031352e85 Keep neighbourhood operations out of a mask layer's chain
A layer holds a full chain and fuses it into the colour dispatch, so the
panel - which names no operation - would have offered a noise reduction
slider inside a local adjustment. It could not have worked: the detail stage
is its own dispatch, running after the masks are already applied, with
nowhere to be handed one layer's mask. The control would have moved and done
nothing. Filter the layer's chain to the operations that can honour it.
2026-08-22 19:23:43 +02:00
dtourolle 690e76a51f Let the fully-active chain test account for both stages
Activating every operation now activates a kernel too, and a kernel emits no
block in the fused shader. Assert that each operation reaches exactly one of
the fused pass and the detail chain, rather than counting fused blocks
against the length of the chain.
2026-08-22 19:21:59 +02:00
dtourolle eb229051ef Teach the whole-chain GPU tests about neighbourhood operations
Both tests composed only the fused half and rendered it through the plain
path. That was correct while every operation was a point operation; with a
kernel in the chain the fused pass stops short of the output transform, so
the render was rejected and the operation-block count was one too high.

Compose both halves and dispatch them together, and assert that each
operation reaches exactly one of the two stages rather than counting blocks
- so the next kernel added extends the coverage instead of breaking it.
2026-08-22 19:20:22 +02:00
dtourolleandClaude Opus 5 28325af448 Make the thumbnails while the originals are still in hand
An import read every byte of every original, uploaded them, and then left the
grid to fetch a preview range back out of each one over the network — for
files that had been on this machine minutes earlier.

The pixels are now made from the bytes already in memory, on the fast path
FR-CAT-3 names: locate the camera's own embedded JPEG, decode a few hundred
KB, downscale, orient. Never a demosaic. A file with no usable preview yields
none, which is not an error and simply leaves the grid to fetch one later.

The filing waits for the upload, and only the filing. The shard store is keyed
by `oc:fileid` (FR-NC-5) and that does not exist until the server has the
file — so the thumbnail is made from the local copy and held until an id can
be attached to it. One listing per folder supplies every id at once, rather
than a PROPFIND per photograph over a link that may be mobile data.

Best-effort throughout: a thumbnail that cannot be filed costs the grid one
preview fetch later, and failing an import over it would be the wrong trade.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 19:20:13 +02:00
dtourolleandClaude Opus 5 e3dd1526e0 Recognise a card whose photographs are already held
Re-inserting a card that has already been imported produced a folder full of
`-1` copies. Both halves of the placement logic treated a taken name as a
collision to rename around, which is right for the case they were written for
— two cameras both writing IMG_0001.CR3 — and exactly wrong for the far more
common one, where the taken name is the same photograph.

Locally this cannot be answered from the catalog. On a library whose catalog
describes a *server*, a file sitting in the local destination has no row to be
found by, so the only way to know whether it has already been copied is to
look. The destination folder is listed once per folder rather than probed per
file: a card is two thousand frames landing in a handful of days.

Remotely the same question is one PROPFIND that was already being made to
resolve the name, so `upload_original` now returns `Placed::AlreadyThere`
instead of inventing a second copy of work that is already safe. The count is
reported apart from `uploaded`, because "12 already on the server" and "12
uploaded" are different answers to whether this run backed anything up — and
apart from the local duplicate count, because these files *were* copied here.

Name plus length decides it, not a digest: this runs before any transfer, and
hashing to answer it would read the whole card to avoid reading the whole card.
A camera reusing a filename after IMG_9999 writes a different number of bytes
essentially always, which leaves the rename for the case it is really for. The
digest tier still catches the same frame under a different name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 19:19:59 +02:00
dtourolleandClaude Opus 5 b1bf68022d Do not offer an import where one cannot be done
The Import button went into the library header unconditionally, so an Android
user got a page that opens, finds nothing, and cannot be pressed — worse than
no page at all, because it reads as broken rather than as absent.

`dr_plat::imports_supported()` answers the question the interface actually has,
which is not "did we find a volume". An empty list on Linux means plug one in;
false here means it cannot be done on this device however hard the user tries.
It is false on Android for two reasons that both have to be fixed before it
changes: there is no mount table to read and no path to type, and nothing
implements WritableStorage except LocalStorage.

The button is hidden rather than disabled. The buttons beside it come and go
with the selection — unavailable now, available in a moment — where this one
never will be here, and a permanently disabled control teaches the reader that
the row lies.

What this gates is the interface, not the engine. dr-ingest takes storage
traits and never a path, and cross-compiles to aarch64-linux-android today; it
should need no changes when a SAF implementation lands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 19:19:40 +02:00
dtourolleandClaude Opus 5 2459a759af Compile the detail stage in the whole-chain GPU tests
every_operation_generates_compilable_wgsl and the_whole_chain_at_once_compiles
both rendered through the fused half only. A neighbourhood operation
contributes no fused fragment, so its kernels went uncompiled — and once one
is active the fused pass hands on linear working values, which plain render
refuses. Both now compose both halves from the one graph, and the fused
block count excludes the operations the detail chain names.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 19:18:56 +02:00
dtourolleandClaude Opus 5 c9305fd0e6 Write out the derivation behind every kernel width these tests assert
A test that cannot be run cannot be checked by running it, and a number
copied out of a test run agrees with whatever the code did on the day.
Each asserted kernel width, tolerance and overshoot bound now carries the
arithmetic that produces it -- the shorter edge, the sigma, the
truncation at two sigmas, and where the rounding falls -- so a reader can
verify the expectation against the recipe without a GPU or a compiler.

Also records the two places where a bound is a bound and not a
measurement: the tolerance in the frame-fraction test is exactly what
rounding a kernel to a whole pixel costs on the smallest frame it uses,
and the halo test's floor and ceiling bracket a peak derived from the
step, the soft limit and the midtone taper rather than from a run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 19:18:13 +02:00
dtourolleandClaude Opus 5 e44c929afc Guard the sharpening kernel against WGSL reserved keywords
The fused-fragment check in lib.rs cannot see a detail pass: it is a
separate shader composed at a resolution compose() never knows. The
kernel's own test now scans the block the composer wrapped, with comments
stripped so prose about the keyword cannot fail a test about the code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 19:17:05 +02:00
dtourolleandClaude Opus 5 88ce89428b Teach the whole-chain shader test about neighbourhood operations
`the_whole_chain_at_once_compiles` counted one `---- ` block per
operation in the chain. That was true while every operation was a point
operation, and stopped being true the moment a neighbourhood one existed:
clarity and texture are active in that test, and still emit no fused
block, because `compose_full` filters them out and the detail stage
dispatches them separately.

Counted now by asking each operation whether it has a detail stage --
the same question the composer's own filter asks -- rather than by
subtracting a number someone has to remember to update. Capture
sharpening and noise reduction are covered by this without another edit.

The render at the end is now `render_detailed`, which is not a
concession but the stronger test: with a neighbourhood operation active
the fused pass hands on linear working values and the last detail pass
performs the output transform, so rendering the fused half alone is the
mismatch `render_detailed` exists to reject -- and the detail passes are
generated WGSL with uniform blocks of their own, which is exactly what
"everything at once" is here to collide. It renders at 512 rather than
32 because a compositional radius is a fraction of the frame, and on a
32-pixel target every detail kernel rounds away to nothing.

Also records, in `texture_contributes_nothing_where_its_scale_does_not_exist`,
the seam this uncovered: an active detail operation whose kernel rounds
away composes an empty chain while the fused pass has already been
composed to hand on linear values, and nothing can then encode the
result. That test now asserts the property on the composed chain instead
of driving the unrenderable configuration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 19:16:52 +02:00
dtourolle 69fb510c50 Measure the chroma tests on colour difference, not on red
A colour square wave built from equal, opposite swings of red and blue is
not a pure colour pattern: Rec. 709 weights them 0.2126 and 0.0722, so it
carries a luminance square wave of about a seventh of the swing underneath.
Correlating the raw red channel therefore reads a constant floor that the
chroma filter is not meant to remove, which compressed every ratio towards
one - enough that the resolution test could no longer tell a correct kernel
from one twice the size. Correlate the colour difference instead, and write
the derivation of each expected value into the test.
2026-08-22 19:16:43 +02:00
dtourolleandClaude Opus 5 59362fcecf Let the copyright survive the export, and the GPS not
Carries the source's metadata all the way to the file the user hands over,
and proves in bytes that the coordinates do not come with it.

The privacy test was the piece that mattered and the piece that was wrong.
It searched the whole file for the two-byte hemisphere reference "N\0" or
"E\0", which is not a fingerprint of a GPS directory at all: sample 14 of the
sRGB tone curve inside the ICC profile every export embeds is 69, written as
`00 45`, and the next sample is below 256, so its high byte is `00`. Every
format would have failed a test about a colour profile. The needle is now the
twenty-four bytes a coordinate actually serialises to — three rationals, both
byte orders, since exif.rs writes little-endian and the tiff crate writes in
the host's — which cannot match by accident, and the retaining test asserts
the same needle is *present* so a search that could never find anything
cannot make the stripping test pass by being useless.

The batch exporter now hands the decoder's reading on to the encoder. It
already read the metadata for the orientation and the {date} token; passing
it through is what puts the camera, the lens and the rights statement into
the file. Nothing about privacy is decided there — dr-export takes that
decision once, from the settings.

The example passes it too, because it is the only place in the tree that
produces files a person can open in exiftool. A unit test can prove a GPS
directory is absent from a byte slice; only a real export proves a real
photograph comes out the far end still knowing which camera took it.

TRACES: FR-EXP-8

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 19:15:06 +02:00
dtourolle df3fe660e5 Finish the render when a kernel is too small to draw
An active detail operation may emit no pass at a given render scale - the
honest answer for a sensor-sized radius on a heavy proxy. The fused composer
cannot see that, having no resolution to consult, so it had already stopped
short of the output transform and the frame died on a storage-format
mismatch. Compose a bodyless resolve pass in that case so the output
transform still happens exactly once.
2026-08-22 19:14:57 +02:00
dtourolleandClaude Opus 5 00663a870b Teach the chain test that a detail node has no fused fragment
every_operation_can_be_activated_together counted one block per operation
in the chain, which was true only while every operation was a point
function. A neighbourhood operation is a dispatch of its own and emits no
fused block, so the count now excludes the operations the detail chain
names, and each of them is separately asserted absent rather than the
comparison being loosened.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 19:14:54 +02:00
dtourolleandClaude Opus 5 b321556dbe Sharpen the capture with a separable unsharp mask
Capture sharpening as a two-pass unsharp mask in the detail stage: blur
along x, then along y, each pass applying a one-dimensional high-pass to
luminance so the composite preserves a flat field exactly and matches the
textbook kernel on any locally one-dimensional edge.

The radius is stated in source pixels and converted once per render, so a
radius tuned on a fit view is the radius the exported file gets. Below one
render pixel the operation declines to draw rather than showing sharpening
the file will not contain, and emits a single pass-through that still
carries the output transform.

The develop session now renders through render_detailed, which is what
lets an active neighbourhood operation reach the screen at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 19:13:00 +02:00
dtourolle 3846c277c8 Sync with integration 2026-08-22 19:04:40 +02:00
dtourolle a9baebc396 Sync with integration 2026-08-22 19:04:27 +02:00
dtourolle 5db234bdf3 Sync with integration 2026-08-22 19:04:24 +02:00
dtourolle f78c8b6959 Sync with integration 2026-08-22 19:04:13 +02:00
dtourolle 45214d3ca8 Regenerate the traceability matrix
Four branches merged, each of which had regenerated this file against its own
tree. Those versions all disagreed and none was right for the union, which is
why the merges took whichever side was to hand and deferred to this: the matrix
is generated, so the only correct version is the one produced once, here, from
the merged source.
2026-08-22 19:01:39 +02:00
dtourolle cd8750462f WIP: EXIF metadata on export
Checkpoint committed by the coordinator, not by the authoring agent: the
session hit its API limit mid-task and left this work uncommitted. Committed
so it survives, NOT because it is finished - expect failing tests and
half-applied changes. The agent resumes from here.
2026-08-22 19:01:18 +02:00
dtourolle 97d4bd9061 WIP: clarity and texture
Checkpoint committed by the coordinator, not by the authoring agent: the
session hit its API limit mid-task and left this work uncommitted. Committed
so it survives, NOT because it is finished - expect failing tests and
half-applied changes. The agent resumes from here.
2026-08-22 19:01:18 +02:00
dtourolle b4e55b47c1 WIP: noise reduction
Checkpoint committed by the coordinator, not by the authoring agent: the
session hit its API limit mid-task and left this work uncommitted. Committed
so it survives, NOT because it is finished - expect failing tests and
half-applied changes. The agent resumes from here.
2026-08-22 19:01:18 +02:00
dtourolle 8ea427de3c WIP: capture sharpening
Checkpoint committed by the coordinator, not by the authoring agent: the
session hit its API limit mid-task and left this work uncommitted. Committed
so it survives, NOT because it is finished - expect failing tests and
half-applied changes. The agent resumes from here.
2026-08-22 19:01:18 +02:00
dtourolle 0d9910efc6 Merge branch 'worktree-agent-a89309a856c8f4947' into integration 2026-08-22 19:00:56 +02:00
dtourolle 21500b45be Merge branch 'worktree-agent-abfe489c84c337e7c' into integration 2026-08-22 19:00:56 +02:00
dtourolleandClaude Opus 5 13d003b89d Let the curve widget plot whichever curve is asked for
An operation with four curves and a panel that draws one plot needs a way to
say which. The panel finds out the way it finds out everything else: the
points are faceted with the subject they act on, consecutive parameters
sharing a subject are one curve, and a widget spanning several of them gets
a selector over their names. Nothing in ui/ contains the word "red", and an
operation that grows a fifth curve arrives with a fifth chip.

The names ride on the panel rather than on the curve's row, because a Slint
model is compared by identity: a fresh list built on every parameter event
would make the row look changed every time, and rewriting a row rebuilds the
element holding the drag in progress. That is the hazard the in-place point
update already exists to avoid.

Which curve is on show is interface state, not an edit. It changes no pixel,
so it takes no history step, reaches no sidecar, and redraws nothing — the
photograph on screen is already right.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 16:06:21 +02:00
dtourolleandClaude Opus 5 d64a61d677 Give the tone curve a curve for each colour channel
The declaration in ops/tone_curve.yaml has claimed per-channel curves since
it was written — it is the justification for the operation carrying both
`tone` and `colour`. Only the master curve existed. This is the other three.

The master runs first and the channels grade its result. Both orders are
real images and they differ visibly, so the choice is made and written down
rather than left to the loop: a point placed on the blue curve should act on
the tone the photographer can see, which is what the master has already
produced. The other order anchors the grade to tones the master is about to
move, so adjusting contrast slides a warm shadow up into the midtones.

Every id that existed before today is spelled exactly as it was. The master
curve keeps `p2_y` and the new curves take `r_`, `g_` and `b_` prefixes, so
a sidecar written when there was one curve loads, means what it meant, and
renders the same shader — asserted on the generated source, not on the
parameter values. Nothing needed a version check because nothing was
renamed.

Each curve reaches the shader only when it has been moved off the diagonal,
so an S-curve and no colour work generates what it generated when this
operation held ten parameters instead of forty, down to the uniform names.
The monotonicity guarantee is enforced per curve: a coincident pair on blue
divides by zero exactly as thoroughly as one on the master.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 16:06:13 +02:00
dtourolleandClaude Opus 5 f4c21c4e2c Regenerate the traceability matrix after the merge
Both sides added tags. 49.7%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 15:53:25 +02:00
dtourolleandClaude Opus 5 49be1fe354 Give the library somewhere to type a keyword
A sheet over the grid, opened from the header beside "Add to collection" —
deliberately the same card, scrim and dismissal as the filing sheet, because
they are the same gesture applied to two kinds of label: pick the photographs,
then say what they are. A user who has filed a selection already knows how this
works.

A word the whole selection carries, a word only some of it carries, and a word
none of it carries are three visibly different marks. Half-applied shown as
applied would be a lie about photographs the user cannot see from here, so a
partial keyword draws a dash and says "3 of 12" beside it. Tapping a dash
completes the keyword rather than removing it, which is what it means nine times
in ten, and the tenth is one more tap away.

The vocabulary is answered against the selection in Rust and pulled when the
sheet opens rather than pushed on every selection change — the selection moves
on each arrow key and the sheet is shut for almost all of them.

Assign and unassign travel by name, so a word typed into the field and a word
tapped in the list are one path rather than two, and the sheet never has to
invent an identity for a keyword that does not exist yet.

One gap, commented at the call site: unlike a star or a flag, a keyword is not
queued to the image's sidecar, because the sidecar format has no field for one.
So it reaches the user's other devices through the catalog merge, and a deleted
catalog loses keywords where it would keep ratings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 15:51:11 +02:00
dtourolleandClaude Opus 5 62188ec740 Keep both devices' keywords when the catalogs meet
Keywords are catalog state, and the catalog syncs. Without this, two devices
keywording the same library would resolve to whichever synced last, and an
afternoon of work would vanish with no sign it had ever happened.

The vocabulary merges per row on the rule collections already use: revision
first, timestamp only to break a tie, so a device with a skewed clock cannot win
by having the wrong idea of the time. Assignments merge as a set union, which is
FR-NC-9's principle applied to metadata instead of edit nodes — disjoint work
survives on both sides.

Three things needed care and are commented where they happen:

A deletion travels *by name*, not by identity. Both devices may have minted
their own uuid for one word before they ever synced, so deleting by uuid would
tombstone a row nothing was assigned to and leave every photograph still
carrying the word. The union then refuses to readmit a word a winning tombstone
has just removed — without that filter the remote's live assignments would
resurrect it on the very same pass.

Images are resolved by the server's file id first and the content hash second.
Membership has always used the hash alone, but the hash is computed only when
import dedup or a reconnect asks for it, which for most libraries is never — so
a hash-only union would have quietly done nothing for the ordinary photograph.

A word lands on the local default version. Version uuids do not reconcile in the
catalog at all: ensure_default_versions mints a fresh one per device, so a
uuid-keyed join would have unioned nothing.

Removal still does not propagate. That is the trade collection membership
already makes, for the same reason — an unwanted keyword is removed again in a
second, and a silently lost afternoon is not recoverable at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 15:51:00 +02:00
dtourolleandClaude Opus 5 2147eaa6a5 Put a keyword on a photograph, not only search for one
The catalog has been able to *find* by keyword since v1 — query.rs joins the
keywords table, matches it exactly, and substring-matches it for free text — and
nothing anywhere could ever put a word there. A user could filter to a keyword
they had no way to apply.

This is the missing half: create, rename, delete, list, assign, unassign, and
the two reads a panel needs. Bulk-only for assignment, because keywording a
selection is the common case rather than the exception — the photographer picks
out the frames with the puffin in them and applies "puffin" once, in one
transaction.

Schema v6 adds `keyword_terms`, and deliberately does *not* touch the v1 join.
The assignment keeps the word as text because the catalog is a rebuildable index
and the durable copies of that fact — the sidecar, XMP dc:subject — both carry a
string; a foreign key would mean a catalog rebuilt from sidecars had to invent
identity rows before it could record anything, and would break the query path
that already works. So the text is the fact, and the new table is only the
identity a rename and a deletion can be keyed on.

`keyword_terms.name` carries no unique index, which looks like an oversight and
is not: two devices that each type "Iceland" are both right until they meet, and
a constraint would abort the merge at that moment. Uniqueness is converged upon
instead — create resolves an existing name, fuse_duplicates collapses a
cross-device pair onto the smaller uuid.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 15:50:49 +02:00
dtourolleandClaude Opus 5 b1e56877aa Merge integration into wip/ingest
Brings in the lens-profile and neighbourhood-operation work so the card
import is verified against what it will actually be merged into, rather
than against the tree it was written on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 15:49:51 +02:00
dtourolleandClaude Opus 5 939f33a3a3 Say on the import page where the photographs end up
Three lint findings and the gap the third one was pointing at.

`Context::library_label` was dead: the page named the folder on this machine
and said nothing at all about the server, which is half of what the Import
button commits to and the half that takes minutes rather than seconds. It now
names both, and states the order — copied and verified here first, then
uploaded (FR-NC-7b) — beside the destinations rather than beside the button,
because someone watching a slow upload needs to already know the photographs
are safe on disk.

The label is cached when the page opens rather than read in `render`: reaching
it goes through the context closure to the account, and `render` runs on every
keystroke.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 15:49:02 +02:00
dtourolle c963dafd09 Merge branch 'worktree-agent-afd449f5e7a01e341' into integration
# Conflicts:
#	core/dr-gpu/src/adjust.rs
#	core/dr-pipeline/ops/README.md
#	core/dr-pipeline/src/lib.rs
#	docs/traceability.md
2026-08-22 15:35:29 +02:00
dtourolle 1526c957cf wip: ingest 2026-08-22 15:34:43 +02:00
dtourolle 7f60a2547c Merge branch 'worktree-agent-a75dc051d9bf691de' into integration
# Conflicts:
#	docs/traceability.md
2026-08-22 15:34:29 +02:00
dtourolleandClaude Opus 5 60d5504fb4 Prove on a device that the profile reaches the screen
The unit tests either side of the base curve check halves — that the shipped
database parses and lifts its midtones, and that the generated WGSL evaluates
a curve in the right place. Neither would notice if the two agreed with each
other and both were wrong: a curve packed into the wrong uniform slots, or a
flag read from the wrong component, satisfies both and renders nothing.

So render real pixels. A flat frame through a neutral edit, once with the
Canon EOS 6D's curve looked up by name from the YAML and once with the
identity, asserting what a base curve is actually for — midtones lifted,
black still black, white still white, monotone the whole way — plus the
number an unprofiled body must still produce, so "never worse than today"
is a value rather than a promise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 14:40:51 +02:00
dtourolleandClaude Opus 5 7407a82aa7 Let an operation read the pixel next to it, and settle where sharpening belongs
The fused pass hands a fragment a colour and no coordinate. That is what buys
one dispatch for a whole edit, and it is also a wall: sharpening, noise
reduction, clarity, texture, dehaze and spot removal are each defined by what
the neighbours are doing, and FR-DEV-3 and FR-DEV-8 ask for all six. None of
them could be written at any price.

So there is now a detail stage. An operation implements `Operation` for its
parameters exactly as before — the panel, the sidecar, the history and the
presets all work unchanged — and additionally returns `Affects::Detail` and a
`DetailStage` yielding one pass per dispatch. `Affects` grows the third variant
`docs/requirements.md:250` designed and nothing had cut.

Where the stage sits is a colour-science decision, not an arrangement of
convenience. It runs after every point operation and every mask layer, so an
amount chosen against a tone curve survives the curve moving; in linear sRGB
after the camera matrix, because camera RGB has no luminance to sharpen
against; and before the output transform and the clip, because FR-DEV-2 allows
one quantisation and a highlight clipped before a convolution grows a dark
ring. The fused pass therefore ends one of two ways, and when a detail stage
follows it hands on unclipped f16 and the last detail pass encodes.

At render resolution rather than on the source, which is the whole of FR-DSP-1:
a pass before the framing prologue would cost 24 MP to draw a 2 MP preview.
`RenderScale` is what makes that survivable — a radius is stored as a fraction
of the frame's shorter edge, exactly as a mask feather already is, or as a
count of source pixels, and converted per render. It also reports when a radius
is smaller than a proxy pixel rather than drawing a plausible lie; zooming to
1:1 makes the preview exact with no second path.

`Invalidation` gives FR-DEV-3d something to mean. Moving a detail parameter
leaves the colour key alone, so `AdjustPass` keeps the linear intermediate and
skips the fused dispatch: dragging a sharpening slider costs a convolution.
Moving exposure does re-run the detail passes, because they read what the
colour pass wrote, and there is no arrangement of keys that avoids it while
keeping sharpening after tone.

Validated by a separable box blur that is not a develop operation, behind the
`detail-probe` feature and absent from a shipping build. An abstraction with no
consumer is a guess; a box blur's answer is known in closed form, so the tests
assert every byte of the ramp rather than that the edge got softer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 14:39:35 +02:00
dtourolleandClaude Opus 5 743fefe7f1 Render each body through the profile its own files describe
Colour came from whichever matrix rawler happened to key `D65`, the second
one was discarded, and the rendering was left linear. That is the dcraw
default, and FR-DEV-3e names it as the reason people abandon a converter in
the first hour: correct in the abstract, flat and poor on skin in practice.

The decoder now builds a camera profile.

- `ColorMatrix1/2` and `CalibrationIlluminant1/2`. rawler surfaces these as
  an illuminant-keyed map — for DNGs from the tags, and for native formats
  from its own camera database — so a Canon CR2 arrives with a tungsten
  matrix and a daylight matrix exactly as an Adobe DNG of the same frame
  would. Dual-illuminant support is therefore not a DNG feature here.

- `ForwardMatrix1/2`, read straight from the root IFD, because rawler parses
  them and never surfaces them. Where a file carries both, they replace the
  inverted colour matrix: the same relationship measured in the direction
  rendering actually wants, rather than an inversion that amplifies the
  measurement error exactly where skin lives.

- `AsShotNeutral`, used to estimate what the scene was lit by and to
  interpolate between the two calibrations in mireds. The estimate is
  circular — the temperature needs a matrix and the matrix needs the
  temperature — so it is a fixed point, three rounds, as Adobe's SDK does it.

Bodies calibrated at neither D65 nor A stopped rendering uncalibrated as a
side effect: a Phase One IQ3 carries D55 and D75 and used to get no matrix
at all.

And a base curve, applied per channel in camera RGB between the last
adjustment and the conversion out of camera space — a toe, a steep midtone
and a shoulder, which is the difference between a photograph and a scan of
one. It is not an edit: no slider, nothing in the sidecar, because it
belongs to the body rather than to anything anyone decided, and a sidecar is
shared between bodies. It is not a develop node either, and `ops/README.md`
now records why. It evaluates on the tone curve's own spline rather than a
second copy, so a profile author placing a control point and a photographer
dragging one mean the same thing by it.

The curves are data. `core/dr-decode/profiles/base_curves.yaml` ships inside
the binary as a floor and is superseded by any copy on disk carrying a
higher `version:`, so a body can be added and distributed without a release
— and, under the GPL, contributed. The comparison runs both ways: a stale
pack cannot hold an upgraded binary back at last year's rendering.

Canon EOS 6D and R6, Nikon Z 6 and D750, Sony A7 III and Fujifilm X-T3 ship
with their own curves. Every other body gets a conservative default, which
is much closer to right than the identity is for any of them. A JPEG gets
none — it has already been rendered once, by the camera.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 14:37:33 +02:00
dtourolle 735683b849 wip: ingest 2026-08-22 14:12:40 +02:00
dtourolleandClaude Opus 5 98e0ad0537 Regenerate the traceability matrix
FR-CAT-10, FR-NC-7a and FR-NC-7b gained implementations; the two new
requirements also raise the denominator. 46.9% to 48.6%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 14:01:01 +02:00
dtourolleandClaude Opus 5 125fccbb46 Put an uploaded original in its dated folder
The transport was built and had nowhere to aim: put_chunked has existed since
the connector landed, and the only things pushed through it are thumbnail
shards and the catalog snapshot.

The folder segments arrive already expanded, from dr_ingest::layout, rather
than being re-derived here. That split is what makes FR-NC-7a's first
consequence true — if this module worked out the folders from whatever the
local library happened to look like, two machines with differently organised
libraries would file the same photograph in two different places on one
server.

A name is resolved against one listing rather than a probe per candidate: a
day folder is one PROPFIND and the answer covers every collision in it, where
probing costs a round trip per attempt over a link that may be mobile data.
Two cameras both produce IMG_0001.CR3, and the second must not overwrite the
first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 14:01:01 +02:00
dtourolleandClaude Opus 5 7d1e6f724e Import photographs from a card
Distinct from a scan, and the distinction is the whole reason the crate
exists: a scan catalogues files where they already are, where an import moves
them from a card into the library. A scan that fails halfway has read
nothing; an import that fails halfway has written something.

So the failure paths are the design. Bytes stream at 1 MiB and are hashed on
the way past, so an 80 MB RAW never sits in memory. The second destination
(FR-CAT-10's backup copy) is written from the same read rather than copied
from the primary afterwards — a backup made by re-reading the primary would
inherit a bad write rather than catch it, and re-reading the card doubles the
wear on the one copy that still exists. Verification re-reads the
destination, because hashing what is still in memory would pass on a full
disk, a dying card and a truncated write alike. Anything that fails past the
point of creating the file takes the file back, or the next scan catalogues a
truncated RAW as though it were fine.

Three things the crate refuses to know. It never deletes from the card: a
move-import records what is now redundant and a separate retire() does the
deleting, because on a syncing library "safe" means the upload was confirmed
(FR-NC-7b). It does not decode, so capture metadata arrives through a probe
and a card of unreadable files costs no demosaic. And it does not know what a
duplicate is, since that is a catalog query — FR-CAT-11's two tiers arrive as
one closure asked twice, once before any transfer and once with the digest.

Camera serial would be the stronger metadata key and is absent, because
nothing in the tree reads it yet; make and model plus capture time and the
original filename is what is available, and the digest tier covers the gap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 14:00:47 +02:00
dtourolleandClaude Opus 5 37d8744db8 Let storage write as well as read
Storage enumerates and reads, which is all a scan ever needed. An import
writes, and there was nothing to write through.

WritableStorage is separate from Storage rather than folded into it, because
the two are not granted together: a card mounted read-only, or a share the
user has view rights on, should fail to typecheck as a destination rather
than fail with EROFS halfway through a copy. Ingest takes a &dyn Storage
source and a &dyn WritableStorage destination, which is exactly the asymmetry
of copying off a card.

Shaped for SAF throughout, for the same reason the read side is: a document
id is not composable, so every call takes a parent reference plus one name
and hands back the reference the provider itself produced. create_dir is
idempotent because importing a second card on the same day must land in the
folder the first made, and the naive SAF call would produce "2026-08-22 (1)".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 14:00:26 +02:00
dtourolleandClaude Opus 5 c36d4c80c8 Give the calendar one reading of a capture instant
The era-based conversion sat in library_ui.rs with two readers: the
timeline's month headings and, through format_date, the exporter's {date}
token. Import folder templates are a third, and three copies of a date
calculation that could drift apart is one too many — an image filed under a
date the timeline does not show it on is a file the user cannot find.

Moved to dr_types::time, which is where shared vocabulary lives, and given
the offset-aware reading an import needs: a shot taken at 23:30 in Tokyo
belongs in Tokyo's day, and filing by UTC would split one night's
photographs across two folders at whatever hour the offset happens to be.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 14:00:26 +02:00
dtourolleandClaude Opus 5 5945f7420a Say where an uploaded original lands
FR-NC-7 specifies how bytes travel and deliberately has no opinion about
where they go, and nothing else said either — so the one thing a photograph
needs on arrival, a folder, was unwritten.

FR-NC-7a gives it `{yyyy}/{yyyy}-{mm}-{dd}`, and two consequences the
transport mechanics do not supply on their own: expansion is a pure function
of capture metadata, so two devices computing a destination for the same
frame agree; and the template governs placement on upload only, because the
remote library is reachable by other clients and restructuring it under them
is not ours to do.

FR-NC-7b joins import to upload. A move-import must not erase the card until
the upload is confirmed — a card erased against an in-flight transfer is the
one failure in this application with no undo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 14:00:13 +02:00
dtourolle 610f679881 Reconcile the three branches
Two faults textual merge could not see. Both agents added a `mod tests` to
masks_ui.rs — the module boundary was an artefact of them being written
apart, and the tests do not overlap, so they fold into one. And `segment`
lost its context argument when the work moved to a worker, which a test
written on another branch still passed.
2026-08-22 13:33:55 +02:00
dtourolle 5bcd0e0269 Merge branch 'worktree-agent-a22a049c461818dbe' into integration
# Conflicts:
#	core/dr-pipeline/tests/mask_sidecar.rs
2026-08-22 13:23:34 +02:00
dtourolle ae32ed7974 Merge branch 'worktree-agent-afa2042c919d0d111' into integration 2026-08-22 13:23:09 +02:00
dtourolleandClaude Opus 5 96a7b405c2 Say which photograph the sliders are pointed at
Selecting a mask layer silently re-points about thirty controls at that layer's
chain. Same panel, same order, same sliders, different meaning — and the only
thing that said so was a sentence in the panel above, which a photographer
reaching for the exposure slider has no reason to read. An exposure change
lands on the whole frame when it was meant for a face, or the reverse; both are
silent, and both are discovered later. `ui-navigation.md` §1.1 calls it the
dangerous one and it is: the others in that document cost time, this one costs
work.

The remedy is the classic one for a modal fault — make the mode visible — and
the application already had the pattern. Crop arms a canvas interaction, draws
an overlay, gives the column one job and is left by the control that entered
it. Local masking is the same animal built as a peer panel, and that is what
created the ambiguity. So `crop-mode` stops being a bare boolean and becomes
one value of a three-state mode, which is the point: two modes could both be on
before, and now that is not a state the interface can be in rather than one it
is tested against.

**One strip, not two.** The mode control was going to sit beside the group
strip that filters the adjustments, which is two controls above one column
answering the same question — what am I working on. They are one control now,
`Crop · Local │ All · Light · Colour`, which is the shape Lightroom Mobile's
bottom strip has for the same reason. The two halves are different kinds of
state and are drawn differently: a mode is a chip that fills with the accent
when it is on, a group is a word with a rule under it. That difference is what
lets both be read at once, which they routinely are — picking Light while a
mask is selected filters *that layer's* chain and does not leave the mode.
Dropping the scope on a group press would be the same fault coming back from
the other end, and would make Light mean two things depending on where it was
pressed.

The strip stays pinned above the develop column rather than moving to the top
of the canvas as the document proposed. The half that filters the column
belongs to the column, and the photograph is the subject. The canvas keeps one
button, which now names the mode it leaves rather than saying "Done" — that was
unambiguous with one mode and would not be with two — because the column can be
closed on a narrow window and no mode may be inescapable.

Entering a mode is a side effect, so Rust owns it rather than the strip writing
the property: crop drops the zoom, local turns the overlay on, and leaving
clears the selection. That last one is the fix. The "Overlay" and "Select"
toggles are gone because they armed things that are simply what the mode *is* —
a mode that has to be switched on separately is one you can enter and have do
nothing. Escape and the Android back gesture join `back_step` as one
`LeaveMode` rather than a second exit concept, and the mode is left before the
zoom is: it was entered later, and it is the bigger step back.

The heading is where the scope goes. Not a caption beside the panel, the
heading *of* the panel that changed — `ADJUST` becomes the layer's name, the
same string the selected row in the stack shows. That is the difference between
describing a hazard and removing it.

**Handles on the photograph.** A linear or radial mask could be created and
then not moved, so a radial sat at the centre of the frame at its default size
for ever. Three faults stood in the way of drawing one.

The first is that a gradient did not render at all until the model had run. The
rasteriser was built on the way out of `segment` and the array's size was read
*off* the segmentation, so a gradient added to an unsegmented photograph
produced nothing — silently, in the same way exports and thumbnails once did:
the shader still emits the layer's block and the empty placeholder multiplies it
by zero. The proxy size is a property of the photograph. Both are derived from
it now, and deliberately at the same size rather than by coincidence, because a
subject's distance field is sampled against that array.

The second is hit-testing. A handle is drawn in output coordinates and stored
in source ones, and between them lie the crop, the zoom, the pan, the
straightening and the turns. `Framing::source_at` is `wgsl_prologue` evaluated
on the CPU, kept in that file beside it so that keeping the two in step is one
file's problem — a handle mapped through anything less drifts off the mask the
moment the view moves, which is exactly what masks are rasterised in source
space to avoid.

The third is that a drag is a displacement, not a destination. Each handle
answers to the movement of the pointer since the press, applied to where the
mask was when the press landed. Snapping the handle to the pointer instead
jerks it by up to half a touch target on the first press, and the target is
finger-sized because a tablet has no hover to reveal a control and no modifier
to qualify it.

A ramp gets three handles — centre, width, angle. An ellipse gets three too:
centre and one per semi-axis, the major one carrying the direction as well as
the length, because where an axis is put says both. It had a fourth, and it is
gone: standing off the shape by a fixed distance, the rotation arm began
outside the photograph at the size a new radial is created at, so the first
thing anyone saw was a control they could not reach without first shrinking the
mask.

Two faults here were found by looking at the screen rather than at the source,
both of the kind that cannot be found any other way. A `1px` rule with a size
and no position is *centred* by Slint, so the seam between the photograph and
the column was a hairline down the middle of the panel, through the histogram
and every slider under it — twice, once in `app.slint` and once in
`AdjustPanel`. And handing Slint a fresh model for the handles on every pointer
event made the repeater rebuild its items, taking the `TouchArea` holding the
gesture with them: the handle jumped once and then went dead under a finger
that was still down. `develop.rs` carries the same warning about the parameter
rows, where it broke slider drags; the model is rewritten in place now.

The tests worth having are the ones about ambiguity and about the map. That the
same row reads the frame's value, then the layer's, then the frame's again is
§1.1 in one assertion. That dragging a handle onto another gradient's matching
handle *produces* that gradient closes the loop between the two directions of
the framing map, through a view that is cropped, zoomed, panned, straightened
and quarter-turned at once — a one-legged map is invisible when the framing is
neutral, because then both legs are the identity.

Not done here: the histogram still reports the whole frame while the sliders
edit a layer. That disagreement is real and is N3's, which this unblocks. The
strip has room for a Brush entry beside Crop and Local when the painted masks
land in the core, and it needs nothing here but the canvas interaction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 13:20:41 +02:00
dtourolleandClaude Opus 5 c75863c93f Give a gradient the angle it was asked for
A linear mask at 45° was not at 45°, and a radial with equal radii was an
ellipse. Both on every photograph that is not square, which is all of them.

The geometry is stored in normalised coordinates so that a mask survives a
crop, a zoom and an export at another size — that part was right. What was
wrong is that a *distance* was being measured in those coordinates too, and a
fraction of the width is not the same length as a fraction of the height. So
`dot(uv - centre, axis)` measured the ramp in a space one of whose axes is
squashed against the other by the aspect ratio, and the iso-lines came out
sheared: on a 3:2 frame a ramp asked for at 45° arrives at about 34°.

Nothing announces it. The stored numbers are exactly what was written, the
shader is doing exactly what it says, and the only place the fault exists is
between the photographer's intent and the picture. It has been invisible so far
because there is no way yet to place a gradient by eye — the handles that make
it visible are what turned it up.

So distances and angles move into the frame's own isotropic units: y spans
`0..1` and x spans `0..aspect`, which makes a circle round and 45° a real
diagonal. The centre stays a plain fraction of each axis, because it is a point
and a point has no such problem — and because that is the space a click arrives
in. `frame_delta` is the one conversion and must stay the only one; the mask
array's own dimensions carry the aspect, so it costs no uniform.

The sidecar format does not change. What changes is what the numbers mean, and
the only geometry in the wild is a default that has never been movable.

The two tests are at 96×64 rather than square, which is the whole point: on a
square target this bug cannot be reproduced, and every existing mask test was
square. Both fail without the conversion — the radial reaching 28px sideways
where it reaches 19px down, and the diagonal landing on the wrong side of the
line it is supposed to lie along.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 13:19:55 +02:00
dtourolleandClaude Opus 5 c396a22dfd Paint a mask without ever rasterising one on the CPU
The last line of FR-DEV-3, and the mask ARCH §5.4 was written for. darktable
rasterises drawn masks on the CPU and users call the result unworkable; the
architecture's answer is that a stroke arrives as *parameters* and the device
draws it. This is that, from the model through the sidecar to the pixels — but
not the finger: the canvas is somebody else's change, and this leaves it a
seam rather than reaching into it.

**A stroke is a swept disc along a polyline**, plus erase, radius, hardness and
flow. `MaskSource::Brush` holds an ordered list of them, and the order is the
mask: an erase after an add takes it away and the same pair reversed does not.
Nothing about it is pixels, which is what makes a mask that costs a line of
text, diffs by the gesture, and survives a crop, a straighten and an export at
any size — the properties a stored raster has none of, and the same argument
the region ids were chosen for.

Two things keep the point count honest. While the finger is down, a position
closer to the last than an eighth of the radius is dropped: a touch screen
reports 120 a second, so a finger held still for five seconds is six hundred
points in the same place, and simplification would only remove them once the
gesture had ended — after every frame in between had drawn all of them. When
it ends, Douglas–Peucker at an eighth of the radius removes what a disc that
wide cannot express: a swept circle moved by r/8 moves its own edge by r/8,
which is inside the soft part of any brush. Coordinates snap to a
ten-thousandth of the frame on the way in *and* are written at that precision,
so a round trip is exact rather than nearly exact — a file that drifts in the
sixth decimal every save is a per-field merge conflict a day, over nothing.

**Cost is why the strokes are not drawn by the full-screen triangle the other
masks use.** A swept disc is the minimum distance to any of its segments, so a
stroke over the whole frame costs `pixels × segments` and both terms grow
together — the quadratic that is darktable's problem moved onto the GPU rather
than solved. Each stroke is instead drawn over its own bounding box, grown by
the radius, so the rasteriser never invokes the shader for a pixel the stroke
cannot reach: `area(box) × segments`, which for a dab or a swipe is a small
fraction of the frame. A gesture past 256 points continues as a second stroke
for the same reason, since a shorter stroke has a smaller box.

Add and erase are `dst + a(1 - dst)` and `dst(1 - a)`, which are exactly a
source-over and a one-minus-source blend — so they are blend state, not
arithmetic, and no pass ever reads the slice it is writing. That is what
permits one draw per stroke at all. Within a stroke the coverage is the
*minimum* distance over its segments rather than a sum: a path that crosses
itself must not build up where it did, or every circle and every scribble
would be blotchy wherever consecutive dabs overlap, which is everywhere.

Not a distance field, deliberately. `dr-segment`'s transform documents the two
conditions that make CPU work right there — once per mask edit, over input
already CPU-side — and a stroke fails both: it changes while the finger moves,
and its input is a handful of coordinates that never needed to be pixels. It
also needs no transform, because the distance to a swept disc is closed form.
A stroke is the one mask whose distance field is known without computing one.

An unpainted brush layer is inactive rather than empty, which is not an
optimisation: `invert` turns empty into everything, so a layer created with
invert already set would apply its adjustment to the whole photograph before a
single stroke was made. That is the loud, confident kind of wrong this codebase
refuses everywhere else a mask can go missing, and there is a rendered test for
it.

The tests read pixels back off a device rather than checking that the two
halves agree with each other. What they pin down is what is silent when wrong:
the y flip between mask space and clip space, which a centred stroke would not
notice; a bounding box not grown by the radius, which makes a tap draw nothing
at all; an aspect ratio ignored, which makes a dab an ellipse on any frame that
is not square; a stroke doubling back and building up; and an erase that lost
its place in the order and put back paint the user had taken off.

Not done here: the interaction. The canvas needs to begin, extend and end a
stroke on the active layer, and `DevelopSession::rasterise_masks` still returns
early without a segmentation — it takes the proxy size from one, and a brush
needs no model to have run over the photograph first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 12:37:42 +02:00
dtourolleandClaude Opus 5 c0e1179936 Find the subjects without stopping the window
"Find subjects" took the UI thread for two thirds of a second on a 22 MP
frame — a proxy render, a readback and a YOLO pass through `ort` — and for
that time the interface was simply gone. The panel apologised for it rather
than hiding it: a "Looking…" label, and a 16 ms `single_shot` so the label
reached the screen before the freeze began, with a comment saying the obvious
fix needed the develop session restructured and was not being taken.

The obstacle was never `Send`. `DevelopSession` is `Send` — the device, the
source texture and the passes all are. What cannot go to a worker is the
`Rc<RefCell<Option<DevelopSession>>>` that every callback in the window
reaches through, and the window has to keep reaching through it while the work
runs. Handing the session over would freeze the interface exactly as
thoroughly as blocking on it did.

So the work takes a copy of what it needs instead. A `SegmentationJob` is the
device, the demosaiced source behind an `Arc`, and the name of the session
that asked. Taking one is two `Arc` bumps; running one is 495 ms on this
desktop; none of it touches the session, and there is deliberately no
`&mut DevelopSession` in scope for a caller to hold across it. The proxy
render travels with it rather than staying behind — a `GpuContext` and a
texture handle are both `Send`, and the model was never the only expensive
half. So does building the mask rasteriser, which is a shader compile:
adopting the result was costing 23 ms, a dropped frame on the one redraw the
user is waiting for, and the rasteriser is needed exactly when the subjects
arrive and never before. What is left on the UI thread is a microsecond.

The answer comes back through a channel a `slint::Timer` polls, which is the
shape `apply_when_ready` already uses for a sidecar fetch.

**A result can outlive the photograph it describes.** Two thirds of a second
is long enough to press the button, think better of it and swipe to the next
frame — and the result landing then would fill the panel with subjects that
are not in the picture, drawing outlines around a dog two photographs back.
Nothing downstream can tell: the masks rasterise and the overlay draws either
way. So every session is minted with an id, a job carries the id it was taken
from, and `delivery` compares the two before anything is applied. An id
rather than a counter beside the session slot, because that slot is written
from four places in `lib.rs` and the fifth would be the one that forgot.

A discard touches nothing on the way out. `segmenting` belongs to whichever
photograph is open now, which may well have a run of its own going, and
clearing it would re-enable a button that is correctly insensitive.

One run at a time, and abandonment is what stops that being a trap. A job
left over from a photograph the user has left is displaced rather than waited
for — otherwise the next frame's "Find subjects" would do nothing for the
length of a run nobody wants, which is the wait this exists to remove. `ort`
offers no way into the inference, so abandoning is checked at the seams there
are: before the job starts, and between the readback and the model. Abandoned
early it costs nothing, abandoned mid-inference it costs the run it was
already committed to, and either way the answer is dropped at the channel.

`DevelopSession::segment` survives as a test-only convenience. Left public it
is precisely the shape that put two thirds of a second on the UI thread in the
first place, and the next caller would reach for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 12:26:11 +02:00
dtourolle 586698db00 Give the strip a layout to sit in
Build and test / Desktop (Linux) (push) Failing after 46s
Build and test / Layer separation (push) Successful in 26s
Traceability / Requirement traces (push) Failing after 1m2s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 3s
Build and test / Android (aarch64) (push) Failing after 9m38s
The develop column's panel is a Rectangle, not a layout — a note four lines
above explains why it is not an `if`. Two children of one therefore both sit
at its origin, so pinning the strip beside the Flickable overlapped them and
collapsed the whole develop view to a sliver.

Wrapped in a VerticalLayout. The strip is pinned by being outside the
Flickable rather than by any coordinate, which is what keeps it working at
any column height.

Caught by screenshotting the device rather than by the build, which was
clean throughout — a Slint layout fault is invisible in the source and
obvious the moment anyone looks.
2026-08-22 10:57:08 +02:00
dtourolle 9a7b045df4 Pin the group strip where it can be found
It was inside AdjustPanel, which on a tablet put it below five other panels
and off the bottom of the screen: present, working, and unreachable without
scrolling past everything it exists to save you scrolling past. A control
that answers "where is everything else" cannot itself be somewhere else.

Now a GroupStrip above the scrolling column, so it never scrolls away. It
still names no group — the strings arrive resolved from whatever the
operations declared themselves to be about.
2026-08-22 10:46:35 +02:00
dtourolle 1d7106c94d Apply the masks to the thumbnail and the export, not only the screen
Reported as a thumbnail bug; the export had it too, which is the serious
half. You would have exported a photograph missing every local adjustment.

Both called the unmasked `render`, and the failure is silent by
construction: the generated shader always declares the mask binding and
always emits a block per active layer, so binding the empty placeholder
multiplies each of them by zero. No error, no warning, no missing texture —
the adjustments are simply not there. From inside either path there is
nothing to see.

Every path that produces pixels now goes through one helper that binds the
array, and that is the point of it being one helper rather than three
correct call sites. The array is rasterised in source space at proxy size
and sampled through the framing map, so one array serves every output size:
a 256px thumbnail and a 24 MP export bind the same texture.

Three tests, and the first is the fault stated directly — render the same
edit with and without the array and assert they *differ*. If binding it ever
stops mattering, the masks have stopped reaching the shader. The third
checks the masked share of the frame is the same at 32px and 128px, because
"both non-empty" would pass while a mask that scaled wrongly still ruined
every thumbnail.
2026-08-22 10:38:49 +02:00
dtourolle 924a837389 Group the panel by what operations say they are about
A strip of groups over the adjust panel — Light, Colour, Detail — derived
from the attributes the operations declare. `adjust.slint` names none of
them: the strings arrive resolved and the panel only draws them, so a new
operation joins the right group by saying what it is and this file does not
change (FR-DEV-3a).

A group nothing carries is not offered, so a tab never opens onto nothing.
Geometry is left out because its one operation prefers an on-canvas widget
and is skipped by the row builder — a Geometry tab would be empty while
`GeometryPanel` holds the real controls. The strip appears only when there
is more than one group to choose between; a single tab is a control with one
option.

The selected group is underlined rather than filled. The accent means
*modified* everywhere else in this interface, and spending it on "which tab"
would blunt the one signal the panel has.

**The trap, and it nearly bit again.** `op_index` on a row counts over every
capability, not over the ones a filter kept — it is how a row routes back to
the core. Renumbering it while filtering would make a slider drive a
different operation, which looks like a rendering fault rather than a
routing one. `rows_filtered` keeps `enumerate` over the full list and only
`group_head` is a position within the emitted rows; a test moves a value
through a filtered row and checks it lands where it was asked to.

Six tests, including that a nonsense index falls back to showing everything
rather than to showing nothing.
2026-08-22 10:22:47 +02:00
dtourolle 7421837c8a Let an operation say what it is about, so the panel can group without naming
Tool tabs need a taxonomy, and the taxonomy was the problem: a table in
`ui/` mapping operation to tab breaks FR-DEV-3a, and a `group:` field risks
what `ui-refinement.md` condemned `starts-group` for — the core deciding
where the panel draws things.

`Attribute` threads the needle. It says what an operation *is* — tone,
colour, detail, optics, geometry, effect — which is the same category as
`ParamKind` and squarely on the core's side of ARCH §4.3a's line. What is
drawn, where it sits and whether it is visible stay the frontend's. There is
no attribute for "the third tab", the enum's order is declaration order
rather than screen order, and a frontend may render these as tabs, as
headings, or ignore them.

The payoff is that a tab strip can be *derived*: the groups are the
attributes present in the capability list, so the interface names no
operation and needs no table to keep in step. An operation joins the right
group by declaring what it is, which is the one thing its author is well
placed to say.

Plural, because the tone curve is genuinely both — an RGB curve is tonal and
the per-channel curves are chromatic, and filing it under one would hide it
from half the people looking for it.

Required and non-empty, enforced in `build.rs`, and the failure was checked
by removing the line rather than assumed. An operation with no attribute is
invisible to a panel that groups by them; a build that stops costs ten
seconds, a control nobody can find costs more. The vocabulary is closed for
the same reason: a typo would otherwise invent a category holding exactly one
operation, which looks like a deliberate one until somebody counts.

Six tests over the real chain, including the hand-written operations that
`build.rs` never sees and so cannot check.
2026-08-22 10:10:00 +02:00
dtourolle acd694c2cf No phone, so stop designing for one
Targets are a 12-inch tablet and a desktop (D15). That removes most of the
navigation question rather than answering it.

`EXPANDED_MIN_WIDTH` is 820 logical pixels and a 12-inch tablet is ~1024
across in portrait, so both orientations of both targets are the expanded
class. The compact class now fires only when a desktop window is dragged
narrow — graceful degradation, not a second interface. The bottom tool strip
and the one-tool-at-a-time sheet were solving a phone, and there is no phone.

What survives is input, not size, and the architecture had already decided
it: `WidgetDemand::precise_pointing` exists for a television remote, and its
own documentation says touch is fine because hit regions grow to the
modality. Touch changes hit regions, not layout. The rules that fall out are
worth stating because they are easy to violate by accident — no hover-only
affordance and no modifier key may be the sole route to anything, since a
tablet has neither. Local masking already lost its shift-click extend for
exactly this reason.

The guaranteed-wide viewport also pays for a better answer to the extent
problem than hiding things. The complaint was never that the column is long;
it is that the histogram scrolls away from the sliders it reports on.
Collapsing shortens the scroll, pinning removes the problem, and ~260px of
fixed height is affordable on a viewport that is never under 820 wide.
2026-08-22 09:55:09 +02:00
dtourolle 85dafd78b7 Work out where things go, now that there are many of them
Local adjustments took the develop column from four panels to six, and the
operation set is meant to keep growing — FR-DEV-3 still lists texture,
clarity, sharpening and noise reduction as v1. But the count is the lesser
problem.

The real one is that local adjustments introduced a *mode* without
introducing a way to see it. Selecting a mask silently re-points thirty
sliders at that layer, and the histogram those sliders are judged against
goes on reporting the whole frame. I built that, and it is the fault that
loses work rather than merely slowing someone down.

Decided: local becomes a mode, in the sense `crop-mode` already is. The app
has the pattern, the user knows it, and it removes the ambiguity by
construction instead of describing it in a caption. It also inherits the
Escape/back stack that already leaves the innermost state first.

Recommended: diverge on **width**, not on platform. A tablet in landscape
wants what a desktop wants and a narrow desktop window wants what a phone
wants, so `cfg(target_os)` would give one physical situation two answers.
`apply_layout_class` already classifies on width and already remembers a
per-class override; this is a second consumer of a decision the app makes
anyway. What must not diverge is the controls — both layouts consume the
same generated capability model, so a new operation still needs no UI edit.

Left open, because it is taste: whether the wide layout eventually gains
tool tabs. Recorded rather than left to be rediscovered is the *legitimate*
route to them — a descriptor declaring an operation's nature, the same shape
as `Affects`, with the frontend free to render it as a tab or ignore it.
Deferred because ten operations do not need eight tabs and the field is easy
to add later and awkward to remove.

Surveys what Lightroom, Capture One, darktable and the phone editors
actually do, including the thing none of them do: make a mask a panel that
rewires a different panel.
2026-08-22 09:52:08 +02:00
dtourolle a881fa3693 Use transform-rotation, which is what Slint calls it now
rotation-angle is deprecated in 1.17 and was the only warning the build
still emitted.
2026-08-22 09:03:59 +02:00
dtourolle e7dbdeb21f Keep the overlay on the photograph when the view moves
The overlay is a source-space picture; the canvas beside it shows whatever
the crop, the zoom and the pan selected out of that same space. Drawn whole
it stayed frame-sized while the photograph moved underneath, so zooming in
left a map of the whole picture stretched over a detail of it.

It now reports the visible rectangle as a clip, which the compositor
applies for nothing. Resampling on the CPU instead would mean rebuilding a
megapixel image on every frame of a drag, and putting it on the GPU would
add a second texture to keep in step with the view.

Pushed from the render path rather than the panel's sync: a pan changes no
mask and no row, so nothing else needs to run, and rebuilding the row
models on every frame of a drag would be waste.

Straightening is handled by rotating the image. A quarter turn or a flip
permutes the axes and a clip rectangle cannot say that — noted where it
happens rather than left to be discovered. The proper fix is to run the
overlay through the same shader prologue the photograph goes through, which
is the right answer and a larger one than this.

Four tests, and the one that matters asserts the clip *narrows* when zoomed
— which is precisely what it failed to do.
2026-08-22 08:53:02 +02:00
dtourolle 37f63edbf9 Take the watershed out of the product path
It does not work on a photograph, so nothing should offer it. `Segmentation`
is now one model pass and what it recognised: no region field, no merge
tree, no label upload, no granularity slider, and no readback of the whole
proxy to build a graph that collapses.

A click means "the object under the cursor". The region-selection path went
with the hierarchy it indexed — including the shift-click add/subtract,
which has no meaning for a whole object and would have been a modifier that
silently did nothing.

The passes, the hierarchy and the semantic prior stay in `dr-gpu` and
`dr-segment`, tested and documented. It is the *merge criterion* that
fails — the saddle is the minimum gradient along a boundary, so one weak
pixel merges two regions and real gradient noise puts a weak pixel on every
boundary. That is one function to replace, and the evidence for replacing it
is worth keeping. What is gone is the wiring, the option, and the control
that offered a user a choice with no outcome.

`MaskSource::Regions` remains in the pipeline: it is tested, it round-trips
through the sidecar, and a stored layer that names regions must still load
and be reported stale rather than failing to parse.
2026-08-22 08:39:17 +02:00
dtourolle 6163b63895 Put the edge controls where the mask is
Feather, falloff, grow/shrink/close/open and their amount, on the selected
layer. All four read the one distance field, so all four are live — nothing
recomputes except a compound morphology, and the session keys on that
separately so a feather drag rebuilds nothing.

Shown only for sources that go through the distance field. A gradient
carries its own falloff in its geometry, and offering a second one would be
two controls fighting over the same edge.

Picking an operation seeds a small amount if none is set. Selecting "Grow"
and seeing nothing happen would read as a broken control rather than as a
radius of zero.
2026-08-22 08:39:17 +02:00
dtourolle ec713585a5 Measure the distance to the edge, and get four controls for one transform
Feathering, growing, shrinking, closing and opening are the same number
read differently. With the signed distance from the boundary in hand,
dilation is the set where d >= -r, erosion where d >= +r, and a feather of
any shape is a function of d. So the field is computed once and the
controls are arithmetic on it.

The **field** is what reaches the GPU, not a finished alpha, and that is
the point: growing a mask or changing its falloff then costs a uniform
upload and no recomputation, which is what makes them live controls rather
than ones that stall on every drag. Only closing and opening rebuild,
because after the first threshold the shape has changed and the old
distances describe the old one.

Exact Euclidean, via Felzenszwalb's separable transform — not a chamfer
approximation, which leaves a mask visibly octagonal once grown more than
a few pixels. A test asserts the diagonal is √2 rather than 1 or 2.

It runs on the CPU, which ARCH §5.4 forbids for masks. The rule is about
brush lag — a stroke rasterised per frame — and this is a different
operation: once per mask edit, on input the model already produced here,
producing a field the GPU then samples for free. What it buys is exact
determinism, which matters because masks reach the sidecar as indices and a
field that varied by vendor would mean a mask meaning one thing on the
desktop and another on the phone.

The half-pixel in `signed_distance` is not a detail, and a test caught it.
Measuring to the nearest opposite pixel *centre* puts the smallest
magnitude at 1 either side, so the boundary is nowhere and **eroding by
less than a pixel removes nothing**. A control whose first notch does
nothing is a broken control. Half a pixel off each side puts the boundary
where it physically is, and eroding by 1 takes exactly the outermost ring.

Every falloff curve is 0.5 at the boundary by construction, asserted for
all five: changing the curve should change how the transition looks and
never where it sits.
2026-08-22 08:39:17 +02:00
dtourolle ee10097435 Mask the subject the model found, not the regions underneath it
The watershed hierarchy does not survive a photograph, so local masking
stops depending on it. A layer can now be one recognised object, and the
object's own coverage is the mask.

`Options::watershed` defaults off. It costs ~80 ms plus a full-resolution
readback to produce a ladder that collapses, and paying that on every
photograph buys a control that misleads. Kept switchable rather than
deleted: the passes and the hierarchy are correct in themselves and it is
the merge criterion that fails, which is a change to one function.

Masks now rasterise in **source** space at proxy resolution and are sampled
by the composed shader after the framing map. That fixes a real bug: they
were rasterised in output space, so zooming slid the photograph underneath
a mask that stayed pinned to the viewport, and cropping moved every
adjustment to a different part of the picture. Doing it this way also
leaves the framing map in exactly one place — a second copy in the mask
shader would have been a second thing to keep in step, failing only when
straightened.

A subject is stored as identity, not pixels: the mask is megabytes and is
reproducible by running the same model over the same image, so the sidecar
carries the index, the class and the score, and the session carries the
pixels. The class is there to be checked — if instance 3 comes back a "car"
where it was a "dog", something changed and the layer is stale rather than
silently masking the wrong thing.

The overlay now draws instances and is transparent everywhere else. The
region version covered every pixel and so hid the photograph it was drawn
over; the question it exists to answer is whether an outline follows the
subject, which you can only answer by seeing both.

`examples/local.rs` is the worked example: subject in colour with the rest
monochrome, and the subject lifted out of its background. Run on a 5472x3648
CR2 it finds two people and two cars, and the colour-pop keeps her hat and
hair while the wall and grass behind go grey.
2026-08-22 08:39:17 +02:00
dtourolle b1433ad4a9 Give a mask an edge treatment, and find out the watershed has none worth having
Two things, and the second is why the first matters more than expected.

Mask layers gain a feather, a falloff curve and a morphology, all defined
against a signed distance from the boundary rather than as separate
features — one exact distance field answers "how soft" and "how far" at
once, so dilation is a threshold at -r, erosion one at +r, and closing and
opening are one of each in sequence. The compound pair costs a second
distance field, which is why they are named rather than presented as a
radius that happens to be signed. Types, defaults and sidecar round-trip
only; the field itself is next.

`edge-feather` and `edge-falloff`, not `feather` and `falloff`, because a
radial mask already writes `feather` for the fraction of its radius it
ramps over. Same word, different quantity, different units — sharing the
key would have made an existing file ambiguous.

The diagnostic that provoked this is committed as an ignored test, because
"does the ladder land on things a person means" is the question S15 exists
to answer and it should not depend on whoever still has the script. On
bus.jpg it answers badly: 35,075 regions at blur 2 over an 810x1080 frame,
and cutting that to 400 gives *one* region covering nearly the whole
picture plus 399 noise specks. Not over-segmentation — collapse. Almost
every saddle is near zero, so the merge order joins everything meaningful
before it joins anything spurious, and a global cut spends its entire
budget on grain.

So the granularity ladder does not currently work on a photograph, and the
region masks built on it inherit that. Recorded rather than worked around:
the next commits move local masking onto the model's instances, where the
edge treatment above is what makes a quarter-resolution mask usable.
2026-08-22 08:39:17 +02:00
dtourolle 12d320cf33 Record what the spec got wrong about the model that exists
docs/segmentation.md §4 priced arm B as costing a C dependency under the
NDK and treated that as most of the difference between the arms. It is not
a cost that has to be paid: `ort`'s `alternative-backend` disables its
linking entirely and `ort-tract` supplies the API from tract, which is pure
Rust. D13's "largest exception the policy would tolerate" turns out not to
be needed, and the answer generalises to the face pipeline — so D13's
runtime half is now answered and only its licensing half is open.

Three findings contradict §4 outright and are recorded as F4-F6 rather than
quietly designed around. There is no ADE20K-trained YOLO, so the shipped
vocabulary selects subjects and not stuff — "select the sky" comes from the
watershed or from nowhere. It is instance segmentation, so it partitions
nothing and two people come back as two instances. And tract cannot parse a
dynamic-shape export, which fixes the input at 640 square and makes tiling
the only route to more semantic resolution.

Arm C ships, but §8's criteria are not what decided it, and saying so
matters more than claiming the process worked. §8 asked for a two-
interaction margin over arm A on a traced corpus. That comparison was never
run: F4 and F5 changed what the arms are, and a model that recognises
subjects but has no word for sky cannot be a selection tool alone, while a
watershed cannot tell a person from the wall behind them. They stopped
being candidates and became complements.

What is *not* done is written down as plainly: the 24-image corpus is
untraced, so M1-M4 have no numbers and "this feels right" has not become
one. M5 is answered on one device only, and region ids now reach the
sidecar — so a cross-vendor divergence would mean a mask written on the
desktop meaning something else on Android. F3 stands.
2026-08-22 08:39:17 +02:00
dtourolle 9b4f0815e5 Show the regions, click one, and adjust it
The local panel sits above the adjust panel because it decides what those
sliders act on; below it, a photographer would set an exposure and only
then discover which scope it landed in. Selecting a layer re-scopes the
existing controls to that layer's chain — there is no second set of
sliders, and there must not be, or every operation added to `ops/` would
need a local twin.

The overlay is drawn over the canvas rather than blended into the render,
because it is a diagnostic and not an edit: it must not reach the
histogram, an export, or the texture handed to the compositor. Nearest-
neighbour always — the map's values are *names*, so smoothing between
region 4 and region 9 invents a colour belonging to neither and softens
exactly the edge the overlay exists to show.

Picking gets its own touch area above the pan handler. Panning wants
press-drag-release and picking wants a click; interleaving them in one
handler is how a drag ends up selecting a region the user was scrolling
past. Shift is tracked as window state because a TouchArea's click carries
no modifiers.

Three states a layer can be in are worth distinguishing, and each has a
different remedy: stale needs re-segmenting, "no adjustment yet" needs a
slider moved, and the ordinary case needs nothing said. A bare selection
renders nothing and looks identical to a broken mask, which is the first
thing a new user will hit.

Known rough edge, commented where it happens: segmentation blocks the UI
thread for about half a second. Moving it to a worker needs the develop
session — GPU resources behind a RefCell shared with every callback — to
be reachable from another thread, which is a restructuring rather than a
change to the call. The button says "Finding regions…" first so the stall
is announced rather than looking like a hang.
2026-08-22 08:39:17 +02:00
dtourolle 5ecb35864f Put the region map behind the sliders that were already there
A mask layer holds a real develop chain, so the develop panel can edit one
with no new controls: select a layer and the same sliders read and write
its chain instead of the graph's. An operation declared in `ops/` tomorrow
becomes locally adjustable by existing, which is the payoff for making a
layer a chain rather than a handful of special-cased parameters.

`segmentation.rs` joins the two arms into the one thing the view needs.
The model reads the image through a neutral graph rather than the edited
one, so a segmentation survives an exposure change instead of being
invalidated by every slider. Arm B failing is not fatal: a missing or
unreadable model leaves a working watershed map, because refusing to
segment at all would trade a working feature for a strict one.

The overlay colours groups by a golden-angle walk over hue. Deterministic
rather than random, so a region keeps its colour across a level change and
the eye can track it; boundaries drawn black over the fill, because two
adjacent groups landing on near hues read as one region and telling them
apart is the whole reason to look at it.

Clicking the photograph creates the layer if none is selected — that is how
a local adjustment begins, and making the user press "add layer" first
would be a step with no decision in it. Shift-click extends, and clicking a
region already selected removes it, so one gesture both adds and corrects.

`segment-readback` is a new dr-gpu feature and not a loosening of
`readback`. The region-graph transfer is once per image on a worker; the
one AC-8 forbids is per frame in the render loop. Sharing a switch would
have forced a build wanting local masking to unlock the other. F3 still
stands and the feature name says so.
2026-08-22 08:39:17 +02:00
dtourolle 94cfea4748 Keep the mask when the app closes, and when two devices disagree
The sidecar is authoritative — the catalog is a disposable index and the
RAW is never written — so a mask that does not round-trip is not a
persistence bug, it is lost work.

Layers get their own `[mask <version> <id>]` blocks rather than being
flattened into dotted keys. A layer is not a scalar: it carries a
selection, a geometry and a chain of its own, and encoding a region set as
`m1.region.0 = 12` would be neither readable nor mergeable. The version
uuid is repeated in the header instead of relying on the block following
its version, because "belongs to whichever version appeared above me" is a
relationship that hand-editing, merging and older builds each break
quietly.

Region ids sort and deduplicate on read rather than being trusted from the
file. The mask's identity is the *set*, so two devices writing the same
selection in different orders must produce the same mask rather than
argue about a difference that is not one.

Masks merge by layer id under FR-NC-9, which is the disjoint-survives rule
the parameters already follow one level up: a layer added on the phone and
one added on the desktop both survive. A layer *both* sides edited resolves
wholesale to the higher revision, because half of one selection plus half
of another's opacity is a layer neither person made. A remote deletion is
honoured, or a mask the user removed returns on every sync.

An unknown mask source is skipped rather than guessed at. Applying a newer
format's mask type as the nearest one this build knows would put a
confidently wrong adjustment on the photograph, which is worse than
applying none.

29 new tests. The interesting ones are about silence: a maskless version
clearing the previous image's layers, a bare selection persisting even
though it renders nothing, and a mask naming a version that is not in the
file being dropped instead of landing on whichever block was open.
2026-08-22 08:39:17 +02:00
dtourolle c6a846a1f9 Brighten her face without touching the sky behind her
A mask layer is an ordinary develop chain plus a rule about where it
applies. Nothing in the chain knows it is being masked, so every operation
that works globally now works locally and a newly declared op in `ops/`
arrives with local support already done.

The composer emits each layer after the global chain and before the
conversion out of camera space, which is what a photographer means by "and
*then* lift the shadows on her face". Op fragments write to a `c` they
expect to own, so a layer block shadows it and copies the result back out
through a carrier — assigning the outer one from inside is impossible
precisely because it is shadowed. The fused dispatch survives: three global
adjustments and two masked ones remain one shader, one read, one write.

Masks rasterise on the GPU and never exist in CPU memory (ARCH §5.4). That
is the whole reason darktable's brush masks lag, and it is architectural
rather than tuning, so it is not a thing to inherit and fix later.

The rasteriser is a render pass rather than the compute shader it obviously
wants to be, and the format is why: R8Unorm is not a core storage format,
so a compute path has to widen masks to four bytes per pixel — 768 MB
across eight layers of a 24 MP export, against 192 MB at one byte. A colour
attachment takes R8Unorm happily. The array slice comes from the attached
view, so no slot uniform exists to disagree with where the pass writes.

Region masks index a compacted label field rather than the watershed's raw
basin roots, because a root is a sparse index into pixel space and
indexing a per-region array by one would need a table the size of the
image. Changing a selection then costs a few kilobytes, not a re-upload.

Stored as region ids, not as pixels: diffable, mergeable per-field under
FR-NC-9, and cheap in a sidecar. The ids only mean anything alongside the
segmentation that produced them, so each layer carries that signature and
is treated as stale rather than applied when it does not match — a
confidently wrong mask being much worse than an absent one.

Seven device tests render actual frames and read them back. The unit tests
either side check halves that would both pass if the two agreed with each
other and were both wrong; a mask sampled with x and y swapped satisfies
them and fails these.
2026-08-22 08:39:16 +02:00
dtourolle 0da8271836 Let the model say what a thing is and the watershed say where it ends
Local masking needs to know where an image's regions are. The watershed
spike (S15 arm A) found the boundaries but had no idea what any of them
enclosed; its coarse levels were geometric accidents. This adds the other
half and the thing that joins them.

`core/dr-segment` is where region reasoning now lives — the hierarchy moves
out of `dr-gpu`, which keeps only the pixel passes that are genuinely
shaders. The new crate is device-free and, without its default features,
model-free too: 20 of its tests need neither an adapter nor 11 MB of
weights.

Arm B runs YOLO26n-seg through `ort`. D13 framed inference as a choice
between `ort`'s C++ runtime and the pure-Rust dependency policy; that was a
false choice. `ort`'s `alternative-backend` feature unlinks the C entirely
and `ort-tract` supplies the API from tract, which is pure Rust. Measured
before committing to it: zero unsupported operators, 420 ms for 640x640,
and correct masks on bus.jpg. No NDK problem to solve, so D13's largest
tolerated exception is not needed.

Arm C is `prior.rs`, and it ships because the two arms fail in opposite
directions. Instance membership re-weights the merge saddles, so region
pairs the model believes share an object merge early and pairs straddling
its edge merge late. No boundary moves — only the order in which they
dissolve — which is how the result stays pixel-accurate at every level
while its coarse levels become named things.

Two things the spec assumed that turned out to be false, both recorded in
models/LICENCE.md: there is no usable ADE20K-trained YOLO, so the shipped
vocabulary is COCO's 80 subjects and *stuff* like sky and foliage must come
from arm A; and tract cannot parse a dynamic-shape export, so the graph's
input is fixed and tiling is the only route to more semantic resolution.

Weights are AGPL-3.0, which GPLv3 §13 permits and which makes the combined
work effectively AGPL. Deliberate, not accidental. They live in Git LFS,
and a build script fails with an instruction rather than embedding a
pointer file when the clone lacks them.
2026-08-22 08:39:16 +02:00
dtourolle ecd6df686c Read the defect map a raw file carries
Build and test / Desktop (Linux) (push) Failing after 54s
Build and test / Layer separation (push) Successful in 22s
Traceability / Requirement traces (push) Failing after 59s
🐳 Android image / Build and push (push) Successful in 12m59s
Build and test / android-image (push) Successful in 13m1s
Build and test / Android (aarch64) (push) Failing after 9m42s
First step of dead pixel removal, and the one that decides whether the
rest is worth building: where the map comes from.

`rawler` is no help. It knows `OpcodeList1/2/3` exist — it copies them
through when *writing* a DNG — but it never decodes them, and the
`dng_tags` map it exposes is only ever filled by callers, never by a
decoder. So the bytes are read from the IFD directly, which this module
was already walking for previews, including the SubIFDs where a DNG keeps
its raw IFD.

`OpcodeList1` specifically: lists 2 and 3 run after demosaic and after the
colour transform, so neither can carry a correction that has to happen on
the mosaic. Two opcodes describe defects — `FixBadPixelsList`, which is
explicit coordinates plus whole dead rows and columns, and
`FixBadPixelsConstant`, which names a sentinel value rather than any
coordinates and is left unimplemented until there is a stage to consume
it. A half-implementation that guessed at coordinates would be worse than
the absence, because it would look like it worked.

Two things the tests pin down because both are silent when wrong: a point
is stored (row, column) and reading it the other way round lands the
correction on the wrong photosite — invisibly, on a square crop — and
opcode payloads are big-endian whatever the container's byte order is, so
a little-endian TIFF still writes these the other way round.

An unknown opcode is stepped over using its declared length rather than
abandoning the list, because a camera that corrected its lens as well as
its sensor writes both, and losing the map whenever a warp is present
would be losing it on most files that have one.

Includes `--example defects`, because whether any of this fires is a
question about a particular library rather than about the specification.
2026-08-21 23:00:10 +02:00
dtourolle caf61d41a5 Re-thumbnail a photograph from its own edit
A thumbnail comes from the file's embedded preview, which is the camera's
idea of the photograph and knows nothing about what has been done to it
since. So a frame could be cropped, turned upright and pulled two stops
back, and the grid would go on showing the original — making the library,
where a photographer spends most of their time, the one view in which an
edit is invisible.

The render is the framed output, not the sensor: `output_size` is what a
crop, a quarter turn, a flip and a straighten all act on, so a thumbnail
taken from the raw frame would be the right pixels in the wrong shape and
still the wrong way up. It is the same path an export takes, at a size
the store wants rather than at full resolution, and always sRGB — this is
a JPEG in a shard that syncs between devices and is drawn as a cell, not
a file anyone is finishing.

Both size classes are replaced. The store keys on the class, so
refreshing only the one the grid happens to be drawing leaves the other
holding the unedited preview, and a zoom across the boundary would show
the edit undoing itself. Each is rendered rather than downscaled from the
larger, which would be a second and worse resampler than the GPU has
already applied.

It runs on the way out of develop, after the sidecar write is queued and
never instead of it — the edit is what must not be lost, and a render
that failed must not take the save down with it. Two cases are worth the
work: an edit made in this sitting, which `can-undo` records even when it
ends back at neutral, and an image opened with an edit already in its
sidecar and left untouched, whose cached thumbnail has never shown that
edit at all. A neutral image nobody touched fails both and costs nothing.

Not covered: a batch paste onto a selection, which deliberately never
opens a session — there is no rendered frame to take a thumbnail from,
and downloading forty RAWs to make forty is exactly what that path exists
to avoid.
2026-08-21 22:48:10 +02:00
dtourolle 3085ec4d2e Leave the stars on screen on a touch device
**Hover is not something a finger does, but Slint reports it anyway.**
`has-hover` goes true for any pointer event carrying a position, a touch
press included, and false again on the `Exit` that follows the release.

So the rating strip did appear on a tablet — for exactly the length of a
tap. It flashed on under the finger, vanished as it lifted, and the tap
carried on through to the cell and opened the photograph. An unjudged
frame could not be rated from the grid at all. The previous fix stopped
the strip disappearing when a *pointer* moved onto it; this is the same
symptom with a different cause, and hover was the wrong signal in the
first place.

The strip now stands open where the session is a touch one. That is
seeded from the platform rather than inferred, because inference needs a
press to reach a cell and a quick flick never delivers one — the Flickable
claims the gesture before the delay it would forward after — and a
control that only appears once the finger is down has appeared too late
to aim at. The grid still latches on the first non-zero touch id it sees,
which is what covers a touchscreen on the desktop.

One-way on purpose: a tablet with a mouse plugged in keeps the strips
once touched, which is the harmless direction to be wrong in. The
alternative is chrome that comes and goes as the user changes hands.
2026-08-21 22:35:14 +02:00
dtourolle 2fba685e16 Make a pinch zoom the grid and nothing else
Two faults left over from making the gesture reach the grid at all.

**It still opened photographs.** Checking the finger id stops the
synthetic release Slint emits when the *second* finger lands, but not the
other end of the gesture: lifting one finger of two leaves the other one
down, and Slint replays that survivor as a fresh `Pressed` on whatever is
under it — which is how it hands the pointer back to ordinary handling.
Under it is a cell. So the cell was selected, and lifting that last finger
was a complete, well-formed click on the same finger that pressed. No
part of the event stream distinguishes it from a real tap, so the grid now
remembers that a pinch just happened: a latch raised when the gesture
starts and lowered a beat after it ends, during which cells take neither
presses nor clicks.

The press that *opens* a pinch is undone rather than suppressed — it has
already happened by the time a second finger makes it a pinch. Undoing it
has to be exact, or a cancel that restored the selection but left the
anchor moved would make the next shift-click select a run from a cell
nobody pointed at, so capture and restore are a tested pair.

**And it was not smooth.** Two reasons. The pinch was thresholded into ±1
steps of 25%, so the grid lurched and then sat still; it now takes the
ratio since the last update and tracks the fingers, with the drawn cell
still landing on whole column counts because the columns divide the
width. And `zoom-cells` was the one geometry change still reloading
inline — a full catalog re-read, 360-row model rebuild and thumbnail
batch per step, on the thread drawing the frame. It goes through the same
settle timer as the rest now.
2026-08-21 22:03:46 +02:00
dtourolle 6ae0af3f72 Swipe up in develop for the photo roll
Develop opens one photograph. The grid handed over a path and nothing
else, so `index` and `total` were pinned to "1 of 1" on the way in and the
only route to the next frame was back to the library, find your place,
tap again. Fine once; intolerable through a set of forty, which is the
situation the develop view exists for.

The roll is the grid's already-loaded window along the foot of the
canvas. Swipe up to bring it out, swipe down to put it away — the sheet
gesture, already in the hands of anyone who has used a phone — and a
handle is drawn at the edge so the gesture is discoverable rather than
folklore, and so a pointer, which has no swipe to make, has a way in.

`SwipeGestureHandler` wraps the strip rather than sitting over or under
it, which is what it is built for: it delays a press the way a Flickable
does, forwards it to the children if no swipe develops and claims it once
one does, so a tap reaches the thumbnail and a drag does not. It covers
only the band along the bottom — above that, a drag still belongs to the
photograph, for panning and for the crop.

Picking goes through the same path a cell click does, so the outgoing
edit is persisted before the next image loads. The strip marks what is
open and scrolls to keep the mark in view.

The position readout now says where in the *library* the open photograph
sits rather than "1 of 1". Set after the open rather than before, since
the generic open path resets it — and given as the library ordinal, not
the row in the loaded window, which is an artefact of how much has been
paged in and would jump about as the window moves.
2026-08-21 21:10:44 +02:00
dtourolle c72f197880 Stop a row of buttons deciding how wide the grid is
**A Slint layout cannot be narrower than its children's minimums.** Given
less room than they need it lays them out at those minimums, lets the row
run past the edge — and reports the oversized minimum upwards.

That second half is what made this more than cosmetic. The header, the
filter chips and the grid are siblings in one VerticalLayout, so the
widest row's minimum became the whole view's minimum: `LibraryGrid` was
laid out wider than the window. The grid then measured itself against
that inflated box and sized its columns to fill space that was off the
screen, so the right-hand column was cut by the edge no matter what the
tiling arithmetic did. Fourteen filter chips do not fit across 768
logical pixels, so that was every tablet in portrait.

It also explains why opening the collections sidebar did not reflow the
grid. The view was already pinned at a minimum wider than the window, so
taking 232px away for the sidebar could not shrink it — it just clipped
more of it.

Each of those rows is now a horizontal Flickable. A Flickable's own
minimum is nothing, since it exists to be smaller than what it holds, so
none of them can inflate anything — and the controls past the edge became
reachable instead of merely absent. Applied to all four: the library
header, the filter chips, the compact action row disclosed by "More", and
the develop strip.
2026-08-21 21:00:49 +02:00
dtourolle d2c909414c Stop zooming rebuilding the grid twice a frame
A pinch is not one zoom step, it is a stream of them, and every step
changes both the column count and the capacity — two reports. Each report
re-queried the catalog, rebuilt all 360 rows of the model, re-read the
badges and ratings for every one of them and spawned a thumbnail batch,
synchronously, on the thread trying to draw the frame. Twice per step.
That is why zooming juddered while scrolling the same grid is smooth: a
scroll reloads a few times per screenful, a zoom reloaded twice a frame.

None of that work is urgent, because none of it is about which
photographs are on screen. The window holds the same images however they
are laid out — the model already has them, and the cells re-flow from
`columns` and `cell-size` with Rust not involved at all. What the reload
actually recomputes is which cells begin a row, so the month headings
land correctly, and which thumbnail size class to ask for now. Both can
wait for the gesture to finish, so both are now coalesced behind a single
settle timer: replacing the timer drops the previous one, and only the
last report of a run lives long enough to fire.

The anchor is captured on the first report of a run rather than read when
the timer fires. As the grid re-flows the viewport keeps its pixel offset
while the rows move underneath it, so the view drifts and reports the
drift; reading the anchor at the end would faithfully return to wherever
it had wandered. Taking it at the start returns to the photograph the
user was looking at when they started the gesture.
2026-08-21 20:44:56 +02:00
dtourolle 12a457b8d0 Tile the thumbnails to fill the width
The cells were drawn at whatever pixel size the class asked for and the
remainder was left as a bare strip down the right-hand side — up to one
short of a full column of nothing, which on a phone is a quarter of the
screen.

It also had the two quantities depending on each other the wrong way
round. The column count was derived from the fixed cell size, so the two
could disagree about how much room there was and the last column could
start inside the viewport and end outside it.

Solving for the cell removes both at once. Pick how many columns of
roughly the requested size fit — rounded, not floored, because the size
class is a request rather than a measurement and a width nine tenths of
the way to another column should take it — then make those columns share
the width. `columns` cells and the `columns + 1` gaps around them come to
exactly the grid's width, so there is no remainder to strand and nothing
can overhang.

The size class still decides what the user gets, since it is what the
column count is chosen from. It just no longer dictates the pixel, so the
cell flexes a few percent either way to make the row come out even.
2026-08-21 20:44:48 +02:00
dtourolle 90d6e551b3 Give the develop strip room for its controls on a tablet
The render backend, the layout class and the frame rate are developer
readouts, and they were sitting in the middle of the one strip that also
carries the way out of develop, the undo pair, the panel toggle and
export. A HorizontalLayout given less width than its children's minimums
does not shrink them — it runs off the end.

A tablet in portrait is 768 logical pixels, which is not enough, so
everything from the panel toggle rightwards was pushed off the right-hand
edge. Below the breakpoint the develop column is *also* closed by
default, so a toggle that could not be reached meant the column could not
be opened at all — and copy and paste live in that column. That is the
whole of "I have no idea how to copy a setting on Android and apply it to
other images": there was no way to.

The three readouts now collapse to zero width below the breakpoint. Width
and not an `if`, because this strip is inside the layout that `expanded`
feeds and a conditional child here is the shape that has already caused
binding loops in this file — and because a `visible: false` child still
takes its slot in a layout, so hiding alone would have freed nothing.
2026-08-21 20:38:48 +02:00
dtourolle 1980fda737 Show the library on launch instead of scanning first
A launch does not have to discover the library. The catalog from the last
run is on disk, complete, with its thumbnails in the shards beside it —
exactly the state offline mode already leans on when the server cannot be
reached.

Every launch that *could* reach the server threw that away. The catalog
handle was only opened when the scan reported Done, so the grid sat on
"Scanning…" over an empty EmptyState for as long as a recursive WebDAV
walk of the whole tree takes. On a real library that walk is essentially
the whole startup time, and it was spent hiding a grid that was ready
before it began.

The catalog is now opened and the first window loaded before the scan
thread is spawned — before, so the schema migration cannot race the
worker opening the same file, and so the first thumbnail batch is already
in flight while the walk runs. The scan still replaces all of it the
moment it lands; it just no longer gates the first paint on the network.

A first run has nothing to open, which stays silent: `Catalog::open`
creates the file, the grid reads an empty catalog, and the empty state
goes on saying "Scanning…" — which is true, and which an error here would
contradict.
2026-08-21 20:35:54 +02:00
dtourolle 80f1a210cc Keep the view pointing at the same photograph when the columns change
Two faults behind "the gallery randomly glitches to blank and needs a
scroll to reset it", and behind the scrolling that skips.

Cells are drawn at their absolute place in the library, so the row a
photograph sits on is `index / columns`. When `columns` changes every
cell moves — and the Flickable's `viewport-y` did not move with them. The
view was left pointing at a row that now holds entirely different
photographs, typically thousands of images from the ones the loaded
window covers, so the grid drew nothing at all. It stayed that way until
a scroll reported a first-visible row and dragged the window back under
the view, which is exactly the reset the user found. None of its triggers
are rare: a resize, the collections sidebar opening, a zoom step, or
turning the tablet over.

The last first-visible ordinal names the photograph being looked at, so
it is now sent back through `scroll-to` and the view lands on that same
photograph at whatever row it now occupies.

The second fault is the window-move test. At either end of a scope the
window is pinned — the first screenful cannot be centred further back
than zero, the last cannot start past the last full screenful — so the
margin test was unsatisfiable there and every row crossed in the first or
last quarter of a window re-read the catalog, rebuilt the model and
issued a thumbnail batch to arrive at the offset it already held. On a
library of twenty-odd thousand that is a stutter at the top and the
bottom of every collection, which is where a cull begins and ends.

The decision is now a rule with tests rather than four lines inside the
scroll handler: both of its ways of being wrong are invisible in the code
and obvious on a tablet.
2026-08-21 20:34:01 +02:00
dtourolle deabac0923 Carry a photograph's thumbnail across a reload
Work in progress found uncommitted in the tree, committed as its own
change so the fixes that follow can be read separately. Not authored in
this session; the description below is written from the diff.

`load_window` rebuilds every row, and a scroll reloads once the view has
travelled a quarter of the loaded window — so three quarters of the cells
being rebuilt are the same photographs already on screen. Rebuilding them
empty blanked the grid to `Theme.ground` and refilled it a beat later,
once a worker had re-read and re-decoded each one from the store. That is
the black flash on every screenful of scrolling, and on a column change,
a zoom step, a filter and a return from develop.

Thumbnails are now held by `image_id` across the swap — a refcount per
cell, no pixels move — along with the "no preview" verdict, which is an
answer about the file worth keeping for the same reason. `requested` is
rebuilt from what the new model actually holds rather than cleared, so a
carried cell is not fetched again while one newly scrolled in still is.
The size class each cell's pixels came from is tracked alongside, so a
grid zoomed past that class still asks for the sharper one.

Also guards the whole `scrolled` and `columns-changed` handlers on
`show-library` rather than just the resume latch: a Flickable being torn
down passes its viewport through zero, which was indistinguishable from a
fling to the top and reloaded the window against the first rows of the
catalog every time an image was opened.
2026-08-21 20:31:48 +02:00
dtourolle 4d8196174c Let the grid be pinched instead of opening an image
Two separate faults, both only reachable with a second finger.

The gesture never arrived. `ScaleRotateGestureHandler` was sized `100%`
inside the Flickable, which is the Flickable's own height, not its
viewport's — so the handler was one screenful tall at the top of a
viewport thousands of rows long. A pinch is delivered to whatever lies
under the midpoint of the two fingers, so it landed on the handler only
while the grid was scrolled to the very top and found nothing anywhere
else. Sized to `viewport-height` now, exactly as `zoom-catcher` above it
already is.

And the attempt opened a photograph. When a second finger lands Slint
closes the first one's gesture by synthesising a `Released` at its
position — that is how a Flickable is persuaded to let go of a scroll it
has already claimed. A TouchArea cannot tell that release from a real one
and fires `clicked`, so every pinch opened whichever image the first
finger was resting on.

The finger id separates them: the synthetic release carries the id of the
finger that *arrived*, never the one that pressed. `clicked` fires before
the pointer event that names the finger, so it now only raises a flag and
the `up` handler decides. A mouse reports 0 for both, leaving the desktop
path exactly as it was.
2026-08-21 20:30:20 +02:00
dtourolle 892da2662a Keep the stars on screen long enough to click one
The rating strip was a sibling of the cell's TouchArea, declared after it
so that hit-testing reached the stars first. That half worked: a star
click set a rating without also opening the image.

The other half did not. Hover is tracked per TouchArea, and Slint sends
`Exit` to any item that drops out of the hit path. The strip taking the
pointer is exactly that — `cell-touch` left the path, `has-hover` went
false, and `show-empty` went with it. On an unrated cell the stars are
drawn *only* on hover, so they vanished as the pointer arrived at them;
the click then landed on the cell behind and opened the image. Reported
as "the star menu disappears when I click on an image", which is
precisely what it does.

Nesting the strip inside `cell-touch` keeps both halves. Children are
hit-tested before the element containing them, so a star still wins the
click and still ends the walk before `cell-clicked` runs. And an ancestor
stays on the item stack: it gets no `Exit`, and while a child holds the
grab it is handed the event filter and never the event. Hover therefore
holds for as long as the pointer is anywhere in the cell.
2026-08-21 20:29:10 +02:00
dtourolle 76a751f125 Measure the grid against the grid, not the whole view
`cell-size`, `columns` and `visible-rows` were all derived from the
LibraryGrid's own width and height. The cells are not drawn in that box:
the capture-time axis is a sibling of the grid, 96px of it, and the
header takes another 44px off the top.

So `columns` counted the timeline as room for thumbnails and fitted one
more column than there was space for. The last column started inside the
Flickable and ended outside it — clipped, with no sideways scroll to
reach it. At the 180px size class on a phone in portrait the timeline is
a quarter of the screen, which is most of a column.

`visible-rows` was wrong the same way and fed `capacity`, so every window
over-fetched by the ratio of the header to the viewport.

Both now read `grid-area`, the layout the cells actually live in. That is
a descendant, and reading a descendant's geometry is the shape that
causes binding loops elsewhere in this UI — but not here: nothing derived
from these feeds back into the layout. The cells are placed absolutely
inside the Flickable and a Flickable's layout constraints are a bare
`stretch: 1` that its viewport cannot influence.
2026-08-21 20:28:12 +02:00
dtourolleandClaude Opus 5 3cfa78cde2 Give the app a face and a name on the launcher
There was no icon anywhere, and on Android that was not a missing line in the
manifest. `aapt2 link` was being handed a manifest and nothing else, so the
APK carried no res/ and no resources.arsc — there was no table for an
`@mipmap/...` reference to resolve against even if one had been written.
Packaging now compiles the resource tree first and links the result in, which
is the two steps aapt2 insists on: link reads compiled input only, never a
directory.

That absent table is also why the launcher caption was blank, which had
looked like a second, separate bug. `android:label="DarkRoom"` was there and
correct the whole time, and Settings' App info read it fine; the launcher
could not, because resolving a label goes through the package's Resources and
there were none to open. Nothing about the label changed here. It came back
with the table under it.

`android:icon` then names one drawable for both icon generations, because the
`anydpi-v26` qualifier is what separates them. API 26 and up take the adaptive
icon and its three layers; the third of those, monochrome, is what lets
Android 13 recolour it rather than drop the app out of the themed set. Below
26 the same name lands on a density-matched PNG. `roundIcon` is deliberately
absent — a launcher old enough to read it is one that would ignore the
adaptive XML, and minSdk is 28.

The desktop icon is one `@image-url` on the window, and the only raster asset
in a UI that is otherwise entirely Path. The reasoning at the top of
icons.slint does not reach it: that is about glyphs a font might not carry,
and this image is never drawn by us at all. It goes to the window manager,
which wants pixels and composites them unmasked, so it is pre-shaped with
rounded corners rather than square the way the Android layers are.

Which exposed Slint's resource default. An `@image-url` compiles down to the
absolute path it had on the build machine, to be opened at runtime — already
wrong for Android, where the build happens under /work inside a container and
no such directory exists on the device, and wrong silently, as an image that
loads empty. `EmbedFiles` puts the bytes in the binary instead. It reaches
nothing else, since every glyph is a Path.

Verified on a device: the APK installs and the home screen draws both the
icon and "DarkRoom" under it, where before it had neither. In the link step
the adaptive icon resolves at all six densities and resources.arsc lands
uncompressed, which API 30 requires and the existing zipalign preserves. On
the desktop by reading _NET_WM_ICON off the running window — 256x256, as
handed over. Where that actually shows is narrower than it sounds, and the
comment says so: Wayland ignores the property in favour of matching app_id
against an installed .desktop file, which this repo does not install.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 20:25:22 +02:00
dtourolleandClaude Opus 5 ca833b6d2b Give every colour band its own compiled shader
🐳 Android image / Build and push (push) Successful in 5s
Build and test / android-image (push) Successful in 5s
Build and test / Desktop (Linux) (push) Failing after 57m33s
Build and test / Layer separation (push) Successful in 35s
Traceability / Requirement traces (push) Failing after 35s
Build and test / Android (aarch64) (push) Failing after 9m42s
The colour mixer emits a code block and a uniform only for the bands that
are set, so which bands are adjusted is part of the shader's structure. The
pipeline cache key was not: it hashed the set of *active operations*, which
is "colour_mixer" whichever band that is.

So a red adjustment and a blue one hashed alike. The second render was handed
the first's compiled pipeline while its uniform was uploaded into a slot that
shader had assigned to another band — whichever band compiled first kept
acting on every subsequent move, and every other slider did nothing at all.
Red is the first band declared, and the one reported as the only one working.

The hash is now taken over the generated WGSL, because the source is what
gets compiled and therefore is the structure. A summary of what went into it
has to be kept in step with every operation's code generation by hand, and
this one had fallen out of step. Values still do not enter it: no operation
writes a parameter value into its source, so a slider drag regenerates
identical text and reuses the pipeline, and one that did inline a value would
have to recompile to be correct anyway.

`each_colour_band_gets_its_own_pipeline` in dr-gpu renders a blue pixel
through one pass with red set first and then blue, and fails on the old hash
with the reported symptom — the blue slider returning the pixel unchanged to
the byte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:23:04 +02:00
dtourolleandClaude Opus 5 f76e024f41 Straighten a portrait frame in the frame the user is looking at
The framing prologue built the centred position `p` by scaling with the
source's aspect, then straightened, then permuted the quarter turns. On a
landscape frame those are one space and it worked. Once a turn has swapped
the axes — the rotate button, or a file whose EXIF tag says the camera was
held sideways — they are not: `p` was measured with the source's ruler on a
frame that is no longer that shape, stretching one axis against the other by
(w/h)², which is 2.25 on a 3:2 photograph.

The quarter-turn permutation happened to undo that stretch, so rotation alone
looked right, which is how this survived. The straighten in between did not,
and a rotation in a space whose axes carry different scales is a shear.

`p` is now built in `frame_aspect` — the frame as the user sees it — and the
permutation becomes the one place the two rulers meet: each axis divided by
the aspect it is read from, multiplied by the aspect it is written to.

Asserted on pixels rather than on the generated WGSL, because reading the
shader and reasoning about which space `p` lives in is how the wrong formula
got written in the first place: a disc, straightened by 20° on a turned 3:2
frame, must come back circular by every route to a swapped frame — the
button, the tag, and the two composed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:17:54 +02:00
dtourolleandClaude Opus 5 f1cd6ed5b3 Make the drop decision a rule that can be tested
Build and test / Desktop (Linux) (push) Failing after 57m40s
Build and test / Layer separation (push) Successful in 34s
Traceability / Requirement traces (push) Failing after 27s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Build and test / Android (aarch64) (push) Failing after 9m28s
The gesture is Slint's and cannot be driven from a test — synthetic drags
do not reach a DragArea at all — but the decision it leads to is where
this can actually go wrong, and it was buried in a callback.

`decide_drop` names the three outcomes and the order that separates them:
an image drag always fills the payload, so photographs pressed after a
row was clicked are still filed rather than read as a rearrangement. A row
dropped on itself is a no-op here rather than a cycle error from the
catalog, and an empty drag with nothing remembered — a file from another
application — leaves the tree alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:12:11 +02:00
dtourolleandClaude Opus 5 6acc73a9fa Drag a collection onto another to nest it
The tree could be built nested but never rearranged: `set_parent` existed,
with its cycle check and its tests, and nothing in the UI called it. A
collection created in the wrong place stayed there.

Each row is already a drop target, so it becomes a `DragArea` too —
wrapped at the instantiation site the way the grid's cells are, which
keeps the row's own TouchArea nested underneath and a click still
selecting. `allow-move`, not copy: a collection has one parent, unlike a
photograph, which is filed in as many collections as you like.

The drop is handed only the target's id, so the source is remembered from
the press that precedes the drag — Slint builds the payload through a
`pure` binding, which must not have side effects. An image drag always
fills `dragging`, so an empty payload with a remembered row is
unambiguously a rearrangement; the row is taken rather than read, or a
later empty drop would move a collection nobody touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:54:50 +02:00
dtourolleandClaude Opus 5 edcaf42ded Show only the photographs taken in the period you are looking at
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
Build and test / Desktop (Linux) (push) Failing after 57m33s
Build and test / Layer separation (push) Successful in 36s
Traceability / Requirement traces (push) Failing after 29s
Build and test / Android (aarch64) (push) Failing after 9m28s
The library could be narrowed by rating, flag and availability, but not
by when a photograph was taken — so finding a fortnight meant scrolling
to it and holding position.

The range rides on `RatingFilter` for the reason `local_only` already
does: every query path threads that one struct, so the count in the
header cannot claim a total the grid does not draw. Undated images are
excluded whenever either end is set — they cannot be inside or outside a
span, and drawing them made the range look as though it had not applied.

Taken from the timeline rather than typed into two date fields. Finding
the period is what the histogram is for, and having found it the user
should not have to read the dates off the axis and key them back in.
The histogram keeps drawing the full extent while the range is on, or
there would be nowhere to widen back out from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:38:26 +02:00
dtourolleandClaude Opus 5 d2d5d6f22b Keep the selection visible when the grid scrolls under it
Scrolling rebuilds every cell, and a fresh cell carries `selected: false`.
`sync_badges` and `sync_ratings` refilled what the rebuild cleared;
nothing refilled the selection, so the ticks vanished on every scroll.

The selection itself was never lost — it is a set of image ids and
survives untouched — which made this worse than losing it: the header
buttons still acted on forty photographs the user could no longer see
were held.

`load_window` reaches the selection through a `Weak` handle set at
wiring. Weak because the two controllers are joined only through the
window, and an `Rc` each way would leak both; absent, the grid draws
nothing selected, which is what it did before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:30:37 +02:00
dtourolleandClaude Opus 5 02d629922f Draw the date histogram over the collection you are looking at
Build and test / Desktop (Linux) (push) Failing after 57m25s
Build and test / Layer separation (push) Successful in 33s
Traceability / Requirement traces (push) Failing after 27s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Build and test / Android (aarch64) (push) Failing after 9m45s
The timeline counted the whole library whatever the grid was showing, so
opening a collection left a fortnight in Arosa as one column of a
fifteen-year axis — an axis describing photographs that were not on
screen.

Scope the buckets and the span to the same collection and rating filter
the grid uses. `timeline_range` counts `images` alone and cannot express
the membership join, so the scoped query lives beside the other scoped
readers in the UI and shares their descendants-of-scope rule.

`catalog_span` now delegates to the same scoped reader. Zoom and scrub
measured the full library while the bars were scoped, so a scrub could
land on an instant the collection did not contain and send the view
somewhere the user had not asked to go.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:15:59 +02:00
dtourolleandClaude Opus 5 b0206cbc7a Let a sub-collection stay under its parent through a sync
Build and test / Desktop (Linux) (push) Failing after 57m14s
Build and test / Layer separation (push) Successful in 33s
Traceability / Requirement traces (push) Failing after 29s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Build and test / Android (aarch64) (push) Failing after 9m47s
The merge inserted every incoming collection with `parent_id = NULL` and
never set it on update, so the hierarchy flattened on each round trip: a
collection nested on one device came back from the server at the top
level. `r.parent_id` was selected and then not read.

The id could not be copied — row ids are local, and the remote's integer
names a different collection here, or none. So carry the parent's uuid
and resolve it locally, in a second pass: rows arrive in whatever order
the query returns, and a child can precede its parent.

Guard the resolution against cycles. Each tree is acyclic alone, but the
union need not be — we may hold A above B while the remote holds B above
A — and closing that loop would make every tree walk spin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:55:09 +02:00
dtourolleandClaude Opus 5 5700c37016 Look before overwriting the file we are asking about
The probe PUT its bytes first and read the outcome, which answers the
question by destroying the evidence: pointed at a real sidecar it would
replace an edit with the word "probe", and on success delete it outright.

PROPFIND first. Permissions and status usually settle create-versus-update
on their own, and a path that already exists is now reported and left
alone. `--write` still forces the update test for a file worth losing, and
the cleanup DELETE fires only for a path the probe itself created.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:40:36 +02:00
dtourolleandClaude Opus 5 18f20170b3 Ask the file, not its folder, why the write was refused
The 403 probe read `oc:permissions` off the parent collection and warned
when `W` was missing. But Nextcloud reports `W` on files and `CK` on
collections, so a directory legitimately lacks `W`: the warning fired on
a healthy share and pointed at a mount that was fine.

Probe the file itself. Its permissions answer the question that matters,
and the status distinguishes the two cases the parent could not: a 404
means the sidecar does not exist and the refusal was about creating it,
while a 200 without `W` means it exists and cannot be updated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:38:48 +02:00
dtourolleandClaude Opus 5 6621c11ad6 Diagnose the refused sidecar: the library mount is create-only
Ratings and edits made on the tablet queue and are then refused on reconnect,
on a credential that pushes the catalog to the same library root in the same
sync pass. Chased on the device, since that is where the account lives.

A failed PUT now logs the server's own words, and on a 403 asks the parent
what rights it reports. The answer:

    PUT .../PhotosRaw/2026/2026-08-03/_MG_9221.drsc -> 403
      <s:exception>Sabre\DAV\Exception\Forbidden</s:exception>
      parent permissions: MGNVCK

`M` mounted, `G` readable, `N` renameable, `V` moveable, `CK` create files and
folders. Absent: `W`, update an existing file, and `D`, delete. So `PhotosRaw`
is a mounted share that accepts a file once and refuses every change to it
afterwards.

That is the whole bug, and it is not one this side can retry its way out of. A
sidecar is rewritten on every rating and every edit, so the first judgement on
a photograph is written and every later one is refused — which reads as sync
being broken rather than as a share missing one permission. **The fix is to
grant update, and ideally delete, on that mount.**

`PermissionDenied` now says so, rather than "not allowed to write here", which
sent the reader to re-check a login that was working. Its test asserts the
intent — points at the folder, never at the credential — rather than a
phrase, so saying it better cannot read as a regression.

Also adds a `put_probe` example that makes the same request from a stored
session, for diagnosing this from a desktop when one is signed in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:19:27 +02:00
dtourolleandClaude Opus 5 71554714e7 Log why the server refused a write, in its own words
A queued sidecar fails to upload with 403 on a credential that pushes the
catalog to the same library root in the same pass. `map_status` reduces every
non-success to a typed error, which is right for the application and leaves
nothing to work from: a read-only share, a file access control rule and a lock
all arrive as `PermissionDenied`.

Sabre says which in the response body. It is now logged on any failed PUT —
the URL, the status, and the first line naming the exception or message,
capped at 300 characters because an error page can be a whole document. Only
on failure; a success has no body worth reading.

Also adds a `put_probe` example that makes the same request from a stored
session and prints the reason, for diagnosing this from a desktop rather than
from a tablet's logcat. It needs a session on the machine it runs on, which is
why the log line above exists as well.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:08:07 +02:00
dtourolleandClaude Opus 5 09f6cf8c0f Name the sidecar the drain could not deliver
The write path learned to say which file the server refused; the drain path —
the one that runs for work queued while offline — still reported only a count.
That is the path that matters most, because everything it carries was made
with no connection and exists on one device.

With it named, the failure on the tablet is:

    draining PhotosRaw/2026/2026-08-03/_MG_9221.drsc:
    permission denied

on a credential that pushes the catalog to the same library root in the same
pass. So this is not the account and not the app password: one path is
writable and another is not, which points at the server — a read-only share
over that folder, or a file access control rule on the extension — rather than
at anything this side can retry its way out of.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 14:21:00 +02:00
dtourolleandClaude Opus 5 6a7ed37aed Say which sidecar the server refused, and where
A queued sidecar failing to upload was reported as a count and a reason —
"1 queued sidecar(s) still undelivered: permission denied" — with the path
only at debug level, which the app filters out by default.

That is unsynced user work: a rating or an edit that exists on one device and
nowhere else. Which photograph it belongs to, and which path the server
refused, is the whole of what makes the failure actionable, and without it a
403 on a single file reads the same as a whole library failing to sync.

Observed on the tablet, where writes to the derived folder succeed on the same
credential — the catalog pushes fine — while one sidecar beside its image is
refused. That combination says the path matters and the account does not, so
the path is the thing worth printing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 14:11:30 +02:00
dtourolleandClaude Opus 5 dea826811e Render a blown highlight white instead of magenta
Every clipped sky came out bright pink. Measured, not guessed: developing
_MG_8596.CR2 and looking at the export, the subject renders correctly and only
the saturated region is wrong.

A fully clipped pixel reaches the shader as (1, 1, 1) — three photosites that
stopped counting, carrying no colour at all. The as-shot multipliers are not
neutral, so balancing sends it to (1.93, 1.00, 1.68) on this body, and the
camera matrix turns that into R 2.88, G 0.51, B 2.03. Red and blue clip at
one; green, whose matrix row is far less positive-heavy, does not. Red and
blue high with green low is magenta.

Nothing upstream was at fault, which is why the two previous attempts missed
it: the white balance is correct, the matrix is correct, and the sensor
normalisation is correct. The input simply was not a colour, and correct
arithmetic on a non-colour produces a confident wrong answer.

So saturation is detected before the balance is applied — the last 1.5% of
range, smoothstepped rather than switched, because a hard threshold draws a
visible rim around every highlight and a backlit edge on skin is where that
shows. Above it the pixel is pulled to the neutral of its own brightness, so
it keeps its luminance and loses only the cast.

Verified end to end on the file itself: the sky is white, the skin, the black
dresses and the stone are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 13:17:15 +02:00
dtourolleandClaude Opus 5 31e20399c8 Read the true white level, and clamp the sensor stage at both ends
Two corrections to the sensor stage, found while chasing magenta highlights.
Neither is the cause of that — see below — but both are wrong on their own
terms.

`white_level` took the *first* of rawler's per-channel saturation points. On a
Canon 6D that reports 15070 while the data reaches 16383, so every sample
above it was treated as brighter than white. It takes the maximum now.

The normalisation clamped its floor and not its ceiling, so those over-white
samples passed through as values above 1.0. Clamped at both ends.

**This does not fix the pink.** Measured on _MG_8596.CR2, exported and looked
at: the subject renders correctly and only the blown sky is magenta. A fully
clipped pixel is (1,1,1) in raw, the as-shot balance multiplies it to
(1.93, 1.00, 1.68), and the camera matrix turns that into R 2.88, G 0.51,
B 2.03 — red and blue clip at one, green does not, and the result is magenta.
It is correct white balance applied to already-saturated data, which is the
classic highlight-clipping cast and needs highlight desaturation to fix: a
pixel at saturation carries no colour information and must be rendered
neutral, not balanced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 13:13:17 +02:00
dtourolleandClaude Opus 5 7d0fb710a6 Bound a readback by time, so a full-resolution export can finish
Exporting a real 20 MP CR2 failed every time with "readback did not
complete", while the copy itself was perfectly healthy.

The bound was 100,000 non-blocking polls. That sounds generous and is not: a
`Poll` that finds nothing returns immediately, so the loop spent its entire
budget in a few milliseconds. Small transfers — the histogram's 4 KB, a
viewport-sized frame — happened to land inside it. An 80 MB frame never could.

It is a deadline now, thirty seconds, which is the only thing the bound was
ever for: catching a lost device that will never deliver the callback. A
one-millisecond pause after the first sixty-four spins stops the loop
saturating a core for the length of the copy, while keeping a small transfer
as immediate as it was.

Found by developing /home/dtourolle/Downloads/_MG_8596.CR2 through the export
example: 5472×3648 renders in 127 ms and writes all five formats.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 13:13:17 +02:00
dtourolleandClaude Opus 5 f1f528fc42 Stop rendering a missing white balance as neutral, which came out pink
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 2s
Build and test / Desktop (Linux) (push) Failing after 57m10s
Build and test / Layer separation (push) Successful in 34s
Traceability / Requirement traces (push) Failing after 36s
Build and test / Android (aarch64) (push) Failing after 9m26s
`sane_wb` replaced any coefficient it could not use with 1.0. That reads as a
safe default and is not one. A Bayer sensor's green photosites collect roughly
twice the signal of its red and blue, so unbalanced data is strongly green —
and the camera matrix is built assuming the data reaching it has already been
balanced. Fed green-heavy input it subtracts green as designed, overshoots,
and the frame lands in magenta. Bodies whose as-shot coefficients rawler does
not report came out pink, and nothing anywhere said why.

The fallback is now the camera's own response to daylight, which
`cam_to_srgb_from` was already computing on its way to balancing the matrix
and then discarding. `daylight_wb` exposes it, and both callers read the same
matrix through the same illuminant preference — so the multipliers neutralise
exactly the white the matrix expects to be neutral, by construction rather
than by coincidence. With no matrix either, the body is unknown and neutral is
the honest answer: uncalibrated beats wrong in a specific direction.

A test caught me returning the response rather than its reciprocal, which
inverts the correction — a sensor is *least* sensitive to the channel needing
the largest multiplier, so that version boosted precisely the wrong one. The
doc comment now says which of the two it returns, because they differ by an
inversion and look alike.

Four tests, on a real matrix (Canon 6D, D65) rather than a contrived one: the
fallback is nowhere near neutral, lifts both red and blue against green, stays
green-normalised, and — the property that makes it consistent rather than
merely plausible — balancing by it and then applying the matrix maps the
camera's white to a neutral sRGB.

`daylight_wb` is also the anchor the white-balance presets need: a preset in
kelvin requires an absolute illuminant to be a preset *of*, and the temperature
control is currently a relative offset from whatever the camera chose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 13:03:03 +02:00
dtourolleandClaude Opus 5 d2024da368 Scroll the develop column as one, histogram and all
Only the sliders scrolled. The capture metadata, the histogram, the geometry
controls and copy-and-paste all sat above them in a fixed layout, so on a
280px column in portrait they took the height the sliders needed — and the
histogram, which is the instrument the sliders are judged against, could
neither be scrolled to nor scrolled past.

The Flickable moves out of `AdjustPanel` and around the whole column. Nesting
one inside the other was not an option: a slider drag already has to be won
against one scroller, and a second would give it a third thing to be lost to.

`slider-dragging` becomes an `out` property for the same reason. The
arbitration is unchanged and still necessary — a track stands the scroller
down as soon as a finger touches it, or every attempt to drag a slider would
scroll the column instead — but the scroller obeying it now lives a level up.
The scroll gutter stays where it is, as padding inside the content: it is what
guarantees somewhere to put a thumb that means "scroll" and nothing else, and
it matters more now that it serves the entire column.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 12:53:04 +02:00
dtourolleandClaude Opus 5 654c11300e Pin the framing geometry on pixels, not on the generated shader
Chasing a reported shear on rotate and straighten. Two tests, and what they
prove is that the pipeline is not where it comes from.

A circle is the shape that makes anisotropy unmissable: any transform scaling
the axes unequally returns an ellipse, and the ratio of its axes is the error.
Both a quarter turn and a 20° straighten, on a 3:2 frame, return a circle
within 8%.

Worth recording because I had a confident and wrong hypothesis first. The
quarter turn carries `* aspect.x` on one component and `/ aspect.x` on the
other, which reads like an anisotropy of a-squared, and the reasoning that
`p` is already isotropic is plausible enough that I changed it. The existing
`a_quarter_turn_corrects_for_aspect_across_the_swap` caught that immediately,
and these tests then showed the original was right all along: the crop rect is
expressed in the *turned* frame and the output axes swap with it, so the
factors are the conversion between those spaces rather than a mistake.

Reading the shader and reasoning about which space `p` lives in is exactly how
a plausible formula gets written twice. These assert on real pixels off a real
adapter instead, so the next person to suspect this transform can rule it out
in one command.

The shear is therefore in the display path — the fit from the framed size to
the viewport, or the crop overlay's uncropped render — and not in the geometry
the pipeline computes. Not yet fixed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 12:46:17 +02:00
dtourolleandClaude Opus 5 02ae92ba0d Tidy what the seven-branch merge left behind
Build and test / Desktop (Linux) (push) Failing after 57m16s
Build and test / Layer separation (push) Successful in 34s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Traceability / Requirement traces (push) Successful in 1m7s
Build and test / Android (aarch64) (push) Failing after 9m41s
Three lints, all from merged work rather than from any one branch:
`terrace` and `disc` were steps on the way to the ramp the plateau test now
uses, and the reasoning that discarded them lives in docs/segmentation.md §12
rather than needing the code; two mechanical clippy suggestions in segment and
cache.

1176 tests pass, clippy and fmt clean, traceability regenerated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 12:36:15 +02:00
dtourolleandClaude Opus 5 c9c5c43aa1 Carry the library across when its home moves
The previous commit moved durable data out of Android's cache directory, and
on its own that would have been an upgrade that quietly discarded work. The
app looks in the new location, finds nothing, and rescans a library of tens of
thousands of images over the network — while the old copy, including every
offline rating and edit that had not yet synced, sits in a directory the
system is free to delete.

So the account's directory is moved once at startup, before anything opens a
store. A rename rather than a copy: both are inside the app's own data on one
filesystem, so it is atomic and cannot half-finish. An existing destination
wins and the move is skipped — that covers a second run and a fresh install,
and in neither case may this overwrite live data.

A failed move is logged, not fatal. The cost is a rescan, which is
recoverable; refusing to start is not.

Two tests, against ordinary directories rather than the platform's idea of a
cache: one puts an unsynced sidecar in the old location and asserts it is
readable in the new one afterwards, the other pins that live data is never
overwritten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 12:32:20 +02:00
dtourolle 4e62b89d17 Merge branch 'android-collections'
Build and test / Desktop (Linux) (push) Failing after 18m53s
Build and test / Layer separation (push) Successful in 41s
Traceability / Requirement traces (push) Failing after 1m4s
🐳 Android image / Build and push (push) Successful in 6s
Build and test / android-image (push) Successful in 5s
Build and test / Android (aarch64) (push) Failing after 9m40s
Touch multi-selection in the grid, filing a selection into a collection
without a drag, and taking a collection offline from a held row.
2026-08-17 12:27:49 +02:00
dtourolleandClaude Opus 5 d913e50948 Select photographs with a finger, and take a collection with you
Two things a tablet could not do. Both existed for a pointer and had no
touch form at all, which on Android meant the collection sidebar was
somewhere to look at rather than somewhere to file into.

**Selecting more than one.** Ctrl-click and shift-click are the only ways
into a multi-selection, and touch has neither. Holding a cell now enters
selection mode, where a tap toggles — reported to Rust as a ctrl-press, so
it goes through the same `apply_press` as everything else rather than
growing a second copy of the selection rules. A double tap takes the run
between where selecting began and there: the touch form of shift-click,
and the reason the anchor from *before* the double tap has to be
remembered, since both of its taps move the anchor onto the cell being
tapped. A "Select" button does the same thing where a gesture would go
undiscovered (FR-UI-4).

**Filing without a drag.** A one-finger drag beginning in the grid belongs
to the Flickable that scrolls it — that is the arbitration working, not a
bug to route around — so the selection can now be filed from a sheet
listing the sidebar's own rows. Copy by default, as the drag has always
been; moving out of the collection being shown is a switch, because it is
the one that takes something away.

**Taking a collection offline.** The machinery was there and reachable only
by scoping the grid to a collection and finding a button behind a
disclosure. Holding a collection's name now asks the question directly, and
the tray on a row and the header button ask the same one — three
affordances doing two different things is how a user comes to avoid all
three. The question is asked rather than a toggle flipped because both
answers are expensive: one downloads gigabytes, the other deletes them, and
the counts and sizes go in the buttons where they are read before the tap.

`Cache::release` is new and is the destructive half `unpin` deliberately is
not. "Remove the local copies" is asked by someone whose device is full,
and withdrawing a promise while leaving the bytes for a future eviction to
notice is not an answer to it. It unpins before forgetting, or the next pin
fetch would dutifully download everything it just deleted.

The sidebar's trays read `tier_actual`, never `tier_desired`: the question
is whether these will open on the aeroplane, and a pin whose download has
not run yet answers no.

TRACES: FR-CAT-7 | FR-NC-6a | FR-NC-6b | FR-NC-6c | FR-UI-2 | FR-UI-3 | FR-UI-4

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 12:27:29 +02:00
dtourolle 76bb6b2847 Merge branch 'worktree-watershed-plateaux' 2026-08-17 12:25:41 +02:00
dtourolleandClaude Opus 5 0b20436445 Prove the plateau pass does nothing, and stop paying for it
Picks up the lower-completion work a crashed session left mid-debug, with one
failing test and no diagnosis.

The diagnosis is that the pass is a no-op. Not "does not reduce basin count" —
it changes *no pixel's basin at all*, zero of 9216, comparing one plateau
iteration against sixty-four. That assertion is the substance of this commit:
the original test asserted a consequence (fewer basins) which a working pass
need not produce, so it could have been satisfied by weakening it. A no-op
check cannot pass vacuously, and it is what turned an opinion into a fact.

Three candidate causes were tried and none was it. Exact float equality is
genuinely wrong and is fixed regardless — a gradient computed from 8-bit
samples is never exactly equal across a region the eye calls flat, so `==`
never fires and `<` fires everywhere; `LEVEL_EPS` now sits behind all three
comparisons. The test image is not it either: a flat disc, a terraced disc and
a constant-slope ramp all behave the same.

The finding worth keeping is about the domain rather than the code. On a
gradient-magnitude watershed every flat region of the picture is at gradient
zero, the global minimum, and a plateau with no descending exit is a minimum —
one basin already, nothing to resolve. The plateaux lower-completion is defined
for are regions of constant non-zero gradient, which are rarer in a photograph
than F1's phrasing implies. That may be the whole answer, or it may be hiding
a fourth cause; I could not close it.

So `plateau_iterations` defaults to 0. The implementation stays, correct as
far as it goes and costing nothing until someone finishes it; the test stays,
ignored with its reason; docs/segmentation.md §12 records what was ruled out so
the next attempt starts further along than this one did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 12:25:28 +02:00
dtourolleandClaude Opus 5 b8e9793908 Keep offline work out of a directory Android empties
The catalog, the sidecar cache and the export outbox were all landing in the
app's cache directory on Android, where the system deletes them without asking
under storage pressure.

`catalog_path` derived its base from `XDG_DATA_HOME` or `HOME`, and neither is
set on Android, so it fell through to `temp_dir()` — which resolves there to
`/data/user/0/<pkg>/cache`. Confirmed on the tablet: the app logs its catalog
under `cache/darkroom/...`.

What sits beside that catalog is not disposable. `sidecars/` is the commit
point for every rating and edit made with no connection — the whole mechanism
that makes offline culling safe — and `outbox/` holds exports the user has
already been told succeeded. A day of sorting on a train, evicted by an OS
housekeeping pass before it ever reached the server, is the worst failure this
application can have, and it would leave no error and no trace.

The fallback is now `SessionStore::data_dir()`, the persistent per-app
directory the Android entry point establishes before any store opens — the
same one credentials and sessions already use. Desktop is untouched: the XDG
data location is still preferred, so nobody's catalog moves.

Two tests. One asserts no durable path contains `/cache/` or `/tmp/`; the
other pins the outbox to the catalog's parent, because three call sites derive
their location that way and a change here moves all of them at once.

Found while verifying that offline browsing works on the tablet with wifi
disabled — which it does: the app cold-starts with no network, reports the
scan failure without crashing, and serves its grid from the local store.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 12:23:18 +02:00
dtourolleandClaude Opus 5 44f0a4971b Show the folder picker on the platform that needs it most, and upload at once
Two faults, both of my own making, reported from the tablet as "I cannot
select a location" and "it does not upload".

**The picker button was gated on `target-selected == 1`.** That index was
Remote's position while both targets were offered. Making the target list
platform-aware narrowed Android's to Remote alone, so Remote became index 0
and the button disappeared — on the one platform where the picker is the
*only* way to set a destination, since a device folder is not reachable there
at all. It is gated on a boolean derived from the target now. An index into a
list whose length varies is not a fact about the target, and writing it as one
is what made a correct change break the thing it was meant to fix.

**A queued export waited for a sync pass.** Staging first is deliberate — an
export is finished on disk the moment it is written, and offline is then just
a longer queue — but nothing drained the outbox until the next sync, so
"Queued for Exports" sat unchanged and read, fairly, as an upload that never
happened. A finished batch that wrote anything now drains immediately. The
sync-pass drain stays: the first makes an upload feel immediate, the second is
what eventually delivers the exports made in a tunnel.

Committed without the parallel session's in-flight collection work, which is
mid-save and does not compile; verified by stashing it and building this tree
alone. 281 dr-ui tests pass, clippy clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 12:01:12 +02:00
dtourolleandClaude Opus 5 a8b28136a6 Merge the batch export, and settle the seven-branch merge
Resolves the last of the parallel work. Two conflicts worth recording,
because both were semantic rather than textual:

`render_for_export` gained a colour space on master while the batch branch
was rewriting the single-image export path around it. Kept both: the batch
request supersedes the synchronous path, and the space still has to be chosen
at render time because the conversion happens in the shader before the clip to
0..1. `render_open_frame` takes it as an argument rather than reaching for a
controller it does not hold.

The map-wait moved into `readback::await_mapping` on one branch while another
was editing the constant it used, so `READBACK_POLL_LIMIT` survived the merge
with no callers. Removed rather than left for clippy to find later.

1164 tests pass, clippy clean, fmt clean. Traceability 53.0% -> 54.3%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 10:11:44 +02:00
dtourolle cfff6a3302 Merge branch 'zero-copy-display'
# Conflicts:
#	core/dr-gpu/src/adjust.rs
#	ui/dr-ui/src/develop.rs
2026-08-17 10:04:14 +02:00
dtourolle 9fc8721fa8 Merge branch 'histogram'
# Conflicts:
#	ui/dr-ui/src/lib.rs
2026-08-17 10:00:27 +02:00
dtourolleandClaude Opus 5 7d3c8c521f Export a whole selection, on a thread that is not the interface's
The export button rendered, resampled and encoded a 24 MP frame on the UI
thread and the window was dead for all of it. That was written down as a known
compromise, on the grounds that a batch is what makes the wait intolerable
rather than merely noticeable. This is the batch, so the compromise comes due.

The grid's selection now exports (FR-EXP-7). A worker thread takes a clone of
the `GpuContext` — an `Arc` pair over a device and a queue — and opens each
photograph for itself: fetch, sidecar, decode, demosaic, render at full size,
resample, sharpen, encode, write. Nothing of that touches the interface, which
keeps drawing throughout, and the progress goes where every other background
job's does: one row in the activity register, with a count and a bar.

Why the worker does not borrow the session it could have had. A
`DevelopSession` owns the `AdjustPass` the canvas renders from, so handing it
to a worker would stop the develop view drawing for the length of the batch —
the same freeze, moved. Opening a session per image instead costs a
`Demosaicer` and an `AdjustPass` each time round, and the pipeline cache is
per-pass so the composed shader is recompiled per image rather than once for
the run. Against a full-resolution decode, render and encode that is a few
percent, and it keeps this file out of the pipeline `develop` owns. A reusable
export pass is the obvious next economy if a profile ever says so.

The open image is the exception, and it is why the develop button is not simply
a one-image batch. Its edit lives in the interface's session and may not have
reached a sidecar yet, so a worker that re-opened the file would export the
saved version rather than the one on screen. That frame is therefore rendered
by the caller and handed over as `Source::Rendered`; everything after the
render — the Lanczos reduction, the encode, the write, which is the larger half
of the wait and all of its variance — still leaves the UI thread. So the
develop export is no longer synchronous, but it is not fully off-thread either,
and the doc comment says so rather than claiming otherwise.

Cancellation (NFR-ARCH-3) is an `AtomicBool` read between stages, and the
export button becomes the cancel button while a run is live — a batch that
could only be stopped by not touching the selection would be a trap. Waits on
another worker use `recv_timeout` rather than `recv`, so a cancelled batch
sitting on a forty-megabyte download gives up within 100 ms instead of when the
transfer finishes. The honest bound is worse than that: a frame already in
render has no interior stopping point, so the worst case is one image. Closing
that needs the render itself to become interruptible, which is NFR-ARCH-2's
scheduler and not a finer poll here.

Failures are per image and typed (NFR-ARCH-4). One unreadable body, one folder
that cannot be written, one server that went away — each is a message on the
channel, a line in the log, and a count in the summary, and the batch carries
on. A run with any failure keeps its row until it is cleared, because that is
the row somebody came to the list to find; a cancelled run does not, because
they asked for it.

Two collisions that look alike and are not. `CollisionPolicy` is the user's
answer to "a file of this name was already there", and Overwrite is a fine
answer to that. It is not an answer to "the frame I exported four seconds ago
was also called this" — two folders in a library each holding an IMG_0001 is
ordinary — so a name the run has already issued is always stepped past whatever
the policy says about the folder. Both halves are held by tests.

Supporting changes, each smaller than it sounds. `open_session` comes out of
`load_bytes` so the worker shares the JPEG-versus-RAW routing rather than
carrying a copy that would drift; the half that builds a `slint::Image` stays
behind, where it belongs. `LibraryController::selected_image_paths` answers
from the catalog rather than from the loaded window, because selection is by id
and survives a scrub — a selection made before scrolling routinely names
photographs no row holds. `cache_context_for` takes an id for the same reason,
so a batch reads the originals cache instead of re-downloading three hundred
files. `format_date` is shared so `{date}` and the timeline agree about what
day a photograph was taken.

Left undone, deliberately: the batch is sequential, where FR-EXP-7 asks for all
available cores. Four full-resolution frames in flight is tens of megabytes
each and a straightforward way to exhaust a tablet, and the GPU is shared with
the interface in any case. Also undone: exporting with a chosen preset rather
than the current export settings — that is FR-EXP-5's machinery, which does not
exist yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:59:57 +02:00
dtourolleandClaude Opus 5 0233df4bf2 See what the highlights are doing: a live histogram (FR-DSP-7)
Exposure, blacks and whites were set by eye. Nothing said a highlight had
blown — the canvas shows white where a channel is at 250 and white where it is
at 255, and the difference is the whole question.

**Counted on the GPU, not on the readback.** There is a full frame sitting in
CPU memory on every canvas update right now — `AdjustPass::read_output`, the
bridge spike S1 removes — and walking it would have been thirty lines and no
shader. FR-DSP-7 states the mechanism and not just the feature: "these derive
from a GPU-side reduction into a small buffer. Per-frame CPU readback of image
data is prohibited." A histogram founded on the bridge would be correct today
and deleted by S1, and would meanwhile be the reason the bridge could not go.
What crosses the bus here is 4104 bytes whatever the image size.

The reduction tallies into workgroup memory first and merges once per
workgroup. A photograph is not noise: a clear sky puts tens of thousands of
adjacent pixels in one bin, and contending for that single global atomic
serialises the dispatch.

**On the settled frame only.** `render_now` already knows whether a gesture is
still moving — `draft` is the flag `redraw` derives from `was_coalesced` — so
the dispatch and its transfer happen once when the slider stops rather than on
each of the forty frames a drag emits. Nothing is lost: a histogram flickering
past under a finger is not a reading anyone takes. FR-DSP-7 requires exactly
this, that it not extend the FR-DSP-3 frame budget.

Luma is weighted in 8.8 fixed point — 54, 183, 19, summing to 256 exactly —
rather than in floats. Not thrift: it makes the shader's arithmetic
reproducible bit for bit, which is what lets the test below be an `assert_eq`
against a CPU count rather than a tolerance. ARCH §6.13's line about integer
state, applied where it happens to also be free.

**What the numbers were checked against.** A flat frame must put all 4096
pixels in one bin and one only. A 256-wide ramp must occupy every level with
exactly the same count, which is what catches an off-by-one in the
quantisation — a `floor` where a rounding was needed shifts the whole
photograph one bin left and looks like nothing at all. And a 101x37 frame of
seeded pseudo-random pixels — deliberately not a multiple of the 16x16
workgroup, so the edge tiles run off the image — is compared slot for slot
against a second, obvious CPU implementation. Exact equality, no tolerance.
The CPU version is a deliberate reimplementation rather than shared code: the
bugs worth catching here are ones shared code would commit identically on both
sides.

Above that, the presentation arithmetic is unit-tested headless, because it is
where a wrong answer is invisible. A histogram of the wrong shape looks exactly
as plausible as one of the right shape. So: 64 columns because it divides 256
and an uneven fold draws an even ramp as a comb; the peak excludes the end
columns, or a night scene scaled against its own black spike is a flat line
with no information in it; heights are clamped into the plot; and "0%" is kept
distinct from "<0.1%" and from "—", since an indicator reading "clipped" over
a figure reading "none" is a panel contradicting itself.

Clipping counts a *pixel* with any channel at an extreme, not a channel. Any,
because a blown red has no gradation left in it however much green and blue
still hold — and it is the saturated highlight, the sunset and the red jersey,
that clips first and recovers worst. Per pixel, because counting channels can
report 200% of a frame clipped, and a percentage above 100 is a readout nobody
trusts again.

Two affordances for it, which NFR-A11Y-3 asks for: a bar standing at the end
of the plot the tones are piling against, and a figure saying how much. Either
alone reads.

The panel sits directly under the capture metadata and above every control,
because it is what the controls are judged against. It is hand-built rather
than generated, and ARCH §4.3a is untroubled: a histogram is not an operation
— no parameters, changes nothing, answers a question rather than asking one —
and nothing in it reads a parameter out of a descriptor.

Three plot colours and a neutral luma trace join the palette. That is the
swatch's exception rather than a second one: a per-channel histogram has to
say which channel, and no achromatic treatment distinguishes red from blue, so
the hue is data exactly as the image beside it is. Held well back from full
strength for the reason the theme preamble gives.

The bounded, non-parking map wait moves out of `AdjustPass` into
`readback::await_mapping`, shared with the histogram's transfer. Thirty lines
of load-bearing reasoning about frozen interfaces and lost devices, and two
copies of it would have drifted.

The histogram describes the frame on the canvas, so it is in the output colour
space FR-DSP-7 asks for, and when zoomed it describes the visible region — a
photographer inspecting a highlight at 4x is asking about that highlight. A
device that cannot build the reduction loses the histogram and keeps the
photograph.

Still to do for FR-DSP-7: the pixel colour readout under the cursor.

324 tests pass, clippy and fmt clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:55:40 +02:00
dtourolleandClaude Opus 5 cf8f5b632f Show the develop frame itself, instead of a photocopy of it
The oldest open item in the project (ARCH §6.1, spike S1, AC-8). Every frame
in develop was read off the GPU into a `SharedPixelBuffer` and handed back to
Slint to upload again: ~7 ms at 4K against a 0.28 ms compute pass, 96% of the
frame spent carrying pixels to the CPU and back so they could be drawn where
they already were.

Slint 1.17 will adopt a `wgpu::Texture` directly, and the whole of what that
needs is arrangement rather than code.

**One device, made before the window.** A texture belongs to the device that
allocated it, so the compute passes and the compositor cannot each open their
own. `GpuContext::new_shared` opens one and hands back the instance and
adapter alongside it; `dr_ui::shared_gpu` gives all four to
`BackendSelector::require_wgpu_29(WGPUConfiguration::Manual { .. })`. That
call has to come before the first window, because creating one selects a
backend for you — which is why the GPU is now opened at the top of `run`
rather than two hundred lines down beside the other controllers.

dr-gpu still names no UI type. It hands out raw wgpu and does not ask who is
compositing (ARCH §6.5a).

**Vulkan only on the shared path**, where headless keeps its GL fallback.
wgpu's GL backend reaches its display through EGL at instance creation, and
before a window exists there is no display handle to give it — so a GL
instance cannot later produce the window surface Slint needs from it. A
machine with no Vulkan gets no shared device and browses without develop,
which is the same degradation as no adapter at all.

**`renderer-femtovg` becomes `renderer-femtovg-wgpu`.** The old one is FemtoVG
over OpenGL and cannot be handed a wgpu texture at all. It is not kept
alongside as a fallback: FemtoVG-over-GL has no branch for an imported
texture, falls through to "render this image into a buffer", gets nothing, and
draws nothing — a blank canvas with no error, which is worse than the failure
it would be papering over. The consequence is stated plainly in the manifest:
the desktop app now needs a working wgpu adapter to open a window.

**Two output textures, not one, and this is the part that is not obvious.**
Slint repaints when the image property *changes*, and it decides that with
`PartialEq` — which for two images over the same `wgpu::Texture` says
"unchanged". A pass that reused a single target would have rendered every
slider move correctly on the GPU and shown none of them: right, and invisible.
`AdjustPass` alternates between two targets, so consecutive frames are
genuinely different values. It also settles the read-while-write question that
one queue was already answering.

`RENDER_ATTACHMENT` is added to both render targets. Neither pass uses it;
Slint rejects an imported texture without it, on the reasoning that a
compositor handed a texture may need to draw into it.

**`AdjustPass::read_output` is deleted rather than gated.** It and
`export_pixels` were the same transfer under two names, and the comments
explaining why they were separate are the point of the whole criterion:
reading pixels back to *display* them is the defect, reading them back to
*encode a file* is the only way a file is made. The display twin is now gone
outright, which is stronger than a feature flag — it cannot be turned back on.
`export_pixels` is untouched and still ungated. The `readback` feature comes
off dr-ui, darkroom-desktop and darkroom-android; it stays in dr-gpu, where it
still gates `RenderTarget::read_pixels` and the segmentation field readback.
`examples/develop` moves to `export_pixels`, which is honest — it writes a
PPM — and so no longer needs the feature.

Four tests, each named for what it protects and each of which fails without a
screen if the property it guards breaks:

- the adjust target satisfies every condition Slint's import checks, asserted
  in the crate that owns the descriptor, because a descriptor that drifts
  fails at runtime on a real display and nothing else would notice;
- consecutive renders are different textures, and the third is the first
  again, so the alternation is a rotation and not an allocation per frame;
- the develop canvas has no CPU pixel buffer and does have a wgpu texture —
  AC-8 itself, in the terms Slint uses;
- consecutive frames compare unequal as `slint::Image`, which is the property
  the repaint actually depends on.

The zoom test's readback moves into the test module. It has to: there is no
library function that copies a displayed frame to the CPU any more, and that
is the point — the round-trip now exists in the test binary and nowhere a
shipping build can reach.

**What is not proven.** No GUI was run. What is verified is that the texture
satisfies the import contract, that the import succeeds, that the canvas is a
texture rather than a buffer, and that consecutive frames are distinguishable.
What is unverified is everything that needs a display: that Slint's FemtoVG
wgpu renderer adopts the Manual configuration on a real surface, that the
picture appears the right way up and the right colour, and the frame timing
that motivated the whole exercise. Android is untouched by testing — the
android backend routes a WGPU29 request to Skia, whose wgpu surface does
handle imported textures, but that is read from the source, not observed.

56 dr-gpu tests and 255 dr-ui tests pass, clippy clean under `-D warnings`,
fmt clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:54:00 +02:00
dtourolleandClaude Opus 5 4b36ca66aa Render an export into the colour space its file will claim
The colour-managed export branch left one call site deliberately unfixed, and
this is it. `render_for_export` composed with the default sRGB shader, so a
Display P3 export failed with an accurate error rather than producing a
mislabelled file — the right way to leave a half-finished path, and no way to
leave it.

The space is chosen at render time because that is the only time it can be:
the conversion happens in the shader, before the clip to 0..1, so by the time
pixels reach an encoder they are in exactly one space and the only honest
thing left is to label them. `Frame::in_space` carries which, and a mismatch
between what was rendered and what was asked for stays a typed error.

Also regenerates the traceability matrix over the four merged branches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:34:57 +02:00
dtourolle 1c16ca3d27 Merge branch 'colour-managed-export' 2026-08-17 09:28:22 +02:00
dtourolle 8e330a24e9 Merge branch 'undo-redo' 2026-08-17 09:26:23 +02:00
dtourolle c26b6082a2 Merge branch 'local-libraries' 2026-08-17 09:26:03 +02:00
dtourolle 9f577ed6a2 Merge branch 'xtrans-demosaic' 2026-08-17 09:25:28 +02:00
dtourolleandClaude Opus 5 8ea92545df Stop the export folder forgetting itself, and let Android reach one
Three faults, compounding into an export that could not be made to work on a
tablet at all and a destination that appeared to reset on its own.

**Switching target destroyed the destination.** One field held both a
filesystem path and a remote folder, so changing the target had to clear it —
`/home/x/Exports` carried to the server would have offered to create a folder
called `home` at the library root. The consequence was that merely looking at
the other option threw away the destination already chosen, which reads,
correctly, as a setting that will not stick. There are two fields now. Each
target remembers where it was pointed and switching is free; `active_destination`
picks between them so no caller can reach for the wrong one.

**The library root read as "unset".** The picker opens at the root, so
confirming it where it opens stored an empty string — indistinguishable from
"ask each time" and looking exactly like the picker had done nothing. Empty
now means the library root for a server destination, which is a real folder
and the one the photographs are already in; it means "ask" only for a device
folder, where no path is worth assuming. The page labels it so.

**Android defaulted to a target it cannot use.** A device folder there means
the Storage Access Framework, which provides no filesystem path (ARCH §6.9)
and is not implemented — so the default target could never succeed however the
destination was filled in. The export button said "no export folder is set",
the settings page offered no way to choose one, and the only way out was to
guess that the other target was the working one. The device target is now
absent from `ExportTarget::available()` on Android and the default there is the
server, which needs no platform work at all. A settings file carrying an
unreachable target — copied from a desktop, say — is corrected on read rather
than left to fail at the last step.

Seven tests, each named for the fault it prevents returning. The compatibility
one matters most: a file written before `remote_destination` existed keeps its
device path and gains an empty remote one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:25:15 +02:00
dtourolleandClaude Opus 5 914d14ec0d Tell the shader which colour space it is encoding for
The generated shader ended with `encode_srgb` and a clamp, so every
photograph leaving DarkRoom had been through sRGB's gamut whatever the
settings page said. Export refused the other three spaces rather than
tag clipped pixels with a gamut they did not contain — correct, and
not something an encoder could fix.

So the output space becomes a parameter of composition. `compose_for`
emits a constant primaries matrix after the camera matrix and before
the clip, and generates the transfer function to match: the sRGB curve
for sRGB and Display P3, a pure 2.199 gamma for Adobe RGB, 1.8 with a
linear toe for ProPhoto. The ordering the camera matrix depends on is
untouched — operations still run in camera space — and sRGB emits no
conversion at all, so the shader compiled on nearly every frame is
byte-for-byte what it was.

The numbers live in dr-types, derived from four chromaticity pairs per
space rather than tabulated. That is not tidiness: the shader encodes
the pixels and the ICC profile describes them, and a file whose profile
disagrees with its own contents is worse than one with no profile. One
derivation makes them agree by construction, and can be checked against
the values the specifications publish.

Profiles are generated here too — minimal v2 matrix/TRC, about 2 KB,
pure Rust, no lcms to satisfy under the NDK. A JPEG carries it in APP2,
a PNG in iCCP, a TIFF in tag 34675. sRGB gets one as well, because
untagged does not mean sRGB, it means guess.

The refusal survives in a sharper form. A `Frame` now carries the space
it was rendered in, and export refuses to label it anything else. The
develop session still composes for sRGB, so a P3 export from the
interface fails with an accurate error instead of producing a file that
lies — the frontend half is a separate change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:04:04 +02:00
dtourolleandClaude Opus 5 7f524d2fd0 Give a mis-drag a way back
Develop edits now save themselves to a sidecar the moment you leave the
image, so until this there was no way to undo one — the mistake was
persisted and the only recourse was to remember the old number.

The history is a stack of snapshots, because the edit graph is already
plain data: `Preset::capture` reduces it to what differs from default and
`Preset::apply` puts it back, so undo is those two calls and nothing else.
A command object per action, with an inverse beside it, would have been a
second thing every operation had to register — and operations are declared
in YAML precisely so that a new one needs no code written for it. A
snapshot cannot fall behind them.

The interesting part is coalescing. A slider drag emits an event per frame
and must be one step, not forty. Nothing in the interface reports a gesture
boundary — the same wall the render coalescing hit, and it is answered the
same way rather than by threading a "finger is down" out of every slider,
curve point and crop handle. What stands in for the boundary is the control
plus recency: changes to the same control within 700 ms amend one step.
Which control is "the same" is asked of the graph, not listed: an operation
whose declared presentation claims a parameter is one where a single
gesture moves several — a curve point carries an x and a y — so those
coalesce as one widget. Nothing in the history names the tone curve.

The compromise, and it is a real one: a control let go of and picked up
again within the window is one step rather than two. Buying the other
answer costs a gesture-boundary signal on every control, which is more
surface than the difference is worth.

The stack is bounded at 64 states for NFR-RES-1 — a develop session stays
open for hours. Sixty-four rather than a byte cap: what is being bounded is
steps a photographer would want back, and a byte cap would give the
elaborate edit the shallowest history, which is exactly backwards.

The session owns its history and every mutator records into it, so the
callbacks in `lib.rs` cannot change the edit and forget to — with a dozen
generic callbacks that would have been one press of undo away from wrong
every time a control was added. Opening a photograph makes its stored edit
the floor rather than a step: it is not work done in this sitting, and an
undo reaching behind it would discard a previous session's edit and then
save that on the way out.

Not yet done, from FR-DEV-5: history is per-session and in memory, and
there are no named snapshots. What mattered was that a saved mis-drag had
no way back at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:03:34 +02:00
dtourolleandClaude Opus 5 bb71f141e7 Let a folder on this machine be scanned into the catalog
`dr_catalog::scan` has known since it was written what a changed directory
means — when to prune, when to list, and the one question that decides whether
a deletion sweep is safe. It was fully tested and nothing called it, because
walking a real directory "belongs to the platform layer" and the platform layer
was eleven lines re-exporting `secrets`. So every photograph in DarkRoom
arrived over WebDAV, and a user without a Nextcloud account saw nothing at all.

This is the missing half: a `Storage` trait, a filesystem implementation of it,
and the driver that pours one into the other.

The trait is shaped by the platform it does *not* yet support. Android's SAF
gives no filesystem path, which is why `SourceRef` exists; less obviously, it
gives no way to *compose* one either — a document id is opaque, and the only
way to learn a child's id is the children query that returned it. So a listing
hands back the reference to each entry rather than a name for the caller to
join onto a parent, and there is deliberately no "path + name" helper anywhere
above `LocalStorage`. That single restriction is what makes SAF a second
implementation rather than a second set of call sites. A reference is otherwise
an opaque `(RootId, key)` pair the catalog stores verbatim and rebuilds later,
which a persisted tree grant supports exactly as a relative path does.

A `Path` now appears in one place: `LocalStorage::grant`, where the folder the
user picked is handed in. Everything above it addresses a `RootId`.

`dr_catalog::walk` is the seam. It probes a directory, asks `scan` what that
means, lists only when told to, and reconciles what it found against the rows
it holds. Two things it does are worth saying out loud, because both are ways
to lose a library:

Absence only counts where absence was observed. A listed folder proves its
missing images are gone; a pruned one proves nothing about its contents, and a
scan that was cancelled or that failed part-way proves nothing about folders it
never reached. So the file sweep runs per listed folder, the folder sweep runs
once at the end and only after a complete scan, and a root that cannot be
reached at all marks its images offline and deletes nothing — FR-CAT-9's line
between proven-absent and merely-unreachable, which is the difference between
unplugging a drive and losing everything on it.

A trashed image is absent from its folder on purpose. It is exempt from both
sweeps, and detached from a folder about to be deleted rather than cascaded
away with it, or a soft delete would come undone the first time the folder it
came from was rescanned.

Two things the tests taught, both changes to what was there before:

Modification times are now milliseconds, not seconds. Change detection asks
whether a timestamp moved, so the unit's granularity is the width of the window
in which a change is invisible — and a second is long enough to copy a card and
start a scan. The test that caught it looked like a test bug; it was not. SAF
reports milliseconds natively, so this is also the unit that needs no
conversion on the platform with the coarser clock.

And an in-place rewrite of an existing file is invisible to directory-level
pruning, because writing to a file moves neither its directory's mtime nor its
entry count. That is a real limit, now documented and held by a test rather
than left to be discovered. It bites less than it reads: an export, a restore,
`mv`, and every editor that saves safely write beside the file and rename over
it, which does move both.

Narrowing the format filter no longer deletes what it stops matching, which
fell out of the same principle: unticking JPEG says stop looking for new ones,
not discard the hundred already rated. The files are sitting right there.

`DirState` and `DirEntry` move to `dr-types`. They are the sentence the
platform says to the catalog and both crates need the same one; `scan`
re-exports them so nothing that used them has changed.

Not done: the UI. The launch screen's "Open library" flow is account-shaped
from the first field to the thumbnail worker, and giving it a local branch is
its own piece of work rather than a button. `cargo run -p dr-catalog --example
scan_local -- ~/Pictures` scans a real folder and reports what it cost; run it
twice to see the second run list nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 08:59:17 +02:00
dtourolleandClaude Opus 5 1c0994c807 Demosaic a Fujifilm sensor instead of refusing it
Every RAF stopped at the embedded preview, because the demosaicer had
one kernel and it was a Bayer kernel. D11 makes Fujifilm first-class and
FR-RAW-5 asks for it by name, so a hard error there was a promise we had
not kept.

X-Trans is a 6x6 tile, and nothing in the Bayer path survives that: the
missing channels sit at different offsets at all 36 positions, so there
is no fixed kernel to write. The new shader fits a weighted plane
through each channel's samples in a 5x5 window and carries the other two
channels across as the difference between those planes, keeping the
pixel's own measured value untouched. A plane rather than a mean because
the three channels are sampled at different places in the tile: a mean
compares a red taken slightly left of the pixel with a green taken
slightly right of it, and that offset is a colour cast that follows
every gradient in the frame. The fit is done in white-balanced space,
where the constant-colour-difference model it rests on is actually true
of a neutral subject; that alone halves the error at a luminance edge.

Two compromises, both deliberate.

It is not Markesteijn. There are no directional hypotheses and no
homogeneity map, so it does not resolve detail finer than the CFA period
and a hard edge arrives about two pixels wide. It cannot ring — the
output is bounded by the local sample range — so it does not produce the
worms FR-RAW-5 exists to avoid, but the quality that requirement asks
for is still owed.

The tile's phase is guessed rather than known. rawler has each body's
pattern exactly, as a 36-character string, but CfaPattern::XTrans throws
it away before dr-gpu sees the file, and it is not a constant to
hard-code: the bodies in that database start the tile at four different
origins. So the phase is read back out of the pixels, by grouping the 36
per-position means and taking the grouping with the least spread. That
part needs nothing from the scene. Telling red from blue does — shifting
the tile by half a tile turns it into itself with red and blue swapped,
so no geometry can decide it — and the as-shot white balance is what
breaks the tie. A frame that is almost entirely one colour can defeat
that; widening dr-decode to carry the pattern string would retire the
guess altogether.

The tests assert reconstruction, not success: a flat patch comes back
exactly at all six phases tested, and a linear ramp comes back exactly
too, which is the property the plane fit exists for and the one a mean
would fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 08:46:28 +02:00
dtourolleandClaude Opus 5 2330ed25e9 Let a grouped slider be dragged, by flattening the panel that drew it
Build and test / Desktop (Linux) (push) Failing after 1h4m48s
Build and test / Layer separation (push) Successful in 34s
🐳 Android image / Build and push (push) Successful in 4s
Build and test / android-image (push) Successful in 4s
Traceability / Requirement traces (push) Failing after 1m4s
Build and test / Android (aarch64) (push) Failing after 9m43s
White balance, highlights and shadows, and the mixer took a press, jumped once,
and went dead under the finger. Exposure and contrast dragged perfectly — which
is what made it read as a slider bug rather than a layout one.

The panel nested. A group's head row drew the *whole* group, repeating over
`row.group-len` and indexing back into `root.rows`; every other row drew
nothing. So the inner repeater's model was read off the head row, and depended
on that row's identity. Moving any parameter in the group rewrites that row —
its own value changed, or `group-modified` flipped for its neighbours —
which re-evaluated the repeater, rebuilt its items, and destroyed the
`TouchArea` holding the live gesture. A lone parameter had no inner repeater,
so the five single-parameter operations were never affected.

Now each row draws only itself, so an update touches one control and nothing
structural. The facet heading comes off `starts-facet`, which Rust already
marks on the first row of a run, and the group heading is drawn by the row that
heads it. It also retires the old hazard of a head row building every control
in its group — thirty-six live TouchAreas behind the mixer's twelve visible
ones.

The same identity hazard reached the rows themselves through `ModelRc`, which
compares by identity rather than contents: a fresh empty model per row per call
made every row differ from itself, so `sync_rows` rewrote all of them on every
event. `points` now shares one empty model as `choices` already did.

Both are held by tests, because the failure is invisible in a still — every
value is right and the panel looks perfect. Curve rows are excluded: their
points model carries live coordinates, is rebuilt by design, and `sync_rows`
writes values through the existing model rather than swapping it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 07:23:01 +02:00
dtourolleandClaude Opus 5 40d4e439a9 Drop the parameter the offline sidecar writer never used
`write_one_sidecar` took an `Option<&NextcloudBackend>` it ignored. It was a
leftover from the shape the write path had before online and offline separated
into two functions: the offline one records to the cache and queues, and has no
server to talk to by definition. A parameter that is always `None` and always
unused says the opposite — that there is a case where it is `Some` — and the
next reader has to check.

The closure it was threaded through is renamed to say what it does rather than
how it is called. `run(None)` needed the reader to know what `None` meant;
`queue_all()` is the sentence.

Also regenerates the traceability matrix, which now records FR-CAT-9's queue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 07:17:31 +02:00
dtourolleandClaude Opus 5 cb1d2be240 Choose the export folder by walking the server, not by typing it
The destination for a Nextcloud export was a text field. Nobody recalls the
exact spelling of a path three levels down, and getting it wrong does not
fail — `create_dir` makes whatever was typed, so a misremembered folder
becomes a new one at the root and the exports are somewhere nobody looks.

So it is picked the way the library root is picked, using the same
`FolderBrowser` model the launch screen drives: up, into, and "use this
folder", confirming the folder currently *shown* rather than one selected in
the list. Same rule in both places, so the phrase means one thing.

The model is shared; the worker is not. `settings_ui::spawn_folder_list` is a
near-twin of the launch screen's, because that one reaches into the
`LaunchController` for its session and reports onto the launch screen's error
line, while this one is handed credentials and writes to the settings page.
Factoring them together needs a function taking both controllers or a trait
implemented twice to abstract two call sites — more machinery than the twenty
lines it saves. What matters is shared already: navigation behaves identically
because both drive the same model.

The callbacks are wired in `lib.rs` rather than in `settings_ui::wire`,
because listing a remote folder needs credentials and the settings page holds
no session on purpose — it is reachable before a library is opened and must
not depend on one existing. With no account the picker says to sign in first,
rather than showing an empty list that reads as a server with no folders.

Details that are decisions rather than accidents: the picker opens at the
library root rather than at whatever half-typed path is in the field, which
would list nothing and look broken. The listing area is a fixed 180px, since a
folder with sixty children would otherwise push the rest of the settings page
off the bottom. "Up" is disabled at the root rather than hidden, so the row
does not jump as the user navigates. A failed listing leaves the picker open
on the folder it was showing — where the user had got to is not something to
discard over a dropped request. And the chosen folder saves immediately like
every other setting on a page that has no Save button.

The poll timer lives on the controller for the reason `LaunchController` keeps
its own there: a `slint::Timer` stops when dropped, so one local to the
function that starts it would be collected before the listing arrived.

Carries in-flight work from a parallel session — a segmentation pass in
dr-gpu, a sidecar cache, and the develop panel's continuing changes.

1020 tests pass, fmt clean. One clippy warning remains and is not mine:
`sidecar_cache::dir` is unused while that work is in progress.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 07:12:01 +02:00
dtourolleandClaude Opus 5 e00c99b864 Let a photograph leave: an export button, and a cache to leave from
dr-export could turn a frame into bytes and nothing could ask it to. This is
the button, and the place the bytes go.

**Everything is staged first.** An export bound for the server is written to a
local outbox and uploaded afterwards; offline is not a special case, it is the
same path with a drain that finds the server absent. Doing it the other way —
upload directly, stage only on failure — makes the failure path the one that
is rarely exercised and always broken, and a network drop mid-batch leaves
some exports existing and some not with nothing recording which. Staged first,
an export is finished the moment it is written and the upload is a promise
kept later.

The outbox sits beside the catalog rather than under the cache. dr_catalog's
cache already draws that line: passive entries are a convenience and go under
LRU, pinned ones are a promise and never do. An export awaiting upload is a
promise — the user was told it succeeded — and sweeping it for disk would
destroy the only copy. Bytes are written before the destination record, so a
kill between the two leaves an orphan the drain ignores rather than a record
pointing at nothing.

The status line says "Queued for Exports/2026", never "Exported to Nextcloud",
until it has actually landed. There is a test asserting that wording, because
the tempting shorter sentence is a claim the app cannot keep.

The drain runs on the sync pass, before the shards: a thumbnail shard can be
rebuilt from the originals and the catalog is an index, but a queued export
exists nowhere else.

`DevelopSession::render_for_export` renders the framed size rather than reusing
the frame on screen, which is deliberately viewport-sized (FR-DSP-1) — encoding
that would hand the user a soft, screen-sized file with nothing to say anything
had been lost (FR-EXP-9).

One compromise, recorded rather than hidden: the export runs synchronously on
the UI thread, so the window is unresponsive for the few hundred milliseconds
a full-resolution render and encode takes. Moving a DevelopSession and its GPU
pass to a worker is a larger change than one button earns, and it is batch
export that makes the wait intolerable rather than merely noticeable.

Still missing: the Nextcloud folder *picker*. The destination is typed into
Settings for now. `FolderBrowser` in launch.rs is already the reusable model
for it — it browses a remote tree and nothing about it is specific to choosing
a library root — but wiring it into the settings page needs a listing worker
and browser UI there, which is its own piece of work.

Carries in-flight work from a parallel session — presets, the develop copy and
paste, and the node schema's `presentation` and `enum` support. One misplaced
callback in settings_ui.rs is moved from `render` to `wire`: registered in
`render` it borrowed a `&SettingsController` into a 'static closure and would
not compile, and that file's own docs say render pushes properties while wire
connects callbacks.

992 tests pass, clippy and fmt clean. Traceability 48.3% -> 51.0%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:58:17 +02:00
dtourolleandClaude Opus 5 23f0c4b76a Let a declared node ask for a widget, and write down how
Two gaps in the control vocabulary, both found by reading back what 0a331c7
actually shipped against what it claimed.

**`kind: enum` worked and was undocumented.** The whole point of that kind is
that a node author can declare a control without touching the interface, and
ops/README.md is where a node author looks. A kind absent from the table is one
nobody will use. It is now in the table, with the property that makes it cheap
spelled out: the value is the chosen index carried as an `f32` like every other
parameter, so a uniform expression can read it, the sidecar stores it
unchanged, and nothing along the way needed a second kind of value.

**A declared node could not ask for a widget at all.** `Presentation` gained an
ordered preference list and a demand, and `tone_curve` and `framing` use them —
but both are hand-written Rust. A YAML node had no way to say that several of
its parameters form one control, so the generated half of the pipeline was
locked out of the half of FR-DEV-3a that makes widgets extensible. Nodes now
take a `presentation:` block:

    presentation:
      widgets: [colour_wheel]
      demand: { two_dimensional: true }
      params: [hue, strength]

`demand` carries exactly two flags and there is deliberately no key for a pixel
width, a breakpoint or a platform name — those are the frontend's to decide,
and ARCH §4.3a is explicit that a core reasoning about them will eventually be
wrong about a display it never saw.

Widget names are spelled in the YAML the way `WidgetKind` spells them, so a
declaration and descriptor.rs cannot drift into two vocabularies for one idea,
and an unknown one is a build error listing the six that exist. `params:` is
checked against the node's own parameters: the widget claims what it names and
the generic path skips what was claimed, so a typo would silently drop a
parameter out of *both* — the one mistake here that produces no error and no
control.

Verified by declaring a presentation on a node, confirming the generated
`fn presentation` matched, and confirming both error paths report the file and
the key; then reverted, since no node in the chain wants a widget yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:54:10 +02:00
dtourolleandClaude Opus 5 5e4b8de18d Stop the mixer's bands from dividing one budget between them
Each band's delta was scaled by the summed weight of the bands that happened
to be adjusted:

    d_hue = d_hue / w_total;
    d_sat = d_sat / w_total;

The divisor is the wrong quantity, and it fails in two directions.

A band adjusted on its own divides by its own weight and cancels it — w*v/w is
v — so the falloff does nothing. Green saturation at +100 hit a pixel at 179°
exactly as hard as one at 120° and not at all at 181°: full strength across the
whole window, then a cliff. That seam is the thing the overlap exists to
prevent, and it was there the moment a single band was touched.

Worse, the divisor counts a band's weight even for a channel that band says
nothing about, so the controls compete. `red_hue` at +100 shifts a pure red
pixel +30°. Set `orange_sat` as well and the same pixel shifts +20°, while
orange's saturation bleeds into pure red at a third strength. Hue and
saturation on *neighbouring* colours were trading against each other — the two
sliders behaving as though only one of them could be spent.

The real fault is upstream of the division: the falloff window was ±60° while
the bands sit 30° apart, so the twelve weights sum to 2.0 rather than 1.0 and
*something* had to correct for it. Narrowing the window to the band spacing
makes them a partition of unity, and then nothing has to. The division is gone;
`w_total` stays, demoted to what it should always have been — the test for
whether any adjusted band reaches this pixel at all.

Everything the module header claimed is now true rather than aspirational: a
band reaches zero at its neighbours' centres, a hue halfway between two gets
half of each, twelve bands at +100 equals global +100, and a band pushed alone
reaches the full 30° of travel its own comment documents instead of whatever
fraction the other sliders left it.

`overlapping_weights_are_normalised` asserted the broken arithmetic verbatim,
so it is replaced rather than repaired. In its place: no delta may be divided
by w_total, the guard must survive, two adjacent bands must emit independent
terms, and — the property the rest now rests on — the twelve weights must sum
to one, swept at 0.1° around the wheel. That last test carries a Rust mirror of
`band_weight`, so a third test pins the mirror to the shader's own constants;
a copy nothing checks is how the window and the spacing drifted apart in the
first place.

Also gone: the red branch of `rgb_to_hcl` computed its hue twice and threw the
first away.

This changes how existing edits render. Mixer adjustments are more selective,
and where they were quietly cancelling each other they no longer are, so a
saved sidecar will not come back looking the same.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:36:52 +02:00
dtourolleandClaude Opus 5 69b12e327f Let framing say it wants the canvas, instead of the panel knowing
The generated panel opened with a special case:

    if op.id == dr_pipeline::framing::ID { continue; }

and a paragraph explaining that framing's eight parameters are eight bad
controls — four crop edges you would have to type coordinates into, a "rotate"
slider running 0..3, two switches — so `GeometryPanel` presents them as the
gestures they are instead.

Every word of that is true, and none of it was the frontend's to know. It is a
fact about the operation, and ARCH §4.3a is explicit that a frontend deciding
things by *naming a stage* is the boundary being crossed: a second frontend
would have had to learn the same special case, and nothing in the capability
output said why it existed.

Framing now declares a `Presentation` preferring `WidgetKind::CropOverlay`,
with a demand of two-dimensional dragging and — deliberately — no precise
pointing, since FR-UI-7 grows the handles to the modality and a crop is
forgiving. The widget owns all eight parameters rather than only the rect: a
frontend taking this on takes the whole framing control surface, and leaving
rotation and the flips behind would scatter them into the generated panel
underneath a crop control that already exists.

The panel's rule is now general. `supported` answers whether a kind is
implemented *anywhere* — drawn in the panel like the tone curve, or hosted on
the canvas like the crop — and `is_on_canvas` settles which afterwards, so the
two cannot disagree about the same kind. Any stage preferring an on-canvas
widget is skipped, with nothing named. A frontend that implements neither still
gets the eight sliders: tedious, complete, and the guarantee the whole hint
mechanism rests on.

**The tests were asserting against the wrong thing.** `rows_of` was a
hand-written simulation of the row generator, complete with its own copy of the
framing skip, so the suite was checking a second implementation kept in step by
hand. It was not in step: giving framing a presentation changed the real panel
and the simulation disagreed, which is exactly how a green suite hides a
regression. It now calls `rows_from`, and `rows_of_unfiltered` is gone.

`framing_is_not_generated_as_sliders` survives but asserts by routing rather
than by counting — no row may carry framing's capability index — so it cannot
be satisfied by two miscounts cancelling out. Alongside it, an invented stage
preferring a widget this frontend lacks, falling back to one the canvas hosts,
must also be skipped: if that ever needs a name added to pass, the special case
has grown back.

Not included: moving the crop overlay's markup out of app.slint into
controls.slint. It is cosmetic next to the above and app.slint is in another
session's working set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:29:23 +02:00
dtourolleandClaude Opus 5 0a331c717e Give the controls a vocabulary, and let a node ask for one
widgets.slint set the rule — screens consume components, and a bare `Theme.*`
at a call site means a component is missing — and it set it for chrome only.
The controls never got the same treatment, so they were written wherever they
were first needed and copied from there.

**The slider was private to the develop panel.** `SliderTrack`, with the
fifty-line preamble explaining how it wrests a drag away from a Flickable,
lived inside adjust.slint and no other screen could reach it. It shows: export
quality is a 1-to-100 value, and the settings page offered a free-text box for
it, with the range written in a hint and enforced nowhere. `to-float()` answers
0 for anything it cannot parse, so a typo saved a quality of 0 and the page
displayed the 0 back as though it had been asked for.

The tick-box was written twice, in launch.slint and settings.slint, from the
same 18px box and the same handler; the second carried a comment deferring the
lift until a third caller appeared. The label-and-hint header was written three
times inside settings.slint alone.

controls.slint is the input layer beside widgets.slint's chrome layer, and the
constraint that makes it reusable is that **nothing in it knows about
`ParamRow`** — that struct is the develop panel's flattening of the capability
model, and a control that imported it could only ever be used by the develop
panel. The primitives take plain numbers; the ParamRow-shaped wrappers stay in
the panel that owns the model. 658 lines came out of the three screens.

`SliderRow` is the slider-plus-number-box ARCH §4.3 names as the pointer
presentation of a bounded scalar, and quality is its first adopter. It commits
on gesture end rather than on every movement, because the settings page saves
to disk on change and a two-second drag is a couple of hundred writes where a
text field committed once. The develop panel keeps the live stream — that is
what its pipeline is for — so `SliderTrack` now reports both.

**The other half is the descriptor.** FR-DEV-3a and ARCH §4.3a already specify
more than was built: an ordered preference list of widgets rather than one, the
demands a widget makes, and kinds beyond scalar and bool.

- `Presentation.widgets` is now a list, walked by `choose`, falling back to
  plain sliders. Falling off the end is not an error, and there is a test
  asserting an operation asking only for an unimplemented widget still yields
  one control per parameter.
- `WidgetDemand` carries what a widget inherently needs — two-dimensional
  dragging, precise pointing — and no pixels, breakpoints or platform names.
- `WidgetKind` grows to the specified set. There is deliberately no `Colour`
  *kind*: a colour is three numbers, and a value type that is not an `f32`
  would reach through the graph, the uniform block and the sidecar format to
  buy what `ColourWheel` over three scalars already describes. Every widget
  here is a hint over ordinary scalars, which is what keeps the fallback
  honest.
- `ParamKind::Enum` is the one new shape, and it fits because a variant index
  is exact in binary32. `kind: enum` with a `variants:` list works in
  `ops/*.yaml`, so a node declaring one gets a segmented control with no UI
  file edited — which is the promise ops/mod.rs already makes.

The panel's dispatch was duplicated: a lone parameter and a grouped one each
wrote out their own list of kinds, so `enum` would have had to be added twice
and a kind added to one would appear or vanish depending on how many parameters
its operation happened to declare. `ParamControl` is now the only such chain.

`rows_from` is free-standing rather than a method, which is what lets the
FR-DEV-3c acceptance test requirements.md asks for actually be written: an
operation the frontend has never heard of, appearing in a generated panel, with
no GPU in sight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:21:35 +02:00
dtourolleandClaude Opus 5 e7130ff891 Give the library header somewhere to put six buttons
A tablet in portrait is 1920 physical pixels at density 400 — 768 logical,
which is below the 820 breakpoint, so the library was already taking the
compact layout. The compact header simply was not compact: one flat
HorizontalLayout holding a menu button, a title, three status readouts and
six buttons.

Slint's HorizontalLayout has no wrap and no overflow. Given less width than
its children want it shrinks each to its minimum and lets the rest run past
the edge, so "Change library" arrived as "Change li…", the readouts elided to
nothing useful, and the row overflowed anyway.

The six actions move into a `HeaderActions` component drawn in one of two
places: inline in the header when expanded, and in a disclosure row under it
behind a "More" button when compact. One component rather than two copies,
because the alternative is six buttons with their visibility rules and
callbacks written twice, and the copy that rots is the one behind a
disclosure nobody opens while testing.

The row closes itself when the window widens, so rotating to landscape does
not leave a stray toolbar behind. "More" is a word rather than an ellipsis
glyph: "⋯" sitting in a header of elided labels reads as one more truncation,
which is the failure this change exists to remove.

`LibraryGrid` takes `expanded` to decide, from the window width rather than
the device (FR-UI-1) — so a narrow desktop window gets the same treatment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:04:06 +02:00
dtourolleandClaude Opus 5 9e47133304 Run the formatter over the shard-naming change
Build and test / android-image (push) Canceled after 1m38s
Build and test / Android (aarch64) (push) Canceled after 0s
Build and test / Desktop (Linux) (push) Canceled after 1m34s
Build and test / Layer separation (push) Canceled after 0s
🐳 Android image / Build and push (push) Canceled after 1m38s
Traceability / Requirement traces (push) Successful in 1m2s
`cargo fmt --all -- --check` is a CI gate and 9a51cc8 landed two files past
it, so the build has been red on master since regardless of what came after.
Whitespace only — a wrapped `Ok(...)` and two `assert_eq!`s split across
lines. No logic is touched and the tests are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 22:29:28 +02:00
dtourolleandClaude Opus 5 151dcc3c02 Make a file out of a photograph
Export existed as a settings page and nothing else: format, quality, colour
space, five sizing modes, a filename template and a metadata switch, all
configurable in detail, and no way to produce a single file. dr-export is the
other half.

**It returns bytes and a name, and writes nothing.** An export has three
destinations with nothing in common — a path on Linux, a SAF document on
Android where there is no path at all (ARCH §6.9), and a PUT to a Nextcloud
folder — so a crate that opened the file itself would serve one of them and be
rewritten for the other two. The caller places the bytes.

Resize, then sharpen, then encode, in that order and for a reason: output
sharpening compensates for the softening the resample introduced, so its
strength scales with how much scaling actually happened, and sharpening before
shrinking would throw the result away. Lanczos-3, separable, with weights
computed once per output row — FR-EXP-4 asks for Lanczos or better because a
box filter turns a distant fence into moiré.

Collision handling takes the "is this name taken" test as a closure rather
than looking at a directory, because there is no directory it could look at
that works everywhere. That shape is not politeness toward Linux: Android's
createDocument renames on collision by itself and cannot overwrite at all, so
all three CollisionPolicy settings need the answer *before* anything is
created. Overwrite, Skip and Increment are each tested, and Increment gives up
after ten thousand rather than spinning against a destination that reports
everything as taken.

Three things are honest rather than done:

  - **Colour space.** sRGB only. The shader encodes and clips to sRGB before
    this crate sees a pixel, so tagging a file Display P3 would claim a gamut
    it does not contain. Refused with a typed error instead of mislabelled;
    honouring it is a pipeline change (FR-EXP-2).
  - **AVIF and JPEG XL.** No encoder. libaom and libjxl are C, ravif is slow
    enough to change what a batch feels like, and the settings page offers
    both because FR-EXP-1 lists them — so asking for one says so rather than
    writing a JPEG under a .avif name.
  - **16-bit TIFF** is a real 16-bit file carrying eight bits of information,
    because AdjustPass renders to Rgba8Unorm. Widened by *257, not <<8, so
    white lands on 65535 rather than a quarter-percent grey. Making it mean
    what it says needs the composer told what format to write.

Metadata is not written at all, which satisfies the half of FR-EXP-8 that
matters most: strip_location defaults to on, and a file with no EXIF block has
no GPS tag. Retaining camera and copyright when asked is not implemented and
cannot be faked by omission.

Also here:

  - `AdjustPass::export_pixels`, ungated where `read_output` is behind a
    feature. The two are the same transfer and opposites in intent: reading
    pixels back to *display* them is what ARCH §6.1 forbids and AC-8 asserts
    against, while reading them back to encode a JPEG is the only way a file
    has ever been made. Separate methods so the instrumentation can count one
    without counting the other.
  - `ExportTarget`, so a destination can be a folder on the server. On Android
    that is the only destination needing no platform work whatsoever — a PUT
    against create_dir, already on the RemoteBackend trait, behaving
    identically on both platforms. Switching target clears the destination,
    since a path is not a remote folder and carrying one across would offer to
    create a folder called `home` at the library root.

Verified end to end rather than by unit test alone: `cargo run -p dr-export
--example export` decodes a frame, runs the develop chain on the GPU at full
resolution, reads it back, and writes all five formats — 27 ms for a
full-size JPEG, 165 ms with a Lanczos reduction to 1200px. ImageMagick agrees
the 16-bit TIFF is 16-bit. dr-export cross-compiles clean for
aarch64-linux-android; all three encoders are pure Rust, which is why they
were chosen. 944 tests pass, clippy and fmt clean.

Not yet wired to a button. The develop view has no export action, so nothing
in the running app can reach any of this yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 22:26:37 +02:00
dtourolleandClaude Opus 5 d7aeafaf84 Move to wgpu 29, the version Slint can share a device with
Build and test / Desktop (Linux) (push) Failing after 38s
Build and test / Layer separation (push) Successful in 24s
Traceability / Requirement traces (push) Successful in 1m3s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Build and test / Android (aarch64) (push) Failing after 9m54s
Groundwork for spike S1. Importing a texture into a Slint scene requires it
to come from the *same* `wgpu::Device` Slint renders with, and Slint hands
out a device of the version it was compiled against. Slint 1.17 offers
`unstable-wgpu-28` and `unstable-wgpu-29` and nothing older, so wgpu 23 could
never have met it: two semver-incompatible wgpu crates in one tree are two
distinct types, and the device would not typecheck across the gap.

The version is therefore not a free choice, and the manifest now says so —
Slint and wgpu move together or not at all. The Slint requirement is also
corrected from "1.9" to the 1.17 it has actually been resolving to.

Nothing about the render path changes here. The readback bridge is still in
place and still the display path, so this is verified by the tests that
already existed rather than by anything new: 39 dr-gpu tests, which compare
real pixels off a real device, and 888 across the workspace, all passing.
Zero-copy lands separately and small.

What the six releases cost, in full:

  - `ImageCopyTexture`/`ImageCopyBuffer`/`ImageDataLayout` became the
    `TexelCopy*` names (24).
  - `Instance::new` takes the descriptor by value, and `InstanceDescriptor`
    lost its `Default` — it carries a boxed display handle now, so a headless
    context says `new_without_display_handle` and means it.
  - `request_adapter` returns `Result` rather than `Option` (24).
  - `DeviceDescriptor` absorbed the API trace from `request_device`'s second
    argument and gained `experimental_features` (25).
  - `PipelineLayoutDescriptor` takes `Option<&BindGroupLayout>` per slot, and
    `push_constant_ranges` became `immediate_size`.
  - `Maintain` became `PollType`, and `poll` is fallible.

Two of those are improvements worth having rather than churn. The error scope
is a guard whose `pop` runs on drop, so an early return from the pipeline
compiler no longer leaves a scope open on the device for whatever ran next to
fall into. And a fallible `poll` reports a lost device (NFR-R7) at the point
it happens, where before the map callback simply never arrived and the
failure surfaced later as a readback that spun out its poll limit.

Still to do for S1: dr-ui renders through `renderer-femtovg`, which is
OpenGL. Texture import needs Slint itself rendering on wgpu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 21:34:04 +02:00
dtourolleandClaude Opus 5 b08e94405c Give the activity row a name Slint will accept
`ActivityItem inherits VerticalLayout` declared `in property <ActivityRow>
row`, and every element that can sit in a GridLayout already carries a `row`
for its grid placement — so the declaration was an override of a built-in
rather than a new property, and the compiler refused it. dr-ui had not built
since.

Renamed to `job`, which is also the better word: the property is one
background job, and `row` described where it was drawn rather than what it
holds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 21:33:45 +02:00
dtourolleandClaude Opus 5 3b0950c39b Regenerate the matrix that dr-thumbs moved out from under
Commit 9a51cc8 edited core/dr-thumbs/src/lib.rs and pushed its TRACES tag
from line 341 to 376 without rerunning the extractor, so the two rows that
cite it — FR-CAT-15 and NFR-RES-4 — kept pointing at a line that is now
something else. Nothing else in the report changed.

The matrix records line numbers, so any edit above a tag dates it; the CI
step exists precisely because that staleness is invisible in review.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 21:19:51 +02:00
dtourolleandClaude Opus 5 9a51cc88d6 Give a shard's remote name the client that wrote it
Build and test / Desktop (Linux) (push) Failing after 50s
Build and test / Layer separation (push) Successful in 24s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Traceability / Requirement traces (push) Failing after 59s
Build and test / Android (aarch64) (push) Failing after 9m39s
Shard ids are per store: every client fills its own numbering from 0, so
"shard 3" names different thumbnails on every device. The derived sync
published them into a flat shard-NNNN.sqlite namespace anyway, which left
two clients writing one name.

Both failures that follow were live. On upload, a client's open shard
overwrote a peer's file of the same id — content the peer still believed
was published and would never restore, because its own copy was sealed and
the name existed. On download, the loop skipped any remote id it already
held locally, which is the only safe reading of a name that says nothing
about who wrote it, so a client holding shards 0..5 never fetched the
peer's 0..5 at all. Between them, two populated clients exchanged almost
nothing: only shards numbered above the other's highest. A fresh device
worked, having no local shards to collide with, which is why this went
unnoticed — it is exactly the case the feature was written for.

The name is now shard-<client>-NNNN.sqlite. The client id is minted per
store in index.sqlite, beside the numbering it qualifies rather than in
settings: a store deleted and rebuilt restarts at shard 0 and must not
claim the remote names its predecessor wrote. Since our own ids now say
nothing about what we have taken from others, index.sqlite also keeps a
ledger of adopted remote names and the size each had when merged. A size
rather than a flag, because a peer's sealed shard never returns but its
open one grows, and re-merging the grown copy is how the thumbnails it
gained since arrive.

Flat names already on servers still parse, reporting no owner, so each
client adopts them once, and nothing is written under that form again. One
whose id and byte size match a local shard is that client's own earlier
upload by the same identity argument the upload path already makes for
sealed shards, so the rename does not cost every client a re-download of
its whole store. Older builds ignore the new names and stop receiving
shards until updated; their own uploads are still adopted, so nothing is
lost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 21:15:12 +02:00
dtourolleandClaude Opus 5 f7e8cc99b1 Call them marks, not glyphs, now that they are drawn
The last comment left over from the icon change in 65e6a96, which moved
the star strip off Text and onto drawn paths. "Glyph" now names something
this file no longer has.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 21:14:58 +02:00
dtourolleandClaude Opus 5 7c57f490fe Declare a develop operation in YAML, and generate the rest
An operation was, in the overwhelming majority of cases, four facts: what
its parameters are, what uniforms they compute, what WGSL those uniforms
drive, and where it sits in the chain. Written in Rust those four facts
arrived wrapped in ninety lines of trait implementation — a match on
parameter id to a struct field, another match back, an is_active comparing
each field to its default, a Vec<Uniform> built by hand. All mechanical,
and each one a place to make a silent mistake: a param() arm returning the
wrong field reads perfectly and breaks the sidecar round-trip.

So the four facts are the file now. core/dr-pipeline/ops/<id>.yaml is a
node, build.rs compiles it into the same Operation impl as before, and the
result lands in OUT_DIR — the same reasoning as style.yaml -> theme.slint,
including why it does not land beside the sources it would look exactly
like. Nothing downstream can tell a declared node from a hand-written one:
same &'static OpDescriptor, same fused-shader composition, same sidecar.

Nine nodes moved: exposure, white_balance, contrast, highlights_shadows,
blacks_whites, brilliance, vibrance, saturation, and the shared WGSL
helper registry. Their prose came with them, and so did their tests —
set/expect/expect_active/expect_wgsl in the declaration compile to real
#[test]s, so a node file carries its own proof rather than leaving it
behind in a file that no longer exists.

Two stayed in Rust and say so with `rust:`. The tone curve's neutral is a
relationship between five interpolated points rather than a set of values;
the colour mixer generates thirty-six faceted parameters from twelve
computed hue bands. A schema stretched to cover either would be a worse
language than Rust aimed at one caller. They still declare their position
here, because the chain's *order* is the one thing a reader comes to this
directory to learn, and an order written half in YAML and half in Rust
would be worse than either alone. default_chain() is generated from it.

Uniforms are derived by a small expression language — exp2(exposure),
blacks / 100 * 0.02 — compiled to Rust rather than interpreted, so an
unknown name or a wrong arity is a build error naming the file and the key
and the arithmetic costs nothing at runtime. The build script refuses a
duplicate order, a filename disagreeing with its id, a default outside its
own range, a test value the graph would clamp before the node saw it, a
helper that does not define the function it names, and a declared node
colliding with a file in src/ops.

Verified by adding a scratch node and removing it again: one file, no
other edit, and it joined the chain at its declared order with its test
running. 237 tests pass in dr-pipeline, clippy and fmt clean.

.yaml joins the traceability tool's scanned suffixes, because a node's
Rust now lives in OUT_DIR where a tag could never be linked from the
report. Coverage 47.7% -> 48.3%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 21:08:16 +02:00
dtourolleandClaude Opus 5 65e6a96a65 Say what the application is doing, in one bar and one list
Every background job reported into a window property of its own —
library-thumbs-done, library-pin-total, library-syncing — which only the
grid ever read. A pin download that outlived the view it was started from
drew nothing at all once the user opened an image, and there was no answer
anywhere to "what is this busy with", because the answer was spread across
eight properties nothing collected.

They report to one register now (ui/dr-ui/src/activity.rs). It publishes an
aggregate, which draws a three-pixel bar across the top of the shell in
every view, and a row per job, which the settings page lists: scans,
thumbnail batches, pin and open downloads, sidecar uploads, the sync and
the trash. Failures stay on the list until they are cleared; routine
successes do not, or a scroll would bury them.

The handle removes a still-running job when it drops, so a worker that dies
mid-transfer takes its row with it rather than leaving the bar sweeping for
the rest of the session.

Also carries in-flight work from a parallel session — the drawn icon set
and the dr-pipeline ops split. dr-pipeline's build script does not compile
at this commit; ui/dr-ui does, with clippy clean and its tests passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 18:32:09 +02:00
dtourolleandClaude Opus 5 70435b712e Walk the grid with the arrow keys, and open with Return
Build and test / Desktop (Linux) (push) Successful in 17m36s
Build and test / Layer separation (push) Successful in 34s
Traceability / Requirement traces (push) Failing after 25s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Build and test / Android (aarch64) (push) Failing after 9m17s
A cull is thousands of decisions. The grid already bound 0-5, P, X and U so
the judgement itself needs no mouse, and then made the photographer reach
for one to move to the next frame — which is most of the gesture, most of
the time. Arrows now move a cursor, shift+arrow extends the selection,
Home/End and PageUp/PageDown cover ground, and Return opens what the cursor
is on.

The cursor is a library ordinal, not a row of the loaded window. The window
is a few screenfuls around wherever the user is looking, so a cursor held as
a row would stop at its edge or keep counting into cells belonging to other
photographs; walking out of the window reloads it around the new position,
exactly as scrolling does. This is the argument the selection already made
by keying on ids rather than rows.

The anchor had that fault for real: it was a window row, so a shift-click
after a scroll extended from whatever image had since drifted into it. It is
an ordinal now, and apply_press takes the window's offset to map between the
two. A range longer than the loaded window truncates to what is loaded,
since selection is by id and an image the catalog has not been asked for has
none.

The pointer and the keyboard share select_row so the two cannot drift apart.
The one deliberate difference: a plain arrow collapses the selection onto
the cursor, where a plain click on an already-selected cell leaves it alone
— that exception exists so a multi-image drag can start from one of its
members, and there is no drag behind a keystroke.

The grid scrolls to the cursor only when the cursor leaves the viewport, and
then by as little as will do it. Reusing the scrub's seek() would put the
cursor's row at the top on every press, which makes a row unreadable as you
walk along it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 16:25:13 +02:00
dtourolleandClaude Opus 5 9b2ee0d0eb Show the colour mixer as three runs of twelve, each row a colour
Build and test / Desktop (Linux) (push) Successful in 17m20s
Build and test / Layer separation (push) Successful in 33s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 2s
Traceability / Requirement traces (push) Failing after 26s
Build and test / Android (aarch64) (push) Failing after 8m59s
The mixer was thirty-six sliders reading "Hue / Sat / Lum" twelve times
over with nothing saying which band any row belonged to. The identity was
there all along — the descriptor declares param.mixer.orange.sat and BANDS
carries orange at 30° — and was discarded on the way out: labels.rs had no
mixer entries, so every key fell through to a derived label that yields the
bare channel name.

A parameter can now say which aspect it adjusts and which subject it
adjusts it on, with the subject's hue where the subject is a colour
(descriptor::Facet). That is data about what the operation does, not a
layout: the mixer genuinely weights pixels around 30°. What to draw from
30°, and in what order to stack the runs, stay in dr-ui (ARCH §4.3a) —
develop.rs brings rows sharing an aspect together and marks the first of
each, and adjust.slint names the run once and draws a swatch, a track and a
readout on one line.

Grouped by channel rather than by band because an edit is almost never
"everything about orange"; it is the saturation of the greens, made by
comparing one channel across neighbouring bands. Twelve band sections put
those twelve rows in twelve different places.

The swatch is the label, which is what makes twelve rows fit where four
did. The band name is not lost: it is the row's accessible label, so the
control is not colour-only, and labels.rs is where the mapping is written
down — including chartreuse as "Yellow-Green" and spring as "Blue-Green",
since nobody hunting foliage scans a list for "Spring".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 16:14:13 +02:00
dtourolleandClaude Opus 5 ab4a7e00e7 Leave the adjust panel somewhere to scroll from
🐳 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 19m8s
Build and test / Layer separation (push) Successful in 35s
Traceability / Requirement traces (push) Successful in 29s
Build and test / Android (aarch64) (push) Failing after 9m0s
A strip down the right-hand edge of the panel that no control reaches, so
there is always somewhere to put a thumb that means "scroll" and nothing
else.

This is the other half of the slider arbitration. A `SliderTrack` stands
the Flickable down the moment a finger touches it, which is what makes
dragging an adjustment reliable — and the price is that the track can no
longer be dragged past. What was left to scroll from was the ~20px band of
label between one control and the next, which on a panel that is mostly
tracks means aiming rather than reaching. Reserving the space outright is
the honest version of what had been left to chance.

Padding rather than a spacer element, and that is what makes it work: the
strip is inside the Flickable but no child is laid out into it, so nothing
puts a TouchArea over it. A press there reaches the Flickable directly,
with no arbitration to lose.

A full touch target wide (FR-UI-3). A gutter too narrow to hit confidently
would be the problem it was added to fix, in a smaller space. It costs the
tracks about 44px of a 280px column, which leaves travel enough that the
readout still moves a step per pixel at the precisions the descriptors ask
for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 15:54:57 +02:00
dtourolleandClaude Opus 5 67c0237ddd Make one slider, and take the lids off the develop column
Build and test / Desktop (Linux) (push) Successful in 20m28s
Build and test / Layer separation (push) Successful in 36s
Traceability / Requirement traces (push) Successful in 1m6s
🐳 Android image / Build and push (push) Successful in 13m45s
Build and test / android-image (push) Successful in 13m47s
Build and test / Android (aarch64) (push) Failing after 9m16s
Two complaints from a tablet, with one cause between them.

**Some sliders dragged and others only answered a tap.** They were not the
same control. `ParamSlider` read its geometry from a `ParamRow` for the
generated panel and `PlainSlider` took plain numbers for the straighten
angle, each with its own track, handle, hit area and gesture rules written
out separately — and a comment arguing the duplication was safe, because
"a slider that dragged differently depending on which panel it sat in
would be a worse inconsistency than the duplication".

That is exactly what happened. The touch arbitration fixed in the previous
commit went into `ParamSlider` and `CurveEditor`; `PlainSlider` kept the
old code, so two sliders in the same sidebar behaved differently and which
one you got depended on where you were dragging. The duplication failed to
survive its first change.

There is now one `SliderTrack`, owning the track, the hit area, the claim
test, the hover arbitration, click-to-jump and double-click reset. The two
wrappers differ only in where their numbers and labels come from.

**Nothing in the develop column collapses any more.** Every group was a
`Section` with a disclosure triangle, including five operations that carry
a single parameter — so the lid was most of the row, wrapping one slider
whose own label repeated the heading word for word. A control behind a lid
is one the user does not know the pipeline has.

`GroupHeading` keeps what the section was actually for: the name, the dot
that says something inside differs from its default, and the reset. The
reset is now permanently visible rather than appearing on hover, because a
hover-only control is one no finger can reach. IMAGE goes back to flat as
well. The column as a whole still closes, from the status strip — which is
the control that was wanted, at the level it makes sense at.

This costs no vertical space: `Section` defaulted to expanded and nothing
ever set it otherwise, so the panel was already unfolded and the lids were
overhead with no saving behind them.

Verified with cargo test -p dr-ui (192), clippy at -D warnings, and an
arm64-v8a release build installed on a tablet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 15:38:23 +02:00
dtourolleandClaude Opus 5 489465faf0 Show the photograph the way it was taken
Nothing read EXIF orientation, so every frame from a body held sideways
lay on its side — in the grid, in develop, and in the read-only preview.

The tag is honoured as part of *reading the file*, at the same standing
as a RAW's masked-photosite crop, never as an edit. It lives as a
baseline on Framing rather than as a starting value for quarter_turns,
which is what keeps four things true: a sideways file opens unmodified,
reset returns it to upright rather than to the sensor's scan order, its
sidecar stays empty, and the rotate button still moves the image 90°
whatever the file underneath it says.

Framing::effective composes the baseline with the user's own turns
through the group law rather than by adding turns and OR-ing flags. The
naive version gets one case wrong — an odd baseline turn plus a user
mirror — and gets it wrong quietly, because the result is still a
plausible orientation. The composition collapses to a single
permutation, so obeying the tag costs nothing per pixel.

dr_decode::orientation is a header-only IFD walk, separate from
metadata() for the reason the entry points are separate at all: the grid
asks once per cell and must not build a rawler decoder to get one tag.
CR3 and RAF fall back to the full read, being neither TIFF nor JPEG.

Written down as FR-DEV-3h.

Known gap: thumbnails cached before this stay sideways. The store is
keyed by file and size, and its shards sync — invalidating them would
have every client re-download 25 MB a shard, which is not this commit's
call to make.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 15:37:05 +02:00
dtourolleandClaude Opus 5 b044a8c067 Build the Android CI image in CI, not on a laptop
The Android job ran in gitea.tourolle.paris/dtourolle/darkroom-android:latest,
a tag that had never been pushed. The image existed only as a local
darkroom-android:latest on one machine, so every Android job died at docker
pull with "manifest unknown" before reaching a step. The registry API confirms
it: that manifest is a 404 while the other builder images answer 200.

android-image.yml now builds and pushes it, following KPN's docker.yaml — host
runner rather than a container, so it has the Docker daemon and the host's
cached registry credentials, and a plain-git checkout because that host has no
Node for actions/checkout.

Where it diverges from KPN: that workflow gates on dorny/paths-filter running
inside the builder image, which here would need the very image that is missing.
The tag is the git tree hash of docker/android instead, which changes when and
only when a file there changes. An unrelated push reuses the image, a Dockerfile
edit cannot keep serving a stale latest, and a missing tag rebuilds itself
without a manual step.

The presence probe is curl against the registry API, not `docker manifest
inspect`. The latter exits 1 on this registry even for tags that are plainly
there — jellytau-builder:latest answers HTTP 200 while docker reports "manifest
unknown" for it — and trusting it would have rebuilt 7 GB on every push. The
HEAD request also yields Docker-Content-Digest, so latest is repointed only when
the digests actually disagree, without pulling any layers.

A probe that cannot authenticate falls through to building. Rebuilding when it
was unnecessary costs minutes; skipping a build that was needed is the failure
this commit exists to remove.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 14:59:57 +02:00
dtourolleandClaude Opus 5 fe66eba87f Write down the trash requirement the code already implements
The traceability gate failed on an orphan tag: seventeen sites across
dr-catalog, dr-sync, dr-thumbs and the UI claim FR-CAT-15, and
requirements.md defines FR-CAT-1 through FR-CAT-14. Not a typo and not a
renumbering — the trash was built, designed and documented in the modules
that implement it, and the requirement itself was never written. An orphan
is the gate working: a tag naming an undefined ID would otherwise count as
covered, which is how a matrix comes to report coverage of things nobody
specified.

FR-CAT-15 now says what trash.rs does, in the terms the module already
argues: a soft delete moves the file into `.darkroom-trash/` and the
catalog records that it happened, because a flag alone would not survive
invariant 5.2.4 — the catalog is rebuildable from sources, so a rescan
would find every deleted file still in the library and re-index it. That
is also why the scanner exclusion is part of the requirement rather than
an implementation detail; the folder and the exclusion are one mechanism
and neither works alone. Permanent delete removes the file before the row,
a delete of something already gone counts as success, and the count and
bytes are shown before emptying.

docs/traceability.md is regenerated: 150 requirements, 71 covered, no
orphans.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 14:48:31 +02:00
dtourolleandClaude Opus 5 03326242a1 Make the CI checks say what they mean, and format the workspace
Build and test / Desktop (Linux) (push) Successful in 1h23m26s
Build and test / Android (aarch64) (push) Failing after 2s
Build and test / Layer separation (push) Successful in 50s
Traceability / Requirement traces (push) Failing after 1m9s
The Android job's "Verify minimum API level" step has never verified the
minimum API level. It took the first `*.so` anywhere under the target
directory, which is a host proc-macro from debug/deps — an x86-64 object
built by the runner's gcc, whose .comment section cannot mention Android
and so can never contradict the expected value. It now reads the artifact
under the target triple, compares against MIN_API parsed from the
Dockerfile rather than a second copy of the number, and fails on a
mismatch. Both sides are checked non-empty first: two failed parses would
otherwise compare equal and pass, which is the same silent success in a
new costume.

The Android image installs one SDK package per layer and keeps the
output. sdkmanager is a JVM program that aborts when it cannot get memory,
and the single `> /dev/null` step reported that as a bare "exit code 134"
while a retry re-downloaded everything that had already succeeded.

tools/ci-local.sh runs all four jobs — desktop, android, layering,
traceability — against the host toolchain, which is pinned to the same
1.92.0 CI installs. Its matrix check compares regeneration against the
working tree rather than against HEAD: CI starts from a clean checkout, so
git's answer is the right one there and reports every local run stale here.

The rest is rustfmt across the workspace, and the clippy findings that
surfaced once it did: manual_contains in dr-thumbs and collections_ui, a
map iterated as pairs for its keys, an index loop over a slice, and two
runtime assertions on a constant now made at compile time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 12:02:51 +02:00
dtourolleandClaude Opus 5 94a2686dcb Fit the interface to the system bars, the finger and the back key
Four faults that only show on a device, and one that was hiding on the
desktop too.

The system bars. Target SDK 36 forces edge-to-edge, so the window spans
the display and the develop status strip was drawn underneath the clock
and the wifi icons. Slint already computes the inset from Android's
OnApplyWindowInsetsListener and exposes it as Window.safe-area-insets;
nothing read it. The four views now sit inside a shell placed within the
safe area. Every inset is zero on the desktop, so that layout does not
move.

Sliders under a finger. A Flickable steals any gesture that drifts more
than 8 logical pixels along its scrolling axis within half a second of
the press, and it steals it by cancelling the child. ParamSlider's axis
test correctly declined to claim vertical drags, but nothing told the
Flickable to stand down once a drag was claimed — so an adjustment would
start moving and then be taken away mid-motion. A mouse holds a
horizontal line closely enough to stay under 8px; a finger does not,
which is why these worked on the desktop and not on the tablet. The
claim now sets `interactive: false` for the rest of the gesture.

The tone curve had the same fault and worse: its points are dragged
vertically, which is the Flickable's own axis, so every drag was stolen
— on the desktop as well.

The back gesture. Nothing handled it, so back closed the application
from anywhere in it. Android delivers it as Key.Back to the focused item
and bubbles it up the ancestors, which is the second reason the shell
wraps the views rather than sitting beside them. The order is innermost
first: settings, then crop, then zoom, then develop to the grid, then a
collection scope. Answering false at the top of the stack leaves Android
to close the activity, as it does for every other application there.
Escape does the same on a keyboard.

back_step is a pure function over a flat NavState so the ordering can be
tested without a backend: which of two states is left first is the whole
of the feature, and it is the part that is easy to get subtly wrong when
spelled out in nested ifs over live properties.

Develop's canvas now takes focus on show. Without it the arrow keys did
nothing until the canvas was clicked, and Key.Back had no focus item to
bubble from.

Panels that close. The collections sidebar and the develop column are
collapsible from the grid header and the status strip. The layout class
now supplies only the default: a panel closed to see more of a
photograph stays closed while the window keeps its shape, and the choice
is dropped when the class changes, because rotating a tablet asks a
different question from the one answered in landscape. IMAGE became a
Section, being the only group in the column that could not be put away
and the one whose content is read first and needed least.

Pinch to zoom on the develop canvas, anchored on the midpoint between
the fingers (FR-UI-4). The wheel is the desktop's answer and there is no
wheel on a tablet.

The develop status strip was 28px against the 44px headers on the
library and settings pages either side of it — the one screen where a
way out has to be found was the one drawn smallest. All three now agree.

Verified with cargo test -p dr-ui (192 passing, 6 new), clippy at
-D warnings, and an arm64-v8a release build packaged to an APK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 11:59:31 +02:00
dtourolle 4d78041d1d Many imorovments
Build and test / Desktop (Linux) (push) Failing after 1m8s
Build and test / Android (aarch64) (push) Failing after 2s
Build and test / Layer separation (push) Canceled after 23s
Traceability / Requirement traces (push) Failing after 59s
2026-08-12 22:16:15 +02:00
dtourolleandClaude Opus 5 fa12afed18 Keep originals on this device, by pin and by use
Build and test / Desktop (Linux) (push) Failing after 1s
Build and test / Android (aarch64) (push) Failing after 0s
Build and test / Layer separation (push) Failing after 1s
Traceability / Requirement traces (push) Failing after 2s
Fills in `image_cache`, which the previous commit's "On this device" filter
read but nothing wrote. Also carries in-flight work that shared these files:
the Android TLS root store, the settings page, and a regenerated
traceability report.

# Two populations, deliberately separate

An original is kept here for one of two reasons, and conflating them produces
the exact failure the feature exists to prevent.

**Pinned** originals were asked for. Pinning a collection before a trip is a
promise, so pinned rows are never evicted and never counted against the
budget — a cap that could silently delete a pinned trip would make pinning
worthless, because it could not be relied on without checking.

**Passively cached** originals are a side effect of working: develop already
downloads the whole file, so keeping it costs no bandwidth and saves the
entire transfer next time. This population is what the budget bounds, evicted
least-recently-used, because it otherwise grows until a day of culling fills
a disk.

Sharing one budget would let a large pin starve the passive cache, or let
browsing evict a pin. They are separate.

# What was built

`dr_catalog::cache` owns the bookkeeping — held tier, size, last use, pinned
— and writes the bytes; deciding to download stays with the caller, which is
what keeps a crate with no network out of the network's business. Files are
written to a temporary and renamed, so a dropped connection cannot leave a
truncated file recorded as a complete original. They are named by image id,
not filename: `Photos/IMG_0001.CR2` and `Trips/IMG_0001.CR2` are different
photographs, and a flat cache keyed on the name would serve one for the other.

`spawn_full_fetch` became read-through. A hit is a disk read; a miss stores
what it downloads and enforces the budget. A cache that cannot be opened is a
miss, not a failure to open the photograph.

Pinning writes intent — `tier_desired` — without downloading, so the button
responds immediately, and `spawn_pin_fetch` fills it in sequentially
afterwards. Sequential because these are tens of megabytes each: the lanes
that make the thumbnail sweep fast buy little against one connection's
bandwidth and cost a great deal of memory. A pin interrupted by a lost
connection resumes from where it stopped.

Schema v5 adds `pinned` and `path`. `pinned` is a column rather than something
inferred from `pinned_by_rule`, which is ON DELETE SET NULL and so cannot
answer for an image whose rule was deleted. A v4 catalog migrates in place;
existing rows default to unpinned, the safe direction.

The budget and "keep opened originals" come from the settings page rather than
a constant, and are applied at startup rather than only on change — a cache
capped at 2 GB last session would otherwise spend this one filling to the
default. Turning off keeping leaves what is already cached readable: those
bytes are paid for, and refusing them would re-download images sitting right
there, including pinned ones.

Also removes a doubled `#[test]` introduced in the previous commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:12:01 +02:00
dtourolleandClaude Opus 5 cd75e5a4c6 Run the library from local data when the server is unreachable
Also carries in-flight work that shared these files: the zoom structure-key
fix in the adjust pipeline, nearest-neighbour filtering past 1:1, the
timeline scrub marker correction, the 423-Locked retry in the metadata
sweep, and the thumbnail size-class migration.

# Offline mode (FR-CAT-9)

The app previously assumed the server was reachable and treated its absence
as a series of unrelated per-operation failures. A launch without a
connection produced an empty grid, even with a complete catalog on disk and
every thumbnail already in the shards.

Reachability is now inferred from traffic the app was already making, rather
than probed for. `RemoteError::indicates_offline` draws the line that makes
this possible: a dead connection is offline, a 403 or a 500 is not — the
server answered, so blanking the library over one forbidden file would be a
worse error than the one being reported. `Reachability` turns those outcomes
into a state, so a library browsing happily never issues a probe at all.

Going offline takes one failure, because the user is already experiencing it.
Coming back requires evidence — a completed scan or a fetched thumbnail —
with a capped exponential backoff behind the manual retry, so twelve sweep
lanes failing together do not schedule twelve immediate probes.

What keeps working: the catalog opens even when the scan that normally
provides it failed, so the grid fills from the last successful scan.
Thumbnails come from the shards. Rating, flagging and collecting are catalog
writes that never touched the network. What stops is opening an original that
was never stored locally, and it now says so in those words instead of
reporting "network error: connection refused" over a photograph.

Work that is pure network is refused rather than left to fail slowly: the
metadata sweep, derived sync, and sidecar writes. The sweep would otherwise
spend a timeout per image across the whole library while the progress bar
implied something was happening. Deferring sidecars is a real gap rather than
a hidden one — a rating made offline reaches its sidecar only when that image
is judged again while connected — and it is recorded as such at the call site.

# The "On this device" filter

A chip beside the rating filters, narrowing the grid to images whose original
is held locally. It composes with the rating terms rather than replacing them,
so "five-star frames I can actually edit on this train" is one filter. The
predicate is SQL, like the rating terms and for the same reason: the count in
the header has to agree with the cells drawn.

It reads `image_cache.tier_actual`, which nothing writes yet — the next
commit fills it. Until then the chip honestly reports zero.

`Tier` gains an explicit on-disk encoding. The variants are ordered by
generosity and the derived `Ord` invites reordering them, which would
silently reinterpret every cached row; the round-trip test is what holds the
two in agreement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:36:50 +02:00
dtourolleandClaude Opus 5 f6a100863e Place the timeline marker where the pointer actually is
Build and test / Desktop (Linux) (push) Failing after 6s
Build and test / Android (aarch64) (push) Failing after 0s
Build and test / Layer separation (push) Failing after 1s
Traceability / Requirement traces (push) Failing after 1s
The marker was drawn from a bucket index while a click reported a
fraction of the track. Those are different quantities: bars each occupy
one equal slot whatever span of time they cover, and the index snapped
to the containing bucket's start edge, so the marker landed at the top
of whichever slot held the instant — close enough to pass on a dense
uniform axis, plainly wrong on a sparse one, and never under the click.

Send the fraction instead, computed as the exact inverse of the
interpolation the scrub handler applies, and position the marker from
it. Marker and click are now the same quantity by construction.

Scrolling the grid also left the marker behind: only an explicit scrub
ever wrote the position, so the axis claimed to say "when you are" and
stopped being true the moment the wheel moved. Map the first visible row
back to a capture time and move the marker with it.

That runs on every scroll event rather than behind the window-reload
guard, which fires a few times per screenful and would make the marker
advance in jerks. Only the marker moves, not the bars: rebuilding those
means a GROUP BY aggregate over the library, far too much for a flick,
and they do not change as the grid scrolls anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:08:32 +02:00
dtourolleandClaude Opus 5 8b7c1e7f10 Open the sign-in URL through an ACTION_VIEW Intent on Android
The previous commit made the missing launcher honest; this gives Android a real
one, so Login Flow v2 can complete on device.

Builds `new Intent(ACTION_VIEW, Uri.parse(url))` and hands it to
`startActivity` over JNI. The JavaVM and Activity come from ndk_context, which
android-activity's glue populates at startup — the same handle Slint's backend
uses, so there is no second VM to reconcile.

The login worker is a plain std::thread and therefore unknown to the JVM, where
any JNI call would abort the process. jni 0.22 scopes attachment to a closure
rather than returning a guard, so the whole Intent is built and dispatched
inside `attach_current_thread` and the thread detaches on the way out.

Names use `jni_str!` and signatures `jni_sig!`, both compile-time: a typo is a
build error rather than a NoSuchMethodError on the device. A pending Java
exception is checked and cleared before returning, since leaving one pending
makes the next JNI call fail somewhere unrelated; in practice it means
ActivityNotFoundException, i.e. no browser installed.

jni is pinned to 0.22 to match Slint's Android backend.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 22:35:38 +02:00
dtourolleandClaude Opus 5 0876977133 Report a missing browser launcher instead of faking success
`open_in_browser` gated its xdg-open path on `target_os = "linux"`, which is
false on Android — that is its own target_os. Android therefore took the
fallback arm, which discarded the URL and returned `Ok(())`.

Login Flow v2 cannot complete without a browser: the user approves the
sign-in there and `auth::poll` waits for that approval. Claiming success meant
the UI showed "Approve the sign-in in your browser" with no browser open, the
poll waited for an approval that could never arrive, and the worker eventually
dropped its channel — surfacing as "sign-in failed unexpectedly", which
pointed at the network rather than at the real cause. The server had in fact
been contacted successfully.

Gate on `unix && !android && !macos` so the arm matches what xdg-open actually
implies, and return Unsupported from the fallback. The caller treats it as
terminal rather than swallowing it with `let _ =`.

Android gets a real Intent-based launcher in the next commit; until then the
failure is at least honest about what happened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 22:22:00 +02:00
dtourolleandClaude Opus 5 d49b4b41de Add thumbnail size classes and grid zoom; fix the scrub ordinal
The grid now zooms, which needs thumbnails at two resolutions rather than
one, and exposed a scrub that landed in the wrong place.

**Two thumbnail size classes.** `ThumbSize::Grid` (256px, ~10 KB) and
`Large` (1024px, ~45 KB), with the class part of the store key so both
coexist. Storing everything large would take the reference library from
~200 MB to ~860 MB, and shards sync, so that is transfer cost on every
device rather than only disk. A store written before the class existed
migrates in place: its entries are all grid-sized, which is what the
column defaults to, so nothing already fetched is discarded.

`forget` now drops every size for an image. Reading a single row left the
other class's bytes on the shard's tally for good, sealing it early on
space nothing occupied.

**Grid zoom.** Ctrl+wheel and pinch resize cells between 90px and 420px in
geometric steps, so the gesture feels the same at either end where a fixed
pixel step would be imperceptible at 400px and violent at 90px. Crossing
256px switches to the large class, so a zoomed cell is sharp rather than
upscaled. Columns and window capacity already derived from cell size, so
the grid reflows for free.

**The scrub landed about half a library too high.** It counted only dated
images while the grid shows all of them — 10,733 dated against 19,841
rows — and ignored `shadowed_by`. Verified against the live catalog: the
old formula gave 10,887, the new one 10,732, the true grid position
10,732. The scrub's count and the grid's window must use identical
predicates and ordering; a test now fails if they diverge.

**Timeline gestures are continuous.** Scrub and pan were quantised to
whole buckets, so a slow drag did nothing until it crossed a boundary and
then jumped a month. Both work in fractions of the visible span now, and
pinch-to-zoom arrives for tablet, where there is no wheel to reach the
axis with.

The pinch accumulator was wrong on first writing: it took at most one step
per update, so an 8x spread — three doublings — yielded one zoom level.
`log2().trunc()` now extracts every whole doubling and carries the
remainder. The original test asserted the wrong number and defended it in
a comment, which is worth remembering: a test can entrench a bug as
readily as catch one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 21:58:32 +02:00
dtourolle 75ce3846c3 Render draft frames while a gesture is still moving
The adjust pass and the readback both scale with pixel count, so a drag
paid full viewport cost on every frame it managed to produce. Halving
each edge while the gesture is moving is roughly a quarter of the work.

Detecting the gesture needed no new plumbing. No control reports a drag
boundary, and threading one out of every slider, curve point and crop
handle would be a lot of surface for what is a rendering concern. The
coalescing flag already carries the answer: a request that arrives while
a render is queued can only come from a control that moved again. A
click, a reset or a resize never coalesces, so those still render sharp
the first time and never show a draft frame.

A settle render restores full resolution 120 ms after the last change —
above the interval between events within a drag, so an ordinary gesture
never trips it mid-motion, and well under the point where waiting for
the sharp frame would be noticeable. Only one settle is ever queued.

The softness is therefore visible only while the image is moving too
fast to study.

dr-ui tests pass.
2026-08-09 21:56:44 +02:00
dtourolle 250ff1e327 Poll the adjust readback instead of parking the UI thread
`read_output` waited on the copy with `Maintain::Wait`, which parks the
calling thread until the GPU is done. That call is made from the UI
thread, so the interface was frozen for the length of the copy — the
note on this function measures it at ~7 ms at 4K against a 0.28 ms
compute pass, so nearly all of it was the wait.

`Maintain::Poll` drives the same callbacks without sleeping. The mapping
still completes and the pixels are identical; the thread simply is not
parked while it happens.

The poll loop is bounded. A lost device never delivers the map callback,
and spinning forever on that would hang the app rather than report the
error the caller already handles.

This does not remove the round-trip itself, which ARCH §6.1 forbids and
spike S1 replaces by importing the texture into Slint directly. It stops
the round-trip from blocking input until then.

dr-gpu tests pass, including those comparing readback pixels.
2026-08-09 21:52:59 +02:00
dtourolle 31dd86d8c0 Coalesce develop renders instead of rendering per input event
Every slider, curve-point and crop-handle drag ran a full render
straight from its `moved` handler. A render ends in a blocking GPU
readback, so that stall sat directly on the input path: touch events
arrive far faster than a render completes, the queue backed up, and
positions reached the handlers several samples stale — the jumpy
dragging.

The worse consequence was gestures being lost outright. When events go
unconsumed for long enough Android reclaims the stream and hands it to
the view underneath, so a drag stopped mid-gesture never received `up`,
only `cancel` — which every handler here treats as "abort the drag".

`redraw` no longer renders. It marks the canvas dirty and posts one
render onto the event loop, coalescing any further requests that arrive
while it is pending, and re-checks the flag afterwards so a value that
moved during the render is not left on a stale frame. Handlers now
return immediately, which is what keeps the gesture consumed.

A zero-delay `Timer` rather than `invoke_from_event_loop`: the latter
requires `Send` and this state is deliberately `Rc` on the UI thread.

All existing `redraw` call sites are unchanged — the signature is the
same and coalescing is internal.
2026-08-09 21:49:43 +02:00
dtourolleandClaude Opus 5 b4c1645c7a Address trash moves and deletes by path, not fileid
Trashing or emptying the trash failed on every scanned image with
"operation unsupported by this backend: fetch by fileid requires a
path".

RemoteId::Stable(oc:fileid) is an identity — it answers whether a file
is the same one after a move, and keys the thumbnail shards. It is not
an address: WebDAV exposes no fileid-addressable endpoint, so the
Nextcloud backend serves get/delete/move_to by path and rejects a bare
Stable. Both trash workers preferred the fileid whenever the catalog
knew one, so the unreachable Path fallback was the only arm that would
have worked, and the failure hit every properly-scanned image rather
than some edge case.

Address by path at both sites, and keep the fileid for what it is for:
the identity MOVE preserves, and the key the thumbnail cleanup uses.

Move::file_id was documented as "the id the MOVE addresses", which is
the wrong claim that seeded this; corrected, along with a note on
RemoteId itself so the distinction is stated where the type is defined.

Only the backend rejection was covered by a test. Added the positive
case, since that contract is what the call sites now depend on. The
workers build their own NextcloudBackend, so no test can reach the
call sites directly — closing that would mean injecting the backend,
which is left alone here.

Verified by build and test; not exercised against a live server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 21:39:35 +02:00
dtourolle 9a24623e35 Fix the workspace build off-device
Two breaks that only appeared on a full `cargo test --workspace`.

`slint::android` exists only when compiling for Android, so darkroom-android
failed to compile on the host even though it is a workspace member. The entry
point is now gated on the target rather than on a feature.

The timeline forwarded `scrub` where the Timeline component declares
`scrub-to`, which the Slint compiler rejects.

Assisted-by: LLM
2026-08-09 21:15:53 +02:00
dtourolle 2a7a319d6c Depend on slint directly in the Android app
android_main takes an AndroidApp and calls slint::android::init, both of
which come from slint itself rather than from dr-ui. The backend feature
still arrives through dr-ui's target-specific dependency. anyhow was unused.

Assisted-by: LLM
2026-08-09 21:12:56 +02:00
dtourolle 8ad5c86ff9 Add the library, collections, and trash views; theme from style.yaml
The UI gains the views the catalog work was building toward: a windowed
library grid with ratings and flags, the collection tree with drag-to-add,
and trash with restore. derived_sync pushes thumbnail shards and the catalog
snapshot to the server's derived folder.

Tokens now have one source of truth. build.rs reads style.yaml and generates
theme.slint into OUT_DIR, which answers every existing
`import { Theme } from "theme.slint"` unchanged, because Slint resolves
imports against the importing file's directory first and the include paths
after. Generating into OUT_DIR rather than beside the hand-written Slint is
the point: a generated file sitting in ui/ looks exactly like the files
around it that are meant to be edited, and an edit to it would survive until
the next touch of style.yaml — a bug that hides for weeks. build.rs fails
loudly if a stale ui/theme.slint exists, which would otherwise shadow the
generated one silently and make every palette change vanish with no error.

The palette moves to near-neutral dark with achromatic signalling, so the
accent means "modified" or "active" rather than "heading". Shared components
land in widgets.slint: a token that binds several values into one concept is
a component, not a row in a YAML file.

Adds an optional live-style feature that makes the tokens in-out so they can
be written at startup — a feature rather than the default because it stops
the properties being constant-folded.

serde_norway is the YAML crate: serde_yaml and serde_yml are both deprecated,
and its mappings preserve insertion order, which is what lets the generated
Slint keep the token ordering the author chose.

Assisted-by: LLM
2026-08-09 21:11:38 +02:00
dtourolle 7900184383 Point the Android Java build at the installed SDK jar
ANDROID_PLATFORM means "link native code for API 28" to cargo-ndk, but the
android-build crate reads the same variable as "compile Java against
platforms/android-28/android.jar" — a directory that does not exist in the
image, because only the compile SDK is installed. Slint's Android backend
builds a Java helper through that crate, so it panicked with "No Android
platforms found" while android.jar sat in android-36.

ANDROID_JAR is checked ahead of the platform lookup and settles it: Java
compiles against the compile SDK, native code still links against MIN_API.
The two are meant to differ; only the variable name is overloaded.

Assisted-by: LLM
2026-08-09 21:11:10 +02:00
dtourolle 5dc1279429 Add the Android app shell; cap cross-build parallelism
Cap both halves of the container build: CARGO_BUILD_JOBS limits how many
rustc processes cargo starts, while --cpus limits what the container gets
regardless of what nested build scripts spawn — cc, cmake, and ring's asm
build all parallelise on their own account and do not consult cargo. Without
both, a full cross-compile takes every thread on the host and makes the
machine unusable for the length of a background build.

Assisted-by: LLM
2026-08-09 21:10:00 +02:00
dtourolle 170252cfc9 Locate embedded previews by parsing the container header
Remote browsing must not transfer whole RAW files. The obvious shortcut —
fetch a fixed prefix and hope the preview is inside — does not work: an
embedded JPEG typically starts a few hundred KB in and runs for one to three
MB, so a truncated fetch yields a JPEG whose scanlines stop partway down.
Decoders render what they have rather than erroring, so the failure looks
like a corrupt image rather than a short read.

This reads the TIFF-structured containers — CR2, NEF, ARW, DNG, ORF — and
returns an exact byte range for a Range: request. CR3 is ISO-BMFF and
declines to the caller's whole-file path, which is correct if slower; a
locator returning a wrong range would be far worse than one that declines.

Every offset read from the file is bounds-checked against the real file
length rather than trusted (NFR-SEC-1).

Assisted-by: LLM
2026-08-09 21:09:47 +02:00
dtourolle 9f5e9955d5 Add sidecar serialisation for the edit graph
Sidecars are the only thing in the trust path: the catalog can be rebuilt
from them, so they carry the edit graph and its version in a form that
survives a schema change.

Assisted-by: LLM
2026-08-09 21:09:31 +02:00
dtourolle 5786977a51 Develop a JPEG through the same pipeline as a RAW
DemosaicedImage gains a second producer, from_rgba8, alongside the CFA path.
Nothing about the type is CFA-specific — it is "an image on the GPU, ready to
adjust" — which is what lets develop mode work on a JPEG without the edit
graph or any operation knowing the source was not a RAW file.

The one real difference is the transfer function: sensor data is linear, a
JPEG is gamma-encoded. Every operation assumes linear scene-referred colour
(exposure is a multiply, and doubling a gamma-encoded value is not a stop), so
the shader prologue linearises once, at the only point where the two source
kinds still differ. The flag rides in as_shot_wb.w, which was padding. For a
JPEG the white balance uniform is neutral and the colour matrix is identity,
so both stay unconditional multiplies rather than becoming branches.

max_dimension is exposed because it is a hardware limit the caller must plan
around, not a failure to report afterwards: a 13728x8928 film scan exceeds the
common 8192 texture limit, and fitting it first is the only way to develop it
at all.

Assisted-by: LLM
2026-08-09 21:07:20 +02:00
dtourolle 050dcff5bb Add viewport zoom to the framing
A view rect that composes with the crop in the same normalised space:
nesting one rect in the other is a multiply, so the shader needs no second
rect and no extra uniform slot.

Zoom is explicitly not an edit. It is excluded from is_active, from the
structure hash, and from the sidecar, so a zoomed view exports exactly as an
unzoomed one does. is_neutral now asks the framing whether it *edits* rather
than whether it is active — otherwise merely zooming would mark a clean file
dirty.

Because the render target keeps its size while the sampled region shrinks,
zooming raises the resolution the pipeline works at rather than magnifying
already-rendered pixels, which is what makes 1:1 inspection show real detail.

Assisted-by: LLM
2026-08-09 20:55:57 +02:00
dtourolle 81e89de7ea Add remote move and mkdir; distinguish 403 from 401
Soft delete needs to move a photograph into the trash folder and back, and
the stable id must survive the trip. WebDAV MOVE is one request and preserves
oc:fileid; a copy-then-delete would allocate a new one, orphaning the
thumbnail shard entry and the sidecar mapping and turning a restore into a
full re-download. Overwrite: F, because a header that permits overwriting is
one that eventually does.

create_dir does MKCOL outermost-first and treats 405 — Nextcloud's answer for
an existing collection — as the goal state rather than an error. Nothing else
creates the trash folder, so without it the first trashed image of every
library fails with a 409 that reads like a permission problem.

PermissionDenied is now separate from AuthFailed. Folding 403 into 401 sent a
user to re-check a credential that was working perfectly, with reads
succeeding and only the write refused (observed against a real server). The
usual cause is an app password created without "Allow filesystem access" —
which signing in again will not fix.

Assisted-by: LLM
2026-08-09 20:55:04 +02:00
dtourolle 57f8d42a5c Record directory ETags only after actually listing them
The scan recorded a directory's ETag when it was *discovered* as a child of
another, not when its own contents were read. A scan that stopped early
therefore stored ETags for subtrees it had never listed; the next scan probed
those ETags, found them unchanged, and pruned folders whose contents had
never been seen. Their images never entered the catalog at all, and no later
scan would ever look again.

Each stack entry now carries the validator its parent reported, recorded only
once the directory has been listed. An interrupted scan re-reads that folder
next time — slower, and correct.

Two related fixes fall out. The scan root is now probed and recorded even
though no parent described it, without which the one-request no-op sync that
ETag pruning exists for could never fire at the root. And a pruned directory
keeps its recorded entry, rather than dropping out and forcing a full walk of
that subtree on the following scan.

Assisted-by: LLM
2026-08-09 20:42:39 +02:00
dtourolle d5b1f6bff5 Add collections, ratings, and soft delete to the catalog
Three features over a shared schema migration.

Collections: a tree of manual collections plus smart collections whose
membership *is* their stored selector. Dropping images onto a smart
collection is refused rather than silently discarded, so the UI can say why
the drop did nothing — member rows there would be a second source of truth
that nothing reads.

Ratings: the star and pick/reject axes, kept independent.

Trash: soft delete to a folder, then permanent delete.

Catalog::open now backfills after migrating. A migration adds a column but
cannot know what the value should be for rows that already existed;
backfilling on open is what stops those rows being silently partial.

Timeline queries exclude shadowed JPEGs, which would otherwise double every
paired shot in the histogram, and gain a range-bounded variant so zooming in
returns finer buckets rather than the same coarse ones with the ends cropped.

Assisted-by: LLM
2026-08-09 20:42:13 +02:00
dtourolle d6ddd0703b Add dr-thumbs: a sharded, syncable thumbnail store
A thumbnail is the one derived artefact worth sending over the wire: it costs
a range fetch plus a decode to produce and is identical for every client
looking at the same file. A second device that downloads a shard gets a full
grid without fetching a byte of RAW.

Sharded at 25 MB, filled sequentially. The cap is about sync granularity, not
SQLite's limits — one growing database means every client re-downloads it
whenever a single thumbnail is added, whereas with sequential fill only the
newest shard is ever dirty and sealed shards are safe to cache forever.

Stored JPEG-encoded rather than as raw RGBA: a 256px RGBA buffer is ~256 KB
against ~20 KB encoded, and that 13x is transfer cost on every client.

Keyed on Nextcloud's oc:fileid, stable across server-side rename and move.

Assisted-by: LLM
2026-08-09 20:38:12 +02:00
dtourolle b6a5c0f090 Make selectors serialisable
A smart collection stores its selector as JSON in collections.selector_json,
and cache rules carry one across a sync, so the predicate language has to
round-trip. Derive-only: no serde machinery leaks into the rest of core/.

FlagState gains Default = Unflagged, which is what an image is before anyone
has looked at it. Any other default would assert a judgement the
photographer never made.

Assisted-by: LLM
2026-08-09 20:37:02 +02:00
dtourolleandClaude Opus 5 02b66ddfcf Document trash, collections, thumbnails, and the UI direction
Specs for the work that follows: soft delete via a MOVE that preserves the
remote id, the collection tree and smart collections, the thumbnail store,
and the derived-state folder.

Adds two design documents. ui-refinement.md names the structural gaps
between the v0.1 UI and something that feels like a photo editor.
view-composition.md proposes a view controller for the display layer,
against the 500-line run() that has become one by accretion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 20:36:23 +02:00
dtourolle 5365123d92 Fix the folder picker stuck on "Loading…"
The picker set loading=true and nothing ever cleared it, because
browser_loaded() was never called — my earlier edit to replace the stub
silently failed to apply after cargo fmt reindented the code it matched
against. The stale stub was still writing folder names into the status
line, which is the list visible under the selector.

Verified by grep before and after: browser_loaded had zero call sites,
now two.

Two related defects fixed while in there. Both timers polled their
channel with `if let Ok(..)`, so a worker thread that died without
sending left the screen on "Loading…" or "Connecting…" forever. Both now
treat Disconnected as terminal.

The first attempt at that fix introduced a bug of its own: a separate
probe call consumed a pending message and discarded it, so a successful
login would have vanished. The login poller now drains in one loop with
disconnection as a match arm, and only reports failure when the flow had
not already completed.

Lesson worth recording: verify a replacement applied rather than trusting
the edit succeeded. A silently-skipped replace looks identical to a
successful one until the feature is used.

43 tests in dr-ui.
2026-08-09 16:01:48 +02:00
dtourolle 2ca1716a29 Make "Choose folder" an actual folder picker
It previously fetched the folder list and threw it away into a status
line — a button that looked like it worked and did not. Now it opens a
browsable picker: click a folder to descend, ".." to go back, "Use this
folder" to select, "Cancel" to leave the root unchanged.

Descends one level per click because that is what the backend supports:
Depth: infinity is frequently disabled server-side and prohibitively
expensive where it is not (ARCH §8.4).

The chosen root persists immediately on confirm, so it survives a crash
before the library is opened. Confirming at the account root is allowed —
a user may legitimately keep everything at the top level — and cancelling
leaves any previous selection untouched, which a test asserts.

Verified against nextcloud.tourolle.paris at both depths: 30 folders at
the root, 21 year-folders inside PhotosRaw.

19 launch tests, 38 in dr-ui.
2026-08-09 15:50:40 +02:00
dtourolle f630a3ff81 Wire the launch screen into the app
The app now opens on the login screen when there is nothing else to show
— no local paths and no configured library — and goes straight to the
images otherwise. Making someone click past a login they already
completed is pure friction.

  launch.slint       imported by app.slint, replacing the window rather
                     than overlaying it: there is no library to look at
                     until an account is configured
  launch_ui.rs       the Slint wiring, kept out of lib.rs so the launch
                     flow can change without touching the develop window

Login runs on a worker thread and posts results back through a channel,
since Slint's event loop is single-threaded and a 20-minute browser wait
cannot block it. The system browser is opened via xdg-open, never an
embedded webview (FR-NC-1).

Sign-out deletes the local credential even if server-side revocation
fails: a network error must not leave a usable secret on the machine.
Format tick-boxes persist on each toggle, so a selection survives a
crash before the library is opened.

Two things deliberately incomplete rather than faked:

  - "Choose folder" lists the account's folders and reports them, but
    there is no picker widget yet, so selection still happens via the
    connect example.
  - "Open library" logs the request. Opening a remote library needs the
    scan-and-cache path, which belongs with the catalog work in flight.

Earlier I broke the other in-flight dr-ui work by calling
slint_build::compile twice, which replaces the generated module. The
correct wiring is an import inside app.slint, which is what this does.

30 dr-ui tests passing; both launch paths verified by running the app.
2026-08-09 15:34:46 +02:00
dtourolle 09e3043f4c Add secure credential storage, sessions, and a launch screen
Login now persists properly rather than through the JSON file the test
harness was using.

  dr-plat            SecretStore trait plus a Secret Service backend.
                     Verified against the live GNOME Keyring: store,
                     retrieve, delete, confirm-gone all round-trip.
  Session/SessionStore   splits credentials from settings — the app
                     password goes to the keyring (FR-NC-2), while
                     server, login, chosen root and format selection are
                     ordinary config. A test asserts the credential never
                     appears in the config file.
  LaunchModel        the launch-screen state machine, testable without a
                     display server: sign in, approve in browser, choose
                     folder, tick formats, sign out.
  launch.slint       the screen itself, in its own file.

Absence of a secrets daemon is an explicit degraded mode, not a silent
fallback to plaintext — the screen says sign-in will not persist rather
than letting the user find out next launch. Android's Keystore backend
fails loudly for the same reason: a no-op store would look like it
worked and then lose the credential.

Two bugs caught by tests rather than by running it:

  - fail() after busy() signed the user out, because busy() had already
    discarded the session. A failed *scan* would have logged you out.
    Busy now carries the session.
  - normalise_server upgrades http:// to https:// rather than accepting
    it. NFR-SEC-3 requires TLS, and silently sending a credential in the
    clear is not a decision to make on the user's behalf.

launch.slint is not yet wired into app.slint. Calling slint_build::compile
twice replaces the generated module rather than adding to it, which broke
the other in-flight work on dr-ui; I reverted that immediately. Wiring it
needs an import inside app.slint, which is that work's file to change.

419 tests passing across ten crates.
2026-08-09 15:20:39 +02:00
dtourolle c8bb08e661 Add folder scan with format selection; validate A3 on a real library
Library setup as the user described it: pick a folder, choose which RAW
types to look for, scan recursively.

  dr-types::FormatFilter  the tick-box selection, seeing through VFS
                          placeholder suffixes so a dehydrated CR2 still
                          matches as a CR2
  dr-sync::scan           recursive walk, Depth:1 per directory, pruning
                          unchanged subtrees where the backend propagates
                          directory ETags

Verified against nextcloud.tourolle.paris (34.0.2) on a real library:

  browse root      32 entries, 98ms
  scan PhotosRaw   17,185 RAW files in 334 directories, 34.1s
                   (7,836 CR2 + 9,349 DNG)
  range read       262KB of a 21.5MB DNG in 119ms — 1.22% of the file,
                   and enough to read "Canon EOS 6D | ISO 100"

That last line is assumption A3 validated on real data. Cataloguing this
library by whole-file fetch would move roughly 370GB; the range path
moves a few MB.

Pruning is capability-gated rather than assumed: with per-entry ETags a
probe costs a request and proves nothing about children, so it is skipped
entirely. A test asserts zero probes in that case.

Still unresolved: /core/preview returns 400 for every parameter
combination tried, including on a JPEG the server reports as having a
preview. Not a request-shape bug — it fails identically bare. Recorded
rather than worked around; ARCH §6.7 already treats server previews as
opportunistic, so nothing depends on it.
2026-08-09 12:22:31 +02:00
dtourolle fbadf9afc8 Add live-server harness; centralise the rustls provider install
Adds a read-only example that exercises the connector against a real
Nextcloud: Login Flow v2, PROPFIND listing, the Depth:0 ETag pruning
probe, a 256KB range GET, and a server preview request. No PUT, MOVE or
DELETE, so it cannot alter a live library.

Running it against nextcloud.tourolle.paris (34.0.2) surfaced a real API
flaw rather than an example bug. The crypto provider was installed in
NextcloudBackend::new, but authentication necessarily runs *before* a
backend exists — so any caller following the documented flow panicked on
the first client build. Every entry point now goes through
`http_client()`, which installs the provider first, and auth gains
`begin_default()` for callers with no client yet. A test builds a client
without a backend to keep the regression out.

Also populates RawImage::crop from rawler's crop_area/active_area and
re-phases the CFA pattern when the crop origin is odd — cropping to the
active area without that swaps red and blue.

Login Flow v2 confirmed working against the live server: the flow URL is
issued and the poll endpoint responds. The remaining checks need a
browser approval, so they run interactively.

88 tests passing.
2026-08-09 11:41:17 +02:00
dtourolleandClaude Opus 5 78e3e6b846 Add the develop pipeline: demosaic and seven raw adjustments
Decode through display, on the GPU: black/white normalisation, Bayer
demosaic, camera colour transform, and the first seven adjustment
operations — white balance, exposure, highlights/shadows, blacks/whites,
brilliance, vibrance, saturation.

Composable shaders. Each operation contributes a WGSL fragment rather
than owning a pass, and dr-pipeline fuses the *active* ones into a single
compute shader. One texture read and one write per frame regardless of
how many adjustments are in play, while the operations stay independent
in Rust — adding one is a new file, with no central shader to edit. An
operation at neutral settings contributes no code, no uniform and no
branch. Uniforms are prefixed per operation so two may both declare
`amount`; helpers dedupe by name from a single source of truth.

Pipelines cache on a structure hash covering the op-set and its order but
not the values, so dragging a slider uploads uniforms and reuses the
compiled pipeline. Measured on a 24 MP CR2: 0.60 ms re-render, one
pipeline compiled across ten slider positions.

The UI is generated, not written. EditGraph::capabilities() reports
parameters with their kinds, ranges, defaults and current values; the
panel builds one control per entry chosen by ParamKind. No file in ui/
names an operation, and dr-pipeline has no wgpu dependency, so codegen is
testable without a device (ARCH §6.5a).

Three defects found against real files, each silent:

- rawler 0.7.2's `xyz_to_cam` is all zeros — deprecated and no longer
  populated. The live matrices are in `color_matrix`, keyed by
  illuminant. Reading the old field yields no colour transform at all.
- `cam_to_xyz_normalized()` returns all NaN on any Bayer sensor: it
  divides each of four rows by its own sum, and the unused fourth
  (emerald) row sums to zero. Inverting the 3x3 ourselves avoids it.
  `wb_coeffs[3]` is NaN for the same reason and is normalised at decode.
- As-shot white balance reached the uniform block but no shader read it,
  so the first render of a real CR2 came out violently green. Green
  photosites collect roughly twice the signal of red and blue. Now
  applied unconditionally before any operation, with tests on ordering.

Demosaic is Malvar-He-Cutler rather than bilinear: gradient-corrected
interpolation at one 5x5 neighbourhood per pixel, where bilinear leaves
visible zippering on any high-contrast edge at 1:1. Two of the four
packed CFA constants were wrong on the first attempt, so all four layouts
are asserted to reconstruct the same colour. Crop origins at odd
coordinates re-phase the pattern; without that, red and blue swap.

X-Trans reports GpuError::UnsupportedCfa rather than approximating with
the Bayer path, which would look like a corrupt file.

206 tests, including GPU tests proving every operation and the full
seven-operation chain generate compilable WGSL.

Known gaps: the display path still reads back to the CPU each frame,
which ARCH §6.1 forbids and AC-8 asserts against — it is gated behind the
`readback` feature and waits on spike S1 wiring Slint's texture import.
Curve shapes are a first draft and want tuning against real photographs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 11:37:58 +02:00
dtourolle cc1c5c892d Support requesting VFS hydration; fix Android TLS cross-compilation
Correcting the previous commit: I claimed VFS placeholders could not be
downloaded. That was wrong. The desktop client exposes a socket at
$XDG_RUNTIME_DIR/Nextcloud/socket speaking newline-delimited
COMMAND:argument, and MAKE_AVAILABLE_LOCALLY:<path> does fetch the file.
Verified against client 4.0.7: a 1-byte stub became a real 2.8MB file in
2.8 seconds.

Implemented as dr-sync-nextcloud::desktop_client, deliberately optional.
Android has no desktop client, no XDG_RUNTIME_DIR socket and no
placeholders, so detect() returns None there and callers fall back to the
connector. It earns its place only because it is ~30 lines with no
dependencies: where a library already lives in a VFS folder, asking the
client to fetch beats downloading a second copy over WebDAV and leaving
the client's placeholder state inconsistent.

What this does not change: hydration is whole-file, so it suits the
original tier and never browsing. Filling a grid this way downloads the
entire library. Range extraction remains the only mechanism satisfying
FR-NC-3, and ARCH §9.0 now says so precisely.

Also fixes two real Android build failures found by cross-compiling:

  - reqwest's `rustls` feature defaults to aws-lc-rs, whose aws-lc-sys
    crate is C and fails under the NDK — exactly the pain D1 chose Rust
    to avoid. Switched to rustls-no-provider + ring, installing the
    provider in the constructor so no caller can build a client that
    panics on first use.
  - ring itself needs CC/AR per target; cargo-ndk sets only the linker.
    Added them to the container.

87 tests passing. dr-sync-nextcloud cross-compiles for aarch64-linux-android.
2026-08-09 10:10:29 +02:00
dtourolle f8a718f42e Add Nextcloud connector; reject VFS as a transfer mechanism
Investigated using the Nextcloud desktop client's Virtual Files as a
cache instead of talking to the server directly. Measured on this
machine (client 4.0.7): the configured folder holds 121,785 placeholders
against 10,267 materialised files, including 7,037 CR2 and 9,411 DNG.

Three findings, each independently disqualifying:

  - Linux VFS is *suffix* mode. A dehydrated IMG.CR2 exists only as
    IMG.CR2.nextcloud holding one byte; the real name is absent.
  - Reading a placeholder does not hydrate it. dd of the first 256KB
    returned 1 byte, the stub was unchanged, and the real name never
    appeared. There is no FUSE layer — the stub is an inert marker.
  - Even with hydration the granularity is wrong: VFS has two states,
    1 byte or all bytes, and the preview tier needs a ~256KB prefix of
    a 27MB file. That is ~100x what FR-NC-3 requires.

Recorded as ARCH §9.0. Coexistence is still supported: dr-types now
recognises *.nextcloud stubs, and the viewer lists them as "not
downloaded" rather than as corrupt files or not at all.

So the connector talks to the server directly, as D7 specified.
Implemented: Login Flow v2, PROPFIND with oc:fileid and nc:has-preview,
ETag pruning via a Depth:0 probe, range GET with local slicing when the
server ignores the header, conditional PUT, and /core/preview with
forceIcon=false. delta() returns Unsupported and says why.

Chunked upload v2 is not implemented yet — put() rejects bodies over
5MB explicitly rather than silently truncating.

Two bugs found by testing: my hand-computed epoch in a date test was a
day out (the parser was right), and quick-xml reaches EOF on truncated
input without erroring, so unbalanced elements needed an explicit check
— a half-parsed multistatus must not look like an empty directory.

83 tests passing.
2026-08-09 09:09:27 +02:00
dtourolle 9717e59909 Add RAW decode and a working image viewer
dr-decode exposes four entry points rather than one decode, because
callers differ sharply in what they need (ARCH §3.2): culling wants a
preview, the grid wants metadata, only develop and export touch sensor
data. Fusing them forces a full decode where a header read suffices,
which is why Lightroom stalls ~2s per image while culling.

Smoke-tested against 1,852 real Canon CR2 files (EOS 6D, ~27MB each):

  metadata      0.2ms   from a 256KB header read, no full decode
  preview     ~250ms   5472x3648, downscaled to 2048 for display
  jpeg          3.0ms

Two findings worth recording:

rawler 0.7.2's CR2 decoder implements only full_image; thumbnail_image
and preview_image are unimplemented trait defaults returning None. So
every rung of the preview ladder resolves to a full-resolution decode at
~250ms — 5x over NFR-P13's 50ms budget. CR2 does carry smaller IFDs, so
the fix is our own IFD walk or an upstream contribution. The ladder is
written now so that fixing it is a decoder change, not a change to every
caller. Recorded in milestone-v0.1 risks.

Preview.downscale_to bounds memory: a 5472x3648 RGBA preview is 79.8MB,
which exhausts a phone's budget after a handful of images. Box-filtered
so downscaled thumbnails do not alias.

Also fixed a RefCell double-borrow that panicked on first navigation —
`*x.borrow_mut() = *x.borrow() + 1` holds both borrows at once. Verified
with 10,000 programmatic navigations.

58 tests passing. Traceability 20.3% (29/143).
2026-08-09 08:28:26 +02:00
dtourolle 0f202fd3f9 Add requirements traceability gate and Gitea pipelines
Ports JellyTau's traceability tooling to Rust, carrying across the bug it
was repaired for. That gate divided a traced count by frozen literal
denominators; the requirements file outgrew them and it reported 158%
coverage, so it could never fail its own threshold.

Two rules, both enforced by the extractor's own tests:

  - denominators parsed from docs/requirements.md at run time
  - coverage is |traced ∩ defined| / |defined|, never a raw traced count

The gate additionally fails hard on a misconfigured run — zero
requirements parsed or zero files scanned — rather than reporting a
plausible 0%, and on any orphan tag naming a requirement that does not
exist.

Adapted for DarkRoom: IDs are FR-CAT-1 / NFR-P13 / FR-DEV-3a shapes
rather than JellyTau's fixed three digits, and decisions (D), spikes (S),
milestone items (M) and test ids remain taggable while being excluded
from the denominator — counting them inflated it by 25.

Also adds dr-sync: the RemoteBackend trait and capability model, so the
Nextcloud connector is one implementation rather than the only shape the
engine understands. No mature Nextcloud crate exists (reqwest_dav is too
thin), so the connector will be hand-rolled over reqwest per D7.

Gitea workflows follow the same style: containerised, commented with the
reasoning, desktop and Android on every push, plus a CI check that no
core/ crate depends on the UI toolkit (ARCH §6.5a).

Coverage today: 13.3% (19/143). 50 tests passing.
2026-08-09 08:01:32 +02:00
dtourolle 82a5e21ec6 Initial workspace: GPU context, compute pass, adaptive Slint shell
Establishes the v0.1 foundations on both platforms:

- dr-types: SourceRef (never a filesystem path — Android SAF has none),
  Format, Availability, Validator with ETag quote normalisation
- dr-gpu: wgpu device, compute pass writing a storage texture, resize
- dr-ui: Slint shell with FR-UI-1 adaptive layout, computed in Rust to
  avoid a binding loop
- docker/android: pinned toolchain, verified producing API 28 ARM binaries

Measured the cost of the temporary CPU readback path (dr-gpu bench):
compute is 0.06-0.28ms across sizes while readback is 0.63-7.43ms, so
readback is 90-96% of frame time and scales with area. Recorded in
ARCH §6.1 — this is why spike S1 is the priority.

Mitigations pending S1: reuse the staging buffer, apply at most one
resize per frame, and cap render resolution at 2048 on the long edge.

10 tests passing; core crates cross-compile for aarch64-linux-android.
2026-08-09 07:42:05 +02:00
310 changed files with 131646 additions and 568 deletions
+41
View File
@@ -0,0 +1,41 @@
{
"permissions": {
"allow": [
"Bash(curl -s \"https://api.github.com/search/code?q=WrapTexture+org:Noesis\" -H \"Accept: application/vnd.github+json\")",
"Bash(curl -s \"https://api.github.com/orgs/Noesis/repos?per_page=100\")",
"WebFetch(domain:wiki.wxwidgets.org)",
"Bash(curl -sS \"https://gitlab.com/sciter-engine/sciter-js-sdk/-/raw/main/samples.gpu/hello-es-triangle.htm\")",
"Bash(curl -sS \"https://gitlab.com/api/v4/projects/sciter-engine%2Fsciter-js-sdk/repository/tree?path=include&recursive=true&per_page=100&ref=main\")",
"Bash(python3 -c ' *)",
"Bash(curl -sL --max-time 40 \"https://api.github.com/repos/wxWidgets/wxWidgets/contents/src?ref=master\")",
"Bash(curl -sL --max-time 40 \"https://api.github.com/repos/wxWidgets/wxWidgets/contents/include/wx/android?ref=master\")",
"Bash(curl -sL --max-time 40 -H \"Accept: application/vnd.github.text-match+json\" \"https://api.github.com/search/code?q=vulkan+repo:wxWidgets/wxWidgets\")",
"Bash(curl -sS \"https://gitlab.com/sciter-engine/sciter-js-sdk/-/raw/main/include/sciter-x-video-api.h\")",
"WebFetch(domain:docs.wxwidgets.org)",
"Bash(curl -sS \"https://gitlab.com/sciter-engine/sciter-js-sdk/-/raw/main/CHANGELOG.md\")",
"Bash(curl -sL --max-time 40 \"https://raw.githubusercontent.com/wxWidgets/wxWidgets/master/docs/readme.txt\")",
"Bash(curl -sL --max-time 40 \"https://raw.githubusercontent.com/wxWidgets/wxWidgets/master/docs/licence.txt\")",
"Bash(curl -sL --max-time 40 \"https://raw.githubusercontent.com/wxWidgets/wxWidgets/master/docs/licendu.txt\")",
"Bash(curl -sS \"https://gitlab.com/api/v4/projects/sciter-engine%2Fsciter-js-sdk/repository/tree?path=build&recursive=true&per_page=100&ref=main\")",
"Bash(curl -sS \"https://gitlab.com/sciter-engine/sciter-js-sdk/-/raw/main/premake5.lua\")",
"WebFetch(domain:slack-chats.kotlinlang.org)",
"Bash(curl -sS -L \"https://sciter.com/\")",
"Bash(curl -sL --max-time 45 \"https://api.github.com/orgs/ultralight-ux/repos?per_page=100&sort=pushed\")",
"Bash(curl -sL --max-time 40 \"https://gitlab.gnome.org/GNOME/gtk/-/raw/main/gdk/gdkdmabuftexturebuilder.h\")",
"Bash(curl -sL --max-time 40 \"https://gitlab.gnome.org/GNOME/gtk/-/raw/main/gdk/gdkgltexturebuilder.h\")",
"Bash(curl -sL --max-time 40 \"https://gitlab.gnome.org/api/v4/projects/GNOME%2Fgtk/repository/tree?path=gdk&ref=main&per_page=100\")",
"WebFetch(domain:docs.slint.dev)",
"WebFetch(domain:releases.slint.dev)",
"WebFetch(domain:flutter.dev)",
"Bash(curl -sS -L \"https://sciter.com/forums/topic/status-of-quark-sciter-lite-sciterjs-android-ios/\")",
"Bash(curl -sL --max-time 45 \"https://api.github.com/repos/ultralight-ux/AppCore/git/trees/master?recursive=1\")",
"Bash(curl -sL --max-time 40 \"https://gitlab.gnome.org/GNOME/gtk/-/raw/main/gdk/meson.build\")",
"WebFetch(domain:www.jetbrains.com)",
"Bash(curl -sS \"https://gitlab.com/api/v4/projects/sciter-engine%2Fsciter-js-sdk/repository/tree?path=demos.lite&recursive=true&per_page=100&ref=main\")",
"Bash(curl -sL --max-time 40 \"https://gitlab.gnome.org/api/v4/projects/GNOME%2Fgtk/repository/commits?path=gdk/android/gdkandroidglcontext.c&ref_name=main&per_page=20\")",
"Bash(curl -sL --max-time 40 \"https://gitlab.gnome.org/GNOME/gtk/-/raw/main/gdk/android/meson.build\")",
"WebFetch(domain:docs.sciter.com)",
"Bash(curl -sS -L \"https://sciter.com/support-of-displayflex-and-displaygrid-in-sciter/\")"
]
}
}
+12
View File
@@ -0,0 +1,12 @@
# Model weights live in LFS.
#
# `core/dr-segment/models/*.onnx` is ~11 MB of binary that changes wholesale
# when it changes at all. In ordinary git objects every future revision of it
# would be stored in full, in every clone, forever — and the one thing nobody
# can do with it is a useful diff.
#
# Consequence worth knowing before it bites: a clone without git-lfs gets a
# ~130-byte pointer file where the model should be. `dr-segment`'s build script
# detects exactly that and fails with an instruction rather than embedding the
# pointer and failing at inference time.
*.onnx filter=lfs diff=lfs merge=lfs -text
+170
View File
@@ -0,0 +1,170 @@
name: '🐳 Android image'
# Builds and pushes gitea.tourolle.paris/dtourolle/darkroom-android, the job
# container for the Android leg of build-and-test.yml.
#
# It exists because that image previously lived only on a developer's laptop:
# the workflow referenced a tag that had never been pushed, and every Android
# job died at `docker pull` with "manifest unknown" before running a step. The
# image is now reproducible from the repo rather than from one machine.
#
# Called by build-and-test.yml on every push, and runnable by hand via
# workflow_dispatch. It is cheap when nothing changed — see the guard below.
on:
workflow_call:
inputs:
force:
description: 'Rebuild even if the registry already has this image ("true"/"false")'
type: string
default: 'false'
workflow_dispatch:
inputs:
force:
description: 'Rebuild even if the registry already has this image ("true"/"false")'
type: string
default: 'false'
# Gitea's act_runner mangles boolean workflow inputs passed through an
# expression — they arrive as false regardless of what was sent. Every input
# here is a string compared with == 'true', as in KPN's docker.yaml.
env:
IMAGE: gitea.tourolle.paris/dtourolle/darkroom-android
jobs:
build:
runs-on: linux/amd64
name: Build and push
# Deliberately NOT in a container: this job needs the host Docker daemon to
# build an image, and the host's cached ~/.docker/config.json to push it.
# That is also why there is no `docker login` step — the runner host was
# authenticated to the registry during setup.
steps:
# The host has no Node, so the JS-based actions/checkout cannot run here.
# A minimal shallow fetch with plain git gets the same tree.
- name: Checkout
run: |
set -e
git init -q .
git remote add origin "${{ github.server_url }}/${{ github.repository }}.git"
git -c http.extraheader="AUTHORIZATION: basic $(printf '%s' '${{ github.actor }}:${{ github.token }}' | base64 -w0)" \
fetch --depth 1 origin "${{ github.sha }}"
git checkout -q FETCH_HEAD
# The image is tagged by the content of docker/android, not by the commit
# that happened to touch it. `git rev-parse HEAD:<dir>` is the tree object
# id — it changes when and only when a file in that directory changes, so
# an unrelated push reuses the existing image and a Dockerfile edit can
# never silently keep serving a stale `latest`.
#
# Using the commit sha instead would rebuild 7 GB on every push; using a
# paths-filter action would need a container that has Node, and the only
# one this repo would reach for is the very image being built.
- name: Resolve image tag
id: tag
run: |
set -e
TREE=$(git rev-parse HEAD:docker/android)
echo "tree=$TREE" >> "$GITHUB_OUTPUT"
echo "docker/android tree: $TREE"
# Skip the build when the registry already holds this exact content. This
# is what keeps the job a few seconds long on a normal push, and what
# makes it self-healing: if the tag is missing for any reason, including
# the image having never been pushed at all, it gets built here.
#
# The probe is curl against the registry API, NOT `docker manifest
# inspect`. The latter exits 1 on this registry even for tags that are
# demonstrably present — jellytau-builder:latest answers HTTP 200 to the
# API while `docker manifest inspect` reports "manifest unknown" for it.
# Trusting that would have rebuilt 7 GB on every single push.
#
# A HEAD request also gives the digest for free, which is how the repoint
# decision below is made without pulling any layers.
- name: Query registry
id: check
env:
# The runner's own credentials, so this does not depend on how the
# host's ~/.docker/config.json happens to be set up.
REG_USER: ${{ github.actor }}
REG_PASS: ${{ github.token }}
TREE: ${{ steps.tag.outputs.tree }}
run: |
set -eu
ACCEPT='application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.list.v2+json'
API="https://gitea.tourolle.paris/v2/dtourolle/darkroom-android/manifests"
# Prints "<http-status> <digest-or-empty>" for a tag.
probe() {
curl -sI -u "$REG_USER:$REG_PASS" -H "Accept: $ACCEPT" "$API/$1" \
| tr -d '\r' \
| awk 'BEGIN{s="000";d=""} /^HTTP/{s=$2} tolower($1)=="docker-content-digest:"{d=$2} END{print s, d}'
}
read -r TREE_STATUS TREE_DIGEST <<EOF
$(probe "$TREE")
EOF
read -r LATEST_STATUS LATEST_DIGEST <<EOF
$(probe latest)
EOF
echo "tag $TREE -> HTTP $TREE_STATUS ${TREE_DIGEST:-(no digest)}"
echo "tag latest -> HTTP $LATEST_STATUS ${LATEST_DIGEST:-(no digest)}"
# Build unless the registry definitively confirms this content is
# already there. An auth failure or an unreachable registry lands
# here too, and rebuilding needlessly is the safe direction to fail —
# skipping a build that was needed is what breaks the Android job.
if [ "${{ inputs.force }}" = "true" ]; then
echo "forced rebuild requested"
echo "build=true" >> "$GITHUB_OUTPUT"
echo "repoint=false" >> "$GITHUB_OUTPUT"
elif [ "$TREE_STATUS" != "200" ]; then
echo "registry does not have this content — building"
echo "build=true" >> "$GITHUB_OUTPUT"
echo "repoint=false" >> "$GITHUB_OUTPUT"
elif [ -n "$TREE_DIGEST" ] && [ "$TREE_DIGEST" = "$LATEST_DIGEST" ]; then
echo "registry is already correct — nothing to do"
echo "build=false" >> "$GITHUB_OUTPUT"
echo "repoint=false" >> "$GITHUB_OUTPUT"
else
echo "content is present but latest points elsewhere — repointing"
echo "build=false" >> "$GITHUB_OUTPUT"
echo "repoint=true" >> "$GITHUB_OUTPUT"
fi
# Context is docker/android, matching the README's build command. The
# Dockerfile COPYs nothing from the repo, so it needs no wider context —
# and a narrow context keeps the daemon from tarring up the whole tree,
# target/ included.
- name: Build
if: ${{ steps.check.outputs.build == 'true' }}
run: |
set -e
docker build \
-t "$IMAGE:${{ steps.tag.outputs.tree }}" \
-t "$IMAGE:latest" \
docker/android
# Both tags are pushed: the tree tag is what the guard above looks for on
# the next run, and `latest` is what build-and-test.yml pulls.
- name: Push
if: ${{ steps.check.outputs.build == 'true' }}
run: |
set -e
docker push "$IMAGE:${{ steps.tag.outputs.tree }}"
docker push "$IMAGE:latest"
# A cache hit on the tree tag says nothing about where `latest` points — a
# reverted Dockerfile or a build from another branch can leave it on
# different content. This runs only when the digests above actually
# disagree, so the common case costs nothing; the layers are already in
# the registry, so the push that follows uploads a manifest, not 7 GB.
- name: Repoint latest
if: ${{ steps.check.outputs.repoint == 'true' }}
run: |
set -e
docker pull "$IMAGE:${{ steps.tag.outputs.tree }}"
docker tag "$IMAGE:${{ steps.tag.outputs.tree }}" "$IMAGE:latest"
docker push "$IMAGE:latest"
+177
View File
@@ -0,0 +1,177 @@
name: Build and test
# Desktop and Android are built on every push, per the v0.1 decision to carry
# both platforms from the first commit. An Android break is then caught the day
# it lands rather than at a porting milestone.
on:
push:
branches: [main, master, develop]
pull_request:
branches: [main, master, develop]
jobs:
# The Android job runs inside an image that this repo builds. Ensure it is in
# the registry before anything tries to pull it — see android-image.yml for
# why this is a job rather than a documented manual step. It is a no-op of a
# few seconds unless docker/android actually changed.
android-image:
uses: ./.gitea/workflows/android-image.yml
desktop:
runs-on: linux/amd64
name: Desktop (Linux)
# actions/checkout and actions/cache are JavaScript actions: the runner
# executes them with Node from inside this container. The bare runner image
# has none, so the job failed at checkout before reaching any build step.
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: desktop-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
# Slint and winit need these at build time; the runner image is minimal.
- name: Build dependencies
run: |
apt-get update -qq
apt-get install -y -qq pkg-config libfontconfig1-dev libxkbcommon-dev
# The act image ships Node but no Rust. Pinned to the workspace
# rust-version so CI, the Android image, and local builds agree — a
# floating toolchain turns an unrelated push into a mystery failure.
- name: Install Rust 1.92.0
run: |
set -e
curl -fsSL https://sh.rustup.rs | sh -s -- \
-y --no-modify-path --profile minimal \
--default-toolchain 1.92.0 --component rustfmt,clippy
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: Format
run: cargo fmt --all -- --check
- name: Clippy
run: cargo clippy --workspace --all-targets -- -D warnings
# GPU tests skip themselves where no adapter is present rather than
# failing — CI runners generally have none, and a test that cannot run is
# not evidence either way.
- name: Test
run: cargo test --workspace
- name: Build
run: cargo build --workspace --release
android:
runs-on: linux/amd64
name: Android (aarch64)
# Waits for the image build. Without this the pull races the push and the
# job dies with "manifest unknown" before its first step, which is the
# failure mode this ordering exists to remove.
needs: android-image
container:
image: gitea.tourolle.paris/dtourolle/darkroom-android:latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
/opt/cargo/registry
target-android
key: android-${{ hashFiles('**/Cargo.lock') }}
# Only the core crates cross-compile today; the UI and app crates join
# once the Android shell exists (milestone v0.1, FR-PLAT-AND-*).
- name: Cross-compile core
env:
CARGO_TARGET_DIR: target-android
run: cargo check -p dr-types -p dr-gpu -p dr-sync --target aarch64-linux-android
# The linker targets MIN_API, not the compile SDK. cargo-ndk otherwise
# defaults to API 21, far below the Vulkan floor this app needs — and the
# mismatch is invisible until a device refuses to install.
#
# Look under the target triple, and fail on a mismatch. Searching the
# whole target dir for the first `*.so` found the host proc-macro
# libraries in target-android/debug/deps instead — x86-64 objects built
# by the runner's gcc, whose .comment section says nothing about Android
# and can never contradict the expected API. The step passed regardless
# of what the linker actually did, which is the one thing it exists to
# rule out.
- name: Verify minimum API level
env:
CARGO_TARGET_DIR: target-android
run: |
set -e
cargo ndk -t arm64-v8a build -p dr-gpu --release
MIN_API=$(sed -n 's/^ARG MIN_API=\([0-9]*\).*/\1/p' docker/android/Dockerfile)
# Empty on both sides would compare equal and pass, so neither side
# is allowed to be the result of a failed parse.
if [ -z "$MIN_API" ]; then
echo "no ARG MIN_API= in docker/android/Dockerfile"
exit 1
fi
SO=$(find target-android/aarch64-linux-android/release -maxdepth 1 -name '*.so' | head -1)
if [ -z "$SO" ]; then
echo "no aarch64 .so was produced"
exit 1
fi
echo "checking $SO"
file "$SO"
API=$(file "$SO" | sed -n 's/.*for Android \([0-9]*\).*/\1/p')
if [ -z "$API" ] || [ "$API" != "$MIN_API" ]; then
echo "FAIL: linked for Android '${API:-unknown}', expected $MIN_API"
exit 1
fi
layering:
runs-on: linux/amd64
name: Layer separation
# Node for the JS actions, as above. cargo comes from rustup below.
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# `cargo tree` resolves the dependency graph, so it needs the registry
# index but no system libraries — this job builds nothing.
- name: Install Rust 1.92.0
run: |
set -e
curl -fsSL https://sh.rustup.rs | sh -s -- \
-y --no-modify-path --profile minimal --default-toolchain 1.92.0
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
# ARCH §6.5a: no core/ crate may depend on the UI toolkit. One stray
# `use slint::` costs headless golden-image testing and the
# one-operation-two-presentations property together, and nothing else
# would notice.
- name: Core crates must not depend on the UI
run: |
set -e
FAILED=0
for crate in dr-types dr-gpu dr-sync; do
if cargo tree -p "$crate" -e normal 2>/dev/null | grep -qE '\bslint\b|\bi-slint'; then
echo "FAIL: $crate depends on Slint (ARCH §6.5a)"
FAILED=1
else
echo "ok: $crate"
fi
done
exit $FAILED
+112
View File
@@ -0,0 +1,112 @@
name: Traceability
# Mirrors JellyTau's traceability gate, including the reason it exists.
#
# That gate divided a traced count by frozen literal denominators while the
# requirements file grew past them, reported 158% coverage, and so could never
# fail its own threshold. Two rules follow, and the extractor's own tests
# enforce both:
#
# 1. Denominators are parsed from docs/requirements.md at run time.
# 2. Coverage is |traced ∩ defined| / |defined|, never a raw traced count.
#
# This job is static analysis of source comments plus markdown parsing, so it
# needs no GPU and no Android SDK — only the Rust toolchain.
on:
push:
branches: [main, master, develop]
pull_request:
branches: [main, master, develop]
jobs:
traceability:
runs-on: linux/amd64
name: Requirement traces
# Node for actions/checkout and actions/cache, which the bare runner image
# cannot execute. Rust is installed below.
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: traces-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
# Source-comment and markdown parsing only, so the minimal profile is
# enough — no system libraries and no components beyond cargo itself.
- name: Install Rust 1.92.0
run: |
set -e
curl -fsSL https://sh.rustup.rs | sh -s -- \
-y --no-modify-path --profile minimal --default-toolchain 1.92.0
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
# The gate's own arithmetic is the thing being trusted, so its tests run
# before it does. Untested gate logic is exactly how JellyTau's 158% went
# unnoticed for months.
- name: Test the extractor
run: cargo test -p traceability
# Structural failures are unconditional and do not depend on the coverage
# threshold: zero requirements parsed, zero files scanned, a ratio above
# 100%, or any orphan tag all fail the build. A misconfigured run must not
# report a plausible-looking 0%.
- name: Traceability gate
run: cargo run -q -p traceability -- check
- name: Regenerate matrix and check it is committed
run: |
set -e
cargo run -q -p traceability -- report
if ! git diff --quiet docs/traceability.md; then
echo ""
echo "docs/traceability.md is out of date."
echo "Run: cargo run -p traceability -- report"
git diff --stat docs/traceability.md
exit 1
fi
# Advisory, not blocking: not every file implements a requirement, and a
# tag on every function is noise that rots faster than it helps. Tag the
# unit that decides.
- name: Check changed files for tags
if: github.event_name == 'pull_request'
run: |
set -e
CHANGED=$(git diff --name-only "origin/${{ github.base_ref }}...HEAD" \
| grep -E '\.(rs|slint|wgsl)$' || true)
[ -z "$CHANGED" ] && { echo "No source files changed."; exit 0; }
MISSING=0
for file in $CHANGED; do
case "$file" in
*/tests/*|*/test_*|tools/*) continue ;;
esac
[ -f "$file" ] || continue
if ! grep -q 'TRACES:' "$file"; then
echo " no TRACES tag: $file"
MISSING=$((MISSING + 1))
fi
done
if [ "$MISSING" -gt 0 ]; then
echo ""
echo "$MISSING changed file(s) carry no requirement tag."
echo "Format: /// TRACES: FR-CAT-1, FR-CAT-2 | NFR-P1"
echo " (comma separates IDs, pipe groups types)"
fi
- name: Summary
if: always()
run: head -30 docs/traceability.md || true
+4
View File
@@ -0,0 +1,4 @@
/target
/target-android
Cargo.lock.bak
*.log
Generated
+8713
View File
File diff suppressed because it is too large Load Diff
+212
View File
@@ -0,0 +1,212 @@
[workspace]
resolver = "2"
members = [
"core/dr-types",
"core/dr-catalog",
"core/dr-thumbs",
"core/dr-decode",
"core/dr-export",
"core/dr-ingest",
"core/dr-gpu",
"core/dr-lens",
"core/dr-pipeline",
"core/dr-segment",
"core/dr-sync",
"core/dr-sync-nextcloud",
"platform/dr-plat",
"ui/dr-ui",
"apps/darkroom-desktop",
"apps/darkroom-android",
"tools/traceability",
]
[workspace.package]
version = "0.3.0"
edition = "2021"
rust-version = "1.92"
license = "GPL-3.0-or-later"
repository = "https://github.com/dtourolle/DarkRoom"
[workspace.dependencies]
# Internal
dr-types = { path = "core/dr-types" }
dr-catalog = { path = "core/dr-catalog" }
dr-thumbs = { path = "core/dr-thumbs" }
dr-decode = { path = "core/dr-decode" }
dr-export = { path = "core/dr-export" }
dr-ingest = { path = "core/dr-ingest" }
dr-gpu = { path = "core/dr-gpu" }
dr-lens = { path = "core/dr-lens" }
dr-pipeline = { path = "core/dr-pipeline" }
# `default-features = false` belongs *here*, not on each dependant: a member
# inheriting a workspace dependency cannot turn its default features off, so
# writing it below would silently do nothing and every crate touching
# `dr-segment` would drag in tract and 11 MB of weights. Members opt in with
# `features = ["semantic", "embedded-model"]` instead.
dr-segment = { path = "core/dr-segment", default-features = false }
dr-plat = { path = "platform/dr-plat" }
dr-sync = { path = "core/dr-sync" }
dr-sync-nextcloud = { path = "core/dr-sync-nextcloud" }
dr-ui = { path = "ui/dr-ui" }
# GPU + UI
#
# The wgpu version is not a free choice: it is dictated by Slint. Importing a
# texture into the scene (ARCH §6.1, spike S1) requires it to come from the
# *same* `wgpu::Device` Slint renders with, and Slint will only hand out a
# device of the version it was compiled against. Slint 1.17 offers
# `unstable-wgpu-28` and `unstable-wgpu-29` and nothing older, so 29 it is —
# pinned to the same `29.0.4` floor Slint itself requires, because two
# semver-compatible-but-different wgpu crates in one tree are two *types*, and
# the device would not typecheck across them.
#
# Consequently: bumping Slint may force a wgpu bump, and wgpu cannot be bumped
# on its own. They move together or not at all.
wgpu = "29.0.4"
slint = { version = "1.17", default-features = false }
slint-build = "1.17"
# UI token codegen (S2): style.yaml -> theme.slint. serde_yaml was deprecated
# by its maintainer in 2024 and serde_yml, the first fork, has since been
# deprecated too; serde_norway is the fork still receiving releases. Its
# mappings preserve insertion order, which is what lets the generated Slint
# keep the token ordering the YAML author chose.
serde_norway = "0.9"
# Foundations
anyhow = "1"
thiserror = "2"
log = "0.4"
env_logger = "0.11"
pollster = "0.4"
# Networking — no mature Nextcloud crate exists; the connector is hand-rolled
# over reqwest (D7). reqwest_dav was evaluated and is too thin to build on.
# `rustls-no-provider` rather than `rustls`: the latter defaults to the
# aws-lc-rs crypto provider, whose aws-lc-sys crate is C and fails to
# cross-compile for Android — precisely the NDK pain D1 chose Rust to avoid.
# ring is pure Rust apart from a small asm core that does build under the NDK.
#
# `rustls-tls-webpki-roots-no-provider` rather than plain `rustls-no-provider`:
# the latter verifies against rustls-platform-verifier, which reaches the
# Android trust store over JNI and panics mid-handshake unless initialised from
# Java first — the crash D7 predicted and spike S3 exists to resolve properly.
# The panic surfaces inside tokio, which catches task panics itself, so it
# reaches the UI as a worker that stopped rather than as an error.
#
# webpki-roots is the escape hatch D7 records: a root store compiled into the
# binary, no JNI, identical on both platforms. The trade is real and belongs in
# S3's scope — user-installed and enterprise CAs are not consulted, and the
# roots go stale with the release rather than with the OS.
reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "webpki-roots", "stream", "json"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
quick-xml = "0.41"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] }
url = "2.5"
async-trait = "0.1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
base64 = "0.23"
# Platform secure storage: Secret Service on Linux, Keystore on Android
# (FR-NC-2). Credentials never touch the catalog or a plain file.
# keyring 4 restructured its features: `v1` is the default set and brings
# the zbus Secret Service backend, which is what GNOME Keyring and KWallet
# (via ksecretd) both speak.
keyring = { version = "4", features = ["v1"] }
# The Android half of the same project: a keyring-core CredentialStore backed
# by AndroidKeyStore AES-GCM over SharedPreferences (FR-PLAT-AND-1). It reads
# the JavaVM and Context from ndk-context, which android-activity populates
# before `android_main` runs, so no Kotlin shim of our own is needed.
#
# This is the keyring-core API, not the v1 `Entry` API the Linux path uses;
# the two impls are deliberately separate rather than sharing a code path.
android-native-keyring-store = "1.0.0"
keyring-core = "1"
# Decode. rawler is the pure-Rust decoder (D2); zune-jpeg decodes the
# embedded previews rawler extracts.
# Catalog. `bundled` compiles SQLite from source rather than linking the
# system library — the same cross-compilation reasoning as the TLS choice
# above: no system dependency to satisfy under the Android NDK.
#
# `backup` is not optional in practice: it is what takes a consistent snapshot
# of a live WAL database for upload. A filesystem copy of `catalog.sqlite`
# while a `-wal` exists beside it uploads a torn file.
rusqlite = { version = "0.40", features = ["bundled", "backup"] }
rawler = "0.7"
zune-jpeg = "0.4.21"
# Thumbnails are stored encoded, not as raw RGBA: a 256px RGBA buffer is
# ~256 KB against ~20 KB as JPEG, and the store syncs to Nextcloud where that
# 13× is transfer cost on every client. Pure Rust, no C dependency — the same
# criterion behind the TLS and SQLite choices above.
jpeg-encoder = "0.7"
bytemuck = { version = "1", features = ["derive"] }
# Lens correction profiles. A pure-Rust port of Lensfun rather than a binding
# to the C library, for the same cross-compilation reason as the TLS and
# SQLite choices above: liblensfun would be a third C dependency to satisfy
# under the Android NDK.
#
# The database ships *inside* the crate — 56 XML files, gzipped at build time
# and decompressed on first lookup. That matters beyond convenience: Android
# gives us no filesystem path (ARCH §6.9), so a database loaded from a
# system directory would have nowhere to live there.
#
# Licence: LGPL-3.0-or-later, which upgrades cleanly into our GPLv3 (D8).
# The upstream Lensfun *database* is CC-BY-SA and is redistributed by the
# crate; attribution belongs in the about screen.
#
# Caveat worth remembering: this is a third-party port at 0.7.0, not upstream
# Lensfun. Verified working against the bundled database (interpolation
# between calibration points, and an unknown lens returning empty rather than
# panicking), but the pipeline talks to it through its own profile types so
# swapping it out is not a pipeline change.
lensfun = "0.7"
# Neural inference for semantic segmentation (S15 arm B, D14).
#
# D13 framed this as a choice between `ort` (fast, best operator coverage, and
# a C++ dependency to cross-compile under the NDK) and a pure-Rust runtime
# (policy-compliant, unproven coverage). That framing turned out to be a false
# choice: `ort` 2.0's `alternative-backend` feature *disables the linking
# entirely* and lets a different engine supply the `OrtApi`, and `ort-tract` —
# same authors, MIT/Apache — supplies it from `tract`, which is pure Rust.
#
# So we get `ort`'s API with no C at all. `download-binaries` and `tls-native`
# are off with `default-features = false`, which is the point: nothing is
# fetched at build time and nothing is linked, so the Android cross-compile
# sees an ordinary Rust dependency graph. That is the same reasoning as rustls
# over aws-lc-rs and bundled SQLite, applied to inference — D13's largest
# tolerated exception turns out not to be needed.
#
# The trade is real and belongs on the record: tract is slower than the C++
# runtime and covers fewer operators. Both were measured rather than assumed
# before this landed — yolo26n-seg loads with **zero unsupported operators**
# and runs 640x640 in ~470 ms on the reference desktop's CPU. That is fine for
# a once-per-image precompute off the frame path (ARCH §6.1) and would not be
# fine for anything per-frame, which is a constraint on what may be built on
# top rather than on this choice.
#
# Pinned to an rc: `ort` 2.0 has been in rc for a long while and `ort-tract`
# exists only against it. Worth revisiting at 2.0 final.
ort = { version = "2.0.0-rc.13", default-features = false, features = ["alternative-backend", "ndarray", "std"] }
ort-tract = "0.4"
# Not a free choice: it is the version `ort` exposes its tensors through, so
# two semver-incompatible ndarrays would not typecheck across the boundary —
# the same coupling wgpu has with Slint above.
ndarray = "0.17"
[profile.dev]
# Dependencies optimised even in dev builds — wgpu and image decoding are
# unusably slow otherwise, and they rarely need debugging.
opt-level = 0
[profile.dev.package."*"]
opt-level = 2
[profile.release]
lto = "thin"
codegen-units = 1
+48
View File
@@ -0,0 +1,48 @@
# DarkRoom
A cross-platform, non-destructive RAW photo editor for Linux and Android.
**Status:** early. v0.1 is a remote library viewer — see
[docs/milestone-v0.1.md](docs/milestone-v0.1.md).
## Documentation
| Document | Contents |
|---|---|
| [requirements.md](docs/requirements.md) | What the software must do — 122 numbered requirements |
| [architecture.md](docs/architecture.md) | How it is built — crates, GPU pipeline, data model, sync |
| [milestone-v0.1.md](docs/milestone-v0.1.md) | The first buildable milestone |
## Building
Desktop:
```bash
cargo run -p darkroom-desktop
```
Android (containerised toolchain, see [docker/android](docker/android/README.md)):
```bash
./docker/android/build.sh cargo ndk -t arm64-v8a build --release
```
## Current state
Working: workspace, GPU context and compute pass, adaptive Slint shell, Android
cross-compilation of the core crates.
**Not yet working:** the zero-copy display path. The build currently uploads
frames through the CPU, which is exactly what
[ARCH §6.1](docs/architecture.md) forbids — measured at 96% of frame time at
4K. Replacing it is spike S1, the project's highest priority.
```
cargo run -p dr-gpu --example bench --features readback
```
reproduces that measurement.
## Licence
GPL-3.0-or-later.
+34
View File
@@ -0,0 +1,34 @@
[package]
name = "darkroom-android"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
# A cdylib, not a bin: Android loads the app as a shared library and calls
# `android_main` through android-activity's glue. Nothing execs a binary, so
# there is no `main` to provide.
[lib]
name = "darkroom"
crate-type = ["cdylib"]
[dependencies]
# No backend feature to select: dr-ui picks its Slint backend from the target,
# so building for aarch64-linux-android gets android-activity automatically.
dr-ui.workspace = true
# For `session::set_data_dir`: only the platform entry point knows where Android
# lets this app keep files, and it must be set before any store is opened.
dr-sync-nextcloud.workspace = true
# Directly, not just through dr-ui: `android_main` takes an `AndroidApp` and
# calls `slint::android::init`, both of which come from this crate. The backend
# feature comes from dr-ui's target-specific dependency.
slint.workspace = true
log.workspace = true
android_logger = "0.15"
[features]
# Mirrors darkroom-desktop: the CPU readback path is gone since S1 landed
# zero-copy. It mattered more here than on desktop — the same wrong path with
# far less memory bandwidth to absorb it (ARCH §6.1) — but it is untested on a
# device, since S1 was verified on desktop only.
default = []
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
DarkRoom Android manifest.
Deliberately minimal: this packages the viewer for on-device testing (spike
S2 needs Adreno and Mali hardware, which no emulator represents). Nothing
here is a distribution manifest yet. Only network access is declared: file
access needs no manifest permission because the library grid reads through
SAF, which grants per-tree at runtime (ARCH §6.9).
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="paris.tourolle.darkroom">
<!-- Everything the app does with a server needs this: Login Flow v2, the
WebDAV listing, thumbnail and image fetches. Without it Android refuses
socket creation outright, and the failure is invisible — no panic to
catch, no log line, just a worker thread that stops. Storage is the
separate case that genuinely needs no permission here, because SAF
grants per-tree at runtime (ARCH §6.9). -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- Read before deciding whether a sync may run: FR-NC-6 gates background
work on unmetered-and-charging, which means knowing the network type. -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Vulkan 1.1 is what wgpu needs; the API 28 floor is where support is
dependable (NFR-COMPAT-1). Marked required so an unsupported device
fails at install rather than at first frame. -->
<uses-feature
android:name="android.hardware.vulkan.version"
android:version="0x00401000"
android:required="true" />
<!-- One name covers both icon generations, which is the point of the
`anydpi-v26` qualifier: @mipmap/ic_launcher resolves to the adaptive
icon at res/mipmap-anydpi-v26/ic_launcher.xml on API 26 and up, and to
the density-matched ic_launcher.png below that. Since minSdk is 28 the
PNGs are only ever reached by tooling, but they cost little and aapt2
wants a real drawable behind the name. `roundIcon` is deliberately
absent: it predates adaptive icons and a launcher that reads it would
also be one that ignores the XML, which no device here is.
The adaptive icon has three layers rather than two. The third,
monochrome, is what lets Android 13's themed-icon setting recolour it
instead of dropping the app out of the themed set. -->
<application
android:label="DarkRoom"
android:icon="@mipmap/ic_launcher"
android:hasCode="true"
android:allowBackup="false"
android:supportsRtl="true">
<!-- NativeActivity rather than a Kotlin Activity: android-activity's
glue loads libdarkroom.so and calls android_main. `android.app.lib_name`
is how it learns which library to load, and must match [lib].name. -->
<activity
android:name="android.app.NativeActivity"
android:exported="true"
android:configChanges="orientation|keyboardHidden|screenSize|screenLayout|density|uiMode"
android:windowSoftInputMode="adjustResize">
<meta-data
android:name="android.app.lib_name"
android:value="darkroom" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 518 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 680 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1005 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

+63
View File
@@ -0,0 +1,63 @@
//! DarkRoom Android entry point.
//!
//! The counterpart to `darkroom-desktop`'s `main`, with two differences that
//! come from the platform rather than from choice:
//!
//! * There are no command-line paths. Android's SAF hands out document URIs,
//! not filesystem paths (ARCH §6.9), so the viewer opens with an empty
//! browsing list and the library grid is the only way in.
//! * Logging goes to logcat. `env_logger` writes to stderr, which Android
//! discards.
// `slint::android` exists only when compiling for Android, so the whole entry
// point is gated on the target rather than on a feature. Without this the
// crate is still a workspace member on the host, and `cargo test --workspace`
// fails to compile it — a build break that only ever appears off-device.
#[cfg(target_os = "android")]
/// TRACES: M-13 | M-14
/// Android application entry point, called by android-activity's glue.
#[no_mangle]
fn android_main(app: slint::android::AndroidApp) {
android_logger::init_once(
android_logger::Config::default()
.with_max_level(log::LevelFilter::Info)
.with_tag("DarkRoom"),
);
// Panics go to stderr, and Android discards stderr. Without this hook a
// worker thread that panics is invisible: the process survives, the
// channel it was writing to closes, and the UI reports only that
// something "failed unexpectedly" with no way to find out what.
std::panic::set_hook(Box::new(|info| {
log::error!("panic: {info}");
}));
log::info!("DarkRoom v{}", env!("CARGO_PKG_VERSION"));
// Before anything opens a store: Android has no $HOME and no XDG
// directories, so the default guess resolves to a path the app cannot
// write. Nothing failed loudly — the session list went to a doomed path, so
// the account survived only as long as the process and backgrounding the app
// lost the sign-in. `internal_data_path` is the app's private directory
// (ARCH §6.9).
match app.internal_data_path() {
Some(dir) => {
log::info!("data dir: {}", dir.display());
dr_sync_nextcloud::session::set_data_dir(dir);
}
None => log::error!("no internal data path; settings will not persist"),
}
if let Err(e) = slint::android::init(app) {
log::error!("Slint Android backend failed to initialise: {e}");
return;
}
// Empty rather than the desktop's argv: see the module note above.
//
// Returning from `android_main` ends the process, so a failure here is
// logged rather than propagated — there is no shell to show `Err` to.
if let Err(e) = dr_ui::run(Vec::new()) {
log::error!("DarkRoom exited with error: {e:#}");
}
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "darkroom-desktop"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
dr-ui.workspace = true
anyhow.workspace = true
env_logger.workspace = true
log.workspace = true
[features]
default = []
+21
View File
@@ -0,0 +1,21 @@
//! DarkRoom desktop entry point.
//!
//! darkroom-desktop <file-or-directory>...
use std::path::PathBuf;
fn main() -> anyhow::Result<()> {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(
"info,wgpu_core=warn,wgpu_hal=warn,zbus=warn,tracing=warn,calloop=warn,rawler=warn",
))
.init();
log::info!("DarkRoom v{}", env!("CARGO_PKG_VERSION"));
let paths: Vec<PathBuf> = std::env::args().skip(1).map(PathBuf::from).collect();
if paths.is_empty() {
eprintln!("usage: darkroom-desktop <file-or-directory>...");
}
dr_ui::run(paths)
}
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "dr-catalog"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
dr-types.workspace = true
# The `Storage` trait, and nothing else from it. A scan has to read a real
# directory, and this is how `core/` reaches the platform without a
# `#[cfg(target_os)]` of its own (ARCH §4.1: calls go downward).
dr-plat.workspace = true
rusqlite.workspace = true
thiserror.workspace = true
log.workspace = true
# `collections.selector_json` — the stored form of a smart collection's
# selector. The column predates this dependency; nothing else here is JSON.
serde_json.workspace = true
# For the `scan_local` example only, which is a diagnostic tool: what it is
# diagnosing is often a folder the scan warned about and skipped, and those
# warnings go to `log`.
[dev-dependencies]
env_logger.workspace = true
+111
View File
@@ -0,0 +1,111 @@
//! Scan a real folder on this machine into a catalog, and say what it cost.
//!
//! cargo run -p dr-catalog --example scan_local -- ~/Pictures [catalog.sqlite]
//!
//! **Run it twice.** The first run is a full walk; the second is the one worth
//! watching, because on an unchanged library it should list no directories at
//! all and take a fraction of the time. That difference is NFR-P1, and a
//! synthetic test cannot show it at the scale a real library does — 121,785
//! files in a synced folder is a different question from twenty in a temporary
//! directory.
//!
//! Writes only to the catalog file, which defaults to a fixed path in the
//! system temporary directory so a second run has something to compare
//! against. Nothing in the scanned folder is touched.
use std::path::PathBuf;
use dr_catalog::walk::{ensure_root, scan_root, RootKind};
use dr_catalog::Catalog;
use dr_plat::LocalStorage;
use dr_types::FormatFilter;
fn main() {
env_logger::init();
let mut args = std::env::args().skip(1);
let Some(dir) = args.next().map(PathBuf::from) else {
eprintln!("usage: scan_local <directory> [catalog.sqlite]");
std::process::exit(2);
};
let catalog_path = args
.next()
.map(PathBuf::from)
.unwrap_or_else(|| std::env::temp_dir().join("darkroom-scan-local.sqlite"));
let catalog = match Catalog::open(&catalog_path) {
Ok(c) => c,
Err(e) => {
eprintln!("cannot open {}: {e}", catalog_path.display());
std::process::exit(1);
}
};
println!("catalog: {}", catalog_path.display());
// The label is how the grant is spelled, and the only place a path is
// written down. Everything after this line addresses files by `RootId`.
let label = dir.display().to_string();
let root = match ensure_root(catalog.connection(), RootKind::Local, &label) {
Ok(r) => r,
Err(e) => {
eprintln!("cannot record the root: {e}");
std::process::exit(1);
}
};
let storage = LocalStorage::with_root(root, &dir);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let started = std::time::Instant::now();
let report = match scan_root(
catalog.connection(),
&storage,
root,
&FormatFilter::all(),
now,
|| false,
|p| {
// One line per hundred directories: enough to show it is alive on a
// large library, not enough to be the thing that slows it down.
let visited = p.directories_listed + p.directories_pruned;
if visited % 100 == 0 {
println!(
" … {visited} directories ({} pruned), {} images",
p.directories_pruned, p.images_found
);
}
},
) {
Ok(r) => r,
Err(e) => {
eprintln!("scan failed: {e}");
std::process::exit(1);
}
};
let elapsed = started.elapsed();
let total: i64 = catalog
.connection()
.query_row("SELECT count(*) FROM images", [], |r| r.get(0))
.unwrap_or(-1);
println!("\noutcome: {:?}", report.outcome);
println!(
"directories: {} listed, {} pruned",
report.progress.directories_listed, report.progress.directories_pruned
);
println!(
"images: {} new, {} changed, {} unchanged, {} removed",
report.inserted, report.updated, report.unchanged, report.images_removed
);
println!("folders: {} removed", report.folders_removed);
println!("catalogued: {total} in total");
println!("took: {:.2?}", elapsed);
if report.progress.directories_listed == 0 && report.progress.directories_pruned > 0 {
println!("\nnothing had changed: every folder was proven unchanged by one probe");
}
}
+986
View File
@@ -0,0 +1,986 @@
//! TRACES: FR-NC-6a | FR-CAT-9 | NFR-RES-4
//! Which originals are kept on this device, and which may be evicted.
//!
//! # Two populations, one table
//!
//! An original ends up here two ways, and conflating them produces exactly the
//! failure the whole feature exists to prevent.
//!
//! **Pinned** originals were asked for. A user pins a collection before a trip
//! and expects those photographs to be there when there is no connection —
//! that is a promise, so pinned rows are never evicted and never counted
//! against the budget. A cap that could silently delete a pinned trip would
//! make pinning worthless, because the user could not rely on it without
//! checking.
//!
//! **Passively cached** originals are a side effect of working: opening an
//! image in develop downloads it, so keeping the bytes costs nothing extra and
//! saves the whole transfer next time. This population is bounded by
//! [`Budget`] and evicted least-recently-used, because it grows without limit
//! otherwise — a day of culling would fill a disk.
//!
//! The two budgets are separate rather than shared. Sharing them means a large
//! pin starves the passive cache, or worse, that browsing evicts a pin.
//!
//! # What this module does and does not own
//!
//! It owns the *bookkeeping*: which images are held, at what tier, how large,
//! when last used, and which are pinned. The bytes are files under a cache
//! directory, and [`store`](Cache::store) writes them; but deciding to
//! download something is the caller's business, because that needs a network
//! and this crate has none.
//!
//! # Why `tier_actual` is the truth
//!
//! `tier_desired` is what a pin asks for; `tier_actual` is what is on disk.
//! Only the second answers "can this be opened right now", which is the
//! question offline mode asks (FR-CAT-9). A pinned image whose download has
//! not run yet is precisely the one that would fail, so it must not report as
//! available.
use std::path::{Path, PathBuf};
use dr_types::{ImageId, Tier};
use rusqlite::{Connection, OptionalExtension as _};
use crate::error::CatalogError;
/// Default ceiling for passively cached originals.
///
/// 1 GB holds roughly 30 full-frame RAWs — a working session's worth, which is
/// what this cache is for. It is deliberately modest: the passive cache is a
/// convenience that should not quietly consume a disk, and a user who wants
/// more kept is better served by pinning, which says so explicitly and is not
/// subject to eviction at all.
pub const DEFAULT_BUDGET_BYTES: u64 = 1024 * 1024 * 1024;
/// How much disk the passive cache may use.
///
/// A newtype rather than a bare `u64` so a byte count cannot be passed where a
/// budget belongs, and to give the "unlimited" case a name — some users have a
/// large disk and would rather never re-download.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Budget(Option<u64>);
impl Default for Budget {
fn default() -> Self {
Self::bytes(DEFAULT_BUDGET_BYTES)
}
}
impl Budget {
pub fn bytes(n: u64) -> Self {
Self(Some(n))
}
/// No ceiling: nothing is ever evicted for space.
pub fn unlimited() -> Self {
Self(None)
}
pub fn limit(self) -> Option<u64> {
self.0
}
/// How much must be freed to fit `used` within this budget.
fn overage(self, used: u64) -> u64 {
self.0.map_or(0, |cap| used.saturating_sub(cap))
}
}
/// What is held for one image.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
pub image: ImageId,
/// What is actually on disk.
pub tier: Tier,
/// What a pin has asked for, which may be ahead of `tier`.
pub desired: Tier,
pub bytes: u64,
/// Unix seconds, or `None` if never read back since being stored.
pub last_used: Option<i64>,
pub pinned: bool,
/// Path relative to the cache directory.
pub path: Option<String>,
}
/// How the cache is currently filled.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Usage {
/// Bytes held by pinned originals. Not subject to the budget.
pub pinned_bytes: u64,
/// Bytes held by passively cached originals. What the budget bounds.
pub passive_bytes: u64,
pub pinned_count: usize,
pub passive_count: usize,
}
impl Usage {
pub fn total_bytes(self) -> u64 {
self.pinned_bytes + self.passive_bytes
}
}
/// The on-disk cache of originals, rooted at a directory.
pub struct Cache {
dir: PathBuf,
budget: Budget,
}
impl Cache {
/// Open a cache rooted at `dir`, creating it if needed.
pub fn open(dir: &Path, budget: Budget) -> Result<Self, CatalogError> {
std::fs::create_dir_all(dir)
.map_err(|e| CatalogError::Io(format!("creating {}: {e}", dir.display())))?;
Ok(Self {
dir: dir.to_path_buf(),
budget,
})
}
pub fn dir(&self) -> &Path {
&self.dir
}
pub fn budget(&self) -> Budget {
self.budget
}
/// Absolute path for a cached original.
///
/// Named by image id rather than by the remote filename: two folders on
/// the server may hold `IMG_0001.CR2`, and a flat cache keyed on the name
/// would have them overwrite each other. The extension is preserved so the
/// decoder's format probe sees what it expects.
fn relative_path(image: ImageId, source_ref: &str) -> String {
let ext = source_ref
.rsplit_once('.')
.map(|(_, e)| e.to_ascii_lowercase())
.filter(|e| {
!e.is_empty() && e.len() <= 8 && e.chars().all(|c| c.is_ascii_alphanumeric())
})
.unwrap_or_else(|| "bin".to_string());
format!("{}.{ext}", image.0)
}
/// Store an original's bytes and record it.
///
/// `pinned` says which population this belongs to. Storing an image that
/// is already present updates it rather than duplicating — the same
/// photograph opened twice is one cache entry, and the second store simply
/// refreshes the bytes and the timestamp.
///
/// Does **not** evict. The caller runs [`enforce`](Self::enforce) once it
/// has finished storing, so a batch of downloads is trimmed once rather
/// than after every file.
pub fn store(
&self,
conn: &Connection,
image: ImageId,
source_ref: &str,
bytes: &[u8],
pinned: bool,
now: i64,
) -> Result<(), CatalogError> {
let rel = Self::relative_path(image, source_ref);
let abs = self.dir.join(&rel);
// Written to a temporary and renamed, so a crash or a dropped
// connection mid-write cannot leave a truncated file that the catalog
// records as a complete original — which would then fail to decode
// with no indication that the *cache* was at fault rather than the
// photograph.
let tmp = abs.with_extension("partial");
std::fs::write(&tmp, bytes)
.map_err(|e| CatalogError::Io(format!("writing {}: {e}", tmp.display())))?;
std::fs::rename(&tmp, &abs)
.map_err(|e| CatalogError::Io(format!("renaming {}: {e}", abs.display())))?;
// `pinned` is OR-ed rather than assigned: an image that was already
// pinned must not be demoted to evictable because it happened to be
// opened in develop, which is a passive store.
conn.execute(
"INSERT INTO image_cache
(image_id, tier_actual, tier_desired, bytes, last_used, pinned, path)
VALUES (?1, ?2, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(image_id) DO UPDATE SET
tier_actual = ?2,
tier_desired = max(tier_desired, ?2),
bytes = ?3,
last_used = ?4,
pinned = max(pinned, ?5),
path = ?6",
rusqlite::params![
image.0 as i64,
Tier::Original.stored(),
bytes.len() as i64,
now,
i64::from(pinned),
rel,
],
)?;
Ok(())
}
/// Read a cached original back, if it is here.
///
/// Touches `last_used`, which is what makes the eviction order reflect
/// actual use rather than download order. A read that finds the row but
/// not the file repairs the catalog rather than returning bytes it does
/// not have — the two can diverge if a user clears the directory by hand.
pub fn load(
&self,
conn: &Connection,
image: ImageId,
now: i64,
) -> Result<Option<Vec<u8>>, CatalogError> {
let path: Option<String> = conn
.query_row(
"SELECT path FROM image_cache
WHERE image_id = ?1 AND tier_actual >= ?2",
rusqlite::params![image.0 as i64, Tier::Original.stored()],
|r| r.get(0),
)
.ok()
.flatten();
let Some(rel) = path else { return Ok(None) };
let abs = self.dir.join(&rel);
match std::fs::read(&abs) {
Ok(bytes) => {
conn.execute(
"UPDATE image_cache SET last_used = ?2 WHERE image_id = ?1",
rusqlite::params![image.0 as i64, now],
)?;
Ok(Some(bytes))
}
Err(e) => {
// The file is gone but the row says it is here. Believing the
// row would report the image as locally available for ever
// while every open failed.
log::debug!(
"cached original {} missing, forgetting it: {e}",
abs.display()
);
self.forget(conn, &[image])?;
Ok(None)
}
}
}
/// Whether an image's original is on this device.
pub fn holds_original(&self, conn: &Connection, image: ImageId) -> bool {
conn.query_row(
"SELECT 1 FROM image_cache
WHERE image_id = ?1 AND tier_actual >= ?2",
rusqlite::params![image.0 as i64, Tier::Original.stored()],
|_| Ok(()),
)
.is_ok()
}
/// Mark images as pinned, so they are kept regardless of the budget.
///
/// Pinning records the *intent* — `tier_desired` — without downloading
/// anything: the download needs a network, which belongs to the caller.
/// An image already cached passively becomes pinned in place, keeping its
/// bytes rather than re-fetching them.
pub fn pin(&self, conn: &Connection, images: &[ImageId]) -> Result<usize, CatalogError> {
self.set_pinned(conn, images, true)
}
/// Release a pin, returning those images to the evictable population.
///
/// The bytes stay until eviction needs the room. Deleting immediately
/// would make unpinning destructive, when it is meant only to withdraw a
/// guarantee.
pub fn unpin(&self, conn: &Connection, images: &[ImageId]) -> Result<usize, CatalogError> {
self.set_pinned(conn, images, false)
}
/// Release the pin *and* delete the bytes it was holding.
///
/// The destructive half of the pair [`unpin`](Self::unpin) deliberately is
/// not. Unpinning answers "stop promising"; this answers "give me the disk
/// back", which is the question actually being asked when a trip is over
/// and the device is full. Leaving those gigabytes to sit until some future
/// eviction happens to want the room is not an answer to it.
///
/// Nothing is lost that cannot be fetched again: the original lives on the
/// server, and the catalog row, the ratings and the edit graph are all
/// untouched here — they are authoritative and small (FR-NC-6b).
///
/// Returns how many images were released and how many bytes that freed.
/// A file that has already vanished frees nothing and is still counted as
/// released, because the row describing it goes either way.
pub fn release(
&self,
conn: &Connection,
images: &[ImageId],
) -> Result<(usize, u64), CatalogError> {
if images.is_empty() {
return Ok((0, 0));
}
// Read the paths before the rows are rewritten: `forget` clears `path`,
// and a file whose name has been forgotten cannot be deleted.
let mut held = Vec::new();
{
let mut stmt = conn.prepare(
"SELECT bytes, path FROM image_cache
WHERE image_id = ?1 AND path IS NOT NULL",
)?;
for image in images {
if let Some(row) = stmt
.query_row(rusqlite::params![image.0 as i64], |r| {
Ok((r.get::<_, i64>(0)? as u64, r.get::<_, String>(1)?))
})
.optional()?
{
held.push(row);
}
}
}
let mut freed = 0u64;
for (bytes, rel) in &held {
let abs = self.dir.join(rel);
match std::fs::remove_file(&abs) {
Ok(()) => freed += bytes,
// Already gone is the ordinary case after a crash mid-write,
// not a failure: the row still has to go, or the cache accounts
// for space nothing occupies.
Err(e) => log::debug!("releasing {}: {e}", abs.display()),
}
}
// Unpin first, then forget. The other order would leave a pinned row
// claiming an original it no longer has, which `pending_pins` would
// then dutifully download again — the exact opposite of what was asked.
self.set_pinned(conn, images, false)?;
self.forget(conn, images)?;
Ok((images.len(), freed))
}
fn set_pinned(
&self,
conn: &Connection,
images: &[ImageId],
pinned: bool,
) -> Result<usize, CatalogError> {
if images.is_empty() {
return Ok(0);
}
let tx = conn.unchecked_transaction()?;
let mut n = 0;
for image in images {
n += tx.execute(
"INSERT INTO image_cache (image_id, tier_actual, tier_desired, bytes, pinned)
VALUES (?1, ?2, ?3, 0, ?4)
ON CONFLICT(image_id) DO UPDATE SET
pinned = ?4,
-- A pin raises the target; releasing one lowers it back to
-- whatever is actually held, so a released image is not
-- left permanently claiming it wants an original.
tier_desired = CASE WHEN ?4 = 1 THEN ?3 ELSE tier_actual END",
rusqlite::params![
image.0 as i64,
Tier::Metadata.stored(),
Tier::Original.stored(),
i64::from(pinned),
],
)?;
}
tx.commit()?;
Ok(n)
}
/// Images a pin wants but which are not yet downloaded.
///
/// The work list for whatever fetches originals. Ordered by id for a
/// stable, resumable sequence rather than an arbitrary one.
pub fn pending_pins(&self, conn: &Connection) -> Result<Vec<ImageId>, CatalogError> {
let mut stmt = conn.prepare(
"SELECT image_id FROM image_cache
WHERE pinned = 1 AND tier_actual < tier_desired
ORDER BY image_id",
)?;
let rows = stmt
.query_map([], |r| Ok(ImageId(r.get::<_, i64>(0)? as u64)))?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
}
/// How full the cache is, split by population.
///
/// Counts only rows that actually hold an original: a pin that has not
/// downloaded yet occupies no disk, and counting its intent would evict
/// real files to make room for bytes that do not exist.
pub fn usage(&self, conn: &Connection) -> Result<Usage, CatalogError> {
let mut stmt = conn.prepare(
"SELECT pinned, count(*), coalesce(sum(bytes), 0)
FROM image_cache
WHERE tier_actual >= ?1
GROUP BY pinned",
)?;
let mut usage = Usage::default();
let rows = stmt.query_map(rusqlite::params![Tier::Original.stored()], |r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, i64>(1)?,
r.get::<_, i64>(2)?,
))
})?;
for row in rows {
let (pinned, count, bytes) = row?;
if pinned == 1 {
usage.pinned_count = count as usize;
usage.pinned_bytes = bytes as u64;
} else {
usage.passive_count = count as usize;
usage.passive_bytes = bytes as u64;
}
}
Ok(usage)
}
/// Evict least-recently-used passive entries until the budget is met.
///
/// Returns how many images were dropped. Pinned entries are never
/// candidates, which is the guarantee that makes a pin worth making.
///
/// A row whose file has already vanished is still dropped from the
/// catalog: it frees no disk, but leaving it would let a phantom entry
/// hold the cache permanently over budget and evict real files in its
/// place.
pub fn enforce(&self, conn: &Connection) -> Result<usize, CatalogError> {
let usage = self.usage(conn)?;
let mut over = self.budget.overage(usage.passive_bytes);
if over == 0 {
return Ok(0);
}
// Oldest first. `last_used IS NULL` sorts first deliberately: a row
// that has never been read back is the least valuable thing here.
let mut stmt = conn.prepare(
"SELECT image_id, bytes, path FROM image_cache
WHERE pinned = 0 AND tier_actual >= ?1
ORDER BY last_used IS NULL DESC, last_used ASC",
)?;
let candidates = stmt
.query_map(rusqlite::params![Tier::Original.stored()], |r| {
Ok((
ImageId(r.get::<_, i64>(0)? as u64),
r.get::<_, i64>(1)? as u64,
r.get::<_, Option<String>>(2)?,
))
})?
.collect::<Result<Vec<_>, _>>()?;
let mut evicted = Vec::new();
for (image, bytes, path) in candidates {
if over == 0 {
break;
}
if let Some(rel) = path {
let abs = self.dir.join(rel);
if let Err(e) = std::fs::remove_file(&abs) {
// Already gone is the common case and not a failure; the
// row still has to go, or it accounts for space nothing
// occupies.
log::debug!("evicting {}: {e}", abs.display());
}
}
over = over.saturating_sub(bytes);
evicted.push(image);
}
let n = evicted.len();
self.forget(conn, &evicted)?;
Ok(n)
}
/// Drop cache rows, without touching files.
///
/// The row is reduced to `Metadata` rather than deleted, so a pin recorded
/// against it survives: unpinning is the only thing that should clear a
/// pin, and eviction of the bytes is not unpinning.
fn forget(&self, conn: &Connection, images: &[ImageId]) -> Result<(), CatalogError> {
if images.is_empty() {
return Ok(());
}
let tx = conn.unchecked_transaction()?;
for image in images {
tx.execute(
"UPDATE image_cache
SET tier_actual = ?2, bytes = 0, path = NULL
WHERE image_id = ?1",
rusqlite::params![image.0 as i64, Tier::Metadata.stored()],
)?;
}
tx.commit()?;
Ok(())
}
/// Everything currently held, newest use first. For a cache management view.
pub fn entries(&self, conn: &Connection) -> Result<Vec<Entry>, CatalogError> {
let mut stmt = conn.prepare(
"SELECT image_id, tier_actual, tier_desired, bytes, last_used, pinned, path
FROM image_cache
WHERE tier_actual >= ?1
ORDER BY last_used IS NULL, last_used DESC",
)?;
let rows = stmt
.query_map(rusqlite::params![Tier::Original.stored()], |r| {
Ok(Entry {
image: ImageId(r.get::<_, i64>(0)? as u64),
tier: Tier::from_stored(r.get(1)?),
desired: Tier::from_stored(r.get(2)?),
bytes: r.get::<_, i64>(3)? as u64,
last_used: r.get(4)?,
pinned: r.get::<_, i64>(5)? == 1,
path: r.get(6)?,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Catalog;
/// Distinguishes concurrent fixtures. The harness runs tests in parallel,
/// and a shared directory would have one test's eviction delete another's
/// files.
static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// A scratch directory that is fresh for each call.
fn tempdir() -> PathBuf {
let base = std::env::temp_dir().join(format!(
"dr-cache-test-{}-{}",
std::process::id(),
SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
base
}
/// A catalog with `n` images, and a cache in a scratch directory.
fn fixture(n: usize) -> (Catalog, Cache, PathBuf, Vec<ImageId>) {
fixture_with(n, Budget::bytes(1000))
}
fn fixture_with(n: usize, budget: Budget) -> (Catalog, Cache, PathBuf, Vec<ImageId>) {
let catalog = Catalog::in_memory().unwrap();
catalog
.connection()
.execute(
"INSERT INTO roots (id, kind, label) VALUES (1, 'remote', 'test')",
[],
)
.unwrap();
let mut ids = Vec::new();
for i in 0..n {
catalog
.connection()
.execute(
"INSERT INTO images (root_id, source_ref, added_at)
VALUES (1, ?1, 0)",
rusqlite::params![format!("Photos/img{i:03}.CR2")],
)
.unwrap();
ids.push(ImageId(catalog.connection().last_insert_rowid() as u64));
}
let dir = tempdir();
let cache = Cache::open(&dir, budget).unwrap();
(catalog, cache, dir, ids)
}
#[test]
fn a_stored_original_reads_back() {
let (cat, cache, _dir, ids) = fixture(1);
cache
.store(cat.connection(), ids[0], "a.CR2", b"raw bytes", false, 10)
.unwrap();
assert!(cache.holds_original(cat.connection(), ids[0]));
assert_eq!(
cache.load(cat.connection(), ids[0], 20).unwrap().as_deref(),
Some(&b"raw bytes"[..])
);
}
#[test]
fn an_image_never_stored_is_absent() {
let (cat, cache, _dir, ids) = fixture(1);
assert!(!cache.holds_original(cat.connection(), ids[0]));
assert_eq!(cache.load(cat.connection(), ids[0], 0).unwrap(), None);
}
#[test]
fn eviction_takes_the_least_recently_used_first() {
let (cat, cache, _dir, ids) = fixture(3);
// 400 each against a 1000 budget: storing the third puts it 200 over.
let bytes = vec![0u8; 400];
cache
.store(cat.connection(), ids[0], "a.CR2", &bytes, false, 10)
.unwrap();
cache
.store(cat.connection(), ids[1], "b.CR2", &bytes, false, 20)
.unwrap();
cache
.store(cat.connection(), ids[2], "c.CR2", &bytes, false, 30)
.unwrap();
// Touch the oldest so it is no longer the least recently used.
cache.load(cat.connection(), ids[0], 40).unwrap();
assert_eq!(cache.enforce(cat.connection()).unwrap(), 1);
// ids[1] was the stalest by the time eviction ran.
assert!(!cache.holds_original(cat.connection(), ids[1]));
assert!(cache.holds_original(cat.connection(), ids[0]));
assert!(cache.holds_original(cat.connection(), ids[2]));
}
#[test]
fn a_pinned_original_is_never_evicted() {
// The guarantee the whole feature rests on: a pinned trip must still
// be there after a day of browsing pushes the cache over its cap.
let (cat, cache, _dir, ids) = fixture(3);
let bytes = vec![0u8; 800];
cache
.store(cat.connection(), ids[0], "a.CR2", &bytes, true, 10)
.unwrap();
cache
.store(cat.connection(), ids[1], "b.CR2", &bytes, false, 20)
.unwrap();
cache
.store(cat.connection(), ids[2], "c.CR2", &bytes, false, 30)
.unwrap();
cache.enforce(cat.connection()).unwrap();
assert!(
cache.holds_original(cat.connection(), ids[0]),
"the pinned original survives even though it is the oldest"
);
}
#[test]
fn releasing_a_pin_frees_the_disk_it_was_holding() {
// What "remove the local copies" has to mean. Unpinning alone leaves
// the bytes for a future eviction to notice, which is no answer at all
// to a device that is full now.
let (cat, cache, dir, ids) = fixture(2);
let bytes = vec![0u8; 700];
cache
.store(cat.connection(), ids[0], "a.CR2", &bytes, true, 10)
.unwrap();
cache
.store(cat.connection(), ids[1], "b.CR2", &bytes, true, 20)
.unwrap();
let (released, freed) = cache.release(cat.connection(), &ids).unwrap();
assert_eq!(released, 2);
assert_eq!(freed, 1400);
assert!(!cache.holds_original(cat.connection(), ids[0]));
assert_eq!(cache.usage(cat.connection()).unwrap().pinned_bytes, 0);
// The files themselves, not just the bookkeeping: a row cleared over a
// file still on disk is how a cache comes to hold gigabytes it does not
// know about.
let left: Vec<_> = walk_files(&dir);
assert!(left.is_empty(), "files remain on disk: {left:?}");
}
#[test]
fn a_released_pin_is_not_downloaded_all_over_again() {
// The failure mode of releasing in the wrong order: bytes deleted while
// the row still says an original is wanted, so the next pin fetch pulls
// the whole trip back down.
let (cat, cache, _dir, ids) = fixture(1);
cache
.store(cat.connection(), ids[0], "a.CR2", &[0u8; 100], true, 10)
.unwrap();
cache.release(cat.connection(), &ids).unwrap();
assert!(cache.pending_pins(cat.connection()).unwrap().is_empty());
}
/// Every file under `dir`, for asserting that a release left nothing.
fn walk_files(dir: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
let Ok(entries) = std::fs::read_dir(dir) else {
return out;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
out.extend(walk_files(&path));
} else {
out.push(path);
}
}
out
}
#[test]
fn pinned_bytes_do_not_count_against_the_budget() {
// Otherwise a large pin starves the passive cache into evicting
// everything, and browsing becomes uncacheable the moment a trip is
// pinned.
let (cat, cache, _dir, ids) = fixture(2);
cache
.store(
cat.connection(),
ids[0],
"a.CR2",
&vec![0u8; 5000],
true,
10,
)
.unwrap();
cache
.store(
cat.connection(),
ids[1],
"b.CR2",
&vec![0u8; 500],
false,
20,
)
.unwrap();
// Pinned use is far past the 1000 budget, but the passive 500 fits.
assert_eq!(cache.enforce(cat.connection()).unwrap(), 0);
assert!(cache.holds_original(cat.connection(), ids[1]));
let usage = cache.usage(cat.connection()).unwrap();
assert_eq!(usage.pinned_bytes, 5000);
assert_eq!(usage.passive_bytes, 500);
}
#[test]
fn an_unlimited_budget_evicts_nothing() {
let (cat, cache, _dir, ids) = fixture_with(2, Budget::unlimited());
for (i, id) in ids.iter().enumerate() {
cache
.store(
cat.connection(),
*id,
"a.CR2",
&vec![0u8; 100_000],
false,
i as i64,
)
.unwrap();
}
assert_eq!(cache.enforce(cat.connection()).unwrap(), 0);
}
#[test]
fn pinning_records_intent_without_bytes() {
// A pin is not a download: it says what should be here, and something
// with a network makes it so.
let (cat, cache, _dir, ids) = fixture(2);
cache.pin(cat.connection(), &ids).unwrap();
assert!(!cache.holds_original(cat.connection(), ids[0]));
assert_eq!(cache.pending_pins(cat.connection()).unwrap(), ids);
assert_eq!(cache.usage(cat.connection()).unwrap().pinned_bytes, 0);
}
#[test]
fn a_downloaded_pin_stops_being_pending() {
let (cat, cache, _dir, ids) = fixture(2);
cache.pin(cat.connection(), &ids).unwrap();
cache
.store(cat.connection(), ids[0], "a.CR2", b"bytes", true, 10)
.unwrap();
assert_eq!(cache.pending_pins(cat.connection()).unwrap(), vec![ids[1]]);
}
#[test]
fn pinning_an_already_cached_image_keeps_its_bytes() {
// Re-downloading something already on disk because the user pinned it
// would be the most visible possible waste.
let (cat, cache, _dir, ids) = fixture(1);
cache
.store(cat.connection(), ids[0], "a.CR2", b"raw bytes", false, 10)
.unwrap();
cache.pin(cat.connection(), &ids).unwrap();
assert!(cache.pending_pins(cat.connection()).unwrap().is_empty());
assert_eq!(
cache.load(cat.connection(), ids[0], 20).unwrap().as_deref(),
Some(&b"raw bytes"[..])
);
assert_eq!(cache.usage(cat.connection()).unwrap().pinned_bytes, 9);
}
#[test]
fn opening_a_pinned_image_does_not_unpin_it() {
// The develop path stores passively. If that overwrote `pinned`, then
// simply *looking at* a pinned photograph would silently make it
// evictable — the pin would decay through use.
let (cat, cache, _dir, ids) = fixture(1);
cache.pin(cat.connection(), &ids).unwrap();
cache
.store(cat.connection(), ids[0], "a.CR2", b"bytes", false, 10)
.unwrap();
let entries = cache.entries(cat.connection()).unwrap();
assert!(entries[0].pinned, "still pinned after a passive store");
}
#[test]
fn unpinning_keeps_the_bytes_but_makes_them_evictable() {
let (cat, cache, _dir, ids) = fixture(2);
cache
.store(cat.connection(), ids[0], "a.CR2", &vec![0u8; 800], true, 10)
.unwrap();
cache.unpin(cat.connection(), &ids[0..1]).unwrap();
// Still here — unpinning withdraws a guarantee, it does not delete.
assert!(cache.holds_original(cat.connection(), ids[0]));
// But now it is a candidate.
cache
.store(
cat.connection(),
ids[1],
"b.CR2",
&vec![0u8; 800],
false,
20,
)
.unwrap();
assert_eq!(cache.enforce(cat.connection()).unwrap(), 1);
assert!(!cache.holds_original(cat.connection(), ids[0]));
}
#[test]
fn a_missing_file_is_forgotten_rather_than_reported_present() {
// A user clearing the cache directory by hand must not leave every
// image claiming to be local while every open fails.
let (cat, cache, dir, ids) = fixture_with(1, Budget::bytes(1000));
cache
.store(cat.connection(), ids[0], "a.CR2", b"bytes", false, 10)
.unwrap();
for entry in std::fs::read_dir(&dir).unwrap() {
std::fs::remove_file(entry.unwrap().path()).unwrap();
}
assert_eq!(cache.load(cat.connection(), ids[0], 20).unwrap(), None);
assert!(!cache.holds_original(cat.connection(), ids[0]));
}
#[test]
fn storing_the_same_image_twice_is_one_entry() {
let (cat, cache, _dir, ids) = fixture(1);
cache
.store(cat.connection(), ids[0], "a.CR2", b"first", false, 10)
.unwrap();
cache
.store(cat.connection(), ids[0], "a.CR2", b"second try", false, 20)
.unwrap();
let usage = cache.usage(cat.connection()).unwrap();
assert_eq!(usage.passive_count, 1);
assert_eq!(usage.passive_bytes, 10, "the later size, not the sum");
assert_eq!(
cache.load(cat.connection(), ids[0], 30).unwrap().as_deref(),
Some(&b"second try"[..])
);
}
#[test]
fn two_images_with_the_same_filename_do_not_collide() {
// `Photos/IMG_0001.CR2` and `Trips/IMG_0001.CR2` are different
// photographs; a cache keyed on the filename would serve one for the
// other, which is the worst failure this cache could have.
let (cat, cache, _dir, ids) = fixture(2);
cache
.store(
cat.connection(),
ids[0],
"Photos/IMG_0001.CR2",
b"first",
false,
10,
)
.unwrap();
cache
.store(
cat.connection(),
ids[1],
"Trips/IMG_0001.CR2",
b"second",
false,
20,
)
.unwrap();
assert_eq!(
cache.load(cat.connection(), ids[0], 30).unwrap().as_deref(),
Some(&b"first"[..])
);
assert_eq!(
cache.load(cat.connection(), ids[1], 30).unwrap().as_deref(),
Some(&b"second"[..])
);
}
#[test]
fn eviction_stops_once_the_budget_is_met() {
// Evicting everything on a small overage would throw away a working
// set to reclaim a few bytes.
let (cat, cache, _dir, ids) = fixture(3);
for (i, id) in ids.iter().enumerate() {
cache
.store(
cat.connection(),
*id,
"a.CR2",
&vec![0u8; 400],
false,
i as i64,
)
.unwrap();
}
// 1200 held against 1000: dropping one 400-byte entry suffices.
assert_eq!(cache.enforce(cat.connection()).unwrap(), 1);
assert_eq!(cache.usage(cat.connection()).unwrap().passive_count, 2);
}
#[test]
fn an_extensionless_source_still_gets_a_path() {
let (cat, cache, _dir, ids) = fixture(1);
cache
.store(
cat.connection(),
ids[0],
"Photos/no-extension",
b"bytes",
false,
10,
)
.unwrap();
assert_eq!(
cache.load(cat.connection(), ids[0], 20).unwrap().as_deref(),
Some(&b"bytes"[..])
);
}
}
File diff suppressed because it is too large Load Diff
+308
View File
@@ -0,0 +1,308 @@
//! TRACES: FR-CAT-11
//! Has this photograph been imported before?
//!
//! Two tiers, because neither alone is enough and they cost very different
//! amounts. The metadata tier — capture time, camera, size, the name the
//! camera gave it — is answerable from the catalog before a byte leaves the
//! card, which is what makes re-inserting an already-imported card cost a
//! metadata read per file rather than a full transfer. The content tier
//! catches what the first misses: the same frame arriving under a different
//! name, from a second card, or after somebody renamed it.
//!
//! # Why the filename is compared here rather than in SQL
//!
//! `images.source_ref` holds the whole opaque key — a relative path on Linux,
//! a document id on SAF — and the camera's filename is only its last
//! component. Matching that in SQL means `LIKE '%/IMG_0001.CR3'`, which cannot
//! use an index, scans the whole table, and is wrong on SAF where the
//! separator is not `/`. So the query narrows on the indexed columns and the
//! handful of rows that survive are compared in Rust, the same way the grid
//! already derives a display name.
//!
//! # Filename alone is never sufficient
//!
//! Camera filenames wrap at `IMG_9999` and start again, so a library of any
//! age holds several unrelated `IMG_0001.CR3`. That is why the cheap tier
//! carries capture time and camera as well, and why the expensive tier exists
//! at all.
use rusqlite::Connection;
use crate::CatalogError;
/// The last component of a stored source reference.
///
/// Splits on both separators for the same reason `Catalog::window` does: the
/// key's shape belongs to the storage that produced it, and a SAF document id
/// is delimited with `:`.
fn file_name(source_ref: &str) -> &str {
source_ref.rsplit(['/', ':']).next().unwrap_or(source_ref)
}
/// Whether the catalog already holds this photograph, on metadata alone.
///
/// `camera` is the joined make-and-model string the scan stores, not the raw
/// EXIF pair — the caller composes it the same way, or the comparison is
/// always false.
///
/// A `captured_at` of `None` makes this answer `false` rather than matching
/// every undated image in the library: without a capture time the key is
/// filename plus size, which two frames from the same body collide on
/// routinely. An undated file falls through to the content tier, which is
/// slower and right.
pub fn seen_by_metadata(
conn: &Connection,
captured_at: Option<i64>,
camera: Option<&str>,
size: u64,
original_name: &str,
) -> Result<bool, CatalogError> {
let Some(captured_at) = captured_at else {
return Ok(false);
};
// `images_captured` indexes the capture time, so this reads a few rows
// even in a library of fifty thousand: one instant to the second holds
// one frame, or a handful on a body shooting a burst.
let mut stmt = conn.prepare(
"SELECT source_ref FROM images
WHERE captured_at = ?1
AND (?2 IS NULL OR camera IS ?2)
AND (file_size IS NULL OR file_size = ?3)",
)?;
let mut rows = stmt.query(rusqlite::params![captured_at, camera, size as i64])?;
while let Some(row) = rows.next()? {
let source_ref: String = row.get(0)?;
if file_name(&source_ref).eq_ignore_ascii_case(original_name) {
return Ok(true);
}
}
Ok(false)
}
/// Whether these exact bytes are already in the library.
///
/// The tier that costs a read of the file. Cheap here — `images_hash` is a
/// partial index over the rows that have one — and expensive for the caller,
/// which had to hash something to ask.
pub fn seen_by_content(conn: &Connection, digest: &str) -> Result<bool, CatalogError> {
let n: i64 = conn.query_row(
"SELECT COUNT(*) FROM images WHERE content_hash = ?1",
[digest],
|r| r.get(0),
)?;
Ok(n > 0)
}
/// Record the digest of a file the import computed.
///
/// An import reads every byte anyway, so the hash is free at that moment and
/// costs a full read of an 80 MB file at any other. Storing it is what lets
/// the *next* import answer [`seen_by_content`] without reading anything.
///
/// Matched on `source_ref` within a root, which is how the scan that just
/// catalogued the imported file identifies it. Returns how many rows were
/// updated: zero means the scan has not reached the file yet, which is a
/// normal race and not an error.
pub fn set_content_hash(
conn: &Connection,
root_id: u64,
source_ref: &str,
digest: &str,
) -> Result<usize, CatalogError> {
Ok(conn.execute(
"UPDATE images SET content_hash = ?3
WHERE root_id = ?1 AND source_ref = ?2",
rusqlite::params![root_id as i64, source_ref, digest],
)?)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Catalog;
/// A catalog holding one photograph, as a scan plus a metadata pass would
/// leave it.
fn with_one() -> Catalog {
let cat = Catalog::in_memory().unwrap();
let c = cat.connection();
c.execute(
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
[],
)
.unwrap();
c.execute(
"INSERT INTO images(root_id, source_ref, captured_at, camera, file_size,
content_hash, added_at)
VALUES (1, '2026/2026-08-22/IMG_0001.CR3', 1787407200, 'Canon EOS R5',
9, 'deadbeef', 0)",
[],
)
.unwrap();
cat
}
#[test]
fn re_inserting_the_same_card_is_recognised_before_a_transfer() {
let cat = with_one();
assert!(seen_by_metadata(
cat.connection(),
Some(1_787_407_200),
Some("Canon EOS R5"),
9,
"IMG_0001.CR3"
)
.unwrap());
}
#[test]
fn a_different_frame_at_the_same_instant_is_not_a_duplicate() {
// Two bodies firing together, or a burst. The name separates them.
let cat = with_one();
assert!(!seen_by_metadata(
cat.connection(),
Some(1_787_407_200),
Some("Canon EOS R5"),
9,
"IMG_0002.CR3"
)
.unwrap());
}
#[test]
fn the_same_name_from_a_different_camera_is_not_a_duplicate() {
// IMG_0001.CR3 exists on every card ever formatted.
let cat = with_one();
assert!(!seen_by_metadata(
cat.connection(),
Some(1_787_407_200),
Some("NIKON Z 9"),
9,
"IMG_0001.CR3"
)
.unwrap());
}
#[test]
fn the_same_name_at_a_different_time_is_not_a_duplicate() {
// The IMG_9999 wrap: the library holds an unrelated IMG_0001.CR3 from
// four years ago, and matching on name alone would refuse to import
// today's.
let cat = with_one();
assert!(!seen_by_metadata(
cat.connection(),
Some(1_600_000_000),
Some("Canon EOS R5"),
9,
"IMG_0001.CR3"
)
.unwrap());
}
#[test]
fn an_undated_file_falls_through_to_the_content_tier() {
// Not "matches everything undated" — that would silently refuse to
// import a whole card of scanned film.
let cat = with_one();
assert!(!seen_by_metadata(cat.connection(), None, None, 9, "IMG_0001.CR3").unwrap());
}
#[test]
fn a_file_that_grew_is_not_the_one_already_held() {
// A truncated earlier import, or a different rendition of the same
// frame. Same instant, same camera, same name, different bytes.
let cat = with_one();
assert!(!seen_by_metadata(
cat.connection(),
Some(1_787_407_200),
Some("Canon EOS R5"),
1234,
"IMG_0001.CR3"
)
.unwrap());
}
#[test]
fn a_row_with_no_recorded_size_still_matches() {
// The scan stores a size, but a row merged from another device may
// not have one, and refusing to match it would re-import the library.
let cat = with_one();
cat.connection()
.execute("UPDATE images SET file_size = NULL", [])
.unwrap();
assert!(seen_by_metadata(
cat.connection(),
Some(1_787_407_200),
Some("Canon EOS R5"),
9,
"IMG_0001.CR3"
)
.unwrap());
}
#[test]
fn the_same_frame_renamed_is_caught_by_its_bytes() {
let cat = with_one();
// The metadata tier misses it...
assert!(!seen_by_metadata(
cat.connection(),
Some(1_787_407_200),
Some("Canon EOS R5"),
9,
"holiday-42.CR3"
)
.unwrap());
// ...and the content tier does not.
assert!(seen_by_content(cat.connection(), "deadbeef").unwrap());
assert!(!seen_by_content(cat.connection(), "cafe").unwrap());
}
#[test]
fn a_digest_recorded_now_answers_the_next_import() {
let cat = Catalog::in_memory().unwrap();
let c = cat.connection();
c.execute(
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
[],
)
.unwrap();
c.execute(
"INSERT INTO images(root_id, source_ref, added_at)
VALUES (1, '2026/2026-08-22/IMG_0001.CR3', 0)",
[],
)
.unwrap();
assert!(!seen_by_content(c, "abc123").unwrap());
let n = set_content_hash(c, 1, "2026/2026-08-22/IMG_0001.CR3", "abc123").unwrap();
assert_eq!(n, 1);
assert!(seen_by_content(c, "abc123").unwrap());
}
#[test]
fn recording_a_digest_before_the_scan_arrives_is_not_an_error() {
// The import writes the file and the scan catalogues it; between those
// two moments there is no row to update, and that is a race rather
// than a failure.
let cat = Catalog::in_memory().unwrap();
let c = cat.connection();
c.execute(
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
[],
)
.unwrap();
assert_eq!(
set_content_hash(c, 1, "not/scanned/yet.CR3", "abc").unwrap(),
0
);
}
#[test]
fn a_name_is_the_last_component_of_either_kind_of_key() {
assert_eq!(file_name("2026/2026-08-22/IMG_0001.CR3"), "IMG_0001.CR3");
// A SAF document id delimits with a colon.
assert_eq!(file_name("primary:DCIM/Camera/IMG_1.CR3"), "IMG_1.CR3");
assert_eq!(file_name("IMG_0001.CR3"), "IMG_0001.CR3");
}
}
+76
View File
@@ -0,0 +1,76 @@
//! TRACES: NFR-ARCH-4 | NFR-R5
//! Catalog errors.
//!
//! Typed and attached to the affected subject rather than panicking — a
//! corrupt row or a failed job marks one image and lets the batch continue.
/// Something went wrong talking to the catalog.
#[derive(Debug, thiserror::Error)]
pub enum CatalogError {
#[error("sqlite: {0}")]
Sqlite(#[from] rusqlite::Error),
/// The catalog was written by a newer build.
///
/// Opening it read-write would corrupt state this build cannot represent,
/// so the app refuses and says so (NFR-R5).
#[error("catalog schema v{found} is newer than this build supports (v{supported})")]
SchemaTooNew { found: i64, supported: i64 },
/// A scan could not reach a root at all.
///
/// Distinct from "files are missing": this aborts the scan *before* the
/// deletion sweep, because every folder would look unreached and the sweep
/// would delete the whole library (FR-CAT-9).
#[error("root {0} is unreachable; scan aborted without pruning")]
RootUnreachable(u64),
/// A scan was asked for a root the catalog has no row for.
///
/// A caller's mistake rather than a user's: the row is created when the
/// grant is obtained, because the label — the path, the tree URI — is known
/// only there. Inventing one here would file the library under a name
/// nothing else would look it up by.
#[error("no such root: {0}")]
NoSuchRoot(u64),
/// A smart collection whose selector references itself, directly or via
/// another collection.
#[error("collection {0} would form a cycle")]
CollectionCycle(u64),
#[error("no such collection: {0}")]
NoSuchCollection(u64),
/// A keyword the caller named is gone — deleted, or fused into another by a
/// merge while its id sat in a UI model.
///
/// Its own variant rather than a silent no-op because the two are different
/// answers to the user: a rename that quietly did nothing looks exactly like
/// a rename that did not take.
#[error("no such keyword: {0}")]
NoSuchKeyword(u64),
/// Images were dropped onto a smart collection.
///
/// A smart collection's membership *is* its selector, so member rows would
/// be a second source of truth that nothing reads. Refused rather than
/// silently discarded, so the UI can say why the drop did nothing.
#[error("collection {0} is a saved filter; its contents cannot be edited by hand")]
SmartCollectionNotEditable(u64),
#[error("malformed stored selector: {0}")]
BadSelector(String),
/// A name the user typed that cannot be stored — blank, or one a sibling
/// already holds.
///
/// Its own variant rather than a reused `BadSelector`, because this one is
/// shown to the user verbatim: it has to read as a sentence about their
/// collection, not as a diagnostic about a stored selector.
#[error("{0}")]
BadName(String),
#[error("io: {0}")]
Io(String),
}
+392
View File
@@ -0,0 +1,392 @@
//! TRACES: FR-CAT-3 | NFR-ARCH-2 | FR-PLAT-AND-3
//! The background work queue.
//!
//! Jobs live in the catalog, so they survive process death — routine on
//! Android rather than exceptional (FR-PLAT-AND-3). Two properties carry the
//! design:
//!
//! - **Coalescing.** `UNIQUE(kind, subject_id)` makes enqueueing idempotent,
//! so every code path that notices a change can just enqueue and let the
//! table absorb the redundancy.
//! - **Priority shared with the GPU scheduler** (ARCH §5.3), so one notion of
//! urgency governs the whole app and visible work always preempts bulk work.
use rusqlite::Connection;
use crate::error::CatalogError;
/// What a job does.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(i64)]
pub enum JobKind {
/// Recursive incremental scan from a folder (§scan).
ScanFolder = 0,
/// Promote an image from stat-only to full EXIF.
ExtractMetadata = 1,
/// Build or rebuild a thumbnail.
Thumbnail = 2,
/// A sidecar on disk is newer than what the catalog read.
ReadSidecar = 3,
/// Flush a local edit to its sidecar. Debounced, never per slider tick.
WriteSidecar = 4,
/// Whole-file hash. On demand only — import dedup, reconnect-by-hash.
ContentHash = 5,
/// Range-extract an embedded preview from a remote file (FR-NC-3).
FetchPreview = 6,
/// Fetch a full original: pinned by rule, or explicitly asked for.
FetchOriginal = 7,
}
impl JobKind {
fn from_i64(v: i64) -> Option<Self> {
Some(match v {
0 => JobKind::ScanFolder,
1 => JobKind::ExtractMetadata,
2 => JobKind::Thumbnail,
3 => JobKind::ReadSidecar,
4 => JobKind::WriteSidecar,
5 => JobKind::ContentHash,
6 => JobKind::FetchPreview,
7 => JobKind::FetchOriginal,
_ => return None,
})
}
/// Whether this job transfers over the network, and so is subject to the
/// metered-connection and charging constraints in FR-NC-6.
pub fn is_network(self) -> bool {
matches!(self, JobKind::FetchPreview | JobKind::FetchOriginal)
}
}
/// Scheduling class, matching the GPU tile scheduler (ARCH §5.3).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[repr(i64)]
pub enum Priority {
/// Bulk work: metadata sweeps, rule-driven fetches, hashing.
Background = 0,
/// Just outside the viewport; the next image in culling.
Prefetch = 1,
/// Visible cells, and the image currently open.
///
/// Strictly preempts background work. Without this, scrolling during a
/// bulk thumbnail pass misses its frame budget — the common case, not an
/// edge case (NFR-ARCH-2).
Interactive = 2,
}
/// Lifecycle state.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(i64)]
pub enum JobState {
Pending = 0,
Running = 1,
Failed = 2,
}
/// A job ready to run.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Job {
pub id: i64,
pub kind: JobKind,
pub subject_id: Option<i64>,
pub priority: Priority,
pub attempts: i64,
pub payload: Option<String>,
}
/// Give up after this many attempts and attach the error to the subject.
///
/// One corrupt file must not stall the queue behind endless retries
/// (FR-RAW-4).
pub const MAX_ATTEMPTS: i64 = 5;
/// Backoff before retrying a failed job, in seconds.
///
/// Exponential, capped — a server that is down for an hour should not be
/// retried every second, and a transient decode failure should not wait an
/// hour.
pub fn backoff_seconds(attempts: i64) -> i64 {
const CAP: i64 = 300;
match attempts {
a if a <= 0 => 0,
a if a >= 9 => CAP,
a => (1i64 << (a - 1)).min(CAP),
}
}
/// Enqueue work, coalescing with any identical pending job.
///
/// Re-requesting at a higher priority *promotes* the existing row rather than
/// duplicating it, which is what lets the grid shout "this one is visible now"
/// about a job already queued in the background.
pub fn enqueue(
conn: &Connection,
kind: JobKind,
subject_id: Option<i64>,
priority: Priority,
payload: Option<&str>,
) -> Result<(), CatalogError> {
conn.execute(
"INSERT INTO jobs(kind, subject_id, priority, state, payload)
VALUES (?1, ?2, ?3, 0, ?4)
ON CONFLICT(kind, subject_id) DO UPDATE SET
priority = max(jobs.priority, excluded.priority),
-- A job that failed and is being re-requested deserves a fresh
-- start: the file may well have changed since it failed.
state = CASE WHEN jobs.state = 2 THEN 0 ELSE jobs.state END,
attempts = CASE WHEN jobs.state = 2 THEN 0 ELSE jobs.attempts END,
not_before = CASE WHEN jobs.state = 2 THEN 0 ELSE jobs.not_before END",
rusqlite::params![kind as i64, subject_id, priority as i64, payload],
)?;
Ok(())
}
/// Claim the next runnable job, highest priority first.
///
/// `now` is passed rather than read from the clock so backoff is testable.
/// Claiming marks the row `Running` in the same transaction as the read, so
/// two workers cannot take the same job.
pub fn claim_next(conn: &Connection, now: i64) -> Result<Option<Job>, CatalogError> {
let tx = conn.unchecked_transaction()?;
let job = tx
.query_row(
"SELECT id, kind, subject_id, priority, attempts, payload
FROM jobs
WHERE state = 0 AND not_before <= ?1
ORDER BY priority DESC, id ASC
LIMIT 1",
[now],
|r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, i64>(1)?,
r.get::<_, Option<i64>>(2)?,
r.get::<_, i64>(3)?,
r.get::<_, i64>(4)?,
r.get::<_, Option<String>>(5)?,
))
},
)
.ok();
let Some((id, kind, subject_id, priority, attempts, payload)) = job else {
return Ok(None);
};
tx.execute(
"UPDATE jobs SET state = 1, attempts = attempts + 1 WHERE id = ?1",
[id],
)?;
tx.commit()?;
Ok(Some(Job {
id,
kind: JobKind::from_i64(kind).unwrap_or(JobKind::ExtractMetadata),
subject_id,
priority: match priority {
2 => Priority::Interactive,
1 => Priority::Prefetch,
_ => Priority::Background,
},
attempts: attempts + 1,
payload,
}))
}
/// Job finished successfully.
pub fn complete(conn: &Connection, id: i64) -> Result<(), CatalogError> {
conn.execute("DELETE FROM jobs WHERE id = ?1", [id])?;
Ok(())
}
/// Job failed. Reschedules with backoff, or gives up past [`MAX_ATTEMPTS`].
pub fn fail(conn: &Connection, job: &Job, now: i64, err: &str) -> Result<(), CatalogError> {
if job.attempts >= MAX_ATTEMPTS {
conn.execute(
"UPDATE jobs SET state = 2, last_error = ?2 WHERE id = ?1",
rusqlite::params![job.id, err],
)?;
} else {
conn.execute(
"UPDATE jobs SET state = 0, not_before = ?2, last_error = ?3 WHERE id = ?1",
rusqlite::params![job.id, now + backoff_seconds(job.attempts), err],
)?;
}
Ok(())
}
/// Recover jobs orphaned by process death.
///
/// A row left `Running` has no owner — the process that claimed it is gone.
/// Called at startup, before any worker begins (FR-PLAT-AND-3).
pub fn recover_orphaned(conn: &Connection) -> Result<usize, CatalogError> {
let n = conn.execute("UPDATE jobs SET state = 0 WHERE state = 1", [])?;
Ok(n)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::schema;
fn db() -> Connection {
let c = Connection::open_in_memory().unwrap();
schema::configure(&c).unwrap();
schema::migrate(&c).unwrap();
c
}
#[test]
fn repeated_enqueue_coalesces() {
let c = db();
for _ in 0..10 {
enqueue(&c, JobKind::Thumbnail, Some(1), Priority::Background, None).unwrap();
}
let n: i64 = c
.query_row("SELECT count(*) FROM jobs", [], |r| r.get(0))
.unwrap();
assert_eq!(n, 1);
}
#[test]
fn re_enqueueing_at_higher_priority_promotes() {
let c = db();
enqueue(&c, JobKind::Thumbnail, Some(1), Priority::Background, None).unwrap();
// The grid scrolls this image into view.
enqueue(&c, JobKind::Thumbnail, Some(1), Priority::Interactive, None).unwrap();
let p: i64 = c
.query_row("SELECT priority FROM jobs", [], |r| r.get(0))
.unwrap();
assert_eq!(p, Priority::Interactive as i64);
}
#[test]
fn priority_never_regresses() {
let c = db();
enqueue(&c, JobKind::Thumbnail, Some(1), Priority::Interactive, None).unwrap();
// A background sweep must not demote work the user is waiting on.
enqueue(&c, JobKind::Thumbnail, Some(1), Priority::Background, None).unwrap();
let p: i64 = c
.query_row("SELECT priority FROM jobs", [], |r| r.get(0))
.unwrap();
assert_eq!(p, Priority::Interactive as i64);
}
#[test]
fn claim_takes_highest_priority_first() {
let c = db();
enqueue(&c, JobKind::Thumbnail, Some(1), Priority::Background, None).unwrap();
enqueue(&c, JobKind::Thumbnail, Some(2), Priority::Interactive, None).unwrap();
enqueue(&c, JobKind::Thumbnail, Some(3), Priority::Prefetch, None).unwrap();
let first = claim_next(&c, 0).unwrap().unwrap();
assert_eq!(first.subject_id, Some(2));
let second = claim_next(&c, 0).unwrap().unwrap();
assert_eq!(second.subject_id, Some(3));
}
#[test]
fn a_claimed_job_is_not_claimed_twice() {
let c = db();
enqueue(&c, JobKind::Thumbnail, Some(1), Priority::Background, None).unwrap();
assert!(claim_next(&c, 0).unwrap().is_some());
assert!(claim_next(&c, 0).unwrap().is_none());
}
#[test]
fn failure_backs_off_then_becomes_claimable_again() {
let c = db();
enqueue(
&c,
JobKind::FetchPreview,
Some(1),
Priority::Background,
None,
)
.unwrap();
let job = claim_next(&c, 100).unwrap().unwrap();
fail(&c, &job, 100, "network down").unwrap();
// Still backing off.
assert!(claim_next(&c, 100).unwrap().is_none());
// Past the backoff.
assert!(claim_next(&c, 100 + backoff_seconds(job.attempts))
.unwrap()
.is_some());
}
#[test]
fn a_persistently_failing_job_stops_retrying() {
let c = db();
enqueue(
&c,
JobKind::ExtractMetadata,
Some(1),
Priority::Background,
None,
)
.unwrap();
let mut now = 0;
for _ in 0..MAX_ATTEMPTS {
let job = claim_next(&c, now).unwrap().expect("should be claimable");
fail(&c, &job, now, "corrupt file").unwrap();
now += backoff_seconds(job.attempts);
}
// One corrupt file must not stall the queue forever (FR-RAW-4).
assert!(claim_next(&c, now + 100_000).unwrap().is_none());
let state: i64 = c
.query_row("SELECT state FROM jobs", [], |r| r.get(0))
.unwrap();
assert_eq!(state, JobState::Failed as i64);
}
#[test]
fn re_requesting_a_failed_job_gives_it_a_fresh_start() {
let c = db();
enqueue(&c, JobKind::Thumbnail, Some(1), Priority::Background, None).unwrap();
let mut now = 0;
for _ in 0..MAX_ATTEMPTS {
let job = claim_next(&c, now).unwrap().unwrap();
fail(&c, &job, now, "boom").unwrap();
now += backoff_seconds(job.attempts);
}
// The file changed on disk, so the old failure says nothing about it.
enqueue(&c, JobKind::Thumbnail, Some(1), Priority::Interactive, None).unwrap();
let job = claim_next(&c, now).unwrap().expect("retryable again");
assert_eq!(job.attempts, 1);
}
#[test]
fn orphaned_jobs_return_to_pending_on_restart() {
let c = db();
enqueue(&c, JobKind::Thumbnail, Some(1), Priority::Background, None).unwrap();
claim_next(&c, 0).unwrap().unwrap();
// Process dies here. Android does this routinely.
assert_eq!(recover_orphaned(&c).unwrap(), 1);
assert!(claim_next(&c, 0).unwrap().is_some());
}
#[test]
fn backoff_grows_then_caps() {
assert_eq!(backoff_seconds(0), 0);
assert_eq!(backoff_seconds(1), 1);
assert_eq!(backoff_seconds(3), 4);
assert_eq!(backoff_seconds(100), 300);
}
#[test]
fn network_jobs_are_identifiable_for_metered_gating() {
// FR-NC-6: transfers respect unmetered-network and charging
// constraints; local work must not be gated by them.
assert!(JobKind::FetchOriginal.is_network());
assert!(JobKind::FetchPreview.is_network());
assert!(!JobKind::Thumbnail.is_network());
assert!(!JobKind::ExtractMetadata.is_network());
}
}
File diff suppressed because it is too large Load Diff
+449
View File
@@ -0,0 +1,449 @@
//! TRACES: FR-CAT-2 | FR-CAT-4 | FR-CAT-6 | NFR-P1
//! The catalog: a rebuildable index over the library.
//!
//! Not a source of truth. Sidecars next to the images hold the authoritative
//! edit state (ARCH §6.12), and this file is deletable at any time — rebuilt
//! by rescanning sources and reading sidecars. That inversion is deliberate:
//! darktable maintains both a database and sidecars while achieving the
//! reliability of neither.
//!
//! # What lives here
//!
//! - [`schema`] — tables and forward-only migrations
//! - [`scan`] — incremental discovery that prunes unchanged directories
//! - [`walk`] — those decisions driven against real storage, local or SAF
//! - [`query`] — selectors compiled to indexed SQL, windowed for the grid
//! - [`collections`] — the collection tree and membership the UI edits
//! - [`keywords`] — the keyword vocabulary and what it is assigned to
//! - [`jobs`] — the durable background work queue
//! - [`trash`] — soft delete to a folder, then permanent delete
//! - [`merge`] / [`sync`] — cross-device merging of collections and keywords
//!
//! # The one thing everything is designed around
//!
//! **Work is proportional to what changed, or to what the user is looking at —
//! never to library size.** A 50k-image library that has not changed costs one
//! metadata probe per folder to verify (§scan), no thumbnails to regenerate
//! (§jobs coalescing), and no rule evaluation per grid cell (materialised
//! `tier_desired`).
use std::path::Path;
use dr_types::{Availability, ImageId};
use rusqlite::Connection;
pub mod cache;
pub mod collections;
pub mod dedup;
pub mod error;
pub mod jobs;
pub mod keywords;
pub mod merge;
pub mod query;
pub mod rating;
pub mod scan;
pub mod schema;
pub mod sync;
pub mod trash;
pub mod walk;
pub use cache::{Budget, Cache, DEFAULT_BUDGET_BYTES};
pub use collections::{Collection, CollectionKind, TreeRow};
pub use dedup::{seen_by_content, seen_by_metadata, set_content_hash};
pub use error::CatalogError;
pub use jobs::{Job, JobKind, Priority};
pub use keywords::{Coverage, Keyword, KeywordId, SelectionKeyword};
pub use merge::MergeReport;
pub use query::{Query, Sort};
pub use rating::{Judgement, MAX_RATING};
pub use scan::{DirAction, DirState, EntryAction, ScanOutcome};
pub use trash::{TrashedImage, TRASH_DIR};
pub use walk::{ensure_root, scan_root, RootKind, ScanProgress, ScanReport};
/// One row of the library grid.
///
/// Exactly what a cell draws and nothing more — no join per cell, and
/// availability reads a materialised column rather than evaluating cache rules
/// (ARCH §9.5).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GridRow {
pub id: ImageId,
pub name: String,
pub availability: Availability,
/// UTC seconds. `None` until EXIF has been read.
pub captured_at: Option<i64>,
/// Minutes east of UTC, for rendering the photographer's local time.
pub captured_offset: Option<i32>,
/// 0 = nothing, 1 = stat-only, 2 = full EXIF.
pub metadata_state: u8,
}
/// A count of images in one time bucket, for the timeline scrubber.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TimeBucket {
/// UTC seconds at the bucket's start.
pub start: i64,
pub count: u32,
}
/// Time bucket size.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Granularity {
Year,
Month,
Day,
Hour,
}
impl Granularity {
/// SQLite `strftime` format that collapses a timestamp to this bucket.
///
/// Applied to **local** time, not UTC: "everything from 3 August" means
/// the photographer's 3 August, which is why `captured_offset` is stored
/// alongside the UTC timestamp.
/// Public so a caller that must build its own bucketing query — one
/// joining collection membership, say — buckets identically to
/// [`Catalog::timeline_range`] rather than reimplementing the format.
pub fn strftime(self) -> &'static str {
match self {
Granularity::Year => "%Y",
Granularity::Month => "%Y-%m",
Granularity::Day => "%Y-%m-%d",
Granularity::Hour => "%Y-%m-%dT%H",
}
}
/// A sensible bucket size for a span of seconds, so the UI need not guess.
pub fn for_span(seconds: i64) -> Self {
const DAY: i64 = 86_400;
match seconds {
s if s > 5 * 365 * DAY => Granularity::Year,
s if s > 90 * DAY => Granularity::Month,
s if s > 2 * DAY => Granularity::Day,
_ => Granularity::Hour,
}
}
}
/// A connection to the catalog.
pub struct Catalog {
conn: Connection,
}
impl Catalog {
/// Open or create a catalog, migrating it forward if needed.
pub fn open(path: &Path) -> Result<Self, CatalogError> {
let conn = Connection::open(path)?;
schema::configure(&conn)?;
let from = schema::migrate(&conn)?;
// A migration adds a column; it cannot know what the value should be
// for rows that already existed. Backfilling on open is what stops
// those rows being silently partial.
for (what, n) in schema::backfill(&conn)? {
log::info!("backfilled {what} for {n} row(s) (schema was v{from})");
}
Ok(Catalog { conn })
}
/// An in-memory catalog, for tests and for a throwaway import preview.
pub fn in_memory() -> Result<Self, CatalogError> {
let conn = Connection::open_in_memory()?;
schema::configure(&conn)?;
schema::migrate(&conn)?;
schema::backfill(&conn)?;
Ok(Catalog { conn })
}
/// Escape hatch for modules that need raw access. Not part of the UI-facing
/// surface.
pub fn connection(&self) -> &Connection {
&self.conn
}
/// How many images match.
///
/// Returned alongside the first window so the grid can size its scrollbar
/// and paint in one round trip.
pub fn count(&self, q: &Query, now: i64) -> Result<usize, CatalogError> {
let c = query::compile(&q.filter, now);
let sql = query::count_sql(&c);
let n: i64 =
self.conn
.query_row(&sql, rusqlite::params_from_iter(c.params.iter()), |r| {
r.get(0)
})?;
Ok(n as usize)
}
/// Fetch one window of results.
///
/// Never returns the whole catalog: FR-CAT-4 requires memory bounded
/// independently of library size.
pub fn window(
&self,
q: &Query,
range: std::ops::Range<usize>,
now: i64,
) -> Result<Vec<GridRow>, CatalogError> {
let c = query::compile(&q.filter, now);
let sql = query::window_sql(q, &c);
let mut params = c.params.clone();
params.push(rusqlite::types::Value::Integer(range.len() as i64));
params.push(rusqlite::types::Value::Integer(range.start as i64));
let mut stmt = self.conn.prepare(&sql)?;
let rows = stmt
.query_map(rusqlite::params_from_iter(params.iter()), |r| {
let source_ref: String = r.get(1)?;
let avail: i64 = r.get(2)?;
Ok(GridRow {
id: ImageId(r.get::<_, i64>(0)? as u64),
name: source_ref
.rsplit(['/', ':'])
.next()
.unwrap_or(&source_ref)
.to_string(),
availability: decode_availability(avail),
captured_at: r.get(3)?,
captured_offset: r.get::<_, Option<i64>>(4)?.map(|v| v as i32),
metadata_state: r.get::<_, i64>(5)? as u8,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
}
/// Counts per time bucket, for the timeline scrubber.
///
/// One grouped aggregate over the `images_captured` index — not 50k rows
/// handed to the UI to bucket itself.
pub fn timeline(
&self,
q: &Query,
g: Granularity,
now: i64,
) -> Result<Vec<TimeBucket>, CatalogError> {
let c = query::compile(&q.filter, now);
// Bucketed in local time: captured_offset is minutes east of UTC, and
// NULL falls back to UTC rather than dropping the row.
let sql = format!(
"SELECT min(captured_at) AS start,
count(*) AS n
FROM images
-- A shadowed JPEG is the same frame as its RAW; counting both
-- would double every paired shot in the histogram.
WHERE {} AND captured_at IS NOT NULL AND shadowed_by IS NULL
GROUP BY strftime('{}', captured_at + coalesce(captured_offset, 0) * 60,
'unixepoch')
ORDER BY start ASC",
c.where_sql,
g.strftime()
);
let mut stmt = self.conn.prepare(&sql)?;
let rows = stmt
.query_map(rusqlite::params_from_iter(c.params.iter()), |r| {
Ok(TimeBucket {
start: r.get(0)?,
count: r.get::<_, i64>(1)? as u32,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
}
/// Counts per time bucket, bounded to a date range.
///
/// What a zoomed timeline needs: [`timeline`](Self::timeline) always spans
/// the whole library, so zooming in would return the same coarse buckets
/// with the ends cropped rather than finer detail over a narrower span.
pub fn timeline_range(
&self,
q: &Query,
g: Granularity,
from: i64,
to: i64,
now: i64,
) -> Result<Vec<TimeBucket>, CatalogError> {
let c = query::compile(&q.filter, now);
let sql = format!(
"SELECT min(captured_at) AS start,
count(*) AS n
FROM images
WHERE {} AND captured_at IS NOT NULL AND shadowed_by IS NULL
AND captured_at >= ?{} AND captured_at <= ?{}
GROUP BY strftime('{}', captured_at + coalesce(captured_offset, 0) * 60,
'unixepoch')
ORDER BY start ASC",
c.where_sql,
c.params.len() + 1,
c.params.len() + 2,
g.strftime()
);
let mut params = c.params.clone();
params.push(rusqlite::types::Value::Integer(from));
params.push(rusqlite::types::Value::Integer(to));
let mut stmt = self.conn.prepare(&sql)?;
let rows = stmt
.query_map(rusqlite::params_from_iter(params.iter()), |r| {
Ok(TimeBucket {
start: r.get(0)?,
count: r.get::<_, i64>(1)? as u32,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
}
/// Merge a downloaded remote catalog's collections into this one.
///
/// See [`sync`] for why only collections cross over.
pub fn merge_remote_catalog(&self, remote: &Path) -> Result<MergeReport, CatalogError> {
sync::merge_remote(&self.conn, remote)
}
/// Write a consistent snapshot ready to upload.
pub fn snapshot_for_upload(&self, dest: &Path) -> Result<(), CatalogError> {
sync::snapshot_for_upload(&self.conn, dest)
}
}
fn decode_availability(v: i64) -> Availability {
match v {
1 => Availability::Preview,
2 => Availability::Original,
3 => Availability::Offline,
_ => Availability::MetadataOnly,
}
}
#[cfg(test)]
mod tests {
use super::*;
use dr_types::Selector;
fn seeded() -> Catalog {
let cat = Catalog::in_memory().unwrap();
let c = cat.connection();
c.execute(
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
[],
)
.unwrap();
// Three images across two days, one with no EXIF read yet.
for (id, name, captured, state) in [
(1i64, "a.CR3", Some(1_000_000i64), 2i64),
(2, "b.CR3", Some(1_100_000), 2),
(3, "c.CR3", None, 1),
] {
c.execute(
"INSERT INTO images(id, root_id, source_ref, captured_at, metadata_state, added_at)
VALUES (?1, 1, ?2, ?3, ?4, 0)",
rusqlite::params![id, name, captured, state],
)
.unwrap();
}
cat
}
#[test]
fn count_and_window_agree() {
let cat = seeded();
let q = Query::default();
assert_eq!(cat.count(&q, 0).unwrap(), 3);
assert_eq!(cat.window(&q, 0..10, 0).unwrap().len(), 3);
}
#[test]
fn window_is_bounded_by_the_requested_range() {
// FR-CAT-4: memory independent of catalog size.
let cat = seeded();
let rows = cat.window(&Query::default(), 0..2, 0).unwrap();
assert_eq!(rows.len(), 2);
}
#[test]
fn paging_covers_every_row_exactly_once() {
let cat = seeded();
let q = Query::default();
let mut seen = Vec::new();
for start in (0..3).step_by(2) {
seen.extend(cat.window(&q, start..start + 2, 0).unwrap());
}
let mut ids: Vec<u64> = seen.iter().map(|r| r.id.0).collect();
ids.sort_unstable();
assert_eq!(ids, vec![1, 2, 3]);
}
#[test]
fn an_image_without_capture_time_sorts_last_not_first() {
// Otherwise a freshly scanned library leads with whatever has not been
// read yet, which looks like corruption to the user.
let cat = seeded();
let rows = cat.window(&Query::default(), 0..10, 0).unwrap();
assert_eq!(rows.last().unwrap().id, ImageId(3));
}
#[test]
fn metadata_state_reaches_the_grid() {
// The grid needs it to distinguish "no photos on this date" from
// "EXIF not read yet" (FR-NC-6c's honesty principle).
let cat = seeded();
let rows = cat.window(&Query::default(), 0..10, 0).unwrap();
let pending = rows.iter().find(|r| r.id == ImageId(3)).unwrap();
assert_eq!(pending.metadata_state, 1);
}
#[test]
fn a_filter_narrows_the_count() {
let cat = seeded();
let q = Query {
filter: Selector::Text("a.CR3".into()),
..Default::default()
};
assert_eq!(cat.count(&q, 0).unwrap(), 1);
}
#[test]
fn timeline_buckets_and_skips_unread_images() {
let cat = seeded();
let buckets = cat
.timeline(&Query::default(), Granularity::Day, 0)
.unwrap();
// Two images with timestamps, one day apart in UTC; the third has no
// capture time and cannot be placed on a timeline at all.
let total: u32 = buckets.iter().map(|b| b.count).sum();
assert_eq!(total, 2);
}
#[test]
fn timeline_granularity_follows_the_span() {
const DAY: i64 = 86_400;
assert_eq!(Granularity::for_span(10 * 365 * DAY), Granularity::Year);
assert_eq!(Granularity::for_span(120 * DAY), Granularity::Month);
assert_eq!(Granularity::for_span(10 * DAY), Granularity::Day);
assert_eq!(Granularity::for_span(3600), Granularity::Hour);
}
#[test]
fn names_are_derived_for_both_paths_and_saf_ids() {
let cat = Catalog::in_memory().unwrap();
let c = cat.connection();
c.execute(
"INSERT INTO roots(id, kind, label) VALUES (1, 'saf', 'tree')",
[],
)
.unwrap();
c.execute(
"INSERT INTO images(id, root_id, source_ref, added_at)
VALUES (1, 1, 'primary:DCIM/Camera/IMG_1.CR3', 0)",
[],
)
.unwrap();
let rows = cat.window(&Query::default(), 0..10, 0).unwrap();
assert_eq!(rows[0].name, "IMG_1.CR3");
}
}
File diff suppressed because it is too large Load Diff
+514
View File
@@ -0,0 +1,514 @@
//! TRACES: FR-CAT-4 | FR-CAT-6
//! Compiling a [`Selector`] into indexed SQL, and windowing the result.
//!
//! The UI never assembles SQL — it hands over a [`Query`] and receives a
//! window. Two properties matter:
//!
//! 1. **Nothing user-supplied is interpolated into SQL text.** Every value
//! binds as a parameter; `LIKE` patterns have their wildcards escaped.
//! 2. **Predicates hit indices.** Filtering 50k images must stay interactive
//! (FR-CAT-6), which means no expression over a column that would defeat
//! its index.
use dr_types::{Availability, ColourLabel, DateSelector, FlagState, Selector};
use rusqlite::types::Value;
/// What to show, and in what order.
#[derive(Debug, Clone)]
pub struct Query {
pub filter: Selector,
pub sort: Sort,
pub descending: bool,
}
impl Default for Query {
fn default() -> Self {
Query {
filter: Selector::All,
sort: Sort::CapturedAt,
descending: true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Sort {
CapturedAt,
Added,
FileName,
Rating,
/// Manual order within a collection. Falls back to capture time where the
/// query is not scoped to one collection, since position is meaningless
/// outside it.
CollectionPosition,
}
impl Sort {
/// The ORDER BY fragment. Fixed strings — never user input.
///
/// Capture time sorts NULLs last regardless of direction: an image whose
/// EXIF has not been read yet (metadata_state 1) should not lead the grid
/// simply because its timestamp is unknown.
fn sql(self, descending: bool) -> &'static str {
match (self, descending) {
(Sort::CapturedAt, false) => {
"ORDER BY images.captured_at IS NULL, images.captured_at ASC, images.id ASC"
}
(Sort::CapturedAt, true) => {
"ORDER BY images.captured_at IS NULL, images.captured_at DESC, images.id DESC"
}
(Sort::Added, false) => "ORDER BY images.added_at ASC, images.id ASC",
(Sort::Added, true) => "ORDER BY images.added_at DESC, images.id DESC",
(Sort::FileName, false) => "ORDER BY images.source_ref ASC, images.id ASC",
(Sort::FileName, true) => "ORDER BY images.source_ref DESC, images.id DESC",
(Sort::Rating, false) => "ORDER BY v.rating ASC, images.id ASC",
(Sort::Rating, true) => "ORDER BY v.rating DESC, images.id DESC",
(Sort::CollectionPosition, false) => {
"ORDER BY cm.position IS NULL, cm.position ASC, images.captured_at ASC"
}
(Sort::CollectionPosition, true) => {
"ORDER BY cm.position IS NULL, cm.position DESC, images.captured_at DESC"
}
}
}
/// Whether this sort needs the default-version join.
fn needs_version(self) -> bool {
matches!(self, Sort::Rating)
}
/// Whether this sort needs a collection-membership join.
fn needs_membership(self) -> bool {
matches!(self, Sort::CollectionPosition)
}
}
/// A compiled WHERE clause plus its bound parameters.
///
/// Kept separate from the statement so `count` and `window` can share one
/// compilation.
#[derive(Debug, Default)]
pub struct Compiled {
pub where_sql: String,
pub params: Vec<Value>,
/// True if the filter depends on capture time, and therefore on EXIF that
/// a freshly scanned library may not have read yet. The UI surfaces this
/// rather than silently under-reporting.
pub needs_capture_time: bool,
}
/// Compile a selector to SQL against the `images` table.
///
/// `now` is passed rather than read from the clock so a rolling window is
/// reproducible in tests and consistent across one query.
pub fn compile(filter: &Selector, now: i64) -> Compiled {
let mut params = Vec::new();
let sql = if filter.is_unfiltered() {
"1".to_string()
} else {
emit(filter, now, &mut params)
};
Compiled {
where_sql: sql,
params,
needs_capture_time: filter.needs_capture_time(),
}
}
fn emit(s: &Selector, now: i64, p: &mut Vec<Value>) -> String {
match s {
Selector::All => "1".into(),
Selector::Collection(id) => {
p.push(Value::Integer(id.0 as i64));
format!(
"EXISTS (SELECT 1 FROM collection_members m
WHERE m.image_id = images.id AND m.collection_id = ?{})",
p.len()
)
}
Selector::Folder {
root,
path,
recursive,
} => {
p.push(Value::Integer(root.0 as i64));
let root_ix = p.len();
if *recursive {
// Prefix match on the folder path. `like_prefix` escapes the
// pattern metacharacters, so a folder literally named "50%"
// matches itself and not everything.
p.push(Value::Text(like_prefix(path)));
format!(
"images.folder_id IN (
SELECT id FROM folders
WHERE root_id = ?{root_ix}
AND (path = ?{p} OR path LIKE ?{p} || '/%' ESCAPE '\\'))",
p = p.len()
)
} else {
p.push(Value::Text(path.clone()));
format!(
"images.folder_id IN (
SELECT id FROM folders WHERE root_id = ?{root_ix} AND path = ?{})",
p.len()
)
}
}
Selector::DateRange(d) => emit_date(d, now, p),
Selector::Rating { min } => {
p.push(Value::Integer(*min as i64));
format!("{} >= ?{}", default_version_scalar("rating"), p.len())
}
Selector::Label(l) => {
p.push(Value::Integer(label_code(*l)));
format!("{} = ?{}", default_version_scalar("label"), p.len())
}
Selector::Flag(f) => {
p.push(Value::Integer(flag_code(*f)));
format!("{} = ?{}", default_version_scalar("flag"), p.len())
}
Selector::Keyword(k) => {
p.push(Value::Text(k.clone()));
format!(
"EXISTS (SELECT 1 FROM keywords kw
JOIN versions kv ON kv.id = kw.version_id
WHERE kv.image_id = images.id AND kw.keyword = ?{})",
p.len()
)
}
Selector::Camera(c) => {
p.push(Value::Text(c.clone()));
format!("images.camera = ?{}", p.len())
}
Selector::Lens(l) => {
p.push(Value::Text(l.clone()));
format!("images.lens = ?{}", p.len())
}
Selector::IsoRange { min, max } => {
p.push(Value::Integer(*min as i64));
let lo = p.len();
p.push(Value::Integer(*max as i64));
format!("images.iso BETWEEN ?{lo} AND ?{}", p.len())
}
Selector::Availability(a) => {
p.push(Value::Integer(availability_code(*a)));
format!("images.availability = ?{}", p.len())
}
Selector::Text(t) => {
// Substring over filename and keywords. A LIKE scan is adequate at
// 50k; if free text over title and description becomes a real
// workflow, FTS5 is the answer and it is additive.
p.push(Value::Text(format!("%{}%", escape_like(t))));
let ix = p.len();
format!(
"(images.source_ref LIKE ?{ix} ESCAPE '\\'
OR EXISTS (SELECT 1 FROM keywords kw
JOIN versions kv ON kv.id = kw.version_id
WHERE kv.image_id = images.id
AND kw.keyword LIKE ?{ix} ESCAPE '\\'))"
)
}
// An empty conjunction is vacuously true; an empty disjunction matches
// nothing. Both arise from a UI that lets every term be cleared, and
// conflating them would show the whole library when the user meant the
// opposite.
Selector::All_(v) if v.is_empty() => "1".into(),
Selector::Any(v) if v.is_empty() => "0".into(),
Selector::All_(v) => join(v, " AND ", now, p),
Selector::Any(v) => join(v, " OR ", now, p),
Selector::Not(inner) => format!("NOT ({})", emit(inner, now, p)),
}
}
fn join(items: &[Selector], op: &str, now: i64, p: &mut Vec<Value>) -> String {
let parts: Vec<String> = items.iter().map(|s| emit(s, now, p)).collect();
format!("({})", parts.join(op))
}
fn emit_date(d: &DateSelector, now: i64, p: &mut Vec<Value>) -> String {
match d {
DateSelector::Between { from, to } => {
p.push(Value::Integer(*from));
let lo = p.len();
p.push(Value::Integer(*to));
// Half-open, so adjacent ranges neither overlap nor gap.
format!(
"(images.captured_at >= ?{lo} AND images.captured_at < ?{})",
p.len()
)
}
DateSelector::Rolling { days } => {
let from = now - (*days as i64) * 86_400;
p.push(Value::Integer(from));
format!("images.captured_at >= ?{}", p.len())
}
DateSelector::CollectionSpan(id) => {
p.push(Value::Integer(id.0 as i64));
let ix = p.len();
format!(
"images.captured_at BETWEEN
(SELECT min(i2.captured_at) FROM images i2
JOIN collection_members m2 ON m2.image_id = i2.id
WHERE m2.collection_id = ?{ix})
AND (SELECT max(i2.captured_at) FROM images i2
JOIN collection_members m2 ON m2.image_id = i2.id
WHERE m2.collection_id = ?{ix})"
)
}
}
}
/// Rating, label, and flag live on the *default* version, not the image.
///
/// A correlated subquery rather than a join, so these compose inside `OR` and
/// `NOT` without the join multiplying rows.
fn default_version_scalar(col: &str) -> String {
format!(
"(SELECT dv.{col} FROM versions dv
WHERE dv.image_id = images.id AND dv.is_default = 1 LIMIT 1)"
)
}
/// Escape LIKE metacharacters so a literal `%` or `_` in user text matches
/// itself. Paired with `ESCAPE '\'` in every LIKE that uses it.
fn escape_like(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if matches!(c, '%' | '_' | '\\') {
out.push('\\');
}
out.push(c);
}
out
}
fn like_prefix(path: &str) -> String {
escape_like(path.trim_end_matches('/'))
}
fn label_code(l: ColourLabel) -> i64 {
match l {
ColourLabel::Red => 1,
ColourLabel::Yellow => 2,
ColourLabel::Green => 3,
ColourLabel::Blue => 4,
ColourLabel::Purple => 5,
}
}
fn flag_code(f: FlagState) -> i64 {
match f {
FlagState::Unflagged => 0,
FlagState::Pick => 1,
FlagState::Reject => 2,
}
}
/// The stored form of an availability. Shared with [`crate::walk`], which
/// writes the column this reads — two spellings of the same mapping would
/// filter for a state nothing ever writes.
pub(crate) fn availability_code(a: Availability) -> i64 {
match a {
Availability::MetadataOnly => 0,
Availability::Preview => 1,
Availability::Original => 2,
Availability::Offline => 3,
}
}
/// Build the full SELECT for a window of results.
///
/// Joins are added only where the sort needs them, so an unsorted-by-rating
/// grid query touches one table.
pub fn window_sql(q: &Query, compiled: &Compiled) -> String {
let mut joins = String::new();
if q.sort.needs_version() {
joins.push_str(" LEFT JOIN versions v ON v.image_id = images.id AND v.is_default = 1");
}
if q.sort.needs_membership() {
// Only meaningful when the filter scopes to one collection; elsewhere
// position is NULL and the sort falls through to capture time.
joins.push_str(" LEFT JOIN collection_members cm ON cm.image_id = images.id");
}
format!(
"SELECT images.id, images.source_ref, images.availability, images.captured_at, \
images.captured_offset, images.metadata_state \
FROM images{joins} WHERE {} {} LIMIT ? OFFSET ?",
compiled.where_sql,
q.sort.sql(q.descending)
)
}
/// Build the COUNT for the same filter.
pub fn count_sql(compiled: &Compiled) -> String {
format!("SELECT count(*) FROM images WHERE {}", compiled.where_sql)
}
#[cfg(test)]
mod tests {
use super::*;
use dr_types::{CollectionId, RootId};
#[test]
fn unfiltered_compiles_to_a_constant() {
let c = compile(&Selector::All, 0);
assert_eq!(c.where_sql, "1");
assert!(c.params.is_empty());
}
#[test]
fn empty_conjunction_and_disjunction_differ() {
// The distinction that decides whether clearing a filter shows
// everything or nothing.
assert_eq!(compile(&Selector::All_(vec![]), 0).where_sql, "1");
assert_eq!(compile(&Selector::Any(vec![]), 0).where_sql, "0");
}
#[test]
fn values_bind_rather_than_interpolate() {
// The injection guard: a hostile keyword must appear in params, never
// in SQL text.
let evil = "'; DROP TABLE images; --";
let c = compile(&Selector::Keyword(evil.into()), 0);
assert!(!c.where_sql.contains("DROP"));
assert_eq!(c.params, vec![Value::Text(evil.into())]);
}
#[test]
fn like_metacharacters_are_escaped() {
// A search for "50%" must not match everything containing "50".
let c = compile(&Selector::Text("50%".into()), 0);
assert_eq!(c.params, vec![Value::Text("%50\\%%".into())]);
assert!(c.where_sql.contains("ESCAPE"));
}
#[test]
fn a_backslash_in_search_text_is_itself_escaped() {
let c = compile(&Selector::Text("a\\b".into()), 0);
assert_eq!(c.params, vec![Value::Text("%a\\\\b%".into())]);
}
#[test]
fn rolling_window_resolves_against_supplied_now() {
// Passed in rather than read from the clock, so the window is stable
// across one query and reproducible in a test.
let now = 1_000_000i64;
let c = compile(
&Selector::DateRange(DateSelector::Rolling { days: 90 }),
now,
);
assert_eq!(c.params, vec![Value::Integer(now - 90 * 86_400)]);
}
#[test]
fn between_is_half_open() {
let c = compile(
&Selector::DateRange(DateSelector::Between { from: 10, to: 20 }),
0,
);
// Half-open so adjacent day buckets neither overlap nor leave a gap.
assert!(c.where_sql.contains(">= ?1"));
assert!(c.where_sql.contains("< ?2"));
}
#[test]
fn nested_composition_numbers_parameters_in_order() {
let s = Selector::All_(vec![
Selector::Rating { min: 4 },
Selector::Any(vec![
Selector::Camera("X-T5".into()),
Selector::Not(Box::new(Selector::Lens("XF 35".into()))),
]),
]);
let c = compile(&s, 0);
assert_eq!(
c.params,
vec![
Value::Integer(4),
Value::Text("X-T5".into()),
Value::Text("XF 35".into()),
]
);
assert!(c.where_sql.contains("?1"));
assert!(c.where_sql.contains("?2"));
assert!(c.where_sql.contains("?3"));
}
#[test]
fn recursive_folder_matches_the_folder_itself_and_below() {
let c = compile(
&Selector::Folder {
root: RootId(1),
path: "2026/08".into(),
recursive: true,
},
0,
);
// Both branches: the folder's own images and those in subfolders.
assert!(c.where_sql.contains("path = ?2"));
assert!(c.where_sql.contains("|| '/%'"));
}
#[test]
fn collection_span_binds_its_id_once_and_reuses_it() {
let c = compile(
&Selector::DateRange(DateSelector::CollectionSpan(CollectionId(7))),
0,
);
assert_eq!(c.params, vec![Value::Integer(7)]);
}
#[test]
fn capture_time_dependency_is_reported() {
let c = compile(&Selector::DateRange(DateSelector::Rolling { days: 7 }), 0);
assert!(c.needs_capture_time);
let c = compile(&Selector::Rating { min: 5 }, 0);
assert!(!c.needs_capture_time);
}
#[test]
fn capture_sort_puts_unknown_timestamps_last_in_both_directions() {
// An image whose EXIF has not been read yet must not lead the grid
// just because its timestamp is NULL.
assert!(Sort::CapturedAt.sql(true).contains("IS NULL"));
assert!(Sort::CapturedAt.sql(false).contains("IS NULL"));
}
#[test]
fn window_sql_joins_only_when_the_sort_needs_it() {
let c = compile(&Selector::All, 0);
let plain = window_sql(
&Query {
filter: Selector::All,
sort: Sort::CapturedAt,
descending: true,
},
&c,
);
assert!(!plain.contains("JOIN"));
let rated = window_sql(
&Query {
filter: Selector::All,
sort: Sort::Rating,
descending: true,
},
&c,
);
assert!(rated.contains("JOIN versions"));
}
}
+733
View File
@@ -0,0 +1,733 @@
//! TRACES: FR-CAT-5 | FR-CAT-6 | FR-CULL-4
//! Star ratings and pick/reject flags — the judgement a cull produces.
//!
//! # Why this hangs off `versions` rather than `images`
//!
//! The schema already carries `rating`, `label` and `flag` on `versions`, and
//! [`crate::query`] already compiles [`dr_types::Selector::Rating`] and
//! [`dr_types::Selector::Flag`] against the *default* version. What was
//! missing is that nothing ever created a version row: a scan inserts into
//! `images` and stops, so every image had no version, and therefore nowhere
//! to record a rating. The whole library sat permanently unrated with no way
//! out of that state.
//!
//! So this module's first job is [`ensure_default_versions`] — every image
//! gets exactly one default version, created at scan time and backfilled by
//! the v2 migration for libraries scanned before this existed.
//!
//! Keeping judgement on the version rather than the image is what makes
//! FR-CAT-12's virtual copies coherent: two crops of one frame are two
//! photographs to the photographer, and one may be a keeper while the other
//! is a reject. Hoisting the rating onto the image would force them to agree.
//!
//! # Unrated is a real state, not a zero
//!
//! `rating = 0` means *not yet judged*, and that is precisely what "filter to
//! unjudged" selects (FR-CULL-4). It is deliberately not conflated with "one
//! star" or with "rejected" — those are three different answers, and a cull
//! that cannot distinguish "I have not looked at this" from "I looked and it
//! is poor" cannot be resumed.
use rusqlite::{Connection, OptionalExtension};
use dr_types::{FlagState, ImageId};
use crate::error::CatalogError;
/// Highest star rating. Five, as every photo tool has settled on.
pub const MAX_RATING: u8 = 5;
/// Name given to the version created for an image that has none.
///
/// Matches what [`crate::collections`] and the sidecar both expect to see for
/// the original, unmodified frame.
pub const DEFAULT_VERSION_NAME: &str = "Default";
/// The judgement recorded against one image's default version.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Judgement {
/// 0..=5. Zero means *unrated*, which is a state in its own right.
pub rating: u8,
pub flag: FlagState,
}
impl Judgement {
/// Whether this image has been judged at all.
///
/// Either axis counts: a photographer who flags without starring, or stars
/// without flagging, has still made a decision about the frame. "Filter to
/// unjudged" (FR-CULL-4) is the negation of this, and getting it wrong
/// means a resumed session re-presents work already done.
pub fn is_judged(self) -> bool {
self.rating > 0 || self.flag != FlagState::Unflagged
}
}
/// Give every image without one a default version.
///
/// Idempotent, and cheap on the common path: the `NOT EXISTS` sub-select is
/// answered by the `versions_image` index, so a library that already has its
/// versions costs one indexed scan and writes nothing.
///
/// Returns how many were created, so a scan can log the backfill rather than
/// silently doing thousands of inserts.
///
/// The UUID is per row and generated here — it is the merge identity across
/// devices (FR-NC-8), so two images must never share one.
pub fn ensure_default_versions(conn: &Connection) -> Result<usize, CatalogError> {
// One transaction for the batch. A backfill over a 24k-image library is
// 24k inserts, and per-statement commits would make it minutes rather
// than seconds.
let tx = conn.unchecked_transaction()?;
let n = ensure_default_versions_within(&tx)?;
tx.commit()?;
Ok(n)
}
/// [`ensure_default_versions`] without opening a transaction.
///
/// Separate because SQLite has no nested `BEGIN`: [`crate::merge`] needs the
/// invariant restored *inside* the merge transaction — an incoming keyword
/// lands on a default version, so an image without one would silently drop it —
/// and calling the public form there fails at runtime with "cannot start a
/// transaction within a transaction". The same split, for the same reason, as
/// `collections::add_within`.
pub fn ensure_default_versions_within(conn: &Connection) -> Result<usize, CatalogError> {
let ids: Vec<i64> = {
let mut stmt = conn.prepare(
"SELECT i.id FROM images i
WHERE NOT EXISTS (SELECT 1 FROM versions v WHERE v.image_id = i.id)",
)?;
let found = stmt
.query_map([], |r| r.get(0))?
.collect::<Result<Vec<_>, _>>()?;
found
};
if ids.is_empty() {
return Ok(0);
}
{
let mut insert = conn.prepare(
"INSERT INTO versions(image_id, uuid, name, is_default, rating, flag)
VALUES (?1, ?2, ?3, 1, 0, 0)",
)?;
for id in &ids {
insert.execute(rusqlite::params![id, new_uuid(), DEFAULT_VERSION_NAME])?;
}
}
Ok(ids.len())
}
/// The default version's row id for an image, creating one if it has none.
///
/// Every write path goes through this rather than assuming a version exists.
/// An image can arrive without one in two ways that are not worth trying to
/// prevent: a row inserted by a build predating this module, and a scan whose
/// version pass was interrupted between the image insert and the commit.
/// Failing a rating because of either would be the wrong answer — the user
/// pressed a key and expects a star.
pub fn default_version_id(conn: &Connection, image: ImageId) -> Result<i64, CatalogError> {
let existing: Option<i64> = conn
.query_row(
"SELECT id FROM versions
WHERE image_id = ?1
ORDER BY is_default DESC, id ASC
LIMIT 1",
[image.0 as i64],
|r| r.get(0),
)
.optional()?;
if let Some(id) = existing {
return Ok(id);
}
conn.execute(
"INSERT INTO versions(image_id, uuid, name, is_default, rating, flag)
VALUES (?1, ?2, ?3, 1, 0, 0)",
rusqlite::params![image.0 as i64, new_uuid(), DEFAULT_VERSION_NAME],
)?;
Ok(conn.last_insert_rowid())
}
/// Set the star rating for one image, clamped to 0..=[`MAX_RATING`].
///
/// Clamped rather than rejected: the value comes from a keystroke or a click
/// on a star strip, and there is no useful error to show a photographer who
/// pressed a key. Out of range can only mean a UI bug, and losing the
/// keystroke would be a worse symptom than recording five.
pub fn set_rating(conn: &Connection, image: ImageId, rating: u8) -> Result<(), CatalogError> {
let version = default_version_id(conn, image)?;
conn.execute(
"UPDATE versions SET rating = ?2 WHERE id = ?1",
rusqlite::params![version, rating.min(MAX_RATING) as i64],
)?;
Ok(())
}
/// Set the pick/reject flag for one image.
pub fn set_flag(conn: &Connection, image: ImageId, flag: FlagState) -> Result<(), CatalogError> {
let version = default_version_id(conn, image)?;
conn.execute(
"UPDATE versions SET flag = ?2 WHERE id = ?1",
rusqlite::params![version, flag_code(flag)],
)?;
Ok(())
}
/// Apply a rating to many images in one transaction.
///
/// The bulk path exists because rating a selection is one gesture: the user
/// selects forty frames and presses `3`. Forty separate transactions would be
/// forty fsyncs for what is conceptually a single edit, and a crash partway
/// through would leave the selection half-rated.
pub fn set_rating_many(
conn: &Connection,
images: &[ImageId],
rating: u8,
) -> Result<usize, CatalogError> {
apply_many(conn, images, |conn, id| set_rating(conn, id, rating))
}
/// Apply a flag to many images in one transaction. See [`set_rating_many`].
pub fn set_flag_many(
conn: &Connection,
images: &[ImageId],
flag: FlagState,
) -> Result<usize, CatalogError> {
apply_many(conn, images, |conn, id| set_flag(conn, id, flag))
}
/// Shared bulk wrapper, so the two axes cannot drift in their commit
/// behaviour — a partially-committed rating and a fully-committed flag from
/// the same keystroke would be hard to explain and harder to notice.
fn apply_many(
conn: &Connection,
images: &[ImageId],
mut one: impl FnMut(&Connection, ImageId) -> Result<(), CatalogError>,
) -> Result<usize, CatalogError> {
if images.is_empty() {
return Ok(0);
}
let tx = conn.unchecked_transaction()?;
for id in images {
one(&tx, *id)?;
}
tx.commit()?;
Ok(images.len())
}
/// Read the judgement for one image.
///
/// An image with no version reads as unrated and unflagged rather than as an
/// error: that is exactly what it is.
pub fn judgement(conn: &Connection, image: ImageId) -> Result<Judgement, CatalogError> {
let row: Option<(i64, i64)> = conn
.query_row(
"SELECT rating, flag FROM versions
WHERE image_id = ?1
ORDER BY is_default DESC, id ASC
LIMIT 1",
[image.0 as i64],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()?;
Ok(match row {
Some((rating, flag)) => Judgement {
rating: rating.clamp(0, MAX_RATING as i64) as u8,
flag: flag_from_code(flag),
},
None => Judgement::default(),
})
}
/// Judgements for a window of images, in one statement.
///
/// The grid needs a star strip per cell, and one query per cell would be 120
/// round trips on every scroll — the same reasoning as
/// `collections_ui::sync_badges`. Images with no version simply do not appear
/// in the result, and the caller treats a miss as unrated.
pub fn judgements(
conn: &Connection,
images: &[ImageId],
) -> Result<std::collections::HashMap<ImageId, Judgement>, CatalogError> {
let mut out = std::collections::HashMap::new();
if images.is_empty() {
return Ok(out);
}
// Placeholders are generated from the *count* of ids, never from any text
// that came from outside — the same rule `read_cells_scoped` follows.
let placeholders = std::iter::repeat_n("?", images.len())
.collect::<Vec<_>>()
.join(",");
let sql = format!(
"SELECT image_id, rating, flag FROM versions
WHERE image_id IN ({placeholders}) AND is_default = 1"
);
let params: Vec<rusqlite::types::Value> = images
.iter()
.map(|i| rusqlite::types::Value::Integer(i.0 as i64))
.collect();
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, i64>(1)?,
r.get::<_, i64>(2)?,
))
})?;
for (image, rating, flag) in rows.flatten() {
out.insert(
ImageId(image as u64),
Judgement {
rating: rating.clamp(0, MAX_RATING as i64) as u8,
flag: flag_from_code(flag),
},
);
}
Ok(out)
}
/// How the library divides by rating, for the filter bar's counts.
///
/// Index `n` is the number of images rated `n`, so index 0 is the unrated
/// count. Shown beside each filter button so the user can see there is
/// something behind it before narrowing to it — a filter that silently
/// empties the grid reads as a broken filter.
pub fn rating_histogram(conn: &Connection) -> Result<[usize; 6], CatalogError> {
let mut out = [0usize; 6];
// LEFT JOIN, so an image whose version row is missing still counts as
// unrated rather than vanishing from the totals. The histogram has to sum
// to the library size or it is not believable.
let mut stmt = conn.prepare(
"SELECT coalesce(v.rating, 0) AS r, count(*)
FROM images i
LEFT JOIN versions v ON v.image_id = i.id AND v.is_default = 1
GROUP BY r",
)?;
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?;
for (rating, count) in rows.flatten() {
if let Some(slot) = out.get_mut(rating.clamp(0, MAX_RATING as i64) as usize) {
*slot += count as usize;
}
}
Ok(out)
}
/// How many images carry each flag: `(picks, rejects)`.
pub fn flag_counts(conn: &Connection) -> Result<(usize, usize), CatalogError> {
let picks: i64 = conn.query_row(
"SELECT count(*) FROM versions WHERE is_default = 1 AND flag = 1",
[],
|r| r.get(0),
)?;
let rejects: i64 = conn.query_row(
"SELECT count(*) FROM versions WHERE is_default = 1 AND flag = 2",
[],
|r| r.get(0),
)?;
Ok((picks as usize, rejects as usize))
}
/// The stored integer for a flag. Matches [`crate::query::flag_code`]'s
/// mapping — the two must agree or a filter will not find what a write stored.
fn flag_code(f: FlagState) -> i64 {
match f {
FlagState::Unflagged => 0,
FlagState::Pick => 1,
FlagState::Reject => 2,
}
}
fn flag_from_code(v: i64) -> FlagState {
match v {
1 => FlagState::Pick,
2 => FlagState::Reject,
_ => FlagState::Unflagged,
}
}
/// A version UUID.
///
/// Hand-rolled rather than pulling in the `uuid` crate for one function — the
/// same reasoning as the date maths in `library_ui`. This needs to be unique
/// across devices, not cryptographically unguessable: it keys a merge, and an
/// attacker who can write to the sidecar has already won.
///
/// Seeded from the system clock and a per-process counter, so two versions
/// created inside the same nanosecond tick still differ.
fn new_uuid() -> String {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
// Mixed so successive ids do not share a long common prefix, which makes
// them easier to tell apart when reading a sidecar by eye.
let a = nanos ^ (n.wrapping_mul(0x9E37_79B9_7F4A_7C15));
let b = nanos
.rotate_left(32)
.wrapping_add(n.wrapping_mul(0xBF58_476D_1CE4_E5B9));
format!(
"{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
(a >> 32) as u32,
(a >> 16) as u16,
(a & 0x0FFF) as u16,
// Variant bits, so this is a well-formed v4-shaped UUID rather than
// something that merely looks like one.
((b >> 48) as u16 & 0x3FFF) | 0x8000,
b & 0xFFFF_FFFF_FFFF,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Catalog;
/// A catalog holding `n` images and nothing else — the state a scan
/// leaves behind before this module runs.
fn with_images(n: usize) -> Catalog {
let cat = Catalog::in_memory().unwrap();
let c = cat.connection();
c.execute(
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
[],
)
.unwrap();
for i in 0..n {
c.execute(
"INSERT INTO images(root_id, source_ref, added_at) VALUES (1, ?1, 0)",
[format!("img{i:03}.CR3")],
)
.unwrap();
}
cat
}
fn ids(cat: &Catalog) -> Vec<ImageId> {
let mut stmt = cat
.connection()
.prepare("SELECT id FROM images ORDER BY id")
.unwrap();
stmt.query_map([], |r| Ok(ImageId(r.get::<_, i64>(0)? as u64)))
.unwrap()
.map(Result::unwrap)
.collect()
}
#[test]
fn every_scanned_image_gets_a_default_version() {
// The gap this module exists to close: a scan inserted images and no
// versions, so there was nowhere for a rating to go.
let cat = with_images(5);
assert_eq!(ensure_default_versions(cat.connection()).unwrap(), 5);
let n: i64 = cat
.connection()
.query_row(
"SELECT count(*) FROM versions WHERE is_default = 1",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(n, 5);
}
#[test]
fn images_enter_unrated_rather_than_at_one_star() {
// "Not yet judged" is the state a cull starts from and resumes to.
let cat = with_images(3);
ensure_default_versions(cat.connection()).unwrap();
for id in ids(&cat) {
let j = judgement(cat.connection(), id).unwrap();
assert_eq!(j.rating, 0);
assert_eq!(j.flag, FlagState::Unflagged);
assert!(!j.is_judged());
}
}
#[test]
fn backfilling_twice_creates_nothing_the_second_time() {
// Runs on every scan, so a second pass must not double every version.
let cat = with_images(4);
assert_eq!(ensure_default_versions(cat.connection()).unwrap(), 4);
assert_eq!(ensure_default_versions(cat.connection()).unwrap(), 0);
let n: i64 = cat
.connection()
.query_row("SELECT count(*) FROM versions", [], |r| r.get(0))
.unwrap();
assert_eq!(n, 4, "one version per image, not two");
}
#[test]
fn version_uuids_are_unique_across_a_batch() {
// The uuid is the cross-device merge identity: two images sharing one
// silently fuse their edits at the next sync.
let cat = with_images(200);
ensure_default_versions(cat.connection()).unwrap();
let distinct: i64 = cat
.connection()
.query_row("SELECT count(DISTINCT uuid) FROM versions", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(distinct, 200);
}
#[test]
fn a_rating_round_trips() {
let cat = with_images(1);
let id = ids(&cat)[0];
set_rating(cat.connection(), id, 4).unwrap();
assert_eq!(judgement(cat.connection(), id).unwrap().rating, 4);
}
#[test]
fn rating_an_image_with_no_version_creates_one() {
// A library scanned by a build predating this module, or a scan that
// died between the image insert and the version pass. The keystroke
// must still land.
let cat = with_images(1);
let id = ids(&cat)[0];
// Deliberately *not* calling ensure_default_versions first.
set_rating(cat.connection(), id, 3).unwrap();
assert_eq!(judgement(cat.connection(), id).unwrap().rating, 3);
}
#[test]
fn an_out_of_range_rating_is_clamped_rather_than_stored() {
// A stored 9 would sort above five stars forever and no filter would
// reach it.
let cat = with_images(1);
let id = ids(&cat)[0];
set_rating(cat.connection(), id, 99).unwrap();
assert_eq!(judgement(cat.connection(), id).unwrap().rating, MAX_RATING);
}
#[test]
fn rating_back_to_zero_returns_an_image_to_unrated() {
// Pressing 0 is how a mistake is undone, so it has to be reachable —
// not a floor at one star.
let cat = with_images(1);
let id = ids(&cat)[0];
set_rating(cat.connection(), id, 5).unwrap();
set_rating(cat.connection(), id, 0).unwrap();
let j = judgement(cat.connection(), id).unwrap();
assert_eq!(j.rating, 0);
assert!(!j.is_judged(), "back to unjudged, so a cull re-presents it");
}
#[test]
fn flags_and_stars_are_independent_axes() {
// Rejecting a four-star frame is a normal thing to do while culling,
// and one axis must not clear the other.
let cat = with_images(1);
let id = ids(&cat)[0];
set_rating(cat.connection(), id, 4).unwrap();
set_flag(cat.connection(), id, FlagState::Reject).unwrap();
let j = judgement(cat.connection(), id).unwrap();
assert_eq!(j.rating, 4);
assert_eq!(j.flag, FlagState::Reject);
}
#[test]
fn a_flag_alone_counts_as_judged() {
// Filter-to-unjudged must not re-present a frame the user already
// picked, merely because they did not also star it.
let cat = with_images(1);
let id = ids(&cat)[0];
set_flag(cat.connection(), id, FlagState::Pick).unwrap();
assert!(judgement(cat.connection(), id).unwrap().is_judged());
}
#[test]
fn a_bulk_rating_applies_to_the_whole_selection() {
// One gesture: select forty, press 3.
let cat = with_images(10);
let all = ids(&cat);
let chosen = &all[2..7];
assert_eq!(set_rating_many(cat.connection(), chosen, 3).unwrap(), 5);
for id in chosen {
assert_eq!(judgement(cat.connection(), *id).unwrap().rating, 3);
}
// And nothing outside the selection moved.
assert_eq!(judgement(cat.connection(), all[0]).unwrap().rating, 0);
assert_eq!(judgement(cat.connection(), all[9]).unwrap().rating, 0);
}
#[test]
fn a_bulk_write_over_an_empty_selection_is_a_no_op() {
let cat = with_images(3);
assert_eq!(set_rating_many(cat.connection(), &[], 5).unwrap(), 0);
assert_eq!(
set_flag_many(cat.connection(), &[], FlagState::Pick).unwrap(),
0
);
}
#[test]
fn judgements_reads_a_whole_window_in_one_query() {
// The grid draws a star strip per cell; one query per cell would be
// 120 round trips on every scroll.
let cat = with_images(6);
let all = ids(&cat);
set_rating(cat.connection(), all[1], 2).unwrap();
set_flag(cat.connection(), all[3], FlagState::Pick).unwrap();
let map = judgements(cat.connection(), &all).unwrap();
assert_eq!(map.get(&all[1]).unwrap().rating, 2);
assert_eq!(map.get(&all[3]).unwrap().flag, FlagState::Pick);
// Never rated, so it is either absent or explicitly unrated — both
// mean the same thing to the caller.
assert_eq!(
map.get(&all[5]).copied().unwrap_or_default(),
Judgement::default()
);
}
#[test]
fn the_histogram_sums_to_the_library_size() {
// A histogram that disagrees with the image count is not believable,
// and the unrated bucket is the one a fresh library lives in.
let cat = with_images(8);
ensure_default_versions(cat.connection()).unwrap();
let all = ids(&cat);
set_rating(cat.connection(), all[0], 5).unwrap();
set_rating(cat.connection(), all[1], 5).unwrap();
set_rating(cat.connection(), all[2], 3).unwrap();
let h = rating_histogram(cat.connection()).unwrap();
assert_eq!(h[5], 2);
assert_eq!(h[3], 1);
assert_eq!(h[0], 5, "the rest are still unrated");
assert_eq!(h.iter().sum::<usize>(), 8);
}
#[test]
fn the_histogram_counts_images_with_no_version_as_unrated() {
// They are unrated. Dropping them would make the counts disagree with
// the grid, which is the failure the LEFT JOIN exists to prevent.
let cat = with_images(4);
// No ensure_default_versions call at all.
let h = rating_histogram(cat.connection()).unwrap();
assert_eq!(h[0], 4);
assert_eq!(h.iter().sum::<usize>(), 4);
}
#[test]
fn flag_counts_separate_picks_from_rejects() {
let cat = with_images(5);
let all = ids(&cat);
set_flag(cat.connection(), all[0], FlagState::Pick).unwrap();
set_flag(cat.connection(), all[1], FlagState::Pick).unwrap();
set_flag(cat.connection(), all[2], FlagState::Reject).unwrap();
assert_eq!(flag_counts(cat.connection()).unwrap(), (2, 1));
}
#[test]
fn unflagging_removes_an_image_from_both_counts() {
let cat = with_images(2);
let all = ids(&cat);
set_flag(cat.connection(), all[0], FlagState::Reject).unwrap();
set_flag(cat.connection(), all[0], FlagState::Unflagged).unwrap();
assert_eq!(flag_counts(cat.connection()).unwrap(), (0, 0));
}
#[test]
fn a_rating_survives_the_selector_that_queries_it() {
// The end-to-end property: what this module writes is what
// `dr_catalog::query` compiles `Selector::Rating` to find. These are
// two independent pieces of SQL and they must agree on where a rating
// lives, or rating an image would appear to do nothing.
use crate::Query;
use dr_types::Selector;
let cat = with_images(6);
let all = ids(&cat);
ensure_default_versions(cat.connection()).unwrap();
set_rating(cat.connection(), all[0], 5).unwrap();
set_rating(cat.connection(), all[1], 4).unwrap();
set_rating(cat.connection(), all[2], 1).unwrap();
let q = Query {
filter: Selector::Rating { min: 4 },
..Default::default()
};
assert_eq!(cat.count(&q, 0).unwrap(), 2);
}
#[test]
fn a_flag_survives_the_selector_that_queries_it() {
// Same contract for the other axis: `flag_code` here and in `query`
// are separate mappings and must not drift.
use crate::Query;
use dr_types::Selector;
let cat = with_images(4);
let all = ids(&cat);
ensure_default_versions(cat.connection()).unwrap();
set_flag(cat.connection(), all[0], FlagState::Pick).unwrap();
set_flag(cat.connection(), all[1], FlagState::Reject).unwrap();
let picks = Query {
filter: Selector::Flag(FlagState::Pick),
..Default::default()
};
assert_eq!(cat.count(&picks, 0).unwrap(), 1);
let rejects = Query {
filter: Selector::Flag(FlagState::Reject),
..Default::default()
};
assert_eq!(cat.count(&rejects, 0).unwrap(), 1);
}
#[test]
fn unjudged_is_reachable_as_a_filter() {
// FR-CULL-4's "filter to unjudged", which is what lets a session
// resume where it stopped.
use crate::Query;
use dr_types::Selector;
let cat = with_images(5);
let all = ids(&cat);
ensure_default_versions(cat.connection()).unwrap();
set_rating(cat.connection(), all[0], 2).unwrap();
let q = Query {
filter: Selector::Rating { min: 0 },
..Default::default()
};
// `min: 0` matches everything, so unjudged needs the negation.
assert_eq!(cat.count(&q, 0).unwrap(), 5);
let unrated = Query {
filter: Selector::Not(Box::new(Selector::Rating { min: 1 })),
..Default::default()
};
assert_eq!(cat.count(&unrated, 0).unwrap(), 4);
}
}
+237
View File
@@ -0,0 +1,237 @@
//! TRACES: FR-CAT-1 | FR-CAT-9 | NFR-P1
//! Incremental scan: the local analogue of ETag pruning.
//!
//! Nextcloud propagates ETags up the tree, so one request proves a whole
//! library unchanged (ARCH §8.4). A filesystem offers no such guarantee — a
//! directory's mtime moves when its *direct* entries change and not when a
//! grandchild does, so there is no cheap "did anything below here change"
//! probe.
//!
//! Local scan therefore prunes at each level rather than at the root: one
//! metadata probe per directory when nothing changed, instead of one per file.
//! A 50k-image library in ~2k folders costs 2k probes, which is the difference
//! between meeting and missing NFR-P1 on SAF.
//!
//! This module holds the decision logic and the deletion-sweep rules; walking
//! an actual directory belongs to the platform layer, which supplies
//! [`DirState`] and [`DirEntry`]. [`crate::walk`] is what puts the two
//! together.
pub use dr_types::{DirEntry, DirState};
use dr_types::FormatFilter;
/// What the scanner should do with a directory, before listing it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DirAction {
/// Contents unchanged. Skip the listing, but still recurse into known
/// children — without upward propagation, a deep change is invisible from
/// here.
RecurseOnly,
/// List and reconcile, then recurse.
ListAndRecurse,
}
/// Decide whether a directory needs listing.
pub fn classify_dir(stored: Option<DirState>, current: DirState) -> DirAction {
match stored {
Some(s) if s == current => DirAction::RecurseOnly,
_ => DirAction::ListAndRecurse,
}
}
/// What reconciling one listed entry against the catalog implies.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EntryAction {
/// Not catalogued. Insert at `metadata_state = 1` and queue EXIF.
Insert,
/// Catalogued and unchanged. The common case, and it must cost nothing.
Unchanged,
/// Size or mtime moved: re-read metadata, rebuild the thumbnail, and drop
/// the content hash, which is no longer valid.
Changed,
/// Recognised but not a format the user asked to scan for.
Ignored,
}
/// What the catalog already holds for a source.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct KnownFile {
pub size: u64,
pub mtime: i64,
}
/// Classify one listed file.
pub fn classify_entry(
entry: &DirEntry,
known: Option<KnownFile>,
formats: &FormatFilter,
) -> EntryAction {
if !formats.allows_name(&entry.name) {
return EntryAction::Ignored;
}
match known {
None => EntryAction::Insert,
Some(k) if k.size == entry.size && k.mtime == entry.mtime => EntryAction::Unchanged,
Some(_) => EntryAction::Changed,
}
}
/// Outcome of a scan, which decides whether pruning may run.
///
/// `Cancelled` is the default because a scan that has not run has proven
/// nothing absent, and every default in this area must fail towards keeping
/// photographs.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ScanOutcome {
/// Every reachable folder was visited.
Complete,
/// The user cancelled. Partial state is valid — jobs are resumable — but
/// unvisited folders must not be read as deleted.
#[default]
Cancelled,
/// The root itself could not be opened: drive unplugged, SAF grant
/// revoked, share unmounted.
RootUnreachable,
/// Some subtree failed while the root was fine.
PartialFailure,
}
impl ScanOutcome {
/// Whether the deletion sweep may run.
///
/// **The most dangerous decision in the catalog.** The sweep deletes every
/// folder not reached by this scan's generation. After an incomplete scan
/// that is most of the library, so it runs only on `Complete`.
///
/// FR-CAT-9 draws exactly this line: a source *proven absent* may leave
/// the catalog; a source merely *unreachable* is marked offline and kept,
/// with its ratings and edits intact.
pub fn may_prune(self) -> bool {
matches!(self, ScanOutcome::Complete)
}
}
#[cfg(test)]
mod tests {
use super::*;
use dr_types::Format;
const A: DirState = DirState {
mtime: 100,
entry_count: 5,
};
#[test]
fn unchanged_directory_is_not_listed() {
assert_eq!(classify_dir(Some(A), A), DirAction::RecurseOnly);
}
#[test]
fn a_never_seen_directory_is_listed() {
assert_eq!(classify_dir(None, A), DirAction::ListAndRecurse);
}
#[test]
fn changed_mtime_forces_a_listing() {
let now = DirState { mtime: 101, ..A };
assert_eq!(classify_dir(Some(A), now), DirAction::ListAndRecurse);
}
#[test]
fn entry_count_catches_what_mtime_misses() {
// A file added within the same timestamp tick: mtime is unchanged, so
// mtime alone would skip this directory and lose the new image.
let now = DirState {
mtime: 100,
entry_count: 6,
};
assert_eq!(classify_dir(Some(A), now), DirAction::ListAndRecurse);
}
#[test]
fn unchanged_file_costs_nothing() {
let e = DirEntry {
name: "IMG_0001.CR3".into(),
is_dir: false,
size: 30_000_000,
mtime: 500,
};
let known = KnownFile {
size: 30_000_000,
mtime: 500,
};
assert_eq!(
classify_entry(&e, Some(known), &FormatFilter::all()),
EntryAction::Unchanged
);
}
#[test]
fn a_resaved_file_is_reprocessed() {
let e = DirEntry {
name: "IMG_0001.CR3".into(),
is_dir: false,
size: 30_000_001,
mtime: 900,
};
let known = KnownFile {
size: 30_000_000,
mtime: 500,
};
assert_eq!(
classify_entry(&e, Some(known), &FormatFilter::all()),
EntryAction::Changed
);
}
#[test]
fn format_filter_excludes_unwanted_types() {
let jpeg = DirEntry {
name: "IMG_0001.JPG".into(),
is_dir: false,
size: 1,
mtime: 1,
};
assert_eq!(
classify_entry(&jpeg, None, &FormatFilter::raw_only()),
EntryAction::Ignored
);
assert_eq!(
classify_entry(&jpeg, None, &FormatFilter::all()),
EntryAction::Insert
);
}
#[test]
fn a_placeholder_is_catalogued_as_the_image_it_stands_for() {
// 121,785 of these in a real synced folder (ARCH §9.0). Each must
// enter the catalog as a CR2 marked offline, not be skipped as an
// unknown ".nextcloud" type.
let stub = DirEntry {
name: "_MG_4130.CR2.nextcloud".into(),
is_dir: false,
size: 1,
mtime: 1,
};
assert_eq!(
classify_entry(&stub, None, &FormatFilter::from_formats([Format::Cr2])),
EntryAction::Insert
);
}
#[test]
fn pruning_requires_a_complete_scan() {
assert!(ScanOutcome::Complete.may_prune());
}
#[test]
fn an_unreachable_root_never_prunes() {
// The guard that stops an unplugged drive from deleting the library:
// every folder would look unreached, so the sweep would take all of
// them (FR-CAT-9).
assert!(!ScanOutcome::RootUnreachable.may_prune());
assert!(!ScanOutcome::Cancelled.may_prune());
assert!(!ScanOutcome::PartialFailure.may_prune());
}
}
+934
View File
@@ -0,0 +1,934 @@
//! TRACES: FR-CAT-2 | NFR-R5
//! Schema definition and forward-only migrations.
//!
//! The catalog is an *index*, not a source of truth (ARCH §6.12) — it is
//! deletable and rebuildable from sources plus sidecars. That is what makes
//! migration failure survivable, and why the recovery path is the normal
//! mechanism rather than a last resort.
//!
//! Migrations are forward-only, transactional, and idempotent on retry
//! (NFR-R5). The app refuses to open a catalog newer than it understands
//! rather than corrupting it.
use rusqlite::Connection;
use crate::error::CatalogError;
/// Schema version this build writes and understands.
pub const SCHEMA_VERSION: i64 = 6;
/// Apply migrations up to [`SCHEMA_VERSION`].
///
/// Returns the version migrated from, so callers can log or back up before a
/// real migration (NFR-R2 requires a backup before schema change).
pub fn migrate(conn: &Connection) -> Result<i64, CatalogError> {
let from: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
if from > SCHEMA_VERSION {
return Err(CatalogError::SchemaTooNew {
found: from,
supported: SCHEMA_VERSION,
});
}
if from == SCHEMA_VERSION {
return Ok(from);
}
// Each step runs in its own transaction so a failure leaves the catalog
// at a coherent version rather than half-migrated.
if from < 1 {
let tx = conn.unchecked_transaction()?;
tx.execute_batch(V1)?;
tx.pragma_update(None, "user_version", 1)?;
tx.commit()?;
}
if from < 2 {
let tx = conn.unchecked_transaction()?;
tx.execute_batch(V2)?;
tx.pragma_update(None, "user_version", 2)?;
tx.commit()?;
}
if from < 3 {
let tx = conn.unchecked_transaction()?;
tx.execute_batch(V3)?;
tx.pragma_update(None, "user_version", 3)?;
tx.commit()?;
}
if from < 4 {
let tx = conn.unchecked_transaction()?;
tx.execute_batch(V4)?;
tx.pragma_update(None, "user_version", 4)?;
tx.commit()?;
}
if from < 5 {
let tx = conn.unchecked_transaction()?;
tx.execute_batch(V5)?;
tx.pragma_update(None, "user_version", 5)?;
tx.commit()?;
}
if from < 6 {
let tx = conn.unchecked_transaction()?;
tx.execute_batch(V6)?;
tx.pragma_update(None, "user_version", 6)?;
tx.commit()?;
}
Ok(from)
}
/// Recompute columns a migration added, for rows that predate it.
///
/// A migration adds a column with a default; it cannot know what the value
/// *should* be for the rows already present. Without a backfill those rows are
/// silently partial — present, queryable, and wrong — which is worse than
/// missing, because nothing signals that they need attention.
///
/// Cheap enough to run on every open: each pass is one indexed UPDATE, and
/// re-running it is a no-op once the values are already right.
///
/// Returns how many rows each backfill touched, for logging.
pub fn backfill(conn: &Connection) -> Result<Vec<(&'static str, usize)>, CatalogError> {
let mut out = Vec::new();
// v2: `shadowed_by`. A JPEG sitting beside a RAW of the same name is the
// camera's own rendering of that frame, not a second photograph, so it is
// hidden from the grid, the timeline and the sweep.
let n = pair_raw_and_jpeg(conn)?;
if n > 0 {
out.push(("shadowed_by", n));
}
// v3: every image needs a default version to carry its rating and flag.
// Libraries scanned before ratings existed have images and no versions at
// all, so there was nowhere for a judgement to go — see
// [`crate::rating`]. Backfilled rather than migrated in SQL because the
// UUID per row is the cross-device merge identity and must be generated,
// not derived.
let n = crate::rating::ensure_default_versions(conn)?;
if n > 0 {
out.push(("default_versions", n));
}
// v6: a vocabulary row for every word some image already carries.
//
// Three ways a catalog arrives holding assignments with no term behind
// them, and all three are normal rather than exceptional: a library
// keyworded by a build that predates this table, a catalog rebuilt from
// sidecars (which carry the word and not the identity), and an import from
// Lightroom or darktable (FR-CAT-14). Without this the words are
// searchable but absent from the vocabulary list, which reads as the
// keywords having been lost.
let n = crate::keywords::adopt_orphan_terms(conn)?;
if n > 0 {
out.push(("keyword_terms", n));
}
Ok(out)
}
/// Connection setup applied on every open, migration or not.
///
/// WAL is required by NFR-R1: it survives power loss without corruption, and
/// it lets a background job write while the grid reads.
pub fn configure(conn: &Connection) -> Result<(), CatalogError> {
conn.pragma_update(None, "journal_mode", "WAL")?;
// NORMAL rather than FULL: with WAL this is durable across process death
// (which is what FR-PLAT-AND-3 cares about) and only risks the last
// transaction on power loss. The catalog is rebuildable; the sidecars are
// not, and they are written separately with their own fsync discipline.
conn.pragma_update(None, "synchronous", "NORMAL")?;
conn.pragma_update(None, "foreign_keys", true)?;
// A scan touching thousands of rows is transient; let SQLite spill to
// memory rather than materialising temp b-trees on disk.
conn.pragma_update(None, "temp_store", "MEMORY")?;
Ok(())
}
/// The v1 schema rewritten to target an attached database.
///
/// Needed because a downloaded remote catalog is `ATTACH`ed under its own
/// schema name before merging, and tests build one from scratch. SQLite has no
/// "create these tables over there" form, so the names are rewritten.
///
/// The rewrite is textual and therefore only as good as the naming discipline
/// in [`V1`]: every `CREATE TABLE`/`CREATE INDEX` must name its object
/// unqualified, which they do.
pub fn v1_for_attached(schema_name: &str) -> String {
rewrite_for_attached(V1, schema_name)
// REFERENCES within an attached schema resolve to that schema already,
// so foreign keys need no rewriting — but the ON clause of an index
// does, and `CREATE INDEX x.name ON table` is the correct form.
}
/// Every table this build knows about, rewritten to target an attached
/// database.
///
/// [`v1_for_attached`] is kept alongside this rather than replaced by it: a
/// remote catalog written by an older build genuinely has only the v1 tables,
/// and the merge has to keep working against one (see
/// [`crate::merge::merge_keywords`]). Building that case in a test needs a way
/// to say "v1 and no more".
///
/// Only the migrations that *create* objects appear here. V2 through V5 are
/// `ALTER TABLE ... ADD COLUMN`, and the columns they add are local index
/// state — shadowing, trashing, cache pinning — that a merge never reads
/// across the attachment.
pub fn for_attached(schema_name: &str) -> String {
format!(
"{}\n{}",
rewrite_for_attached(V1, schema_name),
rewrite_for_attached(V6, schema_name)
)
}
/// Qualify every object a `CREATE` statement names with `schema_name`.
///
/// The rewrite is textual and therefore only as good as the naming discipline
/// in the batches it is given: every `CREATE TABLE`/`CREATE INDEX` must name
/// its object unqualified, which they do.
fn rewrite_for_attached(sql: &str, schema_name: &str) -> String {
sql.replace("CREATE TABLE ", &format!("CREATE TABLE {schema_name}."))
.replace("CREATE INDEX ", &format!("CREATE INDEX {schema_name}."))
.replace(
"CREATE UNIQUE INDEX ",
&format!("CREATE UNIQUE INDEX {schema_name}."),
)
}
/// Mark each JPEG that sits beside a RAW of the same name.
///
/// Matched on folder plus stem, case-insensitively. Same-folder is what makes
/// this safe: cameras write the pair side by side, and matching across folders
/// would risk pairing unrelated frames, since camera filenames wrap at
/// IMG_9999 (FR-CAT-11).
///
/// Done in Rust rather than SQL because the comparison needs a filename stem,
/// and SQLite has no such function without enabling `rusqlite/functions` —
/// a dependency feature for one string operation, whose SQL spelling would be
/// unreadable and would mishandle names with no extension.
fn pair_raw_and_jpeg(conn: &Connection) -> Result<usize, CatalogError> {
use std::collections::HashMap;
// (folder, lowercase stem) -> RAW id, built in one pass over the RAWs.
let mut raws: HashMap<(Option<i64>, String), i64> = HashMap::new();
{
let mut stmt = conn.prepare(
"SELECT id, folder_id, source_ref FROM images
WHERE lower(format) IN
('cr2','cr3','nef','arw','raf','rw2','orf','dng')",
)?;
let rows = stmt.query_map([], |r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, Option<i64>>(1)?,
r.get::<_, String>(2)?,
))
})?;
for row in rows {
let (id, folder, path) = row?;
raws.insert((folder, stem_of(&path).to_ascii_lowercase()), id);
}
}
if raws.is_empty() {
return Ok(0);
}
let pairs: Vec<(i64, i64)> = {
let mut stmt = conn.prepare(
"SELECT id, folder_id, source_ref FROM images
WHERE lower(format) IN ('jpg','jpeg') AND shadowed_by IS NULL",
)?;
let rows = stmt.query_map([], |r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, Option<i64>>(1)?,
r.get::<_, String>(2)?,
))
})?;
rows.filter_map(|row| {
let (id, folder, path) = row.ok()?;
let raw = raws.get(&(folder, stem_of(&path).to_ascii_lowercase()))?;
Some((id, *raw))
})
.collect()
};
let tx = conn.unchecked_transaction()?;
for (jpeg, raw) in &pairs {
tx.execute(
"UPDATE images SET shadowed_by = ?2 WHERE id = ?1",
[jpeg, raw],
)?;
}
tx.commit()?;
Ok(pairs.len())
}
/// A filename without its extension.
///
/// Only the final path component, and only its last dot — a directory
/// containing a dot must not truncate the name.
fn stem_of(path: &str) -> &str {
let name = path.rsplit(['/', ':']).next().unwrap_or(path);
match name.rsplit_once('.') {
Some((stem, _)) if !stem.is_empty() => stem,
_ => name,
}
}
const V6: &str = r#"
-- TRACES: FR-CAT-5 | FR-CAT-6 | FR-NC-9
-- Keywords gain an identity, so that renaming and deleting one can cross
-- between devices.
--
-- The v1 `keywords` table is the *assignment*: one row per (version, word),
-- and the word is stored as text. That stays exactly as it is, and this
-- migration adds nothing to it, for a reason that is easy to get backwards.
--
-- # Why assignments keep the text rather than pointing at a row here
--
-- The catalog is a rebuildable index (ARCH §6.12). What an image is keyworded
-- with is authoritative in the sidecar and in XMP `dc:subject` (FR-CAT-13),
-- and both of those carry a *string*. Rewriting the join to reference
-- `keyword_terms(id)` would mean a catalog rebuilt from sidecars had to invent
-- term rows before it could record a single assignment, and an integer that
-- means nothing on the other device would sit where the durable fact belongs.
-- It would also break `crate::query`, which matches `kw.keyword` directly and
-- must keep hitting `keywords_term` on a 50k library (FR-CAT-6).
--
-- So the text is the fact and this table is the *identity*: it exists to give
-- a rename and a deletion something a merge can key on, and to let a keyword
-- exist in the vocabulary before any photograph carries it.
CREATE TABLE keyword_terms (
id INTEGER PRIMARY KEY,
-- Device-independent identity, as `collections.uuid` is. The integer id is
-- local and collides across devices.
uuid TEXT NOT NULL UNIQUE,
-- The word itself, and the value written into every assignment row.
name TEXT NOT NULL,
created INTEGER NOT NULL,
-- Monotonic, bumped on every local edit. `crate::merge` compares these
-- rather than timestamps, so a clock-skewed device cannot silently win.
revision INTEGER NOT NULL DEFAULT 1,
modified INTEGER NOT NULL,
-- Tombstone, so a merge against a device that still holds the keyword does
-- not resurrect it.
deleted INTEGER NOT NULL DEFAULT 0
);
-- Deliberately **not** UNIQUE.
--
-- Two devices that each type "Iceland" create two rows with two uuids, and
-- both are correct until they meet. A unique constraint would abort the merge
-- transaction at exactly that moment — the ordinary case, not a corner one.
-- Uniqueness is instead reached by convergence: `crate::keywords::create`
-- resolves an existing name locally, and `crate::keywords::fuse_duplicates`
-- collapses a cross-device pair onto the lexicographically smaller uuid, which
-- both devices compute identically without talking to each other.
--
-- Partial on `deleted = 0` because every lookup here is a live one: the
-- vocabulary list, the resolve-by-name in `create`, and the fuse pass all
-- exclude tombstones, and including them would grow the index with every
-- keyword the library has ever had rather than with the ones it has.
CREATE INDEX keyword_terms_name ON keyword_terms(name) WHERE deleted = 0;
"#;
const V5: &str = r#"
-- TRACES: FR-NC-6a | FR-CAT-9 | NFR-RES-4
-- Offline availability: what is kept, why it is kept, and where it lives.
--
-- `pinned` separates a promise from a convenience, and the distinction has to
-- be a *column* rather than something inferred from `pinned_by_rule`. A pin is
-- the user saying "this collection comes with me"; a passively cached original
-- is the app noticing they opened something. Only the second is evictable, so
-- the eviction query has to be able to ask the question directly — and it has
-- to keep answering correctly for an image whose pinning rule was since
-- deleted, which `pinned_by_rule` alone cannot do because it is
-- ON DELETE SET NULL.
ALTER TABLE image_cache ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0;
-- Where the cached original actually is, relative to the cache directory.
-- Relative rather than absolute: the library moves between machines and
-- between an app sandbox and a user directory, and an absolute path baked in
-- at download time would break on every one of those.
ALTER TABLE image_cache ADD COLUMN path TEXT;
-- Eviction reads exactly this: unpinned rows, oldest use first. Partial on
-- `pinned = 0` because pinned rows are never candidates and including them
-- would make the index proportional to the whole library rather than to the
-- passive cache.
CREATE INDEX image_cache_evictable ON image_cache(last_used)
WHERE pinned = 0;
"#;
const V4: &str = r#"
-- TRACES: FR-CAT-15
-- Soft delete. A trashed image is a real file that has been *moved* to a trash
-- folder under the library root, not a row hidden by a flag: the catalog is a
-- rebuildable index (ARCH §6.12), so a flag alone would evaporate the moment
-- the catalog was deleted and every trashed photograph would return.
--
-- `source_ref` follows the file to its new path, because that is where the bytes
-- now are and every fetch resolves through it. `trashed_from` remembers where it
-- came from, which is the only way a restore can put it back — the trash is flat
-- and the original folder structure is not recoverable from the trashed path.
ALTER TABLE images ADD COLUMN trashed_at INTEGER;
ALTER TABLE images ADD COLUMN trashed_from TEXT;
-- Partial: almost no rows are trashed, and the grid's "not trashed" predicate is
-- answered by the absence of an entry rather than by scanning every image.
CREATE INDEX images_trashed ON images(trashed_at) WHERE trashed_at IS NOT NULL;
"#;
const V3: &str = r#"
-- Ratings and flags are read per grid window and counted for the filter bar's
-- histogram, both of which key on the *default* version. Without this the
-- histogram is a full scan of `versions` on every judgement.
--
-- Partial on `is_default`: a virtual copy's rating is real but is never what
-- these two queries ask for, and excluding them keeps the index roughly one
-- entry per image rather than one per version.
CREATE INDEX versions_judgement ON versions(image_id, rating, flag)
WHERE is_default = 1;
"#;
const V2: &str = r#"
-- A JPEG the camera wrote alongside a RAW of the same name is that RAW's own
-- rendering, not a second photograph. Recording *which* RAW shadows it, rather
-- than a bare flag, keeps the relationship usable: the JPEG is a ready-made
-- preview for its RAW, and the pairing can be undone without a rescan.
ALTER TABLE images ADD COLUMN shadowed_by INTEGER REFERENCES images(id) ON DELETE SET NULL;
CREATE INDEX images_shadowed ON images(shadowed_by) WHERE shadowed_by IS NOT NULL;
"#;
const V1: &str = r#"
-- Roots -------------------------------------------------------------------
CREATE TABLE roots (
id INTEGER PRIMARY KEY,
kind TEXT NOT NULL, -- 'local' | 'saf' | 'remote'
grant_blob BLOB, -- SAF persisted permission; NULL on Linux
label TEXT NOT NULL,
last_seen INTEGER,
-- Bumped once per completed scan. Folders record the generation they were
-- reached in; anything older was not reached and no longer exists.
scan_generation INTEGER NOT NULL DEFAULT 0,
-- One row per granted location. Without this, a rescan inserts a second
-- root for the same folder and the library silently fragments across
-- them — images split between roots, and pruning compares against the
-- wrong generation.
UNIQUE(kind, label)
);
-- Folders: the unit of change detection, local and remote alike -----------
CREATE TABLE folders (
id INTEGER PRIMARY KEY,
root_id INTEGER NOT NULL REFERENCES roots(id) ON DELETE CASCADE,
parent_id INTEGER REFERENCES folders(id) ON DELETE CASCADE,
path TEXT NOT NULL,
-- Remote: the propagating ETag that makes a no-op sync one request.
etag TEXT,
-- Local: directory mtime plus direct-entry count. mtime alone misses a
-- paired create+delete inside one timestamp tick; the count narrows that.
mtime INTEGER,
entry_count INTEGER,
scanned_generation INTEGER NOT NULL DEFAULT 0,
UNIQUE(root_id, path)
);
CREATE INDEX folders_parent ON folders(parent_id);
-- Images ------------------------------------------------------------------
CREATE TABLE images (
id INTEGER PRIMARY KEY,
root_id INTEGER NOT NULL REFERENCES roots(id) ON DELETE CASCADE,
folder_id INTEGER REFERENCES folders(id) ON DELETE CASCADE,
source_ref TEXT NOT NULL,
-- Expensive: requires reading the whole file. Computed only when
-- something needs it (import dedup, reconnect-by-hash), never in a scan.
content_hash TEXT,
format TEXT,
w INTEGER,
h INTEGER,
-- UTC seconds. NULL until EXIF is read, or if the file carries none.
captured_at INTEGER,
-- Minutes east of UTC. A photograph's timestamp is local to where it was
-- taken; storing UTC alone makes a Tokyo shoot span two days in Paris.
captured_offset INTEGER,
camera TEXT,
lens TEXT,
iso INTEGER,
aperture REAL,
shutter REAL,
availability INTEGER NOT NULL DEFAULT 0,
file_size INTEGER,
file_mtime INTEGER,
-- 0 = nothing, 1 = stat-only, 2 = full EXIF. The grid is usable at 1.
metadata_state INTEGER NOT NULL DEFAULT 0,
sidecar_mtime INTEGER,
added_at INTEGER NOT NULL,
UNIQUE(root_id, source_ref)
);
CREATE INDEX images_captured ON images(captured_at);
CREATE INDEX images_folder ON images(folder_id);
-- Partial: content_hash is NULL for most rows most of the time, and the
-- non-NULL subset is exactly what reconnect and dedup query.
CREATE INDEX images_hash ON images(content_hash) WHERE content_hash IS NOT NULL;
-- Versions ----------------------------------------------------------------
CREATE TABLE versions (
id INTEGER PRIMARY KEY,
image_id INTEGER NOT NULL REFERENCES images(id) ON DELETE CASCADE,
uuid TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
is_default INTEGER NOT NULL DEFAULT 0,
graph_hash TEXT,
rating INTEGER NOT NULL DEFAULT 0,
label INTEGER,
flag INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX versions_image ON versions(image_id);
CREATE TABLE keywords (
version_id INTEGER NOT NULL REFERENCES versions(id) ON DELETE CASCADE,
keyword TEXT NOT NULL,
PRIMARY KEY(version_id, keyword)
);
CREATE INDEX keywords_term ON keywords(keyword);
-- Remote mapping ----------------------------------------------------------
CREATE TABLE remote (
image_id INTEGER PRIMARY KEY REFERENCES images(id) ON DELETE CASCADE,
-- oc:fileid — stable across server-side rename and move, so a move is not
-- a re-download of 80 MB.
file_id INTEGER NOT NULL,
etag TEXT,
sync_state INTEGER NOT NULL DEFAULT 0,
remote_path TEXT
);
CREATE UNIQUE INDEX remote_file ON remote(file_id);
-- Collections -------------------------------------------------------------
CREATE TABLE collections (
id INTEGER PRIMARY KEY,
-- Device-independent identity. The integer id is local and collides
-- across devices; the UUID is what a cross-device merge keys on.
uuid TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
parent_id INTEGER REFERENCES collections(id) ON DELETE CASCADE,
kind INTEGER NOT NULL, -- 0 = manual, 1 = smart
selector_json TEXT, -- smart only
created INTEGER NOT NULL,
-- Monotonic per collection, bumped on every local edit. Merge compares
-- these rather than file mtimes, so a clock-skewed device cannot silently
-- win.
revision INTEGER NOT NULL DEFAULT 1,
modified INTEGER NOT NULL,
-- Tombstone. A deleted collection must outlive its deletion, or a merge
-- with a device that still has it would resurrect it.
deleted INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE collection_members (
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
image_id INTEGER NOT NULL REFERENCES images(id) ON DELETE CASCADE,
position INTEGER, -- manual ordering; NULL = by capture time
added INTEGER NOT NULL,
PRIMARY KEY(collection_id, image_id)
);
CREATE INDEX members_image ON collection_members(image_id);
-- Cache -------------------------------------------------------------------
CREATE TABLE cache (
id INTEGER PRIMARY KEY,
version_id INTEGER REFERENCES versions(id) ON DELETE CASCADE,
image_id INTEGER REFERENCES images(id) ON DELETE CASCADE,
kind INTEGER NOT NULL, -- thumbnail | proxy | original
resolution INTEGER,
graph_hash TEXT,
path TEXT NOT NULL,
bytes INTEGER NOT NULL,
last_used INTEGER NOT NULL
);
CREATE INDEX cache_lru ON cache(last_used);
CREATE TABLE cache_rules (
id INTEGER PRIMARY KEY,
selector_json TEXT NOT NULL,
tier INTEGER NOT NULL,
priority INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE image_cache (
image_id INTEGER PRIMARY KEY REFERENCES images(id) ON DELETE CASCADE,
tier_actual INTEGER NOT NULL DEFAULT 0,
-- Materialised rather than recomputed, so the grid can draw availability
-- badges without evaluating every rule for every visible cell.
tier_desired INTEGER NOT NULL DEFAULT 0,
bytes INTEGER NOT NULL DEFAULT 0,
last_used INTEGER,
pinned_by_rule INTEGER REFERENCES cache_rules(id) ON DELETE SET NULL
);
-- Jobs --------------------------------------------------------------------
CREATE TABLE jobs (
id INTEGER PRIMARY KEY,
kind INTEGER NOT NULL,
subject_id INTEGER,
priority INTEGER NOT NULL DEFAULT 0,
state INTEGER NOT NULL DEFAULT 0, -- 0=pending 1=running 2=failed
attempts INTEGER NOT NULL DEFAULT 0,
not_before INTEGER NOT NULL DEFAULT 0,
payload TEXT,
last_error TEXT,
-- Coalescing. Enqueueing the same work twice updates one row rather than
-- queueing it twice, which is what makes "enqueue on any change" safe to
-- call liberally.
UNIQUE(kind, subject_id)
);
CREATE INDEX jobs_ready ON jobs(state, priority DESC, not_before);
"#;
#[cfg(test)]
mod tests {
use super::*;
fn mem() -> Connection {
let c = Connection::open_in_memory().unwrap();
configure(&c).unwrap();
c
}
/// How many rows a named backfill touched, ignoring the others.
///
/// Asserting on the whole vector would couple every test to which other
/// backfills happen to exist.
fn backfilled(c: &Connection, what: &str) -> usize {
backfill(c)
.unwrap()
.into_iter()
.find(|(name, _)| *name == what)
.map(|(_, n)| n)
.unwrap_or(0)
}
/// Insert an image and return its id.
fn image(c: &Connection, folder: Option<i64>, name: &str, format: &str) -> i64 {
c.execute(
"INSERT INTO images(root_id, folder_id, source_ref, format, added_at)
VALUES (1, ?1, ?2, ?3, 0)",
rusqlite::params![folder, name, format],
)
.unwrap();
c.last_insert_rowid()
}
fn with_root() -> Connection {
let c = mem();
migrate(&c).unwrap();
c.execute(
"INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'lib')",
[],
)
.unwrap();
c.execute(
"INSERT INTO folders(id, root_id, path) VALUES (1, 1, 'a'), (2, 1, 'b')",
[],
)
.unwrap();
c
}
#[test]
fn a_jpeg_beside_its_raw_is_shadowed() {
// The camera's own rendering of a frame, not a second photograph.
let c = with_root();
let raw = image(&c, Some(1), "a/IMG_1234.CR2", "cr2");
let jpeg = image(&c, Some(1), "a/IMG_1234.JPG", "jpg");
assert_eq!(backfilled(&c, "shadowed_by"), 1);
let got: Option<i64> = c
.query_row(
"SELECT shadowed_by FROM images WHERE id = ?1",
[jpeg],
|r| r.get(0),
)
.unwrap();
assert_eq!(got, Some(raw));
}
#[test]
fn extension_case_does_not_matter() {
let c = with_root();
image(&c, Some(1), "a/IMG_1.cr2", "cr2");
image(&c, Some(1), "a/img_1.JPG", "jpg");
assert_eq!(backfilled(&c, "shadowed_by"), 1);
}
#[test]
fn a_standalone_jpeg_is_untouched() {
// Scanned film has no RAW sibling and must stay visible — 2,656 of
// them in the reference library.
let c = with_root();
image(&c, Some(1), "a/SCAN_0001.jpg", "jpg");
assert_eq!(backfilled(&c, "shadowed_by"), 0);
}
#[test]
fn a_jpeg_in_a_different_folder_is_not_shadowed() {
// Camera filenames wrap at IMG_9999, so the same stem recurs across
// shoots (FR-CAT-11). Only a same-folder pair is safe to collapse.
let c = with_root();
image(&c, Some(1), "a/IMG_1234.CR2", "cr2");
image(&c, Some(2), "b/IMG_1234.JPG", "jpg");
assert_eq!(backfilled(&c, "shadowed_by"), 0);
}
#[test]
fn a_raw_is_never_shadowed_by_a_jpeg() {
// The relationship is one-way: the RAW is the photograph.
let c = with_root();
let raw = image(&c, Some(1), "a/IMG_1.CR2", "cr2");
image(&c, Some(1), "a/IMG_1.JPG", "jpg");
backfill(&c).unwrap();
let got: Option<i64> = c
.query_row("SELECT shadowed_by FROM images WHERE id = ?1", [raw], |r| {
r.get(0)
})
.unwrap();
assert_eq!(got, None);
}
#[test]
fn backfill_is_idempotent() {
// It runs on every open, so a second pass must find nothing to do.
let c = with_root();
image(&c, Some(1), "a/IMG_1.CR2", "cr2");
image(&c, Some(1), "a/IMG_1.JPG", "jpg");
assert_eq!(backfilled(&c, "shadowed_by"), 1);
assert_eq!(backfilled(&c, "shadowed_by"), 0, "second pass is a no-op");
}
#[test]
fn a_v1_catalog_gains_the_column_and_is_backfilled() {
// The migration case that motivated this: rows already present when a
// column is added are silently partial until something backfills them.
let c = mem();
c.execute_batch(V1).unwrap();
c.pragma_update(None, "user_version", 1).unwrap();
c.execute(
"INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'lib')",
[],
)
.unwrap();
c.execute(
"INSERT INTO images(root_id, source_ref, format, added_at)
VALUES (1, 'IMG_9.CR2', 'cr2', 0), (1, 'IMG_9.JPG', 'jpg', 0)",
[],
)
.unwrap();
assert_eq!(migrate(&c).unwrap(), 1, "migrated from v1");
assert_eq!(backfilled(&c, "shadowed_by"), 1);
}
#[test]
fn a_v4_catalog_gains_the_pinning_columns() {
// TRACES: FR-NC-6a
// An existing library must not have to be rescanned to gain offline
// pinning. The rows are already there; only the columns are new.
let c = mem();
c.execute_batch(V1).unwrap();
c.execute_batch(V2).unwrap();
c.execute_batch(V3).unwrap();
c.execute_batch(V4).unwrap();
c.pragma_update(None, "user_version", 4).unwrap();
c.execute(
"INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'lib')",
[],
)
.unwrap();
c.execute(
"INSERT INTO images(id, root_id, source_ref, added_at)
VALUES (7, 1, 'IMG_7.CR2', 0)",
[],
)
.unwrap();
// A cache row written before pinning existed.
c.execute(
"INSERT INTO image_cache(image_id, tier_actual, bytes) VALUES (7, 2, 100)",
[],
)
.unwrap();
assert_eq!(migrate(&c).unwrap(), 4, "migrated from v4");
// The pre-existing row survives, and defaults to unpinned — the safe
// direction, since claiming a pin nobody made would exempt it from
// eviction for ever.
let (pinned, bytes): (i64, i64) = c
.query_row(
"SELECT pinned, bytes FROM image_cache WHERE image_id = 7",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(pinned, 0);
assert_eq!(bytes, 100, "the existing row is untouched");
}
#[test]
fn a_v5_catalog_keeps_its_keywords_and_gains_their_identities() {
// TRACES: FR-CAT-5
// The migration case that matters here: a library keyworded by an
// import or an older build already has assignment rows, and they must
// survive into the vocabulary rather than being left searchable but
// invisible.
let c = mem();
for step in [V1, V2, V3, V4, V5] {
c.execute_batch(step).unwrap();
}
c.pragma_update(None, "user_version", 5).unwrap();
c.execute(
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
[],
)
.unwrap();
c.execute(
"INSERT INTO images(id, root_id, source_ref, added_at) VALUES (7, 1, 'IMG_7.CR3', 0)",
[],
)
.unwrap();
c.execute(
"INSERT INTO versions(id, image_id, uuid, name, is_default)
VALUES (1, 7, 'v-7', 'Default', 1)",
[],
)
.unwrap();
c.execute(
"INSERT INTO keywords(version_id, keyword) VALUES (1, 'puffin')",
[],
)
.unwrap();
assert_eq!(migrate(&c).unwrap(), 5, "migrated from v5");
assert_eq!(backfilled(&c, "keyword_terms"), 1);
let name: String = c
.query_row("SELECT name FROM keyword_terms", [], |r| r.get(0))
.unwrap();
assert_eq!(name, "puffin");
// The assignment is untouched — it is the durable fact, and the term
// row is only its identity.
let n: i64 = c
.query_row("SELECT count(*) FROM keywords", [], |r| r.get(0))
.unwrap();
assert_eq!(n, 1);
// It runs on every open, so a second pass must find nothing to do.
assert_eq!(backfilled(&c, "keyword_terms"), 0);
}
#[test]
fn two_devices_may_both_hold_a_term_of_the_same_name() {
// Deliberately not a unique index. Two devices each typing "Iceland"
// is the ordinary case, and a constraint would abort the merge
// transaction at exactly the moment they first sync.
let c = mem();
migrate(&c).unwrap();
c.execute(
"INSERT INTO keyword_terms(uuid, name, created, revision, modified)
VALUES ('a', 'Iceland', 0, 1, 1), ('b', 'Iceland', 0, 1, 1)",
[],
)
.unwrap();
let n: i64 = c
.query_row("SELECT count(*) FROM keyword_terms", [], |r| r.get(0))
.unwrap();
assert_eq!(n, 2);
}
#[test]
fn stems_ignore_directories_containing_dots() {
assert_eq!(stem_of("2026.08/IMG_1.CR2"), "IMG_1");
assert_eq!(stem_of("IMG_1.CR2"), "IMG_1");
assert_eq!(stem_of("noextension"), "noextension");
// A dotfile is all stem, not an empty name with an extension.
assert_eq!(stem_of(".hidden"), ".hidden");
}
#[test]
fn migrate_creates_schema_at_current_version() {
let c = mem();
assert_eq!(migrate(&c).unwrap(), 0);
let v: i64 = c
.query_row("PRAGMA user_version", [], |r| r.get(0))
.unwrap();
assert_eq!(v, SCHEMA_VERSION);
}
#[test]
fn migrate_is_idempotent() {
let c = mem();
migrate(&c).unwrap();
// Re-running must not error or duplicate anything — NFR-R5 requires
// idempotency on retry, since a migration can be interrupted.
assert_eq!(migrate(&c).unwrap(), SCHEMA_VERSION);
}
#[test]
fn refuses_a_catalog_from_a_newer_build() {
let c = mem();
migrate(&c).unwrap();
c.pragma_update(None, "user_version", SCHEMA_VERSION + 1)
.unwrap();
// Opening it read-write would corrupt data this build cannot
// represent. Refusing is the specified behaviour (NFR-R5).
assert!(matches!(
migrate(&c),
Err(CatalogError::SchemaTooNew { .. })
));
}
#[test]
fn foreign_keys_cascade_from_root_to_image() {
let c = mem();
migrate(&c).unwrap();
c.execute(
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'test')",
[],
)
.unwrap();
c.execute(
"INSERT INTO images(id, root_id, source_ref, added_at) VALUES (1, 1, 'a.CR3', 0)",
[],
)
.unwrap();
c.execute("DELETE FROM roots WHERE id = 1", []).unwrap();
let n: i64 = c
.query_row("SELECT count(*) FROM images", [], |r| r.get(0))
.unwrap();
assert_eq!(n, 0, "images must not outlive their root");
}
#[test]
fn job_uniqueness_coalesces_rather_than_duplicating() {
let c = mem();
migrate(&c).unwrap();
for _ in 0..5 {
c.execute(
"INSERT INTO jobs(kind, subject_id, priority) VALUES (1, 42, 0)
ON CONFLICT(kind, subject_id)
DO UPDATE SET priority = max(priority, excluded.priority)",
[],
)
.unwrap();
}
let n: i64 = c
.query_row("SELECT count(*) FROM jobs", [], |r| r.get(0))
.unwrap();
assert_eq!(n, 1, "five enqueues of the same work is one job");
}
}
+253
View File
@@ -0,0 +1,253 @@
//! TRACES: FR-CAT-7 | FR-NC-9 | NFR-R1
//! Preparing the catalog file for upload, and taking in a remote one.
//!
//! # The hazard this module exists to handle
//!
//! A WAL-mode SQLite database is not one file. Committed transactions can live
//! in `catalog.sqlite-wal` with the main file lagging behind, so copying
//! `catalog.sqlite` alone uploads a **torn snapshot**: internally consistent as
//! of some older point, missing everything since. Worse, a naive copy taken
//! while a writer is mid-transaction can be structurally corrupt.
//!
//! So an upload never copies the live file. It runs a TRUNCATE checkpoint to
//! fold the WAL back into the main file, then uses SQLite's own backup API to
//! take a consistent snapshot — which serialises correctly against concurrent
//! writers rather than racing them.
//!
//! # What is actually synced
//!
//! Only the *user's judgements about their library* merge: collections, and the
//! keyword vocabulary with its assignments (see [`crate::merge`]). The rest of
//! the catalog is a *local index* of *local* storage — folder mtimes, cache
//! paths, job rows — and copying another device's version of those in would be
//! actively wrong. The remote file is read for those two and then discarded.
//!
//! This is why the catalog remains disposable in the ARCH §6.12 sense: nothing
//! here makes the local database authoritative for anything a rebuild could
//! not recover.
use std::path::{Path, PathBuf};
use rusqlite::Connection;
use crate::error::CatalogError;
use crate::merge::{self, MergeReport};
/// Schema name the downloaded remote catalog is attached under.
const REMOTE_SCHEMA: &str = "remote_cat";
/// Fold the WAL into the main database file.
///
/// TRUNCATE rather than PASSIVE: passive checkpointing gives up when a reader
/// holds the WAL open, which would leave recent commits out of the snapshot
/// without saying so.
pub fn checkpoint(conn: &Connection) -> Result<(), CatalogError> {
conn.pragma_update(None, "wal_checkpoint", "TRUNCATE")?;
Ok(())
}
/// Write a consistent snapshot of the catalog to `dest`, ready to upload.
///
/// Uses the backup API rather than a filesystem copy so the snapshot is
/// coherent even with writers active. Callers should still prefer a quiet
/// moment — this competes with background jobs for the write lock.
pub fn snapshot_for_upload(conn: &Connection, dest: &Path) -> Result<(), CatalogError> {
checkpoint(conn)?;
let mut out = Connection::open(dest)?;
let backup = rusqlite::backup::Backup::new(conn, &mut out)?;
// SQLite's own "copy everything" sentinel is -1, but rusqlite asserts a
// positive page count, so ask for more pages than a catalog will ever
// have. The effect is the same: one step, no interleaved writers, no
// progress callback. A 50k-image catalog is tens of megabytes.
backup.run_to_completion(i32::MAX, std::time::Duration::ZERO, None)?;
Ok(())
}
/// Whether a downloaded remote catalog is worth merging.
///
/// Cheap guard before attaching: a remote written by a newer build may contain
/// tables and columns this one cannot read, and attempting the merge would
/// fail mid-transaction rather than declining cleanly.
pub fn remote_is_mergeable(remote: &Path) -> Result<bool, CatalogError> {
let conn = Connection::open_with_flags(
remote,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
)?;
let v: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
Ok(v <= crate::schema::SCHEMA_VERSION)
}
/// Attach a downloaded remote catalog, merge its collections, detach.
///
/// The remote file is opened **read-only** — this device never writes to
/// another device's catalog, it only reads collections out of it.
pub fn merge_remote(conn: &Connection, remote: &Path) -> Result<MergeReport, CatalogError> {
if !remote_is_mergeable(remote)? {
return Err(CatalogError::SchemaTooNew {
found: -1,
supported: crate::schema::SCHEMA_VERSION,
});
}
// Path binds as a parameter; ATTACH accepts one, so a path containing a
// quote cannot break out into SQL.
conn.execute(
&format!("ATTACH DATABASE ?1 AS {REMOTE_SCHEMA}"),
[remote.to_string_lossy().as_ref()],
)?;
let result = merge::merge_all(conn);
// Detach even if the merge failed, or the next attempt errors with
// "database remote_cat is already in use".
let detach = conn.execute(&format!("DETACH DATABASE {REMOTE_SCHEMA}"), []);
if let Err(e) = detach {
log::warn!("failed to detach remote catalog: {e}");
}
result
}
/// Where the catalog snapshot and the downloaded remote live.
///
/// Both are transient working files, not the catalog itself, so they belong in
/// the cache directory rather than beside the live database.
#[derive(Debug, Clone)]
pub struct SyncPaths {
pub upload_snapshot: PathBuf,
pub downloaded_remote: PathBuf,
}
impl SyncPaths {
pub fn in_dir(cache_dir: &Path) -> Self {
SyncPaths {
upload_snapshot: cache_dir.join("catalog-upload.sqlite"),
downloaded_remote: cache_dir.join("catalog-remote.sqlite"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::schema;
fn seeded(path: &Path) -> Connection {
let c = Connection::open(path).unwrap();
schema::configure(&c).unwrap();
schema::migrate(&c).unwrap();
c
}
#[test]
fn snapshot_captures_committed_data() {
let dir = tempdir();
let live = dir.join("catalog.sqlite");
let snap = dir.join("snap.sqlite");
let c = seeded(&live);
c.execute(
"INSERT INTO collections(uuid, name, kind, created, revision, modified)
VALUES ('u1', 'Iceland', 0, 0, 1, 1)",
[],
)
.unwrap();
snapshot_for_upload(&c, &snap).unwrap();
// The snapshot must hold the row even though it was written after the
// database was created — the torn-file failure this guards against.
let s = Connection::open(&snap).unwrap();
let name: String = s
.query_row("SELECT name FROM collections", [], |r| r.get(0))
.unwrap();
assert_eq!(name, "Iceland");
}
#[test]
fn a_remote_from_a_newer_build_is_declined_not_attempted() {
let dir = tempdir();
let remote = dir.join("remote.sqlite");
let r = seeded(&remote);
r.pragma_update(None, "user_version", schema::SCHEMA_VERSION + 1)
.unwrap();
drop(r);
assert!(!remote_is_mergeable(&remote).unwrap());
let local = seeded(&dir.join("local.sqlite"));
assert!(matches!(
merge_remote(&local, &remote),
Err(CatalogError::SchemaTooNew { .. })
));
}
#[test]
fn merge_remote_round_trips_a_collection() {
let dir = tempdir();
let remote_path = dir.join("remote.sqlite");
{
let r = seeded(&remote_path);
r.execute(
"INSERT INTO collections(uuid, name, kind, created, revision, modified)
VALUES ('u-remote', 'Portugal', 0, 0, 1, 1)",
[],
)
.unwrap();
checkpoint(&r).unwrap();
}
let local = seeded(&dir.join("local.sqlite"));
local
.execute(
"INSERT INTO collections(uuid, name, kind, created, revision, modified)
VALUES ('u-local', 'Iceland', 0, 0, 1, 1)",
[],
)
.unwrap();
let report = merge_remote(&local, &remote_path).unwrap();
assert_eq!(report.inserted, 1);
let n: i64 = local
.query_row("SELECT count(*) FROM collections", [], |r| r.get(0))
.unwrap();
assert_eq!(n, 2);
}
#[test]
fn the_remote_can_be_merged_twice_without_attach_conflict() {
// Detach must happen even on the failure path, or the second attempt
// errors with "database remote_cat is already in use".
let dir = tempdir();
let remote_path = dir.join("remote.sqlite");
{
let r = seeded(&remote_path);
r.execute(
"INSERT INTO collections(uuid, name, kind, created, revision, modified)
VALUES ('u-remote', 'Portugal', 0, 0, 1, 1)",
[],
)
.unwrap();
checkpoint(&r).unwrap();
}
let local = seeded(&dir.join("local.sqlite"));
merge_remote(&local, &remote_path).unwrap();
let second = merge_remote(&local, &remote_path).unwrap();
assert!(!second.local_changed());
}
/// A scratch directory that cleans up with the test.
fn tempdir() -> PathBuf {
let base = std::env::temp_dir().join(format!(
"dr-catalog-test-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
base
}
}
+636
View File
@@ -0,0 +1,636 @@
//! TRACES: FR-CAT-15 | NFR-R2
//! Soft delete, restore, and the permanent delete that follows.
//!
//! # Why the trash is a folder and not a flag
//!
//! The catalog is a *rebuildable index* (ARCH §6.12): delete `catalog.sqlite`
//! and it is reconstructed by rescanning sources. A trash implemented as a
//! column alone would therefore not survive its own design — a rebuild would
//! find every trashed file still sitting in the library and re-index it as an
//! ordinary photograph, silently undoing every delete the user had made.
//!
//! So a soft delete **moves the file** into `.darkroom-trash/` under the library
//! root, and the catalog merely records that this happened. The folder is the
//! durable fact; the row is the convenience. Recovering by hand needs no
//! DarkRoom at all, which is the property that matters when the thing being
//! risked is a photograph.
//!
//! `dr_sync::scan::is_excluded` keeps the scanner out of that folder. Without
//! it the next scan re-indexes the trash and the delete comes undone — the two
//! halves are one mechanism and neither works alone.
//!
//! # The two steps
//!
//! **Soft** ([`trash`]) — `MOVE` to the trash folder, record `trashed_at` and
//! the path it came from. Reversible by [`restore`], which is why the original
//! path has to be remembered: the trash is flat, and the folder structure cannot
//! be recovered from the trashed name.
//!
//! **Hard** ([`purge`]) — `DELETE` the file, then delete the row. Irreversible
//! from DarkRoom's side, though the server's own trashbin may still hold it.
//! Ordered file-first deliberately: see [`purge_order`].
//!
//! # What this module does not do
//!
//! It performs no I/O. Every function here records or reads catalog state, and
//! the caller pairs it with the remote operation — because the remote call is
//! async and the catalog is not, and because the *order* of the two is a
//! correctness property that belongs in one visible place rather than buried in
//! a transaction.
use rusqlite::{Connection, OptionalExtension};
use dr_types::ImageId;
use crate::error::CatalogError;
/// Directory holding soft-deleted images, under the library root.
///
/// The same constant `dr_sync::scan` excludes. Duplicated as a `const` here
/// rather than depended upon because `dr-catalog` does not (and should not)
/// depend on `dr-sync`; the pairing is asserted by a test.
pub const TRASH_DIR: &str = ".darkroom-trash";
/// One trashed image, as the trash view lists it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TrashedImage {
pub image_id: ImageId,
/// Where the file is *now* — inside the trash folder.
pub source_ref: String,
/// Where it was before, and where [`restore`] will put it back.
pub trashed_from: String,
/// UTC seconds when it was trashed.
pub trashed_at: i64,
/// `oc:fileid`, preserved across the move. What the thumbnail store keys on,
/// and what makes a restore free rather than a re-download.
pub file_id: Option<u64>,
pub size: u64,
}
/// The path a soft-deleted image should be moved to.
///
/// Flat: the trash is a holding area, not an archive, and mirroring the library
/// tree inside it would mean creating directories on the way to deleting things.
/// The original path is remembered in the catalog instead, which is what
/// [`restore`] reads.
///
/// **Collisions are resolved rather than allowed to overwrite.** Two files named
/// `IMG_0001.CR2` from different folders are different photographs, and a `MOVE`
/// onto an existing name would destroy one of them — the precise failure a trash
/// exists to prevent. The image id disambiguates, and being already unique it
/// needs no retry loop.
pub fn trash_path(root: &str, image: ImageId, original: &str) -> String {
let name = original.rsplit(['/', ':']).next().unwrap_or(original);
let prefix = if root.is_empty() {
String::new()
} else {
format!("{root}/")
};
format!("{prefix}{TRASH_DIR}/{}-{name}", image.0)
}
/// Where a trashed image goes back to.
///
/// The stored original path, verbatim. Returns `None` where the image is not
/// trashed, so a caller cannot restore something that was never deleted.
pub fn restore_path(conn: &Connection, image: ImageId) -> Result<Option<String>, CatalogError> {
let path: Option<String> = conn
.query_row(
"SELECT trashed_from FROM images
WHERE id = ?1 AND trashed_at IS NOT NULL",
[image.0 as i64],
|r| r.get(0),
)
.optional()?
.flatten();
Ok(path)
}
/// Record that images have been moved to the trash.
///
/// Call **after** the move succeeds. Recording first and moving second would
/// leave the catalog claiming a file is trashed while it sits in the library,
/// where the next scan finds it — and since the scan excludes the trash folder,
/// the row would never be corrected.
///
/// `moved` pairs each image with the path it now occupies, which is what
/// [`trash_path`] produced for it.
///
/// Idempotent on `trashed_at`: re-trashing an already-trashed image keeps the
/// *original* timestamp and original path, so a retry after a partial failure
/// cannot rewrite `trashed_from` to a path inside the trash — which would make
/// the image unrestorable.
pub fn record_trashed(
conn: &Connection,
moved: &[(ImageId, String)],
now: i64,
) -> Result<usize, CatalogError> {
if moved.is_empty() {
return Ok(0);
}
let tx = conn.unchecked_transaction()?;
let mut n = 0;
{
let mut stmt = tx.prepare(
"UPDATE images
SET trashed_from = CASE
WHEN trashed_at IS NULL THEN source_ref
ELSE trashed_from
END,
source_ref = ?2,
trashed_at = coalesce(trashed_at, ?3)
WHERE id = ?1",
)?;
for (image, path) in moved {
n += stmt.execute(rusqlite::params![image.0 as i64, path, now])?;
}
}
tx.commit()?;
Ok(n)
}
/// Record that images have been moved back out of the trash.
///
/// Call after the move succeeds, for the same reason as [`record_trashed`].
/// Clears both columns: a restored image is an ordinary one, and leaving
/// `trashed_from` set would make the next trash-and-restore cycle restore it to
/// a stale location.
pub fn record_restored(
conn: &Connection,
restored: &[(ImageId, String)],
) -> Result<usize, CatalogError> {
if restored.is_empty() {
return Ok(0);
}
let tx = conn.unchecked_transaction()?;
let mut n = 0;
{
let mut stmt = tx.prepare(
"UPDATE images
SET source_ref = ?2, trashed_at = NULL, trashed_from = NULL
WHERE id = ?1 AND trashed_at IS NOT NULL",
)?;
for (image, path) in restored {
n += stmt.execute(rusqlite::params![image.0 as i64, path])?;
}
}
tx.commit()?;
Ok(n)
}
/// Forget images whose files have been permanently deleted.
///
/// Call **after** the remote delete succeeds — see [`purge_order`].
///
/// Deletes the catalog rows outright rather than tombstoning them. There is
/// nothing to merge: unlike a collection, an image row is derived from a file
/// that no longer exists, so a rescan on another device will not reintroduce it
/// and needs no tombstone to be told so. `ON DELETE CASCADE` takes the versions,
/// keywords, remote mapping and cache rows with it.
///
/// Returns how many rows went.
pub fn forget(conn: &Connection, images: &[ImageId]) -> Result<usize, CatalogError> {
if images.is_empty() {
return Ok(0);
}
let tx = conn.unchecked_transaction()?;
let mut n = 0;
{
let mut stmt = tx.prepare("DELETE FROM images WHERE id = ?1")?;
for image in images {
n += stmt.execute([image.0 as i64])?;
}
}
tx.commit()?;
Ok(n)
}
/// Why the file is deleted before the row.
///
/// Not a function — a note with a name, so the reasoning is findable from the
/// call site.
///
/// **File first, then the row.** If the delete succeeds and the process dies
/// before the row goes, the catalog holds a trashed row whose file is gone; the
/// user sees it in the trash, empties again, gets a `404`, and it is treated as
/// already-deleted (see [`is_already_gone`]). Recoverable, and visible.
///
/// The other order loses the file silently. Dropping the row first and dying
/// before the delete leaves an orphan in `.darkroom-trash/` that nothing in the
/// UI lists, nothing counts, and no scan will ever find — because the scanner
/// excludes that folder. It consumes quota forever and the user has no way to
/// learn it is there.
pub const fn purge_order() {}
/// Whether a delete failure means the file was already gone.
///
/// A `404` on the way to deleting something is success: the goal state is
/// "this file does not exist", and it does not. Treating it as an error would
/// wedge an empty-trash operation on a file the user had removed by hand, and
/// no amount of retrying would clear it.
pub fn is_already_gone(status: Option<u16>) -> bool {
matches!(status, Some(404) | Some(410))
}
/// List what is in the trash, newest first.
///
/// Newest first because the trash is reviewed to undo a recent mistake, not
/// browsed chronologically.
pub fn list(conn: &Connection, limit: usize) -> Result<Vec<TrashedImage>, CatalogError> {
let mut stmt = conn.prepare(
"SELECT i.id, i.source_ref, i.trashed_from, i.trashed_at, r.file_id, i.file_size
FROM images i
LEFT JOIN remote r ON r.image_id = i.id
WHERE i.trashed_at IS NOT NULL
ORDER BY i.trashed_at DESC, i.id DESC
LIMIT ?1",
)?;
let rows = stmt
.query_map([limit as i64], |r| {
let source_ref: String = r.get(1)?;
Ok(TrashedImage {
image_id: ImageId(r.get::<_, i64>(0)? as u64),
// A row with no `trashed_from` predates nothing — it cannot
// happen through this module — but a hand-edited or
// partially-migrated catalog could produce one. Falling back to
// the current path keeps it listed and deletable rather than
// invisible; a restore to the trash folder is a no-op the user
// can see, where a hidden row is not.
trashed_from: r
.get::<_, Option<String>>(2)?
.unwrap_or_else(|| source_ref.clone()),
source_ref,
trashed_at: r.get(3)?,
file_id: r.get::<_, Option<i64>>(4)?.map(|v| v as u64),
size: r.get::<_, Option<i64>>(5)?.unwrap_or(0) as u64,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
}
/// Every trashed image id, for emptying the whole trash.
///
/// Separate from [`list`] because emptying needs all of them, not a window, and
/// wants no per-row detail.
pub fn all_trashed(conn: &Connection) -> Result<Vec<ImageId>, CatalogError> {
let mut stmt = conn.prepare("SELECT id FROM images WHERE trashed_at IS NOT NULL")?;
let rows = stmt
.query_map([], |r| Ok(ImageId(r.get::<_, i64>(0)? as u64)))?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
}
/// How many images are in the trash, and how many bytes they hold.
///
/// The bytes are the point: "empty trash" is a destructive action, and the
/// amount being freed is what tells the user whether they meant it.
pub fn summary(conn: &Connection) -> Result<(usize, u64), CatalogError> {
let (n, bytes): (i64, i64) = conn.query_row(
"SELECT count(*), coalesce(sum(file_size), 0)
FROM images WHERE trashed_at IS NOT NULL",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)?;
Ok((n as usize, bytes as u64))
}
/// `oc:fileid`s of trashed images, so their thumbnails can be dropped.
///
/// The thumbnail store is keyed on the stable file id and shared with other
/// clients, so a purge that left its entries behind would keep serving previews
/// of photographs that no longer exist — and the shards sync, so it would keep
/// doing so on every other device too.
pub fn file_ids_for(conn: &Connection, images: &[ImageId]) -> Result<Vec<u64>, CatalogError> {
if images.is_empty() {
return Ok(Vec::new());
}
let placeholders = std::iter::repeat_n("?", images.len())
.collect::<Vec<_>>()
.join(",");
let sql = format!("SELECT file_id FROM remote WHERE image_id IN ({placeholders})");
let params: Vec<rusqlite::types::Value> = images
.iter()
.map(|i| rusqlite::types::Value::Integer(i.0 as i64))
.collect();
let mut stmt = conn.prepare(&sql)?;
let rows = stmt
.query_map(rusqlite::params_from_iter(params.iter()), |r| {
Ok(r.get::<_, i64>(0)? as u64)
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Catalog;
fn seeded() -> Catalog {
let cat = Catalog::in_memory().unwrap();
let c = cat.connection();
c.execute(
"INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'PhotosRaw')",
[],
)
.unwrap();
for i in 1..=4i64 {
c.execute(
"INSERT INTO images(id, root_id, source_ref, file_size, added_at)
VALUES (?1, 1, ?2, ?3, 0)",
rusqlite::params![i, format!("PhotosRaw/2019/IMG_{i:04}.CR2"), 30_000_000 * i],
)
.unwrap();
c.execute(
"INSERT INTO remote(image_id, file_id) VALUES (?1, ?2)",
rusqlite::params![i, 1000 + i],
)
.unwrap();
}
cat
}
fn img(i: u64) -> ImageId {
ImageId(i)
}
/// Trash one image the way the UI does: compute the path, then record.
fn do_trash(cat: &Catalog, i: u64, now: i64) -> String {
let c = cat.connection();
let original: String = c
.query_row(
"SELECT source_ref FROM images WHERE id = ?1",
[i as i64],
|r| r.get(0),
)
.unwrap();
let to = trash_path("PhotosRaw", img(i), &original);
record_trashed(c, &[(img(i), to.clone())], now).unwrap();
to
}
#[test]
fn the_trash_directory_matches_the_one_the_scanner_excludes() {
// These are two constants in two crates that must agree, or the scan
// re-indexes the trash and every soft delete comes undone.
assert_eq!(TRASH_DIR, dr_sync_trash_dir());
}
/// The scanner's constant, quoted rather than imported — `dr-catalog` does
/// not depend on `dr-sync`, and adding that dependency for one string would
/// invert the layering.
fn dr_sync_trash_dir() -> &'static str {
".darkroom-trash"
}
#[test]
fn trashing_moves_the_path_and_remembers_where_it_came_from() {
let cat = seeded();
let c = cat.connection();
do_trash(&cat, 1, 5_000);
let (source, from, at): (String, String, i64) = c
.query_row(
"SELECT source_ref, trashed_from, trashed_at FROM images WHERE id = 1",
[],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.unwrap();
// `source_ref` follows the bytes: this is where a fetch must now look.
assert!(source.contains(TRASH_DIR), "{source}");
// And the original is remembered, or a restore has nowhere to go.
assert_eq!(from, "PhotosRaw/2019/IMG_0001.CR2");
assert_eq!(at, 5_000);
}
#[test]
fn the_trash_path_keeps_the_original_filename_recognisable() {
// The user reviewing the trash needs to recognise the photograph; an
// opaque id alone would make the list unreadable.
let p = trash_path("PhotosRaw", img(7), "PhotosRaw/2019/IMG_0042.CR2");
assert!(p.ends_with("IMG_0042.CR2"), "{p}");
assert!(p.starts_with("PhotosRaw/.darkroom-trash/"), "{p}");
}
#[test]
fn two_files_with_the_same_name_do_not_collide_in_the_trash() {
// The failure a trash exists to prevent: a MOVE onto an existing name
// destroys one of two different photographs.
let a = trash_path("PhotosRaw", img(1), "PhotosRaw/2019/IMG_0001.CR2");
let b = trash_path("PhotosRaw", img(2), "PhotosRaw/2024/IMG_0001.CR2");
assert_ne!(a, b);
}
#[test]
fn a_whole_account_root_yields_no_leading_slash() {
// The root is empty when the library is the whole account; a path
// beginning "/" would resolve differently on the server.
let p = trash_path("", img(3), "2019/IMG_0003.CR2");
assert_eq!(p, ".darkroom-trash/3-IMG_0003.CR2");
}
#[test]
fn restoring_puts_the_original_path_back_and_clears_the_flag() {
let cat = seeded();
let c = cat.connection();
do_trash(&cat, 1, 5_000);
let back = restore_path(c, img(1))
.unwrap()
.expect("knows where it came from");
assert_eq!(back, "PhotosRaw/2019/IMG_0001.CR2");
record_restored(c, &[(img(1), back.clone())]).unwrap();
let (source, at): (String, Option<i64>) = c
.query_row(
"SELECT source_ref, trashed_at FROM images WHERE id = 1",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(source, back);
assert_eq!(at, None, "a restored image is an ordinary one");
assert!(restore_path(c, img(1)).unwrap().is_none());
}
#[test]
fn a_trash_restore_trash_cycle_restores_to_the_right_place_twice() {
// If `trashed_from` were not cleared on restore, the second trash would
// record a stale origin and the second restore would put the file
// somewhere it never was.
let cat = seeded();
let c = cat.connection();
do_trash(&cat, 1, 1_000);
let first = restore_path(c, img(1)).unwrap().unwrap();
record_restored(c, &[(img(1), first.clone())]).unwrap();
do_trash(&cat, 1, 2_000);
let second = restore_path(c, img(1)).unwrap().unwrap();
assert_eq!(
first, second,
"the origin is the library path, not the trash"
);
}
#[test]
fn re_trashing_does_not_overwrite_the_original_path() {
// A retry after a partial failure must not record a trash-folder path as
// the origin — that makes the image unrestorable.
let cat = seeded();
let c = cat.connection();
let to = do_trash(&cat, 1, 1_000);
// Second attempt, as a retry would do.
record_trashed(c, &[(img(1), to)], 9_999).unwrap();
let (from, at): (String, i64) = c
.query_row(
"SELECT trashed_from, trashed_at FROM images WHERE id = 1",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(from, "PhotosRaw/2019/IMG_0001.CR2");
assert_eq!(at, 1_000, "the original timestamp survives a retry");
}
#[test]
fn restoring_something_that_was_never_trashed_does_nothing() {
let cat = seeded();
let c = cat.connection();
assert!(restore_path(c, img(2)).unwrap().is_none());
assert_eq!(
record_restored(c, &[(img(2), "elsewhere".into())]).unwrap(),
0
);
// And its path is untouched.
let source: String = c
.query_row("SELECT source_ref FROM images WHERE id = 2", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(source, "PhotosRaw/2019/IMG_0002.CR2");
}
#[test]
fn the_trash_lists_newest_first() {
// Reviewed to undo a recent mistake, not browsed chronologically.
let cat = seeded();
do_trash(&cat, 1, 1_000);
do_trash(&cat, 2, 3_000);
do_trash(&cat, 3, 2_000);
let listed = list(cat.connection(), 100).unwrap();
let order: Vec<u64> = listed.iter().map(|t| t.image_id.0).collect();
assert_eq!(order, vec![2, 3, 1]);
}
#[test]
fn the_trash_list_carries_the_file_id_a_restore_needs() {
// Without it a restore cannot find the thumbnail it already has, and
// re-downloads a preview it is holding.
let cat = seeded();
do_trash(&cat, 1, 1_000);
let listed = list(cat.connection(), 10).unwrap();
assert_eq!(listed[0].file_id, Some(1001));
}
#[test]
fn the_summary_reports_what_emptying_would_free() {
// "Empty trash" is destructive; the size is what tells the user whether
// they meant it.
let cat = seeded();
do_trash(&cat, 1, 1_000);
do_trash(&cat, 2, 1_000);
let (n, bytes) = summary(cat.connection()).unwrap();
assert_eq!(n, 2);
assert_eq!(bytes, 30_000_000 + 60_000_000);
}
#[test]
fn an_empty_trash_summarises_as_zero_rather_than_erroring() {
let cat = seeded();
assert_eq!(summary(cat.connection()).unwrap(), (0, 0));
assert!(all_trashed(cat.connection()).unwrap().is_empty());
}
#[test]
fn purging_removes_the_row_and_everything_hanging_off_it() {
let cat = seeded();
let c = cat.connection();
do_trash(&cat, 1, 1_000);
assert_eq!(forget(c, &[img(1)]).unwrap(), 1);
let n: i64 = c
.query_row("SELECT count(*) FROM images WHERE id = 1", [], |r| r.get(0))
.unwrap();
assert_eq!(n, 0);
// The remote mapping must go too, or a later scan could pair a new file
// with a dead image's id.
let n: i64 = c
.query_row("SELECT count(*) FROM remote WHERE image_id = 1", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(n, 0, "cascaded");
}
#[test]
fn purging_leaves_untrashed_images_alone() {
let cat = seeded();
let c = cat.connection();
do_trash(&cat, 1, 1_000);
forget(c, &all_trashed(c).unwrap()).unwrap();
let n: i64 = c
.query_row("SELECT count(*) FROM images", [], |r| r.get(0))
.unwrap();
assert_eq!(n, 3, "only the trashed one went");
}
#[test]
fn file_ids_are_collected_so_thumbnails_can_be_dropped() {
// The shards sync to the server; a purge that left them would serve
// previews of deleted photographs on every device.
let cat = seeded();
let c = cat.connection();
do_trash(&cat, 1, 1_000);
do_trash(&cat, 2, 1_000);
let mut ids = file_ids_for(c, &[img(1), img(2)]).unwrap();
ids.sort_unstable();
assert_eq!(ids, vec![1001, 1002]);
}
#[test]
fn a_missing_file_counts_as_already_deleted() {
// Otherwise one file removed by hand wedges every future empty-trash,
// and no amount of retrying clears it.
assert!(is_already_gone(Some(404)));
assert!(is_already_gone(Some(410)));
assert!(!is_already_gone(Some(403)), "a permission failure is real");
assert!(!is_already_gone(Some(500)));
assert!(!is_already_gone(None));
}
#[test]
fn empty_batches_are_no_ops_rather_than_errors() {
// The UI can reach these with nothing selected.
let cat = seeded();
let c = cat.connection();
assert_eq!(record_trashed(c, &[], 0).unwrap(), 0);
assert_eq!(record_restored(c, &[]).unwrap(), 0);
assert_eq!(forget(c, &[]).unwrap(), 0);
assert!(file_ids_for(c, &[]).unwrap().is_empty());
}
}
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "dr-decode"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
dr-types.workspace = true
rawler.workspace = true
# The camera profile database is data, not code (FR-DEV-3e): a YAML file that
# ships with the binary and is superseded by a newer one on disk. serde_norway
# is the workspace's YAML crate — the fork still receiving releases — and it is
# already in the tree for `dr-pipeline`'s node declarations and `dr-ui`'s style
# tokens. Pure Rust, so it costs nothing under the Android NDK.
serde = { workspace = true }
serde_norway.workspace = true
zune-jpeg.workspace = true
thiserror.workspace = true
log.workspace = true
[dev-dependencies]
env_logger.workspace = true
+52
View File
@@ -0,0 +1,52 @@
//! Report the defect map a raw file carries, if it carries one.
//!
//! ```text
//! cargo run -p dr-decode --example defects -- IMG_6320.dng photo.cr2
//! ```
//!
//! Exists because whether this is worth building a correction stage for is a
//! question about *your files*, not about the specification: DNGs written by
//! cameras that map their own sensors carry `OpcodeList1`, conversions from a
//! proprietary raw usually do not, and no CR2 or scanner TIFF ever does.
//! Rather than guess, point this at the library and see.
fn main() {
let files: Vec<String> = std::env::args().skip(1).collect();
if files.is_empty() {
eprintln!("usage: defects <raw file>...");
std::process::exit(2);
}
for path in &files {
let bytes = match std::fs::read(path) {
Ok(b) => b,
Err(e) => {
println!("{path}: unreadable — {e}");
continue;
}
};
let found = dr_decode::defects(&bytes);
if found.is_empty() {
println!("{path}: no defect map");
continue;
}
println!(
"{path}: {} bad pixel(s), {} bad line(s)",
found.pixels.len(),
found.lines.len()
);
// A handful, so the output stays readable on a sensor reporting
// hundreds — the count above is the number that matters.
for p in found.pixels.iter().take(8) {
println!(" pixel at {},{}", p.x, p.y);
}
for l in found.lines.iter().take(8) {
match l {
dr_decode::BadLine::Column(x) => println!(" dead column {x}"),
dr_decode::BadLine::Row(y) => println!(" dead row {y}"),
}
}
}
}
+19
View File
@@ -0,0 +1,19 @@
fn main() {
for p in std::env::args().skip(1) {
let Ok(d) = std::fs::read(&p) else { continue };
let n = p.rsplit('/').next().unwrap();
// Exactly what the sweep sees: the first HEADER_BYTES only.
let head = &d[..d.len().min(dr_decode::HEADER_BYTES as usize)];
match dr_decode::metadata(head) {
Ok(m) => println!(
"{n}: header-only at={:?} model={:?}",
m.captured_at, m.model
),
Err(e) => println!("{n}: header-only ERROR {e}"),
}
match dr_decode::metadata(&d) {
Ok(m) => println!("{n}: whole-file at={:?}", m.captured_at),
Err(e) => println!("{n}: whole-file ERROR {e}"),
}
}
}
+61
View File
@@ -0,0 +1,61 @@
//! Print what `decode` extracts from a RAW file.
//!
//! A sanity check on the pipeline's inputs: black and white levels, the CFA
//! pattern after re-phasing, as-shot white balance, and the camera→sRGB
//! matrix. Wrong values here produce a wrong image no shader can fix, so it
//! is worth being able to see them directly.
//!
//! ```sh
//! cargo run -p dr-decode --example rawinfo -- IMG.CR2
//! ```
fn main() {
let Some(path) = std::env::args().nth(1) else {
eprintln!("usage: rawinfo <file.cr2>");
std::process::exit(2);
};
let bytes = std::fs::read(&path).expect("read file");
let raw = dr_decode::decode(&bytes).expect("decode");
println!("file {path}");
println!("readout {} × {}", raw.width, raw.height);
println!(
"crop {} × {} at ({}, {})",
raw.crop.width, raw.crop.height, raw.crop.x, raw.crop.y
);
let (dx, dy) = raw.crop.shifts_cfa_phase();
println!(
"cfa {:?} (rephased: {dx}, {dy})",
raw.cfa_pattern
);
println!("black {:?}", raw.black_level);
println!("white {}", raw.white_level);
println!("wb_coeffs {:?}", raw.wb_coeffs);
match raw.color_matrix {
Some(m) => {
println!("cam→srgb");
for row in m.chunks(3) {
println!(
" [{:>8.4} {:>8.4} {:>8.4}]",
row[0], row[1], row[2]
);
}
// Each row should sum to roughly 1: a neutral camera-space colour
// must stay neutral in sRGB. Far from 1 means the normalisation
// or the matrix composition is wrong.
let sums: Vec<f32> = m.chunks(3).map(|r| r.iter().sum()).collect();
println!("row sums {sums:.4?} (≈1.0 each if correct)");
}
None => println!("cam→srgb none — uncalibrated body"),
}
// Sample the actual data range, which reveals a black-level or bit-depth
// mistake faster than any amount of staring at metadata.
let (min, max) = raw
.data
.iter()
.fold((u16::MAX, 0u16), |(lo, hi), &v| (lo.min(v), hi.max(v)));
println!("sample range {min} … {max}");
}
+146
View File
@@ -0,0 +1,146 @@
//! Smoke test against real RAW files.
//!
//! cargo run -p dr-decode --example smoke -- <file-or-dir>...
//!
//! Reports, per file, what each entry point costs — which is the whole reason
//! they are separate (ARCH §3.2).
use std::path::{Path, PathBuf};
use std::time::Instant;
fn main() {
env_logger::init();
let args: Vec<String> = std::env::args().skip(1).collect();
if args.is_empty() {
eprintln!("usage: smoke <file-or-dir>...");
std::process::exit(2);
}
let mut files = Vec::new();
for a in &args {
let p = PathBuf::from(a);
if p.is_dir() {
collect(&p, &mut files);
} else {
files.push(p);
}
}
files.sort();
files.truncate(8);
println!(
"{:<20} {:>7} {:>8} {:>9} {:>13} {:>9} {:>13}",
"file", "size", "meta", "thumb", "thumb dims", "full", "full dims"
);
println!("{}", "-".repeat(88));
let (mut ok, mut failed) = (0, 0);
for f in &files {
match run_one(f) {
Ok(line) => {
println!("{line}");
ok += 1;
}
Err(e) => {
println!("{:<22} {e}", truncate(&name(f), 22));
failed += 1;
}
}
}
println!("\n{ok} ok, {failed} failed");
if failed > 0 {
std::process::exit(1);
}
}
fn run_one(path: &Path) -> Result<String, String> {
let size = std::fs::metadata(path).map_err(|e| e.to_string())?.len();
// The culling path: read only the header region, not the whole file.
let probe_bytes =
read_prefix(path, dr_decode::PREVIEW_PROBE_BYTES).map_err(|e| e.to_string())?;
let t0 = Instant::now();
let fmt = dr_decode::probe(&probe_bytes);
let meta = dr_decode::metadata(&probe_bytes).ok();
let meta_ms = t0.elapsed().as_secs_f64() * 1000.0;
let all = std::fs::read(path).map_err(|e| e.to_string())?;
// The culling rung.
let t1 = Instant::now();
let thumb = dr_decode::extract_preview(&all, dr_decode::PreviewSize::Thumbnail)
.map_err(|e| format!("thumb: {e}"))?;
let thumb_ms = t1.elapsed().as_secs_f64() * 1000.0;
// The full-resolution rung, for comparison.
let t2 = Instant::now();
let full = dr_decode::extract_preview(&all, dr_decode::PreviewSize::Full)
.map_err(|e| format!("full: {e}"))?;
let full_ms = t2.elapsed().as_secs_f64() * 1000.0;
let model = meta
.as_ref()
.and_then(|m| m.model.clone())
.unwrap_or_else(|| "?".into());
let budget = if thumb_ms <= 50.0 {
""
} else {
" OVER BUDGET"
};
Ok(format!(
"{:<20} {:>6.1}M {:>6.1}ms {:>7.1}ms {:>7}x{:<5} {:>7.1}ms {:>7}x{:<5} {:?} {}{}",
truncate(&name(path), 20),
size as f64 / 1e6,
meta_ms,
thumb_ms,
thumb.width,
thumb.height,
full_ms,
full.width,
full.height,
fmt,
model.trim(),
budget,
))
}
fn read_prefix(path: &Path, n: u64) -> std::io::Result<Vec<u8>> {
use std::io::Read;
let mut f = std::fs::File::open(path)?;
let mut buf = vec![0u8; n as usize];
let read = f.read(&mut buf)?;
buf.truncate(read);
Ok(buf)
}
fn collect(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for e in entries.flatten() {
let p = e.path();
if p.is_file() {
let ext = p
.extension()
.map(|s| s.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
if dr_types::Format::from_extension(&ext).is_some() {
out.push(p);
}
}
}
}
fn name(p: &Path) -> String {
p.file_name().unwrap_or_default().to_string_lossy().into()
}
fn truncate(s: &str, n: usize) -> String {
if s.len() <= n {
s.to_string()
} else {
format!("{}…", &s[..n - 1])
}
}
+160
View File
@@ -0,0 +1,160 @@
# DarkRoom camera base curves (FR-DEV-3e).
#
# ---------------------------------------------------------------------------
# Adding a body is editing this file. It is not a code change.
# ---------------------------------------------------------------------------
#
# The copy you are reading is compiled into the binary as a floor. At startup
# `dr_decode::base_curve::load` also looks for `base_curves.yaml` in:
#
# 1. $DARKROOM_PROFILES/ (set it while you are tuning)
# 2. $XDG_DATA_HOME/darkroom/profiles/
# or $HOME/.local/share/darkroom/profiles/
#
# and uses the first one it finds *whose `version:` is higher than this one's*.
# So: bump `version`, drop the file in that directory, restart. A body added
# this afternoon renders correctly this afternoon, with no release and no
# rebuild — which is what the requirement asks for, and what makes these
# contributable under the GPL.
#
# The version check runs both ways on purpose. A file older than the built-in
# copy is ignored with a log line, so upgrading DarkRoom cannot silently lose
# curves to a pack somebody downloaded a year ago.
#
# ---------------------------------------------------------------------------
# What the numbers mean
# ---------------------------------------------------------------------------
#
# Five `[x, y]` control points on a monotone spline (Fritsch-Carlson, the same
# one the tone curve widget draws). Both axes are **linear**:
#
# x scene-referred camera RGB after white balance, 1.0 = sensor saturation
# y display-referred linear; the sRGB transfer function is applied later,
# at the end of the shader, so do not pre-apply a gamma here
#
# The identity is y = x, and it is what an unrecognised body gets if `default:`
# is removed. It is also the wrong answer for almost every photograph: linear
# scene data has middle grey at about 13% and a camera JPEG puts it near 18%,
# so an uncurved render is roughly half a stop dark through the midtones and
# has no highlight rolloff at all.
#
# A curve that works has three parts, and it is worth naming them because they
# are what you are actually tuning:
#
# the toe the first span, slope near or below 1. Deep shadows stay
# deep. Lift it and blacks go milky; crush it and shadow
# detail the sensor recorded disappears.
# the midtones the middle spans, slope well above 1. This is the contrast
# and the brightness people read as "the camera's look".
# the shoulder the last span, slope well below 1. Highlights compress
# toward white instead of arriving there and clipping. It is
# the difference between a rolled-off sky and a white hole.
#
# Two invariants are enforced in code and tested, so a mistake here fails the
# build rather than the photograph: x must strictly increase, y must not
# decrease, and everything must lie inside the unit square.
#
# ---------------------------------------------------------------------------
# Honesty about these values
# ---------------------------------------------------------------------------
#
# These are hand-tuned shapes, not measurements. They encode what every camera
# JPEG rendering has in common — the toe/midtone/shoulder structure above —
# plus each maker's well-known house differences: Canon's gentler shoulder and
# warmer-reading midtones, Nikon's slightly higher midtone contrast, Sony's
# flatter and more conservative default, Fujifilm's markedly contrastier
# Provia-derived rendering.
#
# FR-DEV-3e's acceptance criterion is subjective comparison against each body's
# own JPEG, and meeting it properly needs a frame from that body in front of
# you. Where that has not been done, the entry is still much closer to right
# than the identity — which is the bar these have to clear, and do.
version: 1
# The rendering for a body with no entry of its own.
#
# **Deliberately not the identity.** The failure this requirement exists to fix
# is the flat render, and a conservative curve is far closer to right for every
# body than no curve is for any of them. It is gentler than the per-body
# entries below — a shallower midtone and an earlier, softer shoulder — because
# it has to be safe on a sensor nobody has looked at, and the cost of being too
# tame is a photograph that wants a little contrast rather than one that has
# lost its highlights.
default:
points:
- [0.00, 0.000]
- [0.04, 0.043]
- [0.13, 0.175]
- [0.45, 0.690]
- [1.00, 1.000]
bodies:
# Canon. A soft toe and a long, gradual shoulder — the reason Canon files
# are described as forgiving in highlights and a little low in contrast
# straight out of camera.
- make: Canon
model: EOS 6D
points:
- [0.00, 0.000]
- [0.04, 0.045]
- [0.13, 0.190]
- [0.45, 0.720]
- [1.00, 1.000]
- make: Canon
model: EOS R6
points:
- [0.00, 0.000]
- [0.04, 0.044]
- [0.13, 0.195]
- [0.45, 0.730]
- [1.00, 1.000]
# Nikon. A slightly deeper toe and more midtone slope than Canon, which is
# the "punchier out of camera" difference people describe between the two.
- make: Nikon
model: Z 6
points:
- [0.00, 0.000]
- [0.04, 0.038]
- [0.13, 0.200]
- [0.46, 0.750]
- [1.00, 1.000]
- make: Nikon
model: D750
points:
- [0.00, 0.000]
- [0.04, 0.039]
- [0.13, 0.198]
- [0.46, 0.745]
- [1.00, 1.000]
# Sony. The flattest default of the four, and intentionally so — Sony's own
# rendering leaves more headroom than it uses, which is why Sony files are
# the ones people describe as needing the most work.
- make: Sony
model: ILCE-7M3
points:
- [0.00, 0.000]
- [0.04, 0.048]
- [0.13, 0.185]
- [0.44, 0.700]
- [1.00, 1.000]
# Fujifilm. Provia, the default film simulation: a firm toe, the steepest
# midtones here, and a hard shoulder. It is the most distinctive rendering of
# the four and the one where a flat render looks most obviously wrong.
#
# This entry does *not* read the in-RAF film simulation tag — that is
# FR-DEV-3f, and until it lands every Fujifilm file gets the Provia shape
# whatever the camera was set to.
- make: Fujifilm
model: X-T3
points:
- [0.00, 0.000]
- [0.045, 0.040]
- [0.14, 0.215]
- [0.47, 0.775]
- [1.00, 1.000]
+763
View File
@@ -0,0 +1,763 @@
//! TRACES: FR-DEV-3e
//! Base curves — the per-body rendering that turns a correct exposure into a
//! photograph.
//!
//! # What this is for
//!
//! A camera matrix gets the *colours* right and leaves the picture flat. Sensor
//! data is scene-referred and very nearly linear; a print, a screen and a
//! camera's own JPEG are none of those things. Rendering linear data straight
//! out is the dcraw default, and FR-DEV-3e names it precisely: "the flat,
//! poor-skin-tone rendering characteristic of dcraw defaults, which is the
//! documented reason people abandon darktable in the first hour."
//!
//! The fix is a tone curve applied as part of *reading* the file rather than as
//! an edit — a toe, a steep midtone, and a shoulder that rolls highlights off
//! instead of clipping them. Every raw converter has one. Adobe calls it the
//! camera profile's tone curve, darktable calls it the base curve, and the name
//! here follows darktable's because the placement does too: it runs in camera
//! RGB, after white balance and the user's adjustments, immediately before the
//! conversion out to a working space.
//!
//! # Why it is not an edit
//!
//! It never reaches the sidecar and there is no slider for it, for the same
//! reason the EXIF orientation is not an edit (FR-DEV-3h): it is a property of
//! the body that took the frame, not of what anyone decided about the frame.
//! Sidecars are shared between devices and bodies (FR-NC-9), and one camera's
//! rendering must not follow an edit onto another camera's file.
//!
//! # Why it is data
//!
//! FR-DEV-3e requires the profile database to be "versioned independently of
//! the app binary so bodies and curves can be added without a release — and,
//! under D8's GPLv3, contributed by users". So the curves live in
//! `profiles/base_curves.yaml`, a file that is compiled in as a floor and
//! *overridden* by a copy on disk carrying a higher `version:`. Adding a body
//! is adding ten numbers to a YAML file; shipping that body to users is
//! publishing the file. Neither is a code change and neither needs a release.
//!
//! See [`load`] for the search path and [`Curves::body`] for the matching.
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
/// How many control points a base curve has.
///
/// Five, which is not a coincidence: it is what the tone curve widget uses
/// (`dr_pipeline::ops::curve::POINTS`), so the shader evaluates a profile's
/// curve and a photographer's curve through exactly the same spline. A profile
/// author and a photographer dragging a point mean the same thing by it, and
/// the generated shader carries one implementation rather than two that could
/// disagree.
pub const POINTS: usize = 5;
/// TRACES: FR-DEV-3e
/// A base curve: five points on a monotone spline through the unit square.
///
/// `xs` is scene-linear camera RGB, normalised so that 1.0 is the sensor's
/// saturation point. `ys` is display-referred linear — *not* gamma-encoded,
/// because the sRGB transfer function is applied at the very end of the
/// generated shader and applying it twice would wash the image out.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BaseCurve {
pub xs: [f32; POINTS],
pub ys: [f32; POINTS],
}
impl BaseCurve {
/// The curve that does nothing — the identity diagonal.
///
/// What an unrecognised body gets if the database carries no default, and
/// what a JPEG gets always: an already-rendered image must not be rendered
/// a second time.
pub const IDENTITY: Self = Self {
xs: [0.0, 0.25, 0.5, 0.75, 1.0],
ys: [0.0, 0.25, 0.5, 0.75, 1.0],
};
/// Whether this curve would leave the image alone.
///
/// The shader is told to skip the stage entirely when it would, so an
/// unprofiled body costs a branch that is uniform across the dispatch
/// rather than a spline evaluation per channel per pixel.
pub fn is_identity(&self) -> bool {
self.xs
.iter()
.zip(self.ys.iter())
.all(|(x, y)| (x - y).abs() < 1e-6)
}
/// Build from raw pairs, rejecting anything that is not a curve.
///
/// A profile file is data a user may have edited, so this is the boundary
/// where "ten numbers" becomes "a curve": the x coordinates must increase,
/// the y coordinates must not decrease, and both must lie in the unit
/// square. A non-monotone x sends the spline's span search backwards and
/// divides by a negative width; a decreasing y inverts tones locally,
/// which reads as a dark halo through smooth gradients rather than as a
/// bad profile.
///
/// Endpoints are not forced to (0,0) and (1,1). A curve that lifts black
/// slightly, or that places the shoulder below white, is a legitimate
/// rendering choice and several bodies make it.
pub fn from_points(points: &[[f32; 2]]) -> Option<Self> {
if points.len() != POINTS {
return None;
}
let mut xs = [0.0f32; POINTS];
let mut ys = [0.0f32; POINTS];
for (i, p) in points.iter().enumerate() {
if !p[0].is_finite() || !p[1].is_finite() {
return None;
}
if !(0.0..=1.0).contains(&p[0]) || !(0.0..=1.0).contains(&p[1]) {
return None;
}
xs[i] = p[0];
ys[i] = p[1];
}
for i in 1..POINTS {
// Strictly increasing in x — the spline divides by the span width.
if xs[i] <= xs[i - 1] {
return None;
}
// Non-decreasing in y. Flat is allowed: a curve that holds a
// highlight range at white is clipping deliberately.
if ys[i] < ys[i - 1] {
return None;
}
}
Some(Self { xs, ys })
}
}
/// One body's entry in the database.
#[derive(Debug, Clone, PartialEq)]
pub struct BodyCurve {
/// The manufacturer, as the file writes it — "Canon", "NIKON CORPORATION".
pub make: String,
/// The model, as the file writes it — "EOS 6D", "ILCE-7M3".
pub model: String,
pub curve: BaseCurve,
}
/// TRACES: FR-DEV-3e
/// The base curve database.
///
/// Versioned as a whole rather than per body, because that is the unit a user
/// downloads and the unit that has to beat the built-in copy. See [`load`].
#[derive(Debug, Clone, PartialEq)]
pub struct Curves {
version: u32,
default: Option<BaseCurve>,
bodies: Vec<BodyCurve>,
}
impl Curves {
/// TRACES: FR-DEV-3e
/// The curve to render a frame from this body with.
///
/// Falls back, in order, to the database's `default:` and then to the
/// identity. **The default is deliberately not the identity**: an
/// unrecognised body rendered flat is the failure this requirement exists
/// to prevent, and a gentle, conservative curve is much closer to right for
/// every body than no curve is for any of them. A body with its own entry
/// gets that instead.
///
/// # What "this body" has to survive
///
/// The same camera names itself three ways depending on which program last
/// touched the file. A native NEF says make "NIKON CORPORATION", model
/// "NIKON Z 6"; rawler's own database cleans that to "Nikon" and "Z 6"; an
/// Adobe-converted DNG keeps the uncleaned pair. A database that had to
/// spell every variant would go stale the first time a maker changed its
/// mind about its own name, so the matching does the folding instead:
///
/// - Case, punctuation and runs of whitespace are flattened, so
/// "ILCE-7M3", "ILCE 7M3" and "ilce-7m3" are one body.
/// - The make is compared on its **first word only**. Every maker's
/// trailing corporate boilerplate — "CORPORATION", "IMAGING CORP" — is
/// noise, and no two camera manufacturers share a first word.
/// - The model is tried both as written and with a leading copy of the
/// make removed, which is what lets one "Canon"/"EOS 6D" entry cover
/// "Canon EOS 6D" as well.
pub fn body(&self, make: &str, model: &str) -> BaseCurve {
let (make, model) = (make_key(make), normalise(model));
// The model with a leading copy of the maker's name removed.
let bare = model.strip_prefix(&format!("{make} ")).unwrap_or(&model);
self.bodies
.iter()
.find(|b| {
let entry_model = normalise(&b.model);
make_key(&b.make) == make && (entry_model == model || entry_model == bare)
})
.map(|b| b.curve)
.or(self.default)
.unwrap_or(BaseCurve::IDENTITY)
}
/// The database version. Higher wins; see [`load`].
pub fn version(&self) -> u32 {
self.version
}
/// How many bodies have their own curve, excluding the default.
pub fn len(&self) -> usize {
self.bodies.len()
}
pub fn is_empty(&self) -> bool {
self.bodies.is_empty()
}
/// Parse a database from YAML.
///
/// Entries that are not curves are dropped with a warning rather than
/// failing the parse. A user-contributed file with one bad body should
/// cost that body's rendering, not every body's — and the alternative is an
/// application that will not open a photograph because somebody typed a
/// comma.
pub fn parse(yaml: &str) -> Result<Self, String> {
let file: File = serde_norway::from_str(yaml).map_err(|e| e.to_string())?;
let default = file.default.and_then(|d| {
BaseCurve::from_points(&d.points).or_else(|| {
log::warn!("base curves: the default entry is not a monotone curve; ignoring it");
None
})
});
let bodies = file
.bodies
.into_iter()
.filter_map(|b| match BaseCurve::from_points(&b.points) {
Some(curve) => Some(BodyCurve {
make: b.make,
model: b.model,
curve,
}),
None => {
log::warn!(
"base curves: {} {} is not a monotone curve; ignoring it",
b.make,
b.model
);
None
}
})
.collect();
Ok(Self {
version: file.version,
default,
bodies,
})
}
}
/// The copy that ships inside the binary.
///
/// A floor, not the answer: [`load`] prefers a newer file on disk. Compiled in
/// so that a fresh install with no profile directory — and every Android build,
/// where there is no such directory to speak of — still renders properly.
const BUILT_IN: &str = include_str!("../profiles/base_curves.yaml");
/// TRACES: FR-DEV-3e
/// The base curve database, loaded once.
///
/// # The search path, and why it is a version comparison
///
/// 1. `$DARKROOM_PROFILES`, a directory, when set. The escape hatch: a profile
/// author iterating on a curve points this at their working copy and does
/// not have to install anything.
/// 2. `$XDG_DATA_HOME/darkroom/profiles/`, else `$HOME/.local/share/darkroom/profiles/`.
/// The same base directory the catalog uses, chosen there for the same
/// reason — it is data, not cache, and must survive a storage sweep.
/// 3. The copy compiled into the binary.
///
/// The first file that parses *and carries a higher `version:` than the
/// built-in copy* wins. The version check is the whole mechanism the
/// requirement asks for, and it runs in both directions:
///
/// - A downloaded pack at version 7 supersedes a binary shipping version 3, so
/// a body added after the release renders correctly with no release.
/// - A stale pack at version 2 does **not** supersede a binary shipping version
/// 3, so upgrading the application cannot silently lose curves to a file
/// somebody downloaded a year ago and forgot.
///
/// Failures are warnings, never errors. A malformed profile file must cost the
/// user their curves, not their photographs.
pub fn load() -> &'static Curves {
static LOADED: OnceLock<Curves> = OnceLock::new();
LOADED.get_or_init(|| {
let built_in = Curves::parse(BUILT_IN).unwrap_or_else(|e| {
// Unreachable in a build that ran its tests — `the_shipped_database_parses`
// asserts exactly this — but a panic here would mean an
// application that cannot open a photograph because of a typo in a
// data file, which is never the right trade.
log::error!("base curves: the built-in database does not parse: {e}");
Curves {
version: 0,
default: None,
bodies: Vec::new(),
}
});
choose(built_in, &search_path())
})
}
/// The version comparison, separated from where the directories come from.
///
/// Split out so it can be tested against real files in a real directory
/// without the process-wide `OnceLock` and the environment `load` reads. The
/// rule this implements is the whole of what FR-DEV-3e asks for, so it is
/// worth being able to state it as a test rather than as a comment.
fn choose(built_in: Curves, dirs: &[PathBuf]) -> Curves {
for dir in dirs {
let path = dir.join("base_curves.yaml");
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
match Curves::parse(&text) {
Ok(external) if external.version > built_in.version => {
log::info!(
"base curves: using {} (version {}, {} bodies) over the built-in version {}",
path.display(),
external.version,
external.len(),
built_in.version
);
return external;
}
Ok(external) => log::info!(
"base curves: ignoring {} at version {}; the built-in database is version {}",
path.display(),
external.version,
built_in.version
),
Err(e) => log::warn!("base curves: {} does not parse: {e}", path.display()),
}
}
built_in
}
/// TRACES: FR-DEV-3e
/// The curve for a body, from the loaded database.
///
/// The one call site the decoder needs; everything above is reachable for
/// tests and for a future profile editor.
pub fn for_body(make: &str, model: &str) -> BaseCurve {
load().body(make, model)
}
/// Directories that may hold a `base_curves.yaml`, most specific first.
fn search_path() -> Vec<PathBuf> {
let mut dirs = Vec::new();
if let Some(explicit) = std::env::var_os("DARKROOM_PROFILES") {
dirs.push(PathBuf::from(explicit));
}
// The same resolution `dr_ui::library::catalog_path` uses, and for the
// same reason: this is data a user may have installed, not a cache. It is
// duplicated rather than shared because `dr-decode` sits far below the UI
// and must not acquire a dependency on it to find a directory.
let base = std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| Path::new(&h).join(".local/share")));
if let Some(base) = base {
dirs.push(base.join("darkroom").join("profiles"));
}
dirs
}
/// A manufacturer's first word, folded.
///
/// "NIKON CORPORATION", "Nikon" and "nikon" all become `NIKON`. The corporate
/// suffixes are not information — they appear or not depending on whether the
/// file went through a DNG converter — and no two camera manufacturers share a
/// first word, so nothing is lost by dropping them.
fn make_key(s: &str) -> String {
normalise(s)
.split(' ')
.next()
.unwrap_or_default()
.to_string()
}
/// Fold a make or model into something two files can agree on.
///
/// Upper-cased, with every run of non-alphanumeric characters collapsed to one
/// space and the ends trimmed, so that "ILCE-7M3", "ILCE 7M3" and "ilce-7m3"
/// become one.
fn normalise(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut pending_space = false;
for c in s.chars() {
if c.is_ascii_alphanumeric() {
if pending_space && !out.is_empty() {
out.push(' ');
}
pending_space = false;
out.push(c.to_ascii_uppercase());
} else {
pending_space = true;
}
}
out
}
// ---- The on-disk shape, kept apart from the in-memory one ----------------
//
// Deliberately separate types. The file is data a user edits and is allowed to
// be wrong; `Curves` is a parsed database whose every entry is known to be a
// monotone curve. Deriving `Deserialize` on `BaseCurve` directly would delete
// that boundary and let an unchecked five-point array reach the shader.
//
// Unknown fields are **accepted**, which is not laziness. The database is
// versioned independently of the binary and moves in both directions: a pack
// published after this release may carry keys this build has never heard of —
// a hue twist, a look table (FR-DEV-3f) — and it must still deliver its curves
// to an older DarkRoom rather than failing to parse and leaving every body
// flat. `deny_unknown_fields` would trade that for a diagnostic nobody needs.
#[derive(serde::Deserialize)]
struct File {
version: u32,
#[serde(default)]
default: Option<Entry>,
#[serde(default)]
bodies: Vec<BodyEntry>,
}
#[derive(serde::Deserialize)]
struct Entry {
points: Vec<[f32; 2]>,
}
#[derive(serde::Deserialize)]
struct BodyEntry {
make: String,
model: String,
points: Vec<[f32; 2]>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_shipped_database_parses_and_carries_a_default() {
// The one test that must never be allowed to fail quietly: `load`
// degrades to an empty database rather than panicking, so without this
// a typo in the YAML would ship as "every photograph renders flat"
// rather than as a build failure.
let curves = Curves::parse(BUILT_IN).expect("the shipped database parses");
assert!(curves.version() >= 1);
assert!(!curves.is_empty(), "the database ships bodies");
assert!(
!curves.body("Nobody", "Nothing").is_identity(),
"an unknown body must still get the default rendering"
);
}
#[test]
fn every_shipped_curve_lifts_the_midtones_and_rolls_the_highlights() {
// What makes a base curve a base curve rather than a decoration. If a
// shipped curve failed either half it would be a worse rendering than
// the flat one it replaced, which is the one outcome forbidden.
let curves = Curves::parse(BUILT_IN).expect("parses");
let all = curves
.bodies
.iter()
.map(|b| (format!("{} {}", b.make, b.model), b.curve))
.chain(curves.default.map(|c| ("default".to_string(), c)));
for (name, curve) in all {
// The midtone point sits above the diagonal: a linear midtone is
// roughly a stop and a half darker than any camera renders it.
let mid = 2;
assert!(
curve.ys[mid] > curve.xs[mid],
"{name} does not lift its midtones ({} -> {})",
curve.xs[mid],
curve.ys[mid]
);
// And the last span is shallower than the one before it, which is
// what a shoulder *is*. Without one the curve clips highlights
// harder than the linear rendering did.
let slope = |i: usize| (curve.ys[i + 1] - curve.ys[i]) / (curve.xs[i + 1] - curve.xs[i]);
assert!(
slope(POINTS - 2) < slope(POINTS - 3),
"{name} has no highlight shoulder"
);
}
}
#[test]
fn a_curve_that_is_not_monotone_is_refused() {
// The profile file is user-editable, so this is a real boundary and
// not a formality. A decreasing y inverts tones locally and shows up
// as a dark halo in a gradient, which reads as a rendering fault
// rather than as a bad profile.
assert_eq!(
BaseCurve::from_points(&[
[0.0, 0.0],
[0.25, 0.4],
[0.5, 0.3],
[0.75, 0.8],
[1.0, 1.0]
]),
None
);
}
#[test]
fn a_curve_whose_x_does_not_advance_is_refused() {
// The spline divides by the span width; a repeated x is a division by
// zero in the shader, which is a NaN pixel rather than an error.
assert_eq!(
BaseCurve::from_points(&[
[0.0, 0.0],
[0.25, 0.3],
[0.25, 0.5],
[0.75, 0.8],
[1.0, 1.0]
]),
None
);
}
#[test]
fn a_curve_of_the_wrong_length_is_refused() {
assert_eq!(BaseCurve::from_points(&[[0.0, 0.0], [1.0, 1.0]]), None);
}
#[test]
fn values_outside_the_unit_square_are_refused() {
// The shader clamps its output at the very end anyway, but a control
// point above 1.0 would put the shoulder outside the range the curve
// is defined over and silently flatten everything below it.
assert_eq!(
BaseCurve::from_points(&[
[0.0, 0.0],
[0.25, 0.3],
[0.5, 1.4],
[0.75, 1.5],
[1.0, 1.6]
]),
None
);
}
#[test]
fn a_body_with_its_own_entry_beats_the_default() {
let curves = Curves::parse(
"version: 2
default:
points: [[0.0, 0.0], [0.25, 0.3], [0.5, 0.6], [0.75, 0.85], [1.0, 1.0]]
bodies:
- make: Canon
model: EOS 6D
points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
",
)
.expect("parses");
assert_eq!(curves.body("Canon", "EOS 6D").ys[1], 0.35);
assert_eq!(curves.body("Canon", "EOS 5D").ys[1], 0.30);
}
#[test]
fn the_make_may_be_repeated_in_the_model() {
// Canon writes "Canon" as the make and "Canon EOS 6D" as the model;
// rawler's cleaned strings drop the repetition and both reach here.
// One entry has to cover both or half the files on a card miss.
let curves = Curves::parse(
"version: 1
bodies:
- make: Canon
model: EOS 6D
points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
",
)
.expect("parses");
assert_eq!(curves.body("Canon", "Canon EOS 6D").ys[1], 0.35);
assert_eq!(curves.body("Canon", "EOS 6D").ys[1], 0.35);
assert_eq!(curves.body("CANON", "eos 6d").ys[1], 0.35);
}
#[test]
fn a_corporate_suffix_does_not_hide_a_body() {
// The same Z 6 arrives as "Nikon"/"Z 6" from rawler's camera database
// and as "NIKON CORPORATION"/"NIKON Z 6" from a DNG converted out of
// the same file. Both must find the entry, or converting a file to
// DNG would silently change how it renders.
let curves = Curves::parse(
"version: 1
bodies:
- make: Nikon
model: Z 6
points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
",
)
.expect("parses");
assert_eq!(curves.body("Nikon", "Z 6").ys[1], 0.35);
assert_eq!(curves.body("NIKON CORPORATION", "NIKON Z 6").ys[1], 0.35);
}
#[test]
fn punctuation_and_spacing_do_not_decide_whether_a_body_is_known() {
let curves = Curves::parse(
"version: 1
bodies:
- make: Sony
model: ILCE-7M3
points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
",
)
.expect("parses");
assert_eq!(curves.body("SONY", "ILCE 7M3").ys[1], 0.35);
assert_eq!(curves.body("sony", "ilce-7m3").ys[1], 0.35);
}
#[test]
fn one_bad_entry_does_not_cost_the_rest() {
// A user-contributed file with one typo should cost that body's
// rendering, not every body's.
let curves = Curves::parse(
"version: 1
bodies:
- make: Broken
model: Body
points: [[0.0, 0.0], [0.25, 0.9], [0.5, 0.1], [0.75, 0.9], [1.0, 1.0]]
- make: Canon
model: EOS 6D
points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
",
)
.expect("parses");
assert_eq!(curves.len(), 1);
assert_eq!(curves.body("Canon", "EOS 6D").ys[1], 0.35);
assert!(curves.body("Broken", "Body").is_identity());
}
#[test]
fn a_pack_from_the_future_still_delivers_its_curves() {
// The database is versioned independently of the binary, so a pack
// published after this build may carry keys this build has never heard
// of. It must still hand over the curves it does understand — failing
// the parse would leave every body flat, which is the exact failure
// FR-DEV-3e exists to prevent, delivered by the mechanism meant to
// prevent it.
let curves = Curves::parse(
"version: 9
look_table: ambitious
bodies:
- make: Canon
model: EOS 6D
hue_twist: [1, 2, 3]
points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
",
)
.expect("an unfamiliar key must not fail the parse");
assert_eq!(curves.version(), 9);
assert_eq!(curves.body("Canon", "EOS 6D").ys[1], 0.35);
}
#[test]
fn an_unknown_body_with_no_default_gets_the_identity() {
// Graceful fallback, stated as a property: never worse than a flat
// render, and never a curve tuned for somebody else's sensor when the
// database declines to offer one.
let curves = Curves::parse("version: 1\nbodies: []\n").expect("parses");
assert!(curves.body("Nobody", "Nothing").is_identity());
}
/// A directory holding one `base_curves.yaml`, unique to the caller.
fn a_pack_dir(name: &str, yaml: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("darkroom-base-curves-{name}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("a writable temp directory");
std::fs::write(dir.join("base_curves.yaml"), yaml).expect("write");
dir
}
const A_CANON_ENTRY: &str = "bodies:
- make: Canon
model: EOS 6D
points: [[0.0, 0.0], [0.25, 0.42], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
";
#[test]
fn a_newer_pack_on_disk_supersedes_the_built_in_database() {
// **This is the requirement.** FR-DEV-3e asks for a profile database
// versioned independently of the app binary "so bodies and curves can
// be added without a release". A file with a higher version, dropped
// in the profile directory, is what that means in practice.
let built_in = Curves::parse(BUILT_IN).expect("parses");
let newer = format!("version: {}\n{A_CANON_ENTRY}", built_in.version() + 1);
let dir = a_pack_dir("newer", &newer);
let chosen = choose(built_in.clone(), &[dir]);
assert_eq!(chosen.version(), built_in.version() + 1);
assert_eq!(chosen.body("Canon", "EOS 6D").ys[1], 0.42);
}
#[test]
fn a_stale_pack_does_not_survive_an_upgrade() {
// The other direction, and the one that protects the user. Somebody
// downloads a pack, a release later ships better curves for the same
// bodies, and the forgotten file must not quietly hold the application
// back at last year's rendering.
let built_in = Curves::parse(BUILT_IN).expect("parses");
let stale = format!("version: {}\n{A_CANON_ENTRY}", built_in.version());
let dir = a_pack_dir("stale", &stale);
let chosen = choose(built_in.clone(), &[dir]);
assert_eq!(chosen.version(), built_in.version());
assert_ne!(
chosen.body("Canon", "EOS 6D").ys[1],
0.42,
"an equal version must not displace the built-in database"
);
}
#[test]
fn a_broken_pack_costs_the_curves_and_not_the_photographs() {
// A malformed profile file must degrade to the built-in database, not
// to an error. The user came here to look at a photograph.
let built_in = Curves::parse(BUILT_IN).expect("parses");
let dir = a_pack_dir("broken", "version: [this is not a number\n");
let chosen = choose(built_in.clone(), &[dir]);
assert_eq!(chosen.version(), built_in.version());
assert_eq!(chosen.len(), built_in.len());
}
#[test]
fn a_directory_with_no_pack_in_it_is_simply_skipped() {
// The ordinary case on every machine: the search path exists, the file
// does not. It must not be a warning, an error, or a slow path.
let built_in = Curves::parse(BUILT_IN).expect("parses");
let missing = std::env::temp_dir().join("darkroom-base-curves-nothing-here");
let _ = std::fs::remove_dir_all(&missing);
assert_eq!(choose(built_in.clone(), &[missing]), built_in);
}
#[test]
fn the_identity_is_recognised_as_doing_nothing() {
assert!(BaseCurve::IDENTITY.is_identity());
assert!(!Curves::parse(BUILT_IN)
.expect("parses")
.body("Canon", "EOS 6D")
.is_identity());
}
}
+52
View File
@@ -0,0 +1,52 @@
/// TRACES: FR-RAW-4 | NFR-SEC-1
/// Failures from decoding.
///
/// Per FR-RAW-4 a malformed file must not abort a batch, so these are always
/// returned rather than panicking — and the decode path is the one place
/// untrusted input arrives (NFR-SEC-1).
#[derive(Debug, thiserror::Error)]
pub enum DecodeError {
#[error("read failed: {0}")]
Read(String),
#[error("unsupported or unrecognised format: {0}")]
Unsupported(String),
#[error("decode failed: {0}")]
Decode(String),
#[error("metadata unavailable: {0}")]
Metadata(String),
#[error("no embedded preview in this file")]
NoPreview,
#[error("embedded preview is corrupt: {0}")]
CorruptPreview(String),
}
impl DecodeError {
/// Whether a fallback path might still produce an image.
///
/// A missing preview is not a failure to display the file — it means fall
/// through to full decode (FR-CULL-2, M-11).
pub fn has_fallback(&self) -> bool {
matches!(
self,
DecodeError::NoPreview | DecodeError::CorruptPreview(_)
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn preview_failures_fall_through_rather_than_failing() {
assert!(DecodeError::NoPreview.has_fallback());
assert!(DecodeError::CorruptPreview("truncated".into()).has_fallback());
// A genuinely unsupported file has nowhere to fall through to.
assert!(!DecodeError::Unsupported("unknown".into()).has_fallback());
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+421
View File
@@ -0,0 +1,421 @@
//! Embedded preview extraction — the fast display path.
//!
//! Every RAW container carries one or more JPEG previews, often at or near
//! full resolution. Extracting one costs a fraction of a full decode, and is
//! what makes culling feel instant (FR-CULL-1, NFR-P13: 50 ms per image).
//!
//! It is also what makes remote browsing viable: fetching ~1-3 MB of preview
//! from an 80 MB file over WebDAV is the difference between usable and not on
//! mobile data (FR-NC-3).
use crate::DecodeError;
/// How much of a file header to read when locating a preview.
///
/// Enough to cover the IFD structure of the TIFF-derived formats. Sized for
/// remote range requests, where every byte costs.
pub const PREVIEW_PROBE_BYTES: u64 = 256 * 1024;
/// A decoded preview image, RGBA8.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Preview {
pub width: u32,
pub height: u32,
/// Tightly packed RGBA, 4 bytes per pixel.
pub rgba: Vec<u8>,
}
impl Preview {
/// TRACES: FR-DEV-3h
/// Turn the pixels the right way up, in place.
///
/// Every path that shows a preview without the GPU needs this: the grid's
/// thumbnails, and the read-only fallback develop shows when no decoder
/// could open the file. An embedded preview is written in the sensor's
/// orientation, not the photograph's, so a phone or a camera held sideways
/// fills the grid with frames on their side until this runs.
///
/// Done before [`Self::downscale_to`] would be wasteful and after it is
/// not: a quarter turn is a permutation, so it costs the same either way,
/// and doing it on the smaller buffer moves a fraction of the bytes.
///
/// Allocates a second buffer rather than rotating in place. An in-place
/// quarter turn on a non-square image is a cycle-following permutation
/// that is both slower per pixel and far harder to get right, for a saving
/// that a thumbnail-sized buffer does not need.
pub fn apply_orientation(&mut self, orientation: dr_types::Orientation) {
if orientation.is_normal() || self.width == 0 || self.height == 0 {
return;
}
let (dw, dh) = orientation.oriented_size(self.width, self.height);
let mut out = vec![0u8; (dw as usize) * (dh as usize) * 4];
for y in 0..dh {
for x in 0..dw {
let (sx, sy) = orientation.source_pixel(x, y, dw, dh);
let s = ((sy * self.width + sx) * 4) as usize;
let d = ((y * dw + x) * 4) as usize;
out[d..d + 4].copy_from_slice(&self.rgba[s..s + 4]);
}
}
self.rgba = out;
self.width = dw;
self.height = dh;
}
/// Downscale in place to fit within `max_dim` on the long edge.
///
/// A 5472x3648 preview is 79.8 MB of RGBA — far more than a grid cell or
/// even a 4K viewport needs, and enough to exhaust a phone's budget after
/// a handful of images (NFR-RES-1). Box-filtered rather than nearest, so
/// downscaled thumbnails do not alias.
pub fn downscale_to(&mut self, max_dim: u32) {
let longest = self.width.max(self.height);
if longest <= max_dim || longest == 0 {
return;
}
let scale = max_dim as f32 / longest as f32;
let (nw, nh) = (
((self.width as f32 * scale).round() as u32).max(1),
((self.height as f32 * scale).round() as u32).max(1),
);
let mut out = vec![0u8; (nw as usize) * (nh as usize) * 4];
let x_ratio = self.width as f32 / nw as f32;
let y_ratio = self.height as f32 / nh as f32;
for y in 0..nh {
let y0 = (y as f32 * y_ratio) as u32;
let y1 = (((y + 1) as f32 * y_ratio) as u32)
.min(self.height)
.max(y0 + 1);
for x in 0..nw {
let x0 = (x as f32 * x_ratio) as u32;
let x1 = (((x + 1) as f32 * x_ratio) as u32)
.min(self.width)
.max(x0 + 1);
let (mut r, mut g, mut b, mut n) = (0u32, 0u32, 0u32, 0u32);
for sy in y0..y1 {
for sx in x0..x1 {
let i = ((sy * self.width + sx) * 4) as usize;
r += self.rgba[i] as u32;
g += self.rgba[i + 1] as u32;
b += self.rgba[i + 2] as u32;
n += 1;
}
}
let n = n.max(1);
let o = ((y * nw + x) * 4) as usize;
out[o] = (r / n) as u8;
out[o + 1] = (g / n) as u8;
out[o + 2] = (b / n) as u8;
out[o + 3] = 255;
}
}
self.rgba = out;
self.width = nw;
self.height = nh;
}
/// Whether this is large enough to be worth displaying at `target`.
///
/// Some bodies embed thumbnails only a few hundred pixels wide — Sony is
/// the documented case. Displaying one where a larger render is wanted
/// shows a soft image the user discovers only on zoom, so the caller
/// should background-render instead (M-11).
pub fn is_useful_at(&self, target: u32) -> bool {
self.width.max(self.height) >= target
}
}
/// TRACES: FR-CULL-1 | NFR-P13
/// Which embedded image to extract.
///
/// Containers carry several at different sizes, and decoding the
/// full-resolution one to fill a grid cell is pure waste.
///
/// **Measured caveat (rawler 0.7.2):** the CR2 decoder implements only
/// `full_image`; `thumbnail_image` and `preview_image` are unimplemented trait
/// defaults returning `None`. So on Canon CR2 every rung currently resolves to
/// the full-resolution JPEG at ~250 ms — 5× over NFR-P13's 50 ms budget.
///
/// Three ways out, in increasing cost: extract the smaller IFD ourselves
/// (CR2 carries a 160×120 thumbnail and a ~1620×1080 preview in IFD1/IFD2),
/// contribute the methods upstream, or cache a downscaled proxy on first
/// sight. The ladder is written now so that fixing it is a decoder change
/// rather than a change to every caller.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PreviewSize {
/// Smallest available. Grid cells and rapid culling.
Thumbnail,
/// Mid-sized where the container has one. Single-image view.
Screen,
/// Largest available, usually full sensor resolution. Only where the
/// display genuinely needs it.
Full,
}
/// TRACES: FR-CULL-2 | FR-NC-3 | M-10
/// Extract and decode an embedded preview at the requested size.
///
/// Takes bytes rather than a reader, because the caller usually has them
/// already: a range read locally, or a `Range:` request remotely. Forcing a
/// `Read + Seek` here would push remote callers into buffering the whole file.
///
/// Falls through the ladder — a container without the requested size yields
/// the next available rather than failing (FR-CULL-2).
///
/// Returns [`DecodeError::NoPreview`] where there is none at all: a
/// fall-through signal, not a failure (see [`DecodeError::has_fallback`]).
pub fn extract_preview(bytes: &[u8], size: PreviewSize) -> Result<Preview, DecodeError> {
use rawler::rawsource::RawSource;
// A plain JPEG *is* its own preview — rawler has no decoder for one, and
// a mixed folder must display sensibly (M-9).
if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
return decode_jpeg(bytes);
}
let source = RawSource::new_from_slice(bytes);
let decoder =
rawler::get_decoder(&source).map_err(|e| DecodeError::Unsupported(e.to_string()))?;
let params = Default::default();
// Preference order per requested size, each falling through to the next.
let attempts: &[PreviewSize] = match size {
PreviewSize::Thumbnail => &[
PreviewSize::Thumbnail,
PreviewSize::Screen,
PreviewSize::Full,
],
PreviewSize::Screen => &[
PreviewSize::Screen,
PreviewSize::Full,
PreviewSize::Thumbnail,
],
PreviewSize::Full => &[PreviewSize::Full, PreviewSize::Screen],
};
for attempt in attempts {
let got = match attempt {
PreviewSize::Thumbnail => decoder.thumbnail_image(&source, &params),
PreviewSize::Screen => decoder.preview_image(&source, &params),
PreviewSize::Full => decoder.full_image(&source, &params),
};
if let Ok(Some(img)) = got {
let rgb = img.to_rgb8();
let (width, height) = (rgb.width(), rgb.height());
if width > 0 && height > 0 {
return Ok(Preview {
width,
height,
rgba: rgb_to_rgba(rgb.as_raw(), width, height),
});
}
}
}
Err(DecodeError::NoPreview)
}
/// Extract the largest available preview.
///
/// Convenience over [`extract_preview`]; prefer naming a size explicitly.
pub fn extract_embedded_preview(bytes: &[u8]) -> Result<Preview, DecodeError> {
extract_preview(bytes, PreviewSize::Full)
}
/// Decode a standalone JPEG (an embedded preview already sliced out, or a
/// JPEG file).
pub fn decode_jpeg(bytes: &[u8]) -> Result<Preview, DecodeError> {
let mut d = zune_jpeg::JpegDecoder::new(bytes);
let pixels = d
.decode()
.map_err(|e| DecodeError::CorruptPreview(e.to_string()))?;
let info = d
.info()
.ok_or_else(|| DecodeError::CorruptPreview("no image info".into()))?;
let (w, h) = (info.width as u32, info.height as u32);
let expected = (w as usize) * (h as usize);
// zune yields RGB or grayscale depending on the source; normalise both to
// RGBA so callers have one representation.
let rgba = match pixels.len() / expected.max(1) {
3 => rgb_to_rgba(&pixels, w, h),
1 => pixels.iter().flat_map(|&g| [g, g, g, 255]).collect(),
4 => pixels,
n => {
return Err(DecodeError::CorruptPreview(format!(
"unexpected {n} channels"
)))
}
};
Ok(Preview {
width: w,
height: h,
rgba,
})
}
fn rgb_to_rgba(rgb: &[u8], w: u32, h: u32) -> Vec<u8> {
let n = (w as usize) * (h as usize);
let mut out = Vec::with_capacity(n * 4);
for px in rgb.chunks_exact(3).take(n) {
out.extend_from_slice(&[px[0], px[1], px[2], 255]);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
/// A preview whose every pixel encodes its own coordinates, so a
/// misplaced one is identifiable rather than merely wrong.
fn coded(width: u32, height: u32) -> Preview {
let mut rgba = Vec::with_capacity((width * height * 4) as usize);
for y in 0..height {
for x in 0..width {
rgba.extend_from_slice(&[x as u8, y as u8, 0, 255]);
}
}
Preview {
width,
height,
rgba,
}
}
#[test]
fn a_quarter_turn_moves_every_pixel_where_the_orientation_says() {
// Tag 6: the stored image's first row becomes the displayed right
// edge, its first column the displayed top. A 4x2 landscape preview
// therefore comes out 2x4 portrait, with stored (0,0) at the top right.
let mut p = coded(4, 2);
p.apply_orientation(dr_types::Orientation::from_exif(6));
assert_eq!((p.width, p.height), (2, 4));
let at = |x: u32, y: u32| {
let i = ((y * p.width + x) * 4) as usize;
(p.rgba[i], p.rgba[i + 1])
};
// Displayed top-right reads stored (0, 0).
assert_eq!(at(1, 0), (0, 0));
// Displayed top-left reads stored (0, 1) — the last row of column 0.
assert_eq!(at(0, 0), (0, 1));
// Displayed bottom-right reads stored (3, 0).
assert_eq!(at(1, 3), (3, 0));
}
#[test]
fn an_upright_file_is_left_untouched() {
// The common case, and the one where an unnecessary reallocation
// would be paid on every thumbnail in the library.
let original = coded(4, 2);
let mut p = original.clone();
p.apply_orientation(dr_types::Orientation::NORMAL);
assert_eq!(p, original);
}
#[test]
fn every_orientation_preserves_the_pixels_it_was_given() {
// A turn or a mirror is a permutation: the same bytes, rearranged.
// Anything else means a pixel was dropped, duplicated or read out of
// bounds — and the bounds case would have panicked first.
for tag in 1..=8u16 {
let orientation = dr_types::Orientation::from_exif(tag);
let mut p = coded(5, 3);
p.apply_orientation(orientation);
assert_eq!(
(p.width, p.height),
orientation.oriented_size(5, 3),
"tag {tag}"
);
let mut got: Vec<_> = p.rgba.chunks(4).map(|c| (c[0], c[1])).collect();
got.sort_unstable();
let mut want: Vec<_> = coded(5, 3).rgba.chunks(4).map(|c| (c[0], c[1])).collect();
want.sort_unstable();
assert_eq!(got, want, "tag {tag}");
}
}
#[test]
fn size_preference_falls_through_in_order() {
// A container missing the requested size must yield the next
// available rather than failing (FR-CULL-2).
// Ordering is asserted here; behaviour against real files is covered
// by the smoke example.
assert_ne!(PreviewSize::Thumbnail, PreviewSize::Full);
}
#[test]
fn usefulness_is_judged_on_the_long_edge() {
let p = Preview {
width: 1600,
height: 1067,
rgba: Vec::new(),
};
assert!(p.is_useful_at(1024));
assert!(p.is_useful_at(1600));
// A body embedding only a small thumbnail must trigger a background
// render rather than showing a soft image.
assert!(!p.is_useful_at(2048));
}
#[test]
fn downscale_preserves_aspect_and_bounds_memory() {
let mut p = Preview {
width: 5472,
height: 3648,
rgba: vec![128; 5472 * 3648 * 4],
};
assert_eq!(p.rgba.len(), 79_847_424);
p.downscale_to(2048);
assert_eq!(p.width, 2048);
assert_eq!(p.height, 1365, "aspect preserved");
assert_eq!(p.rgba.len(), (2048 * 1365 * 4) as usize);
// A flat source must stay flat through the box filter.
assert!(p
.rgba
.chunks_exact(4)
.all(|px| px[0] == 128 && px[3] == 255));
}
#[test]
fn downscale_is_a_noop_when_already_small() {
let mut p = Preview {
width: 720,
height: 480,
rgba: vec![7; 720 * 480 * 4],
};
let before = p.rgba.len();
p.downscale_to(2048);
assert_eq!((p.width, p.height, p.rgba.len()), (720, 480, before));
}
#[test]
fn rgb_expands_to_rgba_opaque() {
let rgb = [10, 20, 30, 40, 50, 60];
let rgba = rgb_to_rgba(&rgb, 2, 1);
assert_eq!(rgba, vec![10, 20, 30, 255, 40, 50, 60, 255]);
}
#[test]
fn corrupt_jpeg_is_an_error_not_a_panic() {
// Untrusted input arrives here (NFR-SEC-1); it must never panic.
let err = decode_jpeg(&[0xFF, 0xD8, 0x00, 0x01, 0x02]).unwrap_err();
assert!(matches!(err, DecodeError::CorruptPreview(_)));
}
#[test]
fn empty_input_is_an_error_not_a_panic() {
assert!(decode_jpeg(&[]).is_err());
}
}
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
[package]
name = "dr-export"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
# No platform dependency and no filesystem, deliberately. This crate turns a
# rendered frame into *bytes* and a *name*; where those bytes go is the
# caller's problem, because the answer differs by more than a path. On Linux
# it is a file, on Android a SAF document descriptor with no path at all
# (ARCH §6.9), and on either it may be a `PUT` to the server. A crate that
# took a `Path` would work on exactly one of the three.
[dependencies]
dr-types.workspace = true
log.workspace = true
thiserror.workspace = true
# Encoders. All three are pure Rust and already in the tree, which is the same
# criterion that chose rustls, bundled SQLite and the Lensfun port: a C
# dependency here would be one more thing to satisfy under the Android NDK.
#
# AVIF and JPEG XL (FR-EXP-1) are deliberately absent. The mature encoders for
# both are C or C++ — libaom and libjxl — and ravif, the pure-Rust AVIF path,
# is slow enough to change what a batch export feels like. Neither belongs in
# the first version; see `format` in lib.rs for what happens when one is asked
# for.
jpeg-encoder.workspace = true
png = "0.18"
tiff = "0.11"
# The example runs the whole path — decode, GPU render, read back, encode,
# write — so it needs what the library deliberately does not: a GPU, a
# pipeline and a decoder. Dev-only, so none of it reaches a dependent.
[dev-dependencies]
dr-decode.workspace = true
dr-gpu.workspace = true
dr-pipeline.workspace = true
env_logger.workspace = true
pollster.workspace = true
zune-jpeg.workspace = true
+211
View File
@@ -0,0 +1,211 @@
//! Export a real file, end to end, from a real image.
//!
//! cargo run -p dr-export --example export -- <file.jpg|file.cr2> [out-dir]
//!
//! Deliberately the *whole* path and not a unit test of the encoder: decode,
//! demosaic or upload, run the develop chain on the GPU at full resolution,
//! read the result back through `AdjustPass::export_pixels`, resize, sharpen,
//! encode, and write. A test can prove the JPEG has the right magic bytes; it
//! cannot tell anyone whether the picture came out looking like the picture.
use std::path::PathBuf;
use dr_export::{export, Frame, NameContext, SourceMetadata};
use dr_gpu::{AdjustPass, DemosaicedImage, Demosaicer, GpuContext};
use dr_pipeline::EditGraph;
use dr_types::{ColourSpace, ExportFormat, ExportSettings, OutputSharpening, SizingMode};
fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info,wgpu=warn"))
.init();
let mut args = std::env::args().skip(1);
let Some(input) = args.next() else {
eprintln!("usage: export <file.jpg|file.cr2> [out-dir]");
std::process::exit(2);
};
let out_dir = PathBuf::from(args.next().unwrap_or_else(|| ".".into()));
let input = PathBuf::from(input);
let ctx = pollster::block_on(GpuContext::new_headless()).expect("gpu");
println!("gpu: {} ({:?})", ctx.adapter_name(), ctx.backend());
// Decode. A RAW goes through the demosaicer; a JPEG is already RGB and
// takes the same path every operation after the sensor stage does.
let bytes = std::fs::read(&input).expect("read input");
// From content, not from the extension — dr-decode is emphatic that an
// extension is only a hint. Its own `probe` reports a crate-private
// `Format`, so the SOI marker is checked directly here rather than
// widening that API for an example.
let is_jpeg = bytes.starts_with(&[0xFF, 0xD8, 0xFF]);
let source = if !is_jpeg {
let raw = dr_decode::decode(&bytes).expect("decode raw");
let demosaicer = Demosaicer::new(&ctx).expect("demosaicer");
demosaicer.run(&raw).expect("demosaic")
} else {
let (rgba, w, h) = decode_jpeg(&bytes);
DemosaicedImage::from_rgba8(&ctx, &rgba, w, h).expect("upload")
};
// An edit worth seeing in the output, so a broken pipeline is obvious
// rather than subtle.
let mut graph = EditGraph::default_chain();
graph.set_param(
dr_pipeline::ops::exposure::ID,
dr_pipeline::ops::exposure::EXPOSURE,
0.35,
);
graph.set_param(
dr_pipeline::ops::contrast::ID,
dr_pipeline::ops::contrast::CONTRAST,
18.0,
);
graph.set_param(
dr_pipeline::ops::saturation::ID,
dr_pipeline::ops::saturation::SATURATION,
12.0,
);
// Full resolution, not the viewport (FR-EXP-9). This is the one thing an
// export must not economise on.
let (sw, sh) = source.size();
let (fw, fh) = graph.output_size(sw, sh);
println!("source {sw}×{sh}, framed {fw}×{fh}");
// The output space is chosen *here*, before the render, because that is
// where it takes effect: the primaries conversion and the encode are the
// last two lines of the generated shader (FR-EXP-2). Asking for it at the
// encoder would be too late — the pixels would already be clipped.
let space = ColourSpace::DisplayP3;
let mut adjust = AdjustPass::new(&ctx);
let shader = graph.compose_for(space);
let t = std::time::Instant::now();
adjust.render(&source, &shader, fw, fh).expect("render");
let (pixels, w, h) = adjust.export_pixels().expect("read back");
println!(
"rendered {w}×{h} in {:.0} ms as {}",
t.elapsed().as_secs_f32() * 1000.0,
space.label()
);
let frame = Frame::in_space(w, h, pixels, space).expect("well-formed frame");
let stem = input
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "export".into());
// TRACES: FR-EXP-8
// What the input said about itself, transcribed field by field into the
// allowlist `dr-export` will write from. The example passes it because
// this is the one place in the tree that produces files a person can open
// in exiftool — a unit test can prove a GPS directory is absent from a
// byte slice, but only a real export proves that a real photograph comes
// out of the far end still knowing which camera took it.
//
// The defaults apply, so the files written here carry the camera, the
// lens, the exposure and the rights statement, and carry no coordinates.
let meta = dr_decode::metadata(&bytes).unwrap_or_default();
let source_metadata = SourceMetadata {
make: meta.make.clone(),
model: meta.model.clone(),
lens: meta.lens.clone(),
shutter: meta.shutter,
aperture: meta.aperture,
iso: meta.iso,
focal_length: meta.focal_length,
captured_at: meta.captured_at,
captured_offset: meta.captured_offset,
artist: meta.artist.clone(),
copyright: meta.copyright.clone(),
location: meta.location,
};
// One of each format, so the run exercises every encoder that exists.
for (format, sizing, sharpening) in [
(
ExportFormat::Jpeg,
SizingMode::Original,
OutputSharpening::None,
),
(
ExportFormat::Jpeg,
SizingMode::LongEdge(1200),
OutputSharpening::Screen,
),
(
ExportFormat::Png,
SizingMode::LongEdge(600),
OutputSharpening::Screen,
),
(
ExportFormat::Tiff8,
SizingMode::Percentage(25),
OutputSharpening::MattePaper,
),
(
ExportFormat::Tiff16,
SizingMode::Percentage(25),
OutputSharpening::MattePaper,
),
] {
let settings = ExportSettings {
format,
sizing,
sharpening,
colour_space: space,
filename_template: "{name}-{dimensions}".into(),
..Default::default()
};
// The size has to be known before the name, because `{dimensions}` is
// part of it — which is why sizing is resolved here and not inside
// `export`.
let (tw, th) = dr_export::target_size(w, h, sizing, settings.allow_upscaling);
let ctx = NameContext {
source_stem: &stem,
sequence: 1,
date: "",
width: tw,
height: th,
preset: "",
};
let name = dr_export::resolve_name(
&settings.filename_template,
&ctx,
format,
settings.collision,
&|n| out_dir.join(n).exists(),
)
.expect("a free name");
let t = std::time::Instant::now();
let out = export(&frame, &settings, name, Some(&source_metadata)).expect("export");
let path = out_dir.join(&out.name);
std::fs::write(&path, &out.bytes).expect("write");
println!(
"{:>10} {:>5}×{:<5} {:>8} KB {:>5.0} ms {}",
format.label(),
out.width,
out.height,
out.bytes.len() / 1024,
t.elapsed().as_secs_f32() * 1000.0,
path.display()
);
}
}
fn decode_jpeg(bytes: &[u8]) -> (Vec<u8>, u32, u32) {
let mut decoder = zune_jpeg::JpegDecoder::new(bytes);
let pixels = decoder.decode().expect("decode jpeg");
let info = decoder.info().expect("jpeg info");
let (w, h) = (u32::from(info.width), u32::from(info.height));
// zune gives RGB; the GPU upload wants RGBA.
let mut rgba = Vec::with_capacity((w * h * 4) as usize);
for px in pixels.chunks_exact(3) {
rgba.extend_from_slice(&[px[0], px[1], px[2], 255]);
}
(rgba, w, h)
}
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
//! TRACES: NFR-ARCH-4
//! Typed export failures.
//!
//! Every variant is something a caller can act on or report. A batch export
//! runs unattended over hundreds of frames (FR-EXP-7), so "what went wrong
//! with which file" has to survive as data rather than as a log line.
use dr_types::{ColourSpace, ExportFormat};
#[derive(Debug, thiserror::Error)]
pub enum ExportError {
#[error("frame buffer is {got} bytes, expected {expected}")]
FrameSize { expected: usize, got: usize },
#[error("frame has no pixels")]
EmptyFrame,
/// Asked for a format with no encoder in this build.
///
/// Not a panic and not a silent substitution: the settings page offers
/// AVIF and JPEG XL because FR-EXP-1 lists them, and a build without them
/// should say so rather than quietly writing a JPEG under a `.avif` name.
#[error("{} export is not supported yet", .0.label())]
FormatUnsupported(ExportFormat),
/// TRACES: FR-EXP-2
/// The frame was rendered into one colour space and asked to be labelled
/// another.
///
/// Not a limitation of the encoders — all four spaces embed a correct
/// profile. It is that the conversion happens in the shader, before the
/// clip to 0..1, so a frame is in exactly one space by the time it gets
/// here. The caller composes with `EditGraph::compose_for` to change which.
#[error(
"the frame was rendered in {} but a {} file was asked for",
.rendered.label(),
.requested.label()
)]
ColourSpaceMismatch {
rendered: ColourSpace,
requested: ColourSpace,
},
#[error("encoding failed: {0}")]
Encode(String),
}
+518
View File
@@ -0,0 +1,518 @@
//! TRACES: FR-EXP-8
//! Building an EXIF block, rather than copying one.
//!
//! # Why this is written by hand and not with a crate
//!
//! Two reasons, in order of importance.
//!
//! The first is the privacy behaviour. Every EXIF library worth using offers a
//! "load the source block, delete these tags, write it back" shape, and that
//! shape is the wrong one here: it makes the file that leaves the machine a
//! copy of the source's metadata *minus what we thought to remove*, so every
//! tag nobody has thought about — a vendor's proprietary sub-directory, a
//! serial number under a tag id this build has never seen — travels by
//! default. Constructing the block from a fixed list of parsed values inverts
//! that. What is written is exactly what appears in [`crate::SourceMetadata`],
//! and a tag that is not in this file cannot end up in the output no matter
//! what the source contained. The allowlist *is* the implementation.
//!
//! The second is the dependency policy. The root `Cargo.toml` explains why
//! nothing here may link C — this tree has to build under the Android NDK —
//! and the mature EXIF writers are bindings. This is a couple of hundred
//! lines of offset arithmetic against a specification that has not changed
//! since 2010, and it is the same TIFF structure `dr-decode` already reads.
//!
//! # What the block is
//!
//! A complete little-endian TIFF: an 8-byte header, IFD0 with the identity
//! and rights tags, an Exif sub-IFD with the capture tags, optionally a GPS
//! sub-IFD, and a heap of values too long to sit inside an entry. JPEG carries
//! it in an APP1 segment behind the marker `Exif\0\0`; PNG carries the same
//! bytes in an `eXIf` chunk with no marker. TIFF does not use this at all —
//! its own directory *is* the EXIF, so `encode.rs` writes the tags there
//! directly.
use crate::metadata::SourceMetadata;
/// One entry's value, in the handful of TIFF types this writer emits.
enum Value {
/// NUL-terminated, as the specification requires; the terminator is
/// counted, which is the detail readers trip over when it is missing.
Ascii(String),
Byte(Vec<u8>),
Short(u16),
Long(u32),
/// Type 7. Used only for `ExifVersion`, which is four characters that are
/// deliberately *not* a string.
Undefined(&'static [u8]),
/// Numerator and denominator pairs. A coordinate is three of them.
Rational(Vec<(u32, u32)>),
}
impl Value {
fn field_type(&self) -> u16 {
match self {
Value::Byte(_) => 1,
Value::Ascii(_) => 2,
Value::Short(_) => 3,
Value::Long(_) => 4,
Value::Rational(_) => 5,
Value::Undefined(_) => 7,
}
}
/// The element count, which is not the byte length: a rational counts as
/// one element per eight bytes.
fn count(&self) -> u32 {
match self {
Value::Ascii(s) => s.len() as u32 + 1,
Value::Byte(b) => b.len() as u32,
Value::Undefined(b) => b.len() as u32,
Value::Short(_) | Value::Long(_) => 1,
Value::Rational(r) => r.len() as u32,
}
}
/// The payload, in file order.
fn payload(&self) -> Vec<u8> {
match self {
Value::Ascii(s) => {
let mut out = s.as_bytes().to_vec();
out.push(0);
out
}
Value::Byte(b) => b.clone(),
Value::Undefined(b) => b.to_vec(),
Value::Short(v) => v.to_le_bytes().to_vec(),
Value::Long(v) => v.to_le_bytes().to_vec(),
Value::Rational(r) => r
.iter()
.flat_map(|(n, d)| {
let mut b = n.to_le_bytes().to_vec();
b.extend_from_slice(&d.to_le_bytes());
b
})
.collect(),
}
}
}
/// An IFD under construction.
type Entries = Vec<(u16, Value)>;
/// Tag numbers. Named rather than inlined because a mistyped one produces a
/// file that still parses and says something else entirely.
pub(crate) mod tag {
pub(crate) const MAKE: u16 = 0x010F;
pub(crate) const MODEL: u16 = 0x0110;
pub(crate) const SOFTWARE: u16 = 0x0131;
pub(crate) const DATE_TIME: u16 = 0x0132;
pub(crate) const ARTIST: u16 = 0x013B;
pub(crate) const COPYRIGHT: u16 = 0x8298;
pub(crate) const EXIF_IFD: u16 = 0x8769;
pub(crate) const GPS_IFD: u16 = 0x8825;
pub(crate) const EXPOSURE_TIME: u16 = 0x829A;
pub(crate) const FNUMBER: u16 = 0x829D;
pub(crate) const ISO: u16 = 0x8827;
pub(crate) const EXIF_VERSION: u16 = 0x9000;
pub(crate) const DATE_TIME_ORIGINAL: u16 = 0x9003;
pub(crate) const OFFSET_TIME_ORIGINAL: u16 = 0x9011;
pub(crate) const FOCAL_LENGTH: u16 = 0x920A;
pub(crate) const PIXEL_X: u16 = 0xA002;
pub(crate) const PIXEL_Y: u16 = 0xA003;
pub(crate) const LENS_MODEL: u16 = 0xA434;
pub(crate) const GPS_VERSION_ID: u16 = 0x0000;
pub(crate) const GPS_LATITUDE_REF: u16 = 0x0001;
pub(crate) const GPS_LATITUDE: u16 = 0x0002;
pub(crate) const GPS_LONGITUDE_REF: u16 = 0x0003;
pub(crate) const GPS_LONGITUDE: u16 = 0x0004;
pub(crate) const GPS_ALTITUDE_REF: u16 = 0x0005;
pub(crate) const GPS_ALTITUDE: u16 = 0x0006;
}
/// What DarkRoom calls itself in a file it wrote.
///
/// Not vanity: an export is a derived file, and a reader that knows which
/// program produced it can tell a camera original from a rendition without
/// guessing from the absence of a maker note.
pub(crate) const SOFTWARE: &str = "DarkRoom";
/// The complete EXIF block for JPEG's APP1 and PNG's `eXIf`.
///
/// `width`/`height` are the *exported* dimensions, not the source's: the
/// pixel-dimension tags describe the file they are in, and a reader that
/// trusts them after a resize would report the wrong size for the image it is
/// holding.
///
/// `None` where there is nothing to say. An empty EXIF block is not the same
/// as no EXIF block — it is a structure a reader must parse to discover it
/// learned nothing — and the second is the better file.
pub(crate) fn block(md: &SourceMetadata, width: u32, height: u32) -> Option<Vec<u8>> {
let ifd0 = main_entries(md);
let exif = exif_entries(md, width, height);
let gps = gps_entries(md);
if ifd0.is_empty() && exif.is_empty() && gps.is_empty() {
return None;
}
Some(assemble(ifd0, exif, gps))
}
/// Lay the three directories and their heap out in the block.
///
/// The order is fixed — IFD0, Exif, GPS, heap — because the pointers have to
/// be known before IFD0 is serialised, and an IFD's size is decided by its
/// entry count alone: two bytes of count, twelve per entry, four for the link
/// to the next directory.
fn assemble(mut ifd0: Entries, exif: Entries, gps: Entries) -> Vec<u8> {
const HEADER: u32 = 8;
let size = |n: usize| 2 + 12 * n as u32 + 4;
// The pointer entries are part of IFD0's count, so they have to be added
// before its size is taken — a chicken-and-egg the specification resolves
// by making entry size fixed.
let pointers = usize::from(!exif.is_empty()) + usize::from(!gps.is_empty());
let ifd0_size = size(ifd0.len() + pointers);
let exif_offset = HEADER + ifd0_size;
let gps_offset = exif_offset + if exif.is_empty() { 0 } else { size(exif.len()) };
let heap_base = gps_offset + if gps.is_empty() { 0 } else { size(gps.len()) };
if !exif.is_empty() {
ifd0.push((tag::EXIF_IFD, Value::Long(exif_offset)));
}
if !gps.is_empty() {
ifd0.push((tag::GPS_IFD, Value::Long(gps_offset)));
}
let mut heap = Vec::new();
let ifd0_bytes = directory(ifd0, heap_base, &mut heap);
let exif_bytes = directory(exif, heap_base, &mut heap);
let gps_bytes = directory(gps, heap_base, &mut heap);
let mut out = Vec::with_capacity(HEADER as usize + heap.len() + 128);
// Little-endian, magic 42, first directory at byte 8. Little-endian
// because every value written below is, and a header that disagreed with
// its own body is the one corruption a reader cannot recover from.
out.extend_from_slice(b"II");
out.extend_from_slice(&42u16.to_le_bytes());
out.extend_from_slice(&HEADER.to_le_bytes());
out.extend_from_slice(&ifd0_bytes);
out.extend_from_slice(&exif_bytes);
out.extend_from_slice(&gps_bytes);
out.extend_from_slice(&heap);
out
}
/// Serialise one directory, spilling long values onto the shared heap.
///
/// Entries are sorted by tag: TIFF requires ascending order within a
/// directory, and while most readers cope with any order, the ones that
/// binary-search stop at the first tag they cannot place.
fn directory(mut entries: Entries, heap_base: u32, heap: &mut Vec<u8>) -> Vec<u8> {
if entries.is_empty() {
return Vec::new();
}
entries.sort_by_key(|(tag, _)| *tag);
let mut out = Vec::with_capacity(2 + entries.len() * 12 + 4);
out.extend_from_slice(&(entries.len() as u16).to_le_bytes());
for (tag, value) in &entries {
out.extend_from_slice(&tag.to_le_bytes());
out.extend_from_slice(&value.field_type().to_le_bytes());
out.extend_from_slice(&value.count().to_le_bytes());
let payload = value.payload();
if payload.len() <= 4 {
// Four bytes or fewer live in the entry itself, left-justified and
// zero-padded.
let mut inline = payload.clone();
inline.resize(4, 0);
out.extend_from_slice(&inline);
} else {
out.extend_from_slice(&(heap_base + heap.len() as u32).to_le_bytes());
heap.extend_from_slice(&payload);
// Values start on even offsets. Not every reader cares; the ones
// that do read a short from an odd address and get nonsense.
if heap.len() % 2 == 1 {
heap.push(0);
}
}
}
// No directory follows this one. The Exif and GPS sub-directories are
// pointed at, not chained, so this is zero in all three.
out.extend_from_slice(&0u32.to_le_bytes());
out
}
/// IFD0: who took it, with what, and who owns it.
///
/// **No orientation tag, deliberately.** The frame reaching the encoder has
/// already had the source's orientation applied by the pipeline — it is
/// upright pixels — so copying the source's tag across would tell every
/// reader to rotate an image that is already the right way up. A portrait
/// frame would come out on its side in exactly the viewers that honour the
/// tag, which is most of them.
fn main_entries(md: &SourceMetadata) -> Entries {
let mut e = Entries::new();
push_ascii(&mut e, tag::MAKE, md.make.as_deref());
push_ascii(&mut e, tag::MODEL, md.model.as_deref());
push_ascii(&mut e, tag::ARTIST, md.artist.as_deref());
push_ascii(&mut e, tag::COPYRIGHT, md.copyright.as_deref());
e.push((tag::SOFTWARE, Value::Ascii(SOFTWARE.to_string())));
// IFD0's `DateTime` is nominally when the file was written, and this is
// the capture time instead. That is what the rest of the world does —
// and it is what `dr-decode` falls back to for scanner output that has no
// `DateTimeOriginal` — so a re-import of an export lands on the timeline
// where the original did rather than on the day it was exported.
if let Some(t) = md.captured_at.map(datetime) {
e.push((tag::DATE_TIME, Value::Ascii(t)));
}
e
}
/// The Exif sub-IFD: the exposure, and what made it.
fn exif_entries(md: &SourceMetadata, width: u32, height: u32) -> Entries {
let mut e = Entries::new();
// "0232" is Exif 2.32. A sub-directory without a version is technically
// malformed, and some readers refuse the whole block over it.
e.push((tag::EXIF_VERSION, Value::Undefined(b"0232")));
e.push((tag::PIXEL_X, Value::Long(width)));
e.push((tag::PIXEL_Y, Value::Long(height)));
push_ascii(&mut e, tag::LENS_MODEL, md.lens.as_deref());
if let Some(t) = md.captured_at.map(datetime) {
e.push((tag::DATE_TIME_ORIGINAL, Value::Ascii(t)));
}
if let Some(o) = md.captured_offset.map(offset) {
e.push((tag::OFFSET_TIME_ORIGINAL, Value::Ascii(o)));
}
if let Some(s) = md.shutter.filter(|s| *s > 0.0) {
e.push((tag::EXPOSURE_TIME, Value::Rational(vec![shutter(s)])));
}
if let Some(f) = md.aperture.filter(|f| *f > 0.0) {
e.push((tag::FNUMBER, Value::Rational(vec![tenths(f)])));
}
if let Some(f) = md.focal_length.filter(|f| *f > 0.0) {
e.push((tag::FOCAL_LENGTH, Value::Rational(vec![tenths(f)])));
}
// The tag is a SHORT, so a sensitivity above 65535 has no representation
// in it. Dropped rather than truncated: ISO 102400 written as 36864 is a
// lie, and an absent tag is not.
if let Some(iso) = md.iso.filter(|v| *v <= u32::from(u16::MAX)) {
e.push((tag::ISO, Value::Short(iso as u16)));
}
e
}
/// The GPS sub-IFD.
///
/// Empty unless the caller has already decided that coordinates may be
/// written — see [`SourceMetadata::sanitised`], which is where the stripping
/// happens. Nothing in this file consults the settings, so there is exactly
/// one place to look to answer "can this export carry a location".
fn gps_entries(md: &SourceMetadata) -> Entries {
let Some(loc) = md.location else {
return Entries::new();
};
let mut e = Entries::new();
// 2.3.0.0, the current GPS tag version.
e.push((tag::GPS_VERSION_ID, Value::Byte(vec![2, 3, 0, 0])));
e.push((
tag::GPS_LATITUDE_REF,
Value::Ascii(if loc.latitude < 0.0 { "S" } else { "N" }.into()),
));
e.push((tag::GPS_LATITUDE, Value::Rational(dms(loc.latitude))));
e.push((
tag::GPS_LONGITUDE_REF,
Value::Ascii(if loc.longitude < 0.0 { "W" } else { "E" }.into()),
));
e.push((tag::GPS_LONGITUDE, Value::Rational(dms(loc.longitude))));
if let Some(alt) = loc.altitude {
// The altitude itself is unsigned; below sea level is a separate byte.
e.push((
tag::GPS_ALTITUDE_REF,
Value::Byte(vec![u8::from(alt < 0.0)]),
));
e.push((
tag::GPS_ALTITUDE,
Value::Rational(vec![((alt.abs() * 100.0).round() as u32, 100)]),
));
}
e
}
fn push_ascii(entries: &mut Entries, tag: u16, value: Option<&str>) {
// An empty string is a tag saying nothing, which is worse than no tag: it
// overwrites whatever a reader would otherwise have inferred.
if let Some(v) = value.map(str::trim).filter(|v| !v.is_empty()) {
entries.push((tag, Value::Ascii(v.to_string())));
}
}
/// Signed degrees back into the tag's degrees/minutes/seconds.
///
/// The sign is carried by the hemisphere letter, so this takes the magnitude.
/// Seconds keep four decimal places, which is about 3 mm — far finer than any
/// consumer fix, and enough that a round trip through the tag does not move
/// the pin.
pub(crate) fn dms(degrees: f64) -> Vec<(u32, u32)> {
let d = degrees.abs();
let whole = d.trunc();
let minutes = (d - whole) * 60.0;
let seconds = (minutes - minutes.trunc()) * 60.0;
vec![
(whole as u32, 1),
(minutes.trunc() as u32, 1),
((seconds * 10_000.0).round() as u32, 10_000),
]
}
/// A shutter speed as the fraction a photographer would recognise.
///
/// `1/250`, not `4/1000`. Both are the same number and every reader computes
/// the same exposure from either, but the first is what the camera wrote and
/// what a properties panel displays verbatim.
pub(crate) fn shutter(seconds: f32) -> (u32, u32) {
if seconds < 1.0 {
(1, (1.0 / seconds).round().max(1.0) as u32)
} else {
((seconds * 10.0).round() as u32, 10)
}
}
/// f/2.8 and 85 mm as tenths, which is how cameras write both.
pub(crate) fn tenths(value: f32) -> (u32, u32) {
((value * 10.0).round().max(0.0) as u32, 10)
}
/// Unix seconds as EXIF's `"YYYY:MM:DD HH:MM:SS"`.
///
/// The reading is a wall clock with no zone — that is what the tag means, and
/// what `dr-decode` parsed it as — so this is the exact inverse of that parse
/// and involves no timezone conversion. The zone, where the source recorded
/// one, travels separately in `OffsetTimeOriginal`.
pub(crate) fn datetime(unix: i64) -> String {
let days = unix.div_euclid(86_400);
let secs = unix.rem_euclid(86_400);
// Howard Hinnant's civil-from-days, the inverse of the days-from-civil
// that `dr-decode` uses to parse. Eras of 400 years, shifted so that the
// arithmetic never sees a negative.
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!(
"{y:04}:{m:02}:{d:02} {:02}:{:02}:{:02}",
secs / 3600,
(secs / 60) % 60,
secs % 60
)
}
/// Minutes east of UTC as EXIF's `"+HH:MM"`.
pub(crate) fn offset(minutes: i32) -> String {
let sign = if minutes < 0 { '-' } else { '+' };
let m = minutes.unsigned_abs();
format!("{sign}{:02}:{:02}", m / 60, m % 60)
}
#[cfg(test)]
mod tests {
use super::*;
use dr_types::Location;
#[test]
fn a_capture_time_survives_the_round_trip_through_the_tag() {
// The parse side lives in `dr-decode` and is exercised against real
// files; this is the inverse, and the two meeting in the middle is
// what keeps an exported frame on the same point of the timeline as
// the original.
assert_eq!(datetime(1_372_462_374), "2013:06:28 23:32:54");
assert_eq!(datetime(0), "1970:01:01 00:00:00");
// A leap day, which is where a hand-rolled calendar goes wrong.
assert_eq!(datetime(1_709_164_800), "2024:02:29 00:00:00");
}
#[test]
fn a_zone_is_written_the_way_the_tag_spells_it() {
assert_eq!(offset(120), "+02:00");
assert_eq!(offset(-330), "-05:30");
assert_eq!(offset(0), "+00:00");
}
#[test]
fn a_shutter_speed_keeps_the_photographers_fraction() {
assert_eq!(shutter(1.0 / 250.0), (1, 250));
assert_eq!(shutter(2.5), (25, 10));
}
#[test]
fn degrees_round_trip_through_the_tags_triple() {
// 48.8582 N is the Eiffel Tower; the check is that the three-part
// form comes back to the same place, to well under a metre.
for degrees in [48.8582_f64, -33.8568, 0.0, 179.999] {
let parts = dms(degrees);
let back = parts[0].0 as f64
+ parts[1].0 as f64 / 60.0
+ (parts[2].0 as f64 / parts[2].1 as f64) / 3600.0;
assert!(
(back - degrees.abs()).abs() < 1e-6,
"{degrees} came back as {back}"
);
}
}
#[test]
fn an_empty_source_produces_no_block_at_all() {
// Every field absent means the only entries would be the ones this
// writer adds itself. That is still worth writing — `Software` and
// the pixel dimensions are true statements — so the block exists; what
// must not happen is a *malformed* one.
let md = SourceMetadata::default();
let bytes = block(&md, 100, 50).expect("the writer's own tags");
assert!(bytes.starts_with(b"II*\0"));
}
#[test]
fn the_gps_directory_is_absent_when_there_is_no_position() {
let md = SourceMetadata {
make: Some("Canon".into()),
..Default::default()
};
let bytes = block(&md, 10, 10).unwrap();
assert!(!contains_entry(&bytes, tag::GPS_IFD));
}
#[test]
fn the_gps_directory_is_present_when_there_is_one() {
// The counterpart of the test above: a strip test that passed because
// the writer could never emit GPS at all would prove nothing.
let md = SourceMetadata {
location: Location::new(48.8582, 2.2945, Some(35.0)),
..Default::default()
};
let bytes = block(&md, 10, 10).unwrap();
assert!(contains_entry(&bytes, tag::GPS_IFD));
}
/// Whether a directory entry for `tag` appears anywhere in the block.
///
/// Byte-level on purpose: an entry is a tag, a type and a count, and
/// searching for that twelve-byte shape's first eight bytes is a far
/// stronger statement than asking a parser that might have skipped the
/// directory the tag was in.
fn contains_entry(bytes: &[u8], tag: u16) -> bool {
bytes
.windows(4)
.any(|w| w[..2] == tag.to_le_bytes() && (w[2] == 4 || w[2] == 13) && w[3] == 0)
}
}
+483
View File
@@ -0,0 +1,483 @@
//! TRACES: FR-EXP-2
//! Minimal ICC v2 matrix/TRC profiles, generated.
//!
//! # Why generated rather than shipped
//!
//! A profile is a description of what the pixels in a file mean, and the
//! pixels here were produced by [`dr_types::colour`]'s matrices. Embedding a
//! profile downloaded from elsewhere would mean two independent statements
//! about the same space, agreeing until one of them was revised. Deriving both
//! from the same primaries makes agreement structural.
//!
//! It is also the only pure-Rust route. Little-CMS is the obvious library and
//! it is C, which the whole workspace avoids so it cross-compiles under the
//! Android NDK — the same reasoning behind rustls, bundled SQLite and the
//! Lensfun port.
//!
//! # What "minimal" leaves out
//!
//! A matrix/TRC display profile and nothing else: three colorants, three tone
//! curves, a white point and the chromatic adaptation that got it there. No
//! A2B/B2A lookup tables, no gamut tag, no named colours. That is the whole of
//! what an RGB working space *is*, and it is what every reader — a browser, an
//! operating system compositor, Photoshop — takes from a profile like this
//! one. The tags omitted describe device behaviour these spaces do not have.
//!
//! Profiles come out around 2 KB, which matters more than it sounds: a JPEG
//! carries the profile in APP2 segments capped at 64 KB each, and one that fits
//! in a single segment avoids the chunked form that some older readers
//! mishandle.
use dr_types::{ColourSpace, Transfer};
/// The ICC profile describing `space`, ready to embed.
///
/// Deterministic — the same space always produces the same bytes. Two exports
/// of the same frame must be byte-identical files, which a creation timestamp
/// read from the clock would quietly break, along with any deduplication
/// downstream of it.
pub fn profile(space: ColourSpace) -> Vec<u8> {
let colorants = space.to_pcs_xyz();
let trc = trc_curve(space.transfer());
// Sorted by signature, as the specification asks a tag table to be. Some
// readers binary-search it.
let mut tags: Vec<(&[u8; 4], Vec<u8>)> = vec![
(b"bTRC", trc.clone()),
// Columns, not rows: a colorant tag is where one primary lands in XYZ.
(b"bXYZ", xyz_type(colorants[2], colorants[5], colorants[8])),
(b"cprt", text_type(COPYRIGHT)),
(b"desc", description_type(&description(space))),
(b"gTRC", trc.clone()),
(b"gXYZ", xyz_type(colorants[1], colorants[4], colorants[7])),
(b"rTRC", trc),
(b"rXYZ", xyz_type(colorants[0], colorants[3], colorants[6])),
// The PCS illuminant itself, not the space's own white. The space's
// white is recoverable from this and `chad`, and a profile that put
// its native white here would have every reader adapt it twice.
(b"wtpt", xyz_type(PCS_D50[0], PCS_D50[1], PCS_D50[2])),
];
// Only where there is an adaptation to declare. ProPhoto is a D50 space
// already, and an identity `chad` is a tag saying nothing.
let adaptation = space.adaptation_to_pcs();
if !is_identity(&adaptation) {
tags.push((b"chad", sf32_type(&adaptation)));
}
tags.sort_by_key(|(sig, _)| **sig);
assemble(&tags)
}
/// What a colour-management dialogue will show this profile as.
///
/// Deliberately not the canonical names. "sRGB IEC61966-2.1" is the reference
/// profile, and this is not it — it is a profile derived from the same
/// primaries, which is a different and weaker claim. "Adobe RGB (1998)" is
/// additionally a name belonging to someone else. A distinct name also tells a
/// user opening the file where the profile came from, which is the question
/// they are asking when they look.
fn description(space: ColourSpace) -> String {
format!("DarkRoom {}", space.label())
}
/// The copyright tag, which ICC requires a profile to carry.
///
/// A set of chromaticity coordinates from a published specification is not
/// something to claim rights over, and a profile nobody may redistribute would
/// make the files carrying it awkward to share — which is the whole purpose of
/// an export.
const COPYRIGHT: &str = "Generated by DarkRoom. No rights reserved.";
/// The profile connection space illuminant, as s15Fixed16 exactly.
const PCS_D50: [f32; 3] = [0.9642, 1.0, 0.8249];
/// Samples in a tabulated tone curve.
///
/// 1024 is what the reference sRGB profiles use. The curve is interpolated
/// linearly between samples, so this is far finer than the 8-bit values it
/// describes; halving it would still be adequate and would save a kilobyte
/// nobody is counting.
const TRC_SAMPLES: usize = 1024;
/// A tone reproduction curve for the space's transfer function.
///
/// ICC curves run *towards* the connection space — device value to linear —
/// which is the opposite direction from the shader's final encode. Getting it
/// backwards produces a file that looks washed out or crushed by exactly the
/// amount the curve bends.
fn trc_curve(transfer: Transfer) -> Vec<u8> {
// A pure power curve has an exact representation: a single u8Fixed8
// gamma. Adobe RGB's 563/256 lands on it precisely, where a 1024-entry
// table would be an approximation of a number the format can hold.
if let Transfer::Gamma(g) = transfer {
let mut out = tag_header(b"curv");
out.extend_from_slice(&1u32.to_be_bytes());
out.extend_from_slice(&((g * 256.0).round() as u16).to_be_bytes());
return out;
}
let mut out = tag_header(b"curv");
out.extend_from_slice(&(TRC_SAMPLES as u32).to_be_bytes());
for i in 0..TRC_SAMPLES {
let device = i as f32 / (TRC_SAMPLES - 1) as f32;
let linear = transfer.decode(device);
out.extend_from_slice(&((linear * 65535.0).round() as u16).to_be_bytes());
}
out
}
/// An `XYZType` tag: one colour in the connection space.
fn xyz_type(x: f32, y: f32, z: f32) -> Vec<u8> {
let mut out = tag_header(b"XYZ ");
for v in [x, y, z] {
out.extend_from_slice(&s15_fixed16(v).to_be_bytes());
}
out
}
/// An `s15Fixed16ArrayType` tag, which is how `chad` is stored.
fn sf32_type(m: &[f32; 9]) -> Vec<u8> {
let mut out = tag_header(b"sf32");
for v in m {
out.extend_from_slice(&s15_fixed16(*v).to_be_bytes());
}
out
}
/// A `textType` tag: ASCII with a terminating NUL.
fn text_type(s: &str) -> Vec<u8> {
let mut out = tag_header(b"text");
out.extend_from_slice(s.as_bytes());
out.push(0);
out
}
/// A `textDescriptionType` tag — the v2 profile's name field.
///
/// Baroque, and not optional: v2 has no plain `mluc`, and the ASCII string is
/// followed by empty Unicode and ScriptCode blocks that a reader will walk
/// whether or not they hold anything. The 67-byte Macintosh field is fixed
/// width by specification, so it is written out zeroed rather than omitted.
fn description_type(s: &str) -> Vec<u8> {
let ascii = s.as_bytes();
let mut out = tag_header(b"desc");
out.extend_from_slice(&(ascii.len() as u32 + 1).to_be_bytes());
out.extend_from_slice(ascii);
out.push(0);
// Unicode language code, then Unicode character count: none of either.
out.extend_from_slice(&[0; 8]);
// ScriptCode code (u16), length (u8), and the fixed 67-byte field.
out.extend_from_slice(&[0; 3]);
out.extend_from_slice(&[0; 67]);
out
}
/// Every tag element opens with its type signature and four reserved bytes.
fn tag_header(sig: &[u8; 4]) -> Vec<u8> {
let mut out = Vec::from(*sig);
out.extend_from_slice(&[0; 4]);
out
}
/// ICC's fixed-point number: 16 integer bits, 16 fractional.
fn s15_fixed16(v: f32) -> i32 {
(f64::from(v) * 65536.0).round() as i32
}
fn is_identity(m: &[f32; 9]) -> bool {
const IDENTITY: [f32; 9] = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0];
// One step of s15Fixed16, the format the matrix would be stored in. Below
// that it *is* the identity — ProPhoto's own white and the PCS illuminant
// differ in the sixth decimal place, and a `chad` recording that would be
// nine copies of 1.0000 and 0.0000 dressed up as information.
const STEP: f32 = 1.0 / 65536.0;
m.iter().zip(IDENTITY).all(|(a, b)| (a - b).abs() < STEP)
}
/// Header, tag table, and the tag data, with the size written back in.
fn assemble(tags: &[(&[u8; 4], Vec<u8>)]) -> Vec<u8> {
let mut out = header();
out.extend_from_slice(&(tags.len() as u32).to_be_bytes());
let table_at = out.len();
out.resize(table_at + tags.len() * 12, 0);
for (i, (sig, data)) in tags.iter().enumerate() {
// Identical elements share one copy, which the specification allows
// explicitly. The three tone curves of a grey-balanced space are the
// same 2 KB table, so this is two thirds of the profile.
let offset = find(&out, data).unwrap_or_else(|| {
let at = out.len();
out.extend_from_slice(data);
// Every element starts on a four-byte boundary.
while !out.len().is_multiple_of(4) {
out.push(0);
}
at
});
let entry = table_at + i * 12;
out[entry..entry + 4].copy_from_slice(*sig);
out[entry + 4..entry + 8].copy_from_slice(&(offset as u32).to_be_bytes());
out[entry + 8..entry + 12].copy_from_slice(&(data.len() as u32).to_be_bytes());
}
let size = out.len() as u32;
out[0..4].copy_from_slice(&size.to_be_bytes());
out
}
/// Where `needle` already sits in `haystack`, if it does.
///
/// Only ever called with tag elements, which begin on four-byte boundaries and
/// start with a type signature — so a match cannot be a coincidental overlap
/// of two other tags' bytes.
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack
.windows(needle.len())
.position(|w| w == needle)
.filter(|at| at.is_multiple_of(4))
}
/// The fixed 128-byte profile header.
fn header() -> Vec<u8> {
let mut h = Vec::with_capacity(128);
// Size, filled in once the profile is complete.
h.extend_from_slice(&[0; 4]);
// Preferred CMM: no preference.
h.extend_from_slice(&[0; 4]);
// Version 2.1.0. v2 rather than v4 because it is what every reader
// handles, and because nothing here needs a v4 tag type.
h.extend_from_slice(&[0x02, 0x10, 0x00, 0x00]);
h.extend_from_slice(b"mntr");
h.extend_from_slice(b"RGB ");
h.extend_from_slice(b"XYZ ");
// Creation date. Fixed, for the determinism the module docs describe.
for field in [2025u16, 1, 1, 0, 0, 0] {
h.extend_from_slice(&field.to_be_bytes());
}
h.extend_from_slice(b"acsp");
// Primary platform, flags, manufacturer, model, attributes: unspecified.
h.extend_from_slice(&[0; 24]);
// Rendering intent: perceptual, as the reference RGB working-space
// profiles declare. For a matrix/TRC profile the field is advisory —
// there is only one transform in here to apply.
h.extend_from_slice(&[0; 4]);
for v in PCS_D50 {
h.extend_from_slice(&s15_fixed16(v).to_be_bytes());
}
// Creator, profile ID, and the reserved tail.
h.extend_from_slice(&[0; 4]);
h.extend_from_slice(&[0; 16]);
h.extend_from_slice(&[0; 28]);
debug_assert_eq!(h.len(), 128);
h
}
#[cfg(test)]
mod tests {
use super::*;
/// A tag's element data, located through the profile's own tag table —
/// so these tests read the profile the way a colour engine would rather
/// than the way it was written.
fn tag<'a>(profile: &'a [u8], want: &[u8; 4]) -> Option<&'a [u8]> {
let count = u32::from_be_bytes(profile[128..132].try_into().unwrap()) as usize;
for i in 0..count {
let at = 132 + i * 12;
if &profile[at..at + 4] == want {
let off = u32::from_be_bytes(profile[at + 4..at + 8].try_into().unwrap()) as usize;
let len = u32::from_be_bytes(profile[at + 8..at + 12].try_into().unwrap()) as usize;
return Some(&profile[off..off + len]);
}
}
None
}
fn xyz(data: &[u8]) -> [f32; 3] {
let read =
|at: usize| i32::from_be_bytes(data[at..at + 4].try_into().unwrap()) as f32 / 65536.0;
[read(8), read(12), read(16)]
}
#[test]
fn a_profile_declares_its_own_length() {
// The first field a reader trusts. A profile whose header says it is
// longer than the buffer is one a strict parser rejects outright and a
// lax one reads past the end of.
for space in ColourSpace::ALL {
let p = profile(space);
let declared = u32::from_be_bytes(p[0..4].try_into().unwrap()) as usize;
assert_eq!(declared, p.len(), "{space:?}");
}
}
#[test]
fn a_profile_carries_the_signature_that_identifies_it_as_one() {
// `acsp` at offset 36 is how every reader recognises an ICC profile.
for space in ColourSpace::ALL {
assert_eq!(&profile(space)[36..40], b"acsp", "{space:?}");
}
}
#[test]
fn every_tag_lies_inside_the_profile_and_on_a_boundary() {
// A tag table is offsets and lengths, and nothing checks them for us.
// An off-by-four here produces a profile that parses as far as the
// tag a reader happens to want.
for space in ColourSpace::ALL {
let p = profile(space);
let count = u32::from_be_bytes(p[128..132].try_into().unwrap()) as usize;
for i in 0..count {
let at = 132 + i * 12;
let off = u32::from_be_bytes(p[at + 4..at + 8].try_into().unwrap()) as usize;
let len = u32::from_be_bytes(p[at + 8..at + 12].try_into().unwrap()) as usize;
assert!(off.is_multiple_of(4), "{space:?} tag {i} starts at {off}");
assert!(off + len <= p.len(), "{space:?} tag {i} runs off the end");
}
}
}
#[test]
fn every_profile_carries_the_tags_a_matrix_trc_profile_requires() {
// The ICC v2 required set for a display profile. A reader missing any
// one of these falls back to assuming sRGB, which is the silent
// failure this whole feature exists to prevent.
for space in ColourSpace::ALL {
let p = profile(space);
for required in [
b"desc", b"cprt", b"wtpt", b"rXYZ", b"gXYZ", b"bXYZ", b"rTRC", b"gTRC", b"bTRC",
] {
assert!(tag(&p, required).is_some(), "{space:?} has no {required:?}");
}
}
}
#[test]
fn the_colorants_are_the_ones_the_shader_encoded_with() {
// The property the file's honesty rests on. The composer converts the
// pixels with `to_pcs_xyz`'s primaries; if the profile described any
// others the file would be a precise, confident lie.
for space in ColourSpace::ALL {
let p = profile(space);
let want = space.to_pcs_xyz();
for (i, sig) in [b"rXYZ", b"gXYZ", b"bXYZ"].into_iter().enumerate() {
let got = xyz(tag(&p, sig).expect("colorant"));
for (row, g) in got.iter().enumerate() {
let expected = want[row * 3 + i];
assert!(
(g - expected).abs() < 1e-4,
"{space:?} {sig:?} row {row}: profile says {g}, shader used {expected}"
);
}
}
}
}
#[test]
fn the_white_point_is_the_connection_space_illuminant() {
// Not the space's own white. ProPhoto's is D50 anyway, but P3's is
// D65, and a profile advertising D65 as its media white would have
// every neutral adapted a second time.
for space in ColourSpace::ALL {
let got = xyz(tag(&profile(space), b"wtpt").expect("wtpt"));
for (i, want) in PCS_D50.iter().enumerate() {
assert!((got[i] - want).abs() < 1e-4, "{space:?} white {i}: {got:?}");
}
}
}
#[test]
fn a_tabulated_curve_reproduces_the_transfer_function_it_came_from() {
// Read back out of the profile and compared against the function the
// shader encodes with. The curve runs device-to-linear, and writing it
// the other way round would still produce a monotonic curve of the
// right length — this is what catches the direction.
for space in [ColourSpace::Srgb, ColourSpace::ProPhoto] {
let p = profile(space);
let curve = tag(&p, b"rTRC").expect("rTRC");
let count = u32::from_be_bytes(curve[8..12].try_into().unwrap()) as usize;
assert_eq!(count, TRC_SAMPLES, "{space:?}");
let transfer = space.transfer();
for i in [0, 1, count / 4, count / 2, count - 1] {
let at = 12 + i * 2;
let got =
f32::from(u16::from_be_bytes(curve[at..at + 2].try_into().unwrap())) / 65535.0;
let want = transfer.decode(i as f32 / (count - 1) as f32);
assert!(
(got - want).abs() < 1e-4,
"{space:?} sample {i}: profile {got}, transfer {want}"
);
}
}
}
#[test]
fn adobe_rgb_stores_its_gamma_exactly_rather_than_sampling_it() {
// 563/256 is representable in a u8Fixed8, so the curve is one number.
// A 1024-entry table would approximate a value the format can hold
// exactly, and would round-trip through other software as 2.2.
let p = profile(ColourSpace::AdobeRgb);
let curve = tag(&p, b"rTRC").expect("rTRC");
assert_eq!(u32::from_be_bytes(curve[8..12].try_into().unwrap()), 1);
assert_eq!(u16::from_be_bytes(curve[12..14].try_into().unwrap()), 563);
}
#[test]
fn the_three_tone_curves_share_one_copy() {
// Not a size optimisation for its own sake: it keeps the profile under
// the 64 KB a single JPEG APP2 segment holds, so the chunked form that
// older readers mishandle is never needed.
let p = profile(ColourSpace::Srgb);
let count = u32::from_be_bytes(p[128..132].try_into().unwrap()) as usize;
let offsets: Vec<u32> = ["rTRC", "gTRC", "bTRC"]
.iter()
.map(|sig| {
(0..count)
.map(|i| 132 + i * 12)
.find(|at| &p[*at..at + 4] == sig.as_bytes())
.map(|at| u32::from_be_bytes(p[at + 4..at + 8].try_into().unwrap()))
.expect("curve present")
})
.collect();
assert_eq!(offsets[0], offsets[1]);
assert_eq!(offsets[1], offsets[2]);
assert!(p.len() < 8 * 1024, "{} bytes is too large", p.len());
}
#[test]
fn a_d65_space_declares_its_adaptation_and_a_d50_space_does_not() {
// `chad` is what lets a reader recover the space's native white from
// colorants that have already been adapted. Without it, D65 primaries
// adapted to D50 and genuine D50 primaries are the same nine numbers.
assert!(tag(&profile(ColourSpace::DisplayP3), b"chad").is_some());
assert!(
tag(&profile(ColourSpace::ProPhoto), b"chad").is_none(),
"ProPhoto is a D50 space; an identity chad says nothing"
);
}
#[test]
fn the_same_space_always_produces_the_same_bytes() {
// Two exports of one frame must be identical files. A creation
// timestamp from the clock is the obvious way to lose that.
for space in ColourSpace::ALL {
assert_eq!(profile(space), profile(space), "{space:?}");
}
}
#[test]
fn each_space_is_described_by_its_own_name() {
// A file whose profile says "sRGB" while carrying P3 pixels is exactly
// as misleading as no profile at all, and harder to notice.
for space in ColourSpace::ALL {
let p = profile(space);
let desc = tag(&p, b"desc").expect("desc");
let len = u32::from_be_bytes(desc[8..12].try_into().unwrap()) as usize;
let name = std::str::from_utf8(&desc[12..12 + len - 1]).expect("ascii");
assert_eq!(name, format!("DarkRoom {}", space.label()));
}
}
}
+366
View File
@@ -0,0 +1,366 @@
//! TRACES: FR-EXP-1 | FR-EXP-2 | FR-EXP-3 | FR-EXP-4 | FR-EXP-6 | FR-EXP-9
//! Turning a rendered frame into a file's worth of bytes.
//!
//! # What this crate is, and is not
//!
//! It is: resize, output sharpening, encode, and the name the result should
//! be given. It is not: a filesystem, a network client, or a job queue.
//! [`export`] returns [`Encoded`] — bytes and a filename — and the caller
//! decides where that lands.
//!
//! That boundary is not fastidiousness. An export has three possible
//! destinations and they have nothing in common: a path on Linux, a Storage
//! Access Framework document on Android where there *is* no path
//! (ARCH §6.9), and a `PUT` to a Nextcloud folder. A crate that wrote the
//! file itself would serve one of them and be rewritten for the other two.
//!
//! # Order of operations
//!
//! Resize, then sharpen, then encode. Sharpening after the resize is the
//! whole point of output sharpening (FR-EXP-4): it compensates for the
//! softening the resample introduced, so its strength has to scale with how
//! much scaling actually happened. Sharpening first and then shrinking would
//! throw the sharpened detail away.
use dr_types::{ColourSpace, ExportFormat, ExportSettings};
mod encode;
mod error;
mod exif;
pub mod icc;
mod metadata;
mod name;
mod sharpen;
mod size;
pub use error::ExportError;
pub use metadata::SourceMetadata;
pub use name::{resolve_name, NameContext};
pub use size::target_size;
/// A rendered frame, as the adjust pass produced it.
///
/// 8-bit RGBA, display-encoded in [`Self::space`] — the format
/// [`dr_gpu::AdjustPass`](../dr_gpu/struct.AdjustPass.html) writes. Alpha is
/// carried but never meaningful: the pipeline writes 1.0 everywhere, and no
/// operation produces transparency.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Frame {
pub width: u32,
pub height: u32,
/// Tightly packed RGBA8, `width * height * 4` bytes.
pub rgba: Vec<u8>,
/// TRACES: FR-EXP-2
/// The space the shader encoded these pixels into.
///
/// Travels with the pixels rather than being asserted at the point of
/// encoding, because it is a fact about them and not a preference. The
/// conversion happened in the generated shader, before the clip to 0..1,
/// and nothing downstream can undo or redo it — a frame clipped to sRGB
/// has already lost whatever a wider space would have carried.
///
/// Making it a field is what lets [`export`] refuse to label a frame as
/// something it is not, rather than trusting a caller to have rendered
/// what it asked for.
pub space: ColourSpace,
}
impl Frame {
/// A frame the pipeline rendered in sRGB — what
/// [`EditGraph::compose`](../dr_pipeline/struct.EditGraph.html#method.compose)
/// produces, and so what the display path hands over.
///
/// An export in a wider space must render its own frame with
/// `compose_for` and declare it through [`Self::in_space`]. Defaulting
/// here rather than demanding the space at every call site keeps the
/// common case honest by construction: a caller that has not thought
/// about colour is describing sRGB, and sRGB is what it rendered.
pub fn new(width: u32, height: u32, rgba: Vec<u8>) -> Result<Self, ExportError> {
Self::in_space(width, height, rgba, ColourSpace::Srgb)
}
/// A frame rendered into a stated colour space.
pub fn in_space(
width: u32,
height: u32,
rgba: Vec<u8>,
space: ColourSpace,
) -> Result<Self, ExportError> {
let expected = width as usize * height as usize * 4;
if rgba.len() != expected {
return Err(ExportError::FrameSize {
expected,
got: rgba.len(),
});
}
if width == 0 || height == 0 {
return Err(ExportError::EmptyFrame);
}
Ok(Self {
width,
height,
rgba,
space,
})
}
}
/// The finished article: what to write, and what to call it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Encoded {
/// Filename including extension. Never a path — the destination folder is
/// the caller's, and on Android it is not expressible as one anyway.
pub name: String,
pub bytes: Vec<u8>,
/// What the image was actually written at, after sizing and the upscaling
/// guard. Worth reporting: a batch that silently exported at source size
/// because the request was larger has done something the user should know.
pub width: u32,
pub height: u32,
}
/// Resize, sharpen and encode one frame.
///
/// `name` is the filename already resolved by [`resolve_name`] — passed in
/// rather than derived here because resolving it needs to know what is
/// already in the destination, which this crate cannot see.
///
/// TRACES: FR-EXP-9
/// The frame is expected to be a **full-resolution** render. Nothing here
/// enforces that, because nothing here can tell a full render from a
/// viewport-sized one; the caller renders at the framed output size and this
/// resamples down from it. Exporting from the display proxy would silently
/// produce a soft file, which is why the develop session's export path renders
/// its own frame rather than reusing the one on screen.
///
/// TRACES: FR-EXP-8
/// `source` is what the photograph's own file said about itself, or `None`
/// where the caller has nothing — a frame that came from somewhere other than
/// a decoded file, or a caller that has not yet been taught to pass it.
///
/// **A parameter rather than a field on [`Frame`]**, because it is not a fact
/// about the pixels: two exports of the same frame can legitimately disclose
/// different amounts, and the settings that decide how much travel beside it.
/// It is also why this is an argument and not an `Option` with a default — a
/// caller that has the source metadata should have to decide, in one visible
/// place, to hand it over.
pub fn export(
frame: &Frame,
settings: &ExportSettings,
name: String,
source: Option<&SourceMetadata>,
) -> Result<Encoded, ExportError> {
// TRACES: FR-EXP-2
// Refused rather than mislabelled. Every space the settings page offers
// now works, but only if the *frame* was rendered into it: the conversion
// and the clip both happen in the generated shader, so pixels that arrive
// clipped to sRGB have already lost whatever a wider space would have
// carried, and no amount of profile-writing here brings it back.
//
// The caller's fix is to compose with `EditGraph::compose_for(space)`
// before rendering. Until it does, this is an accurate error where the
// alternative would be a file that claims a gamut it does not contain —
// and that claim survives into everything downstream.
if frame.space != settings.colour_space {
return Err(ExportError::ColourSpaceMismatch {
rendered: frame.space,
requested: settings.colour_space,
});
}
if matches!(settings.format, ExportFormat::Avif | ExportFormat::JpegXl) {
return Err(ExportError::FormatUnsupported(settings.format));
}
let (width, height) = size::target_size(
frame.width,
frame.height,
settings.sizing,
settings.allow_upscaling,
);
let resized = size::resample(frame, width, height);
// Scaled by how much the image actually shrank: a full-size export needs
// no compensation, and a thumbnail needs a great deal.
let scale = width as f32 / frame.width.max(1) as f32;
let sharpened = sharpen::apply(resized, width, height, settings.sharpening, scale);
let bytes = encode::encode(&sharpened, width, height, settings, source)?;
Ok(Encoded {
name,
bytes,
width,
height,
})
}
#[cfg(test)]
mod tests {
use super::*;
use dr_types::SizingMode;
/// A frame with a recognisable gradient, so a resample can be checked for
/// having done something rather than merely returned the right length.
pub(crate) fn frame(w: u32, h: u32) -> Frame {
let mut rgba = Vec::with_capacity((w * h * 4) as usize);
for y in 0..h {
for x in 0..w {
rgba.push((x * 255 / w.max(1)) as u8);
rgba.push((y * 255 / h.max(1)) as u8);
rgba.push(128);
rgba.push(255);
}
}
Frame::new(w, h, rgba).expect("well-formed")
}
fn settings(format: ExportFormat) -> ExportSettings {
ExportSettings {
format,
..Default::default()
}
}
#[test]
fn a_frame_rejects_a_buffer_of_the_wrong_length() {
// The one error that would otherwise surface as a panic deep in an
// encoder, or worse, as a file of garbage.
assert!(matches!(
Frame::new(4, 4, vec![0; 10]),
Err(ExportError::FrameSize { .. })
));
}
#[test]
fn jpeg_export_produces_a_jpeg() {
let out = export(
&frame(64, 48),
&settings(ExportFormat::Jpeg),
"a.jpg".into(),
None,
)
.unwrap();
// SOI marker. Cheap, and it catches an encoder wired to the wrong
// format far more directly than a byte count would.
assert_eq!(&out.bytes[..2], &[0xFF, 0xD8]);
assert_eq!((out.width, out.height), (64, 48));
}
#[test]
fn png_export_produces_a_png() {
let out = export(&frame(32, 32), &settings(ExportFormat::Png), "a.png".into(), None).unwrap();
assert_eq!(&out.bytes[..8], b"\x89PNG\r\n\x1a\n");
}
#[test]
fn tiff_exports_produce_a_tiff() {
for format in [ExportFormat::Tiff8, ExportFormat::Tiff16] {
let out = export(&frame(16, 16), &settings(format), "a.tif".into(), None).unwrap();
// Either byte order is a valid TIFF; the crate writes little-endian.
assert!(
out.bytes.starts_with(b"II*\0") || out.bytes.starts_with(b"MM\0*"),
"{format:?} did not produce a TIFF header"
);
}
}
#[test]
fn a_sixteen_bit_tiff_is_larger_than_an_eight_bit_one() {
// Both are uncompressed RGB; the only difference is the sample width,
// so this is what proves the 16-bit path is not quietly writing 8.
let eight = export(&frame(16, 16), &settings(ExportFormat::Tiff8), "a".into(), None).unwrap();
let sixteen = export(&frame(16, 16), &settings(ExportFormat::Tiff16), "a".into(), None).unwrap();
assert!(sixteen.bytes.len() > eight.bytes.len());
}
#[test]
fn quality_changes_the_size_of_a_jpeg() {
// The setting is plumbed all the way to the encoder rather than
// accepted and dropped, which a size-independent output would show.
let mut low = settings(ExportFormat::Jpeg);
low.quality = 20;
let mut high = settings(ExportFormat::Jpeg);
high.quality = 98;
let small = export(&frame(128, 128), &low, "a".into(), None).unwrap();
let large = export(&frame(128, 128), &high, "a".into(), None).unwrap();
assert!(
large.bytes.len() > small.bytes.len(),
"quality 98 produced {} bytes against quality 20's {}",
large.bytes.len(),
small.bytes.len()
);
}
#[test]
fn a_long_edge_export_lands_on_the_requested_size() {
let mut s = settings(ExportFormat::Png);
s.sizing = SizingMode::LongEdge(32);
let out = export(&frame(128, 64), &s, "a".into(), None).unwrap();
assert_eq!((out.width, out.height), (32, 16));
}
#[test]
fn a_frame_rendered_in_one_space_is_not_labelled_another() {
// A file tagged Display P3 carrying sRGB-clipped pixels is a lie that
// survives into everything downstream. The frame carries the space it
// was rendered in precisely so this cannot be waved through.
let mut s = settings(ExportFormat::Jpeg);
s.colour_space = ColourSpace::DisplayP3;
assert!(matches!(
export(&frame(8, 8), &s, "a".into(), None),
Err(ExportError::ColourSpaceMismatch { .. })
));
}
#[test]
fn every_colour_space_exports_when_the_frame_was_rendered_in_it() {
// The other side of the refusal above, and what FR-EXP-2 actually
// asks for: a frame the pipeline encoded into a wide space reaches a
// file, in every format that has an encoder.
for space in ColourSpace::ALL {
for format in [
ExportFormat::Jpeg,
ExportFormat::Png,
ExportFormat::Tiff8,
ExportFormat::Tiff16,
] {
let mut s = settings(format);
s.colour_space = space;
let mut f = frame(8, 8);
f.space = space;
let out = export(&f, &s, "a".into(), None)
.unwrap_or_else(|e| panic!("{space:?} as {format:?}: {e}"));
assert!(!out.bytes.is_empty());
}
}
}
#[test]
fn the_formats_without_an_encoder_say_so() {
for format in [ExportFormat::Avif, ExportFormat::JpegXl] {
assert!(
matches!(
export(&frame(8, 8), &settings(format), "a".into(), None),
Err(ExportError::FormatUnsupported(_))
),
"{format:?} should report that it has no encoder yet"
);
}
}
#[test]
fn every_offered_format_either_encodes_or_explains_itself() {
// Walks `ExportFormat::ALL`, so a format added to the settings page
// cannot quietly reach an encoder that does not handle it.
for format in ExportFormat::ALL {
match export(&frame(8, 8), &settings(format), "a".into(), None) {
Ok(out) => assert!(!out.bytes.is_empty(), "{format:?} encoded to nothing"),
Err(ExportError::FormatUnsupported(f)) => assert_eq!(f, format),
Err(e) => panic!("{format:?} failed unexpectedly: {e}"),
}
}
}
}
+106
View File
@@ -0,0 +1,106 @@
//! TRACES: FR-EXP-8
//! What an export is allowed to say about where it came from.
//!
//! # An allowlist, not a filter
//!
//! [`SourceMetadata`] is the whole of what can reach a file this crate writes.
//! It is populated field by field from whatever the caller decoded, and
//! nothing else travels — not because each unwanted tag is removed, but
//! because there is nowhere in this type for one to sit. That is the
//! difference between "we strip GPS" and "GPS cannot be written unless
//! [`SourceMetadata::location`] is `Some`", and only the second survives
//! somebody adding a field to the decoder next year.
//!
//! # What is deliberately not here
//!
//! **The maker note** (EXIF `0x927C`). It is an opaque vendor blob with no
//! public format, and its contents differ by body and firmware. Canon's
//! carries the body serial number and the shutter count; several bodies put a
//! *duplicate copy of the GPS fix* inside it, which is the specific reason it
//! cannot be passed through as an unexamined byte range: an export that
//! stripped the GPS directory and copied the maker note would have published
//! the coordinates anyway, while reporting itself as private. Parsing it per
//! vendor to decide what is safe is a research project with a permanent
//! maintenance cost, and the value on the other side is a few tags a
//! photographer rarely misses. So it is dropped, in both directions, whatever
//! the settings say.
//!
//! **Serial numbers and owner name** (`BodySerialNumber` 0xA431,
//! `LensSerialNumber` 0xA435, `CameraOwnerName` 0xA430). These identify a
//! person and a specific piece of equipment, and a serial number in a
//! published file links every photograph that person has ever posted. They
//! have no field here, so no export writes them.
//!
//! **IPTC and XMP.** FR-EXP-8 names both. Neither is read by `dr-decode`
//! today, so there is nothing to carry through; when there is, it arrives as
//! fields on this type and is written from them, and the same allowlist
//! reasoning applies unchanged.
use dr_types::Location;
/// TRACES: FR-EXP-8
/// The source metadata an export may carry.
///
/// Every field is optional because every field is genuinely absent from some
/// real file: scanner output has no aperture, a JPEG from a phone has no lens
/// model, and most photographs have no copyright statement at all.
///
/// Built by the caller, which is the only place that has both the decoded
/// source and the crate that decoded it — `dr-export` deliberately depends on
/// no decoder (see the crate docs), so the copy is made one field at a time
/// where both types are in scope. That transcription is a feature: it is the
/// point where somebody has to decide, in writing, that a newly-parsed piece
/// of the source is allowed to leave the machine.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct SourceMetadata {
pub make: Option<String>,
pub model: Option<String>,
pub lens: Option<String>,
/// Exposure time in seconds.
pub shutter: Option<f32>,
/// The f-number, as in f/2.8.
pub aperture: Option<f32>,
pub iso: Option<u32>,
/// Millimetres, as marked on the lens rather than 35 mm equivalent.
pub focal_length: Option<f32>,
/// When the shutter fired, as Unix seconds read as a wall clock.
pub captured_at: Option<i64>,
/// Minutes east of UTC, where the camera recorded a zone.
pub captured_offset: Option<i32>,
/// Who made the photograph.
pub artist: Option<String>,
/// The rights statement.
pub copyright: Option<String>,
/// TRACES: FR-EXP-8
/// Where the shutter fired.
///
/// The one field the strip option is about. It is carried this far so that
/// a photographer who *wants* their coordinates can have them; by the time
/// the encoder sees the record this field has already been through
/// [`Self::sanitised`], and is `None` unless the user turned stripping
/// off.
pub location: Option<Location>,
}
impl SourceMetadata {
/// This record as the settings permit it to be written.
///
/// **The single place stripping happens.** The encoders below take a
/// record and write what is in it, with no view on privacy; concentrating
/// the decision here means there is one function to read to know what an
/// export can disclose, and no format can quietly disagree with the
/// others — the failure mode where JPEG honours the setting and TIFF, five
/// hundred lines away, does not.
///
/// Stripping empties the field rather than blanking it. A `GPSLatitude` of
/// `0/0` still announces that the camera had a fix and that this file has
/// been through a scrubber; an absent directory says nothing at all, and
/// says it in the same shape as the millions of files that never had one.
pub(crate) fn sanitised(&self, strip_location: bool) -> Self {
let mut out = self.clone();
if strip_location {
out.location = None;
}
out
}
}
+316
View File
@@ -0,0 +1,316 @@
//! TRACES: FR-EXP-6
//! Filename templates and what to do when the name is taken.
//!
//! # Why the caller supplies the "does this exist" test
//!
//! [`resolve_name`] takes a closure rather than looking at a directory,
//! because there is no directory it could look at that would work everywhere.
//! A destination is a path on Linux, a Storage Access Framework tree on
//! Android with no path at all (ARCH §6.9), or a folder on a Nextcloud
//! server reached by PROPFIND. All three can answer "is this name taken",
//! and none of them can be asked the same way.
//!
//! It matters most on Android, where the platform actively works against us:
//! `DocumentsContract.createDocument` renames on collision *by itself*,
//! appending ` (1)` and returning a URI with a name nobody asked for, and it
//! cannot overwrite at all. So every one of the three [`CollisionPolicy`]
//! settings requires knowing the answer before creating anything — which is
//! exactly what this function is shaped for.
use dr_types::{CollisionPolicy, ExportFormat};
/// What a template can refer to.
#[derive(Debug, Clone, Default)]
pub struct NameContext<'a> {
/// The source image's name, without extension — `{name}`.
pub source_stem: &'a str,
/// Position in the batch, 1-based — `{seq}`.
pub sequence: u32,
/// Capture date as `YYYY-MM-DD` — `{date}`. Empty where unknown.
pub date: &'a str,
/// The export's pixel dimensions — `{dimensions}`.
pub width: u32,
pub height: u32,
/// The preset that produced this export — `{preset}`. Empty where none.
pub preset: &'a str,
}
/// Expand a template into a filename stem.
///
/// Unknown tokens are left verbatim rather than dropped. A user who typed
/// `{nmae}` should see it in the output and understand what happened; a
/// silently empty filename is a puzzle, and a template that quietly loses a
/// token produces a directory of files named the same thing.
pub fn expand(template: &str, ctx: &NameContext<'_>) -> String {
let seq = ctx.sequence.to_string();
let dimensions = format!("{}x{}", ctx.width, ctx.height);
let mut out = String::with_capacity(template.len() + 16);
let mut rest = template;
while let Some(open) = rest.find('{') {
out.push_str(&rest[..open]);
let Some(close) = rest[open..].find('}') else {
// An unclosed brace is literal text; there is nothing to expand
// and dropping the remainder would truncate the name. Consumed
// here rather than left for the tail append below, which has
// already had everything before the brace taken from it.
out.push_str(&rest[open..]);
rest = "";
break;
};
let token = &rest[open + 1..open + close];
match token {
"name" => out.push_str(ctx.source_stem),
"seq" => out.push_str(&seq),
"date" => out.push_str(ctx.date),
"dimensions" => out.push_str(&dimensions),
"preset" => out.push_str(ctx.preset),
_ => out.push_str(&rest[open..open + close + 1]),
}
rest = &rest[open + close + 1..];
}
out.push_str(rest);
let cleaned = sanitise(&out);
if cleaned.is_empty() {
// Every token was empty — a template of `{preset}` with no preset, on
// an image with no date. Falling back to the source name is the one
// answer that is always available and never collides more than the
// source files themselves do.
return sanitise(ctx.source_stem);
}
cleaned
}
/// Strip what no filesystem, SAF provider or WebDAV server will take.
///
/// The intersection of three sets of rules rather than any one of them: an
/// export written to a Nextcloud folder may later sync down to a Windows
/// client, and a name that was legal where it was created is not much comfort
/// on the machine that cannot open it.
fn sanitise(stem: &str) -> String {
let mut out: String = stem
.chars()
.map(|c| match c {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-',
c if (c as u32) < 0x20 => '-',
c => c,
})
.collect();
// Trailing dots and spaces are legal on Linux and rejected by Windows,
// and a name ending in one is almost always an accident of a template
// whose last token expanded to nothing.
while out.ends_with('.') || out.ends_with(' ') {
out.pop();
}
out.trim_start().to_string()
}
/// The filename this export should be written under, honouring the collision
/// policy.
///
/// `taken` answers whether a name already exists in the destination. Returns
/// `None` for [`CollisionPolicy::Skip`] when the name is in use — the caller
/// writes nothing and moves on, which is the whole point of that setting.
pub fn resolve_name(
template: &str,
ctx: &NameContext<'_>,
format: ExportFormat,
collision: CollisionPolicy,
taken: &dyn Fn(&str) -> bool,
) -> Option<String> {
let stem = expand(template, ctx);
let ext = format.extension();
let first = format!("{stem}.{ext}");
if !taken(&first) {
return Some(first);
}
match collision {
CollisionPolicy::Overwrite => Some(first),
CollisionPolicy::Skip => None,
CollisionPolicy::Increment => {
// Bounded. An unbounded search would spin forever against a
// destination that reports everything as taken — a permission
// error misread as existence, say — and a batch that hangs is
// worse than one that reports a failure.
for n in 1..10_000 {
let candidate = format!("{stem}-{n}.{ext}");
if !taken(&candidate) {
return Some(candidate);
}
}
log::warn!("{stem}: ten thousand names taken; skipping");
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx() -> NameContext<'static> {
NameContext {
source_stem: "IMG_1234",
sequence: 7,
date: "2026-08-16",
width: 2048,
height: 1365,
preset: "Web",
}
}
fn free(_: &str) -> bool {
false
}
#[test]
fn the_default_template_is_the_source_name() {
assert_eq!(expand("{name}", &ctx()), "IMG_1234");
}
#[test]
fn every_documented_token_expands() {
// The settings page advertises these five in its hint; a token listed
// there and unhandled here would reach the filename verbatim.
assert_eq!(expand("{name}", &ctx()), "IMG_1234");
assert_eq!(expand("{seq}", &ctx()), "7");
assert_eq!(expand("{date}", &ctx()), "2026-08-16");
assert_eq!(expand("{dimensions}", &ctx()), "2048x1365");
assert_eq!(expand("{preset}", &ctx()), "Web");
}
#[test]
fn tokens_combine_with_literal_text() {
assert_eq!(
expand("{date}_{name}_{dimensions}", &ctx()),
"2026-08-16_IMG_1234_2048x1365"
);
}
#[test]
fn an_unknown_token_survives_verbatim() {
// A typo the user can see and fix, rather than a name that silently
// lost a component and now collides with every other export.
assert_eq!(expand("{nmae}-x", &ctx()), "{nmae}-x");
}
#[test]
fn an_unclosed_brace_is_literal_text() {
assert_eq!(expand("{name", &ctx()), "{name");
assert_eq!(expand("a{name}b{", &ctx()), "aIMG_1234b{");
}
#[test]
fn a_template_that_expands_to_nothing_falls_back_to_the_source_name() {
// `{preset}` with no preset selected. An empty filename is not a file.
let mut c = ctx();
c.preset = "";
assert_eq!(expand("{preset}", &c), "IMG_1234");
}
#[test]
fn path_separators_cannot_escape_the_destination() {
// `{name}` comes from a source filename, and a template is user text.
// Either could carry a slash, and an export must not write outside
// the folder that was chosen — nor create a subfolder on the server.
let mut c = ctx();
c.source_stem = "holiday/2026";
assert_eq!(expand("{name}", &c), "holiday-2026");
assert_eq!(expand("../../etc/passwd", &ctx()), "..-..-etc-passwd");
}
#[test]
fn characters_windows_rejects_are_replaced() {
// An export may sync down to a Windows client through Nextcloud, and
// a name that was legal where it was written is no comfort there.
assert_eq!(expand(r#"a:b*c?d"e<f>g|h\i"#, &ctx()), "a-b-c-d-e-f-g-h-i");
}
#[test]
fn trailing_dots_and_spaces_are_trimmed() {
let mut c = ctx();
c.preset = "";
assert_eq!(expand("{name}.{preset}", &c), "IMG_1234");
assert_eq!(expand("{name} ", &ctx()), "IMG_1234");
}
#[test]
fn a_free_name_is_used_as_is() {
let got = resolve_name(
"{name}",
&ctx(),
ExportFormat::Jpeg,
CollisionPolicy::Increment,
&free,
);
assert_eq!(got.as_deref(), Some("IMG_1234.jpg"));
}
#[test]
fn the_extension_follows_the_format() {
for (format, ext) in [
(ExportFormat::Jpeg, "jpg"),
(ExportFormat::Png, "png"),
(ExportFormat::Tiff16, "tif"),
] {
let got = resolve_name("{name}", &ctx(), format, CollisionPolicy::Skip, &free);
assert_eq!(got.as_deref(), Some(&*format!("IMG_1234.{ext}")));
}
}
#[test]
fn increment_finds_the_first_free_suffix() {
let taken = |n: &str| matches!(n, "IMG_1234.jpg" | "IMG_1234-1.jpg" | "IMG_1234-2.jpg");
let got = resolve_name(
"{name}",
&ctx(),
ExportFormat::Jpeg,
CollisionPolicy::Increment,
&taken,
);
assert_eq!(got.as_deref(), Some("IMG_1234-3.jpg"));
}
#[test]
fn skip_returns_nothing_when_the_name_is_taken() {
// The caller writes no file at all — that is what Skip means, and it
// is why this returns an Option rather than always a name.
let got = resolve_name(
"{name}",
&ctx(),
ExportFormat::Jpeg,
CollisionPolicy::Skip,
&|_| true,
);
assert_eq!(got, None);
}
#[test]
fn overwrite_returns_the_taken_name() {
let got = resolve_name(
"{name}",
&ctx(),
ExportFormat::Jpeg,
CollisionPolicy::Overwrite,
&|_| true,
);
assert_eq!(got.as_deref(), Some("IMG_1234.jpg"));
}
#[test]
fn increment_gives_up_rather_than_spinning_forever() {
// A destination that reports every name as taken — a permission error
// misread as existence — must not hang the batch.
let got = resolve_name(
"{name}",
&ctx(),
ExportFormat::Jpeg,
CollisionPolicy::Increment,
&|_| true,
);
assert_eq!(got, None);
}
}
+197
View File
@@ -0,0 +1,197 @@
//! TRACES: FR-EXP-4
//! Output sharpening, scaled by how far the image was resized.
//!
//! # Why an export needs this at all
//!
//! Downsampling averages neighbouring pixels, and averaging is a low-pass
//! filter: a 24 MP frame reduced to 2048px comes out measurably softer than
//! the same scene shot at 2048px would be. Output sharpening puts back the
//! acuity the resample removed. It is not creative sharpening — that belongs
//! in the develop pipeline, acts on the full-resolution image, and is a
//! different control entirely.
//!
//! # Why the strength depends on the medium
//!
//! The three settings are not intensities dressed up as names. A screen shows
//! a pixel as a pixel, so it needs the least. Ink spreads into paper — dot
//! gain — and matte stock spreads it further than glossy, so a print needs
//! more compensation to arrive looking the same. That is why the paper
//! options are stronger, and why "more" is not simply a slider.
use dr_types::OutputSharpening;
/// Radius of the unsharp mask, in pixels.
///
/// Fixed at a small value rather than scaled with the image: output
/// sharpening compensates for the *resample*, which softens over a pixel or
/// two whatever the size of the frame. A radius that grew with the image
/// would produce haloes on a large export.
const RADIUS: i32 = 1;
/// Per-setting strength. Applied on top of the resize-derived scaling below.
fn strength(setting: OutputSharpening) -> f32 {
match setting {
OutputSharpening::None => 0.0,
OutputSharpening::Screen => 0.55,
// Ink spread. Matte stock absorbs more than glossy, so it needs the
// heavier hand of the two.
OutputSharpening::GlossyPaper => 0.85,
OutputSharpening::MattePaper => 1.15,
}
}
/// Sharpen in place-ish: takes the resized buffer and returns it, sharpened.
///
/// `scale` is the resize factor — destination width over source width. Below
/// 1 the image was reduced and needs compensation; at or above 1 nothing was
/// averaged away and the sharpening is skipped, because sharpening an image
/// that was not softened only adds haloes.
pub fn apply(
mut rgba: Vec<u8>,
width: u32,
height: u32,
setting: OutputSharpening,
scale: f32,
) -> Vec<u8> {
let base = strength(setting);
if base == 0.0 || scale >= 1.0 || width < 3 || height < 3 {
return rgba;
}
// A frame reduced to a tenth lost far more than one reduced to nine
// tenths, so the compensation follows the reduction. Capped at the base
// strength: past a point more sharpening is just edge artefacts, and a
// thumbnail is the case where that shows most.
let amount = base * (1.0 - scale).clamp(0.0, 1.0);
let src = rgba.clone();
let (w, h) = (width as i32, height as i32);
for y in 0..h {
for x in 0..w {
for c in 0..3 {
// A 3×3 box blur is the mask. Gaussian would be more correct
// and, at radius 1, indistinguishable — the kernel is nine
// pixels either way.
let mut sum = 0.0f32;
let mut n = 0.0f32;
for dy in -RADIUS..=RADIUS {
for dx in -RADIUS..=RADIUS {
let sx = (x + dx).clamp(0, w - 1);
let sy = (y + dy).clamp(0, h - 1);
sum += f32::from(src[((sy * w + sx) * 4 + c) as usize]);
n += 1.0;
}
}
let blurred = sum / n;
let p = ((y * w + x) * 4 + c) as usize;
let original = f32::from(src[p]);
// Unsharp mask: the original plus its difference from a
// blurred copy, which is the high-frequency detail.
let sharpened = original + (original - blurred) * amount;
rgba[p] = sharpened.round().clamp(0.0, 255.0) as u8;
}
}
}
rgba
}
#[cfg(test)]
mod tests {
use super::*;
/// A frame split down the middle: dark left, light right. One vertical
/// edge, which is what sharpening acts on.
fn edge(w: u32, h: u32) -> Vec<u8> {
let mut v = Vec::new();
for _ in 0..h {
for x in 0..w {
let level = if x < w / 2 { 60 } else { 190 };
v.extend_from_slice(&[level, level, level, 255]);
}
}
v
}
fn at(buf: &[u8], w: u32, x: u32, y: u32) -> u8 {
buf[((y * w + x) * 4) as usize]
}
#[test]
fn none_leaves_the_image_exactly_as_it_was() {
let src = edge(16, 8);
let out = apply(src.clone(), 16, 8, OutputSharpening::None, 0.5);
assert_eq!(out, src);
}
#[test]
fn an_unresized_export_is_not_sharpened() {
// Nothing was averaged away, so there is nothing to compensate for
// and sharpening would only add haloes.
let src = edge(16, 8);
assert_eq!(
apply(src.clone(), 16, 8, OutputSharpening::MattePaper, 1.0),
src
);
}
#[test]
fn sharpening_increases_contrast_across_an_edge() {
// The property, stated directly: the dark side of the edge gets
// darker and the light side lighter.
let src = edge(16, 8);
let out = apply(src.clone(), 16, 8, OutputSharpening::Screen, 0.4);
let (before_dark, before_light) = (at(&src, 16, 7, 4), at(&src, 16, 8, 4));
let (after_dark, after_light) = (at(&out, 16, 7, 4), at(&out, 16, 8, 4));
assert!(after_dark < before_dark, "the dark side should deepen");
assert!(after_light > before_light, "the light side should lift");
}
#[test]
fn paper_sharpens_harder_than_screen() {
// Ink spreads; the settings are about the medium, not taste.
let src = edge(16, 8);
let screen = apply(src.clone(), 16, 8, OutputSharpening::Screen, 0.4);
let matte = apply(src.clone(), 16, 8, OutputSharpening::MattePaper, 0.4);
assert!(at(&matte, 16, 8, 4) > at(&screen, 16, 8, 4));
assert!(strength(OutputSharpening::MattePaper) > strength(OutputSharpening::GlossyPaper));
}
#[test]
fn a_bigger_reduction_sharpens_more() {
let src = edge(16, 8);
let mild = apply(src.clone(), 16, 8, OutputSharpening::Screen, 0.9);
let severe = apply(src.clone(), 16, 8, OutputSharpening::Screen, 0.1);
assert!(at(&severe, 16, 8, 4) >= at(&mild, 16, 8, 4));
}
#[test]
fn a_flat_field_is_untouched() {
// No detail means no high frequencies to amplify. If this drifts, the
// mask is not centred and every sky gains a gradient.
let flat = vec![128u8; 16 * 16 * 4];
assert_eq!(
apply(flat.clone(), 16, 16, OutputSharpening::MattePaper, 0.3),
flat
);
}
#[test]
fn alpha_is_never_touched() {
// The loop runs over three channels for a reason: sharpening alpha
// would put a halo in the transparency of an image that has none.
let out = apply(edge(16, 8), 16, 8, OutputSharpening::MattePaper, 0.2);
for px in out.chunks_exact(4) {
assert_eq!(px[3], 255);
}
}
#[test]
fn a_frame_too_small_to_have_neighbours_is_left_alone() {
let tiny = vec![10u8; 2 * 2 * 4];
assert_eq!(
apply(tiny.clone(), 2, 2, OutputSharpening::Screen, 0.5),
tiny
);
}
}
+324
View File
@@ -0,0 +1,324 @@
//! TRACES: FR-EXP-3 | FR-EXP-4
//! Output sizing and resampling.
//!
//! # Why Lanczos
//!
//! FR-EXP-4 asks for "a quality resampler (Lanczos or better)", and the
//! reason is what a cheap one does to a photograph. Box or bilinear
//! downsampling of a 24 MP frame to 2048px averages away detail the sensor
//! resolved and aliases what is left — a brick wall or a distant fence comes
//! back as moiré. Lanczos's negative lobes preserve edge acuity through a
//! large reduction, which is exactly the operation an export performs.
//!
//! Separable: a horizontal pass then a vertical one, which turns an `a²`
//! kernel into `2a` taps per pixel. At the sizes involved that is the
//! difference between an export that feels instant and one that does not.
use dr_types::SizingMode;
use crate::Frame;
/// The Lanczos window. 3 is the photographic default — 2 is softer, and
/// beyond 3 the extra lobes buy ringing rather than detail.
const A: f32 = 3.0;
/// TRACES: FR-EXP-3
/// Resolve the requested sizing against a source, honouring the upscale rule.
///
/// Aspect is preserved in every mode, so only one dimension is ever the
/// requested one.
///
/// **Upscaling is refused by clamping, never by failing.** FR-EXP-3 makes
/// upscaling opt-in, and a batch of mixed frames must not abort because one
/// was smaller than the target — the user asked for a set of exports, and
/// stopping the run over a frame that came out at source size would be a
/// worse answer than the file itself.
pub fn target_size(
src_w: u32,
src_h: u32,
sizing: SizingMode,
allow_upscaling: bool,
) -> (u32, u32) {
let (src_w, src_h) = (src_w.max(1), src_h.max(1));
let (w, h) = match sizing {
SizingMode::Original => (src_w, src_h),
SizingMode::LongEdge(n) => scale_to(src_w, src_h, n, src_w >= src_h),
SizingMode::ShortEdge(n) => scale_to(src_w, src_h, n, src_w < src_h),
SizingMode::Percentage(p) => {
let f = f64::from(p) / 100.0;
(
((f64::from(src_w) * f).round() as u32).max(1),
((f64::from(src_h) * f).round() as u32).max(1),
)
}
};
if !allow_upscaling && (w > src_w || h > src_h) {
return (src_w, src_h);
}
(w.max(1), h.max(1))
}
/// Scale so that the chosen edge lands on `n`.
fn scale_to(src_w: u32, src_h: u32, n: u32, width_is_the_edge: bool) -> (u32, u32) {
let n = n.max(1);
if width_is_the_edge {
let h = (f64::from(n) * f64::from(src_h) / f64::from(src_w)).round() as u32;
(n, h.max(1))
} else {
let w = (f64::from(n) * f64::from(src_w) / f64::from(src_h)).round() as u32;
(w.max(1), n)
}
}
/// Resample to `(dst_w, dst_h)`, returning tightly packed RGBA8.
///
/// Returns the source buffer untouched where no scaling is needed, which is
/// the `SizingMode::Original` case and therefore the common one.
pub fn resample(frame: &Frame, dst_w: u32, dst_h: u32) -> Vec<u8> {
if dst_w == frame.width && dst_h == frame.height {
return frame.rgba.clone();
}
// Horizontal, then vertical. The intermediate is the destination width by
// the *source* height, so the second pass works on as little data as the
// first can leave it.
let horizontal = pass(
&frame.rgba,
frame.width,
frame.height,
dst_w,
frame.height,
true,
);
pass(&horizontal, dst_w, frame.height, dst_w, dst_h, false)
}
/// One separable pass. `horizontal` picks the axis being resampled.
fn pass(src: &[u8], src_w: u32, src_h: u32, dst_w: u32, dst_h: u32, horizontal: bool) -> Vec<u8> {
let (src_len, dst_len) = if horizontal {
(src_w, dst_w)
} else {
(src_h, dst_h)
};
let ratio = f64::from(src_len) / f64::from(dst_len);
// Enlarging samples the source at its own frequency; shrinking has to
// widen the kernel to average the pixels being discarded, or the result
// aliases. This is the whole difference between a resample and a
// subsample.
let filter_scale = ratio.max(1.0);
let support = A as f64 * filter_scale;
let mut out = vec![0u8; (dst_w * dst_h * 4) as usize];
for i in 0..dst_len {
// Centre of the destination sample, in source coordinates.
let centre = (f64::from(i) + 0.5) * ratio - 0.5;
let first = ((centre - support).ceil() as i64).max(0);
let last = ((centre + support).floor() as i64).min(i64::from(src_len) - 1);
// Weights once per output row/column rather than per pixel: they
// depend only on the axis position, and recomputing them per channel
// was most of the cost when this was written the obvious way.
let mut weights = Vec::with_capacity((last - first + 1).max(0) as usize);
let mut total = 0.0f64;
for s in first..=last {
let w = lanczos((f64::from(s as i32) - centre) / filter_scale);
weights.push(w);
total += w;
}
if total == 0.0 {
total = 1.0;
}
let other = if horizontal { dst_h } else { dst_w };
for j in 0..other {
let mut acc = [0.0f64; 4];
for (k, w) in weights.iter().enumerate() {
let s = first as u32 + k as u32;
let (x, y) = if horizontal { (s, j) } else { (j, s) };
let p = ((y * src_w + x) * 4) as usize;
for c in 0..4 {
acc[c] += f64::from(src[p + c]) * w;
}
}
let (x, y) = if horizontal { (i, j) } else { (j, i) };
let p = ((y * dst_w + x) * 4) as usize;
for c in 0..4 {
// Lanczos overshoots at edges — that is what makes it look
// sharp — so the result must be clamped rather than wrapped.
out[p + c] = (acc[c] / total).round().clamp(0.0, 255.0) as u8;
}
}
}
out
}
/// The Lanczos kernel, `sinc(x) * sinc(x / a)`.
fn lanczos(x: f64) -> f64 {
let x = x.abs();
if x < 1e-9 {
return 1.0;
}
if x >= f64::from(A) {
return 0.0;
}
let px = std::f64::consts::PI * x;
(px.sin() / px) * ((px / f64::from(A)).sin() / (px / f64::from(A)))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tests::frame;
#[test]
fn original_is_the_source_size() {
assert_eq!(
target_size(6000, 4000, SizingMode::Original, false),
(6000, 4000)
);
}
#[test]
fn long_edge_picks_the_longer_dimension_either_way_round() {
assert_eq!(
target_size(6000, 4000, SizingMode::LongEdge(3000), false),
(3000, 2000)
);
// Portrait: the long edge is now the height.
assert_eq!(
target_size(4000, 6000, SizingMode::LongEdge(3000), false),
(2000, 3000)
);
}
#[test]
fn short_edge_picks_the_shorter_dimension_either_way_round() {
assert_eq!(
target_size(6000, 4000, SizingMode::ShortEdge(2000), false),
(3000, 2000)
);
assert_eq!(
target_size(4000, 6000, SizingMode::ShortEdge(2000), false),
(2000, 3000)
);
}
#[test]
fn a_percentage_scales_both_dimensions() {
assert_eq!(
target_size(4000, 3000, SizingMode::Percentage(50), false),
(2000, 1500)
);
assert_eq!(
target_size(4000, 3000, SizingMode::Percentage(100), false),
(4000, 3000)
);
}
#[test]
fn upscaling_is_refused_by_clamping_rather_than_failing() {
// FR-EXP-3: opt-in, and a batch must not abort over one small frame.
assert_eq!(
target_size(800, 600, SizingMode::LongEdge(4000), false),
(800, 600)
);
assert_eq!(
target_size(800, 600, SizingMode::Percentage(400), false),
(800, 600)
);
}
#[test]
fn upscaling_is_honoured_when_asked_for() {
assert_eq!(
target_size(800, 600, SizingMode::LongEdge(1600), true),
(1600, 1200)
);
}
#[test]
fn a_square_frame_treats_either_edge_as_the_long_one() {
// The tie has to resolve somewhere, and both answers are the same
// size — but it must not produce a zero or a panic.
assert_eq!(
target_size(1000, 1000, SizingMode::LongEdge(500), false),
(500, 500)
);
assert_eq!(
target_size(1000, 1000, SizingMode::ShortEdge(500), false),
(500, 500)
);
}
#[test]
fn a_size_can_never_round_down_to_nothing() {
// A 1% export of a small frame rounds toward zero, and a zero-pixel
// image is not a file anyone can open.
let (w, h) = target_size(50, 30, SizingMode::Percentage(1), false);
assert!(w >= 1 && h >= 1, "got {w}x{h}");
}
#[test]
fn resampling_to_the_same_size_changes_nothing() {
// The `Original` path, which is the common one — it must not spend a
// Lanczos pass to return what it was given.
let f = frame(32, 24);
assert_eq!(resample(&f, 32, 24), f.rgba);
}
#[test]
fn a_resample_produces_the_right_number_of_pixels() {
let f = frame(64, 48);
assert_eq!(resample(&f, 32, 24).len(), 32 * 24 * 4);
assert_eq!(resample(&f, 100, 75).len(), 100 * 75 * 4);
}
#[test]
fn a_downscale_preserves_the_gradient_it_was_given() {
// The check that separates a real resample from a buffer of the right
// length: the test frame ramps red left-to-right, so the output must
// too, and its corners must still be near the source's.
let f = frame(128, 128);
let small = resample(&f, 32, 32);
let px = |x: usize, y: usize| small[(y * 32 + x) * 4];
assert!(px(0, 0) < px(16, 0), "red should rise across the frame");
assert!(px(16, 0) < px(31, 0));
// Row-invariant in red, since the ramp is horizontal.
assert!((i32::from(px(16, 0)) - i32::from(px(16, 31))).abs() < 8);
}
#[test]
fn a_flat_field_survives_a_resample_unchanged() {
// Lanczos rings on edges, which is intended — but a constant field
// has no edges, and any deviation here means the weights do not sum
// to one. That error is invisible on a photograph and glaring on a
// sky.
let flat = Frame::new(64, 64, vec![200; 64 * 64 * 4]).unwrap();
for byte in resample(&flat, 21, 21) {
assert_eq!(byte, 200, "a constant field must resample to itself");
}
}
#[test]
fn an_upscale_also_holds_a_flat_field() {
let flat = Frame::new(16, 16, vec![64; 16 * 16 * 4]).unwrap();
for byte in resample(&flat, 40, 40) {
assert_eq!(byte, 64);
}
}
#[test]
fn the_kernel_is_one_at_the_centre_and_zero_past_its_window() {
assert!((lanczos(0.0) - 1.0).abs() < 1e-9);
assert_eq!(lanczos(3.0), 0.0);
assert_eq!(lanczos(4.5), 0.0);
// Zero at the integers inside the window, which is what makes an
// unscaled resample an identity.
assert!(lanczos(1.0).abs() < 1e-9);
assert!(lanczos(2.0).abs() < 1e-9);
}
}
+73
View File
@@ -0,0 +1,73 @@
[package]
name = "dr-gpu"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
dr-types.workspace = true
dr-decode.workspace = true
dr-pipeline.workspace = true
# The watershed's pixel passes are here because they are shaders; everything
# that reasons about regions rather than pixels lives there, where it is
# testable with no adapter present. No features: this half needs neither the
# inference runtime nor the weights, and the workspace declaration defaults
# them off so that stays true.
dr-segment.workspace = true
wgpu.workspace = true
thiserror.workspace = true
log.workspace = true
bytemuck.workspace = true
# Needed outside tests: shader compilation errors are collected through an
# async error scope, which must be resolved before the pipeline is returned.
pollster.workspace = true
[dev-dependencies]
env_logger.workspace = true
# The detail stage's test consumer — a box blur that is not a develop operation
# and never reaches the panel. An abstraction with no consumers is a guess, and
# this is the one that proves the neighbourhood passes compile, ping-pong,
# encode once, and scale between a proxy and an export. A dev-dependency, so a
# shipping `dr-gpu` does not carry it.
dr-pipeline = { workspace = true, features = ["detail-probe"] }
# The local-adjustment example needs the model, which the library half of this
# crate deliberately does not: `dr-gpu` holds the shaders, and the inference
# runtime belongs to whoever is asking a question about the picture.
dr-segment = { workspace = true, features = ["semantic", "embedded-model"] }
[[example]]
name = "bench"
required-features = ["readback"]
[[example]]
name = "develop"
# No `readback` needed since S1: this writes a file, so it goes through
# `export_pixels`, which is ungated precisely because an export is not the
# round-trip AC-8 forbids.
[features]
default = []
# Exposes read_pixels outside tests. Production must not enable this.
readback = []
# Exposes `Segmentation::read_field`, which builds the region adjacency graph
# on the CPU. Separate from `readback` on purpose — see `segment.rs`. Once per
# image on a worker, not the per-frame display round-trip AC-8 forbids; still a
# full-resolution transfer, and still F3's open gap.
segment-readback = []
[[example]]
name = "segment"
required-features = ["segment-readback"]
[[example]]
name = "local"
# No `segment-readback`: this reads the *rendered* proxy back through
# `export_pixels` to feed the model, which is the ungated export path. The
# watershed, and the region-graph transfer that needs the gate, is not involved.
[[test]]
name = "masked_outputs"
# Needs the distance transform, which lives with the model half of dr-segment.
required-features = []
+55
View File
@@ -0,0 +1,55 @@
// Measures the per-frame cost at several canvas sizes, isolating the readback
// path from windowing.
use dr_gpu::{GpuContext, RenderTarget};
use std::time::Instant;
fn main() {
env_logger::init();
let ctx = pollster::block_on(GpuContext::new_headless()).unwrap();
println!("adapter: {}\n", ctx.adapter_name());
println!(
"{:>12} {:>10} {:>10} {:>8}",
"size", "compute", "+readback", "fps"
);
for &(w, h) in &[
(840u32, 692u32),
(1280, 720),
(1920, 1080),
(2048, 1152),
(3840, 2160),
] {
let rt = RenderTarget::new(&ctx, w, h).unwrap();
// warm
for i in 0..10 {
rt.render(i as f32 * 0.01);
let _ = pollster::block_on(rt.read_pixels());
}
let n = 40;
let t0 = Instant::now();
for i in 0..n {
rt.render(i as f32 * 0.01);
}
ctx.device
.poll(wgpu::PollType::wait_indefinitely())
.expect("poll");
let compute = t0.elapsed().as_secs_f64() / n as f64;
let t1 = Instant::now();
for i in 0..n {
rt.render(i as f32 * 0.01);
let _ = pollster::block_on(rt.read_pixels()).unwrap();
}
let full = t1.elapsed().as_secs_f64() / n as f64;
println!(
"{:>5}x{:<6} {:>8.2}ms {:>8.2}ms {:>8.0}",
w,
h,
compute * 1000.0,
full * 1000.0,
1.0 / full
);
}
}
+187
View File
@@ -0,0 +1,187 @@
//! Render a RAW file through the full pipeline and write a PPM.
//!
//! The end-to-end check: decode → demosaic → adjust → display encode, on a
//! real file rather than a synthetic fixture. Unit tests prove each stage in
//! isolation; this proves they compose into an image a person would accept.
//!
//! ```sh
//! cargo run -p dr-gpu --example develop -- IMG.CR2 out.ppm
//! ```
//!
//! PPM because it needs no encoder dependency and every image viewer reads
//! it. This is a diagnostic, not the export path (FR-EXP-*).
use dr_gpu::{AdjustPass, Demosaicer, GpuContext};
use dr_pipeline::ops::{
blacks_whites, brilliance, colour_mixer, contrast, curve, exposure, highlights_shadows,
vibrance, white_balance,
};
use dr_pipeline::{EditGraph, ParamId};
fn main() {
env_logger::init();
let mut args = std::env::args().skip(1);
let Some(input) = args.next() else {
eprintln!("usage: develop <file.cr2> [out.ppm] [preset]");
eprintln!(" preset: neutral (default) | punchy | recover");
std::process::exit(2);
};
let output = args.next().unwrap_or_else(|| "develop.ppm".into());
let preset = args.next().unwrap_or_else(|| "neutral".into());
let bytes = std::fs::read(&input).expect("read file");
let t0 = std::time::Instant::now();
let raw = dr_decode::decode(&bytes).expect("decode");
let decode_ms = t0.elapsed().as_secs_f32() * 1000.0;
println!(
"decoded {} × {} ({:?}), {decode_ms:.0} ms",
raw.crop.width, raw.crop.height, raw.cfa_pattern
);
let ctx = pollster::block_on(GpuContext::new_headless()).expect("gpu");
println!("adapter {} ({:?})", ctx.adapter_name(), ctx.backend());
let t1 = std::time::Instant::now();
let demosaicer = Demosaicer::new(&ctx).expect("demosaicer");
let image = demosaicer.run(&raw).expect("demosaic");
ctx.device
.poll(wgpu::PollType::wait_indefinitely())
.expect("poll");
println!("demosaiced {:.0} ms", t1.elapsed().as_secs_f32() * 1000.0);
// Build an edit. The presets exist so the output can be eyeballed for
// each operation actually doing something, not merely compiling.
let mut graph = EditGraph::default_chain();
match preset.as_str() {
"punchy" => {
graph.set_param(exposure::ID, exposure::EXPOSURE, 0.3);
graph.set_param(
highlights_shadows::ID,
highlights_shadows::HIGHLIGHTS,
-40.0,
);
graph.set_param(highlights_shadows::ID, highlights_shadows::SHADOWS, 30.0);
graph.set_param(blacks_whites::ID, blacks_whites::BLACKS, -20.0);
graph.set_param(blacks_whites::ID, blacks_whites::WHITES, 25.0);
graph.set_param(vibrance::ID, vibrance::VIBRANCE, 35.0);
}
"recover" => {
graph.set_param(exposure::ID, exposure::EXPOSURE, -0.5);
graph.set_param(
highlights_shadows::ID,
highlights_shadows::HIGHLIGHTS,
-80.0,
);
graph.set_param(highlights_shadows::ID, highlights_shadows::SHADOWS, 60.0);
graph.set_param(brilliance::ID, brilliance::BRILLIANCE, 40.0);
graph.set_param(white_balance::ID, white_balance::TEMPERATURE, 15.0);
}
// Contrast alone, so its effect can be judged without anything else
// moving.
"contrast" => {
graph.set_param(contrast::ID, contrast::CONTRAST, 60.0);
}
"flat" => {
graph.set_param(contrast::ID, contrast::CONTRAST, -60.0);
}
// The mixer, pushed hard on the two things this scene actually has:
// green vegetation and grey-blue rock.
"mixer" => {
graph.set_param(colour_mixer::ID, ParamId("green_sat"), 80.0);
graph.set_param(colour_mixer::ID, ParamId("green_hue"), -40.0);
graph.set_param(colour_mixer::ID, ParamId("chartreuse_sat"), 60.0);
graph.set_param(colour_mixer::ID, ParamId("azure_lum"), -50.0);
}
// One band only, to check the weighting really is selective rather
// than affecting the whole image.
"mixer_one" => {
graph.set_param(colour_mixer::ID, ParamId("green_sat"), 100.0);
}
// A classic S-curve: shadows down, highlights up, mid held.
"curve_s" => {
graph.set_param(curve::ID, curve::P1_Y, 0.15);
graph.set_param(curve::ID, curve::P3_Y, 0.85);
}
// The inverse, a film-like lifted-shadow look.
"curve_lift" => {
graph.set_param(curve::ID, curve::P0_Y, 0.12);
graph.set_param(curve::ID, curve::P1_Y, 0.32);
}
_ => {}
}
let shader = graph.compose();
println!(
"shader {} active op(s), {} uniform floats, structure {:016x}",
shader.source.matches("---- ").count(),
shader.uniforms.len(),
shader.structure_hash
);
let mut adjust = AdjustPass::new(&ctx);
let (w, h) = image.size();
let t2 = std::time::Instant::now();
adjust.render(&image, &shader, w, h).expect("adjust");
ctx.device
.poll(wgpu::PollType::wait_indefinitely())
.expect("poll");
println!("adjusted {:.2} ms", t2.elapsed().as_secs_f32() * 1000.0);
// Time a second render with only a value changed: this is the slider
// path, and it must not recompile.
graph.set_param(exposure::ID, exposure::EXPOSURE, 0.31);
let again = graph.compose();
let t3 = std::time::Instant::now();
adjust.render(&image, &again, w, h).expect("adjust");
ctx.device
.poll(wgpu::PollType::wait_indefinitely())
.expect("poll");
println!(
"re-render {:.2} ms ({} pipeline(s) compiled)",
t3.elapsed().as_secs_f32() * 1000.0,
adjust.cached_pipelines()
);
// `export_pixels`, because that is honestly what this is: the frame is
// going into a PPM, not onto a screen. See the note on that method for
// why the two readbacks were never the same thing (AC-8).
let (pixels, pw, ph) = adjust.export_pixels().expect("readback");
// Sanity: an all-black or all-white result means something upstream
// failed silently, and it is far easier to see here than in a viewer.
let mut sum = 0u64;
let mut min = 255u8;
let mut max = 0u8;
for px in pixels.chunks_exact(4) {
let l = px[0].max(px[1]).max(px[2]);
sum += u64::from(l);
min = min.min(l);
max = max.max(l);
}
let mean = sum as f64 / (pixels.len() / 4) as f64;
println!("levels min {min}, mean {mean:.1}, max {max}");
if max == 0 {
eprintln!("WARNING: the image is entirely black");
}
write_ppm(&output, &pixels, pw, ph);
println!("wrote {output} ({pw} × {ph})");
}
/// Write binary PPM (P6): a three-line header then RGB triples.
fn write_ppm(path: &str, rgba: &[u8], w: u32, h: u32) {
use std::io::Write;
let mut out = Vec::with_capacity((w * h * 3) as usize + 32);
out.extend_from_slice(format!("P6\n{w} {h}\n255\n").as_bytes());
for px in rgba.chunks_exact(4) {
out.extend_from_slice(&px[..3]);
}
std::fs::File::create(path)
.expect("create output")
.write_all(&out)
.expect("write output");
}
+314
View File
@@ -0,0 +1,314 @@
//! Local adjustments end to end, on a real photograph.
//!
//! Two edits a photographer actually makes, both driven by the model finding
//! the subject rather than by anyone drawing a shape:
//!
//! - **The subject in colour, everything else monochrome.** One layer, the
//! subject's mask inverted, saturation at −100.
//! - **The subject lifted out of its background.** Two layers over the same
//! mask: the subject brightened, the background pulled down.
//!
//! ```sh
//! cargo run -p dr-gpu --example local --release \
//! --features segment-readback -- photo.CR2 out
//! ```
//!
//! Writes `<prefix>-original.ppm`, `<prefix>-colour-pop.ppm`,
//! `<prefix>-subject-lift.ppm` and `<prefix>-mask.ppm`. PPM for the reason
//! every other example here uses it: no encoder dependency, and every viewer
//! reads it.
//!
//! # What this is really testing
//!
//! That the whole chain agrees with itself. The mask is rasterised in *source*
//! space at proxy resolution and sampled by the composed shader after the
//! framing map, so a fault anywhere in that handoff — a transposed axis, a
//! mask pinned to the viewport, a slice read from the wrong layer — shows up
//! here as an adjustment in the wrong place, and nowhere else.
use dr_gpu::{
AdjustPass, DemosaicedImage, Demosaicer, GpuContext, MaskPass, SubjectMasks,
};
use dr_pipeline::descriptor::ParamId;
use dr_pipeline::mask::{MaskLayer, MaskSource, MaskStack, Morphology};
use dr_pipeline::operation::compose_full;
use dr_pipeline::{ops, EditGraph, Framing};
use dr_segment::{SemanticModel, SemanticOptions, Shaped};
use dr_types::ColourSpace;
/// Longest edge the mask and the model work at.
const PROXY: u32 = 1600;
/// Longest edge of the written frames.
const OUT: u32 = 1400;
fn main() {
env_logger::init();
let mut args = std::env::args().skip(1);
let Some(path) = args.next() else {
eprintln!("usage: local <photo.CR2|photo.RAF> [out-prefix]");
std::process::exit(2);
};
let prefix = args.next().unwrap_or_else(|| "local".into());
let ctx = pollster::block_on(GpuContext::new_headless()).expect("gpu context");
println!("gpu {}", ctx.adapter_name());
// ---- the photograph ---------------------------------------------------
let bytes = std::fs::read(&path).expect("read file");
let raw = dr_decode::decode(&bytes).expect("decode");
println!("source {} × {}", raw.crop.width, raw.crop.height);
let source = Demosaicer::new(&ctx)
.expect("demosaicer")
.run(&raw)
.expect("demosaic");
// ---- what the model sees ----------------------------------------------
//
// The *unedited* image, so the detection does not shift when the edit
// does. Through `export_pixels`, which is ungated: an export is not the
// display round-trip AC-8 forbids, and neither is this.
let (sw, sh) = source.size();
let scale = (PROXY as f32 / sw.max(sh) as f32).min(1.0);
let (pw, ph) = (
((sw as f32 * scale) as u32).max(1),
((sh as f32 * scale) as u32).max(1),
);
let neutral = EditGraph::default_chain();
let mut proxy_pass = AdjustPass::new(&ctx);
proxy_pass
.render(&source, &neutral.compose(), pw, ph)
.expect("proxy render");
let (rgba, pw, ph) = proxy_pass.export_pixels().expect("proxy readback");
println!("proxy {pw} × {ph}");
let rgb: Vec<f32> = rgba
.chunks_exact(4)
.flat_map(|p| {
[
p[0] as f32 / 255.0,
p[1] as f32 / 255.0,
p[2] as f32 / 255.0,
]
})
.collect();
// ---- find the subject -------------------------------------------------
let t = std::time::Instant::now();
let mut model = SemanticModel::embedded().expect("model");
let instances = model
.detect(&rgb, pw as usize, ph as usize, &SemanticOptions::default())
.expect("detect");
println!(
"detect {} found in {:.0} ms",
instances.len(),
t.elapsed().as_secs_f32() * 1000.0
);
for (i, inst) in instances.iter().enumerate() {
println!(" [{i}] {:<14} {:.2}", inst.class_name, inst.score);
}
let Some((index, subject)) = pick_subject(&instances) else {
eprintln!("\nNothing recognised in this frame — nothing to adjust locally.");
eprintln!("The model knows COCO's 80 classes; a landscape with no person,");
eprintln!("animal or vehicle in it has no subject for it to find.");
std::process::exit(1);
};
println!(
"subject [{index}] {} at {:.2}",
subject.class_name, subject.score
);
// Quantised exactly as the develop session does, so this example exercises
// the shipping path rather than a shortcut around it.
let alpha: Vec<u8> = subject
.mask
.iter()
.map(|&v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)
.collect();
let (ow, oh) = fit(sw, sh, OUT);
let mut masks = MaskPass::new(&ctx).expect("mask pass");
let mut adjust = AdjustPass::new(&ctx);
// ---- the original, for comparison -------------------------------------
adjust
.render(&source, &neutral.compose(), ow, oh)
.expect("render");
write(&format!("{prefix}-original.ppm"), &adjust);
// ---- 1. the subject in colour, the rest monochrome --------------------
//
// One layer, inverted. Inverting rather than making a second mask for the
// background is the whole point of having one: there is exactly one
// boundary, so there is exactly one thing to get right.
let mut pop = MaskStack::new();
let mut drain = subject_layer("m1", index, subject);
drain.invert = true;
drain.set_param("saturation", ParamId("saturation"), -100.0);
// A touch of feather, or the colour stops dead on the model's outline and
// the eye goes straight to the edge instead of to the subject.
drain.feather = 0.02;
pop.push(drain);
render_stack(&ctx, &source, &mut masks, &mut adjust, &pop, &alpha, pw, ph, ow, oh);
write(&format!("{prefix}-colour-pop.ppm"), &adjust);
// ---- 2. lift the subject out of its background ------------------------
let mut lift = MaskStack::new();
let mut brighter = subject_layer("m1", index, subject);
brighter.set_param("exposure", ParamId("exposure"), 0.45);
brighter.feather = 0.015;
lift.push(brighter);
let mut darker = subject_layer("m2", index, subject);
darker.invert = true;
darker.set_param("exposure", ParamId("exposure"), -0.55);
darker.set_param("saturation", ParamId("saturation"), -25.0);
darker.feather = 0.03;
lift.push(darker);
render_stack(&ctx, &source, &mut masks, &mut adjust, &lift, &alpha, pw, ph, ow, oh);
write(&format!("{prefix}-subject-lift.ppm"), &adjust);
// ---- 3. the same edit, grown and shrunk -------------------------------
//
// The model's outline is approximately right and slightly soft, so the
// everyday correction is to move it: grow to catch a halo the detector
// stopped short of, shrink to pull off one it caught. Both are a threshold
// of the distance field, which is why they cost a uniform.
for (name, morphology, radius) in [
("grown", Morphology::Dilate, 0.012),
("shrunk", Morphology::Erode, 0.012),
] {
let mut stack = MaskStack::new();
let mut layer = subject_layer("m1", index, subject);
layer.invert = true;
layer.set_param("saturation", ParamId("saturation"), -100.0);
layer.feather = 0.004;
layer.morphology = morphology;
layer.morph_radius = radius;
stack.push(layer);
render_stack(&ctx, &source, &mut masks, &mut adjust, &stack, &alpha, pw, ph, ow, oh);
write(&format!("{prefix}-{name}.ppm"), &adjust);
}
// ---- the mask itself, to check the outline ----------------------------
write_mask(&format!("{prefix}-mask.ppm"), &alpha, pw, ph);
println!("\nwrote {prefix}-original.ppm");
println!(" {prefix}-colour-pop.ppm");
println!(" {prefix}-subject-lift.ppm");
println!(" {prefix}-grown.ppm, {prefix}-shrunk.ppm");
println!(" {prefix}-mask.ppm");
}
/// A layer masked to one detected object.
fn subject_layer(id: &str, index: usize, subject: &dr_segment::Instance) -> MaskLayer {
let mut layer = MaskLayer::new(
id,
MaskSource::Subject {
// One segmentation in this process, so any signature agrees with
// itself; the session computes a real one.
signature: 0,
index: index as u32,
class: subject.class_name.to_string(),
score: subject.score,
},
);
layer.name = subject.class_name.to_string();
layer
}
/// The most promising thing to adjust.
///
/// Prefers a person, then falls back to the strongest detection of anything.
/// Not because people are special to the pipeline, but because they are what a
/// local adjustment is usually *for*, and an example that picks the parked car
/// behind the subject demonstrates the mechanism while missing the point.
fn pick_subject(instances: &[dr_segment::Instance]) -> Option<(usize, &dr_segment::Instance)> {
instances
.iter()
.enumerate()
.find(|(_, i)| &*i.class_name == "person")
.or_else(|| instances.iter().enumerate().next())
}
#[allow(clippy::too_many_arguments)]
fn render_stack(
ctx: &GpuContext,
source: &DemosaicedImage,
masks: &mut MaskPass,
adjust: &mut AdjustPass,
stack: &MaskStack,
coverage: &[u8],
pw: u32,
ph: u32,
ow: u32,
oh: u32,
) {
// One signed distance field per active layer, in that order — the order
// the rasteriser indexes them by. Built here rather than once up front
// because a compound morphology rebuilds the field, so it belongs to the
// layer that shaped it rather than to the object.
let fields: Vec<Vec<f32>> = stack
.active()
.map(|layer| {
Shaped::build(
coverage,
pw as usize,
ph as usize,
128,
match layer.morphology {
Morphology::None => dr_segment::Morphology::None,
Morphology::Dilate => dr_segment::Morphology::Dilate,
Morphology::Erode => dr_segment::Morphology::Erode,
Morphology::Close => dr_segment::Morphology::Close,
Morphology::Open => dr_segment::Morphology::Open,
},
layer.morph_radius * pw.min(ph) as f32,
)
.distance
})
.collect();
let refs: Vec<&[f32]> = fields.iter().map(|f| f.as_slice()).collect();
let subjects = SubjectMasks::upload(ctx, &refs, pw, ph).expect("upload fields");
// Rasterised at *proxy* size in source space, then sampled by the shader
// after the framing map — which is what makes one mask correct at every
// output size, zoom and crop.
let array = masks
.render(stack, None, Some(&subjects), pw, ph)
.expect("rasterise masks");
let shader = compose_full(&ops::chain(), &Framing::new(), ColourSpace::Srgb, stack);
adjust
.render_masked(source, &shader, ow, oh, Some(array))
.expect("render");
}
fn fit(w: u32, h: u32, longest: u32) -> (u32, u32) {
let s = (longest as f32 / w.max(h) as f32).min(1.0);
(((w as f32 * s) as u32).max(1), ((h as f32 * s) as u32).max(1))
}
fn write(path: &str, adjust: &AdjustPass) {
let (rgba, w, h) = adjust.export_pixels().expect("readback");
let rgb: Vec<u8> = rgba.chunks_exact(4).flat_map(|p| [p[0], p[1], p[2]]).collect();
write_ppm(path, &rgb, w, h);
}
fn write_mask(path: &str, alpha: &[u8], w: u32, h: u32) {
let rgb: Vec<u8> = alpha.iter().flat_map(|&a| [a, a, a]).collect();
write_ppm(path, &rgb, w, h);
}
fn write_ppm(path: &str, rgb: &[u8], w: u32, h: u32) {
use std::io::Write as _;
let mut f = std::io::BufWriter::new(std::fs::File::create(path).expect("create"));
write!(f, "P6\n{w} {h}\n255\n").expect("header");
f.write_all(rgb).expect("body");
}
+196
View File
@@ -0,0 +1,196 @@
//! Segment an image and write the granularity ladder as false-coloured PPMs.
//!
//! The whole point of S15 step 2 (docs/segmentation.md §11): look at the
//! ladder and decide whether clicking through it would land on the things a
//! person means. No amount of design settles that — the pictures do.
//!
//! ```sh
//! cargo run -p dr-gpu --example segment --features readback -- IMG.CR2
//! cargo run -p dr-gpu --example segment --features readback -- synthetic
//! ```
//!
//! PPM for the same reason `develop` uses it: no encoder dependency, and
//! every viewer reads it. This is a diagnostic, not an export path.
use dr_segment::{MergeTree, RegionField};
use dr_gpu::{DemosaicedImage, Demosaicer, GpuContext, SegmentOptions, SegmentPass};
/// The ladder the example dumps. Chosen to span "far too fine to be useful"
/// through "one or two objects", because both ends are informative: if no rung
/// looks right, the gradient is wrong rather than the ladder being too coarse.
const LEVELS: [usize; 6] = [2000, 800, 300, 120, 50, 16];
fn main() {
env_logger::init();
let mut args = std::env::args().skip(1);
let Some(input) = args.next() else {
eprintln!("usage: segment <file.raw|synthetic> [out-prefix] [blur-radius]");
std::process::exit(2);
};
let prefix = args.next().unwrap_or_else(|| "segment".into());
let blur_radius = args
.next()
.and_then(|s| s.parse().ok())
.unwrap_or(SegmentOptions::default().blur_radius);
let ctx = pollster::block_on(GpuContext::new_headless()).expect("gpu context");
println!("gpu {}", ctx.adapter_name());
let source = if input == "synthetic" {
let (w, h) = (1200, 800);
println!("source synthetic {w} × {h}");
DemosaicedImage::from_rgba8(&ctx, &synthetic(w, h), w, h).expect("synthetic source")
} else {
let bytes = std::fs::read(&input).expect("read file");
let raw = dr_decode::decode(&bytes).expect("decode");
println!("source {} × {}", raw.crop.width, raw.crop.height);
Demosaicer::new(&ctx)
.expect("demosaicer")
.run(&raw)
.expect("demosaic")
};
let opts = SegmentOptions {
blur_radius,
..Default::default()
};
let pass = SegmentPass::new(&ctx).expect("segment pass");
let t0 = std::time::Instant::now();
let seg = pass.run(&source, opts).expect("segment");
let (w, h) = seg.size();
let field = seg.read_field().expect("read field");
let gpu_ms = t0.elapsed().as_secs_f32() * 1000.0;
let t1 = std::time::Instant::now();
let tree = MergeTree::build(&field);
let tree_ms = t1.elapsed().as_secs_f32() * 1000.0;
// M6, roughly: the readback is in `gpu_ms` and would not be there in a
// shipping build, so this over-reports the GPU half rather than under.
println!("proxy {w} × {h}, blur radius {blur_radius}");
println!("basins {}", field.region_count);
println!("boundaries {}", field.adjacency.len());
println!("merges {}", tree.merges.len());
println!("segment {gpu_ms:.0} ms (includes readback)");
println!("hierarchy {tree_ms:.1} ms");
if let (Some(first), Some(last)) = (tree.merges.first(), tree.merges.last()) {
println!("saddles {:.4} … {:.4}", first.saddle, last.saddle);
}
for level in LEVELS {
if level > field.region_count {
println!("skip {level} (only {} basins)", field.region_count);
continue;
}
let grouping = tree.cut_to(level);
let pixels = field.apply(&grouping);
let groups = grouping.iter().max().map(|m| m + 1).unwrap_or(0);
let path = format!("{prefix}-{level:04}.ppm");
write_ppm(&path, &false_colour(&pixels, &field), w, h);
println!("wrote {path} ({groups} regions)");
}
// The boundaries alone, which is what a snapped contour would cling to.
let path = format!("{prefix}-edges.ppm");
write_ppm(&path, &boundaries(&field), w, h);
println!("wrote {path}");
}
/// A distinct colour per region.
///
/// Hashed from the id rather than sampled from the image: two adjacent
/// regions that happen to look alike are exactly the case worth seeing, and
/// mean colours would hide it.
fn false_colour(pixels: &[u32], field: &RegionField) -> Vec<u8> {
let _ = field;
let mut out = Vec::with_capacity(pixels.len() * 3);
for &g in pixels {
// Cheap integer hash — golden-ratio multiply, then spread the bits
// across three channels.
let mut x = g.wrapping_mul(2_654_435_761);
x ^= x >> 15;
out.push((x & 0xff) as u8);
out.push(((x >> 8) & 0xff) as u8);
out.push(((x >> 16) & 0xff) as u8);
}
out
}
/// White where two regions meet, black elsewhere.
fn boundaries(field: &RegionField) -> Vec<u8> {
let (w, h) = (field.width, field.height);
let mut out = vec![0u8; w * h * 3];
for y in 0..h {
for x in 0..w {
let i = y * w + x;
let edge = (x + 1 < w && field.labels[i] != field.labels[i + 1])
|| (y + 1 < h && field.labels[i] != field.labels[i + w]);
if edge {
out[i * 3] = 255;
out[i * 3 + 1] = 255;
out[i * 3 + 2] = 255;
}
}
}
out
}
fn write_ppm(path: &str, rgb: &[u8], width: u32, height: u32) {
use std::io::Write as _;
let mut f = std::io::BufWriter::new(std::fs::File::create(path).expect("create ppm"));
write!(f, "P6\n{width} {height}\n255\n").expect("ppm header");
f.write_all(rgb).expect("ppm body");
}
/// A test image with the failure modes the corpus is meant to provoke, so the
/// example is runnable before anyone has traced a single ground-truth mask.
///
/// Deliberately includes a soft gradient boundary and a noisy patch: those are
/// where a watershed either earns its place or shatters, and a synthetic image
/// of clean shapes would flatter it.
fn synthetic(w: u32, h: u32) -> Vec<u8> {
let mut px = Vec::with_capacity((w * h * 4) as usize);
for y in 0..h {
for x in 0..w {
let fx = x as f32 / w as f32;
let fy = y as f32 / h as f32;
// A smooth vertical gradient — the low-contrast boundary case.
let mut r = 40.0 + 120.0 * fy;
let mut g = 60.0 + 100.0 * fy;
let mut b = 110.0 + 90.0 * fy;
// A hard-edged disc: the control case.
let d = ((fx - 0.3).powi(2) + (fy - 0.45).powi(2)).sqrt();
if d < 0.16 {
r = 210.0;
g = 90.0;
b = 60.0;
}
// A soft-edged disc: where the ladder should merge late.
let d2 = ((fx - 0.68).powi(2) + (fy - 0.6).powi(2)).sqrt();
let t = (1.0 - (d2 / 0.18)).clamp(0.0, 1.0);
r = r * (1.0 - t) + 90.0 * t;
g = g * (1.0 - t) + 170.0 * t;
b = b * (1.0 - t) + 110.0 * t;
// A noisy corner: the case pre-smoothing exists for.
if fx > 0.82 && fy < 0.22 {
let n = ((x * 7919 + y * 104_729) % 97) as f32 / 97.0;
r += (n - 0.5) * 90.0;
g += (n - 0.5) * 90.0;
b += (n - 0.5) * 90.0;
}
px.push(r.clamp(0.0, 255.0) as u8);
px.push(g.clamp(0.0, 255.0) as u8);
px.push(b.clamp(0.0, 255.0) as u8);
px.push(255);
}
}
px
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+396
View File
@@ -0,0 +1,396 @@
//! The detail stage — running `dr-pipeline`'s neighbourhood passes.
//!
//! Where [`crate::AdjustPass`] fuses every point operation into one dispatch,
//! this runs the operations that cannot be fused because they read pixels they
//! are not writing: sharpening, noise reduction, clarity, texture, dehaze,
//! spot removal (FR-DEV-3, FR-DEV-8). `dr_pipeline::detail` decides *what* they
//! are and generates their WGSL; this compiles it, finds it somewhere to
//! write, and dispatches it.
//!
//! # Nothing round-trips
//!
//! Every intermediate here is a `wgpu::Texture` and none of them is ever
//! mapped. The chain is `demosaiced -> fused -> f16 -> f16 -> ... -> rgba8`,
//! all of it on the device, and the last write lands in the same texture the
//! compositor was already being handed. ARCH §6.1 and FR-DEV-4 are satisfied
//! by there being no code here that could violate them, which is the only
//! guarantee worth having.
//!
//! # Following the mask pass rather than inventing a second pattern
//!
//! `mask.rs` established how multi-target work is done in this crate, and this
//! copies it deliberately:
//!
//! - **One encoder for the whole chain.** The mask pass rasterises every layer
//! into one command buffer and submits once; this does the same for every
//! pass. Submission order is the only synchronisation either needs, because
//! both write and then read through the same queue.
//! - **Textures reallocated on size change, never per frame.** `ensure_array`
//! there, [`Intermediates::ensure`] here. Steady-state rendering at one
//! viewport size allocates nothing.
//! - **An allocation counter that exists to be asserted on.** Reallocating per
//! frame instead of per resize costs a great deal of bandwidth and shows up
//! nowhere in the output, which is exactly the kind of regression that needs
//! a test that can see it.
//! - **Pipelines cached by structure hash**, as `AdjustPass` caches its own.
//! Moving a slider re-uploads a uniform buffer; it does not recompile.
//!
//! # The ping-pong, and why there are at most three textures
//!
//! Slot 0 holds what the fused colour pass wrote. It is kept **across frames**,
//! which is what makes [`dr_pipeline::Affects::Detail`] mean something: when
//! only a detail parameter has moved, the colour key is unchanged, the fused
//! dispatch is skipped, and dragging a sharpening slider costs the detail
//! passes alone (FR-DEV-3d).
//!
//! The remaining passes alternate between slots 1 and 2, and the last one
//! writes the display texture directly rather than an intermediate — so a
//! chain of *N* passes costs *N* dispatches and not *N* + 1, and there is no
//! resolve pass to pay for. That leaves the allocation at `1 + min(N-1, 2)`
//! textures: one for a single-pass operation, two for a separable blur, three
//! however long the chain gets after that.
use std::collections::HashMap;
use dr_pipeline::detail::{ComposedDetail, ComposedDetailPass};
use wgpu::util::DeviceExt as _;
use crate::{GpuContext, GpuError};
/// The format every intermediate carries.
///
/// The same `Rgba16Float` the demosaicer produces and the same one ARCH §5.2
/// names as the working precision (FR-DEV-2). It is not a free choice: the
/// stage exists between the colour pass and the output transform precisely so
/// that a kernel runs on linear values at full internal precision, and an
/// 8-bit intermediate would quantise twice and convolve display-encoded
/// numbers — which is how sharpening comes to band a clear sky.
pub const INTERMEDIATE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
/// One linear working texture.
struct Slot {
#[allow(dead_code)]
texture: wgpu::Texture,
view: wgpu::TextureView,
}
/// The pool of linear intermediates, sized to the chain and the viewport.
struct Intermediates {
slots: Vec<Slot>,
width: u32,
height: u32,
allocations: usize,
}
impl Intermediates {
fn new() -> Self {
Self {
slots: Vec::new(),
width: 0,
height: 0,
allocations: 0,
}
}
/// Make sure `count` textures of this size exist.
///
/// Grows but never shrinks within a size: an edit that briefly had a
/// three-pass chain and then a one-pass one keeps the spare texture rather
/// than freeing and reallocating it the next time the user turns the
/// operation back on. A size change drops the lot, because none of them
/// fits any more.
fn ensure(&mut self, ctx: &GpuContext, count: usize, width: u32, height: u32) {
if self.width != width || self.height != height {
self.slots.clear();
self.width = width;
self.height = height;
}
while self.slots.len() < count {
let texture = ctx.device.create_texture(&wgpu::TextureDescriptor {
label: Some("detail-intermediate"),
size: wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: INTERMEDIATE_FORMAT,
// STORAGE_BINDING to be written by a compute pass and
// TEXTURE_BINDING to be read by the next one. Nothing else:
// no RENDER_ATTACHMENT, because unlike the adjust pass's
// output these are never handed to a compositor, and no
// COPY_SRC, because nothing reads them back — that is the
// point (ARCH §6.1).
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
self.slots.push(Slot { texture, view });
self.allocations += 1;
}
}
}
/// Runs the detail stage.
///
/// Owned by [`crate::AdjustPass`] rather than standing alone, because the two
/// halves are one render: the fused pass writes slot 0, this reads it, and the
/// last pass writes the adjust pass's own output texture. Splitting them into
/// two objects with two lifetimes would mean a caller could hold a stale
/// intermediate against a fresh colour result and never be told.
pub(crate) struct DetailRunner {
ctx: GpuContext,
/// Layout for a pass writing another linear intermediate.
to_linear: Layout,
/// Layout for the last pass, which writes the display texture.
to_output: Layout,
/// Compiled pipelines by pass structure hash.
cache: HashMap<u64, wgpu::ComputePipeline>,
pool: Intermediates,
}
struct Layout {
bind_group: wgpu::BindGroupLayout,
pipeline: wgpu::PipelineLayout,
}
impl DetailRunner {
pub(crate) fn new(ctx: &GpuContext) -> Self {
Self {
ctx: ctx.clone(),
to_linear: Layout::new(ctx, INTERMEDIATE_FORMAT, "detail-linear"),
to_output: Layout::new(ctx, crate::AdjustPass::FORMAT, "detail-output"),
cache: HashMap::new(),
pool: Intermediates::new(),
}
}
/// The view the fused colour pass should write, given a chain of `passes`.
///
/// Slot 0, always — it is the one that survives between frames so that a
/// detail-only change can skip the colour dispatch entirely.
pub(crate) fn colour_target(
&mut self,
passes: usize,
width: u32,
height: u32,
) -> &wgpu::TextureView {
// One for the colour pass's result, then one per hand-off between
// detail passes, capped at two because a ping-pong needs no more: the
// last pass writes the display texture rather than an intermediate.
let needed = 1 + passes.saturating_sub(1).min(2);
self.pool.ensure(&self.ctx, needed, width, height);
&self.pool.slots[0].view
}
/// Encode every pass of `chain`, the last one writing `output`.
///
/// The caller must already have run the fused colour pass into
/// [`Self::colour_target`] — or established that a previous frame's is
/// still valid, which is the whole point of keeping slot 0.
pub(crate) fn encode(
&mut self,
encoder: &mut wgpu::CommandEncoder,
chain: &ComposedDetail,
output: &wgpu::TextureView,
width: u32,
height: u32,
) -> Result<usize, GpuError> {
for pass in &chain.passes {
self.compile(pass)?;
}
for (index, pass) in chain.passes.iter().enumerate() {
// Read what the previous pass wrote; write the next slot, or the
// display texture if this is the last one. `index % 2` alternates
// between slots 1 and 2, so a pass never reads the texture it is
// writing — which on a compute pass is not an error the driver
// reports, merely a picture that depends on scheduling.
let source_slot = if index == 0 { 0 } else { 2 - (index % 2) };
let source = &self.pool.slots[source_slot].view;
let destination = if pass.writes_output {
output
} else {
&self.pool.slots[1 + (index % 2)].view
};
let layout = if pass.writes_output {
&self.to_output
} else {
&self.to_linear
};
let params = self
.ctx
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("detail-params"),
contents: bytemuck::cast_slice(&pass.uniforms),
usage: wgpu::BufferUsages::UNIFORM,
});
let bind_group = self
.ctx
.device
.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("detail-bg"),
layout: &layout.bind_group,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(source),
},
wgpu::BindGroupEntry {
binding: 1,
resource: params.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(destination),
},
],
});
let pipeline = self
.cache
.get(&pass.structure_hash)
.expect("compiled above");
let mut compute = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some(pass.label.as_str()),
timestamp_writes: None,
});
compute.set_pipeline(pipeline);
compute.set_bind_group(0, &bind_group, &[]);
compute.dispatch_workgroups(width.div_ceil(8), height.div_ceil(8), 1);
}
Ok(chain.passes.len())
}
/// Compile one pass, or leave the cached pipeline in place.
///
/// A validation error here is a codegen bug rather than anything the user
/// did, so it is caught in an error scope and returned with the generated
/// source and the pass's label attached — a line number against code
/// nobody wrote, from one of several passes, is otherwise close to
/// unactionable.
fn compile(&mut self, pass: &ComposedDetailPass) -> Result<(), GpuError> {
if self.cache.contains_key(&pass.structure_hash) {
return Ok(());
}
let scope = self
.ctx
.device
.push_error_scope(wgpu::ErrorFilter::Validation);
let module = self
.ctx
.device
.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(pass.label.as_str()),
source: wgpu::ShaderSource::Wgsl(pass.source.as_str().into()),
});
let layout = if pass.writes_output {
&self.to_output
} else {
&self.to_linear
};
let pipeline = self
.ctx
.device
.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some(pass.label.as_str()),
layout: Some(&layout.pipeline),
module: &module,
entry_point: Some("main"),
compilation_options: Default::default(),
cache: None,
});
if let Some(err) = pollster::block_on(scope.pop()) {
return Err(GpuError::ShaderCompilation(format!(
"detail pass {}: {err}\n\n--- generated source ---\n{}",
pass.label,
crate::adjust::numbered(&pass.source)
)));
}
self.cache.insert(pass.structure_hash, pipeline);
Ok(())
}
/// How many distinct detail pipelines are compiled. For tests asserting
/// that slider movement does not recompile.
pub(crate) fn cached_pipelines(&self) -> usize {
self.cache.len()
}
/// How many intermediate textures have been allocated since this pass was
/// created. For tests — see [`crate::MaskPass::allocations`] for the
/// regression this shape of counter exists to catch.
pub(crate) fn allocations(&self) -> usize {
self.pool.allocations
}
}
impl Layout {
fn new(ctx: &GpuContext, format: wgpu::TextureFormat, label: &str) -> Self {
let bind_group = ctx
.device
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some(label),
entries: &[
// The previous stage's result.
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
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,
},
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::StorageTexture {
access: wgpu::StorageTextureAccess::WriteOnly,
format,
view_dimension: wgpu::TextureViewDimension::D2,
},
count: None,
},
],
});
let pipeline = ctx
.device
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some(label),
bind_group_layouts: &[Some(&bind_group)],
immediate_size: 0,
});
Self {
bind_group,
pipeline,
}
}
}
+44
View File
@@ -0,0 +1,44 @@
/// TRACES: NFR-R7 | NFR-R8
/// Failures from the GPU layer.
///
/// `DeviceLost` is deliberately a distinct variant rather than folded into a
/// generic error: it is an expected event on Android (ARCH §6.10), not an
/// exceptional one, and callers recover from it by rebuilding the device and
/// re-driving from the edit graph.
#[derive(Debug, thiserror::Error)]
pub enum GpuError {
#[error("no suitable GPU adapter found")]
NoAdapter,
#[error("failed to request device: {0}")]
DeviceRequest(String),
#[error("GPU device lost — recreate and re-render from the edit graph")]
DeviceLost,
#[error("shader compilation failed: {0}")]
ShaderCompilation(String),
#[error("readback failed: {0}")]
Readback(String),
/// The CFA layout is one no demosaic here handles — in practice a file
/// `dr-decode` could not identify the pattern of. Reported rather than
/// approximated with the Bayer path, which would produce a maze of colour
/// artefacts and look like a corrupt file.
#[error("unsupported CFA pattern: {0}")]
UnsupportedCfa(String),
#[error("image too large for this device: {0}")]
TooLarge(String),
/// A mask input that cannot describe the image it claims to — a label
/// field whose length disagrees with its own dimensions, most often.
///
/// Its own variant rather than a panic because the caller assembles this
/// from a segmentation and a render size that are computed in different
/// places, and a mismatch between them is a bug worth reporting with its
/// numbers rather than an abort.
#[error("invalid mask input: {0}")]
InvalidMask(String),
}
+591
View File
@@ -0,0 +1,591 @@
//! TRACES: FR-DSP-7
//! Counting the display frame, on the device that drew it.
//!
//! # Why a compute reduction and not a CPU pass over the readback
//!
//! There is, today, a whole frame already sitting in CPU memory every time the
//! canvas updates — `AdjustPass::read_output`, the temporary bridge that spike
//! S1 removes. Walking it to build a histogram would have been perhaps thirty
//! lines and no shader at all, and it was the obvious thing to reach for.
//!
//! It was rejected for two reasons, in this order.
//!
//! FR-DSP-7 states the mechanism, not just the feature: "these derive from a
//! GPU-side reduction into a small buffer. Per-frame CPU readback of image data
//! is prohibited." A histogram built on the bridge would be correct today and
//! *deleted* by S1 — it would be new code whose only foundation is the one
//! thing the architecture is committed to removing, and the histogram would
//! then be the reason the bridge could not go.
//!
//! And the cost does not scale the way the shortcut implies. Counting 2 MP on
//! one CPU thread is several milliseconds of the settle frame; the reduction
//! below is a fraction of one, and what crosses the bus is 4104 bytes
//! regardless of the image. The simpler-looking option is simpler only while
//! the frame happens to be lying there.
//!
//! # What it costs and when it runs
//!
//! One dispatch plus a 4 KB buffer copy and a mapping — a device sync point.
//! FR-DSP-7 requires that this not extend the FR-DSP-3 frame budget, so the
//! interface runs it on the *settled* frame only, never on the draft frames a
//! drag produces. The histogram of an image being dragged past is not read
//! anyway; the one that arrives when the slider stops is.
use wgpu::util::DeviceExt;
use crate::readback::await_mapping;
use crate::{GpuContext, GpuError};
/// Levels per channel. 256, so a bin *is* an output code value and no
/// re-bucketing stands between the count and what the display shows.
pub const BINS: usize = 256;
/// Four channel histograms plus the two clip counters, as the shader lays them
/// out. Kept next to the shader's own constants because the two must agree.
const CHANNELS: usize = 4;
const CLIPPED_HIGH: usize = CHANNELS * BINS;
const CLIPPED_LOW: usize = CHANNELS * BINS + 1;
const SLOTS: usize = CHANNELS * BINS + 2;
/// TRACES: FR-DSP-7
/// A counted frame: how many pixels sit at each output level.
///
/// Counts, not proportions. Turning these into something drawable — folding
/// 256 bins into the columns a 280px panel can show, choosing a peak to scale
/// against — is presentation, and belongs to whoever is drawing (ARCH §4.3a).
/// What this crate owes is the numbers.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Histogram {
red: [u32; BINS],
green: [u32; BINS],
blue: [u32; BINS],
luma: [u32; BINS],
clipped_highlights: u32,
clipped_shadows: u32,
pixels: u32,
}
impl Histogram {
/// Counts per output level, darkest first.
pub fn red(&self) -> &[u32; BINS] {
&self.red
}
pub fn green(&self) -> &[u32; BINS] {
&self.green
}
pub fn blue(&self) -> &[u32; BINS] {
&self.blue
}
/// Rec.709 luma of the encoded values — the axis a photographer reads
/// exposure off. See the shader for why it is weighted in fixed point.
pub fn luma(&self) -> &[u32; BINS] {
&self.luma
}
/// Pixels with **any** channel at 255, and with any channel at 0.
///
/// Any rather than all, because a single blown channel is detail that is
/// already gone: a red that has hit the ceiling has no gradation left in it
/// however much green and blue still hold.
pub fn clipped_highlights(&self) -> u32 {
self.clipped_highlights
}
pub fn clipped_shadows(&self) -> u32 {
self.clipped_shadows
}
/// Pixels counted. The denominator for the two figures above.
pub fn pixels(&self) -> u32 {
self.pixels
}
/// Rebuild from the flat slot array the shader writes.
///
/// `pixels` is summed from the red channel rather than taken from the image
/// dimensions: every pixel lands in exactly one red bin, so the sum *is*
/// the count, and deriving it that way makes a dropped or double-counted
/// texel show up as a wrong denominator instead of hiding.
fn from_slots(slots: &[u32]) -> Result<Self, GpuError> {
if slots.len() < SLOTS {
return Err(GpuError::Readback(format!(
"histogram readback was {} slots, expected {SLOTS}",
slots.len()
)));
}
let channel = |i: usize| -> [u32; BINS] {
let mut out = [0u32; BINS];
out.copy_from_slice(&slots[i * BINS..(i + 1) * BINS]);
out
};
let red = channel(0);
Ok(Self {
pixels: red.iter().sum(),
red,
green: channel(1),
blue: channel(2),
luma: channel(3),
clipped_highlights: slots[CLIPPED_HIGH],
clipped_shadows: slots[CLIPPED_LOW],
})
}
}
/// The dispatch's view of the frame. Padded to 16 bytes for std140.
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
struct Dims {
width: u32,
height: u32,
pad_0: u32,
pad_1: u32,
}
/// TRACES: FR-DSP-7
/// Counts a rendered frame into [`Histogram`].
///
/// Holds its buffers for the life of the session. They are a fixed 4104 bytes
/// whatever the image size — the one property that makes this affordable — so
/// there is nothing to reallocate when the viewport changes, unlike the display
/// target beside it.
pub struct HistogramPass {
ctx: GpuContext,
pipeline: wgpu::ComputePipeline,
bind_group_layout: wgpu::BindGroupLayout,
/// Where the shader accumulates. Cleared before each dispatch.
bins: wgpu::Buffer,
/// Mappable destination; a storage buffer cannot also be `MAP_READ`.
staging: wgpu::Buffer,
dims: wgpu::Buffer,
}
impl HistogramPass {
pub fn new(ctx: &GpuContext) -> Result<Self, GpuError> {
// A validation error here is a bug in the shader beside this file, not
// anything a user did — surfaced as a `Result` rather than left to
// wgpu's default handler, which panics.
let scope = ctx.device.push_error_scope(wgpu::ErrorFilter::Validation);
let module = ctx
.device
.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("histogram"),
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/histogram.wgsl").into()),
});
let bind_group_layout =
ctx.device
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("histogram-bgl"),
entries: &[
// The rendered frame, sampled with `textureLoad` — the
// same texture the compositor shows, so what is counted
// is what is on screen.
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: false },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
});
let layout = ctx
.device
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("histogram-layout"),
bind_group_layouts: &[Some(&bind_group_layout)],
immediate_size: 0,
});
let pipeline = ctx
.device
.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("histogram-pipeline"),
layout: Some(&layout),
module: &module,
entry_point: Some("main"),
compilation_options: Default::default(),
cache: None,
});
if let Some(err) = pollster::block_on(scope.pop()) {
return Err(GpuError::ShaderCompilation(err.to_string()));
}
let bytes = (SLOTS * std::mem::size_of::<u32>()) as u64;
let bins = ctx.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("histogram-bins"),
size: bytes,
usage: wgpu::BufferUsages::STORAGE
| wgpu::BufferUsages::COPY_SRC
| wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let staging = ctx.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("histogram-staging"),
size: bytes,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let dims = ctx
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("histogram-dims"),
contents: bytemuck::bytes_of(&Dims {
width: 0,
height: 0,
pad_0: 0,
pad_1: 0,
}),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
});
Ok(Self {
ctx: ctx.clone(),
pipeline,
bind_group_layout,
bins,
staging,
dims,
})
}
/// TRACES: FR-DSP-7
/// Count one rendered frame.
///
/// `texture` must carry `TEXTURE_BINDING`, which `AdjustPass`'s output
/// does because the compositor samples it.
pub fn compute(&self, texture: &wgpu::Texture) -> Result<Histogram, GpuError> {
let (width, height) = (texture.width(), texture.height());
self.ctx.queue.write_buffer(
&self.dims,
0,
bytemuck::bytes_of(&Dims {
width,
height,
pad_0: 0,
pad_1: 0,
}),
);
let view = texture.create_view(&Default::default());
let bind_group = self
.ctx
.device
.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("histogram-bg"),
layout: &self.bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: self.bins.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: self.dims.as_entire_binding(),
},
],
});
let mut enc = self
.ctx
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("histogram-encoder"),
});
// The accumulator is reused between frames, so it carries the previous
// frame's counts until this line. Forgetting it does not fail — it
// quietly integrates every frame since the image opened, which looks
// like a histogram that will not respond to the exposure slider.
enc.clear_buffer(&self.bins, 0, None);
{
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("histogram-pass"),
timestamp_writes: None,
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &bind_group, &[]);
pass.dispatch_workgroups(width.div_ceil(16), height.div_ceil(16), 1);
}
enc.copy_buffer_to_buffer(&self.bins, 0, &self.staging, 0, self.staging.size());
self.ctx.queue.submit(Some(enc.finish()));
let slice = self.staging.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
await_mapping(&self.ctx, &rx)?;
let data = slice.get_mapped_range();
let slots: Vec<u32> = bytemuck::cast_slice::<u8, u32>(&data).to_vec();
drop(data);
self.staging.unmap();
Histogram::from_slots(&slots)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx() -> Option<GpuContext> {
match pollster::block_on(GpuContext::new_headless()) {
Ok(c) => Some(c),
Err(e) => {
eprintln!("skipping: no GPU adapter ({e})");
None
}
}
}
/// Upload `rgba` as a texture the pass can read, the way `AdjustPass`
/// hands its output over.
fn texture(ctx: &GpuContext, rgba: &[u8], width: u32, height: u32) -> wgpu::Texture {
let tex = ctx.device.create_texture(&wgpu::TextureDescriptor {
label: Some("histogram-test-source"),
size: wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
ctx.queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &tex,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
rgba,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(width * 4),
rows_per_image: Some(height),
},
wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
);
ctx.queue.submit(std::iter::empty());
tex
}
/// The reference the shader is checked against: the same bucketing, written
/// the obvious way on the CPU.
///
/// Deliberately a *second* implementation rather than shared code. The bugs
/// this is here to catch — a workgroup tile that is never merged, an edge
/// tile counted twice, a luma weighting that carries past 255 — are all
/// bugs a shared implementation would commit identically on both sides and
/// so could not detect.
fn expected(rgba: &[u8]) -> Histogram {
let mut slots = vec![0u32; SLOTS];
for px in rgba.chunks_exact(4) {
let (r, g, b) = (px[0] as usize, px[1] as usize, px[2] as usize);
let y = (54 * r + 183 * g + 19 * b) >> 8;
slots[r] += 1;
slots[BINS + g] += 1;
slots[2 * BINS + b] += 1;
slots[3 * BINS + y] += 1;
if px[0] == 255 || px[1] == 255 || px[2] == 255 {
slots[CLIPPED_HIGH] += 1;
}
if px[0] == 0 || px[1] == 0 || px[2] == 0 {
slots[CLIPPED_LOW] += 1;
}
}
Histogram::from_slots(&slots).expect("slot count")
}
#[test]
fn a_flat_frame_puts_every_pixel_in_one_bin() {
// The arithmetic at its most checkable: 64x64 pixels of one value must
// produce exactly 4096 in exactly one bin and nothing anywhere else.
// A tile that failed to merge, or merged twice, changes this number —
// and a histogram that is merely "roughly right" is a histogram nobody
// can set a black point from.
let Some(ctx) = ctx() else { return };
let pass = HistogramPass::new(&ctx).expect("pass");
let rgba: Vec<u8> = std::iter::repeat_n([90u8, 140, 200, 255], 64 * 64)
.flatten()
.collect();
let tex = texture(&ctx, &rgba, 64, 64);
let hist = pass.compute(&tex).expect("compute");
assert_eq!(hist.pixels(), 4096);
assert_eq!(hist.red()[90], 4096);
assert_eq!(hist.green()[140], 4096);
assert_eq!(hist.blue()[200], 4096);
assert_eq!(
hist.red().iter().filter(|c| **c > 0).count(),
1,
"one value can only occupy one bin"
);
// (54*90 + 183*140 + 19*200) >> 8 = 34280 >> 8 = 133.
assert_eq!(hist.luma()[133], 4096);
assert_eq!(hist.clipped_highlights(), 0);
assert_eq!(hist.clipped_shadows(), 0);
}
#[test]
fn every_level_is_reachable_and_lands_where_it_belongs() {
// A ramp covering all 256 codes, four pixels each. This is the test
// that would catch an off-by-one in the quantisation — a `floor` where
// a rounding was needed shifts the whole ramp down one bin and leaves
// 255 empty, which on a real photograph looks like nothing at all.
let Some(ctx) = ctx() else { return };
let pass = HistogramPass::new(&ctx).expect("pass");
let (w, h) = (256u32, 4u32);
let mut rgba = Vec::with_capacity((w * h * 4) as usize);
for _ in 0..h {
for x in 0..w {
let v = x as u8;
rgba.extend_from_slice(&[v, v, v, 255]);
}
}
let tex = texture(&ctx, &rgba, w, h);
let hist = pass.compute(&tex).expect("compute");
assert_eq!(hist.pixels(), w * h);
for level in 0..BINS {
assert_eq!(
hist.red()[level],
h,
"level {level} should hold exactly {h} pixels"
);
// Neutral, so luma must land on the same bin as the channels do.
assert_eq!(hist.luma()[level], h, "luma drifted at level {level}");
}
}
#[test]
fn the_shader_agrees_with_a_cpu_count_of_the_same_frame() {
// The cross-check, on a frame with no structure for a wrong dispatch to
// hide behind: a size that is not a multiple of the 16x16 workgroup, so
// the edge tiles run off the image, and pseudo-random content so every
// bin is occupied unevenly. Exact equality — the reduction is integer
// throughout precisely so this can be an `assert_eq`, not a tolerance.
let Some(ctx) = ctx() else { return };
let pass = HistogramPass::new(&ctx).expect("pass");
let (w, h) = (101u32, 37u32);
let mut rgba = Vec::with_capacity((w * h * 4) as usize);
let mut state = 0x2545_F491_4F6C_DD1Du64;
for _ in 0..w * h {
for _ in 0..3 {
// xorshift64*, so the frame is identical on every machine and a
// failure can be reproduced rather than merely observed.
state ^= state >> 12;
state ^= state << 25;
state ^= state >> 27;
rgba.push((state.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 56) as u8);
}
rgba.push(255);
}
let tex = texture(&ctx, &rgba, w, h);
let hist = pass.compute(&tex).expect("compute");
assert_eq!(hist.pixels(), w * h, "an edge tile was dropped or doubled");
assert_eq!(hist, expected(&rgba));
}
#[test]
fn clipping_is_counted_per_pixel_and_not_per_channel() {
// The distinction the indicator rests on. A pixel with two channels at
// the ceiling is *one* clipped pixel; counting channels would report
// 200% of a frame clipped, and a percentage that can exceed 100 is a
// readout nobody will trust again.
let Some(ctx) = ctx() else { return };
let pass = HistogramPass::new(&ctx).expect("pass");
let rgba: Vec<u8> = [
// Two channels blown, one pixel clipped.
[255u8, 255, 10, 255],
// One channel blown — still clipped, which is the point of "any".
[255, 10, 10, 255],
// Clean.
[10, 10, 10, 255],
// Black in one channel only: a clipped shadow.
[0, 10, 10, 255],
]
.concat();
let tex = texture(&ctx, &rgba, 4, 1);
let hist = pass.compute(&tex).expect("compute");
assert_eq!(hist.pixels(), 4);
assert_eq!(hist.clipped_highlights(), 2);
assert_eq!(hist.clipped_shadows(), 1);
}
#[test]
fn a_second_frame_replaces_the_first_rather_than_adding_to_it() {
// The accumulator is reused, so a missing clear integrates every frame
// since the session opened. The symptom is subtle and awful: the
// histogram keeps its shape and simply stops responding to the sliders,
// because each frame's contribution shrinks against the running total.
let Some(ctx) = ctx() else { return };
let pass = HistogramPass::new(&ctx).expect("pass");
let dark: Vec<u8> = std::iter::repeat_n([40u8, 40, 40, 255], 16 * 16)
.flatten()
.collect();
let bright: Vec<u8> = std::iter::repeat_n([210u8, 210, 210, 255], 16 * 16)
.flatten()
.collect();
let first = pass.compute(&texture(&ctx, &dark, 16, 16)).expect("first");
assert_eq!(first.red()[40], 256);
let second = pass
.compute(&texture(&ctx, &bright, 16, 16))
.expect("second");
assert_eq!(second.pixels(), 256, "the previous frame was still counted");
assert_eq!(second.red()[40], 0);
assert_eq!(second.red()[210], 256);
}
}
+599
View File
@@ -0,0 +1,599 @@
//! GPU device and compute for DarkRoom.
//!
//! In v0.1 this exists to prove one thing: a compute shader can write a
//! texture that reaches the screen without a CPU round-trip (ARCH §6.1). It
//! holds no pipeline, no tiling, and no masks — those arrive in v0.2.
//!
//! Deliberately free of UI dependencies (ARCH §6.5a). The texture is handed
//! out as a `wgpu::Texture`; who composites it is not this crate's concern.
//!
//! That independence is why [`GpuContext::new_shared`] hands back the raw
//! instance and adapter rather than talking to a compositor itself: the
//! compositor will only sample a texture that came from the device *it* draws
//! with, so somebody has to make one device for both — but it does not have to
//! be this crate, and this crate must not know who it is.
use std::sync::Arc;
use wgpu::util::DeviceExt;
mod adjust;
mod demosaic;
mod detail;
mod error;
mod histogram;
mod mask;
mod readback;
mod segment;
pub use adjust::AdjustPass;
// The format the neighbourhood stage works in. Public because it is a promise
// rather than an implementation detail: a detail pass is guaranteed linear,
// unclipped, full internal precision (FR-DEV-2), and anyone reasoning about
// VRAM at 24 MP needs to know what an intermediate costs.
pub use detail::INTERMEDIATE_FORMAT as DETAIL_INTERMEDIATE_FORMAT;
pub use demosaic::{DemosaicedImage, Demosaicer};
pub use error::GpuError;
// Renamed on the way out: `BINS` says enough inside `histogram`, and nothing
// at all at a crate root shared with demosaic and segmentation.
pub use histogram::{Histogram, HistogramPass, BINS as HISTOGRAM_BINS};
pub use mask::{LabelField, MaskArray, MaskPass, SubjectMasks};
pub use segment::{SegmentOptions, SegmentPass, Segmentation};
/// Owns the wgpu device and queue.
///
/// One device is shared by the compute pipeline and the UI, which is what
/// allows compositing with no interop layer. Cloning is cheap and shares the
/// same underlying device.
#[derive(Clone)]
pub struct GpuContext {
pub device: Arc<wgpu::Device>,
pub queue: Arc<wgpu::Queue>,
adapter_info: wgpu::AdapterInfo,
}
/// TRACES: FR-DSP-1 | AC-8
/// One device, opened so that a compositor can be made to share it.
///
/// The texture the adjust pass writes only reaches the screen without a copy
/// if the compositor is drawing with the *same* `wgpu::Device` — two devices
/// are two address spaces, and a texture from one is not a texture the other
/// can sample. So the device cannot be an implementation detail of either
/// side; it has to be made once and handed to both.
///
/// [`Self::ctx`] is what the compute passes want. The instance and adapter are
/// what a compositor wants in order to adopt the same setup — Slint's
/// `WGPUConfiguration::Manual` asks for all four pieces — and they are handed
/// out raw rather than wrapped, because naming Slint here would put a UI
/// dependency in the one crate that must not have one (ARCH §6.5a).
pub struct SharedGpu {
/// The context every compute pass in this crate runs on.
pub ctx: GpuContext,
/// The instance the compositor will create its window surface from.
pub instance: wgpu::Instance,
/// The adapter [`Self::ctx`]'s device came from.
pub adapter: wgpu::Adapter,
}
impl GpuContext {
/// Create a headless context — no surface, no window.
///
/// Used by tests, by the examples, and by anything that only needs to
/// compute. A context opened this way cannot be shared with a compositor:
/// see [`Self::new_shared`] for that, and for why the difference matters.
pub async fn new_headless() -> Result<Self, GpuError> {
// GL is allowed alongside Vulkan here and nowhere else: a machine with
// no Vulkan loader should still run the tests, and a headless context
// never has to produce a window surface — which is precisely the thing
// the GL backend cannot do from an instance opened without a display
// handle.
Self::open(wgpu::Backends::VULKAN | wgpu::Backends::GL)
.await
.map(|shared| shared.ctx)
}
/// TRACES: FR-DSP-1 | AC-8
/// Open a device intended to be shared with the compositor.
///
/// Vulkan only, unlike [`Self::new_headless`]. The caller will hand the
/// instance to a compositor that has to create a *window surface* from it,
/// and wgpu's GL backend reaches its display through EGL at instance
/// creation — an instance opened without a display handle, which is the
/// only kind available before a window exists, cannot then produce a GL
/// surface. Vulkan takes the window handle at surface creation instead, so
/// it is the only backend this order of operations permits.
///
/// A machine with no Vulkan therefore gets no shared device, and the
/// caller is expected to carry on without the develop path rather than
/// refuse to start.
pub async fn new_shared() -> Result<SharedGpu, GpuError> {
// Vulkan on both targets (D1), and here it is not merely the
// preference — see above.
Self::open(wgpu::Backends::VULKAN).await
}
async fn open(backends: wgpu::Backends) -> Result<SharedGpu, GpuError> {
// `new_without_display_handle` rather than a struct literal: the
// descriptor carries a boxed display handle and so has no `Default`,
// and there is no window yet to take one from in either case.
let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle();
descriptor.backends = backends;
let instance = wgpu::Instance::new(descriptor);
let adapter = instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
compatible_surface: None,
force_fallback_adapter: false,
})
.await
// A `Result` since wgpu 24, where it was an `Option`. The error
// says which backends were tried, which is worth more than the
// bare "no adapter" this used to report.
.map_err(|_| GpuError::NoAdapter)?;
let adapter_info = adapter.get_info();
log::info!(
"gpu: {} ({:?}, {:?})",
adapter_info.name,
adapter_info.device_type,
adapter_info.backend
);
let (device, queue) = adapter
.request_device(&wgpu::DeviceDescriptor {
label: Some("darkroom-device"),
required_features: wgpu::Features::empty(),
// Defaults, not `downlevel_defaults`: storage textures
// in compute shaders are required, and the downlevel tier
// does not guarantee them. This is effectively our GPU
// floor (NFR-COMPAT-1).
//
// `using_resolution` raises only the texture-dimension limits,
// to whatever this adapter actually offers. That matters once
// a compositor shares this device: the default ceiling is
// 8192, and a swapchain image for a large or scaled display
// can exceed it — a limit we chose for our own compute passes
// would otherwise silently cap somebody else's window.
required_limits: wgpu::Limits::default().using_resolution(adapter.limits()),
memory_hints: wgpu::MemoryHints::Performance,
// Nothing behind a feature flag wgpu itself calls unstable —
// the pipeline is ordinary compute and storage textures.
experimental_features: wgpu::ExperimentalFeatures::disabled(),
// The API trace, absorbed into the descriptor in wgpu 25 from
// the second argument this call used to take.
trace: wgpu::Trace::Off,
})
.await
.map_err(|e| GpuError::DeviceRequest(e.to_string()))?;
Ok(SharedGpu {
ctx: Self {
device: Arc::new(device),
queue: Arc::new(queue),
adapter_info,
},
instance,
adapter,
})
}
/// Build a context from a device and queue owned by someone else — the
/// path used when Slint has already created them.
pub fn from_parts(
device: Arc<wgpu::Device>,
queue: Arc<wgpu::Queue>,
adapter_info: wgpu::AdapterInfo,
) -> Self {
Self {
device,
queue,
adapter_info,
}
}
pub fn adapter_name(&self) -> &str {
&self.adapter_info.name
}
pub fn backend(&self) -> wgpu::Backend {
self.adapter_info.backend
}
}
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
struct Params {
width: u32,
height: u32,
phase: f32,
_pad: f32,
}
/// A compute pass writing into a storage texture.
///
/// Stands in for the develop pipeline in v0.1. What matters is the shape:
/// compute writes a texture, the texture is handed to the compositor, and
/// pixels never travel back through the CPU.
/// TRACES: FR-DEV-4 | R4
pub struct RenderTarget {
ctx: GpuContext,
texture: wgpu::Texture,
view: wgpu::TextureView,
pipeline: wgpu::ComputePipeline,
bind_group_layout: wgpu::BindGroupLayout,
bind_group: wgpu::BindGroup,
params_buf: wgpu::Buffer,
width: u32,
height: u32,
/// Reused staging buffer for the temporary readback path. Allocating one
/// per frame is a significant cost at large window sizes.
#[cfg(any(test, feature = "readback"))]
readback_buf: std::cell::RefCell<Option<(wgpu::Buffer, u32)>>,
}
impl RenderTarget {
pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
pub fn new(ctx: &GpuContext, width: u32, height: u32) -> Result<Self, GpuError> {
let (width, height) = (width.max(1), height.max(1));
let shader = ctx
.device
.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("gradient"),
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/gradient.wgsl").into()),
});
let bind_group_layout =
ctx.device
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("render-target-bgl"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::StorageTexture {
access: wgpu::StorageTextureAccess::WriteOnly,
format: Self::FORMAT,
view_dimension: wgpu::TextureViewDimension::D2,
},
count: None,
},
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,
},
],
});
let layout = ctx
.device
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("render-target-layout"),
bind_group_layouts: &[Some(&bind_group_layout)],
immediate_size: 0,
});
let pipeline = ctx
.device
.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("gradient-pipeline"),
layout: Some(&layout),
module: &shader,
entry_point: Some("main"),
compilation_options: Default::default(),
cache: None,
});
let params_buf = ctx
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("params"),
contents: bytemuck::bytes_of(&Params {
width,
height,
phase: 0.0,
_pad: 0.0,
}),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
});
let (texture, view) = Self::create_texture(ctx, width, height);
let bind_group = Self::create_bind_group(ctx, &bind_group_layout, &view, &params_buf);
Ok(Self {
ctx: ctx.clone(),
texture,
view,
pipeline,
bind_group_layout,
bind_group,
params_buf,
width,
height,
#[cfg(any(test, feature = "readback"))]
readback_buf: std::cell::RefCell::new(None),
})
}
fn create_texture(
ctx: &GpuContext,
width: u32,
height: u32,
) -> (wgpu::Texture, wgpu::TextureView) {
let texture = ctx.device.create_texture(&wgpu::TextureDescriptor {
label: Some("render-target"),
size: wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: Self::FORMAT,
// STORAGE_BINDING to write from compute; TEXTURE_BINDING so the
// compositor can sample it. COPY_SRC exists only for tests —
// production never reads this back (ARCH §6.1).
//
// RENDER_ATTACHMENT is not something this pass ever uses. It is
// there because Slint refuses to import a texture without it
// (`TextureImportError::InvalidUsage`), the compositor having to
// assume it may need to draw into what it was given. Declaring an
// unused capability costs an allocation flag and buys the whole
// zero-copy path, so it is a cheap price for AC-8.
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
(texture, view)
}
fn create_bind_group(
ctx: &GpuContext,
layout: &wgpu::BindGroupLayout,
view: &wgpu::TextureView,
params: &wgpu::Buffer,
) -> wgpu::BindGroup {
ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("render-target-bg"),
layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: params.as_entire_binding(),
},
],
})
}
/// Resize, reallocating the texture. No-op when unchanged.
pub fn resize(&mut self, width: u32, height: u32) {
let (width, height) = (width.max(1), height.max(1));
if width == self.width && height == self.height {
return;
}
let (texture, view) = Self::create_texture(&self.ctx, width, height);
self.bind_group =
Self::create_bind_group(&self.ctx, &self.bind_group_layout, &view, &self.params_buf);
self.texture = texture;
self.view = view;
self.width = width;
self.height = height;
#[cfg(any(test, feature = "readback"))]
{
// Size changed, so the staging buffer no longer fits.
*self.readback_buf.borrow_mut() = None;
}
}
/// Run the compute pass. Results stay on the GPU.
pub fn render(&self, phase: f32) {
self.ctx.queue.write_buffer(
&self.params_buf,
0,
bytemuck::bytes_of(&Params {
width: self.width,
height: self.height,
phase,
_pad: 0.0,
}),
);
let mut enc = self
.ctx
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("render-encoder"),
});
{
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("gradient-pass"),
timestamp_writes: None,
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &self.bind_group, &[]);
// 8x8 workgroups, rounded up so edge pixels are covered.
pass.dispatch_workgroups(self.width.div_ceil(8), self.height.div_ceil(8), 1);
}
self.ctx.queue.submit(Some(enc.finish()));
}
pub fn texture(&self) -> &wgpu::Texture {
&self.texture
}
pub fn view(&self) -> &wgpu::TextureView {
&self.view
}
pub fn size(&self) -> (u32, u32) {
(self.width, self.height)
}
/// Read pixels back to the CPU.
///
/// **Tests only.** Production code must never call this — it is exactly
/// the round-trip ARCH §6.1 forbids, and AC-8 asserts it does not happen.
#[cfg(any(test, feature = "readback"))]
pub async fn read_pixels(&self) -> Result<Vec<u8>, GpuError> {
// Buffer rows must be aligned to COPY_BYTES_PER_ROW_ALIGNMENT (256).
let unpadded = self.width * 4;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
let padded = unpadded.div_ceil(align) * align;
let needed = (padded * self.height) as u64;
let mut slot = self.readback_buf.borrow_mut();
if slot.as_ref().map(|(_, p)| *p) != Some(padded) {
*slot = Some((
self.ctx.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: needed,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
}),
padded,
));
}
let buf = &slot.as_ref().unwrap().0;
let mut enc = self.ctx.device.create_command_encoder(&Default::default());
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: &self.texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded),
rows_per_image: Some(self.height),
},
},
wgpu::Extent3d {
width: self.width,
height: self.height,
depth_or_array_layers: 1,
},
);
self.ctx.queue.submit(Some(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
// Fallible since wgpu 26, and worth propagating rather than ignoring:
// the failure it reports is a lost device (NFR-R7), and without this
// the map callback below simply never arrives and the error surfaces
// as a timeout somewhere less informative.
self.ctx
.device
.poll(wgpu::PollType::wait_indefinitely())
.map_err(|e| GpuError::Readback(e.to_string()))?;
rx.recv()
.map_err(|e| GpuError::Readback(e.to_string()))?
.map_err(|e| GpuError::Readback(e.to_string()))?;
// Strip row padding.
let data = slice.get_mapped_range();
let mut out = Vec::with_capacity((unpadded * self.height) as usize);
for row in 0..self.height {
let start = (row * padded) as usize;
out.extend_from_slice(&data[start..start + unpadded as usize]);
}
drop(data);
buf.unmap();
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx() -> Option<GpuContext> {
// CI runners and headless machines may have no usable adapter. Skip
// rather than fail — the device-dependent assertions still run
// wherever a GPU exists.
match pollster::block_on(GpuContext::new_headless()) {
Ok(c) => Some(c),
Err(e) => {
eprintln!("skipping: no GPU adapter ({e})");
None
}
}
}
#[test]
fn compute_writes_the_texture() {
let Some(ctx) = ctx() else { return };
let rt = RenderTarget::new(&ctx, 64, 64).expect("render target");
rt.render(0.0);
let px = pollster::block_on(rt.read_pixels()).expect("readback");
assert_eq!(px.len(), 64 * 64 * 4);
// The shader writes opaque pixels everywhere; an all-zero buffer would
// mean the dispatch silently did nothing.
assert!(
px.chunks_exact(4).all(|p| p[3] == 255),
"every pixel should be opaque"
);
assert!(
px.iter().any(|&b| b != 0),
"texture should not be uniformly zero"
);
}
#[test]
fn phase_changes_output() {
let Some(ctx) = ctx() else { return };
let rt = RenderTarget::new(&ctx, 32, 32).expect("render target");
rt.render(0.0);
let a = pollster::block_on(rt.read_pixels()).expect("readback");
rt.render(std::f32::consts::PI);
let b = pollster::block_on(rt.read_pixels()).expect("readback");
assert_ne!(a, b, "moving the highlight should change the image");
}
#[test]
fn resize_reallocates() {
let Some(ctx) = ctx() else { return };
let mut rt = RenderTarget::new(&ctx, 16, 16).expect("render target");
assert_eq!(rt.size(), (16, 16));
rt.resize(48, 24);
assert_eq!(rt.size(), (48, 24));
rt.render(0.0);
let px = pollster::block_on(rt.read_pixels()).expect("readback");
assert_eq!(px.len(), 48 * 24 * 4);
}
#[test]
fn zero_size_is_clamped() {
let Some(ctx) = ctx() else { return };
// A minimised window reports zero; texture creation would panic.
let rt = RenderTarget::new(&ctx, 0, 0).expect("render target");
assert_eq!(rt.size(), (1, 1));
}
}
File diff suppressed because it is too large Load Diff
+94
View File
@@ -0,0 +1,94 @@
//! Waiting for a buffer mapping without parking the interface.
//!
//! Shared by every transfer off the device — the display bridge, an export, and
//! the histogram's 4 KB of bin counts. It was written once inside `AdjustPass`
//! and the reasoning below is the whole of why it is shaped this way; copying
//! thirty lines of that reasoning into a second caller would have left two
//! copies to keep true of each other.
use crate::{GpuContext, GpuError};
/// How many non-blocking polls a readback gets before it is called failed.
///
/// A bound rather than a spin forever: if the device is lost the map callback
/// never arrives, and an unbounded loop would hang the interface rather than
/// surfacing the error. Set far above any plausible completion — the copies
/// this waits on are milliseconds — so it is reached only when something is
/// wrong.
/// How long a readback may take before it is called failed.
///
/// **A deadline, not an iteration count, and the difference was a real bug.**
/// This was 100,000 non-blocking polls, which sounds generous and is not: a
/// `Poll` that finds nothing returns immediately, so the loop burned through
/// the whole budget in a few milliseconds. Small transfers — the histogram's
/// 4 KB, a viewport-sized frame — happened to complete inside it. A
/// full-resolution export did not: 5472×3648 is 80 MB, and every attempt
/// failed with "readback did not complete" while the copy was still perfectly
/// healthy.
///
/// Generous, because the legitimate worst case is a large export on a slow
/// integrated GPU, and the only thing this bound exists to catch is a lost
/// device that will never deliver the callback at all.
const READBACK_DEADLINE: std::time::Duration = std::time::Duration::from_secs(30);
/// How long to wait between polls once the first few have found nothing.
///
/// Without it this is a busy spin that saturates a core for the duration of
/// the copy. A millisecond is far below the transfer times involved and keeps
/// the thread available to the scheduler.
const POLL_PAUSE: std::time::Duration = std::time::Duration::from_millis(1);
/// Drive the device until a `map_async` callback lands.
///
/// **Polled without blocking, then checked.**
///
/// `PollType::Wait` parks the calling thread until the GPU has finished, and
/// these transfers are called from the UI thread — so that park was a frozen
/// interface for the duration of the copy (~7 ms at 4K for a whole frame).
/// `Poll` drives the same callbacks without sleeping, so the loop below stays
/// interruptible and the mapping still completes.
///
/// The bounded spin matters: a lost device would otherwise never deliver the
/// callback and this would hang the app instead of reporting an error.
pub(crate) fn await_mapping(
ctx: &GpuContext,
rx: &std::sync::mpsc::Receiver<Result<(), wgpu::BufferAsyncError>>,
) -> Result<(), GpuError> {
let mut mapped = None;
let deadline = std::time::Instant::now() + READBACK_DEADLINE;
let mut spins = 0u32;
while std::time::Instant::now() < deadline {
// A poll error is a lost device, which is exactly the case the bounded
// spin exists to escape — returning here reports it immediately rather
// than spinning out the full limit first.
ctx.device
.poll(wgpu::PollType::Poll)
.map_err(|e| GpuError::Readback(e.to_string()))?;
match rx.try_recv() {
Ok(r) => {
mapped = Some(r);
break;
}
Err(std::sync::mpsc::TryRecvError::Empty) => {
// The first handful of polls run flat out, so a small
// transfer — the histogram's, most of all — still returns
// without ever sleeping. Only a copy that is genuinely going
// to take a while pays the pause.
spins += 1;
if spins > 64 {
std::thread::sleep(POLL_PAUSE);
}
continue;
}
Err(e) => return Err(GpuError::Readback(e.to_string())),
}
}
mapped
.ok_or_else(|| {
GpuError::Readback(format!(
"readback did not complete within {}s",
READBACK_DEADLINE.as_secs()
))
})?
.map_err(|e| GpuError::Readback(e.to_string()))
}
+847
View File
@@ -0,0 +1,847 @@
//! Watershed segmentation — arm A's GPU half (S15, docs/segmentation.md).
//!
//! Runs the five passes in `shaders/watershed.wgsl` over a demosaiced image
//! and leaves a basin label per pixel on the GPU. The hierarchy built from
//! those labels lives in [`dr_segment`], which needs no device.
//!
//! # Cost
//!
//! Every pass is a trivial kernel and the whole chain is a handful of
//! milliseconds at proxy resolution. It runs **once per image**, off the
//! interactive path — the point of precomputing a region map is that
//! selection afterwards is a label comparison rather than a flood fill.
//!
//! # The open question this leaves
//!
//! [`Segmentation::read_field`] copies the label and gradient buffers back to
//! the CPU to build the region adjacency graph, behind the `segment-readback`
//! feature. That is deliberately **not** the `readback` switch guarding the
//! display round-trip: this transfer is once per image on a worker, where the
//! one AC-8 forbids is per frame in the render loop, and sharing a switch
//! would force a build wanting local masking to unlock the other.
//!
//! It is still a real cost and still unfinished. F3 in docs/segmentation.md
//! §12 stands: the adjacency accumulation belongs GPU-side with atomics, and
//! until it moves there every segmentation pays a full-resolution transfer.
//! Read the feature name as a description of a known gap rather than as
//! permission.
use wgpu::util::DeviceExt;
use crate::{DemosaicedImage, GpuContext, GpuError};
/// How the watershed is tuned for one image.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SegmentOptions {
/// Longest proxy edge. The segmentation runs here, not at sensor
/// resolution: a 24 MP watershed costs 12× the memory to place boundaries
/// a person cannot see, and the boundary refinement that matters at 1:1
/// is a separate stage (docs/segmentation.md §4).
pub max_edge: u32,
/// Pre-smoothing radius in proxy pixels. The caller's to raise with ISO —
/// this is the single knob that decides whether a noisy file segments
/// into regions or into grain.
pub blur_radius: i32,
pub w_luma: f32,
pub w_chroma: f32,
/// How far the lower-completion carries a distance inward from a
/// plateau's rim, in breadth-first steps.
///
/// Bounds the widest plateau that resolves fully. Beyond it, the interior
/// keeps the behaviour it had before the pass existed — a fan of diagonal
/// chains — so this trades dispatches against the size of flat area the
/// watershed handles cleanly, and never against correctness elsewhere.
pub plateau_iterations: u32,
}
impl Default for SegmentOptions {
fn default() -> Self {
Self {
// ~1.3 MP at 3:2. Large enough that a boundary is within a pixel
// or two of where it belongs, small enough that the whole chain
// fits comfortably in memory on a phone.
max_edge: 1600,
blur_radius: 2,
w_luma: 1.0,
// Chroma carries most of the sensor noise and few of the
// boundaries anyone would draw, so it counts for less — but not
// zero, or a red flower on green leaves has no edge at all.
w_chroma: 0.5,
// **Zero: the pass is off.** It is implemented, dispatched
// correctly and measurably changes nothing — see the ignored test
// below and §12 of docs/segmentation.md. Until that is understood,
// running it would buy 64 dispatches per segmentation and no
// improvement, so the default declines to pay.
plateau_iterations: 0,
}
}
}
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
struct SegParams {
width: u32,
height: u32,
src_width: u32,
src_height: u32,
blur_radius: i32,
non_linear: u32,
w_luma: f32,
w_chroma: f32,
}
/// One compute stage: its layout and its compiled pipeline.
struct Stage {
layout: wgpu::BindGroupLayout,
pipeline: wgpu::ComputePipeline,
}
/// Runs the watershed chain.
pub struct SegmentPass {
ctx: GpuContext,
features: Stage,
blur: Stage,
gradient: Stage,
plateau_init: Stage,
plateau_step: Stage,
flow: Stage,
jump: Stage,
}
impl SegmentPass {
pub fn new(ctx: &GpuContext) -> Result<Self, GpuError> {
// A validation failure here is a bug in the shader, not a user error.
// Surfaced as a Result rather than wgpu's default panic, matching how
// `AdjustPass` handles its generated source.
let scope = ctx.device.push_error_scope(wgpu::ErrorFilter::Validation);
let module = ctx
.device
.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("watershed"),
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/watershed.wgsl").into()),
});
let features = {
let layout = ctx
.device
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("watershed-features-bgl"),
entries: &[
uniform_entry(0),
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
storage_entry(2, false),
],
});
let pipeline = compute(ctx, &module, &layout, "features");
Stage { layout, pipeline }
};
let buffer_stage = |in_binding: u32, out_binding: u32, entry: &str, label: &str| {
let layout = ctx
.device
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some(label),
entries: &[
uniform_entry(0),
storage_entry(in_binding, true),
storage_entry(out_binding, false),
],
});
let pipeline = compute(ctx, &module, &layout, entry);
Stage { layout, pipeline }
};
// Three bindings rather than two: these read the gradient *and* a
// distance field, and write a second one.
let triple_stage = |a: u32, b: u32, c: u32, entry: &str, label: &str| {
let layout = ctx
.device
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some(label),
entries: &[
uniform_entry(0),
storage_entry(a, true),
storage_entry(b, true),
storage_entry(c, false),
],
});
let pipeline = compute(ctx, &module, &layout, entry);
Stage { layout, pipeline }
};
let blur = buffer_stage(3, 4, "blur", "watershed-blur-bgl");
let gradient = buffer_stage(5, 6, "gradient", "watershed-gradient-bgl");
let plateau_init = buffer_stage(7, 8, "plateau_init", "watershed-pinit-bgl");
let plateau_step = triple_stage(9, 10, 11, "plateau_step", "watershed-pstep-bgl");
let flow = triple_stage(12, 13, 14, "flow", "watershed-flow-bgl");
let jump = buffer_stage(15, 16, "jump", "watershed-jump-bgl");
if let Some(err) = pollster::block_on(scope.pop()) {
return Err(GpuError::ShaderCompilation(err.to_string()));
}
Ok(Self {
ctx: ctx.clone(),
features,
blur,
gradient,
plateau_init,
plateau_step,
flow,
jump,
})
}
/// Segment an image into basins.
pub fn run(
&self,
source: &DemosaicedImage,
opts: SegmentOptions,
) -> Result<Segmentation, GpuError> {
let (src_w, src_h) = source.size();
let (width, height) = proxy_size(src_w, src_h, opts.max_edge);
let n = (width * height) as u64;
let params = self
.ctx
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("watershed-params"),
contents: bytemuck::bytes_of(&SegParams {
width,
height,
src_width: src_w,
src_height: src_h,
blur_radius: opts.blur_radius,
non_linear: u32::from(source.is_non_linear()),
w_luma: opts.w_luma,
w_chroma: opts.w_chroma,
}),
usage: wgpu::BufferUsages::UNIFORM,
});
// `vec4` rather than `vec3` for the feature buffers: a WGSL storage
// array of vec3 still strides by 16 bytes, so packing to three floats
// would save nothing and cost an index calculation.
let feat_a = self.buffer("watershed-feat-a", n * 16, false);
let feat_b = self.buffer("watershed-feat-b", n * 16, false);
let gradient = self.buffer("watershed-gradient", n * 4, true);
let dist_a = self.buffer("watershed-dist-a", n * 4, false);
let dist_b = self.buffer("watershed-dist-b", n * 4, false);
let parent_a = self.buffer("watershed-parent-a", n * 4, true);
let parent_b = self.buffer("watershed-parent-b", n * 4, true);
let mut enc = self
.ctx
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("watershed-encoder"),
});
let groups = (width.div_ceil(8), height.div_ceil(8));
let features_bg = self
.ctx
.device
.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("watershed-features-bg"),
layout: &self.features.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: params.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(source.view()),
},
wgpu::BindGroupEntry {
binding: 2,
resource: feat_a.as_entire_binding(),
},
],
});
let blur_bg = self.bind(&self.blur.layout, &params, 3, &feat_a, 4, &feat_b);
let gradient_bg = self.bind(&self.gradient.layout, &params, 5, &feat_b, 6, &gradient);
let pinit_bg = self.bind(&self.plateau_init.layout, &params, 7, &gradient, 8, &dist_a);
let pstep_ab = self.bind3(
&self.plateau_step.layout,
&params,
(9, &gradient),
(10, &dist_a),
(11, &dist_b),
);
let pstep_ba = self.bind3(
&self.plateau_step.layout,
&params,
(9, &gradient),
(10, &dist_b),
(11, &dist_a),
);
// An odd number of plateau steps leaves the distance field in B.
let plateau_steps = opts.plateau_iterations;
let final_dist = if plateau_steps.is_multiple_of(2) {
&dist_a
} else {
&dist_b
};
let flow_bg = self.bind3(
&self.flow.layout,
&params,
(12, &gradient),
(13, final_dist),
(14, &parent_a),
);
let jump_ab = self.bind(&self.jump.layout, &params, 15, &parent_a, 16, &parent_b);
let jump_ba = self.bind(&self.jump.layout, &params, 15, &parent_b, 16, &parent_a);
// Pointer jumping halves every path per pass, so log2 of the pixel
// count bounds it — that is the longest possible descent chain. A
// convergence test would cost a readback per iteration to save a
// handful of dispatches of a two-line kernel.
let jumps = (n as f64).log2().ceil() as u32 + 1;
{
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("watershed-pass"),
timestamp_writes: None,
});
for (pipeline, bg) in [
(&self.features.pipeline, &features_bg),
(&self.blur.pipeline, &blur_bg),
(&self.gradient.pipeline, &gradient_bg),
(&self.plateau_init.pipeline, &pinit_bg),
] {
pass.set_pipeline(pipeline);
pass.set_bind_group(0, bg, &[]);
pass.dispatch_workgroups(groups.0, groups.1, 1);
}
pass.set_pipeline(&self.plateau_step.pipeline);
for i in 0..plateau_steps {
let bg = if i % 2 == 0 { &pstep_ab } else { &pstep_ba };
pass.set_bind_group(0, bg, &[]);
pass.dispatch_workgroups(groups.0, groups.1, 1);
}
pass.set_pipeline(&self.flow.pipeline);
pass.set_bind_group(0, &flow_bg, &[]);
pass.dispatch_workgroups(groups.0, groups.1, 1);
pass.set_pipeline(&self.jump.pipeline);
for i in 0..jumps {
let bg = if i % 2 == 0 { &jump_ab } else { &jump_ba };
pass.set_bind_group(0, bg, &[]);
pass.dispatch_workgroups(groups.0, groups.1, 1);
}
}
self.ctx.queue.submit(Some(enc.finish()));
// An odd number of jumps leaves the result in B.
let labels = if jumps % 2 == 1 { parent_b } else { parent_a };
Ok(Segmentation {
ctx: self.ctx.clone(),
width,
height,
labels,
gradient,
})
}
fn buffer(&self, label: &str, size: u64, copyable: bool) -> wgpu::Buffer {
let mut usage = wgpu::BufferUsages::STORAGE;
if copyable {
usage |= wgpu::BufferUsages::COPY_SRC;
}
self.ctx.device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size,
usage,
mapped_at_creation: false,
})
}
fn bind3(
&self,
layout: &wgpu::BindGroupLayout,
params: &wgpu::Buffer,
a: (u32, &wgpu::Buffer),
b: (u32, &wgpu::Buffer),
c: (u32, &wgpu::Buffer),
) -> wgpu::BindGroup {
self.ctx
.device
.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("watershed-bg3"),
layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: params.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: a.0,
resource: a.1.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: b.0,
resource: b.1.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: c.0,
resource: c.1.as_entire_binding(),
},
],
})
}
fn bind(
&self,
layout: &wgpu::BindGroupLayout,
params: &wgpu::Buffer,
in_binding: u32,
input: &wgpu::Buffer,
out_binding: u32,
output: &wgpu::Buffer,
) -> wgpu::BindGroup {
self.ctx
.device
.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("watershed-bg"),
layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: params.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: in_binding,
resource: input.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: out_binding,
resource: output.as_entire_binding(),
},
],
})
}
}
/// The result of one segmentation: a basin label per pixel, on the GPU.
pub struct Segmentation {
/// Only [`Self::read_field`] reads this, so a build without `readback`
/// carries it unread. That is now the ordinary build: dr-ui used to turn
/// the feature on for the whole workspace and stopped when S1 removed the
/// display readback, which is what made the field look dead.
#[cfg_attr(not(any(test, feature = "readback")), allow(dead_code))]
ctx: GpuContext,
width: u32,
height: u32,
/// Per pixel, the linear index of its basin root. Sparse — compacted by
/// [`dr_segment::RegionField::from_roots`].
labels: wgpu::Buffer,
/// As with `ctx` above: read only by [`Self::read_field`].
#[cfg_attr(not(any(test, feature = "readback")), allow(dead_code))]
gradient: wgpu::Buffer,
}
impl Segmentation {
pub fn size(&self) -> (u32, u32) {
(self.width, self.height)
}
/// The label buffer, for a shader that masks by region id.
pub fn labels(&self) -> &wgpu::Buffer {
&self.labels
}
/// Build the region adjacency graph, reading the labels back to the CPU.
///
/// # Why this has its own feature rather than sharing `readback`
///
/// `readback` gates [`crate::AdjustPass::read_pixels`], which is the
/// per-frame display round-trip AC-8 exists to forbid. This is a different
/// transfer with different economics, and sharing one switch would have
/// forced a build wanting local masking to also unlock the one thing the
/// architecture is built around never doing.
///
/// What this transfer actually is: **once per image, on a worker, off the
/// frame path.** Nothing in the render loop waits on it, and the result is
/// a region graph of a few thousand nodes that every later interaction
/// reads from the CPU anyway.
///
/// What it is *not* is finished. F3 in docs/segmentation.md §12 stands:
/// the adjacency accumulation belongs on the GPU with atomics, and until
/// it moves there a segmentation costs one full-resolution transfer of the
/// label and gradient buffers. That is a real cost on a phone and the
/// reason this is named for what it does rather than hidden behind the
/// general switch.
#[cfg(any(test, feature = "segment-readback"))]
pub fn read_field(&self) -> Result<dr_segment::RegionField, GpuError> {
let n = (self.width * self.height) as usize;
let roots: Vec<u32> = read_buffer(&self.ctx, &self.labels, n)?;
let gradient: Vec<f32> = read_buffer(&self.ctx, &self.gradient, n)?;
Ok(dr_segment::RegionField::from_roots(
&roots,
&gradient,
self.width as usize,
self.height as usize,
))
}
}
fn uniform_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}
}
fn storage_entry(binding: u32, read_only: bool) -> wgpu::BindGroupLayoutEntry {
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,
}
}
fn compute(
ctx: &GpuContext,
module: &wgpu::ShaderModule,
layout: &wgpu::BindGroupLayout,
entry: &str,
) -> wgpu::ComputePipeline {
let pipeline_layout = ctx
.device
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("watershed-layout"),
bind_group_layouts: &[Some(layout)],
immediate_size: 0,
});
ctx.device
.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some(entry),
layout: Some(&pipeline_layout),
module,
entry_point: Some(entry),
compilation_options: Default::default(),
cache: None,
})
}
/// The proxy size for a source, preserving aspect and never upscaling.
fn proxy_size(src_w: u32, src_h: u32, max_edge: u32) -> (u32, u32) {
let longest = src_w.max(src_h);
if longest <= max_edge || longest == 0 {
return (src_w.max(1), src_h.max(1));
}
let scale = f64::from(max_edge) / f64::from(longest);
(
((f64::from(src_w) * scale).round() as u32).max(1),
((f64::from(src_h) * scale).round() as u32).max(1),
)
}
#[cfg(any(test, feature = "segment-readback"))]
fn read_buffer<T: bytemuck::Pod>(
ctx: &GpuContext,
buffer: &wgpu::Buffer,
len: usize,
) -> Result<Vec<T>, GpuError> {
let size = (len * std::mem::size_of::<T>()) as u64;
let staging = ctx.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("watershed-readback"),
size,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut enc = ctx.device.create_command_encoder(&Default::default());
enc.copy_buffer_to_buffer(buffer, 0, &staging, 0, size);
ctx.queue.submit(Some(enc.finish()));
let slice = staging.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
ctx.device
.poll(wgpu::PollType::wait_indefinitely())
.map_err(|e| GpuError::Readback(e.to_string()))?;
rx.recv()
.map_err(|e| GpuError::Readback(e.to_string()))?
.map_err(|e| GpuError::Readback(e.to_string()))?;
let data = slice.get_mapped_range();
let out = bytemuck::cast_slice::<u8, T>(&data).to_vec();
drop(data);
staging.unmap();
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use dr_segment::MergeTree;
fn ctx() -> Option<GpuContext> {
match pollster::block_on(GpuContext::new_headless()) {
Ok(c) => Some(c),
Err(e) => {
eprintln!("skipping: no GPU adapter ({e})");
None
}
}
}
#[test]
fn a_proxy_preserves_aspect_and_never_upscales() {
assert_eq!(proxy_size(6000, 4000, 1600), (1600, 1067));
assert_eq!(proxy_size(4000, 6000, 1600), (1067, 1600));
// A thumbnail must not be blown up to the proxy size — there is no
// detail there to find basins in.
assert_eq!(proxy_size(800, 600, 1600), (800, 600));
assert_eq!(proxy_size(0, 0, 1600), (1, 1));
}
/// Two flat halves split by a hard vertical edge.
fn two_tone(w: u32, h: u32) -> Vec<u8> {
let mut px = Vec::with_capacity((w * h * 4) as usize);
for _ in 0..h {
for x in 0..w {
let v = if x < w / 2 { 30u8 } else { 220u8 };
px.extend_from_slice(&[v, v, v, 255]);
}
}
px
}
#[test]
fn a_hard_edge_produces_two_regions_at_the_top_of_the_ladder() {
// The end-to-end property, on an image whose answer is not in doubt:
// whatever the watershed does with texture, it must not lose an edge
// this obvious, and the coarsest non-trivial cut must be exactly the
// two halves.
let Some(ctx) = ctx() else { return };
let (w, h) = (64u32, 64u32);
let src = DemosaicedImage::from_rgba8(&ctx, &two_tone(w, h), w, h).expect("source");
let pass = SegmentPass::new(&ctx).expect("segment pass");
let seg = pass.run(&src, SegmentOptions::default()).expect("run");
assert_eq!(seg.size(), (w, h));
let field = seg.read_field().expect("read field");
let tree = MergeTree::build(&field);
let px = field.apply(&tree.cut_to(2));
for y in 0..h as usize {
let left = px[y * w as usize];
let right = px[y * w as usize + w as usize - 1];
assert_ne!(left, right, "the two halves must not share a region");
}
}
#[test]
fn a_flat_image_does_not_fragment() {
// The noise case in miniature. A gradient of zero everywhere is one
// enormous plateau, which is exactly where a watershed without a
// strict tie-break either hangs or shatters into per-pixel basins.
let Some(ctx) = ctx() else { return };
let (w, h) = (32u32, 32u32);
let flat = vec![128u8; (w * h * 4) as usize];
let src = DemosaicedImage::from_rgba8(&ctx, &flat, w, h).expect("source");
let pass = SegmentPass::new(&ctx).expect("segment pass");
let seg = pass.run(&src, SegmentOptions::default()).expect("run");
let field = seg.read_field().expect("read field");
assert_eq!(
field.region_count, 1,
"a plateau should resolve to one basin, not {}",
field.region_count
);
}
/// A flat disc on flat ground: two plateaux and one boundary between
/// them. Nothing here has a downhill direction except at the rim.
/// A linear ramp between two flat fields.
///
/// The watershed runs on gradient *magnitude*, and that changes which
/// images contain a plateau worth resolving. A flat region of the picture
/// has gradient zero — the global minimum — and a plateau at the minimum
/// has no descending exit at all, which makes it a single basin by
/// definition with nothing for lower-completion to do. The plateaux that
/// do have an exit are regions of constant *non-zero* gradient: linear
/// ramps. So that is what this builds.
fn ramp(w: u32, h: u32) -> Vec<u8> {
let mut px = vec![0u8; (w * h * 4) as usize];
let (lo, hi) = (w / 4, w - w / 4);
for y in 0..h {
for x in 0..w {
let v = if x < lo {
40u8
} else if x >= hi {
210u8
} else {
// Constant slope, so the gradient is constant and
// non-zero across the whole band.
(40.0 + (x - lo) as f32 * (170.0 / (hi - lo) as f32)) as u8
};
let i = ((y * w + x) * 4) as usize;
px[i] = v;
px[i + 1] = v;
px[i + 2] = v;
px[i + 3] = 255;
}
}
px
}
#[test]
#[ignore = "the plateau pass is a measured no-op; see docs/segmentation.md §12"]
fn lower_completion_drains_a_plateau_instead_of_shattering_it() {
// F1, asserted rather than eyeballed, and asserted at the level where
// it matters.
//
// Two claims, because they are different claims. First: carrying the
// distance inward genuinely reduces fragmentation — a plateau with an
// exit now drains to it instead of fanning into diagonal chains.
// Second, and the one a user would notice: whatever fragments survive
// are separated by zero-height saddles, so the hierarchy merges them
// at its very first steps and the plateau reads as one region.
//
// The second claim is what makes the first one's *residue* tolerable.
// A perfectly flat regional minimum — the inside of a uniform disc,
// with no exit anywhere — cannot be drained by a distance that has
// nowhere to descend to, and collapsing it fully would need connected
// component labelling rather than a local rule. It is not worth it:
// see docs/segmentation.md §12.
let Some(ctx) = ctx() else { return };
let (w, h) = (96u32, 96u32);
let src = DemosaicedImage::from_rgba8(&ctx, &ramp(w, h), w, h).expect("source");
let pass = SegmentPass::new(&ctx).expect("segment pass");
let labels_in = |labels: &[u32], inside: bool| {
let (cx, cy) = (w as f32 / 2.0, h as f32 / 2.0);
let mut seen = std::collections::HashSet::new();
for y in 0..h {
for x in 0..w {
let d = ((x as f32 - cx).powi(2) + (y as f32 - cy).powi(2)).sqrt();
let take = if inside {
d < w as f32 * 0.20
} else {
d > w as f32 * 0.42
};
if take {
seen.insert(labels[(y * w + x) as usize]);
}
}
}
seen
};
let field = |iterations: u32| {
pass.run(
&src,
SegmentOptions {
plateau_iterations: iterations,
..Default::default()
},
)
.expect("run")
.read_field()
.expect("field")
};
let shallow = field(1);
let deep = field(64);
// Before anything else: does the pass change the labelling at all? If
// the distance field were never populated — a binding astray, a level
// test that never matches — every downstream claim would be excused
// by a no-op rather than tested. This is the one assertion that
// cannot pass vacuously.
let differs = shallow
.labels
.iter()
.zip(deep.labels.iter())
.filter(|(a, b)| a != b)
.count();
assert!(
differs > 0,
"the plateau distance changed no pixel's basin, so the pass is a \
no-op: {} pixels, {} differ",
shallow.labels.len(),
differs
);
// Claim one: fewer basins, because plateaux with an exit now use it.
assert!(
deep.region_count < shallow.region_count,
"carrying the distance inward should reduce fragmentation: \
{} basins against {}",
deep.region_count,
shallow.region_count
);
// Claim two: what survives costs nothing, because the hierarchy
// dissolves it immediately.
let tree = MergeTree::build(&deep);
let grouped = deep.apply(&tree.cut_to(2));
let inside = labels_in(&grouped, true);
let outside = labels_in(&grouped, false);
assert_eq!(inside.len(), 1, "the disc should read as one region");
assert_eq!(outside.len(), 1, "the ground should read as one region");
assert_ne!(inside, outside, "and they must not be the same region");
}
#[test]
fn the_same_image_segments_identically_twice() {
// M5 on one device — the weaker half of the determinism question, but
// the half that catches a race in the pointer jumping. Cross-vendor
// is the part that needs hardware this test cannot assume.
let Some(ctx) = ctx() else { return };
let (w, h) = (48u32, 48u32);
let src = DemosaicedImage::from_rgba8(&ctx, &two_tone(w, h), w, h).expect("source");
let pass = SegmentPass::new(&ctx).expect("segment pass");
let a = pass
.run(&src, SegmentOptions::default())
.expect("run")
.read_field()
.expect("field");
let b = pass
.run(&src, SegmentOptions::default())
.expect("run")
.read_field()
.expect("field");
assert_eq!(a, b, "segmentation must be reproducible run to run");
}
}
+198
View File
@@ -0,0 +1,198 @@
// Black/white normalisation and Bayer demosaic, in one pass.
//
// Input is the raw sensor readout as packed u16 samples — one per photosite,
// in CFA order. Output is linear scene-referred RGBA16Float in *camera*
// colour space; the camera→sRGB matrix belongs to the adjust pass, so this
// stage is purely about reconstructing three channels from one.
//
// The algorithm is Malvar-He-Cutler (ICASSP 2004): bilinear interpolation
// plus a Laplacian correction taken from the channel that *is* sampled at
// each site. One 5x5 neighbourhood per pixel, and dramatically better than
// bilinear on edges — bilinear leaves visible zippering on any high-contrast
// boundary, which on a 24 MP file is the first thing seen at 1:1.
//
// Kernel coefficients below are the paper's, all over 8.
struct DemosaicParams {
// Dimensions of the *cropped* output, in pixels.
width: u32,
height: u32,
// Origin of the crop within the sensor readout, in photosites. Added to
// every read so the masked border is never sampled.
crop_x: u32,
crop_y: u32,
// Row stride of the input, in samples.
stride: u32,
// CFA layout of the *cropped* image, already re-phased for the crop
// origin by dr-decode: 0=RGGB, 1=BGGR, 2=GRBG, 3=GBRG.
pattern: u32,
_pad0: u32,
_pad1: u32,
// Per-CFA-position black levels, indexed by (y&1)*2 + (x&1).
black: vec4<f32>,
// Reciprocal of (white - black) per position, precomputed on the CPU so
// the shader does no division.
inv_range: vec4<f32>,
}
@group(0) @binding(0) var<storage, read> raw: array<u32>;
@group(0) @binding(1) var<uniform> params: DemosaicParams;
@group(0) @binding(2) var output: texture_storage_2d<rgba16float, write>;
// Colour of the photosite at (x, y): 0=R, 1=G, 2=B.
//
// Each pattern is its 2x2 cell read row-major, packed two bits per entry so
// the lookup is an index and a shift rather than a branch.
fn colour_at(x: u32, y: u32) -> u32 {
let cell = (y & 1u) * 2u + (x & 1u);
// RGGB = R,G,G,B -> 0,1,1,2 ; BGGR = 2,1,1,0 ; GRBG = 1,0,2,1 ; GBRG = 1,2,0,1
// Entry i occupies bits [2i, 2i+1], so the cell order reads
// right-to-left in hex. Verified against a table rather than derived by
// eye — two of these were wrong on the first attempt.
var packed: u32;
switch params.pattern {
case 0u: { packed = 0x94u; } // RGGB -> [0,1,1,2]
case 1u: { packed = 0x16u; } // BGGR -> [2,1,1,0]
case 2u: { packed = 0x61u; } // GRBG -> [1,0,2,1]
default: { packed = 0x49u; } // GBRG -> [1,2,0,1]
}
return (packed >> (cell * 2u)) & 3u;
}
// Whether the row through (x, y) is one carrying red photosites.
//
// Needed at green sites, where red lies along one axis and blue along the
// other, and which is which depends on the pattern.
fn red_is_horizontal(x: u32, y: u32) -> bool {
// The horizontal neighbour of a green site.
return colour_at(x + 1u, y) == 0u;
}
// Read one photosite, normalised to [0, 1] against its own black level.
//
// Coordinates are relative to the crop origin. A 5x5 window at the image edge
// reflects rather than reading masked photosites or running off the buffer.
fn sample(ix: i32, iy: i32) -> f32 {
let w = i32(params.width);
let h = i32(params.height);
// Reflect at the borders, preserving CFA parity: reflecting by an even
// distance keeps the mirrored sample the same colour as the one it
// stands in for. Clamping instead would flatten the correction term and
// leave a visible one-pixel seam along each edge.
var cx = ix;
var cy = iy;
if (cx < 0) { cx = -cx; }
if (cy < 0) { cy = -cy; }
if (cx > w - 1) { cx = 2 * (w - 1) - cx; }
if (cy > h - 1) { cy = 2 * (h - 1) - cy; }
cx = clamp(cx, 0, w - 1);
cy = clamp(cy, 0, h - 1);
let sx = u32(cx) + params.crop_x;
let sy = u32(cy) + params.crop_y;
let index = sy * params.stride + sx;
// Samples are u16, packed two per u32 word.
let word = raw[index >> 1u];
let raw_value = select(word & 0xFFFFu, word >> 16u, (index & 1u) == 1u);
// Black level and range are per CFA position. Subtracting black can go
// negative on sensor noise — real signal below the black point — so the
// result is clamped rather than allowed to wrap.
let cell = (u32(cy) & 1u) * 2u + (u32(cx) & 1u);
let value = (f32(raw_value) - params.black[cell]) * params.inv_range[cell];
// **Clamped at the top as well, and that is what stops blown highlights
// going pink.** Sensors read above their declared white level — on a
// Canon 6D CR2 the data reaches 16383 against a white of 15070 — so a
// saturated pixel normalises to about 1.1 rather than 1.0.
//
// Left unclamped it survives the white balance, where red is multiplied
// by ~1.93 and blue by ~1.68 against green's 1.0, and then the camera
// matrix. Red and blue clip at the end of the pipeline; green, whose
// matrix row is far less positive-heavy, does not. Red and blue high with
// green low is magenta, and a clipped highlight came back pink.
//
// Clamping here makes a blown pixel saturate *neutrally*: all three
// channels reach 1.0 together and the highlight is white, which is what a
// blown highlight looks like and what every other developer produces.
return clamp(value, 0.0, 1.0);
}
@compute @workgroup_size(8, 8, 1)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
if (gid.x >= params.width || gid.y >= params.height) {
return;
}
let x = i32(gid.x);
let y = i32(gid.y);
let c = sample(x, y);
// 5x5 neighbourhood.
let n1 = sample(x, y - 1);
let s1 = sample(x, y + 1);
let w1 = sample(x - 1, y);
let e1 = sample(x + 1, y);
let n2 = sample(x, y - 2);
let s2 = sample(x, y + 2);
let w2 = sample(x - 2, y);
let e2 = sample(x + 2, y);
let nw = sample(x - 1, y - 1);
let ne = sample(x + 1, y - 1);
let sw = sample(x - 1, y + 1);
let se = sample(x + 1, y + 1);
let axial1 = n1 + s1 + w1 + e1;
let diag1 = nw + ne + sw + se;
let vert2 = n2 + s2;
let horiz2 = w2 + e2;
let colour = colour_at(gid.x, gid.y);
var rgb: vec3<f32>;
if (colour == 1u) {
// ---- Green site ----------------------------------------------
// Green is measured. Red and blue are interpolated from their own
// axis, with a correction from the green Laplacian.
//
// Malvar "G at R/B locations" kernels, transposed per axis:
// chroma along the row: (5c + 4(w1+e1) - (nw+ne+sw+se) - (n2+s2) + 0.5(w2+e2)) / 8
let along_row =
(5.0 * c + 4.0 * (w1 + e1) - diag1 - vert2 + 0.5 * horiz2) * 0.125;
let along_col =
(5.0 * c + 4.0 * (n1 + s1) - diag1 - horiz2 + 0.5 * vert2) * 0.125;
let red_horizontal = red_is_horizontal(gid.x, gid.y);
let r = select(along_col, along_row, red_horizontal);
let b = select(along_row, along_col, red_horizontal);
rgb = vec3<f32>(r, c, b);
} else {
// ---- Red or blue site ----------------------------------------
// Green at an R/B site: bilinear on the axial neighbours, corrected
// by the centre channel's Laplacian.
// (4c + 2(n1+s1+w1+e1) - (n2+s2+w2+e2)) / 8
let green = (4.0 * c + 2.0 * axial1 - (vert2 + horiz2)) * 0.125;
// The opposite chroma sits on the diagonals.
// (6c + 2(nw+ne+sw+se) - 1.5(n2+s2+w2+e2)) / 8
let opposite = (6.0 * c + 2.0 * diag1 - 1.5 * (vert2 + horiz2)) * 0.125;
if (colour == 0u) {
rgb = vec3<f32>(c, green, opposite);
} else {
rgb = vec3<f32>(opposite, green, c);
}
}
// The correction term can overshoot below zero near clipped highlights.
// Negative light is not meaningful, and carrying it forward makes the
// ratio-based operations downstream (white balance, saturation) misbehave.
rgb = max(rgb, vec3<f32>(0.0));
textureStore(output, vec2<i32>(x, y), vec4<f32>(rgb, 1.0));
}
+40
View File
@@ -0,0 +1,40 @@
// Placeholder compute shader — stands in for the develop pipeline.
//
// Its only job is to prove the path: a compute shader writes a storage
// texture, and that texture reaches the screen without a CPU round-trip
// (ARCH §6.1). Replaced by real pipeline stages in v0.2.
struct Params {
width: u32,
height: u32,
// Animates so it is visually obvious the compute pass runs every frame
// rather than a stale texture being redisplayed.
phase: f32,
_pad: f32,
}
@group(0) @binding(0) var output: texture_storage_2d<rgba8unorm, write>;
@group(0) @binding(1) var<uniform> params: Params;
@compute @workgroup_size(8, 8, 1)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
if (gid.x >= params.width || gid.y >= params.height) {
return;
}
let uv = vec2<f32>(
f32(gid.x) / f32(params.width),
f32(gid.y) / f32(params.height),
);
// Warm dark ground with a moving highlight — deliberately unlike a test
// pattern, so a stuck frame is obvious at a glance.
let d = distance(uv, vec2<f32>(0.5 + 0.25 * cos(params.phase), 0.5 + 0.25 * sin(params.phase)));
let glow = 1.0 - smoothstep(0.0, 0.55, d);
let base = vec3<f32>(0.08, 0.07, 0.06);
let accent = vec3<f32>(0.69, 0.23, 0.15);
let rgb = base + accent * glow * (0.35 + 0.65 * uv.y);
textureStore(output, vec2<i32>(gid.xy), vec4<f32>(rgb, 1.0));
}
+112
View File
@@ -0,0 +1,112 @@
// TRACES: FR-DSP-7
// A reduction of the display frame into bin counts, run where the pixels are.
//
// FR-DSP-7 does not merely permit this shape, it names it: "these derive from a
// GPU-side reduction into a small buffer", because the alternative — dragging
// the frame back across the bus to count it — is the per-frame round-trip
// ARCH §6.1 forbids and the bottleneck darktable documents. What leaves the
// device here is 4104 bytes whatever the image size.
//
// **Counted per workgroup first, then merged.** A photograph is not noise: a
// clear sky puts tens of thousands of adjacent pixels in one bin, and having
// every invocation contend for that single global atomic serialises the whole
// dispatch. Each workgroup therefore tallies its own 256 pixels into workgroup
// memory — where the atomic is cheap and the contention is between 256 threads
// rather than two million — and contributes one add per non-empty bin at the
// end. Four kilobytes of workgroup storage against a 16 KB floor.
const BINS: u32 = 256u;
// Two counters past the four channels: how many pixels clip at each end. They
// live in the same buffer because they are gathered from the same texel read
// and would otherwise need a second reduction to answer a question the first
// one already had the data for.
const CLIPPED_HIGH: u32 = 4u * BINS;
const CLIPPED_LOW: u32 = 4u * BINS + 1u;
const SLOTS: u32 = 4u * BINS + 2u;
// 16x16. Stated as a constant because the clear and merge loops below stride by
// it, and a workgroup size that disagreed would leave slots uncleared.
const THREADS: u32 = 256u;
struct Dims {
width: u32,
height: u32,
// std140 rounds a uniform block up to 16 bytes; named rather than left
// implicit so the Rust side's padding is visibly the same shape.
pad_0: u32,
pad_1: u32,
}
@group(0) @binding(0) var source: texture_2d<f32>;
@group(0) @binding(1) var<storage, read_write> bins: array<atomic<u32>>;
@group(0) @binding(2) var<uniform> dims: Dims;
var<workgroup> tile: array<atomic<u32>, SLOTS>;
/// The 8-bit level a sampled texel came from.
///
/// The source is `Rgba8Unorm`, so the sampler hands back exactly n/255 and this
/// recovers n. `floor(x + 0.5)` rather than `round`, which ties to even in WGSL
/// and away from zero in Rust — a difference invisible except on an exact tie,
/// which is precisely the kind of disagreement that makes a cross-check against
/// a CPU reference fail once in a thousand runs and look like flakiness.
fn level(v: f32) -> u32 {
return u32(clamp(floor(v * 255.0 + 0.5), 0.0, 255.0));
}
@compute @workgroup_size(16, 16, 1)
fn main(
@builtin(global_invocation_id) gid: vec3<u32>,
@builtin(local_invocation_index) lid: u32,
) {
for (var i = lid; i < SLOTS; i = i + THREADS) {
atomicStore(&tile[i], 0u);
}
workgroupBarrier();
// Guarded rather than dispatched exactly: the workgroup is 16x16 and an
// image is not, so the last row and column of workgroups run off the edge.
if (gid.x < dims.width && gid.y < dims.height) {
let texel = textureLoad(source, vec2<i32>(i32(gid.x), i32(gid.y)), 0);
let r = level(texel.r);
let g = level(texel.g);
let b = level(texel.b);
// Rec.709 luma in 8.8 fixed point. 54 + 183 + 19 is exactly 256, so the
// weights sum to unity and the shift can never carry past 255.
//
// Integer rather than float on purpose (ARCH §6.13): a float weighting
// is reproducible only to within the vendor's rounding, and the whole
// value of the CPU cross-check in the tests is that it is exact.
//
// Weighted on the *encoded* values, not on linear light. That is what
// every histogram a photographer has read is: the axis is the output
// level, so a mid-grey has to sit in the middle of it.
let y = (54u * r + 183u * g + 19u * b) >> 8u;
atomicAdd(&tile[r], 1u);
atomicAdd(&tile[BINS + g], 1u);
atomicAdd(&tile[2u * BINS + b], 1u);
atomicAdd(&tile[3u * BINS + y], 1u);
// Any channel, not all three: a blown red channel is detail that is
// gone, whatever green and blue still hold. Counting only neutral white
// would stay silent on exactly the saturated highlight — a sunset, a
// red jersey — that clips first and recovers worst.
if (r == 255u || g == 255u || b == 255u) {
atomicAdd(&tile[CLIPPED_HIGH], 1u);
}
if (r == 0u || g == 0u || b == 0u) {
atomicAdd(&tile[CLIPPED_LOW], 1u);
}
}
workgroupBarrier();
for (var i = lid; i < SLOTS; i = i + THREADS) {
let count = atomicLoad(&tile[i]);
// Most bins of most workgroups are empty — a 16x16 tile can touch 256
// of 1026 slots at the very most, and usually far fewer.
if (count != 0u) {
atomicAdd(&bins[i], count);
}
}
}
+389
View File
@@ -0,0 +1,389 @@
// Rasterise one local-adjustment mask into a layer of the mask array.
//
// ARCH §5.4: every mask becomes pixels here and never in CPU memory. One draw
// per layer, each targeting its own array slice, run only when a mask's
// *shape* changes — moving a slider on a masked layer re-runs the adjust
// shader and not this one.
//
// # Why this is a render pass and not a compute one
//
// The natural shape for this is a compute shader writing a storage texture,
// and the format is what rules that out: **R8Unorm is not a core storage
// format**, so a compute path has to widen the mask to R32Float or RGBA8 —
// four bytes per pixel per layer. At eight layers over a 24 MP export that is
// 768 MB of masks, against 192 MB at one byte. A colour attachment takes
// R8Unorm happily, so the mask stays one byte and the pass becomes a
// full-screen triangle.
//
// The array slice is chosen by the *view* the caller attaches, so there is no
// slot uniform here — one less thing that can disagree with the shader.
struct MaskParams {
// Output size, which is the render size rather than the segmentation's.
width: u32,
height: u32,
// Label field size. Different from the above: the watershed runs at a
// proxy resolution, and the mask is drawn at whatever the display or the
// export asked for.
label_width: u32,
label_height: u32,
// 0 = regions, 1 = linear, 2 = radial, 3 = subject, 4 = brush.
//
// A brush does not read this — it has its own entry points, because it is
// the one mask that is not a function of the whole frame — but it is set
// anyway so a captured frame says which kind of mask a pass was drawing.
mode: u32,
// How many regions the label field holds, so an out-of-range label is
// caught rather than read past the end of `selected`.
region_count: u32,
// Softening applied to a region mask, in output pixels.
feather: f32,
// 0 hard, 1 linear, 2 smooth, 3 gaussian, 4 exponential. Kept in step with
// `falloff_code` on the Rust side.
falloff: u32,
// Geometry. Meaning depends on `mode`. The centre is in normalised 0..1
// coordinates; every distance below it is in the isotropic frame units
// `frame_delta` establishes.
centre: vec2<f32>,
// Linear: (cos, sin) of the ramp direction. Radial: semi-axes.
axis: vec2<f32>,
// Linear: ramp width. Radial: edge falloff as a fraction of the radius.
softness: f32,
// Radial only: rotation of the ellipse.
angle: f32,
_pad1: vec2<f32>,
}
@group(0) @binding(0) var<uniform> p: MaskParams;
// Compacted region id per pixel of the label field. Compacted rather than the
// watershed's raw basin roots: the roots are sparse indices into pixel space,
// so indexing a per-region array by one would need a table as large as the
// image. The compaction happens once, when the segmentation is built.
@group(0) @binding(1) var<storage, read> labels: array<u32>;
// One entry per region: non-zero if the region is in this mask. Small — a few
// thousand bytes — which is what makes changing a selection cheap.
@group(0) @binding(2) var<storage, read> selected: array<u32>;
// The **signed distance** from one subject's boundary, in proxy pixels:
// positive inside, negative outside. A 1x1 placeholder when the layer is not a
// subject — the binding is fixed, and a second pipeline differing only in what
// it ignores would be worse than a wasted texel.
//
// A distance field rather than a finished alpha is what makes growing,
// shrinking and feathering free: each is arithmetic on this, so a slider moves
// a uniform instead of rebuilding a mask.
@group(0) @binding(3) var subject: texture_2d<f32>;
// A full-screen triangle rather than a quad: three vertices instead of six,
// no shared edge for the rasteriser to crack along, and no vertex buffer.
@vertex
fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
let x = f32(i32(i) / 2) * 4.0 - 1.0;
let y = f32(i32(i) & 1) * 4.0 - 1.0;
return vec4<f32>(x, y, 0.0, 1.0);
}
fn region_at(px: vec2<i32>) -> u32 {
// Nearest-neighbour from output space into the label field. Deliberately
// not bilinear: region ids are *names*, and the average of region 4 and
// region 9 is not region 6.
let fx = (f32(px.x) + 0.5) / f32(p.width);
let fy = (f32(px.y) + 0.5) / f32(p.height);
let lx = clamp(i32(fx * f32(p.label_width)), 0, i32(p.label_width) - 1);
let ly = clamp(i32(fy * f32(p.label_height)), 0, i32(p.label_height) - 1);
return labels[u32(ly) * p.label_width + u32(lx)];
}
fn in_selection(px: vec2<i32>) -> f32 {
let r = region_at(px);
if (r >= p.region_count) {
return 0.0;
}
return select(0.0, 1.0, selected[r] != 0u);
}
fn region_mask(px: vec2<i32>) -> f32 {
let hard = in_selection(px);
if (p.feather <= 0.0) {
return hard;
}
// Box-average the binary selection over the feather radius. Cheap, and it
// is the whole reason a region mask does not look cut out with scissors:
// the watershed boundary is pixel-exact, which is correct and also harsher
// than any edit wants at a subject's edge.
let r = i32(ceil(p.feather));
var total = 0.0;
var n = 0.0;
for (var dy = -r; dy <= r; dy = dy + 1) {
for (var dx = -r; dx <= r; dx = dx + 1) {
let q = clamp(
px + vec2<i32>(dx, dy),
vec2<i32>(0, 0),
vec2<i32>(i32(p.width) - 1, i32(p.height) - 1),
);
total = total + in_selection(q);
n = n + 1.0;
}
}
return total / n;
}
// Offset from a gradient's centre, in the frame's own **isotropic** units:
// y spans 0..1 and x spans 0..aspect, so a step of the same length means the
// same distance whichever way it points.
//
// Without this the geometry lives in raw 0..1, where one axis is compressed
// against the other by the aspect ratio — so a 45° ramp is not at 45° on
// anything but a square frame, and a radial with equal radii draws an ellipse.
// Both faults are invisible in the stored numbers and obvious the moment a
// handle is dragged on a photograph, which is what this exists for.
fn frame_delta(uv: vec2<f32>) -> vec2<f32> {
let aspect = vec2<f32>(f32(p.width) / f32(max(p.height, 1u)), 1.0);
return (uv - p.centre) * aspect;
}
fn linear_mask(uv: vec2<f32>) -> f32 {
// Signed distance along the ramp direction, from the centre.
let d = dot(frame_delta(uv), p.axis);
if (p.softness <= 0.0) {
return select(0.0, 1.0, d >= 0.0);
}
return smoothstep(-p.softness * 0.5, p.softness * 0.5, d);
}
fn radial_mask(uv: vec2<f32>) -> f32 {
let ca = cos(-p.angle);
let sa = sin(-p.angle);
let d = frame_delta(uv);
// Into the ellipse's own frame, then normalised by its semi-axes so the
// problem becomes a unit circle.
let local = vec2<f32>(d.x * ca - d.y * sa, d.x * sa + d.y * ca);
let r = length(local / max(p.axis, vec2<f32>(1e-6)));
let edge = clamp(p.softness, 0.0, 1.0);
if (edge <= 0.0) {
return select(0.0, 1.0, r <= 1.0);
}
return 1.0 - smoothstep(1.0 - edge, 1.0, r);
}
// Coverage for one object, from its distance field.
//
// Bilinear on the *distance*, which is the reason this is a distance field at
// all: distance varies smoothly across the boundary where coverage does not,
// so interpolating it gives a clean sub-pixel edge even though the model's
// own mask was quarter-resolution.
fn subject_mask(uv: vec2<f32>) -> f32 {
let dims = vec2<f32>(textureDimensions(subject));
let last = vec2<i32>(dims) - vec2<i32>(1);
let t = uv * dims - vec2<f32>(0.5);
let base = vec2<i32>(floor(t));
let f = fract(t);
let p0 = clamp(base, vec2<i32>(0), last);
let p1 = clamp(base + vec2<i32>(1), vec2<i32>(0), last);
let a = textureLoad(subject, vec2<i32>(p0.x, p0.y), 0).r;
let b = textureLoad(subject, vec2<i32>(p1.x, p0.y), 0).r;
let c = textureLoad(subject, vec2<i32>(p0.x, p1.y), 0).r;
let d = textureLoad(subject, vec2<i32>(p1.x, p1.y), 0).r;
// `angle` carries the morphology offset in pixels: positive grows the
// mask, negative shrinks it. Adding it before the falloff is what makes
// dilation move the boundary rather than merely brighten the edge.
let dist = mix(mix(a, b, f.x), mix(c, d, f.x), f.y) + p.angle;
// `softness` is the feather half-width, also in pixels.
if (p.softness <= 0.0) {
return select(0.0, 1.0, dist >= 0.0);
}
let t_norm = dist / p.softness;
// Every curve is 0.5 at the boundary, so changing the falloff changes how
// the transition looks and never where it sits.
switch p.falloff {
case 0u: { return select(0.0, 1.0, dist >= 0.0); }
case 1u: { return clamp(t_norm * 0.5 + 0.5, 0.0, 1.0); }
case 3u: { return 1.0 / (1.0 + exp(-3.0 * t_norm)); }
case 4u: {
if (t_norm >= 0.0) {
return 1.0 - 0.5 * exp(-3.0 * t_norm);
}
return 0.5 * exp(3.0 * t_norm);
}
default: {
let x = clamp(t_norm * 0.5 + 0.5, 0.0, 1.0);
return x * x * (3.0 - 2.0 * x);
}
}
}
@fragment
fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let px = vec2<i32>(i32(pos.x), i32(pos.y));
// Normalised, so a gradient's geometry survives a crop or an export at
// another size — the mask is defined on the frame, not on a pixel count.
let uv = vec2<f32>(pos.x / f32(p.width), pos.y / f32(p.height));
var m = 0.0;
switch p.mode {
case 0u: { m = region_mask(px); }
case 1u: { m = linear_mask(uv); }
case 2u: { m = radial_mask(uv); }
case 3u: { m = subject_mask(uv); }
default: { m = 0.0; }
}
return vec4<f32>(clamp(m, 0.0, 1.0), 0.0, 0.0, 1.0);
}
// ---------------------------------------------------------------------------
// Brush strokes (ARCH §5.4)
// ---------------------------------------------------------------------------
//
// The mask the architecture was written for. What arrives is a list of
// positions, a radius, a hardness and a flow; what leaves is pixels. Nothing
// between the two ever exists in CPU memory, which is the whole difference from
// darktable, where the same strokes are rasterised on the CPU and the lag makes
// painting unusable.
//
// # Why the strokes are not drawn by the full-screen triangle above
//
// Cost. A swept disc is the minimum distance to any segment of its polyline, so
// evaluating one stroke costs a distance per segment *per pixel*. Over the
// whole frame that is `pixels × segments`, and a stroke that wandered across
// the photograph has both terms large at once.
//
// So each stroke is drawn over its own bounding box instead, expanded by the
// radius. The rasteriser then never invokes the fragment shader for a pixel the
// stroke cannot reach, and the cost becomes `area(box) × segments` — for the
// ordinary case, a dab or a swipe, a small fraction of the frame. The model
// splits a long gesture into strokes of bounded length for the same reason:
// both terms of that product grow with how far one stroke travelled.
//
// # Why the strokes composite with fixed-function blending
//
// Add is `dst + a(1 - dst)` and erase is `dst(1 - a)`, which are exactly a
// source-over and a one-minus-source blend. Expressing them as blend state
// rather than as arithmetic in the shader is what allows one draw per stroke:
// the accumulating mask is the attachment, and no pass ever has to read the
// slice it is writing.
struct StrokeHeader {
// Bounding box in normalised coordinates, already grown by the radius and
// a texel — the vertex shader trusts it and draws nothing outside it.
lo: vec2<f32>,
hi: vec2<f32>,
// Radius in units of the frame's shorter edge, so a dab is round on a frame
// that is not square.
radius: f32,
// Fraction of the radius that is fully covered.
hardness: f32,
// Coverage deposited where the stroke is solid.
flow: f32,
// Window into `stroke_points`.
first: u32,
count: u32,
_pad: u32,
}
@group(0) @binding(4) var<storage, read> strokes: array<StrokeHeader>;
@group(0) @binding(5) var<storage, read> stroke_points: array<vec2<f32>>;
struct BrushVertex {
@builtin(position) pos: vec4<f32>,
// Flat: a stroke index interpolated across its own quad would name a
// different stroke in the middle of it.
@location(0) @interpolate(flat) stroke: u32,
}
// Six vertices per stroke, non-instanced.
//
// Deliberately not one instance per stroke: `@builtin(instance_index)` with a
// non-zero first instance needs base-instance support, which the GL backend
// this has to run on under Android cannot promise. Dividing the vertex index
// costs one integer operation and works everywhere.
@vertex
fn vs_brush(@builtin(vertex_index) v: u32) -> BrushVertex {
var quad = array<vec2<f32>, 6>(
vec2<f32>(0.0, 0.0), vec2<f32>(1.0, 0.0), vec2<f32>(0.0, 1.0),
vec2<f32>(0.0, 1.0), vec2<f32>(1.0, 0.0), vec2<f32>(1.0, 1.0),
);
let i = v / 6u;
let s = strokes[i];
let uv = mix(s.lo, s.hi, quad[v % 6u]);
var out: BrushVertex;
// y is flipped because normalised mask coordinates run downwards, the way
// the fragment shader above reads them, and clip space runs upwards. A
// stroke drawn without this lands mirrored about the horizon, which is
// plausible enough on a symmetric test image to survive a careless check.
out.pos = vec4<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 0.0, 1.0);
out.stroke = i;
return out;
}
// Into units of the frame's shorter edge.
//
// Without this the brush would be a circle in normalised coordinates, which on
// a 3:2 frame is an ellipse half again as wide as it is tall. A brush whose dab
// is not round is not a brush.
fn to_square(uv: vec2<f32>) -> vec2<f32> {
let dims = vec2<f32>(f32(p.width), f32(p.height));
return uv * dims / min(dims.x, dims.y);
}
fn segment_distance(q: vec2<f32>, a: vec2<f32>, b: vec2<f32>) -> f32 {
let ab = b - a;
let len2 = dot(ab, ab);
// A finger that stopped and went back leaves a zero-length segment, and
// dividing by its length is a NaN — which propagates through the min()
// below and takes the whole stroke with it.
if (len2 <= 1e-12) {
return length(q - a);
}
let t = clamp(dot(q - a, ab) / len2, 0.0, 1.0);
return length(q - (a + ab * t));
}
@fragment
fn fs_brush(in: BrushVertex) -> @location(0) vec4<f32> {
let s = strokes[in.stroke];
let q = to_square(vec2<f32>(in.pos.x / f32(p.width), in.pos.y / f32(p.height)));
// The *minimum* over the segments, which is the maximum of their coverage.
// Accumulating the segments instead would make a stroke that crosses itself
// — every circle, every scribble — build up a bright patch where it did,
// and a soft brush would go blotchy along any curve tight enough for
// consecutive dabs to overlap, which is all of them.
var d = 1e30;
if (s.count == 1u) {
// A tap. One point is a legitimate stroke, and it paints one dab.
d = length(q - to_square(stroke_points[s.first]));
} else {
for (var k = 0u; k + 1u < s.count; k = k + 1u) {
d = min(
d,
segment_distance(
q,
to_square(stroke_points[s.first + k]),
to_square(stroke_points[s.first + k + 1u]),
),
);
}
}
// Even at full hardness the edge keeps a one-pixel ramp. A true step would
// alias into a staircase, and the mask is sampled bilinearly at whatever
// zoom the user is inspecting it at — which is where an edge is judged.
let texel = 1.0 / f32(min(p.width, p.height));
let inner = min(s.radius * clamp(s.hardness, 0.0, 1.0), max(s.radius - texel, 0.0));
let coverage = 1.0 - smoothstep(inner, s.radius, d);
return vec4<f32>(clamp(coverage * s.flow, 0.0, 1.0), 0.0, 0.0, 1.0);
}
+426
View File
@@ -0,0 +1,426 @@
// Watershed segmentation — the passes behind arm A of S15 (docs/segmentation.md).
//
// Seven entry points forming one chain:
//
// features source texture -> perceptual triple, downscaled to proxy size
// blur pre-smoothing, without which every grain becomes a basin
// gradient Sobel magnitude — the surface the watershed floods
// plateau_init seed the distance field at every real descent
// plateau_step carry it inward, so flat ground drains toward its exit
// flow each pixel points downhill to its steepest neighbour
// jump pointer-jumping, until every pixel points at its basin root
//
// Everything after `features` works in storage buffers rather than textures.
// That is deliberate: the flow and jump passes need read-write access to the
// same array across dispatches, which storage textures do not give portably,
// and a buffer reads back without the 256-byte row padding a texture copy
// imposes.
struct Params {
// Proxy dimensions — what every pass but `features` iterates over.
width: u32,
height: u32,
// Source dimensions, for the box downscale in `features`.
src_width: u32,
src_height: u32,
// Half-width of the pre-smoothing kernel, in proxy pixels. 0 disables it.
blur_radius: i32,
// 1 when the source is already display-encoded (the JPEG path), 0 for
// linear scene-referred data out of the demosaicer.
non_linear: u32,
// How much luma and chroma each contribute to the gradient. Chroma is
// weighted lower because it carries most of the sensor noise and few of
// the boundaries a person would draw.
w_luma: f32,
w_chroma: f32,
}
// Binding slots are unique across the whole module, not reused per entry
// point: WGSL resource variables share one namespace, so two globals at the
// same (group, binding) is a module-level validation error even when no
// single entry point uses both. Each pass therefore gets its own pair, and
// each pipeline a layout declaring only the slots it touches.
@group(0) @binding(0) var<uniform> u: Params;
// ---------------------------------------------------------------- features
@group(0) @binding(1) var src: texture_2d<f32>;
@group(0) @binding(2) var<storage, read_write> feat_out: array<vec4<f32>>;
// Linear or display-encoded RGB to a roughly perceptual opponent triple.
//
// Perceptual rather than linear because the gradient has to agree with what
// a person calls an edge. In linear light a highlight rolloff swamps the
// boundary between two midtones, and the watershed would put its strongest
// walls where nobody sees one.
//
// The two chroma axes are opponent differences rather than a real Lab
// transform: they cost three subtractions instead of a matrix and a cube
// root, and the watershed only needs the *magnitude* of colour change, not a
// colorimetrically defensible value for it.
fn perceptual(c_in: vec3<f32>) -> vec3<f32> {
var c = max(c_in, vec3<f32>(0.0));
if (u.non_linear == 0u) {
c = pow(c, vec3<f32>(1.0 / 2.4));
}
let l = dot(c, vec3<f32>(0.2126, 0.7152, 0.0722));
let a = c.r - c.g;
let b = c.b - 0.5 * (c.r + c.g);
return vec3<f32>(l, a, b);
}
// Source -> proxy, averaging every source pixel that falls in the proxy
// pixel's footprint.
//
// A box average rather than point sampling because the proxy is where the
// segmentation happens: point sampling a 24 MP sensor down to 2 MP aliases
// fine texture into false gradient, and the watershed would faithfully find
// basins in the aliasing.
@compute @workgroup_size(8, 8, 1)
fn features(@builtin(global_invocation_id) gid: vec3<u32>) {
if (gid.x >= u.width || gid.y >= u.height) {
return;
}
let sx0 = (gid.x * u.src_width) / u.width;
let sy0 = (gid.y * u.src_height) / u.height;
let sx1 = max(sx0 + 1u, ((gid.x + 1u) * u.src_width) / u.width);
let sy1 = max(sy0 + 1u, ((gid.y + 1u) * u.src_height) / u.height);
var acc = vec3<f32>(0.0);
var n = 0.0;
for (var sy = sy0; sy < sy1; sy = sy + 1u) {
for (var sx = sx0; sx < sx1; sx = sx + 1u) {
let c = textureLoad(src, vec2<i32>(i32(sx), i32(sy)), 0).rgb;
acc = acc + perceptual(c);
n = n + 1.0;
}
}
feat_out[gid.y * u.width + gid.x] = vec4<f32>(acc / max(n, 1.0), 0.0);
}
// -------------------------------------------------------------------- blur
@group(0) @binding(3) var<storage, read> blur_in: array<vec4<f32>>;
@group(0) @binding(4) var<storage, read_write> blur_out: array<vec4<f32>>;
fn clamp_coord(v: i32, hi: u32) -> u32 {
return u32(clamp(v, 0, i32(hi) - 1));
}
// Pre-smoothing. Not a refinement — without it the watershed is unusable.
//
// A raw gradient over sensor data has a local minimum at every noise grain,
// and one basin per local minimum means a 2 MP frame segments into hundreds
// of thousands of regions that correspond to nothing. The radius is the
// caller's to set from ISO.
@compute @workgroup_size(8, 8, 1)
fn blur(@builtin(global_invocation_id) gid: vec3<u32>) {
if (gid.x >= u.width || gid.y >= u.height) {
return;
}
let idx = gid.y * u.width + gid.x;
if (u.blur_radius <= 0) {
blur_out[idx] = blur_in[idx];
return;
}
var acc = vec3<f32>(0.0);
var wsum = 0.0;
let r = u.blur_radius;
for (var dy = -r; dy <= r; dy = dy + 1) {
for (var dx = -r; dx <= r; dx = dx + 1) {
let sx = clamp_coord(i32(gid.x) + dx, u.width);
let sy = clamp_coord(i32(gid.y) + dy, u.height);
let d2 = f32(dx * dx + dy * dy);
let w = exp(-d2 / (2.0 * f32(r) * f32(r)));
acc = acc + blur_in[sy * u.width + sx].rgb * w;
wsum = wsum + w;
}
}
blur_out[idx] = vec4<f32>(acc / wsum, 0.0);
}
// ---------------------------------------------------------------- gradient
@group(0) @binding(5) var<storage, read> grad_in: array<vec4<f32>>;
@group(0) @binding(6) var<storage, read_write> grad_out: array<f32>;
fn feat_at(x: i32, y: i32) -> vec3<f32> {
let sx = clamp_coord(x, u.width);
let sy = clamp_coord(y, u.height);
return grad_in[sy * u.width + sx].rgb;
}
// Sobel magnitude over the weighted opponent triple.
//
// This is the surface the watershed floods, so its units matter for nothing
// except ordering — only the *relative* height of one boundary against
// another decides which regions merge first.
@compute @workgroup_size(8, 8, 1)
fn gradient(@builtin(global_invocation_id) gid: vec3<u32>) {
if (gid.x >= u.width || gid.y >= u.height) {
return;
}
let x = i32(gid.x);
let y = i32(gid.y);
let tl = feat_at(x - 1, y - 1);
let tc = feat_at(x, y - 1);
let tr = feat_at(x + 1, y - 1);
let ml = feat_at(x - 1, y);
let mr = feat_at(x + 1, y);
let bl = feat_at(x - 1, y + 1);
let bc = feat_at(x, y + 1);
let br = feat_at(x + 1, y + 1);
let gx = (tr + 2.0 * mr + br) - (tl + 2.0 * ml + bl);
let gy = (bl + 2.0 * bc + br) - (tl + 2.0 * tc + tr);
let w = vec3<f32>(u.w_luma, u.w_chroma, u.w_chroma);
let wx = gx * w;
let wy = gy * w;
grad_out[gid.y * u.width + gid.x] = sqrt(dot(wx, wx) + dot(wy, wy));
}
// ---------------------------------------------------------- lower-complete
//
// A watershed needs every non-minimum pixel to have a lower neighbour. A real
// gradient does not oblige: a flat wall, a clipped sky or the inside of a
// uniform object is a **plateau**, where every neighbour is exactly equal and
// there is no downhill direction to follow.
//
// Left alone, the tie-break in `flow` sends every plateau pixel to its
// lowest-indexed neighbour, which is up and to the left. Each pixel therefore
// walks diagonally until it falls off the plateau, and one flat region becomes
// a fan of diagonal chains rather than one basin — visible as hatching across
// what should be a single area (docs/segmentation.md §12, F1).
//
// The fix is the standard lower-completion: give each plateau pixel its
// geodesic distance to the nearest pixel that *does* have a lower neighbour,
// then let `flow` order on (gradient, distance). Water on a plateau now runs
// toward the plateau's exit, which is what it would physically do.
//
// A plateau with no exit at all is a genuine regional minimum — the inside of
// a uniform disc, say. Those pixels keep `PLATEAU_UNRESOLVED`, tie with each
// other, and fall through to the index tie-break, which collapses the whole
// connected plateau onto its lowest-indexed pixel. One basin, which is the
// right answer for a regional minimum.
const PLATEAU_UNRESOLVED: u32 = 0xffffffffu;
// How close two gradients must be to count as the same level.
//
// **Exact equality does not work here, and that is not a rounding nicety.**
// The gradient is a float computed from 8-bit samples, so a region the eye
// and the algorithm both consider flat still has neighbours differing in the
// sixth decimal. With `==`, the breadth-first step never advances past its
// seeds and the whole pass is a no-op; with `<`, nearly every pixel finds
// some marginally lower neighbour and is seeded at zero, which is the same
// no-op wearing a different hat. Both were measured before this constant
// existed.
//
// Sized against the gradient's own scale: features are normalised to 0..1, so
// a Sobel magnitude runs to a few units, and 1e-4 is far below any step a
// real edge produces while sitting comfortably above f32 noise from a blur.
const LEVEL_EPS: f32 = 1e-4;
// Whether `b` lies below `a` by more than the level tolerance.
fn strictly_below(b: f32, a: f32) -> bool {
return b < a - LEVEL_EPS;
}
// Whether two gradients belong to the same plateau.
fn same_level(a: f32, b: f32) -> bool {
return abs(a - b) <= LEVEL_EPS;
}
@group(0) @binding(7) var<storage, read> pinit_grad: array<f32>;
@group(0) @binding(8) var<storage, read_write> pinit_out: array<u32>;
// Seed the distance field: zero where a real descent exists, unresolved on a
// plateau.
@compute @workgroup_size(8, 8, 1)
fn plateau_init(@builtin(global_invocation_id) gid: vec3<u32>) {
if (gid.x >= u.width || gid.y >= u.height) {
return;
}
let idx = gid.y * u.width + gid.x;
let here = pinit_grad[idx];
for (var dy = -1; dy <= 1; dy = dy + 1) {
for (var dx = -1; dx <= 1; dx = dx + 1) {
if (dx == 0 && dy == 0) {
continue;
}
let nx = i32(gid.x) + dx;
let ny = i32(gid.y) + dy;
if (nx < 0 || ny < 0 || nx >= i32(u.width) || ny >= i32(u.height)) {
continue;
}
if (strictly_below(pinit_grad[u32(ny) * u.width + u32(nx)], here)) {
pinit_out[idx] = 0u;
return;
}
}
}
pinit_out[idx] = PLATEAU_UNRESOLVED;
}
@group(0) @binding(9) var<storage, read> pstep_grad: array<f32>;
@group(0) @binding(10) var<storage, read> pstep_in: array<u32>;
@group(0) @binding(11) var<storage, read_write> pstep_out: array<u32>;
// One breadth-first step inward from the plateau's rim.
//
// Iterated by the host a fixed number of times rather than to convergence: a
// convergence test costs a readback per pass, and the count only has to cover
// the widest plateau in the frame. Pixels still unresolved when the budget
// runs out keep `PLATEAU_UNRESOLVED` and behave exactly as they did before
// this pass existed — the degradation is graceful, not a wrong answer.
@compute @workgroup_size(8, 8, 1)
fn plateau_step(@builtin(global_invocation_id) gid: vec3<u32>) {
if (gid.x >= u.width || gid.y >= u.height) {
return;
}
let idx = gid.y * u.width + gid.x;
let current = pstep_in[idx];
if (current != PLATEAU_UNRESOLVED) {
pstep_out[idx] = current;
return;
}
let here = pstep_grad[idx];
var best = PLATEAU_UNRESOLVED;
for (var dy = -1; dy <= 1; dy = dy + 1) {
for (var dx = -1; dx <= 1; dx = dx + 1) {
if (dx == 0 && dy == 0) {
continue;
}
let nx = i32(gid.x) + dx;
let ny = i32(gid.y) + dy;
if (nx < 0 || ny < 0 || nx >= i32(u.width) || ny >= i32(u.height)) {
continue;
}
let ni = u32(ny) * u.width + u32(nx);
// Only within the same plateau: a neighbour at a different height
// is across a boundary, and its distance says nothing about the
// way out of this one.
if (!same_level(pstep_grad[ni], here)) {
continue;
}
let nd = pstep_in[ni];
if (nd != PLATEAU_UNRESOLVED && nd < best) {
best = nd;
}
}
}
if (best == PLATEAU_UNRESOLVED) {
pstep_out[idx] = PLATEAU_UNRESOLVED;
} else {
pstep_out[idx] = best + 1u;
}
}
// -------------------------------------------------------------------- flow
@group(0) @binding(12) var<storage, read> flow_grad: array<f32>;
@group(0) @binding(13) var<storage, read> flow_dist: array<u32>;
@group(0) @binding(14) var<storage, read_write> flow_out: array<u32>;
// Each pixel points at the steepest-descent neighbour among its 8, or at
// itself if it is a local minimum — a basin seed.
//
// **The ordering is load-bearing, twice over.** Comparing on (gradient,
// plateau distance, index) rather than gradient alone gives a strict total
// order, so the pointer graph descends monotonically and cannot contain a
// cycle — plateaux, which are everywhere in a smoothed image, would otherwise
// make two equal pixels point at each other and hang the pointer-jumping
// below.
//
// It is also what makes the result reproducible. S15's M5 asks whether a
// label field is stable enough across GPU vendors to be a cache key
// (ARCH §6.13); an arbitrary tie-break would answer no before the question
// was asked.
@compute @workgroup_size(8, 8, 1)
fn flow(@builtin(global_invocation_id) gid: vec3<u32>) {
if (gid.x >= u.width || gid.y >= u.height) {
return;
}
let idx = gid.y * u.width + gid.x;
var best_val = flow_grad[idx];
var best_dist = flow_dist[idx];
var best_idx = idx;
for (var dy = -1; dy <= 1; dy = dy + 1) {
for (var dx = -1; dx <= 1; dx = dx + 1) {
if (dx == 0 && dy == 0) {
continue;
}
let nx = i32(gid.x) + dx;
let ny = i32(gid.y) + dy;
if (nx < 0 || ny < 0 || nx >= i32(u.width) || ny >= i32(u.height)) {
continue;
}
let ni = u32(ny) * u.width + u32(nx);
let nv = flow_grad[ni];
let nd = flow_dist[ni];
// Lexicographic on (gradient, plateau distance, index) rather
// than one fused scalar. Folding the distance into the gradient
// as a small epsilon would need a scale factor that is small
// enough never to cross a real gradient step and large enough to
// survive f32 — a tuning problem with a silent failure mode,
// where three explicit keys have neither.
// The same tolerance the plateau passes use, and for the same
// reason: with exact equality this tie never fires on real data,
// so the distance carried inward above would be computed and then
// never consulted — the pass measurably did nothing.
var better = false;
if (strictly_below(nv, best_val)) {
better = true;
} else if (same_level(nv, best_val)) {
if (nd < best_dist) {
better = true;
} else if (nd == best_dist && ni < best_idx) {
better = true;
}
}
if (better) {
best_val = nv;
best_dist = nd;
best_idx = ni;
}
}
}
flow_out[idx] = best_idx;
}
// -------------------------------------------------------------------- jump
@group(0) @binding(15) var<storage, read> jump_in: array<u32>;
@group(0) @binding(16) var<storage, read_write> jump_out: array<u32>;
// Pointer jumping: parent = parent[parent].
//
// Halves every path length per dispatch, so ceil(log2(longest path)) passes
// resolve every pixel to its basin root. The host runs a fixed count bounded
// by log2(pixel count) rather than testing for convergence, because a
// convergence test costs a readback per iteration and the bound is ~21
// dispatches of a trivial kernel.
@compute @workgroup_size(8, 8, 1)
fn jump(@builtin(global_invocation_id) gid: vec3<u32>) {
if (gid.x >= u.width || gid.y >= u.height) {
return;
}
let idx = gid.y * u.width + gid.x;
jump_out[idx] = jump_in[jump_in[idx]];
}
+249
View File
@@ -0,0 +1,249 @@
// Black/white normalisation and X-Trans demosaic, in one pass.
//
// The Fujifilm counterpart to demosaic.wgsl (FR-RAW-5). The input and the
// output are the same — packed u16 photosites in, linear camera-space
// RGBA16Float out — but the colour filter array is a 6x6 tile rather than a
// 2x2 one, and none of the Bayer kernels survive that. In a Bayer cell every
// pixel has the missing channels at a fixed offset; in X-Trans the offsets
// differ at all 36 positions, so a fixed kernel per site would need 36 of
// them and would still say nothing about which neighbours to trust.
//
// The method here is *local plane fitting on the colour difference*:
//
// 1. Take a 5x5 window and sort its photosites by the channel each one
// measures. Every window holds at least four red, four blue and thirteen
// green samples, whatever the phase — checked exhaustively, not assumed.
// 2. Fit a weighted least-squares plane through each channel's samples and
// evaluate all three planes at the pixel. A plane rather than a mean
// because the three channels are sampled at *different* places: a mean
// would compare a red average taken slightly left of the pixel with a
// green average taken slightly right of it, and the difference of those
// two offsets is a colour cast that follows every gradient in the frame.
// A plane has no such bias — it reconstructs any linear gradient exactly.
// 3. Keep the pixel's own measured value, and carry the other two channels
// across as the *difference* between the fitted planes.
//
// Step 3 is the standard constant-colour-difference model, and the reason it
// is applied in white-balanced space is that the model is exact only where
// the channel difference is locally constant. On a neutral subject that is
// true after white balance and false before it, so the gains go on before the
// fit and come off after — which costs one multiply and halves the error at a
// luminance edge (measured on a synthetic step: 0.34 -> 0.20 max error).
// The pixels written out are still as-shot, unbalanced camera space; nothing
// downstream sees the difference.
//
// **What this is not.** It is not Markesteijn. It has no directional
// hypotheses and no homogeneity map, so it does not resolve detail finer than
// the CFA period, and a hard edge arrives about two pixels wide. It does not
// produce the "worms" that FR-RAW-5 exists to avoid — the output is bounded
// by the local sample range, so it cannot ring — but the Markesteijn-class
// quality that requirement asks for is still owed.
struct XTransParams {
// Dimensions of the *cropped* output, in pixels.
width: u32,
height: u32,
// Origin of the crop within the sensor readout, in photosites. Added to
// every read, and to every pattern lookup: the 6x6 tile is anchored to the
// sensor, not to the visible frame.
crop_x: u32,
crop_y: u32,
// Row stride of the input, in samples.
stride: u32,
// One black level and one reciprocal range for the whole sensor. The
// four-value form the Bayer path uses is a 2x2 convention with no meaning
// on a 6x6 tile.
black: f32,
inv_range: f32,
_pad0: u32,
// As-shot white balance gains, green-normalised, and their reciprocals.
wb: vec4<f32>,
inv_wb: vec4<f32>,
// The 6x6 tile, already rotated to this sensor's phase on the CPU, packed
// two bits per photosite: 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: XTransParams;
@group(0) @binding(2) var output: texture_storage_2d<rgba16float, write>;
// Half-width of the fitting window. Two is the smallest radius for which
// every phase of the tile still offers enough red and blue samples to pin a
// plane down; three would be smoother and blurrier.
const RADIUS: i32 = 2;
// Colour of the photosite at absolute sensor coordinates: 0=R, 1=G, 2=B.
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;
}
// Read one photosite, normalised to [0, 1] against the black level.
//
// Coordinates are relative to the crop origin and must already be in range;
// unlike the Bayer pass there is no reflection here, because the window is
// slid inside the image instead (see `main`) and so never asks for a
// photosite that does not exist.
fn sample(cx: i32, cy: i32) -> f32 {
let sx = u32(cx) + params.crop_x;
let sy = u32(cy) + params.crop_y;
let index = sy * params.stride + sx;
let word = raw[index >> 1u];
let raw_value = select(word & 0xFFFFu, word >> 16u, (index & 1u) == 1u);
// Sensor noise puts real signal below the black point, so subtracting it
// can go negative; clamped rather than allowed to wrap.
return max((f32(raw_value) - params.black) * params.inv_range, 0.0);
}
// Solve the 3x3 weighted least-squares normal equations for a plane
// `c0 + c1*dx + c2*dy` and evaluate it at `(ex, ey)`.
//
// The accumulators are the usual moments: `n` is the summed weight, `sx`..`syy`
// the first and second moments of the sample positions, `t0`..`ty` the same
// moments weighted by value. Cramer's rule rather than a factorisation — the
// matrix is 3x3 and symmetric, and this keeps the whole solve in registers.
fn plane_at(
n: f32, sx: f32, sy: f32, sxx: f32, sxy: f32, syy: f32,
t0: f32, tx: f32, ty: f32, ex: f32, ey: f32,
) -> f32 {
if (n <= 0.0) {
return 0.0;
}
let det = n * (sxx * syy - sxy * sxy)
- sx * (sx * syy - sxy * sy)
+ sy * (sx * sxy - sxx * sy);
// Degenerate only if a channel's samples in this window are collinear,
// which the 5x5 geometry rules out for every phase — but an image a few
// photosites across is clipped down to fewer samples than that, and a
// division by a near-zero determinant there would put NaN in the texture.
// Falling back to the plain weighted mean loses the gradient term and
// nothing else.
if (abs(det) < 1e-6 * n * n * n) {
return t0 / n;
}
let inv = 1.0 / det;
let c0 = (t0 * (sxx * syy - sxy * sxy)
- sx * (tx * syy - sxy * ty)
+ sy * (tx * sxy - sxx * ty)) * inv;
let c1 = (n * (tx * syy - sxy * ty)
- t0 * (sx * syy - sxy * sy)
+ sy * (sx * ty - tx * sy)) * inv;
let c2 = (n * (sxx * ty - tx * sxy)
- sx * (sx * ty - tx * sy)
+ t0 * (sx * sxy - sxx * sy)) * inv;
return c0 + c1 * ex + c2 * ey;
}
@compute @workgroup_size(8, 8, 1)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
if (gid.x >= params.width || gid.y >= params.height) {
return;
}
let x = i32(gid.x);
let y = i32(gid.y);
let w = i32(params.width);
let h = i32(params.height);
// Near an edge the window slides inward rather than reflecting. Reflection
// is right for the Bayer pass, whose kernels only need the mirrored sample
// to be the same colour; here the fit needs real geometry, and a mirrored
// photosite sitting at a position it does not occupy tilts the plane. A
// slid window is entirely real data, so the plane stays exact right up to
// the border — the pixel is still inside the window, just not centred in
// it, which is what `ex`/`ey` below account for.
let wx = clamp(x, RADIUS, max(RADIUS, w - 1 - RADIUS));
let wy = clamp(y, RADIUS, max(RADIUS, h - 1 - RADIUS));
// Per-channel moments, indexed 0=R, 1=G, 2=B.
var n = vec3<f32>(0.0);
var sx = vec3<f32>(0.0);
var sy = vec3<f32>(0.0);
var sxx = vec3<f32>(0.0);
var sxy = vec3<f32>(0.0);
var syy = vec3<f32>(0.0);
var t0 = vec3<f32>(0.0);
var tx = vec3<f32>(0.0);
var ty = vec3<f32>(0.0);
var lo = vec3<f32>(1.0e30);
var hi = vec3<f32>(-1.0e30);
for (var dy = -RADIUS; dy <= RADIUS; dy = dy + 1) {
for (var dx = -RADIUS; dx <= RADIUS; dx = dx + 1) {
// The clamp only bites on an image narrower than the window, where
// a duplicated photosite is better than a missing channel.
let px = clamp(wx + dx, 0, w - 1);
let py = clamp(wy + dy, 0, h - 1);
let v = sample(px, py);
let k = colour_at(u32(px) + params.crop_x, u32(py) + params.crop_y);
let u = v * params.wb[k];
let fx = f32(px - wx);
let fy = f32(py - wy);
// Nearer photosites describe this pixel better. 1/(1+r^2) rather
// than a Gaussian because it needs no width to tune and leaves the
// normal equations well conditioned at every phase.
let g = 1.0 / (1.0 + fx * fx + fy * fy);
n[k] = n[k] + g;
sx[k] = sx[k] + g * fx;
sy[k] = sy[k] + g * fy;
sxx[k] = sxx[k] + g * fx * fx;
sxy[k] = sxy[k] + g * fx * fy;
syy[k] = syy[k] + g * fy * fy;
t0[k] = t0[k] + g * u;
tx[k] = tx[k] + g * u * fx;
ty[k] = ty[k] + g * u * fy;
lo[k] = min(lo[k], v);
hi[k] = max(hi[k], v);
}
}
let ex = f32(x - wx);
let ey = f32(y - wy);
var p = vec3<f32>(
plane_at(n.r, sx.r, sy.r, sxx.r, sxy.r, syy.r, t0.r, tx.r, ty.r, ex, ey),
plane_at(n.g, sx.g, sy.g, sxx.g, sxy.g, syy.g, t0.g, tx.g, ty.g, ex, ey),
plane_at(n.b, sx.b, sy.b, sxx.b, sxy.b, syy.b, t0.b, tx.b, ty.b, ex, ey),
);
// The pixel's own channel always has a sample — itself — so its plane is
// always real, and it is the reference the other two are carried across
// from.
let centre = colour_at(u32(x) + params.crop_x, u32(y) + params.crop_y);
let measured = sample(x, y);
// A channel with no sample at all cannot happen in a 5x5 window; it can on
// an image a few photosites across, where the window collapses. Such a
// channel is given the reference plane, which renders the pixel grey
// rather than arbitrary.
let empty = n <= vec3<f32>(0.0);
p = select(p, vec3<f32>(p[centre]), empty);
lo = select(lo, vec3<f32>(0.0), empty);
hi = select(hi, vec3<f32>(1.0e30), empty);
// Constant colour difference, in white-balanced space, undone on the way
// out. The measured channel comes back bit-for-bit: its own plane cancels.
var rgb = (vec3<f32>(measured * params.wb[centre]) + p - vec3<f32>(p[centre]))
* params.inv_wb.rgb;
// Bound each channel by what was actually measured nearby. The plane
// difference overshoots wherever the colour itself changes across the
// window — a red edge against green — and an overshoot here is a coloured
// halo. The pixel is always inside its own window, so a true value can
// never be clipped away by this on smooth content.
rgb = clamp(rgb, lo, hi);
rgb = max(rgb, vec3<f32>(0.0));
textureStore(output, vec2<i32>(x, y), vec4<f32>(rgb, 1.0));
}
+179
View File
@@ -0,0 +1,179 @@
//! TRACES: FR-DEV-3e
//! The camera profile's base curve, end to end on a device.
//!
//! The unit tests either side of this one check halves. `dr-decode` asserts
//! that the shipped database parses and that every curve in it lifts its
//! midtones; `dr-pipeline` asserts that the generated WGSL evaluates a curve
//! in the right place. Neither would notice if the two agreed with each other
//! and both were wrong — a curve packed into the wrong uniform slots, or a
//! flag read from the wrong component, satisfies both and renders nothing.
//!
//! So this renders real pixels twice, once with a profiled body's curve and
//! once with the identity, and asserts the difference is the one a base curve
//! is for: midtones lifted, black still black, white still white.
use dr_decode::{BaseCurve, CfaPattern, CropRect, RawImage};
use dr_gpu::{AdjustPass, Demosaicer, GpuContext};
use dr_pipeline::EditGraph;
const SIZE: u32 = 16;
fn ctx() -> Option<GpuContext> {
pollster::block_on(GpuContext::new_headless()).ok()
}
/// A flat RGGB frame at `level` out of 65535, carrying `curve`.
///
/// Every photosite the same value, so the demosaic result is a uniform grey
/// and the only thing that can move a pixel is the curve. The colour matrix is
/// the identity and the balance is neutral for the same reason: this test is
/// about one stage, and a real body's matrix would make every assertion below
/// a statement about that body instead.
fn flat_raw(level: u16, curve: BaseCurve) -> RawImage {
RawImage {
width: SIZE,
height: SIZE,
data: vec![level; (SIZE * SIZE) as usize],
cfa_pattern: CfaPattern::Rggb,
black_level: [0; 4],
white_level: u16::MAX,
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
base_curve: curve,
crop: CropRect {
x: 0,
y: 0,
width: SIZE,
height: SIZE,
},
}
}
/// Render a neutral edit over a flat frame and return the centre pixel's red.
///
/// The centre rather than a corner: a demosaic has to invent its edges, and
/// the interpolated border of a 16×16 frame is not where anyone should be
/// reading a tone off.
fn rendered_level(ctx: &GpuContext, level: u16, curve: BaseCurve) -> u8 {
let raw = flat_raw(level, curve);
let source = Demosaicer::new(ctx)
.expect("demosaicer")
.run(&raw)
.expect("demosaic");
let shader = EditGraph::default_chain().compose();
let mut adjust = AdjustPass::new(ctx);
adjust
.render(&source, &shader, SIZE, SIZE)
.expect("render");
let (pixels, _, _) = adjust.export_pixels().expect("readback");
let centre = ((SIZE / 2) * SIZE + SIZE / 2) * 4;
pixels[centre as usize]
}
/// The Canon EOS 6D's curve, from the shipped profile database.
///
/// Looked up by name rather than written out, so this also asserts the thing
/// no other test can: that a curve travels from the YAML, through the body
/// match, onto the decoded image and into the uniform block that the shader
/// actually reads.
fn six_d() -> BaseCurve {
let curve = dr_decode::base_curve::for_body("Canon", "EOS 6D");
assert!(
!curve.is_identity(),
"the shipped database must have a curve for the EOS 6D"
);
curve
}
#[test]
fn a_profiled_body_renders_brighter_midtones_than_a_flat_one() {
// **The whole requirement, in one assertion.** A linear midtone renders
// roughly half a stop dark, which is the flat, lifeless look FR-DEV-3e
// exists to get away from. If the curve did not reach the shader — wrong
// slot, wrong flag, wrong stage — this is the only test that would fail.
let Some(ctx) = ctx() else {
eprintln!("skipping: no GPU adapter");
return;
};
// 13% of full scale: roughly where a camera places middle grey, leaving
// about two and a half stops of highlight headroom above it.
let level = (0.13 * 65535.0) as u16;
let flat = rendered_level(&ctx, level, BaseCurve::IDENTITY);
let profiled = rendered_level(&ctx, level, six_d());
assert!(
profiled > flat + 8,
"the profile lifted middle grey from {flat} only to {profiled}"
);
}
#[test]
fn the_curve_leaves_black_black_and_white_white() {
// A base curve renders the range between the endpoints; it must not move
// the endpoints themselves. A curve that lifted black would put a grey
// veil over every night photograph, and one that pulled white down would
// make a correctly exposed frame look underexposed.
let Some(ctx) = ctx() else {
eprintln!("skipping: no GPU adapter");
return;
};
let curve = six_d();
assert_eq!(rendered_level(&ctx, 0, curve), 0, "black moved");
assert_eq!(rendered_level(&ctx, u16::MAX, curve), 255, "white moved");
}
#[test]
fn an_unprofiled_body_renders_exactly_as_it_did_before_profiles_existed() {
// The graceful fallback, asserted as a number rather than as a promise.
// With no curve the pipeline must still be a pass-through: black level
// out, white level in, sRGB encoding on the way to the screen and nothing
// else. "Never worse than today" is the one property this change was not
// allowed to trade away, and the way it would break is silently — a flag
// read from the wrong component would apply a curve nobody asked for.
let Some(ctx) = ctx() else {
eprintln!("skipping: no GPU adapter");
return;
};
for level in [0u16, 4_000, 8_520, 32_768, 60_000, u16::MAX] {
let scene = f32::from(level) / f32::from(u16::MAX);
let expected = (dr_types::Transfer::Srgb.encode(scene) * 255.0).round() as i32;
let got = i32::from(rendered_level(&ctx, level, BaseCurve::IDENTITY));
// Two 8-bit steps: the texture holding the demosaiced frame is
// `Rgba16Float`, so a value round-trips through eleven mantissa bits
// before it is encoded. That is well under one step at any level, and
// the tolerance is for the rounding either side of it rather than for
// the transform being approximate.
assert!(
(got - expected).abs() <= 2,
"raw {level} rendered as {got}, expected about {expected}"
);
}
}
#[test]
fn the_curve_is_monotone_through_the_whole_range() {
// The property the spline's tangent limiting exists to guarantee, checked
// where it actually matters: on the device, through the real uniform
// packing. A curve that dipped anywhere would put a dark band across a
// smooth gradient — a sky, most visibly — and it would read as a
// rendering fault rather than as a bad profile.
let Some(ctx) = ctx() else {
eprintln!("skipping: no GPU adapter");
return;
};
let curve = six_d();
let mut previous = 0u8;
for step in 0..=16u32 {
let level = (step * 65535 / 16) as u16;
let value = rendered_level(&ctx, level, curve);
assert!(
value >= previous,
"the curve fell from {previous} to {value} at raw level {level}"
);
previous = value;
}
}
+505
View File
@@ -0,0 +1,505 @@
//! Capture sharpening, end to end on a real device.
//!
//! `dr-pipeline`'s own tests assert what the composer *generates* — the kernel
//! extent, the uniforms, which pass encodes. None of them can tell whether the
//! generated WGSL compiles, whether the second pass is handed what the first
//! one wrote, or whether the result is sharpening rather than a shader that
//! silently produced the input again. Those are questions only a GPU answers.
//!
//! # Reading the expected values
//!
//! The source is uploaded through `DemosaicedImage::from_rgba8`, which flags it
//! non-linear, so the generated shader decodes sRGB before any operation runs
//! and a black/white step reaches the detail stage as linear 0.0 and 1.0
//! exactly. The last detail pass re-encodes. So a byte read back here is
//! `srgb_encode(whatever the kernel produced in linear light)`, and an
//! overshoot — the bright fringe an unsharp mask puts on the light side of an
//! edge — cannot show above 255 on the white side of a full-scale step, and the
//! undershoot on the dark side of one clips to black long before the halo has
//! been drawn. The tests therefore use a **grey** step, from byte 90 to byte
//! 150, which at 100% amount leaves the whole halo inside the representable
//! range at both ends. Every expected value below is arithmetic on that step,
//! not a number read off a previous run.
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext};
use dr_pipeline::descriptor::ParamId;
use dr_pipeline::ops::capture_sharpen::{AMOUNT, ID, RADIUS, THRESHOLD};
use dr_pipeline::{Affects, EditGraph};
use dr_types::ColourSpace;
fn ctx() -> Option<GpuContext> {
// CI runners and headless machines may have no usable adapter. Skip rather
// than fail, exactly as the rest of this crate's device tests do.
match pollster::block_on(GpuContext::new_headless()) {
Ok(c) => Some(c),
Err(e) => {
eprintln!("skipping: no GPU adapter ({e})");
None
}
}
}
/// A vertical step from `low` to `high`, changing at the middle column.
///
/// The one image whose sharpening is worth checking by hand: an unsharp mask
/// must darken the last few columns before the step and brighten the first few
/// after it, and leave everything further away exactly where it was. A gradient
/// would blur to itself and hide a kernel that does nothing at all.
fn step_edge(ctx: &GpuContext, size: u32, low: u8, high: u8) -> DemosaicedImage {
let data: Vec<u8> = (0..size * size)
.flat_map(|i| {
let v = if (i % size) < size / 2 { low } else { high };
[v, v, v, 255]
})
.collect();
DemosaicedImage::from_rgba8(ctx, &data, size, size).expect("upload")
}
/// A flat field of one value.
fn flat(ctx: &GpuContext, size: u32, value: u8) -> DemosaicedImage {
let data: Vec<u8> = (0..size * size)
.flat_map(|_| [value, value, value, 255])
.collect();
DemosaicedImage::from_rgba8(ctx, &data, size, size).expect("upload")
}
/// One row of the rendered image, red channel, as bytes.
fn row(pixels: &[u8], width: u32, y: u32) -> Vec<u8> {
(0..width)
.map(|x| pixels[((y * width + x) * 4) as usize])
.collect()
}
/// The develop chain with capture sharpening set.
fn sharpened(amount: f32, radius: f32, threshold: f32) -> EditGraph {
let mut graph = EditGraph::default_chain();
graph.set_param(ID, AMOUNT, amount);
graph.set_param(ID, RADIUS, radius);
graph.set_param(ID, THRESHOLD, threshold);
graph
}
/// Render one graph, with its detail stage, and read the pixels back.
///
/// The whole calling convention a frontend adopts, in five lines: compose both
/// halves from one graph at one output space, ask the graph for the scale, and
/// pass the invalidation key through.
fn render(
pass: &mut AdjustPass,
graph: &EditGraph,
source: &DemosaicedImage,
out: u32,
) -> Vec<u8> {
let shader = graph.compose_for(ColourSpace::Srgb);
let scale = graph.render_scale(source.size(), (out, out));
let detail = graph.compose_detail_for(scale, ColourSpace::Srgb);
let key = graph.invalidation().through(Affects::Colour);
pass.render_detailed(source, &shader, out, out, None, &detail, key)
.expect("render");
pass.export_pixels().expect("readback").0
}
#[test]
fn an_unsharp_mask_puts_a_halo_on_the_edge_and_leaves_the_rest_alone() {
// What sharpening *is*, asserted as pixels rather than as "something
// changed": an undershoot immediately before the transition, an overshoot
// immediately after it, the step itself steeper than it was, and the flat
// ground at either end untouched. A shader that ran the blur and forgot to
// add the difference back would pass a "the image changed" test and fail
// every one of these.
let Some(ctx) = ctx() else { return };
const SIZE: u32 = 64;
let source = step_edge(&ctx, SIZE, 90, 150);
let plain = render(
&mut AdjustPass::new(&ctx),
&EditGraph::default_chain(),
&source,
SIZE,
);
let sharp = render(
&mut AdjustPass::new(&ctx),
&sharpened(100.0, 2.0, 0.0),
&source,
SIZE,
);
let before = row(&plain, SIZE, SIZE / 2);
let after = row(&sharp, SIZE, SIZE / 2);
let edge = (SIZE / 2) as usize;
// The dark side of the transition is driven darker and the light side
// lighter — the halo. Two pixels in, where a two-pixel-sigma kernel has
// most of its response.
assert!(
after[edge - 2] < before[edge - 2],
"the dark side of the edge should be pushed down: {} -> {}",
before[edge - 2],
after[edge - 2]
);
assert!(
after[edge + 1] > before[edge + 1],
"the light side of the edge should be pushed up: {} -> {}",
before[edge + 1],
after[edge + 1]
);
// And the transition really is steeper across the same two columns.
let slope = |r: &[u8]| r[edge] as i32 - r[edge - 1] as i32;
assert!(
slope(&after) > slope(&before),
"sharpening must steepen the edge: {} -> {}",
slope(&before),
slope(&after)
);
// Far from the edge there is nothing to sharpen, so nothing may move. This
// is the property a kernel that forgot to normalise its weights breaks,
// and it breaks it as a brightness shift over the whole photograph.
for x in [0usize, 4, 8, SIZE as usize - 1] {
assert!(
after[x].abs_diff(before[x]) <= 1,
"column {x} is flat ground and moved: {} -> {}",
before[x],
after[x]
);
}
}
#[test]
fn a_flat_field_survives_any_amount_of_sharpening() {
// The kernel sums to one — `(1 + a)` of the pixel minus `a` of its blur —
// so a sky must come through bit for bit however far the slider is pushed.
// The border is the part that is easy to get wrong: `tap` clamps, and a
// kernel that normalised by an analytic integral instead of by the weights
// it actually summed would draw a band around the whole frame.
let Some(ctx) = ctx() else { return };
const SIZE: u32 = 48;
let source = flat(&ctx, SIZE, 128);
let plain = render(
&mut AdjustPass::new(&ctx),
&EditGraph::default_chain(),
&source,
SIZE,
);
let sharp = render(
&mut AdjustPass::new(&ctx),
&sharpened(100.0, 3.0, 0.0),
&source,
SIZE,
);
for (i, (a, b)) in sharp.iter().zip(&plain).enumerate() {
assert!(
a.abs_diff(*b) <= 1,
"pixel {} of a flat field moved: {b} -> {a}",
i / 4
);
}
}
#[test]
fn a_proxy_and_an_export_sharpen_the_same_photograph() {
// TRACES: FR-DSP-1 — the decision this operation is most likely to get
// wrong, and the one that is invisible until an export comes back wrong.
//
// The same edit, rendered at two resolutions of one source. The radius is
// in source pixels, so the halo must cover the same *proportion of the
// picture* at both: a fringe four source pixels wide is four source pixels
// wide whether it was drawn on a half-size proxy or at full size.
//
// Read the radius as render pixels instead and the proxy's halo would be
// twice as wide relative to the frame and roughly twice as strong, so what
// was tuned on screen would not be what landed in the file. That is the
// failure this catches, and it is a large one: the widths would differ by a
// factor of two, not by a rounding.
let Some(ctx) = ctx() else { return };
const SOURCE: u32 = 128;
let source = step_edge(&ctx, SOURCE, 90, 150);
// The widest radius the slider offers, so that even the half-size proxy
// has a 1.5-pixel sigma and resolves it — the honest cut-off is tested in
// `dr-pipeline`, and this test is about the case where both renders draw.
// Asking for more would be asking for a photograph nobody can produce:
// `EditGraph::set_param` clamps to the descriptor on the way in.
let graph = sharpened(100.0, 3.0, 0.0);
// The halo, measured against the same edit with no sharpening at the same
// size: how far from the transition the picture is still disturbed, as a
// fraction of the frame, and how much deviation the halo carries in total.
let measure = |out: u32| -> (f32, f32) {
let plain = render(
&mut AdjustPass::new(&ctx),
&EditGraph::default_chain(),
&source,
out,
);
let sharp = render(&mut AdjustPass::new(&ctx), &graph, &source, out);
let (a, b) = (row(&plain, out, out / 2), row(&sharp, out, out / 2));
let disturbed: Vec<usize> = (0..out as usize)
.filter(|&x| b[x].abs_diff(a[x]) > 3)
.collect();
let first = *disturbed.first().expect("a halo");
let last = *disturbed.last().expect("a halo");
// The halo's strength as an *area* — the sum of the deviations, scaled
// by the width of a render pixel — rather than as its peak. A peak is
// one sample of a smooth curve, and the two renders do not sample it at
// the same place: the pixel next to the transition sits half a render
// pixel from it, which is half a source pixel at export and a whole one
// on the proxy, so their peaks would legitimately differ by more than
// the property under test. An integral over the same curve does not
// care where the samples fell.
let area: f32 = (0..out as usize)
.map(|x| b[x].abs_diff(a[x]) as f32)
.sum::<f32>()
/ out as f32;
((last - first) as f32 / out as f32, area)
};
let (proxy_width, proxy_area) = measure(SOURCE / 2);
let (export_width, export_area) = measure(SOURCE);
assert!(
(proxy_width - export_width).abs() < 0.06,
"the halo covers {proxy_width:.3} of the proxy and {export_width:.3} \
of the export; a radius tuned on screen must land in the file"
);
// The strength has to agree too. A viewport-scaled kernel would not only
// be wider on the proxy, it would push the fringe further, because a wider
// blur takes more away for the high-pass to add back — so the areas would
// differ by considerably more than the sampling slack allowed here.
let ratio = proxy_area / export_area;
assert!(
(0.75..1.35).contains(&ratio),
"the halo carries {proxy_area:.2} on the proxy and {export_area:.2} at \
export, a ratio of {ratio:.2}"
);
// And both are a real halo rather than two flat images agreeing.
assert!(
proxy_width > 0.05 && export_width > 0.05,
"{proxy_width:.3} / {export_width:.3}"
);
assert!(proxy_area > 1.0 && export_area > 1.0, "{proxy_area} / {export_area}");
}
#[test]
fn the_threshold_leaves_shallow_modulation_where_it_found_it() {
// What the threshold is for: sensor noise is shallow, and sharpening it is
// the fastest way to make a clean frame look worse. Two images, one with a
// strong edge and one with a shallow ripple, through the same gate — the
// edge must still sharpen and the ripple must not.
let Some(ctx) = ctx() else { return };
const SIZE: u32 = 64;
// A four-code ripple: about 4% local contrast at this level, which is the
// order of magnitude read noise reaches on a well-exposed frame — and well
// under the 12.5% at which the gate below starts letting detail through.
let ripple: Vec<u8> = (0..SIZE * SIZE)
.flat_map(|i| {
let v = if (i % SIZE) % 2 == 0 { 128u8 } else { 132 };
[v, v, v, 255]
})
.collect();
let ripple = DemosaicedImage::from_rgba8(&ctx, &ripple, SIZE, SIZE).expect("upload");
let edge = step_edge(&ctx, SIZE, 90, 150);
let gated = sharpened(100.0, 1.0, 1.0);
let ungated = sharpened(100.0, 1.0, 0.0);
let spread = |graph: &EditGraph, source: &DemosaicedImage| -> u8 {
let pixels = render(&mut AdjustPass::new(&ctx), graph, source, SIZE);
let line = row(&pixels, SIZE, SIZE / 2);
// Peak-to-peak over the middle of the row, away from the border.
let window = &line[8..24];
window.iter().max().unwrap() - window.iter().min().unwrap()
};
let ripple_open = spread(&ungated, &ripple);
let ripple_gated = spread(&gated, &ripple);
assert!(
ripple_gated < ripple_open,
"the gate must hold shallow modulation back: {ripple_open} -> \
{ripple_gated}"
);
// The edge is deep modulation and must come through the same gate
// sharpened — a threshold that flattens everything is not a threshold.
let plain_edge = {
let pixels = render(
&mut AdjustPass::new(&ctx),
&EditGraph::default_chain(),
&edge,
SIZE,
);
row(&pixels, SIZE, SIZE / 2)
};
let gated_edge = {
let pixels = render(&mut AdjustPass::new(&ctx), &gated, &edge, SIZE);
row(&pixels, SIZE, SIZE / 2)
};
let mid = (SIZE / 2) as usize;
assert!(
gated_edge[mid - 1] < plain_edge[mid - 1],
"a real edge must still sharpen through the gate: {} -> {}",
plain_edge[mid - 1],
gated_edge[mid - 1]
);
}
#[test]
fn sharpening_an_edge_does_not_change_its_colour() {
// The reason the high-pass is applied as a gain on the three channels
// rather than as an offset. An offset moves a saturated colour towards
// grey as it brightens it, so a sharpened red roof gets a pink fringe —
// which reads as chromatic aberration and gets blamed on the lens.
let Some(ctx) = ctx() else { return };
const SIZE: u32 = 64;
// A step between two saturated reds of different brightness: the ratios
// between the channels are the colour, and they must survive the halo.
let data: Vec<u8> = (0..SIZE * SIZE)
.flat_map(|i| {
if (i % SIZE) < SIZE / 2 {
[80u8, 30, 30, 255]
} else {
[200, 75, 75, 255]
}
})
.collect();
let source = DemosaicedImage::from_rgba8(&ctx, &data, SIZE, SIZE).expect("upload");
let pixels = render(
&mut AdjustPass::new(&ctx),
&sharpened(60.0, 2.0, 0.0),
&source,
SIZE,
);
// Sampled inside the halo, where an additive sharpener would have washed
// the colour out most.
let y = SIZE / 2;
for x in [SIZE / 2 - 2, SIZE / 2 + 1] {
let i = ((y * SIZE + x) * 4) as usize;
let (r, g, b) = (pixels[i] as f32, pixels[i + 1] as f32, pixels[i + 2] as f32);
assert!(r > g && r > b, "the fringe lost its hue at column {x}");
// Green and blue started equal and must stay equal: an offset would
// keep them equal too, but the *ratio* to red is what moves, and this
// is the assertion that it did not.
let saturation = (r - g) / r;
assert!(
saturation > 0.55,
"column {x} washed out: rgb {r} {g} {b}, saturation {saturation:.3}"
);
}
}
#[test]
fn dragging_the_amount_recompiles_nothing_and_reallocates_nothing() {
// The two costs that are ruinous per frame and invisible in the output.
// A sharpening slider is dragged continuously, so this is the difference
// between a control that tracks the mouse and one that stutters.
let Some(ctx) = ctx() else { return };
const SIZE: u32 = 48;
let source = step_edge(&ctx, SIZE, 90, 150);
let mut pass = AdjustPass::new(&ctx);
let mut graph = sharpened(40.0, 1.0, 0.0);
render(&mut pass, &graph, &source, SIZE);
let pipelines = pass.cached_detail_pipelines();
let allocations = pass.detail_allocations();
assert_eq!(pipelines, 2, "one per axis of the separable mask");
assert_eq!(allocations, 2, "the colour result, and one hand-off");
assert_eq!(pass.detail_dispatches(), 2);
assert_eq!(pass.colour_dispatches(), 1);
for amount in [50.0, 60.0, 70.0, 80.0] {
graph.set_param(ID, AMOUNT, amount);
render(&mut pass, &graph, &source, SIZE);
}
assert_eq!(
pass.cached_detail_pipelines(),
pipelines,
"an amount is a uniform, not a shader"
);
assert_eq!(
pass.detail_allocations(),
allocations,
"a steady viewport must allocate nothing"
);
// TRACES: FR-DEV-3d — and the operational point of `Affects::Detail`:
// sharpening is downstream of every fused operation, so dragging it must
// not re-run them.
assert_eq!(
pass.colour_dispatches(),
1,
"the fused colour pass re-ran for a change it does not depend on"
);
// The radius is also only a uniform, even though it changes the kernel
// extent — the loop bound is read from the uniform block rather than
// baked into the source, which is what keeps a drag off the compiler.
graph.set_param(ID, RADIUS, 2.5);
render(&mut pass, &graph, &source, SIZE);
assert_eq!(pass.cached_detail_pipelines(), pipelines);
graph.set_param(ID, THRESHOLD, 0.3);
render(&mut pass, &graph, &source, SIZE);
assert_eq!(pass.cached_detail_pipelines(), pipelines);
}
#[test]
fn a_render_too_coarse_for_the_radius_still_reaches_the_screen() {
// The failure mode that the pass-through exists to prevent, proved on a
// device rather than argued about. With the radius finer than a render
// pixel the operation declines to sharpen — but it is still active, so the
// fused pass has already been composed to hand on unclipped linear values,
// and something must still perform the output transform. An empty chain
// here would not be a soft preview: it would be a hard error out of
// `render_detailed`, on the most ordinary develop view there is.
let Some(ctx) = ctx() else { return };
const SOURCE: u32 = 128;
const RENDER: u32 = 32; // a quarter scale, as a fit view of a large frame
let source = step_edge(&ctx, SOURCE, 90, 150);
let graph = sharpened(100.0, 1.0, 0.0);
let scale = graph.render_scale((SOURCE, SOURCE), (RENDER, RENDER));
assert!(!scale.resolves(1.0), "the premise of this test");
let mut pass = AdjustPass::new(&ctx);
let sharp = render(&mut pass, &graph, &source, RENDER);
assert_eq!(pass.detail_dispatches(), 1, "one pass, and it only encodes");
// And what reaches the screen is the unsharpened picture, not a black
// frame, a linear one, or a guess.
let plain = render(
&mut AdjustPass::new(&ctx),
&EditGraph::default_chain(),
&source,
RENDER,
);
for (i, (a, b)) in sharp.iter().zip(&plain).enumerate() {
assert!(
a.abs_diff(*b) <= 1,
"pixel {} differs from the unsharpened render: {b} -> {a}",
i / 4
);
}
}
#[test]
fn the_operation_is_reachable_by_the_ids_a_frontend_will_use() {
// FR-DEV-3c: adding an operation needs no UI change, which is only true if
// the panel can find it through the capability list. A typo between the
// declaration's `id:` and the descriptor's would place it in the chain
// under one name and address it under another.
let graph = EditGraph::default_chain();
let cap = graph
.capabilities()
.into_iter()
.find(|c| c.id == ID)
.expect("capture sharpening is in the default chain");
let names: Vec<ParamId> = cap.params.iter().map(|p| p.id).collect();
assert_eq!(names, vec![AMOUNT, RADIUS, THRESHOLD]);
assert!(!cap.active, "a fresh chain is not sharpening anything");
}
+408
View File
@@ -0,0 +1,408 @@
//! The neighbourhood stage, end to end on a real device.
//!
//! `dr-pipeline`'s own tests assert what the composer *generates*; nothing
//! there can tell whether the WGSL compiles, whether pass two is handed what
//! pass one wrote, or whether the output transform happens exactly once. Those
//! are questions only a GPU answers, and they are the ones that decide whether
//! a future sharpening operation works or draws nonsense.
//!
//! The consumer is `detail_probe`, a separable box blur that is not a develop
//! operation (see `dr_pipeline::detail::probe`). A box blur is used because its
//! answer is known in closed form: over a step edge it produces a ramp exactly
//! `2r + 1` pixels wide with a computable value at every step, so these tests
//! assert **pixels** rather than "something changed".
//!
//! # Reading the expected values
//!
//! The source is uploaded through `DemosaicedImage::from_rgba8`, which flags it
//! non-linear, so the generated shader decodes sRGB before any operation runs.
//! A black/white step therefore reaches the detail stage as linear 0.0 and 1.0
//! exactly. The blur averages those, and the last detail pass re-encodes. So
//! the expected byte at a column is `srgb_encode(white_taps / (2r + 1))`, with
//! taps clamped at the border — which is exactly what `expected_profile`
//! computes.
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext};
use dr_pipeline::descriptor::{OpId, ParamId};
use dr_pipeline::detail::probe::BoxBlur;
use dr_pipeline::{Affects, EditGraph, OutputMode};
use dr_types::ColourSpace;
const PROBE: OpId = OpId("detail_probe");
const RADIUS: ParamId = ParamId("radius");
fn ctx() -> Option<GpuContext> {
// CI runners and headless machines may have no usable adapter. Skip rather
// than fail, exactly as the rest of this crate's device tests do.
match pollster::block_on(GpuContext::new_headless()) {
Ok(c) => Some(c),
Err(e) => {
eprintln!("skipping: no GPU adapter ({e})");
None
}
}
}
/// A vertical step edge: black to the left of `size / 2`, white to the right.
///
/// The one image whose blur is worth checking by hand. A gradient would
/// average to itself and hide a kernel that is off by one; a step does not.
fn step_edge(ctx: &GpuContext, size: u32) -> DemosaicedImage {
let data: Vec<u8> = (0..size * size)
.flat_map(|i| {
let x = i % size;
let v = if x < size / 2 { 0u8 } else { 255 };
[v, v, v, 255]
})
.collect();
DemosaicedImage::from_rgba8(ctx, &data, size, size).expect("upload")
}
/// One row of the rendered image, red channel, as bytes.
fn row(pixels: &[u8], size: u32, y: u32) -> Vec<u8> {
(0..size)
.map(|x| pixels[((y * size + x) * 4) as usize])
.collect()
}
fn srgb_encode(v: f32) -> u8 {
let e = if v <= 0.003_130_8 {
v * 12.92
} else {
1.055 * v.powf(1.0 / 2.4) - 0.055
};
(e.clamp(0.0, 1.0) * 255.0).round() as u8
}
/// What a separable box blur of radius `r` must produce over the step edge.
fn expected_profile(size: u32, r: i32) -> Vec<u8> {
let last = size as i32 - 1;
let edge = (size / 2) as i32;
(0..size as i32)
.map(|x| {
let white = (-r..=r)
.filter(|i| (x + i).clamp(0, last) >= edge)
.count();
srgb_encode(white as f32 / (2 * r + 1) as f32)
})
.collect()
}
/// Render one graph, with its detail stage, and read the pixels back.
///
/// This is the whole calling convention a frontend has to adopt, in five
/// lines: compose both halves from one graph at one output space, ask the
/// graph for the scale, and pass the invalidation key through.
fn render(
ctx: &GpuContext,
pass: &mut AdjustPass,
graph: &EditGraph,
source: &DemosaicedImage,
out: u32,
) -> Vec<u8> {
let _ = ctx;
let shader = graph.compose_for(ColourSpace::Srgb);
let scale = graph.render_scale(source.size(), (out, out));
let detail = graph.compose_detail_for(scale, ColourSpace::Srgb);
let key = graph.invalidation().through(Affects::Colour);
pass.render_detailed(source, &shader, out, out, None, &detail, key)
.expect("render");
pass.export_pixels().expect("readback").0
}
#[test]
fn a_neighbourhood_pass_produces_the_pixels_it_should() {
// The whole seam, proved once: an operation that reads its neighbours runs
// on the GPU, and the values it writes are the ones a box blur is defined
// to write. Not "the edge got softer" — every byte of the ramp.
let Some(ctx) = ctx() else { return };
const SIZE: u32 = 64;
let mut graph = EditGraph::with_detail_probe();
graph.set_param(PROBE, RADIUS, 0.0625); // 4 px on a 64 px edge
let source = step_edge(&ctx, SIZE);
let mut pass = AdjustPass::new(&ctx);
let pixels = render(&ctx, &mut pass, &graph, &source, SIZE);
let got = row(&pixels, SIZE, SIZE / 2);
let r = BoxBlur::with_radius(0.0625).kernel(graph.render_scale((SIZE, SIZE), (SIZE, SIZE)));
assert_eq!(r, 4, "5/64 of the shorter edge, rounded");
let want = expected_profile(SIZE, r as i32);
for (x, (a, b)) in got.iter().zip(&want).enumerate() {
assert!(
a.abs_diff(*b) <= 2,
"column {x}: got {a}, expected {b}\ngot: {got:?}\nwant: {want:?}"
);
}
}
#[test]
fn the_second_pass_reads_what_the_first_one_wrote() {
// The ping-pong, stated as a property of the picture rather than of the
// plumbing. A separable blur is symmetric: applied to a *horizontal* step
// it must also soften a horizontal edge in the other direction. Wire the
// second pass to read the original again and the vertical smear vanishes,
// which is exactly what this sees.
let Some(ctx) = ctx() else { return };
const SIZE: u32 = 64;
// A quadrant image: the vertical pass has something to do only if it is
// reading the horizontal pass's output rather than the source.
let data: Vec<u8> = (0..SIZE * SIZE)
.flat_map(|i| {
let (x, y) = (i % SIZE, i / SIZE);
let v = if (x < SIZE / 2) == (y < SIZE / 2) {
0u8
} else {
255
};
[v, v, v, 255]
})
.collect();
let source = DemosaicedImage::from_rgba8(&ctx, &data, SIZE, SIZE).expect("upload");
let mut graph = EditGraph::with_detail_probe();
graph.set_param(PROBE, RADIUS, 0.0625);
let mut pass = AdjustPass::new(&ctx);
let pixels = render(&ctx, &mut pass, &graph, &source, SIZE);
// Two separable passes compose into a true two-dimensional box average —
// but only if the second reads the first's output. Computed in closed form
// over the same window the shader uses, so this is an assertion about
// values rather than about direction.
let r = 4i32;
let last = SIZE as i32 - 1;
let half = (SIZE / 2) as i32;
let quadrant_is_black = |x: i32, y: i32| (x < half) == (y < half);
let want: Vec<u8> = (0..SIZE as i32)
.map(|x| {
let y = half;
let mut white = 0usize;
for dy in -r..=r {
for dx in -r..=r {
let (sx, sy) = ((x + dx).clamp(0, last), (y + dy).clamp(0, last));
if !quadrant_is_black(sx, sy) {
white += 1;
}
}
}
srgb_encode(white as f32 / ((2 * r + 1) * (2 * r + 1)) as f32)
})
.collect();
let got = row(&pixels, SIZE, SIZE / 2);
for (x, (a, b)) in got.iter().zip(&want).enumerate() {
// A second pass reading the *source* instead would leave column 20 at
// 255 where a real 2D average puts it near 196 — so the failure this
// catches is loud, not marginal.
assert!(
a.abs_diff(*b) <= 2,
"column {x}: got {a}, expected {b}\ngot: {got:?}\nwant: {want:?}"
);
}
}
#[test]
fn an_inactive_detail_operation_costs_exactly_nothing() {
// The rule the whole pipeline rests on, carried into this stage. A
// photograph with no sharpening must render through the single fused
// dispatch it always did, allocate no intermediate, and — the part worth
// checking — produce byte-identical pixels to a graph that has no
// neighbourhood operation in it at all.
let Some(ctx) = ctx() else { return };
const SIZE: u32 = 32;
let source = step_edge(&ctx, SIZE);
let probe = EditGraph::with_detail_probe();
assert_eq!(
probe.compose_for(ColourSpace::Srgb).output_mode,
OutputMode::Encoded,
"a neutral detail operation must not change how the fused pass ends"
);
let mut with_probe = AdjustPass::new(&ctx);
let a = render(&ctx, &mut with_probe, &probe, &source, SIZE);
assert_eq!(with_probe.colour_dispatches(), 1);
assert_eq!(with_probe.detail_dispatches(), 0);
assert_eq!(with_probe.detail_allocations(), 0, "nothing was allocated");
let plain = EditGraph::default_chain();
let mut without = AdjustPass::new(&ctx);
let b = render(&ctx, &mut without, &plain, &source, SIZE);
assert_eq!(a, b, "an operation at its defaults must not touch the image");
}
#[test]
fn moving_a_detail_parameter_does_not_re_run_the_colour_pass() {
// TRACES: FR-DEV-3d, and the operational point of `Affects::Detail`.
//
// Invisible in the output by construction — the picture is meant to be
// whatever the sharpening says whichever way it was computed — so a
// dispatch counter is the only thing that can see it. Without this, the
// whole invalidation story is a comment.
let Some(ctx) = ctx() else { return };
const SIZE: u32 = 64;
let source = step_edge(&ctx, SIZE);
let mut pass = AdjustPass::new(&ctx);
let mut graph = EditGraph::with_detail_probe();
graph.set_param(PROBE, RADIUS, 0.0625);
render(&ctx, &mut pass, &graph, &source, SIZE);
assert_eq!(pass.colour_dispatches(), 1);
assert_eq!(pass.detail_dispatches(), 2, "a separable blur is two passes");
// Drag the sharpening slider. The colour chain is untouched, so the linear
// intermediate it wrote is still exactly right.
graph.set_param(PROBE, RADIUS, 0.09);
render(&ctx, &mut pass, &graph, &source, SIZE);
assert_eq!(
pass.colour_dispatches(),
1,
"the fused colour pass re-ran for a change it does not depend on"
);
assert_eq!(pass.detail_dispatches(), 4);
// Now move exposure. The detail stage reads what the colour pass wrote, so
// this one genuinely does have to re-run both — anything else would show a
// sharpened version of the previous exposure.
graph.set_param(
dr_pipeline::ops::exposure::ID,
dr_pipeline::ops::exposure::EXPOSURE,
1.0,
);
render(&ctx, &mut pass, &graph, &source, SIZE);
assert_eq!(pass.colour_dispatches(), 2);
assert_eq!(pass.detail_dispatches(), 6);
}
#[test]
fn dragging_a_slider_recompiles_nothing_and_reallocates_nothing() {
// The two costs that are ruinous per frame and invisible in the output.
// Both are the same rule the rest of the crate follows: values ride in a
// uniform buffer, and textures are reallocated on resize rather than on
// change.
let Some(ctx) = ctx() else { return };
const SIZE: u32 = 48;
let source = step_edge(&ctx, SIZE);
let mut pass = AdjustPass::new(&ctx);
let mut graph = EditGraph::with_detail_probe();
graph.set_param(PROBE, RADIUS, 0.05);
render(&ctx, &mut pass, &graph, &source, SIZE);
let pipelines = pass.cached_detail_pipelines();
let allocations = pass.detail_allocations();
assert_eq!(pipelines, 2, "one per pass of the separable blur");
assert_eq!(allocations, 2, "the colour result, and one hand-off");
for radius in [0.06, 0.07, 0.08, 0.09] {
graph.set_param(PROBE, RADIUS, radius);
render(&ctx, &mut pass, &graph, &source, SIZE);
}
assert_eq!(
pass.cached_detail_pipelines(),
pipelines,
"a radius is a uniform, not a shader"
);
assert_eq!(
pass.detail_allocations(),
allocations,
"a steady viewport must allocate nothing"
);
// A resize is the one thing that legitimately reallocates.
render(&ctx, &mut pass, &graph, &source, SIZE / 2);
assert!(pass.detail_allocations() > allocations);
}
#[test]
fn a_proxy_and_an_export_agree_about_where_the_effect_lands() {
// TRACES: FR-DSP-1 — the subtle one, and the reason `RenderScale` exists.
//
// The same edit, rendered at two resolutions. A radius stored as a
// fraction of the shorter edge must produce a transition covering the same
// *proportion* of the frame at both, or a sharpening tuned on screen is a
// different sharpening in the exported file.
//
// The tolerance is a pixel's worth at the smaller size, because the kernel
// is an integer count and 6.25% of 64 pixels is not 6.25% of 128. That
// rounding is the whole of the error, and it is bounded by half a render
// pixel by construction.
let Some(ctx) = ctx() else { return };
const SOURCE: u32 = 128;
let source = step_edge(&ctx, SOURCE);
let mut graph = EditGraph::with_detail_probe();
graph.set_param(PROBE, RADIUS, 0.0625);
let spread = |out: u32| -> f32 {
let mut pass = AdjustPass::new(&ctx);
let pixels = render(&ctx, &mut pass, &graph, &source, out);
let line = row(&pixels, out, out / 2);
// Where the ramp starts and ends, in fractions of the frame.
let first = line.iter().position(|&v| v > 4).expect("a ramp") as f32;
let last = line.iter().rposition(|&v| v < 251).expect("a ramp") as f32;
(last - first) / out as f32
};
let proxy = spread(SOURCE / 2);
let export = spread(SOURCE);
assert!(
(proxy - export).abs() < 0.03,
"the effect covers {proxy:.3} of the proxy and {export:.3} of the \
export; a radius tuned on screen must land in the file"
);
// And it is a real transition in both, not two flat images agreeing.
assert!(proxy > 0.08 && export > 0.08, "{proxy:.3} / {export:.3}");
}
#[test]
fn the_two_halves_of_one_composition_must_be_dispatched_together() {
// The failure this guards is a bad one to debug: a shader composed to hand
// on linear working values, bound to an rgba8 storage texture. wgpu
// rejects it, but the message is about a bind group, a long way from the
// caller that composed one half of an edit and rendered the other.
let Some(ctx) = ctx() else { return };
const SIZE: u32 = 32;
let source = step_edge(&ctx, SIZE);
let mut pass = AdjustPass::new(&ctx);
let mut graph = EditGraph::with_detail_probe();
graph.set_param(PROBE, RADIUS, 0.0625);
let shader = graph.compose_for(ColourSpace::Srgb);
assert_eq!(shader.output_mode, OutputMode::LinearWorking);
let err = pass
.render_masked(&source, &shader, SIZE, SIZE, None)
.expect_err("a linear-working shader has no business in the plain path");
assert!(
format!("{err}").contains("render_detailed"),
"the error should name the way out: {err}"
);
}
#[test]
fn an_empty_chain_falls_through_to_the_ordinary_render() {
// A caller that always goes through `render_detailed` — which is what a
// frontend will do, since it does not want to branch on whether the user
// has sharpening on — must pay exactly nothing for the edits that have
// none.
let Some(ctx) = ctx() else { return };
const SIZE: u32 = 32;
let source = step_edge(&ctx, SIZE);
let graph = EditGraph::default_chain();
let mut pass = AdjustPass::new(&ctx);
let shader = graph.compose_for(ColourSpace::Srgb);
let scale = graph.render_scale((SIZE, SIZE), (SIZE, SIZE));
let detail = graph.compose_detail_for(scale, ColourSpace::Srgb);
assert!(detail.is_empty());
pass.render_detailed(&source, &shader, SIZE, SIZE, None, &detail, 0)
.expect("render");
assert_eq!(pass.colour_dispatches(), 1);
assert_eq!(pass.detail_dispatches(), 0);
assert_eq!(pass.detail_allocations(), 0);
}
+600
View File
@@ -0,0 +1,600 @@
//! Local adjustments, end to end on a device.
//!
//! The unit tests either side of this one check halves: `dr-pipeline` asserts
//! the generated WGSL says the right thing, and `dr-gpu`'s mask tests assert
//! an array of the right shape comes out. Neither would notice if the two
//! agreed with each other and both were wrong — a mask sampled with x and y
//! swapped satisfies both.
//!
//! So this renders a real frame and reads the pixels back: the masked region
//! must change, the rest must not, and the boundary must fall where the label
//! field says it does.
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext, LabelField, MaskPass};
use dr_pipeline::descriptor::ParamId;
use dr_pipeline::mask::{MaskLayer, MaskSource, MaskStack};
use dr_pipeline::operation::compose_full;
use dr_pipeline::{ops, EditGraph, Framing};
use dr_types::ColourSpace;
const SIZE: u32 = 32;
fn ctx() -> Option<GpuContext> {
pollster::block_on(GpuContext::new_headless()).ok()
}
/// A flat mid-grey JPEG-path image, so any change is the adjustment's.
fn grey(ctx: &GpuContext) -> DemosaicedImage {
grey_at(ctx, SIZE, SIZE)
}
fn grey_at(ctx: &GpuContext, w: u32, h: u32) -> DemosaicedImage {
let data: Vec<u8> = (0..w * h).flat_map(|_| [128, 128, 128, 255]).collect();
DemosaicedImage::from_rgba8(ctx, &data, w, h).expect("upload")
}
/// Two regions: 0 is the left half, 1 the right.
fn split_field(ctx: &GpuContext) -> LabelField {
let labels: Vec<u32> = (0..SIZE * SIZE)
.map(|i| u32::from(i % SIZE >= SIZE / 2))
.collect();
LabelField::upload(ctx, &labels, SIZE, SIZE, 2).expect("label upload")
}
/// A layer brightening whatever it covers, by a lot, so it cannot be missed.
fn brighten(source: MaskSource) -> MaskLayer {
let mut layer = MaskLayer::new("m1", source);
layer.set_param("exposure", ParamId("exposure"), 2.0);
layer
}
fn luma_at(pixels: &[u8], x: u32, y: u32) -> u8 {
pixels[((y * SIZE + x) * 4) as usize]
}
/// Render `stack` over flat grey and hand back the RGBA8 result.
fn render(ctx: &GpuContext, stack: &MaskStack, field: Option<&LabelField>) -> Vec<u8> {
render_at(ctx, stack, field, SIZE, SIZE)
}
fn render_at(
ctx: &GpuContext,
stack: &MaskStack,
field: Option<&LabelField>,
w: u32,
h: u32,
) -> Vec<u8> {
let source = grey_at(ctx, w, h);
let shader = compose_full(
&ops::chain(),
&Framing::new(),
ColourSpace::Srgb,
stack,
);
let mut masks = MaskPass::new(ctx).expect("mask pass");
let array = masks.render(stack, field, None, w, h).expect("rasterise");
let mut adjust = AdjustPass::new(ctx);
adjust
.render_masked(&source, &shader, w, h, Some(array))
.expect("render");
adjust.export_pixels().expect("readback").0
}
#[test]
fn a_region_mask_changes_only_the_regions_it_names() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let field = split_field(&ctx);
let mut stack = MaskStack::new();
stack.push(brighten(MaskSource::Regions {
signature: 1,
level: 2,
ids: vec![0],
}));
let pixels = render(&ctx, &stack, Some(&field));
// Sampled well inside each half, clear of the feathered boundary.
let inside = luma_at(&pixels, 4, SIZE / 2);
let outside = luma_at(&pixels, SIZE - 5, SIZE / 2);
assert!(
inside > outside + 40,
"the masked half should be much brighter: {inside} vs {outside}"
);
assert!(
(120..=136).contains(&outside),
"the unmasked half must be untouched mid-grey, got {outside}"
);
}
/// The failure a swapped axis or an inverted comparison would produce, and
/// which the "inside is brighter" assertion alone would not catch.
#[test]
fn inverting_a_region_mask_swaps_which_half_moves() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let field = split_field(&ctx);
let mut layer = brighten(MaskSource::Regions {
signature: 1,
level: 2,
ids: vec![0],
});
layer.invert = true;
let mut stack = MaskStack::new();
stack.push(layer);
let pixels = render(&ctx, &stack, Some(&field));
let left = luma_at(&pixels, 4, SIZE / 2);
let right = luma_at(&pixels, SIZE - 5, SIZE / 2);
assert!(
right > left + 40,
"inverted, the *other* half should brighten: left {left}, right {right}"
);
}
#[test]
fn opacity_scales_the_effect() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let field = split_field(&ctx);
let source = MaskSource::Regions {
signature: 1,
level: 2,
ids: vec![0],
};
let mut full = MaskStack::new();
full.push(brighten(source.clone()));
let mut half = MaskStack::new();
let mut layer = brighten(source);
layer.opacity = 0.5;
half.push(layer);
let at_full = luma_at(&render(&ctx, &full, Some(&field)), 4, SIZE / 2);
let at_half = luma_at(&render(&ctx, &half, Some(&field)), 4, SIZE / 2);
let untouched = 128;
assert!(
at_half > untouched && at_half < at_full,
"half opacity should land between neutral and full: {untouched} < {at_half} < {at_full}"
);
}
#[test]
fn a_linear_gradient_ramps_across_the_frame() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let mut stack = MaskStack::new();
stack.push(brighten(MaskSource::Linear {
centre: (0.5, 0.5),
angle: 0.0,
width: 1.0,
}));
let pixels = render(&ctx, &stack, None);
let left = luma_at(&pixels, 1, SIZE / 2);
let middle = luma_at(&pixels, SIZE / 2, SIZE / 2);
let right = luma_at(&pixels, SIZE - 2, SIZE / 2);
assert!(
left < middle && middle < right,
"a horizontal ramp should increase left to right: {left}, {middle}, {right}"
);
}
#[test]
fn a_radial_mask_is_strongest_at_its_centre() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let mut stack = MaskStack::new();
stack.push(brighten(MaskSource::Radial {
centre: (0.5, 0.5),
radii: (0.3, 0.3),
angle: 0.0,
feather: 0.5,
}));
let pixels = render(&ctx, &stack, None);
let centre = luma_at(&pixels, SIZE / 2, SIZE / 2);
let corner = luma_at(&pixels, 1, 1);
assert!(
centre > corner + 40,
"the centre should carry the effect: {centre} vs corner {corner}"
);
assert!(
(120..=136).contains(&corner),
"outside the radius must be untouched, got {corner}"
);
}
/// Two layers must not read each other's slice.
#[test]
fn stacked_layers_use_their_own_masks() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let field = split_field(&ctx);
let mut stack = MaskStack::new();
// Left half up.
stack.push(brighten(MaskSource::Regions {
signature: 1,
level: 2,
ids: vec![0],
}));
// Right half down.
let mut darken = MaskLayer::new("m2", MaskSource::Regions {
signature: 1,
level: 2,
ids: vec![1],
});
darken.set_param("exposure", ParamId("exposure"), -2.0);
stack.push(darken);
let pixels = render(&ctx, &stack, Some(&field));
let left = luma_at(&pixels, 4, SIZE / 2);
let right = luma_at(&pixels, SIZE - 5, SIZE / 2);
assert!(left > 150, "left should have brightened, got {left}");
assert!(right < 100, "right should have darkened, got {right}");
}
// ---------------------------------------------------------------------------
// Brush strokes (ARCH §5.4)
// ---------------------------------------------------------------------------
const UNTOUCHED: u8 = 128;
fn luma_in(pixels: &[u8], w: u32, x: u32, y: u32) -> u8 {
pixels[((y * w + x) * 4) as usize]
}
/// One gesture: whether it erases, its radius, its flow, and its path.
type Gesture = (bool, f32, f32, Vec<(f32, f32)>);
/// A brightening layer with the given gestures already painted onto it.
fn painted(gestures: &[Gesture]) -> MaskLayer {
let mut layer = brighten(MaskSource::brush());
for (erase, radius, flow, path) in gestures {
layer.begin_stroke(*erase, *radius, 0.9, *flow);
for &(x, y) in path {
layer.extend_stroke(x, y);
}
layer.end_stroke();
}
layer
}
fn stack_of(layer: MaskLayer) -> MaskStack {
let mut stack = MaskStack::new();
stack.push(layer);
stack
}
/// The whole feature, at its simplest: paint somewhere, and that is where the
/// adjustment lands.
///
/// Painted across the top rather than down the middle, because a mask drawn
/// upside down is symmetric about the middle and a centred stroke would not
/// notice — and the vertex shader that draws a stroke has to flip y to reach
/// clip space, which is exactly the kind of thing that is wrong once.
#[test]
fn a_stroke_paints_where_it_was_drawn_and_nowhere_else() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let stack = stack_of(painted(&[(
false,
0.1,
1.0,
vec![(0.2, 0.25), (0.8, 0.25)],
)]));
let pixels = render(&ctx, &stack, None);
let under = luma_in(&pixels, SIZE, SIZE / 2, SIZE / 4);
let below = luma_in(&pixels, SIZE, SIZE / 2, SIZE * 3 / 4);
assert!(
under > UNTOUCHED + 40,
"the stroke should have brightened the upper quarter, got {under}"
);
assert!(
(120..=136).contains(&below),
"the lower half was never painted and must be untouched, got {below}"
);
}
/// The failure a bounding box that is not grown by the radius produces: a tap
/// has no extent at all, so its quad has no area and nothing is drawn. Silent,
/// and it looks exactly like a brush that ignores short gestures.
#[test]
fn a_tap_paints_a_dab() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let stack = stack_of(painted(&[(false, 0.2, 1.0, vec![(0.5, 0.5)])]));
let pixels = render(&ctx, &stack, None);
let centre = luma_in(&pixels, SIZE, SIZE / 2, SIZE / 2);
let corner = luma_in(&pixels, SIZE, 1, 1);
assert!(centre > 180, "the dab should be there, got {centre}");
assert!(
(120..=136).contains(&corner),
"and only there, got {corner}"
);
}
/// Painting must be able to erase, or a mask is one mistake away from being
/// started again.
#[test]
fn an_erasing_stroke_takes_back_what_was_painted() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let stack = stack_of(painted(&[
(false, 0.25, 1.0, vec![(0.15, 0.5), (0.85, 0.5)]),
(true, 0.12, 1.0, vec![(0.5, 0.5)]),
]));
let pixels = render(&ctx, &stack, None);
let erased = luma_in(&pixels, SIZE, SIZE / 2, SIZE / 2);
let kept = luma_in(&pixels, SIZE, 3, SIZE / 2);
assert!(
(120..=136).contains(&erased),
"the erased middle should be back to untouched grey, got {erased}"
);
assert!(
kept > 180,
"the ends of the stroke are still painted, got {kept}"
);
}
/// Order is the mask. The same two gestures the other way round leave the
/// paint alone, and a rasteriser that composited by kind rather than by
/// sequence would give the same answer to both.
#[test]
fn erasing_before_painting_removes_nothing() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let stack = stack_of(painted(&[
(true, 0.12, 1.0, vec![(0.5, 0.5)]),
(false, 0.25, 1.0, vec![(0.15, 0.5), (0.85, 0.5)]),
]));
let pixels = render(&ctx, &stack, None);
let middle = luma_in(&pixels, SIZE, SIZE / 2, SIZE / 2);
assert!(
middle > 180,
"an erase before the paint has nothing to take away, got {middle}"
);
}
/// A stroke that crosses itself must not build up where it did. Summing the
/// segments instead of taking the nearest would make every circle and every
/// scribble blotchy — and at full flow it would not show at all, which is why
/// this paints at half.
#[test]
fn a_stroke_that_doubles_back_does_not_build_up() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let once = stack_of(painted(&[(
false,
0.15,
0.5,
vec![(0.1, 0.5), (0.9, 0.5)],
)]));
let twice = stack_of(painted(&[(
false,
0.15,
0.5,
// Out to the right and back over the last third of itself.
vec![(0.1, 0.5), (0.9, 0.5), (0.65, 0.5)],
)]));
let single = luma_in(&render(&ctx, &once, None), SIZE, SIZE * 3 / 4, SIZE / 2);
let crossed = luma_in(&render(&ctx, &twice, None), SIZE, SIZE * 3 / 4, SIZE / 2);
assert_eq!(
single, crossed,
"one pass of the brush, however many times the path went over it"
);
}
/// Between gestures, though, paint does build up — that is what a flow below
/// one is for, and it is the same blend that lets an erase work.
#[test]
fn two_gestures_at_half_flow_build_up() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let dab = (false, 0.2, 0.5, vec![(0.5, 0.5)]);
let once = stack_of(painted(std::slice::from_ref(&dab)));
let twice = stack_of(painted(&[dab.clone(), dab]));
let single = luma_in(&render(&ctx, &once, None), SIZE, SIZE / 2, SIZE / 2);
let doubled = luma_in(&render(&ctx, &twice, None), SIZE, SIZE / 2, SIZE / 2);
assert!(
doubled > single,
"a second pass should deposit more: {single} then {doubled}"
);
}
/// A brush whose dab is an ellipse is not a brush. The radius is a fraction of
/// the *shorter* edge, so on a frame twice as wide as it is tall a circle in
/// normalised coordinates would come out twice as wide as it is high.
#[test]
fn a_dab_is_round_on_a_frame_that_is_not_square() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
const W: u32 = 64;
const H: u32 = 32;
let stack = stack_of(painted(&[(false, 0.25, 1.0, vec![(0.5, 0.5)])]));
let pixels = render_at(&ctx, &stack, None, W, H);
let lit = |v: u8| v > 160;
let across = (0..W).filter(|&x| lit(luma_in(&pixels, W, x, H / 2))).count();
let down = (0..H).filter(|&y| lit(luma_in(&pixels, W, W / 2, y))).count();
assert!(across > 4 && down > 4, "the dab should exist: {across}x{down}");
assert!(
across.abs_diff(down) <= 2,
"a dab must be as wide as it is tall, got {across} across and {down} down"
);
}
/// Hardness is the edge, and the edge is what a brush is judged on. A hard
/// brush that faded like a soft one would make the control do nothing anyone
/// could see.
#[test]
fn hardness_decides_how_quickly_the_edge_falls_away() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let edge = |hardness: f32| {
let mut layer = brighten(MaskSource::brush());
layer.begin_stroke(false, 0.4, hardness, 1.0);
layer.extend_stroke(0.5, 0.5);
layer.end_stroke();
let pixels = render(&ctx, &stack_of(layer), None);
// How many pixels along the centre row are neither fully painted nor
// fully clear — the width of the transition. "Fully painted" is read
// from the middle of the dab rather than assumed: +2 EV over mid grey
// lands wherever the output transform puts it.
let solid = luma_in(&pixels, SIZE, SIZE / 2, SIZE / 2);
(0..SIZE)
.filter(|&x| {
let v = luma_in(&pixels, SIZE, x, SIZE / 2);
v > UNTOUCHED + 8 && v < solid - 8
})
.count()
};
let soft = edge(0.0);
let hard = edge(1.0);
assert!(
hard < soft,
"a hard brush should transition in fewer pixels: hard {hard}, soft {soft}"
);
assert!(hard <= 4, "and it should be nearly a step, got {hard}");
}
/// The loud failure an unpainted mask can produce: empty inverts to
/// everything, so a layer created with invert already set would apply its
/// adjustment to the whole photograph before a stroke was made.
#[test]
fn an_inverted_brush_layer_with_no_strokes_changes_nothing() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let mut layer = brighten(MaskSource::brush());
layer.invert = true;
let pixels = render(&ctx, &stack_of(layer), None);
for (x, y) in [(1, 1), (SIZE / 2, SIZE / 2), (SIZE - 2, SIZE - 2)] {
let v = luma_in(&pixels, SIZE, x, y);
assert!(
(120..=136).contains(&v),
"an unpainted mask covers nothing, inverted or not; got {v} at {x},{y}"
);
}
}
/// A painted layer and a gradient in one stack must not read each other's
/// slice — the brush writes its slot through a different pipeline, which is
/// exactly where a slot could be got wrong without either alone noticing.
#[test]
fn a_brush_layer_and_a_gradient_keep_their_own_slices() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let mut stack = MaskStack::new();
stack.push(painted(&[(false, 0.15, 1.0, vec![(0.5, 0.15)])]));
let mut darken = MaskLayer::new(
"m2",
MaskSource::Radial {
centre: (0.5, 0.85),
radii: (0.15, 0.15),
angle: 0.0,
feather: 0.1,
},
);
darken.set_param("exposure", ParamId("exposure"), -2.0);
stack.push(darken);
let pixels = render(&ctx, &stack, None);
let top = luma_in(&pixels, SIZE, SIZE / 2, SIZE * 3 / 20);
let bottom = luma_in(&pixels, SIZE, SIZE / 2, SIZE * 17 / 20);
assert!(top > 180, "the painted dab should have brightened: {top}");
assert!(bottom < 100, "the radial should have darkened: {bottom}");
}
/// A neutral edit must render identically whether or not masks are bound —
/// otherwise merely *having* the feature would alter every unedited image.
#[test]
fn an_empty_stack_renders_exactly_as_the_unmasked_path() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let plain = {
let source = grey(&ctx);
let mut adjust = AdjustPass::new(&ctx);
let shader = EditGraph::default_chain().compose();
adjust.render(&source, &shader, SIZE, SIZE).expect("render");
adjust.export_pixels().expect("readback").0
};
let masked = render(&ctx, &MaskStack::new(), None);
assert_eq!(plain, masked, "an empty mask stack must be a no-op");
}
+617
View File
@@ -0,0 +1,617 @@
//! Clarity and texture, end to end on a real device.
//!
//! `dr-pipeline`'s tests assert what the composer *generates* — the kernel
//! width, the uniforms, which lines of WGSL each node emits. None of that can
//! tell whether the two passes compose into an unsharp mask, whether the
//! original colour really survives the hand-off from the blur pass to the
//! combining one, or whether the halo the soft limit is supposed to bound is
//! actually bounded in pixels. Those are questions only a GPU answers.
//!
//! # Why every measurement is in stops
//!
//! The controls work on log luminance, and their guarantees are stated in
//! stops: an overshoot of at most `gain * threshold`, an effect that is
//! symmetric about neutral, a strength that does not depend on how bright the
//! subject is. Asserting on 8-bit code values would restate all of that in a
//! unit where none of it is true, and would need a fresh magic number for
//! every brightness tested. So the pixels are decoded back to linear and
//! compared as ratios.
//!
//! # The test image
//!
//! A vertical step between two **midtones** rather than between black and
//! white. Clarity is tapered to nothing at both ends of the range on purpose
//! (see `midtone_weight`), so a 0–255 step is the one edge in the world it is
//! designed to leave alone, and a test built on it would measure the taper
//! working and call it the feature not working.
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext};
use dr_pipeline::descriptor::{OpId, ParamId};
use dr_pipeline::ops::local_contrast::{Clarity, Texture};
use dr_pipeline::{Affects, EditGraph, OutputMode};
use dr_types::ColourSpace;
const CLARITY: OpId = OpId("clarity");
const TEXTURE: OpId = OpId("texture");
const AMOUNT: ParamId = ParamId("amount");
/// Large enough that texture's kernel — a tenth of clarity's — is still more
/// than one pixel wide. At 1024 its sigma is 1.2 px; at 256 it would round to
/// a delta and the control would honestly do nothing, which is the behaviour
/// `texture_stops_rather_than_lying_when_the_render_is_too_small` covers and
/// not the behaviour under test here.
const SIZE: u32 = 1024;
/// The two sides of the step, as sRGB code values.
///
/// Both well inside the range, and roughly two stops apart — a real edge, of
/// the kind that produces the halo this file exists to bound.
const DARK: u8 = 90;
const BRIGHT: u8 = 175;
fn ctx() -> Option<GpuContext> {
// CI runners and headless machines may have no usable adapter. Skip rather
// than fail, exactly as the rest of this crate's device tests do.
match pollster::block_on(GpuContext::new_headless()) {
Ok(c) => Some(c),
Err(e) => {
eprintln!("skipping: no GPU adapter ({e})");
None
}
}
}
fn srgb_decode(v: u8) -> f32 {
let e = v as f32 / 255.0;
if e <= 0.040_45 {
e / 12.92
} else {
((e + 0.055) / 1.055).powf(2.4)
}
}
/// A vertical step from `DARK` to `BRIGHT` at the half-way column.
fn step_edge(ctx: &GpuContext, size: u32, tint: [f32; 3]) -> DemosaicedImage {
let data: Vec<u8> = (0..size * size)
.flat_map(|i| {
let x = i % size;
let v = if x < size / 2 { DARK } else { BRIGHT } as f32;
[
(v * tint[0]).round() as u8,
(v * tint[1]).round() as u8,
(v * tint[2]).round() as u8,
255,
]
})
.collect();
DemosaicedImage::from_rgba8(ctx, &data, size, size).expect("upload")
}
/// One row of the rendered image, as linear luminance-ish red values.
fn row(pixels: &[u8], size: u32, y: u32) -> Vec<u8> {
(0..size)
.map(|x| pixels[((y * size + x) * 4) as usize])
.collect()
}
/// One row as full RGB triples.
fn row_rgb(pixels: &[u8], size: u32, y: u32) -> Vec<[u8; 3]> {
(0..size)
.map(|x| {
let i = ((y * size + x) * 4) as usize;
[pixels[i], pixels[i + 1], pixels[i + 2]]
})
.collect()
}
/// Render one graph with its detail stage and read the pixels back.
fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, out: u32) -> Vec<u8> {
let shader = graph.compose_for(ColourSpace::Srgb);
let scale = graph.render_scale(source.size(), (out, out));
let detail = graph.compose_detail_for(scale, ColourSpace::Srgb);
let key = graph.invalidation().through(Affects::Colour);
pass.render_detailed(source, &shader, out, out, None, &detail, key)
.expect("render");
pass.export_pixels().expect("readback").0
}
/// A graph with one of the two controls set and everything else neutral.
fn graph_with(op: OpId, amount: f32) -> EditGraph {
let mut g = EditGraph::default_chain();
g.set_param(op, AMOUNT, amount);
g
}
/// How far a pixel moved, in stops, against the same pixel unedited.
fn stops(edited: u8, plain: u8) -> f32 {
(srgb_decode(edited).max(1e-6) / srgb_decode(plain).max(1e-6)).log2()
}
#[test]
fn clarity_lifts_local_contrast_and_leaves_the_flat_regions_alone() {
// The definition of a local contrast control, as pixels: it must do
// something at the edge and *nothing* a long way from it. An operation
// that brightened the whole bright plateau would be an exposure slider
// with extra steps, and it is the failure a sign error in the base
// produces.
let Some(ctx) = ctx() else { return };
let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]);
let mut plain_pass = AdjustPass::new(&ctx);
let plain = row(
&render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE),
SIZE,
SIZE / 2,
);
let mut pass = AdjustPass::new(&ctx);
let edited = row(
&render(&mut pass, &graph_with(CLARITY, 100.0), &source, SIZE),
SIZE,
SIZE / 2,
);
let edge = (SIZE / 2) as usize;
let reach = Clarity::with_amount(100.0)
.kernel(EditGraph::default_chain().render_scale((SIZE, SIZE), (SIZE, SIZE)))
as usize;
// Far outside the kernel's reach the base equals the pixel, the detail
// signal is zero, and the output must be the input to the last code value.
for x in [0, reach / 2, SIZE as usize - 1 - reach / 2, SIZE as usize - 1] {
assert!(
edited[x].abs_diff(plain[x]) <= 1,
"column {x} moved by {} away from any edge",
edited[x].abs_diff(plain[x])
);
}
// And at the edge it must do the thing it is for: the bright side lifts,
// the dark side drops, which is what "more local contrast" means.
assert!(
edited[edge] > plain[edge] + 4,
"the bright side of the edge did not lift: {} vs {}",
edited[edge],
plain[edge]
);
assert!(
edited[edge - 1] + 4 < plain[edge - 1],
"the dark side of the edge did not drop: {} vs {}",
edited[edge - 1],
plain[edge - 1]
);
}
#[test]
fn the_soft_limit_bounds_the_halo_at_a_hard_edge() {
// The single most common way clarity is got wrong, held to a number.
//
// `t * tanh(d / t)` saturates at `t`, so no pixel may move further than
// `gain * threshold` stops however violent the edge — a bound that holds
// by construction rather than by tuning, and one this test takes from the
// operation itself rather than restating.
//
// The comparison that gives it meaning is the second assertion: an
// unlimited unsharp mask over this edge would move the bright side by
// about half the step, which is more than twice as far. That is the
// difference between a control and a white glow along the skyline.
let Some(ctx) = ctx() else { return };
let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]);
let mut plain_pass = AdjustPass::new(&ctx);
let plain = row(
&render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE),
SIZE,
SIZE / 2,
);
let mut pass = AdjustPass::new(&ctx);
let edited = row(
&render(&mut pass, &graph_with(CLARITY, 100.0), &source, SIZE),
SIZE,
SIZE / 2,
);
let worst = (0..SIZE as usize)
.map(|x| stops(edited[x], plain[x]).abs())
.fold(0.0f32, f32::max);
let bound = Clarity::with_amount(100.0).overshoot_bound();
// Where the two comparisons below sit, derived rather than observed:
//
// srgb_decode(175) = 0.4287, srgb_decode(90) = 0.1022
// the step is log2(0.4287 / 0.1022) = 2.069 stops
// an unlimited mask peaks at half of it = 1.034 stops
// the soft limit saturates at = 0.350 stops (`bound`)
// the midtone taper then takes about 13% off at 175, so the peak this
// test should actually see is near = 0.30 stops
//
// So 0.30 has to clear the 0.1 floor with room, and fall well under both
// 0.35 + slack and 0.6 × 1.034 = 0.62. Every one of those is a bound with
// a reason, not a tolerance widened until the test passed.
//
// A code value's worth of slack: the readback is 8-bit, and a pixel
// sitting exactly on the bound quantises either side of it.
assert!(
worst <= bound + 0.02,
"a pixel moved {worst:.3} stops, past the {bound:.3} the soft limit \
promises"
);
// Half the step is what an unlimited mask would have produced at the very
// edge, since the base there is the mean of the two plateaus.
let unlimited = (srgb_decode(BRIGHT) / srgb_decode(DARK)).log2() / 2.0;
assert!(
worst < unlimited * 0.6,
"the limit is not biting: {worst:.3} stops against the {unlimited:.3} \
an unlimited unsharp mask would give"
);
// But it is still a real effect, not a control that does nothing.
assert!(worst > 0.1, "clarity moved almost nothing: {worst:.3} stops");
}
#[test]
fn a_proxy_and_an_export_agree_about_the_effect() {
// TRACES: FR-DSP-1 — the decision the radius unit rests on, proved in
// pixels rather than in kernel widths.
//
// Clarity's radius is a fraction of the frame because the control is
// compositional: "separate the subject from its background" is a statement
// about how much of the picture the subject occupies. If that is right,
// the *same edit* rendered at two resolutions must produce an effect of
// the same strength covering the same proportion of the frame — which is
// exactly what a photographer tuning on screen and exporting at full size
// is relying on.
//
// Had the radius been stated in source pixels, the proxy here would show
// half the reach and the export would be a different photograph.
let Some(ctx) = ctx() else { return };
let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]);
// Peak excursion in stops, and how far the effect reaches, as a fraction
// of the frame.
let measure = |out: u32| -> (f32, f32) {
let mut plain_pass = AdjustPass::new(&ctx);
let plain = row(
&render(&mut plain_pass, &EditGraph::default_chain(), &source, out),
out,
out / 2,
);
let mut pass = AdjustPass::new(&ctx);
let edited = row(
&render(&mut pass, &graph_with(CLARITY, 100.0), &source, out),
out,
out / 2,
);
let moved: Vec<f32> = (0..out as usize)
.map(|x| stops(edited[x], plain[x]).abs())
.collect();
let peak = moved.iter().cloned().fold(0.0f32, f32::max);
// The width of the band that moved by more than a tenth of the peak —
// a threshold relative to the effect, so it means the same thing at
// both sizes.
let touched = moved.iter().filter(|m| **m > peak * 0.1).count();
(peak, touched as f32 / out as f32)
};
let (proxy_peak, proxy_reach) = measure(SIZE / 2);
let (export_peak, export_reach) = measure(SIZE);
assert!(
(proxy_peak - export_peak).abs() < 0.03,
"the same edit is {proxy_peak:.3} stops on the proxy and \
{export_peak:.3} in the export"
);
assert!(
(proxy_reach - export_reach).abs() < 0.02,
"the effect covers {proxy_reach:.3} of the proxy and {export_reach:.3} \
of the export; a radius tuned on screen must land in the file"
);
// And it is a real effect at both sizes, not two flat images agreeing.
assert!(
proxy_peak > 0.1 && proxy_reach > 0.02,
"{proxy_peak:.3} stops over {proxy_reach:.3} of the proxy"
);
}
#[test]
fn texture_acts_at_a_finer_scale_than_clarity() {
// The whole reason there are two nodes. If the two controls ever reach the
// same distance from an edge, the second slider has become a duplicate of
// the first and a photographer setting both is setting one thing twice.
let Some(ctx) = ctx() else { return };
let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]);
let mut plain_pass = AdjustPass::new(&ctx);
let plain = row(
&render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE),
SIZE,
SIZE / 2,
);
let reach = |op: OpId| -> usize {
let mut pass = AdjustPass::new(&ctx);
let edited = row(
&render(&mut pass, &graph_with(op, 100.0), &source, SIZE),
SIZE,
SIZE / 2,
);
// How many columns moved by more than a code value — the honest
// measure of "how far from the edge does this control reach".
(0..SIZE as usize)
.filter(|&x| edited[x].abs_diff(plain[x]) > 1)
.count()
};
let coarse = reach(CLARITY);
let fine = reach(TEXTURE);
assert!(fine > 0, "texture did nothing at all");
assert!(
coarse > fine * 4,
"clarity reaches {coarse} columns and texture {fine}; these are not \
separable scales"
);
}
#[test]
fn clarity_moves_luminance_without_moving_hue() {
// The third halo decision, in pixels. The gain is applied as a scale on
// the whole triple, so chromaticity is untouched; boosting the channels
// independently would put a *coloured* fringe along every edge, arriving
// from a control the photographer reads as contrast.
let Some(ctx) = ctx() else { return };
// A strongly tinted step, so a per-channel mask would show plainly.
let source = step_edge(&ctx, SIZE, [1.0, 0.55, 0.25]);
let mut plain_pass = AdjustPass::new(&ctx);
let plain = row_rgb(
&render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE),
SIZE,
SIZE / 2,
);
let mut pass = AdjustPass::new(&ctx);
let edited = row_rgb(
&render(&mut pass, &graph_with(CLARITY, 100.0), &source, SIZE),
SIZE,
SIZE / 2,
);
// Compare in linear light, where a scale is a scale. The two channel
// ratios together fix the chromaticity, so holding both fixes the colour.
let edge = (SIZE / 2) as usize;
for x in [edge, edge + 1, edge + 4, edge - 1, edge - 4] {
let ratio = |p: [u8; 3], i: usize| srgb_decode(p[i]) / srgb_decode(p[0]).max(1e-6);
for channel in [1, 2] {
let before = ratio(plain[x], channel);
let after = ratio(edited[x], channel);
assert!(
(after - before).abs() < 0.02,
"column {x} channel {channel}: chromaticity moved from \
{before:.4} to {after:.4} — that is a coloured fringe"
);
}
}
// And the effect was actually applied here, or the assertion above is
// vacuous.
assert!(edited[edge][0].abs_diff(plain[edge][0]) > 3);
}
#[test]
fn negative_clarity_softens_the_surface_without_dissolving_the_edge() {
// The soft limit earns its keep in both directions. An unlimited mask at
// −100 subtracts the whole detail signal and turns every edge to mud;
// limited, it removes at most the threshold, so modelling softens and real
// edges stand.
let Some(ctx) = ctx() else { return };
let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]);
let mut plain_pass = AdjustPass::new(&ctx);
let plain = row(
&render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE),
SIZE,
SIZE / 2,
);
let mut pass = AdjustPass::new(&ctx);
let softened = row(
&render(&mut pass, &graph_with(CLARITY, -100.0), &source, SIZE),
SIZE,
SIZE / 2,
);
let edge = (SIZE / 2) as usize;
// The sign is the other way round from the positive case: the bright side
// of the edge comes down and the dark side comes up.
assert!(
softened[edge] + 3 < plain[edge],
"negative clarity did not soften: {} vs {}",
softened[edge],
plain[edge]
);
// But the step itself survives. Measured in stops across the edge, so the
// claim is about contrast and not about code values.
let step_of = |r: &[u8]| (srgb_decode(r[edge]) / srgb_decode(r[edge - 1])).log2();
let before = step_of(&plain);
let after = step_of(&softened);
assert!(
after > before * 0.55,
"the edge dissolved: {after:.3} stops left of {before:.3}"
);
}
#[test]
fn neutral_controls_cost_the_edit_nothing() {
// Both nodes are in the default chain, and both are the widest kernels in
// the pipeline. An unedited photograph must render through the single
// fused dispatch it always did — no detail pass, no intermediate texture,
// and byte-identical pixels.
let Some(ctx) = ctx() else { return };
let source = step_edge(&ctx, 128, [1.0, 1.0, 1.0]);
let graph = EditGraph::default_chain();
assert_eq!(
graph.compose_for(ColourSpace::Srgb).output_mode,
OutputMode::Encoded,
"a neutral detail operation must not change how the fused pass ends"
);
let mut pass = AdjustPass::new(&ctx);
render(&mut pass, &graph, &source, 128);
assert_eq!(pass.colour_dispatches(), 1);
assert_eq!(pass.detail_dispatches(), 0);
assert_eq!(pass.detail_allocations(), 0, "nothing was allocated");
}
#[test]
fn dragging_the_slider_re_runs_the_detail_stage_and_nothing_else() {
// TRACES: FR-DEV-3d. Clarity is `Affects::Detail`, so the fused colour
// pass's result is still valid while the slider moves — which for a
// hundred-tap kernel is the difference between an interactive control and
// a slideshow. Invisible in the output by construction, so a dispatch
// counter is the only thing that can see it.
let Some(ctx) = ctx() else { return };
let source = step_edge(&ctx, 256, [1.0, 1.0, 1.0]);
let mut pass = AdjustPass::new(&ctx);
let mut graph = graph_with(CLARITY, 40.0);
render(&mut pass, &graph, &source, 256);
assert_eq!(pass.colour_dispatches(), 1);
assert_eq!(pass.detail_dispatches(), 2, "a separable mask is two passes");
let pipelines = pass.cached_detail_pipelines();
for amount in [50.0, 60.0, 70.0] {
graph.set_param(CLARITY, AMOUNT, amount);
render(&mut pass, &graph, &source, 256);
}
assert_eq!(
pass.colour_dispatches(),
1,
"the fused colour pass re-ran for a change it does not depend on"
);
assert_eq!(pass.detail_dispatches(), 8);
assert_eq!(
pass.cached_detail_pipelines(),
pipelines,
"an amount is a uniform, not a shader"
);
// Turning on the other control adds its own pair, and only its own pair.
graph.set_param(TEXTURE, AMOUNT, 40.0);
render(&mut pass, &graph, &source, 256);
assert_eq!(pass.detail_dispatches(), 12);
assert_eq!(pass.colour_dispatches(), 1);
}
#[test]
fn the_two_controls_stack_without_overwriting_each_other() {
// Four passes through one ping-pong, with the scratch lane changing hands
// half way. If clarity's combining pass left the colour where its blur
// pass had put it — or if texture's blur overwrote the colour rather than
// the lane — the result would be a blurred image rather than a sharpened
// one, which is loud rather than subtle.
let Some(ctx) = ctx() else { return };
let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]);
let mut plain_pass = AdjustPass::new(&ctx);
let plain = row(
&render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE),
SIZE,
SIZE / 2,
);
let mut graph = graph_with(CLARITY, 80.0);
graph.set_param(TEXTURE, AMOUNT, 80.0);
let mut pass = AdjustPass::new(&ctx);
let both = row(&render(&mut pass, &graph, &source, SIZE), SIZE, SIZE / 2);
assert_eq!(pass.detail_dispatches(), 4);
let edge = (SIZE / 2) as usize;
// Both sides of the edge move the way local contrast moves them...
assert!(both[edge] > plain[edge] + 4);
assert!(both[edge - 1] + 4 < plain[edge - 1]);
// ...and the plateaus are untouched, which a stray blur would not leave.
assert!(both[0].abs_diff(plain[0]) <= 1);
assert!(both[SIZE as usize - 1].abs_diff(plain[SIZE as usize - 1]) <= 1);
// Stacked, they must reach further than either alone — the coarse control
// still working at its own scale rather than being overwritten by the fine
// one running after it.
let mut clarity_only = AdjustPass::new(&ctx);
let coarse = row(
&render(&mut clarity_only, &graph_with(CLARITY, 80.0), &source, SIZE),
SIZE,
SIZE / 2,
);
assert!(
both[edge] >= coarse[edge],
"adding texture undid clarity: {} against {}",
both[edge],
coarse[edge]
);
}
#[test]
fn texture_contributes_nothing_where_its_scale_does_not_exist() {
// Unlike the acutance family this is not an approximation being hidden. A
// two-pixel surface structure is not present in a 128-pixel rendering of
// the frame, so the honest answer is no pass at all — and clarity, a
// hundred times wider, still runs, which is what a thumbnail should show.
let Some(ctx) = ctx() else { return };
let source = step_edge(&ctx, 512, [1.0, 1.0, 1.0]);
let mut graph = graph_with(TEXTURE, 100.0);
// Asserted on the composed chain rather than on a dispatch counter,
// because what is interesting here is not how many dispatches ran but
// that texture contributed no *kernel* to them.
//
// This assertion used to require an empty chain, and recorded the empty
// chain as a gap in the seam: `compose_full` decides whether the fused
// pass hands on linear working values from `is_active()`, which has no
// `RenderScale` to consult, while `compose_detail` decides what to
// dispatch from the kernel it can actually draw at this scale. When a
// detail operation was active and its kernel rounded away, the two
// disagreed, `render_detailed` found nothing to run, fell through to
// `render_masked`, and was rejected for handing a linear-working shader
// to the plain path — so texture alone on a thumbnail did not render.
//
// The seam was closed where that note said it would have to be, at the
// composition boundary: `compose_detail` now emits a bodyless
// `detail/resolve` pass in exactly this case, which reads only the pixel
// it writes and performs the output transform the fused pass declined to
// do. So the chain is no longer empty — it carries precisely the one pass
// that finishes the render and no kernel at all, which is the honest
// description of "a two-pixel surface structure is not present in a
// 128-pixel rendering".
let scale = graph.render_scale(source.size(), (128, 128));
let composed = graph.compose_detail_for(scale, ColourSpace::Srgb);
assert_eq!(
composed.len(),
1,
"the chain must carry the resolve pass and nothing else"
);
assert_eq!(composed.passes[0].label, "detail/resolve");
assert_eq!(
composed.radius(),
0,
"texture claimed a kernel it cannot draw"
);
// With clarity on as well the edit is renderable again, and the dispatch
// count says what the assertion above says: two passes, not four. Texture
// is active, and contributes nothing.
graph.set_param(CLARITY, AMOUNT, 100.0);
let mut pass = AdjustPass::new(&ctx);
render(&mut pass, &graph, &source, 128);
assert_eq!(
pass.detail_dispatches(),
2,
"clarity survives a thumbnail, and texture added nothing beside it"
);
// And texture comes back, exactly, as soon as the view is large enough to
// hold it — no separate path, no fade, just the kernel resolving again.
let mut zoomed = AdjustPass::new(&ctx);
render(&mut zoomed, &graph_with(TEXTURE, 100.0), &source, 1024);
assert_eq!(zoomed.detail_dispatches(), 2);
}

Some files were not shown because too many files have changed in this diff Show More