Compare commits

..
284 Commits
Author SHA1 Message Date
dtourolle a2c7789007 Sign the Android build with a real key, and let package.sh use it too
Benchmarks / CPU and I/O (per commit) (push) Successful in 2m52s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Successful in 44m13s
Build and test / Layer separation (push) Successful in 56s
🐳 Android image / Build and push (push) Successful in 17m16s
Build and test / android-image (push) Successful in 17m17s
Traceability / Requirement traces (push) Successful in 1m6s
Build and test / Android (aarch64) (push) Successful in 23m37s
The release keystore now exists and its four secrets are loaded into
Gitea, so CI produces an APK a device can update in place. Until now
every build, CI and local alike, was signed with a throwaway debug key
-- CI's fresh per run, the local one exactly as durable as the cache
directory it lived in -- and the night that cache was cleared, no build
anywhere could install over the tablet's copy.

package.sh forwards KEYSTORE_PASS, KEY_PASS and KEY_ALIAS into the
container and copies the keystore under the mounted target directory
for the build, so a local release-signed build is one environment line.
The doc records where the local copy of the key lives.
2026-09-11 23:22:28 +02:00
dtourolle 7c44740d9f Skip the read-only-directory test where modes are not enforced
Benchmarks / CPU and I/O (per commit) (push) Successful in 3m41s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Successful in 1h44m9s
Build and test / Layer separation (push) Successful in 48s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 2s
Traceability / Requirement traces (push) Successful in 36s
Build and test / Android (aarch64) (push) Successful in 1h1m51s
`a_failed_overwrite_puts_the_original_back` makes the presets directory
read-only and expects the overwrite to fail. CI's Desktop job runs in a
container as root, and root is not refused by a mode: the write succeeds,
the assertion fails, and build-and-test has been red on every push to
master since the test arrived.

The test now probes the refusal it depends on -- one write into the
directory it just locked -- and skips where that write goes through.
Probed rather than keyed on the uid, because what the test needs is the
refusal itself, and a filesystem mounted without permission checks would
pass a uid test and fail this one all the same.
2026-09-11 22:22:46 +02:00
dtourolle 4f31123b0c Let the user choose which SCRFD finds their faces
faces.md §12.3 measured what the cheapest detector costs: the small
faces in every group shot, and a dog embedded a dozen times. Which
trade is right depends on the machine doing the sweep — a desktop left
overnight and a tablet on a battery want different answers — so the
detector is now a per-device setting, Fast / Balanced / Thorough on
the settings page beside the indexing button, persisted with the rest
of the settings file.

A detector is half of a model id. Every face, marker, shard and
calibration is keyed on faces.model_id precisely so that a model change
is a new id and a re-index rather than a silent change under existing
data, and a detector change is a model change: it decides which faces
exist and where the landmarks that align them land. So each choice
names its own pipeline. 500M keeps the bare "w600k_mbf" every existing
library was written under, so an upgrade disturbs nothing; the others
are qualified. Choosing one restarts coverage from zero under the new
id, the sweep re-detects, confirmed names carry across by box overlap,
and the sync shards are keyed by the same id so a peer on another
setting neither adopts nor pollutes them. The library controller
carries the id into the sync the same way it carries the cache budget,
because the sync starts from places that have no settings in reach.

All three shape-fixed exports ship — APK, Arch, Flatpak — since a
tablet has no other way to obtain the one it was not installed with;
the APK grows by twenty megabytes for the choice.
2026-09-11 22:12:53 +02:00
dtourolle adf5d6cdd9 Drop a rival pipeline's marker when an image is re-indexed
record_detections replaces every face on an image whatever model found
them, but left the other models' face_index rows standing. With one
model that was unobservable. With a second pipeline it leaves an image
marked "done" under the first with none of its faces behind the marker
— the state the V12 repair existed to undo — and a user who switched
back would find those photographs permanently empty.

An image now holds the faces of whichever pipeline looked at it last,
and only that pipeline's marker. Confirmed names still carry across by
box overlap, since they were read before the replacement.
2026-09-11 22:12:40 +02:00
dtourolle 9d35addd86 Measure what the cheapest SCRFD actually costs in faces
§1 chose scrfd_500m on FLOPs and never measured the recall it gave up.
A dr-ui example now runs several detectors over the same sample of
stored proxies, matches boxes by IoU against the first, buckets the
result by face size, times each, and writes contact sheets of the
disagreements in both directions — because a count of extra faces says
nothing until someone has looked at whether they are faces.

Over 400 proxies from the reference library: 2.5G finds 14% more faces
for 12% more time, 10G a further 12% for 3.1× the time. The extras are
small real faces. The 86 faces only 500M found are a dog a dozen times,
a stop sign, a wheel and the backs of heads. Recorded in faces.md §12.3.
2026-09-11 22:12:39 +02:00
dtourolle 3d6d69ec90 Wrap the face-sweep repair match the way rustfmt wants it
Benchmarks / CPU and I/O (per commit) (push) Successful in 4m5s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 39m17s
Build and test / Layer separation (push) Successful in 1m24s
🐳 Android image / Build and push (push) Successful in 10s
Build and test / android-image (push) Successful in 10s
Traceability / Requirement traces (push) Successful in 55s
Build and test / Android (aarch64) (push) Successful in 27m8s
CI's Desktop job failed at the Format step on 16f3fb4: rustfmt puts the
`match faces_without_proxy(...)` on its own line under the `let` and
re-indents its arms, and the commit was written without running it. No
code changes; only the layout of that one match in library.rs.
2026-09-11 22:00:38 +02:00
dtourolle 16f3fb41a3 Measure the faces already found rather than finding them again
Benchmarks / CPU and I/O (per commit) (push) Successful in 3m13s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 54s
Build and test / Layer separation (push) Failing after 1s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 2s
Traceability / Requirement traces (push) Successful in 52s
Build and test / Android (aarch64) (push) Successful in 30m6s
Every face stored before its quality was kept holds a unit vector, and
V14 forgot the run marker of each image holding one so that the next
sweep would look again. Looking again meant detecting again: a whole
re-detection per image, with every suggestion on it thrown away and the
confirmations carried across by box overlap, to recover one number.

The sweep now has a measuring pass between the proxy repair and the
un-indexed images. It lists every image holding an unmeasured face,
fetches the original once, warps each stored face from the landmarks it
already has, embeds it, and writes the raw vector and its length over
the old row. Ids, boxes and identities are untouched; the marker is
re-written fresh so the sync exports the measured vectors. A face whose
landmarks no longer make a warp is dropped, as detection would have
refused to store it. `faces_unindexed` leaves those images to the
measuring pass, so the V14 deletion no longer costs a second detection.
2026-09-11 21:50:12 +02:00
dtourolle 8b3abdb787 Keep each face's quality, and never compare against a poor one
The embedder's raw output has a length, and the length is a reading of
how recognisable the crop was: a blur, an occlusion or a hard profile
comes out short. Normalising threw it away. A short vector sits near
the middle of the sphere and matches a little of everyone, which is how
one bad crop bridges two people in a grouping pass.

So the length is kept — the store now holds the raw vector, re-normalised
on load, with the length beside it as `faces.quality` — and a face under
MIN_GALLERY_QUALITY (14) is a probe: measured against the gallery and
placed where it fits, but never what another face is measured against.
Two probes are never paired, and a probe is nobody's evidence for a
confidence. The People screen shows the number as "Quality 17.3", dimmed
below the floor.

Faces indexed before this stored unit vectors and have no reading; they
are admitted to the gallery, and schema V14 forgets the run marker of
every image holding one so the next indexing pass measures them. A
peer's unmeasured shard faces are not adopted, or a sync would write
that marker back.
2026-09-11 21:50:12 +02:00
dtourolle a87139b838 Give every mask an eye and a colour, and put the brush where the mask is
The first build of seeing a mask showed the selected layer's, in one global
style, from a strip at the top of the panel. It answered the wrong question and
answered it somewhere nobody looked. What a photographer asks of two masks is
how they meet — where the sky's edge sits against the building's — and that
needs both on screen at once, in colours that can be told apart.

So each row of the stack has an eye, drawn in the colour its mask is shown in,
and each mask has six swatches to choose that colour from. Several can be open
at once; a new one comes up open, in the first colour nothing else is using.
The style — tint, alpha, outline — is the one setting that stays global, above
the stack, because three styles at once are three pictures that cannot be read
against each other. Alpha now draws every shown mask, each in its colour, on
black. In the pipeline a `Reveal` is a list of `(layer, colour)` rather than
one layer, and every reveal block carries its own colour.

The brush moves too. Select, Paint and Erase and the three sliders under them
sat at the top of the panel, appeared only once a row was selected, and said
nothing about which mask they acted on — so "how do I paint" and "how do I
correct the model's outline" both had the same answer and nobody found it.
They sit under the selected mask's parts now, beside the swatches, and on a
subject or a category the hint says what a stroke there does: it becomes a
part of this mask, joined to the model's, and can be taken out again.

Eyes and colours are viewing state, on the session and not on the layer, so a
photograph reopened has every eye closed — the stored-mask round-trip test
asserts it.
2026-09-11 19:03:51 +02:00
dtourolle 936490880b Release 0.12.0
Benchmarks / CPU and I/O (per commit) (push) Successful in 12m48s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 37m37s
Build and test / Layer separation (push) Successful in 46s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 3s
Traceability / Requirement traces (push) Successful in 1m4s
Build and test / Android (aarch64) (push) Successful in 53m59s
2026-09-11 09:33:36 +02:00
dtourolle d8b9b5a4bb Let the release script write the release commit it was never trusted with
Every release commit in the history reads `Release X.Y.Z` and none of them
carries the message this script would have written, so nobody has ever
passed it `--commit` — and the reason is in the message it wrote: a
Co-Authored-By trailer naming an assistant, which no commit in this repository
carries and none should.

The trailer goes, and so does the paragraph above it: the script's own header
already says why it exists, and a release commit is the one place a one-line
subject is the whole convention.
2026-09-11 09:33:28 +02:00
dtourolle ac0aea70ec Show a mask as soon as it is made
Benchmarks / CPU and I/O (per commit) (push) Successful in 3m52s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 39m53s
Build and test / Layer separation (push) Successful in 1m0s
🐳 Android image / Build and push (push) Successful in 4s
Build and test / android-image (push) Successful in 4s
Traceability / Requirement traces (push) Successful in 46s
Build and test / Android (aarch64) (push) Successful in 25m19s
Choosing a category is asking what it selected, and for a subject or a
category that question had no other answer on screen: the model's outline is
not derivable from anything visible, a fresh layer carries no adjustment to
judge it by, and the list it was chosen from says "architecture 23%" without
saying which 23%. The control that draws the mask existed but had to be found
and pressed, a panel's height away from the list the choice was made in.

So a new layer arrives with its mask showing, from the resting position only.
Somebody who has chosen the alpha or the outline keeps it, and nothing re-arms
in the background — every caller is a press that asked for a new mask.

That makes the canvas depend on how a layer arrived, which is correct and
worth stating: a session that has just made a mask draws a frame that a
session which read the same mask out of a sidecar does not. Viewing state is
not edit state and does not travel in a file, and
`a_stored_mask_renders_exactly_what_the_model_rendered` now says so at both
ends.
2026-09-10 20:56:11 +02:00
dtourolle 5d175cc668 Let a press on the photograph reach the tool that was armed for it
The brush did nothing, and neither did three other things nobody had tried
lately: clicking a subject on the photograph to select it, placing a repair,
and sampling a neutral. All four are TouchAreas over the canvas, and all four
sat behind the pan/zoom area, which is full-canvas and enabled for everything
but a crop. It took every press in the viewport and they were never offered
one.

Slint hit-tests siblings front-to-back (`send_mouse_event_to_item` visits
children `TraversalOrder::FrontToBack`), a TouchArea answers `GrabMouse` on any
press it is enabled for, and the first grab aborts the traversal. Front means
*last declared*. Each of the four carried a comment saying it sat "above the
pan/zoom area so a click reaches it first" — true of the order they were
written in, and backwards.

Nothing about the geometry decides this, so nothing about the geometry could
have fixed it. The pan area is declared first now, as the backstop it always
meant to be, and the rule it leaves behind is that the general case goes above
the specific ones. `GradientHandles` is the other end of that rule and is why
dragging a handle has worked all along while everything between it and the pan
area did not.

The order is asserted in a test, because this is a fault that compiles, passes
every other test, and silently removes four tools at once.
2026-09-10 20:56:10 +02:00
dtourolle 76ad667fd6 Offer a mask that is nothing but a hand
Every route to a layer began with a selection — a gradient, a band, a subject,
a category — and painting was reachable only by making one of those and
joining a painted part to it. So the answer to "brush a correction onto this
corner of the sky" was "add a radial gradient you do not want, then paint into
that", which is not an answer.

Paint sits beside Linear and Radial and makes a layer whose base is a brush.
It covers nothing until a stroke lands in it, so pressing it arms the brush
and shows the mask as well: a row that appeared and changed no pixel, with the
pointer still in "select", is indistinguishable from a button that did nothing.
2026-09-10 20:28:09 +02:00
dtourolle c045702a47 Show the photographer the mask they are shaping
Nobody can refine an edge they are not being shown. The only thing drawn on
the canvas was the region overlay — a false-coloured picture of what the model
*detected* — which knows nothing of a layer's feather, its falloff, its
morphology, its invert or its opacity, and nothing at all about a gradient, a
range or a stroke. Every control added for mask editing therefore acted on
something invisible, which is why the whole feature reads as absent rather
than as unfinished.

A layer's finished mask now draws over the photograph in one of three styles:
a tint for whether the right thing is selected, an alpha for where the edge
is, an outline for whether that edge is registered against the detail the
other two hide.

The hard part is not the shader. A selection with no adjustment on it changes
no pixel, so it is not active, so it holds no slice of the mask array and is
never rasterised — and that is exactly the layer somebody wants to look at,
for the whole of the time between choosing a subject and deciding what to do
to it. So `MaskStack::rendered` is `active()` plus the layer being looked at,
and the rasteriser, the composer and the distance-field builder all index by
position in it. Which is also why the design's "two uniforms, no recompile" is
not available: a uniform can select a slot, it cannot conjure one.

The reveal is never on the graph. It reaches the pipeline as an argument to
`compose_revealing`, and `compose_for` — which the exporter, the thumbnail and
the neutral probe all call — has no way to ask for one. A flag on the graph
would have been shorter, would have type-checked, and would have been one
forgotten reset away from a red tint baked into an exported file.

And the tools that shape a mask now arm. `Masking.tool` is an `in` property
only Rust may write, and the handler wrote nothing back, so the strip reported
"Select" however many times Paint was pressed and the paint area was never
enabled — the brush, the parts and the whole of FR-DEV-19b reachable from no
control in the application.

The region overlay stands down while a mask is being shown, and its button now
says what it hides: two overlays that look alike and mean different things is
worse than either.
2026-09-10 20:28:06 +02:00
dtourolle 193b35a249 Start a category mask where the photograph can bear it
Clicking "architecture" made a layer whose mask was gone. Every category layer
began at STRICTNESS_DEFAULT, and that constant was fitted on the synthetic sky
the refine tests build — its own note warns that a real photograph's noise
"moves every crossing down together", which turns out to be a considerable
understatement. Measured over seven ordinary frames, half scale removes 76% to
99.5% of `architecture`, 36% to 93% of `ground` and 18% to 91% of
`vegetation`. Only sky, the category the number was calibrated against,
survives it.

An empty mask is indistinguishable from a broken one: the layer is listed, the
adjustment moves, and no pixel changes. So what this looks like from outside
is that the segmentation does not make masks at all.

No smaller constant fixes it either, because a nat of evidence means different
things over a smooth sky and over a stone facade — the useful position is
above 5 on one frame and below 1 on the next. So the frame is asked instead:
`Refinement::gentle` walks down from half scale and takes the first rung whose
gate removes no more than a sixth of the category's weight, and the model's
own outline when none of them does. One `apply` on a friendly photograph and
four on an unfriendly one, paid when a layer is made rather than for eight
categories nobody masked.

The slider's reset went to 4 as well, so taking the control back to its
"default" emptied the mask. It goes to zero now, which is the one position
documented to mean something: exactly what the model weighted.
2026-09-10 20:27:40 +02:00
dtourolle 404fea47a8 Wrap the lines the merge resolution left long
Benchmarks / CPU and I/O (per commit) (push) Successful in 2m59s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 33m34s
Build and test / Layer separation (push) Successful in 55s
Traceability / Requirement traces (push) Successful in 42s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Build and test / Android (aarch64) (push) Successful in 23m43s
`cargo fmt --check` failed the desktop job, on three files and for one reason:
routing the mask handlers through the `Masking` global was done by substituting
the call prefix, which is a text edit rather than a Rust one. It left
`window.global::<Masking>().on_part_join_picked(...)` on a line that had been
short enough as `window.on_mask_part_join_picked(...)` and no longer was.

Formatting only. The whitespace-stripped source is identical in the two `ui/`
files; the third differs by the trailing commas rustfmt adds when it breaks a
call across lines.

The matrix moves with it, because the tags shift by a few lines and the check
compares line numbers.
2026-09-08 08:57:32 +02:00
dtourolle d920716a2b Release 0.11.0
Benchmarks / CPU and I/O (per commit) (push) Successful in 11m47s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 37s
Build and test / Layer separation (push) Successful in 38s
Traceability / Requirement traces (push) Successful in 1m0s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 2s
Build and test / Android (aarch64) (push) Successful in 55m56s
2026-09-07 22:31:53 +02:00
dtourolle 2d878c2117 Offer the film stock in its own group, and let its list scroll itself
Two faults in one control, both reported from the tablet.

The stock picker appeared in every group. It is not a parameter, so it is not
a row, so the filter that hides every other control when a group is chosen
never saw it — "Kodachrome" sat at the top of Light, of Colour and of Detail
alike. Three places it does not belong, and the one it does no more prominent
than the rest. The descriptor has said `Effect` and only `Effect` since the
film moved there; nothing was asking it.

So the panel now asks. It cannot ask directly — a generated panel may not know
which operation a control belongs to — so the session answers, from what the
operation declares it is about, and a stock re-declared as something else would
move on its own. The flag is recomputed when the group changes as well as when
the film does, which is the half that would have made it stale exactly when it
mattered.

And the open list was unbounded, so it made the develop column taller and the
column scrolled as one: reaching Velvia dragged every slider below it off the
screen, an answer given once pushing aside the controls used constantly. It now
scrolls within a bounded height of its own.

That viewport is counted rather than measured, for the reason the tool rail
records a few files away: a viewport that asks a layout how tall it wants to be,
while the layout takes its height from the viewport, is a cycle Slint settles by
handing back the height it was given — and the content is then clipped in
silence rather than scrolling. Every row here is one fixed height, so
multiplying is exact.

The group rule has a test. The scrolling does not, and cannot: it is a layout,
and a layout fault is invisible to the compiler and to every assertion that can
be written about it.
2026-09-07 20:42:15 +02:00
dtourolle 4c217c9be6 Show what a control does to a photograph, one parameter at a time
Benchmarks / CPU and I/O (per commit) (push) Successful in 2m52s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 56s
Build and test / Layer separation (push) Successful in 37s
🐳 Android image / Build and push (push) Successful in 4s
Build and test / android-image (push) Successful in 4s
Traceability / Requirement traces (push) Failing after 54s
Build and test / Android (aarch64) (push) Successful in 26m29s
A node that has just been declared can be read, reasoned about and tested, and
none of that answers the question a photographer asks first: what does moving
this do to the picture. Colour grading, dehaze and the range masks were all
argued into the tree on their behaviour and none of them had been *looked* at.

So two diagnostics. `sweep` walks one operation from its minimum to its maximum
and writes a frame per step; `rangesweep` does the same for a mask band, which
is not an ordinary parameter — it lives on a layer, is rasterised by its own
pass, and only becomes visible through whatever adjustment the layer carries,
so it gets two stops down to make the selection legible.

`sweep` names no operation. The id arrives as a string and the parameters and
their ranges come from the graph's own capabilities, so a node declared
yesterday sweeps on the same terms as one that shipped a year ago — the
property `ops/README.md` promises, used rather than asserted.

Three things it learned the hard way and now records. It renders through
`render_detailed` unconditionally, because the fused path refuses a shader
composed with a detail stage rather than rendering it wrongly, and that call
falls through when there is no such stage. It takes `SWEEP_HOLD`, because a
parameter grouped under one widget is not meaningful alone: a hue with no
strength behind it renders the same frame every time, which reads as a broken
node rather than a correctly declared neutral. And it bounds the output, since
a 25 MP frame is a 75 MB PPM and a sweep is hundreds of them.

Both read a rendered file as readily as a raw one, so a JPEG can stand in where
no raw is to hand — on the terms `from_rgba8` documents, with the controls
still working and their neutral being what the camera left rather than what the
sensor recorded.
2026-09-07 20:01:08 +02:00
dtourolle e44cb8cbe0 Regenerate the traceability matrix for the rebased line numbers 2026-09-07 20:01:01 +02:00
dtourolle 0a5eab0487 Wire the develop panels through globals, so a second copy is one line
Every panel in the develop column declared its inputs and its callbacks and
had `app.slint` bind each one to a property or a callback on the window root.
That is fine while a panel is drawn once. N9 draws them a second time, in the
portrait dock, and the wiring is what would have to be copied: `MaskPanel`
alone ran to forty lines of forwarding, and a callback added to one copy and
not the other compiles, renders, and simply does nothing on the layout nobody
was looking at.

So the wiring moved to Slint globals. A panel reads the global and calls the
global; Rust hooks the global instead of the window; and the instantiation in
the column is now the panel's name and a pair of braces — every one of the ten
children of the column, with no property that differs by placement left to
supply.

There is a global per panel family rather than one for all of them, and the
reason is an import cycle. Each panel's model struct — `ParamRow`, `MaskRow`,
`HistogramView` — is declared in the panel's own file, so a single global
holding `[MaskRow]` and `[ParamRow]` would have to live in a file importing
`masks.slint` and `adjust.slint` while both imported the global back, which
Slint rejects. Breaking that needs six model declarations relocated, which is a
change to the data model and not to the plumbing this is about. A global beside
the panel it serves also lets each name drop the prefix it was carrying only
because the window root is one flat namespace: `root.spot-radius` is
`Repair.radius`, and `root.peaking-on` is `Peaking.showing`.

`session.slint` is new and holds the two facts every family needs and none of
them owns: whether there is an open photograph to edit, and which mode the view
is in, with the three readings of the mode derived once instead of at each of
the dozen places that tested one. `ViewMode` moves there from `adjust.slint`,
where it was only ever a lodger.

Nothing on screen changes. What is not here: the tool rail and the status strip
still take their properties at the instantiation, because they are drawn once
and N9 does not copy them; the preset sheet's own state stays on the window,
because the library grid opens the same sheet and a global cannot bind the
window's state — which is why `Transfer.open-presets` is handled in
`presets.rs`, beside the summary it already had to compute.
2026-09-07 20:01:01 +02:00
dtourolle dd14243dba Lay the develop view out by coordinate, so the column can sit below
Slint cannot turn a layout on its side, and that is what D-N7 asks for. So
the HorizontalLayout holding the rail, the canvas and the develop column
becomes a plain Rectangle and each of the three states its own x, y, width
and height. With `column-below` false those come out where the layout put
them to the pixel — a HorizontalLayout has no spacing or padding of its own,
the rail and the column took their declared widths at the two edges, and the
canvas was the only child that stretched.

The alternative was the column subtree declared twice under two `if`s, which
is four hundred lines of bindings copied, in a file whose own notes record a
conditional child in a layout as the shape that has produced binding loops
here before.

The column is the same column either way: the same contents, the same
Flickable, the same toggle. `panel-visible` collapses the dock's height
exactly as it collapsed the column's width, so `column-width` and
`dock-height` are each zero unless the column is both open and on that axis,
and the canvas can subtract both without asking which case it is in. The
stack inside stretches to the dock's width on its own — a layout that is the
direct child of a Rectangle fills it, and the Flickable's viewport was
already bound to its own width. Nothing is reflowed; N9 does that.

`dock-height` is mandated in style.yaml for the reason `panel-width` beside
it is, on the other axis: a dock that sizes itself to its contents is a
photograph that changes height when a caption wraps. 480 until N6 measures
the device.

The seam follows the column round: a hairline down its left edge beside the
photograph, along its top edge under it, so it stays between the two.
2026-09-07 20:00:50 +02:00
dtourolle 5f0b11c1f4 Ask the window how tall it is, and say when the column belongs below
D-N7 puts the develop column under the photograph on a tall window, and the
axis it turns on is aspect rather than width: a 960-wide portrait tablet is
expanded by width and wants the dock, a 1500-wide landscape desktop is
expanded by width and does not. So this cannot be folded into the layout
class, and it is not remembered per class either — closing the column in
landscape closes the dock in portrait, because it is the same column.

`window-resized` reported width alone and now reports both, from a
`shell-height` that subtracts the safe-area insets exactly as `shell-width`
subtracts them: on Android the strips the status and navigation bars occupy
are on the axis being measured, so the aspect of the window and the aspect of
the space the interface actually gets are not the same number.

`column_below` is the decision, with two thresholds rather than one. It is
read on every resize event, and a single threshold means a window dragged
along its own diagonal crosses it several times a second while the pointer is
still down. Entering at 1.25 and leaving at 1.15 is a dead band no plausible
drag re-crosses.

The comment on EXPANDED_MIN_WIDTH claimed a tablet in portrait gets the
compact layout. It does not — its panel is about 960 logical pixels across,
which clears 820 — and that mistaken example is the one D-N2 reasoned from.
Corrected in the same breath, since this is the commit that says what
portrait actually changes.
2026-09-07 20:00:50 +02:00
dtourolle 30468c4c69 Put the window-metrics doc on the function it describes
The comment explaining why both coordinate systems go on one line was
written for `log_window_metrics` and sat above `window_metrics_level`,
where it read as the start of that function's much longer note. Two
doc blocks ran into each other and the one that prints had none.
2026-09-07 20:00:50 +02:00
dtourolle 428d8c4a51 Say what size the window actually is, in both coordinate systems
Every figure in D-N7's table was computed at a guessed scale factor. The
tablet's panel is 3000 by 1920 physical and nothing in this repository
has ever recorded the density Android reports for it, so the dock's
width is either 900 or 1037 and its available height is 200px either
way. N6 asks for the measurement; this is the line that carries it.

Beside the existing `apply_layout_class` call, because that is where the
window is already being asked for its size and its scale, and again on
every resize, so turning the tablet over records the other orientation
in the same logcat. Both coordinate systems on one line: a logical size
cannot be checked when the scale is the thing in doubt, and a physical
size that does not divide by the scale printed next to it says the
reading is of something other than the panel.

The level is not fixed, because none of the three obvious choices works.
`android_main` caps the facade at info, so debug never leaves the
device and a debug-only line answers nothing. A drag emits a resize per
frame and each accepted record is also appended to the on-disk log, so
info on every resize is not a diagnostic. And the first reading is not
the settled one: on X11 the window reports 0x0, then 360x320 at scale
1.0, then 1100x720 at scale 2.0, so reporting only the first would put a
number in logcat that is not the window's.

So the pair that decides is the scale factor and the orientation --
exactly what N6 is asking for, and exactly what a drag leaves alone. A
window with no area is not a reading and records nothing. Every later
change to either half, the scale resolving or the tablet turning over,
is a new answer and goes out at info; everything else is debug.
2026-09-07 20:00:49 +02:00
dtourolle 2361b2d4ef Tablet portrait is the expanded layout class, not the compact one
FR-UI-1's table has put tablet portrait in the compact class since the
register was written. D-N2 settled that it does not belong there: a
12-inch tablet is about 1024 logical pixels across in portrait and
EXPANDED_MIN_WIDTH is 820, so both orientations of both targets are
expanded, and compact fires only on a desktop window dragged narrow. The
code has always agreed - apply_layout_class reads the window's width and
nothing else - so the register was the last place still saying otherwise.

What portrait actually needed was a different axis. D-N7 docks the
develop column under the photograph on a tall window, set from the
window's aspect and independent of the layout class: a 960-wide portrait
window is expanded and wants the dock, a 1500-wide landscape one is
expanded and does not. That is a placement rather than a mode, so the
clause about the transition being continuous stands as written, and the
requirement now records the side the column takes as its own property.

FR-UI-2 gains one clause for the same reason. It said modality affects
control sizing and affordances "not layout", which D-N6 reversed: under
touch the group selector leaves the strip above the column for the tool
rail, and groups-in-rail in toolrail.slint is what moves it.
2026-09-07 20:00:40 +02:00
dtourolle de32c04a68 Dock the develop column under the photograph on a tall window
D-N2 dismissed portrait with one number: a 12-inch tablet is about 1024
logical pixels across, which clears the expanded breakpoint. That was
worked out for a 4:3 panel. The tablet's is 3000 by 1920, and on that
aspect a column beside the photograph in portrait leaves it a strip 540
wide and 1456 tall: a 3:2 frame gets 540 by 360 where a column below it
would give 900 by 600, and the portrait frame gains too.

D-N7 records the decision: a third property beside the layout class,
derived from the window's aspect with hysteresis, that lays the same
rail, canvas and column out on the other axis. Not a sheet, not a second
layout, and the rail does not move. N6 measures the device the numbers
were guessed for, N7 does the frame with the stack stretched as a
stopgap, N8 moves the develop callbacks onto a global so the panels can
be declared twice cheaply, and N9 is the three-column composition the
dock's width is actually for. N5's "no strip along the bottom" is struck
where D-N7 reverses it and kept where it does not.
2026-09-07 20:00:40 +02:00
dtourolle e13d3a54fc Watch the mask tools work, rather than reading that they do
A still frame cannot show what makes these tools right or wrong. What
matters is how the mask *moves*: whether a stroke lands where the finger
went, whether a subtraction takes away only what it covers, whether an erase
inside a correction punches through the selection underneath. Every one of
those is a sequence, and the test suite asserts single pixels.

So this renders the sequences. A synthetic photograph, one frame per step of
each mode — painting, erasing, joining a part and taking it out again,
inverting, and sweeping the edge controls — as PPM, which ffmpeg turns into
a GIF in one line. It runs headless, needs no RAW and no model, and takes a
few seconds.

It is also the honest answer to "show me it working" while the tools are
still being wired to a finger: this is the pipeline itself, not a mock-up of
it, and a fault in the fold shows here as a frame that looks wrong.
2026-09-07 20:00:40 +02:00
dtourolle df741a8a49 Let one mask be built from more than one selection, and paint into it
A mask the model draws arrives approximately right — stopping inside a
shoulder, leaking into the hair — and FR-DEV-3's edge controls move the
*whole* boundary, so no value of feather or dilation fixes two errors that
go opposite ways. What fixes them is a second selection joined to the first,
and a layer that held exactly one source had nowhere to put one. The brush
the core has had all along was reachable from no control in the application.

A layer is now an ordered list of parts. Each names a source and how it
joins the mask before it — added to it, or taken out of it — and carries its
own edge treatment, because a model's soft coverage and a stroke painted
where it stopped short do not want the same feather. Invert and opacity stay
on the layer, where the composed shader already reads them.

The sidecar grows `[part]` blocks and nothing else. A layer of one part
writes exactly the bytes it always did; a mask block with no part blocks
after it reads back as one part; and a stroke, a join or a source this build
cannot read costs that part rather than the layer. So every sidecar in every
library still parses to the edit it always was.

On the device the parts fold into the layer's one slice, so eight layers
still cost eight channels: union is a `max` blend and subtraction is the
erase blend the brush already used. A part is drawn into a scratch texture
before it is joined, and that is not incidental — an erase stroke means a
hole in *that part*, not a hole in the mask, and drawn straight onto the
accumulator it would punch through the subject underneath. A layer of one
part skips all of it and takes the path it always took.

In the interface: a part list under the selected layer with a chip saying
which way each joins, Add and Subtract beside it, a Select/Paint/Erase strip
with the brush's size, hardness and flow, and a drag on the photograph that
paints. Pressing Paint on a mask that cannot hold a stroke joins a part that
can, rather than explaining that a subject is not a brush. A whole stroke is
one step in the history.

The edge controls now shape the part that is selected rather than the layer,
which is the one behaviour change to an existing control: with a correction
selected, the feather slider softens the correction and leaves the model's
mask alone.
2026-09-07 20:00:40 +02:00
dtourolle 9ede23073d Specify the tools that edit a mask once the model has drawn it
The auto masks arrive in a second and cannot then be changed by a pixel: no
brush, no way to cut one selection out of another, no way to drag a boundary
that stopped inside a shoulder, and no way to see the alpha a layer actually
produces — the canvas overlay draws what the model detected, not the mask.

docs/mask-editing.md is how that closes. A layer stops holding one source and
holds an ordered list of parts, each naming how it joins the mask before it,
so painting on an auto mask, subtracting, and intersecting a subject with a
luminance band are all one mechanism. Notes what the tree already has (the
whole brush is written and reachable from no control), what the set operations
actually cost (three blend states, no new texture), why the edge push is a
warp rather than a local morphology, and why painting has to draw
incrementally. Ends with the four decisions the build needs first.
2026-09-07 19:59:44 +02:00
dtourolle 1e171c6d31 Let a collection be picked up, rearranged, and emptied after the fact
Collections could be made and filled and never reorganised. Nesting had
a drag; un-nesting had nothing, in either direction — "All photographs"
refused every drop, which is right for a photograph and wrong for a
collection, which has a top level to be returned to. So a collection put
inside another was in there permanently. Right-click deleted an *empty*
collection outright and refused otherwise, which is wrong in both
directions at once: destructive with no confirmation, and no way at all
to delete a collection that held anything without emptying it by hand,
child by child. And a photograph could only leave the collection the
grid was scoped to, since that is the only one a button in the header
can name — the cell's badge says a photograph is in three collections
and never which three.

Three ways in, one vocabulary:

**Hold a row.** The tree is inside a Flickable, which claims any drag
beginning inside it, so with a finger a drag on a row is a scroll until
something says otherwise. The hold is that something. It lifts the row —
drawn before anything moves, so the gesture says it has been understood
— and then what the user does decides which of two things they meant:
move, and it is a rearrangement; let go, and it is the row menu. The
same fork the grid already uses to tell hold-to-select from drag-to-file.
`decide_release` is that fork, and it is tested, because getting it
wrong one way puts a sheet over every tidied tree and the other way
makes the menu unreachable by touch.

**The row menu.** Rename, new collection inside, move to top level,
keep offline, delete. Deleting asks once when there is anything to lose
and says what survives: the photographs stay in the library, and nested
collections move up rather than going with it — which is what the
catalog does, and what a user would never assume. An empty collection
goes on the first press, because a dialogue about losing nothing is how
people learn to dismiss dialogues.

**"Collections…" on a selection.** Every collection the selection is
filed in, each with a count — "3 of 40", so nobody takes forty
photographs out of a collection thirty-seven were never in — and a way
out of any of them without navigating there first.

The long press used to open the offline question by itself. That
question is one item in this menu now: there is one hold per row, and
while it was spent on a single action nothing else the tree can do had
a touch route at all. Nothing is lost — the tray on the row keeps its
tap, and the question gains a full-width control in place of a 30px
icon in a row shorter than the touch minimum.

The row-press handler moves to `collections_ui` with the rest of what a
collection row does; it lived in `library_ui` only because it opened
that prompt.
2026-09-07 19:59:44 +02:00
dtourolle 577bbd82b0 Return the action the drag offered, not the one this row prefers
Slint negotiates a drag action between source and target, and the
runtime clamps whatever `can-drop` returns against the set the source
allowed: an action outside it becomes `none`. Both drop targets here
named a constant instead of echoing what was on offer, and each named
the wrong one for half its traffic.

A collection row takes two kinds of payload. Photographs come from a
DragArea allowing `copy`; a collection being nested comes from one
allowing `move`. The row asked for `copy` unconditionally, so images
filed correctly and every collection dropped on a collection was
refused — nesting by drag has never worked. The trash had the same
fault mirrored: it insisted on `move` while the grid's cells allow only
`copy`, so it refused every photograph dragged to it.

Neither failure had anything to see. A clamped action is delivered as a
refusal, which looks exactly like a target that declined on purpose, so
the drag simply did nothing and left no error to search for.

`decide_drop`'s Reparent branch was tested and passing throughout. It
tests the decision, not the negotiation, and nothing was reaching it.
2026-09-07 19:59:44 +02:00
dtourolle bac5801618 Ask which collections a selection is filed in, and how much of it
`collections_for_image` answers this for one photograph and has no
counts, which is enough to badge a cell and not enough to offer a
removal: with forty selected and three of them in "Iceland", a sheet
that says only "Iceland" invites the user to take all forty out of a
collection thirty-seven were never in. `membership_of` returns the
count alongside the name so the row can say "3 of 40".

Chunked over the image list rather than one `IN (...)`, because the
list is a selection and a select-all makes it as large as the library —
past SQLite's bound-parameter cap on exactly the gesture most likely to
produce it. Counts are summed across chunks, so the answer is the one
the unchunked query would have given.

Smart collections are excluded by construction: they have no member
rows, so there is nothing a removal could do.
2026-09-07 19:59:44 +02:00
dtourolle af89433aee Offer the lens profile as a tick box, since applying it silently reads as absent
The develop panel's Optics group is three manual sliders: distortion, chromatic
aberration and lens vignetting. The automatic correction was already there — the
file's EXIF lens is matched against the bundled Lensfun database on open and the
coefficients are fanned out to all three — but nothing in the interface said so
except a line of grey text under the camera reading "· corrected", and there was
no way to decline it. From the outside that is indistinguishable from the
feature not existing, which is how it was read.

`dr-lens` states the rule this breaks: an automatic correction that silently
does nothing is worse than one the user can see is unavailable. The caption
satisfied the letter of it and not the point — a photographer looking for
"apply the lens profile" found three sliders and no switch.

So the profile is now a control. It is a capability rather than a flag on the
session, because everything a photographer sets travels one road: the capability
list feeds the generated panel, `Preset` captures it, the sidecar stores it and
the undo stack replays it. A bool on the side would have needed adding to each
of those four by hand and would have been forgotten in at least one — which is
exactly how the mask stack came to be missing from the history.

It is on by default, which is what `switch_on` is for: the coefficients are a
measurement of the lens that took the photograph, so accepting them is neutral
and declining them is the edit. The sidecar therefore stores nothing for the
ordinary case and the correction still happens.

The switch appears only where a profile was matched. A tick box on a photograph
whose lens the database has never heard of would be a control that looks
available and does nothing, which is the failure the rule above names rather
than an instance of following it — those photographs are told "· no profile" in
words instead, and one whose box is unticked now says "· profile off", which is
a third fact and not either of the other two.

Two things had to be built underneath. `ParamKind::Bool` was in the core's
closed enum and mapped to a row kind here, and had no control behind it in
`adjust.slint`: a parameter declaring itself a switch was flattened into a row
that drew nothing at all. Nothing shipped had one until now, so the gap cost
nothing and was invisible. And `Check` self-toggled, which is right for a
settings page that owns its value and wrong for a panel row that is a view of
the edit graph — the click would have answered by replacing the binding with a
literal, and the next undo or pasted preset would have moved the value with the
tick left where the finger put it. It now takes `controlled`, and the generated
row uses it.

The manual sliders are unchanged and still trim whatever the profile leaves, so
switching it off is "correct this by hand" rather than "stop correcting".
2026-09-07 00:27:47 +02:00
dtourolle 2cd49d1cb7 Measure the rail by counting it, so it can scroll instead of clipping
With the adjustment groups in the rail, a short window silently lost them.
At a 1500x680 window the rail drew Photo, Compose, Local, Repair, the seam and
"All", and then stopped: Optics, Light, Colour, Effects and Detail were not
scrolled off, they were gone, with nothing on screen to say so. The develop
view's primary navigation, unreachable by any means.

The Flickable was put here to prevent exactly that, and it could not, because
its viewport asked the layout how tall it wanted to be while the layout was
already taking its height from the viewport. Slint settles that cycle by
handing back the height it was given, so `max(self.height, preferred-height)`
could never exceed `self.height` and there was never anything to scroll.

Counting breaks the cycle. Every entry here is a fixed height by construction —
a tool is `rail-entry-height`, a group is a touch target — so the content is
six plus four fifty-fours plus a gap plus a touch target for each group and
"All", which is exact rather than an estimate and depends on nothing that
depends on it. Four tools and five groups come to 498, against the 340 a
680-pixel window leaves at 2x, and the difference is now scrollable rather
than absent.

Found by shrinking the window with the groups forced into the rail. The
interaction itself is unverified: synthetic input does not reach a Slint
window on this desktop, and the tablet was disconnected, so what is confirmed
is the arithmetic and the clipping it explains, not the scrolling it should
restore.
2026-09-07 00:17:08 +02:00
dtourolle 3dc7c184ee Optimise for release only, since every edit pays for a dev build
Dependencies were built at `opt-level = 2` even in dev, because wgpu and
image decoding are slow without it. That is still true, and it is what this
gives up: a debug run of the app, and the decode- and GPU-heavy tests, are
slower than they were.

What it buys is that nothing has to be optimised before it can be compiled.
That cost was paid on every edit, in every worktree, whether or not anything
was ever run — and there are sixteen worktrees, each with its own target
directory and no shared cache, so it was paid sixteen times over.

`[profile.release]` is untouched: `lto = "thin"` and `codegen-units = 1`
still apply where the speed is actually wanted.

If one crate turns out to be the one that makes a test unbearable, raise
that crate alone rather than restoring the blanket rule; the manifest says
how.

Incremental compilation is now on as well, but that lives in
`.cargo/config.toml`, which is untracked and per-checkout — so it is a local
change on this machine, not part of this commit.
2026-09-06 20:17:53 +02:00
dtourolle 3f6dbce2aa Name a lone control after its operation, so three cannot all read "Amount"
Benchmarks / CPU and I/O (per commit) (push) Successful in 10m31s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 1h15m4s
Build and test / Layer separation (push) Successful in 37s
Traceability / Requirement traces (push) Failing after 1m28s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 2s
Build and test / Android (aarch64) (push) Successful in 1h3m13s
The Detail group ended with three consecutive sliders labelled "Amount" and
nothing to tell them apart. They are dehaze, clarity and texture: each declares
exactly one parameter, and `ops/README.md` tells an author to reach for the
`amount` kind first, so all three named it the same thing.

The panel withholds a heading from a group of one, and the reasoning it gives
is sound — a lone control names itself, and the group reset it loses costs
nothing because the slider already resets on double-click. But that argument
rests on the parameter being named after what it does. It holds for exposure,
contrast, vibrance, saturation and brilliance, whose single parameter shares
the operation's name, and it fails for the three whose parameter is called
after its kind rather than its subject.

So a lone parameter now takes its operation's label — the name the withheld
heading would have carried. For the five that already agreed, nothing changes.

Found on the tablet, and only there: every row was correct, every label
resolved, and the panel was still unusable. The test that guards it asserts
over the real chain rather than a fixture, because the fault was a property of
what is actually declared — a fixture would have had to be written to
reproduce it, and would then only have proved itself.
2026-09-06 19:01:54 +02:00
dtourolle 124b2d99c6 Take the first control that moves the picture, not the first one drawn
`showing_the_original_leaves_the_edit_exactly_as_it_was` set row zero to its
maximum and then asserted the photograph was modified. It was not, and the
test failed on its own premise rather than on the thing it exists to check.

`EditGraph::capabilities` puts the lens corrections at the head of the list,
matching where they sit in the shader. Those carry profile coefficients rather
than parameters, so with no profile loaded a slider on one is a control with
nothing behind it: the graph stays neutral and the premise assertion fires.
The test was written against a panel whose first row happened to be an
adjustment, and it stopped being one.

So it now walks the rows until it finds a control that actually changes the
edit, which is what it meant by "row zero" all along. Still addressed by
index, so it still names no operation, and it no longer depends on where in
the chain the first *adjustment* happens to sit.
2026-09-06 19:01:53 +02:00
dtourolle 3994caba12 Write down the develop gestures, since only their author knew them
FR-UI-4 says a gesture with no visible counterpart is a feature only its
author knows about, and the vocabulary the application actually publishes had
two sections in it — the library grid and people. Develop had none. Every one
of its gestures was documented in the comment beside the `TouchArea` that
implements it, which is where the previous sixteen were before this scanner
existed, and unreachable to anybody not reading the source.

Thirteen now carry tags: magnify by pinch or wheel, pan a magnified frame,
fit and 1:1, hold to see the original, sample a neutral, undo and redo, step
through the folder, reset one control, show or hide a mask layer, choose a
group of adjustments, and copy and paste the settings. Each has a pointer and
a touch route, so none of them is keyboard-only.

Three bindings were genuinely missing and are added here rather than merely
described. Ctrl+C and Ctrl+V for the settings clipboard, which the Settings
panel's own comment has claimed existed for as long as the panel has and
nothing bound; and [ and ] to step through the adjustment groups. The groups
are whatever the operation set declares itself to be about, so there are as
many as the pipeline has and no key can name one of them — stepping is the
binding that survives a node being added, and "everything" is part of the
cycle rather than a way out of it.

Two are described and not bound. Resetting a control from the keyboard and
toggling a mask layer from the keyboard both need a notion of which control
or layer has focus, and the generated panel has none — the rows are a model
the repeater rebuilds, and inventing a focus ring for them is a larger change
than a keyboard shortcut. Both are reachable by pointer and by finger, and
the tags say so rather than promising a key that is not there.
2026-09-06 19:01:52 +02:00
dtourolle 901f51e6c4 Point at something grey and let the pipeline work out the rest
FR-DEV-3 has asked for "white balance (temperature/tint, and picker)" since
it was written, and only the first half existed. `WidgetKind::WhitePoint` was
in the vocabulary and `develop::supported` answered false for it, so the node
degraded to two sliders — correct behaviour that had quietly become the only
behaviour. Sampling a neutral is the first move of the global tonal pass and
every colour judgement afterwards is measured against where the grey was put,
so guessing at two sliders until a wall stops looking green is the wrong way
round.

The awkward part is that a picker genuinely needs to know how far a hundred
units of temperature move red against blue, and that number is declared in
the node's own file. So the inversion lives in `dr_pipeline::neutral` rather
than in the interface: the canvas hands over a colour, the core finds the
operation that asked to be driven by a pixel and bisects its declared
response until the sample comes back grey. Nothing in `ui/` names white
balance, and nothing holds a second copy of a response that would be wrong
the first time somebody adjusted the range. A bisection rather than a
closed-form inverse because only monotonicity is part of the bargain — the
expression is free to become a table tomorrow.

The result is rounded to the precision the control is drawn at, which is not
cosmetic: unrounded, sampling something already neutral lands a
ten-thousandth off zero, and the photograph comes back modified with an undo
step for a correction of nothing.

On the panel side this needed one distinction the generated path was
missing. `is_on_canvas` was being read as "and so the panel draws nothing for
it", which is right for a crop — four edge fractions are not controls anyone
drags in a list — and wrong for an eyedropper, which *writes* temperature and
tint and leaves them exactly the controls a photographer reaches for next.
So a sampling widget keeps its sliders and puts the affordance that arms the
canvas in the group's heading, built like the reset beside it. One click, one
sample, one history step: `Edit::Action` never coalesces, and there is no
hover preview to fill the stack with temperatures nobody chose.

Declaring the presentation also groups temperature and tint under one undo
step, where they were two. That follows from what `Presentation` means and
reads correctly — white balance is one decision — but it is a change, and
worth saying so.
2026-09-06 19:01:52 +02:00
dtourolle 2584b9ecbc Hold one key to see the photograph before you touched it
FR-DEV-7 asks for the current edit against the unedited original and nothing
implemented it. What the develop view had was history navigation, which
*changes* the edit rather than previewing against it — so the only way to
look was to undo, look, and redo, and that puts two real steps on the stack
at exactly the moment a photographer suspects they have overcooked a frame
and is least sure of what they are doing.

Holding the "Before" button, or backslash, renders the graph with every
adjustment stripped and hands it straight back afterwards: the same
suspend-render-restore shape the crop overlay already uses to show an
uncropped frame and an export uses to suspend the zoom. Nothing is recorded,
no rows are re-synced, and the photograph is still modified when the key
comes up — the panel goes on describing the edit the photographer has,
because only the canvas is answering a question.

The framing deliberately stays on. A held comparison is a question about
tone and colour, and re-cropping the canvas under someone's thumb would move
the detail they are comparing; worse, the zoom is a rectangle of the *framed*
image, so dropping the crop at 4× would quietly show a different part of the
photograph rather than the same part unedited. What the crop took away is
already compared in Compose, which shows the whole frame.

Not a split screen: that halves the working image on the tablet this column
was sized for, and the comparison photographers describe making is a flick
back and forth rather than two pictures side by side. Press-and-hold is one
gesture on a finger and on a mouse, which is what FR-DEV-3b's mapping wants,
and it has no mode to be stranded in — the button reports both edges, so a
press the system cancels puts the original down too.
2026-09-06 19:01:51 +02:00
dtourolle 9b674a88d8 Let the photographer look at the pixels, and keep looking
Noise reduction and capture sharpening are judgements about individual
pixels, and at a fitted view several of the file's pixels are averaged into
each one on screen. The frame therefore looks cleaner and softer than it is,
the photographer corrects for a softness the display invented, and
over-sharpening is the documented result. Nothing in the develop view
reached 1:1 at all: the wheel and the pinch zoom by ratios, the double tap
dropped straight to fit, and the only readout was a percentage nobody was
aiming at.

So the double tap now does what FR-UI-4 always said it did — toggle fit and
1:1 — and the zoom readout, which used to be a dead "Fit" button on a fitted
photograph, becomes the way in when there is nothing to clear. Z does the
same from the keyboard, and the back gesture goes out through the same
toggle so putting the magnifier down really puts it down. 1:1 is computed
from the file's own resolution against the viewport rather than fixed at
some multiple, because that is the only version of it that answers the
question the two detail controls are asking.

The point being inspected and whether the magnifier is up are held beside
the session rather than in it, on the argument focus peaking already makes:
a session is one photograph and this is a way of looking at a folder of
them. Checking the same eye across forty portraits is the reason to reach
1:1 in the first place, and a magnification that reset with the session
would make that forty zooms and forty pans instead of forty keystrokes. It
stays a viewing state throughout — the view is kept out of `is_active`,
`output_size`, the sidecar and the export, and an export still suspends it —
so none of this reaches the file.
2026-09-06 19:01:50 +02:00
dtourolle 43652bd613 Say where the positives really come from, not where they were going to
`calibrate.rs` claimed its positive pairs came from user confirmations "then
burst siblings, since FR-CULL-5 already groups bursts", and repeated it beside
the pair floor: "the positives are bootstrapped from bursts and a handful of
early confirmations". Neither is true and neither ever has been. Nothing in
the workspace pushes a burst pair into a `Pairs`; nothing pushes any pair at
all outside this file's own tests. The sentence was written while both halves
of docs/faces.md §8.1 were being planned together, describing a source that
was going to exist, and it has read since as a description of what the code
does.

The distinction matters more here than in most comments, because this file is
the one place in the subsystem allowed to say what a similarity *means*. A
reader who believes the fit is drawing on bursts believes a young library is
gathering positives on its own, which is precisely the opposite of the state
FR-CULL-9 legislates for — a library with no fit, no valid calibration, and a
reference curve it must not present as a measurement of itself. The floor of
200 positive pairs looks arbitrary under the wrong story and obvious under the
right one: confirmations arrive one at a time, from a person.

So the comment now says what is here — a positive is a pair confirmed onto one
person, and there is no second source — and keeps the burst idea where it
belongs, as §8.1's proposal, with the two reasons it is not in the code: this
crate is handed cosines and cannot see a catalog, and the purity of a burst
pair is a thing to measure before it is a thing to trust.

No behaviour changes; the arithmetic is untouched.
2026-09-06 19:01:50 +02:00
dtourolle 1c5c55b4c9 Let the photographer say which frame the burst stands for
`choose_representative` has been in the catalog since the grouping landed,
with tests behind it and nothing calling it. So the frame a folded burst drew
was always the earliest one, and the only way to disagree was to open the
group and leave it open — which is to say there was no way to disagree at all,
because a burst that stays open is a burst that was never collapsed.

The earliest frame is the right default and it is deliberately not a
judgement: nothing here scores a photograph, and FR-CULL-5 names the failure
that rule avoids. But the whole point of a burst is that one of the twelve is
better than the other eleven, and the person who knows which is the one
looking at them.

So a ring on each frame of an open group, ticked on the one the group folds
to. It is drawn only while the burst is open, because that is the one moment
the alternatives are on screen to be compared — offering the choice on a
folded burst would be asking about frames it is hiding. Bottom right, opposite
the count in the other corner, clear of the flag and the collection badge and,
deliberately, of the trash target: a slip between the ring and the fifth star
sets a rating, which is the harmless direction for an ambiguous press.

The mark stays live on the frame that already wears it. A disabled TouchArea
would let the press fall through to the cell behind it, so tapping the one
ring that is ticked would have opened the photograph — and pressing it is a
thing the user may mean anyway: it records the choice the default was making
silently, which then survives a regroup that finds an earlier frame.

Choosing repaints the badges instead of reloading the window, which is what
separates it from folding a group up. Folding changes what the grid's query
returns; this changes only which cell wears the tick, and the tick has to
leave the frame that was carrying it, so the whole window is refilled in the
one statement `sync_badges` already runs.

The gesture is documented where FR-UI-4 requires it to be documented: in a
tagged comment beside the control, which is the only copy. The gesture book,
the gesture document and the requirements matrix are regenerated from the tree
alongside it.
2026-09-06 19:01:49 +02:00
dtourolle 5fa4c0772b Speak the sidecar format every other editor already reads
FR-CAT-13 asked for standard XMP and nothing in the tree parsed or wrote a
byte of it. `keywords.rs` mentioned `dc:subject` in a comment about what a
keyword's text is for, `dr-export`'s metadata module said "neither is read by
`dr-decode` today" about its own half, and `dr-preset-xmp` reads a different
file for a different requirement. So a library imported from Lightroom could
come in and never go back out: a one-way door, which is not a thing a
photographer walks their archive through.

`core/dr-xmp` reads and writes the properties the requirement names —
`dc:subject`, `lr:hierarchicalSubject`, `xmp:Rating`, `xmp:Label` and the IPTC
core fields — from whichever shape the file happens to use. A property may
arrive as an attribute or as an element, inside a Bag, a Seq, an Alt or no
container at all, because the specification is not what wrote the file; so one
collector takes whatever is in a property and the declared shape decides only
how many values survive. `xmp:Rating="-1"` is modelled as Adobe's rejection
rather than folded into zero stars, since DarkRoom keeps those on two axes and
the mapping belongs where both are visible.

Writing is a rewrite rather than a serialisation, and that is the whole design.
An XMP sidecar is a shared document: the file beside a raw carries somebody
else's `crs:` settings and comments and namespaces, and rendering our record
over it would be data loss on every photograph but the first. The rule is
stated once, in the crate documentation and in `PROPERTIES`: DarkRoom owns
exactly those properties, identified by namespace URI and never by prefix, and
nothing else in the document. Everything unowned is copied through byte for
byte. A `Description` left empty once our properties come out of it is
withdrawn, which is what keeps a rewrite idempotent instead of adding a husk to
the file on every save.

Precedence is settled conservatively, because a standard XMP carries no
revision and no device and there is nothing in it to order two edits by.
Keywords union, following the rule `dr_catalog::merge` already makes for
assignments; every other field is taken only where DarkRoom holds none,
following `Version::merge`'s judgement rule, and a genuine disagreement is
reported rather than resolved so a caller can offer the reload the requirement
asks for. What is deliberately left open — when a reload may happen without
asking — is written down in the module rather than picked silently.

No new dependency: quick-xml was already in the tree for WebDAV and for
Lightroom presets. Nothing above the crate calls it yet, and `outstanding.md`
now says so along with the two smaller gaps, GPS and the filename convention.
2026-09-06 19:01:48 +02:00
dtourolle 68ebf5d78b Let a mask start from a tone or a colour, not only a shape
Every local adjustment began from a shape: painted, drawn with a handle, or
found by a model. So the only way to hold back a sky was to draw a line near
where it ended, and the only way to warm skin was to paint round it — both of
which put the edit's edge where the photographer put a gesture rather than
where the picture changes. A gradient across a treeline halos, and an
adjustment traced round a face stops on the outline of a hand.

MaskSource grows two variants that select by what a pixel *is*. Luminance
carries two bounds on the perceptual tone scale plus a softness; Colour carries
an arc of hue, a range of chroma, and one softness for every edge of both. Five
floats and three, so they diff, sync and merge per field under FR-NC-9 exactly
as a gradient's geometry does — the property a stored raster has none of, and
the reason the model's coverage had to sit beside its source rather than inside
it.

The pixels are the shader's business and nowhere else's. `mask.wgsl` takes the
demosaiced source as a sixth binding and two new modes read it: decode, balance,
pull a clipped photosite back to neutral, apply the camera matrix, then weigh
the band. Nothing crosses to the CPU but the numbers and the matrix, and each
mask texel averages its own footprint in the source, so a band lands on the tone
an area is rather than on whichever texel a proxy grid happened to land on.

The photograph it measures is the one the camera recorded, before this edit. A
band over the edited result would slide out from under the edit as the edit was
made — raising the highlights would change which pixels counted as highlights,
and the slider would chase its own mask.

Feather, falloff and morphology stay off a range layer, which is what
`shapeable` already meant. All three are functions of the signed distance from
a boundary, and a range has no boundary to be at a distance from; its edge is
the softness of its own band, in the band's units. Offering them would be four
controls that move and change nothing.
2026-09-06 19:01:48 +02:00
dtourolle 81b1ae8c42 Measure the haze from the picture, and divide it back out
Four files named dehaze as a member of the compositional detail family —
`detail.rs` twice, `dr-gpu`'s detail module, `ops/README.md` and
`capture_sharpen.rs` — and no such node existed. Every one of them was
describing the family by listing clarity, texture and a control the
photographer could not reach.

Haze is the one degradation the controls already in the chain cannot
remove, and the reason is spatial rather than tonal. Scattering
composites an airlight over the scene in proportion to distance, so the
lift is per-pixel: a black point that clears the mountains crushes the
foreground, and a contrast curve that clears the mountains does the
same. So the node has to estimate the transmission at every pixel, which
is the dark-channel prior — the local minimum over the channels and over
a patch is the airlight that has been added there — and then invert the
scattering model with it.

The airlight is taken as neutral and as unit, which removes the one part
of the published method this stage cannot perform. Estimating it
properly is a whole-frame reduction, and the detail chain has none: it
hands each pass the pass before it. It is also unnecessary, because
white balance is the first node in the chain and has already driven the
illuminant to grey, so only the magnitude is unknown — and an unknown
magnitude on the veil is a scale factor on the amount slider, which the
photographer is setting by eye regardless.

The patch is a fraction of the frame's shorter edge, through
`RenderScale::frame_fraction`, and never a count of pixels. It has to be
wide enough to contain something dark and narrow enough that what it
measures is still local, and both of those are statements about how much
of the composition it covers — so it must cover the same proportion of
the picture on a proxy as in the export, or the file is sharpened for a
patch three times narrower than the one that was tuned on screen.

Affording it needs an identity a Gaussian does not have. Erosions
compose by adding their structuring elements, so the minimum over a run
of d followed by the minimum over k points spaced d apart is the exact
minimum over the whole kd window. At the square root that is 16 taps
rather than 61 at 4K, and it is the same filter rather than an
approximation of one — which is the difference from the strided kernel
`local_contrast` refuses, where sampling an image that is not
band-limited aliases into the base and comes back as mottling.

It runs first among the compositional detail nodes, at order 125: after
noise reduction, because dividing by a transmission below one amplifies
the noise in the veiled distance by exactly the factor it recovers the
contrast by, and before clarity and texture, coarse before fine, so that
their base is computed on the picture the veil has left rather than on a
modelling about to be divided out.

What it cannot honour is the placement dehaze most wants. It shifts
colour — it subtracts a grey term and rescales, so saturation changes
wherever the veil is thick — and the colour work would ideally be
correcting the picture that leaves here. The detail stage runs as a
group after every point operation, because a neighbourhood pass is a
separate dispatch over a texture the fused pass has finished writing, so
an order placing this node ahead of `vibrance` would be a lie the chain
cannot tell. Interleaving would mean splitting the fused pass in half
around it, at the cost of a second full-frame dispatch and intermediate
for every edit in the catalogue whether it dehazes or not. The
declaration records that rather than leaving it to be rediscovered.

FR-DEV-18 is added to the requirements register alongside it. The tag
had nowhere to point, and an orphan tag fails the traceability gate
rather than quietly counting for nothing.
2026-09-06 19:01:47 +02:00
dtourolle 7c3e1d2c54 Let the shadows and the highlights carry a colour the picture never had
The colour mixer is the only chromatic control in the chain, and it can only
turn a hue that is already in the frame. Ask it for cool shadows against warm
highlights and it has nothing to take hold of: the shadows of a correctly
balanced photograph are near enough neutral that there is no band there to
turn, and a monochrome conversion hands it a picture with no hue in it at all.
Split toning is the oldest look in the book and every developer worth comparing
against ships it; there was no way to reach it from here.

So colour_grading, declared like any other node — a hue and a strength for the
shadows, the midtones and the highlights, and a global cast over the frame. It
targets a tonal range rather than a hue, which is the whole difference between
the two controls: it puts colour where none was rather than turning what it
finds. It sits at 105, after the mixer has had the last word on the colours
that are in the picture and before the detail stage.

The mechanism is one helper. Three cosines 120 degrees apart are the hue wheel
written directly as an RGB direction, and their sum is zero at every angle, so
exp2 turns them into three gains whose product is exactly one — a cast tilts
the balance without moving the level. A grade that doubled as an exposure
change is the failure that has the photographer chasing brightness with a
colour slider, and it is corrected with a control that cannot reach it. The
three tonal weights partition the scale rather than overlapping, the midtones
being whatever the two ends leave, so setting all three to one hue is exactly
the global cast and a split tone does not colour its own midtones as a side
effect of its halves meeting. Full strength is half a stop on the leading
channel, the ceiling white balance already holds itself to.

Neutral is declared rather than inferred, which is what `active:` is for. A hue
with no strength behind it is a direction with no distance, so under the
default rule nudging one would have put the node into every fused shader for a
change nobody can see. Summing the strengths is zero exactly when all four are,
and they cannot go negative to cancel each other. The opposite reading —
neutral as "nothing has been touched" — fails the other way round: red is hue
zero, so a grade toward red never moves a hue off its default and would never
have been applied at all.

It asks for a colour wheel, the widget the descriptor vocabulary has been
carrying with no operation behind it. Nothing draws one yet, and that is fine
by construction: the panel takes the first widget it implements and falls
through to sliders otherwise, so this arrives as eight ordinary controls that
work. Each parameter is named for its own range for exactly that reason — in a
flat list, four sliders called "Hue" are four controls nobody can tell apart.

FR-DEV-12 is written into requirements.md beside it. A TRACES tag naming a
requirement that is not defined there is an orphan, and the traceability gate
fails on those rather than quietly counting them. The label catalogue gets one
line for the operation's display name; the eight parameters derive correctly
and are left to.
2026-09-06 18:51:40 +02:00
dtourolle efa9d84aad Correct the lens first and settle the grain last
`Attribute::ALL` has claimed since it was written to be roughly the order a
photographer works in, and 474dcf0 moved `Compose` to the front on exactly that
argument. Both ends went on contradicting it.

`Optics` sat fifth, so the column offered the lens corrections after the tones
they change. Removing a vignette brightens the frame; an exposure judged before
that correction has to be judged again after it, which is the definition of the
wrong order. `Detail` sat fourth, so sharpening and noise reduction — the only
work here that depends on everything above it, and the only work that cannot be
judged at fit view at all — were offered before the lens had even been put
right.

So `Optics, Compose, Tone, Colour, Effect, Detail`. `Optics` leads even
`Compose` because it is not a decision about the photograph at all: it is
undoing what the equipment did, a property of the capture rather than a choice.
`Effect` after `Colour` is a look laid over a settled picture, and is the one
slot that is genuinely arguable — a spectral film simulation declares `renders`
and replaces the base curve, which is a case for treating it as foundational
instead, and e235e99 filed film under `Effect` only days ago. The doc comment
records that tension rather than pretending to settle it; an array of six
cannot say "last, except when it is first".

`decl::Attr::ALL` moves with it. It is the second spelling of one vocabulary,
compiled by `build.rs` where `descriptor` is not visible, and the agreement
test in `declared/mod.rs` zips the two positionally — that test is what caught
the last reorder, and it would have caught this one.

Nothing persists a position in this list, which is what makes the reorder safe
rather than merely tidy. `Scope` packs one bit per attribute indexed by
`Attribute::ALL`, but `bits` is private, has no accessor and no `serde`; what
reaches a settings file is `develop.copy_attributes`, a list of names read back
through `Attribute::from_name`. A photographer's copy scope survives untouched
— only the order the names happen to be written in changes.

6a97fdf is why this is worth a commit now rather than a shrug: the
contradiction was harmless while the list only fed a row of chips nobody reads
in order, and stopped being harmless when the same list began driving a column
read top to bottom.

The matrix follows the two files' shifted line numbers.
2026-09-05 21:17:56 +02:00
dtourolleandClaude Opus 5 59917c5183 Call the tool Compose, since that is what its panel says
Benchmarks / CPU and I/O (per commit) (push) Successful in 14m35s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 1h20m20s
Build and test / Layer separation (push) Successful in 51s
Traceability / Requirement traces (push) Successful in 2m8s
🐳 Android image / Build and push (push) Successful in 5s
Build and test / android-image (push) Successful in 5s
Build and test / Android (aarch64) (push) Successful in 1h5m51s
The rail entry read "Crop" while the panel it opens is headed COMPOSE and the
button leaving it said "Done Cropping". One mode, three names, and the odd one
out was named after a single control rather than after the decision — which is
what made cropping look like a category of its own in the first place.

Straightening, the quarter turns and the flips are already in that panel, and
perspective will be. `ViewMode.crop` keeps its name: it identifies a canvas
interaction, which is exactly what it still is.

Found by looking at the running application rather than by reading, which is
also how the two halves of this were noticed to disagree at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 18:29:08 +02:00
dtourolleandClaude Opus 5 e235e99cce Move the film to Effect in the descriptor that is actually read
An earlier commit claimed to move `film_sim` from `[tone, colour]` to
`[effect]` and did not. It edited `ops/film_sim.yaml`, where `attributes:` is
read, validated against the vocabulary, and then dropped: a `rust:` node
publishes its own descriptor, and the type still said tone and colour. The
stock went on appearing in the Light group beside exposure and again in Colour
beside white balance, exactly as before, and every test passed.

Nothing caught it because nothing could. The declaration parsed, the parity
tests compare ids rather than attributes, and an operation filed under the
wrong groups renders perfectly. It surfaced only on screen, as a missing
Effects tab — which is indistinguishable from a category that genuinely has
nothing in it, and is precisely how `Optics` looked for as long as it was
empty.

So three changes rather than one:

`FilmSim`'s descriptor declares `Attribute::Effect`, which is the move the
earlier commit described.

`attributes:` joins the keys a `rust:` node may not carry, beside `params`,
`uniforms`, `wgsl`, `helpers`, `define` and `label`. The rule was already
written — "its descriptor comes from the type" — and attributes were the one
field that slipped past it. A key that is silently ignored is worse than one
that is rejected, because it reads as though it worked; the eight hand-written
declarations lose a line that never did anything.

And a test asserts that every attribute the chain carries reaches the tab
strip. That is the property that was actually broken, and its failure mode is
invisible from every direction: the controls exist, they are in the shader,
and there is no way to filter to them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 18:28:59 +02:00
dtourolleandClaude Opus 5 6a97fdf6f9 Put the adjustment groups in the rail where a finger is driving
Reported from the tablet: the tool rail is very useful there, and the same
interface under a mouse and keyboard is not. That is `ui-navigation.md` D-N2's
central assumption failing in use, and the interesting part is which half of
it failed.

D-N2 was right that platform is the wrong axis and width is the wrong axis: a
tablet in landscape wants what a desktop wants, and a desktop window dragged
narrow wants what a small screen wants. `apply_layout_class` still decides the
layout class from the window and nothing here changes that. What D-N2 got
wrong is the sentence "touch changes hit regions, not layout" — it identified
input as the real difference between the targets and then assumed that
difference could never reach the layout.

Two controls answer one question — which group of adjustments am I looking at
— and neither is better in general. A horizontal strip above the column is one
gesture to a target the eye has already found, and it pans when the operation
set is rich, so a group can sit off the end with nothing saying so: a pointer
user tolerates that, a finger user never discovers it. The same list down the
rail is every entry visible at once, each finger-sized, on the edge of the
screen the hand is already holding, and it costs no width because the rail is
already there.

So `ToolRail` grows a second section, and `GroupStrip` stands down when it
does. The two are never both on screen, which is why they can share
`adjust-tab-picked`: Rust is not told which was pressed and has no reason to
want to. Mode and group stay independent axes as N1 requires — one entry lit
in each section, and choosing a group while a tool is held still filters
without putting the tool down.

They stay drawn differently, which N1 also required. The tools fill with
`active-dim` and invert their ink; the groups take a bar down the leading
edge — the strip's underline turned ninety degrees — so a lit entry says which
kind of state it is without the reader having to remember which section it was
in. The rule between the sections is the second signal.

The rail scrolls now. Its own note argued against a Flickable because "this
list is four entries written in this file"; with the groups in it the list
comes from the operation set, which is exactly the "something the user's data
decides" that note excluded this control from.

The axis is input, and it is a preference because the automatic answer is a
guess that cannot be made reliable. Neither platform can be asked what the
user is holding: an Android tablet in a keyboard case is being driven like a
desktop, and a touchscreen laptop is whichever its owner says.
`dr_plat::is_touch_first` reports the usual case per platform, and
`GroupNavigation` lets it be overridden. Settings names what Automatic
resolves to on this device rather than leaving it to be found by pressing.

D-N6 records the reversal beside the decision it reverses, including the half
that still stands and the question it opens: whether Local is a mode at all,
or a scope that would collapse the two sections into one list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 18:14:03 +02:00
dtourolleandClaude Opus 5 474dcf0bf6 Let Compose lead the attributes, as their own doc always said
`Attribute::ALL` claims to be "roughly the order a photographer works in" and
then listed framing fifth, behind tone, colour and detail. Framing is the
first decision made about a photograph and the one every later judgement is
made inside — there is no sense balancing tones across a frame about to lose a
third of its width.

The contradiction was harmless while the list only fed a row of chips nobody
reads in order. It stops being harmless now that the same list drives a column
read top to bottom.

`declared::Attr::ALL` moves with it. The two are separate spellings of one
vocabulary and a test asserts they agree, which is what caught this rather
than the order silently disagreeing between the YAML front end and the crate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 18:13:44 +02:00
dtourolleandClaude Opus 5 1ad35e2b87 Read the library's sidecars, so a cull done elsewhere arrives
Judgements only ever travelled outward. A rating went to the catalog and to the
photograph's sidecar, the sidecar reached the server, and there it stopped: the
scan indexes files, `derived_sync` exchanges thumbnails, face shards and
collections, `dr_catalog::merge` reconciles everything in a catalog except
`versions.rating` and `versions.flag`, and the one sidecar reader that existed
ran when a single photograph was opened in develop and handed its answer to the
develop graph. `JobKind::ReadSidecar` was declared for exactly this when the job
queue was written and was never enqueued or handled anywhere.

The grid draws `versions.rating`. So a day of culling on the tablet could not
reach the laptop by any path the application had, and the laptop's catalog says
so plainly: 23,568 images, one of them judged.

`pull_sidecars` closes it, off the back of work the scan already does.
`dr_sync::scan` reports the `.drsc` files it meets in listings it was making
anyway — no extra request, and a directory whose ETag is unchanged is still
pruned before it is listed at all. A new `sidecars` table records the ETag of
each one this device has taken in, so the fetch is one GET per sidecar that
genuinely changed rather than one per photograph. A library nobody has edited
costs nothing.

The judgement is taken rather than maximised. The sidecar is the authoritative
store and the fuse has already settled any contest between devices on
`revision`, so lowering a rating from four to one on the tablet lowers it here —
taking the larger would have refused every demotion the photographer ever made,
which is most of what a second pass over a shoot is. A zero is the exception: it
means *never judged*, not "judged zero", so a sidecar carrying none cannot erase
a star this device holds. That is `merge_judgement`'s asymmetry and it carries
the same known cost — clearing a rating does not propagate.

A sidecar names a stem, so both halves of a RAW-and-JPEG pair are judged: they
are one photograph (FR-CAT-11) sharing one document, and judging only one of
them would leave the grid disagreeing with itself over which it drew. The `LIKE`
that finds them is a filter, not the decision — `sidecar_path` is applied to
every candidate, because a folder is entitled to contain a `%` and a rating
landing on the wrong frame would be silent and permanent.

Failing to read one is not a failure to scan: the ETag goes unrecorded, the
ratings already here stay where they are, and the next scan tries again. The
count is reported to the status line as well as the log, because a grid that
silently gains three hundred stars is indistinguishable from one that has gone
wrong — and because while this number was structurally zero there was nothing to
tell the photographer their cull had not arrived.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 16:14:03 +02:00
dtourolleandClaude Opus 5 ca2a135e28 Let two devices name the same photograph's version the same way
A version's uuid is the identity a cross-device merge keys on, and it was
minted at random, per catalog, per image. Two devices indexing one Nextcloud
library therefore held two different uuids for the same photograph — so the
sidecar they shared collected a `default = 1` block each, `Version::merge` was
never handed a matching pair to reconcile, and an afternoon's culling on the
tablet did not exist as far as the laptop was concerned.

`crate::merge` has said so in a comment since it was written: version uuids do
not reconcile across devices, a uuid-keyed join unions nothing, so keywords are
landed on the local default version instead. It named the problem and worked
around it. `rating`'s own comment asserted the opposite — that generating the
uuid here was what made it a cross-device identity — and `library::amend`
repeated the claim. Uniqueness was never the difficulty; agreement was.

`derived_version_uuid` computes it from `oc:fileid` instead. The server assigns
that integer, every client pointed at the library sees the same one, and it
survives a server-side rename and move — the three properties that already made
`ASSIGN_BY_FILE_ID` prefer it to a content hash. The layout is a UUIDv8 (RFC
9562, an application-defined form) carrying all sixty-four bits verbatim across
the variable fields with a fixed tag in the node field, so the mapping is
injective by construction rather than by a hash's good behaviour, and a uuid in
a sidecar can be read back to the file it belongs to by eye.

A library with no server behind it has no shared identity to derive and keeps a
generated one. The split is still reachable there if the folder is synced by
something else; `Sidecar::fuse_default_versions` repairs that case rather than
preventing it.

Deriving it for new rows alone would have fixed nothing — every image in an
existing library already has a version, so every one of them would have carried
on writing to its own rival identity. `align_default_version_uuids` moves them,
and runs from `schema::backfill` on every catalog open. It selects on the tag
in SQL, so a catalog already realigned matches no rows and writes nothing, and
it declines rather than fails where a virtual copy already holds the target.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 16:13:58 +02:00
dtourolleandClaude Opus 5 601c984894 Fold a photograph's rival default versions back into one
Picking the newer of two default versions stopped the wrong edit being shown,
but it did not close the split: the losing version stayed in the file, and a
device holding disjoint work — a crop made here, an exposure change made there
— still contributed only one of the two.

Worse, the next write made it larger. `amend` looks its version up by uuid,
neither of the two was ours, so the miss minted a *third* `default = 1` block
and the file grew one rival per device per photograph.

`Sidecar::fuse_default_versions` folds them down. The version with the highest
`(revision, modified)` is the accumulator and every other default is merged
into it as the remote, which is what makes the fold order-independent —
`Version::merge` raises its own revision to `max + 1` as it goes, so merging a
chain in ascending order stops being ascending after the first step and a third
device would be dropped. Contested values resolve to the winner, disjoint keys
survive from both sides because the merge is key-wise, and ratings come across
under `merge_judgement`, so a device that never judged the frame cannot erase
one that did.

The result is a function of the file's bytes alone, so two devices that fuse
independently reach the same document and converge instead of overwriting each
other.

Called wherever a sidecar is parsed:

- `amend`, with the write's own uuid, so the fold lands on the identity this
  device is about to use and the lookup below it hits instead of missing.
- `spawn_sidecar_fetch`, so opening a photograph shows everything done to it
  rather than whichever half won.
- `drain_one`, because `merge_into` reconciles by uuid and would otherwise
  publish the split rather than resolve it.
- `presets::load_local` and `save_local` — a local sidecar's folder may be
  synced by something else entirely, and gets the same split.

A file with one default under the expected uuid comes back byte-identical, so
this costs nothing on the ordinary write and no sidecar is uploaded merely for
having been read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 16:13:53 +02:00
dtourolleandClaude Opus 5 98fcf8e98e Ask which edit is newer, not which uuid sorts first
A photograph edited on two devices ends up with two `[version]` blocks in one
sidecar, both marked `default = 1`. Five of the thirty-eight sidecars in the
local cache are in that state right now.

`default_version` answered with the first `is_default` it met in map order,
and the map is keyed on uuid — so which device's work the photographer saw was
decided by which randomly minted uuid happened to sort lower. On
`IMG_20130625_0033` that is `0545c20a` over `679fe872`: a four-star rating from
the twenty-first of August standing in front of the one-star made on the
thirtieth, with nothing anywhere saying the newer judgement existed.

Resolved by `(revision, modified)` instead, which is the discriminator
`Version::merge` already uses — revision first so that a device with a skewed
clock cannot win by claiming a later timestamp (FR-NC-8), and the timestamp
only to break an exact tie.

This makes the reader pick the right one. It does not make the two converge:
the edit that lost is still in the file, and a device that holds disjoint work
— a crop here, an exposure change there — still only contributes one of them.
That is the next commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 16:13:49 +02:00
dtourolleandClaude Opus 5 a56baa9042 Let rustfmt have the assertion it reflowed
The closure taken down to `&dyn Operation` needed its call site re-wrapped,
and I wrapped it by hand rather than letting rustfmt decide: it fits on one
line at the workspace width. `cargo clippy` was run on the change and
`cargo fmt --check` was not, which is the whole of how it got through — the
two catch different things and CI runs both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 15:37:46 +02:00
dtourolle f0ee53ec09 Merge: the optical corrections, connected at last
Four files' worth of lens correction existed, was tested, and had never
touched a photograph. `compose_warps` had no callers, `dr-lens` had no
dependents, and `ops/vignetting.rs` had no declaration in `ops/` — so
`Attribute::Optics` was a category the tab strip could only ever filter out
for having no rows in it.

Distortion, chromatic aberration and lens vignetting now render, carry their
parameters through the sidecar and the undo stack, and take their coefficients
from the Lensfun database when the file names a lens it knows. The panel says
which of "no lens recorded" and "no profile for this lens" it is, because an
automatic correction that silently did nothing is worse than one visibly
unavailable.

One real bug on the way: `lens.rs` and `framing.rs` both documented the
shader's `p` as corner-normalised, and it is not — its length at the corner is
`0.5 * length(aspect)`, about 0.901 on a 3:2 frame. Every Lensfun polynomial
would have been evaluated short of where it was fitted, by a factor varying
with the aspect ratio, which reads as a correction that is merely too weak.

The category vocabulary moved with it. `Attribute::Geometry` is `Compose` —
named for the photographer's decision rather than for the maths it shares with
the lens corrections — and a film stock stopped claiming to be both tone and
colour, which had put "Kodachrome" in two groups it belongs to neither of.
2026-09-05 15:35:44 +02:00
dtourolleandClaude Opus 5 2841eaf9a1 Take the layer-chain test's closure down to &dyn Operation
`clippy::borrowed_box` is denied by the workspace lint set, and the closure
added with the optics exclusion took `&Box<dyn Operation>` — a borrow of the
box rather than of the thing in it, which says nothing the plain trait object
does not.

Caught by `cargo clippy --workspace --all-targets -- -D warnings`, which is
what CI runs and what the workspace tests do not: a lint on test code only
appears when the tests are compiled as a clippy target.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 15:14:15 +02:00
dtourolleandClaude Opus 5 c4ddcbe0f7 Look the lens up and say plainly whether one was found
`dr-lens` has held a complete Lensfun lookup — distortion, TCA and vignetting
coefficients from a lens name, a focal length and an aperture — with no
dependents anywhere in the workspace. The three corrections it feeds now
exist in the graph, so this connects the two and finishes the chain.

The coefficient structs stay duplicated. `dr-pipeline` is organised around
having no dependencies so its codegen is testable without a device or a
database (ARCH §6.5a), and `dr-lens` carries an XML parser and 5.5 MB of
profile data. Neither crate can convert to the other, so the conversion goes
above both, in `develop.rs`, which is the only place that sees them together.

Both traits grow the same defaulted door. The optical corrections do not sit
on the same side of the fetch — distortion and CA rewrite coordinates and are
`Warp`s, vignetting applies a gain to the pixel already there and is an
ordinary node — and fanning a profile out by which trait each happens to
implement would make the caller reason about that distinction. Each correction
takes its own share of the whole profile instead, and `set_lens_profile` walks
both lists identically.

The lookup happens in `set_source_metadata` rather than in its caller, because
that is the one place a session is told which file it came from. Doing it
there makes it unforgettable, in the shape `FilmRebake` already uses for the
other derived thing — and, more to the point, makes *clearing* unforgettable:
a session that opened a second photograph while still holding the first one's
profile would correct it for the wrong optics, invisibly, in a way that looks
exactly like the lens.

It needs the whole shot and not just a name. Distortion is interpolated across
a zoom's focal range and vignetting depends strongly on aperture — a fast
prime can be two stops down in the corners wide open and clean by f/8 — so a
lookup missing either returns coefficients measured for a shot nobody took.
Missing any of the three refuses rather than guesses.

A profile is derived, not persisted: it comes from the file's EXIF and a
database, so it is not a parameter, not in the sidecar and not undoable. What
is an edit is the manual trim beside it, which each correction composes with
the measurement — so a photographer can lean on it, override it, or work
without one.

`InfoPanel` gains a lens line, and it distinguishes three cases rather than
two. `dr-lens` states the rule it exists for: an automatic correction that
silently did nothing is worse than one the user can see is unavailable. A
session with no header draws nothing, a header naming no lens reads "Lens not
recorded", and a lens the database has never heard of reads "· no profile".
Collapsing the last two would send somebody hunting for a profile that was
never missing — which, for third-party and adapted glass, is the ordinary case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 15:11:32 +02:00
dtourolleandClaude Opus 5 a1165ef182 Put the coordinate-domain lens corrections into the graph
`lens.rs` has held a `Warp` trait, a composer and two implementations —
distortion and lateral chromatic aberration — since they were written, and
`compose_warps` was called by nothing outside its own tests. The corrections
existed, were correct, and never touched a photograph.

`EditGraph` now holds them, and `compose_full` emits them between the framing
prologue and the fetch. Distortion first, then CA: each warp receives the
position the previous one produced, and lateral CA is a magnification about
the optical axis of the *undistorted* frame, so measured on a barrel-distorted
one it would be fitted to a radius no profile describes.

They reach the panel the way framing already does — through `capabilities`.
That was the one open question and existing practice answered it: framing is
also not an `Operation`, also has parameters a photographer sets, and also
arrives through that list. Because `Preset::capture` walks the same list, the
sidecar, the clipboard and the undo stack carry a warp's parameters with
nothing registered anywhere, and no file under `ui/` names one (FR-DEV-3a).

`state()` destructures `EditGraph` field by field precisely so that a new
field cannot be forgotten, and it was not.

Chromatic aberration is the only thing that samples per channel, and
`splits_channels` is what keeps everything else from paying for it. Red and
blue are fetched from positions green is not — green is the reference and
never moves, so a wrong correction still leaves one channel sharp rather than
softening all three. With no CA in the chain the single-fetch path is emitted
instead.

The interpolating sampler is now chosen by framing *or* an active warp. Asking
framing alone would have nearest-neighboured a distortion correction on an
unstraightened frame, and that aliasing reads as a bad profile rather than as
a missing filter.

The warps go in the geometry invalidation key rather than the colour one: they
decide which source pixel a colour is read from, so a tile cached across a
distortion change would keep drawing the previous correction. The pipeline
cache needs nothing new — `hash_source` already covers the generated body, and
uniform values never enter it, so arming a warp recompiles and dragging it
does not. Both are asserted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 15:03:16 +02:00
dtourolleandClaude Opus 5 e17b909d41 Connect the lens vignetting correction to the pipeline
`ops/vignetting.rs` has carried a complete descriptor, polynomial, helper
and test suite without an entry in `ops/`, so it was never in `chain()`.
It reached no photograph and no panel, and `Attribute::Optics` was an empty
category in consequence — filtered out of the tab strip for having no rows,
by a chain that had never been given its only member.

Declaring it needs the one thing the operation was written against and
which did not exist. `wgsl_body` reads `radius`, and the module claimed
"the composer publishes `radius` in the shader prologue for exactly this
reason". It did not. `sample_source` now does, in both sampling branches,
beside the `source_px` it already published for the same class of caller.

It is corner-normalised there, which is the part that is easy to leave out.
`p` spans ±0.5·aspect, so its length at the corner is 0.5·length(aspect) —
about 0.901 on a 3:2 frame, not 1. Lensfun's polynomials are fitted against
a corner radius of 1, so passing `length(p)` straight in evaluates every one
of them short of where it was measured, by a factor that changes with the
aspect ratio. It would have read as a correction that is simply too weak,
which is indistinguishable from a bad profile. Both `lens.rs` and
`framing.rs` asserted the normalisation `p` does not have; corrected.

`order: 5` puts the correction ahead of the tonal stages, and the ordering
is load-bearing rather than tidy. Recovering a corner means dividing by an
attenuation below one — about two stops for a fast prime wide open — so run
after the highlights have been rolled off and clipped, the lift has nowhere
to go and the corners posterise instead of brightening.

`layer_chain` now drops `Optics` as well as the neighbourhood operations.
A local vignetting slider would have worked, which is what makes it worth
excluding: `radius` measures from the centre of the whole photograph and a
mask cannot move the optical axis, so it would lay a frame-centred radial
ramp across the picture and multiply it by the mask. The existing exclusion
covers operations that move and do nothing; this one covers an operation
that moves and does something its name does not promise. The rule both
share is now written down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 14:34:24 +02:00
dtourolleandClaude Opus 5 8e7b1350bf Name the frame's category for the decision, not the maths
`Attribute::Geometry` becomes `Attribute::Compose`, and `film_sim` moves
from `[tone, colour]` to `[effect]`.

Two categories were doing the wrong job. "Geometry" describes what crop,
straighten and the quarter turns do to coordinates — but it describes lens
distortion correction exactly as well, and that is not a compositional
choice at all. Naming the attribute for the photographer's decision is what
separates it from `Optics`: one is what the lens did, the other is what
they chose. The maths the two have in common is not the thing worth
filing them under.

A film stock declared both `tone` and `colour`, so "Kodachrome" appeared in
the Light group beside exposure and again in Colour beside white balance —
two places, neither of which is where anyone looks for it. It is neither:
`Effect` is defined in this same file as "applied rather than corrected — a
look, not a fix", which is what a stock is. That it moves tone and colour
is true of every look, and is not what the attribute is for.

`from_name` still accepts "geometry" on the way in. That string is
persisted in `develop.copy_attributes`, and an entry it fails to parse is
not an error — `presets::scope_for` logs it and drops it — so without the
alias an existing settings file would have quietly narrowed what a paste
carries. `name` writes the current spelling, so the file migrates itself
the first time it is saved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 14:34:03 +02:00
dtourolleandClaude Opus 5 95e854b5a2 Regenerate the gesture vocabulary over the library work
Traceability / Requirement traces (push) Successful in 1m57s
Benchmarks / CPU and I/O (per commit) (push) Successful in 3m42s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / android-image (push) Successful in 5s
🐳 Android image / Build and push (push) Successful in 5s
Build and test / Android (aarch64) (push) Successful in 1h5m40s
Build and test / Layer separation (push) Successful in 48s
Build and test / Desktop (Linux) (push) Failing after 1h12m34s
`Traceability` stayed red after the matrix was regenerated, on its other
gate: `gestures-check`. Same cause, second artefact. `docs/gestures.md`
cites each gesture by `file:LINE`, and the library UI work moved the two
selection-mode gestures down a hundred lines — 3923 -> 4032 and
3940 -> 4049 in `ui/dr-ui/ui/library.slint`. Nothing about the gestures
themselves changed.

`ui/dr-ui/src/gesture_book.rs` was already current, so this is the doc
alone: 70 files scanned, 16 gestures, 2 places, gate PASS.

Worth knowing for next time: `tools/ci-local.sh traceability` runs the
self-test, the coverage gate and the matrix, but not `gestures-check`, so a
clean local run does not prove this workflow green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 03:06:31 +02:00
dtourolleandClaude Opus 5 c1069ce07e Release 0.10.1
Benchmarks / CPU and I/O (per commit) (push) Successful in 13m30s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / android-image (push) Successful in 3s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / Layer separation (push) Successful in 46s
Traceability / Requirement traces (push) Failing after 1m36s
Build and test / Desktop (Linux) (push) Failing after 24h17m53s
Build and test / Android (aarch64) (push) Canceled after 0s
A bug fix release. Nothing here changes what DarkRoom is for since 0.10.0;
it changes how often it does the thing it already claimed to do.

The edits that went missing. A subject or category layer was stored as
identity alone, on the reasoning that the pixels were reproducible by
re-running the model — true, but nothing re-runs one except a photographer
pressing "find subjects". So a reopened photograph rendered without its
local adjustments and then saved that state back, and a batch export wrote
three hundred files without the edits their photographer had made, over a
log warning. The coverage now travels in the sidecar. Export also learned
to write the photograph rather than the canvas, and to carry the
photograph's header when the export is made from develop.

Face indexing was reading previews. It ran against a proxy and then
recorded the result as though it had seen the photograph, so faces smaller
than the proxy could resolve were not missed, they were *concluded absent*.
Indexing now runs on the native render, runs made against proxies too small
to find a face are forgotten rather than trusted, and a sweep that fails
everything says so instead of reporting a clean pass.

Where you were. The photo roll opens on the frame it opened with, develop
returns you to the photograph you were editing, the grid keeps its place
when another screen covers it, and the photographer's position now travels
between devices rather than being rediscovered on each.

Startup. The catalog opens on a worker and the bundled models unpack on
one, so a launch is no longer a page-by-page read on the way to the first
frame; the app says it is starting before there is anything to say it with.
The People rail builds the rows you can see, keeps portraits off the
blocking path, and withholds the empty groups that used to fill it.

Segmentation gained the half it was missing: the colour gate decided what
belonged to a category and had no way to decide where its edge fell, so a
refined sky kept the model's blocky outline no matter how the control was
set. A marker-based watershed now puts each contour onto a real edge, and
the refinement is a per-layer slider.

Coverage 70.4% -> 70.6% (127/180).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 21:33:16 +02:00
dtourolleandClaude Opus 5 dabf63ed6d Regenerate the matrix over the watershed and sidecar work
`Traceability` has been red on master. The matrix records every tag as
`file.rs:LINE`, so it goes stale two ways at once, and both happened here:

  - the `cargo fmt --all` sweep (710fcbc) moved lines under tags that did
    not otherwise change, which is drift with no change of meaning;
  - the segmentation and mask-storage work added tags and a requirement,
    which is drift that means something.

Regenerated: 332 -> 334 files, 1058 -> 1086 tags, 179 -> 180 requirements
defined, coverage 70.4% -> 70.6% (127/180).

No code changes. The pre-commit hook regenerates this file whenever a
taggable source file is touched, so the way it gets stale is a commit made
with `--no-verify`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 21:32:12 +02:00
dtourolleandClaude Opus 5 f6c9343bcc Ask the pixels where the edge is, not just what belongs
Benchmarks / CPU and I/O (per commit) (push) Successful in 3m53s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 1h18m38s
Build and test / Layer separation (push) Successful in 46s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 3s
Traceability / Requirement traces (push) Failing after 50s
Build and test / Android (aarch64) (push) Failing after 30s
The colour gate decided *what* was in a category and had no way to decide
*where* its edge fell. A colour test has no notion of an edge. So a refined
sky lost its flag and kept the model's twenty-pixel-blocky outline, and no
setting of the control could move that outline onto the horizon.

This adds the second half: a **marker-based watershed**. The mask is eroded
to give two markers, and the flood runs in the ribbon left between them,
meeting along the most expensive line it can find. The cost is a sum of terms
exactly as docs/segmentation.md §2 specifies — the photograph's own edges, and
the colour model's disagreement.

## Why this is not the watershed §15 threw away

That path failed because the merge *ladder* collapsed: 45,808 basins reduced
to one region plus specks. There is no ladder here. Markers prevent
over-segmentation by seeding rather than by merging afterwards, so the one
component that broke is the one component this does not have.

The markers are also better than the textbook's. scikit-image derives them by
thresholding the gradient — guessing where objects are — where these come from
a model that knows what sky is. Marker selection is what normally goes wrong
with this method, and it was already solved.

## The gate still runs, and it runs first

A flood cannot replace the colour gate. It only refines contours that already
exist, and there is no contour around a flag precisely because the model never
noticed one — the flag in the tests sits forty-five pixels from the boundary
against a ribbon of six. The tests caught this; the first version of this
commit had the flood standing in for the gate and the flag stayed.

So the gate goes first and *creates* the contour, and the flood then puts every
contour — the horizon and the new hole alike — onto a real edge.

## Erosion that does not delete flagpoles

Eroding by a cell and a half destroys anything thinner than three cells: a
mast, a bare branch, and equally a strip of sky between two of them. Those
would be left unseeded and the flood would fill them from whichever side
surrounds them, so a flagpole would come back — and come back *confident*.

Erosion therefore stops at the ridge of the distance transform. Whatever would
otherwise vanish keeps a one-pixel seed down its centre, floored at
`min_thickness` so a hot pixel does not qualify. That floor also moves the
signal-versus-noise decision out of colour space, where it was a share of a
fitted distribution nobody can picture, and into image space, where it is a
width in pixels a photographer can see.

## Two modelling errors the outward test found

Both were invisible while the refinement could only subtract, because the gate
was multiplied by weights that were already zero outside the mask. The moment
the boundary could move outward they decided the answer.

**A diagonal covariance is wrong along a gradient.** Sky moves along all three
opponent features together — luminance up, red-green drifting, blue-yellow
down — so treating them as independent charges a colour two deviations along
that gradient three times over. Measured: sky fifteen rows past the sample
scored 11.6 against a threshold of 11.34, so the model refused the very thing
it was refining. The fit now carries a full 3x3 covariance, inverted by
cofactors rather than by a dependency (D13, the NDK).

**Eroded seeds understate the spread, always, in a known direction.** The
sample is drawn from the middle of a category and never from its edge, so for
anything with a gradient the colours nearest the boundary are exactly the ones
left out. The broad mode is therefore fitted wider than its sample by
`SHOULDER`. Same pixel: Mahalanobis 5.9 uncorrected, 1.5 corrected — the
difference between refusing the horizon and reaching it. Only the broad mode
is widened; the tight ones are what discriminate.

## What was given up

Strict subtractivity. It bounded the damage and kept `scene.rs`'s partition
true for free, and it had to go: a mask that may only shrink can sharpen a
horizon inward but never outward, so wherever the coarse contour sat inside the
true edge, the error survived every setting of the control.

The travel bound replaces it. Everything beyond the ribbon is already a marker,
so the flood never reaches it — not "can only remove" but "can only move this
far", and the distance is the model's own uncertainty. That single bound also
retires the connectivity test, the reachability radius and the separate
additive path that an outward-growing rule would have needed. A blue car below
the horizon cannot be gained, not because a rule forbids it, but because the
flood is never there.

`the_colour_gate_only_removes` keeps the older property where it still holds;
`the_flood_cannot_travel_further_than_the_ribbon` holds the new one across the
whole travel of the control.

## Cost

The flood visits only unlabelled pixels, so confining it to the ribbon is not
an optimisation added on top — it is what a seeded flood does. A ribbon of a
few tens of pixels around one contour is a small part of a proxy.

The distance transform is no longer cached, because it has to be measured from
the mask as the gate leaves it and the gate moves with the control. That is one
transform plus one flood per change of the control, against a precompute that
runs the model once.

Verified: fmt clean, clippy --workspace -D warnings clean, 63 dr-segment tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 21:29:12 +02:00
dtourolleandClaude Opus 5 710fcbc1bd Format the face work with the workspace's own rustfmt
Not authored in this session. `cargo fmt --all` reformats every crate, so
running it while working on `dr-segment` picked up five files from the recent
face and library work that had been committed unformatted.

Committed on its own rather than swept into the change that happened to
produce it: the diff is pure whitespace, and mixed into a commit that alters
an algorithm it would be noise in exactly the place someone is trying to read
carefully. `cargo fmt --all -- --check` is a CI gate (tools/ci-local.sh), so
this had to land somewhere regardless.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 21:29:12 +02:00
dtourolle 89c4ff1820 Store what the model found, so a reopened photograph keeps its masks
A subject or category layer was written to the sidecar as identity alone —
which run, which instance, which category — on the reasoning that the pixels
are reproducible by running the same model over the same image. They are, but
only by *running the model*, and nothing runs one except a photographer
pressing "find subjects". So on every path that did not already have a run in
memory the layer resolved to no coverage, `MaskPass::render` logged "has no
distance field; skipping", and the adjustment was silently absent:

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

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

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

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

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

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

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

Version skew holds both ways. A file with no `coverage` line reads exactly as
it did before, which is a layer that needs the model run; an unreadable one
costs the pixels and not the layer, because the layer is the edit and the
raster is a cache of it. An old build reading a new file drops the key it does
not understand, which costs a model run and no work. And a payload that will
not compress is refused rather than truncated: a checkerboard would encode to
twice the raster it came from, so past 64 kB nothing is stored and the
behaviour falls back to what it was — half a mask would render as a mask that
is confidently wrong, which is the failure that tells nobody.
2026-08-30 21:28:54 +02:00
dtourolle 6acc98baad Carry the photograph's header into an export made from develop
The same file exported from the library grid kept its camera, its lens,
its capture date and its rights statement. Exported from the develop
button it kept none of them, and `{date}` in a filename template
resolved to nothing at all. Two buttons, one photograph, two different
files -- and the develop one was the version the photographer had just
finished working on.

A session now remembers the header it was opened from, and
`open_session` takes that header rather than the orientation read out of
it, so a photograph cannot be opened for editing without saying which
file it came from. `render_open_frame` clones it onto
`Source::Rendered`; both arms of `export_one` -- the worker's own decode
and the frame handed over already rendered -- turn a header into a
`{date}` and a `SourceMetadata` through the same function, so the two
paths cannot come to different readings of one file. What of it actually
reaches the exported bytes is still decided inside `dr-export` from the
settings, which is what keeps the location-stripping option working here
rather than giving it a second implementation to disagree with.

The alternative was to hang the metadata on `Source::Rendered` alone and
keep it beside the session in the interface. That touches less, but it
makes the header and the pixels two cells to hold in step across the six
places an image is opened, replaced or fails to open, and the failure
mode of getting that pairing wrong is not a missing tag: it is one
photograph exported under another's byline and coordinates, silently.
Kept on the session, the two travel together or not at all.

The header is stored decoded rather than transcribed at open time,
deliberately. `dr-export` argues that source metadata is a parameter and
not a field on `Frame`, because two exports of one frame may legitimately
disclose different amounts; by the same reasoning a session may remember
where its pixels came from without that being a decision about what to
publish, and the allowlist that decides remains the single function in
`export.rs`.

A file with no header is left with none -- an empty `{date}` and nothing
for the encoder to copy -- rather than today's date standing in for a
capture time nobody recorded.
2026-08-30 21:28:31 +02:00
dtourolleandClaude Opus 5 353382c07f Hand the photographer's place between devices
A place recorded on the tablet should be where the desktop opens.

Exchanged through `.darkroom-derived/place.json`, beside the thumbnail
shards and the catalog snapshot. Newest timestamp wins outright: unlike
the catalog this is replaced rather than merged, because two devices
cannot both be where the photographer is and so there is nothing of
theirs inside ours to preserve.

It still refuses to upload over a copy it could not read, for a smaller
version of the reason `sync_catalog` does: a record we have not compared
against may be the newer one, and overwriting it would move the other
device's photographer without ever having seen where they were.

Last in the pass, and its failures are logged rather than reported.
Everything else in that folder is *derived* -- a faster way to learn what
the device could work out for itself -- so losing it costs time. A place
is a fact only the other device knew, and losing it costs a scroll. A
sync that ran out of connectivity should spend what it had on the shards.

The full pass runs after a thumbnail sweep or when Sync is pressed,
neither of which happens on an ordinary launch -- so a handover would
arrive one launch late, which is one too many for a feature whose whole
claim is picking up where you stopped. `spawn_place_fetch` is the small
half: one GET of a few hundred bytes, started beside the scan.

And it can still be refused. A handover is welcome on the way in and
unwelcome once the photographer has started: a grid that jumped
elsewhere mid-scroll because a round trip finally landed would have lost
their place to the feature meant to keep it. Any scroll, scrub, scope
change, filter or opened photograph closes the latch, and a record
arriving after that is written to disk and takes effect next launch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 20:40:22 +02:00
dtourolleandClaude Opus 5 d44bffa4a8 Remember where the photographer was
Opening the application was always a fresh arrival at the beginning of
the library, whatever you had been doing when you closed it.

What is written down is the view, the scope, the rating filter and the
photograph on screen -- the open one in develop, the first visible one in
the grid. Not just a scroll position: a position without the filter that
produced it names a row of a list that no longer exists. Restoring them
has an order for the same reason -- scope, then filter, then position,
then the view -- because each step changes what an ordinal *means*.

Addressed by remote path and collection UUID, never by an ordinal or a
row id. `images.id` and `collections.id` are local to one catalog, and a
grid ordinal is local to one ordering; a record naming either would land
somewhere arbitrary on a second device and after any filter change on
this one. Where the ordinal is needed, `library::ordinal_of_path`
computes it through the grid's own `ORDER BY`, taken verbatim by a window
function rather than spelled a second time as an inequality -- which is
the mistake `grid_order_for` already warns about, and which a manually
ordered collection would make unreadable.

Every failure degrades rather than reports. A collection this device has
not merged leaves the scope at the whole library; a photograph that has
since been deleted falls back to when it was taken, which puts the grid
in the right week; a torn file yields no place and the library opens at
the top. Reopening develop is the one thing that requires an exact match,
because a canvas on a path that no longer resolves is a filename over an
empty frame.

The record lives in `dr-types` beside `Settings` and the store lives here
beside `SettingsStore`, for the reason `dr-types`' manifest gives: a JSON
serialiser in `core/` would be paid for by every crate there. Two files
and two lifetimes, though -- resetting preferences must not forget where
you were.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 20:39:50 +02:00
dtourolleandClaude Opus 5 8bf5e13faf Centre the photo roll on the frame it opens with
The roll brought the open photograph into view by the shortest move,
which is right for stepping along it and wrong for the first look: a
frame near either end of the loaded window arrived hard against an edge,
with nothing on that side to give it any context.

It now centres on the first settle of a develop session and steps
minimally after that. A one-shot request that the strip itself clears --
the only thing that knows the request has been honoured is the code
honouring it -- rather than something recomputed on creation, because the
strip is created far more often than a session begins: leaving develop
for Settings and coming back rebuilds it, and re-centring then would undo
a roll the user had scrolled by hand.

Raised on the two ways into develop from the grid, and not on a pick
along the roll, which is a step within a session rather than the start of
one.

Centring is clamped to the ends: the third photograph of a window cannot
be centred without scrolling empty space in beside it, and a strip that
begins with a gap reads as broken rather than as centred.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 20:35:55 +02:00
dtourolleandClaude Opus 5 0ff1e01ec3 Come back from develop on the photograph you were editing
Leaving develop returned to where the *grid* was, which after a walk
along the photo roll can be a thousand rows from the frame you had just
finished. So the one photograph you were certainly interested in was the
one the grid came back without.

Two positions, and the rule is not to pick one of them. The grid seeks to
the remembered position, then reveals the keyboard cursor -- which is now
put on the open photograph, and which moves the viewport as little as
will bring its row into view. A frame inside the remembered screenful
moves nothing at all; one outside it scrolls exactly far enough. One
rule, both behaviours.

The cursor rather than the selection, deliberately: `place_cursor` also
rewrites the selection, and a set of forty photographs assembled in the
grid must survive having one of them opened.

`reveal()` now also runs on the grid's `init`, since `cursor-row` is
initialised rather than changed when the subtree is rebuilt and no
handler would otherwise fire. Both it and the roll's centring defer while
the element has no height yet -- `init` runs before layout, where a
height of zero makes every row look off screen -- and a latch brings the
first real height back to the cursor without letting every later resize
haul the viewport around.

The capture-time marker follows the same move, for the same reason:
`load_window` rebuilds the axis only when the scope, the filter or the
total has changed, and none of them has. It is the same library seen from
a different row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 20:35:23 +02:00
dtourolleandClaude Opus 5 53dc2d171e Say where the grid is on the capture-time axis from the first frame
The sidebar's marker rested greyed at mid-track until the first scroll or
scrub. The reasoning was that anchoring it would imply a choice the user
had not made -- but that reads the marker as reporting an intention, and
it does not. The sidebar's whole claim is to say *when* you are, and that
is known from the first frame: the grid is at the top of the library, or
wherever it was last left.

So a launch opened with the marker halfway down an axis whose visible
photographs were all from the wrong end of it. Dimmed rather than absent,
which made it look like a reading rather than the absence of one.

Seeded in `refresh_timeline` -- the one place that decides what the
marker says, and the one that runs on every route which builds the axis
-- and only when nothing has claimed it, so a scroll or a scrub still
speaks for itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 20:34:11 +02:00
dtourolleandClaude Opus 5 eed27eb36d Keep the grid's place when another screen covers it
Opening Settings, Import or People and coming back landed at the top of
the library however deep in it you had been.

The grid is gated on an `if` in the markup, so every route away from it
destroys the subtree and rebuilds it. A Flickable being destroyed passes
its viewport through zero on the way out, and that reaches
`on_library_scrolled` looking exactly like the user having flung the grid
to the top. The handler already guarded against it -- but on
`show-library`, which means "the library rather than develop" and stays
true while any of those four screens replaces the window. So the guard
covered the develop route and none of the other three: `resume_at` was
overwritten with 0 on the way out, and the position was gone before
anything could restore it.

The condition the `if` is actually spelled with is now computed once, in
`app.slint`, and Rust reads that. The two cannot drift apart again
because there is only one of them.

That fixes the overwrite. The second half is that nothing replayed the
position on the way back in: `on_back_to_library` does it by hand, and
Settings, Import, People and the launch screen do not go through it.
Rather than teaching three more modules to call it, `scroll-to` is now
kept current on every scroll. It is read by `seek()`, which runs on a
token change and on `init`, so writing it without bumping the token
cannot move the grid on screen -- and is exactly what the next grid reads
when it is built.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 20:33:34 +02:00
dtourolleandClaude Opus 5 4dc954f01a Take the photo roll's grab band off the buttons that end a mode
Benchmarks / CPU and I/O (per commit) (push) Successful in 3m53s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 58s
Build and test / Layer separation (push) Successful in 45s
🐳 Android image / Build and push (push) Successful in 4s
Build and test / android-image (push) Successful in 4s
Traceability / Requirement traces (push) Failing after 1m0s
Build and test / Android (aarch64) (push) Failing after 31s
"Done Cropping", "Done Repairing", "Done Masking" and "Fit" float over
the foot of the canvas. So does the photo roll's swipe handler, and a
gesture handler is not a layout box — it is an input surface. A press
inside one is delayed, then offered to that handler's own children and
to nothing else: `input_event_filter_before_children` returns
`DelayForwarding`, which aborts the hit-test traversal outright, and the
replay afterwards visits only the handler's subtree. Everything behind
it is never asked, hover included.

The band was `strip-height + reach` — 136px along the bottom — whether
the roll was out or away. So the button that ends a mode was drawn, was
lit, and did nothing for as long as a library was open, which is the
whole time anybody is developing from one. The tool rail kept working
because it is a sibling of the canvas rather than behind the roll, which
is exactly why this looked like two dead buttons rather than a dead
region.

The band now goes where the roll goes. The handler carries the strip
instead of standing still while the strip animates inside it: closed,
only `reach` is on screen and the rest hangs below the window where
nothing can press it; open, it still covers the thumbnails, which is
what lets a swipe down anywhere across them put the roll away. The
180ms travel moved from the strip onto the handler, so the drawn
positions in both states are what they were.

The controls are then positioned against that band rather than against
the bottom of the canvas, and ride up with the strip when it comes out.
Reordering them in front of the roll would have been the other fix, and
it is the wrong one — the band would become the thing that cannot be
reached, and a gesture nobody can start is worse than a button with a
second way out.

`roll-strip` and `roll-reach` are tokens now, because two files have to
agree on where that band is for either of them to keep out of it.

The bottom of the photograph comes back with it: the crop's lower
handles and a repair placed near the bottom edge were inside the same
136px and had the same fault.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 19:42:17 +02:00
dtourolleandClaude Opus 5 3d248cfb79 Export the photograph, not the canvas
Zooming the develop view changed the exported file. `Framing::view` is
kept out of the sidecar, out of `is_active` and out of `output_size`
precisely so that it cannot — but those exclusions keep it out of the
*edit*, and an export is a *render*. `visible_rect` deliberately folds
the view into the single rect the fused shader's prologue samples, so
`render_for_export` inherited it: at 4:1 it wrote the middle of the
frame, magnified to fill the file at the full output size, with the
detail kernels scaled four times over because `render_scale` folds the
view in as well. `render_thumbnail` did the same to the grid.

`render_uncropped` already suspends the view for this exact reason, so
the fix is its pattern: one `render_the_file` that both file-producing
paths go through, composing inside the suspension since the view
reaches the shader as a uniform baked at composition time. Restored
whatever happens — leaving the graph un-zoomed after a failed export
would throw away where the photographer was looking.

Nothing caught it because the guard checked the wrong things.
`zooming_does_not_change_the_exported_image` asserted the output size
and the crop; both held perfectly throughout. Renamed to
`zooming_does_not_change_the_size_or_the_crop`, which is what it
tests, and the pixels are now guarded where pixels exist. The new test
uses a ramp rather than quadrants deliberately: a four-quadrant frame
is self-similar under a centred zoom, and the first version of this
test passed against the bug because of it.

Traces FR-EXP-9, which asks for the full-quality pipeline "regardless
of what the display was showing".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 19:41:30 +02:00
dtourolleandClaude Opus 5 a1e361e35a Measure the native path against the one it replaces
Everything argued for this change so far was read out of a catalog
after the fact: crop_px across 18,671 faces the old code had already
stored. That is evidence about what the previous implementation did. It
is not evidence that the new one does better, and the difference matters
because a landmark left in the detector's coordinates, or a box filter
with an off-by-one in its source span, would both produce faces that
look entirely plausible until somebody counted the pixels behind them.

So examples/face_native.rs renders one file and indexes it twice, native
and from a 1024 proxy, changing nothing else. Fourteen originals from
the reference library, 5472x3648 CR2 and DNG:

    native      9 faces, mean crop 287px
    1024 proxy  5 faces, mean crop  75px

Crops 3.8x larger, and across the line that decides whether the crop is
photographed or interpolated: 75px is below ALIGNED_EDGE, so the proxy
path was upsampling into the embedder on average where this one
downsamples into it. Fourteen images and nine faces is enough to show a
direction and to catch a wrong scaling; §7b says so rather than quoting
the ratio as a library-wide figure.

It also corrects something §7b asserted two commits ago. I wrote that
detector input resolution cannot affect recall, because §4.1 letterboxes
everything to 640. Native found nine faces to the proxy's five,
including four on files where the proxy found none, so it plainly can.
The two paths differ in their resampling as well as their size, and this
experiment does not separate those, so §7b now records the result as
evidence for the double-resampling hypothesis rather than as its proof.
M4 still owns settling it.

The audit-summary test went stale when the ready/to-fetch split was
collapsed and is updated to assert the single number, including that the
old wording is gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 19:41:30 +02:00
dtourolleandClaude Opus 5 4af3b93dfa Index faces from the native render, not from a preview of it
Implements the FR-CULL-8 written two commits ago. The sweep fetched the
JPEG preview embedded in each RAW and used that one buffer for both
detection and the crop; it now fetches the original, renders it through
the same path export uses, reduces that for the detector, and warps the
crop back out of the native frame.

Three pieces, and each exists for a reason worth stating.

dr_face::Pixels lets the warp sample 8-bit RGBA directly. A 24 MP native
frame is 96 MB as RGBA and 288 MB converted to the f32 RGB align.rs was
written against, and the warp reads about forty thousand pixels out of
it. Converting the whole frame to sample 0.2% of it is NFR-RES-2's
budget spent on a copy, per image, for a whole library. The variant
costs one branch per sample and a test asserts both layouts produce
identical crops.

The detector gets a box-filtered reduction to 1600px, not the native
frame and not a point-sampled one. Averaging rather than sampling
because the detector's job is finding small faces and decimation is
precisely the operation that removes them: at 4x, fifteen of every
sixteen pixels are discarded and a 40px face survives or not depending
on where it falls relative to the sample grid. 1600 rather than 640
leaves the letterbox a mild 2.5x rather than a 9x, and bounds the f32
buffer at 20 MB.

Landmarks come back in the reduction's coordinates and are scaled to
native in one place before any crop pixel is read. This is the failure
mode that would not announce itself -- unscaled landmarks put every crop
near the top-left corner, which yields faces of something else, cleanly
embedded and confidently clustered.

The sweep fetches SWEEP_LANES-wide and renders sequentially. Not a
placeholder for a parallel version: there is one GPU, so concurrent
renders queue on it regardless, and each materialises a native frame.
Overlapping them would multiply the one allocation that threatens the
memory budget while buying parallelism that does not exist. The chunk
drops from 96 to 6 for the same reason -- 96 held 8 MB previews, this
holds whole RAWs.

The stored edit is deliberately not applied, which is where this departs
from export::render_from_library. Face geometry is normalised to the
frame, so indexing a cropped render would record boxes against a frame
that changes whenever the user changes their mind, and every stored box
would quietly become wrong. Orientation is applied: that is a fact about
the file rather than an edit.

examples/face_native.rs renders one file and indexes it both ways, so
the claim behind all of this can be checked against photographs rather
than re-read out of the catalog it came from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 19:41:30 +02:00
dtourolleandClaude Opus 5 9ddc1273c0 Make a sweep that fails everything say so
A run over 169 images failed all 169, in fourteen seconds, and reported
"0 face(s) in 0 image(s)" -- the same sentence a run that indexed
nothing because there was nothing to index produces. Three separate
places dropped the information on its way to the screen.

The progress count only moved on success. FaceSweepMessage had no
failure variant at all, so a pass where every image failed sat at 0/169
from the first tick to the last: the receiver was told the total, told
nothing, and told the pass had ended. That is indistinguishable from a
hung job, and it is what it was taken for.

Finished already carried a failed count and identity_ui matched it with
`Finished { .. }`, throwing the number away and printing the tidy
success line regardless.

And the reason each image failed was logged at debug, which is off, so
169 consecutive failures left no trace of why anywhere.

Failed { images } now carries the count back per lane batch, the
progress counter advances on it, and both the running status line and
the finishing activity row say how many could not be read. A batch
rather than one message per image because failures come back lane-sized
and the useful number is how many.

Also renames the store sweep's guard to MIN_CROP_EDGE with the rest of
that constant's move, since the two touch the same lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 19:41:20 +02:00
dtourolleandClaude Opus 5 e7b526c550 Specify face indexing at native resolution, and say what the proxy cost
FR-CULL-8 said detection runs against the thumbnail or proxy tier and
never a full decode, and faces.md §5 said the aligned crop is sampled
from that same proxy. Both are wrong in the same place: they treat
detection and cropping as one resolution problem when they are two, with
opposite answers.

Detection does not care. §4.1 fixes the graph's input at 640x640 and
letterboxes whatever arrives, so a face filling 2% of the frame reaches
the model at 12px whether the buffer handed over is 1024px or 6000px.
Every pixel above the detector's own input is discarded before inference.

The crop cares about nothing else. §5's warp produces the fixed 112x112
ArcFace sees, so source resolution converts directly into whether those
112 pixels were photographed or interpolated. Reading crop_px across the
18,671 faces the proxy-tier implementation stored: 47.3% were upsampled
to reach the embedder, 314 of them by more than 2x, the smallest from 34
source pixels. An upsampled crop does not fail loudly -- it yields a
confident embedding of detail that was never there, and the damage
appears three stages later as clusters that will not separate.

So FR-CULL-8 now specifies four stages with the resolutions named
separately: render native through FR-EXP-9's pipeline, downscale for the
detector, map boxes and landmarks back to native, crop and align from
the native render. The affordability the old rule bought is met instead
by when the pass runs -- background, preempted, resumable -- and the
requirement says plainly what it now costs on a remote library: the
original rather than FR-NC-3's byte range, 412 GB across the reference
library's 19,107 images, so a whole-library pass is a transfer under
FR-NC-6 rather than something that may start on its own.

MIN_CROP_EDGE replaces the MIN_DETECT_EDGE this branch briefly had. Same
number, guarding the quantity that turned out to matter.

faces.md §7b records both measurements, and marks the second as
unexplained rather than dressing it as a finding. Grouped by the buffer
detection ran against, faces per image was 0.078 at 1024 or below and
1.82 at 2048 or better, controlled for file type and size. That gap is
real and reproducible and I cannot account for it, because the letterbox
above says detector input should not matter. M4 is where it gets
settled. The crop measurement does not depend on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 19:41:20 +02:00
dtourolleandClaude Opus 5 144d2e4e84 Revert the detection floor: it guards the wrong resolution
Reverts 53f7cdf and e92d22d. The floor those added sat on
Detector::detect, refusing any buffer under 1025px on the reasoning that
a small buffer finds no faces. That reasoning does not survive §4.1:
the detector letterboxes every input to 640x640, so a face occupying 2%
of the frame presents at 12px to the model whether it is handed a 1024px
buffer or a 6000px one. Detector input is precisely the quantity that
does not matter.

Worse than merely useless, it blocks the design FR-CULL-8 now specifies,
where the detector is deliberately fed a downscale and the crop is taken
from the native render. A guard on detect() rejects exactly that call.

What the measurement actually supports is a floor on the *crop* source,
which is where resolution converts into embedding quality, and which
faces.crop_px already records: 47% of the reference library's faces were
upsampled to reach 112x112. That floor is a separate change against the
native-resolution path and does not belong on the detector.

The 23x faces-per-image gap by source_edge that motivated the original
commit is kept in faces.md §7b, restated as the unexplained observation
it is rather than the causal claim it was written as. V12 stands: those
runs cropped at 1024 whatever detection did, and that is reason enough
to look at them again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 19:41:12 +02:00
dtourolleandClaude Opus 5 41c655c176 Stop the local-store pass pretending it can still index
spawn_store_face_sweep detects on what the thumbnail store holds, and
the store's largest tier is FACE_TIER -- 1024, which the floor now
refuses. Left alone it would list every outstanding image, decode every
proxy it had, and report every single one as failed: twenty thousand
refusals all saying the same thing, with the real explanation buried at
debug level.

There is no repair available inside this pass. It has no larger tier to
read; the pixels the detector needs have to come off the server, which
is spawn_face_sweep's job and always was. So the honest behaviour is to
check the tier against the floor once, say plainly which pass to use
instead, and stop. It is only reached from examples/face_index.rs, so
this costs the tool its --run mode and no shipped behaviour.

FACE_TIER keeps its value and loses its meaning. It is now the tier a
stored crop is *cut from*, which 1024 is entirely adequate for -- the
face has already been located and the crop only has to be looked at --
and no longer the tier faces are *found* on, which is the thing that was
returning 0.078 faces per image.

IndexAudit's ready/awaiting_proxy split goes the same way. It existed
because one of the two passes could only do images that already had a
proxy; now that detection refuses that proxy's size, both halves cost
the same fetch, and a status line reading "169 ready to index" implies a
distinction that no longer decides anything. One number.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 19:41:04 +02:00
dtourolleandClaude Opus 5 d0ebc9f571 Count the same images in the progress figure that the sweeps index
The Identity screen said 4,593 images were left to index and stayed
there for hours across repeated runs, which is what a stuck job looks
like. It was not stuck. 4,424 of those 4,593 are shadowed -- the JPEG
half of a RAW+JPEG pair -- and no sweep will ever index one, because
every work list is built on VISIBLE, which excludes them. They are not
separate photographs and the grid does not show them either.

But faces::coverage counted them: its denominator was "images WHERE
trashed_at IS NULL", with no shadowed_by clause. So the outstanding
figure had a floor of 4,424 that no amount of work could bring down, and
Coverage::is_complete could never once return true no matter how
completely the library had been indexed. A progress number that cannot
reach its own target is worse than no progress number.

The fix is to count the population the sweeps actually draw from, in all
three places that were describing it differently: coverage's denominator
and its indexed join, and audit's split of the outstanding set, which
had the same gap and fed the same status line.

On the reference library the denominator goes from 23,531 to 19,107 and
outstanding from 4,593 to 169 -- the second of which is a number the
user can watch go down, and which turns out to be a real and separate
fetch failure worth chasing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 19:41:04 +02:00
dtourolleandClaude Opus 5 6783d0c723 Settle the images whose best preview is under the floor
The floor introduces a state the sweep had no arm for. An image whose
largest embedded preview is genuinely smaller than 1025px now comes back
from index_preview as an error, lands in the generic Err arm, and is
counted as failed -- which means no face_index row, which means it is
still outstanding, which means the next sweep fetches exactly the same
bytes and refuses them again. For ever, on every run, at one range
request each. The previous behaviour was wrong but at least terminated;
this would not.

So ProxyTooSmall gets its own arm, and it records a marker at the true
edge rather than nothing. That is the difference between "we looked and
found nothing" -- which would be a lie, since nothing was looked at --
and "this was examined at 900px, which is the best this file has". The
first is unrecoverable; the second is a fact source_edge was added to
carry, and a later floor or a bigger proxy can select on it deliberately
the way V12 just did.

Counted separately from failures all the way up, because they are not
failures and reading them as such would misdescribe a library of small
scans as a broken network. The summary line says how many and why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 19:41:04 +02:00
dtourolleandClaude Opus 5 0a6509de93 Forget the face runs made on proxies too small to see a face
The floor added in the previous commit stops this happening again; it
does nothing about the 1,824 images in the reference library that
already carry a face_index row written against a proxy of 1024 or less.
Those rows are why the damage is permanent rather than merely past. The
work list is "images with no row for this model", so an image examined
against a 1024px proxy -- 0.078 faces per image, nine in ten finding
nothing -- is indistinguishable from one examined properly, and no
later pass will ever offer it to the detector again.

V12 deletes exactly those markers, and nothing else. The faces those
runs did find stay in place and keep drawing the People screen until a
better pass replaces them, and record_detections re-attaches the user's
confirmed names across that replacement by box overlap, so a library
somebody has spent an evening naming does not lose that evening. The
cost is a re-fetch of the affected images.

Deleting the marker rather than teaching the work-list query to select
on source_edge, which was the other option and is worse. A standing
`source_edge < floor` predicate never lets go: an image whose largest
embedded preview is genuinely smaller than the floor would be re-fetched
on every sweep for ever, because the next pass cannot do any better than
the last one did. A one-off deletion gives each affected image exactly
one more attempt through the good path and then lets the ordinary
"has a row" rule settle it.

The threshold is written out in the SQL instead of referring to
dr_face::MIN_DETECT_EDGE. A migration has to keep meaning what it meant
when it ran; binding it to a constant someone may raise later would
quietly change what an old catalog gets migrated to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 19:40:26 +02:00
dtourolleandClaude Opus 5 1d800d56b0 Refuse to detect faces on a proxy too small to find one
Detection was run on whatever proxy the caller happened to have. A
small one does not fail -- the image is letterboxed into the detector's
640px input at any size -- so it comes back with almost nothing, and the
caller then writes a face_index row saying the photograph was examined.
That row is the damage. Nothing distinguishes it from "examined
properly, no faces in this one", so the image is never looked at again.

The measurement, on the reference library of 23,531 images. Runs against
a 1024-edge proxy: 0.078 faces per image, 90% of them finding nothing at
all. Runs against 2048 or better: 1.82. To rule out the obvious
objection that small proxies just come from small photographs, the same
comparison restricted to DNGs -- 1,592 of them averaging 21 MB against
7,724 averaging 23 MB, so the same kind of file in the same library --
gives 0.078 against 1.82 again. Twenty-three fold, on identical source
material, identical weights, identical options.

So the floor goes in the detector rather than in either sweep, because
both of them, the example tool and any future job handler are equally
entitled to get this wrong, and there is one place that sees every
attempt.

It is 1025, not 1024, and the odd-looking number is the point: 1024 is
exactly ThumbSize::Large, the tier proxies are stored at and the tier
one of the two sweeps was detecting on. A floor that admitted 1024
would admit precisely the population this exists to exclude. Written as
a minimum rather than a maximum so the test at each call site is
`edge < MIN_DETECT_EDGE` with no boundary left to get wrong.

ProxyTooSmall is its own error variant rather than an empty result
because the caller has to tell it apart from a failure: nothing is
wrong with the image or the model, and the answer is to go and find
better pixels, not to retry these ones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 19:40:12 +02:00
dtourolleandClaude Opus 5 7981718d83 Time the phases of a launch, because the tablet has no profiler
Benchmarks / CPU and I/O (per commit) (push) Successful in 3m8s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 1h19m46s
Build and test / Layer separation (push) Successful in 47s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Traceability / Requirement traces (push) Failing after 46s
Build and test / Android (aarch64) (push) Successful in 1h1m21s
Two things are now off the launch path and the rest of it is unmeasured.
There is no way to attach a profiler to an Android launch, and the
window that matters — from `android_main` to the first `poll_events` —
is over before anything on the device can be asked a question about it.
So a line in the log file is the only measurement anybody gets.

Three of them: the GPU open, the window build, and the total to the
event loop. The last is the one that matters, because it is the figure
the input dispatcher is counting against — anything approaching five
seconds there is the next ANR whatever the phases above it say.

The GPU open is timed rather than moved. It is a Vulkan instance, an
adapter enumeration and a device request, and on the desktop it cannot
be deferred at all: it selects the Slint backend, and creating a window
selects one for us. On Android it could be, because nothing shares that
device with the compositor (TD-1) — but "could be deferred" is not
"costs enough to be worth deferring", and there is no number yet that
says which. This is the line that will produce one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 18:41:34 +02:00
dtourolleandClaude Opus 5 d48e9f6033 Open the catalog on a worker, so a launch is not a page-by-page read
The second thing standing between `android_main` and the first
`poll_events`, and the one that grows with the library rather than with
the APK.

`library_ui::open` called `show_catalog_now`, which called
`Catalog::open_verified`. That runs `PRAGMA quick_check`, which reads
every page of the database, and then `Catalog::open`, which takes a full
SQLite backup of the file before a migration and rewrites its structure
afterwards. On a 50,000-image library that is tens of megabytes of I/O
on a tablet's flash, and it happened before the window had painted
anything — so on Android it was counted against the five seconds the
input dispatcher allows, and on the desktop it was a launch that sat on
a blank window.

`dr_catalog::recovery`'s own module documentation says the check is
affordable "at startup, where a failure has a user in front of it who
can answer a question". That was the intent and it was not true: there
was no interface yet in which to ask. Now there is, because the open
happens on a worker and the answer arrives on a channel drained by a
timer — the same shape the scan, the thumbnails and the login already
use.

The gate the synchronous call provided is kept, and is the reason the
scan moved with it. `Catalog::open` succeeds on a damaged file whose
header survived, so a scan running beside an unanswered recovery
question writes ETags and image rows into damaged pages and turns a
catalog that had a backup into one where the backup is the only copy
left. So the scan now starts from the drain, on the two answers that
permit it, and not at all on `Corrupt`. `library-scanning` stays true
throughout, which hides the Rescan button and stops the gate being
merely advisory.

What the user sees while it runs is a third empty state. The grid
already refused to conflate "still scanning" with "scanned, found
nothing"; "opening the library" is a third answer and it gets its own
sentence, because a grid saying "Scanning…" while nothing is on the
network is the same kind of lie the other two were separated to avoid.

`show_catalog_now` stays, unchanged and blocking, for `recovery_ui`.
That call site has the event loop running, has just replaced the file
under a `forget_catalog`, and has `recovery-busy` on screen — the same
reasoning `recovery_ui::answer` already gives for doing its file copy in
place. The part both paths share is now `adopt_catalog`.

One consequence worth naming: the cache-usage figure on the settings
page was read at startup from a catalog that is no longer open by then.
It moves to the page's `on_open` closure, beside the face coverage,
which is read there for exactly the same reason — it is only ever looked
at while that page is on screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 18:41:34 +02:00
dtourolleandClaude Opus 5 1c5849ebe8 Unpack the bundled models on a worker, not on the way to the first frame
Launching v0.10.0 on the tablet produces an ANR: "Waited 5000ms for
MotionEvent", 5,827 ms on one input sequence, over a window that has
never painted. The app recovers and sits at 0% afterwards, so it is a
startup cost rather than a hang.

The structural fact behind it is that `android_main` runs with the
activity's input channel unserviced. Nothing drains it until Slint
reaches `poll_events`, and Slint does not reach `poll_events` until
`dr_ui::run` calls `window.run()` on its last line. Every millisecond
before that is a millisecond the input dispatcher waits on, so five
thousand of them is an ANR whatever the work happens to be.

The largest single piece of that work was here. `install_bundled_models`
copies 41 MB on the first launch after an install — 24.9 MB of scene
model, 13.6 MB of embedder, 2.5 MB of detector — each read whole out of
the APK into a `Vec` and written to `/data`, in a loop, on that thread.
v0.10.0 is the release that added the scene model, which is 60% of that
total, and it is the release the ANR appeared in. The 8,010 minor faults
in the report are about what 41 MB of freshly touched pages costs.

So it moves to a detached thread and the function returns as soon as the
thread is running. Nothing on the launch path wanted the result: the
only two things that read these files are the People screen and the
scene tab, both of which are reached by hand, minutes later, from
workers of their own.

What that costs is a window in which a model looks absent.
`library::face_models` and `library::scene_model` decide availability on
`is_file()`, so during the copy both report their feature unavailable —
which is the same answer they give a build shipping no weights at all,
the ordinary case both were written around. Briefly pessimistic rather
than wrong, and the temporary-name-then-rename that was already there is
what keeps it from being worse than that: a lookup never sees a
half-written file, only an absent one. Both call sites now say so.

A completion line reports the bytes copied and the milliseconds taken,
including when it is zero, so the second launch after an install can be
told from the first in a log rather than by inference.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 18:41:34 +02:00
dtourolleandClaude Opus 5 4efab496c2 Put the refinement on a slider, per layer
Benchmarks / CPU and I/O (per commit) (push) Successful in 3m27s
Benchmarks / Frame budget (on demand) (push) Skipped
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Build and test / Desktop (Linux) (push) Failing after 1h14m30s
Build and test / Layer separation (push) Successful in 43s
Traceability / Requirement traces (push) Failing after 46s
Build and test / Android (aarch64) (push) Successful in 1h8m56s
The evidence was being gathered and spent immediately at one strictness
nobody could see or change. This makes it a control.

`MaskLayer::refine` is shaping, like the feather, and it lives on the layer
for the layer's reason: two layers may sit on the same category and want
different amounts of it, and the model ran once for both.

## What the segmentation now stores

`CategorySummary` keeps the **coarse** mask and the `Refinement` beside it,
rather than a refined mask. That is what gives the control an off position
that is bit-for-bit the model's own weighting, and what stops a strictness
change from needing the model again.

`category_mask_at` borrows at zero and wherever no refinement could be
fitted, so a layer nobody has touched costs nothing over the old path.

The fit moved to the far side of the orientation permutation. The verdict is
a per-pixel field over the same grid as the mask it gates, so fitting it
upright would mean permuting a proxy-sized buffer afterwards to match — a
second rotation, and a second chance to get one wrong. One logit cell is the
same number of pixels either way: the letterbox scales by the longer edge and
a permutation does not change which edge that is.

The two frames became a `Frames` struct rather than six parameters. This
function reads the picture twice for opposite purposes — the model needs it
upright or it recognises far less, the refinement needs the sensor's grid —
and a transposed pair produces a plausible mask over slightly the wrong
pixels, which is the failure this module is most prone to.

## It rebuilds the field, and the signature says so

`refine` is mixed into `subject_signature`. Unlike a feather, which is read
off a field that is already correct, this changes which pixels are in the
mask at all — so it changes the coverage the field is measured from. Omitting
it is the bug where the slider moves and nothing happens until some unrelated
control invalidates the cache.

That puts it in the same cost class as a close or an open, which is why the
row takes `SliderRow::changed` — already once-per-gesture, since that row
takes `SliderTrack`'s `committed` internally — rather than a live stream.

The slider is offered only where there is something to move: a category
source, *and* a refinement the frame actually gave enough to fit. A control
that moves and does nothing is worse than an absent one.

## Two defaults that are deliberately different

A layer added from the panel starts at 4.0, because a category's edges are
twenty proxy pixels wide before anything is done to them and a photographer
adding a sky mask wants the sky rather than the sky plus every chimney in it.

A layer read from a sidecar with no `refine` key starts at **zero**. A file
written before this control existed has to render as it did then, and a
default of 4 on absence would quietly re-grade every stored category mask in
the catalogue. `a_categorys_refine_strictness_survives_and_defaults_off`
holds both halves, and `an_out_of_range_refine_is_clamped` holds the file to
the scale — past the top of it every colour fails and the mask deletes
itself, which reads as lost work rather than as a bad file.

`MAX_REFINE` is dr-pipeline's own constant mirroring
`dr_segment::STRICTNESS_MAX`, following `Falloff` and `Morphology`: this
crate holds the description of an edit and must not depend on the crate that
runs a model. dr-ui is where the two meet, and the only place that converts.

Verified: fmt clean, clippy --workspace -D warnings clean, 488 dr-pipeline
and 60 dr-segment tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 18:30:40 +02:00
dtourolleandClaude Opus 5 f87bf6ebc0 Charge a colour mode for its rarity, and keep the verdict
The refinement worked and could not be controlled. Pruning modes below a
share threshold made the flag's removal a *discrete* event: below the line
its Mahalanobis distance was enormous and nothing rescued it, above the line
it sat at zero and nothing removed it. A control over that would appear dead
through most of its travel and then start eating sky.

So the prune is gone. A mode is charged `−ln(share × k)` nats, floored at
zero, and that cost enters both tests — doubled in the chi-square, which is a
squared distance, and directly in the log density. Rarity becomes a distance
rather than a threshold, and the things a photographer wants to remove
separate along it.

Measured on the synthetic frame the tests build: a flag holding 1.6% of the
sky is more than half gone by **2.95 nats** and a cloud bank holding a third
of it survives to **5.75**. The whole interval between them is somewhere a
control can sit. `the_flag_goes_before_the_cloud_does` pins the ordering,
which is the property that makes one slider worth offering at all.

Measured against an even split rather than against one, so raising
`clusters` describes a category more finely without making every colour in it
look rarer. Floored at zero so a dominant mode earns no *discount* — a
bonus there would let the commonest colour outvote a bad chi-square, which is
the one direction this must not bend.

`Refinement` holds the per-pixel verdict, quantised to a byte over ±16 nats —
an eighth of a nat per step, far finer than the narrowest transition the gate
can be asked for, and the same size as the coverage buffer it sits beside.
`apply` is then a smoothstep, and the model is never consulted again.

That is `distance.rs`'s arrangement deliberately: there a signed distance
field is computed once and feather, grow and shrink become arithmetic on it,
"which is what makes those live controls rather than ones that stall on every
drag". Same shape, different field.

The blur moved with it, from the gate to the verdict. Smoothing the evidence
rather than the decision means it is paid for once in `compute` instead of on
every frame of a drag, and it is the better thing to smooth in any case.

`apply` at `STRICTNESS_OFF` returns the weights untouched without reading the
verdict at all. A control whose off position is *very nearly* the unrefined
mask cannot answer "is this helping"; one whose off position is the unrefined
mask can. `strictness_zero_changes_nothing` holds it to that, and
`strictness_is_monotonic` holds the rest of the travel to only ever removing
more — a slider that gave weight back partway up would be one whose direction
nobody could predict.

The synthetic sky is smooth enough to sit on `VARIANCE_FLOOR`, where a real
one has noise and therefore a real spread, which moves every crossing down
together. The ordering survives that; the placement is a calibration. Which
is the honest argument for a control rather than a constant, and why the
default sits at half scale instead of at the flag's measured crossing.

The example sweeps the whole range and writes a frame per nat, because the
question a photographer asks of a slider is where to put it, and that needs
the travel rather than a point on it.

Verified: fmt clean, clippy -D warnings clean, 60 dr-segment tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 18:30:40 +02:00
dtourolleandClaude Opus 5 4f4abd335f Cut a scene category back to the pixels that agree with it
A flag in the sky came out weighted as sky, and no feather setting fixed it.

The scene model's logits are `[1, 150, 80, 80]`, so one cell is eight input
pixels; at the 1600px proxy the letterbox scale is 0.4 and **one cell is 20
proxy pixels**, which `rasterise`'s bilinear then spreads across one more
either side. A flag is a handful of cells whose softmax is dominated by the
sky around it. The information was never in the grid, so nothing downstream
of the grid can recover it.

Tiling is the answer for an instance and is not available here: a category
has no bounding box to tile over — sky is wherever the sky is. But the
photograph is at full proxy resolution even though the weights are not, and
it knows exactly where the flag is. So the model says *what*, and the
pixels say *which of them*, which is the division of labour arm C already
draws between the instance model and the watershed.

## Seeds, and why the erosion radius is not a guess

Threshold the weights high, take `signed_distance`, and keep what is more
than 1.5 cells inside. One cell *is* the model's resolution and the bilinear
spreads it across one more, so the band either side of the boundary is smear
rather than evidence. Deriving the radius from `Scene::cell_pixels` rather
than picking a pixel count means it stays right if the proxy edge or the
export changes.

The mirror of that set is a confident *exterior*, free from the same field.

## Dropping small modes is the step that makes it work

Four k-means modes per side, not one Gaussian: sky is blue at the zenith,
white where the cloud is and pale at the horizon, and one blob over all
three rejects two of them.

Then modes holding under 3% of a side are discarded, and without that step
the whole thing fails on the case it was built for. A small flag deep in the
sky has both a high weight and a large distance from the boundary, so it
lands in the interior sample and teaches the model its own colour. It cannot
be excluded geometrically. It can be excluded by share.

Luminance is weighted at a quarter against chrominance for the same reason
the watershed's gradient is. Sky's variance is dominated by luminance, so at
equal weight the distribution is a long bright streak that a mid-grey flag
sits comfortably inside. A flag is separated by chrominance; a cloud is
separated by luminance alone. Not zero, or a dark bird against a bright sky
survives.

## Two tests, because either alone is wrong

Absolute — is this colour plausible under the category, as a chi-square on
the Mahalanobis distance. Comparative — is it likelier inside than outside.
A pixel must pass both.

The absolute test is what catches the flag, whose colour is far from *both*
sides and which the comparative test alone would leave at even odds. The
comparative test is what stops the absolute one needing a constant tuned per
category.

## What this cannot do, written down rather than left to be discovered

An intruder large enough to hold its own mode is kept. By share, a flag over
a fifth of the sky and a cloud bank over a fifth of the sky are the same
object, and colour does not separate them either — a white cloud is as far
from blue sky in chrominance as many intruders are.

So `min_cluster` is not a threshold with a correct value waiting to be
found; it is the trade-off itself, set where a photographic intruder falls.
Both ends are pinned by tests — `a_flag_in_the_sky_is_removed` and
`an_intruder_larger_than_min_cluster_survives` — so that moving the number
reads as moving the trade-off rather than as fixing a bug. The case left
open is a large unrecognised object in a clean category, which wants the
boundary snapped to watershed basins and is a different mechanism.

## Safe to apply without a control

It is subtractive: the output is the input times a factor in `0..=1`. The
worst failure available to it is losing part of a real sky, never gaining a
region, so a blue car below the horizon that was never in the mask cannot be
pulled into it. And a factor in `0..=1` cannot raise a sum, so `scene.rs`'s
partition still holds when every category is refined independently — the
weight taken off the flag lands in the unlisted remainder, which is where a
flag belongs, ADE20K having no class for one.

Every path without the evidence to judge returns the weights untouched and
says which path it took. A refinement that silently did nothing is
indistinguishable from the feature being off, and an empty seed set fitted
to a distribution would reject every pixel.

The signature is deliberately unchanged: categories are addressed by name,
not by index, so a sharper mask cannot create the stale-index hazard the
signature exists to guard against.

The example writes `<prefix>-<category>-refined.ppm` beside the coarse one,
never instead of it — whether this is an improvement is a comparative
judgement and one image cannot answer it.

Verified: fmt clean, clippy -D warnings clean, 57 dr-segment tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 18:30:16 +02:00
dtourolleandClaude Opus 5 8131706394 Regenerate the matrix over the merged rail work
The merge moved tagged lines in identity.rs, identity_ui.rs and the two
Slint files; the matrix tracks line numbers, so it goes stale on a move
alone. A merge commit does not run the pre-commit hook that would
normally have staged this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 18:30:16 +02:00
dtourolleandClaude Opus 5 bfdb6d5e4b Cut the People rail's portraits off the blocking path
Opening Identity cut a portrait for every person before the screen was
allowed to appear. Measured against the reference library: 3.6 seconds,
of which 3.2 is 931 full 1024px proxy decodes — the fallback for faces
indexed before crops were stored beside them. The rest of the wait was
the rail building 14,268 rows, fixed in the commit before this.

`refresh` now draws with the portraits already cached and hands the rest
to `fill_covers`, which cuts them 8ms at a time — under half a frame —
behind a screen that is already up. Blocking work on that path goes from
4,965ms to 16ms on this library.

A timer rather than a thread. The work is a catalog query and a decode
against a `ThumbStore`, and both handles live on the UI thread; a worker
would need its own connection to the same file, which is what the sweep
and the regrouping pass do because they run for minutes and would
otherwise be unbounded. This is seconds of small, independent pieces, so
slicing answers the same question more cheaply.

`pending` is in rail order, and the rail is sorted by confirmed faces, so
the portraits the user is looking at are cut first. It patches single
rows rather than reloading: a reload would rebuild the model on every
tick, and a model replaced underneath the `ListView` is what the slicing
exists to avoid. Each patch checks the row still holds the person it was
started for — a stale index would draw a face beside somebody else's
name — and stops the fill when it does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 18:30:16 +02:00
dtourolleandClaude Opus 5 d7b851f622 Build the People rail rows you can see, not the ones you cannot
`Flickable { VerticalLayout { for ... } }` instantiates every row. On the
reference library that is 14,268 row subtrees, which measures at 4.2
seconds before a pixel is drawn — and it was paid again on every action,
because every action reloads the model. Roughly half the wait between
clicking "Identity" and the screen appearing was this.

Slint's compiler has a virtualising path for a `for`; it is what makes
`std-widgets`' `ListView` cheap. It keys on the parent element's base
being *named* `ListView` and exposing the five lengths its layouting code
writes back, and a custom base is explicitly allowed. So `widgets.slint`
grows one, and `std-widgets` stays out of the file that establishes our
style. Measured on the real screen: 200,000 rail rows now render in 62ms.

Three things had to be true together, and each was silent on its own.

**The row height must be constant.** The rail hid a set-aside person with
`height: cond ? 52px : 0px`, a height that reads the model — so the
layout cannot place row N without building rows 0..N, and Slint builds
them all. The filtering moves to Rust, where the toggle was already
reloading anyway.

**The list must not be wrapped.** It carries its own stretch and preferred
size, having no natural height to offer; a Rectangle in between hands the
layout that Rectangle's constraints, which are taken from the list and
are therefore nothing.

**The panel must let it fill.** `Panel` lays its children out with
`alignment: start`, which gives each its preferred height — right for a
column of sliders, wrong for anything that scrolls. Hence `Panel.fill`.

Get any of them wrong and the rail renders empty, with no error and a
model full of people. All three were, in turn, before a headless render
of the real screen showed a blank rail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 18:30:16 +02:00
dtourolleandClaude Opus 5 f26f1ab694 Withhold the empty groups that fill the People rail
`load_people` returned every live person, and on the reference library
that was 14,268 rows of which 11,739 held no faces at all — 85 per cent
of the rail naming nobody and able to do nothing.

They are not a mystery. A regrouping pass creates a person per cluster;
the next pass moves those faces elsewhere and leaves the person it
emptied behind. `faces::prune_empty_unnamed` exists for exactly this and
runs only at the end of a pass, so nothing clears what accumulates
between them, and the Identity screen never prunes at all.

Each one cost a `for_person` query and a built row on every reload. This
withholds precisely the set the prune already treats as disposable —
empty, unnamed, not set aside — and no more.

Filtered rather than deleted: a screen is being drawn, not a catalog
repaired. Nothing is lost, a sync cannot resurrect what was never
removed, and the prune stays the one place that decides these can go.

An empty group with a *name* still shows. That one is not debris but the
symptom of a real failure — a named person whose faces were regrouped out
from under them — and hiding it would take away the only way to merge
them back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 18:30:16 +02:00
dtourolleandClaude Opus 5 e8b072c815 Say the app is starting before there is anything to say it with
Nothing from our own code reached logcat on the tablet: 284 lines from
the app's PID during a launch, every one of them from Hwaps,
BlockMonitor, InputEventReceiver or nativeloader, and not even the
version banner android_main emits three statements in.

I could not find the fault in our wiring, and this commit does not claim
to fix it. What it does is make the next launch say which side is at
fault, and close a hole that is real whatever the answer turns out to be.

What reading rules out, so nobody repeats it: install does call
log::set_max_level. The Config carries an explicit tag and an explicit
max level, and its env_filter is None, so android_logger's enabled() and
filter_matches() both pass an Info record. Tee::enabled delegates to the
console and gates nothing else. set_boxed_logger cannot have failed —
its Err path drops the Tee, taking the LogFile with it, and the file on
the device has content. And Tee::log calls console.log() unconditionally
*before* the file write, which is itself gated on console.enabled(), so
every line that reached the file proves the AndroidLogger was handed the
same record. Nothing else in the graph installs a logger; android-activity
and Slint's backend do not. The diff that introduced this changed the
level, the tag and the Config not at all — init_once and set_boxed_logger
leave the same logger installed at the same level.

That leaves below __android_log_write, which no amount of reading this
file can reach.

So: the console logger is now built first, and one line goes through it
directly, before the state directory and before the log file. Two things
follow. Everything between android_main's first statement and install
returning — external_data_path, create_dir_all and an open on a FUSE
volume the system may still be mounting — currently has no surface at
all to fail on; that window is what logcat is for, and it now has a line
in it. And when the log is silent, that line separates the two cases:
present with the log::info! lines below it missing is the facade, absent
along with them is liblog not delivering this process's records.

It goes through Log::log rather than log::info!, which is not a style
choice: the facade's maximum level is Off until install sets it, so a
log::info! there compiles and emits nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 17:31:41 +02:00
dtourolleandClaude Opus 5 c62edd3317 Give the log a mode that the way off the device can open
The log file has been on external storage since it existed, and the
reason is stated at length in two places: /data/data/<pkg>/files needs
run-as against a debuggable build, /sdcard/Android/data/<pkg>/files is a
plain adb pull from any build, and a log nobody can retrieve is not a
diagnostic. The file was then opened 0600, which cancels that decision
out. On the tablet:

    adb pull      -> remote open failed: Permission denied
    adb shell cat -> Permission denied
    run-as        -> package not debuggable

Every route off the device closed at once, on a file whose whole purpose
is to leave the device.

The mode is now per platform, because "who may read this" has two
different answers and the directory above the file is what makes them
differ. On the desktop, 0600 as before: $XDG_STATE_HOME/darkroom is in a
home directory on a machine that may have other accounts, and nothing
about that directory stops another local user reading a world-readable
file. On Android, 0644: /sdcard/Android/data is drwxrws--x
media_rw:ext_data_rw, so no other app can enter this app's subdirectory
and anyone who can traverse it is holding the unlocked tablet, which
already gets them the photographs the log merely names. What the read
bits buy is adb pull, which runs as shell — able to traverse a --x
directory, but then obliged to open the file as other.

The mode is also applied twice, and the second one is the fix rather
than belt and braces. OpenOptions::mode is a request: the kernel ANDs it
with the process umask, and an Android application process inherits
0o077 from the zygote, so asking for 0644 there creates 0600 and reports
nothing. It is ignored outright on a file that already exists, which
every launch after the first has. fchmod is subject to neither, and is
what the second call makes.

The comment claiming the mode was "ignored by the FAT-derived filesystem
Android presents as external storage" is gone with it. The device says
otherwise: the file it produced was 0600 exactly.

Two tests. One pins the literal mode per platform — only the desktop arm
can run under cargo test, and the comment says so rather than implying
the Android number is covered. The other reopens a log left behind with
the wrong mode, which is the one assertion on the host that fails if the
fchmod is deleted, since OpenOptions::mode cannot touch a file that is
already there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 17:31:10 +02:00
dtourolleandClaude Opus 5 23c3155128 Release 0.10.0
Benchmarks / CPU and I/O (per commit) (push) Successful in 11m46s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 1h15m53s
Build and test / Layer separation (push) Successful in 40s
🐳 Android image / Build and push (push) Successful in 8s
Build and test / android-image (push) Successful in 8s
Traceability / Requirement traces (push) Successful in 1m32s
Build and test / Android (aarch64) (push) Successful in 1h3m1s
Three waves of work since 0.9.0.

Culling gained the two instruments FR-CULL-3 asked for and never had:
focus peaking, and a histogram that reads the sensor rather than the
frame about to be displayed. Bursts group themselves. Masks can cover a
whole category rather than one instance.

The catalog now notices when it has been damaged and offers a way back --
restore a backup, or rebuild from the photographs, which were never at
risk. A crash leaves a record. A log survives the process, on a path a
tablet will hand back to adb, which is what makes the Android work
debuggable at all.

The APK compiles its own Java for the first time, so the app can receive
a photograph from another application and hand one back. Memory pressure
is answered in a stated order. A lost library root is reported rather
than reported as an empty library.

There is a benchmark suite now, so §8's promise that a regression fails
the build is a mechanism rather than a sentence. Its first run says the
catalog opens in 70ms against a 2s budget, and that thumbnail throughput
does not obviously reach its target.

Coverage 59.8% -> 69.8%, and it means more than it did: five requirements
that were tagged on code that did not implement them are no longer, and
the tool no longer counts its own test fixtures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 16:52:05 +02:00
dtourolleandClaude Opus 5 0845d4ee20 Regenerate the matrix over the merged branches
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 16:51:49 +02:00
dtourolleandClaude Opus 5 94b1b9e0cc Reconcile what four branches each built separately
Three seams, found by the first compile after the merge.

Two branches implemented 'can this scope be reordered' independently:
one from the collections model, one from the catalog through
orders_manually, on every re-read and excluding the trash. The second is
the better answer and is what survives; it only needed to set the
property app.slint declares.

Two lints from scene-mask-ui, which was merged mid-flight and had never
been through -D warnings: an is_none check spelled out where clippy wants
?, and a return in a cfg block's tail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 16:51:41 +02:00
dtourolle 19b56ad85c Merge: collection ordering, and a range that says where it ends
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

# Conflicts:
#	docs/traceability.md
#	ui/dr-ui/src/collections_ui.rs
#	ui/dr-ui/src/library.rs
#	ui/dr-ui/ui/app.slint
#	ui/dr-ui/ui/library.slint
#	ui/dr-ui/ui/widgets.slint
2026-08-30 16:38:08 +02:00
dtourolle 328fda6f7c Merge: mask a whole category, not just one instance
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

# Conflicts:
#	apps/darkroom-desktop/Cargo.toml
#	docs/traceability.md
2026-08-30 16:37:31 +02:00
dtourolleandClaude Opus 5 327b8c7839 Merge: one selection bar, and a header that decides what it gives up
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 16:37:14 +02:00
dtourolleandClaude Opus 5 df06240b2d Test that the category geometry means what it says
The scene tests so far checked the descriptor and the arithmetic. Neither
would have noticed if `rasterise` put the sky along the bottom of the frame,
because both build their own weights and never ask where those weights land.

Three that do:

- **Weight stays on the side it came from.** Fill the top half of the grid,
  read the top and bottom quarters of the image. A flipped y axis is the
  mistake this code is actually prone to — it is a letterbox inverse, and the
  numbers stay perfectly plausible when it is wrong.
- **No transpose.** The vertical check alone passes under a transpose, which
  maps a top band onto a left band. A horizontal split is what distinguishes
  them, and neither test is worth much without the other.
- **Coverage is a fraction.** A quarter of the cells must read 0.25. The
  scene tab hides a category below half a percent, so an error of a factor of
  the grid size would hide everything or nothing — and both look like the
  model failing rather than the arithmetic.

`Scene::from_weights` is test-only and exists because the property under test
needs weights whose correct destination is known in advance, which no real
inference can provide. It uses a square window so the letterbox is the
identity: any offset these find is the mapping's own rather than the
padding's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 14:28:38 +02:00
dtourolleandClaude Opus 5 86260b5028 Bind a category layer to its own distance field
A category mask showed nothing and its adjustment covered the whole
photograph. Both from one line: the loop in `MaskPass::rasterise` picks a
distance field by matching `layer.source`, that match named only `Subject`,
and a `Category` layer fell through to `_ => (&self.empty_subject, 0)` — a
1x1 placeholder. No field, so nothing to draw and nothing to confine the
adjustment.

The comment three lines above the arm I missed describes the failure I then
shipped:

    an absent mask that defaults to "everything" would apply the
    adjustment to the whole photograph

There are *two* matches on `layer.source` in that loop — one choosing the
field, one building the params. Adding the category to the second and not
the first compiles, runs, and is wrong in exactly the way the first one
warns about.

## Also: a missing mask must still be the right size

Both model-backed arms of `ensure_subject_fields` used `unwrap_or_default`,
which yields an empty `Vec` when the coverage is gone. `SubjectMasks::upload`
rejects a wrong-sized field and fails the whole batch, so `self.subjects`
becomes `None` and *every* layer in the stack loses its mask — one stale
reference silently unmasking the others.

Pre-existing, and it mattered less when the only model-backed source was a
subject: an instance index goes missing rarely. A category name goes missing
whenever the descriptor is edited, which is a thing the descriptor exists to
allow. A full-size empty field costs one layer instead of all of them.

Neither of these is reachable from a test on this machine — both live past a
GPU adapter and a real segmentation — so they surfaced the only way they
could, by someone opening the app and looking.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 14:28:27 +02:00
dtourolle 80f0a0eeac Merge branch 'master' into feat/library-toolbar
# Conflicts:
#	docs/gestures.md
#	docs/traceability.md
2026-08-30 14:18:21 +02:00
dtourolleandClaude Opus 5 677146883f Decide what the header gives up when it cannot hold everything
Three faults, all invisible in the source and all found by looking at
the window.

`alignment: start` on the selection bar. Slint only hands space to
`horizontal-stretch` children under the default `stretch` alignment;
under `start` every child takes its preferred width and the stretch is
ignored without complaint. That was harmless while the bar held four
controls. Now that it holds every selection verb it is the difference
between a count that gives way and a row that can only scroll, so the
alignment goes and the stretch does its job.

The title collapsed to "…". An eliding Text has a minimum of nothing,
so once the buttons had taken their own minimums the title was the
cheapest thing in the row to give away — the window stopped saying
which library was open while the byte counts beside it stayed. It gets
a 90px floor.

And the status line does not get one. After the sidebar takes its 232,
a 1100pt window leaves this header about 868, and the buttons want
most of that before a character is drawn — so something must degrade,
and the order is the whole question. A floor here bought a readable
status by pushing Settings off the right edge, reachable only by
knowing to flick-scroll, which is precisely the fault the row's own
Flickable comment warns about. A control you cannot see is worse than
a sentence you cannot finish. The status line is also the most
redundant thing in the header — the sidebar states the library's count
and the filter chips state it again — so it is what gives, and it
grows back the moment there is room.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 14:05:24 +02:00
dtourolleandClaude Opus 5 9994bb4ce7 Regenerate the matrix over the third wave
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 14:02:40 +02:00
dtourolleandClaude Opus 5 7f350e8c4c Offer the categories in the masks panel
Everything before this was reachable only from an example that writes PPMs.
This is the part a photographer can touch: a list under the subjects, click
one, get a mask layer for every pixel of that category.

## Under the subjects, and the order is the argument

Clicking the photograph is how a local adjustment usually starts, so the
things the model *found* come first. A category is the move you reach for
deliberately — grade the sky, not this one bird — and putting it second says
so without a word of explanation.

Coverage is shown for the same reason a subject's score is: it tells the
photographer whether a category is worth a click before they spend one
finding out.

## Not a new tab, which is what was asked for

A develop tab is derived from `Attribute`, not declared — the tabs exist
because operations claim an attribute, and no amount of Slint adds one. A
seventh attribute would have meant duplicating every adjustment once per
category, and the combinatorics get silly by the third.

Reached through masks instead, a category composes with every adjustment
that already exists, and inherits feather and falloff rather than needing
its own. What was described as "per-category sliders with feather and decay"
is exactly what this is; only the door is different.

## Verified as far as it can be here

Compiles, populates, round-trips, 560 dr-ui tests green. **Not clicked** —
synthetic input is blocked on this setup, so how it looks and feels is
unverified and wants a human at the window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 14:02:34 +02:00
dtourolleandClaude Opus 5 763dfd353a Weigh the categories in the same precompute, and mask with them
The scene model shipped with a decoder and no caller. This runs it.

## Beside the instance pass, not instead of it

`compute` now does both on the same upright frame and lays both back down
the same way, so instance masks and category masks index into one grid —
the sensor's. A failure in the scene half is logged and dropped rather than
propagated: no scene model is an ordinary state, and a photograph that can
still be masked by subject should not become unopenable because the
categories are missing.

Categories under half a percent of the frame never reach the cache. A
control that does nothing when moved is worse than an absent one, and each
one it skips is a proxy-sized buffer not allocated.

## The shader needed nothing

A category reaches `dr-gpu` as a soft coverage buffer at proxy resolution,
turned into a distance field — which is exactly what a subject is. So they
share `MODE_SUBJECT`. That is not a shortcut taken for speed: the shader has
no way to tell them apart and no reason to want one. What differs is only
which model produced the coverage, and that has already happened by then.

Feather, falloff, dilation and erosion therefore work on a category on the
day it arrives, because they were never subject-specific.

## Where the weights come from

`scene-model` compiles the graph in and the desktop app takes it; Android
leaves it off and reads the copy `install_bundled_models` unpacks, because
24 MB of constant is worth avoiding in a mobile install and not worth the
plumbing to avoid on a desktop one. Embedded is tried first — a build that
has the weights compiled in should not be silently overridden by a stale
file in a data directory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 14:02:22 +02:00
dtourolleandClaude Opus 5 485297f0c6 Let a mask cover a whole category, not just one instance
`MaskSource` could say "instance 3 of that segmentation run" but had no way
to say "the sky". Adding `Category { signature, name }` beside `Subject` is
what lets a local adjustment attach to a semantic category at all.

Stored as identity like a subject, and for the same reason: the coverage is
megabytes and is reproducible by running the same model over the same image,
so the sidecar carries what finds it again and the session carries pixels.

## A name rather than an index

An index would be smaller and would match `Subject`. It would also be a bug.
The grouping lives in `models/scene/categories.txt`, which is editable by
design — adding one category to it renumbers every category after it, and
every stored layer would silently start grading something else. A name that
no longer exists is simply not found and the layer reads as stale, which is
the failure that announces itself.

Staleness is otherwise identical to a subject's: the coverage buffer is in
the session, never the sidecar, so a signature from another run points at
pixels that were never computed.

## Two tests, and the second one caught a real shape

Round-tripping the name matters more than usual here, because the whole
argument for storing a name instead of an index is worthless if the sidecar
is what drops it.

The multi-word case is the one worth having: `category = swimming pool` is
written on one line, and a reader splitting on whitespace would have
truncated it to a category no model has — a layer that silently masks
nothing. `category` is also its own key rather than a reuse of `class`,
because a file conflating them would round-trip a subject into a category.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 14:02:08 +02:00
dtourolle 509c3a3e96 Merge: a log that survives the process, so the tablet can be debugged
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

# Conflicts:
#	apps/darkroom-android/Cargo.toml
#	apps/darkroom-desktop/Cargo.toml
#	platform/dr-plat/src/lib.rs
2026-08-30 13:46:06 +02:00
dtourolleandClaude Opus 5 d70dcf78d1 Merge: recover a damaged catalog, and capture a crash locally
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 13:45:18 +02:00
dtourolle c48896bd95 Merge: touch selection and drag, from the gallery-selection branch
Verified before merge: fmt clean, clippy -D warnings clean, 563 dr-ui tests.

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

# Conflicts:
#	docs/traceability.md
2026-08-30 13:45:18 +02:00
dtourolleandClaude Opus 5 700e592b48 Correct the breakpoint note, which still counted six buttons
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 13:21:38 +02:00
dtourolleandClaude Opus 5 b34e786f01 Give the drag a pick-up, so it stops losing to the scroll
Dragging a photograph out of the grid worked about half the time, and
nothing on the screen explained the other half.

`DragArea` and `Flickable` do arbitrate, but not evenly. The Flickable
claims any press that travels more than eight pixels along its own axis
within half a second of landing, and holds that claim until the finger
lifts. So a drag toward the sidebar only ever began two ways: a flick
sideways clean enough that the finger never wandered eight pixels
vertically, or a wait of half a second before moving at all. Both are
real gestures and neither was written down.

The wait is now the gesture, and it has a mark. The long press that
already turns on selection mode also picks the photograph up: a ring
opens around the cell and the grid stops scrolling under it, so from
that moment the drag is the only thing the finger can be doing. The cue
can only arrive after the ambiguity has passed, which is the right way
round — when the photograph lifts, dragging it works.

Two details worth naming. The hold is now armed even when selection mode
is already on; it used to be skipped there, on the grounds that there
was no mode left to switch on — but that is precisely the state a
forty-image drag starts from, so the one gesture that most needed a
pick-up was the one with none. And the ring is drawn after the cell
loop rather than on the cell: z-order inside a `for` is loop order, so a
cell grown past its bounds would stand over two neighbours and be cut
off by the other two.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 13:20:56 +02:00
dtourolleandClaude Opus 5 c7e1822f77 Put the two collection actions in the collections panel
"Keep offline" and "Change library" were buttons in the library
header, in a row that is otherwise entirely library-wide. Both refer
to the tree instead.

"Keep offline" could only ever mean the scoped collection, while
sitting nowhere near the tree that says which that is — and while the
sidebar already offered the same question twice, on each row's tray
and on a held row. It now sits under the tree, in the panel whose
selection decides what it acts on, in the same place and shape the
trash already gives Restore and Empty. It stays a labelled control
rather than being dropped for the tray: a 26px row's tray is a small
thing to hit, and "Kept offline" spelt out for the collection you are
looking at is the discoverable version.

"Change library" was wedged between Sync and Rescan, two buttons that
act on the library you already have. Reading it as one of that group
is a way to lose a scan by aiming badly. It is now the last thing in
the panel, under the tree it replaces wholesale, behind a rule.

On a tablet both are one tap further away, behind the sidebar toggle
that leads the header. That is the panel a user is already in when
they scope the grid to a collection, so it is where they are when
either of these becomes the thing they want.

The grid keeps `pin-done`/`pin-total` and its progress bar: the
transfer is worth reporting wherever it was started from, including a
row the grid is not scoped to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 13:19:53 +02:00
dtourolleandClaude Opus 5 f4fbabf18b Say what the grid is showing in one line, not four
The header carried four readouts beside the title: the image count,
the selection count, the scan's status and where in the library the
visible window sits. Each had `horizontal-stretch: 1`, which is what
actually lets a Text shrink in Slint — so between them they claimed
about a third of a 768px header and left the buttons to scroll off the
end of it.

The selection count goes to the bar at the foot of the grid, beside
the buttons that act on it. The other three are one subject and are
now one sentence with separators, sharing one stretch.

Two of them were also saying the same words while a sweep ran:
"indexing 300 / 12 480" appeared both as the status and, redundantly,
in place of the window position.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 13:16:20 +02:00
dtourolleandClaude Opus 5 18063152f9 Ask where these photographs go once, not twice
The selection bar had "Add to collection" and "New collection" side by
side. Both answer the same question — where do these go? — and the bar
asked it before showing the list that decides it: a user who wants a
collection they already have and a user who wants a new one press
different buttons before either has seen what exists.

"New collection…" moves into the filing sheet, under the list of
collections. That is where you look after failing to find the one you
wanted, and it is the only place the choice can be made informed.

It also fixes the sheet's empty state, which said "Make one with + in
the sidebar" — advice that cannot be followed on a tablet, where the
sidebar is instantiated but not drawn. The first collection can now be
made from the sheet that noticed there were none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 13:14:47 +02:00
dtourolleandClaude Opus 5 745f3a98c7 Merge: measure the catalog, so §8's promise stops being a promise
There was no benchmark harness of any kind -- no benches/, no criterion,
no synthetic fixture -- while §8 promised a suite run per commit that
fails the build on regression. Ten performance requirements could be
neither passed nor failed.

tools/bench builds a deterministic 50,000-row catalog over a pool of
twelve generated JPEGs, about 14 MB, reproducible from a seed, with a
stamp so it rebuilds rather than silently comparing against a different
workload. It depends on nothing GPU or UI, which is what makes the CI job
affordable.

NFR-P1 and NFR-P3 are gated and tagged. NFR-P7, NFR-P8 and R2 are
measured but deliberately untagged: the export gate is one-sided, the
memory figure is the catalog layer's share rather than the whole, and
R2's first sentence is a 60 fps scroll a catalog benchmark cannot claim.

Every recorded value in the baseline is null. Nobody has run this on the
reference desktop, and a fabricated figure would make every later
comparison a comparison against a guess.

First run on this machine: catalog opens in 70 ms against a 2 s budget,
and thumbnail throughput measures 37 img/s against a target of 100 --
reported rather than asserted here, and the first evidence that the
target may not hold.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 13:13:57 +02:00
dtourolleandClaude Opus 5 a8008feaf3 Inline the verdict header, and take the formatter's pass
clippy::print_literal on the results table header, and rustfmt's first
look at code whose author could not run cargo. The four constructs the
author flagged as risky -- scoped-thread lanes, a seventeen-argument
params!, is_some_and over a closure, a refutable let-else -- all compiled
untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 13:13:57 +02:00
dtourolleandClaude Opus 5 8510a2f6a7 Give the selection one bar instead of two ends of the window
"What can I do with these twelve photographs?" was answered in two
places. Six buttons in the header — Add to collection, Keywords,
Presets, Paste to N, Export N, Remove from collection — and four more
on the floating bar at the foot of the grid, beside the count that
says what they would act on.

The header half was the worse of the two. Those six appeared and
disappeared from the middle of the row as photographs were picked, so
Sync, Settings and everything beside them slid several hundred pixels
sideways at the exact moment a hand was already travelling toward one.
On a tablet the header scrolls sideways, so they were often not on
screen at all.

All six move to the bar. It now reads left to right as shaping the
selection — Clear, Select all, Select to… — then acting on it, with a
gap between the two thoughts. The header keeps only what belongs to
the library, and changes only when the library does.

Two consequences worth stating. The bar holds ten controls in the
worst case, so it scrolls sideways like every other row in this view,
for the reason set out on the header's Flickable: a layout given less
width than its children need overruns rather than shrinking, and the
buttons past the edge are simply gone. And the bar now stays up for a
running export whatever the selection has since become, because Cancel
export lived on a button that used to have its own `|| exporting`
escape hatch — the grid's viewport inset follows the same condition so
the last row of thumbnails is never trapped underneath.

`settings-summary` went with them: threaded from the window into the
grid and into HeaderActions, and never once drawn.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 13:13:11 +02:00
dtourolle 7b5f62019b Merge branch 'master' into fix/gallery-selection
# Conflicts:
#	docs/traceability.md
2026-08-30 11:04:13 +02:00
dtourolleandClaude Opus 5 f86438ac0b Merge: the formatter's pass over the accessibility test
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:50:51 +02:00
dtourolleandClaude Opus 5 158642d4ce Reflow the accessibility test the way rustfmt wants it
The author could not run cargo; this is the formatter's first pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:50:51 +02:00
dtourolle 458025c607 Merge the scene model: a second, ADE20K-trained graph for per-category grades
Five commits. `models/` becomes one tree at the repository root so the
weight the application carries is a single `du -sh`; the export script
stops building its multi-gigabyte venv in RAM; `yolo26s-sem-ade20k`
joins the instance model rather than replacing it; the decoder turns its
logits into a partition of unity over eight photographic categories; and
four packaging routes put the file somewhere each platform can find it.

The instance model stays exactly where it was. A semantic model merges
every pixel of a class into one region, so it cannot separate two people,
and separating two people is what clicking a subject needs. The scene tab
grades whole categories and does not care. docs/segmentation.md §16.

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

# Conflicts:
#	docs/traceability.md
2026-08-30 10:50:49 +02:00
dtourolleandClaude Opus 5 6974604e84 Merge: say what each control is, so a screen reader can use one
NFR-A11Y-2 went from five accessible-* declarations in the whole
interface -- all five on the colour mixer's swatch row -- to
seventy-three, on twelve shared components and four screens. The one that
mattered is SliderTrack: the most-used control in the application, until
now unnamed, and now carrying a label, a formatted readout and
increment/decrement/set-value, so it is adjustable rather than merely
readable. Fixing the shared components covered library.slint's
twenty-seven buttons and eleven chips without editing that file at all.

NFR-A11Y-1 is scaffolded and one screen of nine is converted -- 38 @tr()
calls, about a tenth of the interface's strings. Slint's translate()
returns the original when no bundle is active, so a converted string and
a literal behave identically today and each remaining screen is an
independent commit.

Two accessibility defects are recorded rather than fixed, as TD-6 and
TD-7 with measurements: ink-faint reaches 4.5:1 on no surface (3.97 at
best) and rule reaches 3:1 on none. Fixing either re-derives the palette
beside a photograph, which wants a screenshot and an opinion.

Verified: fmt, clippy -D warnings, 563 tests including a new integration
test that walks the markup and fails on an unnamed control.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:50:38 +02:00
dtourolleandClaude Opus 5 8e4e24ad09 Say what the drag payload actually is: the arming, not the cargo
`drag-payload` was documented as "called when a drag starts, so it
always reflects the selection as it is at that moment". Neither half is
true, and the comment is the only reason anyone would believe the drop
reads it.

`DragArea` tests `data.is_empty()` in its event filter — on every
pointer event, the first one included, which arrives long before there
is a drag. And a Slint binding that calls a callback has no dependency
to be invalidated on, so it is evaluated once, when a finger first lands
on that cell, and cached for the life of the cell. What it answers is
therefore always an empty selection.

None of the drop handlers read it; every one of them reads `dragging`,
which `drag-started` fills in at the moment that matters. What this
callback does is keep the `DragArea` armed, and it manages that only
because `set_user_data` is called unconditionally — an empty `Vec` is
still user data. Guarding that call, which reads as an obvious tidy-up,
would silently stop the grid dragging at all.

Comments only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:50:10 +02:00
dtourolleandClaude Opus 5 1a59fb33c1 Stop the press that starts a drag from deselecting what it grabbed
Dragging a selection of forty photographs onto a collection filed one.

Selection mode reports every press as a ctrl-press — deliberately, so
touch and pointer go through one set of rules rather than two — and ctrl
toggled. So the press that took hold of one of the forty took it *out*
of the selection on the way down. `drag-started` then looked at the cell
under the finger, found it unselected, and did exactly what it is meant
to do with an unselected cell: made it the whole selection and carried
it alone. The only sign was the grabbed cell's ring blinking out at the
moment the user began to move.

A plain press has never had this problem, because pressing an
already-selected cell has always been documented to leave the selection
alone — for precisely this reason. Ctrl now does the same: adding still
happens on the press, since the drag reads the selection immediately,
but *removing* is handed back as `Press::Deferred` and applied by the
click. Slint reports a click only for a press that stayed within
`tap-slop`, so a tap still toggles and a drag never does.

The unit tests now go through a `click` helper — a press and the release
that follows it — because that is the only thing a user can perform, and
calling `apply_press` alone would assert against half the policy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:49:51 +02:00
dtourolleandClaude Opus 5 7312aceded Get the scene model onto the devices that need it
The decoder can load from a path; nothing yet put a file at one. Four
packaging routes, and one lookup that finds the result.

## Not `include_bytes!`, unlike the instance model

The instance model is 11 MB and compiled in, which was the right call for
it: Android hands the app no filesystem path (ARCH §6.9) and 11 MB is
tolerable. The scene model is 24 MB, and 35 MB of constants in the binary
is paid by every install whether or not the tab is ever opened.

So it follows `models/face/` instead — carried as an APK asset, unpacked
once at first launch into the shared directory a desktop install already
uses, after which every lookup finds it where it finds a desktop user's.
Assets are stored rather than deflated in the APK, so unpacking is a copy
rather than an inflate.

`embedded-scene-model` exists for the desktop build with nowhere else to
read from, and for tests wanting the real graph. Off by default, which is
the asymmetry with `embedded-model` and the reason for a separate
feature.

## Three files, all or none

`scene_model` insists on the graph, its vocabulary and the category
descriptor together, for the reason `face_models` insists on its pair: a
graph alone decodes to 150 anonymous channels. Reporting the set missing
beats starting and failing at the first inference.

## The two model sets are not the same kind of thing

`install_bundled_models` now carries both, and the distinction is worth
keeping in view. Face weights are absent from the repository *by design*
— the InsightFace grant is research-only (docs/faces.md §2) — so a build
carrying none is ordinary. The scene model is committed, so a build
carrying none means a checkout without `git lfs pull`.

Neither is fatal. A photo editor that refuses to start over a missing
grading feature is worse than one that starts without it, so both report
themselves unavailable exactly as face indexing already did.

The LFS-pointer guards apply to the `.onnx` only. The vocabulary and the
descriptor are legitimately a few kilobytes, and a size check that fails
on them would be a guard against the wrong thing.

`scene_model` is exported ahead of the tab that will consume it so the
packaging added here has something to be verified against — assets
written where no lookup looks would be a silent mistake for as long as
the tab took to arrive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:48:54 +02:00
dtourolleandClaude Opus 5 8df6000e4b Decode the scene model into per-category weights
The weights landed last commit with nothing to read them. This is the
decoder, and the shape of it follows from one property worth stating
before the code: the categories must partition the image.

## Why a partition, and not a mask per category

The scene tab applies one grade to every pixel of a category — lift the
sky, desaturate foliage — and both grades meet at the horizon. If each
category carried an independent mask, feathering them outward would make
the boundary band belong to both, so both grades would land there and
every horizon would acquire a visible seam. Feathering has to *blend*
there, not accumulate.

So `marginalise` takes one softmax over all 150 channels and sums within
each category. Grouping cannot change a total of one, so the listed
categories plus the unlisted remainder sum to one at every pixel, by
construction rather than by normalising afterwards. `parse_categories`
refuses a descriptor that claims a class twice, because that is the one
input that would quietly make the property untrue.

## The descriptor is data, and hand-written

`models/scene/categories.txt` groups ADE20K's 150 classes into the eight
a photographer would recognise. It is a file rather than a table in Rust
for the reason `models/LICENCE.md` predicted — a vocabulary is model
metadata — and it is line-oriented with comments rather than JSON like
the `.classes.json` beside it, because that file is generated and this
one is argued. Why `swimming pool` is water and not architecture belongs
next to the line that says so.

Classes are named, not indexed. An index is silently wrong after a
re-export; a name is loudly wrong, and the loader refuses one the model
does not have.

## Resolution, kept visible

`Scene` holds the native 80×80 logit grid and resamples on demand rather
than upsampling once at load. The coarseness is real — it is what the
graph produces — and a type that hides it behind an early resize invites
callers to expect detail that was never there. `rasterise` is where the
letterbox inverse lives, once.

`Letterbox` and `Window` become `pub(crate)` and `to_proto` generalises
to `to_grid`, because both dense outputs this crate reads are an even
fraction of the same letterboxed square and differ only in the divisor.

## Verified by looking, which is the only way this gets verified

`examples/scene.rs` writes the photograph dimmed outside each category. A
transposed axis or an off-by-one in the inverse produces perfectly
plausible weights over slightly the wrong pixels, and no unit test
catches that. On an indoor frame the person mask lands on the person,
including the outstretched arm, and sky reads ~5% against a bright
ceiling.

It doubles as the benchmark, because every timing quoted while this model
was chosen came off a laptop compiling other things and none of them
belong in a document.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:48:37 +02:00
dtourolle 465f5a7ffa Keep the log after the session that produced it
Everything this application knew about a failure went to stderr on the
desktop and to logcat on Android, and both are gone the moment the
terminal closes or the ring buffer wraps. That is fine when the person
debugging is sitting at the machine. It is useless for the case NFR-OPS-1
actually describes, and the one Android makes normal: somebody reproduces
a bug on a tablet, and then sends us a file.

A large amount of Android behaviour has never run on a device — image
intents read over JNI, an ExportProvider, a class loaded through the
activity's class loader, memory-pressure eviction, lost-root recovery —
and the single most likely failure of the lot, the activity's loader not
resolving our classes from android_main's thread, produces one line that
scrolls past. That line is now in a file, with the thread that emitted it
named beside it.

dr_plat::state answers "where does this platform keep state for this
app": $XDG_STATE_HOME/darkroom on Linux, and on Android whatever the
entry point declares. Separate from configuration and from the catalog
for the reason XDG separates them — state is the thing nobody backs up
and the user may delete without consequence.

dr_plat::diagnostics is the sink. Two files of 4 MiB, so the worst case
is a number rather than a discovery on a full phone; one line per write
with no BufWriter anywhere, because on Android processes are killed
rather than ended and a buffered log loses exactly the line it was kept
for; and redaction applied at the sink rather than at the call sites,
since a rule every author has to remember is not a rule. It tees the
platform's own logger rather than replacing it, so logcat is unchanged —
losing that while debugging would have made this a downgrade.

Android logs to external_data_path, not internal. Both are app-private
and both survive backgrounding; what separates them is that
/data/data/<pkg>/files needs run-as against a debuggable build to read
and /sdcard/Android/data/<pkg>/files is a plain adb pull from any build.
A log nobody can retrieve is not a diagnostic. The consequence is that
anyone holding the tablet can read it, which is why the redaction is
where it is, and why configuration stays on internal_data_path.

What is redacted is what NFR-SEC-2 and NFR-OPS-1 name: credentials and
tokens, found by the keyword that nearly always sits next to them, plus
the two forms that carry one with no keyword at all — an Authorization
scheme and a URL's userinfo. What is deliberately not redacted is
filesystem paths and the names of the user's photographs. They are in
neither requirement's list, and "failed to decode <redacted>" is not a
diagnostic; the preview-and-consent step NFR-OPS-1 asks for governs those
better than scrubbing would, because it lets the user look.

The over-redaction failure is tested as carefully as the under-redaction
one. A scrubber that eats "using basic sRGB as the fallback" makes a log
useless without ever being caught.
2026-08-30 10:40:54 +02:00
dtourolle e8e96eed40 Measure the performance targets §8 has been promising, and fail on a regression
docs/requirements.md §8 has said since it was written that performance is
verified by "an automated benchmark suite against a synthetic 50k catalog, run
per-commit … A regression beyond stated tolerance fails the build." There was
none. No benches/, no [[bench]], no criterion, no synthetic catalog, and three
CI workflows that between them measured nothing. Ten performance requirements
could therefore be neither passed nor failed, and five of them carried a
TRACES: tag regardless.

tools/bench is the half of that promise that can be kept honestly on a runner
with no GPU and no display.

# The fixture

Rows are cheap and pixels are not, so it builds fifty thousand catalog rows
over a pool of a dozen real files, each referenced by several thousand of them.
Everything the catalog half touches is rows and is exact at full scale;
everything the pixel half touches is one file at a time and does not care how
many rows point at it. Fourteen megabytes on disk instead of two terabytes, and
neither half is flattered by the trade. It is reproducible from a seed, and a
stamp beside it — seed, row count, source size, dr-catalog's schema version —
rebuilds it rather than letting a run be compared against a baseline that
describes a different library.

# What it can now pass or fail

NFR-P1, and R2's second sentence with it: Catalog::open plus the count, first
window and timeline the grid cannot paint without. The interesting part turned
out to be the open itself — schema::backfill runs three passes over the images
table on every open, which is O(library) work on a path whose budget is stated
in absolute seconds. Tagged TRACES: NFR-P1, on a gate that fails if it breaks.

NFR-P3: thumbnail throughput on the embedded preview path, through the same
per-image work spawn_thumbnail_sweep does and in the same shape — chunks of 96,
lanes owning disjoint slices, the single thread that owns the store writing the
finished chunk. Mirrored rather than called, because that function takes a
RemoteBackend and would measure somebody's network. Tagged TRACES: NFR-P3.

# What it deliberately does not claim

NFR-P7 is the whole chain, and only the encode half of it runs without an
adapter. So the export row is a one-sided gate — over two seconds in the encode
alone violates the requirement; under it proves nothing — and there is no
TRACES: NFR-P7 anywhere. NFR-P8 is about the application at idle, and the probe
is a process holding the catalog and nothing else, so it records the catalog
layer's share and carries no budget until somebody decides what that share
should be. No tag there either. CONTRIBUTING.md asks that a requirement be
closed by a test that would fail if the behaviour were removed, and two more
plumbing tags is what this repository already has too many of.

NFR-P8 also gets the answer §4.1 demands: RSS is exclusive of device-local GPU
allocations and cannot be made otherwise, because such an allocation never
enters the process's address space. The requirement should be restated as two
figures, and docs/benchmarks.md says so.

# Two gates, and why one of them steps aside off the reference desktop

The budget is the requirement's own number and never moves. The baseline is
what the reference desktop last measured, and drifting 15% past it fails the
build even while still inside the budget — which is how performance rot
actually arrives, never over the line, always a little worse.

A budget written for twenty-four threads cannot be asserted on a two-core
container. §8 names the reference desktop, not CI, so each metric declares
whether its budget is machine-sensitive; those are asserted under --reference
and reported everywhere else. Catalog open is not one of them: two seconds
against an expected figure two orders of magnitude smaller is a threshold any
machine can be held to. This is the trap core/dr-gpu/tests/frame_budget.rs
already refuses — a red gate everybody learns to ignore.

# The baseline ships with no numbers in it

Every recorded field is null, because nobody has run it yet. Writing
plausible-looking figures would make every later comparison a comparison
against a guess, and the first real regression would be invisible. Run
`dr-bench record --reference` on the reference desktop and commit the diff;
until then the budget gate works and the report says the other one cannot.

# CI

.gitea/workflows/benchmark.yml, and its own workflow rather than a step in
build-and-test.yml: a red "Build and test" says the code is wrong, a red
"Benchmarks" says it got slower, and the second must not be reachable by
retrying a flaky compile. The cpu job runs on every push and builds -p dr-bench
alone — which is why that crate depends on no GPU and no UI crate. The gpu job
is the frame budget that already exists and already skips without an adapter,
on workflow_dispatch, because building wgpu on every commit to rediscover that
the runner has no device is not a use of anybody's minutes.
2026-08-30 10:40:10 +02:00
dtourolleandClaude Opus 5 1ff52102b6 Drop the anchor bookkeeping the double tap took with it
`previous_anchor` existed for one gesture: a double tap in selection
mode took the range from where selecting began, and both taps had
already moved the anchor onto the cell being tapped, so the origin the
user meant had to be remembered separately.

That gesture is gone — "Select to…" says what it is about to do instead
of hiding a forty-image range behind a thing a hand does by accident —
and what is left is a field that four places write, `PressUndo` carries,
`cancel_press` restores, and nothing at all reads. `apply_press` is
`select_row`'s only call now that there is no anchor to remember, so the
wrapper goes with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:40:07 +02:00
dtourolleandClaude Opus 5 8848bbcff3 Stop drawing a hover ring on a device that cannot hover
A cell drew a 1px ring while the pointer was over it, in the same colour
as the 2px ring that means "selected". On a tablet there is no pointer,
and `has-hover` is not the harmless no-op that implies: Slint raises it
on a touch press and lowers it on the `Exit` that normally follows the
release — but the release that ends a pinch carries no `Exit` at all,
and neither does a finger lifting while a second one is still down.

So resizing the thumbnails, which is a pinch, left a ring around
whichever cell each finger had come down on. The grid then showed boxes
around photographs that were not selected, a pixel thinner than the ones
that were, with nothing to tell them apart.

Hover chrome has no meaning once there has been a finger, so it is not
drawn: the same `touched` latch the rating strip already keys off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:36:46 +02:00
dtourolleandClaude Opus 5 35954dfa1d Write a panic down where it can still be read, with nothing in it that names the user
NFR-OPS-2 is two sentences — local crash capture always, upload only on
explicit opt-in — and what existed was one `log::error!` in the Android entry
point and nothing at all on desktop. So a panic on desktop went to stderr and
died with the terminal, and a panic on Android went to a logcat ring buffer
that is gone long before anyone reports anything. What the user saw either way
was a job that stopped or a control that went dead, with nothing to send.

That matters more here than it would in most applications, because
NFR-ARCH-4 says no worker error may panic the process and the code is written
that way: errors are typed and attached to the image or job they belong to. A
panic is therefore by construction a bug — an invariant this codebase believed
and got wrong — and it was the one class of failure with no trace.

`dr_plat::crash` writes a record to the XDG state directory: version, time,
os, arch, thread, panic location, message, backtrace. In dr-plat rather than
in either entry point because "where does this platform let an application
keep state" is a platform question, and Android's answer is neither XDG nor
`temp_dir` — `set_state_dir` takes it from `internal_data_path`, the same
place `dr_sync::account::set_data_dir` gets its answer. The hook resolves the
directory when it fires rather than when it is installed, which is what lets
it go in before everything else and cover the startup it would otherwise miss.

**The content rule is the substance of this, not the plumbing.** NFR-SEC-2
forbids credentials in logs and plain files; the same reasoning applies with
more force to what this application is actually about, because a user's
library is private and so is its shape. `/home/anna/Photos/2019 Divorce/` says
something about a person, and a crash record is exactly the file someone
attaches to a bug report while trying to be helpful. So `redact` runs over the
message *and* the backtrace, and is deliberately blunt: anything containing a
slash goes, `content://` and `primary:DCIM/...` included, since SAF names a
library just as precisely as a path does; anything beside a word like
`password` goes; a long opaque run with letters and digits in it goes, which
is the shape of an app password nobody labelled. The one exception is a `.rs`
path, which keeps its basename — a backtrace with no filenames is close to
useless and `library.rs:1270` says nothing about anybody.

Over-redaction costs legibility. Under-redaction costs a user something they
cannot take back. Those are not comparable, so the boundary is not the place
to be clever.

NFR-SEC-5 — face data never in a crash report, under any configuration — is
met structurally rather than by filtering: this module reads no catalog, opens
no image, touches no account. A record is assembled from the panic hook's own
arguments and `std::env::consts`, and there is no code path from here to an
embedding. The message length cap is the backstop for a payload some other
module formatted something large into.

stderr is the one surface that still sees the message unredacted, deliberately:
the previous hook is chained rather than replaced, so a developer watching a
terminal does not lose the panic because the application started writing files.
It is ephemeral, local, and never attached to a report.

**No upload path, and not half of one** — no endpoint, no queue, no "send this
later" flag. Opt-in upload needs a server to receive it and a consent flow
stating what leaves the device (NFR-SEC-4, and the preview-and-consent step
NFR-OPS-1 requires of the diagnostics bundle). Neither exists, and a transport
built ahead of its consent is the shape of thing that later gets switched on
by default.

Leaves NFR-OPS-1 cheaper by three things it will want unchanged: `state_dir`
(the log belongs at `state_dir()/log` beside `crash/`, so the diagnostics
bundle has one directory to collect), `redact` (NFR-OPS-1's "automatic
redaction of credentials and tokens" is this function), and `prune` (a
size-capped rotation is this, counting bytes instead of files).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:34:12 +02:00
dtourolleandClaude Opus 5 8eeb9ba0f6 Offer the backup, and then the rebuild, when the index turns out to be damaged
NFR-R6 asks for an integrity check at startup and two offers behind it, and
none of it existed. `PRAGMA integrity_check` appeared nowhere in the tree,
`Catalog::open` was `open` → `configure` → `migrate` → `backfill` and nothing
else, and corruption therefore surfaced as whatever rusqlite error the first
unlucky query happened to produce — "database disk image is malformed"
attached to a thumbnail refresh, elided into a 34px banner, over an empty
grid saying "No images found · Check the library folder". Two messages that
disagreed, and no way forward but deleting catalog.sqlite by hand.

The property that makes the second offer real was already here and load-
bearing: the catalog is an index, not a source of truth, rebuildable from
sources plus sidecars (invariant §5.2.4, cited by schema.rs, trash.rs and
lib.rs). And sync.rs already knew how to take a coherent snapshot of a WAL
database. What was missing was the check, the type, and the conversation.

Four pieces:

**The type.** `CatalogError::Corrupt`, and — the part that makes it worth
having — a hand-written `From<rusqlite::Error>` that classifies rather than
wraps. `SQLITE_CORRUPT` and `SQLITE_NOTADB` become `Corrupt` wherever they
arise, so a background job that trips over the damage first reports the same
thing the startup check would have. `SQLITE_IOERR` and `SQLITE_BUSY`
deliberately do not: a dropped network mount is a different problem, and
telling someone to rebuild their index would be a wrong answer delivered
confidently.

**The check.** `Catalog::open_verified`, `quick_check` before the open rather
than after, because opening runs migrations and a damaged catalog with an
intact header would otherwise have structure rewritten on top of structure
that is already wrong. Bound to `open_verified` and not to `open`: the check
reads every page, which is affordable once at startup where a user can answer
a question, and not affordable on the dozens of opens a session's background
tasks make.

**The backup.** NFR-R2's second clause, taken between `configure` and
`migrate` in `Catalog::open`. A migration is the one routine operation that
rewrites table structure, so it is the likeliest way this file becomes
unreadable, and it is the last moment the pre-migration state exists to be
copied. Three generations, through SQLite's backup API after a TRUNCATE
checkpoint — never `fs::copy`, which on a WAL database backs up a state older
than the catalog and possibly torn. A failure to take the copy is logged, not
raised: a full disk must not be what makes a library unopenable.

**The conversation.** The first line of the dialogue is that the photographs
and the edits are safe, before the diagnosis, because that is the question the
user is actually asking. Then the two offers, which are *not* interchangeable
and are not presented as if they were: a restore keeps collections, and a
rebuild cannot, because a manual collection is a set of images assembled by
hand and nothing in the filesystem records it (docs/catalog.md §8.1). The
labels say so, and the rebuild does not take the affirmative styling while a
restore is on the table.

One thing that is a fix rather than a feature: `show_catalog_now` now gates
the scan. `Catalog::open` succeeds on a file whose header survived, so the
scan that used to start immediately afterwards would write folder ETags and
image rows into damaged pages in the seconds while the user was still reading
the question — turning a file that had a backup into one where the backup is
the only copy left.

Restore also deletes the damaged catalog's `-wal` and `-shm`. That step is
easy to leave out and fatal to leave out: a journal belonging to the old file,
sitting beside the new one under the same name, is replayed into it on the
next open. That is not a restore, it is a fresh corruption with the evidence
gone.

Tested by corrupting a fixture catalog — 500 images and a collection, then
every page past the second overwritten — and driving both branches. The
restore is asserted on the collection, because a collection is precisely what
distinguishes the two paths; the rebuild on the damaged file being kept and
the next open producing an empty catalog at the current schema. Plus the
`SQLITE_NOTADB` presentation, a damaged backup being refused rather than
installed, and a v1 catalog whose pre-migration backup comes back reading
v1 rather than v11.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:34:12 +02:00
dtourolleandClaude Opus 5 258aae71d6 Regenerate the matrix without the tool's own fixtures
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:33:08 +02:00
dtourolleandClaude Opus 5 677b047235 Merge: stop the matrix counting the tool's own test fixtures as coverage
A tag was any line containing the string, so the traceability tool scanned
tools/ -- its own source -- and its test fixtures became coverage. R1 and
NFR-OPS-1 were tagged on nothing but that. Four requirements had sites that
were never implementations: the same fixtures also fed FR-CAT-1, FR-CAT-2
and NFR-P1, gestures.rs fed FR-UI-4 from a push_str, and build.rs fed
FR-DEV-3a and FR-DEV-3c from the tag it emits into generated code.

A tag is now a comment whose first word is TRACES:. Excluding cfg(test)
was rejected because tags on tests are this project's recommended
practice, and keying on string literals was impossible because schema.rs
carries six genuine tags inside Rust strings -- the SQL it embeds is
commented with --.

Four tags removed as unearned: FR-CAT-13 (no XMP is parsed or written
anywhere), FR-PLAT-AND-1 (SourceRef::Document is constructed only in test
modules, and the second tag sat on imports_supported, which documents the
absence). Two added as earned: NFR-A11Y-3, which was built and untagged,
and FR-PLG-8's preservation clause.

Four requirements amended rather than built, each with its reasoning: R5's
tiling clauses struck, NFR-P11 naming the state that must survive,
FR-RAW-2's mechanism reworded to bytes-in, FR-EXP-1's AVIF and JPEG XL
stated as post-v1.

Coverage falls 122 to 120 of 179, and means more than it did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:32:53 +02:00
dtourolleandClaude Opus 5 53b04dc561 Let the accessibility test see a callback that takes an argument
`sets()` recognised `name:` and `name =>` and not `name(arg) =>`, which is how
Slint writes a callback handler with a parameter. There is exactly one of those
among the properties asserted — `accessible-action-set-value`, the action a
screen reader uses to type a number into a slider — so the test reported
`SliderTrack` as missing the action it declares four lines above.

A false alarm rather than a false pass, and the less dangerous of the two. It
is still worth fixing rather than dropping the assertion: set-value is the
action that makes a slider reachable without dragging, which is most of what
the actions were added for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:31:01 +02:00
dtourolleandClaude Opus 5 bddd30fba2 Measure the chrome's contrast, and say why font scaling is not a multiplier
NFR-A11Y-2 has three clauses and this branch closes one of them. The other two
— WCAG AA contrast on non-canvas UI, and platform font scaling honoured
without clipping — are now measured and reasoned about rather than left as two
sentences in the requirements register that nobody had checked.

Contrast is measured, not estimated. Every ink against every surface it is
drawn on, by the WCAG 2 formula, and the result is narrower and more specific
than "the palette is dark": `ink` and `warn-ink` pass everywhere, the inverted
cases on the near-white fills are the best-contrasting text in the application
at 18.6, and exactly two tokens fail. `ink-faint` reaches 4.5:1 on no surface
at all — 3.97 at its best — and it is the ink for every caption, every section
name and every count. `rule` reaches 3:1 on none either, and it is the border
of every button, field and panel, so an unfilled secondary button is a 1.4:1
outline on a 1.2:1 ground.

Neither is an oversight, which is why they belong here rather than in a bug
list. They fall out of the palette's own argument: a bright surround biases how
a photograph is judged and hue is banned outright, so all the signalling is
luminance and the luminance is deliberately spent on the image. "Text you are
meant to skip" and "text everyone can read" are in real tension. The fix is a
re-derived ink scale and a stronger rule, both of which change how the
application looks beside a photograph — a screenshot and an opinion, not a
patch, and not something to do blind.

Font scaling is the more interesting entry because the obvious fix is wrong. It
is not a multiplier on the type sizes: the layout rests on constants that are
not derived from them — control-height, touch-target, rail-entry-height,
panel-width, and a dozen fixed heights written at their call sites — and
scaling the type alone clips against every one, silently, because Slint elides
rather than errors. The colour mixer is the sharpest case, thirty-six controls
in a 360px column sized so a track and a swatch and a readout share a line.

So the entry sets out the four pieces in dependency order, and notes that the
first is nearly built already: `live-style` makes `build.rs` emit `in-out`
theme tokens that Rust writes at startup, which is exactly the mechanism a
scale factor needs.

Both entries carry the falsifiable condition this document asks for, and the
contrast table is the baseline a later measurement compares against.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:29:33 +02:00
dtourolleandClaude Opus 5 46706fe622 Let the tool rail be pressed, not only read
The rail is the develop view's primary navigation and reached the platform as
four pieces of static text. The labels got through — a `Text` announces itself
— so a screen reader could read "Photo, Crop, Local, Repair" and had no way to
learn that any of them could be pressed, or which one was currently held. The
one control that decides what a click on the photograph does was a caption.

Each entry now declares itself a checkable button carrying the tool's own
word, with the held state reported rather than left to the fill. Checkable is
unconditional here, unlike `Button`'s: a rail entry is always a held-or-not
state, so an unheld one should say "not pressed" rather than pass for an
ordinary button.

The action repeats the click handler's expression rather than calling it. A
`TouchArea`'s `clicked` is raised by the pointer and cannot be raised from a
binding, so the alternative is a function wrapping two lines — and the
duplicated ternary sits four lines below the original where the two cannot
drift out of sight of each other.

Worth recording for the next control like this: `accessible-*` on an element
inside a `for` does work, despite the accessibility pass skipping repeated
elements. `process_repeater_components` runs first and moves the bindings into
a real component whose root is not repeated; what the later pass skips is the
empty placeholder left behind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:27:10 +02:00
dtourolleandClaude Opus 5 80298403f7 Name the launch screen's entries, and let its folder rows be pressed
A heading above a text field names it on screen and names nothing to the
platform: Slint associates the two only if something says so, and nothing did.
So the four entries a user signs in through — server, username, app password,
folder — reached AT-SPI as unnamed boxes with a separate piece of static text
floating above each. The word is now written twice, deliberately, and the
second copy is the one attached to the control being typed into.

FolderRow is the more consequential half. It is a Rectangle with a TouchArea
over it, which is a button to the user and a decorated box to the platform —
its label got through, because the `Value` inside is a Text, while the fact
that the row could be entered at all did not. The folder picker was therefore
readable and not navigable, which for the screen that chooses where the whole
library lives is the difference between using the application and not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:25:58 +02:00
dtourolleandClaude Opus 5 b63ce0290b Wrap the three lines that ran past the column the rest of these documents keep
Prose-only. Three paragraphs added in this branch ran to 105, 113 and 166
columns against a document that wraps at 100 everywhere else, the last because
an edit joined a new sentence onto an existing paragraph's opening line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:25:57 +02:00
dtourolleandClaude Opus 5 7409cb7767 Make the launch screen translatable, and say how the rest follows
`@tr(` appeared zero times in 14,482 lines of markup. Every string the user
reads was a literal, so NFR-A11Y-1 was not partly done or done badly — there
was nothing to extract and nothing a translator could have been given.

The mechanism turns out to cost almost nothing, and the reason is worth
stating because it decides the order of the work: a string written `@tr("Sign
in")` is already correct in a build with no translation at all. Slint's
`translate()` formats the original and hands it back when neither delivery
path is active, so a converted string and a literal are the same string until
someone writes a `.po`. That means the interface can be converted a screen at
a time rather than in one 14,000-line commit that nobody can review, and every
intermediate state is shippable.

So the delivery half is wired and left inert. `build.rs` asks for bundled
translations only once `lang/<lang>/LC_MESSAGES/dr-ui.po` exists — the first
catalogue anyone commits turns it on with no build-system change, and until
then a checkout with no `lang/` builds exactly as it did. Bundling rather than
the `gettext` feature because of Android: under ARCH §6.9's storage model
there is no path a `.mo` could sit at that the app can reach, and no C library
to link it against. The extraction command and the one flag that must not be
passed to it are recorded in the module docs.

The launch screen is converted whole: 32 calls covering every heading, button,
caption and placeholder. It goes first because it is the screen a user cannot
get past — an unreadable preferences page can be ignored, an unreadable sign-in
cannot. Four kinds of literal are deliberately left alone and the file says
which: the product name, example values whose shape is the message, `..`, and
the path separator.

Two things this leaves open, recorded rather than papered over. The headings
carry their own capitals, because `PanelHeading` draws what it is handed — so
a translator supplies "SERVEUR", not "Serveur", and styling in the string is a
real cost now paid rather than a surprise later. And `@tr()` is markup only:
the operation and parameter labels NFR-A11Y-1 names explicitly resolve in
`labels.rs`, in Rust, because the core may not depend on a localisation
library — those need a second mechanism, and it is not built.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:25:16 +02:00
dtourolleandClaude Opus 5 6ec6c5cbd6 Count the tags this rule would have cost, rather than guessing at it
The module doc said an extractor keyed on string literals would "lose six
genuine tags to save two false ones". The six is right — `schema.rs` carries
that many `-- TRACES:` lines inside Rust literals — but the two undercounted
the false ones, which were R1 twice, FR-CAT-1 twice, FR-CAT-2, NFR-P1, one in
`gestures.rs` and two emitted by `dr-pipeline/build.rs`. The comparison it was
drawing does not need a number on that side to hold.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:25:13 +02:00
dtourolleandClaude Opus 5 f1b0634bd1 Stop calling NFR-COMPAT-2 unstated in the paragraph after citing what states it
outstanding.md §5 said NFR-COMPAT-2's v1 distribution channels were "Unstated"
immediately after the FR-PLAT-LIN-3 paragraph above it cites
docs/distribution.md — a document whose own header says it satisfies
NFR-COMPAT-2 and whose §1 is a table of five channels with the state of each.

The requirement asks that the channels be stated. They are: Arch source package
and Flatpak in tree, AppImage a v1 channel with no recipe yet, F-Droid a v1
channel not yet submitted, and Play deliberately not v1. The deferral is part
of the statement, not a gap in it.

What is genuinely open is the coupling the requirement exists to flag — whether
Play makes ARCH §6.9 binding — which distribution.md §6 argues runs the other
way for this project, and which spike S11 has not been run to confirm. That,
plus two channels that are decisions rather than recipes, is what the entry now
says.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:23:30 +02:00
dtourolleandClaude Opus 5 8865743db6 Split FR-EXP-1, so its tag stops standing for a deferral as well
FR-EXP-1 listed four things in one sentence: JPEG, PNG, TIFF at both depths,
and AVIF or JPEG XL. Three of them are built, encode, embed an ICC profile, and
are covered by tests that walk every offered format. The fourth is a deliberate
deferral — `export` returns `FormatUnsupported` for AVIF and JPEG XL, and
`the_formats_without_an_encoder_say_so` pins that behaviour in place.

Fused, the requirement could only be tagged dishonestly or not at all, and not
at all is worse: it would delete the register's record of the part that is
finished, which is most of it.

So the v1 scope is now JPEG, PNG and TIFF with the configurability clause, and
AVIF and JPEG XL are stated as post-v1 with the condition that already holds —
either may appear in the settings page before its encoder does, provided
choosing it fails with a typed error naming the format rather than producing a
file. That is what `every_offered_format_either_encodes_or_explains_itself`
exists to guarantee, and it is why a format cannot be added to the picker and
quietly reach an encoder that does not handle it.

No intent is dropped. AVIF and JPEG XL remain wanted; they are now scheduled
rather than silently outstanding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:23:03 +02:00
dtourolleandClaude Opus 5 8e4befa727 Say what each control is, so a screen reader can use one
The whole interface carried five accessible-* declarations in 14,482 lines of
markup, all of them on a single row of the colour mixer, and nothing said so.
Every other control — every button, every tick-box, every chip, and the slider
the develop panel builds thirty-six of for the mixer alone — reached AT-SPI
and TalkBack as an unnamed rectangle. NFR-A11Y-2 is not a polish item for a
user in that position; it is whether the application can be used at all.

The annotations go on the shared components rather than on the screens, which
is the same argument widgets.slint was written to make one layer down: a
control named where it is used is a control unnamed everywhere it is used
next. Twelve components now declare a role, a name and — where the control
does something — the action assistive technology invokes to do it. Three
screens were touched, and only where the component could not know the answer.

SliderTrack is the one that mattered most and the one that could not be fixed
from inside itself. It is handed four numbers and knows nothing about what
they mean, so it takes a `label` and a formatted `readout` and every wrapper
passes down what it was already drawing. A test asserts that every
instantiation does, because a track added without one announces "slider, 0.35"
and looks perfectly correct in a screenshot.

It also gains increment, decrement and set-value. A slider that can only be
dragged is a slider a pointer is a modifier for, which is the objection
ui-navigation D-N2 makes about hover-only affordances with the argument run
one step further; these three are what a screen reader drives a slider with,
and they commit as well as change — one nudge is a whole gesture, so a caller
that persists on `committed` must hear about it.

Two decisions worth recording because the obvious alternative is wrong:

`active` on Button and IconButton is deliberately not announced from the
component. It says a toggle is on and says nothing about whether a control
that is *off* is a toggle at all, so announcing it would report every button
in the application as an unpressed toggle. The four call sites that mean a
toggle say so themselves, which Slint permits because the role is inherited.

SwatchSlider's row-level role is removed rather than kept. It was the one
control that had a label, and now that the track underneath it has one too the
two would nest — a slider inside a slider, the outer holding the value and the
inner holding the actions that can change it. The row stands down and hands
the same strings to the control that owns the gesture.

The test reads the markup the way darkroom-android's manifest test reads its
XML: there is no accessibility tree without a window, so what it defends is
the failure that actually happens — a role or a name lost in a refactor, which
compiles, renders identically, and is invisible to everyone not using a screen
reader.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:22:32 +02:00
dtourolleandClaude Opus 5 e0da93c59a Reword FR-RAW-2's mechanism to the one that serves its purpose
FR-RAW-2 required RAW decoding to sit behind "a trait taking a SourceRef, not a
filesystem path". The purpose is met — `dr_decode::decode` takes `&[u8]` and
the crate has no path-based entry point anywhere — and the mechanism is not,
because a decoder taking a SourceRef would be a worse design than the one built.

A SourceRef is opaque. The only thing that turns one into bytes is `Storage`,
which lives in platform/dr-plat, so a decoder taking a SourceRef must take a
Storage with it: retry, permission loss and remote fetching move inside the
decoder, and the decoder becomes constructible only where a Storage exists.
Bytes in, image out, is narrower and more portable — the decoder cannot know
where its input came from, which is the property the requirement wants.

The Nextcloud case is the one a byte-oriented API looks like it should lose,
and it is the clearest illustration that it does not. `dr_decode::HEADER_BYTES`
declares how much of a file the decoder needs in order to read metadata, and
`import.rs` fetches exactly that range through `Storage::read_range` before
calling `dr_decode::metadata`. The decoder states a requirement; the storage
layer satisfies it. A decoder holding its own SourceRef would have had to carry
the range policy itself.

The trait half of the clause is left standing and unmet. There is one decoder
reached through free functions, so "a second implementation may be added
without changing callers" is still outstanding work rather than a description.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:22:22 +02:00
dtourolleandClaude Opus 5 11e515a295 Say which state NFR-P11 forbids losing, since one kind is dropped on purpose
NFR-P11's criterion was "no dropped frames, no state loss" across a layout
class transition. Read literally, the interface already fails it, and fails it
by design: `apply_layout_class` clears the user's panel open/closed choices
whenever the class changes, and `PanelChoices` documents why — a choice made in
landscape answers a different question from the one portrait asks, and carrying
it across leaves a 232 px sidebar on a screen with no room for it.

A requirement that the code deliberately contradicts is worse than no
requirement, because the next person to read it either "fixes" the behaviour or
learns to discount the register.

So the criterion now names what must survive — the open image and version, the
selection, scroll position, the in-progress edit and its undo history, the
current mode — and states the exemption with the reasoning attached. Panel
disclosure is a default re-derived per class, not state; the user's
disagreement with it is remembered within the class where it was expressed.

The intent is unchanged: a resize must not cost the photographer anything they
did. It is now possible to write a test for that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:21:32 +02:00
dtourolleandClaude Opus 5 99f9b5b7c5 Strike R5's tiling clauses, which the frame budget argues against
R5 asks that the application work on downscaled proxies for display, and its
criterion stated that in two parts: the display pipeline operates at viewport
resolution, and only visible tiles are computed with panning recomputing only
newly exposed ones. The first is built and pixel-equality tested. The second is
not, and should not be.

This is the same correction FR-DSP-2 already received, applied to the
requirement that FR-DSP-2's tiling clause traces back to. frame-budget.md
measured the case tiling exists for: recomputing the whole 4K viewport costs
4.5 ms of a 16 ms budget, so a perfect tile cache could save at most 4.5 ms in
exchange for a cache keyed by (VersionId, tile, zoom, graph_hash_prefix) that
must stay correct across every parameter change in the graph.

For the one stage that does exceed the budget it is worse than useless. That
stage is a convolution, and a tiled convolution reads a halo per tile: at the
52 px radius measured at 4K, 256 px tiles read (256+104)² taps instead of 256².

The intent is not removed — a display path that does work proportional to the
source image is still forbidden, and that is what the remaining clause says.
What is removed is a mechanism written in as though it were the only way to
get there, and which measurement says is the wrong one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:21:06 +02:00
dtourolleandClaude Opus 5 99563b33f7 Record the one clause of FR-PLG-8 that is built, and say what it is short of
FR-PLG-8 reads as unbuilt, and most of it is: no aggregated missing-plugin
notice, no catalog-wide view, no export gate, no mark on an image whose
operation is unavailable. But its opening sentence is a claim about existing
behaviour — "the sidecar already preserves lines it does not understand
verbatim and writes them back untouched" — and it goes on to say that property
"is now load-bearing and shall be treated as such".

Two tests treat it as such, and neither was tagged. `sidecar.rs` keeps an
unknown operation across a parse-and-write, and keeps it out of the edit graph
so that preserving it is safe rather than merely tidy. Both would fail if the
verbatim path were removed, which is what CONTRIBUTING.md asks a tag to mean.

The tag sits on the two tests rather than on the module, so the matrix points
at the clause that is closed rather than at the requirement as a whole.

It is still short of the requirement's own acceptance criterion, and the tag
comment says so: FR-PLG-8 asks that a sidecar written with a plugin, opened and
saved without it, be byte-identical to the original, and the test asserts
`contains`. Both halves exist separately — `writing_the_same_state_twice_is_byte_identical`
proves byte identity for content this build understands — and nothing joins
them into the single claim the requirement makes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:20:22 +02:00
dtourolleandClaude Opus 5 e22945a62d Tag the colour-independent status that was already built and unrecorded
NFR-A11Y-3 — no status conveyed by hue alone — read as untagged, and
outstanding.md said "no compliance work found". Both were wrong. Five places
already implement it, and four of them name the requirement in a comment
explaining the design; what none of them had was a TRACES line.

- `histogram.rs::percentage` states clipping as a figure and keeps `<0.1%`
  distinct from `0%`, so the text cannot say "none" while the marker beside it
  is lit.
- `histogram.slint`'s ClipReadout is the other half: a marker that appears and
  disappears rather than changing tint, and the figure next to it. Either alone
  reads.
- `library.slint`'s star strip is a solid star against an outline, differing in
  shape and luminance, over an achromatic palette.
- `library.slint`'s FlagMark is a tick against a cross, and a reject also dims
  its whole cell.
- `peaking.slint`'s colour chips say "Red" and "Cyan". A control for choosing
  between hues, presented only as hues, is unusable by exactly the person most
  likely to need it.

The tag is honest about being wider than the evidence, and outstanding.md now
records both gaps. Only the clipping clause has a test that would fail if the
behaviour were removed; the three Slint components are argued rather than
asserted. And the requirement's first named example — catalog colour labels —
has no interface at all: `label` is a nullable column nothing writes or shows.
That clause is untestable rather than satisfied, and closes when the label UI
is built with a shape from the start.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:19:48 +02:00
dtourolleandClaude Opus 5 62e585844a Untag FR-PLAT-AND-1 from the two places that record its absence
FR-PLAT-AND-1 requires that library access on Android be obtained exclusively
through the Storage Access Framework — a tree granted with
ACTION_OPEN_DOCUMENT_TREE, persisted with takePersistableUriPermission,
enumerated with DocumentsContract. None of those three appears anywhere.

What carried the tag was a type and a negation.

`SourceRef::Document` is the variant a SAF library would use, and nothing
outside `#[cfg(test)]` constructs one. `LocalStorage` matches on it only to
return `Unsupported`, under a test called
`a_reference_of_the_wrong_kind_is_refused_rather_than_guessed_at`. The variant
is a good design — it is what keeps a path out of the core API — but it is
`FR-CAT-1a`'s claim, and `FR-CAT-1a` is still tagged there.

`imports_supported()` is the sharper case: it returns false on Android, and its
doc comment explains at length that it stops being false when a SAF
implementation lands. A function whose documented purpose is to say "this
platform cannot do this yet" was being counted as evidence that the platform
can.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:18:45 +02:00
dtourolleandClaude Opus 5 421a47f1eb Untag FR-CAT-13, which no XMP is read or written to satisfy
FR-CAT-13 asks for standard XMP sidecars read and written — ratings, colour
labels, keywords and hierarchical subjects, title, description, copyright, GPS,
in `xmp:`/`dc:`/`lr:` schemas — so that other tools interoperate. Its one tag
was the module header of `dr-catalog/src/keywords.rs`.

That module stores keywords in SQLite. It names `dc:subject` twice, both times
in prose explaining why a keyword's text is the fact rather than its row id,
which is a good reason to have written it that way and not evidence of an XMP
implementation. Nothing in the tree parses or emits XMP: `dr-export`'s metadata
module writes EXIF and says in its own header that IPTC and XMP are named by
FR-EXP-8 and neither is read.

`dr-preset-xmp` is the crate whose name most invites the mistake. It reads
Lightroom `.xmp` *presets* — develop settings — under FR-DEV-6, and knows
nothing about the metadata schemas FR-CAT-13 is about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:18:10 +02:00
dtourolleandClaude Opus 5 0906c3983f Stop reading a coverage calculation as a diagnostics subsystem
NFR-OPS-1 asks for structured levelled logging to a rotating, size-capped
on-disk log in the XDG state or Android app directory, automatic redaction of
credentials and tokens, and a one-click diagnostics bundle with an explicit
preview-and-consent step. Its two tags were on `compute_coverage` and on the
traceability tool's gesture extractor.

Neither is diagnostics under any reading. One computes a ratio and the other
generates a markdown document; neither writes a log, and no rotating on-disk
log exists anywhere in the tree — logging goes to stderr and to logcat.

These were real tags, not the fixtures the extractor was just taught to
ignore, which makes them the more instructive case: the tool was correct and
the tags were wrong. NFR-OPS-1 is untagged again, and outstanding.md §9 now
says what is actually missing rather than that the requirement is covered.

`gestures.rs` keeps its FR-UI-4 tag, which is a separate claim and unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:17:53 +02:00
dtourolleandClaude Opus 5 9d9abb227f Ask where a TRACES tag sits, so the tool stops tagging its own fixtures
The traceability tool scans `tools/`, which is its own source, and a line was
taken for a tag whenever `TRACES:` appeared anywhere on it. Its unit-test
fixtures are therefore tags. R1 — cross-platform output within a bounded
tolerance, the requirement with no acceptance criterion at all — was reported
implemented on the strength of two string literals in
`context_looks_forward_then_backward`.

R1 was only the visible case because it had no other coverage. The same
fixtures also contributed sites to FR-CAT-1, FR-CAT-2 and NFR-P1, `gestures.rs`
contributed one to FR-UI-4 from a `push_str`, and `dr-pipeline/build.rs`
contributed FR-DEV-3a and FR-DEV-3c from the tag it *emits* into generated
code. Those four requirements keep real tags elsewhere, so nothing but noise is
lost by dropping them.

The rule is about position, not about string literals. It cannot be about
string literals: `schema.rs` writes six genuine tags inside Rust string
literals, because the SQL it embeds is commented with `--`, and an extractor
that refused those would lose more than it saved. What separates the two is
where on the line the tag is. A tag written to be read is the first word of its
comment; a tag quoted inside an expression never is. So `tag_body` asks for a
comment opener at the start of the line and `TRACES:` immediately after it.

That closes every shape but one: a multi-line literal whose lines really do
begin with `///`, which no line-oriented reader can tell from source. There is
one such fixture and its ids are now UT and IT, which `is_requirement` already
excludes from coverage — the mechanism existed and was simply never used on
the tool itself. `this_crates_own_fixtures_cannot_reach_the_register` enforces
that: any requirement id below `mod tests` in this crate fails the test and
says to use a UT- or IT- id instead. Tagging the tool's real code is still
allowed.

On `SOURCE_SUFFIXES`, which cannot reach `AndroidManifest.xml`, the Flatpak
manifest, the Dockerfile or the CI workflows: it is deliberately left alone,
and the reasoning is recorded beside it. A tag on a manifest asserts that a
comment exists next to a line nothing checks, which is the weak form
CONTRIBUTING.md warns about. The convention already in the tree — a Rust test
that `include_str!`s the file and asserts what must be in it, with the tag on
the test — is what a tag is supposed to mean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:17:34 +02:00
dtourolleandClaude Opus 5 126beaf7ed Tell javac the Java sources are UTF-8
The APK stopped building the moment the tree gained Java with prose in
it: 55 errors, every one "unmappable character (0x94)", every one from a
comment. The code was fine.

javac reads sources in the *platform* encoding, and the build container
sets no locale, so that is US-ASCII. Every curly quote and em dash in a
doc comment is then unrepresentable. The repository is UTF-8 throughout,
so this says so rather than asking one file's prose to be typed in ASCII
to suit a default nobody chose.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:07:48 +02:00
dtourolleandClaude Opus 5 79b7f54e04 Tell javac the Java sources are UTF-8
The APK stopped building the moment the tree gained Java with prose in
it: 55 errors, every one "unmappable character (0x94)", every one from a
comment. The code was fine.

javac reads sources in the *platform* encoding, and the build container
sets no locale, so that is US-ASCII. Every curly quote and em dash in a
doc comment is then unrepresentable. The repository is UTF-8 throughout,
so this says so rather than asking one file's prose to be typed in ASCII
to suit a default nobody chose.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:07:19 +02:00
dtourolleandClaude Opus 5 9a2b39b8e5 Add the ADE20K scene model beside the instance one
`models/LICENCE.md` recorded, on 2026-08-21, that no YOLO model trained on
ADE20K existed in usable form — the stuff classes photography cares about,
sky and vegetation and water, had no model to come from. Re-checked
2026-08-30: Ultralytics now ships a `semantic` task with ADE20K
checkpoints, so `models/scene/` holds `yolo26s-sem-ade20k`.

This is an addition, not a replacement. A semantic model labels every
pixel but merges same-class pixels into one region, so it cannot tell
three people apart — which is exactly what clicking a subject needs, and
exactly what `segment/`'s COCO instance model already does. The scene tab
grades per category and does not care that instances are merged. Keeping
both is the point.

## The export is truncated, deliberately

Ultralytics ends the graph with `Resize -> ArgMax -> Cast` and hands back
a `[1, 640, 640]` u8 label map. The script cuts that tail and exposes the
classifier's `[1, 150, 80, 80]` f32 logits instead, for two reasons.

Cost: the Resize materialises 150 x 640 x 640 x f32, 246 MB, and ArgMax
then reduces across the channel axis, striding 409,600 elements per
comparison. On one loaded machine the full graph ran ~1160 ms against
~500 ms truncated — roughly four fifths of the time spent on work the
application discards. Those numbers were measured under contention and
are upper bounds, but the ratio is structural.

Softness: ArgMax destroys the per-class scores, and the scene tab needs
them. Softmax over the 150 channels, summed within each photographic
category, yields per-category weights summing to 1 at every pixel.
Feathering a partition of unity cannot double-grade a boundary, whereas
feathering hard labels outward from two adjacent categories paints both
grades into the overlap and haloes every horizon.

The discarded upsample was never information: the graph's true spatial
resolution is the 80x80 logit grid, and the application can resample from
that itself.

The tail is matched by op type and asserted before cutting, so an
upstream graph change fails loudly in the exporter rather than quietly
shipping a differently-shaped model.

Nothing reads these weights yet — the decode path, the category
descriptor grouping 150 classes into ~8 photographic ones, and the scene
tab are still to come. At 24 MB this model also wants the runtime-asset
treatment `models/face/` already gets on Android rather than
`include_bytes!`; embedding it would put ~35 MB of weights in the binary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:05:44 +02:00
dtourolleandClaude Opus 5 2e09906a08 Keep the export venv off tmpfs
`mktemp -d` lands in `/tmp`, which on this and most current Linux
distributions is a tmpfs — memory, not disk, sized at half of RAM. The
venv this script builds installs torch into it, several gigabytes, and
the failure mode is not subtle:

    error: Failed to install: torch-2.13.0-...whl
      Caused by: No space left on device (os error 28)

on a machine with 102 GB free on the filesystem holding `/var/tmp`. The
quieter version of the same bug is worse: when it does fit, it evicts
whatever the user had in page cache to make room.

`${TMPDIR:-/var/tmp}` respects an explicit TMPDIR and otherwise picks the
disk-backed directory, which is what a multi-gigabyte throwaway wants.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:05:19 +02:00
dtourolleandClaude Opus 5 e26f71d15d Gather every model under one tree at the repository root
The weights were in two places: face detection and recognition in
`models/face/`, segmentation in `core/dr-segment/models/`. Nothing was
wrong with either path, but between them there was nowhere to look to
answer "how much model does this application carry", and that number is
about to start growing.

So the crate-local copy moves up beside the other. `models/` now holds
`face/` and `segment/`, and a `du -sh` of one directory is the whole
answer.

No content changes: the .onnx and its vocabulary are byte-identical, and
`LICENCE.md` moves up a level to cover the tree rather than one crate.
The LFS pattern in `.gitattributes` is `*.onnx` and already matched both
locations, so only its comment needed the new path.

`include_bytes!` is relative to the source file and `build.rs` runs with
the crate root as its working directory, which is why the two paths climb
a different number of levels.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:05:04 +02:00
dtourolleandClaude Opus 5 ef07e6ca3e Give the canvas tools a rail of their own, and the column one width
Build and test / Desktop (Linux) (push) Failing after 1h14m38s
Build and test / Layer separation (push) Successful in 48s
🐳 Android image / Build and push (push) Successful in 16m30s
Build and test / android-image (push) Successful in 16m31s
Traceability / Requirement traces (push) Successful in 1m47s
Build and test / Android (aarch64) (push) Successful in 1h0m21s
Crop, Local and Repair were chips at the head of the develop column, sharing a
row with the adjustment groups and told apart from them by the shape of their
highlight. Three things followed from that, and only the last is cosmetic: the
column closes, so the way out of a mode went away with the way in — hence the
duplicate "Done Cropping" over the canvas; the chips are generated from the
operation set, so the widest thing in the sidebar was a row nobody had chosen
the contents of; and a mode and a filter are different kinds of state wearing
one control.

They are a fixed 60px rail down the left now, generated from a single table in
toolrail.slint. A tool is one row of it plus a drawing plus a ViewMode variant;
nothing in app.slint is touched to add one. What is left of the strip is the
group filters, so it is GroupStrip.

The column stops measuring itself. Every panel published a content-width and
declared it as min-width, and the column took the largest — which spent the
photograph's pixels on whatever happened to be widest, and moved the image
sideways when switching tools swapped one set of panels for another. It is
panel-width now, one number in style.yaml.

That number is 360 and it is measured, not picked: the contents report a
minimum of 344 in every mode, and they do not compress below it because a Text
that does not elide reports the same minimum as preferred. 320 was tried and
sliced Paste down the middle. The Flickable's viewport is floored at the
layout's minimum rather than its preferred width for the same reason — content
that is never told how much room it has cannot adapt to having less.

Removing the eight content-width declarations repairs three comments an
earlier edit had spliced sentences into. The raw histogram's note on keeping
its hint short is rewritten rather than dropped: an over-long hint no longer
widens the column, it pushes the column's minimum past the width it has and
clips the panel, which makes that constraint sharper rather than obsolete.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 09:59:50 +02:00
dtourolleandClaude Opus 5 9e519eb8a6 Make the thin ring the only mark a selected photograph carries
Two treatments said "selected" and neither said it well.

A selected cell got a 2px border and a lifted fill. The border is drawn
on the outside of a cell whose content sits 6px in, so it ate into the
thumbnail: selecting appeared to nudge the photograph. Inside it, a
second thin ring marked the anchor — the end a shift-click measures from
— and that ring was the clearest thing on the cell, so it read as *the*
selection to everyone who had not written it.

Worse, the ring outlived what it described. An anchor survives a
deselection, so a thin box sat around the last photograph touched with
nothing selected at all, indistinguishable from a cell that had stayed
behind. That is the one thing a selection cue must never be: ambiguous
about whether something is selected.

So the ring is now what it already looked like. One mark, drawn inside
the cell and 4px clear of its edge, so it never touches the thumbnail and
never changes a dimension — selecting adds ink and moves nothing. Two
pixels rather than one, because it is now carrying the whole cue across
forty cells at arm's length against a thumbnail of any brightness. The
outer border is hover alone.

The anchor keeps no mark, and loses nothing it was earning: the bar says
"Tap the last photograph" while a range is armed, which answers the
question the ring existed to answer. The ordinal still lives in the
controller and still decides where a range extends from; what is gone is
the claim that the user needs to see it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 09:47:32 +02:00
dtourolleandClaude Opus 5 b120ce48ec Float the selection bar over the grid instead of above it
Selecting the first photograph inserted a 40px row into the view's
vertical flow, so every cell in the grid moved down by it. The act of
selecting shifted the thing being selected out from under the finger —
and a second tap aimed at the neighbour landed on the row below it,
which is the worst possible response to a gesture whose whole job is to
say "this one".

The bar is a floating one now, at the foot of the view. Nothing above it
is re-laid out, so selecting changes what is drawn and never where.

The grid's viewport grows by the same 40px while the bar is there rather
than the Flickable shrinking, which is what keeps that change invisible
too: every cell stays exactly where it was and there is simply further to
scroll, so the last row can be brought clear of the bar instead of being
trapped under it.

It also swallows presses that land on it. A bar floating over the grid is
a bar a thumb can reach for and miss into.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 09:47:20 +02:00
dtourolleandClaude Opus 5 ea88216008 Follow the raw histogram's line numbers after the module doc grew
Five tags in dr-gpu moved by three lines when lib.rs's module doc was
corrected. Coverage is unchanged at 68.2%; only the anchors moved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 09:31:16 +02:00
dtourolleandClaude Opus 5 3b2bb58fa4 Say what these documents describe now, not what they described in August
Three that had drifted past being merely out of date.

`docs/outstanding.md` still marked burst grouping, Flatpak and the Android
cluster as in progress, and described FR-CULL-5 as absent while listing a
forward reference in calibrate.rs that "will need correcting either way" --
it needs correcting now, and differently: the comment claims bursts
bootstrap the face calibration, which is still not what the code does.
FR-PLAT-AND-4 and FR-PLAT-AND-6 are half-met rather than unbuilt, which is
the state most likely to be reported as closed, so each says what is left.
FR-PLAT-LIN-3 is packaged but still unsatisfiable by packaging.

`core/dr-gpu/src/lib.rs` claimed for eight releases to hold "no pipeline, no
tiling, and no masks". It holds masks, segmentation, demosaic, detail, two
histograms and focus peaking. The zero-copy claim it was written to make is
the part still worth making.

`docs/milestone-v0.1.md` was a plan for a milestone delivered long ago and
read as though it were still ahead.

Committed with --no-verify, and the matrix is regenerated separately: the
hook would have scanned another session's uncommitted work in this shared
checkout and written its line numbers into the file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 09:30:46 +02:00
dtourolleandClaude Opus 5 49787414fb Regenerate the matrix after taking master in
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 09:24:38 +02:00
dtourolle f41edc03ff Merge master into wave-2
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

# Conflicts:
#	docs/traceability.md
2026-08-30 09:24:37 +02:00
dtourolleandClaude Opus 5 091306f736 Gate the gesture vocabulary the way the matrix is gated
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Build and test / Desktop (Linux) (push) Failing after 1h16m50s
Build and test / Layer separation (push) Successful in 39s
Traceability / Requirement traces (push) Successful in 50s
Build and test / Android (aarch64) (push) Successful in 22m27s
Three holes, all found by the gate catching itself out.

**Prose that mentions the tag was read as a tag.** `dr-ui`'s module list
carries a comment saying where the generated table comes from, and it
names `GESTURE:` in passing; the scan extracted that sentence fragment as
a gesture with no place and no way to perform it. A tag must now *open*
its comment. A line that merely mentions it is describing the mechanism,
not declaring a member of it, and position is the only thing that tells
the two apart — which also makes the string-literal guard fall out for
free rather than being a special case.

**Neither artefact was regenerated on commit.** They cite line numbers,
so they go stale on anything that moves a line — the sheet commit made
the document wrong about every gesture in `library.slint` without
touching a single one. The pre-commit hook that already keeps the matrix
in step now keeps these too, and unlike the matrix it *fails* rather than
shrugging when the scan does: a matrix that will not build leaves a stale
one in place, where a malformed gesture block means a user about to be
told the wrong thing.

**CI did not check them at all.** It does now, blocking. The matrix is
read; the gesture table is *shown to somebody using the application*, and
a stale one tells them to perform a gesture that no longer exists — from
which they will conclude the application is broken rather than the page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 00:51:13 +02:00
dtourolleandClaude Opus 5 1cd5ab6815 Put the gesture reference in the application
The document the previous commit generates is for somebody reading the
repository. The person who needs it most is holding a tablet, has just
discovered that a hold does something, and has nowhere to ask what else
does.

So the same scan writes a table the application draws: a "Gestures"
button beside Settings, a sheet with the same scrim and dismissal as the
ones that file and name, and every gesture grouped by where it applies
with its touch, pointer and keyboard routes side by side. Not the `why` —
that is the argument for the design and belongs in the document; on a
phone-sized card it would bury the one line the sheet was opened to read.

The sheet's file knows nothing about what a gesture is. It draws the rows
it is handed, and the rows come from the generated table, because a help
screen with its text typed into it is a second description of one
behaviour — and the second description is always the one that goes stale.
The commit before this deleted a gesture; a hand-kept sheet would still
be describing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 00:30:21 +02:00
dtourolleandClaude Opus 5 04203b4a82 Regenerate the matrix after taking master in
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 00:25:51 +02:00
dtourolle 4b212089d2 Merge master into wave-2
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

# Conflicts:
#	docs/traceability.md
2026-08-30 00:25:42 +02:00
dtourolleandClaude Opus 5 964e72bca2 Merge: the formatter's pass over the raw histogram
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 00:25:21 +02:00
dtourolleandClaude Opus 5 e38b730aa6 Reflow what rustfmt wanted in the raw histogram
The author could not run cargo, so this is the formatter's first pass
over the new module and its presentation half.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 00:25:16 +02:00
dtourolleandClaude Opus 5 12812452e8 Regenerate the matrix over the second wave
67.0% to 68.2% (122/179). FR-PLAT-AND-4 and FR-PLAT-AND-6 are the two
that moved; FR-CULL-3 was already tagged by the peaking half and now has
the reduction its other two bullets asked for behind the same tag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 00:24:54 +02:00
dtourolleandClaude Opus 5 df90dd95a2 Merge: a histogram that reads the sensor, beside the one that reads the frame
FR-CULL-3's other two bullets. What existed was a display histogram
tagged FR-DSP-7, counting AdjustPass's 8-bit output with r == 255
clipping counters -- it says a highlight is gone precisely where this
requirement needs it to say the highlight is recoverable.

The new reduction runs over the demosaiced scene-linear texture on a
stops-below-saturation axis: camera-native, unbalanced, unmatrixed,
uncurved, normalised by the sensor's own black and white levels, so 1.0
is saturation by construction. Four series, and the fourth is the
brightest channel rather than luma, because a weighted sum of unbalanced
values is a number about nothing. Cached per photograph, not per frame:
nothing downstream of the demosaic can move a count.

Both readings are legitimate and answer different questions, so the
panel offers a choice rather than replacing one with the other.

ARCH 5.5 is amended to match. It specified a pre-demosaic reduction;
retaining the CFA samples costs 48 MB at 24 MP and 120 MB at 60 MP
resident on every photograph opened, whether or not anyone looks at the
histogram, on the platform ARCH 6.2 exists for. The spec now records two
reductions, why the more complete one was not worth its cost, and what
the cheaper one cannot answer: it counts pixels not photosites, it
cannot see above white, and it is measured after the CFA pattern is gone.

Verified: clippy -D warnings clean, 98 dr-gpu tests, 556 dr-ui tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 00:14:34 +02:00
dtourolleandClaude Opus 5 31a3580f9d Extract the gesture vocabulary from the code that implements it
Every gesture the application has was documented in the comment beside
the `TouchArea` that implements it. Excellent comments, and unreachable
by anyone not reading the source — which is the FR-UI-4 failure in a
different costume: a gesture nobody can find is a feature only its author
knows about.

Writing them out again in a hand-kept help page is the failure this
avoids. Two descriptions of one gesture drift, and it is always the prose
that drifts: the code is exercised every time somebody uses the
application and the page is exercised never. A help screen confidently
describing a double tap the grid stopped honouring last week is worse
than no help screen — and the grid did stop honouring one, in the commit
before this.

So the comment beside the implementation stays the only copy, and a
`GESTURE:` block beside it is scanned into two artefacts: `docs/gestures.md`
for a reader, and a Rust table for the application to draw a help sheet
from. Both committed, both gated, so neither can quietly stop describing
the code.

It lives in the traceability crate because it is the same operation on
the same input — walk the tree, pull structured tags out of comments,
render, fail if the committed artefact has moved. Only the vocabulary is
new. It scans `ui` and `apps` alone: a gesture needs an interface to be
performed on, and excluding `tools` is also what stops the scanner
extracting its own worked examples as broken gestures.

Fifteen gestures so far, across the library grid and the People screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 00:09:49 +02:00
dtourolleandClaude Opus 5 20c368d3fc Give each touch gesture one meaning, and give tap-to-open back
I broke opening a photograph. The dwell added in "Tell a tap on a
photograph from a hand going past" required a finger to stay down 120 ms,
and a deliberate tap is routinely quicker than that — so the grid stopped
opening anything. Duration was the wrong discriminator: a tap and a brush
are the same length.

**Travel is what separates them, and a graze is by definition a moving
contact.** A press now records where it landed and the release compares:
within 12px it is a tap, beyond that the hand was going somewhere else.
No dwell, so no deliberate tap can be refused, and the rule is the same
for a finger and a mouse — one rule instead of two, and the `touch`
argument the dwell needed goes away with it.

Two real conflicts went with it, because a gesture set that overlaps
itself is unlearnable however each half is documented.

**A drag was also a hold.** Grabbing a cell and moving inside 450 ms left
the hold timer armed underneath the drag, so it fired mid-gesture and put
the grid into selection mode nobody asked for — the drag finished into a
mode that changed what every later tap meant. Starting a drag now cancels
it, exactly as a pinch already did.

**A double tap was also a range.** In selection mode two taps on one cell
selected everything back to where selecting began: no visible state, no
warning, from a thing a hand does by accident. "Select to…" does that job
and announces itself first, so the double tap is gone and two taps are
now two toggles that land where they started. `extend_to_row` went with
it — a second range implementation that only the double tap reached,
where every other range goes through `apply_press`.

The resulting vocabulary, one meaning each: tap opens, tap-and-slide does
nothing, hold starts selecting, drag files, two fingers resize, and while
selecting a tap only ever toggles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 00:09:37 +02:00
dtourolleandClaude Opus 5 c57fd8ec5a Merge: compile our own Java into the APK, and answer an image intent
The APK carried no Java of its own -- the only dex in it was Slint's --
which blocked FR-PLAT-AND-4's foreground service and FR-PLAT-AND-6's
outbound half alike. assemble-apk.sh now compiles everything under
android/java against android.jar and has d8 merge it with Slint's dex
into one classes.dex. With no .java in the tree the step is skipped and
the APK is byte-for-byte what it was.

-source 8 -target 8 -bootclasspath android.jar is load-bearing: from
-target 9 javac rejects -bootclasspath, platform classes then come from
the JDK instead of android.jar, and the build stays green while the
device raises NoClassDefFoundError.

FR-PLAT-AND-6 with it: VIEW, SEND and SEND_MULTIPLE filters, the launch
Intent read over JNI before Slint is given the app, and a hand-written
ExportProvider rooted at getFilesDir() rather than AndroidX FileProvider,
which would have meant Gradle. singleTask, because the new filters let
another app launch the activity while it runs and the default mode would
start a second android_main, Slint backend and wgpu device in one
process. Classes load through the activity's loader; FindClass on
android_main's thread sees only the system loader.

The share half has no caller yet and is deliberately untagged. The five
tests read the manifest and the Java through include_str!, so they fail
if a filter goes, if the activity stops being singleTask, if the provider
becomes exported, or if the authority and the class drift apart.

Verified: javac, d8 and a real dex merge run against the host SDK;
aapt2 link over the manifest; clippy -D warnings clean; 5 tests pass.
Not verified: anything needing a device.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 00:00:39 +02:00
dtourolle da3b1487f9 Merge: drain the job queue that nothing was draining
FR-PLAT-AND-4's Rust half, and FR-PLAT-AND-3's resumability with it. The
queue's claim_next, complete, fail and recover_orphaned had no callers
outside their own tests, so the jobs table accumulated rows nothing ever
ran.

It also fixes a claim that was not safe across two connections: the
deferred transaction took a read lock for the SELECT and only tried to
upgrade at the UPDATE, so in WAL the second worker got
SQLITE_BUSY_SNAPSHOT, which a busy handler cannot retry away. It never
double-claimed, but the loser errored. Now one UPDATE ... RETURNING.

No handler is wired, deliberately. The only enqueue site reachable in
the shipping app produces remote thumbnail jobs already served by the
async grid worker, and inventing a second network path blind is not
worth a requirement reading as covered on the strength of plumbing.

Verified: clippy -D warnings clean, 376 dr-catalog and 548 dr-ui tests,
18 runner tests including four-thread contention and crash recovery.

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

# Conflicts:
#	docs/traceability.md
#	ui/dr-ui/src/library.rs
#	ui/dr-ui/src/settings_ui.rs
2026-08-29 23:49:38 +02:00
dtourolleandClaude Opus 5 758436cc28 Keep the runner's borrow alive as long as the connection it reads
The first compile this branch had. One borrow error, in the four-thread
contention test: the `Runner` was the block's tail expression, and a
tail's temporaries are dropped after the block's locals, so it outlived
the `conn` it borrowed. Bound to a local, with the ordering rule written
down beside it -- it is exactly the shape someone tidies back.

Everything else stood: clippy clean at -D warnings, and all 18 runner
tests pass, including the four-thread four-connection claim and the
`UPDATE ... RETURNING` rewrite the author flagged as the riskiest line
in the diff.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:48:15 +02:00
dtourolle 4b6c110816 Count the sensor's own numbers, so a cull can see headroom the render hides
FR-CULL-3's remaining two bullets. What existed was a *display* histogram
tagged FR-DSP-7: it binds AdjustPass's Rgba8Unorm output, recovers an 8-bit
code value, and counts clipping as `r == 255`. Its own documentation says a
clipped bin means "a highlight that is actually gone rather than one the
transform might still recover", which is the opposite of what a culling
decision needs. FR-CULL-3 asks for the histogram of the sensor data, on the
explicit grounds that a rendered image "systematically lies about what is
recoverable in the raw", and a readout that measures the render cannot answer
that however it is presented.

So this is a second instrument beside the first rather than a setting on it.
Both are true; they are true about different things; the panel offers both
behind a chip row and the words travel with the numbers, because a raw
saturation figure drawn under a heading saying Highlights would be mislabelled
exactly where the difference matters.

**What is reduced over, and what it cost to decide.** ARCH §5.5 specified the
pre-demosaic CFA samples. This reduces over the demosaiced scene-linear
texture instead, and §5.5 is amended to record the choice rather than let the
specification and the code disagree in silence. The texture is camera-native —
unbalanced, unmatrixed, uncurved — and normalised by the sensor's own black
and white levels, so 1.0 is saturation by construction and the distribution
below it is the headroom question with no calibration to carry. Retaining the
CFA samples would mean keeping the packed u32 buffer Demosaicer::run currently
drops: 48 MB at 24 MP, 120 MB at 60 MP, resident per open photograph whether
or not anyone looks at the histogram, on a platform §6.2 exists because memory
is scarce on.

Three things it therefore cannot say, written into the module docs and into
§5.5 rather than left to be discovered: it counts pixels not photosites, so a
saturated site drags its interpolated neighbours up and per-channel clipping
is smeared by about a demosaic kernel; it cannot see above white, because
demosaic.wgsl clamps each photosite at 1.0 for its own good reasons (a Canon
6D reads to 16383 against a declared 15070) so "at saturation" and "a stop
past it" share a bin; and it is measured after the CFA pattern is gone, so it
can name which colour clipped in the reconstructed image but not which
photosite went first.

The axis is stops below saturation, 16 bins per stop over 256 bins — the same
bin count the display reduction uses, so the fold into drawable columns is
shared and a divergence between the two plots would have to be deliberate. A
linear axis spends half its width on the top stop, which is why nobody has
ever drawn a useful linear raw histogram. The fourth series is the brightest
channel rather than luma: these values are unbalanced, so any weighted sum of
them is a number about nothing, and the brightest channel is the one that
saturates first and so the one the headroom question is actually about.

It is a property of the file and not of the render, which has two
consequences. It is computed once per photograph and cached — nothing
downstream of the demosaic can move a count in it — so a cull does not pay the
display histogram's per-frame cost three thousand times. And it describes the
whole frame rather than the visible region, deliberately opposite to
DevelopSession::histogram: a crop changes what is on screen and changes
nothing about what the sensor recorded.

Tags are on the reduction, the type, its constructor and the presentation
arithmetic, each of which has a test that fails if the behaviour goes. The
Slint panel and the push from lib.rs keep their reasoning as prose: nothing
asserts them, and a tag would claim coverage the assertions are not making.
2026-08-29 23:36:21 +02:00
dtourolleandClaude Opus 5 09dddde594 Take the photograph another app hands over, and hand an export back
FR-PLAT-AND-6 asks for two things this app did neither of: be a receiver for
image view and share intents, and share exported results out through a
FileProvider. The manifest declared one activity with one MAIN/LAUNCHER filter,
so nothing on the device ever offered DarkRoom for a photograph, and there was
no route out at all — Android has refused file:// URIs between apps since API
24, and a content:// URI needs a provider to be behind it.

Inbound. Three filters now: VIEW for a gallery or a file manager, SEND and
SEND_MULTIPLE for the share sheet, all on image/*. `android_main` reads the
launch Intent before it gives `app` away to Slint, and what comes back is
passed to `dr_ui::run` exactly as argv is on the desktop — `startup_action`
already treats a non-empty list as "the user asked for these specifically",
which is what a share is.

The URIs are copied into the cache before the viewer opens, and that cost is
real: a shared raw file is written once, in full, on the startup path. A
content:// URI is a handle into another app's provider, not a path, and the
decoders take paths; the alternative is teaching the whole read path about
URIs, which is FR-PLAT-AND-1's SAF connector and is not built.

Outbound. ExportProvider serves one directory — getFilesDir(), which is the
same path `internal_data_path` gives the Rust side — and refuses everything
else by canonicalising the request and checking it is inside that root, so
`../` and a planted symlink fail the same test. Not AndroidX's FileProvider,
because AndroidX is a Maven artefact and this build has no resolver; what it
does is a hundred lines and they are here.

The share half has no caller. The provider, the URI grant and the chooser are
all in place, but the control that would invoke them belongs in `ui/dr-ui`, and
wiring it needs an `AndroidApp` the interface can reach. It is documented as
unwired and deliberately not tagged as covering the requirement.

`launchMode="singleTask"` comes with the filters and is not decoration: another
app can now launch this activity while it is running, and the default mode
answers that by creating a second NativeActivity in the same process — a second
android_main, a second Slint backend, a second wgpu device. The cost of the
fix is stated in the manifest: a share arriving while DarkRoom is already open
brings it forward without opening the image, because onNewIntent has no route
through android-activity's event stream.

The Java is Java because Android constructs it: a ContentProvider is
instantiated by the system from its manifest entry, and getIntent() exists only
on an activity object. Both directions live there rather than in JNI so that
what crosses the boundary is two method signatures instead of forty, each of
which is a string checked at run time and nowhere else.

What a test can hold: the declarations. Nothing about an Intent or a
ContentProvider is reachable from `cargo test`, but an intent filter that is
deleted takes the app out of every "open with" menu silently, and an authority
that stops matching its class raises a SecurityException inside somebody else's
app. The tests in lib.rs read the manifest and ExportProvider.java through
`include_str!` and hold both to that, on the host, which is the only place in
the workspace that looks at either file from Rust.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:32:48 +02:00
dtourolleandClaude Opus 5 846a249156 Drain the queue that nothing has ever drained
`jobs` has been a complete durable work queue since the catalog was
written, and nothing has ever taken a job out of it. `claim_next`,
`complete`, `fail` and `recover_orphaned` had no callers outside their own
tests; `enqueue` had three. So the table grew one row per photograph and
kept it forever, and FR-PLAT-AND-3's resumability was a property of code
that never ran.

`runner` is the missing half. It owns no thread, no clock and no policy,
and that is the whole design: on Android the process does not decide when
background work may run. WorkManager does, subject to Doze, battery saver
and FR-NC-6's network constraints, and it revokes permission mid-job by
calling onStopped(). So the runner exposes `run_one` — claim, run, record —
and `drain`, which repeats it against a budget, a deadline and a
cancellation flag the host owns. A `Worker.doWork()` with ten minutes calls
drain with a deadline; a desktop idle pass calls it with none. That is the
seam the Android service plugs into, and it needs no Android to test.

Handlers are supplied from above, because the catalog knows what needs
doing and nothing about how: a thumbnail needs a decoder and a fetch needs
a network stack, neither of which belongs under core/dr-catalog. A runner
claims only kinds some handler declares, so a queue holding work this
device cannot do is left alone rather than failed five times.

Four outcomes, and only two of them are the job's fault. Done deletes the
row; Retry backs off; Abandon gives up now, for a failure no retry can fix;
Interrupted releases the claim with its attempt refunded and ends the
drain, because the host stopped rather than the job — five backgroundings
in a row must not mark good work as failed. Process death is the fifth and
cannot report itself, which is what `recover` is for.

Recovery is called from `show_catalog_now`, which is the one place a
catalog is opened for a session and already returns early if one is open.
It has to be exactly once and before any worker starts: there is no owner
column, so a second pass while a worker held a claim would take it away.
The attempt a dead claim consumed is deliberately kept — a job that takes
the process down with it is indistinguishable from one that fails, and the
attempt counter is the only evidence that survives a death.

The tests cover claiming under contention twice over: sequentially across
two connections, and with four threads on four connections against one
catalog on disk, asserting every job ran exactly once. Plus completion,
backoff, giving up, abandoning, interruption, budget, deadline,
cancellation, and a job orphaned by a simulated crash being reclaimed and
run once rather than lost or repeated.

Not wired to a handler yet, and deliberately not: the only enqueue site
the app actually reaches is the remote scan's, whose thumbnails are already
served by the async grid worker, and `walk`'s two sites are reachable only
from the scan_local example. Inventing a handler to make the plumbing look
used is how a requirement comes to read as covered by code that does not
implement it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:31:47 +02:00
dtourolleandClaude Opus 5 d63872e5a9 Make a claim one statement, and give the queue what a runner needs
The claim was a deferred transaction around a SELECT and an UPDATE, and
under a single connection that is fine. Under two it is not what it looks
like: the SELECT takes only a read lock, the UPDATE tries to upgrade, and
in WAL a worker that read the same snapshot as another gets
SQLITE_BUSY_SNAPSHOT on its write. That is not an error a busy handler can
retry away — the fix is to roll back and start over — so the queue was
"safe" only in the sense that the loser failed loudly instead of taking a
job someone else was holding.

`UPDATE jobs SET state = 1, attempts = attempts + 1 WHERE id = (SELECT ...)
RETURNING ...` is one statement and so one implicit transaction that takes
the write lock immediately. Two workers serialise, the loser waits out its
busy timeout, and neither can see a row the other already holds. The
existing tests are unchanged by it, because from one connection the two
forms are indistinguishable — which is exactly why it was never noticed.

The rest is the surface a runner has to have and did not:

- `claim_next_matching` takes only kinds a worker can actually do. Without
  it a device with no connector claims `FetchOriginal`, fails it, and pays
  five wakeups and five backoffs per photograph to reach a conclusion known
  before it started. Filtering after a claim cannot work: the claim has
  already marked the row running.
- `abandon` gives up now, for failures no retry can fix. `fail` uses it for
  its own MAX_ATTEMPTS branch, so there is one statement that ends a job.
- `release` hands a claim back with its attempt refunded, for a worker that
  is being stopped rather than a job that is going wrong. `attempts` stands
  in for the owner column the table does not have: it is bumped by every
  claim, so a stale worker's release matches nothing and changes nothing.
- `reap_orphan_subjects` deletes jobs whose photograph is gone. Coalescing
  keeps the table one row per unit of work and nothing ever shrank it when
  the work stopped existing. `ScanFolder` is excluded because its subject
  is a folder id, and joining that against `images` deletes by coincidence
  of numbering — hence `JobKind::subject_is_image`, and `JobKind::ALL` so
  the next kind added cannot quietly fall out of the filter.
- `counts` is the number a foreground service's notification is built from.

One behaviour change worth stating: a kind this build does not recognise is
now parked with an error rather than read as `ExtractMetadata`. The old
`unwrap_or` would have run a job of an unknown kind as some arbitrary known
one, which is worse than not running it at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:31:27 +02:00
dtourolleandClaude Opus 5 d70f5c14e0 Name rust-analyzer everywhere the toolchain is installed
Build and test / Desktop (Linux) (push) Failing after 1h18m38s
Build and test / Layer separation (push) Successful in 48s
🐳 Android image / Build and push (push) Successful in 9m41s
Build and test / android-image (push) Successful in 9m41s
Traceability / Requirement traces (push) Successful in 48s
Build and test / Android (aarch64) (push) Successful in 21m54s
VS Code's rust-analyzer extension ships a server newer than 1.92.0
supports and prompts, on every window, to add one. Listing the component
in rust-toolchain.toml makes rustup supply the server matching the pin —
the same thing the pin buys everywhere else.

The cost lands outside the editor, which is why this is four files
rather than one. rustup reconciles that component list against the
installed toolchain on the first cargo call in the work tree and
downloads what is missing, inside whatever job happens to be running. An
unasked-for fetch in the middle of a build step is nobody's line item
and hard to find in a log. So every environment that builds this repo
names it too: baked into the Android image, and in the install step of
each CI job that rolls its own toolchain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 23:21:44 +02:00
dtourolleandClaude Opus 5 2955cad654 Regenerate the matrix over a night's merges
Coverage 59.8% to 67.0% (120/179). Eight of those came from tagging what
was already built; the rest are tonight's features -- FR-CULL-3's peaking
half, FR-CULL-5, FR-PLAT-AND-5.

FR-PLAT-LIN-3 and NFR-COMPAT-2 stay untagged deliberately. Both were
satisfied by a manifest and a document, and there is no source file to
hang a tag on that would fail if the behaviour went away.

This is also the first commit tonight the pre-commit hook has checked.
Every branch used --no-verify, because the hook runs the traceability
build and the branches were under a no-build rule; the matrix each of
them left untouched is regenerated here, once, over all of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:21:44 +02:00
dtourolleandClaude Opus 5 7418b040da Refuse a scan whose root has gone, instead of reporting it empty
FR-PLAT-AND-2, and a silent failure on both platforms. `dr_sync::scan`
stepped over a NotFound or PermissionDenied the way it does for a child
that vanished mid-walk -- correct for a child, wrong for the root, where
it ended the walk, returned Ok with nothing in it, and reported a
successful scan of a library that was no longer there.

A lost root is now its own error. The images under it are marked
Availability::Offline per FR-CAT-9 and no catalog row is deleted;
`library::persist` clears the mark per file as each one is listed again,
so a root that comes back needs no repair step.

Partly satisfied rather than closed, and the gap is worth stating.
The recovery half is real and reachable on Android today, because
`map_status` turns Nextcloud's 403 and 404 into it and Nextcloud is how
a phone actually gets a library in this build. The causes the
requirement names -- revocation, reinstall, a removed card -- are
properties of a persisted tree permission, and there is none: SAF does
not exist here, `SourceRef::Document` is constructed only in test
modules, and `LocalStorage` rejects the variant outright. When SAF
lands it becomes a third producer of this error and nothing above it
changes, which is why the discovery belongs in the connector.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:20:57 +02:00
dtourolleandClaude Opus 5 085ab766b3 Give memory back in the order the user will miss it least
FR-PLAT-AND-5. Android asks for memory back through onTrimMemory and
kills the process if it is not given; until now nothing listened, so the
answer was always "no".

A tiered registry answers instead: GPU caches first, then proxies, then
thumbnails, driven from android_main on MainEvent::LowMemory and
MainEvent::Stop. The order is the argument. A backgrounded app has no
window to draw and therefore no use for a render pipeline, while its
thumbnails are exactly what the user will be looking at half a second
after they come back -- so going into the background frees only the GPU
tier, and only being measured against death frees everything.

Sinks register beside the cache they free and hold weak handles, so the
registry cannot keep a controller -- and every decoded portrait in it --
alive past the interface it belonged to. `try_borrow_mut` and skip: a
warning can land mid-render, freeing textures under the code drawing
with them is worse than missing one, and a warning not acted on is
always followed by another.

The GPU test is the one that matters: an eviction must change no pixel.
A freed intermediate pool whose `colour_key` promise still stands
renders an empty texture, and nothing else would have caught it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:20:57 +02:00
dtourolleandClaude Opus 5 55cb9b5b30 Keep the test scene's own arithmetic from overflowing a u64
The first compile this branch ever had. `cargo fmt` reflowed four files
and clippy passed at -D warnings untouched, but one test panicked:
`the_signature_does_not_change_with_scale`, on "attempt to multiply with
overflow".

It is the fixture, not the feature. `scene()`'s little LCG multiplied the
block's y by the golden-ratio constant with a plain `*` while the term
beside it already used `wrapping_mul`, so any scene taller than about 104
pixels overflowed in debug. Only the scale test builds one that large,
which is why 345 of 346 passed around it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:20:26 +02:00
dtourolleandClaude Opus 5 e5db849f04 Mark a burst in the grid, and let it be folded away
The counterpart to the grouping: where the signatures come from, and how a
group reaches a cell.

Signatures are computed from the 256px thumbnails dr-thumbs already holds --
vastly more resolution than a 9x8 reduction can use -- so a library that has
been browsed, or that has synced somebody else's shards, has already paid for
them and no RAW is decoded for this. The consequence is stated rather than
hidden: an image with no thumbnail gets no signature and never joins a burst.
That is self-correcting, and it is why the pass runs when the thumbnail sweep
finishes rather than on a timer. Nothing happens at import and nothing happens
at query time.

The mark is drawn as a child of the cell's TouchArea, for the same reason the
star strip is: a click on it must not also reach `cell-clicked` and throw the
user into develop, and children are hit-tested before the element they sit in.
It is never hidden on hover the way the stars are -- a collapsed burst stands
in for frames that are not on screen, and something has to say so whether or
not a pointer is nearby.

Folding changes what the grid's *query* returns rather than what its cells
draw, because the grid is a window over an ordered query and the frames a fold
hides are mostly not loaded. So the predicate joins VISIBLE in every query
that lists or counts cells -- the window, the header's count, the run a
shift-click resolves, and the ordinal a scrub lands on -- under the discipline
VISIBLE's own comment sets out: present in four places of five is worse than
absent, because the counts disagree with the cells and neither looks wrong on
its own. There is a test for exactly that.

`the_window_read_walks_the_ordering_index` now includes the burst clause. It
asserts on the query plan while holding its own copy of the query, so left
alone it would have gone on reporting green against a query the grid no longer
runs. If the clause costs `images_grid_order` and puts the sort back, that
fails here rather than becoming jitter someone measures in six months.

The pass keeps its own drain timer in a thread-local instead of taking fields
on the library controller, so everything the feature needs to run lives in one
file and the screen that starts it holds nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:20:26 +02:00
dtourolleandClaude Opus 5 5226f7223b Group the frames of one moment, by when they were taken and what they look like
A burst is the commonest thing in a cull and the least interesting: twelve
frames of the same gull at 10 fps occupy twelve cells, are scrolled past
twelve times, and end with the photographer keeping one. FR-CULL-5 asks for
them to collapse to one representative and be judged as a unit.

Two signals, because neither alone survives a real library. Time alone groups
a whole wedding ceremony -- a photographer working steadily never leaves the
gap that would end the run. Similarity alone groups a studio setup shot across
two days, which is a project rather than a moment. Together they are specific:
adjacent in time *and* looks like the frame before it.

Two seconds is the time bound, and the reason is worth recording because the
figure looks absurd next to a 10 fps camera. `images.captured_at` is whole
seconds -- EXIF's DateTimeOriginal has no sub-second field and
SubSecTimeOriginal is optional and widely omitted -- so a burst arrives in the
catalog as ten frames sharing one timestamp. Any threshold finer than a second
is a threshold on information that is not there. Where the pace really is
faster, the similarity bound is what separates the frames.

Similarity is a 64-bit difference hash over a 9x8 box-averaged reduction,
compared between *adjacent* frames only. Chained rather than anchored on the
first frame, because by frame twenty a camera following a bird has nothing in
common with frame one while no two neighbours differ by much; the time bound is
what stops the chain running away. There is no all-pairs step and there must
never be one -- that is what turns a grouping pass into something nobody can
afford to run over 50k images.

Nothing here ranks a frame. FR-CULL-5 names the failure it is avoiding, which
is rejecting the only frame of an important moment because somebody blinked, so
there is no sharpness score and no best-of-burst. The representative is the
earliest frame -- a fact about the clock, not a judgement about the photograph
-- and the user's own choice lives in its own table so that rebuilding the
grouping cannot erase it. Same argument `people.ignored` makes one subsystem
over: nothing short of remembering a decision survives re-clustering.

A newly found burst is recorded *open*. Collapsing on discovery would be
tidier, and would also mean a background pass taking photographs off the screen
part way through a cull. The pass marks; the user folds.

It is a pass rather than a job kind for the reason catalog.md 10.2 gives for
face clustering: a burst is a property of a run of frames and has no natural
subject_id, so a per-image job would rebuild the world once per photograph.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:18:46 +02:00
dtourolleandClaude Opus 5 d0b671d4db Put the grouping dials where the regrouping is
The merge probability was `dr_face`'s constant and the smallest group was
a bare `< 2` in the clustering pass. Both were tuned on one library —
1,813 faces of one photographer's family — and the quantity they optimise
is a property of the population, not of the model. A household at close
family resemblance and two thousand strangers at a wedding want different
answers, and neither of them is the reference library. The doc comment
already conceded the point and pointed at `face_index --tune`; a
photographer does not have a terminal.

So they are `FaceSettings` now, saved per device beside the cache budgets
and edited from the People screen — beside the Regroup button that
applies them and the rail that shows what they did, because a value
changed three screens away from its effect is one nobody can tune.

Moving them is safe by construction, which is why nothing asks for
confirmation: a regroup writes only the suggested half, and
confirmations, names and ignores enter as anchors and come back
unchanged. The smallest-group rule is applied only to groups the system
invented — a group the user named or set aside survives it whatever its
size, because a display preference does not overrule a judgement.

**Withdrawal, without which the setting does nothing visible.** Raising
the smallest group stops the pass creating small groups; it does not
remove the ones a previous pass made, because those still hold their
suggestions, so they are not empty, so the prune leaves them. The pass
now releases every unanchored face it did not place before pruning.

And a dial you cannot see the effect of is not a dial. "What would this
do?" runs the same population through the clusterer without opening a
transaction and reports groups, faces grouped and largest group — one row
of `--tune`'s table, on the user's own library, on a worker thread. The
line leads with the group count because that is the number that says
which side of the right setting you are on: it climbs as fragments are
gathered into people and falls as separate people start being welded,
while the grouped-face count rises straight through both.

The preview parks its poll timer in a slot of its own. A preview and a
regroup are allowed to be in flight together, and sharing the sweep's
single slot would have the second to start drop the first's timer —
visible as a Regroup that finished on its worker and never said so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:18:46 +02:00
dtourolleandClaude Opus 5 24bf5be574 Compile our own Java into the APK, so the classes Android constructs can exist
The APK's only dex was Slint's. `assemble-apk.sh` found `classes.dex` under
the android-activity backend's build directory and copied it in, and there was
no `javac` step and no `d8` of anything of ours — package.sh's header said so
outright, on the reasoning that the app has no Java because android-activity
calls `android_main` directly.

That reasoning holds for everything the app *calls* and fails for everything
Android *constructs*. A `ContentProvider` is instantiated by the system from
its manifest entry; nothing in the process ever reaches its constructor, so
there is no JNI route to writing one in Rust. The launch `Intent` is the same
shape of problem from the other end: it arrives through `Activity.getIntent()`,
and the activity android-activity hands out is a stock `NativeActivity` rather
than a subclass with room for code. FR-PLAT-AND-6 needs both, and FR-PLAT-AND-4
needs a foreground `Service`, which is a third.

So: everything under `apps/darkroom-android/android/java/` goes through javac
against `android.jar`, and d8 merges the classes with Slint's finished dex into
one `classes.dex`. Merging rather than emitting a second dex keeps the staging
and zip steps as they are — multidex is native at API 28, but two files to keep
in step buys nothing at this size.

The step is skipped when the tree holds no Java, which is the state this commit
leaves it in. Nothing about the APK changes until a `.java` file appears.

`-source 8 -target 8 -bootclasspath android.jar` is not caution about language
features. It is the last combination in which javac allows the boot class path
to be replaced: from `-target 9` the flag is rejected, the platform classes
come from the JDK instead of from android.jar, and the build stays green while
the device raises `NoClassDefFoundError` for a class Android never shipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:18:18 +02:00
dtourolleandClaude Opus 5 e465ff0c80 Put the people filter where the filters are
Narrowing the grid to two people at once has worked since people became
a selector term, and it was effectively unreachable. The only control
that could add a second person lived on the People screen, behind
selecting them there, and it appeared only once the grid was already
narrowed to somebody — so "photographs with both of them" needed a
two-screen round trip the user had to guess at.

A filter belongs on the filter bar. A "People" chip there opens a tray of
everyone the library knows; tapping a name adds or removes them, and the
any/all chip beside it — already there, and already the thing nobody
found — now has something to sit next to that explains it. The caption
leads the row so a pair of chips means something before either is
pressed.

The tray is a strip under the bar rather than a popup, the way the
develop column's film picker is: the view scrolls as one, so an inline
strip is taller content and not a second overlay to dismiss. It scrolls
horizontally for the same hard reason the bar above it does — a layout
cannot be narrower than its children's minimums, and forty people would
otherwise set the minimum width of the whole view.

The roster is built on open, not kept in step: indexing and regrouping
change who exists, and a list cached at startup would be stale for
exactly the user who has just been naming people. Named first, then by
how much of them the library holds — the catalog orders by face count
alone, which puts a dozen unnamed strangers ahead of the two people the
user actually cares about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:18:07 +02:00
dtourolleandClaude Opus 5 6d5de21fb7 Tell a tap on a photograph from a hand going past
A brush across the grid opened whichever photograph was under it. Travel
was already answered — the Flickable claims the pointer and the press is
cancelled — but a contact that neither travels nor lasts reaches a
TouchArea as an ordinary press and release, and it was landing the user
in develop.

So a finger now has to stay down for `TAP_MIN_MS` before letting go
counts as opening anything. That is the floor under a tap where the 450 ms
`HOLD_DELAY_MS` is the ceiling: below is a graze, between is a tap, above
is a hold that starts a selection. One scale, three gestures.

Only a finger is held to it. A mouse click is a discrete decision made by
a button and is routinely over in thirty milliseconds, so `cell-pressed`
now reports whether a finger did it — the same finger-id convention the
pinch arbitration beside it already uses — and the dwell applies to touch
alone.

A graze still *selects* the cell it landed on, because the press already
did that. That is the right failure mode: something visible and
reversible rather than a silent nothing, and rather than develop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:18:07 +02:00
dtourolleandClaude Opus 5 e40f2bfee9 Let go of the keyboard when a name is finished
Pressing Enter on a person's name committed it and then kept the field
focused, so on a tablet the on-screen keyboard stayed up over the faces
the user had pressed Enter to get back to. The name looked accepted and
the screen looked stuck.

`Field` grows `release-focus()`, the other half of the `take-focus()` it
already had, and the Identity screen calls it from `accepted`. A function
rather than a property for the reason the existing one gives: focus is an
event, and bound to a property it would fight anything else that took it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:18:07 +02:00
dtourolleandClaude Opus 5 f5edd49b6b Let a manual collection be put in the order it is meant to be seen in
`collection_members.position` and `Sort::CollectionPosition` have been in the
catalog since collections were, and nothing above dr-catalog has ever written
or read either: `collections::set_order` had no callers, and the grid ordered
everything by capture time whatever it was scoped to — dr-ui does not construct
a `Query` at all, it has its own `GRID_ORDER` constant. So a manual collection
was a set with an order nobody could see or change.

Three pieces, because it could not be fewer:

`grid_order_for` decides the ordering from the scope, and both readers take it
from there. That is the load-bearing part. An ordinal only names a photograph
relative to an ordering, so the window read and the span read have to agree —
a shift-click resolved through a different ORDER BY than the cells were drawn
with selects a different run than the one on screen, and the user finds out
when the export runs. `read_ids_span` already stated that invariant about
`GRID_ORDER`; this widens it to an ordering that depends on the scope.

Only a single manual collection has one. A set draws its descendants' images
too, and two children's positions are unrelated integers that interleave
arbitrarily; a smart collection has no member rows to carry a position at all.
Both fall back to capture time and refuse the drop rather than pretending.

The drop is on the cell, on whichever half of it the finger landed — the
trailing edge is the only way to name the last place in a collection, since
there is no cell beyond the last one to drop in front of.

`reordered` is pure and the membership is rewritten whole. `set_order` sets the
positions it is given and leaves the rest, so a partial write would interleave
the moved run with rows nobody touched; and it is read unfiltered, so what the
filter is hiding keeps its place relative to what the user can see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:18:06 +02:00
dtourolleandClaude Opus 5 9d11554c71 Make the range gesture visible, and offer the whole grid at once
Touch has had a range gesture for as long as selection mode has: double-tap the
far end. It is invisible, it is unreliable on a grid that scrolls under the
second tap, and it extends from the anchor *before* the two taps moved it — a
rule subtle enough that the code needs two paragraphs to explain it to itself.
Nobody who was not told about it has ever used it.

"Select to…" is the same operation with state you can see. Press it, the strip
stops reporting and says "Tap the last photograph", and the next cell taken is
the far end. It reaches Rust as shift on `cell-pressed`, so it lands in
`apply_press` as the ctrl+shift it already is, and there is no third selection
policy to keep in step with the other two.

This is deliberately not the sweep gesture. A drag that paints cells can only
reach what is on screen, and the ranges that hurt on a tablet are longer than a
screenful — between the two taps here the user may scroll as far as they like,
and the run is resolved by the catalog rather than by what happened to be
loaded. A sweep is still worth having for short runs; it is not what this
should have rested on.

"Select all" beside it, asked of the catalog for the same reason: a select-all
that quietly meant "the hundred cells that happen to be loaded" is a lie the
user cannot see until the export runs.

The double-tap stays. It is tested, and an accelerator that costs nothing is
worth keeping for whoever has already learnt it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:18:01 +02:00
dtourolleandClaude Opus 5 d1f3b97245 Ask for the collection's name where the keyboard can reach it
"New collection from selection" created the collection under a placeholder
name and then opened the rename field in the sidebar tree.

On a tablet the sidebar is not on screen. It is instantiated all the same —
app.slint collapses it to zero width and `visible: false` rather than using an
`if`, because an `if` there is a layout loop Slint panics on — so the rename
field was created, its `init` took focus, and Android raised the on-screen
keyboard over a box nobody could see. Nothing else on the screen is focusable,
so the keyboard had nowhere to go: it stayed, the name could not be typed, and
the collection was already written under the name the user did not want.

Asked in a sheet instead, on the same card as the filing and keywording sheets,
before anything is written. That also fixes what was hiding behind it: an
abandoned rename used to leave a "New collection" in the tree, because the
collection existed before the name did.

`Field` gains `take-focus()` so a sheet whose field is the only thing to do in
it can answer the keyboard for the user — a function rather than a property,
because focus is an event and a bound property would re-take it on every
unrelated re-evaluation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:17:40 +02:00
dtourolleandClaude Opus 5 a2a0131693 Merge: face grouping the photographer can tune, people on the filter bar, and a tap that means it
Four fixes to the identity work, and one to the grid.

The name field lets the keyboard go when a name is finished, instead of
leaving it up over the faces the user pressed Enter to get back to.

Filtering to two people at once has worked since people became a selector
term and was unreachable behind a two-screen round trip. It is a tray on
the filter bar now, where filters are.

The merge probability and the smallest group the clusterer will call a
person were constants tuned on one library. They are settings, edited
beside the Regroup button that applies them, with a read-only preview
that answers what they would do to *this* library.

And a hand brushing past a photograph no longer opens it: a finger has to
stay down long enough to have meant it, on a scale between the graze and
the hold that starts a selection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 22:53:13 +02:00
dtourolleandClaude Opus 5 b9891e2c04 Merge master into tablet-selection
Two real conflicts, both from work that landed either side of the same
lines rather than against them.

`lib.rs`: the settings controller was hoisted above the People screen's
wiring, and Android's thumbnail-tier eviction registered itself at the
same point. Independent, so both stay.

`library.rs`: manual collection ordering and burst folding each added a
clause to the same two queries. The scoped range read now carries both —
the folding matters there for one step further on than it does in the
grid, because a collapsed burst is one cell, so an ordinal counted over a
list still holding every frame names a photograph several places away
from the one the user pointed at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 22:52:42 +02:00
dtourolleandClaude Opus 5 23c74d7063 Put the grouping dials where the regrouping is
The merge probability was `dr_face`'s constant and the smallest group was
a bare `< 2` in the clustering pass. Both were tuned on one library —
1,813 faces of one photographer's family — and the quantity they optimise
is a property of the population, not of the model. A household at close
family resemblance and two thousand strangers at a wedding want different
answers, and neither of them is the reference library. The doc comment
already conceded the point and pointed at `face_index --tune`; a
photographer does not have a terminal.

So they are `FaceSettings` now, saved per device beside the cache budgets
and edited from the People screen — beside the Regroup button that
applies them and the rail that shows what they did, because a value
changed three screens away from its effect is one nobody can tune.

Moving them is safe by construction, which is why nothing asks for
confirmation: a regroup writes only the suggested half, and
confirmations, names and ignores enter as anchors and come back
unchanged. The smallest-group rule is applied only to groups the system
invented — a group the user named or set aside survives it whatever its
size, because a display preference does not overrule a judgement.

**Withdrawal, without which the setting does nothing visible.** Raising
the smallest group stops the pass creating small groups; it does not
remove the ones a previous pass made, because those still hold their
suggestions, so they are not empty, so the prune leaves them. The pass
now releases every unanchored face it did not place before pruning.

And a dial you cannot see the effect of is not a dial. "What would this
do?" runs the same population through the clusterer without opening a
transaction and reports groups, faces grouped and largest group — one row
of `--tune`'s table, on the user's own library, on a worker thread. The
line leads with the group count because that is the number that says
which side of the right setting you are on: it climbs as fragments are
gathered into people and falls as separate people start being welded,
while the grouped-face count rises straight through both.

The preview parks its poll timer in a slot of its own. A preview and a
regroup are allowed to be in flight together, and sharing the sweep's
single slot would have the second to start drop the first's timer —
visible as a Regroup that finished on its worker and never said so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 22:32:26 +02:00
dtourolleandClaude Opus 5 a4b9deaf96 Put the people filter where the filters are
Narrowing the grid to two people at once has worked since people became
a selector term, and it was effectively unreachable. The only control
that could add a second person lived on the People screen, behind
selecting them there, and it appeared only once the grid was already
narrowed to somebody — so "photographs with both of them" needed a
two-screen round trip the user had to guess at.

A filter belongs on the filter bar. A "People" chip there opens a tray of
everyone the library knows; tapping a name adds or removes them, and the
any/all chip beside it — already there, and already the thing nobody
found — now has something to sit next to that explains it. The caption
leads the row so a pair of chips means something before either is
pressed.

The tray is a strip under the bar rather than a popup, the way the
develop column's film picker is: the view scrolls as one, so an inline
strip is taller content and not a second overlay to dismiss. It scrolls
horizontally for the same hard reason the bar above it does — a layout
cannot be narrower than its children's minimums, and forty people would
otherwise set the minimum width of the whole view.

The roster is built on open, not kept in step: indexing and regrouping
change who exists, and a list cached at startup would be stale for
exactly the user who has just been naming people. Named first, then by
how much of them the library holds — the catalog orders by face count
alone, which puts a dozen unnamed strangers ahead of the two people the
user actually cares about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 22:32:10 +02:00
dtourolleandClaude Opus 5 2403f355d6 Tell a tap on a photograph from a hand going past
A brush across the grid opened whichever photograph was under it. Travel
was already answered — the Flickable claims the pointer and the press is
cancelled — but a contact that neither travels nor lasts reaches a
TouchArea as an ordinary press and release, and it was landing the user
in develop.

So a finger now has to stay down for `TAP_MIN_MS` before letting go
counts as opening anything. That is the floor under a tap where the 450 ms
`HOLD_DELAY_MS` is the ceiling: below is a graze, between is a tap, above
is a hold that starts a selection. One scale, three gestures.

Only a finger is held to it. A mouse click is a discrete decision made by
a button and is routinely over in thirty milliseconds, so `cell-pressed`
now reports whether a finger did it — the same finger-id convention the
pinch arbitration beside it already uses — and the dwell applies to touch
alone.

A graze still *selects* the cell it landed on, because the press already
did that. That is the right failure mode: something visible and
reversible rather than a silent nothing, and rather than develop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 22:31:45 +02:00
dtourolleandClaude Opus 5 bbbc23f376 Let go of the keyboard when a name is finished
Pressing Enter on a person's name committed it and then kept the field
focused, so on a tablet the on-screen keyboard stayed up over the faces
the user had pressed Enter to get back to. The name looked accepted and
the screen looked stuck.

`Field` grows `release-focus()`, the other half of the `take-focus()` it
already had, and the Identity screen calls it from `accepted`. A function
rather than a property for the reason the existing one gives: focus is an
event, and bound to a property it would fight anything else that took it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 22:31:22 +02:00
dtourolleandClaude Opus 5 e027d34b65 Regenerate the matrix over a night's merges
Coverage 59.8% to 67.0% (120/179). Eight of those came from tagging what
was already built; the rest are tonight's features -- FR-CULL-3's peaking
half, FR-CULL-5, FR-PLAT-AND-5.

FR-PLAT-LIN-3 and NFR-COMPAT-2 stay untagged deliberately. Both were
satisfied by a manifest and a document, and there is no source file to
hang a tag on that would fail if the behaviour went away.

This is also the first commit tonight the pre-commit hook has checked.
Every branch used --no-verify, because the hook runs the traceability
build and the branches were under a no-build rule; the matrix each of
them left untouched is regenerated here, once, over all of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 22:22:01 +02:00
dtourolleandClaude Opus 5 82d9d077b0 Merge: answer Android's memory warnings, and stop reporting a lost root as an empty library
FR-PLAT-AND-5 in full, FR-PLAT-AND-2 in part -- the recovery is built and
live for Nextcloud roots, the SAF cause it names does not exist yet.

FR-PLAT-AND-4 and FR-PLAT-AND-6 are not here, both blocked behind the
same gap: assemble-apk.sh compiles no Java, so the APK cannot carry a
Service or a FileProvider. The container has JDK 17 and build-tools 36;
the build step is what is missing.

Verified: fmt, clippy --workspace --all-targets -D warnings, and 1043
tests across dr-catalog, dr-sync, dr-sync-folder, dr-sync-nextcloud,
dr-plat and dr-ui. The aarch64 target was checked before the branch was
finished but not after; no device was available.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 22:19:46 +02:00
dtourolleandClaude Opus 5 75fd5619ca Refuse a scan whose root has gone, instead of reporting it empty
FR-PLAT-AND-2, and a silent failure on both platforms. `dr_sync::scan`
stepped over a NotFound or PermissionDenied the way it does for a child
that vanished mid-walk -- correct for a child, wrong for the root, where
it ended the walk, returned Ok with nothing in it, and reported a
successful scan of a library that was no longer there.

A lost root is now its own error. The images under it are marked
Availability::Offline per FR-CAT-9 and no catalog row is deleted;
`library::persist` clears the mark per file as each one is listed again,
so a root that comes back needs no repair step.

Partly satisfied rather than closed, and the gap is worth stating.
The recovery half is real and reachable on Android today, because
`map_status` turns Nextcloud's 403 and 404 into it and Nextcloud is how
a phone actually gets a library in this build. The causes the
requirement names -- revocation, reinstall, a removed card -- are
properties of a persisted tree permission, and there is none: SAF does
not exist here, `SourceRef::Document` is constructed only in test
modules, and `LocalStorage` rejects the variant outright. When SAF
lands it becomes a third producer of this error and nothing above it
changes, which is why the discovery belongs in the connector.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 22:19:38 +02:00
dtourolleandClaude Opus 5 2b812ebe21 Give memory back in the order the user will miss it least
FR-PLAT-AND-5. Android asks for memory back through onTrimMemory and
kills the process if it is not given; until now nothing listened, so the
answer was always "no".

A tiered registry answers instead: GPU caches first, then proxies, then
thumbnails, driven from android_main on MainEvent::LowMemory and
MainEvent::Stop. The order is the argument. A backgrounded app has no
window to draw and therefore no use for a render pipeline, while its
thumbnails are exactly what the user will be looking at half a second
after they come back -- so going into the background frees only the GPU
tier, and only being measured against death frees everything.

Sinks register beside the cache they free and hold weak handles, so the
registry cannot keep a controller -- and every decoded portrait in it --
alive past the interface it belonged to. `try_borrow_mut` and skip: a
warning can land mid-render, freeing textures under the code drawing
with them is worse than missing one, and a warning not acted on is
always followed by another.

The GPU test is the one that matters: an eviction must change no pixel.
A freed intermediate pool whose `colour_key` promise still stands
renders an empty texture, and nothing else would have caught it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 22:19:29 +02:00
dtourolleandClaude Opus 5 6246a2e346 Merge: group the frames of one moment, and let a burst fold away
FR-CULL-5. Frames join a burst when they are adjacent in time and look
like the frame before them -- both, because time alone groups a whole
ceremony and similarity alone groups a studio setup across two days.
Adjacent pairs only, chained; there is no all-pairs step and there must
never be one.

No selection of any kind. The representative is the earliest frame, a
fact about the clock rather than a judgement about the photograph, and a
newly found burst arrives open, so the pass never takes a row off the
screen.

Verified: fmt, clippy --workspace --all-targets -D warnings, 346
dr-catalog tests, 511 dr-ui tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 22:07:05 +02:00
dtourolleandClaude Opus 5 fc4157a1e0 Keep the test scene's own arithmetic from overflowing a u64
The first compile this branch ever had. `cargo fmt` reflowed four files
and clippy passed at -D warnings untouched, but one test panicked:
`the_signature_does_not_change_with_scale`, on "attempt to multiply with
overflow".

It is the fixture, not the feature. `scene()`'s little LCG multiplied the
block's y by the golden-ratio constant with a plain `*` while the term
beside it already used `wrapping_mul`, so any scene taller than about 104
pixels overflowed in debug. Only the scale test builds one that large,
which is why 345 of 346 passed around it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 22:07:05 +02:00
dtourolle 91fe6cf300 Merge master into partial-preset-scope
🐳 Android image / Build and push (push) Successful in 8s
Build and test / android-image (push) Successful in 8s
Build and test / Desktop (Linux) (push) Failing after 1h17m12s
Build and test / Layer separation (push) Successful in 56s
Traceability / Requirement traces (push) Successful in 1m33s
Build and test / Android (aarch64) (push) Successful in 1h0m38s
# Conflicts:
#	docs/traceability.md
#	ui/dr-ui/ui/app.slint
2026-08-29 22:02:03 +02:00
dtourolleandClaude Opus 5 754ab91347 Bring a Lightroom library across, and start with something in the list
Two halves of the same complaint: a preset sheet that opens on "No
presets yet" is homework, and a photographer with ten years of presets
in Lightroom has no way to bring them.

`dr-preset-xmp` reads Camera Raw `.xmp`. The mapping turned out to be
mostly a rename rather than a conversion, because Adobe and this
pipeline already agree: exposure is in stops in both, and contrast, the
four recovery controls, clarity, texture, vibrance and saturation are
all ±100 in both. That is not imitation, it is the convention raw
developers converged on — `highlights_shadows.yaml` cites it in as many
words. Only sharpening needed arithmetic, Adobe's 0…150 against our
0…100.

The white balance does not come across, and says so rather than
guessing. Adobe writes absolute Kelvin for a raw file where ours is a
relative nudge from what the camera recorded, so converting needs the
*target image's* as-shot white balance — exactly what a preset cannot
carry, since the same preset lands on a frame shot at 3200K and one shot
at 7000K. A guess would be wrong on most images and invisibly so.

A folder is read as readily as a file, nested, because that is the shape
an exported preset folder is in and importing ninety files one at a time
is asking someone not to bother.

`dr_pipeline::starter` is six presets a first run begins with, written
against this pipeline in its units and deliberately mild — a starting
point, not a caricature. They are seeded when the library *file* does
not exist rather than when the library is empty, so deleting all six
does not hand them back on the next launch.

Both of these name operations, and `ui_names_no_operation` was right to
stop them living in `ui/`. That test exists because the failure is
silent and cumulative, and it caught exactly what it was written for: a
preset called "Punch" is a statement about contrast, clarity and
vibrance, and a table mapping Adobe's vocabulary to ours is a statement
about the pipeline. Neither is a fact about an interface. So the starter
set went into `dr-pipeline`, and the importer into its own crate —
between two walls, since `dr-pipeline` depends on nothing on purpose and
XMP is real XML not worth hand-rolling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 22:00:53 +02:00
dtourolleandClaude Opus 5 36ea53cceb Merge: stop a subject's name from setting the width of the develop column
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 21:49:40 +02:00
dtourolleandClaude Opus 5 3e19628324 Stop a subject's name from setting the width of the develop column
The column moved sideways the moment segmentation finished. It takes the
widest minimum any panel declares, each panel publishes
`layout.preferred-width` as that minimum, and `MaskPanel` gained rows
whose width came from the model's output -- so a photograph the user was
looking at jumped because a label said "traffic light".

`overflow: elide` did not prevent it and was never going to. Eliding is
what a `Text` does when it draws; at layout time it still asks for the
width of its whole string, and it is the asking that reaches the column.

Both the subject rows and the mask entries are bounded, because a mask is
itself a segmentation result -- without the second half the column moved
when a subject was clicked instead of when one was found.

The bound is stated for the reason `ChipGrid` declares its width from its
column count rather than from its options (ff6c313): what a panel asks
for must follow from its structure, never from its data. 160px is the
same judgement as that commit's 88px chip, and it is a policy rather than
a measurement -- there is no harness in the tree that measures a panel's
width, and the 482-to-351 figure in ff6c313 was read off the running app.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 21:49:33 +02:00
dtourolle 4a04496c78 Merge: focus peaking, so a frame can be judged without zooming to 100%
FR-CULL-3's peaking half. The raw histogram and raw clipping indicators
remain unbuilt -- what exists is a display histogram tagged FR-DSP-7,
counting AdjustPass's 8-bit output, which reports a highlight as gone
precisely where FR-CULL-3 needs it to report the highlight recoverable.

Verified before merge: fmt clean, clippy --workspace --all-targets
-D warnings green, 11 focus GPU tests, 79 baseline dr-gpu tests, 511
dr-ui tests. The cfg(target_os = "android") arm is unverified -- the
host-target clippy never compiled it.

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

# Conflicts:
#	ui/dr-ui/src/lib.rs
#	ui/dr-ui/ui/app.slint
2026-08-29 21:43:35 +02:00
dtourolleandClaude Opus 5 2168cdd1c4 Mark what is in focus, so a frame can be judged without zooming to 100%
FR-CULL-3's focus peaking. One compute dispatch measures local contrast
in WGSL and writes an overlay texture; on desktop it reaches Slint
through the same zero-copy wgpu import the canvas uses, so nothing
per-pixel touches the CPU on the frame path.

With peaking off the cost is zero and structurally so: focus_overlay
opens with `let settings = self.peaking?;` before the frame is touched,
and clearing drops both overlay textures, so no VRAM is held either.

NFR-P14 is met by construction rather than by measurement -- one
dispatch, no second render, no pipeline compile after session open, and
a test asserting allocations stay at 2 over eight frames. The budget
test asserts 50ms at 4K rather than a tight bound, deliberately: a tight
bound fails on a loaded machine and gets deleted, which is worse than a
loose one that still catches the regression that matters.

TD-1 is amended rather than joined by a TD-6: on Android the overlay
rides the readback that already exists there, roughly doubling that
transfer while peaking is on, and TD-1's own "Done when" removes both
because both are the same missing capability.

Verified: cargo fmt clean; clippy --workspace --all-targets -D warnings
green, which also compiles peaking.slint through dr-ui's build.rs; 11
focus GPU tests and 79 baseline dr-gpu tests pass; 511 dr-ui tests pass.
Not verified: the cfg(target_os = "android") arm, which the host-target
clippy never compiled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 21:43:08 +02:00
dtourolleandClaude Opus 5 7ad75dd905 Let a manual collection be put in the order the photographer wants
`collections::set_order` and `Sort::CollectionPosition` have been in the
catalog since collections were, and nothing above dr-catalog has ever
called either. A manual order existed, could not be seen, and could not be
set. This is the half that was missing.

Three pieces, because it needed all three to be visible at all.

The catalog gains `orders_manually` and `members_in_order`. The first is
the rule about *when* a manual order means anything, kept in one place with
one name: a collection must be manual, and it must have no children. A set
shows its descendants' images, and positions are only ever assigned within
one collection — so two children's positions are unrelated integers, and
ordering by them would sort the grid by a coincidence. The existing comment
on `read_cells_scoped` already argued this; now something enforces it.

`members_in_order` returns the *whole* membership rather than the filtered
view, because `set_order` renumbers exactly what it is handed. Reordering a
filtered list would renumber those and leave every hidden image on a stale
position — two images sharing one, and a grid that rearranges itself the
moment the filter comes off.

The grid reads position where the scope qualifies and capture time
everywhere else. Manual order joins the member row rather than testing
membership with `IN`, which is safe from fanning out rows *because* that
branch is a single collection.

The gesture is a DropArea over the viewport, drawn only where a reorder
means something, with a caret in the gap the photographs would go into —
a line between two images rather than a highlight on one, because lighting
up a cell would say the drop replaces it.

The trap worth naming: `DropEvent.position` is in **window** coordinates.
Slint maps it through `map_to_window` when the drag begins and hands every
target the same event untranslated, so a target inside a Flickable has to
subtract its own `absolute-position`. Getting that wrong is invisible until
the grid is scrolled, because at the top the two frames coincide.

`reordered` is pure and names its destination by the image it goes before
rather than by an index, because the grid can only name a gap in what it is
showing and the ids are what survive a window swap. A drop that changes
nothing returns the order untouched: that counter is what a cross-device
merge resolves by, and spending a revision on a no-op makes this device win
an argument it did not have.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:49:00 +02:00
dtourolleandClaude Opus 5 b52be080ff Merge: say which requirements the code already satisfied, and which it does not
Eight requirements were implemented and untagged; tagging them takes
coverage from 59.8% to 64.2% without a line of feature code. Five more
were refused rather than tagged -- R2's own acceptance criterion still
reads '(figure TBD)', R5 has one of three clauses built, FR-RAW-2 meets
the requirement's purpose but not its stated mechanism.

docs/outstanding.md records what is genuinely unbuilt, so the gap reads
as a decision rather than an oversight. It also names four requirements
the matrix reports as covered that are not, including two covered only
by string literals inside the traceability tool's own test fixtures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 20:46:08 +02:00
dtourolleandClaude Opus 5 4d0ad0601c Merge: a Flatpak that asks for no filesystem, and the channels v1 ships through
FR-PLAT-LIN-3 and NFR-COMPAT-2. The manifest grants no filesystem
permission of any kind, which is not an oversight: a folder library is
chosen by typing an absolute path, nothing in the tree calls the
FileChooser portal, and a static --filesystem= grant would have made
that design appear to work by not testing it. docs/distribution.md
records what a portal-based picker would take.

No Flatpak has been built -- flatpak-builder is not installed here --
so the finish-args set is reasoned from what the binary links, not
observed to be sufficient.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 20:46:02 +02:00
dtourolleandClaude Opus 5 115653a262 Mark a burst in the grid, and let it be folded away
The counterpart to the grouping: where the signatures come from, and how a
group reaches a cell.

Signatures are computed from the 256px thumbnails dr-thumbs already holds --
vastly more resolution than a 9x8 reduction can use -- so a library that has
been browsed, or that has synced somebody else's shards, has already paid for
them and no RAW is decoded for this. The consequence is stated rather than
hidden: an image with no thumbnail gets no signature and never joins a burst.
That is self-correcting, and it is why the pass runs when the thumbnail sweep
finishes rather than on a timer. Nothing happens at import and nothing happens
at query time.

The mark is drawn as a child of the cell's TouchArea, for the same reason the
star strip is: a click on it must not also reach `cell-clicked` and throw the
user into develop, and children are hit-tested before the element they sit in.
It is never hidden on hover the way the stars are -- a collapsed burst stands
in for frames that are not on screen, and something has to say so whether or
not a pointer is nearby.

Folding changes what the grid's *query* returns rather than what its cells
draw, because the grid is a window over an ordered query and the frames a fold
hides are mostly not loaded. So the predicate joins VISIBLE in every query
that lists or counts cells -- the window, the header's count, the run a
shift-click resolves, and the ordinal a scrub lands on -- under the discipline
VISIBLE's own comment sets out: present in four places of five is worse than
absent, because the counts disagree with the cells and neither looks wrong on
its own. There is a test for exactly that.

`the_window_read_walks_the_ordering_index` now includes the burst clause. It
asserts on the query plan while holding its own copy of the query, so left
alone it would have gone on reporting green against a query the grid no longer
runs. If the clause costs `images_grid_order` and puts the sort back, that
fails here rather than becoming jitter someone measures in six months.

The pass keeps its own drain timer in a thread-local instead of taking fields
on the library controller, so everything the feature needs to run lives in one
file and the screen that starts it holds nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 20:39:51 +02:00
dtourolleandClaude Opus 5 bb35665bd2 Let a paste carry some kinds of edit and not others
FR-DEV-6 asks for presets "covering a subset of the edit graph". What
landed with the named presets covered two subsets: everything, and
everything but the crop. "Match the colour but not the sharpening" had
no way to be said.

`Scope` is now a set of `Attribute` — the same six kinds every operation
already declares and the develop panel already builds its tabs from. The
photographer ticking "tone and colour" is naming the groups they
navigate by, and neither this module nor the interface has to name an
operation to do it (FR-DEV-3c).

The pleasing part is what left. Framing used to be excluded by an
explicit test against one operation's id; it is now excluded because
Geometry is not in the default set. The special case dissolved into the
general rule, and the argument for it — a crop is a decision about *this*
photograph, and carrying it across forty destroys forty compositions —
is now a statement about a kind of edit rather than about a node. All
thirty-three existing preset tests pass unchanged, which is the evidence
that the generalisation kept its promises.

One decision that is a field rather than a rule, because the two cases
genuinely differ. An operation this build cannot classify — from a newer
version, arriving over sync — travels under "everything" and "everything
but the crop", because those are claims about the whole edit and an
unrecognised operation is part of it (FR-NC-8). It does not travel under
a hand-picked set, because that is a claim about kinds, and an unknown
kind is not one of the kinds that were ticked.

The settings page's "Copy crop and rotation" checkbox is gone, replaced
by the same chips the preset sheet draws. It asked the right first
question — geometry is the kind whose accidental travel destroys work —
but it was the only question a boolean could ask. The field stays in
`Settings`, read exactly once to seed the new set, so anyone who had
ticked it keeps their behaviour.

The chips are deliberately not in the develop column. Six of them there
would set the width of the whole sidebar, which is the bug `ChipGrid`'s
comment records at length.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:38:19 +02:00
dtourolleandClaude Opus 5 d3dadbd725 Group the frames of one moment, by when they were taken and what they look like
A burst is the commonest thing in a cull and the least interesting: twelve
frames of the same gull at 10 fps occupy twelve cells, are scrolled past
twelve times, and end with the photographer keeping one. FR-CULL-5 asks for
them to collapse to one representative and be judged as a unit.

Two signals, because neither alone survives a real library. Time alone groups
a whole wedding ceremony -- a photographer working steadily never leaves the
gap that would end the run. Similarity alone groups a studio setup shot across
two days, which is a project rather than a moment. Together they are specific:
adjacent in time *and* looks like the frame before it.

Two seconds is the time bound, and the reason is worth recording because the
figure looks absurd next to a 10 fps camera. `images.captured_at` is whole
seconds -- EXIF's DateTimeOriginal has no sub-second field and
SubSecTimeOriginal is optional and widely omitted -- so a burst arrives in the
catalog as ten frames sharing one timestamp. Any threshold finer than a second
is a threshold on information that is not there. Where the pace really is
faster, the similarity bound is what separates the frames.

Similarity is a 64-bit difference hash over a 9x8 box-averaged reduction,
compared between *adjacent* frames only. Chained rather than anchored on the
first frame, because by frame twenty a camera following a bird has nothing in
common with frame one while no two neighbours differ by much; the time bound is
what stops the chain running away. There is no all-pairs step and there must
never be one -- that is what turns a grouping pass into something nobody can
afford to run over 50k images.

Nothing here ranks a frame. FR-CULL-5 names the failure it is avoiding, which
is rejecting the only frame of an important moment because somebody blinked, so
there is no sharpness score and no best-of-burst. The representative is the
earliest frame -- a fact about the clock, not a judgement about the photograph
-- and the user's own choice lives in its own table so that rebuilding the
grouping cannot erase it. Same argument `people.ignored` makes one subsystem
over: nothing short of remembering a decision survives re-clustering.

A newly found burst is recorded *open*. Collapsing on discovery would be
tidier, and would also mean a background pass taking photographs off the screen
part way through a cull. The pass marks; the user folds.

It is a pass rather than a job kind for the reason catalog.md 10.2 gives for
face clustering: a burst is a property of a run of frames and has no natural
subject_id, so a per-image job would rebuild the world once per photograph.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 20:37:54 +02:00
dtourolleandClaude Opus 5 8d5dc423ea Say that all three of FR-CULL-3's bullets are unbuilt, not one
The first draft got this half right and half wrong. It correctly said the
existing histogram and clipping indicators are not FR-CULL-3's, but it
described them as adjacent — as though the work left were mostly focus
peaking and a relocation.

They are not adjacent. The histogram reads AdjustPass's 8-bit output and
counts clipping as r == 255, so it describes the frame the display is
about to show, after the entire develop chain. FR-CULL-3 asks for the
histogram of the sensor data, and gives its reason in the requirement
itself: a rendered image "systematically lies about what is recoverable in
the raw". A readout taken from the render cannot answer that question
however it is presented, which means two of the three bullets need a new
measurement rather than a new placement.

Found by the agent building focus peaking, who had to go looking at the
counters to find out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 20:36:51 +02:00
dtourolleandClaude Opus 5 1cd58bcaaa Say where a range ends in words, instead of hiding it in a double tap
Touch has had a range gesture for as long as selection mode has: hold to
enter it, then double-tap the far cell. Nobody finds it. It is invisible,
it is unreliable on a grid that scrolls under the second tap, and it
extends from the anchor as it was *before* the two taps moved it — a rule
subtle enough to need two paragraphs of Rust to explain itself.

The deeper problem is the timer. A double tap is bounded by the double-tap
interval, so the two ends have to be on screen together. The ranges that
actually hurt on a tablet are longer than a screenful, and those are
exactly the ones it cannot describe — which is also why a drag-to-select
sweep would not have fixed this, and why it is not what went in.

So: a "Select to…" button arms the range, the strip stops reporting and
starts instructing ("Tap the last photograph — scroll first if you need
to"), and a Cancel puts it down. Nothing is timing the two taps, so the
user may scroll as far as they like between them, and the run is resolved
by the catalog rather than by what happens to be loaded.

An armed range reaches Rust as `cell-pressed`'s shift argument, because
that is what it is: `apply_press` already reads ctrl+shift as "add the run
from the anchor to here", and in selection mode ctrl is already set. So
this needs no Rust state and no third selection policy — one copy of the
rules, in the function that had them.

"Select all" goes in beside it, asked of the catalog for the same reason:
the window is a hundred cells over a library of thousands, and a select-all
that quietly meant "the hundred that are loaded" is a lie the user cannot
see until the export runs. It sets the anchor to the first frame, or a
"Select to…" straight afterwards would reach `apply_press` with no anchor,
take its shift branch, and clear everything it had just taken.

The strip is at four buttons and a count now, so "New collection from
selection" loses its tail — the sheet it opens already says "New collection
holding 12 photographs" across the top.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:31:38 +02:00
dtourolleandClaude Opus 5 ab49280614 Ask for the collection's name before there is a collection to name
"New collection from selection" created the collection under a placeholder
name and then opened the sidebar tree's rename field to correct it.

On a tablet that field is not on screen. The collections panel is always
instantiated — app.slint collapses it to zero width and `visible: false`
rather than using an `if`, because an `if` there is a layout loop Slint
panics on — so the rename TextInput was created all the same, its `init`
called `self.focus()`, and Android raised the on-screen keyboard for a box
nobody could see. Nothing else on the screen is focusable, so the keyboard
had nowhere to go back to: it stayed up, the name could not be typed, and
the collection was already written under the name the user did not want.

Asked in a sheet instead, on the same card, scrim and dismissal the filing
and keywording sheets use. The field takes the keyboard as the sheet
appears — over a field that is actually drawn, which is the whole
difference — and the card sits a third of the way down rather than centred,
because on a tablet the keyboard is the bottom half of the window.

Nothing reaches the catalog until Create. That also ends a second bug the
old order could not avoid: an abandoned rename used to leave a collection
called "New collection" behind, because creating came first.

`Field` grows a `take-focus()` for this. A function rather than a property:
focus is an event, and bound to a property it would fight whatever took
focus next and re-take it on every unrelated re-evaluation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:31:02 +02:00
dtourolleandClaude Opus 5 0d9f802f88 Regenerate the matrix that this branch is about
Eight requirements gained a tag in this branch's first commit, so the
generated matrix is stale until it is rerun. Coverage moves from 59.8%
(107/179) to 64.2% (115/179), and the untagged list drops from 72 entries
to 64.

Regenerating here rather than leaving it to the pre-commit hook, because
this branch's subject *is* the matrix: a reader comparing the two commits
should see the number move for a stated reason. The eight are R3, R6,
FR-DEV-1, FR-UI-6, FR-NC-6d, NFR-OPS-3, NFR-PORT-2 and NFR-SEC-3, and
none of them is new work — every one was already satisfied by code that
had simply never said so.

Six of the thirteen tag sites were new `TRACES:` lines rather than
additions to existing ones, so the tag count moves 896 → 902 while the
covered count moves by eight. Orphan tags remain at zero.

Run from source with `cargo run -p traceability -- report`, never from a
prebuilt binary, and note that the tool tracks line numbers — the six
prepended module tags shift every subsequent line in their own files,
which accounts for the churn in this diff that is not a coverage change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 20:26:07 +02:00
dtourolleandClaude Opus 5 88ef7148d6 Write down what is not built, so the gap reads as a decision
The traceability matrix reports one number and cannot say what it means.
An untagged requirement is either one nobody built or one somebody built
and did not label, and both look identical in the summary table. Eight of
the second kind were tagged in the previous commit. This is the first
kind, written out with the reasoning, so that the distance between the
register and the binary is something a reader can see rather than
reconstruct from a percentage.

Eleven clusters, each verified against the tree rather than inherited
from a survey. Several turned out to be more interesting than "not done":

Plugins are 21 untagged requirements — nearly a third of the shortfall —
and §7 already lists the Plugin API as out of scope for v1 while §3.10
spends 280 lines specifying it. The two that *are* built, FR-PLG-2 and
FR-PLG-2d, are the declarative operation format, which is a plugin system
that resolves at build time. The fix here is an edit to requirements.md,
not code, and D16 has to be answered before any format is called stable.

FR-DSP-2 is not merely unbuilt; it is under challenge. frame_budget.rs
and TD-4 independently argue that tiling costs more than it saves on the
interactive path, so the open question is whether the requirement should
survive, not when it will be met — and the spike that would settle it, S6,
has not run.

NFR-A11Y-1's hard half is done and its easy half is not. LocalizedKey
already keeps display strings out of core/ and labels::resolve is a single
resolution point; what that point does is a hardcoded English match, so a
translation needs a recompile, which is the one thing the requirement
forbids.

The §4.1 performance targets are unverified rather than unmet. §8 requires
a per-commit benchmark suite whose regressions fail the build; there is no
benches/ directory, no criterion, and no benchmark step in any of the
three CI workflows. The one guard that does live in CI skips itself
without a GPU adapter and asserts its CPU half only in release, while CI
tests in dev. Nothing says the targets are missed. It says nobody would
find out.

And three requirements are reported as covered while not being met: R1 and
NFR-OPS-1 are tagged only by string literals inside the traceability
tool's own tests, which it scans along with everything else, and
FR-CAT-13's single tag sits on keyword storage while no XMP is parsed or
written anywhere. CONTRIBUTING already warns that a tag proves a tag
exists; these are the specific ones.

Focus peaking, burst grouping, Flatpak packaging and the Android platform
integration are being built in parallel and are marked in progress rather
than listed as absent, so those lines can be struck as they land.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 20:24:26 +02:00
dtourolleandClaude Opus 5 8165619065 Describe the project the README is actually in front of
It said the status was "early", that v0.1 is a remote library viewer, and
that the zero-copy display path was "not yet working" and its replacement
was the highest priority. All three were true when they were written and
none of them has been true for eight releases.

The zero-copy line was the damaging one, because it is the project's
central architectural constraint and the README stated the opposite of
what happened. Desktop keeps the zero-copy path — the compute pass writes
a texture Slint composites directly — and the readback survives in exactly
one place, the Android develop view, because wgpu's Vulkan swapchain tears
a portrait window on a tablet whose panel is mounted landscape. That is
TD-1, with the on-device measurements and the three things any one of
which would remove it. A reader who took the old text at face value would
have gone looking for a bug that was fixed, and missed a compromise that
was chosen.

"Current state" now says what runs, checked item by item against the tree
rather than from memory: the first draft claimed AVIF and JPEG XL export,
which the settings page offers and dr-export refuses on purpose, and
sixteen declared operations where there are fifteen. Both are the kind of
error this commit exists to remove.

The requirement count moves from 122 to 179, the documentation table
gains the five documents somebody would actually want next, and the
"Not built" paragraph points at docs/outstanding.md rather than leaving
the reader to infer the gap from silence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 20:23:35 +02:00
dtourolleandClaude Opus 5 ab0ef6a26d Say which requirements the code was already satisfying
Thirteen requirements were surveyed as built but untagged. Eight of them
were: R3, R6, FR-DEV-1, FR-UI-6, FR-NC-6d, NFR-OPS-3, NFR-PORT-2 and
NFR-SEC-3. Each was read against its full text in requirements.md and
against the code before the tag was added, because a tag that is wrong is
worse than an absent one — it turns a visible gap into an invisible one.

The five that were refused, and why, because the reasoning is the part
worth keeping:

R2 carries "(figure TBD)" in its own acceptance criterion and asks for a
stated prefetch margin and cache-hit rate; neither figure exists anywhere
in the tree and neither quantity is measured, while TD-2 and TD-3 both
describe the thumbnail path falling short of it.

R5 asks for three things and the code does one. The display pipeline does
run at viewport resolution, but "only visible tiles are computed" and
"panning recomputes only newly exposed tiles" need a tile scheduler that
does not exist — and frame_budget.rs currently argues for striking tiled
computation from the interactive path rather than building it.

FR-RAW-2 asks for a trait taking a SourceRef, so that a second decoder can
be added without changing callers. What exists is free functions over
&[u8]. That meets the requirement's stated *purpose* — the same decoder
serves a local file, a SAF document and a byte range, which is exactly why
it takes bytes — but there is no trait and no second implementation seam,
so the requirement should probably be amended rather than tagged.

NFR-ARCH-1 asks for named executors with stated thread counts.
architecture.md §7.1 states the table; nothing implements it. Workers are
twenty-odd ad-hoc std::thread::spawn sites, each building its own
one-worker tokio runtime, with no decode pool, no GPU-submit executor and
no I/O pool. The requirement's own text says R4 and NFR-P9 "assert an
outcome with no stated means", and that is still true.

NFR-SEC-4 is satisfied by absence — there is no telemetry — and absence
has no module to tag. A tag would point at nothing.

NFR-OPS-3 was the closest call of the eight taken. The store is single,
separate from the catalog, survives a catalog rebuild and does not sync
between devices; it has no version *field*, deliberately, and
settings.rs argues why and names the condition that would need one. The
substance is met and the reasoning is recorded where it belongs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 20:23:16 +02:00
dtourolleandClaude Opus 5 a56ac87e2c Build a Flatpak that asks for no filesystem at all
FR-PLAT-LIN-3 says that where DarkRoom is distributed as a Flatpak, filesystem
access uses portals. There was no manifest, so the sandboxed path had never been
exercised and the requirement had never been tested against the code.

The manifest is YAML rather than JSON because it has more to explain than to
declare, and every permission in finish-args carries the argument for itself.
The two that are not obvious:

- --device=dri is not an optimisation. The develop pipeline is compute shaders
  through wgpu with nothing behind it while NFR-R8 is open, so without the
  render node the application starts and cannot develop.
- --talk-name=org.freedesktop.secrets rather than the Secret portal. These are
  different things and the requirement's wording invites the wrong one: the
  portal hands an app a master key for a store it keeps itself, whereas the
  session's secret daemon is what keeps the Nextcloud app password visible to
  secret-tool and Seahorse, and therefore individually revocable by the user.
  FR-NC-2's degraded mode is what happens if nothing answers, inside the sandbox
  exactly as outside it.

There is no --filesystem= line, and that absence is the substance rather than an
oversight. It leaves the document-portal path working — a photograph opened from
a file manager arrives in argv under /run/user/$UID/doc and opens with no code
change — and leaves library selection broken, because dr-sync-folder takes a
typed absolute path and nothing in the tree calls the FileChooser portal.
--filesystem=host would fix that and is precisely what the requirement forbids;
--filesystem=xdg-pictures would fix it by not testing the design, in a smaller
directory. docs/distribution.md §4 records what actually closes the gap and the
flatpak override to use in the meantime.

The runtime version is chosen from what the binary needs rather than from what
is newest. ldd on a release build names fontconfig, freetype, expat, libpng,
zlib, brotli and bzip2 and nothing more: wgpu dlopens libvulkan.so.1, and x11rb
and wayland-client speak the wire protocols in Rust rather than binding libxcb
or libwayland. So what the runtime must supply at runtime is a Vulkan loader and
an ICD, which is the GL extension's job, and freedesktop 25.08 carries a
rust-stable extension at 1.98.0 — comfortably above the workspace's 1.92
minimum. That extension is not rustup, so rust-toolchain.toml's pin is ignored
here; the manifest says why that is correct rather than a violation of
CONTRIBUTING.md, since the pin exists to make fmt and clippy agree and neither
runs in a packaging build.

Like the PKGBUILD, it builds from the local checkout, so a build is of what you
are working on. That needs network for cargo, which Flathub forbids — the
comment says what a submission there would need instead and why generating
30,000 lines of vendored sources buys nothing yet. The LFS pointer check is
carried over from the PKGBUILD for the same reason it exists there: a dir source
copies a 130-byte pointer in without complaint, and the failure would land on a
user's machine rather than the packager's.

Not verified: nothing has been built. flatpak-builder is not installed here and
the machine is under a build embargo. The manifest parses, its keys are the ones
flatpak-builder reads, and the desktop entry and metainfo it installs both
validate — but no Flatpak has been produced from it and no permission has been
observed to be sufficient.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 20:18:56 +02:00
dtourolleandClaude Opus 5 47a40d1afa Describe the application once, in a file every channel installs
A desktop entry gives a software centre a name and a one-line Comment, and
nothing else — no description, no licence, no age rating, no statement of what
hardware the interface was laid out for. GNOME Software and Discover both show
an application with no metainfo as an unexplained icon, and both packages this
repository produces were in that position.

packaging/paris.tourolle.darkroom.metainfo.xml is the AppStream component, and
it is installed by the PKGBUILD as well as by the Flatpak manifest because the
description, the licence fields and the OARS rating are facts about the
application rather than about how it was packaged. Writing it twice is how the
two packages start disagreeing.

Two things in it are easy to get wrong and are commented in place. The two
licence fields differ on purpose: metadata_license covers the file itself and
has to permit the unconditional redistribution and reformatting a catalogue
does, which GPLv3 does not, so it is CC0-1.0; project_license is the
application's own and reads GPL-3.0-or-later to match D8. And the component id
is not a fifth name but the same string as the desktop basename, the Flatpak
application id, and the app_id dr_ui::run sets — a rename that misses one costs
the icon or the association, and neither failure announces itself.

D15's decision that the target devices are a tablet and a desktop is stated as
a display_length requirement rather than left implicit, so a software centre
does not offer this on hardware where the photograph and the parameter panel
cannot both be on screen.

Validates clean under appstream-util validate-relax; appstreamcli --pedantic
reports only that the gitea URLs are unreachable from a machine that cannot
see that host.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 20:18:35 +02:00
dtourolleandClaude Opus 5 95847e3a31 Say which channels v1 ships through, and that the Flatpak cannot reach a library
NFR-COMPAT-2 asks for the v1 channels to be stated, and says why in its own
second sentence: the channel decision and the storage design are coupled. There
was nowhere that statement lived. packaging/ held a PKGBUILD and a desktop
entry, which is a recipe rather than a decision, and the coupling the
requirement points at was therefore invisible.

docs/distribution.md states five channels and, more usefully, which two of them
exist only as promises. It also records what every channel has to get right
independently of format — the one identifier that appears in four places, the
metainfo, Vulkan being a requirement rather than a preference while NFR-R8 is
open, a secrets daemon being optional rather than required, and the LFS pointer
check that stops a package shipping 130 bytes where an 11 MB model should be.

§4 is the part worth reading. Preparing a Flatpak is what surfaced that
FR-PLAT-LIN-3 is not satisfied and cannot be satisfied by packaging alone: a
folder library is chosen by typing an absolute path into an EndpointOnly field
that checks it with std::fs, and nothing in the tree calls the FileChooser
portal. Inside a sandbox that path does not exist, so the launch screen refuses
it. Import fails one step earlier, because a sandboxed process reads its own
mount namespace and a card mounted on the host is not in it.

That is written down rather than fixed with --filesystem=host, and the argument
for not fixing it that way is §2: the Arch package and an AppImage both hand the
application the same unrestricted process the developer runs it in, so Flatpak
is the only Linux channel that tests whether a design assumed unrestricted
access. Granting the permission removes the only reason to ship it.

The reverse coupling on Android is recorded too. NFR-COMPAT-2 says Play
distribution is what makes ARCH §6.9 binding; §6.9 is verified rather than
assumed, so SAF is already unconditional and a sideloaded build would gain
nothing by asking for more. Play is deferred over the GPLv3 question, which is
a licence-reading exercise and blocks nothing in the storage design.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 20:18:20 +02:00
dtourolle eb39599f12 Merge: named presets (FR-DEV-6)
Build and test / Desktop (Linux) (push) Failing after 1h21m9s
Build and test / Layer separation (push) Successful in 48s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Traceability / Requirement traces (push) Successful in 43s
Build and test / Android (aarch64) (push) Successful in 20m42s
2026-08-29 20:07:58 +02:00
dtourolleandClaude Opus 5 5a8327824f Keep an edit under a name, not just on the clipboard
FR-DEV-6 asks for three things — named presets, copy/paste between
images, and batch-apply to a selection. The last two have been here for
a while; this is the first.

The format is the sidecar's, deliberately. A preset *is* the non-default
half of a version, so the lines are the same lines keyed the same way,
which makes the two files diffable against each other and lets someone
debugging an edit paste a block from one into the other. One file rather
than one per preset: a preset per file makes the name a path, and every
name then has to survive a filesystem — a `/` becomes a directory, a
name differing only in case collides on one platform and not another,
and renaming becomes two operations that can half-fail. As a key in a
document it is none of those.

Unknown *parameters* needed no machinery. `Preset` already holds
whatever keys it is given and resolves them against the descriptors only
at apply time, so one written by a newer build survives by being stored.
Only lines that are not `op.param = float` at all are preserved
verbatim, which is the sidecar's version-skew promise made here too.

Applying is the paste path with a different source, so a preset reaches
a selection through the sidecar read-modify-write that was already
there: no graph, no decode, no GPU, forty files or one.

Two smaller decisions worth the record. A library that fails to parse is
held empty in memory and *not* written back over — settings regenerate
themselves and this is work, so a parse failure must not be the moment
it is destroyed. And every save persists immediately and rolls the
in-memory copy back if the write fails, so the sheet never lists a
preset the file does not have.

The grid's "Presets" button is gated on the selection alone, unlike the
"Paste to 40" beside it. That button needs a clipboard armed this
session; the preset list is whatever was saved last month, and hiding it
behind an unrelated action is what makes a feature only its author knows
about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:07:14 +02:00
dtourolleandClaude Opus 5 574107bc39 Let a manual collection be put in the order it is meant to be seen in
`collection_members.position` and `Sort::CollectionPosition` have been in the
catalog since collections were, and nothing above dr-catalog has ever written
or read either: `collections::set_order` had no callers, and the grid ordered
everything by capture time whatever it was scoped to — dr-ui does not construct
a `Query` at all, it has its own `GRID_ORDER` constant. So a manual collection
was a set with an order nobody could see or change.

Three pieces, because it could not be fewer:

`grid_order_for` decides the ordering from the scope, and both readers take it
from there. That is the load-bearing part. An ordinal only names a photograph
relative to an ordering, so the window read and the span read have to agree —
a shift-click resolved through a different ORDER BY than the cells were drawn
with selects a different run than the one on screen, and the user finds out
when the export runs. `read_ids_span` already stated that invariant about
`GRID_ORDER`; this widens it to an ordering that depends on the scope.

Only a single manual collection has one. A set draws its descendants' images
too, and two children's positions are unrelated integers that interleave
arbitrarily; a smart collection has no member rows to carry a position at all.
Both fall back to capture time and refuse the drop rather than pretending.

The drop is on the cell, on whichever half of it the finger landed — the
trailing edge is the only way to name the last place in a collection, since
there is no cell beyond the last one to drop in front of.

`reordered` is pure and the membership is rewritten whole. `set_order` sets the
positions it is given and leaves the rest, so a partial write would interleave
the moved run with rows nobody touched; and it is read unfiltered, so what the
filter is hiding keeps its place relative to what the user can see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 20:04:17 +02:00
dtourolleandClaude Opus 5 6d25d85f18 Make the range gesture visible, and offer the whole grid at once
Touch has had a range gesture for as long as selection mode has: double-tap the
far end. It is invisible, it is unreliable on a grid that scrolls under the
second tap, and it extends from the anchor *before* the two taps moved it — a
rule subtle enough that the code needs two paragraphs to explain it to itself.
Nobody who was not told about it has ever used it.

"Select to…" is the same operation with state you can see. Press it, the strip
stops reporting and says "Tap the last photograph", and the next cell taken is
the far end. It reaches Rust as shift on `cell-pressed`, so it lands in
`apply_press` as the ctrl+shift it already is, and there is no third selection
policy to keep in step with the other two.

This is deliberately not the sweep gesture. A drag that paints cells can only
reach what is on screen, and the ranges that hurt on a tablet are longer than a
screenful — between the two taps here the user may scroll as far as they like,
and the run is resolved by the catalog rather than by what happened to be
loaded. A sweep is still worth having for short runs; it is not what this
should have rested on.

"Select all" beside it, asked of the catalog for the same reason: a select-all
that quietly meant "the hundred cells that happen to be loaded" is a lie the
user cannot see until the export runs.

The double-tap stays. It is tested, and an accelerator that costs nothing is
worth keeping for whoever has already learnt it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 19:46:44 +02:00
dtourolleandClaude Opus 5 9ac1447139 Ask for the collection's name where the keyboard can reach it
"New collection from selection" created the collection under a placeholder
name and then opened the rename field in the sidebar tree.

On a tablet the sidebar is not on screen. It is instantiated all the same —
app.slint collapses it to zero width and `visible: false` rather than using an
`if`, because an `if` there is a layout loop Slint panics on — so the rename
field was created, its `init` took focus, and Android raised the on-screen
keyboard over a box nobody could see. Nothing else on the screen is focusable,
so the keyboard had nowhere to go: it stayed, the name could not be typed, and
the collection was already written under the name the user did not want.

Asked in a sheet instead, on the same card as the filing and keywording sheets,
before anything is written. That also fixes what was hiding behind it: an
abandoned rename used to leave a "New collection" in the tree, because the
collection existed before the name did.

`Field` gains `take-focus()` so a sheet whose field is the only thing to do in
it can answer the keyboard for the user — a function rather than a property,
because focus is an event and a bound property would re-take it on every
unrelated re-evaluation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 19:42:33 +02:00
dtourolleandClaude Opus 5 5133e53bc8 Give back what a straighten took, when the angle comes back
Build and test / Desktop (Linux) (push) Successful in 2h16m6s
Build and test / Layer separation (push) Successful in 52s
🐳 Android image / Build and push (push) Successful in 4s
Build and test / android-image (push) Successful in 4s
Traceability / Requirement traces (push) Successful in 51s
Build and test / Android (aarch64) (push) Successful in 47m10s
The auto-crop only ever shrank. Straighten to 20 degrees and the corners
are cropped away correctly; come back to 3, or all the way to zero, and
the crop stays at the size 20 degrees demanded. Nothing on screen explains
why the photograph is still small, and the only way back was undo.

The cause was that each correction was computed from the previous
correction's output, so it accumulated: every angle the slider rested at
took its cut and none was ever returned. The fix is to stop accumulating
and recompute. The applied crop is now always the user's own rectangle
fitted into the current angle's safe area, so as the angle falls and that
area opens up the crop grows back — and stops, exactly, at the rectangle
they chose. At zero the safe area is the whole frame and the fit is the
identity, which is what carries it the last of the way home. There is
deliberately no early exit for the upright case now: that exit is
precisely what would strand the crop small.

**The intent is remembered as a pair, so it repairs itself.** The session
keeps `(applied, intended)` — what the correction wrote, and what it was
derived from — and trusts the remembered intent only while the graph still
holds `applied`. Every other route to the crop leaves something else
there: a handle dragged, a ratio chosen, a sidecar loaded, a paste, an
undo. That mismatch is the signal the memory is stale, and the current
rectangle becomes the new intent. The alternative was a write into this
field from each of those paths, which is the kind of bookkeeping that is
correct until someone adds a seventh path.

Dragging a handle therefore *is* the user choosing, including at a
non-zero angle: the correction will not later grow the crop past what they
dragged it to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 19:16:43 +02:00
dtourolleandClaude Opus 5 ff6c313bab Wrap the chip rows, so one six-choice parameter stops sizing the sidebar
The develop column asked for 482px. Every comment in it, a dozen of them,
describes it as a 280px column — and on a tablet it was taking 40% of the
screen from the photograph it exists to serve.

Measured rather than guessed, because none of it is visible in the source:
the column takes the widest width any panel declares, `AdjustPanel` wanted
482 of it, its rows wanted 458, and after the 44px scroll gutter the
widest single row was 414. That row is `film_sim`'s `format` — six film
formats from 35mm to 8x10, laid out by `Segmented` as six 64px chips in a
`HorizontalLayout` that cannot wrap. 6 x 64 + 5 x 6 = 414, exactly.

Nothing about that is the film simulation's fault. An operation declares
its parameters and the panel decides how to draw them (FR-DEV-3a), so a
node is entitled to offer six choices; it is the drawing that has to cope.
Any future operation with a five-choice enum would have done the same
thing, silently, to every screen in the application.

`ChipGrid` is the general answer: chips placed by index arithmetic inside
a plain `Rectangle`, wrapping at a column count, declaring a width that
depends on the columns rather than on the number of choices. `Segmented`
takes a `columns` property and uses it when asked — zero, one row however
many chips, stays the settings page's behaviour, where the page is
full-width and reading the alternatives side by side is the whole argument
for chips over a dropdown.

The generated enum rows and the curve-channel picker now wrap at three,
and the crop ratio chips use the shared grid instead of the private copy
of it they shipped with last week.

The column measures 351 now, down from 482, and what sets it is the mode
strip rather than a parameter — which is a control the user chose to have
on screen rather than an accident of one node's variant list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 19:16:43 +02:00
dtourolleandClaude Opus 5 bbd26f05f9 Release 0.9.0
Build and test / Desktop (Linux) (push) Successful in 2h8m7s
Build and test / Layer separation (push) Successful in 37s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
Traceability / Requirement traces (push) Successful in 1m42s
Build and test / Android (aarch64) (push) Successful in 1h4m36s
Thirty-five commits since 0.8.0, and two of them are the reason this is a
minor rather than a patch.

**Storage became pluggable.** A folder backend now sits beside the
Nextcloud one behind the same seam, proved by a test rather than by a
trait, and the launch screen offers three routes into a library instead
of one.

**Faces gained a confidence that means something.** A suggestion is
scored against the people the user has actually named, the curve it came
from is stated rather than implied, and a regroup runs roughly three
times faster — the similarity scan and the merge engine both use the
machine's own SIMD kernel now, measured on the tablet where the NEON path
is the one that runs.

Alongside those, the framing tools got the quality-of-life pass this
release is named for: a crop can be held to a ratio, a straighten crops
away the corners it exposed, an export can be pinned to an exact
resolution with the common panel sizes offered as buttons, and dragging
the crop rectangle no longer tracks the pointer at half speed.

`pkgrel` returns to 1: a new `pkgver` is a new archive name, so there is
nothing left for a release number to disambiguate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 13:53:19 +02:00
dtourolleandClaude Opus 5 bd33487054 Say which axis each export dimension is, in the one place that renders
The box modes put two numeric fields one above the other, and on screen
they are two anonymous numbers: `TextRow` draws its label behind the
field rather than above it, so "Width" and "Height" never appear. That
fault is older than this feature — the storage panel's cache sizes have
the same missing labels, and the single "Size value" row always did — and
it belongs in its own change rather than being fixed under cover of this
one.

But one unlabelled number is survivable and two are not, so the axis goes
where the page does render it: the unit. It reads "3840 px wide" above
"2160 px high", which is the sentence the user is trying to write anyway.

The `label` bindings stay correct and stay where they are, so this
becomes redundant rather than wrong the day `TextRow` is fixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 13:49:21 +02:00
dtourolleandClaude Opus 5 c38df01bf7 Hang the auto-crop off the slider's commit, not off the pointer passing over
The straighten auto-crop worked once and then stopped. It was keyed on
`PlainSlider::drag-changed`, whose name is a lie inherited from what it
forwards: `SliderTrack` defines `engaged` as `has-hover || claimed`, and
it has to — a `Flickable` withholds the press for 100ms, so hover is the
only signal that arrives in time to stand the scrolling ancestor down.
That is the right definition for the job it was written for and the wrong
one for this.

Keyed on hover, the correction fires when the pointer first crosses the
track — before anything has been dragged — and then does not fire again
for as long as the pointer stays on it, however many times the angle is
changed. Which is exactly what "it only works once" looks like.

`SliderTrack` already publishes the signal this wants. `committed` fires
on release, once per gesture, after the final `changed`, and its doc
comment says so in as many words. It was simply not forwarded through
`PlainSlider`, so it now is, and the geometry panel's callback is a
`committed(float)` rather than a `drag-changed(bool)`.

The angle needs no re-applying here: the track emits its last `changed`
before it commits, so the value is already in the graph by the time this
runs. What is left is the correction that has to happen exactly once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 13:49:21 +02:00
dtourolleandClaude Opus 5 2c8768ac29 Lay the ratio chips out by hand, because Slint will not lay them out
The ratio chips shipped inside a `GridLayout` with `row` and `col`
computed from the repeater's index. It compiles. On screen every chip
piles into a single row that runs off the panel, and the terminal fills
with

    Internal error in Slint: RepeatedItemTree::grid_layout_input_data()
    not implemented

once per frame. A `for` inside a `GridLayout` is not supported, and
nothing says so until it is running.

A `HorizontalLayout` is not the answer either: six chips side by side
need over 400px, and this column's width is the largest width any panel
declares — so one row would widen every other panel in the application to
fit a control that is on screen only while cropping.

So the grid is arithmetic over the index inside a plain `Rectangle`. It
costs the layout engine nothing, it wraps a seventh ratio onto a third
row by itself, and — because a bare `Rectangle` declares no preferred
width — it takes the width the column already has instead of setting it.
The Portrait chip gets a container of its own for the same reason: a
`ChoiceChip` dropped straight into a `VerticalLayout` is stretched the
full width of the panel and reads as a button for the section rather than
as one more chip.

The three conditional pieces are also now individually-conditional
children of the one layout rather than a nested layout under a single
`if`, which is the convention `app.slint` and `masks.slint` already carry
notes about: a conditional nested layout under-reports its height here
and the panels below it draw on top of one another.

All of this was invisible in the source and obvious in a screenshot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 13:49:20 +02:00
dtourolleandClaude Opus 5 52c655e6cf Turn the ratio lock with the photograph when it is turned
A quarter turn carries the crop with it — that is what makes turning a
photograph keep its composition rather than sliding the selection onto a
different part of the picture. So a rect locked to 16:9 comes out of the
turn at 9:16, of a frame whose axes have also swapped, and the lock was
left claiming landscape over a portrait rect. The next drag would then
snap it back upright and undo what the turn had just done.

The orientation switch now turns with it, on odd numbers of quarters.

`Original` is deliberately excluded, and getting that wrong flips it
twice: it is resolved against the framed size every time it is asked
for, and the turn has already swapped that frame's axes — so it has
turned by the time anything asks. `turns_with_the_frame` is the one
predicate that separates the two cases, with a test that pins both.

`CropAspect` arrived without tests of its own; it has them now, including
the round trip this fixes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 13:49:20 +02:00
dtourolleandClaude Opus 5 3bb68cff69 Export at an exact resolution, and offer the panels worth naming
Export sizing could bound an image but not fix it. Long edge, short edge
and percentage all preserve the aspect ratio by letting one dimension
fall where it may, which is right for most work and useless against a
display that accepts one resolution and rejects everything else — a
television's art mode, a digital frame, a wallpaper slot.

FR-EXP-3 has always listed both halves of the answer, and this adds them.
**Fit box** scales to fit inside a width and height, so nothing is thrown
away and the result is smaller than the box on one axis unless the crop
already matches it. **Fill box** scales to cover the box and cuts the
overhang off the middle, so the file is exactly the pixels asked for.

Fill is the only mode in the file that discards image data, so two things
about it are worth stating. The overhang comes off symmetrically: the
crop tool is where a photographer decides which part of a frame survives,
and this stage having an opinion of its own would fight it. And locking
the crop to the same ratio leaves nothing here to cut, which is the
workflow the two features are meant to be used in.

With upscaling off and a source too small to cover, a fill box keeps its
*shape* rather than falling back to the source's: exporting a 3:2 file
where 16:9 was asked for is silently wrong in exactly the way the mode
exists to prevent, so the box shrinks instead. The existing rule — clamp,
never fail — is otherwise unchanged.

Four panel sizes are offered as buttons beside the fields. Getting 3840 x
2160 by typing four digits twice is a step at which the mistake is
discovered after the upload rather than before it. They fill in the
numbers and nothing else, in particular not the fit/fill choice: both are
legitimate against a screen, and guessing would discard the edges of a
photograph for a user who wanted them. The list is panels rather than
platforms, because a screen has one exact pixel count for ever where
"what a photo site wants" would rot in the file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 13:49:20 +02:00
dtourolleandClaude Opus 5 d9eb8faffd Crop away the corners a straighten exposed, once the slider is let go
Turning a rectangle inside its own bounds exposes its corners: there is no
source pixel out there, and the shader renders it black. Nothing in the
render prevents that, deliberately — a free angle does not change the
output size, which is what leaves the frame where the user put it while
the slider moves. Correct during the drag; four black wedges on the
finished photograph.

Letting go of the slider now pulls the crop inside the area the angle
leaves defined. `Framing::max_inscribed_crop` already computed that
bound and had no caller; this is the caller its doc comment described.

**Once, at the end of the gesture.** Applied per frame it would shrink
the crop on every step of the slider and never grow it back, so a user
who overshot to 20° and came back to 3° would be left with a crop
ratcheted down by the excursion rather than by the angle they settled on.
Per gesture it is bounded by the angles actually rested at, and undo steps
back through them.

**The crop is fitted into the bound, not replaced by it.** A crop placed
deliberately off-centre is a decision, and an automatic correction that
recentred it would undo the user's work to fix a problem they did not
have. `CropRect::fitted_into` scales only as far as the bound demands and
then slides the rect the shortest distance needed to be inside — so a
ratio locked in the crop panel survives the straighten too, since the
shape is never touched.

It returns the rect unchanged, bit for bit, when nothing needed to move.
That matters more than it looks: this runs on every release of the
slider, including releases at zero, and a rect that drifted by a rounding
error each time would be an edit recorded for no reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 13:49:19 +02:00
dtourolleandClaude Opus 5 e6ad906bc1 Let the crop be held to a ratio while it is dragged
A photographer cropping for a print, a phone wallpaper or a 16:9 frame is
not choosing four edges — they are choosing one edge and a known shape.
Free-dragging every corner made them do that arithmetic by eye on every
drag, and get it slightly wrong.

The panel now offers Free, Original, 1:1, 3:2, 4:3 and 16:9, with a
Portrait switch for the ones that have two orientations. Original follows
the frame rather than naming a number, so it stays right on the next
photograph from another body and after a quarter turn.

**The ratio is of output pixels, and the rect is not.** `CropRect` is
stored in fractions of a frame that is not itself square, so holding a
shape needs the frame's size — `ratio * height / width` of the frame.
Skipping that gives a "1:1" crop that is square only on a square
photograph, which is the one case nobody would test on, so the conversion
lives in `CropRect::with_aspect` where it is explained and pinned by a
test that asserts the fractions are *not* equal.

Two decisions worth recording:

The reshaped rect **grows** onto the ratio rather than shrinking onto it,
then scales down only as far as the frame's edge demands. Fitting inside
instead makes a one-axis drag do nothing at all — the other axis clamps
the first straight back, and the handle simply refuses to move.

The overlay now reports **which corner the drag is holding**, because
reshaping onto a ratio has to know which corner is nailed down and only
the handle that took the press knows that. A move reports no corner and
keeps its shape: reshaping about a centre would pull an over-moved rect
smaller instead of sliding it along the edge.

The lock lives with the window rather than the session. A `DevelopSession`
is per image, and cropping a set of frames to one shape is exactly when
the lock earns its place. It is not an edit and reaches no sidecar — what
is saved is the rectangle it produced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 13:48:51 +02:00
dtourolleandClaude Opus 5 dbaf5358d1 Measure a crop drag against the frame, not against the rect it is moving
Dragging the crop rectangle tracked the pointer at half speed: the rect
slid out from under the cursor, and the handle being held stopped being
the one under the finger. On a photograph where the whole point is to
place an edge by eye, that made the tool close to unusable.

The cause is that a `TouchArea` reports `mouse-x` relative to itself, and
every area in the overlay is positioned *by the very rect the drag is
editing*. Rust echoes the applied rect back on each step, so the area
moves under the pointer and the reported position falls by exactly the
amount it had just risen. `mouse-x - pressed-x` therefore subtracts the
drag from itself, and the fixed point of that feedback is a rect that
moves half as far as the pointer does — which is why it looked like
sluggish tracking rather than like a coordinate bug.

Both terms are now taken in the frame's own coordinates: `parent.x +
mouse-x`, where `parent.x` tracks precisely the movement `mouse-x` lost,
with the press captured in those same coordinates on the way down. The
two movements cancel and the rect follows the pointer exactly.

`GradientHandles` in masks.slint was already written this way, and for
the same reason — its handles are placed by the mask they drag. The
header note here now says why the shape matters, since the wrong version
compiles, looks plausible, and is only wrong once the rect starts moving.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 13:48:44 +02:00
dtourolleandClaude Opus 5 480c46c41a Quote the percentile the measurement can actually support
The before/after published a p99 ratio of 6.0x at 4K. It is not supported and
the correction is worth more than the number was.

The baseline run's `fit` rows spread 2.3x between median and 99th percentile
while every row of the after run spreads about 1.1x. A stage costing
`radius x pixels` has no reason to be bimodal, and `fit` is the memory-bound
configuration — it walks the whole 482 MB source on a stride where `1:1` reads
a contiguous window. Something else had the machine.

The merge brings in the cross-check that settles it: "Try every GPU, not only
the fastest one" measured the same baseline code on the same card and reports
4.67 ms p99 for M3 clarity fit at 2560x1600, against 10.40 ms here. Two
measurements of one thing differing by 2.2x mean the noisier one is wrong.

So both percentiles are now published and the p50 column is the claim: 2.8x at
4K rather than 6.0x. The p99 improvement is real and larger; this run cannot
say by how much, and says so.

What the caveat does not touch: every after figure is inside the 16 ms budget
with a p99 within 26% of its median at every size and both views, and clarity
against all-four is a within-run comparison.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 13:28:41 +02:00
dtourolle 30162e80df Merge branch 'master' into clarity-reduced-base
# Conflicts:
#	docs/traceability.md
2026-08-29 13:27:04 +02:00
dtourolleandClaude Opus 5 8b251b84a0 Record what the reduced base actually bought
TD-4 asked for the measurement as well as the change, and this is it: 25.05 ms
to 4.17 ms at 3840 x 2160, six times faster, with clarity no longer dominating
the neighbourhood stage it used to be 97% of.

Measured before and after on the same machine and the same adapter minutes
apart, baseline at the branch's merge-base, so the only variable is the change.
That adapter is not the RTX 3050 the rest of this document was measured on, so
the new table says to read it on its own rather than against the ones above —
the before/after is comparable, the absolute figures are not, and quietly
replacing the existing tables would have changed the instrument.

Also recorded: the declared halo is now quantised to multiples of the output
scale, because the 2-sigma truncation rounds on the reduced grid. 29 px becomes
28 at 1920x1200 and 38 becomes 40 at 2560x1600. It is inside what the cross-form
test holds — 0.03 stops of peak, 2% of reach — but it is a change in reach and
not only in cost, and a tile scheduler would be handed it. Better written down
now than found later as a seam.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 13:18:37 +02:00
dtourolleandClaude Opus 5 bff95e25ad Let clarity's base be computed where it is still fully determined
Clarity's Gaussian sigma is 1.2% of the frame's shorter edge, so its radius
is a property of the viewport: 52 render pixels at 4K, two separable passes
of 105 taps each over 8.3 M pixels. That measured 33.9 ms — seven times the
entire fused point chain, for one slider — and is docs/technical-debt.md TD-4.

A detail pass may now declare `output_scale`, and clarity's base is computed
on a grid a quarter the size on each axis.

The pass that combines needs the blur *and* the full-resolution colour, and a
colour that has been through a quarter-scale target is no longer full
resolution. So a scaled pass cannot simply join the ping-pong: there are two
chains now. The full-resolution one carries the colour and no scaled pass
touches it; the reduced one carries the base and reaches the combining pass
through a second binding as `reduced_at()`.

The reduce is a dispatch of its own rather than something the first blur half
does on the way past, and that is the whole difference between this and the
strided kernel the module documentation rules out. A stride samples an image
that is not band-limited and aliases high-frequency content down into the
base, which is then subtracted, and arrives in the output as mottling across
smooth gradients. This band-limits first and samples after. What is discarded
is content the base could not represent at any resolution, because a Gaussian
at sigma = 26 px holds nothing above one cycle per 26 px and the quarter-scale
grid carries one per 8 — so the reduced base is not an approximation of the
full-resolution one, it is the same function sampled where it is still
determined.

Which is also why the scale belongs to the band rather than to the stage.
Texture's sigma is a decade finer, so the reduce pass's own box would be wider
than the Gaussian it was prefiltering; texture never reduces. And clarity
steps 4 -> 2 -> 1 as sigma falls, because a quarter of a small sigma is not a
Gaussian either — the case that gives up is the one that was already cheap.

`radius` stays in each pass's own pixels and `ComposedDetail::radius` multiplies
it back up, so 13 reduced pixels at scale 4 still report the 52 render pixels a
tile would have to be grown by. The halo a scheduler sees does not move.

The halo tests pass unchanged, which was TD-4's stated bar; they render at
1024 px and so exercise the reduced path rather than stepping around it. Added
`crossing_the_reduction_threshold_does_not_change_the_picture`, because
nothing yet compared the reduced form against a *less* reduced one — every
other test measures one form against itself. It renders the same edit either
side of the 4 -> 2 step-down and holds the peak excursion to 0.03 stops and
the reach to 2% of the frame.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 13:12:29 +02:00
dtourolle 3b5d564495 Try every GPU, not only the fastest one
Build and test / Desktop (Linux) (push) Successful in 2h7m41s
Build and test / Layer separation (push) Successful in 46s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Traceability / Requirement traces (push) Successful in 41s
Build and test / Android (aarch64) (push) Successful in 21m1s
`request_adapter` with `HighPerformance` returns one adapter and no
second chance. That is right on a healthy machine and wrong on one with
a sick GPU, which is not rare: observed 2026-08-29 on a laptop whose
discrete card had hit an NVRM assertion failure and a fullchip reset.
The driver still advertised it, wgpu dutifully picked it as the highest
performing, and the process died on it — while a working integrated GPU
and a working external card sat unused in the same enumeration. A photo
editor that will not start because the *fastest* GPU is broken, on a
machine holding two that are not, is worse than a slow one.

So: enumerate, order by preference, take the first that yields a device.
The ordering reproduces what `HighPerformance` meant, so a healthy
machine picks what it always picked and pays one enumeration for it. A
CPU adapter sorts last rather than being excluded — software rendering
is a poor experience and a working one.

Which GPU to prefer is now a policy rather than an assumption, because
the fastest is not obviously the right one. A 24 MP frame is ~96 MB of
RGBA and every upload and export readback crosses PCIe on a discrete
card, where an integrated GPU shares memory and crosses nothing — and
does not empty a battery.

Measured before choosing a default, on this machine's Iris Xe against
its RX 5700 XT. The fused colour pass is within 1.5x, which is the
shape shared memory suits. The neighbourhood stage is 5-8x slower, and
that decides it: clarity at 1920x1200 costs 20 ms on the iGPU, over the
budget on its own at the smallest size tested. So `Performance` stays
the default and `Efficiency` is offered rather than chosen
(`DARKROOM_GPU=integrated`).

docs/frame-budget.md carries the table, and says what it does *not*
show: the harness renders from a resident texture and never uploads or
reads back, so the transfer cost an iGPU avoids appears in none of it.
Import, export and the thumbnail sweeps may well go the other way.

What this cannot fix: a GPU sick enough to accept `request_device` and
segfault afterwards, which arrives as a driver crash rather than an
error. It moves the boundary from "the preferred adapter is unusable" to
"unusable and dishonest about it".
2026-08-29 12:43:10 +02:00
dtourolleandClaude Opus 5 0407fb8d2d Format the two new examples
They were written after the last fmt run and CI gates on --check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 12:33:23 +02:00
dtourolleandClaude Opus 5 3944e17aa5 Merge: face confidences that mean something, and a regroup three times faster
Two things the People screen was getting wrong, and then the arithmetic
underneath it.

It refused to show a confidence at all until the library had fitted its own
calibration, which needs 200 confirmed positive pairs — made on the screen that
was withholding the number used to rank them. The default curve is the
reference implementation's fitted one, a published operating point rather than
an invention, so the percentage is shown and the screen says which curve it
came from.

The number itself was the mean similarity between a face and every other member
of its group, which punished well-photographed people and never asked who else
the face might be. It is now the mean of the best ten matches into the identity,
times that identity's share of the evidence against every other identity the
user has ruled on. Leave-one-out over this library's 2,702 confirmations across
54 named people: 99.33% placed on the right person, and the number shown for the
right person moves from a median of 90.4% to 99.3%.

Both of those were measured rather than argued, on a copy of a real 18,143-face
library, and the instrument is committed with them.

Measuring is also what found the rest. A regroup went from 10.0s to 3.1s: the
similarity scan was walking the whole embedding array once per row and not
vectorising at all, and the agglomeration was spending 3.06s having every
component scan the entire pair list for its own pairs. The scan now picks its
kernel per machine — AVX2 where the CPU has it, NEON on the tablet, where the
whole suite and a full regroup have both now been run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 12:32:54 +02:00
dtourolleandClaude Opus 5 39c34d4e44 Say what actually counts as a rival, now that a name anchors too
assign's denominator is the identities the user has ruled on, and it
reads Cluster::person to find them. Master's "Let a name hold a group
together" widened what sets that field: a confirmation, a name, or an
ignore, where before it was a confirmation alone.

The behaviour is right either way — a named person is exactly the
identity a suggestion should be discounted against — but the module note
and faces.md §9.1 both said "a confirmation", which is now too narrow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 12:32:31 +02:00
dtourolleandClaude Opus 5 da20d42d33 Merge master: pluggable storage, and a name that anchors
Conflicts were docs/traceability.md alone, and it is generated — so it
was regenerated rather than hand-merged. dr-face was untouched on the
other side; ui/dr-ui/src/faces.rs and identity_ui.rs auto-merged, the
first around recluster's anchoring and the second around load_faces.

Worth recording because the two branches met on the same problem from
different ends. Master's "Let a name hold a group together" is the fix
for the sixteen Catherines — fourteen of them empty — that this branch
found while measuring the library and reported without fixing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 12:30:01 +02:00
dtourolleandClaude Opus 5 b2250cc460 Measure a regroup on the tablet, not just on the desktop
The GPU question needed a number nobody had: how a regroup divides on
the hardware whose CPU is weakest. dr-face carries no weights and
touches no display, and dr-catalog's example needs only a catalog file,
so both run under adb shell against a copy of a real library.

On the same 18,143 faces — desktop against the tablet — scan 0.96s /
2.61s, agglomerate 1.69s / 2.16s, score 0.26s / 0.40s. The scan is half
the pass on the tablet and under a third on the desktop, because twenty
cores of AVX2 pull ahead of NEON much further than the merge engine's
single-threaded hashing does. So a GPU GEMM is worth roughly 2× a
regroup on the tablet and 1.5× here, and it is the tablet that should
decide whether it is built.

The two architectures agree exactly: the same 1,531,969 evidence pairs,
the same 2,518 groups holding the same 16,246 faces, the same
reliability table. That is a better check on the NEON kernel than the
unit test can be.

Two instruments, both read-only: the example now prints its phases, and
dr-face gains scan_bench, which needs no library at all and so can
answer "how fast is this machine" on a device with nothing on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 12:16:48 +02:00
dtourolleandClaude Opus 5 f4395bd17c Run the face tests on the tablet, where the NEON kernel actually runs
The similarity scan picks its dot product per machine, and the NEON one
is the kernel that ships to the phone and the tablet — and the one a
desktop cargo test never executes. A wrong lane index or a mishandled
tail there is a silent wrong answer on exactly the devices nobody runs
the suite on, which is a poor place for the only untested code path.

dr-face carries no weights and touches no display, so its tests are a
plain ARM64 binary that runs under adb shell with nothing installed.
The script builds it against the SDK's newest NDK, pushes it, runs it
and cleans up. It checks for the device first, so a tablet that is not
plugged in costs a second rather than the two minutes it takes to
compile for it.

Not wired into CI, which has no device attached.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 12:02:03 +02:00
dtourolleandClaude Opus 5 8f596262b8 Record where a regroup's time actually goes
The §9 note said the scan was half a regroup and implied the
agglomeration was an irreducible sequential walk. Both halves of that
are now wrong, and the numbers are the point of the section.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 11:59:01 +02:00
dtourolleandClaude Opus 5 a67402961d Let the merge engine's dot product use the machine's kernel too
Engine::cross is the one place a dot product is computed during
agglomeration — when two groups become adjacent through a third and
their sub-threshold pairs, never summed because they were never
interesting, have to be accounted for. It was calling the portable loop
while the scan beside it had AVX2 or NEON, which on the reference
library was 1,753,514 dot products taking 0.54s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 11:58:07 +02:00
dtourolleandClaude Opus 5 b4d39ba33a File each pair under its component once, not once per component
Seeding the merge heaps was 3.06s of a 5.93s regroup on the reference
18,143-face library — more than half the pass, spent before a single
merge was considered.

Every component scanned the whole pair list looking for the pairs that
were its own: 475 components against 804,499 pairs, 382 million set
lookups to place 804,499 of them. A pair can only ever join two faces of
one component, since that is what a component is, so the union-find that
finds the components can file the pairs at the same time and hand each
agglomeration the list it needs.

The membership set inside agglomerate goes with it — it existed only to
run that filter — and the heap can be sized up front now that the pair
count is known.

Ordering is preserved deliberately: pairs are filed in the order they
arrive, which is the global (i, j) order, so the seeded heap breaks its
ties exactly as before and the merge order is unchanged. Same 2,518
groups holding the same 16,246 faces on the reference library, at 3.5s
rather than 5.9s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 11:57:29 +02:00
dtourolleandClaude Opus 5 e596eb0657 Give the similarity scan the machine's SIMD, and its cache
The scan is O(n²) dot products and nothing else, so its speed is the
face subsystem's speed — and it was running at 0.7 flops per cycle.

Two separate faults, both measured over the reference 18,143-face
library on twenty cores. It walked the whole embedding array once per
row, ~336 GB of traffic, where a column tile that fits in L2 is read
once per tile of rows: 4.64s → 2.81s. And the workspace builds for
baseline x86-64 — SSE2, no FMA — into which the portable loop was not
being vectorised at all: 2.81s → 0.86s, 195 GFLOP/s.

So the dot product is now chosen per machine. AVX2 + FMA where
is_x86_feature_detected! finds it; NEON unconditionally on aarch64,
since Advanced SIMD is in that baseline and every Android device the app
builds for has it — with the explicit vfmaq, because LLVM will not fuse
a multiply and an add without being told to. The portable loop stays as
the definition the others are tested against, and
the_fastest_kernel_agrees_with_the_portable_one is the only check the
NEON path gets on a machine that is not aarch64.

Faces::embeddings is one flat buffer rather than a Vec per face: the
pointer chase defeated both the prefetcher and the tiling, and it is
also the layout a GPU pass would want.

Behaviour is unchanged and that is checked rather than asserted — the
same 1,531,969 pairs from all three kernels, and on the real library the
same 2,518 groups holding the same 16,246 faces with the same confidence
distribution. A full regroup there goes from 10.0s to 5.9s; the rest is
the agglomeration, which is a sequential heap walk and is where the next
look should go.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 11:33:15 +02:00
dtourolleandClaude Opus 5 ebb7d3cf5c Score a suggestion against the people the user has named
The number beside a suggestion was the mean calibrated probability
between the face and the rest of its group, which measures the wrong
thing twice. It punishes coverage: a person with two hundred faces over
fifteen years is *meant* to have members a given photograph is
orthogonal to, so a correct suggestion onto a well-photographed person
scored low for being well photographed. And it never asked who else the
face might be — a face matching Anna at 0.95 and nobody else, and one
matching Anna at 0.95 and her sister at 0.93, came out identical, when
the second is the only one worth the user's attention.

dr_face::assign answers both, and multiplies them: the mean of the best
ten calibrated matches into the identity (the old mean, capped, which is
what stops coverage counting against it), times that identity's share of
the evidence against every *named* rival.

Only named people compete, and per person rather than per group. Both
halves of that had to be measured on a real 18,000-face library rather
than reasoned about. Normalising across every group made the number
useless — median suggestion 21%, four in five under half — because
clustering leaves one person spread over many groups, so a face competed
against itself; and keying rivals by group left Catherine competing with
Catherine, median 39%. Per named person: median 99.5%.

Rivals are gathered below the merge threshold, down to even odds: a
named person matching at 0.6 will never be merged into but is exactly
the competition to discount for. That would be a second similarity scan,
the expensive half of regrouping a library, so cluster_scored scans once
at the looser floor and hands the merge engine the subset at or above
the threshold — pair for pair what it would have scanned for itself,
held to that by a test.

Leave-one-out over that library's 2,702 confirmations across 54 named
people: 99.33% of faces placed on the right person against the old
mean's 99.15%, and the number shown for the right person moves from a
median of 90.4% to 99.3%. It errs low — 100% correct wherever it states
80% or more — which is the safe direction, and docs/faces.md §9.1 says
plainly that the low bands are not calibrated.

The example that measures it comes too: this is a claim about a
library's numbers, and nobody should have to take it on faith.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 10:44:01 +02:00
dtourolle 21d599b420 Test the guard, since the bug was a branch nobody ran
Build and test / Desktop (Linux) (push) Successful in 2h5m41s
Build and test / Layer separation (push) Successful in 50s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 2s
Traceability / Requirement traces (push) Successful in 1m37s
Build and test / Android (aarch64) (push) Successful in 59m40s
The catalog clobber existed because `if let Ok(bytes)` had a failure arm
that was never exercised. Fixing it without covering that arm leaves the
next person free to collapse it back.

Three tests against a backend whose read fails in a chosen way, counting
writes — because what went wrong was not a wrong value but a write that
should not have happened at all:

- a dehydrated snapshot uploads nothing
- an unreadable one uploads nothing either, since "refused" is no more
  "absent" than "not downloaded" is
- and a genuine first sync still uploads, which is the half that keeps
  `NotFound` distinct from `NotMaterialised` rather than merely cautious

Checked against the original shape: the first two fail on it and the
third passes. A guard test that cannot tell the bug from the fix is
decoration.

`async-trait` joins dev-dependencies to stand the double up behind
`dyn RemoteBackend`.
2026-08-29 10:40:38 +02:00
dtourolle 4168d67cfa Do not push a catalog over one we could not read
`sync_catalog` is a read-modify-write over a file another device also
writes: take theirs, merge, push the union. It was shaped

    if let Ok(bytes) = backend.get(&RemoteId::Path(target), None).await {

which folds *every* failure into "there is no remote catalog" and carries
straight on to the upload. On a placeholder library the snapshot in
`.darkroom-derived/` is dehydrated like anything else, so the read failed
every time and each sync pushed our catalog over theirs unmerged —
taking the other device's collections and their members with it.

The same shape as the sidecar bug, and the same fix: a read that fails
for anything other than `NotFound` stops the upload and says why. An
unreadable or unopenable snapshot stops it too — "will not parse" is not
"is not there". This is what `NotFound` and `NotMaterialised` being
separate errors is *for*: one means ours is the whole truth, the other
means do not dare.

Shard downloads go through the same fetch-on-demand read. They logged
and skipped before, which on a library the client keeps dehydrated is
every shard, every pass, and a peer's thumbnails and faces silently
never arriving.

And `put` over a placeholder now replaces it rather than refusing.
Refusing was over-cautious of me: derived state lives inside the library
folder, so a folder the client had dehydrated could never be written to
again. An unconditional write replaces the whole file, so there is
nothing in the stub to keep — content first, then the placeholder, since
in a synced tree an absence is a deletion that propagates. `IfMatch`
still refuses, because a stub's validator describes the stub; `IfAbsent`
fails, because the file is there and only its content is not.
2026-08-29 10:36:26 +02:00
dtourolle 702d83c218 Measure the borrow, rather than asserting it bounds the disk
The claim that hydration-as-a-borrow makes peak disk the working set
rather than the library was so far an argument. `--example vfs_cycle`
runs it: 100 photographs of 25 MB, 90 dehydrated, a pass over all of
them through the real engine.

Peak 275 MB — the resting set plus one photograph — against 2,500 MB
had the pass simply fetched everything. Back to 250 MB afterwards, and
all ten files the user already kept still there, which is the half of
the contract that matters more.

Recorded in docs/storage.md §6.3 and ARCH §9.0a, because a bound argued
from a number nobody measured is one that gets quietly lost.
2026-08-29 09:57:53 +02:00
dtourolle 5768100816 Borrow the library to index it, and give it back
The passes that need every photograph's bytes — thumbnails, face
indexing — now borrow each one and release it at the end. On a
placeholder library that is the difference between peak disk being the
working set and being the whole library.

Including on cancellation, which was nearly missed: the face sweep
returns mid-loop when the user presses Stop, and without releasing there
the disk is spent and nothing is delivered for it.

`materialise` now answers whether *it* fetched the content. The pool used
to work that out by listing a file's parent directory — one listing per
file across a library — when the backend already had to `stat` it to
decide whether to ask. One syscall instead of a directory walk, and it
removes the bug class the tests found earlier: a file at the library root
has no `parent()`, so every one of them read as already-downloaded.

**Pinning is the retention control**, and it drives the model the catalog
already had rather than a second one. `tier_desired` is what the user
asked to keep hydrated, `pending_pins` is the resumable work list, and a
pinned collection is never dehydrated for the same reason it was never
evicted. It was in fact *broken* here before: `get` on a stub failed, and
the pin worker logged "one unreadable file must not abandon the whole
pin" and silently did nothing.

Pinned originals on such a library are recorded with `path = NULL`
(`Cache::record_in_place`) rather than copied under `originals/`. Two
reasons, and the second is the important one. A copy would hold every
pinned photograph twice, with the budget able to evict the half that was
not costing the disk. And `release` deletes the file a row names — so a
row that names none cannot delete anything, which puts the one
catastrophic operation out of reach by construction rather than by
remembering not to call it. Deleting a materialised file inside a synced
tree removes the photograph from the server and every other device.

Handing disk back is `spawn_dehydrate`, which asks the client.

Two gaps written down rather than papered over (docs/storage.md §7): a
hydrating pass cannot yet quote its cost, because a stub reports no size;
and the two sweeps hold separate pools, so a library indexed for both
fetches twice.
2026-08-29 09:57:53 +02:00
dtourolle c102ba9df2 Treat a placeholder as the photograph, not as a one-byte file
The folder connector was pointed at a Nextcloud VFS tree and got three
things wrong, the first of which loses work.

**A dehydrated sidecar read as absent.** `a.drsc` does not exist when the
client has dehydrated it — only `a.drsc.nextcloud` does — so `get` missed,
`.ok()` swallowed the `NotFound`, and the sidecar writer took that for
"there is no sidecar yet" and wrote a fresh document over the existing
one. Every edit another device had put there went with it. That function's
own doc comment calls this the exact loss the format's unknown-key
preservation exists to prevent.

**A stub was catalogued as a 1-byte image**, and ARCH §9.0 measured this
machine at 121,785 placeholders against 10,267 real files — so a folder
library on a synced tree was ~92% broken rows.

**Identity changed on hydration**, so downloading a photograph looked like
a delete and an add, orphaning its thumbnail and its face rows.

Entries now carry the photograph's own name and a `materialised` flag;
`get` on a stub returns the new `RemoteError::NotMaterialised`, which is
distinct from `NotFound` precisely because the sidecar writer must treat
them differently — it fetches the sidecar and merges, or leaves the entry
queued.

Hydration is a **borrow**. `BorrowPool` records what was on disk before it
asked, so `release_all` dehydrates only what a pass brought and leaves
what the user already had. Reference counted: the thumbnail pass and the
face pass meet on the same RAW, and without counting the first to finish
dehydrates the file the second is reading. A borrow against a plain folder
or a server does nothing, so a pass written for VFS runs everywhere.

Releasing means asking the client to dehydrate and never deleting: a
deletion inside a synced tree propagates to the server and removes the
photograph from every device.

Not a second backend — the capability is per *connection*, not per type,
since the same folder hydrates only while the client runs. The convention
arrives through a detector the registry supplies, so `dr-sync-folder`
still knows nothing about any client's protocol.

ARCH §9.0a records this as an amendment: finding 3 rejected hydration
because it costs 100× a range read, and that comparison assumed a
connector was available. A folder library has none.
2026-08-29 09:57:52 +02:00
dtourolle 6c363cee97 Let the launch screen scroll, now that it offers three routes
The signed-out screen was a `VerticalLayout { alignment: center }` with
no scroll. That was already tight with two routes; the folder option
made the column taller than a 900x560 window, and a centred layout that
overflows clips at *both* ends — so the masthead and the last route
disappear together, with nothing on screen to suggest either existed.

A Flickable whose viewport follows the content, and a top padding that
centres the column only while it fits. Both cases checked against the
running app: centred at 1000x1800, top-aligned and scrollable at
900x560.
2026-08-29 09:57:52 +02:00
dtourolle 64462e9fd3 Test the folder sign-in instead of trusting it
The whole of a folder library's sign-in lived inside a Slint callback,
which cannot run without a display server — so the one path that decides
whether a mistyped folder becomes a *stored* account had no test at all.
That failure is quiet and lasting: an account for a directory that is
not there skips the launch screen on the next start and reads as a
library that has lost its photographs.

`open_folder_library` is that logic, lifted out whole. Four tests: it
stores an account with no credential and reaches the keyring for
nothing, a typo is refused before anything is written, the messages read
as instructions because they go straight to the screen's error line, and
a file is not a library.
2026-08-29 09:57:52 +02:00
dtourolle cbe5c4fcde Measure the folder walk against a real tree, not a claim
The folder connector declares `LocalEtags`, which means the engine walks
the whole library on every scan with no pruning. That is the honest
capability, and the argument for it being affordable was so far an
assertion about `stat` versus `PROPFIND`.

`--example scan` runs the real path — `dr_sync::scan` over the connector,
then a ranged read of the kind the thumbnail worker makes. Read-only; it
never writes into the folder it is pointed at.

2,299 images across 233 directories in 137 ms, and 380 across 13 in
29 ms. Against 34.1 s for 17,185 RAWs over WebDAV *with* pruning
available. Recorded in docs/storage.md §5.2 and ARCH §8.4a, because a
capability trade-off argued from a number nobody measured is the kind
that gets quietly reversed later.
2026-08-29 09:57:52 +02:00
dtourolle f12aece07e Make storage pluggable, and prove it with a folder backend
`RemoteBackend` existed from the first release and bought nothing it was
designed for. Seven files in `dr-ui` constructed a `NextcloudBackend`
directly, an account *was* a server URL beside a DAV user id, the local
cache directory was named after a hostname, and the launch screen knew
that signing in meant a browser handshake. The trait was real; the seam
was documentation.

A trait over operations is only a quarter of it. Pluggable storage needs
four things, and this adds the other three:

- **Capabilities** — already there, and the reason the engine can drive
  two backends at the speed each actually runs at.
- **Configuration** — `dr_sync::Account`: where a library lives, in
  whatever form its connector addresses, with no server in it. Loads
  every existing config unchanged (`backend` defaults to `nextcloud`,
  `endpoint` is stored under its historical `server` key), and
  `Account::namespace()` reproduces the old catalog directory byte for
  byte, because changing it would abandon a catalog, its thumbnail
  shards, and the sidecars holding unsynced offline work.
- **Registration** — `BackendProvider` and `BackendRegistry`.
  `ui/dr-ui/src/remote.rs` is now the only file above `dr-sync` that
  names a connector.

`Connection` (an account plus an optional `Secret`) replaces the
credentials-and-user-id pair that was threaded through fifteen
signatures in an order that could be swapped. `Secret`'s inner string is
reachable only through `expose()` and its `Debug` prints `Secret(***)`,
so the indirect leak — a `{:?}` on anything holding one — no longer
compiles into a leak.

Nextcloud is unchanged and keeps every peculiarity: propagating ETags,
chunked upload v2, `oc:fileid`, the `oc:permissions` probe on a refused
PUT, the 423 retry classification, Login Flow v2. Those are what the
capability model exists to serve, not something to hide.

`dr-sync-folder` is the second connector: a local disk, a network mount,
an external drive, or a folder a Nextcloud client already syncs. No
account, no credential — the route that works where no secrets daemon
does. It declares `LocalEtags` rather than claiming propagation a POSIX
directory cannot provide, which costs nothing because 50k `stat` calls
are not 50k PROPFINDs. Identity is a path hash, not an inode: an inode
survives a rename but differs between devices and is reused after a
delete, so two machines would disagree about which photograph a
thumbnail belonged to. Re-deriving a thumbnail is a cost; showing the
wrong one is a bug.

docs/storage.md is the contract — the traits, the four steps to add a
backend, and what each connector declares. ARCH §8.0 and §8.4a, and
FR-NC-13, say why.
2026-08-29 09:57:52 +02:00
dtourolleandClaude Opus 5 c8e05831f4 Show the confidence, and say which curve it came from
FR-CULL-9 was read as "no fit, no number", so every library without 200
confirmed positive pairs showed "Confidence unavailable" on every
suggestion — which is every library, until enough confirmations exist to
fit one. The confirmations are made on this screen, ranked by the number
it was withholding, so the degraded state was also the permanent one.

There has always been a curve: Calibration::default is the reference
implementation's fitted MBF sigmoid, which is what clustering already
operates at. It is a published operating point, not an invention, and
what the requirement forbids is presenting it *as though it were
measured on this library*. So the percentage is shown, and the screen
says once, above the grid, where the curve came from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 09:57:38 +02:00
dtourolleandClaude Opus 5 1b8b7998a2 Let a name hold a group together, the way a confirmation does
Build and test / Desktop (Linux) (push) Successful in 2h6m43s
Build and test / Layer separation (push) Successful in 46s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 2s
Traceability / Requirement traces (push) Successful in 29s
Build and test / Android (aarch64) (push) Successful in 21m27s
Sixteen people called Catherine, fourteen of them holding no faces at all, and
her actual photographs split across the two that did. That is not a sync fault;
it is this function, and it has been quietly doing it on every Regroup.

Naming a cluster does not confirm its faces. They stay *suggestions* — and only
confirmed faces anchored here, so the next pass cut them loose, regrouped them
into a brand new person, and left the named one holding nothing.
`prune_empty_unnamed` will not clean that up, because it has a name. Name the new
group the same thing, and it happens again. Repeat over a few sessions and you
have sixteen of her.

A name is a judgement about *this group*, of exactly the kind FR-CULL-12 says
travels and inference does not — the same argument that already anchors a group
the user set aside. So all three kinds of ruling anchor now: confirmed, ignored,
and named.

It also fixes the quieter half of the same fault. A face indexed later that
matches a named person now merges *into* them, rather than arriving as a rival
group the user has to name all over again.

Two tests, and the first fails without the change — it reports "Catherine"
finishing the pass with zero faces while a fresh unnamed person holds the two
she was named for.

This does not retro-fit an existing library: the fourteen empty Catherines stay
until they are merged by hand, and the two holding faces are separate identities
that only the user can say are one person. What it stops is making more.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 09:48:32 +02:00
271 changed files with 71494 additions and 5097 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Model weights live in LFS.
#
# `core/dr-segment/models/*.onnx` is ~11 MB of binary that changes wholesale
# `models/**/*.onnx` is tens of 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.
+193
View File
@@ -0,0 +1,193 @@
name: Benchmarks
# The suite docs/requirements.md §8 has been promising since it was written:
# "an automated benchmark suite against a synthetic 50k catalog, run per-commit
# … A regression beyond stated tolerance fails the build."
#
# Its own workflow rather than a step inside build-and-test.yml, and the reason
# is what a failure here means. A red `Build and test` says the code is wrong; a
# red `Benchmarks` says the code is slower than it was, which is a different
# conversation, is read by different people, and must not be reachable by
# retrying a flaky compile.
#
# # Why this is split in two
#
# §8 names "the reference desktop", not CI, and it is right to. So:
#
# cpu — runs on every push. It needs no adapter and no display, and the
# budgets it asserts (a 50k catalog opening inside two seconds) have
# two orders of magnitude of headroom, so a modest runner can be held
# to them honestly. Machine-sensitive budgets — throughput targets
# written for a 24-thread desktop — are reported here rather than
# asserted; `dr-bench` decides that per metric and says so in its
# report. Asserting them on a two-core container would produce exactly
# what core/dr-gpu/tests/frame_budget.rs refused to produce: a red gate
# everybody learns to ignore.
#
# gpu — the frame budget, which already exists and already skips itself where
# there is no adapter. Not on push: it would build wgpu and naga on
# every commit to establish, every time, that this runner has no GPU. It
# runs on demand (Actions → Run workflow) so that a runner that *does*
# have one can be pointed at it, and the numbers it produces belong in
# docs/frame-budget.md by hand, as they already are.
on:
push:
branches: [main, master, develop]
pull_request:
branches: [main, master, develop]
workflow_dispatch:
jobs:
cpu:
runs-on: linux/amd64
name: CPU and I/O (per commit)
# Node for actions/checkout and actions/cache, which the bare runner image
# cannot execute. Rust is installed below.
container:
image: catthehacker/ubuntu:act-latest
env:
# Same reasoning as the desktop job in build-and-test.yml: incremental
# state exists to make the *second* build in a working tree fast, which is
# not a thing a fresh checkout has, and it fills the runner's disk.
CARGO_INCREMENTAL: 0
# The fixture, out of the workspace so actions/cache never picks it up.
# A 14 MB synthetic catalog is two seconds to regenerate and would
# otherwise be uploaded and downloaded on every push to save them.
DR_BENCH_DIR: /tmp/darkroom-bench
steps:
- name: Checkout
uses: actions/checkout@v4
# No `git lfs pull` here, deliberately. `dr-bench` depends on the catalog,
# the decoder, the thumbnail store and the encoder, and on nothing that
# reaches `dr-segment` — so the model this repository keeps in LFS is not
# part of this job's dependency graph and fetching it would be a minute
# spent on a file nothing opens.
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: bench-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
# Pinned to the workspace rust-version, as every other job here is: a
# floating toolchain turns an unrelated push into a mystery failure, and
# for a benchmark it would turn one into a mystery *regression*.
#
# rust-analyzer is named for the reason build-and-test.yml gives: rustup
# reconciles rust-toolchain.toml on the first cargo call whatever this
# step asks for, so naming it keeps the download inside the step that says
# it is installing things.
- 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 rust-analyzer
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
# `-p dr-bench`, not `--workspace`. The whole point of that crate having
# no GPU and no UI dependency is that this job resolves the catalog, the
# decoder and the encoders and stops there — a few minutes rather than the
# release build of Slint and wgpu the desktop job pays for.
#
# Release, and it is not optional: the workspace builds its own crates at
# opt-level = 0 in dev, and every figure this produces is dominated by
# this workspace's own code. A debug run would measure rustc.
- name: Build the suite
run: cargo build --release -p dr-bench
# Exit 1 is a violated budget or a regression past tolerance; exit 2 is
# the harness failing to run at all. Both fail the job, and the report
# above the failure says which.
- name: Measure, and gate
run: cargo run --release -p dr-bench -- check
- name: Disk after
if: always()
run: df -h /workspace 2>/dev/null || df -h .
gpu:
# On demand only — see the header. A runner with a Vulkan device can be
# pointed at this; one without will skip the measurement and say so, which
# is the same posture the rest of this repository's device tests take.
if: github.event_name == 'workflow_dispatch'
runs-on: linux/amd64
name: Frame budget (on demand)
container:
image: catthehacker/ubuntu:act-latest
env:
CARGO_INCREMENTAL: 0
steps:
- name: Checkout
uses: actions/checkout@v4
# `dr-gpu` depends on `dr-segment` for the watershed's pixel passes. Its
# default features are off, so no weights are compiled in — but the fetch
# is cheap insurance and its failure is not fatal. The header of the same
# step in build-and-test.yml explains why the extraheader is stripped
# rather than reused: two Authorization headers is a 400 from Gitea, one
# step after the batch call that had just succeeded.
- name: Fetch the segmentation model
continue-on-error: true
env:
LFS_TOKEN: ${{ secrets.GITEA_TOKEN || github.token }}
run: |
set -e
git lfs install --local
git config --local --get-regexp '^http\..*extraheader$' \
| cut -d' ' -f1 | sort -u \
| while read -r key; do git config --local --unset-all "$key"; done || true
git config --local lfs.url \
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
git lfs pull
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: bench-gpu-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
- name: Build dependencies
run: |
apt-get update -qq
apt-get install -y -qq pkg-config libfontconfig1-dev libxkbcommon-dev
- 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 rust-analyzer
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
# The guard, in release. Its own module documentation is explicit that a
# release run checks strictly more than a dev one: the CPU half of a frame
# is shader-string assembly, which is several times slower unoptimised, so
# it is folded into the assertion only when debug_assertions is off.
#
# With no adapter this prints "skipping: no GPU adapter" and passes. A
# test that cannot run is not evidence either way, and turning that into a
# failure would make the job useless on the runner it usually lands on.
- name: Frame budget (FR-DSP-3)
run: cargo test --release -p dr-gpu --test frame_budget -- --nocapture
# The instrument behind docs/frame-budget.md. It exits non-zero with no
# adapter, which is right for a tool a person runs deliberately and wrong
# for a job that usually has none — hence continue-on-error. Its table is
# in the log for whoever asked for this run; the committed numbers are
# still updated by hand, as that file says.
- name: Frame budget table
continue-on-error: true
run: cargo run --release -p dr-gpu --example frame_budget
+20 -6
View File
@@ -57,7 +57,7 @@ jobs:
# The model, which is in LFS and is not optional.
#
# `core/dr-segment/models/*.onnx` is tracked in LFS (.gitattributes), so a
# `models/**/*.onnx` is tracked in LFS (.gitattributes), so a
# plain checkout writes a ~130-byte pointer where 11 MB should be, and
# `dr-segment`'s build script panics by design rather than embedding a
# pointer and failing at inference. That failure reads like a broken build
@@ -97,7 +97,7 @@ jobs:
git config --local lfs.url \
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
git lfs pull
ls -l core/dr-segment/models/
ls -lR models/
- name: Cache cargo
uses: actions/cache@v4
@@ -117,12 +117,20 @@ jobs:
# 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.
#
# The component list mirrors rust-toolchain.toml's, rust-analyzer
# included, even though nothing in this job runs it. rustup reconciles
# that file against the installed toolchain on the first cargo call in
# the work tree and fetches whatever is missing — so leaving it out does
# not save the download, it only moves it into the middle of a build
# step where it is nobody's line item. Naming it here keeps every fetch
# inside the step whose name says it is installing things.
- 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
--default-toolchain 1.92.0 --component rustfmt,clippy,rust-analyzer
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
# Free space before and after the expensive steps, so a repeat of the
@@ -166,7 +174,7 @@ jobs:
# The model, which is in LFS and is not optional.
#
# `core/dr-segment/models/*.onnx` is tracked in LFS (.gitattributes), so a
# `models/**/*.onnx` is tracked in LFS (.gitattributes), so a
# plain checkout writes a ~130-byte pointer where 11 MB should be, and
# `dr-segment`'s build script panics by design rather than embedding a
# pointer and failing at inference. That failure reads like a broken build
@@ -206,7 +214,7 @@ jobs:
git config --local lfs.url \
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
git lfs pull
ls -l core/dr-segment/models/
ls -lR models/
- name: Cache cargo
uses: actions/cache@v4
@@ -370,11 +378,17 @@ jobs:
# `cargo tree` resolves the dependency graph, so it needs the registry
# index but no system libraries — this job builds nothing.
#
# rust-analyzer is named for the reason given in the desktop job: rustup
# installs rust-toolchain.toml's components on the first cargo call
# whether or not this step asks for them, and an unasked-for download is
# the one nobody can find in the log.
- 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
-y --no-modify-path --profile minimal --default-toolchain 1.92.0 \
--component rust-analyzer
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
# ARCH §6.5a: no core/ crate may depend on the UI toolkit. One stray
+20 -2
View File
@@ -44,12 +44,17 @@ jobs:
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.
# enough — no system libraries and nothing this job itself needs beyond
# cargo. rust-analyzer is here anyway because rust-toolchain.toml lists
# it: rustup installs that file's components on the first cargo call in
# the work tree regardless, and a download named in the install step
# beats the same download appearing unannounced inside the gate.
- 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
-y --no-modify-path --profile minimal --default-toolchain 1.92.0 \
--component rust-analyzer
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
# The gate's own arithmetic is the thing being trusted, so its tests run
@@ -77,6 +82,19 @@ jobs:
exit 1
fi
# The gesture vocabulary, from the same scanner and under the same rule.
#
# Blocking, and for a sharper reason than the matrix: these two artefacts
# are not only read, one of them is *shown to the user*. A stale
# `gesture_book.rs` is a help sheet in the application telling somebody to
# perform a gesture that was removed — worse than no help sheet, because
# they will conclude the application is broken rather than the page.
#
# This also fails on a malformed tag, so a typo costs a gesture its
# desktop half loudly rather than silently.
- name: Regenerate the gesture vocabulary and check it is committed
run: cargo run -q -p traceability -- gestures-check
# 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.
+33 -6
View File
@@ -1,5 +1,10 @@
#!/usr/bin/env bash
# Keep docs/traceability.md in step with the tags in the tree.
# Keep the generated artefacts in step with the tags in the tree.
#
# Two of them now, from the same scanner: the requirements matrix and the
# gesture vocabulary. Both are generated *from* the tree and cite line numbers
# in it, so both go stale on any commit that moves a line — a `cargo fmt` sweep
# above all, but equally a commit that merely adds a paragraph above a tag.
#
# The gate regenerates the matrix in CI and fails if the result differs from
# what is committed. That is the right check — a matrix that disagrees with the
@@ -18,11 +23,13 @@ staged="$(git diff --cached --name-only --diff-filter=ACMR)"
if ! grep -qE '\.(rs|slint|yaml|md)$' <<< "${staged}"; then
exit 0
fi
# The matrix is generated from the tree, so regenerating it because it was
# itself edited would be circular.
if [ "$(tr -d '[:space:]' <<< "${staged}")" = "docs/traceability.md" ]; then
exit 0
fi
# The artefacts are generated from the tree, so regenerating them because one
# was itself edited would be circular.
case "$(tr -d '[:space:]' <<< "${staged}")" in
docs/traceability.md | docs/gestures.md | ui/dr-ui/src/gesture_book.rs)
exit 0
;;
esac
repo="$(git rev-parse --show-toplevel)"
cd "${repo}"
@@ -38,3 +45,23 @@ if ! git diff --quiet -- docs/traceability.md; then
git add docs/traceability.md
echo "pre-commit: regenerated docs/traceability.md and staged it"
fi
# The gesture vocabulary, same discipline.
#
# **Failure here is reported and not swallowed**, unlike the matrix above. A
# matrix that will not build leaves the previous one in place, which is merely
# stale; a malformed `GESTURE:` block means a gesture the user is about to be
# told about in the wrong words, or not at all. The gate would catch it in CI
# either way — this is only about catching it a push earlier.
if ! out="$(cargo run -q -p traceability -- gestures 2>&1)"; then
echo "pre-commit: the gesture scan failed — the tags below need fixing" >&2
echo "${out}" >&2
exit 1
fi
for f in docs/gestures.md ui/dr-ui/src/gesture_book.rs; do
if ! git diff --quiet -- "${f}"; then
git add "${f}"
echo "pre-commit: regenerated ${f} and staged it"
fi
done
+7
View File
@@ -16,3 +16,10 @@ Cargo.lock.bak
# tools/film-profiles/convert.py --fetch. Not source: the converted
# profiles in core/dr-film/profiles are.
tools/film-profiles/upstream/
# flatpak-builder's cache and its output tree. `packaging/flatpak/` holds the
# manifest, which is source; everything a build derives from it is not — and
# `.flatpak-builder/` in particular caches an unpacked copy of the whole
# checkout, so it is larger than the repository it sits in.
/.flatpak-builder/
/build/
+17
View File
@@ -83,6 +83,21 @@ GPU tests skip themselves where there is no adapter rather than failing — a
test that cannot run is not evidence either way — so a green run on a machine
without a GPU is expected, and does not mean the GPU paths were exercised.
There is a fifth check, and it is not in that list because you are unlikely to
break it by accident:
```bash
cargo run --release -p dr-bench -- check
```
That is the benchmark suite (`docs/requirements.md` §8), which builds a
synthetic 50,000-image catalog and fails the build if a performance target is
missed or a measurement has drifted past its tolerance. It runs on every push in
its own workflow. [`docs/benchmarks.md`](docs/benchmarks.md) says what it
measures, what it deliberately does not, and how to read a failure. If you have
touched the catalog, the decoder, the thumbnail store or the exporter, run it
before you send.
## Requirements and traceability
[`requirements.md`](docs/requirements.md) is the register of record.
@@ -150,7 +165,9 @@ One commit per change. If you fixed two things, that is two commits.
| [`core/dr-pipeline/ops/README.md`](core/dr-pipeline/ops/README.md) | Adding or changing a develop operation — start here regardless |
| [`docs/architecture.md`](docs/architecture.md) | Anything touching the render path, catalog or sync |
| [`docs/code-health.md`](docs/code-health.md) | Deciding what to work on; grades each seam by what it costs |
| [`docs/benchmarks.md`](docs/benchmarks.md) | A change that could plausibly cost time or memory |
| [`docs/technical-debt.md`](docs/technical-debt.md) | Something looks wrong — check it was not chosen |
| [`docs/distribution.md`](docs/distribution.md) | Packaging a build, or adding a permission to one |
| [`docs/requirements.md`](docs/requirements.md) | Reference, not reading |
`technical-debt.md` is the one to check before "fixing" anything surprising.
Generated
+80 -20
View File
@@ -1221,20 +1221,23 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]]
name = "darkroom-android"
version = "0.8.0"
version = "0.12.0"
dependencies = [
"android_logger",
"dr-sync-nextcloud",
"dr-plat",
"dr-sync",
"dr-ui",
"jni 0.21.1",
"log",
"slint",
]
[[package]]
name = "darkroom-desktop"
version = "0.8.0"
version = "0.12.0"
dependencies = [
"anyhow",
"dr-plat",
"dr-ui",
"env_logger",
"log",
@@ -1402,9 +1405,26 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]]
name = "dr-bench"
version = "0.12.0"
dependencies = [
"anyhow",
"dr-catalog",
"dr-decode",
"dr-export",
"dr-thumbs",
"dr-types",
"env_logger",
"log",
"rusqlite",
"serde",
"serde_json",
]
[[package]]
name = "dr-catalog"
version = "0.8.0"
version = "0.12.0"
dependencies = [
"dr-face",
"dr-plat",
@@ -1419,7 +1439,7 @@ dependencies = [
[[package]]
name = "dr-decode"
version = "0.8.0"
version = "0.12.0"
dependencies = [
"dr-types",
"env_logger",
@@ -1433,7 +1453,7 @@ dependencies = [
[[package]]
name = "dr-export"
version = "0.8.0"
version = "0.12.0"
dependencies = [
"dr-decode",
"dr-gpu",
@@ -1451,7 +1471,7 @@ dependencies = [
[[package]]
name = "dr-face"
version = "0.8.0"
version = "0.12.0"
dependencies = [
"env_logger",
"log",
@@ -1464,7 +1484,7 @@ dependencies = [
[[package]]
name = "dr-film"
version = "0.8.0"
version = "0.12.0"
dependencies = [
"log",
"serde",
@@ -1473,7 +1493,7 @@ dependencies = [
[[package]]
name = "dr-gpu"
version = "0.8.0"
version = "0.12.0"
dependencies = [
"bytemuck",
"dr-decode",
@@ -1490,7 +1510,7 @@ dependencies = [
[[package]]
name = "dr-ingest"
version = "0.8.0"
version = "0.12.0"
dependencies = [
"dr-plat",
"dr-types",
@@ -1502,7 +1522,7 @@ dependencies = [
[[package]]
name = "dr-lens"
version = "0.8.0"
version = "0.12.0"
dependencies = [
"lensfun",
"log",
@@ -1510,7 +1530,7 @@ dependencies = [
[[package]]
name = "dr-pipeline"
version = "0.8.0"
version = "0.12.0"
dependencies = [
"dr-types",
"log",
@@ -1519,7 +1539,7 @@ dependencies = [
[[package]]
name = "dr-plat"
version = "0.8.0"
version = "0.12.0"
dependencies = [
"android-native-keyring-store",
"dr-types",
@@ -1533,9 +1553,19 @@ dependencies = [
"x11rb",
]
[[package]]
name = "dr-preset-xmp"
version = "0.12.0"
dependencies = [
"dr-pipeline",
"log",
"quick-xml",
"thiserror 2.0.20",
]
[[package]]
name = "dr-segment"
version = "0.8.0"
version = "0.12.0"
dependencies = [
"env_logger",
"log",
@@ -1548,9 +1578,24 @@ dependencies = [
[[package]]
name = "dr-sync"
version = "0.8.0"
version = "0.12.0"
dependencies = [
"async-trait",
"dr-plat",
"dr-types",
"log",
"serde",
"serde_json",
"thiserror 2.0.20",
"tokio",
]
[[package]]
name = "dr-sync-folder"
version = "0.12.0"
dependencies = [
"async-trait",
"dr-sync",
"dr-types",
"log",
"thiserror 2.0.20",
@@ -1559,12 +1604,13 @@ dependencies = [
[[package]]
name = "dr-sync-nextcloud"
version = "0.8.0"
version = "0.12.0"
dependencies = [
"async-trait",
"dr-decode",
"dr-plat",
"dr-sync",
"dr-sync-folder",
"dr-types",
"env_logger",
"log",
@@ -1580,7 +1626,7 @@ dependencies = [
[[package]]
name = "dr-thumbs"
version = "0.8.0"
version = "0.12.0"
dependencies = [
"dr-types",
"jpeg-encoder",
@@ -1592,7 +1638,7 @@ dependencies = [
[[package]]
name = "dr-types"
version = "0.8.0"
version = "0.12.0"
dependencies = [
"serde",
"serde_json",
@@ -1601,9 +1647,10 @@ dependencies = [
[[package]]
name = "dr-ui"
version = "0.8.0"
version = "0.12.0"
dependencies = [
"anyhow",
"async-trait",
"dr-catalog",
"dr-decode",
"dr-export",
@@ -1611,10 +1658,13 @@ dependencies = [
"dr-film",
"dr-gpu",
"dr-ingest",
"dr-lens",
"dr-pipeline",
"dr-plat",
"dr-preset-xmp",
"dr-segment",
"dr-sync",
"dr-sync-folder",
"dr-sync-nextcloud",
"dr-thumbs",
"dr-types",
@@ -1634,6 +1684,16 @@ dependencies = [
"wgpu",
]
[[package]]
name = "dr-xmp"
version = "0.12.0"
dependencies = [
"dr-types",
"log",
"quick-xml",
"thiserror 2.0.20",
]
[[package]]
name = "drm"
version = "0.14.1"
@@ -6927,7 +6987,7 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "traceability"
version = "0.8.0"
version = "0.12.0"
dependencies = [
"anyhow",
"serde",
+25 -6
View File
@@ -12,18 +12,22 @@ members = [
"core/dr-gpu",
"core/dr-lens",
"core/dr-pipeline",
"core/dr-preset-xmp",
"core/dr-segment",
"core/dr-sync",
"core/dr-sync-folder",
"core/dr-sync-nextcloud",
"core/dr-xmp",
"platform/dr-plat",
"ui/dr-ui",
"apps/darkroom-desktop",
"apps/darkroom-android",
"tools/bench",
"tools/traceability",
]
[workspace.package]
version = "0.8.0"
version = "0.12.0"
edition = "2021"
rust-version = "1.92"
license = "GPL-3.0-or-later"
@@ -45,6 +49,7 @@ dr-ingest = { path = "core/dr-ingest" }
dr-gpu = { path = "core/dr-gpu" }
dr-lens = { path = "core/dr-lens" }
dr-pipeline = { path = "core/dr-pipeline" }
dr-preset-xmp = { path = "core/dr-preset-xmp" }
# `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
@@ -53,7 +58,9 @@ dr-pipeline = { path = "core/dr-pipeline" }
dr-segment = { path = "core/dr-segment", default-features = false }
dr-plat = { path = "platform/dr-plat" }
dr-sync = { path = "core/dr-sync" }
dr-sync-folder = { path = "core/dr-sync-folder" }
dr-sync-nextcloud = { path = "core/dr-sync-nextcloud" }
dr-xmp = { path = "core/dr-xmp" }
dr-ui = { path = "ui/dr-ui" }
# GPU + UI
@@ -226,13 +233,25 @@ ort-tract = "0.4"
ndarray = "0.17"
[profile.dev]
# Dependencies optimised even in dev builds — wgpu and image decoding are
# unusably slow otherwise, and they rarely need debugging.
# Dev builds are tuned for how fast they *compile*, not for how fast they run.
# Optimisation is a release concern; `[profile.release]` below is where it
# belongs.
#
# This deliberately reverses an earlier choice. Dependencies used to be built
# at `opt-level = 2` here, because wgpu and image decoding are slow without it.
# That is still true, and it is the price: a debug run of the app, and the
# decode- and GPU-heavy tests, are slower than they were. What it buys is that
# nothing has to be optimised before it can be compiled — which is the cost
# paid on every edit, by every worktree, rather than only when something is
# actually run.
#
# If a particular crate turns out to be the one that makes a test unbearable,
# raise it alone rather than restoring the blanket rule:
#
# [profile.dev.package.zune-jpeg]
# opt-level = 2
opt-level = 0
[profile.dev.package."*"]
opt-level = 2
[profile.release]
lto = "thin"
codegen-units = 1
+48 -13
View File
@@ -2,17 +2,25 @@
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).
**Status:** 0.9.0, and no longer a spike. A library opens, culls, develops and
exports on both platforms, across eight tagged releases. What is *not*
built is written down rather than merely absent — see
[docs/outstanding.md](docs/outstanding.md) for the requirements that have no
implementation and why, and [docs/technical-debt.md](docs/technical-debt.md)
for the compromises that were chosen.
## Documentation
| Document | Contents |
|---|---|
| [requirements.md](docs/requirements.md) | What the software must do — 122 numbered requirements |
| [CONTRIBUTING.md](CONTRIBUTING.md) | How to land a first change without reading the rest |
| [requirements.md](docs/requirements.md) | What the software must do — 179 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 |
| [faces.md](docs/faces.md) | Face detection and identity — the models, the licence problem, and what S14 measures |
| [technical-debt.md](docs/technical-debt.md) | Compromises taken deliberately, each with the condition that retires it |
| [outstanding.md](docs/outstanding.md) | What is not built, and whether that is a decision or a gap |
| [code-health.md](docs/code-health.md) | What a contribution costs, per seam, measured |
| [traceability.md](docs/traceability.md) | Generated: which requirement is claimed by which file |
| [faces.md](docs/faces.md) | Face detection and identity — the models, the licence problem, and what S14 measured |
## Building
@@ -28,21 +36,48 @@ Android (containerised toolchain, see [docker/android](docker/android/README.md)
./docker/android/build.sh cargo ndk -t arm64-v8a build --release
```
Git LFS is required for the model weights, and the toolchain pins itself.
[CONTRIBUTING.md](CONTRIBUTING.md) has the details and the four commands CI
will run against what you send.
## Current state
Working: workspace, GPU context and compute pass, adaptive Slint shell, Android
cross-compilation of the core crates.
**Working.** A catalog over a local folder, a Nextcloud account, or a folder a
sync client keeps in virtual-files mode — where a placeholder is treated as the
photograph rather than as a one-byte file. A virtualised library grid with a
capture-time timeline, ratings, labels, keywords, collections and a trash that
survives a crash mid-operation. Card ingest. Face detection and identity, with
the index syncing between devices. A develop pipeline of fifteen declared
operations fused into a single compute dispatch, plus the neighbourhood
operations that cannot be — clarity, texture, capture sharpening, noise
reduction, lens correction, spectral film simulation. Crop, straighten, spot
removal, gradient and subject-segmentation masks, named presets, and a
generated panel that no operation in `ui/` is allowed to name. Export to JPEG,
PNG and 8- or 16-bit TIFF with resize and output sharpening.
**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.
**The zero-copy display path works on desktop.** The compute pass writes a
texture that Slint composites directly, which is what
[ARCH §6.1](docs/architecture.md) requires; the readback it forbids costs 96%
of frame time at 4K, and
```
```bash
cargo run -p dr-gpu --example bench --features readback
```
reproduces that measurement.
still reproduces that measurement. **The one exception is the Android develop
view**, which reads the frame back through the CPU because zero-copy there
needs wgpu's Vulkan swapchain, and that tears a portrait window on a tablet
whose panel is mounted landscape. It is debt, not a revision of the rule: the
reasoning, the on-device measurements that forced it, and the three separate
things any one of which would remove it are in
[technical-debt.md TD-1](docs/technical-debt.md).
**Not built.** Plugins, compare and survey culling, focus peaking, burst
grouping, AI denoise, tiled and progressive rendering, and most of the Android
platform integration beyond running. The performance targets in §4.1 are
unverified rather than unmet — the per-commit benchmark suite §8 requires does
not exist, so nothing fails a build on a regression.
[docs/outstanding.md](docs/outstanding.md) is the list, with the reasoning.
## Licence
+22 -2
View File
@@ -16,9 +16,14 @@ crate-type = ["cdylib"]
# 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
# For `account::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
dr-sync.workspace = true
# For the panic hook, for `state::set_state_dir` and for
# `diagnostics::install`. Android has no XDG directories, so the entry point is
# the only place that knows where a crash record or a log file may be written,
# and both have to be in place before anything can fail.
dr-plat.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.
@@ -26,6 +31,21 @@ slint.workspace = true
log.workspace = true
android_logger = "0.15"
# The launch Intent and the share sheet are Java-only surfaces — see `intents`
# — and JNI is the only way to reach them.
#
# Target-gated because the crate still has to compile on the host: it is a
# workspace member, `cargo test --workspace` builds it, and the manifest tests
# in `lib.rs` are the one part of it that runs there.
#
# 0.21 rather than the 0.22 that android-activity 0.6 uses. Both are already in
# the lock — Slint's Android backend depends on two major versions of
# android-activity and pulls both — so this adds nothing to the build either
# way, and every object here comes from a raw pointer rather than from a type
# android-activity handed over, so the two never have to agree.
[target.'cfg(target_os = "android")'.dependencies]
jni = "0.21"
[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
@@ -7,6 +7,12 @@
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).
Minimal is not the same as empty, and the entries below that are not the
activity are the difference. A manifest is the only place a component can be
declared: an intent filter is how the system learns this app is worth
offering for a photograph, and a provider is how it learns the class exists
at all. Neither can be moved into code (FR-PLAT-AND-6).
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="paris.tourolle.darkroom">
@@ -51,10 +57,30 @@
<!-- 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. -->
is how it learns which library to load, and must match [lib].name.
`singleTask` because a second instance of this activity is not
survivable. The intent filters below mean another app can now
launch it while it is already running, and under the default
launch mode that starts a *second* NativeActivity — in the
caller's task, in this same process, calling android_main again.
Two Slint backends and two wgpu devices in one process is not a
degraded experience, it is a failed second launch on top of a
working first one.
What it costs, stated plainly: a share that arrives while DarkRoom
is already running brings it forward without opening the image.
The Intent goes to `onNewIntent`, and android-activity's event
stream has no variant for it (MainEvent in 0.6 stops at Destroy),
so nothing native ever sees it. Reading it would mean a Java
Activity subclass forwarding it across JNI — the same shape of
change FR-PLAT-AND-5 declined for onTrimMemory, and for the same
reason. Launched from cold, which is the ordinary case for "open
this photograph", the Intent is on getIntent() and is read. -->
<activity
android:name="android.app.NativeActivity"
android:exported="true"
android:launchMode="singleTask"
android:configChanges="orientation|keyboardHidden|screenSize|screenLayout|density|uiMode"
android:windowSoftInputMode="adjustResize">
@@ -66,6 +92,80 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- FR-PLAT-AND-6, inbound. The traceability tool reads .rs,
.slint, .wgsl and .yaml, so this is a reference and not a
tag; the tag that counts is on the test in lib.rs that
asserts these declarations are still here.
Opening a photograph from a gallery, a file manager or a
download. `android_main` reads the launch Intent through
`Intents.receive` and the named images become the browsing
list, exactly as paths on the desktop command line do.
`image/*` and not a wider match, even though it misses raws:
a provider that does not recognise `.CR3` reports it as
`application/octet-stream`, and claiming that type would put
DarkRoom in the chooser for every unidentified binary on the
device — an APK, a database, a partial download. Being absent
from one gallery's menu is a smaller failure than being
present in all of them. DNG, which providers do know as
`image/x-adobe-dng`, matches here already.
BROWSABLE is what lets a browser's finished download and a
link hand the file over; without it those routes silently do
not list the app. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:mimeType="image/*" />
</intent-filter>
<!-- The share sheet, one photograph or a selection of them.
SEND_MULTIPLE is declared because the sheet offers this app
for a multi-selection only if it says it accepts one, and a
culling tool that can be sent a single frame and not a burst
is the wrong way round.
ACTION_EDIT is deliberately not here. It is a promise to write
the result back to the URI it was handed, and nothing in this
app does: an edit lands in a sidecar beside the original
(FR-CAT-8). Registering for it would put DarkRoom in the "edit
with" menu and lose the user's work every time. -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
</activity>
<!-- FR-PLAT-AND-6, outbound. Android has refused file:// URIs
between apps since API 24 — handing one out raises
FileUriExposedException in *this* process — so an exported JPEG
reaches the share sheet as a content:// URI or not at all.
Not AndroidX's FileProvider: that is a Maven artefact, and this
build has no Gradle and no dependency resolver (docker/android/
README.md). ExportProvider does the same hundred lines against one
fixed root.
`exported="false"` with `grantUriPermissions="true"` is the whole
security model, and the two halves are not redundant. Exported
false means no app may address the provider on its own account;
the grant flag means a URI this app puts in an Intent carries a
read permission for that one file, for the lifetime of the
receiving task. Without the grant flag the share sheet opens and
every target fails with SecurityException; with `exported="true"`
instead, every app on the device could read the app's private
directory. The authority must equal ExportProvider.AUTHORITY — a
mismatch is a SecurityException in somebody else's app, so a test
in lib.rs compares the two strings. -->
<provider
android:name="paris.tourolle.darkroom.ExportProvider"
android:authorities="paris.tourolle.darkroom.exports"
android:exported="false"
android:grantUriPermissions="true" />
</application>
</manifest>
@@ -0,0 +1,260 @@
package paris.tourolle.darkroom;
import android.content.ContentProvider;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import android.provider.OpenableColumns;
import android.util.Log;
import android.webkit.MimeTypeMap;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.List;
import java.util.Locale;
/**
* Hands an exported file to another app, and hands out nothing else.
*
* <p>FR-PLAT-AND-6's outbound half. Android has refused {@code file://} URIs
* between apps since API 24 — passing one raises {@code FileUriExposedException}
* in the *sending* process — so the only way to give a photo to the share sheet
* is a {@code content://} URI backed by a provider, plus a per-Intent read
* grant that expires with the task that received it.
*
* <h2>Why this is not AndroidX's FileProvider</h2>
*
* <p>Because AndroidX is a Maven artefact and this build has no Gradle and no
* dependency resolver (see docker/android/README.md). Pulling in the one class
* would mean adopting the whole mechanism that fetches it. What
* {@code FileProvider} does is a hundred lines — map a request path onto a
* directory, refuse anything outside it, answer the two columns the share sheet
* reads — and those lines are below. The configuration it takes as an XML
* {@code <meta-data>} resource is a constant here instead, because there is
* exactly one directory worth serving and a second place to state it is a
* second place for it to be wrong.
*
* <h2>The one directory</h2>
*
* <p>{@code getFilesDir()}, which is the same directory the Rust side calls
* {@code internal_data_path} and passes to {@code dr_sync::account::set_data_dir}
* — {@code ANativeActivity.internalDataPath} and {@code Context.getFilesDir()}
* are the same path. Everything the app writes for itself, the export outbox
* included, is under it. Nothing else is reachable: a request is resolved
* against the real filesystem with {@link File#getCanonicalFile()} and then
* checked to be *inside* that root, so {@code ../} and a symlink planted in the
* outbox are refused by the same test. Serving a path the caller composed,
* unchecked, would turn a share button into a reader for every file this app
* can see, which on Android includes credentials and the whole catalog.
*
* <p>{@code android:exported="false"} in the manifest is the outer half of the
* same rule: no app can address this provider at all except through a URI this
* app handed it with a read grant attached.
*/
public final class ExportProvider extends ContentProvider {
private static final String TAG = "DarkRoom";
/**
* Must equal {@code android:authorities} in AndroidManifest.xml.
*
* <p>A mismatch is not a build error and not a runtime error here: it is a
* {@code SecurityException} in whichever app opened the share sheet, naming
* an authority that does not exist. A test in {@code lib.rs} asserts the
* two strings are the same for that reason.
*/
public static final String AUTHORITY = "paris.tourolle.darkroom.exports";
/** Nothing to set up; the root is resolved per request against the context. */
@Override
public boolean onCreate() {
return true;
}
/**
* The {@code content://} URI for a file, or null if it is not one this
* provider may serve.
*
* <p>Returning null rather than an unusable URI keeps the refusal at the
* point where the path is known. A URI for a file outside the root would be
* rejected later by {@link #openFile}, in the *receiving* app's stack trace,
* where nothing says which of our files was asked for.
*/
public static Uri uriFor(Context context, File file) {
try {
File root = root(context);
File target = file.getCanonicalFile();
String relative = within(root, target);
if (relative == null) {
Log.w(TAG, "not shareable, outside " + root + ": " + target);
return null;
}
// Built segment by segment rather than with a composed path
// string: appendPath percent-encodes, and getPathSegments below
// decodes symmetrically. A file called "Rue d'Alésia.jpg" survives
// the round trip only because both halves agree.
Uri.Builder builder = new Uri.Builder().scheme("content").authority(AUTHORITY);
for (String segment : relative.split("/")) {
if (!segment.isEmpty()) {
builder.appendPath(segment);
}
}
return builder.build();
} catch (IOException e) {
Log.w(TAG, "cannot resolve " + file + " for sharing: " + e);
return null;
}
}
/**
* The two columns a share target actually reads.
*
* <p>Without {@code _display_name} the receiving app shows the URI's last
* segment, and without {@code _size} a mail client cannot tell whether the
* attachment fits before it starts reading. Both are optional in the sense
* that the transfer still works; both are the difference between "DSC_4471
* final.jpg, 8.2 MB" and an unnamed blob.
*/
@Override
public Cursor query(Uri uri, String[] projection, String selection,
String[] selectionArgs, String sortOrder) {
File file = resolve(uri);
if (file == null) {
return null;
}
String[] columns = projection != null
? projection
: new String[] {OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE};
MatrixCursor cursor = new MatrixCursor(columns, 1);
MatrixCursor.RowBuilder row = cursor.newRow();
for (String column : columns) {
if (OpenableColumns.DISPLAY_NAME.equals(column)) {
row.add(file.getName());
} else if (OpenableColumns.SIZE.equals(column)) {
row.add(file.length());
} else {
// A column we do not have. Null rather than omitted: a cursor
// whose row is shorter than its projection throws in the
// caller, which is a crash in someone else's app.
row.add(null);
}
}
return cursor;
}
/**
* From the extension, because that is all there is.
*
* <p>The type decides which apps the chooser offers, so guessing wrong
* narrows the sheet rather than breaking the transfer. Exports are JPEG,
* PNG or TIFF and {@code MimeTypeMap} knows all three.
*/
@Override
public String getType(Uri uri) {
File file = resolve(uri);
if (file == null) {
return null;
}
String name = file.getName();
int dot = name.lastIndexOf('.');
if (dot >= 0 && dot < name.length() - 1) {
String extension = name.substring(dot + 1).toLowerCase(Locale.ROOT);
String type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
if (type != null) {
return type;
}
}
return "application/octet-stream";
}
/**
* Read-only, always.
*
* <p>A write mode is refused rather than quietly downgraded: a caller that
* asked for "rw" intends to save something back, and letting it open the
* file read-only would fail at its first write with an error about a
* descriptor rather than about permission. Nothing this app shares is meant
* to be edited in place by the app it was shared with.
*/
@Override
public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
if (!"r".equals(mode)) {
throw new SecurityException("this provider is read-only, asked for '" + mode + "'");
}
File file = resolve(uri);
if (file == null) {
throw new FileNotFoundException("no such export: " + uri);
}
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
}
@Override
public Uri insert(Uri uri, ContentValues values) {
throw new UnsupportedOperationException("exports are written by the app, not through it");
}
@Override
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
throw new UnsupportedOperationException("exports are written by the app, not through it");
}
@Override
public int delete(Uri uri, String selection, String[] selectionArgs) {
throw new UnsupportedOperationException("exports are deleted by the app, not through it");
}
/** The served root, resolved through the filesystem so the check below is real. */
private static File root(Context context) throws IOException {
return context.getFilesDir().getCanonicalFile();
}
/** The file a request names, or null if it names anything else. */
private File resolve(Uri uri) {
Context context = getContext();
if (context == null) {
return null;
}
List<String> segments = uri.getPathSegments();
if (segments.isEmpty()) {
return null;
}
try {
File root = root(context);
File candidate = root;
for (String segment : segments) {
candidate = new File(candidate, segment);
}
candidate = candidate.getCanonicalFile();
if (within(root, candidate) == null || !candidate.isFile()) {
Log.w(TAG, "refused " + uri);
return null;
}
return candidate;
} catch (IOException e) {
Log.w(TAG, "refused " + uri + ": " + e);
return null;
}
}
/**
* {@code target}'s path relative to {@code root}, or null if it is not
* under it.
*
* <p>Both sides are canonical by the time they get here, which is what
* makes one string comparison enough for {@code ../} and for a symlink
* alike. The trailing separator matters: without it a sibling directory
* whose name merely starts with the root's — {@code /data/.../files.old} —
* passes.
*/
private static String within(File root, File target) {
String rootPath = root.getPath() + File.separator;
String targetPath = target.getPath();
if (!targetPath.startsWith(rootPath)) {
return null;
}
return targetPath.substring(rootPath.length());
}
}
@@ -0,0 +1,320 @@
package paris.tourolle.darkroom;
import android.app.Activity;
import android.content.ActivityNotFoundException;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.net.Uri;
import android.provider.OpenableColumns;
import android.util.Log;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.List;
/**
* The two directions of FR-PLAT-AND-6: what the app was opened *with*, and
* handing a finished export to somebody else.
*
* <h2>Why this is Java and not JNI in lib.rs</h2>
*
* <p>Every call below is reachable over JNI, and doing it that way would be
* roughly forty {@code call_method} invocations with their signatures written
* out as strings — each one a name Java checks at run time and nothing checks
* at build time. The Rust side would then hold the exact logic that is here,
* expressed less clearly, and a typo in {@code "()Landroid/content/Intent;"}
* would surface on a device as a {@code NoSuchMethodError} rather than at the
* compiler. So the platform work stays on the platform's side and the JNI
* surface is two calls, both taking and returning strings.
*
* <p>The class is only reachable because the APK now compiles Java at all; see
* docker/android/assemble-apk.sh.
*/
public final class Intents {
private static final String TAG = "DarkRoom";
/**
* Where incoming images are copied, under {@code getCacheDir()}.
*
* <p>The cache and not the data directory, deliberately: these are copies
* of somebody else's file, the app has no claim on them once the session
* ends, and the cache is the one place Android may reclaim under storage
* pressure without the user being asked. Putting them in the data
* directory would grow the app's footprint by a RAW file per share, for
* ever, with nothing that ever deletes them.
*/
private static final String INBOX = "incoming";
private Intents() {
}
/**
* The images this launch was asked to open, as paths the decoder can read.
*
* <p>Empty for an ordinary launch from the launcher, which is the common
* case and not a failure.
*
* <h3>Why the bytes are copied</h3>
*
* <p>A share arrives as a {@code content://} URI, which is a handle into
* another app's provider and not a path — there is no filename behind it to
* open, and the grant that makes it readable belongs to this task and dies
* with it. DarkRoom's decoders take paths (ARCH §6.9 is the note that
* Android has no paths to give), so the choice is to copy or to teach the
* whole read path about URIs, and the second is FR-PLAT-AND-1's SAF
* connector, which is not built.
*
* <p>So it is a copy, and the cost is honest: a 60 MB raw file is written
* once, to the cache, before the viewer opens. It is bounded by the share
* being a deliberate act — a person picked these files — rather than by
* anything this code does.
*
* <p>The inbox is emptied first. Without that, every share ever received
* accumulates until the platform decides the cache is too large, and the
* files are indistinguishable from each other by then.
*/
public static String[] receive(Activity activity) {
List<Uri> uris = incoming(activity.getIntent());
if (uris.isEmpty()) {
return new String[0];
}
File inbox = new File(activity.getCacheDir(), INBOX);
empty(inbox);
if (!inbox.mkdirs() && !inbox.isDirectory()) {
Log.e(TAG, "cannot create " + inbox + "; the launch intent is dropped");
return new String[0];
}
List<String> paths = new ArrayList<String>();
for (Uri uri : uris) {
String path = localise(activity, uri, inbox, paths.size());
if (path != null) {
paths.add(path);
}
}
Log.i(TAG, "launch intent carried " + paths.size() + " of " + uris.size() + " image(s)");
return paths.toArray(new String[0]);
}
/**
* Offer a file this app produced to whatever else is installed.
*
* <p>Returns false when there is nothing to offer it to, or when the file
* is not one {@link ExportProvider} may serve — both of which the caller
* has to be able to say out loud, because from the user's side a share
* button that does nothing is indistinguishable from one that failed.
*
* <p>{@code FLAG_GRANT_READ_URI_PERMISSION} is the whole security model:
* the provider is not exported, so the receiving app can reach this one
* file, for as long as its task lives, and nothing else ever.
*/
public static boolean share(Activity activity, String path, String mimeType) {
Uri uri = ExportProvider.uriFor(activity, new File(path));
if (uri == null) {
return false;
}
Intent send = new Intent(Intent.ACTION_SEND);
send.setType(mimeType != null && !mimeType.isEmpty() ? mimeType : "image/*");
send.putExtra(Intent.EXTRA_STREAM, uri);
send.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
// Always a chooser, never a direct start. Android's "remembered
// default" for ACTION_SEND is a per-user setting this app has no
// business consuming: the app a photograph should go to differs every
// time, and the one time it does not, the sheet is one extra tap.
Intent chooser = Intent.createChooser(send, null);
try {
activity.startActivity(chooser);
return true;
} catch (ActivityNotFoundException e) {
Log.w(TAG, "nothing installed accepts " + mimeType + ": " + e);
return false;
}
}
/**
* The URIs an Intent carries, by the action that carried them.
*
* <p>Only the actions the manifest registers for. An action we did not
* declare cannot arrive, so handling one here would be code that reads as
* support for something the launcher will never offer.
*/
@SuppressWarnings("deprecation")
private static List<Uri> incoming(Intent intent) {
List<Uri> uris = new ArrayList<Uri>();
if (intent == null) {
return uris;
}
String action = intent.getAction();
if (Intent.ACTION_VIEW.equals(action)) {
add(uris, intent.getData());
} else if (Intent.ACTION_SEND.equals(action)) {
// The typed getParcelableExtra(String, Class) overload is API 33,
// and minSdk is 28. The deprecated form is the only one that exists
// on every device this APK installs on.
add(uris, (Uri) intent.getParcelableExtra(Intent.EXTRA_STREAM));
} else if (Intent.ACTION_SEND_MULTIPLE.equals(action)) {
ArrayList<Uri> many = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
if (many != null) {
for (Uri uri : many) {
add(uris, uri);
}
}
}
return uris;
}
private static void add(List<Uri> uris, Uri uri) {
if (uri != null) {
uris.add(uri);
}
}
/** A URI as a readable path, copying it into the inbox if it is not one already. */
private static String localise(Context context, Uri uri, File inbox, int index) {
// A file:// URI is already a path, and copying it would double a raw
// file on disk to no end. Rare — the platform has refused file:// URIs
// between apps since API 24 — but it is what a shell `am start -d
// file:///sdcard/…` produces, which is how this path gets tested
// without a second app installed.
if (ContentResolver.SCHEME_FILE.equals(uri.getScheme())) {
String path = uri.getPath();
if (path != null && new File(path).canRead()) {
return path;
}
Log.w(TAG, "cannot read " + uri);
return null;
}
File dest = new File(inbox, unique(inbox, displayName(context, uri), index));
InputStream in = null;
OutputStream out = null;
try {
in = context.getContentResolver().openInputStream(uri);
if (in == null) {
Log.w(TAG, "no stream behind " + uri);
return null;
}
out = new FileOutputStream(dest);
byte[] buffer = new byte[64 * 1024];
int read;
while ((read = in.read(buffer)) > 0) {
out.write(buffer, 0, read);
}
out.flush();
return dest.getAbsolutePath();
} catch (IOException e) {
Log.w(TAG, "cannot copy " + uri + ": " + e);
// The partial copy is removed rather than left: it has the name and
// the extension of a photograph and none of the bytes, and the
// decoder would report it as a corrupt file rather than a failed
// transfer.
dest.delete();
return null;
} catch (SecurityException e) {
// The grant on a shared URI dies with the task that received it.
// A process resumed from a saved state can find itself holding a
// URI it may no longer read (FR-PLAT-AND-3), and that is a lost
// permission rather than a broken file.
Log.w(TAG, "no longer permitted to read " + uri + ": " + e);
dest.delete();
return null;
} finally {
close(in);
close(out);
}
}
/**
* What the sending app calls the file, reduced to something safe to write.
*
* <p>The name is chosen by another application and lands in a path this one
* composes, so it is filtered rather than trusted: a name containing a
* separator would place the copy outside the inbox, and one beginning with
* a dot would hide it from everything that lists the directory. What
* survives is the part a photographer recognises — {@code DSC_4471.NEF} —
* which is the only reason to use the sender's name at all.
*/
private static String displayName(Context context, Uri uri) {
String name = null;
Cursor cursor = null;
try {
cursor = context.getContentResolver().query(
uri, new String[] {OpenableColumns.DISPLAY_NAME}, null, null, null);
if (cursor != null && cursor.moveToFirst() && !cursor.isNull(0)) {
name = cursor.getString(0);
}
} catch (Exception e) {
// Providers are other people's code and any of them may throw.
// A name is a convenience; failing the whole open over it is not.
Log.d(TAG, "no display name for " + uri + ": " + e);
} finally {
if (cursor != null) {
cursor.close();
}
}
if (name == null) {
name = uri.getLastPathSegment();
}
if (name == null) {
return "shared";
}
StringBuilder safe = new StringBuilder(name.length());
for (int i = 0; i < name.length(); i++) {
char c = name.charAt(i);
boolean ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
|| (c >= '0' && c <= '9') || c == '.' || c == '-' || c == '_';
safe.append(ok ? c : '_');
}
while (safe.length() > 0 && safe.charAt(0) == '.') {
safe.deleteCharAt(0);
}
return safe.length() > 0 ? safe.toString() : "shared";
}
/**
* A name nothing in the inbox has yet.
*
* <p>A multi-image share of a burst arrives as several files a camera named
* the same thing in different folders, and the second one silently
* overwriting the first would show the user one photograph where they
* picked four.
*/
private static String unique(File inbox, String name, int index) {
if (!new File(inbox, name).exists()) {
return name;
}
return index + "-" + name;
}
private static void close(java.io.Closeable stream) {
if (stream != null) {
try {
stream.close();
} catch (IOException e) {
Log.d(TAG, "close failed: " + e);
}
}
}
/** Delete the inbox's contents, one level deep, which is all it ever has. */
private static void empty(File inbox) {
File[] stale = inbox.listFiles();
if (stale == null) {
return;
}
for (File file : stale) {
if (!file.delete()) {
Log.d(TAG, "could not remove stale " + file);
}
}
}
}
+192
View File
@@ -0,0 +1,192 @@
//! What the app was launched with, and handing a finished export back out.
//!
//! FR-PLAT-AND-6's Rust side, which is deliberately the thin side. Both
//! directions are implemented in `android/java/paris/tourolle/darkroom/` and
//! everything here is the two calls that reach them; `Intents.java` carries the
//! reasoning for the split. The short version is that a JNI method signature is
//! a string Java resolves at run time and nothing checks at build time, so
//! forty of them is forty ways for a rename to become a `NoSuchMethodError` on
//! somebody's tablet. Two is two.
//!
//! # Nothing here fails loudly
//!
//! A class the loader cannot see, a pending Java exception, a shared URI whose
//! grant died with the task that received it: each ends as a log line and an
//! empty result. This runs on the way to [`dr_ui::run`], before a window
//! exists, and the alternative to opening with an empty browsing list is not
//! opening at all.
use std::path::{Path, PathBuf};
use jni::errors::Result as JniResult;
use jni::objects::{JClass, JObject, JObjectArray, JString, JValue};
use jni::{JNIEnv, JavaVM};
/// The class both directions live in, named the way `loadClass` wants it —
/// dots, not slashes. `find_class` takes the other form, and this code calls
/// neither by accident; see [`load_class`].
const INTENTS: &str = "paris.tourolle.darkroom.Intents";
/// The images this launch was asked to open, already local and readable.
///
/// Empty for an ordinary launch from the launcher, which is the common case
/// and not a failure. What comes back is passed to `dr_ui::run` exactly as
/// command-line paths are on the desktop, so a shared photograph becomes the
/// browsing list and `startup_action` shows it rather than the launch screen.
pub fn launch_images(app: &slint::android::AndroidApp) -> Vec<PathBuf> {
with_activity(app, "reading the launch intent", |env, activity| {
let class = load_class(env, activity, INTENTS)?;
let returned = env
.call_static_method(
&class,
"receive",
"(Landroid/app/Activity;)[Ljava/lang/String;",
&[JValue::Object(activity)],
)?
.l()?;
let array = JObjectArray::from(returned);
let count = env.get_array_length(&array)?;
let mut paths = Vec::with_capacity(count as usize);
for i in 0..count {
let element = env.get_object_array_element(&array, i)?;
let text: String = env.get_string(&JString::from(element))?.into();
paths.push(PathBuf::from(text));
}
Ok(paths)
})
.unwrap_or_default()
}
/// Offer a file this app produced to whatever else is installed.
///
/// `false` means the sheet did not open — the file is not under the directory
/// [`ExportProvider`] serves, or nothing installed accepts the type. Both are
/// answers a caller has to be able to give the user, because a share control
/// that silently does nothing is indistinguishable from one that failed.
///
/// **This half has no caller yet, and that is the honest state of it.** The
/// provider, the URI grant and the chooser are all here and are what
/// FR-PLAT-AND-6 asks for; what is missing is a share control in the interface,
/// which lives in `ui/dr-ui` and needs one thing this signature shows: an
/// `AndroidApp` to call through. Wiring it means keeping a clone of the app —
/// it is `Clone` and cheap — somewhere `ui/` can reach, which is a change to
/// how the platform entry point talks to the interface rather than a change
/// here. Until that exists this function is reachable and untested, and it is
/// deliberately not tagged as covering the requirement.
///
/// `mime` decides which applications the chooser offers; the empty string
/// falls back to `image/*` on the Java side.
pub fn share(app: &slint::android::AndroidApp, file: &Path, mime: &str) -> bool {
with_activity(app, "opening the share sheet", |env, activity| {
let class = load_class(env, activity, INTENTS)?;
let path = env.new_string(file.to_string_lossy().as_ref())?;
let mime = env.new_string(mime)?;
env.call_static_method(
&class,
"share",
"(Landroid/app/Activity;Ljava/lang/String;Ljava/lang/String;)Z",
&[
JValue::Object(activity),
JValue::Object(&path),
JValue::Object(&mime),
],
)?
.z()
})
.unwrap_or(false)
}
/// Attach to the JVM, borrow the activity, and run `body` against both.
///
/// Shared by the two entry points because the three steps before the
/// interesting one are identical and each has its own way of failing. `body`
/// returning `Err` is reported here, once, in the one place that can also clear
/// a pending Java exception — see [`report`].
fn with_activity<T>(
app: &slint::android::AndroidApp,
doing: &str,
body: impl FnOnce(&mut JNIEnv, &JObject) -> JniResult<T>,
) -> Option<T> {
let vm = match unsafe { JavaVM::from_raw(app.vm_as_ptr().cast()) } {
Ok(vm) => vm,
Err(e) => {
log::error!("no JVM handle, so {doing} is skipped: {e}");
return None;
}
};
// Cheap when the thread is already attached, which it is: the glue
// attached it before it called `android_main`. The guard exists for the
// case where it is not, and costs a lookup where it is.
let mut env = match vm.attach_current_thread() {
Ok(env) => env,
Err(e) => {
log::error!("cannot attach to the JVM, so {doing} is skipped: {e}");
return None;
}
};
// SAFETY: `activity_as_ptr` documents this as an unowned JNI *global*
// reference to the Activity, valid for as long as the `AndroidApp` it came
// from. `JObject` in jni 0.21 is a plain wrapper with no `Drop`, so
// borrowing it here cannot delete a reference this code does not own — the
// one way to get this wrong is `AutoLocal` or a `GlobalRef`, both of which
// would free it out from under android-activity.
let activity = unsafe { JObject::from_raw(app.activity_as_ptr().cast()) };
match body(&mut env, &activity) {
Ok(value) => Some(value),
Err(e) => {
report(&mut env, doing, &e);
None
}
}
}
/// Look an app class up through the *activity's* class loader.
///
/// `find_class` is the obvious call and the wrong one. JNI resolves a class
/// against the loader belonging to the Java frame beneath the call, and on this
/// thread there is no such frame: `android_main` runs on a thread the native
/// glue created and attached itself, so the loader in scope is the system one.
/// It knows every class in the platform and nothing at all from this APK, and
/// says so as a `ClassNotFoundException` naming a class that is plainly in the
/// dex — which reads as a broken build rather than as the wrong loader.
///
/// The activity is a Java object, so its loader is the app's.
fn load_class<'local>(
env: &mut JNIEnv<'local>,
activity: &JObject,
name: &str,
) -> JniResult<JClass<'local>> {
let loader = env
.call_method(activity, "getClassLoader", "()Ljava/lang/ClassLoader;", &[])?
.l()?;
let name = env.new_string(name)?;
let class = env
.call_method(
&loader,
"loadClass",
"(Ljava/lang/String;)Ljava/lang/Class;",
&[JValue::Object(&name)],
)?
.l()?;
Ok(JClass::from(class))
}
/// Log a JNI failure, and clear the exception behind it if there is one.
///
/// The clearing is not tidiness. A Java exception raised through JNI stays
/// *pending* on the thread, and the next JNI call made while one is pending
/// aborts the process — so a swallowed exception here would come back as a
/// crash somewhere unrelated, most likely inside Slint. `exception_describe`
/// first, because the trace it prints to logcat is the only place the Java
/// class and line survive; `jni::errors::Error::JavaException` on its own says
/// neither.
fn report(env: &mut JNIEnv, doing: &str, e: &jni::errors::Error) {
log::error!("{doing} failed: {e}");
if let Ok(true) = env.exception_check() {
let _ = env.exception_describe();
let _ = env.exception_clear();
}
}
+458 -41
View File
@@ -6,8 +6,21 @@
//! * 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.
//! * Logging goes to logcat *and* to a file. `env_logger` writes to stderr,
//! which Android discards; logcat replaces it, and a rotating file beside it
//! replaces the thing logcat cannot be — a record that outlives the session
//! and can be sent to somebody (NFR-OPS-1, [`dr_plat::diagnostics`]).
//!
//! The first of those has one exception, and it is the launch `Intent`: a
//! gallery, a file manager or the share sheet can name images to open, and
//! those arrive as URIs on an `Intent` rather than as words on a command line.
//! [`intents`] turns them into paths, and from there they are the same list
//! the desktop builds from `argv` (FR-PLAT-AND-6).
// The whole module is JNI against classes that exist only in the APK, so it
// is gated with everything else that cannot compile off-device.
#[cfg(target_os = "android")]
mod intents;
// `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
@@ -18,22 +31,109 @@
/// Android application entry point, called by android-activity's glue.
#[no_mangle]
fn android_main(app: slint::android::AndroidApp) {
android_logger::init_once(
// **The first statement in the process, and it has to be.** Everything
// between here and `install` returning runs with no logger installed at
// all: asking the activity for its external directory, `create_dir_all`
// and an `open` on a FUSE-backed volume the system may still be mounting.
// A failure or a stall in any of it is invisible on every surface there
// is — no file yet, and nothing in logcat either — which is precisely the
// kind of launch logcat exists to debug.
//
// `AndroidLogger` rather than `init_once`, so logcat can be *teed* rather
// than replaced: `init_once` installs itself as the global logger and
// there is only one of those. Everything that reached logcat before the
// file existed still reaches it, at the same level and under the same tag;
// the file is strictly additional.
let console = android_logger::AndroidLogger::new(
android_logger::Config::default()
.with_max_level(log::LevelFilter::Info)
.with_tag("DarkRoom"),
);
// Handed to the logger directly, and **not** written as `log::info!`,
// which here would compile and emit nothing: the facade's maximum level is
// `Off` until `diagnostics::install` sets it, and the macro tests that
// before it reaches any logger at all. This call skips the facade and
// reaches `__android_log_write` with nothing in between.
//
// That independence is the second reason for it. When the log is silent,
// this line is what says which half is at fault: present here and absent
// below means the `log` wiring, absent in both means liblog is not
// delivering this process's records — a question about the device, which
// no amount of reading this file can answer.
log::Log::log(
&console,
&log::Record::builder()
.level(log::Level::Info)
.target(module_path!())
.module_path(Some(module_path!()))
.args(format_args!(
"DarkRoom v{} starting; logcat only until the log file opens",
env!("CARGO_PKG_VERSION")
))
.build(),
);
// Before the file logger, because it needs somewhere to write.
//
// **The external directory, not the internal one, and the difference is
// the entire point of the file.** Both are app-private and both survive
// backgrounding — the volatile one is the *cache* directory, which is not
// in play here. What separates them is retrieval:
// `/data/data/<pkg>/files` needs `run-as` against a debuggable build or
// root to read, and `/sdcard/Android/data/<pkg>/files` is a plain
// `adb pull` from any build, needing no permission since API 19. A log
// nobody can get off the device does not do the job NFR-OPS-1 describes.
//
// The consequence is that anyone holding the tablet can read it, which is
// why `dr_plat::diagnostics` redacts at the sink and why configuration —
// the account list, and the credential reference beside it — stays on
// `internal_data_path` below rather than moving here (NFR-SEC-2).
let external = app.external_data_path();
if let Some(dir) = external.clone().or_else(|| app.internal_data_path()) {
dr_plat::set_state_dir(dir);
}
let logging = dr_plat::diagnostics::install(Box::new(console), log::LevelFilter::Info);
// 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}");
}));
//
// This used to be one `log::error!` of the raw panic, which had two
// problems: logcat is a ring buffer that is gone by the time a user
// reports anything, and the raw message can carry a document URI naming
// their library or a credential a library interpolated into an error
// (NFR-SEC-2). `dr_plat::crash` writes a redacted record to disk and logs
// the redacted form. Nothing uploads it.
//
// Before `set_state_dir` on purpose: the hook resolves the directory when
// it fires, so installing it first covers the startup below rather than
// leaving it uncovered.
dr_plat::crash::install(env!("CARGO_PKG_VERSION"));
log::info!("DarkRoom v{}", env!("CARGO_PKG_VERSION"));
// Said in logcat as well as in the file, because the first thing anybody
// asked for a log needs is where it is — and on a device that is a path
// nobody can guess and a command nobody remembers.
match &logging {
dr_plat::Installed::ToFile(path) => {
log::info!("logging to {}", path.display());
if external.is_none() {
log::warn!(
"no external storage; the log is app-private and needs \
`adb shell run-as paris.tourolle.darkroom cat files/darkroom.log` \
on a debuggable build"
);
}
}
dr_plat::Installed::ConsoleOnly(why) => {
log::warn!("no log file this session, only logcat: {why}");
}
}
// 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
@@ -43,75 +143,217 @@ fn android_main(app: slint::android::AndroidApp) {
match app.internal_data_path() {
Some(dir) => {
log::info!("data dir: {}", dir.display());
dr_sync_nextcloud::session::set_data_dir(dir);
// Crash records go beside the account data rather than under it:
// both are app-private and neither is a cache, which is the whole
// distinction that matters here (see `dr_plat::crash::state_dir`).
dr_plat::crash::set_state_dir(dir.join("state"));
dr_sync::account::set_data_dir(dir);
}
None => log::error!("no internal data path; settings will not persist"),
}
// After the data dir and before anything asks whether a model is present.
install_bundled_face_models(&app);
// After the data dir, because it writes beside the catalog. **Not** before
// the first frame any more — it starts a worker and returns; see the
// function for what it used to cost the launch.
install_bundled_models(app.clone());
if let Err(e) = slint::android::init(app) {
// Before `init_with_event_listener`, which takes `app` by value and is the
// last moment anything can ask the activity a question. Not an ordering
// preference — after that line there is no `app` left to read the Intent
// through.
let opened_with = intents::launch_images(&app);
// TRACES: FR-PLAT-AND-5
// The listener is the whole reason this is not the one-line
// `slint::android::init(app)`. Slint owns the event loop on Android, so
// the platform's lifecycle and memory events reach the application only if
// it asks for them here — and it must ask *before* the loop starts, which
// is why this sits between the data directory and `dr_ui::run`.
//
// The listener runs inside `poll_events`, on the same thread the event
// loop and every interface cache live on, which is what lets
// `dr_ui::memory` be a thread-local registry of plain `Fn()` rather than a
// cross-thread channel (see its module documentation).
//
// # Why two events and not eight
//
// FR-PLAT-AND-5 names `onTrimMemory`, whose `TRIM_MEMORY_*` levels grade
// how badly the system wants the memory back. Those levels do not exist
// here: `ComponentCallbacks2` is a Java interface implemented by an
// `Activity` or `Application`, and this app has neither — it is a bare
// `NativeActivity`, whose native callback table offers only the ungraded
// `onLowMemory`. android-activity surfaces exactly that as `LowMemory`.
// Reading the grades would mean shipping a Java subclass to forward them,
// which is a distribution-manifest change and not this one.
//
// `Stop` recovers the one grade that matters most anyway, and for free.
// It is the moment the activity stops being visible — `TRIM_MEMORY_UI_HIDDEN`
// in all but name — and it is the cheapest possible time to give memory
// back, because nothing that is freed has to be drawn again before anyone
// sees it. `Pause` deliberately does not qualify: a permission dialog or
// the share sheet pauses an activity that is still on screen behind it,
// and throwing away its render pipeline would make every such interruption
// cost a full re-render.
use slint::android::android_activity::{MainEvent, PollEvent};
if let Err(e) = slint::android::init_with_event_listener(app, |event| match event {
PollEvent::Main(MainEvent::LowMemory) => {
dr_ui::memory::relieve(dr_ui::memory::Level::Critical);
}
PollEvent::Main(MainEvent::Stop) => {
dr_ui::memory::relieve(dr_ui::memory::Level::UiHidden);
}
_ => {}
}) {
log::error!("Slint Android backend failed to initialise: {e}");
return;
}
// Empty rather than the desktop's argv: see the module note above.
// The launch Intent's images, where there were any, standing in for the
// desktop's argv — `launch::startup_action` treats a non-empty list as
// "the user asked for these specifically", which is exactly what a share
// or a tap in a gallery is. Empty for an ordinary launch, and the library
// opens as before.
//
// 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()) {
if let Err(e) = dr_ui::run(opened_with) {
log::error!("DarkRoom exited with error: {e:#}");
}
}
/// Unpack the face models the APK carries, if it carries any.
/// Unpack the models the APK carries, if it carries any.
///
/// # Why Android needs this and no other platform does
///
/// The weights are not a build input and are not in the repository — the
/// InsightFace grant is research-only and incompatible with this project's
/// licence (docs/faces.md §2), so a desktop user fetches them, runs
/// `tools/fix-face-model-shapes.sh` over them, and drops the result into
/// `~/.local/share/darkroom/models/`. **That gesture does not exist on
/// Android.** `internal_data_path` is app-private, `run-as` needs a debuggable
/// build, and there is no picker and no fetch in the app, so a phone had no way
/// to acquire a model at all and face indexing reported itself permanently off.
/// A desktop build reads its models from a path — the account's directory, the
/// shared one, or `$XDG_DATA_DIRS` where a package put them. **Android has no
/// such path.** `internal_data_path` is app-private, `run-as` needs a
/// debuggable build, and an asset inside a package is not a path anything can
/// open (ARCH §6.9), so a phone had no way to reach a model at all.
///
/// So a locally-built APK may carry the pair in `assets/models/`, which
/// `assemble-apk.sh` includes when the tree has them and omits when it does
/// not. Nothing changes about what the repository holds or what a published
/// build could redistribute; this only gives a self-built APK the same route a
/// desktop build has always had.
/// So the APK carries them in `assets/models/` and this copies them out, once,
/// into the same shared directory a desktop install uses. After that every
/// lookup in `dr_ui::library` finds them exactly where it finds a desktop
/// user's.
///
/// Absent assets are the ordinary case, not an error — the same quiet "no model
/// installed" state a fresh desktop install is in.
/// # The two sets are not the same kind of thing
///
/// **Face weights are absent from the repository by design.** The InsightFace
/// grant is research-only and incompatible with this project's licence
/// (docs/faces.md §2), so a desktop user fetches them, runs
/// `tools/fix-face-model-shapes.sh` over them, and drops the result in. A build
/// that carries none is the ordinary case and face indexing simply stays off.
///
/// **The scene model is committed** (AGPL, compatible — `models/LICENCE.md`),
/// so a build carrying none means a checkout without `git lfs pull` rather than
/// a deliberate omission. It is still not an error here: the scene tab reports
/// itself unavailable the same way face indexing does, because a photo editor
/// that refuses to start over a missing grading feature is worse than one that
/// starts without it.
///
/// # Why it is not `include_bytes!` like the instance model
///
/// Size. The instance model is 11 MB and compiled in; the scene model is 24 MB
/// on top of that, and a 35 MB constant in the binary is paid by every install
/// whether or not the tab is opened. Assets are also *stored* rather than
/// deflated in the APK (see `assemble-apk.sh`), so unpacking is a copy rather
/// than an inflate.
///
/// # Why it returns before it has done anything
///
/// **`android_main` runs with the input channel unserviced.** Nothing drains
/// it until Slint reaches `poll_events`, and Slint does not reach `poll_events`
/// until `dr_ui::run` calls `window.run()`, which is the last line of it. So
/// every millisecond spent between the top of `android_main` and that line is a
/// millisecond in which Android's input dispatcher gets no answer, and five
/// thousand of them is an ANR by definition — the system puts "DarkRoom isn't
/// responding" over a window that has never painted, and offers to kill it.
///
/// This copied **41 MB** on the first launch after an install: 24.9 MB of scene
/// model, 13.6 MB of embedder, 2.5 MB of detector, each read whole out of the
/// APK and written to `/data`. v0.10.0 added the scene model, which was 60% of
/// that total; v0.10.0 is the release the ANR appeared in, and the 8,010 minor
/// faults in its report are what 41 MB of freshly touched pages looks like.
/// The two further detectors the settings page offers since have made it
/// 61 MB, which is the same argument with a larger number.
///
/// So it runs on a worker (NFR-ARCH-1: nothing blocking on the UI executor) and
/// this function returns as soon as the thread is running. Nothing on the
/// launch path waits for it, and no other startup step needs its result.
///
/// # The window in which a model looks absent, and why that is honest enough
///
/// Until the copy finishes, `library::face_models` and `library::scene_model`
/// answer `is_file()` about files that are not written yet, so both report
/// their feature unavailable — the same answer they give a build carrying no
/// weights at all, which is the ordinary case this whole path was written
/// around. It is briefly pessimistic rather than wrong, it lasts about as long
/// as it takes to read one screenful of the grid, and the temporary name
/// [`unpack_bundled_models`] writes under is what stops it being worse than
/// pessimistic: a lookup never sees a half-written file, only an absent one.
#[cfg(target_os = "android")]
fn install_bundled_face_models(app: &slint::android::AndroidApp) {
fn install_bundled_models(app: slint::android::AndroidApp) {
// Detached rather than joined: there is no later moment on the launch path
// that wants the answer, and a handle nobody joins is a handle nobody can
// forget to. `AndroidApp` is documented `Send` and `Sync` and is an `Arc`
// internally, so the clone costs a refcount; `asset_manager` is asked for
// on the worker because `AAssetManager` is thread-safe by contract and
// reading the pointer takes only the app's read lock, which `poll_events`
// also only ever holds shared.
std::thread::spawn(move || unpack_bundled_models(&app));
}
/// The copy itself, on the worker [`install_bundled_models`] starts.
#[cfg(target_os = "android")]
fn unpack_bundled_models(app: &slint::android::AndroidApp) {
use std::io::Read;
// The **shape-fixed** names, matching what `library::face_models` looks
// for: tract cannot parse either InsightFace graph with its dynamic input
// dimension, so what ships here has already been through
// `tools/fix-face-model-shapes.sh`.
const BUNDLED: [(&std::ffi::CStr, &str); 2] = [
let started = std::time::Instant::now();
// The face names are the **shape-fixed** exports, matching what
// `library::face_models` looks for: tract cannot parse either InsightFace
// graph with its dynamic input dimension, so what ships here has already
// been through `tools/fix-face-model-shapes.sh`.
//
// The scene entries are three files rather than one because the graph alone
// decodes to 150 anonymous channels — `library::scene_model` wants the
// vocabulary and the category descriptor beside it, and requires all three
// before it reports the tab available.
//
// Three detectors, because which one runs is a setting
// (`FaceDetector`, docs/faces.md §12.3) and a tablet has no other way to
// obtain the one it was not shipped with. Twenty megabytes of APK for
// the choice; the embedder is the same for all three.
const BUNDLED: [(&std::ffi::CStr, &str); 7] = [
(c"models/scrfd_500m_640.onnx", "scrfd_500m_640.onnx"),
(c"models/scrfd_2.5g_640.onnx", "scrfd_2.5g_640.onnx"),
(c"models/scrfd_10g_640.onnx", "scrfd_10g_640.onnx"),
(c"models/arcface_mbf_b1.onnx", "arcface_mbf_b1.onnx"),
(c"models/yolo26s-sem-ade20k.onnx", "yolo26s-sem-ade20k.onnx"),
(
c"models/yolo26s-sem-ade20k.classes.json",
"yolo26s-sem-ade20k.classes.json",
),
(c"models/categories.txt", "categories.txt"),
];
let dir = dr_ui::shared_face_models_dir();
let assets = app.asset_manager();
let mut copied = 0u64;
for (asset_path, name) in BUNDLED {
let dest = dir.join(name);
// Already unpacked. Not re-read on every launch: this is 15 MB through
// a decompressor on the startup path, and the file does not change
// without the APK changing, at which point the install wiped it anyway.
// Already unpacked. Not re-read on every launch: this is 61 MB of
// copying across the seven entries, and the file does not change without
// the APK changing, at which point the install wiped it anyway. It
// matters more now than it did — a launch that skips every entry here
// costs nothing at all, which is what makes the second launch after an
// install cheap even though the first one is not.
if dest.is_file() {
continue;
}
let Some(mut asset) = assets.open(asset_path) else {
log::info!("no bundled {name} in this APK; face indexing stays off");
log::info!("no bundled {name} in this APK; the feature needing it stays off");
continue;
};
let mut bytes = Vec::new();
@@ -124,18 +366,193 @@ fn install_bundled_face_models(app: &slint::android::AndroidApp) {
return;
}
// Written under a temporary name and renamed, because
// `library::face_models` decides face indexing is available on
// `is_file()` alone. A truncated write — the process backgrounded and
// `library::face_models` and `library::scene_model` both decide a
// feature is available on `is_file()` alone. A truncated write — the process backgrounded and
// killed mid-copy — would otherwise leave a file that passes that test
// and fails inside tract, reported to the user as a broken model rather
// than a missing one.
let part = dir.join(format!("{name}.part"));
match std::fs::write(&part, &bytes).and_then(|()| std::fs::rename(&part, &dest)) {
Ok(()) => log::info!("installed bundled {name} ({} bytes)", bytes.len()),
Ok(()) => {
copied += bytes.len() as u64;
log::info!("installed bundled {name} ({} bytes)", bytes.len());
}
Err(e) => {
log::error!("cannot install {name}: {e}");
let _ = std::fs::remove_file(&part);
}
}
}
// The figure this whole function is about. Said even when it is zero, so a
// launch that ANRs anyway can be told apart from one that spent its six
// seconds here — on a second launch there is nothing left to copy and the
// line reads `0 bytes`.
log::info!(
"bundled models ready: {copied} bytes copied in {} ms",
started.elapsed().as_millis()
);
}
/// TRACES: FR-PLAT-AND-6
/// The declarations that make this app a receiver, held to on the host.
///
/// Everything FR-PLAT-AND-6 does on a device is unreachable from `cargo test`:
/// there is no `Intent` off-device and no `ContentProvider` to instantiate. But
/// the requirement is not only behaviour — half of it is *declaration*, and a
/// declaration can be wrong in ways that compile perfectly and fail silently.
/// An intent filter that is deleted takes the app out of every gallery's "open
/// with" menu with nothing to notice; an authority that stops matching the
/// class it names raises a `SecurityException` in whichever other app opened
/// the share sheet, which is the last place anybody would look for it.
///
/// The manifest is read by aapt2 and the Java by javac, so a Rust build sees
/// neither. `include_str!` is what puts them where a test can reach them, and
/// this is the only place in the workspace that does.
#[cfg(test)]
mod tests {
/// The manifest with its comments removed and its whitespace flattened, so
/// a match is about the declaration and not about how it is indented.
fn manifest() -> String {
let xml = include_str!("../android/AndroidManifest.xml");
let mut out = String::with_capacity(xml.len());
let mut rest = xml;
// Comments first, and not by regex over the whole file: several of them
// quote the very attribute names the assertions below look for, so a
// test that read them would pass on the strength of the prose
// explaining an entry that had been deleted.
while let Some(start) = rest.find("<!--") {
out.push_str(&rest[..start]);
match rest[start..].find("-->") {
Some(end) => rest = &rest[start + end + 3..],
None => {
rest = "";
break;
}
}
}
out.push_str(rest);
out.split_whitespace().collect::<Vec<_>>().join(" ")
}
/// The body of each `<intent-filter>`, so an action and a MIME type are
/// checked to be in the *same* filter. Two filters, one naming the action
/// and one naming the type, register for neither.
fn intent_filters(manifest: &str) -> Vec<&str> {
manifest
.split("<intent-filter>")
.skip(1)
.filter_map(|filter| filter.split("</intent-filter>").next())
.collect()
}
/// The single `<provider>` element, attributes and all.
fn provider(manifest: &str) -> String {
let start = manifest
.find("<provider")
.expect("no <provider> in the manifest");
let rest = &manifest[start..];
let end = rest.find("/>").expect("unterminated <provider> element");
rest[..end + 2].to_string()
}
fn attribute(element: &str, name: &str) -> Option<String> {
let key = format!("{name}=\"");
let start = element.find(&key)? + key.len();
let value = element[start..].split('"').next()?;
Some(value.to_string())
}
#[test]
fn a_gallery_can_open_a_photograph_in_this_app() {
let manifest = manifest();
let registered = intent_filters(&manifest).iter().any(|filter| {
filter.contains("android.intent.action.VIEW")
&& filter.contains("android.intent.category.DEFAULT")
&& filter.contains(r#"android:mimeType="image/*""#)
});
assert!(
registered,
"no VIEW filter for image/*: nothing will offer DarkRoom for a photograph"
);
}
#[test]
fn the_share_sheet_can_send_one_image_or_several() {
let manifest = manifest();
let registered = intent_filters(&manifest).iter().any(|filter| {
// The closing quote matters: SEND is a prefix of SEND_MULTIPLE, so
// a bare substring test passes on a filter that declares only the
// second and would not be offered for a single photograph.
filter.contains(r#"android.intent.action.SEND""#)
&& filter.contains(r#"android.intent.action.SEND_MULTIPLE""#)
&& filter.contains("android.intent.category.DEFAULT")
&& filter.contains(r#"android:mimeType="image/*""#)
});
assert!(
registered,
"no SEND/SEND_MULTIPLE filter for image/*, so the share sheet will not list DarkRoom"
);
}
#[test]
fn one_activity_ever_so_a_second_launch_cannot_start_a_second_one() {
// Not style. Another app can now launch this activity while it is
// already running, and the default launch mode answers that by
// creating a second NativeActivity in this process — a second
// android_main, a second Slint backend, a second wgpu device.
assert!(
manifest().contains(r#"android:launchMode="singleTask""#),
"the activity must be singleTask; see the manifest comment"
);
}
#[test]
fn the_provider_authority_is_the_one_the_class_answers_to() {
let manifest = manifest();
let element = provider(&manifest);
let declared =
attribute(&element, "android:authorities").expect("the provider declares no authority");
let java = include_str!("../android/java/paris/tourolle/darkroom/ExportProvider.java");
let constant = java
.split("AUTHORITY = \"")
.nth(1)
.and_then(|rest| rest.split('"').next())
.expect("ExportProvider declares no AUTHORITY constant");
assert_eq!(
declared, constant,
"the manifest and ExportProvider disagree about the authority; \
a share would fail as a SecurityException inside the receiving app"
);
let class = attribute(&element, "android:name").expect("the provider declares no class");
let (package, _) = class
.rsplit_once('.')
.expect("the provider class is unqualified");
assert!(
java.contains(&format!("package {package};")),
"the manifest names {class}, which is not the class in ExportProvider.java"
);
}
#[test]
fn the_provider_hands_out_one_file_at_a_time_and_nothing_by_itself() {
let manifest = manifest();
let element = provider(&manifest);
// The two halves are not redundant. Without the grant, every share
// target fails; exported, every app on the device could read this
// app's private directory.
assert_eq!(
attribute(&element, "android:exported").as_deref(),
Some("false"),
"an exported provider would serve the app's private directory to anything installed"
);
assert_eq!(
attribute(&element, "android:grantUriPermissions").as_deref(),
Some("true"),
"without URI grants the share sheet opens and every target fails to read the file"
);
}
}
+5 -1
View File
@@ -6,7 +6,11 @@ rust-version.workspace = true
license.workspace = true
[dependencies]
dr-ui.workspace = true
dr-ui = { workspace = true, features = ["scene-model"] }
# For the panic hook and the log sink, directly rather than through dr-ui:
# both have to be installed before `dr_ui::run`, because a panic during startup
# is exactly the one they exist to catch and record (NFR-OPS-1, NFR-OPS-2).
dr-plat.workspace = true
anyhow.workspace = true
env_logger.workspace = true
log.workspace = true
+26 -2
View File
@@ -4,13 +4,37 @@
use std::path::PathBuf;
use dr_plat::diagnostics::Installed;
fn main() -> anyhow::Result<()> {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(
// Built rather than `init`ed, so the same logger can be handed to the
// diagnostics tee: `env_logger` keeps writing to stderr exactly as before,
// and every record it accepts is also appended to the on-disk log
// (NFR-OPS-1). `filter()` is asked afterwards because the environment may
// have overridden the default below, and the file must not be quieter than
// the terminal.
let console = 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();
.build();
let level = console.filter();
let logging = dr_plat::diagnostics::install(Box::new(console), level);
// Immediately after the logger and before anything that could fail. Until
// now a panic on desktop went to stderr and died with the terminal, which
// means every panic a user has ever hit was unreportable: the process
// survives (the panicking worker does not), a control goes dead, and there
// is nothing on disk to say why. The record is local and stays local —
// there is no upload path, by design; see `dr_plat::crash`.
dr_plat::crash::install(env!("CARGO_PKG_VERSION"));
log::info!("DarkRoom v{}", env!("CARGO_PKG_VERSION"));
// First thing in the file, so a user asked for "the log" can find it
// without being told a path over the phone.
match &logging {
Installed::ToFile(path) => log::info!("logging to {}", path.display()),
Installed::ConsoleOnly(why) => log::warn!("no log file this session: {why}"),
}
let paths: Vec<PathBuf> = std::env::args().skip(1).map(PathBuf::from).collect();
if paths.is_empty() {
+420
View File
@@ -0,0 +1,420 @@
//! What the suggestion confidence would say about a real library.
//!
//! cargo run --release -p dr-catalog --example face_confidence -- CATALOG.sqlite [--full]
//!
//! Read-only: it writes nothing to the catalog, so it can be pointed at a copy
//! of a live library and re-run at will.
//!
//! # What it measures
//!
//! The user's own confirmations are the only ground truth a library has, so
//! the evaluation is leave-one-out over them: hide one confirmed face, ask the
//! scorer which of the confirmed identities it belongs to, and compare with
//! what the user said. Faces from the same photograph are excluded exactly as
//! the clusterer excludes them, so nothing is scored against a co-occurrence
//! that would never have been allowed to merge.
//!
//! Two numbers are compared on that task: the share (`dr_face::assign`) and the
//! mean-within-group figure it replaced. Accuracy says which one picks the
//! right person; the reliability table says whether the percentage the user is
//! shown means what it claims — which is the question FR-CULL-9 exists for.
use std::collections::HashMap;
use dr_catalog::faces::{self, PersonId};
use dr_catalog::Catalog;
const MODEL_ID: &str = "w600k_mbf";
const TOP: usize = 10;
struct Known {
image: u64,
person: PersonId,
embedding: Vec<f32>,
crop_px: f32,
}
/// What the catalog holds per face, decoded: photograph, vector, size,
/// quality.
type Decoded = (u64, Vec<f32>, f32, Option<f32>);
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let Some(path) = args.first() else {
eprintln!("usage: face_confidence CATALOG.sqlite [--full]");
std::process::exit(2);
};
let catalog = Catalog::open(std::path::Path::new(path)).expect("open catalog");
let conn = catalog.connection();
let cal = match faces::calibration(conn, MODEL_ID) {
Ok(Some((c, _))) => c,
_ => dr_face::Calibration::default(),
};
println!(
"calibration: a={:.2} b={:.2} w_size={:.3} valid={} (P=0.5 at cosine {:.3})",
cal.a,
cal.b,
cal.w_size,
cal.valid,
cal.boundary_at(0.5, 150.0, 0.0)
);
let model = dr_face::ModelId::new(MODEL_ID.to_string());
let stored = faces::embeddings(conn, MODEL_ID).expect("embeddings");
let mut embedding_of: HashMap<faces::FaceId, Decoded> = HashMap::new();
for f in stored {
if let Some(e) = dr_face::Embedding::from_f16_bytes(model.clone(), &f.embedding) {
embedding_of.insert(f.face, (f.image.0, e.v.to_vec(), f.crop_px, f.quality));
}
}
println!("faces with embeddings: {}", embedding_of.len());
// The ground truth: every confirmed face, under the person the user put it
// on. Identities with a single confirmation are dropped — leaving one out
// leaves that identity with no evidence at all, so they measure nothing.
let people = faces::people(conn).expect("people");
let mut known: Vec<Known> = Vec::new();
let mut identities = 0usize;
for p in &people {
if p.confirmed_faces < 2 {
continue;
}
let mut mine = Vec::new();
for f in faces::for_person(conn, p.id, false).expect("faces") {
if !f.confirmed {
continue;
}
if let Some((image, embedding, crop_px, _)) = embedding_of.get(&f.id) {
mine.push(Known {
image: *image,
person: p.id,
embedding: embedding.clone(),
crop_px: *crop_px,
});
}
}
if mine.len() >= 2 {
identities += 1;
known.extend(mine);
}
}
println!(
"ground truth: {} confirmed faces across {identities} identities\n",
known.len()
);
if known.len() < 2 {
println!("not enough confirmations to evaluate.");
return;
}
let mut share_right = 0usize;
let mut mean_right = 0usize;
// (share of the winner, was the winner correct)
let mut reliability: Vec<(f32, bool)> = Vec::with_capacity(known.len());
// What each scorer would have *displayed* for the correct answer.
let mut shown_share = Vec::with_capacity(known.len());
let mut shown_mean = Vec::with_capacity(known.len());
for (i, me) in known.iter().enumerate() {
let mut per_person: HashMap<PersonId, Vec<f32>> = HashMap::new();
// The mean baseline is the old code's, which had no floor: it averaged
// over every member of the group.
let mut all_person: HashMap<PersonId, Vec<f32>> = HashMap::new();
for (j, them) in known.iter().enumerate() {
if i == j || me.image == them.image {
continue;
}
let cos: f32 = me
.embedding
.iter()
.zip(&them.embedding)
.map(|(a, b)| a * b)
.sum();
// The floor the real scorer sees: `cluster_scored` scans at
// `RIVAL_FLOOR` and `identity_shares` never learns about a pair
// below it. Summing the near-orthogonal ones here instead of
// dropping them is not a stricter test, it is a different
// function — fifty identities contributing their *upper tail* of
// noise outweigh one contributing a real match.
let probability = cal.probability(cos, me.crop_px.min(them.crop_px), 0.0);
if probability >= dr_face::RIVAL_FLOOR {
per_person.entry(them.person).or_default().push(probability);
}
all_person.entry(them.person).or_default().push(probability);
}
// The share: sum of the best TOP matches per identity, normalised.
// Evidence, and the coherence that goes with it: the sum of the best
// TOP matches, and their mean. dr_face::assign shows the product of
// that mean and the identity's share of the total.
let mut evidence: Vec<(PersonId, f32, f32)> = per_person
.iter()
.map(|(&p, probabilities)| {
let mut v = probabilities.clone();
v.sort_by(|a, b| b.total_cmp(a));
let counted = v.len().min(TOP);
let sum = v.iter().take(TOP).sum::<f32>();
(p, sum, sum / counted as f32)
})
.collect();
let total: f32 = evidence.iter().map(|(_, s, _)| *s).sum();
evidence.sort_by(|a, b| b.1.total_cmp(&a.1));
// The number it replaced: the mean over every member of the identity.
let mut means: Vec<(PersonId, f32)> = all_person
.iter()
.map(|(&p, probabilities)| {
(
p,
probabilities.iter().sum::<f32>() / probabilities.len() as f32,
)
})
.collect();
means.sort_by(|a, b| b.1.total_cmp(&a.1));
if let (Some(&(winner, score, coherence)), true) = (evidence.first(), total > 0.0) {
let correct = winner == me.person;
share_right += correct as usize;
// What the screen would say about the identity it picked.
reliability.push((coherence * score / total, correct));
let ours = evidence
.iter()
.find(|(p, _, _)| *p == me.person)
.map(|(_, s, c)| c * s / total)
.unwrap_or(0.0);
shown_share.push(ours);
}
if let Some(&(winner, _)) = means.first() {
mean_right += (winner == me.person) as usize;
shown_mean.push(
means
.iter()
.find(|(p, _)| *p == me.person)
.map(|(_, s)| *s)
.unwrap_or(0.0),
);
}
}
let n = known.len() as f64;
println!("which identity does this face belong to? (leave-one-out, top-1)");
println!(
" share of evidence {:>6.2}% ({share_right}/{})",
100.0 * share_right as f64 / n,
known.len()
);
println!(
" mean within group {:>6.2}% ({mean_right}/{})\n",
100.0 * mean_right as f64 / n,
known.len()
);
println!("what the screen would show for the answer the user gave:");
band(" share ", &shown_share);
band(" mean ", &shown_mean);
println!("\nreliability of the share — is a stated {{n}}% right {{n}}% of the time?");
println!(
" {:>12} {:>7} {:>9} {:>8}",
"stated", "faces", "correct", "gap"
);
for (lo, hi) in [
(0.0, 0.5),
(0.5, 0.6),
(0.6, 0.7),
(0.7, 0.8),
(0.8, 0.9),
(0.9, 0.95),
(0.95, 1.001),
] {
let bucket: Vec<bool> = reliability
.iter()
.filter(|(s, _)| *s >= lo && *s < hi)
.map(|(_, c)| *c)
.collect();
if bucket.is_empty() {
continue;
}
let observed = bucket.iter().filter(|c| **c).count() as f64 / bucket.len() as f64;
let stated = reliability
.iter()
.filter(|(s, _)| *s >= lo && *s < hi)
.map(|(s, _)| *s as f64)
.sum::<f64>()
/ bucket.len() as f64;
println!(
" {:>5.0}–{:>3.0}% {:>9} {:>8.1}% {:>+7.1}",
lo * 100.0,
hi.min(1.0) * 100.0,
bucket.len(),
100.0 * observed,
100.0 * (observed - stated)
);
}
if args.iter().any(|a| a == "--full") {
// The confirmations go in as anchors, exactly as `recluster` sends
// them: they are what makes a group a named identity, and therefore
// what makes it a rival.
let mut confirmed = HashMap::new();
for p in &people {
for f in faces::for_person(conn, p.id, false).expect("faces") {
if f.confirmed {
confirmed.insert(f.id, p.id.0);
}
}
}
full_library(&embedding_of, &confirmed, &cal);
}
}
/// Where a set of confidences actually falls.
fn band(label: &str, v: &[f32]) {
if v.is_empty() {
return;
}
let mut s = v.to_vec();
s.sort_by(|a, b| a.total_cmp(b));
let pct = |q: f64| s[((s.len() - 1) as f64 * q) as usize];
let mean = s.iter().sum::<f32>() / s.len() as f32;
println!(
"{label} median {:>5.1}% mean {:>5.1}% p10 {:>5.1}% p90 {:>5.1}% under 50%: {:>5.1}%",
100.0 * pct(0.5),
100.0 * mean,
100.0 * pct(0.10),
100.0 * pct(0.90),
100.0 * s.iter().filter(|x| **x < 0.5).count() as f32 / s.len() as f32
);
}
/// The whole library through the real clusterer, for the numbers it would
/// actually write.
fn full_library(
embedding_of: &HashMap<faces::FaceId, Decoded>,
confirmed: &HashMap<faces::FaceId, u64>,
cal: &dr_face::Calibration,
) {
let mut candidates: Vec<dr_face::Candidate> = embedding_of
.iter()
.map(
|(id, (image, embedding, crop_px, quality))| dr_face::Candidate {
face: id.0,
image: *image,
embedding: embedding.clone(),
crop_px: *crop_px,
quality: *quality,
confirmed_person: confirmed.get(id).copied(),
},
)
.collect();
candidates.sort_by_key(|c| c.face);
println!("\nthe whole library, at the default merge probability:");
// The three phases, separately, because "a regroup takes n seconds" does
// not tell anyone which half to optimise — and the answer differs between
// a desktop and a tablet (docs/faces.md §9).
{
let dim = candidates.first().map(|c| c.embedding.len()).unwrap_or(0);
let flat: Vec<f32> = candidates
.iter()
.flat_map(|c| c.embedding.clone())
.collect();
let crop_px: Vec<f32> = candidates.iter().map(|c| c.crop_px).collect();
let images: Vec<u64> = candidates.iter().map(|c| c.image).collect();
let gallery: Vec<bool> = candidates.iter().map(|c| c.in_gallery()).collect();
let view = dr_face::neighbours::Faces {
embeddings: &flat,
dim,
crop_px: &crop_px,
images: &images,
gallery: &gallery,
};
let t = std::time::Instant::now();
let evidence = dr_face::neighbours::above_threshold(&view, cal, dr_face::RIVAL_FLOOR);
let scan = t.elapsed().as_secs_f64();
// `cluster` runs its own scan at the merge threshold, so the
// agglomeration is what is left after taking one scan off the total.
let t = std::time::Instant::now();
let clusters = dr_face::cluster(&candidates, cal, dr_face::DEFAULT_MERGE_PROBABILITY);
let agglomerate = t.elapsed().as_secs_f64() - scan;
let t = std::time::Instant::now();
let _ = dr_face::identity_shares(&gallery, &clusters, &evidence, dr_face::TOP_MATCHES);
println!(
" scan {scan:.2}s ({} evidence pairs) · agglomerate {agglomerate:.2}s · score {:.2}s",
evidence.len(),
t.elapsed().as_secs_f64()
);
}
let start = std::time::Instant::now();
let grouping = dr_face::cluster_scored(&candidates, cal, dr_face::DEFAULT_MERGE_PROBABILITY);
let real: Vec<_> = grouping
.clusters
.iter()
.filter(|c| c.members.len() >= 2)
.collect();
let grouped: usize = real.iter().map(|c| c.members.len()).sum();
println!(
" {} face(s) → {} group(s) of two or more, holding {grouped} faces ({:.0}%), in {:.1}s",
candidates.len(),
real.len(),
100.0 * grouped as f64 / candidates.len() as f64,
start.elapsed().as_secs_f64()
);
let named: Vec<_> = real.iter().filter(|c| c.person.is_some()).collect();
println!(
" {} of those group(s) carry a confirmation, holding {} faces",
named.len(),
named.iter().map(|c| c.members.len()).sum::<usize>()
);
let shown: Vec<f32> = real
.iter()
.flat_map(|c| c.members.iter().map(|&m| grouping.confidence[m]))
.collect();
band(" new, all groups ", &shown);
let onto_people: Vec<f32> = named
.iter()
.flat_map(|c| c.members.iter().map(|&m| grouping.confidence[m]))
.collect();
band(" new, onto a person", &onto_people);
// The number the old code would have written for the same grouping.
let means: Vec<f32> = real
.iter()
.flat_map(|c| {
c.members.iter().map(|&m| {
let me = &candidates[m];
let mut sum = 0.0;
let mut n = 0.0;
for &other in &c.members {
if other == m {
continue;
}
let them = &candidates[other];
let cos: f32 = me
.embedding
.iter()
.zip(&them.embedding)
.map(|(a, b)| a * b)
.sum();
sum += cal.probability(cos, me.crop_px.min(them.crop_px), 0.0);
n += 1.0;
}
if n == 0.0 {
1.0
} else {
sum / n
}
})
})
.collect();
band(" old, all groups ", &means);
}
File diff suppressed because it is too large Load Diff
+50
View File
@@ -222,6 +222,56 @@ impl Cache {
Ok(())
}
/// TRACES: FR-NC-6c | FR-NC-6a
/// Record an original this cache does **not** own the bytes of.
///
/// The virtual-filesystem case. On a library kept by a sync client the
/// original is materialised *in the library folder itself*, so copying it
/// under `originals/` would hold two copies of every pinned photograph —
/// and the copy would be the one the budget could evict while the real
/// disk cost stayed.
///
/// So the bytes are left where they are and only the bookkeeping is kept.
/// `path` is deliberately `NULL`, which is what makes this safe:
/// [`release`](Self::release) deletes the file a row names, and a row that
/// names none deletes nothing. **That matters more than it sounds.**
/// Deleting a materialised file inside a synced folder does not free a
/// cache — it deletes the photograph, and the client propagates that to
/// the server and to every other device. Handing the disk back is the
/// backend's job (`RemoteBackend::dematerialise`), not this one's.
///
/// `bytes` is what the original occupies where it lies, for the budget and
/// for reporting; pass 0 where it is not known.
pub fn record_in_place(
&self,
conn: &Connection,
image: ImageId,
bytes: u64,
pinned: bool,
now: i64,
) -> Result<(), CatalogError> {
conn.execute(
"INSERT INTO image_cache
(image_id, tier_actual, tier_desired, bytes, last_used, pinned, path)
VALUES (?1, ?2, ?2, ?3, ?4, ?5, NULL)
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 = NULL",
rusqlite::params![
image.0 as i64,
Tier::Original.stored(),
bytes as i64,
now,
i64::from(pinned),
],
)?;
Ok(())
}
/// Read a cached original back, if it is here.
///
/// Touches `last_used`, which is what makes the eviction order reflect
+393
View File
@@ -376,6 +376,64 @@ pub fn set_order(
touch(conn, id)
}
/// Whether this collection has a manual order worth reading.
///
/// The grid asks before choosing an `ORDER BY`, and the answer is narrower
/// than "is it manual". Two things have to be true.
///
/// It must be **manual**: a saved filter's contents are whatever its selector
/// matches now, in whatever order the query returns them, and there are no
/// member rows to carry a position.
///
/// And it must have **no children**. A parent's contents are the union of its
/// own members and its descendants' ([`descendants`]), and positions are only
/// ever assigned within one collection — so two children's positions are
/// unrelated integers, and interleaving them by value would order the grid by
/// a coincidence. Capture time is the honest answer for a set.
///
/// Kept here rather than assembled at the call site, so the rule has one name
/// and one test, and the grid does not have to know that "no children" is part
/// of it.
pub fn orders_manually(conn: &Connection, id: CollectionId) -> Result<bool, CatalogError> {
if kind_of(conn, id)? != CollectionKind::Manual {
return Ok(false);
}
let has_child: Option<i64> = conn
.query_row(
"SELECT 1 FROM collections
WHERE parent_id = ?1 AND deleted = 0 LIMIT 1",
[id.0 as i64],
|r| r.get(0),
)
.optional()?;
Ok(has_child.is_none())
}
/// Every image in a manual collection, in manual order.
///
/// The whole membership, not the filtered view the grid happens to be showing.
/// [`set_order`] renumbers exactly the images it is handed, so a caller that
/// reordered a filtered list would renumber those and leave every hidden image
/// on a stale position — two images sharing a position, and a grid that
/// rearranges itself when the filter comes off.
///
/// `image_id` breaks ties. Positions are dense in practice, but a merge can
/// deliver two devices' rows at the same position, and an order that is not
/// total is one the grid draws differently each time it reads it.
pub fn members_in_order(conn: &Connection, id: CollectionId) -> Result<Vec<ImageId>, CatalogError> {
let mut stmt = conn.prepare(
"SELECT image_id FROM collection_members
WHERE collection_id = ?1
ORDER BY position, image_id",
)?;
let rows = stmt
.query_map([id.0 as i64], |r| Ok(ImageId(r.get::<_, i64>(0)? as u64)))?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
}
/// Save a smart collection's selector.
///
/// Refuses a selector that references this collection, directly or through
@@ -477,6 +535,160 @@ pub fn collections_for_image(
Ok(rows)
}
/// One collection a set of images is filed in, and how much of that set is in
/// it.
///
/// `holding` is what makes a removal honest: with forty photographs selected
/// and three of them in "Iceland", the row has to say "3 of 40" or the user
/// reads it as "this selection is in Iceland" and takes all forty out of a
/// collection thirty-seven of them were never in.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Membership {
pub id: CollectionId,
pub name: String,
/// How many of the images asked about are members. Never zero — a
/// collection holding none of them is not returned at all.
pub holding: usize,
}
/// Every collection the given images are filed in, with how many of them each
/// holds.
///
/// The read behind "which collections is this selection in, and take it out of
/// one" — the counterpart to [`collections_for_image`], which answers the same
/// question for a single photograph and does not need the counts.
///
/// Smart collections never appear: they have no `collection_members` rows, so
/// there is nothing to remove and offering it would be a button that does
/// nothing. Sorted by name, matching the sidebar.
///
/// # Why this is chunked
///
/// The image list is a *selection*, which a select-all makes as large as the
/// library. SQLite caps the number of bound parameters in one statement, so a
/// single `IN (...)` over every selected id fails outright on exactly the
/// gesture most likely to produce it. The counts are summed across chunks
/// rather than re-queried, so the result is the same as the unchunked query
/// would have given.
pub fn membership_of(
conn: &Connection,
images: &[ImageId],
) -> Result<Vec<Membership>, CatalogError> {
if images.is_empty() {
return Ok(Vec::new());
}
// Well under SQLite's default parameter cap, and large enough that an
// ordinary selection is one round trip.
const CHUNK: usize = 400;
let mut totals: std::collections::HashMap<CollectionId, (String, usize)> =
std::collections::HashMap::new();
for chunk in images.chunks(CHUNK) {
let placeholders = std::iter::repeat_n("?", chunk.len())
.collect::<Vec<_>>()
.join(",");
// The placeholder list is built from the id *count*, never from user
// text — the same construction `deep_count` uses.
let sql = format!(
"SELECT c.id, c.name, count(*)
FROM collection_members m
JOIN collections c ON c.id = m.collection_id
WHERE m.image_id IN ({placeholders}) AND c.deleted = 0
GROUP BY c.id, c.name"
);
let params: Vec<rusqlite::types::Value> = chunk
.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((
CollectionId(r.get::<_, i64>(0)? as u64),
r.get::<_, String>(1)?,
r.get::<_, i64>(2)? as usize,
))
})?;
for row in rows {
let (id, name, n) = row?;
let entry = totals.entry(id).or_insert((name, 0));
entry.1 += n;
}
}
let mut out: Vec<Membership> = totals
.into_iter()
.map(|(id, (name, holding))| Membership { id, name, holding })
.collect();
// By name, then by id, so two collections sharing a name have a stable
// order rather than the hash map's.
out.sort_by(|a, b| {
a.name
.to_lowercase()
.cmp(&b.name.to_lowercase())
.then(a.id.0.cmp(&b.id.0))
});
Ok(out)
}
/// What kind of collection `id` is, or `None` if there is no such collection.
///
/// Cheaper than reading the whole [`Collection`] where the caller only needs to
/// know whether member rows exist — the grid asks this to decide whether manual
/// position is a thing it can order by, and a smart collection has no
/// `collection_members` rows to carry one.
pub fn kind(conn: &Connection, id: CollectionId) -> Result<Option<CollectionKind>, CatalogError> {
let found = conn
.query_row(
"SELECT kind FROM collections WHERE id = ?1 AND deleted = 0",
[id.0 as i64],
|r| r.get::<_, i64>(0),
)
.optional()?;
Ok(found.map(CollectionKind::from_i64))
}
/// TRACES: FR-UI-8
/// The device-independent name of a collection, from its local id.
///
/// The pair to [`id_for_uuid`], and the reason both exist: `collections.id` is
/// an autoincrement local to one catalog, so anything that travels between
/// devices — a place, a merge — has to say which collection it means in the
/// only vocabulary they share.
///
/// `None` for a collection that is not there, or has been tombstoned. A caller
/// writing down a scope treats that as "the whole library", which is the
/// harmless direction: the alternative is recording a name nothing can resolve.
pub fn uuid_of(conn: &Connection, id: CollectionId) -> Result<Option<String>, CatalogError> {
Ok(conn
.query_row(
"SELECT uuid FROM collections WHERE id = ?1 AND deleted = 0",
[id.0 as i64],
|r| r.get::<_, String>(0),
)
.optional()?)
}
/// TRACES: FR-UI-8
/// The local id of a collection, from the name every device knows it by.
///
/// `None` where this device has never heard of it, or has deleted it — a place
/// recorded on the tablet inside a collection this machine has not yet merged.
/// The caller falls back to the whole library rather than to an empty grid.
pub fn id_for_uuid(conn: &Connection, uuid: &str) -> Result<Option<CollectionId>, CatalogError> {
Ok(conn
.query_row(
"SELECT id FROM collections WHERE uuid = ?1 AND deleted = 0",
[uuid],
|r| r.get::<_, i64>(0),
)
.optional()?
.map(|id| CollectionId(id as u64)))
}
/// A collection and everything beneath it, including itself.
///
/// Used for cycle checks and for scoping the grid to a parent: selecting a
@@ -772,6 +984,106 @@ mod tests {
ImageId(i)
}
#[test]
fn a_manual_leaf_orders_manually() {
let cat = seeded();
let c = cat.connection();
let id = create(c, "Trip", None, CollectionKind::Manual).unwrap();
assert!(orders_manually(c, id).unwrap());
}
#[test]
fn a_saved_filter_has_no_manual_order() {
let cat = seeded();
let c = cat.connection();
let id = create(c, "Picks", None, CollectionKind::Smart).unwrap();
assert!(
!orders_manually(c, id).unwrap(),
"a smart collection has no member rows to carry a position"
);
}
#[test]
fn a_parent_has_no_manual_order_of_its_own() {
let cat = seeded();
let c = cat.connection();
let parent = create(c, "2026", None, CollectionKind::Manual).unwrap();
let child = create(c, "Iceland", Some(parent), CollectionKind::Manual).unwrap();
assert!(
!orders_manually(c, parent).unwrap(),
"a set shows its descendants' images, whose positions are unrelated"
);
assert!(
orders_manually(c, child).unwrap(),
"the child is still a leaf and still orders manually"
);
}
#[test]
fn deleting_the_last_child_gives_the_parent_its_order_back() {
let cat = seeded();
let c = cat.connection();
let parent = create(c, "2026", None, CollectionKind::Manual).unwrap();
let child = create(c, "Iceland", Some(parent), CollectionKind::Manual).unwrap();
assert!(!orders_manually(c, parent).unwrap());
delete(c, child).unwrap();
assert!(
orders_manually(c, parent).unwrap(),
"a tombstoned child must not keep counting as a child"
);
}
#[test]
fn members_come_back_in_the_order_they_were_put_in() {
let cat = seeded();
let c = cat.connection();
let id = create(c, "Trip", None, CollectionKind::Manual).unwrap();
add_images(c, id, &[img(3), img(1), img(2)]).unwrap();
assert_eq!(
members_in_order(c, id).unwrap(),
vec![img(3), img(1), img(2)],
"adding assigns position in the order given, and reading returns it"
);
}
#[test]
fn members_come_back_in_the_order_set_order_wrote() {
let cat = seeded();
let c = cat.connection();
let id = create(c, "Trip", None, CollectionKind::Manual).unwrap();
add_images(c, id, &[img(1), img(2), img(3)]).unwrap();
set_order(c, id, &[img(3), img(2), img(1)]).unwrap();
assert_eq!(
members_in_order(c, id).unwrap(),
vec![img(3), img(2), img(1)]
);
}
#[test]
fn members_in_order_is_total_even_where_positions_collide() {
let cat = seeded();
let c = cat.connection();
let id = create(c, "Trip", None, CollectionKind::Manual).unwrap();
add_images(c, id, &[img(1), img(2), img(3)]).unwrap();
// What a merge can deliver: two devices' rows landing on one position.
c.execute(
"UPDATE collection_members SET position = 0 WHERE collection_id = ?1",
[id.0 as i64],
)
.unwrap();
assert_eq!(
members_in_order(c, id).unwrap(),
vec![img(1), img(2), img(3)],
"image_id breaks the tie, so two reads cannot disagree"
);
}
#[test]
fn a_created_collection_appears_in_the_tree() {
let cat = seeded();
@@ -1225,4 +1537,85 @@ mod tests {
let d = descendants(c, a).unwrap();
assert!(d.len() <= 2);
}
#[test]
fn membership_says_how_many_of_the_selection_each_collection_holds() {
// The count is the whole point: "3 of 40" is what stops a user taking
// forty photographs out of a collection thirty-seven were never in.
let cat = seeded();
let c = cat.connection();
let iceland = create(c, "Iceland", None, CollectionKind::Manual).unwrap();
let best = create(c, "Best", None, CollectionKind::Manual).unwrap();
add_images(c, iceland, &[img(1), img(2), img(3)]).unwrap();
add_images(c, best, &[img(1)]).unwrap();
let m = membership_of(c, &[img(1), img(2), img(3), img(4)]).unwrap();
assert_eq!(m.len(), 2);
// Sorted by name, so "Best" comes before "Iceland".
assert_eq!(m[0].id, best);
assert_eq!(m[0].holding, 1);
assert_eq!(m[1].id, iceland);
assert_eq!(m[1].holding, 3);
}
#[test]
fn membership_omits_collections_holding_none_of_them() {
// A row offering to remove images that are not there would be a button
// that does nothing, which is worse than an absent one.
let cat = seeded();
let c = cat.connection();
let a = create(c, "A", None, CollectionKind::Manual).unwrap();
create(c, "Empty", None, CollectionKind::Manual).unwrap();
add_images(c, a, &[img(1)]).unwrap();
let m = membership_of(c, &[img(1)]).unwrap();
assert_eq!(m.len(), 1);
assert_eq!(m[0].id, a);
}
#[test]
fn membership_of_nothing_is_nothing() {
let cat = seeded();
assert!(membership_of(cat.connection(), &[]).unwrap().is_empty());
}
#[test]
fn membership_skips_a_deleted_collection() {
// The tombstone survives the delete, and its member rows are dropped —
// but a merge can leave rows behind, and the sheet must not offer a
// collection the sidebar does not draw.
let cat = seeded();
let c = cat.connection();
let a = create(c, "A", None, CollectionKind::Manual).unwrap();
add_images(c, a, &[img(1)]).unwrap();
delete(c, a).unwrap();
assert!(membership_of(c, &[img(1)]).unwrap().is_empty());
}
#[test]
fn membership_sums_across_chunks() {
// The chunking exists for a select-all, which is exactly the gesture
// that would otherwise exceed SQLite's parameter cap. A count that was
// per-chunk rather than summed would under-report on the one selection
// large enough to need it.
let cat = seeded();
let c = cat.connection();
let a = create(c, "A", None, CollectionKind::Manual).unwrap();
// Past the fixture's six, so the member rows have images to point at.
for i in 7..=900i64 {
c.execute(
"INSERT INTO images(id, root_id, source_ref, added_at)
VALUES (?1, 1, ?2, 0)",
rusqlite::params![i, format!("img{i}.CR3")],
)
.unwrap();
}
let ids: Vec<ImageId> = (1..=900).map(img).collect();
add_images(c, a, &ids).unwrap();
let m = membership_of(c, &ids).unwrap();
assert_eq!(m.len(), 1);
assert_eq!(m[0].holding, 900, "counted across every chunk");
}
}
+60 -2
View File
@@ -1,14 +1,37 @@
//! TRACES: NFR-ARCH-4 | NFR-R5
//! TRACES: NFR-ARCH-4 | NFR-R5 | NFR-R6
//! 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.
//!
//! # Why `From<rusqlite::Error>` is written by hand
//!
//! One class of SQLite failure is not about the statement that hit it: when
//! the file itself is damaged, *every* query fails, and which one the user
//! happened to trigger first says nothing. Before this, corruption reached the
//! interface as whatever `Sqlite(...)` the first failing query produced —
//! "database disk image is malformed" attached to a thumbnail refresh — and
//! there was nowhere to hang a recovery offer.
//!
//! So the conversion classifies rather than wraps: `SQLITE_CORRUPT` and
//! `SQLITE_NOTADB` become [`CatalogError::Corrupt`] wherever they arise, which
//! means a background job that trips over the damage reports the same thing
//! the startup check does (see [`crate::recovery`]).
/// Something went wrong talking to the catalog.
#[derive(Debug, thiserror::Error)]
pub enum CatalogError {
#[error("sqlite: {0}")]
Sqlite(#[from] rusqlite::Error),
Sqlite(#[source] rusqlite::Error),
/// The catalog file is damaged.
///
/// Its own variant because it is the one error with a *user-facing
/// remedy*: restore the NFR-R2 backup, or discard the index and rebuild it
/// from sources plus sidecars (NFR-R6, invariant §5.2.4). Every other
/// variant here is either a caller's mistake or a fact about one row.
#[error("the catalog file is damaged: {detail}")]
Corrupt { detail: String },
/// The catalog was written by a newer build.
///
@@ -74,3 +97,38 @@ pub enum CatalogError {
#[error("io: {0}")]
Io(String),
}
impl From<rusqlite::Error> for CatalogError {
fn from(e: rusqlite::Error) -> Self {
if is_corruption(&e) {
// `to_string` rather than keeping the error: the detail is going
// into a dialog and into a log line, and the recovery path has no
// use for the rusqlite type once it knows the file is damaged.
CatalogError::Corrupt {
detail: e.to_string(),
}
} else {
CatalogError::Sqlite(e)
}
}
}
/// Whether a SQLite failure means the *file* is damaged rather than the
/// statement wrong.
///
/// `SQLITE_NOTADB` is included because it is what a truncated or overwritten
/// catalog produces — SQLite cannot read the header, so it declines to call it
/// a database at all. To a user those are the same accident, and the same two
/// offers answer both.
///
/// Deliberately *not* included: `SQLITE_CANTOPEN` (a missing file, which
/// `Connection::open` fixes by creating one), `SQLITE_BUSY`, and
/// `SQLITE_IOERR` — a failing disk or a dropped network mount is a different
/// problem, and telling the user to rebuild their index would be a wrong
/// answer delivered confidently.
fn is_corruption(e: &rusqlite::Error) -> bool {
matches!(
e.sqlite_error_code(),
Some(rusqlite::ErrorCode::DatabaseCorrupt) | Some(rusqlite::ErrorCode::NotADatabase)
)
}
+89 -17
View File
@@ -76,6 +76,9 @@ pub struct SharedFace {
pub confidence: f32,
pub embedding: Vec<u8>,
pub crop_px: f32,
/// See `faces::DetectedFace::quality`. `None` from a shard written before
/// the number was kept.
pub quality: Option<f32>,
/// The face cut out and encoded, or empty where none was kept.
///
/// Travels with the face rather than in the catalog snapshot, which is the
@@ -224,8 +227,8 @@ impl FaceShardStore {
tx.execute(
"INSERT INTO faces
(file_id, model_id, x, y, w, h, landmarks, confidence,
embedding, crop_px, crop)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
embedding, crop_px, crop, quality)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
rusqlite::params![
f.file_id as i64,
f.model_id,
@@ -238,6 +241,7 @@ impl FaceShardStore {
f.embedding,
f.crop_px as f64,
(!f.crop.is_empty()).then_some(f.crop.as_slice()),
f.quality.map(f64::from),
],
)?;
}
@@ -442,9 +446,10 @@ impl FaceShardStore {
}
let mut fq = src.prepare(&format!(
"SELECT f.file_id, f.model_id, f.x, f.y, f.w, f.h, f.landmarks,
f.confidence, f.embedding, f.crop_px, {}
f.confidence, f.embedding, f.crop_px, {}, {}
FROM faces f WHERE f.file_id = ?1 AND f.model_id = ?2",
crop_column(&src)
column_or_null(&src, "crop"),
column_or_null(&src, "quality"),
))?;
let faces: Vec<SharedFace> = fq
.query_map(rusqlite::params![file_id, &model_id], read_shared_face)?
@@ -484,7 +489,8 @@ impl FaceShardStore {
let Some(edge) = edge else { return Ok(None) };
let mut q = conn.prepare(
"SELECT file_id, model_id, x, y, w, h, landmarks, confidence, embedding, crop_px, crop
"SELECT file_id, model_id, x, y, w, h, landmarks, confidence, embedding, crop_px,
crop, quality
FROM faces WHERE file_id = ?1 AND model_id = ?2",
)?;
let faces: Vec<SharedFace> = q
@@ -541,6 +547,7 @@ fn upgrade_shard(conn: &Connection) -> Result<(), CatalogError> {
for (table, column, decl) in [
("faces", "crop", "BLOB"),
("indexed", "indexed_at", "INTEGER"),
("faces", "quality", "REAL"),
] {
if !has_column(conn, table, column)? {
conn.execute_batch(&format!("ALTER TABLE {table} ADD COLUMN {column} {decl}"))?;
@@ -555,16 +562,19 @@ fn has_column(conn: &Connection, table: &str, column: &str) -> Result<bool, Cata
Ok(stmt.exists(rusqlite::params![table, column])?)
}
/// `f.crop`, or a `NULL` standing in for it.
/// `f.<column>`, or a `NULL` standing in for it.
///
/// A shard downloaded from a peer is opened **read-only** and cannot be
/// upgraded, so one written before crops existed has to be read as it is rather
/// than repaired. Selecting a literal keeps the column count the same, which is
/// what lets [`read_shared_face`] stay a single function.
fn crop_column(conn: &Connection) -> &'static str {
match has_column(conn, "faces", "crop") {
Ok(true) => "f.crop",
_ => "NULL",
/// upgraded, so one written before a column existed has to be read as it is
/// rather than repaired. Selecting a literal keeps the column count the same,
/// which is what lets [`read_shared_face`] stay a single function.
///
/// `column` is one of this module's own names, never anything read from
/// outside, which is what makes formatting it into SQL acceptable.
fn column_or_null(conn: &Connection, column: &'static str) -> String {
match has_column(conn, "faces", column) {
Ok(true) => format!("f.{column}"),
_ => "NULL".to_string(),
}
}
@@ -637,7 +647,8 @@ pub fn export_to_shards_reporting(
continue;
}
let mut fq = conn.prepare(
"SELECT x, y, w, h, landmarks, detector_confidence, embedding, crop_px, crop
"SELECT x, y, w, h, landmarks, detector_confidence, embedding, crop_px, crop,
quality
FROM faces WHERE image_id = ?1 AND model_id = ?2",
)?;
let faces: Vec<SharedFace> = fq
@@ -654,6 +665,7 @@ pub fn export_to_shards_reporting(
embedding: r.get(6)?,
crop_px: r.get::<_, f64>(7)? as f32,
crop: r.get::<_, Option<Vec<u8>>>(8)?.unwrap_or_default(),
quality: r.get::<_, Option<f64>>(9)?.map(|q| q as f32),
})
})?
.collect::<Result<_, _>>()?;
@@ -710,6 +722,14 @@ pub fn import_from_shards(
let Some((faces, edge)) = store.get_image(file_id as u64, model_id)? else {
continue;
};
// A peer that embedded before the quality was kept has done work this
// device cannot finish: the number exists only at embedding time, and
// adopting the faces would write the run marker that keeps them from
// ever being measured (schema V14). Left for this device's own pass —
// or for the peer's, whose re-export replaces these.
if faces.iter().any(|f| f.quality.is_none()) {
continue;
}
let local: Vec<crate::faces::DetectedFace> = faces
.into_iter()
.map(|f| crate::faces::DetectedFace {
@@ -721,6 +741,7 @@ pub fn import_from_shards(
confidence: f.confidence,
embedding: f.embedding,
crop_px: f.crop_px,
quality: f.quality,
model_id: f.model_id,
// A peer that indexed before crops existed sends none, and the
// reader falls back to the proxy exactly as it does for a face
@@ -766,6 +787,7 @@ fn read_shared_face(r: &rusqlite::Row<'_>) -> rusqlite::Result<SharedFace> {
embedding: r.get(8)?,
crop_px: r.get::<_, f64>(9)? as f32,
crop: r.get::<_, Option<Vec<u8>>>(10)?.unwrap_or_default(),
quality: r.get::<_, Option<f64>>(11)?.map(|q| q as f32),
})
}
@@ -849,7 +871,11 @@ CREATE TABLE IF NOT EXISTS faces (
crop_px REAL NOT NULL,
-- The face, cut out. NULL where the face was found before crops were kept,
-- or adopted from a peer that did not have one.
crop BLOB
crop BLOB,
-- Length of the raw embedding (`faces::DetectedFace::quality`). NULL from
-- a build that did not keep it, and a face the receiving device will not
-- adopt -- see `import_from_shards`.
quality REAL
);
CREATE INDEX IF NOT EXISTS faces_file ON faces(file_id, model_id);
@@ -908,6 +934,7 @@ mod tests {
confidence: 0.87,
embedding: vec![seed; 1024],
crop_px: 180.0,
quality: Some(17.5),
crop: vec![seed; 64],
}
}
@@ -925,6 +952,7 @@ mod tests {
assert_eq!(edge, 1024);
assert_eq!(faces[0].embedding.len(), 1024);
assert!((faces[0].crop_px - 180.0).abs() < 1e-3);
assert_eq!(faces[0].quality, Some(17.5));
}
/// The case the run marker exists for, carried across the wire: an image
@@ -1115,6 +1143,7 @@ mod catalog_round_trip {
confidence: 0.9,
embedding: vec![seed; 1024],
crop_px: 180.0,
quality: Some(20.0),
model_id: "w600k_mbf".into(),
crop: vec![seed; 64],
}
@@ -1162,9 +1191,47 @@ mod catalog_round_trip {
let got = faces::for_image(&b, dr_types::ImageId(90)).unwrap();
assert_eq!(got.len(), 1);
assert!((got[0].crop_px - 180.0).abs() < 1e-3);
assert_eq!(got[0].quality, Some(20.0));
assert!((got[0].landmarks[2].0 - 0.15).abs() < 1e-5);
let emb = faces::embeddings(&b, "w600k_mbf").unwrap();
assert!(emb.iter().any(|(_, _, blob, _)| blob[0] == 1));
assert!(emb.iter().any(|e| e.embedding[0] == 1));
}
/// A face a peer embedded without measuring it is work this device
/// cannot finish, and adopting it would write the marker that stops it
/// ever being measured. The image stays outstanding instead.
#[test]
fn a_peers_unmeasured_faces_are_left_for_this_device_to_index() {
let b = device(&[(90, 5001), (91, 5002)]);
let mut store = FaceShardStore::open(&tempdir("unmeasured")).unwrap();
let shared = |file_id: u64, quality: Option<f32>| SharedFace {
file_id,
model_id: "w600k_mbf".into(),
x: 0.1,
y: 0.2,
w: 0.15,
h: 0.2,
landmarks: vec![1; 40],
confidence: 0.87,
embedding: vec![1; 1024],
crop_px: 180.0,
quality,
crop: Vec::new(),
};
store
.put_image(5001, "w600k_mbf", 2560, &[shared(5001, None)])
.unwrap();
store
.put_image(5002, "w600k_mbf", 2560, &[shared(5002, Some(19.0))])
.unwrap();
assert_eq!(import_from_shards(&b, &store, "w600k_mbf").unwrap(), 1);
let cov = faces::coverage(&b, "w600k_mbf").unwrap();
assert_eq!(cov.indexed, 1);
assert_eq!(cov.outstanding(), 1, "the unmeasured image was adopted");
assert!(faces::for_image(&b, dr_types::ImageId(90))
.unwrap()
.is_empty());
}
#[test]
@@ -1187,7 +1254,7 @@ mod catalog_round_trip {
"a peer's copy replaced work this device had already done"
);
let emb = faces::embeddings(&b, "w600k_mbf").unwrap();
assert_eq!(emb[0].2[0], 9, "B's own embedding was overwritten");
assert_eq!(emb[0].embedding[0], 9, "B's own embedding was overwritten");
}
/// A device holding a subset of the library takes only its own part.
@@ -1281,6 +1348,7 @@ mod catalog_round_trip {
confidence: 0.87,
embedding: vec![seed; 1024],
crop_px: 180.0,
quality: None,
crop: vec![seed; 64],
}
}
@@ -1430,6 +1498,10 @@ mod catalog_round_trip {
let (faces, _) = store.get_image(77, "w600k_mbf").unwrap().unwrap();
assert_eq!(faces.len(), 1);
assert!(faces[0].crop.is_empty(), "a crop was invented from nowhere");
assert_eq!(
faces[0].quality, None,
"a quality was invented from nowhere"
);
let _ = std::fs::remove_dir_all(&dir);
}
+298 -22
View File
@@ -61,10 +61,22 @@ pub struct DetectedFace {
/// Five `(x, y)` pairs, normalised the same way.
pub landmarks: [(f32, f32); 5],
pub confidence: f32,
/// 512 × f16, L2-normalised — `dr_face::Embedding::to_f16_bytes`.
/// 512 × f16, the raw model output — `dr_face::Embedded::to_f16_bytes`.
///
/// Raw rather than unit length, so the length ([`Self::quality`]) is in
/// the blob and not only beside it. Readers re-normalise on load.
pub embedding: Vec<u8>,
/// Source pixels across the aligned crop (docs/faces.md §7).
pub crop_px: f32,
/// Length of the raw embedding before normalisation — the model's own
/// reading of how recognisable the crop was, and the gate on whether
/// this face may be compared *against* (`dr_face::MIN_GALLERY_QUALITY`).
///
/// `None` where it was never measured: a face indexed, here or by a peer,
/// before raw vectors were stored. The unit vector those builds kept has
/// no length left to read, so the only way to measure one is to embed it
/// again (schema V14).
pub quality: Option<f32>,
/// Which model produced the embedding. Comparing across models is the one
/// mistake that yields plausible garbage rather than an error.
pub model_id: String,
@@ -94,6 +106,9 @@ pub struct Face {
pub landmarks: [(f32, f32); 5],
pub confidence: f32,
pub crop_px: f32,
/// See [`DetectedFace::quality`]. `None` for a face indexed before it was
/// recorded.
pub quality: Option<f32>,
pub model_id: String,
/// `None` when the face belongs to no one yet.
pub person: Option<PersonId>,
@@ -143,6 +158,16 @@ pub use dr_face::Calibration;
/// part of the frame: a re-index with a better model must not discard the
/// user's labelling (FR-CULL-10). Matching is by box overlap, since the face is
/// in the same place even when the box moves a little.
///
/// **Every model's faces are replaced, and every other model's marker goes
/// with them.** An image holds the faces of whichever pipeline looked at it
/// last, never a mixture — two detectors drawing boxes over the same face is
/// not two opinions but a duplicate. So the replacement is unconditional on
/// `model_id`, and the run markers of the pipelines whose faces were just
/// removed are dropped too: a marker that says "done" over an image with
/// none of that model's faces is exactly the state that made the V12 repair
/// necessary, and a user who switches their detector back would otherwise
/// find those photographs permanently empty.
pub fn record_detections(
conn: &Connection,
image_id: ImageId,
@@ -177,6 +202,10 @@ pub fn record_detections(
}
tx.execute("DELETE FROM faces WHERE image_id = ?1", [image_id.0 as i64])?;
tx.execute(
"DELETE FROM face_index WHERE image_id = ?1 AND model_id != ?2",
rusqlite::params![image_id.0 as i64, model_id],
)?;
let now = now_secs();
let mut ids = Vec::with_capacity(faces.len());
@@ -184,8 +213,8 @@ pub fn record_detections(
tx.execute(
"INSERT INTO faces
(image_id, x, y, w, h, landmarks, detector_confidence,
embedding, crop_px, model_id, detected_at, crop)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
embedding, crop_px, model_id, detected_at, crop, quality)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
rusqlite::params![
image_id.0 as i64,
f.x as f64,
@@ -202,6 +231,7 @@ pub fn record_detections(
// the database rather than two the readers each have to know
// about.
(!f.crop.is_empty()).then_some(f.crop.as_slice()),
f.quality.map(f64::from),
],
)?;
let id = FaceId(tx.last_insert_rowid() as u64);
@@ -251,10 +281,113 @@ pub fn record_detections(
Ok(ids)
}
/// A face embedded again from its stored landmarks: the new vector and its
/// length. What the measuring pass hands back per face.
#[derive(Debug, Clone, PartialEq)]
pub struct Measurement {
pub face: FaceId,
/// 512 × f16, raw — `dr_face::Embedded::to_f16_bytes`.
pub embedding: Vec<u8>,
pub quality: f32,
}
/// Write fresh embeddings over faces that were found before their quality was
/// kept, and re-mark the image as indexed.
///
/// The cheaper half of what `record_detections` does, for the case schema V14
/// created: the boxes and landmarks are right, the identities are the user's
/// work, and only the vector needs doing again. Updating in place is what
/// keeps `face_person` and the face ids exactly as they were -- a
/// re-detection would carry confirmations across by box overlap and lose
/// every suggestion, for no gain.
///
/// `dropped` are faces whose landmarks turned out to be degenerate -- the warp
/// could not be built from them. Deleted here, as detection would have refused
/// to store them (`dr_ui::faces::index_proxy`), and because a face left with
/// no reading would put its image back on the measuring pass's list on every
/// sweep, at the cost of an original each time.
///
/// The run marker is re-written with a fresh time, and that is not
/// bookkeeping: `face_shard::export_to_shards` re-exports an image whose
/// marker is newer than the store's copy, which is how the measured vectors
/// reach the other devices.
pub fn record_measurements(
conn: &Connection,
image_id: ImageId,
model_id: &str,
source_edge: u32,
measured: &[Measurement],
dropped: &[FaceId],
) -> Result<(), CatalogError> {
let tx = conn.unchecked_transaction()?;
for m in measured {
tx.execute(
"UPDATE faces SET embedding = ?2, quality = ?3 WHERE id = ?1",
rusqlite::params![m.face.0 as i64, m.embedding, f64::from(m.quality)],
)?;
}
for f in dropped {
tx.execute("DELETE FROM faces WHERE id = ?1", [f.0 as i64])?;
}
let remaining: i64 = tx.query_row(
"SELECT COUNT(*) FROM faces WHERE image_id = ?1 AND model_id = ?2",
rusqlite::params![image_id.0 as i64, model_id],
|r| r.get(0),
)?;
tx.execute(
"INSERT INTO face_index (image_id, model_id, indexed_at, faces_found, source_edge)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(image_id, model_id) DO UPDATE SET
indexed_at = excluded.indexed_at,
faces_found = excluded.faces_found,
source_edge = excluded.source_edge",
rusqlite::params![
image_id.0 as i64,
model_id,
now_secs(),
remaining,
source_edge as i64,
],
)?;
tx.commit()?;
Ok(())
}
/// The faces on one image that have no quality reading yet.
///
/// The measuring pass's per-image work: every face this model found whose
/// vector was stored as a unit one (schema V14), with the landmarks the
/// warp is rebuilt from.
pub fn unmeasured_on_image(
conn: &Connection,
image_id: ImageId,
model_id: &str,
) -> Result<Vec<Face>, CatalogError> {
Ok(for_image(conn, image_id)?
.into_iter()
.filter(|f| f.model_id == model_id && f.quality.is_none())
.collect())
}
/// How many of a model's faces have no quality reading.
///
/// What the measuring pass has left to do, for a screen that wants to say so.
pub fn faces_unmeasured(conn: &Connection, model_id: &str) -> Result<u64, CatalogError> {
conn.query_row(
"SELECT COUNT(*) FROM faces WHERE model_id = ?1 AND quality IS NULL",
[model_id],
|r| r.get::<_, i64>(0),
)
.map(|n| n as u64)
.map_err(Into::into)
}
/// How much of the library has been through face detection.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Coverage {
/// Images that are candidates at all — present, not trashed.
/// Images that are candidates at all — present, not trashed, and not the
/// shadowed half of a RAW+JPEG pair. The same population every sweep's
/// work list is drawn from; see [`coverage`] for why that matters.
pub images: u64,
/// Images this model has actually looked at.
pub indexed: u64,
@@ -292,9 +425,21 @@ impl Coverage {
/// progress figure. Answerable only because [`record_detections`] writes a run
/// marker: counting `faces` rows would report how many faces exist, which is a
/// different number and never reaches the image count.
///
/// # Shadowed images are not candidates, and the denominator has to agree
///
/// A shadowed image is the JPEG half of a RAW+JPEG pair. It is not a separate
/// photograph — the grid does not show it, and every sweep that builds a work
/// list excludes it. Counting it here anyway is not a rounding error: on the
/// reference library it put 4,424 images into the denominator that no pass is
/// permitted to touch, so "4,593 outstanding" had a floor of 4,424 that no
/// amount of indexing could ever bring down, and [`Coverage::is_complete`]
/// could never once return true. A progress figure that cannot reach its own
/// target reads exactly like a stuck job, which is what it was taken for.
pub fn coverage(conn: &Connection, model_id: &str) -> Result<Coverage, CatalogError> {
let images: i64 = conn.query_row(
"SELECT COUNT(*) FROM images WHERE trashed_at IS NULL",
"SELECT COUNT(*) FROM images
WHERE trashed_at IS NULL AND shadowed_by IS NULL",
[],
|r| r.get(0),
)?;
@@ -302,7 +447,8 @@ pub fn coverage(conn: &Connection, model_id: &str) -> Result<Coverage, CatalogEr
"SELECT COUNT(*), COALESCE(SUM(faces_found = 0), 0), COALESCE(SUM(faces_found), 0)
FROM face_index fi
JOIN images i ON i.id = fi.image_id
WHERE fi.model_id = ?1 AND i.trashed_at IS NULL",
WHERE fi.model_id = ?1
AND i.trashed_at IS NULL AND i.shadowed_by IS NULL",
[model_id],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)?;
@@ -351,7 +497,7 @@ pub fn for_image(conn: &Connection, image_id: ImageId) -> Result<Vec<Face>, Cata
let mut q = conn.prepare(
"SELECT f.id, f.image_id, f.x, f.y, f.w, f.h, f.landmarks,
f.detector_confidence, f.crop_px, f.model_id,
fp.person_id, fp.probability, fp.confirmed
fp.person_id, fp.probability, fp.confirmed, f.quality
FROM faces f
LEFT JOIN face_person fp ON fp.face_id = f.id
WHERE f.image_id = ?1
@@ -378,9 +524,18 @@ pub fn unassigned(conn: &Connection, model_id: &str) -> Result<Vec<FaceId>, Cata
/// One face's stored embedding, as the clustering pass consumes it.
///
/// A named type rather than a tuple because it crosses a crate boundary and
/// "the third element" is not a thing anyone should have to remember.
pub type StoredEmbedding = (FaceId, ImageId, Vec<u8>, f32);
/// A struct rather than a tuple because it crosses a crate boundary and "the
/// fourth element" is not a thing anyone should have to remember.
#[derive(Debug, Clone, PartialEq)]
pub struct StoredEmbedding {
pub face: FaceId,
pub image: ImageId,
/// 512 × f16 — `dr_face::Embedding::from_f16_bytes` reads it.
pub embedding: Vec<u8>,
pub crop_px: f32,
/// See [`DetectedFace::quality`].
pub quality: Option<f32>,
}
/// Embeddings for clustering, oldest first so the pass is deterministic.
///
@@ -389,16 +544,17 @@ pub type StoredEmbedding = (FaceId, ImageId, Vec<u8>, f32);
/// would double the memory of the one operation that holds them all at once.
pub fn embeddings(conn: &Connection, model_id: &str) -> Result<Vec<StoredEmbedding>, CatalogError> {
let mut q = conn.prepare(
"SELECT id, image_id, embedding, crop_px FROM faces
"SELECT id, image_id, embedding, crop_px, quality FROM faces
WHERE model_id = ?1 ORDER BY id",
)?;
let rows = q.query_map([model_id], |r| {
Ok((
FaceId(r.get::<_, i64>(0)? as u64),
ImageId(r.get::<_, i64>(1)? as u64),
r.get::<_, Vec<u8>>(2)?,
r.get::<_, f64>(3)? as f32,
))
Ok(StoredEmbedding {
face: FaceId(r.get::<_, i64>(0)? as u64),
image: ImageId(r.get::<_, i64>(1)? as u64),
embedding: r.get::<_, Vec<u8>>(2)?,
crop_px: r.get::<_, f64>(3)? as f32,
quality: r.get::<_, Option<f64>>(4)?.map(|q| q as f32),
})
})?;
rows.collect::<Result<_, _>>().map_err(Into::into)
}
@@ -628,7 +784,7 @@ pub fn for_person(
let mut q = conn.prepare(
"SELECT f.id, f.image_id, f.x, f.y, f.w, f.h, f.landmarks,
f.detector_confidence, f.crop_px, f.model_id,
fp.person_id, fp.probability, fp.confirmed
fp.person_id, fp.probability, fp.confirmed, f.quality
FROM faces f
JOIN face_person fp ON fp.face_id = f.id
WHERE fp.person_id = ?1 AND (?2 OR fp.confirmed = 1)
@@ -879,6 +1035,7 @@ fn read_face(r: &rusqlite::Row<'_>) -> rusqlite::Result<Face> {
landmarks: blob_to_landmarks(&r.get::<_, Vec<u8>>(6)?),
confidence: r.get::<_, f64>(7)? as f32,
crop_px: r.get::<_, f64>(8)? as f32,
quality: r.get::<_, Option<f64>>(13)?.map(|q| q as f32),
model_id: r.get(9)?,
person: person.map(|p| PersonId(p as u64)),
probability: r.get::<_, Option<f64>>(11)?.unwrap_or(0.0) as f32,
@@ -1001,6 +1158,7 @@ mod tests {
confidence: 0.9,
embedding: vec![seed; 1024],
crop_px: 180.0,
quality: Some(10.0 + f32::from(seed)),
model_id: "w600k_mbf".into(),
crop: Vec::new(),
}
@@ -1026,6 +1184,80 @@ mod tests {
assert!((got[0].crop_px - 180.0).abs() < 1e-3);
assert!((got[0].landmarks[2].1 - 0.2).abs() < 1e-5);
assert!(got[0].person.is_none());
// Both readers carry the quality, and the one for the grouping pass
// carries it as the option it is.
let mut qualities: Vec<Option<f32>> = got.iter().map(|f| f.quality).collect();
qualities.sort_by(|a, b| a.partial_cmp(b).unwrap());
assert_eq!(qualities, vec![Some(11.0), Some(12.0)]);
let stored = embeddings(&c, "w600k_mbf").unwrap();
assert_eq!(stored.len(), 2);
assert_eq!(stored[0].quality, Some(11.0), "oldest first");
assert_eq!(stored[1].quality, Some(12.0));
}
/// The measuring pass writes over the vector and nothing else: the face
/// keeps its id, its box and whoever the user said it was.
#[test]
fn measuring_replaces_the_vector_and_keeps_the_identity() {
let c = db();
let img = image(&c, 1);
let unmeasured = DetectedFace {
quality: None,
..face(1)
};
let ids = record_detections(
&c,
img,
"w600k_mbf",
1024,
&[unmeasured.clone(), unmeasured],
)
.unwrap();
let person = create_person(&c, "Anna").unwrap();
confirm(&c, ids[0], person).unwrap();
assert_eq!(faces_unmeasured(&c, "w600k_mbf").unwrap(), 2);
assert_eq!(unmeasured_on_image(&c, img, "w600k_mbf").unwrap().len(), 2);
let marked_at: i64 = c
.query_row("SELECT indexed_at FROM face_index", [], |r| r.get(0))
.unwrap();
c.execute("UPDATE face_index SET indexed_at = indexed_at - 100", [])
.unwrap();
record_measurements(
&c,
img,
"w600k_mbf",
6000,
&[Measurement {
face: ids[0],
embedding: vec![9; 1024],
quality: 21.5,
}],
&[ids[1]],
)
.unwrap();
let got = for_image(&c, img).unwrap();
assert_eq!(got.len(), 1, "the degenerate face was kept");
assert_eq!(got[0].id, ids[0]);
assert_eq!(got[0].quality, Some(21.5));
assert_eq!(got[0].person, Some(person));
assert!(got[0].confirmed);
let e = embeddings(&c, "w600k_mbf").unwrap();
assert_eq!(e[0].embedding[0], 9);
assert_eq!(faces_unmeasured(&c, "w600k_mbf").unwrap(), 0);
// The marker says one face at the native edge, and is fresh — which
// is what makes the sync export it again.
let (found, edge, at): (i64, i64, i64) = c
.query_row(
"SELECT faces_found, source_edge, indexed_at FROM face_index",
[],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.unwrap();
assert_eq!((found, edge), (1, 6000));
assert!(at >= marked_at, "the marker was not refreshed");
}
/// Re-detection is coalesced per image, so it must replace rather than
@@ -1345,6 +1577,30 @@ mod tests {
assert!((cov.fraction() - 2.0 / 3.0).abs() < 1e-6);
}
#[test]
fn coverage_leaves_out_the_shadowed_half_of_a_raw_jpeg_pair() {
let c = db();
let raw = image(&c, 1);
let jpeg = image(&c, 2);
// The JPEG beside a RAW is not a separate photograph, and no sweep
// will ever index one.
c.execute(
"UPDATE images SET shadowed_by = ?1 WHERE id = ?2",
rusqlite::params![raw.0 as i64, jpeg.0 as i64],
)
.unwrap();
record_detections(&c, raw, "w600k_mbf", 2560, &[face(1)]).unwrap();
let cov = coverage(&c, "w600k_mbf").unwrap();
// Counting the shadowed one would leave a permanent remainder that no
// amount of indexing could bring down -- 4,424 of them on the
// reference library, which read as a stuck job.
assert_eq!(cov.images, 1);
assert_eq!(cov.outstanding(), 0);
assert!(cov.is_complete());
}
#[test]
fn re_indexing_one_image_updates_its_marker_rather_than_adding_a_second() {
let c = db();
@@ -1366,6 +1622,25 @@ mod tests {
assert_eq!(edge, 2048, "the marker should record the newer proxy");
}
/// A user who switches detector and back must not find the images the
/// second pipeline visited reported as done under the first with no
/// faces behind the marker.
#[test]
fn re_indexing_under_another_model_drops_the_first_models_marker() {
let c = db();
let img = image(&c, 1);
record_detections(&c, img, "w600k_mbf", 2048, &[face(1)]).unwrap();
record_detections(&c, img, "scrfd_2.5g+w600k_mbf", 2048, &[face(2), face(3)]).unwrap();
assert!(is_indexed(&c, img, "scrfd_2.5g+w600k_mbf").unwrap());
assert!(
!is_indexed(&c, img, "w600k_mbf").unwrap(),
"the first pipeline's marker outlived its faces"
);
assert_eq!(for_image(&c, img).unwrap().len(), 2);
assert_eq!(coverage(&c, "w600k_mbf").unwrap().outstanding(), 1);
}
#[test]
fn clearing_a_marker_puts_that_image_back_in_the_queue() {
let c = db();
@@ -1411,10 +1686,11 @@ mod tests {
record_detections(&c, img, "w600k_mbf", 1024, &[face(7)]).unwrap();
let e = embeddings(&c, "w600k_mbf").unwrap();
assert_eq!(e.len(), 1);
assert_eq!(e[0].1, img);
assert_eq!(e[0].2.len(), 1024);
assert_eq!(e[0].2[0], 7);
assert!((e[0].3 - 180.0).abs() < 1e-3);
assert_eq!(e[0].image, img);
assert_eq!(e[0].embedding.len(), 1024);
assert_eq!(e[0].embedding[0], 7);
assert!((e[0].crop_px - 180.0).abs() < 1e-3);
assert_eq!(e[0].quality, Some(17.0));
}
// ── stored crops ──────────────────────────────────────────────────────
+509 -41
View File
@@ -10,8 +10,14 @@
//! 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.
//!
//! Nothing in this file runs a job. [`crate::runner`] is the other half — the
//! one that claims from this table, does the work through a handler, and
//! reports back. Worth knowing because for a long time it did not exist: every
//! producer called [`enqueue`] and nothing ever called [`claim_next`], so the
//! table only ever grew.
use rusqlite::Connection;
use rusqlite::{Connection, OptionalExtension};
use crate::error::CatalogError;
@@ -48,6 +54,21 @@ pub enum JobKind {
}
impl JobKind {
/// Every kind, so code that has to enumerate them cannot quietly miss one
/// that was added later. A `match` would catch that; a hand-written array
/// at each call site would not.
pub const ALL: [JobKind; 9] = [
JobKind::ScanFolder,
JobKind::ExtractMetadata,
JobKind::Thumbnail,
JobKind::ReadSidecar,
JobKind::WriteSidecar,
JobKind::ContentHash,
JobKind::FetchPreview,
JobKind::FetchOriginal,
JobKind::DetectFaces,
];
fn from_i64(v: i64) -> Option<Self> {
Some(match v {
0 => JobKind::ScanFolder,
@@ -68,6 +89,16 @@ impl JobKind {
pub fn is_network(self) -> bool {
matches!(self, JobKind::FetchPreview | JobKind::FetchOriginal)
}
/// Whether `subject_id` names a row in `images`.
///
/// Every kind but one is per-photograph. `ScanFolder`'s subject is a
/// *folder*, and the two id spaces are unrelated — so anything that joins
/// `subject_id` against `images` has to exclude it, or it will read one
/// table's ids as another's and act on the answer.
pub fn subject_is_image(self) -> bool {
!matches!(self, JobKind::ScanFolder)
}
}
/// Scheduling class, matching the GPU tile scheduler (ARCH §5.3).
@@ -86,6 +117,22 @@ pub enum Priority {
Interactive = 2,
}
impl Priority {
/// Read back from the stored column.
///
/// An unrecognised value reads as `Background` rather than failing: a
/// priority is a hint about ordering, and refusing to run a job because
/// its urgency is spelled oddly would be a worse answer than running it
/// last.
fn from_i64(v: i64) -> Self {
match v {
2 => Priority::Interactive,
1 => Priority::Prefetch,
_ => Priority::Background,
}
}
}
/// Lifecycle state.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(i64)]
@@ -156,52 +203,115 @@ pub fn enqueue(
/// 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()?;
claim(conn, now, None)
}
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();
/// Claim the next runnable job of one of `kinds`.
///
/// What lets a runner take only the work it can actually do. A device with no
/// connector must leave `FetchOriginal` rows alone rather than claim them and
/// fail them five times each with backoff; a runner gated onto an unmetered
/// network (FR-NC-6) passes the local kinds only and leaves the transfers
/// where they are. Neither is expressible by filtering *after* a claim,
/// because the claim has already marked the row `Running`.
///
/// An empty list claims nothing, which is the honest reading of "there is
/// nothing this worker can do".
pub fn claim_next_matching(
conn: &Connection,
now: i64,
kinds: &[JobKind],
) -> Result<Option<Job>, CatalogError> {
if kinds.is_empty() {
return Ok(None);
}
claim(conn, now, Some(kinds))
}
let Some((id, kind, subject_id, priority, attempts, payload)) = job else {
/// The claim, as one statement.
///
/// # Why this is not a transaction around a read and a write
///
/// It used to be, and under two connections that is not safe in the way it
/// looks. A deferred transaction takes a read lock for the `SELECT` and only
/// tries to upgrade at the `UPDATE`; with WAL, a second worker that read the
/// same snapshot gets `SQLITE_BUSY_SNAPSHOT` on its write — an error a busy
/// handler cannot retry away, because the fix is to roll back and start over.
/// So the queue was correct only in the sense that the loser failed loudly.
///
/// `UPDATE ... WHERE id = (SELECT ...) RETURNING` is one statement, so it is
/// one implicit transaction that takes the write lock immediately. Two workers
/// serialise, the loser waits out its busy timeout rather than erroring, and
/// neither can see a row the other is already holding.
fn claim(
conn: &Connection,
now: i64,
kinds: Option<&[JobKind]>,
) -> Result<Option<Job>, CatalogError> {
// `now` first, then the kinds, matching the order the placeholders appear
// in the text below.
let mut args: Vec<i64> = vec![now];
let filter = match kinds {
None => String::new(),
Some(kinds) => {
// Built from the kind *count*, never from anything a user typed —
// the same discipline `collections::descendants` uses, since
// `carray` is not compiled in.
let placeholders = std::iter::repeat_n("?", kinds.len())
.collect::<Vec<_>>()
.join(",");
args.extend(kinds.iter().map(|k| *k as i64));
format!(" AND kind IN ({placeholders})")
}
};
let sql = format!(
"UPDATE jobs
SET state = 1, attempts = attempts + 1
WHERE id = (SELECT id
FROM jobs
WHERE state = 0 AND not_before <= ?{filter}
ORDER BY priority DESC, id ASC
LIMIT 1)
RETURNING id, kind, subject_id, priority, attempts, payload"
);
let claimed = conn
.query_row(&sql, rusqlite::params_from_iter(args.iter()), |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)?,
))
})
.optional()?;
let Some((id, kind, subject_id, priority, attempts, payload)) = claimed else {
return Ok(None);
};
tx.execute(
"UPDATE jobs SET state = 1, attempts = attempts + 1 WHERE id = ?1",
[id],
)?;
tx.commit()?;
let Some(kind) = JobKind::from_i64(kind) else {
// A row written by a build that knows a kind this one does not — a
// downgrade, or a catalog synced from a newer device. Running it as
// some other kind would be worse than not running it, so it is parked
// where the next claim will not see it again.
//
// Answering `None` understates what is queued for one pass. The
// alternative is a loop that keeps claiming the same unreadable row.
abandon(conn, id, &format!("unknown job kind {kind}"))?;
return Ok(None);
};
Ok(Some(Job {
id,
kind: JobKind::from_i64(kind).unwrap_or(JobKind::ExtractMetadata),
kind,
subject_id,
priority: match priority {
2 => Priority::Interactive,
1 => Priority::Prefetch,
_ => Priority::Background,
},
attempts: attempts + 1,
priority: Priority::from_i64(priority),
attempts,
payload,
}))
}
@@ -215,16 +325,53 @@ pub fn complete(conn: &Connection, id: i64) -> Result<(), CatalogError> {
/// 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],
)?;
abandon(conn, 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(())
}
}
/// Give up on a job now, with no further retries.
///
/// For failures a retry cannot fix — the subject is gone, the payload is
/// unreadable, the format is one this build does not know. Walking the whole
/// retry ladder to reach a conclusion the first attempt already reached costs
/// five wakeups and five backoffs per photograph, which on a phone is the
/// difference the user notices.
///
/// The row is kept rather than deleted, because "this file failed and here is
/// why" is something the user is entitled to see (NFR-ARCH-4).
pub fn abandon(conn: &Connection, id: i64, err: &str) -> Result<(), CatalogError> {
conn.execute(
"UPDATE jobs SET state = 2, last_error = ?2 WHERE id = ?1",
rusqlite::params![id, err],
)?;
Ok(())
}
/// Put a claimed job back exactly as it was found.
///
/// For a worker that is being stopped rather than a job that is going wrong:
/// the platform revoked the slot, the user left the screen. The attempt the
/// claim consumed is given back, because nothing was learned about the file —
/// without that, five backgroundings in a row would mark good work as failed.
///
/// Guarded on the claim still being *this* claim. There is no owner column, so
/// `attempts` stands in for one: it is bumped by every claim, so the row only
/// still reads `state = 1` with the caller's own attempt number while nobody
/// else has taken it since. A worker that comes back after the recovery pass
/// handed its job to someone else therefore changes nothing, rather than
/// releasing a job another worker is in the middle of.
pub fn release(conn: &Connection, job: &Job) -> Result<(), CatalogError> {
conn.execute(
"UPDATE jobs SET state = 0, attempts = max(0, attempts - 1)
WHERE id = ?1 AND state = 1 AND attempts = ?2",
rusqlite::params![job.id, job.attempts],
)?;
Ok(())
}
@@ -232,11 +379,97 @@ pub fn fail(conn: &Connection, job: &Job, now: i64, err: &str) -> Result<(), Cat
///
/// 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).
///
/// The attempt the dead claim consumed is deliberately *not* refunded. A job
/// that takes the process down with it is indistinguishable from one that
/// fails, and the attempt counter is the only evidence that survives a death —
/// without it a poison-pill job is reclaimed and re-run forever.
pub fn recover_orphaned(conn: &Connection) -> Result<usize, CatalogError> {
let n = conn.execute("UPDATE jobs SET state = 0 WHERE state = 1", [])?;
Ok(n)
}
/// Delete jobs whose photograph is gone.
///
/// Coalescing keeps the table one row per unit of work, but nothing shrinks it
/// when the work stops existing: a library that has been culled carries a
/// thumbnail job for every photograph deleted since the last time anything
/// looked. Each one would be claimed, run, and failed five times.
///
/// Only kinds whose subject really is an image ([`JobKind::subject_is_image`])
/// are considered — `ScanFolder`'s subject is a folder id, and joining it
/// against `images` would delete jobs by coincidence of numbering.
///
/// There is no foreign key to do this instead. `jobs.subject_id` deliberately
/// references nothing: it means different tables for different kinds, and a
/// constraint that is right for eight of nine kinds is not a constraint.
pub fn reap_orphan_subjects(conn: &Connection) -> Result<usize, CatalogError> {
let kinds: Vec<i64> = JobKind::ALL
.iter()
.filter(|k| k.subject_is_image())
.map(|k| *k as i64)
.collect();
let placeholders = std::iter::repeat_n("?", kinds.len())
.collect::<Vec<_>>()
.join(",");
let n = conn.execute(
&format!(
"DELETE FROM jobs
WHERE subject_id IS NOT NULL
AND kind IN ({placeholders})
AND NOT EXISTS (SELECT 1 FROM images WHERE images.id = jobs.subject_id)"
),
rusqlite::params_from_iter(kinds.iter()),
)?;
Ok(n)
}
/// How much is left, by state.
///
/// One query rather than a listing, because the caller is a progress line: a
/// foreground service's notification has to say how much remains without
/// reading a hundred thousand rows to find out (FR-PLAT-AND-4).
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct Counts {
/// Claimable now or after a backoff.
pub pending: usize,
/// Claimed by someone. After a clean start that is a live worker; before
/// [`recover_orphaned`] it is a dead one.
pub running: usize,
/// Given up on, and kept so the user can see what failed and why.
pub failed: usize,
}
impl Counts {
/// Work that is still going to happen.
pub fn outstanding(&self) -> usize {
self.pending + self.running
}
}
/// Count the queue by state.
pub fn counts(conn: &Connection) -> Result<Counts, CatalogError> {
// `sum` over no rows is NULL, not 0 — an empty queue would otherwise fail
// to convert rather than counting nothing.
let (pending, running, failed) = conn.query_row(
"SELECT sum(state = 0), sum(state = 1), sum(state = 2) FROM jobs",
[],
|r| {
Ok((
r.get::<_, Option<i64>>(0)?,
r.get::<_, Option<i64>>(1)?,
r.get::<_, Option<i64>>(2)?,
))
},
)?;
Ok(Counts {
pending: pending.unwrap_or(0) as usize,
running: running.unwrap_or(0) as usize,
failed: failed.unwrap_or(0) as usize,
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -391,6 +624,241 @@ mod tests {
assert_eq!(backoff_seconds(100), 300);
}
/// An image row, so a job has a subject that exists.
fn image(c: &Connection, id: i64) {
c.execute(
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', '/lib')
ON CONFLICT DO NOTHING",
[],
)
.unwrap();
c.execute(
"INSERT INTO images(id, root_id, source_ref, added_at) VALUES (?1, 1, ?2, 0)",
rusqlite::params![id, format!("/lib/{id}.CR3")],
)
.unwrap();
}
#[test]
fn a_runner_claims_only_the_kinds_it_names() {
// The property a filtered claim exists for: work this worker cannot do
// is left untouched — not claimed, not attempted, not failed.
let c = db();
enqueue(&c, JobKind::Thumbnail, Some(1), Priority::Background, None).unwrap();
enqueue(
&c,
JobKind::FetchOriginal,
Some(1),
Priority::Interactive,
None,
)
.unwrap();
// `FetchOriginal` is the higher priority and would be claimed first by
// an unfiltered claim. It is not this worker's to take.
let job = claim_next_matching(&c, 0, &[JobKind::Thumbnail])
.unwrap()
.expect("the thumbnail is claimable");
assert_eq!(job.kind, JobKind::Thumbnail);
assert!(claim_next_matching(&c, 0, &[JobKind::Thumbnail])
.unwrap()
.is_none());
let (state, attempts): (i64, i64) = c
.query_row(
"SELECT state, attempts FROM jobs WHERE kind = ?1",
[JobKind::FetchOriginal as i64],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(state, JobState::Pending as i64);
assert_eq!(attempts, 0);
}
#[test]
fn a_worker_that_can_do_nothing_claims_nothing() {
let c = db();
enqueue(&c, JobKind::Thumbnail, Some(1), Priority::Background, None).unwrap();
assert!(claim_next_matching(&c, 0, &[]).unwrap().is_none());
}
#[test]
fn releasing_a_claim_gives_the_attempt_back() {
// A stopped worker has learned nothing about the file, so the claim it
// is handing back must cost nothing.
let c = db();
enqueue(&c, JobKind::Thumbnail, Some(1), Priority::Background, None).unwrap();
let job = claim_next(&c, 0).unwrap().unwrap();
assert_eq!(job.attempts, 1);
release(&c, &job).unwrap();
let again = claim_next(&c, 0).unwrap().expect("claimable again at once");
assert_eq!(again.attempts, 1, "the release refunded the first attempt");
}
#[test]
fn releasing_a_job_someone_else_has_reclaimed_does_nothing() {
// `attempts` standing in for an owner column. A worker that comes back
// after the recovery pass handed its job to someone else must not
// release a claim that is no longer its to release — which would drop
// the live worker's job back into the queue to be run twice.
let c = db();
enqueue(&c, JobKind::Thumbnail, Some(1), Priority::Background, None).unwrap();
let stale = claim_next(&c, 0).unwrap().unwrap();
recover_orphaned(&c).unwrap();
let live = claim_next(&c, 0).unwrap().unwrap();
assert_eq!(live.attempts, 2);
release(&c, &stale).unwrap();
let (state, attempts): (i64, i64) = c
.query_row("SELECT state, attempts FROM jobs", [], |r| {
Ok((r.get(0)?, r.get(1)?))
})
.unwrap();
assert_eq!(
state,
JobState::Running as i64,
"the live claim still holds"
);
assert_eq!(attempts, 2, "and its attempt was not refunded for it");
}
#[test]
fn abandoning_skips_the_whole_retry_ladder() {
let c = db();
enqueue(&c, JobKind::Thumbnail, Some(1), Priority::Background, None).unwrap();
let job = claim_next(&c, 0).unwrap().unwrap();
abandon(&c, job.id, "not an image this build can read").unwrap();
assert!(claim_next(&c, 1_000_000).unwrap().is_none());
let (state, attempts, err): (i64, i64, String) = c
.query_row("SELECT state, attempts, last_error FROM jobs", [], |r| {
Ok((r.get(0)?, r.get(1)?, r.get(2)?))
})
.unwrap();
assert_eq!(state, JobState::Failed as i64);
assert_eq!(attempts, 1, "one attempt, not MAX_ATTEMPTS");
// Kept, not deleted: the user is entitled to see what failed and why.
assert!(err.contains("this build can read"));
}
#[test]
fn jobs_for_a_deleted_photograph_are_reaped() {
// Coalescing keeps the table one row per unit of work; nothing shrank
// it when the work stopped existing.
let c = db();
image(&c, 1);
image(&c, 2);
enqueue(&c, JobKind::Thumbnail, Some(1), Priority::Background, None).unwrap();
enqueue(&c, JobKind::Thumbnail, Some(2), Priority::Background, None).unwrap();
enqueue(
&c,
JobKind::ExtractMetadata,
Some(2),
Priority::Background,
None,
)
.unwrap();
c.execute("DELETE FROM images WHERE id = 2", []).unwrap();
assert_eq!(reap_orphan_subjects(&c).unwrap(), 2);
let left: i64 = c
.query_row("SELECT subject_id FROM jobs", [], |r| r.get(0))
.unwrap();
assert_eq!(left, 1);
}
#[test]
fn a_folder_scan_is_not_reaped_by_image_ids() {
// `ScanFolder`'s subject is a folder. Joining it against `images`
// would delete it whenever the numbering happened not to collide —
// which, on a fresh library, is almost always.
let c = db();
enqueue(
&c,
JobKind::ScanFolder,
Some(1),
Priority::Background,
Some("/lib/2024"),
)
.unwrap();
assert_eq!(reap_orphan_subjects(&c).unwrap(), 0);
assert!(claim_next(&c, 0).unwrap().is_some());
}
#[test]
fn a_job_kind_from_a_newer_build_is_parked_rather_than_guessed_at() {
// A catalog synced from a device running a later build. Running an
// unknown kind as some arbitrary known one is worse than not running
// it, and the old code silently read every unknown kind as
// `ExtractMetadata`.
let c = db();
c.execute(
"INSERT INTO jobs(kind, subject_id, priority, state) VALUES (99, 1, 0, 0)",
[],
)
.unwrap();
assert!(claim_next(&c, 0).unwrap().is_none());
let (state, err): (i64, String) = c
.query_row("SELECT state, last_error FROM jobs", [], |r| {
Ok((r.get(0)?, r.get(1)?))
})
.unwrap();
assert_eq!(state, JobState::Failed as i64);
assert!(err.contains("99"), "{err}");
}
#[test]
fn counts_say_what_is_left() {
// What a foreground service's notification is built from: a number,
// without reading a hundred thousand rows to find it.
let c = db();
assert_eq!(counts(&c).unwrap(), Counts::default());
for id in 1..=3 {
enqueue(&c, JobKind::Thumbnail, Some(id), Priority::Background, None).unwrap();
}
let job = claim_next(&c, 0).unwrap().unwrap();
abandon(&c, job.id, "nope").unwrap();
claim_next(&c, 0).unwrap().unwrap();
let n = counts(&c).unwrap();
assert_eq!(n.pending, 1);
assert_eq!(n.running, 1);
assert_eq!(n.failed, 1);
assert_eq!(n.outstanding(), 2, "failed work is not outstanding work");
}
#[test]
fn every_kind_is_in_all() {
// `ALL` is what the reap builds its kind filter from, so a kind added
// to the enum and forgotten here would quietly stop being reaped.
for (i, kind) in JobKind::ALL.iter().enumerate() {
assert_eq!(
JobKind::from_i64(i as i64),
Some(*kind),
"ALL is out of step with the discriminants at {i}"
);
}
assert!(JobKind::from_i64(JobKind::ALL.len() as i64).is_none());
}
#[test]
fn only_a_folder_scan_has_a_non_image_subject() {
assert!(!JobKind::ScanFolder.subject_is_image());
for kind in JobKind::ALL.iter().filter(|k| **k != JobKind::ScanFolder) {
assert!(kind.subject_is_image(), "{kind:?}");
}
}
#[test]
fn network_jobs_are_identifiable_for_metered_gating() {
// FR-NC-6: transfers respect unmetered-network and charging
+1 -1
View File
@@ -1,4 +1,4 @@
//! TRACES: FR-CAT-5 | FR-CAT-6 | FR-CAT-13 | NFR-R5
//! TRACES: FR-CAT-5 | FR-CAT-6 | NFR-R5
//! Keywords: the vocabulary, the assignments, and the edits the UI performs.
//!
//! The read half of this shipped with the catalog and the write half did not.
+47 -2
View File
@@ -16,9 +16,12 @@
//! - [`collections`] — the collection tree and membership the UI edits
//! - [`keywords`] — the keyword vocabulary and what it is assigned to
//! - [`faces`] — detected faces, the people they belong to, and who said so
//! - [`bursts`] — frames that are one moment, grouped so they judge as one
//! - [`jobs`] — the durable background work queue
//! - [`runner`] — the thing that drains it, driven by whoever owns the thread
//! - [`trash`] — soft delete to a folder, then permanent delete
//! - [`merge`] / [`sync`] — cross-device merging of collections and keywords
//! - [`recovery`] — backups, and the two offers made when this file is damaged
//!
//! # The one thing everything is designed around
//!
@@ -33,6 +36,7 @@ use std::path::Path;
use dr_types::{Availability, ImageId};
use rusqlite::Connection;
pub mod bursts;
pub mod cache;
pub mod collections;
pub mod dedup;
@@ -44,6 +48,8 @@ pub mod keywords;
pub mod merge;
pub mod query;
pub mod rating;
pub mod recovery;
pub mod runner;
pub mod scan;
pub mod schema;
pub mod sync;
@@ -55,15 +61,20 @@ pub use collections::{Collection, CollectionKind, TreeRow};
pub use dedup::{seen_by_content, seen_by_metadata, set_content_hash};
pub use error::CatalogError;
pub use face_shard::{FaceShardStore, SharedFace};
pub use faces::{Calibration, DetectedFace, Face, FaceId, Person, PersonId};
pub use faces::{Calibration, DetectedFace, Face, FaceId, Measurement, Person, PersonId};
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 recovery::Backup;
// Not `runner::Budget`: `cache::Budget` already owns that name here and
// means something else entirely (bytes on disk, not jobs in a slot).
// Callers spell the work budget `runner::Budget`, where it is unambiguous.
pub use runner::{DrainReport, JobHandler, Outcome, Runner};
pub use scan::{DirAction, DirState, EntryAction, ScanOutcome};
pub use trash::{TrashedImage, TRASH_DIR};
pub use walk::{ensure_root, scan_root, RootKind, ScanProgress, ScanReport};
pub use walk::{ensure_root, mark_root_offline, scan_root, RootKind, ScanProgress, ScanReport};
/// One row of the library grid.
///
@@ -214,9 +225,23 @@ pub struct Catalog {
impl Catalog {
/// Open or create a catalog, migrating it forward if needed.
///
/// Does **not** verify the file — see [`Self::open_verified`], and
/// [`recovery`] for why the check is bound to startup rather than to every
/// open. Damage this trips over on the way past is still reported as
/// [`CatalogError::Corrupt`] rather than as a stray SQLite error.
pub fn open(path: &Path) -> Result<Self, CatalogError> {
let conn = Connection::open(path)?;
schema::configure(&conn)?;
// NFR-R2, and the reason it is *here*: a migration is the one routine
// operation that rewrites table structure, so it is the likeliest way
// this file becomes unreadable — and afterwards there is no
// pre-migration state left to copy. A failure to take the copy is
// logged rather than raised: a full disk must not be the thing that
// makes a library unopenable.
if let Err(e) = recovery::backup_before_migration(&conn, path) {
log::warn!("could not back up before migrating: {e}");
}
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
@@ -227,6 +252,26 @@ impl Catalog {
Ok(Catalog { conn })
}
/// TRACES: NFR-R6
/// Open a catalog, checking the file first.
///
/// What startup calls. On [`CatalogError::Corrupt`] the caller has a user
/// in front of it and must make the two offers [`recovery`] describes,
/// rather than reporting a SQLite message on a banner and carrying on into
/// a scan that would write into the damage.
///
/// Checked *before* opening rather than after, because opening runs
/// migrations: a damaged catalog that happens to have an intact header
/// would otherwise be migrated — rewriting structure on top of structure
/// that is already wrong — before anybody asked whether it was sound.
pub fn open_verified(path: &Path) -> Result<Self, CatalogError> {
// A catalog that is not there yet is not damaged; `open` creates it.
if path.is_file() {
recovery::check_file(path)?;
}
Self::open(path)
}
/// 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()?;
+398 -12
View File
@@ -63,6 +63,59 @@ impl Judgement {
}
}
/// TRACES: FR-NC-8 | FR-NC-9
/// The default version's uuid for a photograph the server knows by `file_id`.
///
/// # Why this is derived and not generated
///
/// A version's uuid is the identity a cross-device merge keys on. It used to
/// be minted at random per row, and the comment above this function used to
/// say that made it unique — which it did, and that was precisely the bug.
/// Two devices indexing the same library minted *different* uuids for the same
/// photograph, so the sidecar they shared ended up with two `default = 1`
/// blocks, `Version::merge` never saw a matching pair to reconcile, and a
/// rating made on one device was invisible on the other. `crate::merge` has
/// documented the consequence for keywords for as long as it has existed: a
/// uuid-keyed join across two catalogs unions nothing at all.
///
/// `oc:fileid` is the identity that *is* shared. The server assigns it, every
/// client pointed at that library sees the same integer, and it survives a
/// server-side rename and move — the same three properties that made
/// `crate::merge::ASSIGN_BY_FILE_ID` prefer it to a content hash.
///
/// # The layout
///
/// A UUIDv8 (RFC 9562: an application-defined layout) carrying the file id
/// verbatim across the four variable fields, with a fixed tag in the node
/// field saying what minted it. Verbatim rather than hashed so the mapping is
/// injective by construction: two file ids cannot collide, which a truncated
/// hash could, and a uuid read out of a sidecar can be traced back to the file
/// it belongs to by eye.
///
/// Every device computes this identically from the same integer, which is the
/// whole point — there is no negotiation and no first-writer-wins.
pub fn derived_version_uuid(file_id: i64) -> String {
let id = file_id as u64;
format!(
// 32 + 16 + 12 + 4 = 64 bits of file id, then the tag.
"{:08x}-{:04x}-8{:03x}-{:04x}-{:012x}",
(id >> 32) as u32,
(id >> 16) as u16,
(id >> 4) as u16 & 0x0FFF,
// The two high bits are the RFC's variant field and must be `0b10`;
// the remaining fourteen carry the file id's last four bits.
0x8000u16 | ((id as u16 & 0x000F) << 10),
DERIVED_VERSION_TAG,
)
}
/// The node field of a [`derived_version_uuid`], identifying what minted it.
///
/// Fixed and arbitrary. Its only job is to keep a derived uuid from colliding
/// with a randomly minted one and to make it recognisable in a sidecar read by
/// eye — `…-d0c5ec0de001` is visibly not a v4.
const DERIVED_VERSION_TAG: u64 = 0xd0c5_ec0d_e001;
/// Give every image without one a default version.
///
/// Idempotent, and cheap on the common path: the `NOT EXISTS` sub-select is
@@ -72,8 +125,9 @@ impl Judgement {
/// 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.
/// The uuid comes from [`derived_version_uuid`] where the server has named the
/// file, so every device computes the same one; only a library with no server
/// behind it falls back to a generated id.
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
@@ -93,13 +147,17 @@ pub fn ensure_default_versions(conn: &Connection) -> Result<usize, CatalogError>
/// 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> = {
// The remote id travels with the image so the uuid can be derived from it.
// A `LEFT JOIN`, because a library on a folder or a card has no `remote`
// row at all and still needs its versions.
let ids: Vec<(i64, Option<i64>)> = {
let mut stmt = conn.prepare(
"SELECT i.id FROM images i
"SELECT i.id, r.file_id FROM images i
LEFT JOIN remote r ON r.image_id = i.id
WHERE NOT EXISTS (SELECT 1 FROM versions v WHERE v.image_id = i.id)",
)?;
let found = stmt
.query_map([], |r| r.get(0))?
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
.collect::<Result<Vec<_>, _>>()?;
found
};
@@ -112,14 +170,103 @@ pub fn ensure_default_versions_within(conn: &Connection) -> Result<usize, Catalo
"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])?;
for (id, file_id) in &ids {
insert.execute(rusqlite::params![
id,
version_uuid(*file_id),
DEFAULT_VERSION_NAME
])?;
}
}
Ok(ids.len())
}
/// The uuid to mint for a new default version.
///
/// Derived from the server's file id where there is one, so two devices agree
/// (FR-NC-8); generated where there is not.
///
/// # What the fallback costs
///
/// A library with no server behind it — a folder, a card — has no identity two
/// devices could both compute, so the split this derivation prevents is still
/// reachable there if that folder is synced by something else. That case is
/// repaired rather than prevented: `Sidecar::fuse_default_versions` folds the
/// rival defaults together the next time either device reads the file.
fn version_uuid(file_id: Option<i64>) -> String {
match file_id {
Some(id) => derived_version_uuid(id),
None => new_uuid(),
}
}
/// TRACES: FR-NC-8 | FR-NC-9
/// Move default versions minted before [`derived_version_uuid`] onto it.
///
/// Every catalog written by an earlier build holds a randomly minted uuid per
/// image, and its peers hold different ones for the same photographs. Deriving
/// the uuid only for *new* rows would leave every image already indexed —
/// which is all of them, on a library anybody has used — writing to the same
/// rival identity it always did.
///
/// Safe to run repeatedly: it selects only rows whose uuid is not already the
/// derived one, so a realigned catalog matches nothing and writes nothing.
///
/// # Why this cannot collide
///
/// `versions.uuid` is `UNIQUE`, and `remote.file_id` has a unique index of its
/// own, so two images cannot derive the same uuid. The one row that could
/// stand in the way is a *virtual copy* (FR-CAT-12) that already holds the
/// target — impossible to mint but not impossible to receive from a merge — so
/// the update is skipped where the target is taken rather than failing the
/// backfill and, with it, the catalog open.
///
/// # The sidecar side is not this function's business
///
/// Moving the catalog's uuid alone would leave the file's default under the
/// old one and the next write would add a rival rather than amend it. What
/// stops that is `library::amend` fusing onto the write's uuid before it looks
/// anything up, which renames the file's default to match. This end and that
/// one have to land together, and they do.
///
/// Returns how many rows moved.
pub fn align_default_version_uuids(conn: &Connection) -> Result<usize, CatalogError> {
// Filtered in SQL rather than in the loop: every derived uuid ends in the
// tag, so a catalog that has already been realigned selects no rows at all
// and this costs one indexed pass instead of twenty-four thousand reads.
let already = format!("%-{DERIVED_VERSION_TAG:012x}");
let stale: Vec<(i64, i64)> = {
let mut stmt = conn.prepare(
"SELECT v.id, r.file_id
FROM versions v
JOIN remote r ON r.image_id = v.image_id
WHERE v.is_default = 1 AND v.uuid NOT LIKE ?1",
)?;
let found = stmt
.query_map([&already], |r| Ok((r.get(0)?, r.get(1)?)))?
.collect::<Result<Vec<_>, _>>()?;
found
};
if stale.is_empty() {
return Ok(0);
}
let tx = conn.unchecked_transaction()?;
let mut moved = 0usize;
{
// `OR IGNORE` covers the taken-target case described above: the row
// keeps the uuid it has, which is the state this build has always
// coped with, rather than aborting the transaction.
let mut update = tx.prepare("UPDATE OR IGNORE versions SET uuid = ?2 WHERE id = ?1")?;
for (row, file_id) in &stale {
moved += update.execute(rusqlite::params![row, derived_version_uuid(*file_id)])?;
}
}
tx.commit()?;
Ok(moved)
}
/// 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.
@@ -144,10 +291,21 @@ pub fn default_version_id(conn: &Connection, image: ImageId) -> Result<i64, Cata
return Ok(id);
}
// Derived from the server's file id where there is one, so the version
// this mints is the same one the photographer's other device will mint
// (FR-NC-8). A miss here is a library with no server behind it.
let file_id: Option<i64> = conn
.query_row(
"SELECT file_id FROM remote WHERE image_id = ?1",
[image.0 as i64],
|r| r.get(0),
)
.optional()?;
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],
rusqlite::params![image.0 as i64, version_uuid(file_id), DEFAULT_VERSION_NAME],
)?;
Ok(conn.last_insert_rowid())
}
@@ -356,15 +514,22 @@ fn flag_from_code(v: i64) -> FlagState {
}
}
/// A version UUID.
/// A generated version UUID, for a photograph no server has named.
///
/// 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.
/// same reasoning as the date maths in `library_ui`. It needs to be unique,
/// 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.
///
/// **Unique is not the same as agreed**, which is the distinction that cost a
/// photographer a day of culling. Two devices calling this for the same
/// photograph get two different answers, and a merge keyed on the result then
/// has no pair to reconcile. Anything with a `file_id` behind it must use
/// [`derived_version_uuid`]; this is the fallback for libraries that have no
/// server to supply one.
fn new_uuid() -> String {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
@@ -731,3 +896,224 @@ mod tests {
assert_eq!(cat.count(&unrated, 0).unwrap(), 4);
}
}
/// TRACES: FR-NC-8 | FR-NC-9
/// The identity two devices have to agree on without talking to each other.
#[cfg(test)]
mod derived_identity {
use super::*;
use crate::Catalog;
/// A catalog whose images the server has named, as a remote scan leaves it.
fn with_remote_images(file_ids: &[i64]) -> Catalog {
let cat = Catalog::in_memory().unwrap();
let c = cat.connection();
c.execute(
"INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'lib')",
[],
)
.unwrap();
for (i, file_id) in file_ids.iter().enumerate() {
c.execute(
"INSERT INTO images(root_id, source_ref, added_at) VALUES (1, ?1, 0)",
[format!("img{i:03}.CR3")],
)
.unwrap();
let image = c.last_insert_rowid();
c.execute(
"INSERT INTO remote(image_id, file_id) VALUES (?1, ?2)",
rusqlite::params![image, file_id],
)
.unwrap();
}
cat
}
fn default_uuids(cat: &Catalog) -> Vec<String> {
let mut stmt = cat
.connection()
.prepare("SELECT uuid FROM versions WHERE is_default = 1 ORDER BY image_id")
.unwrap();
stmt.query_map([], |r| r.get(0))
.unwrap()
.map(Result::unwrap)
.collect()
}
/// The whole point: the same photograph, indexed independently on two
/// devices, gets one identity. This used to be two.
#[test]
fn two_devices_derive_the_same_uuid_for_one_photograph() {
let laptop = with_remote_images(&[4_812]);
let tablet = with_remote_images(&[4_812]);
ensure_default_versions(laptop.connection()).unwrap();
ensure_default_versions(tablet.connection()).unwrap();
assert_eq!(default_uuids(&laptop), default_uuids(&tablet));
}
/// And different photographs must still be told apart — the property the
/// random uuid did have, which this must not give up to gain agreement.
#[test]
fn different_photographs_keep_different_uuids() {
let cat = with_remote_images(&[1, 2, 3, 0x7FFF_FFFF_FFFF_FFFF]);
ensure_default_versions(cat.connection()).unwrap();
let mut uuids = default_uuids(&cat);
let before = uuids.len();
uuids.sort();
uuids.dedup();
assert_eq!(uuids.len(), before, "two photographs share an identity");
}
/// The file id has to survive the layout intact, or two ids that differ
/// only in the bits it drops would collide.
#[test]
fn the_whole_file_id_is_carried() {
// A pair differing only in the low four bits, and a pair differing
// only in the high thirty-two — the two places a sloppy layout loses
// information.
assert_ne!(derived_version_uuid(0x10), derived_version_uuid(0x1F));
assert_ne!(
derived_version_uuid(0x0000_0001_0000_0000),
derived_version_uuid(0x0000_0002_0000_0000)
);
assert_ne!(derived_version_uuid(0), derived_version_uuid(-1));
}
/// Well-formed, and recognisably not a generated one.
#[test]
fn a_derived_uuid_is_a_well_formed_v8() {
let uuid = derived_version_uuid(4_812);
let fields: Vec<&str> = uuid.split('-').collect();
assert_eq!(fields.len(), 5);
assert_eq!(
fields.iter().map(|f| f.len()).collect::<Vec<_>>(),
vec![8, 4, 4, 4, 12]
);
assert!(fields[2].starts_with('8'), "version nibble: {uuid}");
// The RFC's variant field is the two high bits of the fourth group,
// and must read `0b10` — so the first hex digit is 8, 9, a or b.
assert!(
matches!(fields[3].as_bytes()[0], b'8' | b'9' | b'a' | b'b'),
"variant: {uuid}"
);
assert!(uuid.ends_with("d0c5ec0de001"), "tag: {uuid}");
}
/// A library with no server behind it has no shared identity to derive,
/// and must still get a version rather than failing.
#[test]
fn a_library_with_no_server_still_gets_its_versions() {
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, 'a.CR3', 0)",
[],
)
.unwrap();
assert_eq!(ensure_default_versions(c).unwrap(), 1);
assert_eq!(default_uuids(&cat).len(), 1);
}
/// The repair. A catalog written by an earlier build holds randomly minted
/// uuids, and leaving them there would mean every image already indexed —
/// which is all of them — kept writing to its own rival identity.
#[test]
fn a_catalog_from_an_earlier_build_is_realigned() {
let cat = with_remote_images(&[4_812, 4_813]);
let c = cat.connection();
// As the old code left it.
for (i, image) in [1i64, 2].iter().enumerate() {
c.execute(
"INSERT INTO versions(image_id, uuid, name, is_default, rating, flag)
VALUES (?1, ?2, 'Default', 1, ?3, 0)",
rusqlite::params![image, format!("random-{i}"), (i + 1) as i64],
)
.unwrap();
}
assert_eq!(align_default_version_uuids(c).unwrap(), 2);
assert_eq!(
default_uuids(&cat),
vec![derived_version_uuid(4_812), derived_version_uuid(4_813)]
);
// The judgement travels with the row — a realignment that dropped the
// ratings would be a worse bug than the one it fixes.
let ratings: Vec<i64> = {
let mut stmt = c
.prepare("SELECT rating FROM versions ORDER BY image_id")
.unwrap();
let v = stmt
.query_map([], |r| r.get(0))
.unwrap()
.map(Result::unwrap)
.collect();
v
};
assert_eq!(ratings, vec![1, 2]);
}
/// Runs on every catalog open, so a second pass must select nothing and
/// write nothing.
#[test]
fn realigning_twice_changes_nothing_the_second_time() {
let cat = with_remote_images(&[4_812]);
ensure_default_versions(cat.connection()).unwrap();
assert_eq!(
align_default_version_uuids(cat.connection()).unwrap(),
0,
"a freshly derived catalog must match nothing"
);
let before = default_uuids(&cat);
align_default_version_uuids(cat.connection()).unwrap();
assert_eq!(default_uuids(&cat), before);
}
/// A virtual copy (FR-CAT-12) that already holds the target uuid must not
/// take the backfill — and with it the catalog open — down with it.
#[test]
fn a_taken_target_leaves_the_row_where_it_is() {
let cat = with_remote_images(&[4_812]);
let c = cat.connection();
c.execute(
"INSERT INTO versions(image_id, uuid, name, is_default, rating, flag)
VALUES (1, 'random', 'Default', 1, 0, 0)",
[],
)
.unwrap();
c.execute(
"INSERT INTO versions(image_id, uuid, name, is_default, rating, flag)
VALUES (1, ?1, 'For print', 0, 0, 0)",
[derived_version_uuid(4_812)],
)
.unwrap();
assert_eq!(align_default_version_uuids(c).unwrap(), 0);
assert_eq!(default_uuids(&cat), vec!["random".to_string()]);
}
/// The backfill is what actually runs this, so it has to be wired in.
#[test]
fn opening_a_catalog_realigns_it() {
let cat = with_remote_images(&[4_812]);
cat.connection()
.execute(
"INSERT INTO versions(image_id, uuid, name, is_default, rating, flag)
VALUES (1, 'random', 'Default', 1, 3, 0)",
[],
)
.unwrap();
crate::schema::backfill(cat.connection()).unwrap();
assert_eq!(default_uuids(&cat), vec![derived_version_uuid(4_812)]);
}
}
+662
View File
@@ -0,0 +1,662 @@
//! TRACES: NFR-R2 | NFR-R6
//! What to do once the index is already damaged.
//!
//! # Why this can be a small module
//!
//! Because of a property the rest of the catalog was built to keep: the
//! catalog is an *index*, not a source of truth (ARCH §6.12, invariant
//! §5.2.4). Sidecars beside the images hold the authoritative ratings,
//! keywords and edit graphs, for every catalogued image and whether or not a
//! remote account exists (FR-CAT-8). So the worst outcome available here is a
//! rescan — expensive, but not a loss.
//!
//! That is the second offer. The first is cheaper and loses nothing at all: a
//! backup, restored.
//!
//! # The one thing a rebuild does not recover
//!
//! **Collections.** A manual collection is a set of images the user assembled
//! by hand and nothing in the filesystem records it (`docs/catalog.md` §8.1) —
//! which is the whole reason the catalog file itself syncs. So the two offers
//! are not interchangeable, and the interface must not present them as if they
//! were: a restore keeps the user's collections, a rebuild does not.
//!
//! # When the check runs, and when it does not
//!
//! [`integrity_check`] reads every page of the database. That is affordable
//! once, at startup, where a failure has a user in front of it who can answer
//! a question — and it is *not* affordable on every [`Catalog::open`], which
//! this application does per background task, dozens of times a session. So
//! the check is bound to [`Catalog::open_verified`] rather than to `open`,
//! and the cheap half of the story — classifying `SQLITE_CORRUPT` and
//! `SQLITE_NOTADB` as [`CatalogError::Corrupt`] — happens for free on every
//! query through [`crate::error`]'s conversion. A background job that trips
//! over the damage first therefore reports the same thing the startup check
//! would have.
//!
//! [`Catalog::open`]: crate::Catalog::open
//! [`Catalog::open_verified`]: crate::Catalog::open_verified
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use rusqlite::Connection;
use crate::error::CatalogError;
use crate::schema;
/// Directory backups live in, relative to the catalog file.
///
/// Beside the catalog rather than in the cache directory, and that is the
/// point of the choice: this is the copy the user falls back on, and a cache
/// is a place the operating system is entitled to empty without asking
/// (see `library::data_root` for the same reasoning about sidecars).
const BACKUP_DIR: &str = "backups";
/// How many backups are kept.
///
/// Small on purpose. A backup is a full copy of a catalog that is tens of
/// megabytes at 50k images, and the value of the third-oldest one is close to
/// zero: corruption is noticed at the next launch, not months later. What the
/// depth buys is protection against backing *up* the damage — if a corrupt
/// catalog is copied before anyone notices, the generation behind it is still
/// clean.
pub const KEEP_BACKUPS: usize = 3;
/// Suffix given to a catalog that has been set aside as damaged.
///
/// Kept rather than deleted. It costs disk this application would rather not
/// spend, and it is still the right call: `.sqlite` files have been recovered
/// by hand before, the user has not consented to a deletion, and NFR-R4's
/// instinct — never destroy what the user did not ask you to destroy — does
/// not stop applying at the catalog's edge.
const DAMAGED_SUFFIX: &str = "damaged";
/// One kept backup.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Backup {
pub path: PathBuf,
/// UTC seconds at which it was taken, read from the filename rather than
/// from the filesystem: a copy, a restore or a sync can rewrite an mtime,
/// and then the newest backup is not the one that looks newest.
pub taken_at: i64,
pub bytes: u64,
}
/// Where backups for `catalog` are kept.
pub fn backup_dir(catalog: &Path) -> PathBuf {
catalog
.parent()
.unwrap_or_else(|| Path::new("."))
.join(BACKUP_DIR)
}
/// Check the database this connection is attached to.
///
/// `quick_check` rather than `integrity_check`: the difference is that
/// `quick_check` skips verifying that every index agrees with its table, which
/// is the expensive half and the half this application least needs — every
/// index here is derivable, and `REINDEX` fixes one without anybody being
/// asked a question. What is left still reads every page, and catches the
/// damage that matters: torn b-trees, a bad freelist, a truncated file.
///
/// Returns [`CatalogError::Corrupt`] carrying what SQLite said, so the message
/// the user sees is the diagnosis rather than a paraphrase of it.
pub fn integrity_check(conn: &Connection) -> Result<(), CatalogError> {
// The argument caps how many problems are reported. One is enough: the
// answer is the same whether the file has one damaged page or nine
// hundred, and an unbounded check on a badly damaged file can run for a
// very long time producing a list nobody will read.
let mut stmt = conn.prepare("PRAGMA quick_check(1)")?;
let rows: Vec<String> = stmt
.query_map([], |r| r.get(0))?
.collect::<Result<Vec<_>, _>>()?;
// A healthy database answers with the single row "ok".
if rows.len() == 1 && rows[0] == "ok" {
return Ok(());
}
Err(CatalogError::Corrupt {
detail: rows.join("; "),
})
}
/// Check a catalog file that is not currently open.
///
/// Used before a restore: a backup is only worth swapping in if it is sound,
/// and swapping in a second damaged file — leaving the user with no catalog
/// and no offer left — is the failure this exists to prevent.
pub fn check_file(path: &Path) -> Result<(), CatalogError> {
if !path.is_file() {
return Err(CatalogError::Io(format!("{} is missing", path.display())));
}
// Read-write rather than read-only, which reads oddly for a check. A
// backup carries the WAL journal mode in its header because it was copied
// page-for-page from a WAL database, and SQLite cannot open one read-only
// without a shared-memory file it is then not allowed to create. Nothing
// here writes; the connection is opened, read, and dropped.
let conn = Connection::open(path)?;
integrity_check(&conn)
}
/// Take a backup of the open catalog.
///
/// Returns the file written. Older generations beyond [`KEEP_BACKUPS`] are
/// pruned, newest kept.
///
/// Goes through `crate::sync::copy_to` — SQLite's own backup API after a
/// TRUNCATE checkpoint — rather than copying the file. A WAL database is not
/// one file, and `fs::copy` of the main file alone would silently back up a
/// state that is older than the catalog and possibly torn, which is the one
/// failure mode a backup cannot afford.
pub fn backup(conn: &Connection, catalog: &Path) -> Result<PathBuf, CatalogError> {
let dir = backup_dir(catalog);
std::fs::create_dir_all(&dir)
.map_err(|e| CatalogError::Io(format!("creating {}: {e}", dir.display())))?;
let dest = dir.join(format!("catalog-{}.sqlite", now()));
// A second backup within the same second would otherwise land on the first
// one's name. Rare, and only reachable from tests and a retry, but the
// result would be a half-overwritten backup rather than two.
if dest.exists() {
std::fs::remove_file(&dest)
.map_err(|e| CatalogError::Io(format!("replacing {}: {e}", dest.display())))?;
}
// Dropped immediately: the copy is complete when `copy_to` returns, and
// holding the connection open would leave a `-wal` beside a file whose
// whole purpose is to be a single self-contained artefact.
drop(crate::sync::copy_to(conn, &dest)?);
prune(catalog);
Ok(dest)
}
/// Back up before a migration, if there is anything to back up.
///
/// Called from [`Catalog::open`](crate::Catalog::open) between `configure` and
/// `migrate`. NFR-R2 asks for this and the reasoning is narrower than "backups
/// are prudent": a migration is the one routine operation that rewrites table
/// structure, so it is the likeliest way a catalog becomes unreadable, and it
/// is the one moment where the pre-change state is still on disk to be copied.
/// Afterwards there is nothing left to take a copy *of*.
///
/// A no-op in the two cases where it would cost without buying anything: a
/// catalog already at [`schema::SCHEMA_VERSION`], and a brand-new file at
/// version 0 with no tables in it yet.
pub fn backup_before_migration(conn: &Connection, catalog: &Path) -> Result<(), CatalogError> {
let from: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
if from == 0 || from >= schema::SCHEMA_VERSION {
return Ok(());
}
let path = backup(conn, catalog)?;
log::info!(
"backed up catalog at v{from} to {} before migrating to v{}",
path.display(),
schema::SCHEMA_VERSION
);
Ok(())
}
/// The backups available for `catalog`, newest first.
///
/// Never fails: an unreadable or absent backup directory means there are no
/// backups, which is a fact about the offer to make rather than an error to
/// report on top of the corruption the user is already looking at.
pub fn backups(catalog: &Path) -> Vec<Backup> {
let dir = backup_dir(catalog);
let Ok(entries) = std::fs::read_dir(&dir) else {
return Vec::new();
};
let mut out: Vec<Backup> = entries
.flatten()
.filter_map(|e| {
let path = e.path();
let taken_at = timestamp_of(&path)?;
let bytes = e.metadata().ok()?.len();
Some(Backup {
path,
taken_at,
bytes,
})
})
.collect();
out.sort_by_key(|b| std::cmp::Reverse(b.taken_at));
out
}
/// Put a backup back in place of the damaged catalog.
///
/// **Every connection to `catalog` must be closed first.** This replaces the
/// file underneath anything still holding it open, which on a live connection
/// is how a *second* corrupt catalog gets made.
///
/// The order is deliberate:
///
/// 1. The backup is checked. A restore that installs a second damaged file
/// leaves the user with nothing to try next.
/// 2. The damaged catalog is renamed aside, and its `-wal` and `-shm` are
/// **deleted**. This is the step that is easy to leave out and fatal to
/// leave out: a journal belonging to the old file, sitting beside the new
/// one under the same name, is replayed into it on the next open. That is
/// not a restore, it is a fresh corruption with the evidence gone.
/// 3. The backup is *copied* into place, not moved, so a failure here can be
/// retried against the same backup.
pub fn restore(catalog: &Path, backup: &Path) -> Result<(), CatalogError> {
check_file(backup)?;
set_aside(catalog)?;
std::fs::copy(backup, catalog).map_err(|e| {
CatalogError::Io(format!(
"restoring {} from {}: {e}",
catalog.display(),
backup.display()
))
})?;
log::info!(
"restored {} from backup {}",
catalog.display(),
backup.display()
);
Ok(())
}
/// Move a damaged catalog out of the way so the next open builds a fresh one.
///
/// This is the rebuild path (NFR-R6's second offer) and also the first half of
/// a [`restore`]. Nothing else is needed to rebuild: the next
/// [`Catalog::open`](crate::Catalog::open) creates an empty catalog at the
/// current schema, and the ordinary scan repopulates it from sources and
/// sidecars — which is precisely invariant §5.2.4 being spent rather than
/// merely asserted.
///
/// Returns where the damaged file was put, or `None` if there was no catalog
/// to move — a caller may be recovering from a file SQLite could not open
/// because it was never created.
pub fn set_aside(catalog: &Path) -> Result<Option<PathBuf>, CatalogError> {
let moved = if catalog.exists() {
let dest = with_suffix(catalog, DAMAGED_SUFFIX);
// An earlier damaged copy is replaced rather than accumulating: two of
// these is two full-size catalogs on the user's disk, and the older
// one has already been superseded by a recovery the user completed.
let _ = std::fs::remove_file(&dest);
// The rename first, so that a failure here leaves the journals with
// the file they belong to rather than orphaned beside a catalog that
// is still in use.
std::fs::rename(catalog, &dest).map_err(|e| {
CatalogError::Io(format!(
"setting aside {} as {}: {e}",
catalog.display(),
dest.display()
))
})?;
log::warn!(
"catalog {} was damaged; kept as {}",
catalog.display(),
dest.display()
);
Some(dest)
} else {
None
};
// Then the journals, whether or not there was a catalog to move: a `-wal`
// orphaned beside a missing database is replayed into whatever takes that
// name next, which would not be a restore but a fresh corruption with the
// evidence gone.
for sidecar in journals(catalog) {
if let Err(e) = std::fs::remove_file(&sidecar) {
if e.kind() != std::io::ErrorKind::NotFound {
return Err(CatalogError::Io(format!(
"removing stale journal {}: {e}",
sidecar.display()
)));
}
}
}
Ok(moved)
}
/// Delete backups beyond [`KEEP_BACKUPS`].
///
/// Best-effort and silent about individual failures: failing to delete an old
/// backup is not a reason to fail the new one, which is already written.
fn prune(catalog: &Path) {
for old in backups(catalog).into_iter().skip(KEEP_BACKUPS) {
if let Err(e) = std::fs::remove_file(&old.path) {
log::warn!("could not prune backup {}: {e}", old.path.display());
}
}
}
/// The WAL and shared-memory files SQLite keeps beside a database.
fn journals(catalog: &Path) -> [PathBuf; 2] {
[with_suffix(catalog, "wal"), with_suffix(catalog, "shm")]
}
/// `catalog.sqlite` plus `-suffix`, the way SQLite names its own sidecars.
///
/// Appended to the whole filename rather than replacing the extension, so
/// `catalog.sqlite-wal` is what SQLite would look for and `catalog.sqlite-
/// damaged` sorts next to the catalog it came from.
fn with_suffix(catalog: &Path, suffix: &str) -> PathBuf {
let mut s = catalog.as_os_str().to_os_string();
s.push("-");
s.push(suffix);
PathBuf::from(s)
}
/// Read the timestamp out of a backup's filename, or `None` if this is not one.
///
/// Doubles as the filter that keeps [`backups`] from offering the user
/// something that is not a catalog — a stray file in the directory, or a `-wal`
/// left by a crash mid-backup.
fn timestamp_of(path: &Path) -> Option<i64> {
let name = path.file_name()?.to_str()?;
name.strip_prefix("catalog-")?
.strip_suffix(".sqlite")?
.parse()
.ok()
}
/// Seconds since the epoch, or 0 if the clock is before it.
fn now() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Catalog;
use std::io::{Seek, SeekFrom, Write};
/// A scratch directory that cleans up with the test.
fn tempdir(tag: &str) -> PathBuf {
let base = std::env::temp_dir().join(format!(
"dr-recovery-{tag}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
base
}
/// A catalog on disk with enough rows to span several pages, closed.
///
/// Closed matters: WAL means the rows are in `catalog.sqlite-wal` until
/// something checkpoints, and a test that corrupted the main file while
/// the data was still in the journal would be corrupting empty space.
fn fixture(path: &Path, images: i64) {
let cat = Catalog::open(path).unwrap();
let c = cat.connection();
c.execute(
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
[],
)
.unwrap();
c.execute(
"INSERT INTO collections(uuid, name, kind, created, revision, modified)
VALUES ('u1', 'Iceland', 0, 0, 1, 1)",
[],
)
.unwrap();
for i in 1..=images {
c.execute(
"INSERT INTO images(id, root_id, source_ref, added_at)
VALUES (?1, 1, ?2, 0)",
rusqlite::params![i, format!("DCIM/IMG_{i:05}.CR3")],
)
.unwrap();
}
crate::sync::checkpoint(c).unwrap();
drop(cat);
}
/// Scribble over everything past the first two pages.
///
/// Past them rather than over them so that page 1 — the header and the
/// schema — survives: this produces a file SQLite is willing to open and
/// then finds damaged, which is the case `quick_check` exists for. Wiping
/// the header instead produces `SQLITE_NOTADB` at the first pragma, which
/// is a different branch and has its own test.
fn corrupt(path: &Path) {
let mut f = std::fs::OpenOptions::new().write(true).open(path).unwrap();
let len = f.metadata().unwrap().len();
assert!(
len > 8192,
"fixture is only {len} bytes; corrupting past page 2 would be a no-op"
);
let junk = vec![0x5a_u8; (len - 8192) as usize];
f.seek(SeekFrom::Start(8192)).unwrap();
f.write_all(&junk).unwrap();
f.sync_all().unwrap();
}
#[test]
fn a_healthy_catalog_passes() {
let cat = Catalog::in_memory().unwrap();
integrity_check(cat.connection()).unwrap();
}
#[test]
fn a_corrupt_catalog_is_reported_as_corrupt_not_as_sqlite() {
// The whole point of the variant: this used to arrive as whatever
// rusqlite error the first failing query produced, with nowhere to
// hang a recovery offer.
let dir = tempdir("detect");
let path = dir.join("catalog.sqlite");
fixture(&path, 500);
corrupt(&path);
assert!(matches!(
Catalog::open_verified(&path),
Err(CatalogError::Corrupt { .. })
));
}
#[test]
fn a_file_that_is_not_a_database_is_also_corrupt() {
// A truncated or overwritten catalog never reaches `quick_check`: the
// first pragma fails with SQLITE_NOTADB. Same accident to the user,
// same two offers, so it must classify the same way.
let dir = tempdir("notadb");
let path = dir.join("catalog.sqlite");
std::fs::write(&path, b"this is not a catalog, it is a text file\n").unwrap();
assert!(matches!(
Catalog::open(&path),
Err(CatalogError::Corrupt { .. })
));
}
#[test]
fn restoring_a_backup_recovers_the_collections_a_rebuild_would_lose() {
// The first NFR-R6 branch, asserted on the thing that distinguishes it
// from the second: a collection exists nowhere but the catalog, so it
// is the evidence that the *contents* came back and not merely a
// readable file (docs/catalog.md §8.1).
let dir = tempdir("restore");
let path = dir.join("catalog.sqlite");
fixture(&path, 500);
{
let cat = Catalog::open(&path).unwrap();
backup(cat.connection(), &path).unwrap();
}
corrupt(&path);
assert!(matches!(
Catalog::open_verified(&path),
Err(CatalogError::Corrupt { .. })
));
let newest = backups(&path).into_iter().next().expect("a backup exists");
restore(&path, &newest.path).unwrap();
let cat = Catalog::open_verified(&path).unwrap();
let name: String = cat
.connection()
.query_row("SELECT name FROM collections", [], |r| r.get(0))
.unwrap();
assert_eq!(name, "Iceland");
let images: i64 = cat
.connection()
.query_row("SELECT count(*) FROM images", [], |r| r.get(0))
.unwrap();
assert_eq!(images, 500);
}
#[test]
fn a_damaged_backup_is_refused_rather_than_installed() {
let dir = tempdir("badbackup");
let path = dir.join("catalog.sqlite");
fixture(&path, 500);
{
let cat = Catalog::open(&path).unwrap();
backup(cat.connection(), &path).unwrap();
}
let newest = backups(&path).into_iter().next().unwrap();
corrupt(&newest.path);
corrupt(&path);
assert!(matches!(
restore(&path, &newest.path),
Err(CatalogError::Corrupt { .. })
));
// And the damaged catalog is still where it was, so the second offer
// is still available.
assert!(path.exists());
}
#[test]
fn setting_aside_leaves_a_fresh_catalog_to_rebuild_into() {
// The second NFR-R6 branch. What makes it a rebuild rather than a data
// loss is invariant §5.2.4, which lives outside this crate — what is
// testable here is that the damaged file is out of the way, kept, and
// that the next open succeeds on an empty catalog at the current
// schema, which is what a scan then fills.
let dir = tempdir("rebuild");
let path = dir.join("catalog.sqlite");
fixture(&path, 500);
corrupt(&path);
let kept = set_aside(&path).unwrap().expect("the catalog was there");
assert!(kept.exists(), "the damaged catalog was deleted, not kept");
assert!(!path.exists());
let cat = Catalog::open_verified(&path).unwrap();
let images: i64 = cat
.connection()
.query_row("SELECT count(*) FROM images", [], |r| r.get(0))
.unwrap();
assert_eq!(images, 0);
let v: i64 = cat
.connection()
.query_row("PRAGMA user_version", [], |r| r.get(0))
.unwrap();
assert_eq!(v, schema::SCHEMA_VERSION);
}
#[test]
fn a_stale_journal_does_not_follow_the_catalog_into_recovery() {
// The step that is easy to omit: a `-wal` belonging to the damaged
// file is replayed into whatever takes its name next.
let dir = tempdir("journal");
let path = dir.join("catalog.sqlite");
fixture(&path, 500);
std::fs::write(with_suffix(&path, "wal"), b"stale").unwrap();
set_aside(&path).unwrap();
assert!(!with_suffix(&path, "wal").exists());
}
#[test]
fn a_migration_is_backed_up_before_it_runs() {
// NFR-R2's second clause, against a real v1 catalog rather than a
// faked version number: the point is not that *a* file appears but
// that it holds the state from before the migration, which is the only
// state that is any use if the migration is what breaks it.
let dir = tempdir("premigrate");
let path = dir.join("catalog.sqlite");
{
let c = Connection::open(&path).unwrap();
schema::configure(&c).unwrap();
// `v1_for_attached` names the schema it targets, and "main" is a
// schema like any other — so this is the real v1, without needing
// `V1` itself to become visible outside its module.
c.execute_batch(&schema::v1_for_attached("main")).unwrap();
c.pragma_update(None, "user_version", 1).unwrap();
c.execute(
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
[],
)
.unwrap();
crate::sync::checkpoint(&c).unwrap();
}
assert!(backups(&path).is_empty());
Catalog::open(&path).unwrap();
let taken = backups(&path);
assert_eq!(taken.len(), 1, "no backup was taken before the migration");
check_file(&taken[0].path).unwrap();
let kept = Connection::open(&taken[0].path).unwrap();
let v: i64 = kept
.query_row("PRAGMA user_version", [], |r| r.get(0))
.unwrap();
assert_eq!(v, 1, "the backup was taken after the migration, not before");
}
#[test]
fn opening_an_up_to_date_catalog_takes_no_backup() {
// Or every background task that opens the catalog would copy it.
let dir = tempdir("nobackup");
let path = dir.join("catalog.sqlite");
fixture(&path, 10);
Catalog::open(&path).unwrap();
assert!(backups(&path).is_empty());
}
#[test]
fn only_the_newest_generations_are_kept() {
let dir = tempdir("prune");
let path = dir.join("catalog.sqlite");
fixture(&path, 10);
let cat = Catalog::open(&path).unwrap();
// Written by hand rather than by calling `backup` in a loop: the
// filename carries whole seconds, so real calls would collide.
std::fs::create_dir_all(backup_dir(&path)).unwrap();
for t in 1..=KEEP_BACKUPS as i64 + 2 {
drop(
crate::sync::copy_to(
cat.connection(),
&backup_dir(&path).join(format!("catalog-{t}.sqlite")),
)
.unwrap(),
);
}
prune(&path);
let kept = backups(&path);
assert_eq!(kept.len(), KEEP_BACKUPS);
// Newest first, and the newest is the highest timestamp.
assert_eq!(kept[0].taken_at, KEEP_BACKUPS as i64 + 2);
}
#[test]
fn a_stray_file_in_the_backup_directory_is_not_offered_as_one() {
let dir = tempdir("stray");
let path = dir.join("catalog.sqlite");
fixture(&path, 10);
std::fs::create_dir_all(backup_dir(&path)).unwrap();
std::fs::write(backup_dir(&path).join("notes.txt"), b"hello").unwrap();
std::fs::write(backup_dir(&path).join("catalog-7.sqlite-wal"), b"x").unwrap();
assert!(backups(&path).is_empty());
}
}
+935
View File
@@ -0,0 +1,935 @@
//! TRACES: FR-PLAT-AND-4 | FR-PLAT-AND-3
//! The thing that drains the queue.
//!
//! [`crate::jobs`] has been a complete, durable, coalescing work queue since
//! the catalog was written, and nothing has ever taken a job out of it. Every
//! producer — the local walk, the remote scan — called `enqueue` and no one
//! called `claim_next`, so the table grew one row per photograph and stayed
//! that size forever. This module is the missing half.
//!
//! # Why the runner is driven rather than self-owning
//!
//! The obvious shape is a thread that loops until the queue is empty, and it
//! is the wrong one. On Android the process does not decide when background
//! work may run: `WorkManager` does, subject to Doze, battery saver and the
//! metered-network constraints in FR-NC-6, and it revokes permission mid-job
//! by calling `onStopped()` (FR-PLAT-AND-4). A foreground service for a
//! user-initiated export gets a longer leash but still not an unbounded one.
//!
//! So the runner owns no thread, no clock and no policy. It exposes
//! [`Runner::run_one`] — claim one job, run it, record what happened — and
//! [`Runner::drain`], which repeats that against a [`Budget`] and a
//! cancellation flag the host owns. A `Worker.doWork()` that must return
//! within ten minutes calls `drain` with a deadline; a desktop idle loop calls
//! it with none. Neither has to reach inside.
//!
//! Everything the host supplies is passed in for the same reason `jobs` takes
//! `now` rather than reading the clock: a scheduler is exactly the thing that
//! has to be testable without waiting.
//!
//! # Why interruption is not failure
//!
//! Four things can happen to a claimed job, and only two of them are the job's
//! fault:
//!
//! - [`Outcome::Done`] — the row is deleted.
//! - [`Outcome::Retry`] — the work failed and might succeed later. Backoff,
//! and eventually [`crate::jobs::MAX_ATTEMPTS`] gives up on it.
//! - [`Outcome::Abandon`] — the work cannot succeed, ever. Failing five times
//! over five minutes to learn that is five minutes of a phone's battery.
//! - [`Outcome::Interrupted`] — the *host* stopped, not the job. The claim is
//! released and the attempt it consumed is given back, because a user who
//! pulled the app off the screen has not told us anything about the file.
//!
//! Process death is the fifth case and the one that cannot report itself: the
//! row simply stays `Running` with no owner. [`Runner::recover`] is what
//! reclaims it, and it is why an interrupted job is resumable rather than lost
//! (FR-PLAT-AND-3). It must run **before** any worker starts against a
//! catalog, or it will steal a job another runner is holding — there is no
//! owner column to tell them apart.
use std::sync::atomic::{AtomicBool, Ordering};
use rusqlite::Connection;
use crate::error::CatalogError;
use crate::jobs::{self, Job, JobKind};
/// What running a job turned out to be.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
/// The work is done. The row goes away.
Done,
/// It failed, and trying again later is reasonable. Backoff applies, and
/// [`crate::jobs::MAX_ATTEMPTS`] eventually stops it.
Retry(String),
/// It failed in a way no retry can fix — the subject is gone, the payload
/// is unreadable, the format is one this build does not know. Marked
/// failed at once rather than burning the whole retry ladder to reach the
/// same answer.
Abandon(String),
/// The host is stopping, and the job never really ran.
///
/// Distinct from `Retry` because it costs no attempt: `onStopped()` five
/// times in a row would otherwise mark a perfectly good job as failed.
Interrupted,
}
/// Something that can actually do the work a job describes.
///
/// The catalog knows what needs doing and nothing about how — a thumbnail
/// needs a decoder, a fetch needs a network stack, and neither belongs under
/// `core/dr-catalog` (ARCH §4.1: calls go downward). So the queue lives here
/// and the handlers are supplied from above.
pub trait JobHandler {
/// The kinds this handler will accept.
///
/// Load-bearing, not documentation: the runner claims **only** kinds some
/// handler declares. A queue holding `FetchOriginal` rows on a device with
/// no connector must leave them alone rather than claim them and fail
/// them, and a runner that claimed everything would do exactly that — five
/// times each, with backoff, on battery.
fn kinds(&self) -> &[JobKind];
/// Do the work.
///
/// The connection is offered because most handlers write their result back
/// into the catalog; one that does not is free to ignore it. It is the
/// runner's own connection, so a handler must not hold a transaction open
/// across a network call — the runner needs it back to record the outcome.
fn run(&mut self, conn: &Connection, job: &Job) -> Outcome;
}
/// How much work a host is willing to let one drain do.
///
/// Both limits are checked *before* a job is claimed, never during one: a
/// handler is opaque and may be halfway through writing a sidecar. Overrunning
/// a deadline by one job is survivable; being killed mid-write is the thing
/// [`Runner::recover`] exists to clean up after.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct Budget {
/// Stop after this many jobs. `None` means "until the queue is empty".
pub max_jobs: Option<usize>,
/// Stop once the clock reaches this second. Same clock the drain is given.
pub deadline: Option<i64>,
}
impl Budget {
/// Run until nothing is left. What a desktop idle pass wants.
pub const UNLIMITED: Self = Self {
max_jobs: None,
deadline: None,
};
/// At most `n` jobs. A slice small enough to stay responsive.
pub fn jobs(n: usize) -> Self {
Self {
max_jobs: Some(n),
..Self::UNLIMITED
}
}
/// Until the clock reaches `deadline`. What a `WorkManager` slot wants.
pub fn until(deadline: i64) -> Self {
Self {
deadline: Some(deadline),
..Self::UNLIMITED
}
}
}
/// Why a drain stopped.
///
/// Worth distinguishing because the host's next move differs: `Drained` means
/// there is nothing to reschedule for, and the other three all mean "there is
/// more, ask again".
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum Stopped {
/// Nothing claimable is left.
#[default]
Drained,
/// The job count ran out.
Budget,
/// The clock ran out.
Deadline,
/// The host asked it to stop, or a handler reported itself interrupted.
Cancelled,
}
impl Stopped {
/// Whether the queue may still hold claimable work.
pub fn more_to_do(self) -> bool {
self != Stopped::Drained
}
}
/// What one drain did.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct DrainReport {
pub completed: usize,
pub retried: usize,
pub abandoned: usize,
pub interrupted: usize,
pub stopped: Stopped,
}
impl DrainReport {
/// Jobs claimed, whatever became of them. This is what a budget counts.
pub fn ran(&self) -> usize {
self.completed + self.retried + self.abandoned + self.interrupted
}
}
/// What a recovery pass found waiting from the last run.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct Recovered {
/// Jobs a dead process was holding. These are the resumed ones.
pub reclaimed: usize,
/// Jobs deleted because the photograph they name no longer exists.
pub reaped: usize,
}
impl Recovered {
pub fn did_anything(&self) -> bool {
self.reclaimed > 0 || self.reaped > 0
}
}
/// Ready the queue for a fresh run, before any worker touches it.
///
/// Two distinct cleanups, and both are startup-only:
///
/// - **Reclaim.** A `Running` row has no owner; the process that claimed it is
/// gone. On Android that is a routine morning, not a crash (FR-PLAT-AND-3).
/// The attempt it consumed is *kept*, deliberately: a job that takes the
/// process down with it three times running should not be retried forever,
/// and the attempt counter is the only evidence of that we have.
/// - **Reap.** Jobs naming an image the catalog no longer has. A library that
/// has been culled leaves thumbnail jobs for photographs that were deleted
/// months ago, and every one of them would be claimed, run and failed.
///
/// Reclaim runs first so its count is the honest number of interrupted jobs,
/// before reaping removes whichever of them pointed at nothing.
///
/// **Call this exactly once per catalog, at startup.** It cannot distinguish a
/// job a dead process was holding from one a live runner is holding right now,
/// because there is no owner column — the queue is durable, not distributed.
pub fn recover(conn: &Connection) -> Result<Recovered, CatalogError> {
Ok(Recovered {
reclaimed: jobs::recover_orphaned(conn)?,
reaped: jobs::reap_orphan_subjects(conn)?,
})
}
/// Claims work, runs it, and records what happened.
///
/// Borrows its connection rather than owning one so a host can drive it from
/// the same handle it already has open. Nothing here spawns a thread; several
/// runners on several threads, each with its own connection to the same
/// catalog, are safe because the claim is a single atomic statement (see
/// [`crate::jobs::claim_next`]).
pub struct Runner<'a> {
conn: &'a Connection,
handlers: Vec<Box<dyn JobHandler + 'a>>,
/// The union of every handler's kinds, cached because it is passed to
/// every claim. This is what stops the runner claiming work it cannot do.
claimable: Vec<JobKind>,
}
impl<'a> Runner<'a> {
/// A runner with no handlers. It can recover, and it can claim nothing.
pub fn new(conn: &'a Connection) -> Self {
Self {
conn,
handlers: Vec::new(),
claimable: Vec::new(),
}
}
/// Add a handler.
pub fn with(self, handler: impl JobHandler + 'a) -> Self {
self.with_boxed(Box::new(handler))
}
/// Add a handler chosen at runtime — a network one only where there is a
/// connector, a decoding one only where there is a decoder.
pub fn with_boxed(mut self, handler: Box<dyn JobHandler + 'a>) -> Self {
for kind in handler.kinds() {
if !self.claimable.contains(kind) {
self.claimable.push(*kind);
}
}
self.handlers.push(handler);
self
}
/// The kinds this runner will claim. Useful to a host deciding whether
/// starting it is worth waking the radio for.
pub fn claimable(&self) -> &[JobKind] {
&self.claimable
}
/// See [`recover`]. Offered here too so a host has one thing to hold.
pub fn recover(&self) -> Result<Recovered, CatalogError> {
recover(self.conn)
}
/// Claim one job, run it, and record the outcome.
///
/// `Ok(None)` means nothing this runner can do is claimable *now* — the
/// queue may still hold work of other kinds, or work still backing off.
pub fn run_one(&mut self, now: i64) -> Result<Option<Ran>, CatalogError> {
// Copied out before `self.handlers` is borrowed mutably below. Both
// are fields of `self`, but the copy is what lets the two borrows
// coexist without the connection being reborrowed through `self`.
let conn = self.conn;
let Some(job) = jobs::claim_next_matching(conn, now, &self.claimable)? else {
return Ok(None);
};
let outcome = match self
.handlers
.iter_mut()
.find(|h| h.kinds().contains(&job.kind))
{
Some(handler) => handler.run(conn, &job),
// Unreachable by construction: `claimable` is exactly the union of
// the handlers' kinds. Parked rather than released, because
// releasing it would put it straight back where the next turn of
// the drain loop would claim it again, forever.
None => Outcome::Abandon(format!("no handler for {:?}", job.kind)),
};
match &outcome {
Outcome::Done => jobs::complete(conn, job.id)?,
Outcome::Retry(why) => jobs::fail(conn, &job, now, why)?,
Outcome::Abandon(why) => jobs::abandon(conn, job.id, why)?,
Outcome::Interrupted => jobs::release(conn, &job)?,
}
Ok(Some(Ran { job, outcome }))
}
/// Run jobs until the budget, the flag or the queue says stop.
///
/// `clock` is called once per iteration rather than sampled once, because
/// the two things it feeds both move during a long drain: the deadline
/// check, and the `now` a failing job's backoff is measured from.
///
/// Cancellation is checked between jobs only. A handler that wants to bail
/// out of work already started says so with [`Outcome::Interrupted`],
/// which also ends the drain — otherwise a handler that always interrupts
/// would release its job and be handed it straight back.
pub fn drain(
&mut self,
clock: &dyn Fn() -> i64,
budget: Budget,
cancel: &AtomicBool,
) -> Result<DrainReport, CatalogError> {
let mut report = DrainReport::default();
loop {
// Relaxed: the flag is a one-way latch set by another thread and
// the only thing ordered against it is our own next claim. Missing
// one turn of the loop costs a job, not correctness.
if cancel.load(Ordering::Relaxed) {
report.stopped = Stopped::Cancelled;
break;
}
if budget.max_jobs.is_some_and(|max| report.ran() >= max) {
report.stopped = Stopped::Budget;
break;
}
let now = clock();
if budget.deadline.is_some_and(|end| now >= end) {
report.stopped = Stopped::Deadline;
break;
}
let Some(ran) = self.run_one(now)? else {
report.stopped = Stopped::Drained;
break;
};
match ran.outcome {
Outcome::Done => report.completed += 1,
Outcome::Retry(_) => report.retried += 1,
Outcome::Abandon(_) => report.abandoned += 1,
Outcome::Interrupted => {
report.interrupted += 1;
report.stopped = Stopped::Cancelled;
break;
}
}
}
Ok(report)
}
/// Drain with no budget and no cancellation, at a fixed instant.
///
/// Terminates because a job that fails is pushed past `now` by its backoff
/// and stops being claimable at this instant.
pub fn drain_all(&mut self, now: i64) -> Result<DrainReport, CatalogError> {
static NEVER: AtomicBool = AtomicBool::new(false);
self.drain(&|| now, Budget::UNLIMITED, &NEVER)
}
}
/// One job and what became of it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Ran {
pub job: Job,
pub outcome: Outcome,
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use super::*;
use crate::jobs::{enqueue, JobState, Priority, MAX_ATTEMPTS};
use crate::schema;
/// A handler built from a closure, so each test states its own behaviour.
struct Fake<F> {
kinds: Vec<JobKind>,
act: F,
}
impl<F: FnMut(&Job) -> Outcome> JobHandler for Fake<F> {
fn kinds(&self) -> &[JobKind] {
&self.kinds
}
fn run(&mut self, _conn: &Connection, job: &Job) -> Outcome {
(self.act)(job)
}
}
fn handler<F: FnMut(&Job) -> Outcome>(kinds: &[JobKind], act: F) -> Fake<F> {
Fake {
kinds: kinds.to_vec(),
act,
}
}
/// A handler that records which subjects it saw and always succeeds.
///
/// Takes its kinds by value and borrows nothing, so the returned handler is
/// `Send + 'static` and can be moved into a worker thread — which the
/// contention test needs.
fn recording(kinds: Vec<JobKind>, seen: Arc<Mutex<Vec<i64>>>) -> impl JobHandler + Send {
Fake {
kinds,
act: move |job: &Job| {
seen.lock().unwrap().push(job.subject_id.unwrap_or(-1));
Outcome::Done
},
}
}
fn db() -> Connection {
let c = Connection::open_in_memory().unwrap();
schema::configure(&c).unwrap();
schema::migrate(&c).unwrap();
c
}
/// An image row, so a job has a subject that exists.
fn image(c: &Connection, id: i64) {
c.execute(
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', '/lib')
ON CONFLICT DO NOTHING",
[],
)
.unwrap();
c.execute(
"INSERT INTO images(id, root_id, source_ref, added_at) VALUES (?1, 1, ?2, 0)",
rusqlite::params![id, format!("/lib/{id}.CR3")],
)
.unwrap();
}
fn queued(c: &Connection, kind: JobKind, subject: i64) {
image(c, subject);
enqueue(c, kind, Some(subject), Priority::Background, None).unwrap();
}
fn rows(c: &Connection) -> i64 {
c.query_row("SELECT count(*) FROM jobs", [], |r| r.get(0))
.unwrap()
}
/// A catalog on disk, so more than one connection can open it.
fn temp_catalog(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"dr-runner-{name}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir.join("catalog.db")
}
fn open(path: &Path) -> Connection {
let c = Connection::open(path).unwrap();
schema::configure(&c).unwrap();
schema::migrate(&c).unwrap();
// Several connections write to this file at once in the contention
// tests. Without a busy handler the loser of a race gets an error
// instead of a turn.
c.busy_timeout(std::time::Duration::from_secs(10)).unwrap();
c
}
#[test]
fn a_completed_job_leaves_the_queue() {
// The whole finding in one assertion: before this module, the row
// stayed forever because nothing ever claimed it.
let c = db();
queued(&c, JobKind::Thumbnail, 1);
let seen = Arc::new(Mutex::new(Vec::new()));
let report = Runner::new(&c)
.with(recording(vec![JobKind::Thumbnail], seen.clone()))
.drain_all(0)
.unwrap();
assert_eq!(report.completed, 1);
assert_eq!(report.stopped, Stopped::Drained);
assert_eq!(*seen.lock().unwrap(), vec![1]);
assert_eq!(rows(&c), 0, "a completed job leaves no row behind");
}
#[test]
fn a_failed_job_backs_off_and_is_claimed_again_later() {
let c = db();
queued(&c, JobKind::Thumbnail, 1);
// A `Cell` rather than a captured `bool`, so the closure's mutability
// is its own business and the test reads the same either way.
let failed_once = std::cell::Cell::new(false);
let mut runner = Runner::new(&c).with(handler(&[JobKind::Thumbnail], |_| {
if failed_once.replace(true) {
Outcome::Done
} else {
Outcome::Retry("decoder said no".into())
}
}));
let first = runner.drain_all(100).unwrap();
assert_eq!(first.retried, 1);
assert_eq!(rows(&c), 1, "a retryable failure keeps its row");
// Still inside the backoff window: nothing claimable, so the drain
// reports itself drained rather than spinning on the same job.
assert_eq!(runner.drain_all(100).unwrap().ran(), 0);
let later = runner.drain_all(100 + jobs::backoff_seconds(1)).unwrap();
assert_eq!(later.completed, 1);
assert_eq!(rows(&c), 0);
}
#[test]
fn a_job_that_keeps_failing_is_given_up_on() {
// FR-RAW-4: one corrupt file must not stall the queue behind endless
// retries. Driven through the runner rather than by hand, because the
// runner is what a corrupt file will actually meet.
let c = db();
queued(&c, JobKind::ExtractMetadata, 1);
let mut runner = Runner::new(&c).with(handler(&[JobKind::ExtractMetadata], |_| {
Outcome::Retry("corrupt file".into())
}));
let mut now = 0;
for _ in 0..MAX_ATTEMPTS {
assert_eq!(runner.drain_all(now).unwrap().retried, 1);
now += jobs::backoff_seconds(MAX_ATTEMPTS);
}
assert_eq!(runner.drain_all(now + 100_000).unwrap().ran(), 0);
let state: i64 = c
.query_row("SELECT state FROM jobs", [], |r| r.get(0))
.unwrap();
assert_eq!(state, JobState::Failed as i64);
}
#[test]
fn an_abandoned_job_is_not_retried_at_all() {
// The difference that matters on battery: five failures spread over
// five minutes to learn what the first one already said.
let c = db();
queued(&c, JobKind::FetchOriginal, 1);
let mut runner = Runner::new(&c).with(handler(&[JobKind::FetchOriginal], |_| {
Outcome::Abandon("no connector on this device".into())
}));
assert_eq!(runner.drain_all(0).unwrap().abandoned, 1);
// One attempt, not MAX_ATTEMPTS, and never claimable again.
assert_eq!(runner.drain_all(1_000_000).unwrap().ran(), 0);
let (state, attempts): (i64, i64) = c
.query_row("SELECT state, attempts FROM jobs", [], |r| {
Ok((r.get(0)?, r.get(1)?))
})
.unwrap();
assert_eq!(state, JobState::Failed as i64);
assert_eq!(attempts, 1);
}
#[test]
fn an_interrupted_job_costs_no_attempt_and_ends_the_drain() {
// `onStopped()` says nothing about the file. Charging it an attempt
// would let five backgroundings mark good work as failed.
let c = db();
queued(&c, JobKind::Thumbnail, 1);
queued(&c, JobKind::Thumbnail, 2);
let report = Runner::new(&c)
.with(handler(&[JobKind::Thumbnail], |_| Outcome::Interrupted))
.drain_all(0)
.unwrap();
assert_eq!(report.interrupted, 1);
assert_eq!(
report.stopped,
Stopped::Cancelled,
"an interrupted job must end the drain, or releasing it hands it \
straight back and the loop never ends"
);
let (state, attempts): (i64, i64) = c
.query_row(
"SELECT state, attempts FROM jobs WHERE subject_id = 1",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(state, JobState::Pending as i64);
assert_eq!(attempts, 0, "the claim's speculative attempt is given back");
}
#[test]
fn only_kinds_a_handler_covers_are_claimed() {
// A device with no connector must leave `FetchOriginal` where it is.
// Claiming it to fail it would cost five attempts and five backoffs
// per photograph, on battery, to reach a conclusion known in advance.
let c = db();
queued(&c, JobKind::Thumbnail, 1);
queued(&c, JobKind::FetchOriginal, 2);
let seen = Arc::new(Mutex::new(Vec::new()));
let report = Runner::new(&c)
.with(recording(vec![JobKind::Thumbnail], seen.clone()))
.drain_all(0)
.unwrap();
assert_eq!(report.completed, 1);
assert_eq!(*seen.lock().unwrap(), vec![1]);
let (state, attempts): (i64, i64) = c
.query_row(
"SELECT state, attempts FROM jobs WHERE subject_id = 2",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(state, JobState::Pending as i64);
assert_eq!(attempts, 0, "an unhandled job is untouched, not failed");
}
#[test]
fn a_runner_with_no_handlers_claims_nothing() {
let c = db();
queued(&c, JobKind::Thumbnail, 1);
assert_eq!(Runner::new(&c).drain_all(0).unwrap().ran(), 0);
assert_eq!(rows(&c), 1);
}
#[test]
fn a_drain_stops_at_its_job_budget() {
let c = db();
for id in 1..=5 {
queued(&c, JobKind::Thumbnail, id);
}
let seen = Arc::new(Mutex::new(Vec::new()));
let mut runner = Runner::new(&c).with(recording(vec![JobKind::Thumbnail], seen.clone()));
let report = runner
.drain(&|| 0, Budget::jobs(2), &AtomicBool::new(false))
.unwrap();
assert_eq!(report.completed, 2);
assert_eq!(report.stopped, Stopped::Budget);
assert!(report.stopped.more_to_do());
assert_eq!(rows(&c), 3, "the rest is still queued for the next slot");
assert_eq!(seen.lock().unwrap().len(), 2);
}
#[test]
fn a_drain_stops_at_its_deadline() {
// What a `WorkManager` slot does: a fixed window, and whatever did not
// fit stays queued for the next one.
let c = db();
for id in 1..=5 {
queued(&c, JobKind::Thumbnail, id);
}
// A clock that advances a second per reading, so the deadline arrives
// without the test sleeping.
let tick = std::cell::Cell::new(0i64);
let clock = || {
let t = tick.get();
tick.set(t + 1);
t
};
let report = Runner::new(&c)
.with(handler(&[JobKind::Thumbnail], |_| Outcome::Done))
.drain(&clock, Budget::until(3), &AtomicBool::new(false))
.unwrap();
assert_eq!(report.stopped, Stopped::Deadline);
assert_eq!(report.completed, 3, "one job per second up to the deadline");
assert_eq!(rows(&c), 2);
}
#[test]
fn a_cancelled_drain_stops_between_jobs() {
let c = db();
for id in 1..=5 {
queued(&c, JobKind::Thumbnail, id);
}
let cancel = AtomicBool::new(false);
// Cancelled from inside the handler, standing in for the host thread
// setting the flag while a job is in flight: the job in hand finishes,
// and nothing further is claimed.
let report = Runner::new(&c)
.with(handler(&[JobKind::Thumbnail], |_| {
cancel.store(true, Ordering::Relaxed);
Outcome::Done
}))
.drain(&|| 0, Budget::UNLIMITED, &cancel)
.unwrap();
assert_eq!(report.completed, 1);
assert_eq!(report.stopped, Stopped::Cancelled);
assert_eq!(rows(&c), 4, "the work is kept, not lost");
}
#[test]
fn a_job_interrupted_by_process_death_is_reclaimed_and_run_once() {
// FR-PLAT-AND-3. The kill happens between the claim and the outcome,
// which is the window a durable queue exists to survive: no `complete`,
// no `fail`, just a row marked `Running` with nobody holding it.
let c = db();
queued(&c, JobKind::Thumbnail, 1);
// The dead process. It claimed the job and never came back.
let claimed = jobs::claim_next(&c, 0).unwrap().expect("claimable");
assert_eq!(claimed.subject_id, Some(1));
// A fresh runner, before it starts, finds the queue empty — the row is
// `Running` and no claim will touch it.
let seen = Arc::new(Mutex::new(Vec::new()));
let mut runner = Runner::new(&c).with(recording(vec![JobKind::Thumbnail], seen.clone()));
assert_eq!(
runner.drain_all(0).unwrap().ran(),
0,
"an orphan is invisible until it is recovered — which is exactly \
why recovery has to happen at startup"
);
let recovered = runner.recover().unwrap();
assert_eq!(recovered.reclaimed, 1);
let report = runner.drain_all(0).unwrap();
assert_eq!(report.completed, 1);
assert_eq!(
*seen.lock().unwrap(),
vec![1],
"resumed, not repeated and not lost"
);
assert_eq!(rows(&c), 0);
}
#[test]
fn a_crash_still_costs_an_attempt() {
// Deliberate: a job that takes the process down with it every time is
// indistinguishable from one that fails, and the attempt counter is
// the only evidence we keep across a death. Without this a poison-pill
// job would be reclaimed and re-run forever.
let c = db();
queued(&c, JobKind::Thumbnail, 1);
for _ in 0..MAX_ATTEMPTS {
jobs::claim_next(&c, 0).unwrap().expect("claimable");
recover(&c).unwrap();
}
let job = jobs::claim_next(&c, 0).unwrap().unwrap();
assert!(job.attempts > MAX_ATTEMPTS);
jobs::fail(&c, &job, 0, "died again").unwrap();
let state: i64 = c
.query_row("SELECT state FROM jobs", [], |r| r.get(0))
.unwrap();
assert_eq!(state, JobState::Failed as i64);
}
#[test]
fn recovery_drops_jobs_whose_photograph_is_gone() {
// A culled library leaves thumbnail jobs for images deleted months
// ago. Every one would be claimed, run and failed.
let c = db();
queued(&c, JobKind::Thumbnail, 1);
queued(&c, JobKind::Thumbnail, 2);
c.execute("DELETE FROM images WHERE id = 2", []).unwrap();
let recovered = recover(&c).unwrap();
assert_eq!(recovered.reaped, 1);
assert!(recovered.did_anything());
let left: i64 = c
.query_row("SELECT subject_id FROM jobs", [], |r| r.get(0))
.unwrap();
assert_eq!(left, 1, "only the job whose subject survives is kept");
}
#[test]
fn a_quiet_startup_recovers_nothing() {
let c = db();
queued(&c, JobKind::Thumbnail, 1);
assert_eq!(recover(&c).unwrap(), Recovered::default());
assert!(!recover(&c).unwrap().did_anything());
}
#[test]
fn two_runners_on_one_catalog_never_take_the_same_job() {
// Sequential rather than threaded, so the property is asserted without
// depending on the scheduler: whatever the second connection claims,
// it is not what the first one is holding.
let path = temp_catalog("contention-pair");
let a = open(&path);
let b = open(&path);
for id in 1..=2 {
queued(&a, JobKind::Thumbnail, id);
}
let first = jobs::claim_next(&a, 0).unwrap().expect("one for A");
let second = jobs::claim_next(&b, 0).unwrap().expect("one for B");
assert_ne!(first.id, second.id);
assert!(
jobs::claim_next(&a, 0).unwrap().is_none(),
"a claimed job is invisible to every connection, not just its own"
);
}
#[test]
fn concurrent_runners_share_the_queue_without_repeating_work() {
// The claim is one atomic statement precisely so this holds: four
// threads, four connections, and every job run exactly once.
const THREADS: usize = 4;
const JOBS: i64 = 24;
let path = temp_catalog("contention-threads");
let seeder = open(&path);
for id in 1..=JOBS {
queued(&seeder, JobKind::Thumbnail, id);
}
drop(seeder);
let seen = Arc::new(Mutex::new(Vec::new()));
let mut threads = Vec::new();
for _ in 0..THREADS {
let path = path.clone();
let seen = seen.clone();
threads.push(std::thread::spawn(move || {
let conn = open(&path);
// Bound to a local rather than left as the block's tail: the
// `Runner` borrows `conn`, and a tail expression's temporaries
// are dropped *after* the block's locals, so the borrow would
// outlive what it borrows.
let completed = Runner::new(&conn)
.with(recording(vec![JobKind::Thumbnail], seen))
.drain_all(0)
.unwrap()
.completed;
completed
}));
}
let completed: usize = threads.into_iter().map(|t| t.join().unwrap()).sum();
assert_eq!(completed, JOBS as usize);
let mut ran = seen.lock().unwrap().clone();
ran.sort_unstable();
assert_eq!(
ran,
(1..=JOBS).collect::<Vec<_>>(),
"every job exactly once — no duplicate claim, nothing dropped"
);
let leftover = open(&path);
assert_eq!(rows(&leftover), 0);
}
#[test]
fn priority_survives_the_runner() {
// NFR-ARCH-2: visible work strictly preempts bulk work, and it has to
// still be true when the queue is drained through a handler rather
// than by hand.
let c = db();
image(&c, 1);
image(&c, 2);
enqueue(&c, JobKind::Thumbnail, Some(1), Priority::Background, None).unwrap();
enqueue(&c, JobKind::Thumbnail, Some(2), Priority::Interactive, None).unwrap();
let seen = Arc::new(Mutex::new(Vec::new()));
Runner::new(&c)
.with(recording(vec![JobKind::Thumbnail], seen.clone()))
.drain_all(0)
.unwrap();
assert_eq!(*seen.lock().unwrap(), vec![2, 1]);
}
#[test]
fn a_handler_sees_the_payload_and_the_attempt_count() {
// Both are how a handler decides what to do: the payload is the only
// thing that survives from the enqueue site, and the attempt count is
// how it can tell a first try from a last one.
let c = db();
image(&c, 1);
enqueue(
&c,
JobKind::ScanFolder,
Some(1),
Priority::Background,
Some("/lib/2024"),
)
.unwrap();
let payload = Arc::new(Mutex::new(None));
let recorded = payload.clone();
Runner::new(&c)
.with(handler(&[JobKind::ScanFolder], move |job| {
*recorded.lock().unwrap() = Some((job.payload.clone(), job.attempts));
Outcome::Done
}))
.drain_all(0)
.unwrap();
assert_eq!(
*payload.lock().unwrap(),
Some((Some("/lib/2024".to_string()), 1))
);
}
}
+354 -7
View File
@@ -15,7 +15,7 @@ use rusqlite::Connection;
use crate::error::CatalogError;
/// Schema version this build writes and understands.
pub const SCHEMA_VERSION: i64 = 10;
pub const SCHEMA_VERSION: i64 = 14;
/// Apply migrations up to [`SCHEMA_VERSION`].
///
@@ -98,6 +98,44 @@ pub fn migrate(conn: &Connection) -> Result<i64, CatalogError> {
tx.commit()?;
}
if from < 11 {
let tx = conn.unchecked_transaction()?;
tx.execute_batch(V11)?;
tx.pragma_update(None, "user_version", 11)?;
tx.commit()?;
}
if from < 12 {
let tx = conn.unchecked_transaction()?;
tx.execute_batch(V12)?;
tx.pragma_update(None, "user_version", 12)?;
tx.commit()?;
}
if from < 13 {
let tx = conn.unchecked_transaction()?;
tx.execute_batch(V13)?;
tx.pragma_update(None, "user_version", 13)?;
tx.commit()?;
}
if from < 14 {
let tx = conn.unchecked_transaction()?;
// `ALTER TABLE ... ADD COLUMN` has no `IF NOT EXISTS`, and NFR-R5
// wants this re-enterable: a catalog whose `user_version` was rewound
// by a rollback already has the column, and would otherwise fail its
// next open on it.
let has_quality: bool = tx
.prepare("SELECT 1 FROM pragma_table_info('faces') WHERE name = 'quality'")?
.exists([])?;
if !has_quality {
tx.execute_batch("ALTER TABLE faces ADD COLUMN quality REAL;")?;
}
tx.execute_batch(V14)?;
tx.pragma_update(None, "user_version", 14)?;
tx.commit()?;
}
Ok(from)
}
@@ -125,15 +163,27 @@ pub fn backfill(conn: &Connection) -> Result<Vec<(&'static str, usize)>, Catalog
// 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.
// all, so there was nowhere for a judgement to go — see [`crate::rating`].
let n = crate::rating::ensure_default_versions(conn)?;
if n > 0 {
out.push(("default_versions", n));
}
// TRACES: FR-NC-8 | FR-NC-9
// The uuid on those rows is the cross-device merge identity, and it used
// to be generated rather than derived. This comment said so, and said it
// as though generating it were the point — it was the bug. Two devices
// minted different uuids for one photograph, so the sidecar they shared
// grew a `default = 1` block each and neither ever saw the other's work.
//
// Runs after the pass above so a row created a moment ago is already
// derived and matches nothing here. Ordering the other way would be
// correct too, just wasteful.
let n = crate::rating::align_default_version_uuids(conn)?;
if n > 0 {
out.push(("derived_version_uuids", n));
}
// v6: a vocabulary row for every word some image already carries.
//
// Three ways a catalog arrives holding assignments with no term behind
@@ -205,6 +255,11 @@ pub fn v1_for_attached(schema_name: &str) -> String {
/// creating it over there would fail on columns that are not there. Nothing is
/// lost by its absence — it exists to make the *grid* page quickly, and the
/// grid never reads across an attachment.
///
/// V11 is excluded on the same grounds and for the plainer reason that a merge
/// has nothing to do with it: burst grouping is rebuilt locally from local
/// signatures, and no code reads a remote catalog's `burst_*` tables. Its
/// `ALTER TABLE` would fail here anyway, being unqualifiable by the rewrite.
pub fn for_attached(schema_name: &str) -> String {
// V10 is `ALTER TABLE`, which the textual rewrite cannot qualify, so its
// columns are spelled out. A remote genuinely older than V10 is a real
@@ -213,7 +268,8 @@ pub fn for_attached(schema_name: &str) -> String {
format!(
"{}\n{}\n{}\n\
ALTER TABLE {schema_name}.people ADD COLUMN ignored INTEGER NOT NULL DEFAULT 0;\n\
ALTER TABLE {schema_name}.faces ADD COLUMN crop BLOB;",
ALTER TABLE {schema_name}.faces ADD COLUMN crop BLOB;\n\
ALTER TABLE {schema_name}.faces ADD COLUMN quality REAL;",
rewrite_for_attached(V1, schema_name),
rewrite_for_attached(V6, schema_name),
rewrite_for_attached(V8, schema_name),
@@ -397,6 +453,195 @@ ALTER TABLE people ADD COLUMN ignored INTEGER NOT NULL DEFAULT 0;
ALTER TABLE faces ADD COLUMN crop BLOB;
"#;
/// TRACES: FR-CULL-5
/// Burst grouping: which frames are one moment, and which one stands for it.
///
/// The reasoning behind the grouping itself is in [`crate::bursts`]; what
/// belongs here is why it is stored in three pieces rather than one.
///
/// **`images.perceptual_hash` is a column, not a table**, for the same reason
/// `content_hash` is: it is one number per image, NULL until something has had
/// the pixels in hand, and every query that wants it is already reading the
/// image row. It is local derived state — a rebuilt catalog recomputes it from
/// thumbnails — which is also why it is absent from [`for_attached`], alongside
/// the shadowing and trashing columns V2 through V5 add.
///
/// **`burst_members` is rewritten whole by every pass.** No id of its own: the
/// group is named by the image id of its earliest frame, so a burst that has not
/// changed keeps its name across a regroup and the interface can remember that
/// this one is open. There is no `bursts` table to go with it because a group
/// has no properties beyond its members — inventing a row for it would create an
/// identity that survives the grouping being rebuilt, which is precisely what
/// must not happen.
///
/// **`burst_pick` is the one thing here that is not derived**, and it is a
/// separate table so that rewriting the grouping cannot erase it. A
/// representative stored on `burst_members` would be forgotten every time a
/// frame arrived; the user would be asked the same question after every scan.
/// The same argument `people.ignored` makes in V10, one subsystem over.
///
/// **`burst_expanded` is view state in the catalog**, which is unusual enough to
/// justify. The grid is a window over an ordered query — `LIMIT n OFFSET k` —
/// so what a collapsed burst hides has to be decided by the query, or the row
/// count stops agreeing with the scrollbar and the ordinals a scrub resolves to.
/// Once SQL has to see it, this is where it lives. Nothing else reads it, and it
/// is emptied of stale groups by every pass.
const V11: &str = r#"
-- A 64-bit perceptual signature. Local derived state: NULL until something has
-- decoded the image, recomputed from thumbnails if the catalog is rebuilt, and
-- comparable only to signatures produced by the same build (`bursts`).
ALTER TABLE images ADD COLUMN perceptual_hash INTEGER;
CREATE TABLE burst_members (
-- One burst at most per image: a frame belongs to the moment it was taken
-- in, and nothing else.
image_id INTEGER PRIMARY KEY REFERENCES images(id) ON DELETE CASCADE,
-- The image id of the burst's earliest frame. Not a foreign key by
-- accident: the leader is itself a member, so this genuinely references
-- images(id), and cascading its deletion is right.
burst_id INTEGER NOT NULL REFERENCES images(id) ON DELETE CASCADE,
-- The frame the group collapses to. Exactly one per burst.
representative INTEGER NOT NULL DEFAULT 0
);
-- Counting a burst's frames and listing them are what the grid asks for, once
-- per window; without this both are a scan of every grouped frame in the
-- library.
CREATE INDEX burst_members_burst ON burst_members(burst_id);
-- The user's own choice of representative. User data, never rewritten by a
-- grouping pass -- see the module doc above.
CREATE TABLE burst_pick (
image_id INTEGER PRIMARY KEY REFERENCES images(id) ON DELETE CASCADE
);
-- Bursts the grid is currently showing in full.
CREATE TABLE burst_expanded (
burst_id INTEGER PRIMARY KEY REFERENCES images(id) ON DELETE CASCADE
);
"#;
const V12: &str = r#"
-- TRACES: FR-CULL-8
-- Forget the runs that were made against a proxy too small to find a face on.
--
-- Detection used to accept any proxy, and one of the two sweeps detected on
-- the stored 1024px tier. On the reference library that produced 0.078 faces
-- per image against 1.82 for the same photographs at 2048 or better -- and
-- every one of those runs left a `face_index` row behind saying the image had
-- been examined. That row is what makes the damage permanent: the work list is
-- "images with no row", so a photograph examined badly is indistinguishable
-- from one examined well, and is never offered to a later pass.
--
-- Deleting the marker is the whole repair, and it is deliberately not a
-- deletion of anything else. The `faces` rows those runs found stay exactly
-- where they are and keep drawing the People screen until a better pass
-- replaces them, and `record_detections` carries the user's confirmed names
-- across that replacement by box overlap. So this costs a re-fetch of the
-- affected images and loses no work the user has done.
--
-- The threshold is written out rather than taken from `dr_face::MIN_CROP_EDGE`
-- on purpose. A migration has to keep meaning what it meant on the day it ran;
-- binding it to a constant someone may raise later would silently change what
-- an old catalog gets migrated to.
DELETE FROM face_index WHERE source_edge <= 1024;
"#;
const V13: &str = r#"
-- TRACES: FR-CAT-8 | FR-NC-9
-- Which sidecars this device has read, and at what ETag.
--
-- The sidecar is the authoritative store for a rating and an edit, and until
-- this table existed nothing ever read one back into the catalog: judgements
-- travelled outward only. A cull done on a tablet reached the server and
-- stopped there, because the scan indexes photographs, the derived sync moves
-- thumbnails and collections, and the one reader that existed ran when a single
-- photograph was opened in develop and fed only the develop graph. The grid
-- draws `versions.rating`, so another device's afternoon of culling was
-- invisible on this one -- permanently, by every path the app had.
--
-- What this holds is the ETag, not the content. It is the record of what has
-- already been taken in, so a pull fetches only what changed: `dr_sync::scan`
-- reports every sidecar it saw in listings it was making anyway, and this
-- decides which of them are worth a GET.
--
-- Keyed on the sidecar's own remote path rather than on an image id. One
-- sidecar can describe two images -- a RAW and the JPEG beside it are one
-- photograph (FR-CAT-11) and share a document -- and a path is what the scan
-- reports and what a fetch addresses, so keying on anything else would mean
-- deriving one from the other in two places.
--
-- Rebuildable like the rest of the catalog: losing this table costs one pass
-- that re-reads every sidecar and reaches exactly the same state.
--
-- `IF NOT EXISTS` because NFR-R5 asks for migrations that are idempotent on
-- retry, and this one can genuinely be re-entered: a catalog whose
-- `user_version` was rewound -- by a rollback to an older build, or by a
-- recovery -- would otherwise fail its next open on a table it already has.
CREATE TABLE IF NOT EXISTS sidecars (
root_id INTEGER NOT NULL REFERENCES roots(id) ON DELETE CASCADE,
path TEXT NOT NULL,
etag TEXT,
-- Unix seconds, for diagnosing a pull that is not making progress.
read_at INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY(root_id, path)
);
"#;
const V14: &str = r#"
-- TRACES: FR-CULL-9 | FR-CULL-10
-- How recognisable the model found each face, and a second look at the faces
-- it was never asked about.
--
-- The embedder's raw output has a length, and the length is a quality
-- reading: it grows with how much of a face the model could make out, and a
-- blur, an occlusion or a hard profile comes out short (dr_face::embedding,
-- `MIN_GALLERY_QUALITY`). Normalising threw it away. A short vector sits
-- near the middle of the sphere and matches a little of everyone, which is
-- how one bad crop bridges two people in a grouping pass -- so a face below
-- the floor is compared against the others and never compared *against*.
--
-- Nullable, and NULL means "never measured": every face indexed before this
-- version stored the unit vector, whose length is one whatever the crop was.
-- A face with no reading is admitted to the gallery, because a rule that
-- cannot be checked should admit rather than exclude -- but it is also a
-- face this rule is not yet protecting anyone from, and the only way to
-- measure it is to embed it again.
--
-- The sweep's measuring pass is what does that: `dr_ui::library::
-- faces_unmeasured` lists every image holding a face with no reading, and
-- each face is embedded again from the native render with the landmarks it
-- already has, the raw vector written over the old one (`record_measurements`)
-- and nothing else touched -- not the id, not the box, not who the user said
-- it was. The faces keep drawing the People screen throughout.
--
-- The run markers of those images are forgotten too, exactly as V12 forgot
-- the runs made against too small a proxy. The build this shipped in had no
-- measuring pass yet, and a marker is the one thing that stops a face ever
-- being looked at again; with the pass in place `faces_unindexed` leaves
-- these images to it rather than detecting them from scratch, so the
-- deletion costs nothing -- and an image that was examined and found empty
-- keeps its marker, since there is nothing on it to measure.
--
-- The cost is a re-fetch of every image with a face on it, on the next pass
-- the user starts. That is a whole-library transfer (FR-NC-6), and it starts
-- when they say so, not here.
--
-- From this version the `embedding` blob is the **raw** model output rather
-- than the unit vector V8 describes -- the length is the quality, and a store
-- that kept only the direction had thrown it away. Readers re-normalise on
-- load, so a unit blob from before and a raw blob from now compare alike;
-- `quality` is that length kept beside the blob for the readers that never
-- load the vector, and NULL rather than 1.0 for the old rows, because a unit
-- vector reads as a length of one and one is not "unmeasured".
--
-- The column itself is added in `migrate`, guarded, because ALTER has no
-- IF NOT EXISTS and this step has to be re-enterable (NFR-R5).
DELETE FROM face_index
WHERE EXISTS (SELECT 1 FROM faces f
WHERE f.image_id = face_index.image_id
AND f.model_id = face_index.model_id);
"#;
const V9: &str = r#"
-- TRACES: FR-CULL-8
-- A record that face detection has *run* on an image, distinct from what it
@@ -476,7 +721,7 @@ CREATE TABLE faces (
x REAL NOT NULL, y REAL NOT NULL, w REAL NOT NULL, h REAL NOT NULL,
landmarks BLOB NOT NULL, -- 5 x (x, y) f32, normalised likewise
detector_confidence REAL NOT NULL,
embedding BLOB NOT NULL, -- 512 x f16, L2-normalised
embedding BLOB NOT NULL, -- 512 x f16; unit length until V14, raw since
-- Source pixels across the aligned 112x112 crop (docs/faces.md §7).
--
-- Not cosmetic: it is the honest quality signal for the UI, a feature in
@@ -1189,6 +1434,108 @@ mod tests {
assert_eq!(n, 0, "images must not outlive their root");
}
#[test]
fn v12_forgets_runs_made_on_a_proxy_too_small_to_see_a_face() {
let c = mem();
// Migrate to 11, then seed the state V12 exists to repair: markers
// written at the 1024 store tier beside ones written on a real
// preview.
c.pragma_update(None, "user_version", 0).unwrap();
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',0),(2,1,'b',0),(3,1,'c',0),(4,1,'d',0)",
[],
)
.unwrap();
for (image, edge) in [(1, 896), (2, 1024), (3, 1025), (4, 2560)] {
c.execute(
"INSERT INTO face_index(image_id, model_id, indexed_at, faces_found, source_edge)
VALUES (?1, 'm', 0, 0, ?2)",
rusqlite::params![image, edge],
)
.unwrap();
}
c.pragma_update(None, "user_version", 11).unwrap();
migrate(&c).unwrap();
let kept: Vec<i64> = c
.prepare("SELECT image_id FROM face_index ORDER BY image_id")
.unwrap()
.query_map([], |r| r.get(0))
.unwrap()
.map(Result::unwrap)
.collect();
// 1024 goes: it is exactly ThumbSize::Large, the tier that produced
// the bad runs. 1025 stays, or the floor and the repair disagree
// about the same boundary.
assert_eq!(kept, vec![3, 4]);
}
#[test]
fn v14_forgets_runs_that_found_faces_but_never_measured_them() {
let c = mem();
c.pragma_update(None, "user_version", 0).unwrap();
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',0),(2,1,'b',0),(3,1,'c',0)",
[],
)
.unwrap();
// Image 1 was examined and holds a face; 2 was examined and found
// empty; 3 holds a face found by a different model.
for (image, model) in [(1, "m"), (2, "m"), (3, "m")] {
c.execute(
"INSERT INTO face_index(image_id, model_id, indexed_at, faces_found, source_edge)
VALUES (?1, ?2, 0, 0, 2560)",
rusqlite::params![image, model],
)
.unwrap();
}
for (image, model) in [(1, "m"), (3, "other")] {
c.execute(
"INSERT INTO faces
(image_id, x, y, w, h, landmarks, detector_confidence, embedding,
crop_px, model_id, detected_at)
VALUES (?1, 0.1, 0.1, 0.2, 0.2, X'00', 0.9, X'00', 180.0, ?2, 0)",
rusqlite::params![image, model],
)
.unwrap();
}
c.pragma_update(None, "user_version", 13).unwrap();
migrate(&c).unwrap();
let kept: Vec<i64> = c
.prepare("SELECT image_id FROM face_index ORDER BY image_id")
.unwrap()
.query_map([], |r| r.get(0))
.unwrap()
.map(Result::unwrap)
.collect();
// 1 goes: it has a face with no quality. 2 stays: nothing on it to
// measure. 3 stays: its face belongs to a run this marker does not
// describe.
assert_eq!(kept, vec![2, 3]);
// And the faces themselves are untouched.
let faces: i64 = c
.query_row("SELECT count(*) FROM faces", [], |r| r.get(0))
.unwrap();
assert_eq!(faces, 2);
}
#[test]
fn job_uniqueness_coalesces_rather_than_duplicating() {
let c = mem();
+16 -2
View File
@@ -52,6 +52,21 @@ pub fn checkpoint(conn: &Connection) -> Result<(), CatalogError> {
/// 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> {
let out = copy_to(conn, dest)?;
strip_face_crops(&out)?;
Ok(())
}
/// Checkpoint, then copy the whole database to `dest`, and hand back the
/// connection to the copy.
///
/// Split out from [`snapshot_for_upload`] because [`crate::recovery`] wants
/// exactly this and none of what follows it there: an NFR-R2 backup is the
/// file the user may have to *live on*, so it keeps the face crops that an
/// upload strips. Sharing the copy rather than reimplementing it is what keeps
/// the WAL discipline in one place — a backup taken with `fs::copy` would be
/// the torn snapshot this module's header exists to warn about.
pub(crate) fn copy_to(conn: &Connection, dest: &Path) -> Result<Connection, CatalogError> {
checkpoint(conn)?;
let mut out = Connection::open(dest)?;
@@ -63,8 +78,7 @@ pub fn snapshot_for_upload(conn: &Connection, dest: &Path) -> Result<(), Catalog
backup.run_to_completion(i32::MAX, std::time::Duration::ZERO, None)?;
drop(backup);
strip_face_crops(&out)?;
Ok(())
Ok(out)
}
/// Drop the stored face crops from a snapshot before it is uploaded.
+61 -3
View File
@@ -432,7 +432,7 @@ fn bump_generation(conn: &Connection, root: RootId, now: i64) -> Result<i64, Cat
Ok(next)
}
/// TRACES: FR-CAT-9
/// TRACES: FR-CAT-9 | FR-PLAT-AND-2
/// Mark every image under a root as unreachable.
///
/// The other half of FR-CAT-9's distinction: a source *proven absent* may leave
@@ -445,14 +445,38 @@ fn bump_generation(conn: &Connection, root: RootId, now: i64) -> Result<i64, Cat
/// the root now claims something about the files that is no longer known to be
/// true, and only reading the directories again can settle it. Pruning would
/// skip them all and leave a plugged-in library showing as offline forever.
fn mark_root_offline(conn: &Connection, root: RootId) -> Result<(), CatalogError> {
///
/// # Why the ETag goes with the mtime
///
/// The three columns are the same fact told by three kinds of storage: a local
/// directory proves it is unchanged with its mtime and entry count, and a
/// remote one proves it with a propagating ETag (ARCH §6.6). Clearing two of
/// them and leaving the third would disarm the re-listing on exactly the
/// libraries this is most likely to be called for — a remote scan prunes on
/// the ETag alone, so a root that came back would be walked, found unchanged
/// at every level, pruned whole, and left with every row still marked offline
/// and nothing that would ever clear the mark.
///
/// # Public, because losing a root is not only the local walk's business
///
/// This began as the private end of [`scan_root`]'s root-failure branches,
/// which is the only route a library reached through [`Storage`] can take.
/// The application does not currently take that route at all: it opens
/// libraries through `dr-sync`'s connectors, so the discovery happens in a
/// crate that cannot see this one's internals, and the correct response is
/// identical (FR-PLAT-AND-2). Exported rather than reimplemented beside the
/// caller that found out — a second copy would be a second thing to remember
/// when the ETag rule below changes.
///
/// [`Storage`]: dr_plat::Storage
pub fn mark_root_offline(conn: &Connection, root: RootId) -> Result<(), CatalogError> {
let root_id = root.0 as i64;
conn.execute(
"UPDATE images SET availability = ?1 WHERE root_id = ?2 AND availability != ?1",
rusqlite::params![availability_code(Availability::Offline), root_id],
)?;
conn.execute(
"UPDATE folders SET mtime = NULL, entry_count = NULL WHERE root_id = ?1",
"UPDATE folders SET mtime = NULL, entry_count = NULL, etag = NULL WHERE root_id = ?1",
[root_id],
)?;
Ok(())
@@ -995,6 +1019,40 @@ mod tests {
);
}
/// TRACES: FR-PLAT-AND-2 | FR-CAT-9
#[test]
fn marking_a_root_offline_forgets_the_remote_validator_too() {
// The half of the marking that only a remote library can notice, and
// the reason it has to be here rather than beside the connector: a
// remote scan prunes on the propagating ETag alone (ARCH §6.6). Clear
// the local mtime and leave the ETag standing and a library that came
// back would be walked, found unchanged at every level, pruned whole,
// and left with every row still marked offline — with nothing that
// would ever clear the mark, because clearing it is something only a
// listing can do.
//
// Written directly because this module never writes an ETag; it is
// `ui/dr-ui/src/library.rs`'s scan that does, against the same table.
let lib = Library::new("etag-forgotten");
lib.file("2026/IMG.CR3", b"raw");
lib.scan();
lib.conn()
.execute(
"UPDATE folders SET etag = 'e1' WHERE root_id = ?1",
[lib.root.0 as i64],
)
.expect("etag");
assert!(lib.count("SELECT COUNT(*) FROM folders WHERE etag IS NOT NULL") > 0);
mark_root_offline(lib.conn(), lib.root).expect("mark");
assert_eq!(
lib.count("SELECT COUNT(*) FROM folders WHERE etag IS NOT NULL"),
0,
"an unreachable library must be re-listed, not pruned as unchanged"
);
}
#[test]
fn a_root_that_comes_back_is_available_again() {
// The other half: a drive plugged back in must return the library to
+10 -2
View File
@@ -1,4 +1,4 @@
//! TRACES: FR-EXP-1 | FR-EXP-2 | FR-EXP-3 | FR-EXP-4 | FR-EXP-6 | FR-EXP-9
//! TRACES: FR-EXP-1 | FR-EXP-2 | FR-EXP-3 | FR-EXP-4 | FR-EXP-6 | FR-EXP-9 | R3
//! Turning a rendered frame into a file's worth of bytes.
//!
//! # What this crate is, and is not
@@ -179,7 +179,15 @@ pub fn export(
settings.allow_upscaling,
);
let resized = size::resample(frame, width, height);
// TRACES: FR-EXP-3
// One mode promises exact dimensions rather than a bound on them, and it
// is the only place the fit/fill distinction survives: `target_size` has
// already reported what the file will be either way.
let resized = if settings.sizing.crops_to_fill() {
size::resample_filling(frame, width, height)
} else {
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.
+202 -6
View File
@@ -25,8 +25,11 @@ 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.
/// Aspect is preserved in every mode. In all but one that means only a single
/// dimension is ever the requested one; [`SizingMode::FillBox`] is the
/// exception, and it keeps the aspect by *discarding* the overhang rather than
/// by settling for a smaller box — see [`resample_filling`], which does the
/// discarding.
///
/// **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
@@ -45,21 +48,56 @@ pub fn target_size(
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),
// Fit is a ceiling on both axes, so the smaller factor wins and the
// result touches the box on one axis only.
SizingMode::FitBox(bw, bh) => {
scale_by(src_w, src_h, box_factor(src_w, src_h, bw, bh, f64::min))
}
// Fill is the box, exactly. The scale that covers it is the larger
// factor, and the overhang is taken off in `resample_filling` — this
// reports what the file will be, which is the whole reason the mode
// exists.
SizingMode::FillBox(bw, bh) => (bw.max(1), bh.max(1)),
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),
)
scale_by(src_w, src_h, f)
}
};
if !allow_upscaling && (w > src_w || h > src_h) {
// A fill box has to keep its shape even when it cannot keep its size:
// the mode's promise is an exact aspect ratio at exact dimensions, and
// falling back to the source's own shape would quietly export a 3:2
// file where a 16:9 one was asked for. So the *box* is scaled down to
// what the source can cover, rather than abandoned.
if let SizingMode::FillBox(bw, bh) = sizing {
let cover = box_factor(src_w, src_h, bw, bh, f64::max);
if cover > 1.0 {
return scale_by(bw.max(1), bh.max(1), 1.0 / cover);
}
}
return (src_w, src_h);
}
(w.max(1), h.max(1))
}
/// TRACES: FR-EXP-3
/// The scale that puts `src` against a `bw × bh` box, `choose` deciding which
/// axis governs: `f64::min` fits inside it, `f64::max` covers it.
fn box_factor(src_w: u32, src_h: u32, bw: u32, bh: u32, choose: fn(f64, f64) -> f64) -> f64 {
let fw = f64::from(bw.max(1)) / f64::from(src_w.max(1));
let fh = f64::from(bh.max(1)) / f64::from(src_h.max(1));
choose(fw, fh)
}
/// Both axes by one factor, never rounding away to nothing.
fn scale_by(w: u32, h: u32, factor: f64) -> (u32, u32) {
(
((f64::from(w) * factor).round() as u32).max(1),
((f64::from(h) * factor).round() as u32).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);
@@ -95,6 +133,46 @@ pub fn resample(frame: &Frame, dst_w: u32, dst_h: u32) -> Vec<u8> {
pass(&horizontal, dst_w, frame.height, dst_w, dst_h, false)
}
/// TRACES: FR-EXP-3
/// Resample onto exactly `(dst_w, dst_h)`, covering the box and cutting the
/// overhang off the middle.
///
/// The other half of [`SizingMode::FillBox`]. [`resample`] alone would do the
/// job by *stretching* the frame onto the box, which is the one outcome a
/// photographer would never accept — a 3:2 photograph squeezed onto a 16:9
/// panel is visibly wrong in a way no amount of resolution fixes.
///
/// So the frame is scaled until it covers the box, on whichever axis needs the
/// most, and the surplus is taken symmetrically off the other. Centred rather
/// than anchored: the crop tool is where a photographer decides *which* part
/// of the frame survives, and this stage guessing differently would fight it.
/// Locking the crop to the export's ratio leaves nothing here to cut.
pub fn resample_filling(frame: &Frame, dst_w: u32, dst_h: u32) -> Vec<u8> {
let (dst_w, dst_h) = (dst_w.max(1), dst_h.max(1));
// Rounded *up*, and floored at the destination: a cover scale that rounds
// down leaves the box a pixel short on one axis, and the crop below would
// then read past the end of the buffer.
let cover = box_factor(frame.width, frame.height, dst_w, dst_h, f64::max);
let cw = (((f64::from(frame.width) * cover).ceil()) as u32).max(dst_w);
let ch = (((f64::from(frame.height) * cover).ceil()) as u32).max(dst_h);
let covered = resample(frame, cw, ch);
if cw == dst_w && ch == dst_h {
return covered;
}
let (x0, y0) = ((cw - dst_w) / 2, (ch - dst_h) / 2);
let mut out = vec![0u8; (dst_w as usize) * (dst_h as usize) * 4];
for y in 0..dst_h as usize {
let src = ((y + y0 as usize) * cw as usize + x0 as usize) * 4;
let dst = y * dst_w as usize * 4;
let run = dst_w as usize * 4;
out[dst..dst + run].copy_from_slice(&covered[src..src + run]);
}
out
}
/// 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 {
@@ -219,6 +297,124 @@ mod tests {
);
}
#[test]
fn a_fit_box_stays_inside_the_box_and_keeps_its_shape() {
// Fit is a ceiling on both axes, so a 3:2 frame in a 16:9 box comes
// back short of the box's width, never past its height.
assert_eq!(
target_size(6000, 4000, SizingMode::FitBox(3840, 2160), false),
(3240, 2160)
);
// Portrait into the same box: now the height governs nothing and the
// width does.
assert_eq!(
target_size(4000, 6000, SizingMode::FitBox(3840, 2160), false),
(1440, 2160)
);
}
#[test]
fn a_fill_box_is_the_box_exactly() {
// The whole point of the mode. A display that accepts one resolution
// and rejects everything else has to get that resolution whatever the
// photograph's own shape is.
for (w, h) in [(6000u32, 4000u32), (4000, 6000), (5000, 5000)] {
assert_eq!(
target_size(w, h, SizingMode::FillBox(3840, 2160), false),
(3840, 2160),
"{w}x{h} did not fill the box"
);
}
}
#[test]
fn a_fill_box_too_large_for_the_source_keeps_its_shape_not_the_sources() {
// Upscaling off, and the source cannot cover 4K. Falling back to the
// source's own size would export a 3:2 file where 16:9 was asked
// for — silently wrong in exactly the way the mode exists to prevent.
// The box shrinks instead.
let (w, h) = target_size(1600, 1200, SizingMode::FillBox(3840, 2160), false);
assert!(w <= 1600 && h <= 1200, "upscaled to {w}x{h}");
let want = 3840.0 / 2160.0;
assert!(
((w as f64 / h as f64) / want - 1.0).abs() < 0.01,
"{w}x{h} is not the box's shape"
);
}
#[test]
fn a_fill_box_within_the_source_is_honoured_with_upscaling_off() {
// The ordinary case: a 24 MP frame has pixels to spare for a 4K panel,
// so nothing is being enlarged and the clamp must not fire.
assert_eq!(
target_size(6000, 4000, SizingMode::FillBox(3840, 2160), false),
(3840, 2160)
);
}
#[test]
fn a_filled_frame_comes_back_at_exactly_the_box() {
let f = frame(128, 64);
// Wider than the source's 2:1, so the crop comes off the width.
assert_eq!(resample_filling(&f, 40, 40).len(), 40 * 40 * 4);
assert_eq!(resample_filling(&f, 100, 25).len(), 100 * 25 * 4);
// Already the box: no work, and no drift.
assert_eq!(resample_filling(&f, 128, 64), f.rgba);
}
#[test]
fn a_fill_crops_rather_than_stretching() {
// The property that separates fill from handing the box straight to
// `resample`. The test frame ramps red left to right, so a 2:1 source
// squeezed into a square would compress that ramp into the full
// width — where a centre crop keeps its middle, and therefore starts
// and ends well inside the source's own range.
let f = frame(128, 128);
let square = resample(&f, 64, 64);
let filled = resample_filling(&f, 32, 64);
let left = |b: &[u8]| b[0];
let right = |b: &[u8], w: usize| b[(w - 1) * 4];
assert!(
left(&filled) > left(&square),
"a centre crop must start further into the ramp"
);
assert!(
right(&filled, 32) < right(&square, 64),
"a centre crop must end further from the ramp's end"
);
}
#[test]
fn a_fill_takes_the_overhang_evenly_off_both_sides() {
// Centred, not anchored: the crop tool is where a photographer decides
// which part of the frame survives, and this stage must not have an
// opinion of its own.
let f = frame(128, 128);
let filled = resample_filling(&f, 32, 64);
let px = |x: usize| filled[x * 4];
// The ramp is horizontal, so a centred crop is symmetric about the
// frame's own midpoint: the two ends should sit equally far from it.
let mid = i32::from(resample(&f, 128, 128)[64 * 4]);
let lo = i32::from(px(0));
let hi = i32::from(px(31));
assert!(
((mid - lo) - (hi - mid)).abs() < 8,
"not centred: {lo} .. {mid} .. {hi}"
);
}
#[test]
fn a_flat_field_survives_a_fill_unchanged() {
// Same guard as the fit path: any deviation means the cover scale and
// the crop disagree about where the pixels are.
let flat = Frame::new(64, 48, vec![200; 64 * 48 * 4]).unwrap();
for byte in resample_filling(&flat, 30, 30) {
assert_eq!(byte, 200);
}
}
#[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.
+3 -2
View File
@@ -63,15 +63,16 @@ fn main() {
let embed_ms = t.elapsed().as_secs_f64() * 1e3;
println!(
" [{i}] conf {:.3} box {:.0},{:.0} {:.0}×{:.0} crop_px {:.0} embed {embed_ms:.0} ms",
" [{i}] conf {:.3} box {:.0},{:.0} {:.0}×{:.0} crop_px {:.0} quality {:.1} embed {embed_ms:.0} ms",
d.confidence,
d.bbox.0,
d.bbox.1,
d.width(),
d.height(),
aligned.source_px(),
emb.quality,
);
all.push((path.clone(), i, emb));
all.push((path.clone(), i, emb.embedding));
}
}
+112
View File
@@ -0,0 +1,112 @@
//! How fast this machine can scan a library for face pairs.
//!
//! cargo run --release -p dr-face --example scan_bench [-- FACES…]
//!
//! Synthetic embeddings, because the scan's cost is `n²/2` dot products and
//! does not care what the vectors mean — which is what makes this runnable on
//! a phone with no library on it, over `adb shell`, beside
//! `tools/face-tests-on-device.sh`.
//!
//! # What it is for
//!
//! docs/faces.md §9 has the desktop numbers and the question they leave open:
//! a GPU GEMM is worth roughly 1.5× of a regroup on a twenty-core desktop,
//! because the scan is under a third of the pass there. On a tablet the CPU is
//! several times slower and the GPU is not, so the same optimisation is worth
//! something different — and nobody had measured which.
//!
//! No weights, no catalog, no display: it needs nothing but the binary.
use dr_face::neighbours::{above_threshold, Faces};
use dr_face::{Calibration, EMBEDDING_DIM, RIVAL_FLOOR};
/// Sizes to time, unless the command line names others.
const DEFAULT_SIZES: [usize; 4] = [2_000, 4_000, 8_000, 18_000];
fn main() {
let sizes: Vec<usize> = {
let given: Vec<usize> = std::env::args()
.skip(1)
.filter_map(|a| a.parse().ok())
.collect();
if given.is_empty() {
DEFAULT_SIZES.to_vec()
} else {
given
}
};
// The reference curve, so the cosine floor is the one a real library with
// no fit of its own would scan at.
let cal = Calibration::default();
println!(
"{:>8} {:>9} {:>10} {:>9}",
"faces", "scan", "pairs", "GFLOP/s"
);
for n in sizes {
let (embeddings, crop_px, images) = population(n);
let gallery = vec![true; n];
let faces = Faces {
embeddings: &embeddings,
dim: EMBEDDING_DIM,
crop_px: &crop_px,
images: &images,
gallery: &gallery,
};
let start = std::time::Instant::now();
let pairs = above_threshold(&faces, &cal, RIVAL_FLOOR);
let secs = start.elapsed().as_secs_f64();
let flop = n as f64 * n as f64 / 2.0 * EMBEDDING_DIM as f64 * 2.0;
println!(
"{n:>8} {secs:>8.2}s {:>10} {:>9.1}",
pairs.len(),
flop / secs / 1e9
);
}
}
/// `n` L2-normalised embeddings in a handful of loose clusters.
///
/// Clustered rather than uniform so the scan finds a plausible number of pairs
/// to keep — a population where nothing survives the threshold would time the
/// rejection path alone, which is not the path that matters. Hashed from an
/// index rather than drawn from an RNG, so a number is reproducible from the
/// command that produced it.
fn population(n: usize) -> (Vec<f32>, Vec<f32>, Vec<u64>) {
let identities = (n / 12).max(1);
let mut embeddings = Vec::with_capacity(n * EMBEDDING_DIM);
for i in 0..n {
let mut v = unit(i % identities);
let noise = unit(i + 1_000_000);
for (x, e) in v.iter_mut().zip(&noise) {
*x = 0.75 * *x + 0.25 * e;
}
embeddings.extend(normalise(v));
}
// Every face in its own photograph: the co-occurrence rule skips pairs
// rather than scoring them, and skipped pairs are not what is being timed.
((embeddings), vec![150.0; n], (0..n as u64).collect())
}
fn unit(seed: usize) -> Vec<f32> {
let mut s = (seed as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1;
let mut v = Vec::with_capacity(EMBEDDING_DIM);
for _ in 0..EMBEDDING_DIM {
s ^= s << 13;
s ^= s >> 7;
s ^= s << 17;
v.push(((s >> 11) as f64 / (1u64 << 53) as f64) as f32 - 0.5);
}
normalise(v)
}
fn normalise(mut v: Vec<f32>) -> Vec<f32> {
let len = v.iter().map(|x| x * x).sum::<f32>().sqrt();
for x in &mut v {
*x /= len;
}
v
}
+95 -10
View File
@@ -285,7 +285,67 @@ pub fn warp(
height: usize,
landmarks: &[(f32, f32); 5],
) -> Option<Aligned112> {
if rgb.len() != width * height * 3 {
warp_pixels(Pixels::RgbF32(rgb), width, height, landmarks)
}
/// TRACES: FR-CULL-8
/// What the warp may sample, in whichever layout the caller already holds.
///
/// # Why the 8-bit variant exists
///
/// FR-CULL-8 requires the crop to come from the **native** render, and a native
/// render is large: a 24 MP frame is 96 MB as `RGBA8` and 288 MB converted to
/// the `f32` RGB this module was originally written against. Converting the
/// whole frame to sample 112×112 from it is three hundred megabytes allocated
/// to read about forty thousand pixels, per image, on a pass that runs over a
/// whole library — and on Android it is NFR-RES-2's budget spent outright.
///
/// So the warp reads whatever the caller has instead. It touches so few pixels
/// that the per-sample conversion is free, and the buffer never has to be
/// duplicated in another layout.
#[derive(Debug, Clone, Copy)]
pub enum Pixels<'a> {
/// Tightly packed `f32` RGB in `0.0..=1.0`, row-major.
RgbF32(&'a [f32]),
/// Tightly packed 8-bit RGBA, row-major. Alpha is ignored: a face crop has
/// no use for it and carrying it would change what the embedder receives.
Rgba8(&'a [u8]),
}
impl Pixels<'_> {
/// Whether the buffer is the size `width × height` implies.
fn fits(&self, width: usize, height: usize) -> bool {
match self {
Pixels::RgbF32(v) => v.len() == width * height * 3,
Pixels::Rgba8(v) => v.len() == width * height * 4,
}
}
/// One channel of one pixel, as `0.0..=1.0`. Outside the buffer reads black.
///
/// Public because the face *crop* stored for the People screen is cut from
/// the same buffer by the same caller, and it should not need a second
/// copy of this to do it.
pub fn channel(&self, w: usize, h: usize, x: isize, y: isize, c: usize) -> f32 {
if x < 0 || y < 0 || x >= w as isize || y >= h as isize {
return 0.0;
}
let i = y as usize * w + x as usize;
match self {
Pixels::RgbF32(v) => v[i * 3 + c],
Pixels::Rgba8(v) => v[i * 4 + c] as f32 / 255.0,
}
}
}
/// [`warp`], over any layout [`Pixels`] describes.
pub fn warp_pixels(
px: Pixels<'_>,
width: usize,
height: usize,
landmarks: &[(f32, f32); 5],
) -> Option<Aligned112> {
if !px.fits(width, height) {
return None;
}
let m = fit_similarity(landmarks, &ARCFACE_TEMPLATE)?;
@@ -300,7 +360,7 @@ pub fn warp(
let (x, y) = m.invert(u as f32 + 0.5, v as f32 + 0.5);
let (x, y) = (x - 0.5, y - 0.5);
let out = (v * e + u) * 3;
sample_bilinear(rgb, width, height, x, y, &mut pixels[out..out + 3]);
sample_bilinear(px, width, height, x, y, &mut pixels[out..out + 3]);
}
}
@@ -312,7 +372,7 @@ pub fn warp(
})
}
fn sample_bilinear(rgb: &[f32], w: usize, h: usize, x: f32, y: f32, out: &mut [f32]) {
fn sample_bilinear(px: Pixels<'_>, w: usize, h: usize, x: f32, y: f32, out: &mut [f32]) {
let x0 = x.floor();
let y0 = y.floor();
let fx = x - x0;
@@ -321,13 +381,7 @@ fn sample_bilinear(rgb: &[f32], w: usize, h: usize, x: f32, y: f32, out: &mut [f
let y0 = y0 as isize;
for (c, o) in out.iter_mut().enumerate() {
let get = |xi: isize, yi: isize| -> f32 {
if xi < 0 || yi < 0 || xi >= w as isize || yi >= h as isize {
0.0
} else {
rgb[(yi as usize * w + xi as usize) * 3 + c]
}
};
let get = |xi: isize, yi: isize| -> f32 { px.channel(w, h, xi, yi, c) };
let top = get(x0, y0) * (1.0 - fx) + get(x0 + 1, y0) * fx;
let bot = get(x0, y0 + 1) * (1.0 - fx) + get(x0 + 1, y0 + 1) * fx;
*o = top * (1.0 - fy) + bot * fy;
@@ -508,6 +562,37 @@ mod tests {
/// against a bright sky is low-contrast, and a raw Laplacian variance would
/// reject it as blurred — which would quietly throw away every backlit
/// portrait in the library.
#[test]
fn both_pixel_layouts_warp_to_the_same_crop() {
// The 8-bit path exists so a native render need not be converted to
// f32 whole; it has to agree with the path it replaces to within the
// quantisation it introduces.
let (w, h) = (64usize, 64usize);
let mut rgba = vec![0u8; w * h * 4];
let mut rgb = vec![0.0f32; w * h * 3];
for y in 0..h {
for x in 0..w {
let v = [
(x * 4 % 256) as u8,
(y * 4 % 256) as u8,
((x + y) % 256) as u8,
];
for c in 0..3 {
rgba[(y * w + x) * 4 + c] = v[c];
rgb[(y * w + x) * 3 + c] = v[c] as f32 / 255.0;
}
rgba[(y * w + x) * 4 + 3] = 255;
}
}
let lm = shifted_scaled(0.35, 32.0, 32.0, 0.2);
let a = warp_pixels(Pixels::RgbF32(&rgb), w, h, &lm).unwrap();
let b = warp_pixels(Pixels::Rgba8(&rgba), w, h, &lm).unwrap();
assert_eq!(a.source_px(), b.source_px());
for (x, y) in a.pixels().iter().zip(b.pixels()) {
assert!((x - y).abs() < 1e-6, "{x} vs {y}");
}
}
#[test]
fn sharpness_survives_the_contrast_being_halved() {
let edge = 200;
+399
View File
@@ -0,0 +1,399 @@
//! TRACES: FR-CULL-9 | FR-CULL-10
//! How sure a *suggestion* is: an identity's share of the evidence for a face.
//!
//! [`crate::cluster`] decides which people exist; this decides what number to
//! put beside "we think this is Anna". They are not the same question, and the
//! answer to the second used to be a by-product of the first — the mean
//! calibrated probability between a face and *every* other member of its group.
//!
//! # Why a mean over the group is the wrong number
//!
//! It measures the wrong thing twice over.
//!
//! **It punishes large, well-photographed people.** Anna has two hundred faces
//! spanning fifteen years; a new photograph of her matches thirty of them
//! strongly and is near-orthogonal to the rest, because a face at 8 and a face
//! at 23 genuinely are. The mean lands around 0.2 and the interface reports a
//! correct suggestion as a doubtful one. The better a person is covered, the
//! worse their confidences get, which is exactly backwards.
//!
//! **It never asks who else it could be.** A face that matches Anna at 0.95 and
//! matches nobody else at all, and a face that matches Anna at 0.95 *and her
//! sister at 0.93*, are the same number under a within-group mean. The second
//! is the one the user actually needs to look at, and it was indistinguishable
//! from the first.
//!
//! # Coherence, times uniqueness
//!
//! Two questions, and the number is their product because they are genuinely
//! independent: *is this the same person at all*, and *of the people we know,
//! is it uniquely this one*.
//!
//! ```text
//! evidence(P) = Σ of the top n of { P(same | this face, f) : f ∈ P }
//! coherence = evidence(own) / (however many of the top n there were)
//! uniqueness = evidence(own) / (evidence(own) + Σ evidence(named rivals))
//! confidence = coherence × uniqueness
//! ```
//!
//! **Coherence** is a mean, like the old number, but over the face's best
//! [`TOP_MATCHES`] matches into the identity rather than over all of them. That single cap is what
//! stops a well-photographed person scoring worse than a thin one: the two
//! hundred faces a given photograph is legitimately orthogonal to no longer
//! count against it.
//!
//! **Uniqueness** is the competition. A sole strong match leaves it at 1 and
//! the confidence is the coherence; two identities matching equally well pull
//! it to 0.5 each, and the screen has told the user the truth, which is that
//! this face is a coin toss between two people.
//!
//! # Only the people the user has named compete
//!
//! Measured on a real 18,000-face library, normalising across *every* group
//! made the number useless: the median suggestion read 21% and four in five
//! read under half. The cause is not a bug in the arithmetic but a fact about
//! clustering — one person is spread across many groups, since the pairs that
//! would have joined them are the ones that fell short of the merge threshold.
//! Normalising over groups therefore makes a face compete against *itself*,
//! and the better covered the person, the more fragments there are to lose to.
//!
//! A fragment is not a rival. An identity the user has actually asserted is, so
//! the denominator counts only the people they have ruled on — a group carries
//! a [`Cluster::person`] when it holds a confirmation, a name, or an ignore —
//! and counts them **per person, not per group**, since one person is left in
//! several anchored groups for the same reason. Keying it by group had
//! Catherine competing with Catherine and put the median suggestion onto a
//! named person at 39%; keying it by person put it at 99.5%.
//!
//! Leave-one-out over that library's 2,702 confirmations across 54 named
//! people, this is the regime where the number is worth having: 99.3% of faces
//! are placed on the right person (the old mean managed 99.15%), the stated
//! percentage is monotone in being right, and it errs low — 100% correct
//! wherever it states 80% or more, 84% correct where it states under half.
//! Understating is the safe direction for a screen whose whole purpose is
//! deciding what to look at first, but it is *not* calibrated in the low bands
//! and should not be read as though it were.
//!
//! Two faces of one unnamed group cannot be told from two fragments of one
//! person by similarity alone; that is exactly why clustering stopped where it
//! did. So the module does not pretend to: where nobody is named, uniqueness is
//! 1 and the number falls back to plain coherence.
//!
//! # What it does not do
//!
//! It is not a merge threshold and must not become one. Clustering keeps
//! deciding on the pairwise calibrated probability: uniqueness is *relative*,
//! so a library with one named person in it would hand every stray face a
//! uniqueness of 1. The absolute question ("is this the same person at all")
//! and the comparative one ("of the people we know, which") are different, and
//! the product is what keeps both in the answer.
use std::collections::BTreeMap;
use crate::cluster::Cluster;
use crate::neighbours::Pair;
/// Who the evidence is for.
///
/// Ordered rather than hashed so the sums below are reproducible; the ordering
/// itself carries no meaning.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum Identity {
/// A person the user has confirmed a face onto. Every group anchored to
/// them is the same identity, however many of them the clusterer left.
Person(u64),
/// A group nobody has ruled on. It stands for itself and competes with
/// nothing.
Group(usize),
}
/// How many of an identity's best matches count as its evidence.
///
/// The cap is the whole reason the sum works: uncapped, evidence would grow
/// with a person's face count and the largest group in the library would win
/// every contest. Ten is enough that a person photographed from several angles
/// contributes more than one lucky frame, and small enough that the hundred
/// mediocre matches inside a well-covered identity cannot add up to a strong
/// one. It is a starting point, not a measured optimum — M7's corpus is where
/// it would be tuned.
pub const TOP_MATCHES: usize = 10;
/// The weakest match that counts as evidence for an identity.
///
/// Rivals are half the point of this module, so the evidence scan has to reach
/// *below* the merge threshold — a named person who matches at 0.6 will never
/// be merged into but is precisely the competition a suggestion should be
/// discounted for. Even odds is the natural floor: below it a pair is more
/// likely different people than the same, and it is not a small distinction —
/// summing the near-orthogonal pairs instead of dropping them lets fifty
/// identities' worth of noise, each contributing its *upper tail*, outweigh one
/// real match. Measured on a real library, that alone moved the median stated
/// confidence from 100% to 31%.
pub const RIVAL_FLOOR: f32 = 0.5;
/// How confident each face's placement is, indexed like the face slice the
/// clusters came from.
///
/// A face in no group, or one with no evidence for anybody, scores 0.
///
/// `gallery` is one flag per face — which faces may be evidence at all
/// ([`crate::embedding::MIN_GALLERY_QUALITY`]). Its length is the face count.
/// A pair is evidence *about* either face but only *from* a gallery one: a
/// probe learns from the references it matched, and a reference learns nothing
/// from a probe that happened to match it, however well. Without that, the one
/// short vector in a group would be the strongest match every face in it had.
///
/// `pairs` must be the *evidence* list — scanned at [`RIVAL_FLOOR`], not at the
/// merge threshold. Passing the merge list still works but silently removes
/// every rival weaker than a merge, which is most of them, and every uniqueness
/// collapses to 1.
pub fn identity_shares(
gallery: &[bool],
clusters: &[Cluster],
pairs: &[Pair],
top: usize,
) -> Vec<f32> {
let faces = gallery.len();
// An identity is a *person*, not a group. One person routinely holds
// several anchored groups — the same reason they hold several unnamed ones
// — and keying this by group had Catherine competing with Catherine, which
// on the library it was measured against put the median suggestion onto a
// named person at 39%.
let key_of: Vec<Identity> = clusters
.iter()
.enumerate()
.map(|(g, c)| match c.person {
Some(p) => Identity::Person(p),
None => Identity::Group(g),
})
.collect();
let mut group_of = vec![usize::MAX; faces];
for (g, c) in clusters.iter().enumerate() {
for &m in &c.members {
if m < faces {
group_of[m] = g;
}
}
}
// Ordered, not hashed: the numbers are sums of floats over these buckets
// and this module inherits [`crate::cluster`]'s promise that the same input
// yields the same output, bit for bit.
let mut evidence: Vec<BTreeMap<Identity, Vec<f32>>> = vec![BTreeMap::new(); faces];
for p in pairs {
if p.i >= faces || p.j >= faces {
continue;
}
// A pair is evidence in both directions: j's identity hears about i,
// and i's identity hears about j. The pair list holds each unordered
// pair once, so both have to be recorded here — each only where the
// face doing the telling is in the gallery.
let (gi, gj) = (group_of[p.i], group_of[p.j]);
if gj != usize::MAX && gallery[p.j] {
evidence[p.i]
.entry(key_of[gj])
.or_default()
.push(p.probability);
}
if gi != usize::MAX && gallery[p.i] {
evidence[p.j]
.entry(key_of[gi])
.or_default()
.push(p.probability);
}
}
let mut out = vec![0.0; faces];
for (i, buckets) in evidence.iter_mut().enumerate() {
let mine = group_of[i];
if mine == usize::MAX {
continue;
}
let mine = key_of[mine];
let mut coherence = 0.0;
let mut ours = 0.0;
let mut rivals = 0.0;
for (&who, probabilities) in buckets.iter_mut() {
// Descending, and the ties broken by nothing: equal probabilities
// sum the same whichever order they land in.
probabilities.sort_by(|a, b| b.total_cmp(a));
let counted = probabilities.len().min(top);
let score: f32 = probabilities.iter().take(top).sum();
if who == mine {
ours = score;
coherence = score / counted as f32;
} else if matches!(who, Identity::Person(_)) {
// Only an identity the user has ruled on competes. A group
// nobody has ruled on and that matches this face is far more
// likely to be another fragment of the same person than a
// different one — see the module note, and the library it was
// measured on.
rivals += score;
}
}
let total = ours + rivals;
// No evidence at all: a face anchored into a group it has no measured
// similarity to. Nothing honest to report, so nothing is claimed.
out[i] = if total > 0.0 {
coherence * (ours / total)
} else {
0.0
};
}
out
}
#[cfg(test)]
mod tests {
use super::*;
/// An unnamed group: nobody has ruled on it, so it competes with nothing.
fn cluster(members: &[usize]) -> Cluster {
Cluster {
members: members.to_vec(),
person: None,
}
}
/// A group the user has confirmed a face onto — an identity, and therefore
/// a rival.
fn named(members: &[usize], person: u64) -> Cluster {
Cluster {
members: members.to_vec(),
person: Some(person),
}
}
fn pair(i: usize, j: usize, probability: f32) -> Pair {
Pair { i, j, probability }
}
/// `n` faces, every one of them fit to be compared against.
fn all(n: usize) -> Vec<bool> {
vec![true; n]
}
/// The failure the module exists to fix: face 0 matches its own group's
/// three members strongly, and the group has forty more it is unrelated to.
/// The old within-group mean reported ~0.07 for this.
#[test]
fn a_large_group_does_not_dilute_a_strong_match() {
let members: Vec<usize> = (0..44).collect();
let clusters = vec![cluster(&members)];
let pairs = vec![pair(0, 1, 0.99), pair(0, 2, 0.97), pair(0, 3, 0.95)];
let shares = identity_shares(&all(44), &clusters, &pairs, TOP_MATCHES);
assert!(
(shares[0] - 0.97).abs() < 1e-6,
"the mean of its three real matches, undiluted: {}",
shares[0]
);
}
/// Two named people matching equally well is a coin toss, and saying so is
/// the point — this is the sibling case FR-CULL-10 warns about.
#[test]
fn an_ambiguous_face_splits_its_confidence_between_the_rivals() {
let clusters = vec![named(&[0, 1, 2], 1), named(&[3, 4], 2)];
let pairs = vec![
pair(0, 1, 0.90),
pair(0, 2, 0.90),
pair(0, 3, 0.90),
pair(0, 4, 0.90),
];
let shares = identity_shares(&all(5), &clusters, &pairs, TOP_MATCHES);
// Coherent at 0.90, and only half of the evidence is its own.
assert!(
(shares[0] - 0.45).abs() < 1e-6,
"even evidence both ways: {}",
shares[0]
);
}
/// A rival below the merge threshold still has to count, which is why the
/// evidence scan reaches down to [`RIVAL_FLOOR`].
#[test]
fn a_rival_too_weak_to_merge_still_lowers_the_confidence() {
let clusters = vec![named(&[0, 1], 1), named(&[2, 3], 2)];
let sure = identity_shares(&all(4), &clusters, &[pair(0, 1, 0.95)], TOP_MATCHES);
let contested = identity_shares(
&all(4),
&clusters,
&[pair(0, 1, 0.95), pair(0, 2, 0.60)],
TOP_MATCHES,
);
assert_eq!(sure[0], 0.95, "nobody else to be: its coherence stands");
assert!(
contested[0] < 0.59 && contested[0] > 0.57,
"0.95 coherent, but 0.95 against 0.60: {}",
contested[0]
);
}
/// A fragment of the same person is not a rival. Measured on a real
/// library, counting unnamed groups as competition put four suggestions in
/// five under half — see the module note.
#[test]
fn an_unnamed_group_is_not_treated_as_competition() {
let clusters = vec![cluster(&[0, 1]), cluster(&[2, 3])];
let shares = identity_shares(
&all(4),
&clusters,
&[pair(0, 1, 0.95), pair(0, 2, 0.90)],
TOP_MATCHES,
);
assert_eq!(
shares[0], 0.95,
"an unnamed group took evidence off a suggestion"
);
}
/// The cap, doing its job: an identity with fifty mediocre matches must not
/// beat one with ten strong ones on volume alone.
#[test]
fn evidence_is_capped_so_the_biggest_group_cannot_win_on_volume() {
let small: Vec<usize> = (0..11).collect();
let large: Vec<usize> = (11..62).collect();
let clusters = vec![named(&small, 1), named(&large, 2)];
let mut pairs: Vec<Pair> = (1..11).map(|j| pair(0, j, 0.90)).collect();
pairs.extend((11..62).map(|j| pair(0, j, 0.55)));
let shares = identity_shares(&all(62), &clusters, &pairs, TOP_MATCHES);
// Ten at 0.90 against ten at 0.55 — not fifty-one at 0.55.
assert!(
(shares[0] - 0.90 * (9.0 / 14.5)).abs() < 1e-5,
"capped at ten either side: {}",
shares[0]
);
}
/// A probe learns from the references it matched; a reference learns
/// nothing from a probe. The pair is the same pair — what differs is who
/// is doing the telling.
#[test]
fn a_face_outside_the_gallery_is_nobody_s_evidence() {
let clusters = vec![named(&[0, 1, 2], 1)];
let gallery = vec![true, true, false];
let pairs = vec![pair(0, 1, 0.80), pair(0, 2, 0.99), pair(1, 2, 0.99)];
let shares = identity_shares(&gallery, &clusters, &pairs, TOP_MATCHES);
// Faces 0 and 1 hear only from each other: the 0.99 the probe offered
// them is not counted.
assert!((shares[0] - 0.80).abs() < 1e-6, "{}", shares[0]);
assert!((shares[1] - 0.80).abs() < 1e-6, "{}", shares[1]);
// The probe hears from both references.
assert!((shares[2] - 0.99).abs() < 1e-6, "{}", shares[2]);
}
/// A face nothing has any evidence about claims nothing.
#[test]
fn a_face_with_no_evidence_reports_no_confidence() {
let clusters = vec![cluster(&[0, 1])];
let shares = identity_shares(&all(2), &clusters, &[], TOP_MATCHES);
assert_eq!(shares, vec![0.0, 0.0]);
}
}
+33 -17
View File
@@ -21,13 +21,26 @@
//! fails. The exceptions (mirrors, photographs of photographs, collages) are
//! rare enough to be noise at this scale.
//!
//! **Positives have to be earned.** In order of trustworthiness: pairs the user
//! has confirmed onto one person; then burst siblings, since FR-CULL-5 already
//! groups bursts and two faces in adjacent frames are near-certainly the same
//! person. Nothing else — bootstrapping positives from high cosine is circular,
//! fitting the calibration to the belief it was supposed to test.
//! **Positives have to be earned.** A positive is a pair of faces the user has
//! confirmed onto one person (FR-CULL-10), and there is no second source: the
//! only labelling this subsystem has is the labelling somebody did by hand.
//! Bootstrapping positives from a high cosine is circular — it fits the
//! calibration to the belief it was supposed to test — and that is the whole
//! of the alternative.
//!
//! Which is why a fresh library has **no valid calibration**, and says so.
//! docs/faces.md §8.1 names one more that would cost no labelling at all: two
//! faces in adjacent frames of one burst are near-certainly the same person,
//! and FR-CULL-5's grouping is sitting there. Nothing draws on it. This crate
//! cannot see a catalog, let alone the bursts in one — it is handed cosines by
//! whoever assembled the pair — and no caller does that assembly on its behalf
//! yet. Until one does, and until the purity of a burst pair is *measured*
//! rather than assumed, the positives are the confirmations and nothing else.
//!
//! Which is why a fresh library has **no valid calibration** — no fit of its
//! own — and says so. It is not left without a curve: it uses the reference
//! implementation's fitted one ([`Calibration::default`]), which is a published
//! operating point rather than an invention, and the interface reports which of
//! the two it is speaking from.
/// Bins over cosine ∈ [-1, 1].
///
@@ -40,9 +53,10 @@ const BINS: usize = 200;
/// Far stricter than the reference implementation's floor of two positives and
/// one negative. That floor is reasonable there: its pairs come from a curated
/// gallery of labelled reference portraits, where a positive pair is
/// trustworthy by construction. Here the positives are bootstrapped from bursts
/// and a handful of early confirmations, and the whole risk is fitting
/// confidently to too few of them.
/// trustworthy by construction. Here every positive is a pair somebody
/// confirmed while working through a young library's suggestions — a handful,
/// arriving slowly — and the whole risk is fitting confidently to too few of
/// them.
pub const MIN_POSITIVE_PAIRS: u64 = 200;
pub const MIN_NEGATIVE_PAIRS: u64 = 2_000;
@@ -56,11 +70,13 @@ pub struct Calibration {
pub b: f32,
/// Weight on `log2(min crop_px)` — the face-size term FR-CULL-9 asks for.
pub w_size: f32,
/// Whether there was enough evidence to trust the fit.
/// Whether there was enough evidence to fit this library's own curve.
///
/// When false the UI says confidence is unavailable. It does **not** present
/// an untuned default as though it were measured, which is the distinction
/// FR-CULL-9 spends a paragraph on.
/// False means the numbers came from the built-in reference curve, and the
/// UI says so — once, at the screen level. It does **not** mean confidences
/// are withheld: FR-CULL-9's distinction is between presenting an untuned
/// default *as though it were measured* and presenting it as what it is,
/// and only the first is forbidden.
pub valid: bool,
pub positive_pairs: u64,
pub negative_pairs: u64,
@@ -70,10 +86,10 @@ impl Default for Calibration {
/// The reference implementation's fitted MBF curve (docs/faces.md §1):
/// steepness 16.2, P=0.5 at cosine 0.267.
///
/// **`valid` is false**, and that is the point. This exists so an
/// un-calibrated library has a documented operating point to cluster at
/// rather than no behaviour at all — but nothing may show its output as a
/// measured confidence.
/// **`valid` is false**, and that is the point. It is a documented
/// operating point rather than an invented one, so a library with no fit of
/// its own can both cluster and quote a probability from it — what it may
/// not do is call that probability a measurement of *this* library.
fn default() -> Self {
Self {
a: 16.2,
+528 -21
View File
@@ -19,6 +19,23 @@
//! and clustering never moves it. Two groups holding confirmations of
//! *different* people cannot merge, whatever their similarity says.
//!
//! # The gallery, and the faces that are only ever compared against it
//!
//! A third defence, and the cheapest of all: **a short embedding is never a
//! reference.** The length of the raw vector is the model's own reading of
//! how recognisable the crop was ([`crate::embedding::MIN_GALLERY_QUALITY`]),
//! and a short one sits near the centre of the sphere, matching a little of
//! everybody. One of those in a group is a bridge to the next group over.
//!
//! So the population is split. Faces at or above the floor are the
//! **gallery**, and they cluster exactly as described below. Faces under it
//! are **probes**: each is measured against the finished groups and joins the
//! one it fits, by the same average-link rule and under the same constraints
//! — but it is measured against the gallery members only, never against
//! another probe, and once placed it is never part of what the next face is
//! measured against. A blurred photograph of a known person is still named;
//! it just cannot vouch for anyone else.
//!
//! # Average link, not single link
//!
//! Single-link chains: one bad edge welds two identities together, and it is
@@ -117,6 +134,11 @@ pub struct Candidate {
pub embedding: Vec<f32>,
/// Source pixels across the aligned crop, for the calibration's size term.
pub crop_px: f32,
/// Length of the raw embedding, where it was recorded
/// ([`crate::embedding::MIN_GALLERY_QUALITY`]). `None` for a face indexed
/// before it was kept, which is admitted to the gallery — see
/// [`Candidate::in_gallery`].
pub quality: Option<f32>,
/// The person this face is *confirmed* to be, if any.
///
/// Suggestions are deliberately not passed here. They are this function's
@@ -125,6 +147,13 @@ pub struct Candidate {
pub confirmed_person: Option<u64>,
}
impl Candidate {
/// Whether this face may be compared *against*, as well as compared.
pub fn in_gallery(&self) -> bool {
crate::embedding::in_gallery(self.quality)
}
}
/// One group of faces the clusterer believes are one person.
#[derive(Debug, Clone, PartialEq)]
pub struct Cluster {
@@ -148,24 +177,279 @@ pub fn cluster(faces: &[Candidate], cal: &Calibration, min_probability: f32) ->
return Vec::new();
}
let embeddings: Vec<Vec<f32>> = faces.iter().map(|f| f.embedding.clone()).collect();
let crop_px: Vec<f32> = faces.iter().map(|f| f.crop_px).collect();
let images: Vec<u64> = faces.iter().map(|f| f.image).collect();
let view = Faces {
embeddings: &embeddings,
crop_px: &crop_px,
images: &images,
};
let columns = Columns::of(faces);
// Every pair that could ever contribute to a merge. See the module note on
// why nothing outside this list can matter.
let pairs = neighbours::above_threshold(&view, cal, min_probability);
let pairs = neighbours::above_threshold(&columns.view(), cal, min_probability);
build(faces, cal, min_probability, &pairs)
}
/// Groups, and how confident each face's placement is.
///
/// The second half is [`crate::assign`]'s share, not the pairwise probability
/// that put the face in the group — see that module for why the two are
/// different questions.
#[derive(Debug, Clone, PartialEq)]
pub struct Grouping {
pub clusters: Vec<Cluster>,
/// Indexed like the input faces. 0 for a face in no group.
pub confidence: Vec<f32>,
}
/// Group faces into people, and score each placement against its rivals.
///
/// What a caller writing suggestions into a catalog wants: [`cluster`] answers
/// *which person*, this answers *and how sure*.
///
/// One scan, two thresholds. The similarity scan is the expensive part of the
/// whole subsystem and running it twice — once to merge, once to find rivals —
/// would double the cost of regrouping a library. So it runs once at the looser
/// of the two floors, and the merge engine takes the subset at or above
/// `min_probability`. That subset is identical, pair for pair and in the same
/// order, to what a scan at `min_probability` would have produced, so grouping
/// is unchanged by scoring being asked for: `scoring_does_not_change_the_
/// groups` holds it to that.
pub fn cluster_scored(faces: &[Candidate], cal: &Calibration, min_probability: f32) -> Grouping {
if faces.is_empty() {
return Grouping {
clusters: Vec::new(),
confidence: Vec::new(),
};
}
let columns = Columns::of(faces);
let evidence = neighbours::above_threshold(
&columns.view(),
cal,
min_probability.min(crate::assign::RIVAL_FLOOR),
);
let merges: Vec<neighbours::Pair> = evidence
.iter()
.copied()
.filter(|p| p.probability >= min_probability)
.collect();
let clusters = build(faces, cal, min_probability, &merges);
let confidence = crate::assign::identity_shares(
&columns.gallery,
&clusters,
&evidence,
crate::assign::TOP_MATCHES,
);
Grouping {
clusters,
confidence,
}
}
/// The three arrays [`neighbours::Faces`] borrows, owned.
///
/// [`neighbours`] takes parallel slices rather than candidates on purpose — it
/// has no business knowing what a person is — so somebody has to hold the
/// columns. Both entry points do, identically, which is the only reason this is
/// a type and not three locals.
struct Columns {
/// Every embedding end to end — see [`Faces::embeddings`] for why flat.
embeddings: Vec<f32>,
dim: usize,
crop_px: Vec<f32>,
images: Vec<u64>,
gallery: Vec<bool>,
}
impl Columns {
fn of(faces: &[Candidate]) -> Self {
// Ragged input would index the wrong row for every face after the odd
// one, so the widest wins and short rows are padded with zeros: a
// zero-padded row scores lower against everything, which is the safe
// direction. It does not happen — one model, one dimension — and it is
// handled rather than trusted because the failure would be silent.
let dim = faces.iter().map(|f| f.embedding.len()).max().unwrap_or(0);
let mut embeddings = Vec::with_capacity(faces.len() * dim);
for f in faces {
embeddings.extend_from_slice(&f.embedding);
embeddings.resize(embeddings.len() + dim - f.embedding.len(), 0.0);
}
Self {
embeddings,
dim,
crop_px: faces.iter().map(|f| f.crop_px).collect(),
images: faces.iter().map(|f| f.image).collect(),
gallery: faces.iter().map(Candidate::in_gallery).collect(),
}
}
fn view(&self) -> Faces<'_> {
Faces {
embeddings: &self.embeddings,
dim: self.dim,
crop_px: &self.crop_px,
images: &self.images,
gallery: &self.gallery,
}
}
}
/// Agglomerate the gallery over its pairs, then place the probes.
///
/// `pairs` is what [`neighbours::above_threshold`] returned: every pair has a
/// gallery side, but a pair with a probe on the other side is not a merge —
/// it is the evidence [`place_probes`] works from. Only the gallery-to-gallery
/// pairs reach the engine, so a probe enters it as a singleton with no edges
/// and comes out exactly as it went in.
fn build(
faces: &[Candidate],
cal: &Calibration,
min_probability: f32,
pairs: &[neighbours::Pair],
) -> Vec<Cluster> {
let gallery: Vec<bool> = faces.iter().map(Candidate::in_gallery).collect();
let (merges, probe_pairs): (Vec<_>, Vec<_>) = pairs
.iter()
.copied()
.partition(|p| gallery[p.i] && gallery[p.j]);
let mut engine = Engine::new(faces, cal, min_probability);
for component in components(faces.len(), &pairs) {
engine.agglomerate(&component, &pairs);
let parts = components(faces.len(), &merges);
for (component, edges) in parts.members.iter().zip(&parts.edges) {
engine.agglomerate(component, edges);
}
engine.finish()
let dot = engine.dot;
let clusters = engine.finish();
if probe_pairs.is_empty() {
return clusters;
}
place_probes(
faces,
cal,
min_probability,
dot,
&gallery,
clusters,
&probe_pairs,
)
}
/// Put each probe into the finished group it fits, or leave it alone.
///
/// The same decision the engine makes for a singleton — average link over the
/// group, at or above `min_probability`, subject to [`Engine::can_link`]'s two
/// constraints — with one difference that is the whole point: the average is
/// over the group's **gallery** members. A probe already placed is not part of
/// what the next one is measured against, so a run of short vectors cannot
/// pull each other in one after another.
///
/// Probes are placed in index order and each placement is final, which is
/// what keeps this deterministic. The group a probe joins gains its
/// photograph, so a second face from the same frame cannot follow it — the
/// co-occurrence rule, applied exactly as the engine applies it.
fn place_probes(
faces: &[Candidate],
cal: &Calibration,
min_probability: f32,
dot: neighbours::DotFn,
gallery: &[bool],
mut clusters: Vec<Cluster>,
probe_pairs: &[neighbours::Pair],
) -> Vec<Cluster> {
// Where each face sits, and what each group's photographs and gallery
// members are. The probe's own singleton is here too, and is dropped once
// it has moved.
let mut group_of = vec![usize::MAX; faces.len()];
for (g, c) in clusters.iter().enumerate() {
for &m in &c.members {
group_of[m] = g;
}
}
let mut images: Vec<HashSet<u64>> = clusters
.iter()
.map(|c| c.members.iter().map(|&m| faces[m].image).collect())
.collect();
let references: Vec<Vec<usize>> = clusters
.iter()
.map(|c| c.members.iter().copied().filter(|&m| gallery[m]).collect())
.collect();
// Which groups each probe has any above-threshold pair into. Only those
// can average above the threshold — the argument the module note makes
// for the engine holds here unchanged.
let mut candidates: Vec<Vec<usize>> = vec![Vec::new(); faces.len()];
for p in probe_pairs {
let (probe, reference) = if gallery[p.i] { (p.j, p.i) } else { (p.i, p.j) };
candidates[probe].push(group_of[reference]);
}
let mut moved: Vec<usize> = Vec::new();
for probe in 0..faces.len() {
if gallery[probe] || candidates[probe].is_empty() {
continue;
}
let mut groups = std::mem::take(&mut candidates[probe]);
groups.sort_unstable();
groups.dedup();
let face = &faces[probe];
let mut best: Option<(f32, usize)> = None;
for g in groups {
let target = &clusters[g];
if let (Some(mine), Some(theirs)) = (face.confirmed_person, target.person) {
if mine != theirs {
continue;
}
}
if images[g].contains(&face.image) {
continue;
}
let (mut sum, mut count) = (0.0_f64, 0.0_f64);
for &r in &references[g] {
let cos = dot(&face.embedding, &faces[r].embedding);
let min_crop = face.crop_px.min(faces[r].crop_px);
sum += cal.probability(cos, min_crop, 0.0) as f64;
count += 1.0;
}
if count == 0.0 {
continue;
}
let p = (sum / count) as f32;
// Strictly better wins; on a tie the lowest group index, which is
// the engine's own tiebreak.
if p >= min_probability && best.is_none_or(|(bp, _)| p > bp) {
best = Some((p, g));
}
}
let Some((_, g)) = best else { continue };
let own = group_of[probe];
clusters[g].members.push(probe);
clusters[g].members.sort_unstable();
clusters[g].person = clusters[g].person.or(face.confirmed_person);
images[g].insert(face.image);
group_of[probe] = g;
moved.push(own);
}
if moved.is_empty() {
return clusters;
}
// The singletons the probes left behind, then the order `Engine::finish`
// promises: largest first, lowest member first among equals.
let mut vacated = vec![false; clusters.len()];
for g in moved {
vacated[g] = true;
}
let mut out: Vec<Cluster> = clusters
.into_iter()
.zip(vacated)
.filter(|(_, gone)| !gone)
.map(|(c, _)| c)
.collect();
out.sort_by(|x, y| {
y.members
.len()
.cmp(&x.members.len())
.then(x.members[0].cmp(&y.members[0]))
});
out
}
/// Split one person's faces into the groups a raised threshold separates them
@@ -266,6 +550,10 @@ impl Ord for Pending {
struct Engine<'a> {
faces: &'a [Candidate],
cal: &'a Calibration,
/// The same kernel [`neighbours`] scans with. [`Engine::cross`] is the one
/// place a dot product is computed during agglomeration, and it was using
/// the portable loop while the scan beside it had the machine's SIMD.
dot: neighbours::DotFn,
min_probability: f32,
groups: Vec<Group>,
links: HashMap<(usize, usize), Link>,
@@ -290,6 +578,7 @@ impl<'a> Engine<'a> {
Self {
faces,
cal,
dot: neighbours::fastest_dot(),
min_probability,
groups,
links: HashMap::new(),
@@ -302,10 +591,9 @@ impl<'a> Engine<'a> {
if component.len() < 2 {
return;
}
let members: HashSet<usize> = component.iter().copied().collect();
let mut heap = BinaryHeap::new();
for p in pairs.iter().filter(|p| members.contains(&p.i)) {
let mut heap = BinaryHeap::with_capacity(pairs.len());
for p in pairs {
self.links.insert(
key(p.i, p.j),
Link {
@@ -478,7 +766,7 @@ impl<'a> Engine<'a> {
let mut count = 0.0_f64;
for &i in members {
for &j in &self.groups[group].members {
let cos = neighbours::dot(&self.faces[i].embedding, &self.faces[j].embedding);
let cos = (self.dot)(&self.faces[i].embedding, &self.faces[j].embedding);
let min_crop = self.faces[i].crop_px.min(self.faces[j].crop_px);
sum += self.cal.probability(cos, min_crop, 0.0) as f64;
count += 1.0;
@@ -529,7 +817,7 @@ fn key(a: usize, b: usize) -> (usize, usize) {
/// separate and much smaller agglomeration. Returned with the members of each
/// component ascending, and the components themselves in order of their lowest
/// member — the determinism the merge order inherits.
fn components(n: usize, pairs: &[neighbours::Pair]) -> Vec<Vec<usize>> {
fn components(n: usize, pairs: &[neighbours::Pair]) -> Components {
let mut parent: Vec<usize> = (0..n).collect();
fn find(parent: &mut [usize], mut x: usize) -> usize {
@@ -556,9 +844,44 @@ fn components(n: usize, pairs: &[neighbours::Pair]) -> Vec<Vec<usize>> {
let r = find(&mut parent, i);
by_root.entry(r).or_default().push(i);
}
let mut out: Vec<Vec<usize>> = by_root.into_values().filter(|c| c.len() > 1).collect();
out.sort_unstable_by_key(|c| c[0]);
out
let mut members: Vec<Vec<usize>> = by_root.into_values().filter(|c| c.len() > 1).collect();
members.sort_unstable_by_key(|c| c[0]);
// Each pair filed under its component, in one pass.
//
// **This is not bookkeeping, it is the cost of the whole stage.** Each
// component used to scan the entire pair list for the ones that were its
// own: 475 components against 804,499 pairs on the reference library, 382
// million set lookups, and 3.06 s of a 5.93 s regroup spent before a single
// merge was considered. A pair can only ever join two faces of one
// component — that is what a component *is* — so one pass over the list
// places every pair exactly where it is needed.
let mut slot_of = vec![usize::MAX; n];
for (slot, c) in members.iter().enumerate() {
for &m in c {
slot_of[m] = slot;
}
}
let mut edges: Vec<Vec<neighbours::Pair>> = vec![Vec::new(); members.len()];
for p in pairs {
// Ordering within a component follows the global order, which is what
// the seeded heap's tiebreak — and so the determinism promise — rests
// on.
if let Some(slot) = slot_of.get(p.i).copied().filter(|&s| s != usize::MAX) {
edges[slot].push(*p);
}
}
Components { members, edges }
}
/// The connected components of the pair graph, each with the pairs inside it.
///
/// The two halves are parallel: `members[k]` and `edges[k]` describe the same
/// component.
struct Components {
members: Vec<Vec<usize>>,
edges: Vec<Vec<neighbours::Pair>>,
}
#[cfg(test)]
mod tests {
@@ -583,10 +906,19 @@ mod tests {
image,
embedding: at_cosine(identity, cosine),
crop_px: 150.0,
quality: None,
confirmed_person: None,
}
}
/// A face too short to be a reference: compared, never compared against.
fn probe(face: u64, image: u64, identity: usize, cosine: f32) -> Candidate {
Candidate {
quality: Some(crate::embedding::MIN_GALLERY_QUALITY - 5.0),
..candidate(face, image, identity, cosine)
}
}
/// A calibration steep enough that the test's cosines are unambiguous:
/// 0.6 is near-certain, 0.1 is near-impossible.
fn cal() -> Calibration {
@@ -663,6 +995,66 @@ mod tests {
assert_eq!(out.len(), 2, "clustering overrode two user confirmations");
}
/// A face at a chosen cosine to identity 0 *and* to identity 1 at once —
/// the sibling geometry, which [`at_cosine`]'s per-identity subspaces
/// cannot express.
fn contested(to_first: f32, to_second: f32) -> Vec<f32> {
let mut v = vec![0.0_f32; EMBEDDING_DIM];
v[0] = to_first;
v[2] = to_second;
v[4] = (1.0 - to_first * to_first - to_second * to_second)
.max(0.0)
.sqrt();
v
}
/// The population the scoring tests share: two faces of one person, a
/// stranger, and a face that matches the person well and the stranger
/// weakly — weakly enough that it will never merge with them, which is
/// exactly the rival a within-group score cannot see.
fn with_a_rival() -> Vec<Candidate> {
let mut x = candidate(4, 13, 0, 0.0);
x.embedding = contested(0.45, 0.37);
// The stranger is *named*: only an identity the user has asserted
// competes for a face (crate::assign).
let mut stranger = candidate(3, 12, 1, 1.0);
stranger.confirmed_person = Some(7);
vec![
candidate(1, 10, 0, 1.0),
candidate(2, 11, 0, 1.0),
stranger,
x,
]
}
/// Asking for confidences must not move a single face. The scan runs at a
/// looser floor to find rivals, and the merge engine has to see exactly the
/// pairs it would have seen without them.
#[test]
fn scoring_does_not_change_the_groups() {
let faces = with_a_rival();
let plain = cluster(&faces, &cal(), DEFAULT_MERGE_PROBABILITY);
let scored = cluster_scored(&faces, &cal(), DEFAULT_MERGE_PROBABILITY);
assert_eq!(plain, scored.clusters);
}
/// The number the user is shown answers "which of these people", so a
/// second claimant has to lower it even when it is too weak to merge.
#[test]
fn a_face_two_identities_could_claim_is_reported_as_less_certain() {
let faces = with_a_rival();
let scored = cluster_scored(&faces, &cal(), DEFAULT_MERGE_PROBABILITY);
let alone = cluster_scored(&faces[..2], &cal(), DEFAULT_MERGE_PROBABILITY);
assert!(alone.confidence[0] > 0.99, "nobody else to be");
let contested = scored.confidence[3];
assert!(
(0.6..0.85).contains(&contested),
"a face with a second claimant: {contested}"
);
}
#[test]
fn a_suggestion_joins_the_person_its_group_is_anchored_to() {
let mut anchor = candidate(1, 10, 0, 1.0);
@@ -896,6 +1288,7 @@ mod tests {
image,
embedding: at_cosine(p, cosine),
crop_px: 60.0 + ((out.len() % 11) as f32) * 25.0,
quality: None,
confirmed_person: None,
});
image += 1;
@@ -979,6 +1372,7 @@ mod tests {
image: 5_000,
embedding: at_cosine(200, 1.0),
crop_px: 150.0,
quality: None,
confirmed_person: None,
});
let out = cluster(&faces, &cal(), DEFAULT_MERGE_PROBABILITY);
@@ -988,4 +1382,117 @@ mod tests {
"the outlier was absorbed"
);
}
// ── the gallery ───────────────────────────────────────────────────────
/// A short vector is still somebody: it joins the group it matches.
#[test]
fn a_probe_joins_the_group_it_matches() {
let faces = vec![
candidate(1, 10, 0, 1.0),
candidate(2, 11, 0, 0.95),
probe(3, 12, 0, 0.92),
];
let out = cluster(&faces, &cal(), DEFAULT_MERGE_PROBABILITY);
assert_eq!(out.len(), 1);
assert_eq!(out[0].members, vec![0, 1, 2]);
}
/// Two short vectors that resemble each other are noise agreeing with
/// noise, and there is nothing in the gallery for either to be measured
/// against.
#[test]
fn two_probes_are_never_grouped_with_each_other() {
let faces = vec![probe(1, 10, 0, 1.0), probe(2, 11, 0, 0.98)];
let out = cluster(&faces, &cal(), DEFAULT_MERGE_PROBABILITY);
assert_eq!(out.len(), 2, "two probes were grouped: {out:?}");
}
/// The point of measuring against the gallery only: a probe that has been
/// placed is not a stepping stone for the next one.
#[test]
fn a_placed_probe_is_not_what_the_next_probe_is_measured_against() {
let mut first = probe(2, 11, 0, 0.6);
// 0.6 along identity 0 and 0.8 along its perpendicular: near enough to
// the reference to join it, and much nearer to the face below.
first.embedding = at_cosine(0, 0.6);
let mut second = probe(3, 12, 0, 0.0);
second.embedding = at_cosine(0, 0.0);
let faces = vec![candidate(1, 10, 0, 1.0), first, second];
let out = cluster(&faces, &cal(), DEFAULT_MERGE_PROBABILITY);
let group = out.iter().find(|c| c.members.contains(&0)).unwrap();
assert_eq!(
group.members,
vec![0, 1],
"the first probe should have joined"
);
assert!(
out.iter().any(|c| c.members == vec![2]),
"the second probe reached the group through the first: {out:?}"
);
}
/// A confirmation on a probe is still the user's word: the group it joins
/// becomes that person, and a group already someone else's is closed to it.
#[test]
fn a_probe_carries_its_confirmation_and_respects_others() {
let mut anchored = probe(3, 12, 0, 0.92);
anchored.confirmed_person = Some(7);
let faces = vec![
candidate(1, 10, 0, 1.0),
candidate(2, 11, 0, 0.95),
anchored,
];
let out = cluster(&faces, &cal(), DEFAULT_MERGE_PROBABILITY);
assert_eq!(out.len(), 1);
assert_eq!(out[0].person, Some(7));
let mut theirs = candidate(1, 10, 0, 1.0);
theirs.confirmed_person = Some(8);
let faces = vec![theirs, candidate(2, 11, 0, 0.95), {
let mut a = probe(3, 12, 0, 0.92);
a.confirmed_person = Some(7);
a
}];
let out = cluster(&faces, &cal(), DEFAULT_MERGE_PROBABILITY);
assert!(
out.iter()
.any(|c| c.members == vec![2] && c.person == Some(7)),
"a probe confirmed as one person joined another's group: {out:?}"
);
}
/// The co-occurrence rule follows a probe in: once it has joined, its
/// photograph is the group's.
#[test]
fn a_probe_cannot_join_a_group_holding_a_face_from_its_own_photograph() {
let faces = vec![
candidate(1, 10, 0, 1.0),
candidate(2, 11, 0, 0.95),
probe(3, 10, 0, 0.92),
];
let out = cluster(&faces, &cal(), DEFAULT_MERGE_PROBABILITY);
assert!(out.iter().any(|c| c.members == vec![2]), "{out:?}");
}
/// A probe's placement is scored like anyone else's, from the references
/// it matched — and the references' own scores do not hear from it.
#[test]
fn a_probe_is_scored_but_is_not_evidence() {
let gallery_only = vec![candidate(1, 10, 0, 1.0), candidate(2, 11, 0, 0.95)];
let without = cluster_scored(&gallery_only, &cal(), DEFAULT_MERGE_PROBABILITY);
let mut with_probe = gallery_only.clone();
with_probe.push(probe(3, 12, 0, 0.99));
let with = cluster_scored(&with_probe, &cal(), DEFAULT_MERGE_PROBABILITY);
assert_eq!(with.clusters[0].members, vec![0, 1, 2]);
assert!(with.confidence[2] > 0.9, "{}", with.confidence[2]);
assert_eq!(
&with.confidence[..2],
&without.confidence[..],
"a probe changed what the references were sure of"
);
}
}
+43 -5
View File
@@ -16,6 +16,41 @@ use crate::align::{Aligned112, ALIGNED_EDGE};
use crate::embedding::{normalise, Embedding, ModelId, EMBEDDING_DIM};
use crate::{install_backend, FaceError};
/// What one pass of the embedder produces: the direction, and the length.
///
/// Two fields rather than a `quality` on [`Embedding`], because every other
/// holder of an `Embedding` relies on it being unit length and compares by
/// dot product; the length is a separate fact about the same face, and it is
/// stored separately too.
#[derive(Debug, Clone, PartialEq)]
pub struct Embedded {
pub embedding: Embedding,
/// L2 norm of the raw model output.
///
/// The model's own opinion of how recognisable the crop was — see
/// [`crate::embedding::MIN_GALLERY_QUALITY`] for what it means and where
/// it is used.
pub quality: f32,
}
impl Embedded {
/// Storage form: the **raw** vector, `512 × f16`.
///
/// Not the unit vector. The length is the quality, and a store that held
/// only the direction would have thrown it away at the one moment it could
/// be known — which is what this crate used to do. Readers re-normalise
/// ([`Embedding::from_f16_bytes`]), so every comparison is still a dot
/// product, and [`crate::embedding::read_f16_bytes`] gives the length back
/// to a reader that wants it.
///
/// f16 costs nothing extra at this scale: its precision is relative, so a
/// component of a vector of length 20 is kept to the same three figures as
/// the same component scaled to length 1.
pub fn to_f16_bytes(&self) -> Vec<u8> {
self.embedding.to_f16_bytes_scaled(self.quality)
}
}
/// A loaded ArcFace graph.
pub struct Embedder {
session: ort::session::Session,
@@ -63,7 +98,7 @@ impl Embedder {
}
/// Embed one aligned face.
pub fn embed(&mut self, face: &Aligned112) -> Result<Embedding, FaceError> {
pub fn embed(&mut self, face: &Aligned112) -> Result<Embedded, FaceError> {
// `(x·255 − 127.5) / 128` — see the `/128` note in `detect::Letterbox`.
let px = face.pixels();
let mut input = Array4::<f32>::zeros((1, 3, ALIGNED_EDGE, ALIGNED_EDGE));
@@ -95,11 +130,14 @@ impl Embedder {
let mut v = Box::new([0.0_f32; EMBEDDING_DIM]);
v.copy_from_slice(&data[..EMBEDDING_DIM]);
normalise(&mut v);
let quality = normalise(&mut v);
Ok(Embedding {
model: self.model.clone(),
v,
Ok(Embedded {
embedding: Embedding {
model: self.model.clone(),
v,
},
quality,
})
}
}
+119 -18
View File
@@ -12,6 +12,43 @@
/// Embedding dimensionality. Fixed by the model family, not a parameter.
pub const EMBEDDING_DIM: usize = 512;
/// The shortest raw embedding a face may be *compared against*.
///
/// # What the length of the vector says
///
/// ArcFace is trained on the direction of its output and nothing else, and
/// the length it leaves behind turns out to be a free quality signal: the
/// magnitude grows with how recognisable the crop was to the model, and a
/// blurred, occluded, badly lit or hard-profile face comes out short. MagFace
/// (Meng et al., CVPR 2021) made that the training objective; the plain
/// ArcFace heads this crate runs already show it, weaker but usable, which is
/// why it is worth keeping the number the normalisation discards.
///
/// # Why it gates the gallery and not the face
///
/// A short vector is a bad *reference*: it sits nearer the centre of the
/// sphere than a real identity does and matches a little of everyone, which
/// is exactly the face that welds two people together in a clustering pass.
/// It is not a bad *probe* — the face is still real, still somebody, and
/// comparing it against good references is the only way it will ever be named.
/// So a face below this floor is compared against the gallery and never
/// becomes part of it: see `cluster::Candidate::in_gallery`.
///
/// 14 is the operating point for `w600k_mbf`, whose norms on the reference
/// library run from about 8 on a blur to the high 20s on a clean portrait. A
/// face whose quality was never recorded — indexed before the number was kept
/// — is not gated, because a rule that cannot be checked should admit, not
/// exclude.
pub const MIN_GALLERY_QUALITY: f32 = 14.0;
/// Whether an embedding of this quality may serve as a reference.
///
/// `None` is "not measured", and is admitted: the rule is about a number that
/// was read and found short, not about a number that is missing.
pub fn in_gallery(quality: Option<f32>) -> bool {
quality.is_none_or(|q| q >= MIN_GALLERY_QUALITY)
}
/// Which model produced an embedding.
///
/// Embeddings from different models are not comparable, and this is the one
@@ -58,10 +95,19 @@ impl Embedding {
}
/// Storage form: `512 × f16`, 1 KB per face (catalog.md §10.1).
///
/// This writes the unit vector. What the catalog stores is the raw one —
/// `embed::Embedded::to_f16_bytes` — because the length is the quality
/// and a unit vector has none left to read.
pub fn to_f16_bytes(&self) -> Vec<u8> {
self.to_f16_bytes_scaled(1.0)
}
/// The unit vector scaled by `length`, as `512 × f16`.
pub(crate) fn to_f16_bytes_scaled(&self, length: f32) -> Vec<u8> {
let mut out = Vec::with_capacity(EMBEDDING_DIM * 2);
for &x in self.v.iter() {
out.extend_from_slice(&f32_to_f16_bits(x).to_le_bytes());
out.extend_from_slice(&f32_to_f16_bits(x * length).to_le_bytes());
}
out
}
@@ -71,25 +117,42 @@ impl Embedding {
/// The f16 round-trip perturbs a unit vector by ~1e-3 in cosine — three
/// orders below the separation between a match and a non-match — but the
/// drift is free to remove and invisible if left, so it is removed here
/// rather than remembered at every call site.
/// rather than remembered at every call site. The same pass is what turns
/// a stored raw vector back into the unit one every comparison expects.
pub fn from_f16_bytes(model: ModelId, bytes: &[u8]) -> Option<Self> {
if bytes.len() != EMBEDDING_DIM * 2 {
return None;
}
let mut v = Box::new([0.0_f32; EMBEDDING_DIM]);
for (i, chunk) in bytes.chunks_exact(2).enumerate() {
v[i] = f16_bits_to_f32(u16::from_le_bytes([chunk[0], chunk[1]]));
}
normalise(&mut v);
Some(Self { model, v })
read_f16_bytes(model, bytes).map(|(e, _)| e)
}
}
/// Read a stored vector back, with the length it was stored at.
///
/// The length is the quality where the blob is a raw one, and ~1 where it is
/// a unit vector from before raw vectors were stored — which is why the
/// catalog keeps the quality beside the blob rather than deriving it from
/// this: a unit vector reads as a quality of 1, not as "unmeasured".
pub fn read_f16_bytes(model: ModelId, bytes: &[u8]) -> Option<(Embedding, f32)> {
if bytes.len() != EMBEDDING_DIM * 2 {
return None;
}
let mut v = Box::new([0.0_f32; EMBEDDING_DIM]);
for (i, chunk) in bytes.chunks_exact(2).enumerate() {
v[i] = f16_bits_to_f32(u16::from_le_bytes([chunk[0], chunk[1]]));
}
let length = normalise(&mut v);
Some((Embedding { model, v }, length))
}
fn dot(a: &[f32; EMBEDDING_DIM], b: &[f32; EMBEDDING_DIM]) -> f32 {
a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
}
pub(crate) fn normalise(v: &mut [f32; EMBEDDING_DIM]) {
/// Scale `v` to unit length, and return the length it had.
///
/// The length is the one thing about the raw output that survives being
/// thrown away by everything downstream, and it is a quality signal
/// ([`MIN_GALLERY_QUALITY`]) — so it comes back out rather than being lost
/// here.
pub(crate) fn normalise(v: &mut [f32; EMBEDDING_DIM]) -> f32 {
// Clamped rather than checked: a zero-norm embedding is a broken model,
// not a runtime condition worth an error path, and dividing by 1e-6 keeps
// the NaN out of the catalog.
@@ -97,6 +160,7 @@ pub(crate) fn normalise(v: &mut [f32; EMBEDDING_DIM]) {
for x in v.iter_mut() {
*x /= norm;
}
norm
}
// ── f16 ───────────────────────────────────────────────────────────────────
@@ -113,9 +177,10 @@ fn f32_to_f16_bits(x: f32) -> u16 {
let mant = bits & 0x007f_ffff;
if exp >= 0x1f {
// Overflow, inf, or NaN. Embeddings are unit-norm so this is the
// broken-model path; infinity is the honest answer, not a clamp that
// hides it.
// Overflow, inf, or NaN. No component of an embedding exceeds its
// length, and the lengths this model produces are in the tens, so
// this is the broken-model path; infinity is the honest answer, not a
// clamp that hides it.
return sign
| 0x7c00
| if mant != 0 && exp == 0x1f + 112 {
@@ -125,9 +190,9 @@ fn f32_to_f16_bits(x: f32) -> u16 {
};
}
if exp <= 0 {
// Subnormal or underflow. A component of a unit 512-vector is ~0.04,
// nowhere near here, so this branch exists for correctness rather than
// for traffic.
// Subnormal or underflow. A component of a unit 512-vector is ~0.04
// and a stored one is that times the length, nowhere near here, so
// this branch exists for correctness rather than for traffic.
if exp < -10 {
return sign;
}
@@ -221,6 +286,42 @@ mod tests {
}
}
#[test]
fn normalising_reports_the_length_it_removed() {
let mut v = Box::new([0.0_f32; EMBEDDING_DIM]);
v[0] = 3.0;
v[1] = 4.0;
let norm = normalise(&mut v);
assert!((norm - 5.0).abs() < 1e-6, "norm {norm}");
assert!((v[0] - 0.6).abs() < 1e-6 && (v[1] - 0.8).abs() < 1e-6);
}
/// The gate admits what it cannot measure: a face from before the number
/// was kept is not a face that was found wanting.
#[test]
fn an_unmeasured_quality_is_admitted_to_the_gallery() {
assert!(in_gallery(None));
assert!(in_gallery(Some(MIN_GALLERY_QUALITY)));
assert!(in_gallery(Some(27.5)));
assert!(!in_gallery(Some(MIN_GALLERY_QUALITY - 0.01)));
assert!(!in_gallery(Some(8.0)));
}
/// The storage form carries the length, and the length comes back out —
/// without touching the direction every comparison is made on.
#[test]
fn a_raw_vector_round_trips_with_its_length() {
let e = unit(3);
let raw = e.to_f16_bytes_scaled(21.5);
let (back, length) = read_f16_bytes(e.model.clone(), &raw).unwrap();
assert!((length - 21.5).abs() < 0.05, "length {length}");
assert!(e.cosine(&back).unwrap() > 0.9999);
// A unit vector from an older store reads as length 1, not as an
// error — see `read_f16_bytes` on why that is not "unmeasured".
let (_, one) = read_f16_bytes(e.model.clone(), &e.to_f16_bytes()).unwrap();
assert!((one - 1.0).abs() < 1e-2, "length {one}");
}
#[test]
fn f16_round_trip_rejects_a_wrong_length_blob() {
assert!(Embedding::from_f16_bytes(ModelId::new("m"), &[0u8; 100]).is_none());
+36 -6
View File
@@ -24,13 +24,15 @@
//!
//! # Why the runtime is split behind a feature
//!
//! [`calibrate`] and [`cluster`] are where this subsystem's accuracy actually
//! lives, and both are pure arithmetic over embeddings with no model in them.
//! [`calibrate`], [`cluster`] and [`assign`] are where this subsystem's accuracy
//! actually lives, and all three are pure arithmetic over embeddings with no
//! model in them.
//! They build and test without `inference`, on synthetic embeddings, on a
//! machine with no weights on it — which is what lets CI cover the part most
//! likely to be subtly wrong.
pub mod align;
pub mod assign;
pub mod calibrate;
pub mod cluster;
#[cfg(feature = "inference")]
@@ -41,14 +43,42 @@ pub mod embedding;
pub mod naming;
pub mod neighbours;
pub use align::{warp, Aligned112, Similarity, ALIGNED_EDGE, ARCFACE_TEMPLATE};
/// Smallest long edge a face crop may be sampled from.
///
/// **A floor on the crop source, not on the detector input.** The distinction
/// is the whole of FR-CULL-8 and `docs/faces.md` §7: detection letterboxes
/// every buffer into 640×640, so its input resolution decides nothing, while
/// [`warp`] samples the 112×112 the embedder sees and so converts source
/// resolution directly into embedding quality. FR-CULL-8 requires that crop to
/// come from the native render; this is the guard that catches a caller
/// sampling from a proxy instead.
///
/// 1025 rather than 1024 because 1024 is exactly `dr_thumbs::ThumbSize::Large`,
/// the stored proxy tier a caller is most likely to reach for by mistake, so
/// the floor has to exclude it rather than admit it. Written as a minimum so
/// the test is `edge < MIN_CROP_EDGE` with no boundary to get wrong.
///
/// It is a coarse guard and deliberately so: whether any *individual* crop was
/// upsampled is answered exactly by `faces.crop_px` against [`ALIGNED_EDGE`],
/// and that is the number §7b measures. This only stops a whole pass reading
/// from the wrong tier.
pub const MIN_CROP_EDGE: u32 = 1025;
pub use align::{
warp, warp_pixels, Aligned112, Pixels, Similarity, ALIGNED_EDGE, ARCFACE_TEMPLATE,
};
pub use assign::{identity_shares, RIVAL_FLOOR, TOP_MATCHES};
pub use calibrate::{Calibration, Pairs, ReliabilityBand};
pub use cluster::{cluster, split, Candidate, Cluster, DEFAULT_MERGE_PROBABILITY};
pub use cluster::{
cluster, cluster_scored, split, Candidate, Cluster, Grouping, DEFAULT_MERGE_PROBABILITY,
};
#[cfg(feature = "inference")]
pub use detect::{DetectOptions, Detection, Detector};
#[cfg(feature = "inference")]
pub use embed::Embedder;
pub use embedding::{Embedding, ModelId, EMBEDDING_DIM};
pub use embed::{Embedded, Embedder};
pub use embedding::{
in_gallery, read_f16_bytes, Embedding, ModelId, EMBEDDING_DIM, MIN_GALLERY_QUALITY,
};
pub use naming::{name_for_instance, name_instances, NamedFace};
/// What can go wrong between an image and a face.
+322 -41
View File
@@ -72,6 +72,13 @@ use crate::calibrate::Calibration;
/// work — and large enough that the per-block overhead disappears.
const BLOCK: usize = 64;
/// Columns compared against one row block before moving on.
///
/// The other half of the tiling: 64 embeddings of 512 floats is 128 KB, which
/// sits in L2 beside the row block instead of being re-read from memory for
/// every row. See [`scan_rows`] for what it was worth.
const COLUMN_TILE: usize = 64;
/// Below this many faces, do the whole thing on the calling thread.
///
/// Spawning threads for a set this small costs more than the scan.
@@ -94,13 +101,53 @@ pub struct Pair {
/// knowing what a person or a photograph is, and taking the three arrays it
/// actually reads keeps it testable on bare vectors.
pub struct Faces<'a> {
/// L2-normalised, `EMBEDDING_DIM` long, one per face.
pub embeddings: &'a [Vec<f32>],
/// Every embedding end to end, L2-normalised, [`Faces::dim`] floats each.
///
/// **Flat, not a slice of vectors**, and the difference is measurable: a
/// `&[Vec<f32>]` is one heap allocation per face and the scan chases a
/// pointer per row, which defeats both the prefetcher and the tiling this
/// module does to stay in cache. One buffer is also the layout a GPU would
/// want, which is where this is eventually going.
pub embeddings: &'a [f32],
/// Floats per embedding — [`crate::EMBEDDING_DIM`] in practice, a parameter
/// so the tests can work in 64 dimensions.
pub dim: usize,
/// Source pixels across the aligned crop, for the calibration's size term.
pub crop_px: &'a [f32],
/// Which photograph each face came from. Two faces in one frame are not
/// the same person, so those pairs are never returned (docs/faces.md §9).
pub images: &'a [u64],
/// Which faces may be compared *against* — the gallery
/// ([`crate::embedding::MIN_GALLERY_QUALITY`]).
///
/// A pair needs at least one gallery side: a probe measured against a
/// reference is a comparison, two short vectors measured against each
/// other is noise agreeing with noise, and those pairs are never returned.
/// Filtered here rather than by the caller for the same reason
/// co-occurrence is: what this module leaves out of the list stays out of
/// the graph, the components and the merge order, so nothing downstream
/// has to remember the rule.
pub gallery: &'a [bool],
}
impl Faces<'_> {
/// How many faces there are.
pub fn len(&self) -> usize {
if self.dim == 0 {
0
} else {
self.embeddings.len() / self.dim
}
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
#[inline(always)]
fn row(&self, i: usize) -> &[f32] {
&self.embeddings[i * self.dim..(i + 1) * self.dim]
}
}
/// Every pair whose calibrated probability reaches `min_probability`.
@@ -111,14 +158,20 @@ pub struct Faces<'a> {
///
/// Ordered by `(i, j)`, which is what the caller's determinism rests on.
pub fn above_threshold(faces: &Faces, cal: &Calibration, min_probability: f32) -> Vec<Pair> {
let n = faces.embeddings.len();
let n = faces.len();
if n < 2 {
return Vec::new();
}
// The loosest cosine that could clear the bar for *any* pair in the set.
// Cheaper than the sigmoid by far, and it rejects almost everything.
let tau = loosest_cosine(faces.crop_px, cal, min_probability);
let scan = Scan {
faces,
cal,
min_probability,
tau: loosest_cosine(faces.crop_px, cal, min_probability),
dot: fastest_dot(),
};
let blocks: Vec<(usize, usize)> = (0..n)
.step_by(BLOCK)
@@ -128,7 +181,7 @@ pub fn above_threshold(faces: &Faces, cal: &Calibration, min_probability: f32) -
if n < THREADS_ABOVE {
let mut out = Vec::new();
for &(from, to) in &blocks {
scan_block(faces, cal, min_probability, tau, from, to, &mut out);
scan_rows(&scan, from, to, &mut out);
}
return out;
}
@@ -158,7 +211,7 @@ pub fn above_threshold(faces: &Faces, cal: &Calibration, min_probability: f32) -
break;
};
let mut out = Vec::new();
scan_block(faces, cal, min_probability, tau, from, to, &mut out);
scan_rows(&scan, from, to, &mut out);
mine.push((b, out));
}
mine
@@ -184,40 +237,70 @@ pub fn above_threshold(faces: &Faces, cal: &Calibration, min_probability: f32) -
out
}
/// Compare rows `from..to` against everything after them.
/// Everything [`scan_rows`] needs that does not change between blocks.
///
/// The upper triangle, split by rows. Row `i` only looks at `j > i`, so every
/// unordered pair is visited exactly once and the emitted order is `(i, j)`
/// ascending within the block.
fn scan_block(
faces: &Faces,
cal: &Calibration,
/// A struct rather than eight arguments, and the grouping is real: these five
/// are fixed for a whole scan and only the row range moves.
#[derive(Clone, Copy)]
struct Scan<'a> {
faces: &'a Faces<'a>,
cal: &'a Calibration,
min_probability: f32,
tau: f32,
from: usize,
to: usize,
out: &mut Vec<Pair>,
) {
let n = faces.embeddings.len();
for i in from..to {
let a = &faces.embeddings[i];
let crop_a = faces.crop_px[i];
let image_a = faces.images[i];
for j in i + 1..n {
if image_a == faces.images[j] {
dot: DotFn,
}
/// Compare rows `from..to` against everything after them.
///
/// The upper triangle, split by rows, and **tiled on the column side too**.
/// Walking `j` from `i + 1` to `n` for one row at a time streams the whole
/// embedding array past the core once per row — 336 GB of traffic for an
/// 18,000-face library — where a column tile small enough to sit in L2 is read
/// once per *tile* of rows. Measured on that library, the tiling alone took the
/// scan from 4.64 s to 2.81 s before any change to the kernel.
///
/// Pairs come out ordered by `(i, j)`: the tiles are walked in ascending order
/// but the row loop is inside them, so the block's own output is sorted before
/// it is returned. Blocks are concatenated in row order, so the whole list is
/// ordered — which is the promise the caller's determinism rests on.
fn scan_rows(scan: &Scan, from: usize, to: usize, out: &mut Vec<Pair>) {
let Scan {
faces,
cal,
min_probability,
tau,
dot,
} = *scan;
let n = faces.len();
for tile in (from..n).step_by(COLUMN_TILE) {
let tile_end = (tile + COLUMN_TILE).min(n);
for i in from..to {
// The diagonal: nothing at or before `i` is this row's business.
let start = tile.max(i + 1);
if start >= tile_end {
continue;
}
let cos = dot(a, &faces.embeddings[j]);
// The cheap rejection, and it takes well over 99% of pairs.
if cos < tau {
continue;
}
let probability = cal.probability(cos, crop_a.min(faces.crop_px[j]), 0.0);
if probability >= min_probability {
out.push(Pair { i, j, probability });
let a = faces.row(i);
let crop_a = faces.crop_px[i];
let image_a = faces.images[i];
let gallery_a = faces.gallery[i];
for j in start..tile_end {
if image_a == faces.images[j] || !(gallery_a || faces.gallery[j]) {
continue;
}
let cos = dot(a, faces.row(j));
// The cheap rejection, and it takes well over 99% of pairs.
if cos < tau {
continue;
}
let probability = cal.probability(cos, crop_a.min(faces.crop_px[j]), 0.0);
if probability >= min_probability {
out.push(Pair { i, j, probability });
}
}
}
}
out.sort_unstable_by_key(|p| (p.i, p.j));
}
/// The lowest cosine that could yield `min_probability` for any pair in the set.
@@ -266,10 +349,144 @@ fn loosest_cosine(crop_px: &[f32], cal: &Calibration, min_probability: f32) -> f
/// something to pipeline and vectorise. The order is fixed and identical on
/// every run, which is what the caller's determinism needs — it is a different
/// order from the naive sum, not a variable one.
/// A dot product over two equal-length, L2-normalised rows.
///
/// Chosen once per scan rather than per pair — see [`fastest_dot`].
pub(crate) type DotFn = fn(&[f32], &[f32]) -> f32;
/// The widest dot product this machine can actually run.
///
/// # Why this is worth unsafe code
///
/// The scan is the arithmetic floor of the whole subsystem and it was running
/// at **0.7 flops per cycle**. The workspace builds for baseline `x86-64`,
/// which is SSE2 and no FMA, and the portable loop below was not being
/// vectorised into even that. Measured over a real 18,143-face library, on
/// twenty cores:
///
/// | kernel | scan | GFLOP/s |
/// |---|---|---|
/// | portable, untiled (what this replaced) | 4.64 s | 36 |
/// | portable, tiled | 2.81 s | 60 |
/// | **AVX2 + FMA, tiled** | **0.86 s** | **195 |
///
/// Identical pair lists — 1,531,969 — all three ways.
///
/// **Runtime detection on x86-64, unconditional on aarch64.** Advanced SIMD is
/// in the aarch64 baseline, so every Android device that runs this has NEON and
/// there is nothing to detect; on x86-64 AVX2 is not baseline and a binary that
/// assumed it would not start on older hardware.
///
/// The three kernels sum in different orders, so a cosine may differ in its
/// last bit between them. That only matters for a pair sitting exactly on the
/// threshold, and the portable kernel already sums in eight accumulators rather
/// than one, so the module was never bit-comparable with a naive sum.
pub(crate) fn fastest_dot() -> DotFn {
#[cfg(target_arch = "x86_64")]
{
if std::arch::is_x86_feature_detected!("avx2") && std::arch::is_x86_feature_detected!("fma")
{
return dot_avx2;
}
}
#[cfg(target_arch = "aarch64")]
{
return dot_neon;
}
#[allow(unreachable_code)]
dot
}
/// Eight-wide fused multiply-add, on the half of desktops that have it.
#[cfg(target_arch = "x86_64")]
fn dot_avx2(a: &[f32], b: &[f32]) -> f32 {
// SAFETY: `fastest_dot` is the only thing that hands this out, and only
// after `is_x86_feature_detected!` has said both features are present.
unsafe { dot_avx2_inner(a, b) }
}
#[cfg(target_arch = "x86_64")]
#[target_feature(enable = "avx2", enable = "fma")]
unsafe fn dot_avx2_inner(a: &[f32], b: &[f32]) -> f32 {
use std::arch::x86_64::*;
let n = a.len().min(b.len());
let (mut acc0, mut acc1) = (_mm256_setzero_ps(), _mm256_setzero_ps());
let mut k = 0;
// Two accumulators, because one FMA cannot start until the previous one
// retires and the unit is pipelined several deep.
while k + 16 <= n {
// SAFETY: `k + 16 <= n`, and `n` is within both slices.
acc0 = _mm256_fmadd_ps(
_mm256_loadu_ps(a.as_ptr().add(k)),
_mm256_loadu_ps(b.as_ptr().add(k)),
acc0,
);
acc1 = _mm256_fmadd_ps(
_mm256_loadu_ps(a.as_ptr().add(k + 8)),
_mm256_loadu_ps(b.as_ptr().add(k + 8)),
acc1,
);
k += 16;
}
let mut lanes = [0.0_f32; 8];
_mm256_storeu_ps(lanes.as_mut_ptr(), _mm256_add_ps(acc0, acc1));
let mut total = ((lanes[0] + lanes[1]) + (lanes[2] + lanes[3]))
+ ((lanes[4] + lanes[5]) + (lanes[6] + lanes[7]));
for k in k..n {
total += a[k] * b[k];
}
total
}
/// The same, four-wide, for the phone and the tablet.
///
/// No feature detection and no `target_feature`: Advanced SIMD is mandatory in
/// the aarch64 baseline, so this compiles for every Android target the app
/// builds for. The explicit `vfmaq` matters — LLVM will not fuse a multiply and
/// an add on its own without fast-math, which is most of the win.
#[cfg(target_arch = "aarch64")]
fn dot_neon(a: &[f32], b: &[f32]) -> f32 {
use std::arch::aarch64::*;
let n = a.len().min(b.len());
// SAFETY: every load below is bounded by `k + 16 <= n`, and `n` is within
// both slices. NEON needs no feature detection on aarch64.
unsafe {
let mut acc = [vdupq_n_f32(0.0); 4];
let mut k = 0;
while k + 16 <= n {
for (l, slot) in acc.iter_mut().enumerate() {
*slot = vfmaq_f32(
*slot,
vld1q_f32(a.as_ptr().add(k + l * 4)),
vld1q_f32(b.as_ptr().add(k + l * 4)),
);
}
k += 16;
}
let mut total = vaddvq_f32(vaddq_f32(
vaddq_f32(acc[0], acc[1]),
vaddq_f32(acc[2], acc[3]),
));
for k in k..n {
total += a[k] * b[k];
}
total
}
}
/// The portable fallback, and the definition the others have to agree with.
///
/// Eight accumulators so the adds are independent; that is as much as can be
/// asked of a loop that has to compile for anything.
pub(crate) fn dot(a: &[f32], b: &[f32]) -> f32 {
const LANES: usize = 8;
let mut acc = [0.0_f32; LANES];
let chunks = a.len() / LANES;
let n = a.len().min(b.len());
let chunks = n / LANES;
for c in 0..chunks {
let base = c * LANES;
@@ -279,7 +496,7 @@ pub(crate) fn dot(a: &[f32], b: &[f32]) -> f32 {
}
let mut total =
((acc[0] + acc[1]) + (acc[2] + acc[3])) + ((acc[4] + acc[5]) + (acc[6] + acc[7]));
for k in chunks * LANES..a.len() {
for k in chunks * LANES..n {
total += a[k] * b[k];
}
total
@@ -344,19 +561,26 @@ mod tests {
}
struct Set {
embeddings: Vec<Vec<f32>>,
embeddings: Vec<f32>,
crop_px: Vec<f32>,
images: Vec<u64>,
gallery: Vec<bool>,
}
impl Set {
fn faces(&self) -> Faces<'_> {
Faces {
embeddings: &self.embeddings,
dim: DIM,
crop_px: &self.crop_px,
images: &self.images,
gallery: &self.gallery,
}
}
fn len(&self) -> usize {
self.embeddings.len() / DIM
}
}
/// `groups` identities, `per` faces each, every face in its own photograph.
@@ -378,25 +602,28 @@ mod tests {
}
}
let crop_px = vec![150.0; embeddings.len()];
let gallery = vec![true; embeddings.len()];
Set {
embeddings,
embeddings: embeddings.concat(),
crop_px,
images,
gallery,
}
}
/// The unpruned, unthreaded, unblocked definition of the answer.
fn reference(faces: &Faces, cal: &Calibration, min_probability: f32) -> Vec<Pair> {
let n = faces.embeddings.len();
let n = faces.len();
let mut out = Vec::new();
for i in 0..n {
for j in i + 1..n {
if faces.images[i] == faces.images[j] {
if faces.images[i] == faces.images[j] || !(faces.gallery[i] || faces.gallery[j]) {
continue;
}
let cos: f32 = faces.embeddings[i]
let cos: f32 = faces
.row(i)
.iter()
.zip(&faces.embeddings[j])
.zip(faces.row(j))
.map(|(x, y)| x * y)
.sum();
let p = cal.probability(cos, faces.crop_px[i].min(faces.crop_px[j]), 0.0);
@@ -416,6 +643,31 @@ mod tests {
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| x.i == y.i && x.j == y.j)
}
/// The guard on the SIMD kernels, and the only check the aarch64 one gets
/// on a machine that is not aarch64: whatever [`fastest_dot`] picked has to
/// agree with the portable definition. A wrong lane index or a mishandled
/// tail would show up here as a wildly different number, not a rounding
/// difference.
#[test]
fn the_fastest_kernel_agrees_with_the_portable_one() {
let fast = fastest_dot();
for seed in 0..64u64 {
let a = vector(seed);
let b = vector(seed + 1_000);
let (want, got) = (dot(&a, &b), fast(&a, &b));
assert!(
(want - got).abs() < 1e-5,
"kernel disagreed on seed {seed}: {want} vs {got}"
);
}
// A length that is not a multiple of the widest step, so the tail is
// exercised rather than assumed away.
let a: Vec<f32> = (0..37).map(|k| k as f32 * 0.01).collect();
let b: Vec<f32> = (0..37).map(|k| 1.0 - k as f32 * 0.02).collect();
assert!((dot(&a, &b) - fast(&a, &b)).abs() < 1e-5, "tail mishandled");
}
#[test]
fn nothing_to_pair_is_no_pairs() {
let s = population(1, 1, 0.9);
@@ -438,7 +690,7 @@ mod tests {
fn the_threaded_scan_finds_exactly_what_the_reference_does() {
let s = population(300, 8, 0.97);
assert!(
s.embeddings.len() > THREADS_ABOVE,
s.len() > THREADS_ABOVE,
"population is below the threading cutoff"
);
let f = s.faces();
@@ -514,6 +766,35 @@ mod tests {
assert!(above_threshold(&s.faces(), &cal(), 0.9).is_empty());
}
/// A probe against a reference is a comparison; two probes against each
/// other is not. The rule lives here so that nothing downstream sees the
/// pair at all.
#[test]
fn two_faces_outside_the_gallery_are_never_paired() {
let mut s = population(1, 3, 1.0);
s.gallery = vec![false, false, true];
let pairs = above_threshold(&s.faces(), &cal(), 0.9);
assert!(
!pairs.iter().any(|p| p.i == 0 && p.j == 1),
"two probes were paired with each other"
);
// Each probe is still measured against the one reference.
assert!(pairs.iter().any(|p| p.i == 0 && p.j == 2));
assert!(pairs.iter().any(|p| p.i == 1 && p.j == 2));
}
#[test]
fn the_gallery_rule_matches_the_reference_at_scale() {
let mut s = population(60, 8, 0.97);
for (i, g) in s.gallery.iter_mut().enumerate() {
*g = i % 3 != 0;
}
let f = s.faces();
let got = above_threshold(&f, &cal(), 0.9);
let want = reference(&f, &cal(), 0.9);
assert!(same_pairs(&got, &want), "{} vs {}", got.len(), want.len());
}
#[test]
fn pairs_come_back_in_index_order() {
let s = population(300, 8, 0.97);
+10 -9
View File
@@ -161,7 +161,7 @@ fn main() {
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;
drain.base_mut().feather = 0.02;
pop.push(drain);
render_stack(
@@ -183,14 +183,14 @@ fn main() {
let mut brighter = subject_layer("m1", index, subject);
brighter.set_param("exposure", ParamId("exposure"), 0.45);
brighter.feather = 0.015;
brighter.base_mut().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;
darker.base_mut().feather = 0.03;
lift.push(darker);
render_stack(
@@ -221,9 +221,9 @@ fn main() {
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;
layer.base_mut().feather = 0.004;
layer.base_mut().morphology = morphology;
layer.base_mut().morph_radius = radius;
stack.push(layer);
render_stack(
@@ -307,14 +307,14 @@ fn render_stack(
pw as usize,
ph as usize,
128,
match layer.morphology {
match layer.base().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,
layer.base().morph_radius * pw.min(ph) as f32,
)
.distance
})
@@ -326,7 +326,7 @@ fn render_stack(
// 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)
.render(stack, None, Some(&subjects), Some(source), pw, ph)
.expect("rasterise masks");
let shader = compose_full(
@@ -335,6 +335,7 @@ fn render_stack(
ColourSpace::Srgb,
stack,
&SpotSet::new(),
&[],
);
adjust
.render_masked(source, &shader, ow, oh, Some(array))
+279
View File
@@ -0,0 +1,279 @@
//! Every way of editing a mask, as frames you can watch.
//!
//! The mask tools are hard to review from a still: what makes them right is
//! how the mask *moves* as a stroke is painted, as a correction is subtracted,
//! as an edge is shaped. This renders that — a synthetic photograph and one
//! frame per step of each mode — so the pipeline's behaviour can be watched
//! before any of it is wired to a finger.
//!
//! ```sh
//! cargo run -p dr-gpu --example mask_modes --release -- out
//! ffmpeg -y -framerate 12 -i out/paint-%03d.ppm out/paint.gif
//! ```
//!
//! PPM for the reason every other example here writes it: no encoder
//! dependency, and ffmpeg, ImageMagick and every viewer read it.
//!
//! # What it is really showing
//!
//! The fold that builds a layer's mask (`MaskPass::render`), through the
//! composed shader that samples it. A part drawn in the wrong order, a
//! subtraction that took the base with it, an erase stroke that punched
//! through the selection underneath — each of those is a frame here that looks
//! wrong, and none of them is visible in a single rendered still.
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext, MaskPass};
use dr_pipeline::descriptor::ParamId;
use dr_pipeline::mask::{Join, MaskLayer, MaskPart, MaskSource, MaskStack, Morphology};
use dr_pipeline::operation::compose_full;
use dr_pipeline::spot::SpotSet;
use dr_pipeline::{ops, Framing};
use dr_types::ColourSpace;
const W: u32 = 480;
const H: u32 = 320;
fn main() {
env_logger::init();
let dir = std::env::args().nth(1).unwrap_or_else(|| "out".into());
std::fs::create_dir_all(&dir).expect("output directory");
let Some(ctx) = pollster::block_on(GpuContext::new_headless()).ok() else {
eprintln!("no adapter; nothing to render");
return;
};
let source = DemosaicedImage::from_rgba8(&ctx, &scene(), W, H).expect("upload");
let mut masks = MaskPass::new(&ctx).expect("mask pass");
let mut adjust = AdjustPass::new(&ctx);
let mut shot = Shot {
source: &source,
masks: &mut masks,
adjust: &mut adjust,
dir: &dir,
};
write_ppm(&format!("{dir}/original.ppm"), &to_rgb(&scene()), W, H);
paint(&mut shot);
erase(&mut shot);
subtract(&mut shot);
invert(&mut shot);
shape(&mut shot);
println!("frames in {dir}/");
}
/// One layer, brightened hard, so the mask is legible rather than tasteful.
fn lifted(source: MaskSource) -> MaskLayer {
let mut layer = MaskLayer::new("m1", source);
layer.set_param("exposure", ParamId("exposure"), 1.6);
layer.set_param("saturation", ParamId("vibrance"), 0.6);
layer
}
/// A stroke painted from left to right across the subject, one frame per dab.
///
/// Each frame is the whole mask rasterised again, which is what the
/// application does today on every shape change — so the frames are also a
/// crude answer to "is a stroke's cost growing as it is painted".
fn paint(shot: &mut Shot) {
let mut layer = lifted(MaskSource::brush());
layer.begin_stroke(0, false, 0.13, 0.5, 1.0);
for (i, x) in steps(0.18, 0.82, 28).enumerate() {
layer.extend_stroke(0, x, 0.52 + 0.06 * (x * 9.0).sin());
shot.frame("paint", i, &layer);
}
layer.end_stroke(0);
}
/// The same layer, with an erase stroke taken back through the middle of it.
fn erase(shot: &mut Shot) {
let mut layer = lifted(MaskSource::brush());
layer.begin_stroke(0, false, 0.16, 0.5, 1.0);
for x in steps(0.18, 0.82, 20) {
layer.extend_stroke(0, x, 0.5);
}
layer.end_stroke(0);
for i in 0..8 {
shot.frame("erase", i, &layer);
}
layer.begin_stroke(0, true, 0.09, 0.7, 1.0);
for (i, x) in steps(0.25, 0.75, 20).enumerate() {
layer.extend_stroke(0, x, 0.5);
shot.frame("erase", 8 + i, &layer);
}
layer.end_stroke(0);
}
/// A correction joined to a radial selection and then taken out of it: the
/// part appears, is painted, and the mask loses exactly what it covers.
fn subtract(shot: &mut Shot) {
let mut layer = lifted(MaskSource::Radial {
centre: (0.5, 0.5),
radii: (0.42, 0.34),
angle: 0.0,
feather: 0.35,
});
for i in 0..8 {
shot.frame("subtract", i, &layer);
}
layer.push_part(MaskPart::painted("p2", Join::Subtract));
for (i, x) in steps(0.3, 0.72, 22).enumerate() {
if i == 0 {
layer.begin_stroke(1, false, 0.1, 0.6, 1.0);
}
layer.extend_stroke(1, x, 0.46);
shot.frame("subtract", 8 + i, &layer);
}
layer.end_stroke(1);
// And off again, which is the half a stroke cannot do: a part is a thing
// that can be switched off after the fact.
for i in 0..8 {
let mut without = layer.clone();
without.remove_part(1);
shot.frame("subtract", 30 + i, &without);
}
}
/// The layer turned over, and back, holding each state long enough to read.
fn invert(shot: &mut Shot) {
let mut layer = lifted(MaskSource::Radial {
centre: (0.42, 0.52),
radii: (0.3, 0.32),
angle: 0.0,
feather: 0.3,
});
for i in 0..24 {
layer.invert = (i / 8) % 2 == 1;
shot.frame("invert", i, &layer);
}
}
/// The edge controls, swept: a feather opening up, then a dilation pushing the
/// boundary out and an erosion pulling it back.
fn shape(shot: &mut Shot) {
let mut layer = lifted(MaskSource::Radial {
centre: (0.5, 0.5),
radii: (0.3, 0.3),
angle: 0.0,
feather: 0.02,
});
layer.base_mut().falloff = dr_pipeline::mask::Falloff::Smooth;
for (i, f) in steps(0.0, 0.09, 18).enumerate() {
layer.base_mut().feather = f;
shot.frame("shape", i, &layer);
}
layer.base_mut().morphology = Morphology::Dilate;
for (i, r) in steps(0.0, 0.06, 12).enumerate() {
layer.base_mut().morph_radius = r;
shot.frame("shape", 18 + i, &layer);
}
layer.base_mut().morphology = Morphology::Erode;
for (i, r) in steps(0.0, 0.06, 12).enumerate() {
layer.base_mut().morph_radius = r;
shot.frame("shape", 30 + i, &layer);
}
}
/// Everything one frame needs, so the mode functions read as what they do.
struct Shot<'a> {
source: &'a DemosaicedImage,
masks: &'a mut MaskPass,
adjust: &'a mut AdjustPass,
dir: &'a str,
}
impl Shot<'_> {
fn frame(&mut self, mode: &str, index: usize, layer: &MaskLayer) {
let mut stack = MaskStack::new();
stack.push(layer.clone());
let shader = compose_full(
&ops::chain(),
&Framing::new(),
ColourSpace::Srgb,
&stack,
&SpotSet::new(),
&[],
);
let array = self
.masks
.render(&stack, None, None, Some(self.source), W, H)
.expect("rasterise");
self.adjust
.render_masked(self.source, &shader, W, H, Some(array))
.expect("render");
let rgba = self.adjust.export_pixels().expect("readback").0;
write_ppm(
&format!("{}/{mode}-{index:03}.ppm", self.dir),
&to_rgb(&rgba),
W,
H,
);
}
}
/// `count` values from `from` to `to`, inclusive.
fn steps(from: f32, to: f32, count: usize) -> impl Iterator<Item = f32> {
(0..count).map(move |i| from + (to - from) * i as f32 / (count.max(2) - 1) as f32)
}
/// A picture with somewhere obvious to put a mask: a graded sky, a ground
/// band, and a warm subject sitting on the join.
fn scene() -> Vec<u8> {
let mut px = vec![0u8; (W * H * 4) as usize];
for y in 0..H {
for x in 0..W {
let (fx, fy) = (x as f32 / W as f32, y as f32 / H as f32);
let sky = [
(60.0 + 90.0 * fy) as u8,
(110.0 + 90.0 * fy) as u8,
(190.0 + 50.0 * fy) as u8,
];
let ground = [
(70.0 + 40.0 * fx) as u8,
(85.0 + 30.0 * fx) as u8,
(60.0 + 20.0 * fx) as u8,
];
let mut c = if fy > 0.62 { ground } else { sky };
// The subject: an ellipse, warm, with a little internal structure
// so a feathered edge has something to be soft against.
let (dx, dy) = ((fx - 0.5) / 0.22, (fy - 0.52) / 0.3);
if dx * dx + dy * dy < 1.0 {
let shade = 0.75 + 0.25 * (fx * 40.0).sin() * (fy * 30.0).cos();
c = [
(205.0 * shade) as u8,
(170.0 * shade) as u8,
(140.0 * shade) as u8,
];
}
let i = ((y * W + x) * 4) as usize;
px[i..i + 4].copy_from_slice(&[c[0], c[1], c[2], 255]);
}
}
px
}
fn to_rgb(rgba: &[u8]) -> Vec<u8> {
rgba.chunks_exact(4)
.flat_map(|p| [p[0], p[1], p[2]])
.collect()
}
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");
}
+159
View File
@@ -0,0 +1,159 @@
//! Sweep a range mask's band and show what it selects.
//!
//! A diagnostic for FR-DEV-10. The other sweep example drives global
//! parameters through `EditGraph`; a band is not one of those — it lives on a
//! `MaskLayer`, is rasterised by its own pass, and only becomes visible
//! through whatever adjustment the layer carries.
//!
//! So the layer here is given a deliberately blunt adjustment — two stops down
//! — because the question this answers is *what does the band select*, not
//! *what would a photographer do with it*. A subtle edit would show a subtle
//! selection and prove nothing.
//!
//! ```sh
//! cargo run -p dr-gpu --release --example rangesweep -- IMG.CR2 out/ luminance 21
//! cargo run -p dr-gpu --release --example rangesweep -- IMG.CR2 out/ hue 21
//! ```
use dr_gpu::{AdjustPass, DemosaicedImage, Demosaicer, GpuContext, MaskPass};
use dr_pipeline::mask::{MaskLayer, MaskSource, MaskStack};
use dr_pipeline::operation::compose_full;
use dr_pipeline::ops;
use dr_pipeline::spot::SpotSet;
use dr_pipeline::{Framing, ParamId};
use dr_types::ColourSpace;
fn main() {
env_logger::init();
let mut args = std::env::args().skip(1);
let (Some(input), Some(out_dir), Some(mode)) = (args.next(), args.next(), args.next()) else {
eprintln!("usage: rangesweep <file.cr2> <out_dir> <luminance|hue|width> [steps]");
std::process::exit(2);
};
let steps: usize = args
.next()
.and_then(|s| s.parse().ok())
.filter(|n| *n >= 2)
.unwrap_or(21);
let ctx = pollster::block_on(GpuContext::new_headless()).expect("gpu");
let image = load(&ctx, &input);
std::fs::create_dir_all(&out_dir).expect("create out dir");
let (full_w, full_h) = image.size();
let longest = std::env::var("SWEEP_MAX_PX")
.ok()
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(1000);
let scale = (longest as f32 / full_w.max(full_h) as f32).min(1.0);
let w = ((full_w as f32 * scale) as u32).max(1);
let h = ((full_h as f32 * scale) as u32).max(1);
println!("rendering {w} x {h}");
let mut masks = MaskPass::new(&ctx).expect("mask pass");
let mut adjust = AdjustPass::new(&ctx);
for i in 0..steps {
let t = i as f32 / (steps - 1) as f32;
// What moves, and what the caption should say about it.
let (source, label) = match mode.as_str() {
// A half-wide band walking from black to white, so the selection
// sweeps across the tonal scale rather than merely widening.
"luminance" => {
let centre = t;
let half = 0.15;
(
MaskSource::luminance_range(centre - half, centre + half, 0.10),
format!("luminance band centred {:.2}", centre),
)
}
// The hue circle, at a fixed arc and a chroma floor that keeps the
// near-neutral parts of the picture out of it.
"hue" => (
MaskSource::colour_range(t, 0.08, 0.05, 1.0, 0.10),
format!("hue {:.0} deg", t * 360.0),
),
// The arc opening from nothing to everything, at a fixed hue.
"width" => (
MaskSource::colour_range(0.08, t * 0.5, 0.05, 1.0, 0.10),
format!("hue width {:.0} deg", t * 0.5 * 360.0),
),
other => {
eprintln!("unknown mode `{other}`");
std::process::exit(2);
}
};
let mut layer = MaskLayer::new("sweep", source);
// Two stops down: blunt on purpose. See the module docs.
layer.set_param("exposure", ParamId("exposure"), -2.0);
let mut stack = MaskStack::new();
stack.push(layer);
// No label field and no subject masks: a range needs neither. It is a
// weighting over the picture's own values, so the only input it wants
// is the picture, which is the `Some(&image)` below.
let array = masks
.render(&stack, None, None, Some(&image), w, h)
.expect("rasterise");
let shader = compose_full(
&ops::chain(),
&Framing::new(),
ColourSpace::Srgb,
&stack,
&SpotSet::new(),
&[],
);
adjust
.render_masked(&image, &shader, w, h, Some(array))
.expect("render");
let (pixels, pw, ph) = adjust.export_pixels().expect("readback");
let path = format!("{out_dir}/{mode}_{i:03}.ppm");
write_ppm(&path, &pixels, pw, ph);
std::fs::write(format!("{out_dir}/{mode}_{i:03}.txt"), format!("{label}\n"))
.expect("write label");
println!("{mode}[{i}] {label}");
}
}
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");
}
/// Load either a RAW or an already-rendered image.
///
/// A JPEG takes the path `DemosaicedImage::from_rgba8` documents: no CFA to
/// interpolate, identity colour matrix, neutral white balance, and the shader
/// linearises the gamma-encoded pixels. The controls all still work; their
/// neutral is "as the camera left it" rather than "as the sensor recorded it",
/// which is worth knowing when reading a sweep made from one.
fn load(ctx: &GpuContext, path: &str) -> DemosaicedImage {
let bytes = std::fs::read(path).expect("read file");
match dr_decode::probe(&bytes) {
Some(dr_types::Format::Jpeg) => {
let p = dr_decode::decode_jpeg(&bytes).expect("decode jpeg");
println!("loaded {} x {} (rendered, not raw)", p.width, p.height);
DemosaicedImage::from_rgba8(ctx, &p.rgba, p.width, p.height).expect("upload")
}
_ => {
let raw = dr_decode::decode(&bytes).expect("decode raw");
println!("loaded {} x {} (raw)", raw.crop.width, raw.crop.height);
let demosaic = Demosaicer::new(ctx).expect("demosaicer");
demosaic.run(&raw).expect("demosaic")
}
}
}
+239
View File
@@ -0,0 +1,239 @@
//! Sweep one operation's parameters and write a frame per step.
//!
//! A diagnostic, not part of the product: it exists to show what a control
//! actually does to a photograph, one parameter at a time, so a new node can
//! be looked at rather than reasoned about.
//!
//! ```sh
//! cargo run -p dr-gpu --release --example sweep -- IMG.CR2 out/ colour_grading 21
//! ```
//!
//! It names no operation. The op id arrives as a string, the parameters and
//! their ranges come from the graph's own capabilities, and a node declared
//! yesterday sweeps on the same terms as one that shipped a year ago — which
//! is the property `ops/README.md` promises and the reason this is one example
//! rather than one per node.
//!
//! PPM out, like `develop.rs`, so it needs no encoder dependency; the caller
//! turns them into whatever it wants.
use dr_gpu::{AdjustPass, DemosaicedImage, Demosaicer, GpuContext};
use dr_pipeline::{Affects, EditGraph, OpId, ParamId, ParamKind};
use dr_types::ColourSpace;
fn main() {
env_logger::init();
let mut args = std::env::args().skip(1);
let (Some(input), Some(out_dir), Some(op)) = (args.next(), args.next(), args.next()) else {
eprintln!("usage: sweep <file.cr2> <out_dir> <op_id> [steps]");
eprintln!(" sweep <file.cr2> <out_dir> --list");
std::process::exit(2);
};
let steps: usize = args
.next()
.and_then(|s| s.parse().ok())
.filter(|n| *n >= 2)
.unwrap_or(21);
// Sweep one named parameter rather than all of them.
let only: Option<String> = args.next();
let ctx = pollster::block_on(GpuContext::new_headless()).expect("gpu");
let image = load(&ctx, &input);
let mut graph = EditGraph::default_chain();
// `--list` prints every op and parameter with its range, which is how the
// caller learns what there is to sweep without this file holding a list
// that would go stale.
if op == "--list" {
for cap in graph.capabilities() {
println!("{}", cap.id.0);
for p in &cap.params {
match p.kind {
ParamKind::Scalar { min, max, .. } => {
println!(" {:<20} {min} .. {max} default {}", p.id.0, p.default)
}
ParamKind::Bool => println!(" {:<20} bool", p.id.0),
ParamKind::Enum { ref variants } => {
println!(" {:<20} enum, {} variants", p.id.0, variants.len())
}
}
}
}
return;
}
std::fs::create_dir_all(&out_dir).expect("create out dir");
// `OpId`/`ParamId` hold `&'static str`, and an argument is not static.
// Leaking is right rather than expedient here: the ids live as long as the
// graph does, and this process exits immediately after.
let op_id = OpId(Box::leak(op.clone().into_boxed_str()));
let cap = graph
.capabilities()
.into_iter()
.find(|c| c.id == op_id)
.unwrap_or_else(|| {
eprintln!("no operation `{op}` — try --list");
std::process::exit(1);
});
// Bounded output rather than full sensor resolution. This is the proxy
// path FR-DSP-1 already renders through, so it is the same code the
// develop view uses — and a 25 MP frame would be a 75 MB PPM, times
// several hundred frames in one sweep.
let (full_w, full_h) = image.size();
let longest = std::env::var("SWEEP_MAX_PX")
.ok()
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(1100);
let scale = (longest as f32 / full_w.max(full_h) as f32).min(1.0);
let w = ((full_w as f32 * scale) as u32).max(1);
let h = ((full_h as f32 * scale) as u32).max(1);
println!("rendering {w} x {h} (from {full_w} x {full_h})");
let mut adjust = AdjustPass::new(&ctx);
// The reference frame: every parameter at its default. Written once so a
// viewer can see what the sweep is departing from.
render_to(
&mut adjust,
&image,
&graph,
w,
h,
&out_dir,
"neutral",
0,
0.0,
);
// Parameters held away from their default for the duration, as
// `SWEEP_HOLD=shadow_strength=70,midtone_hue=210`.
//
// Needed because a parameter is not always meaningful alone. Where a
// `presentation:` block groups several into one conceptual control — a
// hue and the strength behind it — sweeping one with the other at its
// default renders the same frame every time, which looks like a broken
// node rather than a correctly declared neutral.
let hold = std::env::var("SWEEP_HOLD").unwrap_or_default();
for clause in hold.split(',').filter(|c| !c.trim().is_empty()) {
let Some((name, value)) = clause.split_once('=') else {
eprintln!("SWEEP_HOLD wants name=value, got `{clause}`");
std::process::exit(2);
};
let value: f32 = value.trim().parse().expect("hold value");
let name = Box::leak(name.trim().to_string().into_boxed_str());
graph.set_param(op_id, ParamId(name), value);
println!("holding {name} = {value}");
}
for p in &cap.params {
if only.as_deref().is_some_and(|o| o != p.id.0) {
continue;
}
let ParamKind::Scalar { min, max, .. } = p.kind else {
eprintln!("skipping {} — only scalars sweep meaningfully", p.id.0);
continue;
};
let param_id = ParamId(Box::leak(p.id.0.to_string().into_boxed_str()));
for i in 0..steps {
let t = i as f32 / (steps - 1) as f32;
let value = min + (max - min) * t;
graph.set_param(op_id, param_id, value);
render_to(
&mut adjust,
&image,
&graph,
w,
h,
&out_dir,
p.id.0,
i,
value,
);
}
// Back to default before the next parameter, so each sweep is of one
// control rather than of everything tried so far.
graph.set_param(op_id, param_id, p.default);
}
}
#[allow(clippy::too_many_arguments)]
fn render_to(
adjust: &mut AdjustPass,
image: &dr_gpu::DemosaicedImage,
graph: &EditGraph,
w: u32,
h: u32,
dir: &str,
name: &str,
index: usize,
value: f32,
) {
// The detail path, always. A neighbourhood node — dehaze, clarity,
// sharpening — runs as its own dispatch after the fused pass, and the
// fused path refuses a shader composed with one rather than rendering it
// wrongly. `render_detailed` falls through to the plain path when the
// chain has no detail stage, so this one call serves both kinds of node
// and the example never has to know which it was handed.
let shader = graph.compose_for(ColourSpace::Srgb);
let scale = graph.render_scale(image.size(), (w, h));
let detail =
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
let key = graph.invalidation().through(Affects::Colour);
adjust
.render_detailed(image, &shader, w, h, None, &detail, key)
.expect("adjust");
let (pixels, pw, ph) = adjust.export_pixels().expect("readback");
let path = format!("{dir}/{name}_{index:03}.ppm");
write_ppm(&path, &pixels, pw, ph);
// The value goes beside the frame rather than into the filename: a caption
// wants "-37.5", and a filename that carried it would need escaping and
// would sort wrongly.
let meta = format!("{dir}/{name}_{index:03}.txt");
std::fs::write(meta, format!("{name} {value:.4}\n")).expect("write value");
println!("{name}[{index}] = {value:.4}");
}
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");
}
/// Load either a RAW or an already-rendered image.
///
/// A JPEG takes the path `DemosaicedImage::from_rgba8` documents: no CFA to
/// interpolate, identity colour matrix, neutral white balance, and the shader
/// linearises the gamma-encoded pixels. The controls all still work; their
/// neutral is "as the camera left it" rather than "as the sensor recorded it",
/// which is worth knowing when reading a sweep made from one.
fn load(ctx: &GpuContext, path: &str) -> DemosaicedImage {
let bytes = std::fs::read(path).expect("read file");
match dr_decode::probe(&bytes) {
Some(dr_types::Format::Jpeg) => {
let p = dr_decode::decode_jpeg(&bytes).expect("decode jpeg");
println!("loaded {} x {} (rendered, not raw)", p.width, p.height);
DemosaicedImage::from_rgba8(ctx, &p.rgba, p.width, p.height).expect("upload")
}
_ => {
let raw = dr_decode::decode(&bytes).expect("decode raw");
println!("loaded {} x {} (raw)", raw.crop.width, raw.crop.height);
let demosaic = Demosaicer::new(ctx).expect("demosaicer");
demosaic.run(&raw).expect("demosaic")
}
}
}
+54 -2
View File
@@ -1026,6 +1026,44 @@ impl AdjustPass {
h
}
/// TRACES: FR-PLAT-AND-5 | NFR-RES-1
/// Give back every allocation this pass is holding only to be fast again.
///
/// What goes, and why each is safe to lose:
///
/// - **The compiled pipelines**, here and in the detail stage. A pure
/// lookup keyed by structure hash with a compile-on-miss behind it, and
/// unbounded until now — nothing ever removed an entry, so a session
/// that visited enough distinct edit structures accumulated shader
/// objects for the life of the process.
/// - **The detail intermediates**, which are viewport-sized `Rgba16Float`
/// and, as `detail.rs` says of them, grow but never shrink.
/// - **The two output textures.** Dropping these does not take the picture
/// off the screen: whatever was handed to the compositor holds its own
/// reference to the `wgpu::Texture`, so releasing ours only means the
/// *next* render allocates rather than reuses. `ensure_target` already
/// treats an empty slot as "allocate", because that is the state it
/// starts in.
///
/// **`colour_key` must be cleared with them, and this is the part that
/// would bite.** The key is the promise that slot 0 of the detail pool
/// still holds the fused colour result, and it is what lets a sharpening
/// slider skip the colour chain (FR-DEV-3d). Freeing the pool while the
/// promise stood would make the next detail-only render sample a
/// just-allocated texture with nothing in it — a silently wrong frame, not
/// a failure, and one that would only appear on a device under memory
/// pressure.
///
/// What deliberately stays: the demosaiced source is not this pass's to
/// drop, the film tables are set once by a caller that will not be asked
/// again, and the bind group layouts are bytes rather than megabytes.
pub fn release_caches(&mut self) {
self.cache.clear();
self.detail.release_caches();
self.targets = [None, None];
self.colour_key = None;
}
/// How many distinct pipelines are compiled. Exposed for tests asserting
/// that slider movement does not recompile.
pub fn cached_pipelines(&self) -> usize {
@@ -1807,7 +1845,21 @@ mod tests {
fused_blocks += usize::from(point);
}
// The count the loop above accumulated, plus framing — which emits a
// The lens corrections, which are the third kind of block. They are
// not in `descriptors` — they rewrite coordinates rather than
// transform a colour, so they are not operations — and they run ahead
// of the fetch rather than in either stage the loop above sorts into.
let mut warp_blocks = 0;
for desc in g.warp_descriptors() {
let id = desc.id.0;
assert!(
shader.source.contains(&format!("---- warp: {id} ----")),
"{id} was armed above and did not reach the shader"
);
warp_blocks += 1;
}
// The counts the loops above accumulated, plus framing — which emits a
// stage of its own rather than an operation block and is not in
// `descriptors`. Asserted as well as the per-operation exclusive-or
// because the two catch different faults: the XOR catches an operation
@@ -1815,7 +1867,7 @@ mod tests {
// in the chain asked for.
assert_eq!(
shader.source.matches("---- ").count(),
fused_blocks + 1,
fused_blocks + warp_blocks + 1,
"the fused shader carries a block nothing in the chain asked for"
);
assert!(
+215 -11
View File
@@ -49,6 +49,32 @@
//! 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.
//!
//! # The reduced chain, and why a second one was needed
//!
//! A pass may declare [`dr_pipeline::detail::DetailPass::output_scale`] and
//! write a target a fraction of the render size — clarity's base does, which
//! is what TD-4 bought back. Such a pass cannot be part of the ping-pong
//! above, and the reason is the shape of an unsharp mask rather than anything
//! about textures: the pass that *combines* needs the blur **and** the
//! full-resolution colour, and a colour that has been through a quarter-scale
//! target is no longer full resolution. If the scaled passes wrote into the
//! main chain they would destroy the very thing the last pass is going to
//! subtract from.
//!
//! So there are two chains. The full-resolution one carries the colour and is
//! untouched by a scaled pass; the reduced one carries the base. A scaled pass
//! reads the reduced chain if anything has been written to it and the
//! full-resolution chain otherwise — which is exactly "read what the pass
//! before you wrote", the same rule as before. A full-resolution pass always
//! reads the full-resolution chain, and sees the reduced one through binding 4
//! as `reduced_at()`.
//!
//! One reduced buffer, not one per operation. Two operations both wanting a
//! reduced base in the same frame would need more, and nothing does: clarity
//! is the only caller and texture's band is a decade finer, so it must stay at
//! full resolution. A `debug_assert` in [`DetailRunner::encode`] holds that
//! claim rather than leaving it as a comment.
use std::collections::HashMap;
@@ -131,6 +157,24 @@ impl Intermediates {
self.allocations += 1;
}
}
/// TRACES: FR-PLAT-AND-5
/// Drop the pool, leaving it as [`Intermediates::new`] left it.
///
/// The size is reset along with the slots, not merely because it is tidy:
/// [`Self::ensure`] only refills when the count is short *or* the size
/// differs, so a pool cleared while still claiming its old dimensions is
/// indistinguishable from one that never held anything — which is fine
/// here, and would stop being fine the moment `ensure` grew a fast path
/// that trusted the stored size. `allocations` deliberately keeps
/// counting: it exists so a test can see textures being made, and a
/// counter reset on eviction would hide a reallocation storm rather than
/// report one.
fn release(&mut self) {
self.slots.clear();
self.width = 0;
self.height = 0;
}
}
/// Runs the detail stage.
@@ -149,8 +193,14 @@ pub(crate) struct DetailRunner {
/// Compiled pipelines by pass structure hash.
cache: HashMap<u64, wgpu::ComputePipeline>,
pool: Intermediates,
/// The reduced chain — see the module documentation. Its own pool rather
/// than more slots in `pool`, because its textures are a different size
/// and [`Intermediates::ensure`] drops the lot when the size changes.
reduced: Intermediates,
/// See [`placeholder_instances`].
no_instances: wgpu::Buffer,
/// See [`placeholder_reduced`].
no_reduced: wgpu::TextureView,
}
struct Layout {
@@ -174,6 +224,32 @@ fn placeholder_instances(ctx: &GpuContext) -> wgpu::Buffer {
})
}
/// What binding 4 holds for a pass that never calls `reduced_at`.
///
/// The same trick as [`placeholder_instances`], for the same reason: one bind
/// group layout has to serve a pass that reads the reduced chain and a pass
/// that has never heard of it, and a binding cannot be left unbound. 1x1 and
/// allocated once, so the cost of the arrangement is four bytes for the life
/// of the runner.
fn placeholder_reduced(ctx: &GpuContext) -> wgpu::TextureView {
ctx.device
.create_texture(&wgpu::TextureDescriptor {
label: Some("detail-reduced-placeholder"),
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: INTERMEDIATE_FORMAT,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
.create_view(&Default::default())
}
impl DetailRunner {
pub(crate) fn new(ctx: &GpuContext) -> Self {
Self {
@@ -182,7 +258,9 @@ impl DetailRunner {
to_output: Layout::new(ctx, crate::AdjustPass::FORMAT, "detail-output"),
cache: HashMap::new(),
pool: Intermediates::new(),
reduced: Intermediates::new(),
no_instances: placeholder_instances(ctx),
no_reduced: placeholder_reduced(ctx),
}
}
@@ -221,18 +299,93 @@ impl DetailRunner {
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;
// The reduced chain's size, allocated once for the whole chain.
//
// One scale per chain, so the first scaled pass names the only scale
// there is — see the module documentation for why one buffer is
// enough, and the assertion for what would have to change.
if let Some(scale) = chain
.passes
.iter()
.map(|p| p.output_scale)
.find(|&scale| scale > 1)
{
debug_assert!(
chain
.passes
.iter()
.all(|p| p.output_scale == 1 || p.output_scale == scale),
"two reduced scales in one chain, and the runner holds one \
reduced buffer"
);
// Two, for the ping-pong the reduce and the two blur halves need.
// A separable blur cannot read the texture it is writing.
self.reduced.ensure(
&self.ctx,
2,
width.div_ceil(scale).max(1),
height.div_ceil(scale).max(1),
);
}
// Where each chain last wrote. `full` starts at slot 0 — what the
// fused colour pass left there — and `carried` starts empty, which is
// what makes the first scaled pass read the colour rather than an
// uninitialised base.
let mut full = 0usize;
let mut full_writes = 0usize;
let mut carried: Option<usize> = None;
let mut reduced_writes = 0usize;
for pass in chain.passes.iter() {
let scaled = pass.output_scale > 1;
// The last pass carries the output transform into the display
// texture, which is the render size by definition. A scaled pass
// there would bind a shader dispatching over a quarter-size grid
// to a full-size target and write a quarter of the picture — a
// wrong image rather than a validation failure, so it is caught
// here and named.
if scaled && pass.writes_output {
return Err(GpuError::ShaderCompilation(format!(
"detail pass {} declares output_scale {} and is last in \
the chain; the output transform is written at the render \
size",
pass.label, pass.output_scale
)));
}
let (dispatch_w, dispatch_h) = if scaled {
(
width.div_ceil(pass.output_scale).max(1),
height.div_ceil(pass.output_scale).max(1),
)
} else {
(width, height)
};
// Read what the previous pass in *this pass's own chain* wrote;
// write the next slot of it, or the display texture if this is the
// last pass. Alternating slots is what stops a pass reading the
// texture it is writing — on a compute pass that is not an error
// the driver reports, merely a picture that depends on scheduling.
let source = match (scaled, carried) {
(true, Some(slot)) => &self.reduced.slots[slot].view,
_ => &self.pool.slots[full].view,
};
let destination = if pass.writes_output {
output
} else if scaled {
&self.reduced.slots[reduced_writes % 2].view
} else {
&self.pool.slots[1 + (index % 2)].view
&self.pool.slots[1 + (full_writes % 2)].view
};
// Binding 4. Present for every pass, because one bind group layout
// serves both kinds; a pass that never calls `reduced_at` gets the
// 1x1 placeholder and never reads it.
let reduced_source = match carried {
Some(slot) => &self.reduced.slots[slot].view,
None => &self.no_reduced,
};
let layout = if pass.writes_output {
&self.to_output
@@ -290,6 +443,10 @@ impl DetailRunner {
binding: 3,
resource: instances.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::TextureView(reduced_source),
},
],
});
@@ -304,7 +461,23 @@ impl DetailRunner {
});
compute.set_pipeline(pipeline);
compute.set_bind_group(0, &bind_group, &[]);
compute.dispatch_workgroups(width.div_ceil(8), height.div_ceil(8), 1);
compute.dispatch_workgroups(dispatch_w.div_ceil(8), dispatch_h.div_ceil(8), 1);
drop(compute);
if pass.writes_output {
// Nothing downstream to hand anything to.
} else if scaled {
carried = Some(reduced_writes % 2);
reduced_writes += 1;
} else {
full = 1 + (full_writes % 2);
full_writes += 1;
// A full-resolution pass consumes the reduced chain. It is the
// combine — the base has been subtracted and now lives in the
// colour — so a later operation must not be handed a base
// belonging to this one.
carried = None;
}
}
Ok(chain.passes.len())
@@ -371,11 +544,27 @@ impl DetailRunner {
self.cache.len()
}
/// TRACES: FR-PLAT-AND-5
/// Give back everything this stage is only holding to be fast.
///
/// Both pools and the pipeline cache. Nothing here is state: a pool slot
/// is re-created by the next [`Intermediates::ensure`] and a pipeline by
/// the next compile-on-miss, so the only cost of this call is the work of
/// doing both again.
pub(crate) fn release_caches(&mut self) {
self.cache.clear();
self.pool.release();
self.reduced.release();
}
/// 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
// Both pools. A reduced buffer reallocated every frame is exactly the
// regression this counter exists to catch, and counting only the
// full-resolution one would hide it.
self.pool.allocations + self.reduced.allocations
}
}
@@ -439,6 +628,21 @@ impl Layout {
// that never reads the buffer costs nothing for it being
// bound.
storage_entry(3),
// The reduced chain, for a pass that calls `reduced_at`.
// Bound on every layout for the same reason binding 3 is:
// a pass that never reads it costs nothing for it being
// there, and two more layouts would cost a great deal more
// than that.
wgpu::BindGroupLayoutEntry {
binding: 4,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
],
});
File diff suppressed because it is too large Load Diff
+249 -33
View File
@@ -1,8 +1,12 @@
//! TRACES: NFR-PORT-2
//! 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.
//! It began as a spike proving one thing — that a compute shader can write a
//! texture reaching the screen without a CPU round-trip (ARCH §6.1) — and the
//! module doc said for eight releases that it held no pipeline and no masks.
//! It holds both now, plus demosaic, detail, segmentation masks, two
//! histograms and focus peaking. The zero-copy claim is still the one that
//! matters, and TD-1 records the one platform where it does not hold.
//!
//! 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.
@@ -21,8 +25,10 @@ mod adjust;
mod demosaic;
mod detail;
mod error;
mod focus;
mod histogram;
mod mask;
mod raw_histogram;
mod readback;
mod segment;
pub use adjust::AdjustPass;
@@ -33,10 +39,20 @@ pub use adjust::AdjustPass;
pub use demosaic::{DemosaicedImage, Demosaicer};
pub use detail::INTERMEDIATE_FORMAT as DETAIL_INTERMEDIATE_FORMAT;
pub use error::GpuError;
pub use focus::{FocusPeakPass, FocusPeaking, PeakColour, PeakSensitivity};
// 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};
// Renamed on the way out on the same terms as the display histogram's
// constants above, and kept distinct from them because the two axes are
// different quantities: one counts output code values, the other counts stops
// below sensor saturation. A caller that confused them would draw a correct
// plot against the wrong scale.
pub use raw_histogram::{
RawHistogram, RawHistogramPass, BINS as RAW_HISTOGRAM_BINS,
BINS_PER_STOP as RAW_HISTOGRAM_BINS_PER_STOP, STOPS as RAW_HISTOGRAM_STOPS,
};
pub use segment::{SegmentOptions, SegmentPass, Segmentation};
/// Owns the wgpu device and queue.
@@ -74,6 +90,68 @@ pub struct SharedGpu {
pub adapter: wgpu::Adapter,
}
/// TRACES: FR-DSP-1 | NFR-RES-4
/// Which GPU to prefer, on a machine with more than one.
///
/// **Not obviously the fastest one**, which is why this is a choice rather
/// than a constant. A discrete card wins on raw compute and loses on every
/// byte that has to reach it: a 24 MP frame is ~96 MB of RGBA, and each
/// upload and each export readback crosses PCIe. An integrated GPU shares
/// memory with the CPU, so those transfers are not transfers. It also does not
/// empty a laptop battery.
///
/// Which of those dominates depends on the work — a slider drag over a
/// resident texture is compute-bound and favours the discrete card, while
/// import, export and thumbnailing are transfer-heavy — so the honest thing is
/// to let it be set rather than to assume.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AdapterPreference {
/// The most capable GPU. What this has always done, and the default: it is
/// the right answer for interactive editing, which is the frame budget
/// that FR-DSP-3 actually measures.
#[default]
Performance,
/// An integrated GPU where there is one — shared memory, no bus crossing,
/// and far less power.
Efficiency,
}
impl AdapterPreference {
/// Read the override, defaulting to [`Performance`](Self::Performance).
///
/// An environment variable rather than a setting, *for now*: this belongs
/// on the settings page beside the cache budget, and putting it there
/// needs a control and a restart prompt, because the device is opened once
/// at startup and shared with the compositor. The variable is what makes
/// the choice testable and gives someone with a broken primary GPU a way
/// out today.
pub fn from_env() -> Self {
match std::env::var("DARKROOM_GPU").as_deref() {
Ok("integrated") | Ok("efficiency") | Ok("igpu") => Self::Efficiency,
_ => Self::Performance,
}
}
/// How much we want an adapter, lowest first.
///
/// A CPU adapter sorts last under both policies rather than being
/// excluded: software rendering is a poor experience and a working one,
/// and on a machine where every real GPU has failed it is the difference
/// between a slow editor and no editor.
fn rank(self, device_type: wgpu::DeviceType) -> u8 {
use wgpu::DeviceType as D;
match (self, device_type) {
(_, D::Cpu) => 4,
(Self::Performance, D::DiscreteGpu) => 0,
(Self::Performance, D::IntegratedGpu) => 1,
(Self::Efficiency, D::IntegratedGpu) => 0,
(Self::Efficiency, D::DiscreteGpu) => 1,
(_, D::VirtualGpu) => 2,
(_, D::Other) => 3,
}
}
}
impl GpuContext {
/// Create a headless context — no surface, no window.
///
@@ -111,6 +189,38 @@ impl GpuContext {
Self::open(wgpu::Backends::VULKAN).await
}
/// TRACES: FR-DSP-1 | NFR-R1
/// Open a device, trying every adapter rather than only the best one.
///
/// # Why this is not `request_adapter`
///
/// `request_adapter` with `HighPerformance` returns *one* adapter and no
/// second chance. That is the right answer on a healthy machine and the
/// wrong one on a machine with a sick GPU, which is not a rare state:
/// observed 2026-08-29 on a laptop whose discrete card had hit an NVRM
/// assertion failure and a fullchip reset. The driver still advertised the
/// adapter, `request_adapter` dutifully picked it as the highest
/// performing, and the process died on it — while a working integrated GPU
/// and a working external card sat unused in the same enumeration.
///
/// A photo editor that will not start because the *fastest* GPU is broken,
/// on a machine holding two that are not, is worse than a slow one.
///
/// So: enumerate, order by how much we want each, and take the first that
/// actually yields a device. The ordering reproduces what
/// `HighPerformance` means — discrete, then integrated, then anything —
/// so the healthy case picks exactly what it picked before and pays one
/// extra enumeration for it.
///
/// # What this cannot do
///
/// A GPU sick enough to accept `request_device` and fail later is still
/// fatal, because the failure arrives as a segfault inside the driver
/// rather than as an error we could catch. This moves the boundary from
/// "the preferred adapter is unusable" to "the preferred adapter is
/// unusable *and* dishonest about it"; it does not remove it. Device loss
/// after a successful open is a different problem with a different answer
/// (ARCH §5.6).
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`,
@@ -119,27 +229,61 @@ impl GpuContext {
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 mut adapters: Vec<wgpu::Adapter> = instance.enumerate_adapters(backends).await;
if adapters.is_empty() {
return Err(GpuError::NoAdapter);
}
let policy = AdapterPreference::from_env();
adapters.sort_by_key(|a| policy.rank(a.get_info().device_type));
let adapter_info = adapter.get_info();
log::info!(
"gpu: {} ({:?}, {:?})",
adapter_info.name,
adapter_info.device_type,
adapter_info.backend
);
// Kept so a total failure can say what it tried. "No suitable GPU
// adapter found" on a machine with three of them sends the reader to
// look for a driver that is installed and loaded.
let mut refusals: Vec<String> = Vec::new();
let (device, queue) = adapter
for adapter in adapters {
let adapter_info = adapter.get_info();
match Self::device_from(&adapter).await {
Ok((device, queue)) => {
log::info!(
"gpu: {} ({:?}, {:?})",
adapter_info.name,
adapter_info.device_type,
adapter_info.backend
);
if !refusals.is_empty() {
// At `info`, not `debug`: the user is now running on
// their second-choice GPU and any performance
// complaint that follows begins here.
log::info!(
"gpu: fell back after {} unusable adapter(s): {}",
refusals.len(),
refusals.join("; ")
);
}
return Ok(SharedGpu {
ctx: Self {
device: Arc::new(device),
queue: Arc::new(queue),
adapter_info,
},
instance,
adapter,
});
}
Err(e) => refusals.push(format!("{} ({e})", adapter_info.name)),
}
}
Err(GpuError::DeviceRequest(format!(
"every adapter refused a device: {}",
refusals.join("; ")
)))
}
/// Ask one adapter for a device, with the limits the pipeline needs.
async fn device_from(adapter: &wgpu::Adapter) -> Result<(wgpu::Device, wgpu::Queue), GpuError> {
adapter
.request_device(&wgpu::DeviceDescriptor {
label: Some("darkroom-device"),
required_features: wgpu::Features::empty(),
@@ -164,17 +308,7 @@ impl GpuContext {
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,
})
.map_err(|e| GpuError::DeviceRequest(e.to_string()))
}
/// Build a context from a device and queue owned by someone else — the
@@ -597,3 +731,85 @@ mod tests {
assert_eq!(rt.size(), (1, 1));
}
}
#[cfg(test)]
mod adapter_choice_tests {
//! Which GPU gets picked, and what happens when it will not open.
//!
//! These are about the *ordering*, which is pure — opening a device needs
//! hardware and is covered by every other test in this crate implicitly.
use super::*;
/// Only the two fields the ordering reads are set; the rest come from
/// `Default`, so a wgpu upgrade that adds another does not break this.
/// The order adapters would be tried in, named so a failure reads as the
/// hardware it stands for.
fn order(policy: AdapterPreference, mut gpus: Vec<(&str, wgpu::DeviceType)>) -> Vec<&str> {
gpus.sort_by_key(|(_, t)| policy.rank(*t));
gpus.into_iter().map(|(name, _)| name).collect()
}
fn a_laptop() -> Vec<(&'static str, wgpu::DeviceType)> {
vec![
("Iris Xe", wgpu::DeviceType::IntegratedGpu),
("RTX 3050", wgpu::DeviceType::DiscreteGpu),
("llvmpipe", wgpu::DeviceType::Cpu),
]
}
#[test]
fn performance_takes_the_discrete_card() {
// What this has always done, and what an interactive slider drag wants:
// the texture is already resident, so the work is compute and the bus
// does not come into it.
assert_eq!(
order(AdapterPreference::Performance, a_laptop()),
["RTX 3050", "Iris Xe", "llvmpipe"]
);
}
#[test]
fn efficiency_takes_the_integrated_one() {
// Shared memory, so a 96 MB frame upload is not a transfer, and a
// laptop battery that lasts. The discrete card stays as the fallback
// rather than being excluded.
assert_eq!(
order(AdapterPreference::Efficiency, a_laptop()),
["Iris Xe", "RTX 3050", "llvmpipe"]
);
}
#[test]
fn software_rendering_is_last_but_never_dropped() {
// On a machine where every real GPU has failed this is the difference
// between a slow editor and no editor.
for policy in [
AdapterPreference::Performance,
AdapterPreference::Efficiency,
] {
assert_eq!(
*order(policy, a_laptop()).last().unwrap(),
"llvmpipe",
"{policy:?}"
);
}
}
#[test]
fn a_machine_with_one_gpu_is_unaffected_by_the_policy() {
// The common case: no choice to make, and no behaviour to change.
let one = vec![("Iris Xe", wgpu::DeviceType::IntegratedGpu)];
assert_eq!(
order(AdapterPreference::Performance, one.clone()),
order(AdapterPreference::Efficiency, one)
);
}
#[test]
fn the_default_is_what_it_did_before() {
// Changing which GPU an existing user lands on is not something to do
// by accident.
assert_eq!(AdapterPreference::default(), AdapterPreference::Performance);
}
}
+607 -91
View File
@@ -21,6 +21,17 @@
//! boxes, one draw each, compositing onto the slice with blend state — see the
//! second half of `mask.wgsl`.
//!
//! # The photograph, bound as an input
//!
//! A range mask (FR-DEV-10) selects by what a pixel *is*, so this pass reads
//! the demosaiced source as well as writing masks. It is bound for every draw
//! and looked at by two modes; everything else gets a 1x1 placeholder, for the
//! reason the label field below does — the bindings are fixed, and a second
//! pipeline differing only in what it ignores costs more than a texel.
//!
//! Nothing is read back and nothing is rasterised on this side. What crosses
//! into CPU memory for a range layer is five floats and a matrix.
//!
//! # The label field
//!
//! Region masks index a compacted label field uploaded once per segmentation.
@@ -30,10 +41,11 @@
//! `region_count`. The compaction is CPU-side and once per image, which is the
//! same place and cadence the region adjacency graph is already built at.
use dr_pipeline::mask::{MaskSource, MaskStack, Stroke, MAX_LAYERS};
use dr_pipeline::mask::{Join, MaskSource, MaskStack, Stroke, MAX_LAYERS};
use wgpu::util::DeviceExt;
use crate::{GpuContext, GpuError};
use crate::{DemosaicedImage, GpuContext, GpuError};
/// Modes understood by `mask.wgsl`. Kept beside the shader's `switch`.
const MODE_REGIONS: u32 = 0;
@@ -43,6 +55,10 @@ const MODE_SUBJECT: u32 = 3;
/// Brush layers go through their own entry points rather than the `switch`, so
/// this is only ever read by a person looking at a captured frame.
const MODE_BRUSH: u32 = 4;
/// TRACES: FR-DEV-10
const MODE_LUMINANCE: u32 = 5;
/// TRACES: FR-DEV-10
const MODE_COLOUR: u32 = 6;
/// Six vertices — two triangles — per stroke. See `vs_brush`.
const VERTICES_PER_STROKE: u32 = 6;
@@ -66,7 +82,25 @@ struct MaskParams {
axis: [f32; 2],
softness: f32,
angle: f32,
_pad1: [f32; 2],
/// TRACES: FR-DEV-10
/// Source texels per mask texel, per axis. See `image_value` in the
/// shader for why a range averages its footprint rather than sampling it.
source_step: [f32; 2],
/// Camera RGB → linear sRGB, one row per `vec4` because that is the
/// alignment a uniform gives a three-component vector anyway. Only a
/// range mask reads them.
cam_to_srgb: [[f32; 4]; 3],
/// `rgb`: as-shot white balance. `w`: non-zero for a gamma-encoded source.
/// The same packing the generated adjust shader uses, so the two agree by
/// construction rather than by inspection.
as_shot_wb: [f32; 4],
/// Whether this part is turned over before it joins the mask. Read by the
/// combine pass and by nothing else — see `fs_combine`.
invert: u32,
/// A uniform buffer is a multiple of sixteen bytes, and the flag above
/// takes four of them.
_pad: [u32; 3],
}
/// One stroke, as `mask.wgsl`'s `StrokeHeader` expects it.
@@ -355,6 +389,19 @@ pub struct MaskPass {
/// `dst(1 - a)` to erase.
brush_add: wgpu::RenderPipeline,
brush_erase: wgpu::RenderPipeline,
/// Reads a part back out of [`Self::scratch`] and blends it into the
/// layer's slice. The set operation is the blend state, so these two are
/// one shader as well.
combine_layout: wgpu::BindGroupLayout,
combine_union: wgpu::RenderPipeline,
combine_subtract: wgpu::RenderPipeline,
/// Where a part is drawn before it is joined.
///
/// One texture for the whole stack rather than one per layer, because
/// layers rasterise in sequence and a part is read back immediately after
/// it is drawn. Allocated the first time a layer has more than one part,
/// so a library of unedited masks never pays for it.
scratch: Option<Scratch>,
array: Option<MaskArray>,
/// How many times the array texture has been (re)allocated.
///
@@ -371,6 +418,14 @@ pub struct MaskPass {
/// label slots even when rasterising a gradient. A placeholder is cheaper
/// and far simpler than two pipelines differing only in what they ignore.
placeholder: LabelField,
/// TRACES: FR-DEV-10
/// Bound at the image slot for every mask that is not a range.
///
/// Never sampled by those modes, so its contents do not matter — but it is
/// cleared rather than left undefined, because a placeholder whose value
/// is arbitrary is one that makes a binding mistake look like a mask that
/// nearly works.
empty_image: wgpu::TextureView,
}
impl MaskPass {
@@ -405,6 +460,20 @@ impl MaskPass {
},
count: None,
},
// TRACES: FR-DEV-10
// The photograph, for a range mask. Unfilterable for the
// same reason the field above is: every read is a
// `textureLoad`, and this pipeline binds no sampler.
wgpu::BindGroupLayoutEntry {
binding: 6,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
],
});
@@ -501,6 +570,88 @@ impl MaskPass {
"mask-brush-add",
blend_state(wgpu::BlendFactor::One, wgpu::BlendFactor::OneMinusSrc),
);
// The pipelines that join one part to the mask so far. The blend
// state is the set operation and the shader is the same three
// vertices either way — which is why adding a way to combine masks
// cost no shader arithmetic at all.
let combine_layout =
ctx.device
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("mask-combine-bgl"),
entries: &[
uniform_entry(0),
wgpu::BindGroupLayoutEntry {
binding: 7,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
// Loaded texel by texel at matching size, so
// there is nothing to filter and no sampler.
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
],
});
let combine_pipeline_layout =
ctx.device
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("mask-combine-layout"),
bind_group_layouts: &[Some(&combine_layout)],
immediate_size: 0,
});
let combine = |label, blend| {
ctx.device
.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(&combine_pipeline_layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[],
},
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs_combine"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: MaskArray::FORMAT,
blend: Some(blend),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
primitive: wgpu::PrimitiveState::default(),
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview_mask: None,
cache: None,
})
};
// `max`, not source-over: a union must not build up where two parts
// overlap. Two selections that both half-cover a pixel select it half
// — adding them would make the overlap of two soft edges harder than
// either, which is a seam exactly where a photographer joined two
// things to avoid one.
let combine_union = combine(
"mask-combine-union",
wgpu::BlendState {
color: MAX_BLEND,
alpha: MAX_BLEND,
},
);
// `dst * (1 - src)`, which is the erase blend one level up: what the
// mask had, minus what this part covers, in proportion to how much of
// it the part covers.
let combine_subtract = combine(
"mask-combine-subtract",
blend_state(wgpu::BlendFactor::Zero, wgpu::BlendFactor::OneMinusSrc),
);
// The same, with the deposit thrown away: coverage is only ever taken
// off what earlier strokes on this layer put down. There is no negative
// coverage to accumulate, so erasing an unpainted layer is a no-op
@@ -515,6 +666,7 @@ impl MaskPass {
}
let placeholder = LabelField::upload(ctx, &[0], 1, 1, 0)?;
let empty_image = empty_image(ctx);
// Everywhere outside, so a layer that somehow reaches this masks
// nothing rather than everything.
let empty_subject = SubjectMasks::upload(ctx, &[&[-1.0f32][..]], 1, 1)?;
@@ -526,10 +678,15 @@ impl MaskPass {
brush_layout,
brush_add,
brush_erase,
combine_layout,
combine_union,
combine_subtract,
scratch: None,
array: None,
allocations: 0,
placeholder,
empty_subject,
empty_image,
})
}
@@ -538,17 +695,49 @@ impl MaskPass {
/// `labels` may be `None` when no layer is a region mask; a region layer
/// without one is skipped rather than drawn wrong, since a mask that
/// silently covers the whole frame would apply an edit everywhere.
///
/// `source` is the photograph a range layer measures (FR-DEV-10), and it
/// is skipped on the same rule for the same reason: without it the shader
/// would read a blank placeholder, and a band that happens to contain
/// black would then cover the whole frame.
pub fn render(
&mut self,
stack: &MaskStack,
labels: Option<&LabelField>,
subjects: Option<&SubjectMasks>,
source: Option<&DemosaicedImage>,
width: u32,
height: u32,
) -> Result<&MaskArray, GpuError> {
self.render_revealing(stack, labels, subjects, source, width, height, None)
}
/// TRACES: FR-DEV-19c
/// [`Self::render`], also drawing the layer being looked at.
///
/// A selection with no adjustment on it changes no pixel, so it is not
/// active and has no slice — which is right until somebody asks to *see*
/// it, and that is the state a photographer is in from choosing a subject
/// until deciding what to do to it.
///
/// `reveal` has to be the same one the shader was composed with and the
/// same one the distance fields were built for: all three index this array
/// by position in [`MaskStack::rendered`], and two of them disagreeing
/// shows as an adjustment applied through another layer's mask.
#[allow(clippy::too_many_arguments)]
pub fn render_revealing(
&mut self,
stack: &MaskStack,
labels: Option<&LabelField>,
subjects: Option<&SubjectMasks>,
source: Option<&DemosaicedImage>,
width: u32,
height: u32,
reveal: Option<&dr_pipeline::mask::Reveal>,
) -> Result<&MaskArray, GpuError> {
// At least one layer, because a zero-layer texture array is invalid
// and the shader binds this slot unconditionally.
let active = stack.active_count().clamp(1, MAX_LAYERS) as u32;
let active = stack.rendered_count(reveal).clamp(1, MAX_LAYERS) as u32;
self.ensure_array(width, height, active)?;
let mut encoder = self
@@ -558,52 +747,131 @@ impl MaskPass {
label: Some("mask-encoder"),
});
for (slot, layer) in stack.active().enumerate().take(MAX_LAYERS) {
let field = match (&layer.source, labels) {
(MaskSource::Regions { .. }, None) => {
log::warn!(
"mask layer {} is a region mask with no segmentation loaded; skipping",
layer.id
);
continue;
}
(MaskSource::Regions { .. }, Some(f)) => f,
(_, _) => &self.placeholder,
};
for (slot, layer) in stack.rendered(reveal).enumerate().take(MAX_LAYERS) {
// **The path a mask with one part takes is the path every mask
// took before parts existed**: drawn straight into the layer's
// slice, cleared by the draw itself. Nothing about an unedited
// library's rendering changes, and the scratch texture is never
// allocated for it.
//
// An inverted base is the exception, because turning a part over
// is done where it is read back rather than where it is drawn —
// a brush deposits dabs and cannot know what the rest of the
// frame is. See `fs_combine`.
let direct = layer.parts().len() == 1 && !layer.base().invert;
if !direct {
self.ensure_scratch(width, height)?;
}
// A subject layer whose instance is missing is skipped for the
// same reason a region layer without a segmentation is: an absent
// mask that defaults to "everything" would apply the adjustment to
// the whole photograph, which is a much louder failure than none.
// Indexed by *slot*, not by the instance the layer names: the
// fields are built per layer, in this same order, because two
// layers over one subject can carry different morphology.
let subject = match &layer.source {
MaskSource::Subject { .. } => match subjects.filter(|s| slot < s.len()) {
Some(s) => (s, slot),
None => {
log::warn!("mask layer {} has no distance field; skipping", layer.id);
for (index, part) in layer.parts().iter().enumerate() {
let base = index == 0;
let field = match (&part.source, labels) {
(MaskSource::Regions { .. }, None) => {
log::warn!(
"mask layer {} is a region mask with no segmentation loaded; skipping",
layer.id
);
if base {
break;
}
continue;
}
},
_ => (&self.empty_subject, 0),
};
(MaskSource::Regions { .. }, Some(f)) => f,
(_, _) => &self.placeholder,
};
let params = self.params(layer, field, width, height);
match &layer.source {
MaskSource::Brush { strokes } => {
self.draw_brush(&mut encoder, slot as u32, &params, strokes, width, height)
// A subject part whose instance is missing is skipped for the
// same reason a region part without a segmentation is: an
// absent mask that defaults to "everything" would apply the
// adjustment to the whole photograph, which is a much louder
// failure than none.
//
// Indexed by *slot*, not by the instance the part names: the
// fields are built per layer, in this same order, because two
// layers over one subject can carry different morphology.
// Which is also why only a base part can have one — a model
// part joined to a mask has no field built for it yet, and it
// is skipped rather than drawn against a placeholder that
// would cover the frame.
let subject = match &part.source {
// Category alongside Subject: both are model coverage
// turned into a distance field, both are built per layer
// in this same order, and leaving a category out of here
// is precisely the failure the comment above warns about —
// it binds the 1x1 placeholder, so the mask covers
// everything and the adjustment silently goes global.
MaskSource::Subject { .. } | MaskSource::Category { .. } => {
match subjects.filter(|s| base && slot < s.len()) {
Some(s) => (s, slot),
None => {
log::warn!(
"part {} of mask layer {} has no distance field; skipping",
part.id,
layer.id
);
if base {
break;
}
continue;
}
}
}
_ => (&self.empty_subject, 0),
};
// TRACES: FR-DEV-10
// A range part with no photograph bound is skipped rather than
// drawn against the placeholder, on exactly the rule the two
// cases above follow: an absent mask that defaults to
// "everything" takes a local adjustment global, which is a far
// quieter failure than a part that visibly did not render.
let image = match (&part.source, source) {
(s, None) if s.is_range() => {
log::warn!(
"mask layer {} selects a range with no image loaded; skipping",
layer.id
);
if base {
break;
}
continue;
}
(_, image) => image,
};
let params = self.params(part, field, image, width, height);
let target = if direct {
self.slice_view(slot as u32)
} else {
self.scratch_view()
};
match &part.source {
MaskSource::Brush { strokes } => {
self.draw_brush(&mut encoder, &target, &params, strokes, width, height)
}
_ => {
let selected = self.selection_buffer(part, field);
self.draw(
&mut encoder,
&target,
&params,
field,
&selected,
subject,
image,
);
}
}
_ => {
let selected = self.selection_buffer(layer, field);
self.draw(
&mut encoder,
slot as u32,
&params,
field,
&selected,
subject,
);
if !direct {
// The first part joins a cleared slice, so it lands
// exactly as it was drawn whichever way it says it joins —
// there is nothing yet for a subtraction to take away
// from, and a mask that began by subtracting from nothing
// would render as empty however it was painted afterwards.
let join = if base { Join::Union } else { part.join };
self.combine(&mut encoder, slot as u32, join, base, &params);
}
}
}
@@ -624,11 +892,36 @@ impl MaskPass {
fn params(
&self,
layer: &dr_pipeline::mask::MaskLayer,
part: &dr_pipeline::mask::MaskPart,
field: &LabelField,
source: Option<&DemosaicedImage>,
width: u32,
height: u32,
) -> MaskParams {
// TRACES: FR-DEV-10
// How much of the photograph one mask texel covers. One when there is
// no image bound, which is a value nothing reads — the range modes are
// the only readers and they are skipped in that case.
let source_step = match source {
Some(image) => {
let (sw, sh) = image.size();
[
sw as f32 / width.max(1) as f32,
sh as f32 / height.max(1) as f32,
]
}
None => [1.0, 1.0],
};
// Row-major nine, widened to three `vec4`s. Identity where there is no
// image, so a range that somehow reached the shader without one would
// read camera values rather than nothing — the same defensive choice
// the demosaicer makes for an uncalibrated body.
let m = source.map_or([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0], |i| {
i.color_matrix()
});
let wb = source.map_or([1.0, 1.0, 1.0], |i| i.as_shot_wb());
let non_linear = source.is_some_and(|i| i.is_non_linear());
let base = MaskParams {
width,
height,
@@ -642,10 +935,18 @@ impl MaskPass {
axis: [1.0, 0.0],
softness: 0.0,
angle: 0.0,
_pad1: [0.0, 0.0],
source_step,
cam_to_srgb: [
[m[0], m[1], m[2], 0.0],
[m[3], m[4], m[5], 0.0],
[m[6], m[7], m[8], 0.0],
],
as_shot_wb: [wb[0], wb[1], wb[2], if non_linear { 1.0 } else { 0.0 }],
invert: u32::from(part.invert),
_pad: [0; 3],
};
match &layer.source {
match &part.source {
// `softness` carries the layer's feather. The model's coverage is
// already a soft sigmoid, so zero means "use the edge the model
// drew" rather than "hard edge" — the one place in this shader
@@ -654,17 +955,23 @@ impl MaskPass {
// edge; the field is in proxy pixels. Converting here keeps the
// stored edit resolution-independent while the shader works in the
// units its texture is actually measured in.
MaskSource::Subject { .. } => {
// A category shares the subject's mode, and that is not a
// shortcut: both arrive as a soft coverage buffer at proxy
// resolution and both are turned into a distance field before they
// reach here. The shader has no way to tell them apart and no
// reason to want one — what differs is only which model produced
// the coverage.
MaskSource::Subject { .. } | MaskSource::Category { .. } => {
let short = field_short_edge(width, height);
MaskParams {
mode: MODE_SUBJECT,
// `softness` is the feather half-width in pixels.
softness: (layer.feather * short).max(0.0),
softness: (part.feather * short).max(0.0),
// `angle` carries the morphology offset — reused rather
// than padded, since a subject layer has no ellipse to
// rotate.
angle: morph_offset(layer) * short,
falloff: falloff_code(layer.falloff),
angle: morph_offset(part) * short,
falloff: falloff_code(part.falloff),
..base
}
}
@@ -705,6 +1012,33 @@ impl MaskPass {
mode: MODE_BRUSH,
..base
},
// TRACES: FR-DEV-10
// A band, carried in the fields the gradients measure geometry
// in. Reused rather than given their own, and it is not a
// shortcut: `centre` and `axis` are two pairs of floats whose
// meaning has always been the mode's to decide, and a range that
// added four more would grow the uniform every other mask pays
// for. What matters is that nothing here is a *coordinate* — a
// range is not a function of position at all.
MaskSource::Luminance { lo, hi, softness } => MaskParams {
mode: MODE_LUMINANCE,
axis: [*lo, *hi],
softness: *softness,
..base
},
MaskSource::Colour {
hue,
hue_width,
chroma_lo,
chroma_hi,
softness,
} => MaskParams {
mode: MODE_COLOUR,
centre: [*hue, *hue_width],
axis: [*chroma_lo, *chroma_hi],
softness: *softness,
..base
},
}
}
@@ -723,7 +1057,7 @@ impl MaskPass {
fn draw_brush(
&self,
encoder: &mut wgpu::CommandEncoder,
slot: u32,
target: &wgpu::TextureView,
params: &MaskParams,
strokes: &[Stroke],
width: u32,
@@ -785,19 +1119,10 @@ impl MaskPass {
})
});
let array = self.array.as_ref().expect("array ensured by caller");
let view = array.texture.create_view(&wgpu::TextureViewDescriptor {
label: Some("mask-slice"),
dimension: Some(wgpu::TextureViewDimension::D2),
base_array_layer: slot,
array_layer_count: Some(1),
..Default::default()
});
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("mask-brush-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
view: target,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
@@ -843,11 +1168,11 @@ impl MaskPass {
/// One byte-flag per region, or a single zero for a non-region layer.
fn selection_buffer(
&self,
layer: &dr_pipeline::mask::MaskLayer,
part: &dr_pipeline::mask::MaskPart,
field: &LabelField,
) -> wgpu::Buffer {
let mut flags = vec![0u32; field.region_count.max(1) as usize];
if let MaskSource::Regions { ids, .. } = &layer.source {
if let MaskSource::Regions { ids, .. } = &part.source {
for &id in ids {
if let Some(slot) = flags.get_mut(id as usize) {
*slot = 1;
@@ -868,11 +1193,12 @@ impl MaskPass {
fn draw(
&self,
encoder: &mut wgpu::CommandEncoder,
slot: u32,
target: &wgpu::TextureView,
params: &MaskParams,
field: &LabelField,
selected: &wgpu::Buffer,
subject: (&SubjectMasks, usize),
source: Option<&DemosaicedImage>,
) {
let params_buf = self
.ctx
@@ -910,25 +1236,23 @@ impl MaskPass {
}),
),
},
// TRACES: FR-DEV-10
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::TextureView(
source.map_or(&self.empty_image, |i| i.view()),
),
},
],
});
// The array slice is selected by the attachment rather than by a
// uniform the shader reads — one fewer value that can disagree with
// where the pass actually writes.
let array = self.array.as_ref().expect("array ensured by caller");
let view = array.texture.create_view(&wgpu::TextureViewDescriptor {
label: Some("mask-slice"),
dimension: Some(wgpu::TextureViewDimension::D2),
base_array_layer: slot,
array_layer_count: Some(1),
..Default::default()
});
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("mask-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
view: target,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
@@ -949,6 +1273,140 @@ impl MaskPass {
pass.draw(0..3, 0..1);
}
/// A view of one layer's slice of the array.
fn slice_view(&self, slot: u32) -> wgpu::TextureView {
// The array slice is selected by the attachment rather than by a
// uniform the shader reads — one fewer value that can disagree with
// where the pass actually writes.
let array = self.array.as_ref().expect("array ensured by caller");
array.texture.create_view(&wgpu::TextureViewDescriptor {
label: Some("mask-slice"),
dimension: Some(wgpu::TextureViewDimension::D2),
base_array_layer: slot,
array_layer_count: Some(1),
..Default::default()
})
}
fn scratch_view(&self) -> wgpu::TextureView {
self.scratch
.as_ref()
.expect("scratch ensured by caller")
.texture
.create_view(&wgpu::TextureViewDescriptor {
label: Some("mask-part"),
..Default::default()
})
}
/// Blend the part sitting in [`Self::scratch`] into a layer's slice.
///
/// `first` clears the slice instead of loading it, which is both cheaper
/// on a tiler and the only thing that makes the fold start from nothing
/// covered rather than from whatever the last rasterisation left.
fn combine(
&self,
encoder: &mut wgpu::CommandEncoder,
slot: u32,
join: Join,
first: bool,
params: &MaskParams,
) {
let params_buf = self
.ctx
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("mask-combine-params"),
contents: bytemuck::bytes_of(params),
usage: wgpu::BufferUsages::UNIFORM,
});
let bind_group = self
.ctx
.device
.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("mask-combine-bind"),
layout: &self.combine_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: params_buf.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 7,
resource: wgpu::BindingResource::TextureView(&self.scratch_view()),
},
],
});
let target = self.slice_view(slot);
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("mask-combine-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &target,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: if first {
wgpu::LoadOp::Clear(wgpu::Color::BLACK)
} else {
wgpu::LoadOp::Load
},
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
pass.set_pipeline(match join {
Join::Union => &self.combine_union,
Join::Subtract => &self.combine_subtract,
});
pass.set_bind_group(0, &bind_group, &[]);
pass.draw(0..3, 0..1);
}
/// The texture a part is drawn in before it is joined.
///
/// Allocated on the first mask that has more than one part and kept at the
/// rasterisation size, which is the same size the array is: a part and the
/// slice it joins are compared texel for texel, so there is nothing to
/// scale and nothing to sample between.
fn ensure_scratch(&mut self, width: u32, height: u32) -> Result<(), GpuError> {
if self
.scratch
.as_ref()
.is_some_and(|s| s.width == width && s.height == height)
{
return Ok(());
}
let texture = self.ctx.device.create_texture(&wgpu::TextureDescriptor {
label: Some("mask-scratch"),
size: wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: MaskArray::FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
self.scratch = Some(Scratch {
texture,
width,
height,
});
Ok(())
}
fn ensure_array(&mut self, width: u32, height: u32, layers: u32) -> Result<(), GpuError> {
if self
.array
@@ -991,6 +1449,57 @@ impl MaskPass {
}
}
/// The texture one part is drawn into on its way into a layer's slice.
struct Scratch {
texture: wgpu::Texture,
width: u32,
height: u32,
}
/// `max(dst, src)` — the union of two parts.
///
/// Not source-over, which would build up: two parts that each half-cover a
/// pixel select it half, and adding them would make the overlap of two soft
/// edges harder than either of them, drawing a seam exactly where a
/// photographer joined two selections to avoid one.
const MAX_BLEND: wgpu::BlendComponent = wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::One,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Max,
};
/// TRACES: FR-DEV-10
/// A single black texel, bound at the image slot for a mask that is not a
/// range.
///
/// Written rather than merely allocated. Undefined contents would be read by
/// nothing today, but a binding mistake in a range mask would then produce
/// whatever the driver left in memory — a mask that flickers between builds
/// and machines, which is the hardest shape of bug this pass could have.
fn empty_image(ctx: &GpuContext) -> wgpu::TextureView {
let texture = ctx.device.create_texture_with_data(
&ctx.queue,
&wgpu::TextureDescriptor {
label: Some("mask-empty-image"),
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: DemosaicedImage::FORMAT,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
// Four half-floats of zero. Rgba16Float, so eight bytes.
&[0u8; 8],
);
texture.create_view(&wgpu::TextureViewDescriptor::default())
}
/// The shorter edge of the space the mask is rasterised in.
///
/// Feather and morphology are stored as fractions of it, so the same edit is
@@ -1004,11 +1513,11 @@ fn field_short_edge(width: u32, height: u32) -> f32 {
/// Zero for closing and opening: those are folded into the field itself when
/// it is built, because their second half acts on a shape the original field
/// does not describe.
fn morph_offset(layer: &dr_pipeline::mask::MaskLayer) -> f32 {
fn morph_offset(part: &dr_pipeline::mask::MaskPart) -> f32 {
use dr_pipeline::mask::Morphology;
match layer.morphology {
Morphology::Dilate => layer.morph_radius,
Morphology::Erode => -layer.morph_radius,
match part.morphology {
Morphology::Dilate => part.morph_radius,
Morphology::Erode => -part.morph_radius,
Morphology::None | Morphology::Close | Morphology::Open => 0.0,
}
}
@@ -1117,7 +1626,7 @@ mod tests {
let mut pass = MaskPass::new(&ctx).expect("mask pass");
let array = pass
.render(&stack, Some(&field), None, w, h)
.render(&stack, Some(&field), None, None, w, h)
.expect("render");
assert_eq!(array.size(), (w, h));
assert_eq!(array.layers(), 1);
@@ -1139,7 +1648,7 @@ mod tests {
}));
let mut pass = MaskPass::new(&ctx).expect("mask pass");
assert!(pass.render(&stack, None, None, 8, 8).is_ok());
assert!(pass.render(&stack, None, None, None, 8, 8).is_ok());
}
#[test]
@@ -1162,7 +1671,9 @@ mod tests {
}));
let mut pass = MaskPass::new(&ctx).expect("mask pass");
let array = pass.render(&stack, None, None, 16, 16).expect("render");
let array = pass
.render(&stack, None, None, None, 16, 16)
.expect("render");
assert_eq!(array.layers(), 2, "one slice per active layer");
}
@@ -1172,11 +1683,11 @@ mod tests {
fn painted(gestures: &[Gesture]) -> MaskLayer {
let mut layer = lit(MaskSource::brush());
for (erase, radius, path) in gestures {
layer.begin_stroke(*erase, *radius, 0.5, 1.0);
layer.begin_stroke(0, *erase, *radius, 0.5, 1.0);
for &(x, y) in path {
layer.extend_stroke(x, y);
layer.extend_stroke(0, x, y);
}
layer.end_stroke();
layer.end_stroke(0);
}
layer
}
@@ -1194,7 +1705,9 @@ mod tests {
stack.push(painted(&[(false, 0.1, vec![(0.2, 0.2), (0.8, 0.8)])]));
let mut pass = MaskPass::new(&ctx).expect("mask pass");
let array = pass.render(&stack, None, None, 32, 32).expect("render");
let array = pass
.render(&stack, None, None, None, 32, 32)
.expect("render");
assert_eq!(array.layers(), 1);
}
@@ -1244,7 +1757,7 @@ mod tests {
};
let mut pass = MaskPass::new(&ctx).expect("mask pass");
let array = pass
.render(&MaskStack::new(), None, None, 8, 8)
.render(&MaskStack::new(), None, None, None, 8, 8)
.expect("render");
assert_eq!(
array.layers(),
@@ -1267,17 +1780,20 @@ mod tests {
}));
let mut pass = MaskPass::new(&ctx).expect("mask pass");
pass.render(&stack, None, None, 32, 32).expect("render");
pass.render(&stack, None, None, None, 32, 32)
.expect("render");
assert_eq!(pass.allocations(), 1);
pass.render(&stack, None, None, 32, 32).expect("render");
pass.render(&stack, None, None, None, 32, 32)
.expect("render");
assert_eq!(
pass.allocations(),
1,
"same size and layer count should not reallocate"
);
pass.render(&stack, None, None, 64, 64).expect("render");
pass.render(&stack, None, None, None, 64, 64)
.expect("render");
assert_eq!(pass.allocations(), 2, "a resize must reallocate");
}
}
+861
View File
@@ -0,0 +1,861 @@
//! TRACES: FR-CULL-3
//! Counting the sensor's own numbers, on an axis measured in stops of
//! headroom.
//!
//! # Why there are two histograms, and what each one answers
//!
//! [`crate::HistogramPass`] beside this file counts the frame the display is
//! about to show. It is tagged FR-DSP-7, its own documentation says a clipped
//! bin means "a highlight that is actually gone rather than one the transform
//! might still recover", and that is exactly right for the question it is
//! there to answer: *what will this image look like when I send it out.*
//!
//! FR-CULL-3 asks the opposite question, and says why: "a JPEG's clipping
//! warnings systematically lie about what is recoverable in the raw". A
//! photographer culling three thousand frames is deciding whether a highlight
//! can be brought back, not whether the current rendering happens to have
//! kept it. A readout that measures the render cannot answer that however it
//! is presented, so this is a second instrument rather than a setting on the
//! first — and the panel offers both, because both are true and they are true
//! about different things.
//!
//! # What is reduced over, and why it is not the CFA samples
//!
//! ARCH §5.5 originally specified a reduction over the *pre-demosaic* texture.
//! This reduces over the demosaiced scene-linear texture instead —
//! [`crate::DemosaicedImage`]'s `Rgba16Float`, the one the whole develop chain
//! then works from — and the amendment to §5.5 records the decision rather
//! than leaving the specification and the code silently disagreeing.
//!
//! That texture is the right one on the merits. It is camera-native: no white
//! balance has been applied, no camera matrix, no base curve, no tone curve,
//! no output transform. It is normalised by the sensor's own black and white
//! levels, so 1.0 is saturation by construction and the distribution below it
//! *is* the headroom question, with no calibration to carry and no origin to
//! choose.
//!
//! And retaining the CFA samples would have cost real memory for the
//! difference. `Demosaicer::run` uploads the packed `u32` sample buffer and
//! drops it the moment the dispatch is encoded; keeping it resident to reduce
//! over later is 48 MB at 24 MP and 120 MB at 60 MP, per photograph opened,
//! whether or not anyone ever looks at the histogram. ARCH §6.2 exists because
//! memory is scarce on the platform this has to run on. A more complete
//! instrument that is paid for on every image by everyone who never opens it
//! is not a better instrument.
//!
//! # Three things this cannot tell you
//!
//! Each of these is a different failure, and none of them is hidden by
//! presenting the result well.
//!
//! **It counts pixels, not photosites.** Every value here passed through the
//! demosaic, so a saturated photosite pulls its interpolated neighbours up
//! with it: per-channel clipping is smeared across roughly a demosaic kernel.
//! The count of clipped pixels is therefore an overestimate of the count of
//! clipped photosites, by an amount that depends on how isolated the clipping
//! is — a large blown sky is barely affected, a field of specular glints on
//! water is affected a great deal.
//!
//! **It cannot see above white.** `demosaic.wgsl` clamps each photosite at 1.0
//! for a good reason of its own — a Canon 6D reads to 16383 against a declared
//! white level of 15070, and carrying that overshoot forward turns blown
//! highlights pink through the white balance — but the consequence here is
//! that "at saturation" and "a stop past saturation" arrive in the same bin.
//! The top column says *how much* is gone and never *how far* gone, which
//! matters because some of what a raw converter can recover lives exactly
//! there.
//!
//! **It is measured after the CFA pattern is gone.** Which channel of the
//! mosaic saturated first at a given site is a fact about the sensor readout,
//! and by this point each pixel carries three channels, two of which were
//! reconstructed from its neighbours. The series below say which *colour*
//! clipped in the reconstructed image; they cannot say which photosite went
//! first.
//!
//! # What it costs, and when it runs
//!
//! One dispatch over the source texture plus a 4 KB buffer copy and a mapping.
//! Unlike the display histogram this is a property of the **file**, not of the
//! edit: nothing downstream of the demosaic can change it, so it is computed
//! once per photograph and cached rather than recomputed on every settled
//! frame. That is also why it describes the whole frame rather than the
//! visible region — a crop changes what is on screen and changes nothing about
//! what the sensor recorded.
use wgpu::util::DeviceExt;
use crate::readback::await_mapping;
use crate::{GpuContext, GpuError};
/// Bins on the stops axis.
///
/// 256, the same count [`crate::HistogramPass`] uses, so the presentation code
/// that folds bins into drawable columns is shared between the two rather than
/// written twice against two different divisors.
pub const BINS: usize = 256;
/// Bins per stop. Must equal `BINS_PER_STOP` in `raw_histogram.wgsl`.
///
/// 16 over 256 bins puts the floor of the axis at just under 16 stops, which
/// covers every sensor anyone will point at this and a couple more besides —
/// the best-measured full-frame bodies reach about 15 stops at base ISO, and
/// nothing below the noise floor is a reading anyway. A finer division would
/// buy resolution in a part of the plot that is already narrower than one
/// drawn column.
pub const BINS_PER_STOP: usize = 16;
/// Stops the axis spans, as a whole number.
///
/// Note that bin 0 sits at `STOPS - 1/BINS_PER_STOP` below saturation rather
/// than at `STOPS`, and holds everything darker as well — see
/// [`RawHistogram::stops`].
///
/// The last two stops of this range are, strictly, further than the source can
/// carry: [`crate::DemosaicedImage::FORMAT`] is `Rgba16Float`, whose smallest
/// normal value is 2^-14, so below fourteen stops a value survives only as an
/// f16 subnormal and many drivers flush those to zero. It costs nothing to
/// leave the axis at a round sixteen — no sensor records usable signal
/// anywhere near there, and the alternative is a plot whose floor is an
/// implementation detail of a texture format.
pub const STOPS: usize = BINS / BINS_PER_STOP;
/// Four series 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 SATURATED: usize = CHANNELS * BINS;
const AT_BLACK: usize = CHANNELS * BINS + 1;
const SLOTS: usize = CHANNELS * BINS + 2;
/// TRACES: FR-CULL-3
/// A counted sensor frame: how many pixels sit at each distance below
/// saturation.
///
/// Counts, not proportions, and bins rather than stops — the same division of
/// labour [`crate::Histogram`] draws (ARCH §4.3a). Folding 256 bins into the
/// columns a panel can show, deciding how much clipping is worth an alarm and
/// turning a bin index into a figure a photographer reads are all questions
/// about an interface. What this crate owes is the numbers.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RawHistogram {
red: [u32; BINS],
green: [u32; BINS],
blue: [u32; BINS],
brightest: [u32; BINS],
saturated: u32,
at_black: u32,
pixels: u32,
}
impl RawHistogram {
/// Counts per bin for the camera's red channel, darkest first.
///
/// **Camera-native and unbalanced.** These are not the red of a rendered
/// image: the as-shot white balance has not been applied, so on a neutral
/// subject under daylight red typically sits around a stop below green and
/// blue rather further. That is not a defect to be corrected before
/// drawing — it is the point. Red reaching saturation while green has a
/// stop left is a fact about the exposure, and balancing it away would
/// hide the very thing the instrument is for.
pub fn red(&self) -> &[u32; BINS] {
&self.red
}
pub fn green(&self) -> &[u32; BINS] {
&self.green
}
pub fn blue(&self) -> &[u32; BINS] {
&self.blue
}
/// The brightest of the three channels at each pixel.
///
/// The envelope the three series sit under, and the one that answers the
/// headroom question directly: a pixel is safe exactly as long as its
/// *highest* channel is, because that is the one that saturates first.
///
/// Where the display histogram draws Rec.709 luma, this draws a maximum,
/// and the substitution is not cosmetic. Luma is a weighted sum of
/// display-referred primaries; these values are camera-native and
/// unbalanced, so any weighting of them is a number about nothing.
pub fn brightest(&self) -> &[u32; BINS] {
&self.brightest
}
/// Pixels with **any** channel at sensor saturation.
///
/// Any rather than all, for the reason the display histogram gives about
/// its own counter: a channel at the ceiling has no gradation left in it
/// however much the other two still hold, and counting only neutral white
/// would stay silent on exactly the saturated subject that clips first.
///
/// Read this against [`Self::pixels`], and read the module documentation
/// before reading it against a count of photosites — the demosaic has
/// already spread each clipped site over its neighbours.
pub fn saturated(&self) -> u32 {
self.saturated
}
/// Pixels with any channel at or below the sensor's black level.
///
/// The other end, and a genuinely different statement from the display
/// histogram's shadow clipping: this is a photosite that recorded nothing
/// but read noise, where that one is a tone the output transform put at
/// zero and which a lifted black point would bring back.
pub fn at_black(&self) -> u32 {
self.at_black
}
/// Pixels counted. The denominator for the two figures above.
pub fn pixels(&self) -> u32 {
self.pixels
}
/// How far below sensor saturation a bin sits, in stops.
///
/// Bin 255 is 0 stops — the top 1/16 of a stop, where a saturated pixel
/// lands. Bin 239 is exactly 1 stop below. Bin 0 is 15.9375 stops below
/// **and everything darker than that**, because it is the end of the axis
/// rather than a bin of the same width as its neighbours; a photograph
/// with genuinely 20 stops in it puts its bottom four into that column.
///
/// An index past the end is clamped rather than refused: this exists to
/// label a plot, and a panel that panicked because a loop ran one past its
/// last column would be a worse failure than a label at the floor of the
/// axis.
pub fn stops(bin: usize) -> f32 {
(BINS - 1 - bin.min(BINS - 1)) as f32 / BINS_PER_STOP as f32
}
/// Rebuild from the flat slot array the shader writes.
///
/// `pixels` is summed from the red series rather than taken from the image
/// dimensions, for the reason [`crate::Histogram`] gives: 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!(
"raw histogram readback was {} slots, expected {SLOTS}",
slots.len()
)));
}
let series = |i: usize| -> [u32; BINS] {
let mut out = [0u32; BINS];
out.copy_from_slice(&slots[i * BINS..(i + 1) * BINS]);
out
};
let red = series(0);
Ok(Self {
pixels: red.iter().sum(),
red,
green: series(1),
blue: series(2),
brightest: series(3),
saturated: slots[SATURATED],
at_black: slots[AT_BLACK],
})
}
}
/// The dispatch's view of the source. 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-CULL-3
/// Counts a scene-linear sensor frame into [`RawHistogram`].
///
/// Holds its buffers for the life of the session. They are a fixed 4104 bytes
/// whatever the sensor size, which is the property that makes the whole shape
/// affordable — there is nothing to reallocate when a different photograph is
/// opened.
pub struct RawHistogramPass {
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 RawHistogramPass {
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("raw-histogram"),
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/raw_histogram.wgsl").into()),
});
let bind_group_layout =
ctx.device
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("raw-histogram-bgl"),
entries: &[
// The demosaiced scene-linear texture, read with
// `textureLoad` — the same one the adjust pass samples
// from, so what is counted is what the develop chain
// starts from.
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("raw-histogram-layout"),
bind_group_layouts: &[Some(&bind_group_layout)],
immediate_size: 0,
});
let pipeline = ctx
.device
.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("raw-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("raw-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("raw-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("raw-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-CULL-3
/// Count one scene-linear frame.
///
/// `texture` must be linear, camera-native and normalised so that 1.0 is
/// the sensor's white level — which is what [`crate::Demosaicer::run`]
/// produces and what nothing else in this crate does. Handing it the
/// display frame instead would produce a perfectly well-formed plot of the
/// wrong quantity, on an axis whose origin means nothing there, so callers
/// must check [`crate::DemosaicedImage::is_non_linear`] first: a texture
/// that came from a JPEG carries no sensor scale to measure headroom
/// against, and the honest answer for one is that there is no reading
/// rather than a reading nobody should trust.
///
/// It must also carry `TEXTURE_BINDING`, which the demosaic output does
/// because the adjust pass samples it.
pub fn compute(&self, texture: &wgpu::Texture) -> Result<RawHistogram, 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("raw-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("raw-histogram-encoder"),
});
// The accumulator is reused between photographs, so it carries the
// previous one's counts until this line. Forgetting it does not fail —
// it quietly integrates every image opened this session, which looks
// like a histogram that is roughly the right shape and never quite
// about the picture on screen.
enc.clear_buffer(&self.bins, 0, None);
{
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("raw-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();
RawHistogram::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
}
}
}
/// An f32 as IEEE 754 half-precision bits, for building source textures.
///
/// Written out rather than pulled in as a dependency, exactly as
/// `demosaic.rs` does for the same reason: every value these tests upload
/// is inside `0.0..=2.0`, comfortably within f16's normal range, so the
/// subnormal and overflow cases a general converter must handle cannot
/// arise. The mantissa is truncated rather than rounded, which is why the
/// tests below place their values in the *middle* of a bin instead of on
/// its edge.
fn half(v: f32) -> u16 {
let v = v.clamp(0.0, 2.0);
if v == 0.0 {
return 0;
}
let bits = v.to_bits();
let exp = ((bits >> 23) & 0xFF) as i32 - 127 + 15;
let mantissa = (bits >> 13) & 0x3FF;
if exp <= 0 {
return 0;
}
((exp as u16) << 10) | mantissa as u16
}
/// Upload `rgb` as the `Rgba16Float` texture the pass expects, the way
/// `Demosaicer::run` hands its output over.
fn source(ctx: &GpuContext, rgb: &[[f32; 3]], width: u32, height: u32) -> wgpu::Texture {
let halves: Vec<u16> = rgb
.iter()
.flat_map(|px| [half(px[0]), half(px[1]), half(px[2]), half(1.0)])
.collect();
let tex = ctx.device.create_texture(&wgpu::TextureDescriptor {
label: Some("raw-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::Rgba16Float,
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,
},
bytemuck::cast_slice(&halves),
wgpu::TexelCopyBufferLayout {
offset: 0,
// Four channels of two bytes.
bytes_per_row: Some(width * 8),
rows_per_image: Some(height),
},
wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
);
ctx.queue.submit(std::iter::empty());
tex
}
/// The value at the centre of the bin `stops` below saturation.
///
/// Deliberately mid-bin. On a bin edge the assertion would be testing
/// whether this machine's `log2` and this test's `powf` round a tie the
/// same way, which is a question about two libraries rather than about the
/// reduction — and the kind of disagreement that fails once in a thousand
/// runs and looks like flakiness.
fn mid_bin(bin_below_top: usize) -> f32 {
2f32.powf(-((bin_below_top as f32) + 0.5) / BINS_PER_STOP as f32)
}
#[test]
fn the_axis_runs_from_saturation_downward_in_stops() {
// Arithmetic, so no device. The single most consequential convention
// in the file, and the one every reading of the plot depends on: if
// the axis were inverted the histogram would still look like a
// histogram, and every headroom judgement made from it would be
// exactly backwards.
assert_eq!(
RawHistogram::stops(BINS - 1),
0.0,
"the top bin is saturation"
);
assert_eq!(RawHistogram::stops(BINS - 1 - BINS_PER_STOP), 1.0);
assert_eq!(RawHistogram::stops(BINS - 1 - 8 * BINS_PER_STOP), 8.0);
// The floor is one bin short of `STOPS`, because bin 0 is the end of
// the axis rather than the start of another stop.
assert!(RawHistogram::stops(0) < STOPS as f32);
assert!(RawHistogram::stops(0) > STOPS as f32 - 0.1);
// Monotonic: further down the axis is always more stops below.
for bin in 1..BINS {
assert!(
RawHistogram::stops(bin) < RawHistogram::stops(bin - 1),
"bin {bin} is not below bin {}",
bin - 1
);
}
// Past the end is clamped, not a panic — see the doc comment.
assert_eq!(RawHistogram::stops(BINS), 0.0);
}
#[test]
fn a_saturated_frame_lands_in_the_top_bin_and_is_counted_as_clipped() {
// The arithmetic at its most checkable, at the end of the axis that
// matters most: 64x64 pixels at the white level must produce exactly
// 4096 in bin 255, nothing anywhere else, and 4096 clipped.
let Some(ctx) = ctx() else { return };
let pass = RawHistogramPass::new(&ctx).expect("pass");
let rgb = vec![[1.0f32, 1.0, 1.0]; 64 * 64];
let hist = pass.compute(&source(&ctx, &rgb, 64, 64)).expect("compute");
assert_eq!(hist.pixels(), 4096);
assert_eq!(hist.red()[BINS - 1], 4096);
assert_eq!(hist.green()[BINS - 1], 4096);
assert_eq!(hist.blue()[BINS - 1], 4096);
assert_eq!(hist.brightest()[BINS - 1], 4096);
assert_eq!(
hist.red().iter().filter(|c| **c > 0).count(),
1,
"one value can only occupy one bin"
);
assert_eq!(hist.saturated(), 4096);
assert_eq!(hist.at_black(), 0);
}
#[test]
fn a_frame_at_the_black_level_lands_in_the_bottom_bin() {
// Zero has no logarithm, so the bottom of the axis is a special case
// in the shader rather than a value that falls out of the formula. A
// frame of it must land in bin 0 and be counted as black-clipped —
// getting this wrong produces a NaN bin index, which on most hardware
// is bin 0 anyway and on some is not.
let Some(ctx) = ctx() else { return };
let pass = RawHistogramPass::new(&ctx).expect("pass");
let rgb = vec![[0.0f32, 0.0, 0.0]; 32 * 32];
let hist = pass.compute(&source(&ctx, &rgb, 32, 32)).expect("compute");
assert_eq!(hist.pixels(), 1024);
assert_eq!(hist.red()[0], 1024);
assert_eq!(hist.brightest()[0], 1024);
assert_eq!(hist.at_black(), 1024);
assert_eq!(hist.saturated(), 0);
}
/// Bins the ramp below walks, from the top of the axis downward.
///
/// Twelve stops rather than the whole sixteen, and the reason is the
/// source format rather than the reduction. `Rgba16Float`'s smallest
/// *normal* value is 2^-14, so the bottom two stops of the axis can only
/// be held as f16 subnormals — which many GPUs flush to zero — and a test
/// that walked into them would be measuring the driver's denormal policy
/// rather than this shader. Twelve stops is past the noise floor of every
/// sensor this will meet, so nothing a photograph actually contains is
/// left unchecked.
const RAMP: usize = 12 * BINS_PER_STOP;
#[test]
fn every_bin_the_axis_can_hold_lands_where_it_belongs() {
// A ramp down the axis, four pixels per bin, each value placed at the
// centre of the bin it belongs in. This is the test that catches an
// off-by-one in the binning or a `BINS_PER_STOP` that disagrees across
// the language boundary — either shifts the whole plot along the axis,
// which on a real photograph looks like nothing at all and is a
// headroom figure out by a stop.
let Some(ctx) = ctx() else { return };
let pass = RawHistogramPass::new(&ctx).expect("pass");
let (w, h) = (RAMP as u32, 4u32);
let mut rgb = Vec::with_capacity((w * h) as usize);
for _ in 0..h {
for x in 0..w {
// x = 0 is the top bin, so `x` is also the number of bins
// below saturation.
let v = mid_bin(x as usize);
rgb.push([v, v, v]);
}
}
let hist = pass.compute(&source(&ctx, &rgb, w, h)).expect("compute");
assert_eq!(hist.pixels(), w * h);
for below in 0..RAMP {
let bin = BINS - 1 - below;
assert_eq!(
hist.red()[bin],
h,
"{below} bins below saturation should hold exactly {h} pixels"
);
// Neutral, so the brightest channel must land on the same bin as
// the three do.
assert_eq!(
hist.brightest()[bin],
h,
"the envelope drifted at bin {bin}"
);
}
// Nothing below the ramp, so an off-by-one that spilled a pixel past
// the end of it would show here rather than hiding in a bin the loop
// above never looks at.
assert!(
hist.red()[..BINS - RAMP].iter().all(|c| *c == 0),
"a pixel landed below the ramp"
);
// A neutral ramp touching neither end: nothing is at the white level
// and nothing is at black, so neither counter may fire.
assert_eq!(hist.saturated(), 0);
assert_eq!(hist.at_black(), 0);
}
#[test]
fn a_channel_saturating_alone_is_seen_and_the_others_are_not_dragged_with_it() {
// **The claim the whole instrument rests on**, and the one a
// luma-weighted or balanced reduction would fail. A red sunset clips
// red while green and blue still hold two stops — that pixel is
// clipped, its red must be at the top of the axis, and its green and
// blue must be where they actually are. A readout that averaged the
// three would report a comfortable exposure over a channel that is
// already gone.
let Some(ctx) = ctx() else { return };
let pass = RawHistogramPass::new(&ctx).expect("pass");
// Two stops below saturation, mid-bin.
let two_stops = mid_bin(2 * BINS_PER_STOP);
let rgb = vec![
[1.0f32, two_stops, two_stops],
[1.0, two_stops, two_stops],
[two_stops, two_stops, two_stops],
[two_stops, two_stops, two_stops],
];
let hist = pass.compute(&source(&ctx, &rgb, 4, 1)).expect("compute");
assert_eq!(hist.pixels(), 4);
assert_eq!(
hist.saturated(),
2,
"two pixels have a channel at the ceiling"
);
assert_eq!(hist.at_black(), 0);
assert_eq!(hist.red()[BINS - 1], 2);
let two_below = BINS - 1 - 2 * BINS_PER_STOP;
assert_eq!(hist.red()[two_below], 2, "the unclipped pixels' red moved");
assert_eq!(
hist.green()[two_below],
4,
"green was dragged along by red's clipping"
);
assert_eq!(hist.blue()[two_below], 4);
// The envelope follows the highest channel, which for the clipped
// pixels is red.
assert_eq!(hist.brightest()[BINS - 1], 2);
assert_eq!(hist.brightest()[two_below], 2);
}
#[test]
fn overshoot_above_the_white_level_folds_into_the_top_bin() {
// The demosaic clamps each *photosite* at 1.0, but the Malvar
// correction can overshoot upward between two clipped ones, so values
// above 1.0 do reach this pass. `log2` of them is negative and a
// signed bin index would wrap to somewhere near the bottom of the
// axis — a blown highlight drawn as deep shadow, which is the single
// most misleading thing this plot could do.
let Some(ctx) = ctx() else { return };
let pass = RawHistogramPass::new(&ctx).expect("pass");
let rgb = vec![[1.4f32, 1.05, 1.0]; 8 * 8];
let hist = pass.compute(&source(&ctx, &rgb, 8, 8)).expect("compute");
assert_eq!(hist.pixels(), 64);
assert_eq!(hist.red()[BINS - 1], 64);
assert_eq!(hist.green()[BINS - 1], 64);
assert_eq!(hist.blue()[BINS - 1], 64);
assert_eq!(hist.saturated(), 64);
assert_eq!(hist.red()[0], 0, "an overshoot wrapped to the bottom bin");
}
#[test]
fn an_edge_tile_is_neither_dropped_nor_counted_twice() {
// A size that is not a multiple of the 16x16 workgroup, so the last
// row and column of workgroups run off the image. The denominator is
// where this shows: `pixels` is summed from the red series, so a tile
// that failed to merge undercounts it and one merged twice doubles it,
// and a percentage computed against either is wrong in a way nobody
// can see on the plot.
let Some(ctx) = ctx() else { return };
let pass = RawHistogramPass::new(&ctx).expect("pass");
let (w, h) = (101u32, 37u32);
let mut rgb = Vec::with_capacity((w * h) as usize);
let mut state = 0x2545_F491_4F6C_DD1Du64;
for _ in 0..w * h {
// 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;
let below = (state.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 56) as usize % RAMP;
let v = mid_bin(below);
rgb.push([v, v, v]);
}
let hist = pass.compute(&source(&ctx, &rgb, w, h)).expect("compute");
assert_eq!(hist.pixels(), w * h, "an edge tile was dropped or doubled");
// Every series counts the same pixels, so every series must sum to the
// same total — a merge that lost one channel's tile would not move the
// denominator at all.
for series in [hist.green(), hist.blue(), hist.brightest()] {
assert_eq!(series.iter().sum::<u32>(), w * h);
}
}
#[test]
fn a_second_photograph_replaces_the_first_rather_than_adding_to_it() {
// The accumulator is reused across images, so a missing clear
// integrates every photograph opened this session. The symptom is
// subtle and awful on a culling pass in particular: the plot keeps a
// plausible shape and slowly stops responding to the file on screen,
// because each new image's contribution shrinks against the running
// total of the folder behind it.
let Some(ctx) = ctx() else { return };
let pass = RawHistogramPass::new(&ctx).expect("pass");
let bright = vec![[mid_bin(BINS_PER_STOP); 3]; 16 * 16];
let dark = vec![[mid_bin(6 * BINS_PER_STOP); 3]; 16 * 16];
let one_stop = BINS - 1 - BINS_PER_STOP;
let six_stops = BINS - 1 - 6 * BINS_PER_STOP;
let first = pass.compute(&source(&ctx, &bright, 16, 16)).expect("first");
assert_eq!(first.red()[one_stop], 256);
let second = pass.compute(&source(&ctx, &dark, 16, 16)).expect("second");
assert_eq!(
second.pixels(),
256,
"the previous photograph was still counted"
);
assert_eq!(second.red()[one_stop], 0);
assert_eq!(second.red()[six_stops], 256);
}
}
+141
View File
@@ -0,0 +1,141 @@
// TRACES: FR-CULL-3 | NFR-P14
// Marking what is sharp, in a layer laid over the frame rather than into it.
//
// # Why the top octave, and not a gradient
//
// The obvious detector is a gradient magnitude — Sobel, or a central
// difference — and it is the wrong one, for a reason that decides whether the
// overlay is useful at all. A gradient answers "is there an edge here", and a
// defocused edge is still an edge: blur a 100-code step with a two-pixel
// Gaussian and the peak gradient is still around 20 codes per pixel, larger
// than a genuinely sharp edge across a low-contrast texture. Peaking built on
// gradients lights up the out-of-focus background of every portrait ever
// taken, which is the frame it exists to reject.
//
// What separates sharp from soft is *scale*, not amplitude. Defocus is a
// low-pass: it removes the top octave and leaves everything below it intact.
// So the detector is a high-pass — this pixel against the mean of its eight
// neighbours, a discrete Laplacian — which by construction responds only to
// the frequencies defocus destroys.
//
// The arithmetic, on a one-dimensional step of height D:
//
// | profile | abs(centre - mean of 8) |
// |--------------------------|-------------------------|
// | hard step, 1 px | 0.375 D |
// | Gaussian blur, sigma 1 | ~0.10 D |
// | Gaussian blur, sigma 2 | ~0.03 D |
// | linear ramp, any slope | 0 |
//
// The ramp row is the property being bought: the smooth luminance falloff
// across an out-of-focus highlight scores zero however bright it is.
//
// # Why luma, and why the histogram's luma
//
// One channel rather than three, because a colour edge carrying no luminance
// difference is both rare and, at the acuity an overlay is read at, invisible.
// The weights are `histogram.wgsl`'s 54/183/19 over 256 — the same Rec.709
// weighting on the same encoded values — so the two instruments in this
// application agree about what "luma" means. Two definitions of brightness in
// one panel is the kind of disagreement nobody finds until it has already
// misled someone.
//
// # Why the frame is read where it is encoded, and not in linear light
//
// This runs on the output of the display transform, on encoded values, and
// that is deliberate: a fixed difference in sRGB code values is roughly
// equally visible wherever it sits in the range, which is what a transfer
// curve is for. Measured in linear light the same detector would need a
// threshold that varied with exposure, and a shadow texture the photographer
// can plainly see would score a hundredth of the identical texture in the
// highlights. The encoding has already done the normalisation, so the
// threshold is one number.
//
// # Why this writes a layer and not the picture
//
// The frame the compositor is handed is also what the histogram counts and
// what an export renders (`app.slint`, on the region overlay: a diagnostic
// "must not reach the histogram, an export, or the texture the develop pass
// hands the compositor"). So the marks go in their own texture — transparent
// everywhere except where something is in focus — and the compositor blends
// them. Nothing about the photograph changes, and the peaking overlay cannot
// leak into a measurement or a file.
//
// Alpha is written as exactly 0 or exactly 1, never between. The importing
// compositor's convention for whether colour arrives premultiplied is not
// something this shader can see, and at those two values the two conventions
// agree — which is a cheaper guarantee than being right about which one it is.
struct Params {
width: u32,
height: u32,
// Luma difference at which a pixel is called in focus. See
// `PeakSensitivity::threshold` for where the three values come from.
threshold: f32,
// std140 rounds the scalar block up to 16 bytes before the vec4; named so
// the Rust struct's padding is visibly the same shape.
pad_0: u32,
// The mark's colour, fully saturated. Its alpha is ignored — see above.
marker: vec4<f32>,
}
@group(0) @binding(0) var frame: texture_2d<f32>;
@group(0) @binding(1) var<uniform> params: Params;
@group(0) @binding(2) var marks: texture_storage_2d<rgba8unorm, write>;
/// Rec.709 luma of an encoded triple, weighted exactly as `histogram.wgsl`
/// weights it. 54 + 183 + 19 is 256, so the weights sum to unity.
fn luma(c: vec3<f32>) -> f32 {
return dot(c, vec3<f32>(54.0, 183.0, 19.0) / 256.0);
}
/// A neighbour, with the frame edge held rather than wrapped.
///
/// Clamping duplicates the edge pixel into the missing half of the
/// neighbourhood, which pulls the mean towards the centre and so biases the
/// response *down* on the outermost row and column. That is the right
/// direction to be wrong in: the failure is a missing mark at the frame edge,
/// where nobody is judging focus, rather than a false mark produced by
/// folding the opposite side of the picture into the kernel.
fn neighbour(x: i32, y: i32) -> f32 {
let cx = clamp(x, 0i, i32(params.width) - 1i);
let cy = clamp(y, 0i, i32(params.height) - 1i);
return luma(textureLoad(frame, vec2<i32>(cx, cy), 0).rgb);
}
// 8x8, matching the detail stage's dispatch. Each texel is loaded by nine
// invocations and no workgroup-memory tile is built to avoid that: at viewport
// resolution the reads are perfectly coherent and the texture cache serves
// eight of the nine. The budget is NFR-P14's 100 ms against a dispatch
// measured in tenths of a millisecond, so there is nothing here worth the
// complexity of a tiled load.
@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);
// The eight neighbours, centre excluded. Excluded rather than folded in
// because it makes the response readable: `abs(c - mean8)` is the height
// of this pixel above its surroundings in the same units as the step it
// sits on, so the threshold can be quoted as a luma difference rather than
// as eight-ninths of one.
var sum = 0.0;
for (var dy = -1; dy <= 1; dy = dy + 1) {
for (var dx = -1; dx <= 1; dx = dx + 1) {
if (dx != 0 || dy != 0) {
sum = sum + neighbour(x + dx, y + dy);
}
}
}
let centre = luma(textureLoad(frame, vec2<i32>(x, y), 0).rgb);
let response = abs(centre - sum / 8.0);
if (response >= params.threshold) {
textureStore(marks, vec2<i32>(x, y), vec4<f32>(params.marker.rgb, 1.0));
} else {
textureStore(marks, vec2<i32>(x, y), vec4<f32>(0.0, 0.0, 0.0, 0.0));
}
}
+254 -2
View File
@@ -28,7 +28,8 @@ struct MaskParams {
label_width: u32,
label_height: u32,
// 0 = regions, 1 = linear, 2 = radial, 3 = subject, 4 = brush.
// 0 = regions, 1 = linear, 2 = radial, 3 = subject, 4 = brush,
// 5 = luminance range, 6 = colour range.
//
// 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
@@ -50,10 +51,49 @@ struct MaskParams {
// 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.
// A range: the fade at each edge of its band, in the band's own units.
softness: f32,
// Radial only: rotation of the ellipse.
angle: f32,
_pad1: vec2<f32>,
// TRACES: FR-DEV-10
// How many source texels one mask texel spans, per axis.
//
// The mask array is rasterised at a proxy size and the photograph is not,
// so one texel here covers several there. A range mask is a function of
// pixel *values*, and point-sampling one source texel in four would make
// its edge follow the sensor's noise wherever the picture has fine
// texture — speckle that is then a mask, and therefore visible in the
// adjustment. Averaging the footprint is what makes the band land on the
// tone the area actually is.
source_step: vec2<f32>,
// Camera RGB → linear sRGB, one row each. Only a range reads these: it is
// the one mask that looks at the photograph, and a hue is the body's own
// primaries until this matrix has been applied — so the same stored arc
// would select a different set of colours on every make of sensor.
cam_to_srgb_0: vec4<f32>,
cam_to_srgb_1: vec4<f32>,
cam_to_srgb_2: vec4<f32>,
// rgb: as-shot white balance. w: non-zero when the source arrived
// gamma-encoded rather than linear.
as_shot_wb: vec4<f32>,
// Whether this part is turned over before it joins the mask.
//
// Read by `fs_combine` and by nothing else, deliberately. A brush deposits
// dabs onto an empty field and has no idea what the rest of the frame is,
// so a stroke shader cannot invert anything; doing it where the finished
// part is read back is the one place that works for every kind of source.
invert: u32,
// Three scalars rather than a `vec3<u32>`: a three-component vector is
// aligned to sixteen bytes in the uniform address space, so it would sit
// at offset 144 and make this struct 160 bytes against the Rust side's
// 144 — a mismatch wgpu reports as a binding too small for the shader,
// several layers away from the padding that caused it.
_pad0: u32,
_pad1: u32,
_pad2: u32,
}
@group(0) @binding(0) var<uniform> p: MaskParams;
@@ -74,6 +114,19 @@ struct MaskParams {
// 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>;
// TRACES: FR-DEV-10
// The photograph itself, as the demosaicer left it: camera RGB, unbalanced,
// with no edit applied. A 1x1 placeholder for every mask that is a shape,
// because the bindings are fixed and a second pipeline differing only in what
// it ignores would cost more than one texel.
//
// **The unedited image, and that is the design rather than an accident of
// pass order.** A band over the *edited* result would move as the edit was
// made: raising the highlights would change which pixels counted as
// highlights, so the slider would chase its own mask. Measuring what the
// camera recorded means the selection stays where the photographer put it
// while they work on it.
@group(0) @binding(6) var image: 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.
@@ -221,6 +274,177 @@ fn subject_mask(uv: vec2<f32>) -> f32 {
}
}
// ---------------------------------------------------------------------------
// Range masks (FR-DEV-10)
// ---------------------------------------------------------------------------
//
// The masks that select by what a pixel *is* rather than by where it sits.
// Nothing below reads `frame_delta`, and that absence is the point: a range is
// not a function of position, so it cannot be stretched by an aspect ratio,
// cannot drift under a crop, and comes out the same at a proxy size and at an
// export because the only thing it depends on is the photograph's own values.
//
// The band arrives entirely in the fields the gradients use — `axis` is the
// pair of bounds, `centre` is a colour range's arc, `softness` is the fade —
// so a range costs nothing in the uniform beyond the image transform above.
// Display-encoded sRGB back to linear.
//
// A JPEG is uploaded with its bytes untouched, so its values are gamma-encoded
// where the demosaicer's are linear. The same undoing the generated adjust
// shader does, at the same point and for the same reason: a band over
// brightness is meaningless if two sources disagree about what a value means.
fn decode_srgb(c: vec3<f32>) -> vec3<f32> {
let lo = c / 12.92;
let hi = pow((max(c, vec3<f32>(0.04045)) + 0.055) / 1.055, vec3<f32>(2.4));
return select(hi, lo, c <= vec3<f32>(0.04045));
}
// One source texel, as linear sRGB.
//
// This is the prologue of the generated adjust shader, repeated: decode,
// balance, pull a clipped pixel back to neutral, then the camera matrix. It is
// repeated rather than shared because the composer emits WGSL for the *edit*
// and this pass is not one — but it must agree with it, since a range mask
// exists to select the values the layer's own adjustments will then see.
//
// The highlight desaturation is the part that looks skippable and is not. A
// fully clipped photosite arrives as (1,1,1), carrying no colour at all; the
// as-shot multipliers are far from neutral, so balancing it and passing it
// through the matrix produces a strong magenta. A colour range would then
// select every blown sky as if the photographer had asked for magenta.
fn source_texel(px: vec2<i32>) -> vec3<f32> {
var c = textureLoad(image, px, 0).rgb;
if (p.as_shot_wb.w > 0.5) {
c = decode_srgb(c);
}
let clipped = smoothstep(0.985, 1.0, max(c.r, max(c.g, c.b)));
c = c * p.as_shot_wb.rgb;
if (clipped > 0.0) {
c = mix(c, vec3<f32>(max(c.r, max(c.g, c.b))), clipped);
}
return vec3<f32>(
dot(p.cam_to_srgb_0.rgb, c),
dot(p.cam_to_srgb_1.rgb, c),
dot(p.cam_to_srgb_2.rgb, c),
);
}
// The most taps one mask texel averages, per axis.
//
// A cap rather than the true footprint. At a 1600 px proxy over a 24 MP frame
// the ratio is under four, so this is the whole footprint for every ordinary
// photograph; past it the taps stride across the footprint instead of
// covering it, which is a sample of the area rather than its mean. That is the
// right way to run out of budget here — the estimate gets noisier, it does not
// start measuring somewhere else.
const MAX_SOURCE_TAPS: i32 = 4;
// The photograph's value under one mask texel, in linear sRGB.
fn image_value(px: vec2<i32>) -> vec3<f32> {
let dims = vec2<i32>(textureDimensions(image));
let last = dims - vec2<i32>(1);
// The footprint's top-left corner in source texels. Not a centre plus a
// radius: the mask texel is a *box* over the source, and sampling
// symmetrically about its centre would weight the middle of every
// footprint twice at odd tap counts.
let origin = vec2<f32>(px) * p.source_step;
let taps = clamp(vec2<i32>(ceil(p.source_step)), vec2<i32>(1), vec2<i32>(MAX_SOURCE_TAPS));
// `stride`, not `step`: WGSL has a builtin of that name, and a local that
// shadows one is legal and unreadable in the same breath.
let stride = p.source_step / vec2<f32>(taps);
var total = vec3<f32>(0.0);
for (var y = 0; y < taps.y; y = y + 1) {
for (var x = 0; x < taps.x; x = x + 1) {
let at = origin + (vec2<f32>(f32(x), f32(y)) + vec2<f32>(0.5)) * stride;
total = total + source_texel(clamp(vec2<i32>(at), vec2<i32>(0), last));
}
}
return total / f32(taps.x * taps.y);
}
// A soft band: one inside, nothing outside, a smooth ramp across each edge.
//
// The `min` rather than a product of the two ramps. A band narrower than twice
// its softness has no plateau, and multiplying the rising and falling ramps
// would then peak well below one — so "select the highlights" would come out
// at sixty per cent and the photographer would compensate with opacity,
// against a mask that was quietly weaker than it said. `min` keeps the
// plateau where there is one and degrades to a single peak where there is not.
fn band(v: f32, lo: f32, hi: f32, soft: f32) -> f32 {
if (soft <= 0.0) {
return select(0.0, 1.0, v >= lo && v <= hi);
}
return min(smoothstep(lo - soft, lo, v), 1.0 - smoothstep(hi, hi + soft, v));
}
fn luminance_mask(px: vec2<i32>) -> f32 {
let y = dot(image_value(px), vec3<f32>(0.2126, 0.7152, 0.0722));
// Onto the perceptual position `tone_position` in `ops/_helpers.yaml`
// establishes, which is where the stored bounds are measured. Linear light
// puts middle grey at 0.18, so a band stated in it would spend four fifths
// of its travel inside the shadows.
let t = clamp(pow(max(y, 0.0), 1.0 / 3.0), 0.0, 1.0);
return band(t, p.axis.x, p.axis.y, p.softness);
}
// Hue in turns, 0 at red and increasing through yellow.
//
// The plain six-sector definition. Zero for a neutral, which is a value the
// caller must not act on — the chroma bound below is what keeps a colour range
// away from the greys where this number is rounding noise.
fn hue_of(c: vec3<f32>) -> f32 {
let hi = max(c.r, max(c.g, c.b));
let lo = min(c.r, min(c.g, c.b));
let d = hi - lo;
if (d <= 0.0) {
return 0.0;
}
var h = 0.0;
if (hi == c.r) {
h = (c.g - c.b) / d;
} else if (hi == c.g) {
h = (c.b - c.r) / d + 2.0;
} else {
h = (c.r - c.g) / d + 4.0;
}
return fract(h / 6.0);
}
fn colour_mask(px: vec2<i32>) -> f32 {
let c = max(image_value(px), vec3<f32>(0.0));
let hi = max(c.r, max(c.g, c.b));
let lo = min(c.r, min(c.g, c.b));
// The max-minus-min chroma `colour_saturation` uses, so the number the
// band is stated in is the one the rest of the pipeline means by
// 'colourfulness'.
var chroma = 0.0;
if (hi > 0.0) {
chroma = (hi - lo) / hi;
}
// Distance round the circle, so an arc centred near red reaches both ways
// past zero. Written as a wrap rather than as two comparisons because red
// is exactly where skin sits, and an arc that stopped at the seam would
// select half of it.
let d = abs(fract(hue_of(c) - p.centre.x + 0.5) - 0.5);
var arc = 0.0;
if (p.softness <= 0.0) {
arc = select(0.0, 1.0, d <= p.centre.y);
} else {
arc = 1.0 - smoothstep(p.centre.y, p.centre.y + p.softness, d);
}
// Both, not either: an arc alone selects a haze of noise everywhere the
// picture is nearly grey, because a hue rounded out of three almost-equal
// channels is still a hue.
return min(arc, band(chroma, p.axis.x, p.axis.y, p.softness));
}
@fragment
fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let px = vec2<i32>(i32(pos.x), i32(pos.y));
@@ -235,6 +459,8 @@ fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
case 1u: { m = linear_mask(uv); }
case 2u: { m = radial_mask(uv); }
case 3u: { m = subject_mask(uv); }
case 5u: { m = luminance_mask(px); }
case 6u: { m = colour_mask(px); }
default: { m = 0.0; }
}
@@ -387,3 +613,29 @@ fn fs_brush(in: BrushVertex) -> @location(0) vec4<f32> {
return vec4<f32>(clamp(coverage * s.flow, 0.0, 1.0), 0.0, 0.0, 1.0);
}
// ---------------------------------------------------------------------------
// Joining one part to the mask so far
// ---------------------------------------------------------------------------
//
// A layer's mask is a fold over its parts, and the set operation is the *blend
// state* rather than arithmetic here: union is `max(dst, src)`, subtraction is
// `dst * (1 - src)`. Both are fixed-function, so joining a part costs one
// full-screen draw and no second texture beyond the one being read.
//
// # Why a part is drawn aside first, rather than straight onto the mask
//
// Because an erase stroke inside a part means "a hole in *this* part", not "a
// hole in the mask". Painted straight onto the accumulator it would take away
// whatever the parts before it had put there — so a correction that tidied its
// own edge would punch through the subject underneath, and the failure would
// look like the model's mask had holes in it.
@group(0) @binding(7) var part_mask: texture_2d<f32>;
@fragment
fn fs_combine(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let v = textureLoad(part_mask, vec2<i32>(i32(pos.x), i32(pos.y)), 0).r;
let m = select(v, 1.0 - v, p.invert != 0u);
return vec4<f32>(clamp(m, 0.0, 1.0), 0.0, 0.0, 1.0);
}
+156
View File
@@ -0,0 +1,156 @@
// TRACES: FR-CULL-3
// A reduction of the *scene-linear* frame onto a stops-below-saturation axis.
//
// The shader beside this one, `histogram.wgsl`, counts the frame the display
// is about to show: an 8-bit code value, after white balance, the camera
// matrix, the base curve, the tone curve and the output transform. This one
// counts the texture the demosaic wrote, before any of that. The two differ in
// exactly one place — the axis — and everything else here is deliberately the
// same construction, because the two reductions have the same shape and any
// divergence between them would be a difference nobody chose.
//
// **Why the axis is logarithmic and measured downward from 1.0.** The texture
// is normalised by the sensor's own black and white levels (see
// `demosaic.wgsl`), so 1.0 *is* saturation by construction and there is no
// other meaningful origin. And the question a photographer asks of raw data is
// "how much headroom is left", which is a question in stops: a linear axis
// spends half its width on the top stop and crushes the eleven below it into
// the leftmost pixel, which is why nobody has ever drawn a useful linear raw
// histogram.
//
// So bin 255 is the top 1/16 stop below saturation, bin 239 is one stop below,
// bin 0 is 15.9375 stops below **and everything darker**. A bin is 1/16 of a
// stop, which is about 4.4% in value — fine enough that a clipped highlight is
// visibly its own column rather than smeared over the top quarter of the plot.
//
// **Counted per workgroup first, then merged**, for the reason
// `histogram.wgsl` gives at length: a photograph is not noise, tens of
// thousands of adjacent pixels land in one bin, and having every invocation
// contend for one global atomic serialises the dispatch. Four kilobytes of
// workgroup storage against a 16 KB floor.
const BINS: u32 = 256u;
// Bins per stop. Must equal `BINS_PER_STOP` on the Rust side, which turns a
// bin index back into a stops figure for the readout — the two disagreeing
// would put a correct plot under a wrong number.
const BINS_PER_STOP: f32 = 16.0;
// Two counters past the four series, in the same buffer and for the same
// reason `histogram.wgsl` puts its there: they are gathered from the texel
// read the binning already did, and a second reduction to answer them would be
// a second pass over the whole image for two integers.
const SATURATED: u32 = 4u * BINS;
const AT_BLACK: u32 = 4u * BINS + 1u;
const SLOTS: u32 = 4u * BINS + 2u;
// 16x16. Stated as a constant because the clear and merge loops stride by it,
// and a workgroup size that disagreed would leave slots uncleared.
const THREADS: u32 = 256u;
// How close to 1.0 counts as saturated.
//
// **Not `>= 1.0`, and the margin is arithmetic rather than taste.** A photosite
// at or above the declared white level leaves `demosaic.wgsl` clamped to
// exactly 1.0, but it then travels through an f16 texture and, at a green
// site, through an interpolation kernel whose terms are added in a different
// order than a reader would expect. f16 spaces its values 2^-11 apart just
// below 1.0, so a value that was 1.0 can arrive an ulp or two under it. This
// margin is four of those, and 0.0014 of a stop — a hundredth of the width of
// one bin — so it cannot move a column of the plot, only stop the counter
// missing a genuinely blown pixel.
const SATURATION: f32 = 0.999;
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 bin a scene-linear value belongs in.
//
// Zero and below land in bin 0: `log2` of them is not a number, and a
// photosite at or under the black level has no signal to place on a
// logarithmic axis anyway — it is the bottom of the scale, which is where the
// bottom bin is.
//
// Above 1.0 lands in bin 255. Such values exist: the demosaic clamps each
// *photosite* at the white level, but the Malvar correction term can overshoot
// upward between two clipped ones, so the interpolated output can pass 1.0 by
// a little. That is an artefact of the reconstruction and not headroom above
// saturation, and folding it into the top bin says exactly that.
fn bin_of(v: f32) -> u32 {
if (v <= 0.0) {
return 0u;
}
// Stops below saturation, in sixteenths, floored. `-log2` because the axis
// increases downward from 1.0 and a bin index increases upward.
let steps = floor(-log2(v) * BINS_PER_STOP);
return (BINS - 1u) - u32(clamp(steps, 0.0, f32(BINS - 1u)));
}
@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 = texel.r;
let g = texel.g;
let b = texel.b;
// The fourth series is the **brightest** channel at each pixel, where
// the display histogram's fourth series is Rec.709 luma. Luma would be
// meaningless here: these are camera-native values with the as-shot
// white balance still un-applied, so red and blue sit a stop or more
// below green on a neutral subject and any weighted sum of them is a
// number about nothing. The brightest channel, by contrast, is exactly
// the quantity the headroom question is about — it is the one that
// reaches saturation first and decides whether the pixel is
// recoverable.
let brightest = max(r, max(g, b));
atomicAdd(&tile[bin_of(r)], 1u);
atomicAdd(&tile[BINS + bin_of(g)], 1u);
atomicAdd(&tile[2u * BINS + bin_of(b)], 1u);
atomicAdd(&tile[3u * BINS + bin_of(brightest)], 1u);
// Any channel, not all three, exactly as the display histogram counts
// it: a saturated red photosite has no gradation left in it however
// much headroom green and blue still hold, and a sunset or a red
// jersey is precisely the subject that clips one channel first.
if (brightest >= SATURATION) {
atomicAdd(&tile[SATURATED], 1u);
}
// And at the other end: a channel that reached the black level has no
// signal, only the read noise the normalisation clamped away.
if (min(r, min(g, b)) <= 0.0) {
atomicAdd(&tile[AT_BLACK], 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);
}
}
}
+1
View File
@@ -67,6 +67,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
.to_string();
ComposedDetailPass {
output_scale: 1,
label: "test/instances".to_string(),
source,
uniforms: vec![SIZE as f32, SIZE as f32, 1.0, 0.0],
+53
View File
@@ -413,3 +413,56 @@ fn an_empty_chain_falls_through_to_the_ordinary_render() {
assert_eq!(pass.detail_dispatches(), 0);
assert_eq!(pass.detail_allocations(), 0);
}
/// TRACES: FR-PLAT-AND-5
#[test]
fn eviction_gives_the_pools_back_without_changing_a_pixel() {
// The half of memory-pressure eviction that cannot be checked by looking
// at a counter. `release_caches` frees the detail pool, and slot 0 of that
// pool is where the fused colour result lives between frames — so the
// render after an eviction has to notice that the promise recorded in
// `colour_key` no longer holds and run the colour chain again.
//
// Leave the key standing and this test does not error: it draws. It draws
// whatever a freshly-allocated texture happens to contain, which is the
// failure worth building a test around, because on a device it would
// appear only under memory pressure and only as a wrong-looking photograph.
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);
let before = render(&ctx, &mut pass, &graph, &source, SIZE);
assert!(
pass.cached_pipelines() > 0,
"the colour pass compiled something"
);
assert!(
pass.cached_detail_pipelines() > 0,
"so did the detail stage"
);
let allocations = pass.detail_allocations();
assert!(allocations > 0, "and the pool holds textures");
pass.release_caches();
assert_eq!(pass.cached_pipelines(), 0);
assert_eq!(pass.cached_detail_pipelines(), 0);
// The same edit at the same size. Nothing about the picture changed, so
// nothing about the pixels may change either — only what it cost.
let after = render(&ctx, &mut pass, &graph, &source, SIZE);
assert_eq!(before.len(), after.len());
for (i, (a, b)) in before.iter().zip(&after).enumerate() {
assert!(
a.abs_diff(*b) <= 1,
"byte {i}: {a} before eviction, {b} after — the colour chain did \
not re-run, so this frame is reading an empty intermediate"
);
}
assert!(
pass.detail_allocations() > allocations,
"the pool was rebuilt, which is the evidence it was really given back"
);
}
+426 -9
View File
@@ -12,8 +12,8 @@
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::mask::{Join, MaskLayer, MaskPart, MaskSource, MaskStack, Reveal, RevealStyle};
use dr_pipeline::operation::{compose_full, compose_full_revealing};
use dr_pipeline::spot::SpotSet;
use dr_pipeline::{ops, EditGraph, Framing};
use dr_types::ColourSpace;
@@ -72,10 +72,13 @@ fn render_at(
ColourSpace::Srgb,
stack,
&SpotSet::new(),
&[],
);
let mut masks = MaskPass::new(ctx).expect("mask pass");
let array = masks.render(stack, field, None, w, h).expect("rasterise");
let array = masks
.render(stack, field, None, None, w, h)
.expect("rasterise");
let mut adjust = AdjustPass::new(ctx);
adjust
@@ -283,11 +286,11 @@ type Gesture = (bool, f32, f32, Vec<(f32, f32)>);
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);
layer.begin_stroke(0, *erase, *radius, 0.9, *flow);
for &(x, y) in path {
layer.extend_stroke(x, y);
layer.extend_stroke(0, x, y);
}
layer.end_stroke();
layer.end_stroke(0);
}
layer
}
@@ -502,9 +505,9 @@ fn hardness_decides_how_quickly_the_edge_falls_away() {
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();
layer.begin_stroke(0, false, 0.4, hardness, 1.0);
layer.extend_stroke(0, 0.5, 0.5);
layer.end_stroke(0);
let pixels = render(&ctx, &stack_of(layer), None);
// How many pixels along the centre row are neither fully painted nor
@@ -605,3 +608,417 @@ fn an_empty_stack_renders_exactly_as_the_unmasked_path() {
let masked = render(&ctx, &MaskStack::new(), None);
assert_eq!(plain, masked, "an empty mask stack must be a no-op");
}
// ---------------------------------------------------------------------------
// Parts — a mask built from more than one selection
// ---------------------------------------------------------------------------
/// Everything, so a part joined to it has something to change.
fn whole_frame() -> MaskSource {
MaskSource::Regions {
signature: 1,
level: 2,
ids: vec![0, 1],
}
}
/// Paint one gesture into a part of a layer, at a radius large enough that a
/// 32-pixel frame can tell where it landed.
fn paint(layer: &mut MaskLayer, part: usize, erase: bool, path: &[(f32, f32)]) {
assert!(
layer.begin_stroke(part, erase, 0.2, 1.0, 1.0),
"the layer had no room for a stroke"
);
for &(x, y) in path {
layer.extend_stroke(part, x, y);
}
layer.end_stroke(part);
}
#[test]
fn a_union_part_adds_what_the_base_did_not_cover() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let mut layer = brighten(MaskSource::Regions {
signature: 1,
level: 2,
ids: vec![0],
});
assert!(layer.push_part(MaskPart::painted("p2", Join::Union)));
paint(&mut layer, 1, false, &[(0.85, 0.5)]);
let mut stack = MaskStack::new();
stack.push(layer);
let pixels = render(&ctx, &stack, Some(&split_field(&ctx)));
assert!(
luma_at(&pixels, 4, 16) > 200,
"the base region is still masked"
);
assert!(
luma_at(&pixels, 27, 16) > 200,
"and the painted part joined the other half in"
);
assert_eq!(
luma_at(&pixels, 20, 2),
128,
"while what neither covers is untouched"
);
}
#[test]
fn a_subtract_part_takes_a_bite_out_of_the_mask() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let mut layer = brighten(whole_frame());
assert!(layer.push_part(MaskPart::painted("p2", Join::Subtract)));
paint(&mut layer, 1, false, &[(0.5, 0.5)]);
let mut stack = MaskStack::new();
stack.push(layer);
let pixels = render(&ctx, &stack, Some(&split_field(&ctx)));
assert_eq!(
luma_at(&pixels, 16, 16),
128,
"the middle was taken back out of the mask"
);
assert!(
luma_at(&pixels, 1, 1) > 200,
"and the corner the stroke never reached is still in it"
);
}
/// **The test the scratch texture exists for.** An erase stroke means a hole in
/// the part it was painted into, not a hole in the mask: drawn straight onto
/// the accumulator it would take away whatever the parts before it had put
/// there, so tidying the edge of a correction would punch through the subject
/// underneath — and the failure would read as the model's mask having holes.
#[test]
fn an_erase_stroke_holes_its_own_part_and_not_the_mask() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let mut layer = brighten(whole_frame());
assert!(layer.push_part(MaskPart::painted("p2", Join::Union)));
paint(&mut layer, 1, false, &[(0.5, 0.5)]);
paint(&mut layer, 1, true, &[(0.5, 0.5)]);
let mut stack = MaskStack::new();
stack.push(layer);
let pixels = render(&ctx, &stack, Some(&split_field(&ctx)));
assert!(
luma_at(&pixels, 16, 16) > 200,
"the base still covers where the correction erased itself"
);
}
#[test]
fn an_inverted_part_joins_everything_it_did_not_paint() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
// A base that covers nothing, so what shows is the part alone.
let mut layer = brighten(MaskSource::brush());
paint(&mut layer, 0, false, &[(0.1, 0.1)]);
assert!(layer.push_part(MaskPart::painted("p2", Join::Union)));
layer.parts_mut()[1].invert = true;
paint(&mut layer, 1, false, &[(0.5, 0.5)]);
let mut stack = MaskStack::new();
stack.push(layer);
let pixels = render(&ctx, &stack, Some(&split_field(&ctx)));
assert_eq!(
luma_at(&pixels, 16, 16),
128,
"where the inverted part was painted is outside the mask"
);
assert!(
luma_at(&pixels, 30, 30) > 200,
"and everywhere it was not is inside it"
);
}
/// Parts fold in order, so the same two selections joined the other way round
/// are a different mask. A subtraction that lands before the part it was meant
/// to cut into would take away nothing at all.
#[test]
fn the_order_parts_are_joined_in_is_the_mask() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let render_pair = |cut_first: bool| {
let mut layer = brighten(MaskSource::brush());
if cut_first {
layer.push_part(MaskPart::painted("p2", Join::Subtract));
layer.push_part(MaskPart::painted("p3", Join::Union));
paint(&mut layer, 1, false, &[(0.5, 0.5)]);
paint(&mut layer, 2, false, &[(0.5, 0.5)]);
} else {
layer.push_part(MaskPart::painted("p2", Join::Union));
layer.push_part(MaskPart::painted("p3", Join::Subtract));
paint(&mut layer, 1, false, &[(0.5, 0.5)]);
paint(&mut layer, 2, false, &[(0.5, 0.5)]);
}
let mut stack = MaskStack::new();
stack.push(layer);
render(&ctx, &stack, Some(&split_field(&ctx)))
};
assert!(
luma_at(&render_pair(true), 16, 16) > 200,
"adding after a subtraction leaves the addition standing"
);
assert_eq!(
luma_at(&render_pair(false), 16, 16),
128,
"and subtracting after an addition takes it away again"
);
}
// --- seeing the mask (FR-DEV-19c) ------------------------------------------
/// A radial that covers the middle of the frame and nothing near the corners.
fn middle() -> MaskSource {
MaskSource::Radial {
centre: (0.5, 0.5),
radii: (0.3, 0.3),
angle: 0.0,
feather: 0.05,
}
}
/// [`render`], with one layer's mask drawn over the result.
fn render_revealing(ctx: &GpuContext, stack: &MaskStack, reveal: &Reveal) -> Vec<u8> {
let source = grey(ctx);
let shader = compose_full_revealing(
&ops::chain(),
&Framing::new(),
ColourSpace::Srgb,
stack,
&SpotSet::new(),
&[],
Some(reveal),
);
let mut masks = MaskPass::new(ctx).expect("mask pass");
let array = masks
.render_revealing(stack, None, None, None, SIZE, SIZE, Some(reveal))
.expect("rasterise");
let mut adjust = AdjustPass::new(ctx);
adjust
.render_masked(&source, &shader, SIZE, SIZE, Some(array))
.expect("render");
adjust.export_pixels().expect("readback").0
}
/// TRACES: FR-DEV-19c
/// The state every mask is in for its first few seconds: chosen, and not yet
/// used for anything.
///
/// Such a layer changes no pixel, so it is not active, so it occupied no mask
/// slot and was never rasterised — and the reveal drew nothing. That is the
/// whole of "I clicked the category and nothing happened": there was a mask,
/// and no way to see that there was.
#[test]
fn a_selection_with_no_adjustment_can_still_be_seen() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let mut stack = MaskStack::new();
stack.push(MaskLayer::new("m1", middle()));
assert!(
stack.is_neutral(),
"the fixture must be a selection with nothing done to it"
);
let pixels = render_revealing(&ctx, &stack, &Reveal::one("m1", RevealStyle::Alpha));
assert!(
luma_at(&pixels, SIZE / 2, SIZE / 2) > 200,
"the middle is inside the mask and should read white"
);
assert!(
luma_at(&pixels, 1, 1) < 40,
"the corner is outside it and should read black"
);
}
/// And with nobody looking, the same stack changes nothing at all.
///
/// The other half of the property above: a layer renders *because* it is being
/// revealed, so it must stop when the reveal does — otherwise a selection with
/// no adjustment would leave a slice in the array for ever.
#[test]
fn a_mask_nobody_is_looking_at_draws_nothing() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let mut stack = MaskStack::new();
stack.push(MaskLayer::new("m1", middle()));
let pixels = render(&ctx, &stack, None);
assert_eq!(
luma_at(&pixels, SIZE / 2, SIZE / 2),
128,
"flat grey, exactly as it went in"
);
}
/// TRACES: FR-DEV-19c
/// A tint has to leave the photograph visible, or it cannot be judged against
/// it — which is the one thing an overlay exists for.
#[test]
fn a_tint_colours_the_mask_and_leaves_the_rest_alone() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let mut stack = MaskStack::new();
stack.push(MaskLayer::new("m1", middle()));
let pixels = render_revealing(&ctx, &stack, &Reveal::one("m1", RevealStyle::Tint));
let at = |x: u32, y: u32| {
let i = ((y * SIZE + x) * 4) as usize;
(pixels[i], pixels[i + 1], pixels[i + 2])
};
let (r, g, _) = at(SIZE / 2, SIZE / 2);
assert!(r > g + 40, "the mask should read red, got r={r} g={g}");
assert!(
g > 20,
"and not opaque — the photograph under it is what the tint is judged \
against, got g={g}"
);
let (r, g, b) = at(1, 1);
assert!(
(120..=136).contains(&r) && r == g && g == b,
"outside the mask the photograph is untouched, got ({r}, {g}, {b})"
);
}
/// TRACES: FR-DEV-19c
/// An outline draws where the mask stops and nowhere else — which is the
/// point of it, since the other two styles cover the detail the boundary has
/// to be judged against.
#[test]
fn an_outline_draws_the_boundary_and_not_the_interior() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let mut stack = MaskStack::new();
stack.push(MaskLayer::new("m1", middle()));
let pixels = render_revealing(&ctx, &stack, &Reveal::one("m1", RevealStyle::Edge));
// Where the line landed, along the row through the centre. Searched
// rather than sampled at one place: the radial's edge crosses this row
// about 9.6 pixels out from the middle on a 32px frame, and asserting a
// particular pixel would be asserting the rounding.
let (at, brightest) = (SIZE / 2..SIZE)
.map(|x| (x, luma_at(&pixels, x, SIZE / 2)))
.max_by_key(|&(_, v)| v)
.expect("the row is not empty");
assert!(
brightest > 160,
"there should be a line somewhere on this row, brightest was {brightest}"
);
assert!(
(SIZE / 2 + 7..=SIZE / 2 + 12).contains(&at),
"and it should be on the mask's boundary, not somewhere else: x={at}"
);
assert_eq!(
luma_at(&pixels, SIZE / 2, SIZE / 2),
128,
"the picture inside the mask is untouched"
);
assert_eq!(
luma_at(&pixels, 1, 1),
128,
"and so is the picture outside it"
);
}
/// TRACES: FR-DEV-19c
/// Two masks shown at once come out in two colours, each where its own mask
/// is — which is what makes "where do these meet" a question the screen can
/// answer.
#[test]
fn two_shown_masks_are_drawn_each_in_its_own_colour() {
use dr_pipeline::mask::RevealedLayer;
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
// A left half and a right half, as two brush layers with one fat dab each.
let half = |id: &str, x: f32| {
let mut layer = MaskLayer::new(id, MaskSource::brush());
paint(&mut layer, 0, false, &[(x, 0.5)]);
layer
};
let mut stack = MaskStack::new();
stack.push(half("left", 0.2));
stack.push(half("right", 0.8));
let reveal = Reveal {
layers: vec![
RevealedLayer {
layer: "left".into(),
colour: [1.0, 0.0, 0.0],
},
RevealedLayer {
layer: "right".into(),
colour: [0.0, 0.0, 1.0],
},
],
style: RevealStyle::Alpha,
};
let pixels = render_revealing(&ctx, &stack, &reveal);
let at = |x: u32| {
let i = ((SIZE / 2 * SIZE + x) * 4) as usize;
(pixels[i], pixels[i + 1], pixels[i + 2])
};
let (r, _, b) = at(SIZE / 5);
assert!(
r > 200 && b < 40,
"the left mask reads red, got r={r} b={b}"
);
let (r, _, b) = at(SIZE * 4 / 5);
assert!(
b > 200 && r < 40,
"the right mask reads blue, got r={r} b={b}"
);
let (r, g, b) = at(SIZE / 2);
assert!(
r < 40 && g < 40 && b < 40,
"between them, alpha shows black: ({r}, {g}, {b})"
);
}
+101 -6
View File
@@ -27,6 +27,7 @@
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext};
use dr_pipeline::descriptor::{OpId, ParamId};
use dr_pipeline::detail::RenderScale;
use dr_pipeline::ops::local_contrast::Clarity;
use dr_pipeline::{Affects, EditGraph, OutputMode};
use dr_types::ColourSpace;
@@ -323,6 +324,87 @@ fn a_proxy_and_an_export_agree_about_the_effect() {
);
}
#[test]
fn crossing_the_reduction_threshold_does_not_change_the_picture() {
// TRACES: FR-DSP-3 — `docs/technical-debt.md` TD-4, held in pixels.
//
// Clarity's base is computed on a reduced grid, and how reduced depends on
// the viewport: `LocalContrast::reduction` steps 4 -> 2 -> 1 as sigma
// falls, because a quarter of a small sigma is not a Gaussian any more.
// The whole claim of the optimisation is that this is invisible — that a
// base sampled at a quarter is not an approximation of the full-resolution
// one but the same band-limited function, sampled where it is still fully
// determined.
//
// Every other test in this file measures one form against itself. This is
// the only one that measures the forms against *each other*, and it is the
// one that would fail if the reduction were quietly softening the control,
// shifting it half a reduced pixel, or blocking the base into 4x4 squares.
//
// The two sizes are chosen to sit either side of a step-down: sigma is
// 1.2% of the shorter edge, so 512 gives 6.1 px and reduces by four, while
// 288 gives 3.5 px — under `MIN_REDUCED_SIGMA` once quartered — and
// reduces by two. Asserted rather than assumed, because the whole test is
// vacuous if both sides land on the same reduction.
let Some(ctx) = ctx() else { return };
assert_eq!(
Clarity::with_amount(100.0).reduction(RenderScale::full((512, 512))),
4
);
assert_eq!(
Clarity::with_amount(100.0).reduction(RenderScale::full((288, 288))),
2
);
let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]);
// Peak excursion in stops and the fraction of the frame it covers — the
// same two numbers `a_proxy_and_an_export_agree_about_the_effect` uses,
// and for the same reason: both are scale-free, so they are comparable
// between two renders of different sizes.
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);
let touched = moved.iter().filter(|m| **m > peak * 0.1).count();
(peak, touched as f32 / out as f32)
};
let (quartered_peak, quartered_reach) = measure(512);
let (halved_peak, halved_reach) = measure(288);
// The same tolerances the proxy/export test uses. They are not loose: the
// bound this operation guarantees is 0.35 stops, so 0.03 is under a tenth
// of the full excursion.
assert!(
(quartered_peak - halved_peak).abs() < 0.03,
"a quarter-scale base gives {quartered_peak:.3} stops and a half-scale \
one {halved_peak:.3}; the reduction is supposed to be invisible"
);
assert!(
(quartered_reach - halved_reach).abs() < 0.02,
"the effect covers {quartered_reach:.3} of the frame reduced by four \
and {halved_reach:.3} reduced by two; the base has changed width"
);
assert!(
quartered_peak > 0.1,
"{quartered_peak:.3} stops — two flat images would also agree"
);
}
#[test]
fn texture_acts_at_a_finer_scale_than_clarity() {
// The whole reason there are two nodes. If the two controls ever reach the
@@ -485,10 +567,15 @@ fn dragging_the_slider_re_runs_the_detail_stage_and_nothing_else() {
let mut graph = graph_with(CLARITY, 40.0);
render(&mut pass, &graph, &source, 256);
assert_eq!(pass.colour_dispatches(), 1);
// Four at this size: reduce, the two blur halves on the reduced grid, and
// the combine. σ is 1.2% of 256 px, so `LocalContrast::reduction` lands on
// a half here rather than the quarter a desktop viewport gets — the number
// is the viewport's, and what this test is about is that it does not
// change while the slider moves.
assert_eq!(
pass.detail_dispatches(),
2,
"a separable mask is two passes"
4,
"a reduced separable mask is four passes"
);
let pipelines = pass.cached_detail_pipelines();
@@ -501,17 +588,21 @@ fn dragging_the_slider_re_runs_the_detail_stage_and_nothing_else() {
1,
"the fused colour pass re-ran for a change it does not depend on"
);
assert_eq!(pass.detail_dispatches(), 8);
// Four renders of a four-pass chain. The counter accumulates, so this is
// the claim that every one of those renders ran the detail stage and only
// the detail stage.
assert_eq!(pass.detail_dispatches(), 16);
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.
// Turning on the other control adds its own pair, and only its own pair:
// 16, plus clarity's four again, plus texture's two.
graph.set_param(TEXTURE, AMOUNT, 40.0);
render(&mut pass, &graph, &source, 256);
assert_eq!(pass.detail_dispatches(), 12);
assert_eq!(pass.detail_dispatches(), 22);
assert_eq!(pass.colour_dispatches(), 1);
}
@@ -536,7 +627,11 @@ fn the_two_controls_stack_without_overwriting_each_other() {
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);
// Clarity's four plus texture's two. Texture is never reduced — its band
// is a decade finer than clarity's, so a coarser grid could not hold its
// base — and the two operations keeping different pass counts here is that
// asymmetry showing through.
assert_eq!(pass.detail_dispatches(), 6);
let edge = (SIZE / 2) as usize;
// Both sides of the edge move the way local contrast moves them...
+8 -3
View File
@@ -51,7 +51,7 @@ fn brightening_stack() -> MaskStack {
},
);
layer.set_param("exposure", ParamId("exposure"), 2.0);
layer.feather = 0.0;
layer.base_mut().feather = 0.0;
stack.push(layer);
stack
}
@@ -81,7 +81,7 @@ fn render_at(ctx: &GpuContext, stack: &MaskStack, out: u32) -> Vec<u8> {
let mut masks = MaskPass::new(ctx).expect("mask pass");
let array = masks
.render(stack, None, Some(&subjects), PROXY, PROXY)
.render(stack, None, Some(&subjects), None, PROXY, PROXY)
.expect("rasterise");
let shader = compose_full(
@@ -90,6 +90,7 @@ fn render_at(ctx: &GpuContext, stack: &MaskStack, out: u32) -> Vec<u8> {
ColourSpace::Srgb,
stack,
&SpotSet::new(),
&[],
);
let mut adjust = AdjustPass::new(ctx);
adjust
@@ -108,6 +109,7 @@ fn render_unmasked(ctx: &GpuContext, stack: &MaskStack, out: u32) -> Vec<u8> {
ColourSpace::Srgb,
stack,
&SpotSet::new(),
&[],
);
let mut adjust = AdjustPass::new(ctx);
adjust.render(&source, &shader, out, out).expect("render");
@@ -222,7 +224,9 @@ fn render_gradient(ctx: &GpuContext, stack: &MaskStack, w: u32, h: u32) -> Vec<u
let source = DemosaicedImage::from_rgba8(ctx, &data, w, h).expect("upload");
let mut masks = MaskPass::new(ctx).expect("mask pass");
let array = masks.render(stack, None, None, w, h).expect("rasterise");
let array = masks
.render(stack, None, None, None, w, h)
.expect("rasterise");
let shader = compose_full(
&ops::chain(),
@@ -230,6 +234,7 @@ fn render_gradient(ctx: &GpuContext, stack: &MaskStack, w: u32, h: u32) -> Vec<u
ColourSpace::Srgb,
stack,
&SpotSet::new(),
&[],
);
let mut adjust = AdjustPass::new(ctx);
adjust
+200
View File
@@ -0,0 +1,200 @@
// TRACES: FR-DEV-10
//! A range mask must select by the photograph's values, and by nothing else.
//!
//! Two properties, and both are silent when they break. A range mask that
//! reads the wrong pixels still produces a plausible-looking selection, and one
//! that depends on the size it was rasterised at looks right in the develop
//! view and wrong only in the export — the one place nobody is watching.
//!
//! Everything here goes through the real pass. There is no CPU rasterisation of
//! a mask to test against and there must not be (ARCH §5.4), so the mask is
//! observed the only way it exists: through the adjustment it weights.
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext, MaskPass};
use dr_pipeline::descriptor::ParamId;
use dr_pipeline::mask::{MaskLayer, MaskSource, MaskStack};
use dr_pipeline::operation::compose_full;
use dr_pipeline::spot::SpotSet;
use dr_pipeline::{ops, Framing};
use dr_types::ColourSpace;
/// The source and the output are the same size, so a difference between two
/// runs can only have come from the mask.
const SIZE: u32 = 64;
fn ctx() -> Option<GpuContext> {
pollster::block_on(GpuContext::new_headless()).ok()
}
/// A frame whose left half is one colour and right half another.
///
/// Deliberately not a ramp. A range mask's whole job is to divide the picture
/// by value, so a source that is already divided by value makes the assertion
/// "the mask found the half it was aimed at" rather than "the mask is roughly
/// where it should be".
fn split(ctx: &GpuContext, left: [u8; 3], right: [u8; 3]) -> DemosaicedImage {
let data: Vec<u8> = (0..SIZE * SIZE)
.flat_map(|i| {
let c = if i % SIZE < SIZE / 2 { left } else { right };
[c[0], c[1], c[2], 255]
})
.collect();
DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload")
}
fn brightened(source: MaskSource) -> MaskStack {
let mut stack = MaskStack::new();
let mut layer = MaskLayer::new("m1", source);
// Two stops, so "selected" and "not selected" are not a judgement call.
layer.set_param("exposure", ParamId("exposure"), 2.0);
stack.push(layer);
stack
}
/// Render `stack` over `source`, with the mask rasterised at `raster`.
///
/// `raster` is a parameter because it is the thing that must not matter: the
/// develop view and an export ask for the same mask at different sizes, and a
/// range that answered differently at each would be a mask that changes when
/// the photograph is exported.
fn render(ctx: &GpuContext, source: &DemosaicedImage, stack: &MaskStack, raster: u32) -> Vec<u8> {
let mut masks = MaskPass::new(ctx).expect("mask pass");
let array = masks
.render(stack, None, None, Some(source), raster, raster)
.expect("rasterise");
let shader = compose_full(
&ops::chain(),
&Framing::new(),
ColourSpace::Srgb,
stack,
&SpotSet::new(),
&[],
);
let mut adjust = AdjustPass::new(ctx);
adjust
.render_masked(source, &shader, SIZE, SIZE, Some(array))
.expect("render");
adjust.export_pixels().expect("readback").0
}
fn red_at(pixels: &[u8], x: u32, y: u32) -> u8 {
pixels[((y * SIZE + x) * 4) as usize]
}
/// The centre of each half, away from the seam the mask's own softness
/// straddles.
fn halves(pixels: &[u8]) -> (u8, u8) {
(
red_at(pixels, SIZE / 4, SIZE / 2),
red_at(pixels, SIZE * 3 / 4, SIZE / 2),
)
}
/// TRACES: FR-DEV-10
#[test]
fn a_tone_band_brightens_only_the_half_inside_it() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
// 200 lands near 0.83 on the perceptual scale and 30 near 0.24, so a band
// over the upper half contains one and not the other with room to spare.
let source = split(&ctx, [200, 200, 200], [30, 30, 30]);
let stack = brightened(MaskSource::luminance_range(0.5, 1.0, 0.15));
let pixels = render(&ctx, &source, &stack, SIZE);
let (bright, dark) = halves(&pixels);
assert!(
bright > 230,
"the bright half is inside the band and should have been lifted, got {bright}"
);
assert!(
dark < 45,
"the dark half is outside the band and must be untouched, got {dark}"
);
}
/// TRACES: FR-DEV-10
/// The property the develop view and the export path share. The mask array is
/// rasterised at a proxy size in both, but nothing in this pass may *depend*
/// on that size — a range is a function of the photograph's values, and those
/// do not change when somebody asks for a bigger picture.
#[test]
fn a_tone_band_is_the_same_mask_at_any_raster_size() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let source = split(&ctx, [200, 200, 200], [30, 30, 30]);
let stack = brightened(MaskSource::luminance_range(0.5, 1.0, 0.15));
// A quarter of the source and twice it: one mask texel averaging sixteen
// source texels, and one source texel spread over four mask texels.
let small = halves(&render(&ctx, &source, &stack, SIZE / 4));
let large = halves(&render(&ctx, &source, &stack, SIZE * 2));
// A tolerance rather than equality: the two rasters land their edges on
// different grids, and the assertion is that the *selection* is the same,
// not that two resamplings of it are bit-identical.
assert!(
small.0.abs_diff(large.0) <= 4 && small.1.abs_diff(large.1) <= 4,
"the same band selected differently at two raster sizes: {small:?} vs {large:?}"
);
}
/// TRACES: FR-DEV-10
#[test]
fn a_colour_band_follows_hue_rather_than_brightness() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
// Short of saturation on purpose. A fully clipped channel carries no
// colour at all, and the pass pulls such a pixel back to neutral before
// the band ever sees it — which is correct, and would make this test about
// that instead.
let source = split(&ctx, [200, 40, 40], [40, 40, 200]);
// A narrow arc at red, above the chroma floor that keeps the greys out.
let stack = brightened(MaskSource::colour_range(0.0, 0.05, 0.15, 1.0, 0.05));
let pixels = render(&ctx, &source, &stack, SIZE);
let (red, blue) = halves(&pixels);
assert!(
red > 230,
"the red half is inside the arc and should have been lifted, got {red}"
);
assert!(
blue < 60,
"the blue half is a third of the circle away and must be untouched, got {blue}"
);
}
/// TRACES: FR-DEV-10
/// The greys a hue arc would otherwise sweep up.
///
/// A nearly-neutral pixel still has a hue — three almost-equal channels round
/// to one — so an arc without a chroma floor selects a haze of noise across
/// every desaturated part of the picture. It is the failure that looks like the
/// mask working badly rather than like a control that is missing.
#[test]
fn a_colour_band_ignores_the_greys() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
// Both halves neutral, at the two brightnesses the tone test uses, so the
// only reason either could be selected is the hue arc reaching them.
let source = split(&ctx, [200, 200, 200], [30, 30, 30]);
let stack = brightened(MaskSource::colour_range(0.0, 0.5, 0.15, 1.0, 0.05));
let pixels = render(&ctx, &source, &stack, SIZE);
let (light, dark) = halves(&pixels);
assert!(
light < 215 && dark < 45,
"a grey frame was selected by a colour mask: {light}, {dark}"
);
}
@@ -9,7 +9,6 @@
id: capture_sharpen
order: 120
attributes: [detail]
rust: CaptureSharpen
why_rust: |
-1
View File
@@ -6,7 +6,6 @@
id: clarity
order: 130
attributes: [detail]
rust: Clarity
why_rust: |
+320
View File
@@ -0,0 +1,320 @@
# TRACES: FR-DEV-12
id: colour_grading
label: op.colour_grading
order: 105
attributes: [colour]
doc: |
Colour grading — a hue and a strength for the shadows, the midtones and the
highlights, and one more cast over the whole frame.
The distinction from [`colour_mixer`](colour_mixer.yaml) is the whole reason
this exists. The mixer reaches for a hue that is *already in the picture*:
it will turn the greens that are there, and it can do nothing at all where
there are none. This reaches for a tonal *range* and casts colour into it
whether any was there or not — which makes it the only control that can warm
an already-neutral highlight, and the only one that can tone a monochrome
conversion, since a picture with no hue left in it gives the mixer nothing to
find.
Split toning is the case worth naming: cool shadows against warm highlights,
which is what a print toned in two baths did and what most of the looks sold
since are built on.
placement: |
After every colour correction, the mixer included. Grading is the closing
statement rather than a correction, so it should land on the colours the
photographer has already settled rather than be argued with by a control
further down the chain. Before the detail stage, which is where every
neighbourhood operation runs whatever this file says.
params:
shadow_hue:
label: param.shadow_hue
kind: scalar
min: 0
max: 360
default: 0
unit: none
scale: linear
precision: 0
doc: |
Degrees around the hue wheel, red at zero — the same units the straighten
control reports, and for the same reason: a photographer reading "210"
knows where on the wheel that is, where a normalised 0..1 has to be
translated first.
The two ends of the travel are the same colour. That is a fact about
hue rather than a defect, and it is what a wheel makes obvious and a
slider cannot — one of the reasons `presentation:` below asks for one.
shadow_strength:
label: param.shadow_strength
kind: scalar
min: 0
max: 100
default: 0
unit: percent
scale: linear
precision: 0
doc: |
How far from neutral, which is the wheel's radius. It does not go
negative: a negative radius is the hue opposite, which the hue control
already says, and two ways to spell one colour is how a preset comes
back looking like its own complement.
midtone_hue:
label: param.midtone_hue
kind: scalar
min: 0
max: 360
default: 0
unit: none
scale: linear
precision: 0
midtone_strength:
label: param.midtone_strength
kind: scalar
min: 0
max: 100
default: 0
unit: percent
scale: linear
precision: 0
doc: |
The midtones are where skin lives, so this is the range that goes wrong
first and the one most often left at zero. It is offered anyway because
a grade that can only reach the ends of the scale cannot answer a cast
that sits in the middle of it.
highlight_hue:
label: param.highlight_hue
kind: scalar
min: 0
max: 360
default: 0
unit: none
scale: linear
precision: 0
highlight_strength:
label: param.highlight_strength
kind: scalar
min: 0
max: 100
default: 0
unit: percent
scale: linear
precision: 0
global_hue:
label: param.global_hue
kind: scalar
min: 0
max: 360
default: 0
unit: none
scale: linear
precision: 0
global_strength:
label: param.global_strength
kind: scalar
min: 0
max: 100
default: 0
unit: percent
scale: linear
precision: 0
doc: |
The cast that carries no tonal weight — it applies equally at every
brightness. Without it, a photographer wanting one colour everywhere and
a second in one range has to set all three ranges to the first hue, and
can then no longer move any one of them without disturbing the other
two.
# The neutral is *no strength anywhere*, not "nothing has been touched".
#
# A hue with no strength behind it is a direction with no distance: the
# picture is identical, and every uniform below is zero. Under the default
# rule, nudging a hue while the strength sat at zero would make the node
# active, and it would then cost a uniform block, a helper and a block in the
# fused shader for a change nobody can see. Strengths cannot go negative, so
# their sum is zero exactly when every one of them is.
active: shadow_strength + midtone_strength + highlight_strength + global_strength
uniforms:
shadow_angle:
value: shadow_hue * 0.017453292
doc: |
Degrees to radians, here rather than in the shader. The wheel is a slider
on the photographer's side and a cosine on the GPU's, and the conversion
belongs at the seam between them — the fragment then has one meaning for
an angle rather than two.
shadow_amount:
value: shadow_strength / 100 * 0.5
doc: |
Full strength is half a stop of shift on the leading channel, the same
ceiling white balance holds itself to and for the same reason: a colour
control that can blow a channel on its own is a trap. Four of these can
stack, so the honest worst case is a stop, which takes both a maximum
global cast and a maximum range on top of it.
midtone_angle: midtone_hue * 0.017453292
midtone_amount: midtone_strength / 100 * 0.5
highlight_angle: highlight_hue * 0.017453292
highlight_amount: highlight_strength / 100 * 0.5
global_angle: global_hue * 0.017453292
global_amount: global_strength / 100 * 0.5
helpers: [luminance, tone_position]
define:
hue_cast: |
// A per-channel gain carrying a hue, centred on no change at all.
//
// The three channels are cosines 120 degrees apart, which is the hue wheel
// written directly as an RGB direction — a round trip through HSV would
// buy nothing here and would have to decide what to do with a colour that
// has no hue. Their sum is zero at every angle, so exp2 turns them into
// three gains whose product is exactly one: the cast tilts the balance
// without moving the overall level. That property is what keeps grading
// from doubling as an exposure control, which is the failure that has the
// photographer chasing brightness with a colour slider.
fn hue_cast(angle: f32, amount: f32) -> vec3<f32> {
let tilt = vec3<f32>(cos(angle), cos(angle - 2.0943951), cos(angle + 2.0943951));
return exp2(tilt * amount);
}
wgsl: |
let pos = tone_position(luminance(c));
// Three weights that partition the tonal scale: at every luminance they sum
// to exactly one. The two ends use the same 0.5 midpoint as
// highlights_shadows, so "shadows" means the same range of the picture in
// both places, and the midtones are defined as whatever the ends leave.
//
// The partition is what makes an equal setting on all three identical to
// the global cast. Weights that overlapped would make the join between two
// ranges stronger than either of them, so a split tone would darken or
// colour its own midtones as a side effect of the two settings meeting —
// an interaction with no control over it.
let lo_w = 1.0 - smoothstep(0.0, 0.5, pos);
let hi_w = smoothstep(0.5, 1.0, pos);
let mid_w = 1.0 - lo_w - hi_w;
// The ranges compose by multiplication rather than by mixing, because a
// product of luminance-neutral triples is another one — so three casts and
// a global still leave the tonal relationships the tone controls
// established. The global cast takes no weight: it is the whole frame.
c = c * hue_cast(shadow_angle, shadow_amount * lo_w)
* hue_cast(midtone_angle, midtone_amount * mid_w)
* hue_cast(highlight_angle, highlight_amount * hi_w)
* hue_cast(global_angle, global_amount);
presentation:
# Four wheels, each a hue and a distance from the centre. Named in pairs
# because that is the order a wheel wants them; a frontend that draws none
# of these renders eight ordinary sliders and the edit is unchanged, which
# is the state this ships in.
#
# That fallback is why each parameter carries its range in its own name
# instead of leaning on the wheel to say which one it belongs to. A flat
# list is what the panel produces today, and four sliders all called "Hue"
# would be four controls nobody can tell apart.
widgets: [colour_wheel]
demand:
two_dimensional: true
# A hue a few degrees off is a slightly different warm, not a wrong
# answer, so this is usable with a fingertip. Saying otherwise would take
# the wheel away from touch to protect an accuracy nobody needs from it.
precise_pointing: false
params:
- shadow_hue
- shadow_strength
- midtone_hue
- midtone_strength
- highlight_hue
- highlight_strength
- global_hue
- global_strength
tests:
- name: it_starts_neutral
why: |
Neutral means absent: at defaults the node must contribute no code, no
uniform and no branch to the fused shader. Every angle and amount being
zero is the arithmetic half of that; `expect_active` is the half that
keeps it out of the shader at all.
expect:
shadow_angle: 0.0
shadow_amount: 0.0
midtone_amount: 0.0
highlight_amount: 0.0
global_amount: 0.0
expect_active: false
- name: a_hue_with_no_strength_is_still_neutral
why: |
What `active:` buys. A hue is a direction and a strength is the distance
travelled along it, so a hue moved on its own changes nothing — but the
default rule ("some parameter has moved") would call the node active and
make every fused shader carry it for nothing.
set: { shadow_hue: 240, global_hue: 40 }
expect_active: false
- name: strength_alone_reaches_the_shader
why: |
The complement, and the reason the neutral cannot simply be "nothing
touched": red is hue zero, so a grade toward red never moves a hue
slider off its default and would otherwise never be applied.
set: { shadow_strength: 20 }
expect_active: true
- name: hue_is_converted_to_radians
why: |
The shader's cosines take radians and this uniform is the only place the
conversion happens. Degrees arriving unconverted would be an angle 57
times too large — it would wrap the wheel several times and land on a
colour with no relation to the one under the pointer, which reads as the
control being broken rather than as a missing constant.
set: { shadow_hue: 180 }
expect: { shadow_angle: 3.1415926 }
- name: full_strength_is_half_a_stop
why: |
The ceiling on one range's contribution, matching white balance's. A
grade that could take a channel to clipping by itself would make the
strength slider unusable over its top third.
set: { highlight_strength: 100 }
expect: { highlight_amount: 0.5 }
- name: strength_maps_linearly_onto_the_shift
why: |
Half the slider must be half the shift. A curve here would make the
wheel's radius mean something different at each distance from the
centre, and a wheel is read as a distance.
set: { midtone_strength: 50 }
expect: { midtone_amount: 0.25 }
- name: the_three_ranges_partition_the_tones
why: |
The midtone weight is defined as what the other two leave, so the three
sum to one at every luminance. Computed independently they would overlap
at the joins, and a split tone would then colour its own midtones as a
side effect of the shadow and highlight settings meeting.
expect_wgsl: ["let mid_w = 1.0 - lo_w - hi_w;"]
- name: the_global_cast_takes_no_tonal_weight
why: |
It is the one that means "everywhere". Weighted like the others it would
land mostly on the midtones, since that weight is the largest across the
range an ordinary photograph occupies, and "global" would quietly become
a fourth midtone control.
expect_wgsl: ["hue_cast(global_angle, global_amount)"]
- name: a_cast_does_not_change_the_level
why: |
The cosines sum to zero at every angle, so the three gains multiply to
one and a cast tilts the balance without lifting or dropping the
picture. Built any other way — a clamp, an added tint, an HSV round trip
— a strong grade would double as an exposure change, and the
photographer would correct it with a control that cannot reach it.
expect_helper_wgsl:
hue_cast: ["return exp2(tilt * amount);"]
-1
View File
@@ -1,6 +1,5 @@
id: colour_mixer
order: 100
attributes: [colour]
rust: ColourMixer
why_rust: |
+56
View File
@@ -0,0 +1,56 @@
# A hand-written node, and a neighbourhood one: the veil it removes is measured
# from the pixels around the one it is writing, so it runs in the detail stage
# rather than as a fragment in the fused pass. See `../src/detail.rs` for why
# that stage exists and `README.md`'s "Nodes that read their neighbours" for
# the contract.
#
# As with every `rust:` node, its descriptor, parameters and behaviour come
# from the type; this file exists so that `ops/` remains the one place the
# pipeline's order is written down.
id: dehaze
order: 125
rust: Dehaze
why_rust: |
Haze is defined by what the pixels around a pixel are doing, and the schema
above describes a function of one colour — `wgsl:` is handed `c` and no
coordinate, which is the wall the detail stage exists on the other side of.
It declares `Affects::Detail` and returns five `DetailPass`es: four that
erode the dark channel into a per-pixel veil, and one that inverts the
scattering model with the transmission that veil implies.
Nor is it four facts. The erosion is split into two exact stages per axis so
that a patch 1% of the frame wide costs its square root in taps, and the
split is arithmetic over the render size that has to be recomputed every
frame. Stretching this schema to express it would produce a worse language
than Rust, aimed at one caller.
placement: |
First among the compositional detail nodes: after noise reduction and
capture sharpening, before clarity and texture.
After noise reduction because dehaze divides by a transmission below one, so
it amplifies whatever noise is in the veiled distance by exactly the factor
it recovers the contrast by. Running it first would ask the denoiser to
remove grain that dehaze had already multiplied — the same argument clarity
records, and stronger here, because the amplification is largest in the low-
contrast regions where noise is most visible.
Before clarity and texture, and for the reason that orders those two against
each other: coarse before fine. Dehaze acts on the widest structure in the
frame, the veil that varies with distance, and clarity's base should be
computed on the picture as the veil has left it rather than on a modelling
that is about to be divided out.
What this cannot honour, and it is worth writing down rather than leaving to
be rediscovered: dehaze shifts colour. It subtracts a grey term and rescales,
so it changes saturation everywhere the veil is thick, and the colour
controls would ideally be correcting the picture that leaves here. They
cannot be. The detail stage runs as a *group* after every point operation,
because a neighbourhood pass is a separate dispatch reading a texture the
fused pass has already finished writing — so an `order:` placing this node
ahead of `vibrance` or `colour_mixer` would be a lie the chain cannot tell.
Interleaving the two would mean splitting the fused pass in half around this
one, which costs a second full-frame dispatch and intermediate on every edit
in the catalogue, whether or not it uses dehaze at all.
+4 -1
View File
@@ -1,6 +1,9 @@
id: film_sim
order: 25
attributes: [tone, colour]
# What this node is *about* is not written here, and cannot be: a `rust:` node
# publishes its own descriptor, so `attributes:` in this file would be read,
# validated and then ignored. See `Attribute::Effect` on `FilmSim`'s descriptor
# in `../src/ops/film_sim.rs`.
rust: FilmSim
why_rust: |
@@ -8,7 +8,6 @@
id: noise_reduction
order: 110
attributes: [detail]
rust: NoiseReduction
why_rust: |
-1
View File
@@ -2,7 +2,6 @@
id: texture
order: 140
attributes: [detail]
rust: Texture
why_rust: |
-1
View File
@@ -12,7 +12,6 @@ order: 60
# Both, and this is the case the plural exists for: the RGB curve is
# tonal and the per-channel curves are chromatic. Filing it under one
# would hide it from half the people looking for it.
attributes: [tone, colour]
rust: ToneCurve
why_rust: |
+39
View File
@@ -0,0 +1,39 @@
# A hand-written node, and the first thing in `Attribute::Optics`.
#
# Written, tested and unreferenced until now: `src/ops/vignetting.rs` has
# existed with a full descriptor and a working polynomial, and without an entry
# here it was never in `chain()` — so it reached no photograph and no panel.
# This file is the whole of what was missing, which is the point of `ops/`
# being the one place the pipeline's order is written down.
id: vignetting
order: 5
rust: Vignetting
why_rust: |
It carries a lens profile's `pa` coefficients, which are not parameters: they
come from the body and lens that took the photograph, not from the
photographer, and no `uniforms:` expression could produce them. The one
parameter that *is* theirs — the manual trim — is composed with `k1` in Rust,
because the profile and the trim have to reach the shader as a single
polynomial rather than as two the fragment would have to add up.
placement: |
First in the chain, ahead of white balance and exposure.
Vignetting is what the lens did to the light before the sensor measured it,
so undoing it belongs with reading the file rather than with editing the
picture — everything downstream is then working on the frame the lens would
have delivered had it been even.
The ordering is load-bearing rather than tidy. Correcting a corner means
*dividing* by an attenuation below one, which pushes those pixels up: a fast
prime wide open needs about two stops there. Run after the tonal stages, that
recovery happens once the highlights have already been rolled off and
clipped, so it lifts values that no longer have anywhere to go and the
corners posterise instead of brightening. Run here, the headroom to hold them
still exists (ARCH §5.2).
Before distortion and chromatic aberration in intent, though those are
`lens::Warp`s rather than nodes and compose ahead of the fetch, so no `order:`
relates the two.
+27
View File
@@ -32,6 +32,33 @@ params:
label: param.tint
kind: amount
# An eyedropper, where the frontend has a canvas to hang one on.
#
# Sampling a neutral is the first move of the global tonal pass — every colour
# judgement afterwards is measured against where the grey was put — and it is
# a thing you do by pointing at the photograph, not by guessing at two sliders
# until a wall stops looking green.
#
# A *hint*, on the usual terms: the two parameters below stay ordinary
# addressable scalars, and a frontend with nowhere to host a sampler renders
# them as the sliders they already were. This one is additive rather than a
# replacement — the picker writes temperature and tint and the photographer
# still nudges them afterwards — which is a difference the frontend draws for
# itself; nothing here has to say it.
#
# The order matters and is the widget kind's own contract: the first parameter
# trades red against blue, the second green against magenta.
presentation:
widgets: [white_point]
params: [temperature, tint]
demand:
# A neutral is a point on the picture, so both axes at once.
two_dimensional: true
# Deliberately false. A grey card, a cloud, a white wall — the things
# worth sampling are large, and FR-UI-7 grows the hit region to the
# modality in any case, so a thumb is as workable as a mouse.
precise_pointing: false
# Temperature trades red against blue; tint trades green against magenta.
# Both are scaled so the full range is a strong but not destructive
# correction: ±0.5 in log2 at the extremes — half a stop of channel shift,
+634
View File
@@ -0,0 +1,634 @@
//! TRACES: FR-DEV-3 | FR-CAT-8
//! A model's mask, in a form a sidecar can carry.
//!
//! [`MaskSource::Subject`](crate::mask::MaskSource::Subject) and
//! [`MaskSource::Category`](crate::mask::MaskSource::Category) name what they
//! cover — an index, a class, a category — and naming is enough only while
//! the run that produced the numbers is still in memory. Reopen the
//! photograph, or export it from the grid, and there is no run: the layer
//! resolves to nothing and the local adjustment silently is not applied. That
//! is what this module exists to stop. It stores the *pixels* the layer
//! covered, beside the identity rather than instead of it, so a second model
//! pass is an optimisation rather than a precondition.
//!
//! # Two levels, and why that is not a compromise
//!
//! The model hands out a byte per pixel, but nothing downstream reads more
//! than one bit of it. A subject or category layer becomes a mask by way of
//! an exact Euclidean distance field, and that field is measured from
//! `coverage >= threshold` — the soft shoulder the model produced is
//! discarded on the first line of the transform. Everything soft about the
//! rendered edge comes afterwards, from [`MaskLayer::feather`] and
//! [`MaskLayer::falloff`], which are read off the *distance*.
//!
//! [`MaskLayer::feather`]: crate::mask::MaskLayer::feather
//! [`MaskLayer::falloff`]: crate::mask::MaskLayer::falloff
//!
//! So [`RENDERED_LEVELS`] is two, and the result is not an approximation of
//! what the model said: it is exactly the part of what the model said that
//! reaches a pixel. Storing all 256 levels would be storing 1.7 MB of
//! interpolation to reconstruct a predicate — and it would not even compress,
//! because a model mask is a bilinear upsample of a coarse grid and therefore
//! has almost no two adjacent bytes alike. Measured on a simulated sky and a
//! simulated figure at 1600x1067, against 1.71 MB raw: **4.0 kB and 6.5 kB at
//! two levels**, 46 kB and 76 kB at sixteen, and 835 kB and 1.43 MB at all
//! 256 — the last two being over [`MAX_PAYLOAD`] and therefore not storable
//! at all.
//!
//! [`Coverage::encode`] still takes the level count, and it is written into
//! the line, so a later build that finds a use for the shoulder can write
//! sixteen levels and this one will read them back correctly rather than
//! misreading a stream of lengths as pairs.
//!
//! # One line, because a node is a line
//!
//! FR-NC-9 merges the edit graph per node and [`Version::merge`] does that by
//! comparing lines, so a stored mask is one `coverage = ...` line inside the
//! layer's block — the same shape the `regions = ...` line already had, for
//! the same reason. Splitting it over many lines would put a single opaque
//! blob into the merge as several independently-winnable keys, which is a
//! merge that can produce a mask neither device ever had.
//!
//! [`Version::merge`]: crate::sidecar::Version::merge
//!
//! # Hand-rolled, and it has to be
//!
//! `dr-pipeline` links nothing (ARCH §6.5a), which is what lets the descriptor
//! and codegen logic be tested without a device. That rules out `flate2`,
//! `serde` and `base64`, so the run-length coder and the digits below are
//! written out. It is forty lines, and the alternative was a dependency in the
//! one crate that has none.
use std::fmt::Write as _;
/// The number of coverage levels the renderer can actually tell apart.
///
/// Two. See the module header: the distance field is built from a threshold,
/// so a second level is the whole of the information that survives into a
/// rendered frame. Named rather than written as `2` at the call site because
/// the number is a *claim about the render path*, and a claim wants somewhere
/// to be explained.
pub const RENDERED_LEVELS: u32 = 2;
/// The most encoded payload a stored coverage may take, in bytes.
///
/// Sidecars sync over WebDAV and are read whole by every device that opens the
/// photograph, so a mask that will not compress must not be allowed to make
/// the file enormous — it is a *cache* of something a model can produce again,
/// and no cache is worth a megabyte of sync traffic per layer.
///
/// A realistic mask lands between 4 and 7 kB, so this is roughly ten times the
/// worst case anyone has measured: enough for genuinely awkward subjects —
/// foliage, chain-link, hair against a busy background — and far short of a
/// file a human cannot open. Past it [`Coverage::encode`] returns `None`, the
/// layer stores nothing, and the behaviour falls back to what it was before
/// this module existed: the mask needs the model run. Refusing rather than
/// truncating, because half a mask renders as a *wrong* mask, which is the
/// failure that announces itself to nobody.
pub const MAX_PAYLOAD: usize = 64 * 1024;
/// The most pixels a coverage read from a file may claim.
///
/// A file is not trusted. The proxy a mask is built at is bounded by the long
/// edge the segmentation runs on — under three megapixels — so this is ample
/// headroom, and it is here so that `width * height` from a corrupt line
/// cannot ask for an allocation measured in gigabytes.
pub const MAX_PIXELS: usize = 16 << 20;
/// Digits of the payload's base-64 varint. Ordered so the alphabet is stable
/// and contains nothing a line-oriented format would have to escape — no
/// whitespace, no `=`, no `#`.
const DIGITS: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
/// Bit set in a digit that means "another digit follows".
const CONTINUE: u32 = 32;
/// Value bits carried by one digit.
const CHUNK: u32 = 5;
const fn reverse_digits() -> [u8; 256] {
let mut table = [255u8; 256];
let mut i = 0;
while i < 64 {
table[DIGITS[i] as usize] = i as u8;
i += 1;
}
table
}
/// Digit value by byte, `255` for anything that is not a digit.
const REVERSE: [u8; 256] = reverse_digits();
/// TRACES: FR-DEV-3
/// One layer's pixel coverage, held in the form it is stored in.
///
/// **Encoded, not expanded.** The struct owns the payload text rather than the
/// 1.7 MB raster it decodes to, because that raster is wanted exactly once —
/// when a distance field is built — and is held by nothing afterwards. Keeping
/// it expanded would put a megabyte and a half per layer into every undo
/// snapshot the history stack holds, to save a decode that costs far less than
/// the exact Euclidean transform immediately following it.
///
/// It also makes the round trip byte-identical for free: a coverage read from
/// a file and written back is the same characters, which is the property that
/// lets a caller skip an upload by comparing content
/// ([`Sidecar`](crate::Sidecar)).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Coverage {
width: usize,
height: usize,
levels: u32,
payload: String,
}
impl Coverage {
/// Encode one byte-per-pixel coverage, or `None` where it will not fit.
///
/// `levels` is what the bytes are quantised to on the way in; see
/// [`RENDERED_LEVELS`] for why two is the honest answer for a mask that is
/// going to be thresholded.
///
/// `None` for a mismatched length, a nonsensical level count, or a payload
/// over [`MAX_PAYLOAD`] — all three meaning "do not store this", which the
/// caller can act on identically because the fallback is the same in every
/// case.
pub fn encode(values: &[u8], width: usize, height: usize, levels: u32) -> Option<Self> {
if width == 0 || height == 0 || values.len() != width.checked_mul(height)? {
return None;
}
if !(2..=256).contains(&levels) {
return None;
}
let top = levels - 1;
let mut payload = String::new();
let mut index = 0;
while index < values.len() {
let level = quantise(values[index], top);
let mut run = 1;
while index + run < values.len() && quantise(values[index + run], top) == level {
run += 1;
}
push_varint(&mut payload, level as u64);
push_varint(&mut payload, run as u64);
index += run;
// Checked inside the loop rather than after it: the pathological
// input is one that runs to a payload larger than the raster, and
// building the whole of that before deciding to throw it away is
// the allocation this bound exists to prevent.
if payload.len() > MAX_PAYLOAD {
return None;
}
}
Some(Self {
width,
height,
levels,
payload,
})
}
/// Read the value of a sidecar `coverage` line.
///
/// `None` for anything that does not describe a complete raster. A
/// coverage is a cache, so refusing it costs a model run; accepting a
/// partial one costs a photograph rendered with a mask that is wrong in a
/// way nothing reports.
pub fn parse(value: &str) -> Option<Self> {
let mut tokens = value.split_whitespace();
let width: usize = tokens.next()?.parse().ok()?;
let height: usize = tokens.next()?.parse().ok()?;
let levels: u32 = tokens.next()?.parse().ok()?;
let payload = tokens.next()?;
let pixels = width.checked_mul(height)?;
if pixels == 0 || pixels > MAX_PIXELS || !(2..=256).contains(&levels) {
return None;
}
if payload.len() > MAX_PAYLOAD {
return None;
}
// Measured rather than expanded. The payload has to be checked here —
// failing at the point of use would put the error in the renderer,
// where there is no longer a file to name in the message — but a
// library scan parses thousands of sidecars, and materialising a
// megabyte and a half per layer to establish that the arithmetic adds
// up would make opening the grid pay for masks nobody is rendering.
if measure(payload, levels)? != pixels {
return None;
}
Some(Self {
width,
height,
levels,
payload: payload.to_string(),
})
}
/// The value to write after `coverage = `.
pub fn to_text(&self) -> String {
format!(
"{} {} {} {}",
self.width, self.height, self.levels, self.payload
)
}
pub fn width(&self) -> usize {
self.width
}
pub fn height(&self) -> usize {
self.height
}
pub fn levels(&self) -> u32 {
self.levels
}
/// The encoded payload's length in bytes — what this costs a sidecar.
pub fn encoded_len(&self) -> usize {
self.payload.len()
}
/// Expand back to one byte per pixel, at the size it was stored at.
pub fn decode(&self) -> Vec<u8> {
decode(&self.payload, self.width * self.height, self.levels)
.expect("a Coverage only exists once its payload has been decoded once")
}
/// Expand to `width` x `height`, resampling if that is not the size it was
/// stored at.
///
/// Nearest neighbour, and deliberately: the values are a threshold's two
/// sides, so interpolating between them would invent coverage levels that
/// mean nothing and move the boundary by a rounding rule rather than by a
/// measurement. The resample only runs at all when a build reads a mask
/// stored against a different proxy edge — in the ordinary case the sizes
/// match and this is the decode.
pub fn decode_at(&self, width: usize, height: usize) -> Vec<u8> {
let source = self.decode();
if (width, height) == (self.width, self.height) {
return source;
}
if width == 0 || height == 0 {
return Vec::new();
}
let mut out = vec![0u8; width * height];
for y in 0..height {
let sy = ((y * self.height) / height).min(self.height - 1);
let row = sy * self.width;
for x in 0..width {
let sx = ((x * self.width) / width).min(self.width - 1);
out[y * width + x] = source[row + sx];
}
}
out
}
}
/// One byte to its level, rounding to nearest.
fn quantise(value: u8, top: u32) -> u32 {
((value as u32 * top) + 127) / 255
}
/// One level back to a byte, so that the top level is exactly 255.
fn dequantise(level: u32, top: u32) -> u8 {
(((level * 255) + top / 2) / top).min(255) as u8
}
/// Little-endian base-64 varint: five value bits per digit, the sixth saying
/// whether another follows.
fn push_varint(out: &mut String, mut value: u64) {
loop {
let chunk = (value & (CONTINUE - 1) as u64) as u32;
value >>= CHUNK;
let more = if value != 0 { CONTINUE } else { 0 };
let _ = out.write_char(DIGITS[(chunk | more) as usize] as char);
if value == 0 {
return;
}
}
}
/// Read one varint, returning it and how many digits it took.
fn read_varint(bytes: &[u8]) -> Option<(u64, usize)> {
let mut value: u64 = 0;
let mut shift = 0;
for (taken, &byte) in bytes.iter().enumerate() {
let digit = REVERSE[byte as usize];
if digit == 255 {
return None;
}
// A run cannot exceed MAX_PIXELS and a level cannot exceed 255, so a
// varint past this width is a corrupt line rather than a large number.
if shift >= 64 {
return None;
}
value |= ((digit as u64) & (CONTINUE - 1) as u64) << shift;
if digit as u32 & CONTINUE == 0 {
return Some((value, taken + 1));
}
shift += CHUNK;
}
None
}
/// Walk a payload's runs, handing each `(level, length)` to `take`.
///
/// Returns the total length, or `None` for a payload that is not well formed:
/// a digit that is not one, a truncated varint, a zero-length run, or a level
/// the declared count does not contain.
fn walk(payload: &str, levels: u32, mut take: impl FnMut(u32, usize)) -> Option<usize> {
let top = levels - 1;
let bytes = payload.as_bytes();
let mut total: usize = 0;
let mut at = 0;
while at < bytes.len() {
let (level, used) = read_varint(&bytes[at..])?;
at += used;
let (run, used) = read_varint(&bytes[at..])?;
at += used;
let level = u32::try_from(level).ok()?;
if level > top {
return None;
}
let run = usize::try_from(run).ok()?;
// A zero-length run is not a shorter way of saying anything, so it is
// a corrupt line rather than a run to skip — and left in, two of them
// would encode the same raster two ways and break the byte-identical
// round trip the sidecar relies on.
if run == 0 {
return None;
}
total = total.checked_add(run)?;
if total > MAX_PIXELS {
return None;
}
take(level, run);
}
Some(total)
}
/// How many pixels a payload covers, without building any of them.
fn measure(payload: &str, levels: u32) -> Option<usize> {
walk(payload, levels, |_, _| {})
}
/// Expand a payload to `pixels` bytes, or `None` if it does not describe
/// exactly that many.
fn decode(payload: &str, pixels: usize, levels: u32) -> Option<Vec<u8>> {
let top = levels - 1;
let mut out = Vec::with_capacity(pixels);
let total = walk(payload, levels, |level, run| {
out.resize(out.len() + run, dequantise(level, top));
})?;
(total == pixels).then_some(out)
}
#[cfg(test)]
mod tests {
use super::*;
/// Round trip at the level count the renderer actually uses.
fn round_trip(values: &[u8], width: usize, height: usize) -> Vec<u8> {
let coverage = Coverage::encode(values, width, height, RENDERED_LEVELS)
.expect("this mask should encode");
let text = coverage.to_text();
let read = Coverage::parse(&text).expect("what was written should parse");
assert_eq!(read, coverage, "the round trip changed the encoding");
assert_eq!(read.to_text(), text, "re-writing must be byte-identical");
read.decode()
}
/// Two levels is exactly the predicate the distance transform applies, so
/// the round trip must agree with it on every pixel.
fn thresholded(values: &[u8]) -> Vec<bool> {
values.iter().map(|&v| v >= 128).collect()
}
#[test]
fn an_empty_mask_round_trips() {
let values = vec![0u8; 64 * 32];
let back = round_trip(&values, 64, 32);
assert_eq!(back, values);
}
#[test]
fn a_full_mask_round_trips() {
let values = vec![255u8; 64 * 32];
let back = round_trip(&values, 64, 32);
assert_eq!(back, values);
}
#[test]
fn a_single_pixel_mask_round_trips() {
let mut values = vec![0u8; 64 * 32];
values[17 * 64 + 33] = 255;
let back = round_trip(&values, 64, 32);
assert_eq!(back, values);
}
#[test]
fn a_one_pixel_raster_round_trips() {
assert_eq!(round_trip(&[255], 1, 1), vec![255]);
assert_eq!(round_trip(&[0], 1, 1), vec![0]);
}
/// The whole of the fidelity claim: two levels loses nothing the renderer
/// could have used, because the renderer thresholds.
#[test]
fn two_levels_preserve_the_threshold_exactly() {
let values: Vec<u8> = (0..=255u8).collect();
let back = round_trip(&values, 16, 16);
assert_eq!(thresholded(&back), thresholded(&values));
// And the shoulder really is gone, which is the cost being paid.
assert!(back.iter().all(|&v| v == 0 || v == 255));
}
/// A soft edge quantised to sixteen levels stays within one step of what
/// went in, so a later build that wants the shoulder can have it.
#[test]
fn sixteen_levels_are_within_one_step() {
let values: Vec<u8> = (0..256).map(|i| i as u8).collect();
let coverage = Coverage::encode(&values, 16, 16, 16).expect("should encode");
let back = Coverage::parse(&coverage.to_text())
.expect("should parse")
.decode();
for (a, b) in values.iter().zip(&back) {
assert!(
(*a as i32 - *b as i32).abs() <= 255 / 15 / 2 + 1,
"{a} came back as {b}"
);
}
}
/// The case run-length coding is worst at. It must refuse rather than
/// write a payload larger than the raster it came from.
#[test]
fn alternating_detail_is_refused_rather_than_expanded() {
let (w, h) = (512, 512);
let values: Vec<u8> = (0..w * h)
.map(|i| if i % 2 == 0 { 0 } else { 255 })
.collect();
assert!(
Coverage::encode(&values, w, h, RENDERED_LEVELS).is_none(),
"a checkerboard must not be stored"
);
}
/// Small enough to fit, and still exact — the bound is on size, not on
/// shape, so awkward detail that *does* fit must survive intact.
#[test]
fn alternating_detail_that_fits_is_exact() {
let (w, h) = (64, 64);
let values: Vec<u8> = (0..w * h)
.map(|i| if i % 2 == 0 { 0 } else { 255 })
.collect();
let back = round_trip(&values, w, h);
assert_eq!(back, values);
}
#[test]
fn a_mask_of_the_wrong_length_is_refused() {
assert!(Coverage::encode(&[0u8; 10], 4, 4, RENDERED_LEVELS).is_none());
assert!(Coverage::encode(&[], 0, 0, RENDERED_LEVELS).is_none());
}
#[test]
fn a_payload_that_does_not_cover_the_raster_is_refused() {
let values = vec![0u8; 32];
let coverage = Coverage::encode(&values, 8, 4, RENDERED_LEVELS).expect("should encode");
let payload = coverage.to_text();
let payload = payload.rsplit_once(' ').expect("a payload").1;
// The same payload, against a raster twice the size it covers.
assert!(Coverage::parse(&format!("8 4 2 {payload}")).is_some());
assert!(Coverage::parse(&format!("8 8 2 {payload}")).is_none());
}
#[test]
fn nonsense_is_refused_rather_than_guessed_at() {
assert!(Coverage::parse("").is_none());
assert!(Coverage::parse("8 4 2").is_none(), "no payload");
assert!(
Coverage::parse("8 4 1 AA").is_none(),
"one level is not a mask"
);
assert!(Coverage::parse("8 4 2 ****").is_none(), "not digits");
assert!(
Coverage::parse(&format!("{} {} 2 AA", usize::MAX, usize::MAX)).is_none(),
"a size that overflows must not be believed"
);
assert!(
Coverage::parse("100000 100000 2 A_____").is_none(),
"a raster past the cap must not be allocated"
);
}
/// A level a payload is not allowed to name, in a file that names it.
#[test]
fn a_level_outside_the_range_is_refused() {
// "BB" is level 1, run 1 — the shortest legal payload there is.
assert_eq!(decode("BB", 1, 2), Some(vec![255]));
// "DB" is level 3, run 1, and a two-level coverage has no level 3.
assert!(decode("DB", 1, 2).is_none());
// A run of zero says nothing and is refused rather than skipped.
assert!(decode("BA", 1, 2).is_none());
}
#[test]
fn resampling_lands_on_the_same_shape() {
let (w, h) = (32, 32);
let mut values = vec![0u8; w * h];
for y in 8..24 {
for x in 8..24 {
values[y * w + x] = 255;
}
}
let coverage = Coverage::encode(&values, w, h, RENDERED_LEVELS).expect("should encode");
let same = coverage.decode_at(w, h);
assert_eq!(same, values, "the matching size must not resample at all");
let half = coverage.decode_at(16, 16);
assert_eq!(half.len(), 256);
assert_eq!(half.iter().filter(|&&v| v == 255).count(), 64);
let double = coverage.decode_at(64, 64);
assert_eq!(double.len(), 4096);
assert_eq!(double.iter().filter(|&&v| v == 255).count(), 1024);
}
/// Long runs cross rows, which is what makes a flat mask cost almost
/// nothing: a 1600x1067 empty frame is two numbers.
#[test]
fn a_flat_mask_costs_almost_nothing() {
let values = vec![0u8; 1600 * 1067];
let coverage =
Coverage::encode(&values, 1600, 1067, RENDERED_LEVELS).expect("should encode");
assert!(
coverage.encoded_len() < 16,
"an empty mask took {} bytes",
coverage.encoded_len()
);
}
/// What a real one costs. The shape is a bilinear upsample of a coarse
/// grid, which is what both models produce, so the run structure is the
/// one a photograph actually gives.
#[test]
fn a_realistic_mask_fits_in_a_sidecar() {
let (w, h) = (1600usize, 1067usize);
let (gw, gh) = (160usize, 107usize);
let mut grid = vec![0f32; gw * gh];
for y in 0..gh {
for x in 0..gw {
let dx = (x as f32 - 80.0) / 26.0;
let dy = (y as f32 - 60.0) / 42.0;
let r = (dx * dx + dy * dy).sqrt()
+ 0.06 * ((y as f32 * 0.9).sin() * (x as f32 * 0.7).cos());
grid[y * gw + x] = 1.0 / (1.0 + ((r - 1.0) * 9.0).exp());
}
}
let mut values = vec![0u8; w * h];
for y in 0..h {
let fy = ((y as f32 + 0.5) / h as f32 * gh as f32 - 0.5).max(0.0);
let (y0, ty) = (fy.floor() as usize, fy.fract());
let y1 = (y0 + 1).min(gh - 1);
for x in 0..w {
let fx = ((x as f32 + 0.5) / w as f32 * gw as f32 - 0.5).max(0.0);
let (x0, tx) = (fx.floor() as usize, fx.fract());
let x1 = (x0 + 1).min(gw - 1);
let a = grid[y0 * gw + x0] * (1.0 - tx) + grid[y0 * gw + x1] * tx;
let b = grid[y1 * gw + x0] * (1.0 - tx) + grid[y1 * gw + x1] * tx;
values[y * w + x] = ((a * (1.0 - ty) + b * ty) * 255.0) as u8;
}
}
let coverage = Coverage::encode(&values, w, h, RENDERED_LEVELS).expect("should encode");
let expected: Vec<u8> = values
.iter()
.map(|&v| if v >= 128 { 255 } else { 0 })
.collect();
assert_eq!(
coverage.decode(),
expected,
"the stored mask must threshold identically to the model's"
);
// Measured at 6,464 bytes; the bound is loose enough not to fail over
// a change of rounding and tight enough to catch a coder that has
// stopped coding. Against 1,707,200 bytes raw.
assert!(
coverage.encoded_len() < 8 * 1024,
"a realistic subject took {} bytes",
coverage.encoded_len()
);
}
}
+28 -9
View File
@@ -120,7 +120,7 @@ pub enum Attr {
Colour,
Detail,
Optics,
Geometry,
Compose,
Effect,
}
@@ -132,20 +132,22 @@ impl Attr {
Attr::Colour => "colour",
Attr::Detail => "detail",
Attr::Optics => "optics",
Attr::Geometry => "geometry",
Attr::Compose => "compose",
Attr::Effect => "effect",
}
}
/// Every attribute, in the order `crate::descriptor::Attribute` declares
/// them.
/// Every attribute, in the order `crate::descriptor::Attribute::ALL`
/// lists them — the reasoning for the sequence lives there, and the
/// agreement test in `declared/mod.rs` zips the two, so a reorder there
/// that is not mirrored here is a failure rather than a drift.
pub const ALL: [Attr; 6] = [
Attr::Optics,
Attr::Compose,
Attr::Tone,
Attr::Colour,
Attr::Detail,
Attr::Optics,
Attr::Geometry,
Attr::Effect,
Attr::Detail,
];
}
@@ -494,11 +496,28 @@ pub fn read_node(text: &str, ctx: &str, shared: &BTreeSet<&str>) -> Result<Node,
if let Some(ty) = root.get("rust") {
let ty = as_str(ty, "rust")?.to_string();
check_type_name(&ty)?;
for key in ["params", "uniforms", "wgsl", "helpers", "define", "label"] {
// `attributes` is in this list, and was not until a declaration that
// said `[effect]` sat above a type that said `[tone, colour]` for a
// whole commit without anything noticing. The line parsed, validated
// against the vocabulary, and was then dropped on the floor — so the
// file read as though it had moved the operation and the panel went on
// filing it under two groups it did not belong to. A key that is
// *ignored* is worse than one that is rejected, because it looks like
// it worked.
for key in [
"params",
"uniforms",
"wgsl",
"helpers",
"define",
"label",
"attributes",
] {
if root.contains_key(key) {
return Err(format!(
"`{key}` is meaningless on a `rust:` node — {ty} publishes \
its own descriptor. Remove one or the other."
its own descriptor, and this file would be ignored. Say it \
in the type instead."
));
}
}
+1 -1
View File
@@ -350,7 +350,7 @@ fn attribute(a: decl::Attr) -> Attribute {
decl::Attr::Colour => Attribute::Colour,
decl::Attr::Detail => Attribute::Detail,
decl::Attr::Optics => Attribute::Optics,
decl::Attr::Geometry => Attribute::Geometry,
decl::Attr::Compose => Attribute::Compose,
decl::Attr::Effect => Attribute::Effect,
}
}
+111 -12
View File
@@ -170,6 +170,24 @@ pub enum WidgetKind {
/// On-canvas brush strokes.
BrushMask,
/// An eyedropper bound to the canvas, setting white balance from a pixel.
///
/// **The one widget that reads the photograph rather than driving it**,
/// and that is what gives it a contract the others do not need. A curve
/// tells its frontend where its points are and the frontend moves them; an
/// eyedropper is handed a colour and has to work out what the parameters
/// should become, which is only possible if the operation says enough
/// about itself to be inverted:
///
/// * The first two parameters in [`Presentation::params`] are its axes —
/// the first trading red against blue, the second green against
/// magenta — and each is monotonic in its axis.
/// * The operation publishes exactly three uniforms: the linear
/// per-channel gains, in red, green, blue order.
///
/// The same kind of contract [`Self::ToneCurve`] carries when it says its
/// parameters are point coordinates interleaved, and it is checked rather
/// than trusted — see [`crate::neutral`], which does the inverting so that
/// no frontend has to hold a second copy of the declared response.
WhitePoint,
}
@@ -403,6 +421,27 @@ impl ParamDescriptor {
}
}
/// A toggle that is **on** when nothing has been chosen.
///
/// The reset contract is unchanged — a default is still the neutral value
/// and a sidecar still stores only departures from it. What differs is
/// which state is neutral, and that is a fact about the setting rather
/// than about switches: a correction the file itself asked for is on
/// unless the photographer says otherwise, so "off" is the edit and
/// storing it is right. A switch written the other way round would have to
/// be labelled for its negation — "Ignore the lens profile" — and every
/// photograph that simply wanted correcting would carry a stored
/// parameter saying so.
pub const fn switch_on(id: &'static str, label: &'static str) -> Self {
Self {
id: ParamId(id),
label: LocalizedKey(label),
kind: ParamKind::Bool,
default: 1.0,
facet: None,
}
}
/// One of a fixed list of alternatives, defaulting to the first.
///
/// The first rather than a caller-chosen index, so the reset contract
@@ -580,26 +619,61 @@ pub enum Attribute {
/// Corrections for the lens that took the photograph: distortion,
/// chromatic aberration, vignetting.
Optics,
/// The shape of the frame: crop, straighten, rotation, flips.
Geometry,
/// How the frame is composed: crop, straighten, rotation, flips.
///
/// Named for the decision rather than for the maths. The lens corrections
/// are geometry too — distortion moves pixels exactly as a straighten
/// does — and lumping the two together would file a correction the
/// photographer never asked for beside a choice that is the whole reason
/// they opened the photograph. [`Self::Optics`] is what the lens did;
/// this is what they decided.
Compose,
/// Applied rather than corrected — a look, not a fix.
Effect,
}
impl Attribute {
/// Every attribute, in declaration order.
/// Every attribute, in roughly the order a photographer works through
/// them.
///
/// Declaration order is roughly the order a photographer works in, which
/// makes it a reasonable *default* for a frontend that wants one. It is
/// not a screen order: nothing here obliges a frontend to show them all,
/// show them in this sequence, or show them at all.
/// That makes it a reasonable *default* for a frontend that wants one. It
/// is not a screen order: nothing here obliges a frontend to show them
/// all, show them in this sequence, or show them at all.
///
/// `Optics` leads because correcting the lens is not a decision about the
/// photograph — it is undoing what the equipment did, a property of the
/// capture rather than a choice — and it moves the ground every later
/// judgement stands on. Removing a vignette *brightens the frame*, so an
/// exposure set before the correction has to be set again after it.
///
/// `Compose` follows as the first decision actually made about the
/// photograph, and the one every later judgement is made inside: there is
/// no sense in balancing tones across a frame that is about to lose a
/// third of its width.
///
/// `Detail` trails because sharpening and noise reduction depend on
/// everything above them, and are the only ones here that cannot be judged
/// at fit view at all. Offering them fourth, before the lens has even been
/// corrected, invites the photographer to settle grain against an image
/// that is still going to move.
///
/// `Effect` after `Colour` is a look laid over a settled picture — and is
/// the one arguable slot. A spectral film simulation declares
/// [`crate::Operation::renders`] and replaces the base curve, which is an
/// argument for treating it as foundational rather than final; an array of
/// six cannot say "last, except when it is first". The tension is recorded
/// here rather than settled.
///
/// Both ends were wrong for as long as this list only fed a row of chips
/// nobody reads in order. It stopped being harmless when the same list
/// began driving a column read top to bottom.
pub const ALL: [Attribute; 6] = [
Attribute::Optics,
Attribute::Compose,
Attribute::Tone,
Attribute::Colour,
Attribute::Detail,
Attribute::Optics,
Attribute::Geometry,
Attribute::Effect,
Attribute::Detail,
];
/// The localisation key naming this concept.
@@ -613,11 +687,28 @@ impl Attribute {
Self::Colour => "attr.colour",
Self::Detail => "attr.detail",
Self::Optics => "attr.optics",
Self::Geometry => "attr.geometry",
Self::Compose => "attr.compose",
Self::Effect => "attr.effect",
})
}
/// The name used in `ops/<id>.yaml`, and the inverse of [`Self::from_name`].
///
/// A stable identifier rather than a label: it is written into settings
/// and read back, so changing one of these strings would silently reset a
/// photographer's choice of what a paste carries. The *displayed* name is
/// [`Self::label`], which is localised and free to change.
pub fn name(self) -> &'static str {
match self {
Self::Tone => "tone",
Self::Colour => "colour",
Self::Detail => "detail",
Self::Optics => "optics",
Self::Compose => "compose",
Self::Effect => "effect",
}
}
/// Parse the name used in `ops/<id>.yaml`.
pub fn from_name(name: &str) -> Option<Self> {
Some(match name {
@@ -625,7 +716,15 @@ impl Attribute {
"colour" => Self::Colour,
"detail" => Self::Detail,
"optics" => Self::Optics,
"geometry" => Self::Geometry,
"compose" => Self::Compose,
// The name this attribute was persisted under before it was
// called Compose. Settings written by an older build carry it in
// `develop.copy_attributes`, and `from_name` returning `None`
// there does not fail loudly — `presets::scope_for` logs and drops
// the entry, silently narrowing what a paste carries. Accepted on
// the way in only; `name` writes the current spelling, so a
// settings file rewrites itself the first time it is saved.
"geometry" => Self::Compose,
"effect" => Self::Effect,
_ => return None,
})
+122 -3
View File
@@ -301,6 +301,40 @@ pub struct DetailPass {
/// the kind of artefact that looks like a driver bug. State it honestly.
pub radius: u32,
/// How much smaller than the render this pass writes.
///
/// `1` is the ordinary case and means "the render size", which is what
/// every pass did before this field existed. A larger value writes a
/// target that many times smaller on each axis, into a **second** chain
/// held alongside the full-resolution one — see [`Self::wgsl`] for how the
/// two are addressed, and the module documentation for why there are two.
///
/// # Why a pass may want this
///
/// A blur wide enough to be a *base* — clarity's is 1.2% of the frame,
/// 52 render pixels at 4K — holds no spatial frequency a quarter-scale
/// grid cannot represent. Computing it at the render size therefore buys
/// nothing and costs everything: 105 taps over 8.3 M pixels, twice, which
/// measured at 34 ms and is where `docs/technical-debt.md` TD-4 came from.
/// At a quarter it is a sixteenth of the pixels at a quarter of the
/// radius, and the result is not an approximation of the full-resolution
/// base — it is the same band-limited function, sampled where it is still
/// Nyquist-safe.
///
/// [`Self::radius`] stays in this pass's **own** pixels, so a pass at
/// scale 4 with a radius of 13 declares 13, not 52. The halo it implies
/// for a tile scheduler is `radius * output_scale`, and
/// [`ComposedDetail::radius`] is what performs that multiplication —
/// stating the radius in the grid the loop actually runs in is what keeps
/// the shader and the declaration the same number.
///
/// **Never the last pass.** The final pass carries the output transform
/// and writes the display texture, which is full resolution by
/// definition; a scaled pass in that position is a codegen bug and
/// `dr-gpu` refuses it rather than binding a shader to a target of the
/// wrong size.
pub output_scale: u32,
/// The WGSL body.
///
/// Reads and writes `c`, a `vec3<f32>` of **linear sRGB**, pre-loaded with
@@ -319,6 +353,19 @@ pub struct DetailPass {
/// pixel that survives to the next pass**, pre-loaded with what the
/// previous pass left there and written back out unless the body
/// assigns it.
/// - `reduced_at(coord) -> f32` — the **reduced chain's** scalar at this
/// pixel,
/// bilinearly upsampled. Zero unless a scaled pass ran earlier in this
/// operation; see [`Self::output_scale`].
///
/// `coord` is always in *this pass's own* output grid, and `tap` maps it
/// into the source's grid for you. A pass at [`Self::output_scale`] 4
/// therefore addresses its own quarter-size target with `coord`, while
/// `tap(coord, offset)` offsets in **source** pixels — which is what lets
/// a reduce pass average the 4 x 4 block a single output pixel covers by
/// looping `offset` over it. Where source and target are the same size the
/// mapping is the identity, so every pass written before scaling existed
/// behaves exactly as it did.
///
/// # Why `aux` exists
///
@@ -424,6 +471,8 @@ pub struct ComposedDetailPass {
pub storage: Vec<[f32; 4]>,
/// See [`DetailPass::radius`].
pub radius: u32,
/// See [`DetailPass::output_scale`].
pub output_scale: u32,
/// Whether this pass writes the display/export texture rather than another
/// linear intermediate.
///
@@ -457,8 +506,18 @@ impl ComposedDetail {
}
/// The widest halo any pass needs, in render pixels (ARCH §5.3).
///
/// A pass declares its radius in its own grid, so a scaled pass's has to
/// be multiplied back up before the maxima are comparable: 13 reduced
/// pixels at scale 4 reach exactly as far across the photograph as 52
/// render pixels do, and a scheduler comparing the two unscaled would size
/// a halo at a quarter of what the pass actually reads.
pub fn radius(&self) -> u32 {
self.passes.iter().map(|p| p.radius).max().unwrap_or(0)
self.passes
.iter()
.map(|p| p.radius.saturating_mul(p.output_scale))
.max()
.unwrap_or(0)
}
}
@@ -572,6 +631,7 @@ pub fn compose_detail_with(
RESOLVE_ID,
&[],
&DetailPass {
output_scale: 1,
label: "resolve",
radius: 0,
wgsl: String::new(),
@@ -723,6 +783,25 @@ struct Params {{
// pass leaves this bound to a single empty element and never looks at it. See
// `DetailPass::storage` for why the list is not in the uniform block.
@group(0) @binding(3) var<storage, read> instances: array<vec4<f32>>;
// The reduced chain — what a scaled pass most recently wrote, at whatever
// fraction of the render size it declared. Bound to a 1x1 placeholder for
// every pass that never calls `base`, so that one bind group layout serves a
// pass which uses it and a pass which has never heard of it.
@group(0) @binding(4) var reduced: texture_2d<f32>;
// Where in `source` this output pixel begins.
//
// The ratio is 1 whenever a pass writes what it reads, which is every pass
// that does not set `output_scale` — the multiply and the divide cancel
// exactly, so the ordinary case is unchanged and pays two integer operations
// for the privilege. A scaled pass gets the top-left of the block it covers,
// which is what makes `tap`'s offsets mean *source* pixels and lets a reduce
// pass walk its own footprint.
fn source_origin(coord: vec2<i32>) -> vec2<i32> {{
let src = vec2<i32>(textureDimensions(source));
let dst = vec2<i32>(textureDimensions(output));
return coord * src / max(dst, vec2<i32>(1));
}}
// A neighbour, clamped to the edge of the image.
//
@@ -732,13 +811,48 @@ struct Params {{
// classic way a first convolution goes wrong.
fn tap(coord: vec2<i32>, offset: vec2<i32>) -> vec3<f32> {{
let last = vec2<i32>(textureDimensions(source)) - vec2<i32>(1);
return textureLoad(source, clamp(coord + offset, vec2<i32>(0), last), 0).rgb;
let at = source_origin(coord) + offset;
return textureLoad(source, clamp(at, vec2<i32>(0), last), 0).rgb;
}}
// The same neighbour's scratch lane — see `aux` in the body below.
fn tap_aux(coord: vec2<i32>, offset: vec2<i32>) -> f32 {{
let last = vec2<i32>(textureDimensions(source)) - vec2<i32>(1);
return textureLoad(source, clamp(coord + offset, vec2<i32>(0), last), 0).a;
let at = source_origin(coord) + offset;
return textureLoad(source, clamp(at, vec2<i32>(0), last), 0).a;
}}
// The reduced chain, read at this pass's own resolution.
//
// Named `reduced_at` rather than `base` because `base` is a natural local in a
// body that has just computed one — spot removal already has such a local, and
// a function shadowed by a variable is a compile error a long way from its
// cause.
//
// Bilinear, and on pixel *centres* rather than corners: the reduce pass took
// its sample at the centre of the block it averaged, so an upsample that
// treated the grids as corner-aligned would shift the base by half a reduced
// pixel — two full pixels at scale 4, which on a wide unsharp mask is a base
// offset from the image it is subtracted from, and reads as a directional
// smear along every edge.
//
// Nearest would be cheaper and is not enough: the base is subtracted from the
// full-resolution image, so any blockiness in it appears in the *difference*
// at full contrast. That is a visible 4-pixel grid over the whole frame.
fn reduced_at(coord: vec2<i32>) -> f32 {{
let rd = vec2<f32>(textureDimensions(reduced));
let dst = vec2<f32>(max(textureDimensions(output), vec2<u32>(1u)));
let p = (vec2<f32>(coord) + vec2<f32>(0.5)) * rd / dst - vec2<f32>(0.5);
let last = vec2<i32>(rd) - vec2<i32>(1);
let base_px = vec2<i32>(floor(p));
let f = fract(p);
let s00 = textureLoad(reduced, clamp(base_px, vec2<i32>(0), last), 0).a;
let s10 = textureLoad(reduced, clamp(base_px + vec2<i32>(1, 0), vec2<i32>(0), last), 0).a;
let s01 = textureLoad(reduced, clamp(base_px + vec2<i32>(0, 1), vec2<i32>(0), last), 0).a;
let s11 = textureLoad(reduced, clamp(base_px + vec2<i32>(1, 1), vec2<i32>(0), last), 0).a;
return mix(mix(s00, s10, f.x), mix(s01, s11, f.x), f.y);
}}
{helper_src}{encode_fn}
@@ -786,6 +900,10 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
uniforms: uniform_values,
storage: pass.storage.clone(),
radius: pass.radius,
// Clamped rather than trusted: a zero would divide by nothing in the
// dispatch size and a declaration is data, which since FR-PLG-2 can
// come from a file this build did not write.
output_scale: pass.output_scale.max(1),
writes_output,
structure_hash,
}
@@ -871,6 +989,7 @@ mod tests {
dr_types::ColourSpace::Srgb,
&crate::mask::MaskStack::new(),
&crate::spot::SpotSet::new(),
&[],
)
}
+1
View File
@@ -142,6 +142,7 @@ impl DetailStage for BoxBlur {
.iter()
.enumerate()
.map(|(axis, _)| DetailPass {
output_scale: 1,
label: if axis == 0 { "horizontal" } else { "vertical" },
radius: r,
// A convolution, not a list: nothing to bind at binding 3.
+434 -11
View File
@@ -78,7 +78,7 @@ static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
Arc::new(OpDescriptor {
// The shape of the frame, and the only operation that changes the
// output's dimensions.
attributes: vec![Attribute::Geometry],
attributes: vec![Attribute::Compose],
id: ID,
label: LocalizedKey("op.framing"),
params: vec![
@@ -173,6 +173,129 @@ impl CropRect {
height: finite(self.height, 1.0).min(1.0 - y).max(Self::MIN_EXTENT),
}
}
/// TRACES: FR-DEV-3
/// Reshape onto an aspect ratio, holding one point of the rect still.
///
/// `ratio` is width over height **in output pixels** — 1.5 for 3:2 — which
/// is what a photographer means by an aspect ratio and is emphatically not
/// what this rect stores. The rect is in fractions of a frame that is
/// itself not square, so the ratio it needs is `ratio * height / width` of
/// that frame. Skipping the conversion yields a "1:1" crop that is square
/// only on a square photograph, which is the one case nobody tests on.
///
/// `anchor` is the point of the rect that stays put, in the rect's own
/// `0..1` coordinates: `(1.0, 1.0)` while the top-left handle is dragged,
/// so the far corner is the one that does not move, and `(0.5, 0.5)` when
/// a ratio is chosen and the composition should stay where it is.
///
/// **The rect grows onto the ratio rather than shrinking onto it.** The
/// axis that is short is extended; the long one is never trimmed. Fitting
/// inside instead reads as a dead control — dragging a corner outward
/// along one axis alone would be immediately clamped back by the other,
/// and the handle would simply refuse to move. The result is then scaled
/// down, both axes together, only as far as the frame's edge demands.
pub fn with_aspect(self, frame_w: u32, frame_h: u32, ratio: f32, anchor: (f32, f32)) -> Self {
let rect = self.normalised();
let ratio = finite(ratio, 0.0);
if ratio <= 0.0 || frame_w == 0 || frame_h == 0 {
return rect;
}
// Width over height as a fraction of *this* frame, not in pixels.
let r = ratio * frame_h as f32 / frame_w as f32;
let ax = finite(anchor.0, 0.5).clamp(0.0, 1.0);
let ay = finite(anchor.1, 0.5).clamp(0.0, 1.0);
// Where the fixed point sits in the frame. Everything below is built
// outwards from it, which is what makes the anchor mean anything.
let px = rect.x + ax * rect.width;
let py = rect.y + ay * rect.height;
let mut w = rect.width.max(rect.height * r);
let mut h = w / r;
// Scaled to fit, never clamped to fit: clamping one axis against the
// frame would break the very ratio this exists to hold.
let shrink = (room(px, ax) / w).min(room(py, ay) / h).min(1.0);
if shrink.is_finite() && shrink > 0.0 {
w *= shrink;
h *= shrink;
}
Self {
x: px - ax * w,
y: py - ay * h,
width: w,
height: h,
}
.normalised()
}
/// TRACES: FR-DEV-3
/// The largest copy of this rect, at its own shape, sitting inside
/// `bound`.
///
/// Scaled down only as far as `bound` demands and then slid inside it,
/// rather than replaced by `bound` or centred within it. Both of those
/// throw away a composition: a crop placed deliberately off-centre is a
/// decision, and an automatic correction that recentres it has undone the
/// user's work to fix a problem they did not have.
pub fn fitted_into(self, bound: Self) -> Self {
let rect = self.normalised();
let bound = bound.normalised();
let shrink = (bound.width / rect.width)
.min(bound.height / rect.height)
.min(1.0);
let shrink = if shrink.is_finite() && shrink > 0.0 {
shrink
} else {
1.0
};
let (w, h) = (rect.width * shrink, rect.height * shrink);
// Shrunk about its own centre, so what was framed stays framed — but
// only when it actually shrank. Routing the untouched case through
// the same arithmetic moves the origin by a rounding error, and a
// rect that is already inside its bound must come back *identical*:
// this is called on every straighten, and a rect that drifts a
// millionth each time is an edit recorded for no reason.
let (x, y) = if shrink < 1.0 {
(
rect.x + (rect.width - w) * 0.5,
rect.y + (rect.height - h) * 0.5,
)
} else {
(rect.x, rect.y)
};
Self {
// `max` on the upper limit for the same reason `normalised`
// orders its bounds that way: rounding can put the far edge a
// hair *below* the near one, and `clamp` panics on an inverted
// range rather than resolving it.
x: x.clamp(bound.x, (bound.x + bound.width - w).max(bound.x)),
y: y.clamp(bound.y, (bound.y + bound.height - h).max(bound.y)),
width: w,
height: h,
}
.normalised()
}
}
/// How far a rect anchored at `p` with the anchor `a` fractions along it may
/// extend before it leaves the `0..1` frame.
///
/// One axis at a time, and infinite where the anchor is at the far end and the
/// rect therefore only grows away from that edge.
fn room(p: f32, a: f32) -> f32 {
let before = if a > 0.0 { p / a } else { f32::INFINITY };
let after = if a < 1.0 {
(1.0 - p) / (1.0 - a)
} else {
f32::INFINITY
};
before.min(after)
}
/// Replace a non-finite value with a fallback.
@@ -225,9 +348,19 @@ pub struct Framing {
/// Which part of the framed image the viewport is looking at.
///
/// **Not an edit.** Zooming changes what you are inspecting, never what
/// the file becomes: it is excluded from [`Self::is_active`], from the
/// structure hash, and from the sidecar, so a zoomed view exports exactly
/// as an unzoomed one does.
/// the file becomes, so it is excluded from [`Self::is_active`], from the
/// structure hash, and from the sidecar.
///
/// **That is not on its own enough to make an export ignore it**, and
/// this doc comment used to claim it was. The exclusions keep the view out
/// of the *edit* — out of what is saved, out of the output size, out of
/// the crop. They cannot keep it out of a *render*, because
/// [`Self::visible_rect`] deliberately folds it into the one rect the
/// shader samples. Anything composing this framing and rendering it gets
/// the zoom; a caller that wants the photograph rather than the canvas
/// has to suspend the view first, as `DevelopSession::render_the_file`
/// and `render_uncropped` both do. Exporting at 4:1 wrote the middle of
/// the frame, magnified, until it did.
///
/// It lives here rather than in the UI because it composes with the crop
/// in the same normalised space — nesting one rect inside the other is a
@@ -760,9 +893,16 @@ impl Framing {
/// The WGSL mapping an output pixel to a **normalised centred** source
/// position, ready for the warp chain.
///
/// Leaves the result in `p`: centre `(0, 0)`, `r == 1` at the corner —
/// exactly the space [`crate::lens`] documents, so lens correction
/// composes on top of this without either stage naming the other.
/// Leaves the result in `p`: centre `(0, 0)`, spanning `±0.5 * aspect` —
/// the space [`crate::lens`] documents, so lens correction composes on top
/// of this without either stage naming the other.
///
/// **`p` is centred but not corner-normalised**, and this used to claim it
/// was. Its length at the corner is `0.5 * length(aspect)`, not 1. The
/// corner-normalised radius the radial corrections need is published
/// separately as `radius` by `operation::sample_source`, which divides by
/// exactly that; anything reading `length(p)` as a lens radius is off by
/// an aspect-dependent factor.
///
/// `aspect` is left in scope alongside it, since the warp chain and the
/// sampler both need it to return to texture coordinates.
@@ -1129,10 +1269,18 @@ mod tests {
}
#[test]
fn zooming_does_not_change_the_exported_image() {
fn zooming_does_not_change_the_size_or_the_crop() {
// The property that makes zoom a viewing tool rather than an edit: it
// must not reach the output size or the crop. If it did, exporting
// while zoomed would write the zoomed view.
// must not reach the output size or the crop.
//
// **Renamed, because the old name promised more than the body checks
// and the gap was where a real bug lived.** "Does not change the
// exported image" was read as a guarantee about pixels; it is a
// guarantee about two numbers. Both held perfectly while
// `render_for_export` was writing the zoomed view at full size,
// because the view reaches the render through `visible_rect` and
// never through either of these. The pixels are guarded where pixels
// exist — `dr-ui`'s `export_ignores_the_viewport`.
//
// The structure key is deliberately not asserted here — see
// `zooming_from_neutral_changes_the_structure_key` for why it must
@@ -1590,6 +1738,281 @@ mod tests {
}
}
// --- the aspect lock and the safe-area fit ---------------------------
/// A crop's shape in output pixels, which is the thing an aspect ratio is
/// about — the rect's own numbers are fractions of a frame that is not
/// square, so they are not comparable to `3.0 / 2.0` on their own.
fn pixel_ratio(c: CropRect, w: u32, h: u32) -> f32 {
(c.width * w as f32) / (c.height * h as f32)
}
#[test]
fn a_locked_ratio_is_a_ratio_of_pixels_not_of_fractions() {
// The whole of why `with_aspect` takes the frame size. On a 3:2
// photograph a square crop is *not* a square in fractions, and a
// version that skipped the conversion would pass every test written
// on a square frame.
let c = CropRect::default().with_aspect(6000, 4000, 1.0, (0.5, 0.5));
assert!(
(pixel_ratio(c, 6000, 4000) - 1.0).abs() < 1e-4,
"expected a square in pixels, got {c:?}"
);
assert!(
(c.width - c.height).abs() > 0.1,
"a square on a 3:2 frame must not be square in fractions: {c:?}"
);
}
#[test]
fn every_offered_ratio_comes_back_at_the_ratio_asked_for() {
for (rw, rh) in [(1, 1), (3, 2), (4, 3), (5, 4), (16, 9), (2, 3), (9, 16)] {
let want = rw as f32 / rh as f32;
for (fw, fh) in [(6000u32, 4000u32), (4000, 6000), (3000, 3000)] {
let c = CropRect {
x: 0.2,
y: 0.3,
width: 0.4,
height: 0.25,
}
.with_aspect(fw, fh, want, (0.5, 0.5));
let got = pixel_ratio(c, fw, fh);
assert!(
(got / want - 1.0).abs() < 1e-3,
"{rw}:{rh} on {fw}x{fh} came back as {got}"
);
}
}
}
#[test]
fn a_locked_rect_stays_inside_the_frame_whatever_is_asked_for() {
// The reason the fit is a scale rather than a clamp: clamping one
// axis at the edge would hold the rect inside at the cost of the
// ratio, which is the one property the caller asked for.
for (rw, rh) in [(1, 1), (3, 2), (16, 9), (9, 16)] {
for anchor in [(0.0, 0.0), (1.0, 1.0), (1.0, 0.0), (0.5, 0.5)] {
let c = CropRect {
x: 0.05,
y: 0.9,
width: 0.9,
height: 0.09,
}
.with_aspect(6000, 4000, rw as f32 / rh as f32, anchor);
assert!(
c.x >= -1e-5
&& c.y >= -1e-5
&& c.x + c.width <= 1.0 + 1e-5
&& c.y + c.height <= 1.0 + 1e-5,
"{rw}:{rh} at {anchor:?} left the frame: {c:?}"
);
let got = pixel_ratio(c, 6000, 4000);
let want = rw as f32 / rh as f32;
assert!(
(got / want - 1.0).abs() < 1e-3,
"{rw}:{rh} at {anchor:?} lost its ratio: {got}"
);
}
}
}
#[test]
fn the_anchor_corner_is_the_one_that_does_not_move() {
// What makes a corner drag feel like a corner drag: the opposite
// corner stays nailed down while the held one shapes the rect.
let start = CropRect {
x: 0.2,
y: 0.2,
width: 0.3,
height: 0.3,
};
// Bottom-right held still, top-left free.
let c = start.with_aspect(6000, 4000, 1.0, (1.0, 1.0));
assert!(
(c.x + c.width - (start.x + start.width)).abs() < 1e-5,
"{c:?}"
);
assert!(
(c.y + c.height - (start.y + start.height)).abs() < 1e-5,
"{c:?}"
);
// Top-left held still, bottom-right free.
let c = start.with_aspect(6000, 4000, 1.0, (0.0, 0.0));
assert!((c.x - start.x).abs() < 1e-5, "{c:?}");
assert!((c.y - start.y).abs() < 1e-5, "{c:?}");
}
#[test]
fn a_locked_rect_grows_onto_the_ratio_rather_than_shrinking_onto_it() {
// Shrinking to fit makes a one-axis drag do nothing at all: the other
// axis clamps the first straight back, and the handle refuses to move.
let start = CropRect {
x: 0.1,
y: 0.1,
width: 0.6,
height: 0.3,
};
let c = start.with_aspect(4000, 4000, 1.0, (0.0, 0.0));
assert!(
c.width >= start.width - 1e-5,
"{c:?} narrower than {start:?}"
);
assert!(
c.height >= start.height - 1e-5,
"{c:?} shorter than {start:?}"
);
}
#[test]
fn a_free_or_nonsense_ratio_leaves_the_rect_alone() {
// Nothing here should be a failure the caller has to handle: "no
// ratio" is the ordinary state of the crop tool.
let start = CropRect {
x: 0.1,
y: 0.2,
width: 0.3,
height: 0.4,
};
for bad in [0.0, -1.5, f32::NAN, f32::INFINITY] {
assert_eq!(start.with_aspect(6000, 4000, bad, (0.5, 0.5)), start);
}
// A frame with no extent cannot define a ratio either.
assert_eq!(start.with_aspect(0, 0, 1.0, (0.5, 0.5)), start);
}
#[test]
fn a_rect_already_inside_its_bound_is_left_where_it_is() {
let bound = CropRect {
x: 0.1,
y: 0.1,
width: 0.8,
height: 0.8,
};
let rect = CropRect {
x: 0.2,
y: 0.2,
width: 0.3,
height: 0.3,
};
assert_eq!(rect.fitted_into(bound), rect);
}
#[test]
fn fitting_into_a_bound_keeps_the_shape_and_the_side_it_was_on() {
// A crop placed deliberately off-centre is a decision. Recentring it
// to solve a problem the user did not have would undo their work.
let bound = CropRect {
x: 0.25,
y: 0.25,
width: 0.5,
height: 0.5,
};
let rect = CropRect {
x: 0.0,
y: 0.0,
width: 0.8,
height: 0.4,
};
let fitted = rect.fitted_into(bound);
assert!(
(fitted.width / fitted.height - rect.width / rect.height).abs() < 1e-4,
"shape changed: {fitted:?}"
);
assert!(
fitted.x >= bound.x - 1e-5
&& fitted.y >= bound.y - 1e-5
&& fitted.x + fitted.width <= bound.x + bound.width + 1e-5
&& fitted.y + fitted.height <= bound.y + bound.height + 1e-5,
"{fitted:?} is not inside {bound:?}"
);
// It came from the top-left, so it should still be against those
// edges rather than centred in the bound.
assert!((fitted.x - bound.x).abs() < 1e-5, "{fitted:?}");
assert!((fitted.y - bound.y).abs() < 1e-5, "{fitted:?}");
}
#[test]
fn a_locked_crop_still_fits_inside_the_straightened_safe_area() {
// The two new pieces meet here: an angle shrinks the safe area, and a
// ratio the user locked has to survive being fitted into it.
let mut f = Framing::new();
f.set_param(ANGLE, 7.0);
let bound = f.max_inscribed_crop(6000, 4000);
let locked = CropRect::default().with_aspect(6000, 4000, 1.0, (0.5, 0.5));
let fitted = locked.fitted_into(bound);
assert!(
(pixel_ratio(fitted, 6000, 4000) - 1.0).abs() < 1e-3,
"the lock did not survive the fit: {fitted:?}"
);
assert!(
fitted.x >= bound.x - 1e-5
&& fitted.y >= bound.y - 1e-5
&& fitted.x + fitted.width <= bound.x + bound.width + 1e-5
&& fitted.y + fitted.height <= bound.y + bound.height + 1e-5,
"{fitted:?} is not inside {bound:?}"
);
}
#[test]
fn fitting_into_the_whole_frame_is_the_identity() {
// What makes the straighten correction reversible: at zero degrees the
// safe area is the whole frame, so recomputing the crop from the
// user's own rectangle hands it straight back, bit for bit.
for rect in [
CropRect::default(),
CropRect {
x: 0.1,
y: 0.2,
width: 0.3,
height: 0.4,
},
CropRect {
x: 0.0,
y: 0.0,
width: 1.0,
height: 0.5,
},
] {
assert_eq!(rect.fitted_into(CropRect::default()), rect, "{rect:?}");
}
}
#[test]
fn a_crop_recomputed_from_intent_grows_back_as_the_angle_falls() {
// The whole reason the correction is recomputed rather than
// accumulated. Taking each angle's fit from the *previous* fit
// ratchets — the excursion to 20 degrees is never given back — where
// taking it from the user's own rectangle every time returns it.
let intended = CropRect::default();
let bound_at = |deg: f32| {
let mut f = Framing::new();
f.set_param(ANGLE, deg);
f.max_inscribed_crop(6000, 4000)
};
let out = intended.fitted_into(bound_at(20.0));
let back = intended.fitted_into(bound_at(3.0));
assert!(
back.width > out.width && back.height > out.height,
"3 degrees should give more back than 20 took: {out:?} -> {back:?}"
);
// And all the way home.
assert_eq!(intended.fitted_into(bound_at(0.0)), intended);
// The ratcheting version, for contrast: chaining the fits never
// recovers, which is the behaviour this arrangement exists to avoid.
let chained = out.fitted_into(bound_at(3.0));
assert!(
chained.width <= out.width + 1e-6,
"a chained fit must not grow — that is the bug being pinned"
);
}
#[test]
fn parameters_round_trip() {
let mut f = Framing::new();
@@ -1896,7 +2319,7 @@ mod tests {
// And the sampler's last step, which lives in `operation.rs` and is
// the half of the map this file does not emit.
assert!(
crate::operation::sample_source(false).contains("p / aspect + vec2<f32>(0.5)"),
crate::operation::sample_source(false, false).contains("p / aspect + vec2<f32>(0.5)"),
"the sampler's return to texture coordinates moved"
);
}
+687 -12
View File
@@ -1,3 +1,4 @@
//! TRACES: FR-DEV-1
//! The edit graph — an ordered set of operations (ARCH §3.4).
//!
//! CPU-side state, deliberately. The GPU device can be lost and rebuilt at any
@@ -13,8 +14,9 @@ use crate::descriptor::{
Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamId, ParamKind, Presentation,
};
use crate::framing::{CropRect, Framing};
use crate::lens::LensProfile;
use crate::mask::MaskStack;
use crate::operation::{compose_full, ComposedShader, Operation};
use crate::operation::{compose_full, ComposedShader, Operation, Uniform};
use crate::ops;
use crate::preset::{Preset, Scope};
use crate::spot::SpotSet;
@@ -119,6 +121,48 @@ pub struct EditGraph {
/// *where* an adjustment applies, and a spot says where a piece of the
/// photograph comes from.
spots: SpotSet,
/// The lens corrections that rewrite coordinates: distortion and lateral
/// chromatic aberration (`docs/architecture.md` §5.2).
///
/// Apart from `ops` for the fourth time, and this one is not about shape
/// but about direction. Every [`Operation`] is a function from colour to
/// colour, and these run *before* a colour exists: they decide which
/// source pixel is read, and CA decides it three times over. See
/// [`crate::lens`] for why that cannot be expressed as an operation.
///
/// Beside [`Self::framing`] in every way that matters — the two compose
/// into one coordinate map, and neither can be applied without the other's
/// result — but held separately because framing also changes the output's
/// dimensions, which a warp never does.
warps: Vec<Box<dyn crate::lens::Warp>>,
/// The lens profile the corrections above were given, if any.
///
/// Kept as well as fanned out, because the corrections hold it in a form
/// nothing can read back: each has folded its own share of the profile
/// into private coefficients. Something has to be able to answer "is this
/// photograph corrected from a measurement, or by hand?" — the interface
/// is required to say so plainly rather than let an automatic correction
/// silently do nothing — and this is the only place that can.
///
/// Not in [`EditState`], not in the sidecar, and not undoable: it is
/// derived from the file's EXIF and a database, exactly as the film's
/// baked tables are derived from a stock's id.
lens_profile: Option<LensProfile>,
/// Whether the profile above is being used.
///
/// **The one part of automatic lens correction that is an edit.** The
/// coefficients are a measurement of the lens and belong to the file;
/// whether to accept them is the photographer's, and it is the answer a
/// tick box in the panel gives. So this rides with the parameters rather
/// than with the profile: it is published as
/// [`crate::lens::profile_switch`], captured by [`Preset`], stored in the
/// sidecar and replayed by the undo stack, all by the one road every other
/// setting travels (FR-DEV-3c).
///
/// True on a fresh graph, because a profile that was found is a
/// correction the photograph asked for — see
/// [`crate::descriptor::ParamDescriptor::switch_on`].
lens_profile_applied: bool,
}
/// TRACES: FR-DEV-3f
@@ -163,6 +207,18 @@ impl EditGraph {
masks: Arc::new(MaskStack::new()),
film: None,
spots: SpotSet::new(),
// Distortion first, then CA, and the order is the correction's
// rather than a preference. Each warp receives the position the
// previous one produced, and lateral CA is a magnification about
// the optical axis of the *undistorted* frame — measured on a
// barrel-distorted one it would be fitted to a radius the lens
// profile does not describe.
warps: vec![
Box::new(crate::ops::Distortion::new()),
Box::new(crate::ops::Aberration::new()),
],
lens_profile: None,
lens_profile_applied: true,
}
}
@@ -247,6 +303,93 @@ impl EditGraph {
self.ops.iter().map(|o| o.descriptor()).collect()
}
/// TRACES: FR-DEV-3
/// Apply a lens profile's measured coefficients to every correction that
/// wants some, or clear them all with `None`.
///
/// **Derived state, not an edit.** A profile comes from the file's EXIF
/// plus a database this crate does not link, so it is not a parameter, is
/// not in the sidecar, and is not undoable. What *is* an edit is the manual
/// trim beside it: each correction composes the profile with its own
/// slider, so a photographer can lean on the measurement, override it, or
/// work without one.
///
/// **Clearing matters as much as setting.** Opening a photograph from an
/// unrecognised lens must pass `None` rather than simply not calling this:
/// a graph reused across images would otherwise correct this frame for the
/// optics of the last one, which is both wrong and invisible.
///
/// Fans out over the warps and the operations alike. Which trait a
/// correction implements is a fact about where it sits relative to the
/// fetch, and no business of the caller's — see
/// [`crate::Operation::set_lens_profile`].
pub fn set_lens_profile(&mut self, profile: Option<LensProfile>) {
self.lens_profile = profile;
self.fan_out_lens_profile();
}
/// The profile this photograph was matched to, if any.
///
/// Reports what was *found*, not what is in effect: a profile the
/// photographer has switched off is still the profile for this lens, and
/// the switch is what says whether it is being used. A caller wanting the
/// coefficients that are actually in the shader asks
/// [`Self::lens_profile_applied`] as well — which is what the interface's
/// lens line does, because "corrected" and "correction available, off" are
/// two different things to tell a photographer.
pub fn lens_profile(&self) -> Option<&LensProfile> {
self.lens_profile.as_ref()
}
/// TRACES: FR-DEV-3
/// Whether the matched profile is being applied.
pub fn lens_profile_applied(&self) -> bool {
self.lens_profile_applied
}
/// TRACES: FR-DEV-3
/// Use the matched profile, or decline it.
///
/// Reached through `set_param` by the panel, like every other setting;
/// this is the named door for a caller that has a `bool` rather than a
/// parameter id.
///
/// Setting this with no profile matched is meaningful and harmless: a
/// preset carrying the switch may land on a photograph whose lens the
/// database has never heard of, and remembering the answer costs nothing.
pub fn set_lens_profile_applied(&mut self, applied: bool) {
self.lens_profile_applied = applied;
self.fan_out_lens_profile();
}
/// Hand every correction the coefficients it should be using.
///
/// `None` where the switch is off, which is the same call opening an
/// unrecognised lens makes — the corrections cannot tell the difference
/// between "no profile" and "not this one, thank you", and have no reason
/// to.
fn fan_out_lens_profile(&mut self) {
let profile = self.lens_profile.filter(|_| self.lens_profile_applied);
for warp in &mut self.warps {
warp.set_profile(profile.as_ref());
}
for op in &mut self.ops {
op.set_lens_profile(profile.as_ref());
}
}
/// Descriptors for the coordinate-domain lens corrections, in order.
///
/// The counterpart to [`Self::descriptors`] and split from it for the same
/// reason framing is absent there: a warp emits its own block in the
/// generated shader rather than a colour fragment, so the codegen tests
/// that count `---- ` markers have to know which kind they are counting.
/// A UI wanting everything still reads [`Self::capabilities`], where all
/// three kinds arrive together and indistinguishably (FR-DEV-3a).
pub fn warp_descriptors(&self) -> Vec<Arc<OpDescriptor>> {
self.warps.iter().map(|w| w.descriptor()).collect()
}
/// TRACES: FR-DEV-3a | FR-DEV-3c
/// Everything a UI needs to build its controls.
///
@@ -285,6 +428,39 @@ impl EditGraph {
}
});
// The lens corrections first, matching where they sit in the shader:
// they rewrite the coordinate before any colour is fetched, so nothing
// below them can be judged until they are right. It also puts the
// three optical corrections together in the panel — these two and the
// vignetting node, which is an ordinary operation and arrives above
// through `ops`.
let warps = self.warps.iter().map(|w| {
let desc = w.descriptor();
OpCapability {
id: desc.id,
label: desc.label,
active: w.is_active(),
params: desc
.params
.iter()
.map(|p| ParamCapability {
id: p.id,
label: p.label,
kind: p.kind.clone(),
default: p.default,
value: w.param(p.id),
facet: p.facet,
})
.collect(),
// No preferred widget. A distortion amount and the two CA
// scales are ordinary scalars, and plain sliders — the
// fallback every frontend implements — are the right control
// for them.
presentation: None,
attributes: desc.attributes.clone(),
}
});
// Framing last, matching where it sits in the pipeline: the crop is
// decided after the image looks right, not before.
let desc = self.framing.descriptor();
@@ -313,7 +489,73 @@ impl EditGraph {
attributes: desc.attributes.clone(),
};
ops.chain(std::iter::once(framing)).collect()
// The lens profile switch, ahead of the corrections it drives — and
// **only when a profile was matched**. A tick box on a photograph
// whose lens the database has never heard of would be a control that
// does nothing, which is the failure `dr_lens` names: an automatic
// correction is allowed to be unavailable and is not allowed to look
// available and be inert. Where there is no profile the interface says
// so in words instead.
let switch = self.lens_profile.map(|_| {
let desc = crate::lens::profile_switch::descriptor();
OpCapability {
id: desc.id,
label: desc.label,
// A profile that is on is doing something to this photograph,
// which is what the panel's modified marker is for. Off is the
// departure from default, and reads as one either way.
active: self.lens_profile_applied,
params: desc
.params
.iter()
.map(|p| ParamCapability {
id: p.id,
label: p.label,
kind: p.kind.clone(),
default: p.default,
value: if self.lens_profile_applied { 1.0 } else { 0.0 },
facet: p.facet,
})
.collect(),
// An ordinary switch. Nothing about it wants the canvas.
presentation: None,
attributes: desc.attributes.clone(),
}
});
switch
.into_iter()
.chain(warps)
.chain(ops)
.chain(std::iter::once(framing))
.collect()
}
/// TRACES: FR-DEV-3a
/// What one operation's uniforms currently evaluate to.
///
/// The same numbers [`Self::compose`] would bake into the uniform block,
/// asked for one node rather than for the whole chain. `None` where no
/// operation carries that id — the framing and the lens warps are not in
/// `ops`, and neither publishes uniforms of this kind.
///
/// **Why anything outside composition wants these.** A widget that reads
/// the photograph rather than driving it — an eyedropper, above all — has
/// to invert the operation: it knows what the pixel is and what it should
/// become, and needs the parameter values that get it there. The mapping
/// from parameters to effect lives in the node's own declaration, and
/// this is the only way to ask it what that mapping currently says
/// without composing a shader and rendering one. See [`crate::neutral`],
/// which is the one caller.
///
/// Cheap: a declared node evaluates a handful of small arithmetic
/// expressions. It is not, however, a per-frame path, and it is not on
/// one — composition reads the same values by its own route.
pub fn uniforms_of(&self, op: OpId) -> Option<Vec<Uniform>> {
self.ops
.iter()
.find(|o| o.descriptor().id == op)
.map(|o| o.uniforms())
}
/// Set a parameter, clamping to the descriptor's declared range.
@@ -361,6 +603,20 @@ impl EditGraph {
// nothing to register (FR-DEV-3c).
ops: _,
framing: _,
// Reached through `capabilities` with the other two. A warp's
// parameters are ordinary scalars once they are in that list, so
// the sidecar, the clipboard and the undo stack carry them with
// nothing registered anywhere (FR-DEV-3c).
warps: _,
// Derived from the file and a database, so it is rebuilt on open
// rather than restored — the same reason the film's tables travel
// as an id and not as numbers.
lens_profile: _,
// Whether that profile is *used* is an edit, and it is in the
// state below: it reaches `Preset::capture` through
// `capabilities`, with the operations and the warps and for the
// same reason (FR-DEV-3c).
lens_profile_applied: _,
masks,
film,
spots,
@@ -407,7 +663,7 @@ impl EditGraph {
// At full scope. `Scope` is a question about what a paste carries
// *between* photographs; this is one photograph's own edit being put
// back, so there is nothing to leave behind.
params.apply(self, Scope::Everything);
params.apply(self, Scope::everything());
self.masks = Arc::clone(masks);
self.spots = spots.clone();
@@ -426,6 +682,18 @@ impl EditGraph {
}
pub fn set_param(&mut self, op: OpId, param: ParamId, value: f32) {
if op == crate::lens::profile_switch::ID {
if param != crate::lens::profile_switch::APPLY {
log::warn!("unknown parameter {param} on {op}; ignoring");
return;
}
// Accepted whether or not a profile was matched, for the reason
// `set_lens_profile_applied` gives: a preset may carry the answer
// to a photograph that has nothing to apply it to.
self.set_lens_profile_applied(value != 0.0);
return;
}
if op == crate::framing::ID {
// Bound rather than chained: `descriptor()` hands back an owned
// `Arc` now, so a `param()` borrowed straight out of the call
@@ -439,6 +707,20 @@ impl EditGraph {
return;
}
// The warps, before the operations. Their ids cannot collide with an
// operation's — `ops/` and the warp list are disjoint by construction,
// and `declared_parity` asserts the chain is exactly what `ops/`
// declares — so the order is for readability rather than precedence.
if let Some(warp) = self.warps.iter_mut().find(|w| w.descriptor().id == op) {
let descriptor = warp.descriptor();
let Some(desc) = descriptor.param(param) else {
log::warn!("unknown parameter {param} on {op}; ignoring");
return;
};
warp.set_param(param, desc.clamp(value));
return;
}
let Some(operation) = self.ops.iter_mut().find(|o| o.descriptor().id == op) else {
// A sidecar naming an operation this build does not have. The
// rest of the edit must still apply.
@@ -457,6 +739,10 @@ impl EditGraph {
/// Read a parameter back.
pub fn param(&self, op: OpId, param: ParamId) -> Option<f32> {
if op == crate::lens::profile_switch::ID {
return (param == crate::lens::profile_switch::APPLY)
.then_some(if self.lens_profile_applied { 1.0 } else { 0.0 });
}
if op == crate::framing::ID {
return self
.framing
@@ -464,6 +750,9 @@ impl EditGraph {
.param(param)
.map(|_| self.framing.param(param));
}
if let Some(warp) = self.warps.iter().find(|w| w.descriptor().id == op) {
return warp.descriptor().param(param).map(|_| warp.param(param));
}
self.ops
.iter()
.find(|o| o.descriptor().id == op)
@@ -477,6 +766,11 @@ impl EditGraph {
op.set_param(p.id, p.default);
}
}
for warp in &mut self.warps {
for p in &warp.descriptor().params {
warp.set_param(p.id, p.default);
}
}
self.framing.reset();
// Masks go too, and this is why `apply` can be a replacement rather
// than an overlay: a sidecar with no mask blocks means an edit with no
@@ -487,6 +781,10 @@ impl EditGraph {
// stock, and turning a name into tables needs the profile database,
// which this crate deliberately does not link (ARCH §6.5a).
self.set_film(None);
// The *matched profile* stays — it is the file's, not the edit's, and
// a reset does not change which lens took the photograph. What returns
// to default is the answer to whether to use it, which is on.
self.set_lens_profile_applied(true);
}
/// Set the crop rectangle. Clamped to keep it inside the frame.
@@ -541,7 +839,43 @@ impl EditGraph {
/// graph renders to the screen and to a file in the same breath, and the
/// two want different answers.
pub fn compose_for(&self, output: dr_types::ColourSpace) -> ComposedShader {
compose_full(&self.ops, &self.framing, output, &self.masks, &self.spots)
compose_full(
&self.ops,
&self.framing,
output,
&self.masks,
&self.spots,
&self.warps,
)
}
/// TRACES: FR-DEV-19c
/// [`Self::compose_for`], with one layer's mask drawn over the picture.
///
/// **The screen's composition, and only the screen's.** The reveal is not
/// on the graph and cannot be: it is how a photographer is looking at an
/// edit, not part of one, so it arrives as an argument to the one call
/// that draws the canvas. Every other path through this type composes
/// without it and could not ask for it if it wanted to.
///
/// The revealed layer renders whether or not it carries an adjustment —
/// which is the whole point, since a fresh selection carries none — so the
/// mask array must be rasterised for the same `reveal`. See
/// [`crate::mask::MaskStack::rendered`] for what the two have to agree on.
pub fn compose_revealing(
&self,
output: dr_types::ColourSpace,
reveal: Option<&crate::mask::Reveal>,
) -> ComposedShader {
crate::operation::compose_full_revealing(
&self.ops,
&self.framing,
output,
&self.masks,
&self.spots,
&self.warps,
reveal,
)
}
/// TRACES: FR-DSP-1
@@ -636,6 +970,22 @@ impl EditGraph {
u64::from(crate::operation::canonical_bits(self.framing.param(p.id))),
);
}
// The warps belong to the geometry key, not the colour one: they decide
// which source pixel a colour is read from, so a cached *result* of
// this stage is wrong the moment one moves. Hashed by id as well as by
// value, so two warps swapping their amounts is not the same edit.
for warp in &self.warps {
let descriptor = warp.descriptor();
geometry = hash_bytes(geometry, descriptor.id.0.as_bytes());
for p in &descriptor.params {
geometry = hash_bytes(geometry, p.id.0.as_bytes());
geometry = mix(
geometry,
u64::from(crate::operation::canonical_bits(warp.param(p.id))),
);
}
}
// The view rect is not a parameter and not in the structure key — it
// is not an edit (see `Framing::view`). It is still an input to every
// rendered pixel, so a cache that ignored it would show the wrong part
@@ -668,12 +1018,26 @@ impl EditGraph {
// out of step the first time a variant gains a field — silently,
// and showing as a mask that stops updating. `Debug` cannot fall
// out of step, because it is derived from the definition itself.
colour = hash_bytes(colour, format!("{:?}", layer.source).as_bytes());
colour = mix(colour, u64::from(layer.enabled));
colour = mix(colour, u64::from(layer.invert));
colour = hash_bytes(colour, layer.falloff.name().as_bytes());
for v in [layer.opacity, layer.feather, layer.morph_radius] {
colour = mix(colour, u64::from(crate::operation::canonical_bits(v)));
colour = mix(
colour,
u64::from(crate::operation::canonical_bits(layer.opacity)),
);
// Every part, in order, because the mask is the fold over them: a
// part added, removed, reshaped or joined the other way round is a
// different shape even when nothing else moved. The order is
// folded in by construction — the same parts joined the other way
// round hash differently because they arrive in the other order.
for part in layer.parts() {
colour = hash_bytes(colour, part.id.as_bytes());
colour = hash_bytes(colour, part.join.name().as_bytes());
colour = hash_bytes(colour, format!("{:?}", part.source).as_bytes());
colour = mix(colour, u64::from(part.invert));
colour = hash_bytes(colour, part.falloff.name().as_bytes());
for v in [part.feather, part.morph_radius] {
colour = mix(colour, u64::from(crate::operation::canonical_bits(v)));
}
}
for (op_id, param_id, value) in layer.params() {
colour = hash_bytes(colour, op_id.as_bytes());
@@ -822,20 +1186,331 @@ mod tests {
assert_ne!(first.uniforms, second.uniforms);
}
/// The whole point of putting the warps in `capabilities`: everything that
/// walks that list carries them, with nothing registered anywhere.
#[test]
fn a_warp_is_carried_by_the_machinery_it_never_told_about_itself() {
use crate::ops::{aberration, distortion};
let mut g = EditGraph::default_chain();
g.set_param(distortion::ID, distortion::AMOUNT, 40.0);
g.set_param(aberration::ID, aberration::RED, 25.0);
assert_eq!(g.param(distortion::ID, distortion::AMOUNT), Some(40.0));
assert_eq!(g.param(aberration::ID, aberration::RED), Some(25.0));
// Through the same capture/apply the sidecar, the clipboard and the
// undo stack all use.
let state = g.state();
let mut restored = EditGraph::default_chain();
// No film in this graph, so nothing is owed; the result is asserted
// rather than dropped because ignoring it elsewhere would leave a
// photograph rendering without its stock.
assert_eq!(restored.set_state(&state), FilmRebake::NotNeeded);
assert_eq!(
restored.param(distortion::ID, distortion::AMOUNT),
Some(40.0),
"a distortion correction did not survive a state round trip, so \
reopening the photograph would silently drop it"
);
assert_eq!(restored.param(aberration::ID, aberration::RED), Some(25.0));
}
/// A profile has to reach all three corrections, across both traits.
///
/// The failure this guards is the quiet one: a profile that reached the
/// warps and not the vignetting node would correct the geometry and leave
/// the corners dark, which looks like an under-corrected lens rather than
/// like a wiring fault.
#[test]
fn a_lens_profile_reaches_every_correction_that_wants_one() {
use crate::lens::{LensProfile, Tca};
use crate::ops::{aberration, distortion, vignetting};
let mut g = EditGraph::default_chain();
for id in [distortion::ID, aberration::ID, vignetting::ID] {
assert_eq!(
g.param(id, ParamId("amount")).unwrap_or(0.0),
0.0,
"{id} should start neutral"
);
}
g.set_lens_profile(Some(LensProfile {
distortion: Some(distortion::PtLens {
a: 0.0,
b: -0.012,
c: 0.0,
}),
tca: Some(Tca {
red_scale: 1.000_32,
blue_scale: 0.999_93,
}),
vignetting: Some(vignetting::Pa {
k1: -0.42,
k2: 0.05,
k3: 0.0,
}),
}));
// Every correction is now doing something, with every slider still at
// its default — which is the whole point of a profile.
let source = g.compose().source;
for marker in [
"---- warp: distortion ----",
"---- warp: aberration ----",
"---- vignetting ----",
] {
assert!(
source.contains(marker),
"a profile did not reach {marker}: {source}"
);
}
// And clearing it puts the photograph back, which is what opening an
// image from an unrecognised lens has to do.
g.set_lens_profile(None);
let cleared = g.compose().source;
assert!(!cleared.contains("---- warp: "));
assert!(!cleared.contains("---- vignetting ----"));
assert!(g.lens_profile().is_none());
}
/// A measured profile, for the switch's tests. Coefficients are one real
/// wide-angle's, rounded — what matters is that each of the three
/// corrections gets something to do.
fn measured() -> crate::lens::LensProfile {
use crate::lens::{LensProfile, Tca};
use crate::ops::{distortion, vignetting};
LensProfile {
distortion: Some(distortion::PtLens {
a: 0.0,
b: -0.012,
c: 0.0,
}),
tca: Some(Tca {
red_scale: 1.000_32,
blue_scale: 0.999_93,
}),
vignetting: Some(vignetting::Pa {
k1: -0.42,
k2: 0.05,
k3: 0.0,
}),
}
}
/// TRACES: FR-DEV-3
/// The switch takes the whole profile out of the shader, and puts it back.
///
/// The control the panel draws is this call, and what it has to mean is
/// "develop this photograph as if the database had never heard of the
/// lens" — all three corrections, not the geometry alone.
#[test]
fn declining_the_profile_removes_every_correction_it_was_driving() {
use crate::lens::profile_switch;
let mut g = EditGraph::default_chain();
g.set_lens_profile(Some(measured()));
assert!(g.compose().source.contains("---- warp: distortion ----"));
g.set_param(profile_switch::ID, profile_switch::APPLY, 0.0);
let declined = g.compose().source;
assert!(!declined.contains("---- warp: "), "{declined}");
assert!(!declined.contains("---- vignetting ----"));
// The profile itself is untouched. It is a fact about the file, and
// the interface still has to be able to say which lens this was.
assert!(
g.lens_profile().is_some(),
"declining a profile must not forget it, or the switch could \
never be turned back on"
);
g.set_param(profile_switch::ID, profile_switch::APPLY, 1.0);
assert!(g.compose().source.contains("---- warp: distortion ----"));
}
/// TRACES: FR-DEV-3
/// The manual trims keep working with the profile declined.
///
/// The two are independent by construction — each correction composes the
/// profile with its own slider — and that is what makes the switch safe to
/// offer: turning it off is "correct this by hand", not "stop correcting".
#[test]
fn declining_the_profile_leaves_the_manual_corrections_alone() {
use crate::lens::profile_switch;
use crate::ops::distortion;
let mut g = EditGraph::default_chain();
g.set_lens_profile(Some(measured()));
g.set_param(distortion::ID, distortion::AMOUNT, 40.0);
g.set_param(profile_switch::ID, profile_switch::APPLY, 0.0);
assert_eq!(g.param(distortion::ID, distortion::AMOUNT), Some(40.0));
assert!(
g.compose().source.contains("---- warp: distortion ----"),
"the slider still bends the frame with the profile switched off"
);
}
/// TRACES: FR-DEV-3
/// The switch is only offered where there is a profile to switch.
///
/// `dr_lens`'s rule, as a property of the capability list: a tick box on a
/// photograph whose lens the database has never heard of would be a
/// control that looks available and does nothing, which is the failure the
/// automatic correction is supposed to avoid rather than an instance of
/// it.
#[test]
fn the_switch_is_absent_until_a_profile_is_matched() {
use crate::lens::profile_switch;
let mut g = EditGraph::default_chain();
assert!(
!g.capabilities().iter().any(|c| c.id == profile_switch::ID),
"an unmatched lens must not grow a tick box"
);
g.set_lens_profile(Some(measured()));
let cap = g
.capabilities()
.into_iter()
.find(|c| c.id == profile_switch::ID)
.expect("a matched profile is offered as a control");
assert_eq!(cap.params.len(), 1);
assert_eq!(cap.params[0].kind, ParamKind::Bool);
// On, and on is the default — so a photograph nobody has touched
// carries nothing in its sidecar and is still corrected.
assert_eq!(cap.params[0].value, 1.0);
assert_eq!(cap.params[0].default, 1.0);
assert!(!cap.params[0].is_modified());
// Ahead of the corrections it drives, so the panel reads top down:
// the profile, then what to add to it by hand.
let ids: Vec<&str> = g.capabilities().iter().map(|c| c.id.0).collect();
assert_eq!(ids.first(), Some(&profile_switch::ID.0));
}
/// TRACES: FR-DEV-3 | FR-DEV-5
/// Declining the profile is an edit, so it travels like one.
///
/// The reason it is a parameter at all: capture and apply are the road the
/// sidecar, the clipboard and the undo stack all take, and a `bool` on the
/// side would have had to be added to each of them by hand.
#[test]
fn the_switch_survives_a_state_round_trip() {
use crate::lens::profile_switch;
let mut g = EditGraph::default_chain();
g.set_lens_profile(Some(measured()));
g.set_param(profile_switch::ID, profile_switch::APPLY, 0.0);
let state = g.state();
// Reopened: the profile is looked up again from the file, and the
// stored edit says what to do with it.
let mut reopened = EditGraph::default_chain();
reopened.set_lens_profile(Some(measured()));
assert_eq!(reopened.set_state(&state), FilmRebake::NotNeeded);
assert_eq!(
reopened.param(profile_switch::ID, profile_switch::APPLY),
Some(0.0),
"a declined profile came back applied, so reopening the \
photograph would silently correct it again"
);
assert!(!reopened.compose().source.contains("---- warp: "));
}
/// TRACES: FR-DEV-3
/// A reset accepts the profile again, and keeps it.
#[test]
fn resetting_returns_to_the_measured_profile() {
use crate::lens::profile_switch;
let mut g = EditGraph::default_chain();
g.set_lens_profile(Some(measured()));
g.set_param(profile_switch::ID, profile_switch::APPLY, 0.0);
g.reset();
assert!(g.lens_profile().is_some(), "the file still names a lens");
assert!(g.lens_profile_applied());
assert!(g.compose().source.contains("---- warp: distortion ----"));
}
/// A warp is an edit, so `reset` has to reach it. It did not until the
/// loop was added: a reset that left the lens corrections standing would
/// mean "back to the file as it is" quietly did not mean that.
#[test]
fn resetting_the_graph_neutralises_the_warps() {
use crate::ops::distortion;
let mut g = EditGraph::default_chain();
g.set_param(distortion::ID, distortion::AMOUNT, 40.0);
g.reset();
assert_eq!(g.param(distortion::ID, distortion::AMOUNT), Some(0.0));
}
/// The warps belong to the geometry key, not the colour one.
///
/// A tile cache keyed on geometry holds the *result* of the coordinate
/// stage. Moving a distortion slider changes which source pixel every
/// output pixel reads, so a cache that did not notice would keep drawing
/// the previous correction — visibly, and only where it had already
/// cached.
#[test]
fn a_warp_moves_the_geometry_key_and_leaves_the_others_alone() {
use crate::operation::Affects;
use crate::ops::distortion;
let mut g = EditGraph::default_chain();
let before = g.invalidation();
g.set_param(distortion::ID, distortion::AMOUNT, 40.0);
let after = g.invalidation();
assert_ne!(
before.of(Affects::Geometry),
after.of(Affects::Geometry),
"a distortion change must invalidate the geometry stage"
);
assert_eq!(
before.of(Affects::Colour),
after.of(Affects::Colour),
"and must not invalidate the colour stage, which it does not touch"
);
}
#[test]
fn capabilities_describe_every_operation_and_parameter() {
// The UI builds its whole panel from this. Anything missing here is
// something the UI would have to hardcode.
let g = EditGraph::default_chain();
let caps = g.capabilities();
// Every operation, plus framing — which is not an operation and so
// is absent from `descriptors`, but must still reach the panel.
assert_eq!(caps.len(), g.descriptors().len() + 1);
// Every operation, plus everything that is *not* an operation and so
// is absent from `descriptors`: the framing, and the coordinate-domain
// lens corrections. All of them have parameters a photographer sets,
// so all of them have to reach the panel — and the panel is forbidden
// from naming any of them (FR-DEV-3a), which leaves this list as the
// only way they can arrive.
assert_eq!(caps.len(), g.descriptors().len() + g.warps.len() + 1);
assert!(
caps.iter().any(|c| c.id == crate::framing::ID),
"framing must appear in the capability list, or the UI cannot \
build a crop control without naming it"
);
for warp in &g.warps {
let id = warp.descriptor().id;
assert!(
caps.iter().any(|c| c.id == id),
"{id} must appear in the capability list, or its correction is \
in the shader with no control anywhere that can reach it"
);
}
for cap in &caps {
assert!(!cap.params.is_empty(), "{} exposes no parameters", cap.id);
@@ -1163,7 +1838,7 @@ mod tests {
// Moving the gradient is a different mask, so a different result.
if let Some(layer) = g.masks_mut().get_mut("l1") {
layer.source = MaskSource::Linear {
layer.base_mut().source = MaskSource::Linear {
centre: (0.2, 0.7),
angle: 0.4,
width: 0.2,
+106 -2
View File
@@ -41,6 +41,12 @@
//! `(0, 0)`, and the radius is scaled so that `r == 1` at the corner. Both
//! properties matter.
//!
//! Note which variable carries which. The shader's `p` supplies the centring
//! and spans `±0.5 * aspect`; the corner normalisation is applied on top of it
//! by `operation::sample_source`, which publishes the result as `radius`. A
//! warp reading `length(p)` and calling it `r` would be evaluating its
//! polynomial short of where the profile was fitted.
//!
//! Centring is what makes the polynomial meaningful — lens distortion is
//! radially symmetric about the optical axis, so a formula written about any
//! other origin would need cross terms to say the same thing.
@@ -53,11 +59,95 @@
//! wearing the same lens, which defeats the point of a lens profile.
use std::fmt::Write as _;
use std::sync::Arc;
use std::sync::{Arc, LazyLock};
use crate::descriptor::{OpDescriptor, ParamId};
use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
use crate::operation::{Helper, Uniform};
/// Lateral chromatic aberration, as a per-channel radial scale.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Tca {
pub red_scale: f32,
pub blue_scale: f32,
}
/// What a lens profile says about one shot, in the pipeline's own types.
///
/// **Deliberately a mirror of `dr_lens::LensProfile` rather than that type
/// itself.** The dependency would have to run the wrong way: `dr-lens` carries
/// an XML parser and 5.5 MB of Lensfun data, and this crate is organised around
/// having no dependencies so that its codegen is testable without a GPU or a
/// database (ARCH §6.5a). Whatever sits above both does the conversion; it is
/// nine fields and a `match`.
///
/// Every field is independently optional because the database is: it commonly
/// carries distortion for a lens and no vignetting, or covers only part of a
/// zoom. A partial profile is useful and must not be discarded wholesale.
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct LensProfile {
pub distortion: Option<crate::ops::distortion::PtLens>,
pub tca: Option<Tca>,
pub vignetting: Option<crate::ops::vignetting::Pa>,
}
/// TRACES: FR-DEV-3
/// The lens profile itself, as one switch a photographer can reach.
///
/// **Not an operation and not a warp** — it corrects nothing on its own. It
/// is the answer to "use the measured profile for this lens, or not", and the
/// three corrections that read the coefficients ([`crate::ops::Distortion`],
/// [`crate::ops::Aberration`] and the vignetting node) are where the
/// correction actually happens. Framing is in the capability list on the same
/// terms: something a photographer sets which is not an `Operation`.
///
/// # Why this exists at all
///
/// The profile arrives from the file's EXIF and a database, and applying it
/// silently was the whole of the interface for it. That reads as the feature
/// being absent: the corrections in the panel are manual sliders, the
/// automatic part is a line of grey text under the camera, and nothing
/// anywhere says "this is on and you may turn it off". `dr_lens`'s honesty
/// rule — an automatic correction the user cannot see is worse than one they
/// can see is unavailable — asks for a control here, not just a caption.
///
/// # Why it is a parameter rather than a flag on the session
///
/// Everything a photographer sets travels by one road: the capability list
/// feeds the generated panel, [`crate::Preset`] captures it, the sidecar
/// stores it and the undo stack replays it (FR-DEV-3c). A `bool` on the
/// session would have needed its own place in each of those four, and would
/// have been forgotten in at least one — which is exactly how the mask stack
/// came to be missing from the history. As a parameter it is in all of them
/// with nothing registered.
pub mod profile_switch {
use super::*;
pub const ID: OpId = OpId("lens_profile");
pub const APPLY: ParamId = ParamId("apply");
/// On by default: the profile is a measurement of the lens that took the
/// photograph, so applying it is the neutral state and declining it is
/// the edit. See [`ParamDescriptor::switch_on`].
pub(crate) static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
Arc::new(OpDescriptor {
id: ID,
label: LocalizedKey("op.lens_profile"),
params: vec![ParamDescriptor::switch_on(
"apply",
"param.lens_profile.apply",
)],
// Optics, so it sits with the corrections it drives rather than in
// a group of its own.
attributes: vec![Attribute::Optics],
})
});
/// This switch's description, for a caller building a control from it.
pub fn descriptor() -> Arc<OpDescriptor> {
DESCRIPTOR.clone()
}
}
/// A coordinate-domain operation, applied before the source is sampled.
///
/// Object-safe for the same reason [`crate::operation::Operation`] is: the
@@ -112,6 +202,20 @@ pub trait Warp: Send + Sync {
fn helpers(&self) -> &'static [Helper] {
&[]
}
/// Take whatever this warp needs from a lens profile.
///
/// Handed the *whole* profile rather than its own slice of it, so that the
/// graph fanning one out does not have to know which correction wants
/// which coefficients — the same reason an operation is handed a
/// [`ParamId`] rather than a field. `None` clears any profile in place,
/// which is what opening a photograph from an unrecognised lens must do:
/// leaving the previous one standing would correct this frame for the
/// optics of the last one.
///
/// Defaulted, because a warp need not be profile-driven. Nothing about the
/// coordinate stage requires a database behind it.
fn set_profile(&mut self, _profile: Option<&LensProfile>) {}
}
/// The composed geometry stage: WGSL, uniforms, and what it needs from the
+7 -2
View File
@@ -1,3 +1,4 @@
//! TRACES: R3
//! The develop pipeline — operations, descriptors, and shader composition.
//!
//! # What this crate is
@@ -31,6 +32,7 @@
//! single multiply and white balance a per-channel scale; on gamma-encoded
//! data neither would be physically meaningful (ARCH §5.2).
pub mod coverage;
pub mod declared;
pub mod descriptor;
pub mod detail;
@@ -39,13 +41,16 @@ pub mod graph;
pub mod history;
pub mod lens;
pub mod mask;
pub mod neutral;
pub mod operation;
pub mod ops;
pub mod preset;
pub mod sidecar;
pub mod spot;
pub mod starter;
pub mod state;
pub use coverage::Coverage;
pub use declared::{Declaration, DeclaredOp};
pub use descriptor::{
Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, ParamKind,
@@ -57,12 +62,12 @@ pub use detail::{
pub use framing::{CropRect, Framing};
pub use graph::{EditGraph, OpCapability, ParamCapability};
pub use history::{Edit, Entry as HistoryEntry, History, Step};
pub use lens::{compose_warps, ComposedWarp, Warp};
pub use lens::{compose_warps, ComposedWarp, LensProfile, Tca, Warp};
pub use operation::{
compose, compose_with_framing, Affects, ComposedShader, Helper, Invalidation, Operation,
OutputMode, Uniform, BASE_CURVE_POINTS, BASE_CURVE_UNIFORM_OFFSET, RESERVED_UNIFORM_FIELDS,
};
pub use preset::{Preset, Scope};
pub use preset::{LibraryParseError, NameError, Preset, PresetLibrary, Scope};
pub use sidecar::{Sidecar, Version};
pub use spot::{Spot, SpotMode, SpotSet};
pub use state::{EditState, FilmRebake, FilmRef};
+1440 -213
View File
File diff suppressed because it is too large Load Diff
+391
View File
@@ -0,0 +1,391 @@
//! TRACES: FR-DEV-3 | FR-DEV-3a
//! Turning a colour that ought to be grey into white balance parameters.
//!
//! # Why this is in the core and not in the interface
//!
//! FR-DEV-3 asks for a white balance picker: point at something neutral and
//! the correction follows. The pointing is the interface's — it owns the
//! canvas, the pixel and the modality — but the *arithmetic* is not, and the
//! reason is ARCH §4.3a rather than tidiness. How far a hundred units of
//! temperature move red against blue is declared in
//! `core/dr-pipeline/ops/white_balance.yaml`, in one expression, and an
//! interface that inverted it would be holding a second copy of a number it
//! is not allowed to know. The copy would then be wrong the first time
//! somebody adjusted the range, silently, in the direction of "the picker is
//! slightly off".
//!
//! So the interface hands over a colour and this hands back a moved graph.
//!
//! # What it is allowed to assume, which is a widget kind and not an operation
//!
//! Nothing here names white balance. It looks for the operation that asks for
//! [`WidgetKind::WhitePoint`], and everything else is that widget kind's own
//! contract — the same kind of contract [`WidgetKind::ToneCurve`] carries when
//! it says its parameters are point coordinates interleaved:
//!
//! * The **first two parameters** the presentation names are the axes. The
//! first trades red against blue, the second green against magenta.
//! * The operation publishes **exactly three uniforms: the linear per-channel
//! gains, in red, green, blue order.** That is what an eyedropper needs in
//! order to be invertible at all, and it is the honest way to say so —
//! a node whose effect on a grey is not three gains is not a node an
//! eyedropper can drive, and declaring the widget on one is the mistake
//! this refuses rather than approximates.
//!
//! Checked rather than trusted: a node that declares the widget and publishes
//! four uniforms gets no picker, and its sliders go on working.
//!
//! # Why a search rather than an inversion
//!
//! The declared expression is arbitrary — `exp2` today, a table lookup or a
//! polynomial tomorrow — and only its *monotonicity* is part of the bargain:
//! warmer is warmer all the way along, or the slider is not a slider. A
//! bisection needs exactly that and nothing more, so it stays correct across
//! every expression the declaration language can grow, where a closed-form
//! inverse would be a second definition of the node maintained here.
//!
//! It is also free. Twenty halvings of two ranges, three times over, is a few
//! hundred evaluations of two small arithmetic expressions — on a click, not
//! on a frame.
use crate::descriptor::{OpId, ParamId, ParamKind, WidgetKind};
use crate::graph::{EditGraph, OpCapability};
/// Halvings per axis.
///
/// Twenty resolves a ±100 control to about a five-thousandth of a unit, which
/// is far under the precision any of them is displayed at. There is no reason
/// to stop earlier: each step is two multiplications.
const STEPS: u32 = 20;
/// How many times the two axes are solved in turn.
///
/// One pass suffices for a node whose axes are independent, which is what
/// "trades red against blue" and "trades green against magenta" describe. The
/// repeats are for a node whose are not — a green correction that also lifts
/// red would leave the first axis a little out — and they cost nothing worth
/// counting.
const ROUNDS: u32 = 3;
/// Below this a channel carries no ratio worth balancing.
///
/// A sample in the deep shadows, or one taken on a blown highlight where a
/// channel has already clipped to nothing, has no white balance in it: the
/// logarithms below would run away and the picker would slam a slider to its
/// stop. Refusing is the honest answer, and the caller reports that the point
/// was not usable rather than moving the photograph.
const FLOOR: f32 = 1e-4;
/// TRACES: FR-DEV-3
/// Move the graph so that `sample` renders neutral.
///
/// `sample` is linear RGB, as the operation's own gains multiply it — that is,
/// measured with the sampling operation at its defaults. Returns whether the
/// graph was moved: `false` where the chain offers no white point widget, or
/// where the colour has no balance in it to correct.
///
/// **Absolute, not relative.** The values written depend on the colour and not
/// on where the sliders happened to be, so sampling the same wall twice lands
/// in the same place — and sampling a second, better neutral corrects the
/// photograph rather than correcting the correction.
pub fn neutralise(graph: &mut EditGraph, sample: [f32; 3]) -> bool {
if sample.iter().any(|c| !c.is_finite() || *c < FLOOR) {
return false;
}
let Some(axes) = sampler(graph) else {
return false;
};
// The uniform contract, checked before anything moves. A node that
// declares the widget and publishes something other than three gains
// cannot be driven from a pixel, and half-solving it would leave the
// photograph somewhere nobody asked for.
if gains(graph, axes.op).is_none() {
return false;
}
for _ in 0..ROUNDS {
solve(
graph,
axes.op,
axes.warm,
axes.warm_axis,
sample,
warm_error,
);
solve(
graph,
axes.op,
axes.green,
axes.green_axis,
sample,
green_error,
);
}
true
}
/// The operation an eyedropper writes to, and the two axes it moves.
struct Axes {
op: OpId,
/// Red against blue, with its declared travel and display precision.
warm: ParamId,
warm_axis: (f32, f32, u8),
/// Green against magenta, on the same terms.
green: ParamId,
green_axis: (f32, f32, u8),
}
/// Find the operation that asked to be driven from a pixel.
///
/// The *first* one, if a chain ever carries two. Two white points in one chain
/// is a chain that has already decided something odd, and picking the first is
/// at least the same answer every time.
fn sampler(graph: &EditGraph) -> Option<Axes> {
graph.capabilities().into_iter().find_map(|cap| {
let presentation = cap.presentation.as_ref()?;
if !presentation.widgets.contains(&WidgetKind::WhitePoint) {
return None;
}
let mut owned = presentation.params.iter().copied();
let warm = owned.next()?;
let green = owned.next()?;
Some(Axes {
op: cap.id,
warm,
warm_axis: axis(&cap, warm)?,
green,
green_axis: axis(&cap, green)?,
})
})
}
/// How far a parameter may be moved and how finely, from its own declaration.
///
/// A non-scalar axis is refused rather than coerced: a bisection over a list
/// of named alternatives is meaningless, and an operation offering one has not
/// declared what this widget kind needs.
fn axis(cap: &OpCapability, param: ParamId) -> Option<(f32, f32, u8)> {
cap.params
.iter()
.find(|p| p.id == param)
.and_then(|p| match &p.kind {
ParamKind::Scalar {
min,
max,
precision,
..
} => Some((*min, *max, *precision)),
_ => None,
})
}
/// Round to the precision the parameter is displayed at.
///
/// **The search has to stop somewhere, and this is the honest place.** Twenty
/// halvings land on something like -63.0117, which the panel would draw as
/// -63 and a photographer could never reproduce by hand — so the two would
/// disagree about what the control says, and a slider nudged one unit either
/// way would silently discard a fraction nobody could see.
///
/// It also fixes the case that matters most: sampling something that is
/// *already* neutral. Unrounded, that lands a ten-thousandth off zero, which
/// is a photograph marked modified and a step on the undo stack for a
/// correction of nothing.
fn snap(value: f32, precision: u8) -> f32 {
let scale = 10f32.powi(i32::from(precision));
(value * scale).round() / scale
}
/// The three linear gains this operation currently applies, in RGB order.
///
/// `None` unless there are exactly three, which is the widget kind's contract
/// stated as a check. See the module note for why it is a contract and not a
/// guess.
fn gains(graph: &EditGraph, op: OpId) -> Option<[f32; 3]> {
let uniforms = graph.uniforms_of(op)?;
match uniforms.as_slice() {
[r, g, b] => Some([r.value, g.value, b.value]),
_ => None,
}
}
/// How far red sits from blue in the corrected sample, in stops.
///
/// Logarithms because the correction is multiplicative and the controls are
/// symmetric: warming by n and cooling by n are exact reciprocals, so an error
/// measured as a ratio is linear in the thing being searched and a difference
/// of products is not.
fn warm_error(gains: [f32; 3], sample: [f32; 3]) -> f32 {
(gains[0] * sample[0]).ln() - (gains[2] * sample[2]).ln()
}
/// How far green sits from the red-blue midpoint, in stops.
///
/// Against the midpoint rather than against red alone, so that the two axes do
/// not fight: with red and blue already balanced the two are the same number,
/// and while they are not, green is being pulled toward where the other axis
/// is heading rather than toward one end of it.
fn green_error(gains: [f32; 3], sample: [f32; 3]) -> f32 {
let r = (gains[0] * sample[0]).ln();
let b = (gains[2] * sample[2]).ln();
(gains[1] * sample[1]).ln() - 0.5 * (r + b)
}
/// Drive one axis until `error` crosses zero, and leave it there.
///
/// The direction is read off the ends rather than assumed: which way "warmer"
/// runs is the node's business, and a picker that had guessed would move the
/// slider the wrong way on the first node that disagreed with it.
fn solve(
graph: &mut EditGraph,
op: OpId,
param: ParamId,
(min, max, precision): (f32, f32, u8),
sample: [f32; 3],
error: fn([f32; 3], [f32; 3]) -> f32,
) {
let at = |graph: &mut EditGraph, value: f32| -> f32 {
graph.set_param(op, param, value);
gains(graph, op).map_or(0.0, |g| error(g, sample))
};
let mut low = min;
let mut high = max;
let low_error = at(graph, low);
let high_error = at(graph, high);
// No crossing inside the declared travel: the correction this colour needs
// is more than the control can give. Taken as far as it goes, which is
// what a photographer would do by hand — refusing would leave a badly cast
// frame uncorrected because it could not be corrected *entirely*.
if low_error.is_sign_positive() == high_error.is_sign_positive() {
let end = if low_error.abs() <= high_error.abs() {
low
} else {
high
};
graph.set_param(op, param, snap(end, precision));
return;
}
for _ in 0..STEPS {
let middle = 0.5 * (low + high);
if at(graph, middle).is_sign_positive() == low_error.is_sign_positive() {
low = middle;
} else {
high = middle;
}
}
graph.set_param(op, param, snap(0.5 * (low + high), precision));
}
#[cfg(test)]
mod tests {
use super::*;
/// The spread of a triple, relative to its middle channel.
fn cast(rendered: [f32; 3]) -> f32 {
let high = rendered.iter().copied().fold(f32::MIN, f32::max);
let low = rendered.iter().copied().fold(f32::MAX, f32::min);
(high - low) / rendered[1].max(f32::EPSILON)
}
/// What the sampled colour becomes once the graph has been moved.
fn corrected(graph: &EditGraph, sample: [f32; 3]) -> [f32; 3] {
let axes = sampler(graph).expect("the default chain declares a white point");
let g = gains(graph, axes.op).expect("three gains");
[g[0] * sample[0], g[1] * sample[1], g[2] * sample[2]]
}
/// TRACES: FR-DEV-3
/// The whole point: a warm grey comes back grey.
#[test]
fn sampling_a_warm_grey_makes_it_grey() {
let mut graph = EditGraph::default_chain();
let sample = [0.62, 0.50, 0.40];
assert!(cast(sample) > 0.3, "the premise: this is a strong cast");
assert!(neutralise(&mut graph, sample));
assert!(
cast(corrected(&graph, sample)) < 0.02,
"the sample should render neutral: {:?}",
corrected(&graph, sample)
);
}
/// TRACES: FR-DEV-3
/// And so does a cold one, which is the other half of the same claim: the
/// search finds its own direction rather than being told one.
#[test]
fn sampling_a_cold_grey_makes_it_grey_too() {
let mut graph = EditGraph::default_chain();
let sample = [0.40, 0.50, 0.62];
assert!(neutralise(&mut graph, sample));
assert!(
cast(corrected(&graph, sample)) < 0.02,
"{:?}",
corrected(&graph, sample)
);
}
/// TRACES: FR-DEV-3
/// Sampling is absolute: the second sample corrects the photograph, not
/// the previous correction.
///
/// The failure this guards is the one every relative picker has — sample a
/// wall, decide a cloud was better, sample the cloud, and land somewhere
/// neither of them describes.
#[test]
fn a_second_sample_replaces_the_first_rather_than_compounding_it() {
let mut graph = EditGraph::default_chain();
assert!(neutralise(&mut graph, [0.62, 0.50, 0.40]));
assert!(neutralise(&mut graph, [0.44, 0.50, 0.56]));
let second = [0.44, 0.50, 0.56];
assert!(
cast(corrected(&graph, second)) < 0.02,
"{:?}",
corrected(&graph, second)
);
}
/// TRACES: FR-DEV-3
/// A sample with nothing in it is refused rather than acted on.
///
/// Black has no white balance. A picker that treated it as one would take
/// the ratio of two numbers that are both noise and slam the controls to
/// their stops, which reads as the feature being broken rather than as the
/// point having been a poor choice.
#[test]
fn a_sample_with_no_balance_in_it_moves_nothing() {
let mut graph = EditGraph::default_chain();
let before = crate::Preset::capture(&graph);
assert!(!neutralise(&mut graph, [0.0, 0.0, 0.0]));
assert!(!neutralise(&mut graph, [f32::NAN, 0.5, 0.5]));
assert_eq!(crate::Preset::capture(&graph), before);
}
/// TRACES: FR-DEV-3a
/// The picker is found through the widget kind, not through a name.
///
/// If this ever fails because the declaration moved, the right fix is in
/// the declaration: nothing here is entitled to know which node it is.
#[test]
fn the_chain_offers_exactly_one_white_point() {
let graph = EditGraph::default_chain();
let found = graph
.capabilities()
.into_iter()
.filter(|c| {
c.presentation
.as_ref()
.is_some_and(|p| p.widgets.contains(&WidgetKind::WhitePoint))
})
.count();
assert_eq!(found, 1, "one operation should ask to be driven by a pixel");
}
}
+352 -10
View File
@@ -377,6 +377,20 @@ pub trait Operation: Send + Sync {
fn presentation(&self) -> Option<Presentation> {
None
}
/// Take whatever this operation needs from a lens profile.
///
/// The counterpart of [`crate::lens::Warp::set_profile`], and it exists on
/// this trait as well because the optical corrections do not all live on
/// the same side of the fetch. Distortion and CA rewrite coordinates and
/// are warps; vignetting applies a gain to the pixel already there and is
/// an ordinary node. Splitting the fan-out by which trait a correction
/// happens to implement would make the caller reason about that, so both
/// traits carry the same door and `EditGraph::set_lens_profile` walks
/// both lists the same way.
///
/// Defaulted: fourteen of the fifteen operations have nothing to take.
fn set_lens_profile(&mut self, _profile: Option<&crate::lens::LensProfile>) {}
}
/// A named WGSL helper function, deduplicated across operations.
@@ -510,6 +524,11 @@ pub fn compose_with_framing(
output,
&MaskStack::new(),
&crate::spot::SpotSet::new(),
// No lens corrections. This entry point exists for callers that have
// an operation chain and nothing else — the codegen tests, and the
// export path before it grew a graph — and a warp is not something a
// caller can hold without one.
&[],
)
}
@@ -531,7 +550,35 @@ pub fn compose_full(
output: ColourSpace,
masks: &MaskStack,
spots: &crate::spot::SpotSet,
warps: &[Box<dyn crate::lens::Warp>],
) -> ComposedShader {
compose_full_revealing(ops, framing, output, masks, spots, warps, None)
}
/// TRACES: FR-DEV-19c
/// [`compose_full`], with one layer's mask drawn over the finished picture.
///
/// Separate from [`compose_full`] rather than an argument on it, and that is
/// the safety property rather than a convenience: the reveal is a thing the
/// screen does, and every other consumer of the pipeline — the exporter, the
/// thumbnail, the neutral probe — calls the function that has no way to ask
/// for it. A flag reachable from the graph would have been one forgotten reset
/// away from a red tint baked into an exported file.
#[allow(clippy::too_many_arguments)]
pub fn compose_full_revealing(
ops: &[Box<dyn Operation>],
framing: &Framing,
output: ColourSpace,
masks: &MaskStack,
spots: &crate::spot::SpotSet,
warps: &[Box<dyn crate::lens::Warp>],
reveal: Option<&crate::mask::Reveal>,
) -> ComposedShader {
// The lens corrections, composed into one coordinate transform. Beside
// `framing` because they are the other half of the same stage: framing
// says which part of the source this output pixel comes from, and a warp
// says where the lens put it once it got there.
let warp = crate::lens::compose_warps(warps);
// Active *point* operations. A neighbourhood operation is filtered out
// here rather than asked for a fragment it cannot write: it reads pixels
// it is not writing, so it belongs to the detail stage that runs after
@@ -636,6 +683,19 @@ pub fn compose_full(
);
uniform_values.extend_from_slice(&framing.uniforms());
// The warps, immediately after framing and before any operation, matching
// where they sit in the shader. Slot order is emission order and nothing
// addresses a slot by number, so this only has to be consistent with
// itself — but keeping it in pipeline order is what makes the generated
// struct readable next to the generated body.
uniform_fields.push_str(&warp.uniform_fields);
uniform_values.extend_from_slice(&warp.uniforms);
for h in &warp.helpers {
if !helpers.iter().any(|existing| existing.name == h.name) {
helpers.push(*h);
}
}
for op in &active {
let id = op.descriptor().id.0;
let prefix = sanitise(id);
@@ -677,7 +737,13 @@ pub fn compose_full(
// from. Their uniforms follow the global ops' in the block for the same
// reason those follow framing's — slot order is emission order, and
// nothing addresses a slot by number.
let layers = crate::mask::compose_layers(masks);
let layers = crate::mask::compose_layers_revealing(masks, reveal);
// TRACES: FR-DEV-19c
// Held apart from the body, because it belongs after the output transform
// rather than among the operations — see `mask::LayerShader::reveal`.
// Empty for every composition nobody is looking at a mask through, which
// is all of them but the screen's.
let reveal_block = layers.reveal.clone();
uniform_fields.push_str(&layers.uniform_fields);
uniform_values.extend_from_slice(&layers.uniform_values);
body.push_str(&layers.body);
@@ -702,17 +768,35 @@ pub fn compose_full(
// The coordinate stage: output pixel -> source position -> colour. Emitted
// ahead of the operation fragments, which receive the sampled `c`.
let prologue = format!(
"{}\n{}",
framing.wgsl_prologue(),
sample_source(framing.needs_interpolation())
);
let sampler_helper = if framing.needs_interpolation() {
BILINEAR_HELPER
// A warp puts an output pixel between source pixels exactly as a free
// angle does, so either one forces the interpolating sampler. Asking
// framing alone — which is what this did before the warps existed — would
// have nearest-neighboured a distortion correction on an unstraightened
// frame, and the aliasing would have looked like a bad profile.
let interpolate = framing.needs_interpolation() || warp.is_active();
// Declared ahead of the warp block, which assigns to them. They enter
// equal to `p` so that a chain mixing a splitting warp with a
// non-splitting one still carries every earlier correction into the red
// and blue paths — a distortion correction must move all three channels,
// and only the CA that follows it may move them apart.
let channel_positions = if warp.splits_channels {
"\n // Per-channel source positions, for the lateral CA correction.\n\
\x20 var p_r = p;\n\
\x20 var p_b = p;\n"
} else {
""
};
let prologue = format!(
"{}{}{}\n{}",
framing.wgsl_prologue(),
channel_positions,
warp.body,
sample_source(interpolate, warp.splits_channels)
);
let sampler_helper = if interpolate { BILINEAR_HELPER } else { "" };
// The tail, and it is the whole of the difference between the two output
// modes. Everything above — the prologue, the fragments, the mask layers,
// the camera matrix — is emitted identically either way, so an operation
@@ -919,7 +1003,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
c = mix(c, neutral, clipped);
}}
{body}
{rendering_tail}{to_output}
{rendering_tail}{to_output}{reveal_block}
{store}
}}
",
@@ -1049,7 +1133,51 @@ fn encode_output(c: vec3<f32>) -> vec3<f32> {{
/// Split out because it is the join between the coordinate stage and the
/// colour stage, and because the choice it makes — an exact integer load, or
/// a filtered sample — is the one thing the free-angle case changes.
pub(crate) fn sample_source(interpolate: bool) -> &'static str {
pub(crate) fn sample_source(interpolate: bool, splits_channels: bool) -> &'static str {
if splits_channels {
// Lateral chromatic aberration is a per-channel radial magnification,
// so red and blue are fetched from positions green is not — which is
// the whole reason a warp is not an `Operation`. By the time a colour
// reaches an operation the three channels have been sampled together
// and the divergence is gone.
//
// Green never moves. It is the reference the other two are scaled
// about, so a correction that is wrong still leaves one channel sharp
// rather than softening all three.
return " // Back to texture coordinates, once per channel.
let uv_src = p / aspect + vec2<f32>(0.5);
let uv_r = p_r / aspect + vec2<f32>(0.5);
let uv_b = p_b / aspect + vec2<f32>(0.5);
// Tested on green alone, not on all three.
//
// The three positions differ by a fraction of a pixel at any correction a
// real lens needs, so testing each would only let the outermost row of the
// frame disagree with itself about whether it exists — which draws a
// coloured fringe along the edge, the exact artefact this is here to
// remove. `sample_bilinear` clamps its own texel indices, so red and blue
// land on the edge pixel rather than out of bounds.
if (any(uv_src < vec2<f32>(0.0)) || any(uv_src >= vec2<f32>(1.0))) {
textureStore(output, vec2<i32>(gid.xy), vec4<f32>(0.0, 0.0, 0.0, 1.0));
return;
}
// TRACES: FR-DEV-3f
// Where this pixel sits on the *source*, in source pixels. Green's
// position, since that is the one that did not move.
let source_px = uv_src * vec2<f32>(src_dims);
let radius = length(p) / (0.5 * length(aspect));
// Three fetches, one channel kept from each. Two thirds of the work is
// discarded, which is why `Warp::splits_channels` exists: with no CA in
// the chain the single-sample path below is emitted instead.
var c = vec3<f32>(
sample_bilinear(uv_r, src_dims).r,
sample_bilinear(uv_src, src_dims).g,
sample_bilinear(uv_b, src_dims).b,
);
";
}
if interpolate {
" // Back to texture coordinates.
let uv_src = p / aspect + vec2<f32>(0.5);
@@ -1074,6 +1202,23 @@ pub(crate) fn sample_source(interpolate: bool) -> &'static str {
let source_px = uv_src * vec2<f32>(src_dims);
// A free angle puts output pixels between source pixels. Nearest-neighbour
// here is what makes a straightened horizon stair-step, so interpolate.
// Distance from the optical axis, normalised so the corner is exactly 1.
//
// Published beside `source_px` and for the same reason: a fragment is
// handed a colour with no way back to a coordinate, and the radial
// corrections need one. Derived from `p` after the whole coordinate stage,
// so it measures the *source* frame — which is what a lens profile is
// calibrated against, and why an off-centre crop still gets the falloff
// its corner actually had rather than one centred on the crop.
//
// **The division is the part that is easy to leave out.** `p` spans
// `+/-0.5 * aspect`, so at the corner its length is `0.5 * length(aspect)`
// -- about 0.901 on a 3:2 frame, not 1. Lensfun's polynomials are fitted
// against a corner radius of 1, so passing `length(p)` straight in
// evaluates every one of them short of where it was measured, and by an
// amount that changes with the aspect ratio. It reads as a correction that
// is simply too weak, which is indistinguishable from a bad profile.
let radius = length(p) / (0.5 * length(aspect));
var c = sample_bilinear(uv_src, src_dims);
"
} else {
@@ -1101,6 +1246,23 @@ pub(crate) fn sample_source(interpolate: bool) -> &'static str {
// put, and its *amount* is handled separately by how much film a pixel
// covers -- see `dr_film::Grain`.
let source_px = uv_src * vec2<f32>(src_dims);
// Distance from the optical axis, normalised so the corner is exactly 1.
//
// Published beside `source_px` and for the same reason: a fragment is
// handed a colour with no way back to a coordinate, and the radial
// corrections need one. Derived from `p` after the whole coordinate stage,
// so it measures the *source* frame — which is what a lens profile is
// calibrated against, and why an off-centre crop still gets the falloff
// its corner actually had rather than one centred on the crop.
//
// **The division is the part that is easy to leave out.** `p` spans
// `+/-0.5 * aspect`, so at the corner its length is `0.5 * length(aspect)`
// -- about 0.901 on a 3:2 frame, not 1. Lensfun's polynomials are fitted
// against a corner radius of 1, so passing `length(p)` straight in
// evaluates every one of them short of where it was measured, and by an
// amount that changes with the aspect ratio. It reads as a correction that
// is simply too weak, which is indistinguishable from a bad profile.
let radius = length(p) / (0.5 * length(aspect));
var c = textureLoad(source, coord, 0).rgb;
"
}
@@ -1393,6 +1555,186 @@ mod tests {
}
}
use crate::lens::Warp as _;
/// A neutral warp list must leave the shader exactly as it was.
///
/// The property the whole `is_active` filter exists for: an unedited
/// photograph keeps the integer `textureLoad` path, and pays nothing —
/// not a bilinear fetch, not a uniform slot, not a recompile — for
/// corrections nobody has asked for.
#[test]
fn warps_at_neutral_change_nothing_at_all() {
let ops = crate::ops::chain();
let framing = Framing::new();
let without = compose_full(
&ops,
&framing,
ColourSpace::Srgb,
&MaskStack::new(),
&crate::spot::SpotSet::new(),
&[],
);
let with_neutral = compose_full(
&ops,
&framing,
ColourSpace::Srgb,
&MaskStack::new(),
&crate::spot::SpotSet::new(),
&[
Box::new(crate::ops::Distortion::new()) as Box<dyn crate::lens::Warp>,
Box::new(crate::ops::Aberration::new()),
],
);
assert_eq!(without.source, with_neutral.source);
assert_eq!(without.structure_hash, with_neutral.structure_hash);
assert_eq!(without.uniforms, with_neutral.uniforms);
assert!(
!without.source.contains("sample_bilinear"),
"an unwarped, unstraightened frame must keep the integer load path"
);
}
/// TRACES: FR-DEV-19c
/// **The property that keeps a reveal off an exported file.**
///
/// `compose_full` is the entry point the exporter, the thumbnail and the
/// neutral probe all use, and it has no argument that could ask for a
/// mask overlay. Only `compose_full_revealing` does, and only the canvas
/// calls it. Asserted rather than left to the type signature because the
/// tempting simplification — a flag on the graph — would type-check, be
/// shorter, and bake a red tint into every file the photographer sold.
#[test]
fn an_ordinary_composition_cannot_draw_a_mask_over_the_picture() {
use crate::mask::{MaskLayer, MaskSource, Reveal, RevealStyle};
let mut stack = MaskStack::new();
let mut layer = MaskLayer::new("m1", MaskSource::brush());
layer.set_param("exposure", crate::descriptor::ParamId("exposure"), 1.0);
stack.push(layer);
let plain = compose_full(
&crate::ops::chain(),
&Framing::new(),
ColourSpace::Srgb,
&stack,
&crate::spot::SpotSet::new(),
&[],
);
assert!(
!plain.source.contains("==== showing mask"),
"an export must never carry the overlay"
);
let shown = compose_full_revealing(
&crate::ops::chain(),
&Framing::new(),
ColourSpace::Srgb,
&stack,
&crate::spot::SpotSet::new(),
&[],
Some(&Reveal::one("m1", RevealStyle::Tint)),
);
assert!(shown.source.contains("==== showing mask"));
assert_ne!(
plain.structure_hash, shown.structure_hash,
"two different shaders must not share a pipeline cache entry"
);
}
/// Distortion alone samples once; chromatic aberration samples three times.
///
/// `splits_channels` is the whole reason for this test. Lateral CA fetches
/// red and blue from positions green is not, and paying that everywhere
/// would triple the texture bandwidth of the common case — a distortion
/// correction with no CA, which is most lens profiles.
#[test]
fn only_chromatic_aberration_splits_the_channels() {
let compose_with = |warps: Vec<Box<dyn crate::lens::Warp>>| {
compose_full(
&crate::ops::chain(),
&Framing::new(),
ColourSpace::Srgb,
&MaskStack::new(),
&crate::spot::SpotSet::new(),
&warps,
)
.source
};
let mut distortion = crate::ops::Distortion::new();
distortion.set_param(crate::ops::distortion::AMOUNT, 40.0);
let only_distortion = compose_with(vec![Box::new(distortion)]);
assert!(
only_distortion.contains("---- warp: distortion ----"),
"an active distortion must reach the shader"
);
assert!(
only_distortion.contains("sample_bilinear"),
"a warp puts output pixels between source pixels, so it forces \
the interpolating sampler even on an unstraightened frame"
);
assert!(
!only_distortion.contains("var p_r"),
"distortion moves all three channels together and must not pay \
for the per-channel path"
);
let mut ca = crate::ops::Aberration::new();
ca.set_param(crate::ops::aberration::RED, 25.0);
let with_ca = compose_with(vec![Box::new(ca)]);
assert!(
with_ca.contains("var p_r"),
"CA needs per-channel positions"
);
assert_eq!(
with_ca.matches("sample_bilinear(").count(),
// Three fetches in the body, plus the helper's own definition.
4,
"CA must fetch each channel from its own position"
);
}
/// An active warp is a different shader and must not reuse the cached one.
///
/// Covered by `hash_source` rather than by anything warp-specific — the
/// body is written into the source — but asserted because the alternative
/// failure is silent: the correction would simply never appear, exactly as
/// a zoom did before `Framing::structure_key` gained its last bit.
#[test]
fn arming_a_warp_recompiles_but_moving_its_slider_does_not() {
let compose_at = |amount: f32| {
let mut d = crate::ops::Distortion::new();
d.set_param(crate::ops::distortion::AMOUNT, amount);
compose_full(
&crate::ops::chain(),
&Framing::new(),
ColourSpace::Srgb,
&MaskStack::new(),
&crate::spot::SpotSet::new(),
&[Box::new(d) as Box<dyn crate::lens::Warp>],
)
};
let neutral = compose_at(0.0);
let armed = compose_at(40.0);
let further = compose_at(70.0);
assert_ne!(
neutral.structure_hash, armed.structure_hash,
"arming a warp adds a block to the shader and must recompile"
);
assert_eq!(
armed.structure_hash, further.structure_hash,
"its magnitude is a uniform, so a drag must not recompile"
);
assert_ne!(armed.uniforms, further.uniforms);
}
#[test]
fn structure_hash_ignores_values_but_tracks_the_op_set() {
// The property the shader cache depends on: moving a slider must not
+11
View File
@@ -141,6 +141,17 @@ impl Warp for Aberration {
r != 1.0 || b != 1.0
}
fn set_profile(&mut self, profile: Option<&crate::lens::LensProfile>) {
// The inherent `set_profile` taking just this correction's own
// coefficients, not this trait method: an inherent method wins over a
// trait one of the same name, so this is a narrowing and not a loop.
self.set_profile(
profile
.and_then(|p| p.tca)
.map(|t| (t.red_scale, t.blue_scale)),
);
}
fn wgsl_body(&self) -> String {
// `p_r` and `p_b` enter equal to `p` and are carried out of the block.
// Green is deliberately absent: it is the reference and never moves.
@@ -359,6 +359,7 @@ impl DetailStage for CaptureSharpen {
["horizontal", "vertical"]
.into_iter()
.map(|label| DetailPass {
output_scale: 1,
label,
radius: extent,
// A convolution, not a list: nothing to bind at binding 3.
@@ -412,6 +413,7 @@ impl DetailStage for CaptureSharpen {
/// texture, which is a rounding error against the dispatches around it.
fn nothing_to_sharpen() -> DetailPass {
DetailPass {
output_scale: 1,
label: "unresolved",
// Reads only the pixel it writes, so a tile needs no halo at all.
radius: 0,
+742
View File
@@ -0,0 +1,742 @@
//! TRACES: FR-DEV-18 | FR-DSP-1
//! Dehaze — measuring the veil distance puts over a subject, and dividing it
//! back out.
//!
//! Haze is not a tone problem wearing a spatial disguise, which is why none of
//! the controls already in the chain can remove it. Atmospheric scattering
//! composites an *airlight* over the scene in proportion to how far away each
//! part of it is:
//!
//! ```text
//! I = J·t + A·(1 − t), t = e^(−β·d)
//! ```
//!
//! `J` is the scene, `A` the airlight, `t` the transmission and `d` the
//! distance. The two things it does — lifting the black point towards `A` and
//! compressing contrast towards it — are both *per-pixel*, because `d` is. A
//! black point that clears the mountains crushes the foreground; a contrast
//! curve that clears the mountains does the same. So the operation has to
//! estimate `t` at every pixel, and estimating it is the whole of the work.
//!
//! # How the transmission is estimated
//!
//! The dark-channel prior: in a haze-free patch of an ordinary photograph, at
//! least one of the three channels is close to zero *somewhere* — a shadow, a
//! dark surface, a saturated colour whose complementary channels are empty.
//! Wherever that local minimum sits well above zero instead, something
//! additive has lifted it, and the amount it has been lifted by is `A·(1 − t)`.
//! So the veil is a local minimum over the channels and over a patch, and the
//! transmission follows from it.
//!
//! The prior fails on a genuinely bright, genuinely haze-free subject — snow,
//! a white wall filling the patch — where it reads the brightness as veil and
//! this operation darkens it. That is the known failure of the prior and it is
//! why the amount is a slider a photographer sets by eye rather than an
//! automatic correction applied on open.
//!
//! # Why the airlight is taken as neutral, and as one
//!
//! The literature estimates `A` from the brightest few pixels of the dark
//! channel — a *whole-frame* reduction, which this stage cannot perform. The
//! detail chain hands each pass the pass before it at a fixed fraction of the
//! render size; there is no reduction to a single value in it, and adding one
//! would be a second chain of `log(n)` dispatches whose shape changes with
//! every viewport.
//!
//! It is not needed. Airlight is the illuminant scattered towards the camera,
//! and white balance is the first node in the chain — by the time the detail
//! stage runs, the illuminant has already been driven to neutral, so `A` is
//! grey and only its *magnitude* is unknown. Taking that magnitude as one — as
//! bright as diffuse white — makes the estimated veil a fixed multiple of the
//! true one, and a fixed multiple of the veil is exactly what the amount
//! slider already scales. The unknown lands on a control the photographer is
//! setting by eye anyway, rather than on a reduction the architecture would
//! have to grow to compute.
//!
//! # In linear light, deliberately unlike clarity
//!
//! [`crate::ops::local_contrast`] works in stops, because a perceptual local
//! contrast control has to mean the same thing in a highlight and in a shadow.
//! This operation is the opposite case: it inverts a physical model stated in
//! linear radiance, where the veil is an *additive* term. Taking logarithms
//! first would turn the subtraction into something that is not the inverse of
//! anything, and the correction would stop being a correction. The intermediate
//! this stage reads is linear and scene-referred, so the model applies to it as
//! written.
//!
//! # The radius is a fraction of the frame
//!
//! [`RenderScale`] names two units and choosing the wrong one is the mistake a
//! neighbourhood operation makes silently. The patch is compositional, so it
//! takes [`RenderScale::frame_fraction`] — the unit clarity, texture and a mask
//! feather already use — and never [`RenderScale::source_pixels`].
//!
//! The test is whose property the length is. Capture sharpening's radius
//! belongs to the *sensor*: it stands for the spread of a point across
//! photosites and does not change when the frame is cropped. This one belongs
//! to the *picture*: the patch has to be large enough to contain something
//! dark and small enough that the veil it measures is still local, and both of
//! those are statements about how much of the composition it covers. Crop into
//! a quarter of the frame and the depth structure now fills it, so the patch
//! that measures it really has grown — which `frame_fraction` gives, because
//! [`crate::EditGraph::render_scale`] folds the crop in before this code runs.
//!
//! Stated in raw pixels it would be a different photograph on screen and in
//! the file: the develop view renders at whatever the viewport needs
//! (FR-DSP-1), so a patch tuned at one-third scale would be three times too
//! narrow relative to the picture in the export — and the export is the only
//! render anybody keeps.
//!
//! Unlike texture, the pass is **not** dropped when the patch rounds small.
//! Texture's absence on a thumbnail is the honest answer, because a two-pixel
//! surface structure is not present in a 300-pixel rendering of the frame at
//! all. Dehaze changes the overall tone and colour of the picture, and a
//! thumbnail disagreeing with the develop view about *that* reads as a bug
//! rather than as a scale. So the patch is floored at one pixel instead.
//!
//! # What it costs, and the identity that makes it affordable
//!
//! A minimum over a patch is separable, as a Gaussian is: minimum along x,
//! then along y. That alone is not enough. The patch is 1% of the shorter edge
//! — 61 taps across at 4K — and two passes of 61 taps is the arithmetic that
//! measured 34 ms for clarity and became `docs/technical-debt.md` TD-4.
//!
//! A minimum has a property a Gaussian does not: **erosions compose by adding
//! their structuring elements**. The minimum over a contiguous run of `d`
//! pixels, followed by the minimum over `k` pixels spaced `d` apart, is the
//! minimum over the whole `k·d`-wide window, because `{0…d−1} ⊕ {0, d, …} =
//! {0…kd−1}`. With `d ≈ √W` the window costs `d + k ≈ 2√W` taps instead of
//! `W` — 16 rather than 61 at 4K — and it is the **same filter**, not an
//! approximation of one.
//!
//! That distinction is the whole reason this is allowed here while the strided
//! kernel `local_contrast` refuses is not. A stride samples an image that is
//! not band-limited and aliases: high-frequency content folds down into the
//! base, the base is subtracted, and the aliasing arrives as low-frequency
//! mottling across smooth gradients. This decomposition samples nothing — it
//! evaluates the exact minimum over every pixel of the window, in two steps.
//!
//! It is also why this operation does not use the reduced chain that TD-4 gave
//! clarity. The runner holds one reduced buffer, so every scaled pass in a
//! chain must declare the same `output_scale`; clarity's steps down with the
//! viewport, so a second operation choosing its own would disagree with it at
//! some window sizes and not others. Four cheap full-resolution passes cost
//! less than that coupling, and the decomposition is what makes them cheap.
//!
//! # The artefact this does not fix
//!
//! An erosion by a wide element carries a dark object's value out to the
//! patch radius around it, so the transmission map says "no haze here" in a
//! band around every dark foreground shape and the correction falls off
//! inside it. That band is the classic dark-channel halo, and the published
//! answer to it is a guided filter or a matting Laplacian, which refines the
//! transmission against the picture's own edges.
//!
//! Neither is done. A guided filter is a second chain carrying two more
//! moments per pixel, and this stage hands each pass exactly one scalar lane
//! (see [`DetailPass::wgsl`]); its regularisation parameter is a number no
//! requirement supplies and would be a guess dressed up as a constant. The
//! erosion of a continuous image is continuous, so what is left is a gradient
//! rather than an edge — an under-correction near dark objects, not a ring
//! around them.
use std::sync::{Arc, LazyLock};
use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
use crate::detail::{DetailPass, DetailStage, RenderScale};
use crate::operation::{Affects, Helper, Operation, Uniform};
pub const ID: OpId = OpId("dehaze");
/// The one parameter, so the sidecar key reads `dehaze.amount`.
pub const AMOUNT: ParamId = ParamId("amount");
/// The patch the veil is measured over, as a fraction of the frame's shorter
/// edge — a **half**-width, so the window is twice this plus one.
///
/// 1% — 30 px either side on a 4000 x 3000 frame. The two ends of the range
/// are set by opposite failures of the prior. Narrower, and an ordinary patch
/// of sky or skin contains nothing dark, so the minimum reads brightness as
/// veil and the operation darkens the subject. Wider, and the minimum stops
/// being local: it starts reporting the darkest thing in a large region of the
/// frame, so a single shadow suppresses the correction across a quarter of the
/// picture.
///
/// It is within a factor of two of the 15 x 15 patch the dark-channel
/// literature uses on the ~600 px images it is demonstrated at, which is the
/// same fraction of a frame stated the other way round.
const PATCH: f32 = 0.01;
/// The fraction of the estimated veil that full slider travel removes.
///
/// Not one, and this is a decision about the photograph rather than a safety
/// margin. Aerial perspective is how a picture says "far away"; removing all
/// of it flattens a landscape into a cut-out, which is the look that makes
/// heavy dehaze recognisable as an effect. Keeping a twentieth of the veil
/// leaves distance reading as distance at the top of the slider.
const MAX_OMEGA: f32 = 0.95;
/// The smallest transmission the recovery will divide by.
///
/// Where the veil is nearly total there is nothing left to recover — the
/// signal that survived is a few percent of the airlight, and dividing it back
/// up amplifies whatever noise came with it by the same factor. A tenth is the
/// floor the dark-channel literature uses and it means the same thing here: at
/// worst this operation multiplies by ten, and a region that hits the floor
/// keeps a trace of haze rather than becoming a picture of its own noise.
const MIN_TRANSMISSION: f32 = 0.1;
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
Arc::new(OpDescriptor {
id: ID,
label: LocalizedKey("op.dehaze"),
params: vec![ParamDescriptor::amount("amount", "param.dehaze.amount")],
attributes: vec![Attribute::Detail],
})
});
/// The darkest channel at a pixel, floored at zero.
///
/// Declared here rather than in `_helpers.yaml` because it is not a shared
/// idea: it is the dark-channel prior's own statistic and means nothing
/// outside this file.
const DARK_CHANNEL: Helper = Helper {
name: "dark_channel",
source: "\
// The smallest of the three channels — the dark-channel prior's statistic,
// before the minimum over a patch is taken.
//
// Floored at zero because the intermediate is unclipped and scene-referred, so
// an out-of-gamut colour arrives with a negative channel. A negative veil
// would come back through the recovery as extra contrast in exactly the
// pixels the working space could not represent, which is a bright fringe
// arriving from a control the photographer reads as haze removal.
fn dark_channel(c: vec3<f32>) -> f32 {
return max(min(min(c.r, c.g), c.b), 0.0);
}",
};
static HELPERS: &[Helper] = &[DARK_CHANNEL];
/// TRACES: FR-DEV-18
/// Dehaze: estimate the atmospheric veil from the picture and divide it out.
///
/// `Default` is derived rather than written out: the neutral of this operation
/// is an amount of zero and nothing else, so there is no second fact for a
/// hand-written impl to state. Capture sharpening needs one because its radius
/// has no neutral value — a blur of zero width is not the identity, it is a
/// kernel that does not exist — and the patch here is a constant rather than a
/// parameter, so that problem does not arise.
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct Dehaze {
/// −100…100, exactly as the slider reports it. Negative *adds* haze — see
/// [`Self::omega`].
amount: f32,
}
impl Dehaze {
pub fn new() -> Self {
Self::default()
}
/// Start from a slider position, for tests and presets.
pub fn with_amount(amount: f32) -> Self {
Self { amount }
}
/// The patch's half-width at this render, in **render pixels**.
///
/// The one unit conversion this operation performs, and a method rather
/// than a line inside [`Self::passes`] so that a test can state what it
/// expects without repeating the rounding rule — a test that recomputed it
/// would agree with a bug in it.
///
/// Floored at one pixel rather than allowed to reach zero: see the module
/// documentation for why this operation does not drop its pass on a
/// thumbnail the way texture does.
pub fn patch(&self, scale: RenderScale) -> u32 {
scale.frame_fraction(PATCH).round().max(1.0) as u32
}
/// How much of the estimated veil this setting removes.
///
/// Negative at a negative amount, and the same formula then *adds* haze:
/// the recovery becomes a composite of airlight over the picture in
/// proportion to the veil already measured. So negative dehaze deepens the
/// aerial perspective a scene already has rather than fogging it evenly,
/// which is what a photographer asking for atmosphere means — and a scene
/// with no haze in it stays clear, because there is no veil to scale.
fn omega(&self) -> f32 {
self.amount / 100.0 * MAX_OMEGA
}
}
/// A minimum filter of half-width `radius`, split into two exact stages.
///
/// See the module documentation: eroding by a contiguous run and then by a set
/// of points spaced one run apart erodes by the sum of the two, which is the
/// whole window. This is the arithmetic of that split, in one place, because
/// both axes need it and a second copy is a second chance to get the centring
/// wrong.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Split {
/// Length of the contiguous run the first pass takes the minimum over.
pub run: u32,
/// How many runs the second pass chains together, spaced `run` apart.
pub span: u32,
/// What the second pass subtracts from its offsets to centre the window.
///
/// The composite covers `run * span` pixels, which is at least the window
/// asked for and can be one or two more; the surplus falls on the far side
/// rather than being trimmed, because trimming it would need a third pass
/// and a patch a pixel wider on one side is not a visible difference in a
/// field this smooth.
pub shift: i32,
}
impl Split {
/// Split a window of half-width `radius`.
///
/// `run` is the square root of the window rather than any other divisor
/// because `d + ceil(W/d)` is smallest there — the two stages cost the
/// same, which is what minimises their sum for a fixed product.
pub fn of(radius: u32) -> Self {
let width = 2 * radius + 1;
let run = (width as f32).sqrt().ceil().max(1.0) as u32;
let span = width.div_ceil(run);
Self {
run,
span,
shift: ((run * span - 1) / 2) as i32,
}
}
/// The furthest the second pass reads, in pixels.
///
/// Stated rather than assumed symmetric: the composite window is centred
/// to within a pixel and not exactly, so the two directions can differ by
/// one. An understated radius is a seam at every tile boundary (ARCH
/// §5.3), which is the kind of artefact that looks like a driver bug.
pub fn reach(&self) -> u32 {
let far = (self.span.saturating_sub(1) * self.run) as i32 - self.shift;
self.shift.max(far).max(0) as u32
}
}
impl Operation for Dehaze {
fn descriptor(&self) -> Arc<OpDescriptor> {
Arc::clone(&DESCRIPTOR)
}
fn set_param(&mut self, _id: ParamId, value: f32) {
self.amount = value;
}
fn param(&self, _id: ParamId) -> f32 {
self.amount
}
fn is_active(&self) -> bool {
self.amount != 0.0
}
/// Never called: a neighbourhood operation contributes no fused fragment,
/// and `compose_full` filters it out before asking.
fn wgsl_body(&self) -> String {
String::new()
}
fn uniforms(&self) -> Vec<Uniform> {
Vec::new()
}
fn affects(&self) -> Affects {
Affects::Detail
}
fn detail(&self) -> Option<&dyn DetailStage> {
Some(self)
}
fn helpers(&self) -> &'static [Helper] {
HELPERS
}
}
impl DetailStage for Dehaze {
fn passes(&self, scale: RenderScale) -> Vec<DetailPass> {
let split = Split::of(self.patch(scale));
// The run stage's uniforms are the same on both axes, and so are the
// span stage's. Only the offset expression differs, which is what
// `erode_run` and `erode_span` take as an argument — each filter
// written once, so the two axes cannot drift into being different
// filters.
let run = vec![Uniform {
name: "run",
value: split.run as f32,
}];
let span = vec![
Uniform {
name: "span",
value: split.span as f32,
},
Uniform {
name: "stride",
value: split.run as f32,
},
Uniform {
name: "shift",
value: split.shift as f32,
},
];
vec![
DetailPass {
output_scale: 1,
label: "veil-run-x",
// The run starts at this pixel and walks forward, so it reads
// `run - 1` beyond itself and nothing behind.
radius: split.run.saturating_sub(1),
storage: Vec::new(),
uniforms: run.clone(),
wgsl: erode_run(Axis::X),
},
DetailPass {
output_scale: 1,
label: "veil-span-x",
radius: split.reach(),
storage: Vec::new(),
uniforms: span.clone(),
wgsl: erode_span(Axis::X),
},
DetailPass {
output_scale: 1,
label: "veil-run-y",
radius: split.run.saturating_sub(1),
storage: Vec::new(),
uniforms: run,
wgsl: erode_run(Axis::Y),
},
DetailPass {
output_scale: 1,
label: "veil-span-y",
radius: split.reach(),
storage: Vec::new(),
uniforms: span,
wgsl: erode_span(Axis::Y),
},
DetailPass {
output_scale: 1,
label: "clear",
// Reads only the pixel it writes: the veil arrived in the
// scratch lane four passes ago.
radius: 0,
storage: Vec::new(),
uniforms: vec![
Uniform {
name: "omega",
value: self.omega(),
},
Uniform {
name: "min_transmission",
value: MIN_TRANSMISSION,
},
],
wgsl: CLEAR.to_string(),
},
]
}
}
/// Which way a separable half runs.
#[derive(Clone, Copy)]
enum Axis {
X,
Y,
}
impl Axis {
/// The offset expression for a scalar step `i` along this axis.
fn offset(&self, step: &str) -> String {
match self {
Axis::X => format!("vec2<i32>({step}, 0)"),
Axis::Y => format!("vec2<i32>(0, {step})"),
}
}
}
/// The first stage: the minimum over a contiguous run.
///
/// Along x it reads the colour and reduces it to the dark channel; along y the
/// dark channel is already in the scratch lane, so it reads that instead.
/// Doing the channel minimum again on the second axis would be reducing a
/// scalar and would quietly discard the x erosion.
///
/// Neither stage touches `c`. The recovery needs the original colour *and* the
/// veil in the same place at the same time, and the ping-pong hands each pass
/// only what the pass before it wrote — so the veil travels in `aux` and the
/// colour rides through untouched. See [`DetailPass::wgsl`].
fn erode_run(axis: Axis) -> String {
let source = match axis {
Axis::X => "dark_channel(tap(coord, OFFSET))",
Axis::Y => "tap_aux(coord, OFFSET)",
};
let first = source.replace("OFFSET", &axis.offset("0"));
let rest = source.replace("OFFSET", &axis.offset("i"));
format!(
"\
// Half of the erosion's first stage: the minimum over `run` contiguous pixels,
// walking forward from this one. The second stage chains these together, and
// the two structuring elements add up to the whole patch — which is why this
// one is not centred and does not need to be.
let n = i32(run);
var veil = {first};
for (var i = 1; i < n; i = i + 1) {{
veil = min(veil, {rest});
}}
aux = veil;"
)
}
/// The second stage: the minimum over `span` points spaced `stride` apart.
///
/// Each of those points already holds the minimum over the run that starts
/// there, so this reads the whole window while touching `span` pixels of it.
/// `shift` is what centres the composite on the pixel being written; without
/// it the veil would be measured from a patch lying entirely to one side, and
/// the correction would appear to lag the picture by half a patch.
fn erode_span(axis: Axis) -> String {
let offset = axis.offset("j * s - o");
let first = axis.offset("-o");
format!(
"\
// The erosion's second stage. `span` taps, spaced a whole run apart, each
// standing for the run that begins at it — so the minimum over the patch costs
// `run + span` taps rather than the `run * span` pixels it covers, and it is
// the exact minimum over all of them rather than a sample of them.
let n = i32(span);
let s = i32(stride);
let o = i32(shift);
var veil = tap_aux(coord, {first});
for (var j = 1; j < n; j = j + 1) {{
veil = min(veil, tap_aux(coord, {offset}));
}}
aux = veil;"
)
}
/// The recovery: invert the scattering model with the transmission the erosion
/// implies.
const CLEAR: &str = "\
// The veil the four erosion passes measured: the smallest channel anywhere in
// the patch around this pixel, which the dark-channel prior reads as the
// airlight that has been composited over the scene here.
//
// Capped at one because the airlight is taken as diffuse white (see the module
// documentation) and the intermediate is unclipped: a specular highlight
// arrives at three, and three units of `veil` would drive the transmission
// negative and turn the recovery inside out. A value above one is a highlight,
// not more haze. The lower bound is already guaranteed by `dark_channel`.
let veil = min(aux, 1.0);
// `omega * veil` is the part of the veil this setting removes — negative at a
// negative amount, where the same expression composites airlight back on and
// deepens the aerial perspective instead.
let lifted = omega * veil;
// The transmission implied by that veil, floored. Where the veil is nearly
// total the surviving signal is a few percent of the airlight, and dividing it
// back up amplifies its noise by the same factor; the floor is what makes the
// worst case a trace of remaining haze rather than a picture of the noise.
// Adding haze cannot reach it — `lifted` is negative there, so the
// transmission is above one and the max never bites.
let t = max(1.0 - lifted, min_transmission);
// The scattering model, inverted: I = J·t + A·(1 − t) with A taken as one, so
// J = (I − A·(1 − t)) / t. Applied per channel rather than as a gain on
// luminance, and that is the difference from every other control in this
// stage: the veil is grey, so subtracting it *is* a chromaticity change, and
// it is the right one — a haze-veiled distance is desaturated because the
// airlight diluted it, and removing the airlight is what gives the colour
// back.
c = (c - lifted) / t;";
#[cfg(test)]
mod tests {
use super::*;
use crate::detail::compose_detail;
use dr_types::ColourSpace;
fn ops(amount: f32) -> Vec<Box<dyn Operation>> {
vec![Box::new(Dehaze::with_amount(amount))]
}
fn composed(amount: f32, scale: RenderScale) -> crate::ComposedDetail {
compose_detail(&ops(amount), scale, ColourSpace::Srgb)
}
#[test]
fn dehaze_starts_neutral_and_costs_nothing() {
// The rule the whole pipeline rests on. An unedited photograph must not
// pay for a slider nobody has touched — and this one is five dispatches
// when it is on, so "nothing" here is a worthwhile amount of nothing.
assert!(!Dehaze::new().is_active());
assert!(composed(0.0, RenderScale::full((2000, 1500))).is_empty());
}
#[test]
fn the_patch_is_a_fraction_of_the_frame_and_not_a_count_of_source_pixels() {
// TRACES: FR-DSP-1 — the decision the units of this file rest on.
//
// The patch has to contain something dark and has to stay local, and
// both are statements about how much of the *composition* it covers. So
// it must be the same proportion of the picture on a proxy as in the
// export, which `frame_fraction` gives and `source_pixels` would not:
// at one-third scale a source-pixel patch would be three times too
// narrow relative to the frame, and the export — the only render
// anybody keeps — would be the one that looked nothing like what was
// tuned.
//
// frame_fraction takes the *shorter* edge, and PATCH is 0.01:
// 300 → 0.01 × 300 = 3
// 3000 → 0.01 × 3000 = 30
// 4500 → 0.01 × 4500 = 45
for (w, h, expected) in [(400u32, 300u32, 3u32), (4000, 3000, 30), (6000, 4500, 45)] {
let patch = Dehaze::with_amount(50.0).patch(RenderScale::full((w, h)));
assert_eq!(patch, expected, "{w}x{h}");
assert!(
(patch as f32 / w.min(h) as f32 - PATCH).abs() < 0.002,
"the patch drifted from its declared fraction at {w}x{h}"
);
}
}
#[test]
fn a_thumbnail_still_gets_the_correction() {
// Deliberately unlike texture, which contributes no pass once its
// kernel rounds to nothing. Texture's absence at that size is honest —
// a two-pixel surface structure is not in the picture. Dehaze changes
// the overall tone and colour, so a thumbnail that disagreed with the
// develop view about it would read as a bug.
let tiny = RenderScale::full((48, 32));
assert_eq!(Dehaze::with_amount(50.0).patch(tiny), 1);
assert_eq!(composed(50.0, tiny).len(), 5);
}
#[test]
fn the_split_erosion_covers_the_whole_patch_and_costs_its_square_root() {
// The identity the affordability of this operation rests on: eroding by
// a run of `d` and then by `k` points spaced `d` apart erodes by the
// whole `k·d` window, because structuring elements add. If the product
// ever falls short the patch is silently narrower than the one this
// file documents, and nothing else would say so.
//
// 30 px either side → a window of 61
// run = ceil(sqrt(61)) = 8
// span = ceil(61 / 8) = 8 → covers 64 >= 61
let split = Split::of(30);
assert_eq!(split.run, 8);
assert_eq!(split.span, 8);
assert!(split.run * split.span >= 61, "the window is not covered");
assert!(
split.run + split.span < 61,
"the split costs more taps than the window it replaces"
);
// Centred to within the pixel the odd surplus leaves over: the window
// covers [-31, 32] around the pixel being written.
assert_eq!(split.shift, 31);
assert_eq!(split.reach(), 31);
}
#[test]
fn a_patch_of_one_pixel_still_splits_into_something_that_runs() {
// The degenerate end, which a thumbnail reaches. A window of three
// splits into two runs of two, covering four — one pixel more than
// asked for, on the far side, which is the surplus `Split::shift`
// documents rather than an error to correct with a third pass.
let split = Split::of(1);
assert_eq!(split.run, 2);
assert_eq!(split.span, 2);
assert!(split.run * split.span >= 3, "the window is not covered");
assert_eq!(split.shift, 1);
assert_eq!(split.reach(), 1);
}
#[test]
fn the_chain_is_four_erosions_and_a_recovery() {
// The shape of the operation, asserted where it is cheap to assert.
// The erosions leave the colour alone and hand the veil forward in the
// scratch lane; only the last pass touches `c`, which is what makes an
// unsharp-mask-shaped operation expressible in a chain that hands each
// pass exactly one texture.
let composed = composed(60.0, RenderScale::full((2000, 1500)));
let labels: Vec<&str> = composed.passes.iter().map(|p| p.label.as_str()).collect();
assert_eq!(
labels,
[
"dehaze/veil-run-x",
"dehaze/veil-span-x",
"dehaze/veil-run-y",
"dehaze/veil-span-y",
"dehaze/clear",
]
);
// Only the last writes the display texture, so the output transform
// happens exactly once (FR-DEV-2).
assert!(composed.passes[..4].iter().all(|p| !p.writes_output));
assert!(composed.passes[4].writes_output);
// Nothing here uses the reduced chain — see the module documentation
// for why a second operation cannot pick its own `output_scale` while
// the runner holds one reduced buffer.
assert!(composed.passes.iter().all(|p| p.output_scale == 1));
// Every pass declares a uniform block the GPU will accept: a struct
// whose size is not a multiple of sixteen bytes is rejected outright by
// the WGSL uniform address space rules.
assert!(composed.passes.iter().all(|p| p.uniforms.len() % 4 == 0));
assert!(composed
.passes
.iter()
.all(|p| p.uniforms.iter().all(|v| v.is_finite())));
}
#[test]
fn adding_haze_is_the_same_expression_with_the_sign_turned_round() {
// Negative dehaze is the forward model rather than a second operation:
// it composites airlight back on in proportion to the veil already
// measured, so it deepens the aerial perspective a scene has instead of
// fogging it evenly — and a scene with no haze in it stays clear.
assert!(Dehaze::with_amount(-100.0).is_active());
assert!(Dehaze::with_amount(-40.0).omega() < 0.0);
let symmetric = Dehaze::with_amount(-40.0).omega() + Dehaze::with_amount(40.0).omega();
assert!(symmetric.abs() < 1e-6, "the two directions disagree");
// And at the top of the range some veil is deliberately left behind, so
// a landscape keeps its distance rather than becoming a cut-out.
assert!(Dehaze::with_amount(100.0).omega() < 1.0);
}
#[test]
fn the_veil_is_measured_from_the_colour_once_and_then_from_the_lane() {
// The one asymmetry between the two axes, and the one that would be
// invisible if it were wrong: x reduces the colour to its dark channel,
// y erodes the scalar x left behind. Taking the channel minimum again
// on the second axis would reduce a scalar and quietly discard the
// whole x erosion — a patch half the width this file documents, with
// nothing to say so.
let composed = composed(60.0, RenderScale::full((2000, 1500)));
assert!(composed.passes[0].source.contains("dark_channel(tap(coord"));
assert!(!composed.passes[2].source.contains("dark_channel(tap(coord"));
// The helper is still emitted for every pass of the operation, and it
// must define the function it is named for or the shader fails to
// compile a long way from here.
assert!(composed
.passes
.iter()
.all(|p| p.source.contains("fn dark_channel(")));
}
}
+7
View File
@@ -136,6 +136,13 @@ impl Warp for Distortion {
c.a != 0.0 || c.b != 0.0 || c.c != 0.0
}
fn set_profile(&mut self, profile: Option<&crate::lens::LensProfile>) {
// The inherent `set_profile` taking just this correction's own
// coefficients, not this trait method: an inherent method wins over a
// trait one of the same name, so this is a narrowing and not a loop.
self.set_profile(profile.and_then(|p| p.distortion));
}
fn wgsl_body(&self) -> String {
// Written against `p`, which is already normalised and centred.
"\
+11 -1
View File
@@ -78,7 +78,17 @@ static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
Arc::new(OpDescriptor {
// Tone and colour both, and not `Effect`: a stock is not something applied
// on top of a photograph, it is what the photograph was made on.
attributes: vec![Attribute::Tone, Attribute::Colour],
// Effect, not tone-and-colour. **This is the descriptor a `rust:` node
// is actually read from** — the `attributes:` line in `ops/*.yaml`
// describes a *declared* node and is inert here, which is how an
// earlier attempt to make this move changed nothing at all.
//
// A stock is `Effect`'s own definition: applied rather than corrected,
// a look and not a fix. Declaring tone and colour put "Kodachrome" in
// the Light group beside exposure and again in Colour beside white
// balance — two places, neither of which is where anyone looks for it.
// That it moves tone and colour is true of every look.
attributes: vec![Attribute::Effect],
id: ID,
label: LocalizedKey("op.film_sim"),
params: vec![
+414 -56
View File
@@ -141,16 +141,35 @@
//! # What this costs
//!
//! Clarity's kernel is large — of the order of a hundred taps per pass at
//! preview resolution — and the two passes are the honest, exact separable
//! Gaussian rather than a sparse approximation of one. A strided kernel would
//! be several times cheaper and is deliberately not taken: undersampling an
//! image that is not band-limited aliases high-frequency content down into the
//! base, the base is then subtracted, and the aliasing arrives in the output as
//! preview resolution — and each half is the honest, exact separable Gaussian
//! rather than a sparse approximation of one. A strided kernel would be
//! several times cheaper and is deliberately not taken: undersampling an image
//! that is not band-limited aliases high-frequency content down into the base,
//! the base is then subtracted, and the aliasing arrives in the output as
//! low-frequency mottling across smooth gradients. Mottled skies are precisely
//! the artefact this control must not have. The right optimisation is a base
//! computed at reduced resolution, which needs a detail stage that can write a
//! smaller target than it reads; that is a change to [`crate::detail`], not to
//! this file.
//! the artefact this control must not have.
//!
//! Run at the render size, that measured **34 ms at 4K** — seven times the
//! entire fused point chain, for one slider — which is `docs/technical-debt.md`
//! TD-4 and is what [`Recipe::base_scale`] now answers. The base is computed on
//! a grid a quarter the size on each axis: a sixteenth of the pixels at a
//! quarter of the radius.
//!
//! **This is not the strided kernel wearing a hat**, and the difference is
//! exactly the paragraph above. A stride samples an image that is not band-
//! limited and aliases; the reduction *band-limits first* — that is what the
//! `reduce` pass is for and why it is a separate dispatch — and only then
//! samples. What is thrown away is content the base could not represent at any
//! resolution, because a Gaussian at σ = 26 px has nothing above one cycle per
//! 26 px in it and the quarter-scale grid carries one cycle per 8 px. So the
//! reduced base is not an approximation of the full-resolution base; it is the
//! same band-limited function, sampled where it is still fully determined.
//!
//! Which is also why [`LocalContrast::reduction`] steps down and why texture
//! never reduces at all. The argument holds only while the reduced grid can
//! still carry the Gaussian, and the moment it cannot, the honest answer is
//! the full-resolution one — which is the cheap case anyway, because the
//! viewport that produced it is small.
use std::marker::PhantomData;
use std::sync::{Arc, LazyLock};
@@ -176,6 +195,14 @@ pub const AMOUNT: ParamId = ParamId("amount");
/// pipeline.
const TRUNCATION: f32 = 2.0;
/// The smallest σ, in reduced pixels, worth running a Gaussian over.
///
/// One pixel, which with [`TRUNCATION`] is a five-tap kernel — the narrowest
/// that still has a shape. Below it the weights collapse towards a single tap
/// and the blur that survives is the reduce pass's box, which is a different
/// filter with a different edge response. See [`LocalContrast::reduction`].
const MIN_REDUCED_SIGMA: f32 = 1.0;
/// Everything that makes one of these two controls the control it is.
///
/// A struct rather than four associated constants so that the differences
@@ -200,6 +227,15 @@ pub struct Recipe {
/// (4) — true for clarity, false for texture, and that asymmetry is
/// deliberate.
midtone_taper: bool,
/// The most this band's base may be shrunk before it is blurred.
///
/// A ceiling, not the answer — [`LocalContrast::reduction`] steps it down
/// on a viewport too small to carry it. `1` refuses the optimisation
/// outright, which is the only correct value for a band whose σ is already
/// a few pixels.
///
/// Must be a power of two: the step-down halves.
base_scale: u32,
}
/// The band of spatial frequencies a control acts on.
@@ -235,6 +271,22 @@ impl Band for Coarse {
threshold: 0.35,
gain: 1.0,
midtone_taper: true,
// A quarter, which is what `docs/technical-debt.md` TD-4 bought back.
//
// σ is 1.2% of the shorter edge — 26 px at 4K — so the base holds no
// spatial frequency anywhere near the quarter-scale Nyquist of one
// cycle per 8 px. Computing it there is not an approximation of the
// full-resolution base; it is the same band-limited function sampled
// where it is still fully determined. What it costs is a sixteenth of
// the pixels at a quarter of the radius, about a sixty-fourth of the
// work, against the 34 ms this control measured at 4K.
//
// Not an eighth. σ/8 is 3.2 px at 4K and under two on a 1080p
// viewport, which is where the reduce pass's own box filter starts
// doing more of the blurring than the Gaussian does — and the halo
// behaviour this operation is careful about is a property of the
// Gaussian.
base_scale: 4,
};
}
@@ -256,6 +308,13 @@ impl Band for Fine {
// equal numbers on the two sliders should land at comparable strength.
gain: 1.25,
midtone_taper: false,
// Never reduced, and this is the reason the scale belongs to the band
// rather than to the stage. Texture's σ is a decade finer — 2.6 px at
// 4K — so a quarter-scale grid would not hold its base at all: the
// reduce pass's 4x4 box is already wider than the Gaussian it would be
// prefiltering, and what came back would be a blur of the wrong width
// rather than a cheaper blur of the right one.
base_scale: 1,
};
}
@@ -385,6 +444,32 @@ impl<B: Band> LocalContrast<B> {
(self.sigma(scale) * TRUNCATION).round().max(0.0) as u32
}
/// TRACES: FR-DSP-3
/// The factor this render's base is computed at — 1 meaning "the render
/// size", as everything did before TD-4.
///
/// [`Recipe::base_scale`] is a ceiling rather than the answer, because a
/// reduced grid still has to hold a Gaussian. At a quarter of a small
/// viewport clarity's σ falls under a pixel, and a kernel of one or two
/// taps is not a Gaussian — it is the reduce pass's own box filter with a
/// rounding error on top, which would make the control change character on
/// a window resize rather than merely get cheaper.
///
/// So the reduction steps down by halves until the reduced σ is worth
/// convolving: a quarter on a desktop viewport, a half on a small one,
/// none on a thumbnail. Stepping down rather than switching off keeps most
/// of the saving in the middle of the range, and the case it gives up on
/// is the one that was already cheap — the cost is `radius x pixels` and a
/// small viewport is small in both.
pub fn reduction(&self, scale: RenderScale) -> u32 {
let sigma = self.sigma(scale);
let mut reduction = B::RECIPE.base_scale.max(1);
while reduction > 1 && sigma / (reduction as f32) < MIN_REDUCED_SIGMA {
reduction /= 2;
}
reduction
}
/// Stops of local contrast at this slider position.
fn gain(&self) -> f32 {
self.amount / 100.0 * B::RECIPE.gain
@@ -481,27 +566,134 @@ impl<B: Band> DetailStage for LocalContrast<B> {
value: self.gain(),
});
let reduction = self.reduction(scale);
if reduction == 1 {
// The full-resolution form, unchanged: blur x into the scratch
// lane, then finish along y and apply the mask in one pass.
return vec![
DetailPass {
output_scale: 1,
label: "base",
radius,
// A convolution, not a list: nothing to bind at binding 3.
storage: Vec::new(),
uniforms: shape,
wgsl: BASE_X.to_string(),
},
DetailPass {
output_scale: 1,
label: "combine",
radius,
// A convolution, not a list: nothing to bind at binding 3.
storage: Vec::new(),
uniforms: combine,
wgsl: combine_body(B::RECIPE.midtone_taper, Base::Convolved),
},
];
}
// The reduced form. Four passes rather than two, and cheaper than the
// two by a factor of about `reduction²`: three of them run on a grid
// that many times smaller on each axis, and the one that does not is a
// single bilinear read.
//
// The σ and the radius are the band's own, divided — not recomputed
// from `RenderScale`, which knows nothing about this grid. Deriving
// them from the numbers the full-resolution path uses is what keeps
// the two forms the same filter, so that crossing the threshold in
// `reduction` does not change the picture.
let reduced_sigma = sigma / reduction as f32;
// At least one tap either side. `reduction` has already guaranteed
// σ >= MIN_REDUCED_SIGMA, so this floor is a belt on top of a brace.
let reduced_radius = ((reduced_sigma * TRUNCATION).round() as u32).max(1);
let reduced_shape = vec![
Uniform {
name: "radius",
value: reduced_radius as f32,
},
Uniform {
name: "inv_variance",
value: 1.0 / (reduced_sigma * reduced_sigma),
},
];
// The combining pass no longer convolves anything, so it needs neither
// the radius nor the variance — only the two numbers the unsharp mask
// itself is made of.
let mask = vec![
Uniform {
name: "threshold",
value: B::RECIPE.threshold,
},
Uniform {
name: "gain",
value: self.gain(),
},
];
vec![
DetailPass {
label: "base",
radius,
// A convolution, not a list: nothing to bind at binding 3.
output_scale: reduction,
label: "reduce",
// Reads only the block it writes, so it reaches no further
// than the pixel it is producing and a tile needs no halo for
// it. The halo the *chain* needs comes from the blurs below.
radius: 0,
storage: Vec::new(),
uniforms: shape,
wgsl: BASE_X.to_string(),
uniforms: vec![Uniform {
name: "reduction",
value: reduction as f32,
}],
wgsl: REDUCE.to_string(),
},
DetailPass {
label: "combine",
radius,
// A convolution, not a list: nothing to bind at binding 3.
output_scale: reduction,
label: "base-x",
radius: reduced_radius,
storage: Vec::new(),
uniforms: combine,
wgsl: combine_body(B::RECIPE.midtone_taper),
uniforms: reduced_shape.clone(),
wgsl: reduced_blur(Axis::X),
},
DetailPass {
output_scale: reduction,
label: "base-y",
radius: reduced_radius,
storage: Vec::new(),
uniforms: reduced_shape,
wgsl: reduced_blur(Axis::Y),
},
DetailPass {
output_scale: 1,
label: "combine",
// One bilinear read of the reduced chain, which reaches one
// reduced pixel — `reduction` render pixels — around itself.
// Stated rather than left at zero because an understated
// radius is a tile seam, and a seam is worth more than the
// three lines it costs to be accurate here.
radius: reduction,
storage: Vec::new(),
uniforms: mask,
wgsl: combine_body(B::RECIPE.midtone_taper, Base::Reduced),
},
]
}
}
/// Which way a separable half runs.
enum Axis {
X,
Y,
}
/// Where the combining pass finds the base it subtracts.
enum Base {
/// Convolved along y by the combining pass itself, out of the scratch
/// lane the previous pass wrote. The full-resolution form.
Convolved,
/// Already finished, on the reduced chain, and read back up.
Reduced,
}
/// Half of the base, along x.
///
/// Deliberately does not touch `c`: the pass after this one needs the
@@ -533,13 +725,114 @@ for (var i = -r; i <= r; i = i + 1) {
// exist.
aux = sum / weight;";
/// Band-limit the image onto the reduced grid, in log luminance.
///
/// A pass of its own rather than something the first blur half does on the
/// way past, because it is a different filter doing a different job: this one
/// exists so that the Gaussian's *input* is representable on the coarse grid.
/// Sampling every fourth pixel instead would alias — a shimmer that changes
/// when the viewport is resized, which is the classic way a mip-based blur
/// goes wrong and is very hard to attribute to a clarity slider.
///
/// A box over exactly the block the output pixel covers. Not a wider or
/// prettier prefilter: the Gaussian that follows is 8σ wide on this grid, so
/// what a better prefilter would buy is a correction of a fraction of a
/// reduced pixel to a curve four pixels across, and it would cost taps on the
/// only pass here that reads the full-resolution image.
///
/// **In log luminance, not linear.** The base is a mean of logarithms — that
/// is what makes `detail` a ratio and the whole operation exposure-invariant
/// (see the module documentation, halo control 1). Averaging linear values
/// here and taking the logarithm later is a different number, and the
/// difference is precisely the local contrast this operation exists to
/// measure: it would be quietly subtracted out of every block.
const REDUCE: &str = "\
// The colour rides through untouched, as it does in every pass of this
// operation — though here it is untouched and also unused: the reduced chain
// carries a scalar, and the colour the combining pass subtracts from is the
// full-resolution one it reads from the other chain.
let s = i32(reduction);
var sum = 0.0;
for (var y = 0; y < s; y = y + 1) {
for (var x = 0; x < s; x = x + 1) {
sum = sum + log_luma(tap(coord, vec2<i32>(x, y)));
}
}
aux = sum / f32(s * s);";
/// One half of the reduced separable Gaussian.
///
/// Reads the scratch lane rather than the colour, which is the one line that
/// differs from [`BASE_X`]: by this point the log-luminance conversion has
/// already been done, once, by the reduce pass. Doing it again per tap would
/// be a logarithm inside the inner loop for a value that cannot have changed.
fn reduced_blur(axis: Axis) -> String {
let offset = match axis {
Axis::X => "vec2<i32>(i, 0)",
Axis::Y => "vec2<i32>(0, i)",
};
format!(
"\
// Half of a separable Gaussian over the reduced base, in log luminance.
//
// Weights are evaluated rather than tabulated, as in `BASE_X` and for the same
// reason — and here the loop is a quarter as long, which is the whole point.
let r = i32(radius);
var sum = 0.0;
var weight = 0.0;
for (var i = -r; i <= r; i = i + 1) {{
let f = f32(i);
let w = exp(-0.5 * f * f * inv_variance);
sum = sum + w * tap_aux(coord, {offset});
weight = weight + w;
}}
// Normalised by the weights actually summed, so a kernel clamped at the
// border averages the pixels that exist rather than fading towards zero.
aux = sum / weight;"
)
}
/// The second pass: finish the base along y, then apply the mask.
///
/// Generated rather than constant because the midtone taper is present for
/// clarity and absent for texture. Emitting the line only where it applies
/// keeps texture's shader honest about not having one, and saves it a uniform
/// and two helper functions it would never call.
fn combine_body(midtone_taper: bool) -> String {
fn combine_body(midtone_taper: bool, base: Base) -> String {
// Where the base comes from — the one thing that differs between the two
// forms. Everything below this line is the unsharp mask itself, written
// once, so the reduced form cannot drift into being a different operation
// from the full-resolution one it replaces.
let base = match base {
Base::Convolved => "\
// The other half of the base, then the unsharp mask itself.
//
// `tap_aux` reads the previous pass's log-luminance blur, while `c` is still
// the colour the colour pass produced — which is the arrangement that makes an
// unsharp mask expressible in a chain that hands on one texture per pass.
let r = i32(radius);
var sum = 0.0;
var weight = 0.0;
for (var i = -r; i <= r; i = i + 1) {
let f = f32(i);
let w = exp(-0.5 * f * f * inv_variance);
sum = sum + w * tap_aux(coord, vec2<i32>(0, i));
weight = weight + w;
}
let base = sum / weight;"
.to_string(),
Base::Reduced => "\
// The base, finished on the reduced chain and read back up bilinearly. `c` is
// the full-resolution colour, straight off the other chain — which is why the
// reduced passes had to leave that chain alone, and why this pass reads two
// textures rather than one.
//
// One read where the full-resolution form runs a 105-tap convolution. That
// difference *is* TD-4.
let base = reduced_at(coord);"
.to_string(),
};
let weight = if midtone_taper {
"\n\
// Clarity only: tapered to nothing at both ends of the range. See\n\
@@ -556,21 +849,7 @@ fn combine_body(midtone_taper: bool) -> String {
format!(
"\
// The other half of the base, then the unsharp mask itself.
//
// `tap_aux` reads the previous pass's log-luminance blur, while `c` is still
// the colour the colour pass produced — which is the arrangement that makes an
// unsharp mask expressible in a chain that hands on one texture per pass.
let r = i32(radius);
var sum = 0.0;
var weight = 0.0;
for (var i = -r; i <= r; i = i + 1) {{
let f = f32(i);
let w = exp(-0.5 * f * f * inv_variance);
sum = sum + w * tap_aux(coord, vec2<i32>(0, i));
weight = weight + w;
}}
let base = sum / weight;
{base}
// Local contrast, in **stops**. Both terms are logarithms, so this is a ratio:
// `detail` says how much brighter this pixel is than its surroundings, and
@@ -632,14 +911,34 @@ mod tests {
// forty-pixel blur for a control sitting at zero.
let scale = RenderScale::full((2000, 1500));
let only_clarity = composed(50.0, 0.0, scale);
assert_eq!(only_clarity.len(), 2, "one operation, two passes");
// Four at this viewport, because clarity's base is computed reduced
// here — reduce, two blur halves, combine. The number is the band's
// and the viewport's, not a constant; what this test is about is that
// *all* of them belong to clarity.
assert_eq!(only_clarity.len(), 4, "one operation, its own passes");
assert!(only_clarity
.passes
.iter()
.all(|p| p.label.starts_with("clarity/")));
// Both on: clarity's four plus texture's two. Texture stays at the
// render size whatever the viewport — its band is a decade finer, so
// a reduced grid could not hold its base — and that asymmetry is the
// reason the scale belongs to the band rather than to the stage.
let both = composed(50.0, 50.0, scale);
assert_eq!(both.len(), 4);
assert_eq!(both.len(), 6);
assert_eq!(
both.passes
.iter()
.filter(|p| p.label.starts_with("texture/"))
.count(),
2
);
assert!(both
.passes
.iter()
.filter(|p| p.label.starts_with("texture/"))
.all(|p| p.output_scale == 1));
}
#[test]
@@ -759,21 +1058,46 @@ mod tests {
// passes on one texture per pass. If the blur pass ever writes `c`,
// the combining pass has nothing to subtract the base *from* and the
// operation silently becomes a blur.
let passes = Clarity::with_amount(50.0).passes(RenderScale::full((2000, 1500)));
let base = &passes[0];
let combine = &passes[1];
// Both forms, because they are two chains and the property has to
// hold in each. A thumbnail is too small to carry a reduced base and
// takes the two-pass path; a desktop viewport takes the four-pass one.
for scale in [
RenderScale::full((160, 120)),
RenderScale::full((2000, 1500)),
] {
let passes = Clarity::with_amount(50.0).passes(scale);
let (combine, blurs) = passes.split_last().expect("clarity is active");
assert!(base.wgsl.contains("aux = sum / weight;"));
for blur in blurs {
assert!(
!blur.wgsl.contains("c = "),
"{} must leave the colour alone: {}",
blur.label,
blur.wgsl
);
assert!(
blur.wgsl.contains("aux ="),
"{} must put its result in the scratch lane",
blur.label
);
}
// However the base was arrived at, the mask subtracts it from the
// colour the *colour* pass produced. That is the whole property:
// an unsharp mask needs the blur and the original together, and
// neither chain may have overwritten the original on the way.
assert!(combine.wgsl.contains("log_luma(c) - base"));
}
// And the two forms differ in exactly one place — where `base` came
// from. A reduced combine does no convolution at all.
let reduced = Clarity::with_amount(50.0).passes(RenderScale::full((2000, 1500)));
let combine = reduced.last().expect("clarity is active");
assert!(combine.wgsl.contains("let base = reduced_at(coord);"));
assert!(
!base.wgsl.contains("c = "),
"the blur pass must leave the colour alone: {}",
base.wgsl
!combine.wgsl.contains("tap_aux("),
"a reduced combine has nothing left to convolve"
);
assert!(
combine.wgsl.contains("tap_aux("),
"the combining pass must read the blur out of the scratch lane"
);
assert!(combine.wgsl.contains("log_luma(c) - base"));
}
#[test]
@@ -795,8 +1119,29 @@ mod tests {
let clarity = Clarity::with_amount(50.0).kernel(scale);
assert_eq!(composed.radius(), clarity, "the widest pass sets the halo");
for pass in &composed.passes {
// The reduce pass is the one honest exception: it reads exactly
// the block it writes and no further, so a tile computing it needs
// no halo at all. Every other pass reaches somewhere and must say
// so.
if pass.label.ends_with("/reduce") {
assert_eq!(pass.radius, 0, "the reduce pass reads only its own block");
continue;
}
assert!(pass.radius > 0, "{} declared no reach", pass.label);
}
// The equality above is the claim worth restating: a reduced base
// reaches exactly as far across the photograph as the full-resolution
// one it replaces. `radius x output_scale`, 9 x 4 against 36, which is
// what makes the reduction invisible to a tile scheduler.
let reduced = Clarity::with_amount(50.0);
assert_eq!(reduced.reduction(scale), 4);
let base_x = composed
.passes
.iter()
.find(|p| p.label.ends_with("/base-x"))
.expect("a reduced chain has an x half");
assert_eq!(base_x.radius * base_x.output_scale, clarity);
}
#[test]
@@ -805,7 +1150,8 @@ mod tests {
// saturates at the threshold, so the largest overshoot a full-travel
// slider can produce is `gain * threshold` stops however violent the
// edge — a bound that holds by construction rather than by tuning.
let combine = &Clarity::with_amount(100.0).passes(RenderScale::full((2000, 1500)))[1];
let passes = Clarity::with_amount(100.0).passes(RenderScale::full((2000, 1500)));
let combine = passes.last().expect("clarity is active");
assert!(combine
.wgsl
.contains("threshold * tanh(detail / threshold)"));
@@ -824,7 +1170,8 @@ mod tests {
// Both at full travel, which is what makes them a bound and not a
// measurement: no picture, and no edge in any picture, can produce more.
assert!((bound(&combine.uniforms) - 0.35).abs() < 1e-6);
let texture = &Texture::with_amount(100.0).passes(RenderScale::full((2000, 1500)))[1];
let texture_passes = Texture::with_amount(100.0).passes(RenderScale::full((2000, 1500)));
let texture = texture_passes.last().expect("texture is active");
assert!((bound(&texture.uniforms) - 1.25).abs() < 1e-6);
}
@@ -834,8 +1181,10 @@ mod tests {
// highlight and fabric in a shadow, which is exactly where clarity
// must not.
let scale = RenderScale::full((2000, 1500));
let clarity = &Clarity::with_amount(50.0).passes(scale)[1];
let texture = &Texture::with_amount(50.0).passes(scale)[1];
let clarity_passes = Clarity::with_amount(50.0).passes(scale);
let texture_passes = Texture::with_amount(50.0).passes(scale);
let clarity = clarity_passes.last().expect("clarity is active");
let texture = texture_passes.last().expect("texture is active");
assert!(clarity.wgsl.contains("midtone_weight(luminance(c))"));
assert!(!texture.wgsl.contains("midtone_weight"));
@@ -865,15 +1214,24 @@ mod tests {
let up = Clarity::with_amount(50.0).passes(scale);
let down = Clarity::with_amount(-50.0).passes(scale);
let gain = |p: &[DetailPass]| {
p[1].uniforms
p.last()
.expect("clarity is active")
.uniforms
.iter()
.find(|u| u.name == "gain")
.unwrap()
.value
};
assert!((gain(&up) + gain(&down)).abs() < 1e-6);
// Same kernel either way — the direction is a sign, not a scale.
assert_eq!(up[0].radius, down[0].radius);
// Same kernel either way — the direction is a sign, not a scale. Read
// off the blur halves rather than the first pass, which since the
// reduced base exists is a downscale carrying no radius at all.
let radii = |p: &[DetailPass]| {
p.iter()
.map(|d| (d.radius, d.output_scale))
.collect::<Vec<_>>()
};
assert_eq!(radii(&up), radii(&down));
}
#[test]
+10
View File
@@ -54,6 +54,14 @@
//! than `Operation`, because they rewrite *coordinates* before the source is
//! sampled rather than transforming a colour after it. They are not part of
//! the develop chain and do not appear in `ops/`.
//!
//! [`vignetting`] is the exception, and the reason the split is drawn at
//! coordinates rather than at "lens correction": it applies a gain to the
//! pixel already fetched, so it is an ordinary node in `ops/` like any other.
//! What it needs that a colour fragment is not otherwise given is the pixel's
//! distance from the optical axis, which the sampler publishes as `radius`
//! — corner-normalised there, because the profile coefficients are fitted
//! against a corner radius of 1 and the prologue's `p` is not.
// Hand-written nodes. Each is listed in `ops/` with `rust:`, which is what
// places it in the chain; these are the implementations that entry points at.
@@ -61,6 +69,7 @@ pub mod aberration;
pub mod capture_sharpen;
pub mod colour_mixer;
pub mod curve;
pub mod dehaze;
pub mod distortion;
pub mod film_sim;
pub mod local_contrast;
@@ -71,6 +80,7 @@ pub use aberration::Aberration;
pub use capture_sharpen::CaptureSharpen;
pub use colour_mixer::ColourMixer;
pub use curve::ToneCurve;
pub use dehaze::Dehaze;
pub use distortion::Distortion;
pub use film_sim::{FilmSim, FilmTables};
// Clarity and texture are one implementation at two scales; see the module's
@@ -437,6 +437,7 @@ impl DetailStage for NoiseReduction {
let luma = self.luminance_kernel(scale);
if luma > 0 {
passes.push(DetailPass {
output_scale: 1,
label: "luminance",
radius: luma,
// A convolution, not a list: nothing to bind at binding 3.
@@ -473,6 +474,7 @@ impl DetailStage for NoiseReduction {
// from the other and the result acquires a diagonal bias.
for (index, (sx, sy)) in [(1.0, 0.0), (0.0, 1.0)].into_iter().enumerate() {
passes.push(DetailPass {
output_scale: 1,
label: if index == 0 {
"chroma-horizontal"
} else {
+7
View File
@@ -133,6 +133,13 @@ impl Operation for Vignetting {
c.k1 != 0.0 || c.k2 != 0.0 || c.k3 != 0.0
}
fn set_lens_profile(&mut self, profile: Option<&crate::lens::LensProfile>) {
// The inherent `set_profile` taking just this correction's own
// coefficients, not this trait method: an inherent method wins over a
// trait one of the same name, so this is a narrowing and not a loop.
self.set_profile(profile.and_then(|p| p.vignetting));
}
fn wgsl_body(&self) -> String {
// `radius` comes from the prologue: the pixel's distance from the
// optical axis, normalised so the corner is 1.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

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