Commit Graph
222 Commits
Author SHA1 Message Date
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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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