A layer holds a full chain and fuses it into the colour dispatch, so the
panel - which names no operation - would have offered a noise reduction
slider inside a local adjustment. It could not have worked: the detail stage
is its own dispatch, running after the masks are already applied, with
nowhere to be handed one layer's mask. The control would have moved and done
nothing. Filter the layer's chain to the operations that can honour it.
Activating every operation now activates a kernel too, and a kernel emits no
block in the fused shader. Assert that each operation reaches exactly one of
the fused pass and the detail chain, rather than counting fused blocks
against the length of the chain.
Both tests composed only the fused half and rendered it through the plain
path. That was correct while every operation was a point operation; with a
kernel in the chain the fused pass stops short of the output transform, so
the render was rejected and the operation-block count was one too high.
Compose both halves and dispatch them together, and assert that each
operation reaches exactly one of the two stages rather than counting blocks
- so the next kernel added extends the coverage instead of breaking it.
every_operation_generates_compilable_wgsl and the_whole_chain_at_once_compiles
both rendered through the fused half only. A neighbourhood operation
contributes no fused fragment, so its kernels went uncompiled — and once one
is active the fused pass hands on linear working values, which plain render
refuses. Both now compose both halves from the one graph, and the fused
block count excludes the operations the detail chain names.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fused-fragment check in lib.rs cannot see a detail pass: it is a
separate shader composed at a resolution compose() never knows. The
kernel's own test now scans the block the composer wrapped, with comments
stripped so prose about the keyword cannot fail a test about the code.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A colour square wave built from equal, opposite swings of red and blue is
not a pure colour pattern: Rec. 709 weights them 0.2126 and 0.0722, so it
carries a luminance square wave of about a seventh of the swing underneath.
Correlating the raw red channel therefore reads a constant floor that the
chroma filter is not meant to remove, which compressed every ratio towards
one - enough that the resolution test could no longer tell a correct kernel
from one twice the size. Correlate the colour difference instead, and write
the derivation of each expected value into the test.
Carries the source's metadata all the way to the file the user hands over,
and proves in bytes that the coordinates do not come with it.
The privacy test was the piece that mattered and the piece that was wrong.
It searched the whole file for the two-byte hemisphere reference "N\0" or
"E\0", which is not a fingerprint of a GPS directory at all: sample 14 of the
sRGB tone curve inside the ICC profile every export embeds is 69, written as
`00 45`, and the next sample is below 256, so its high byte is `00`. Every
format would have failed a test about a colour profile. The needle is now the
twenty-four bytes a coordinate actually serialises to — three rationals, both
byte orders, since exif.rs writes little-endian and the tiff crate writes in
the host's — which cannot match by accident, and the retaining test asserts
the same needle is *present* so a search that could never find anything
cannot make the stripping test pass by being useless.
The batch exporter now hands the decoder's reading on to the encoder. It
already read the metadata for the orientation and the {date} token; passing
it through is what puts the camera, the lens and the rights statement into
the file. Nothing about privacy is decided there — dr-export takes that
decision once, from the settings.
The example passes it too, because it is the only place in the tree that
produces files a person can open in exiftool. A unit test can prove a GPS
directory is absent from a byte slice; only a real export proves a real
photograph comes out the far end still knowing which camera took it.
TRACES: FR-EXP-8
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An active detail operation may emit no pass at a given render scale - the
honest answer for a sensor-sized radius on a heavy proxy. The fused composer
cannot see that, having no resolution to consult, so it had already stopped
short of the output transform and the frame died on a storage-format
mismatch. Compose a bodyless resolve pass in that case so the output
transform still happens exactly once.
every_operation_can_be_activated_together counted one block per operation
in the chain, which was true only while every operation was a point
function. A neighbourhood operation is a dispatch of its own and emits no
fused block, so the count now excludes the operations the detail chain
names, and each of them is separately asserted absent rather than the
comparison being loosened.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Capture sharpening as a two-pass unsharp mask in the detail stage: blur
along x, then along y, each pass applying a one-dimensional high-pass to
luminance so the composite preserves a flat field exactly and matches the
textbook kernel on any locally one-dimensional edge.
The radius is stated in source pixels and converted once per render, so a
radius tuned on a fit view is the radius the exported file gets. Below one
render pixel the operation declines to draw rather than showing sharpening
the file will not contain, and emits a single pass-through that still
carries the output transform.
The develop session now renders through render_detailed, which is what
lets an active neighbourhood operation reach the screen at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four branches merged, each of which had regenerated this file against its own
tree. Those versions all disagreed and none was right for the union, which is
why the merges took whichever side was to hand and deferred to this: the matrix
is generated, so the only correct version is the one produced once, here, from
the merged source.
Checkpoint committed by the coordinator, not by the authoring agent: the
session hit its API limit mid-task and left this work uncommitted. Committed
so it survives, NOT because it is finished - expect failing tests and
half-applied changes. The agent resumes from here.
Checkpoint committed by the coordinator, not by the authoring agent: the
session hit its API limit mid-task and left this work uncommitted. Committed
so it survives, NOT because it is finished - expect failing tests and
half-applied changes. The agent resumes from here.
Checkpoint committed by the coordinator, not by the authoring agent: the
session hit its API limit mid-task and left this work uncommitted. Committed
so it survives, NOT because it is finished - expect failing tests and
half-applied changes. The agent resumes from here.
An operation with four curves and a panel that draws one plot needs a way to
say which. The panel finds out the way it finds out everything else: the
points are faceted with the subject they act on, consecutive parameters
sharing a subject are one curve, and a widget spanning several of them gets
a selector over their names. Nothing in ui/ contains the word "red", and an
operation that grows a fifth curve arrives with a fifth chip.
The names ride on the panel rather than on the curve's row, because a Slint
model is compared by identity: a fresh list built on every parameter event
would make the row look changed every time, and rewriting a row rebuilds the
element holding the drag in progress. That is the hazard the in-place point
update already exists to avoid.
Which curve is on show is interface state, not an edit. It changes no pixel,
so it takes no history step, reaches no sidecar, and redraws nothing — the
photograph on screen is already right.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The declaration in ops/tone_curve.yaml has claimed per-channel curves since
it was written — it is the justification for the operation carrying both
`tone` and `colour`. Only the master curve existed. This is the other three.
The master runs first and the channels grade its result. Both orders are
real images and they differ visibly, so the choice is made and written down
rather than left to the loop: a point placed on the blue curve should act on
the tone the photographer can see, which is what the master has already
produced. The other order anchors the grade to tones the master is about to
move, so adjusting contrast slides a warm shadow up into the midtones.
Every id that existed before today is spelled exactly as it was. The master
curve keeps `p2_y` and the new curves take `r_`, `g_` and `b_` prefixes, so
a sidecar written when there was one curve loads, means what it meant, and
renders the same shader — asserted on the generated source, not on the
parameter values. Nothing needed a version check because nothing was
renamed.
Each curve reaches the shader only when it has been moved off the diagonal,
so an S-curve and no colour work generates what it generated when this
operation held ten parameters instead of forty, down to the uniform names.
The monotonicity guarantee is enforced per curve: a coincident pair on blue
divides by zero exactly as thoroughly as one on the master.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A sheet over the grid, opened from the header beside "Add to collection" —
deliberately the same card, scrim and dismissal as the filing sheet, because
they are the same gesture applied to two kinds of label: pick the photographs,
then say what they are. A user who has filed a selection already knows how this
works.
A word the whole selection carries, a word only some of it carries, and a word
none of it carries are three visibly different marks. Half-applied shown as
applied would be a lie about photographs the user cannot see from here, so a
partial keyword draws a dash and says "3 of 12" beside it. Tapping a dash
completes the keyword rather than removing it, which is what it means nine times
in ten, and the tenth is one more tap away.
The vocabulary is answered against the selection in Rust and pulled when the
sheet opens rather than pushed on every selection change — the selection moves
on each arrow key and the sheet is shut for almost all of them.
Assign and unassign travel by name, so a word typed into the field and a word
tapped in the list are one path rather than two, and the sheet never has to
invent an identity for a keyword that does not exist yet.
One gap, commented at the call site: unlike a star or a flag, a keyword is not
queued to the image's sidecar, because the sidecar format has no field for one.
So it reaches the user's other devices through the catalog merge, and a deleted
catalog loses keywords where it would keep ratings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Keywords are catalog state, and the catalog syncs. Without this, two devices
keywording the same library would resolve to whichever synced last, and an
afternoon of work would vanish with no sign it had ever happened.
The vocabulary merges per row on the rule collections already use: revision
first, timestamp only to break a tie, so a device with a skewed clock cannot win
by having the wrong idea of the time. Assignments merge as a set union, which is
FR-NC-9's principle applied to metadata instead of edit nodes — disjoint work
survives on both sides.
Three things needed care and are commented where they happen:
A deletion travels *by name*, not by identity. Both devices may have minted
their own uuid for one word before they ever synced, so deleting by uuid would
tombstone a row nothing was assigned to and leave every photograph still
carrying the word. The union then refuses to readmit a word a winning tombstone
has just removed — without that filter the remote's live assignments would
resurrect it on the very same pass.
Images are resolved by the server's file id first and the content hash second.
Membership has always used the hash alone, but the hash is computed only when
import dedup or a reconnect asks for it, which for most libraries is never — so
a hash-only union would have quietly done nothing for the ordinary photograph.
A word lands on the local default version. Version uuids do not reconcile in the
catalog at all: ensure_default_versions mints a fresh one per device, so a
uuid-keyed join would have unioned nothing.
Removal still does not propagate. That is the trade collection membership
already makes, for the same reason — an unwanted keyword is removed again in a
second, and a silently lost afternoon is not recoverable at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The catalog has been able to *find* by keyword since v1 — query.rs joins the
keywords table, matches it exactly, and substring-matches it for free text — and
nothing anywhere could ever put a word there. A user could filter to a keyword
they had no way to apply.
This is the missing half: create, rename, delete, list, assign, unassign, and
the two reads a panel needs. Bulk-only for assignment, because keywording a
selection is the common case rather than the exception — the photographer picks
out the frames with the puffin in them and applies "puffin" once, in one
transaction.
Schema v6 adds `keyword_terms`, and deliberately does *not* touch the v1 join.
The assignment keeps the word as text because the catalog is a rebuildable index
and the durable copies of that fact — the sidecar, XMP dc:subject — both carry a
string; a foreign key would mean a catalog rebuilt from sidecars had to invent
identity rows before it could record anything, and would break the query path
that already works. So the text is the fact, and the new table is only the
identity a rename and a deletion can be keyed on.
`keyword_terms.name` carries no unique index, which looks like an oversight and
is not: two devices that each type "Iceland" are both right until they meet, and
a constraint would abort the merge at that moment. Uniqueness is converged upon
instead — create resolves an existing name, fuse_duplicates collapses a
cross-device pair onto the smaller uuid.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The unit tests either side of the base curve check halves — that the shipped
database parses and lifts its midtones, and that the generated WGSL evaluates
a curve in the right place. Neither would notice if the two agreed with each
other and both were wrong: a curve packed into the wrong uniform slots, or a
flag read from the wrong component, satisfies both and renders nothing.
So render real pixels. A flat frame through a neutral edit, once with the
Canon EOS 6D's curve looked up by name from the YAML and once with the
identity, asserting what a base curve is actually for — midtones lifted,
black still black, white still white, monotone the whole way — plus the
number an unprofiled body must still produce, so "never worse than today"
is a value rather than a promise.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fused pass hands a fragment a colour and no coordinate. That is what buys
one dispatch for a whole edit, and it is also a wall: sharpening, noise
reduction, clarity, texture, dehaze and spot removal are each defined by what
the neighbours are doing, and FR-DEV-3 and FR-DEV-8 ask for all six. None of
them could be written at any price.
So there is now a detail stage. An operation implements `Operation` for its
parameters exactly as before — the panel, the sidecar, the history and the
presets all work unchanged — and additionally returns `Affects::Detail` and a
`DetailStage` yielding one pass per dispatch. `Affects` grows the third variant
`docs/requirements.md:250` designed and nothing had cut.
Where the stage sits is a colour-science decision, not an arrangement of
convenience. It runs after every point operation and every mask layer, so an
amount chosen against a tone curve survives the curve moving; in linear sRGB
after the camera matrix, because camera RGB has no luminance to sharpen
against; and before the output transform and the clip, because FR-DEV-2 allows
one quantisation and a highlight clipped before a convolution grows a dark
ring. The fused pass therefore ends one of two ways, and when a detail stage
follows it hands on unclipped f16 and the last detail pass encodes.
At render resolution rather than on the source, which is the whole of FR-DSP-1:
a pass before the framing prologue would cost 24 MP to draw a 2 MP preview.
`RenderScale` is what makes that survivable — a radius is stored as a fraction
of the frame's shorter edge, exactly as a mask feather already is, or as a
count of source pixels, and converted per render. It also reports when a radius
is smaller than a proxy pixel rather than drawing a plausible lie; zooming to
1:1 makes the preview exact with no second path.
`Invalidation` gives FR-DEV-3d something to mean. Moving a detail parameter
leaves the colour key alone, so `AdjustPass` keeps the linear intermediate and
skips the fused dispatch: dragging a sharpening slider costs a convolution.
Moving exposure does re-run the detail passes, because they read what the
colour pass wrote, and there is no arrangement of keys that avoids it while
keeping sharpening after tone.
Validated by a separable box blur that is not a develop operation, behind the
`detail-probe` feature and absent from a shipping build. An abstraction with no
consumer is a guess; a box blur's answer is known in closed form, so the tests
assert every byte of the ramp rather than that the edge got softer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Colour came from whichever matrix rawler happened to key `D65`, the second
one was discarded, and the rendering was left linear. That is the dcraw
default, and FR-DEV-3e names it as the reason people abandon a converter in
the first hour: correct in the abstract, flat and poor on skin in practice.
The decoder now builds a camera profile.
- `ColorMatrix1/2` and `CalibrationIlluminant1/2`. rawler surfaces these as
an illuminant-keyed map — for DNGs from the tags, and for native formats
from its own camera database — so a Canon CR2 arrives with a tungsten
matrix and a daylight matrix exactly as an Adobe DNG of the same frame
would. Dual-illuminant support is therefore not a DNG feature here.
- `ForwardMatrix1/2`, read straight from the root IFD, because rawler parses
them and never surfaces them. Where a file carries both, they replace the
inverted colour matrix: the same relationship measured in the direction
rendering actually wants, rather than an inversion that amplifies the
measurement error exactly where skin lives.
- `AsShotNeutral`, used to estimate what the scene was lit by and to
interpolate between the two calibrations in mireds. The estimate is
circular — the temperature needs a matrix and the matrix needs the
temperature — so it is a fixed point, three rounds, as Adobe's SDK does it.
Bodies calibrated at neither D65 nor A stopped rendering uncalibrated as a
side effect: a Phase One IQ3 carries D55 and D75 and used to get no matrix
at all.
And a base curve, applied per channel in camera RGB between the last
adjustment and the conversion out of camera space — a toe, a steep midtone
and a shoulder, which is the difference between a photograph and a scan of
one. It is not an edit: no slider, nothing in the sidecar, because it
belongs to the body rather than to anything anyone decided, and a sidecar is
shared between bodies. It is not a develop node either, and `ops/README.md`
now records why. It evaluates on the tone curve's own spline rather than a
second copy, so a profile author placing a control point and a photographer
dragging one mean the same thing by it.
The curves are data. `core/dr-decode/profiles/base_curves.yaml` ships inside
the binary as a floor and is superseded by any copy on disk carrying a
higher `version:`, so a body can be added and distributed without a release
— and, under the GPL, contributed. The comparison runs both ways: a stale
pack cannot hold an upgraded binary back at last year's rendering.
Canon EOS 6D and R6, Nikon Z 6 and D750, Sony A7 III and Fujifilm X-T3 ship
with their own curves. Every other body gets a conservative default, which
is much closer to right than the identity is for any of them. A JPEG gets
none — it has already been rendered once, by the camera.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two faults textual merge could not see. Both agents added a `mod tests` to
masks_ui.rs — the module boundary was an artefact of them being written
apart, and the tests do not overlap, so they fold into one. And `segment`
lost its context argument when the work moved to a worker, which a test
written on another branch still passed.
Selecting a mask layer silently re-points about thirty controls at that layer's
chain. Same panel, same order, same sliders, different meaning — and the only
thing that said so was a sentence in the panel above, which a photographer
reaching for the exposure slider has no reason to read. An exposure change
lands on the whole frame when it was meant for a face, or the reverse; both are
silent, and both are discovered later. `ui-navigation.md` §1.1 calls it the
dangerous one and it is: the others in that document cost time, this one costs
work.
The remedy is the classic one for a modal fault — make the mode visible — and
the application already had the pattern. Crop arms a canvas interaction, draws
an overlay, gives the column one job and is left by the control that entered
it. Local masking is the same animal built as a peer panel, and that is what
created the ambiguity. So `crop-mode` stops being a bare boolean and becomes
one value of a three-state mode, which is the point: two modes could both be on
before, and now that is not a state the interface can be in rather than one it
is tested against.
**One strip, not two.** The mode control was going to sit beside the group
strip that filters the adjustments, which is two controls above one column
answering the same question — what am I working on. They are one control now,
`Crop · Local │ All · Light · Colour`, which is the shape Lightroom Mobile's
bottom strip has for the same reason. The two halves are different kinds of
state and are drawn differently: a mode is a chip that fills with the accent
when it is on, a group is a word with a rule under it. That difference is what
lets both be read at once, which they routinely are — picking Light while a
mask is selected filters *that layer's* chain and does not leave the mode.
Dropping the scope on a group press would be the same fault coming back from
the other end, and would make Light mean two things depending on where it was
pressed.
The strip stays pinned above the develop column rather than moving to the top
of the canvas as the document proposed. The half that filters the column
belongs to the column, and the photograph is the subject. The canvas keeps one
button, which now names the mode it leaves rather than saying "Done" — that was
unambiguous with one mode and would not be with two — because the column can be
closed on a narrow window and no mode may be inescapable.
Entering a mode is a side effect, so Rust owns it rather than the strip writing
the property: crop drops the zoom, local turns the overlay on, and leaving
clears the selection. That last one is the fix. The "Overlay" and "Select"
toggles are gone because they armed things that are simply what the mode *is* —
a mode that has to be switched on separately is one you can enter and have do
nothing. Escape and the Android back gesture join `back_step` as one
`LeaveMode` rather than a second exit concept, and the mode is left before the
zoom is: it was entered later, and it is the bigger step back.
The heading is where the scope goes. Not a caption beside the panel, the
heading *of* the panel that changed — `ADJUST` becomes the layer's name, the
same string the selected row in the stack shows. That is the difference between
describing a hazard and removing it.
**Handles on the photograph.** A linear or radial mask could be created and
then not moved, so a radial sat at the centre of the frame at its default size
for ever. Three faults stood in the way of drawing one.
The first is that a gradient did not render at all until the model had run. The
rasteriser was built on the way out of `segment` and the array's size was read
*off* the segmentation, so a gradient added to an unsegmented photograph
produced nothing — silently, in the same way exports and thumbnails once did:
the shader still emits the layer's block and the empty placeholder multiplies it
by zero. The proxy size is a property of the photograph. Both are derived from
it now, and deliberately at the same size rather than by coincidence, because a
subject's distance field is sampled against that array.
The second is hit-testing. A handle is drawn in output coordinates and stored
in source ones, and between them lie the crop, the zoom, the pan, the
straightening and the turns. `Framing::source_at` is `wgsl_prologue` evaluated
on the CPU, kept in that file beside it so that keeping the two in step is one
file's problem — a handle mapped through anything less drifts off the mask the
moment the view moves, which is exactly what masks are rasterised in source
space to avoid.
The third is that a drag is a displacement, not a destination. Each handle
answers to the movement of the pointer since the press, applied to where the
mask was when the press landed. Snapping the handle to the pointer instead
jerks it by up to half a touch target on the first press, and the target is
finger-sized because a tablet has no hover to reveal a control and no modifier
to qualify it.
A ramp gets three handles — centre, width, angle. An ellipse gets three too:
centre and one per semi-axis, the major one carrying the direction as well as
the length, because where an axis is put says both. It had a fourth, and it is
gone: standing off the shape by a fixed distance, the rotation arm began
outside the photograph at the size a new radial is created at, so the first
thing anyone saw was a control they could not reach without first shrinking the
mask.
Two faults here were found by looking at the screen rather than at the source,
both of the kind that cannot be found any other way. A `1px` rule with a size
and no position is *centred* by Slint, so the seam between the photograph and
the column was a hairline down the middle of the panel, through the histogram
and every slider under it — twice, once in `app.slint` and once in
`AdjustPanel`. And handing Slint a fresh model for the handles on every pointer
event made the repeater rebuild its items, taking the `TouchArea` holding the
gesture with them: the handle jumped once and then went dead under a finger
that was still down. `develop.rs` carries the same warning about the parameter
rows, where it broke slider drags; the model is rewritten in place now.
The tests worth having are the ones about ambiguity and about the map. That the
same row reads the frame's value, then the layer's, then the frame's again is
§1.1 in one assertion. That dragging a handle onto another gradient's matching
handle *produces* that gradient closes the loop between the two directions of
the framing map, through a view that is cropped, zoomed, panned, straightened
and quarter-turned at once — a one-legged map is invisible when the framing is
neutral, because then both legs are the identity.
Not done here: the histogram still reports the whole frame while the sliders
edit a layer. That disagreement is real and is N3's, which this unblocks. The
strip has room for a Brush entry beside Crop and Local when the painted masks
land in the core, and it needs nothing here but the canvas interaction.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A linear mask at 45° was not at 45°, and a radial with equal radii was an
ellipse. Both on every photograph that is not square, which is all of them.
The geometry is stored in normalised coordinates so that a mask survives a
crop, a zoom and an export at another size — that part was right. What was
wrong is that a *distance* was being measured in those coordinates too, and a
fraction of the width is not the same length as a fraction of the height. So
`dot(uv - centre, axis)` measured the ramp in a space one of whose axes is
squashed against the other by the aspect ratio, and the iso-lines came out
sheared: on a 3:2 frame a ramp asked for at 45° arrives at about 34°.
Nothing announces it. The stored numbers are exactly what was written, the
shader is doing exactly what it says, and the only place the fault exists is
between the photographer's intent and the picture. It has been invisible so far
because there is no way yet to place a gradient by eye — the handles that make
it visible are what turned it up.
So distances and angles move into the frame's own isotropic units: y spans
`0..1` and x spans `0..aspect`, which makes a circle round and 45° a real
diagonal. The centre stays a plain fraction of each axis, because it is a point
and a point has no such problem — and because that is the space a click arrives
in. `frame_delta` is the one conversion and must stay the only one; the mask
array's own dimensions carry the aspect, so it costs no uniform.
The sidecar format does not change. What changes is what the numbers mean, and
the only geometry in the wild is a default that has never been movable.
The two tests are at 96×64 rather than square, which is the whole point: on a
square target this bug cannot be reproduced, and every existing mask test was
square. Both fail without the conversion — the radial reaching 28px sideways
where it reaches 19px down, and the diagonal landing on the wrong side of the
line it is supposed to lie along.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The last line of FR-DEV-3, and the mask ARCH §5.4 was written for. darktable
rasterises drawn masks on the CPU and users call the result unworkable; the
architecture's answer is that a stroke arrives as *parameters* and the device
draws it. This is that, from the model through the sidecar to the pixels — but
not the finger: the canvas is somebody else's change, and this leaves it a
seam rather than reaching into it.
**A stroke is a swept disc along a polyline**, plus erase, radius, hardness and
flow. `MaskSource::Brush` holds an ordered list of them, and the order is the
mask: an erase after an add takes it away and the same pair reversed does not.
Nothing about it is pixels, which is what makes a mask that costs a line of
text, diffs by the gesture, and survives a crop, a straighten and an export at
any size — the properties a stored raster has none of, and the same argument
the region ids were chosen for.
Two things keep the point count honest. While the finger is down, a position
closer to the last than an eighth of the radius is dropped: a touch screen
reports 120 a second, so a finger held still for five seconds is six hundred
points in the same place, and simplification would only remove them once the
gesture had ended — after every frame in between had drawn all of them. When
it ends, Douglas–Peucker at an eighth of the radius removes what a disc that
wide cannot express: a swept circle moved by r/8 moves its own edge by r/8,
which is inside the soft part of any brush. Coordinates snap to a
ten-thousandth of the frame on the way in *and* are written at that precision,
so a round trip is exact rather than nearly exact — a file that drifts in the
sixth decimal every save is a per-field merge conflict a day, over nothing.
**Cost is why the strokes are not drawn by the full-screen triangle the other
masks use.** A swept disc is the minimum distance to any of its segments, so a
stroke over the whole frame costs `pixels × segments` and both terms grow
together — the quadratic that is darktable's problem moved onto the GPU rather
than solved. Each stroke is instead drawn over its own bounding box, grown by
the radius, so the rasteriser never invokes the shader for a pixel the stroke
cannot reach: `area(box) × segments`, which for a dab or a swipe is a small
fraction of the frame. A gesture past 256 points continues as a second stroke
for the same reason, since a shorter stroke has a smaller box.
Add and erase are `dst + a(1 - dst)` and `dst(1 - a)`, which are exactly a
source-over and a one-minus-source blend — so they are blend state, not
arithmetic, and no pass ever reads the slice it is writing. That is what
permits one draw per stroke at all. Within a stroke the coverage is the
*minimum* distance over its segments rather than a sum: a path that crosses
itself must not build up where it did, or every circle and every scribble
would be blotchy wherever consecutive dabs overlap, which is everywhere.
Not a distance field, deliberately. `dr-segment`'s transform documents the two
conditions that make CPU work right there — once per mask edit, over input
already CPU-side — and a stroke fails both: it changes while the finger moves,
and its input is a handful of coordinates that never needed to be pixels. It
also needs no transform, because the distance to a swept disc is closed form.
A stroke is the one mask whose distance field is known without computing one.
An unpainted brush layer is inactive rather than empty, which is not an
optimisation: `invert` turns empty into everything, so a layer created with
invert already set would apply its adjustment to the whole photograph before a
single stroke was made. That is the loud, confident kind of wrong this codebase
refuses everywhere else a mask can go missing, and there is a rendered test for
it.
The tests read pixels back off a device rather than checking that the two
halves agree with each other. What they pin down is what is silent when wrong:
the y flip between mask space and clip space, which a centred stroke would not
notice; a bounding box not grown by the radius, which makes a tap draw nothing
at all; an aspect ratio ignored, which makes a dab an ellipse on any frame that
is not square; a stroke doubling back and building up; and an erase that lost
its place in the order and put back paint the user had taken off.
Not done here: the interaction. The canvas needs to begin, extend and end a
stroke on the active layer, and `DevelopSession::rasterise_masks` still returns
early without a segmentation — it takes the proxy size from one, and a brush
needs no model to have run over the photograph first.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Find subjects" took the UI thread for two thirds of a second on a 22 MP
frame — a proxy render, a readback and a YOLO pass through `ort` — and for
that time the interface was simply gone. The panel apologised for it rather
than hiding it: a "Looking…" label, and a 16 ms `single_shot` so the label
reached the screen before the freeze began, with a comment saying the obvious
fix needed the develop session restructured and was not being taken.
The obstacle was never `Send`. `DevelopSession` is `Send` — the device, the
source texture and the passes all are. What cannot go to a worker is the
`Rc<RefCell<Option<DevelopSession>>>` that every callback in the window
reaches through, and the window has to keep reaching through it while the work
runs. Handing the session over would freeze the interface exactly as
thoroughly as blocking on it did.
So the work takes a copy of what it needs instead. A `SegmentationJob` is the
device, the demosaiced source behind an `Arc`, and the name of the session
that asked. Taking one is two `Arc` bumps; running one is 495 ms on this
desktop; none of it touches the session, and there is deliberately no
`&mut DevelopSession` in scope for a caller to hold across it. The proxy
render travels with it rather than staying behind — a `GpuContext` and a
texture handle are both `Send`, and the model was never the only expensive
half. So does building the mask rasteriser, which is a shader compile:
adopting the result was costing 23 ms, a dropped frame on the one redraw the
user is waiting for, and the rasteriser is needed exactly when the subjects
arrive and never before. What is left on the UI thread is a microsecond.
The answer comes back through a channel a `slint::Timer` polls, which is the
shape `apply_when_ready` already uses for a sidecar fetch.
**A result can outlive the photograph it describes.** Two thirds of a second
is long enough to press the button, think better of it and swipe to the next
frame — and the result landing then would fill the panel with subjects that
are not in the picture, drawing outlines around a dog two photographs back.
Nothing downstream can tell: the masks rasterise and the overlay draws either
way. So every session is minted with an id, a job carries the id it was taken
from, and `delivery` compares the two before anything is applied. An id
rather than a counter beside the session slot, because that slot is written
from four places in `lib.rs` and the fifth would be the one that forgot.
A discard touches nothing on the way out. `segmenting` belongs to whichever
photograph is open now, which may well have a run of its own going, and
clearing it would re-enable a button that is correctly insensitive.
One run at a time, and abandonment is what stops that being a trap. A job
left over from a photograph the user has left is displaced rather than waited
for — otherwise the next frame's "Find subjects" would do nothing for the
length of a run nobody wants, which is the wait this exists to remove. `ort`
offers no way into the inference, so abandoning is checked at the seams there
are: before the job starts, and between the readback and the model. Abandoned
early it costs nothing, abandoned mid-inference it costs the run it was
already committed to, and either way the answer is dropped at the channel.
`DevelopSession::segment` survives as a test-only convenience. Left public it
is precisely the shape that put two thirds of a second on the UI thread in the
first place, and the next caller would reach for it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The develop column's panel is a Rectangle, not a layout — a note four lines
above explains why it is not an `if`. Two children of one therefore both sit
at its origin, so pinning the strip beside the Flickable overlapped them and
collapsed the whole develop view to a sliver.
Wrapped in a VerticalLayout. The strip is pinned by being outside the
Flickable rather than by any coordinate, which is what keeps it working at
any column height.
Caught by screenshotting the device rather than by the build, which was
clean throughout — a Slint layout fault is invisible in the source and
obvious the moment anyone looks.
It was inside AdjustPanel, which on a tablet put it below five other panels
and off the bottom of the screen: present, working, and unreachable without
scrolling past everything it exists to save you scrolling past. A control
that answers "where is everything else" cannot itself be somewhere else.
Now a GroupStrip above the scrolling column, so it never scrolls away. It
still names no group — the strings arrive resolved from whatever the
operations declared themselves to be about.
Reported as a thumbnail bug; the export had it too, which is the serious
half. You would have exported a photograph missing every local adjustment.
Both called the unmasked `render`, and the failure is silent by
construction: the generated shader always declares the mask binding and
always emits a block per active layer, so binding the empty placeholder
multiplies each of them by zero. No error, no warning, no missing texture —
the adjustments are simply not there. From inside either path there is
nothing to see.
Every path that produces pixels now goes through one helper that binds the
array, and that is the point of it being one helper rather than three
correct call sites. The array is rasterised in source space at proxy size
and sampled through the framing map, so one array serves every output size:
a 256px thumbnail and a 24 MP export bind the same texture.
Three tests, and the first is the fault stated directly — render the same
edit with and without the array and assert they *differ*. If binding it ever
stops mattering, the masks have stopped reaching the shader. The third
checks the masked share of the frame is the same at 32px and 128px, because
"both non-empty" would pass while a mask that scaled wrongly still ruined
every thumbnail.
A strip of groups over the adjust panel — Light, Colour, Detail — derived
from the attributes the operations declare. `adjust.slint` names none of
them: the strings arrive resolved and the panel only draws them, so a new
operation joins the right group by saying what it is and this file does not
change (FR-DEV-3a).
A group nothing carries is not offered, so a tab never opens onto nothing.
Geometry is left out because its one operation prefers an on-canvas widget
and is skipped by the row builder — a Geometry tab would be empty while
`GeometryPanel` holds the real controls. The strip appears only when there
is more than one group to choose between; a single tab is a control with one
option.
The selected group is underlined rather than filled. The accent means
*modified* everywhere else in this interface, and spending it on "which tab"
would blunt the one signal the panel has.
**The trap, and it nearly bit again.** `op_index` on a row counts over every
capability, not over the ones a filter kept — it is how a row routes back to
the core. Renumbering it while filtering would make a slider drive a
different operation, which looks like a rendering fault rather than a
routing one. `rows_filtered` keeps `enumerate` over the full list and only
`group_head` is a position within the emitted rows; a test moves a value
through a filtered row and checks it lands where it was asked to.
Six tests, including that a nonsense index falls back to showing everything
rather than to showing nothing.
Tool tabs need a taxonomy, and the taxonomy was the problem: a table in
`ui/` mapping operation to tab breaks FR-DEV-3a, and a `group:` field risks
what `ui-refinement.md` condemned `starts-group` for — the core deciding
where the panel draws things.
`Attribute` threads the needle. It says what an operation *is* — tone,
colour, detail, optics, geometry, effect — which is the same category as
`ParamKind` and squarely on the core's side of ARCH §4.3a's line. What is
drawn, where it sits and whether it is visible stay the frontend's. There is
no attribute for "the third tab", the enum's order is declaration order
rather than screen order, and a frontend may render these as tabs, as
headings, or ignore them.
The payoff is that a tab strip can be *derived*: the groups are the
attributes present in the capability list, so the interface names no
operation and needs no table to keep in step. An operation joins the right
group by declaring what it is, which is the one thing its author is well
placed to say.
Plural, because the tone curve is genuinely both — an RGB curve is tonal and
the per-channel curves are chromatic, and filing it under one would hide it
from half the people looking for it.
Required and non-empty, enforced in `build.rs`, and the failure was checked
by removing the line rather than assumed. An operation with no attribute is
invisible to a panel that groups by them; a build that stops costs ten
seconds, a control nobody can find costs more. The vocabulary is closed for
the same reason: a typo would otherwise invent a category holding exactly one
operation, which looks like a deliberate one until somebody counts.
Six tests over the real chain, including the hand-written operations that
`build.rs` never sees and so cannot check.
Targets are a 12-inch tablet and a desktop (D15). That removes most of the
navigation question rather than answering it.
`EXPANDED_MIN_WIDTH` is 820 logical pixels and a 12-inch tablet is ~1024
across in portrait, so both orientations of both targets are the expanded
class. The compact class now fires only when a desktop window is dragged
narrow — graceful degradation, not a second interface. The bottom tool strip
and the one-tool-at-a-time sheet were solving a phone, and there is no phone.
What survives is input, not size, and the architecture had already decided
it: `WidgetDemand::precise_pointing` exists for a television remote, and its
own documentation says touch is fine because hit regions grow to the
modality. Touch changes hit regions, not layout. The rules that fall out are
worth stating because they are easy to violate by accident — no hover-only
affordance and no modifier key may be the sole route to anything, since a
tablet has neither. Local masking already lost its shift-click extend for
exactly this reason.
The guaranteed-wide viewport also pays for a better answer to the extent
problem than hiding things. The complaint was never that the column is long;
it is that the histogram scrolls away from the sliders it reports on.
Collapsing shortens the scroll, pinning removes the problem, and ~260px of
fixed height is affordable on a viewport that is never under 820 wide.
Local adjustments took the develop column from four panels to six, and the
operation set is meant to keep growing — FR-DEV-3 still lists texture,
clarity, sharpening and noise reduction as v1. But the count is the lesser
problem.
The real one is that local adjustments introduced a *mode* without
introducing a way to see it. Selecting a mask silently re-points thirty
sliders at that layer, and the histogram those sliders are judged against
goes on reporting the whole frame. I built that, and it is the fault that
loses work rather than merely slowing someone down.
Decided: local becomes a mode, in the sense `crop-mode` already is. The app
has the pattern, the user knows it, and it removes the ambiguity by
construction instead of describing it in a caption. It also inherits the
Escape/back stack that already leaves the innermost state first.
Recommended: diverge on **width**, not on platform. A tablet in landscape
wants what a desktop wants and a narrow desktop window wants what a phone
wants, so `cfg(target_os)` would give one physical situation two answers.
`apply_layout_class` already classifies on width and already remembers a
per-class override; this is a second consumer of a decision the app makes
anyway. What must not diverge is the controls — both layouts consume the
same generated capability model, so a new operation still needs no UI edit.
Left open, because it is taste: whether the wide layout eventually gains
tool tabs. Recorded rather than left to be rediscovered is the *legitimate*
route to them — a descriptor declaring an operation's nature, the same shape
as `Affects`, with the frontend free to render it as a tab or ignore it.
Deferred because ten operations do not need eight tabs and the field is easy
to add later and awkward to remove.
Surveys what Lightroom, Capture One, darktable and the phone editors
actually do, including the thing none of them do: make a mask a panel that
rewires a different panel.
The overlay is a source-space picture; the canvas beside it shows whatever
the crop, the zoom and the pan selected out of that same space. Drawn whole
it stayed frame-sized while the photograph moved underneath, so zooming in
left a map of the whole picture stretched over a detail of it.
It now reports the visible rectangle as a clip, which the compositor
applies for nothing. Resampling on the CPU instead would mean rebuilding a
megapixel image on every frame of a drag, and putting it on the GPU would
add a second texture to keep in step with the view.
Pushed from the render path rather than the panel's sync: a pan changes no
mask and no row, so nothing else needs to run, and rebuilding the row
models on every frame of a drag would be waste.
Straightening is handled by rotating the image. A quarter turn or a flip
permutes the axes and a clip rectangle cannot say that — noted where it
happens rather than left to be discovered. The proper fix is to run the
overlay through the same shader prologue the photograph goes through, which
is the right answer and a larger one than this.
Four tests, and the one that matters asserts the clip *narrows* when zoomed
— which is precisely what it failed to do.
It does not work on a photograph, so nothing should offer it. `Segmentation`
is now one model pass and what it recognised: no region field, no merge
tree, no label upload, no granularity slider, and no readback of the whole
proxy to build a graph that collapses.
A click means "the object under the cursor". The region-selection path went
with the hierarchy it indexed — including the shift-click add/subtract,
which has no meaning for a whole object and would have been a modifier that
silently did nothing.
The passes, the hierarchy and the semantic prior stay in `dr-gpu` and
`dr-segment`, tested and documented. It is the *merge criterion* that
fails — the saddle is the minimum gradient along a boundary, so one weak
pixel merges two regions and real gradient noise puts a weak pixel on every
boundary. That is one function to replace, and the evidence for replacing it
is worth keeping. What is gone is the wiring, the option, and the control
that offered a user a choice with no outcome.
`MaskSource::Regions` remains in the pipeline: it is tested, it round-trips
through the sidecar, and a stored layer that names regions must still load
and be reported stale rather than failing to parse.
Feather, falloff, grow/shrink/close/open and their amount, on the selected
layer. All four read the one distance field, so all four are live — nothing
recomputes except a compound morphology, and the session keys on that
separately so a feather drag rebuilds nothing.
Shown only for sources that go through the distance field. A gradient
carries its own falloff in its geometry, and offering a second one would be
two controls fighting over the same edge.
Picking an operation seeds a small amount if none is set. Selecting "Grow"
and seeing nothing happen would read as a broken control rather than as a
radius of zero.
Feathering, growing, shrinking, closing and opening are the same number
read differently. With the signed distance from the boundary in hand,
dilation is the set where d >= -r, erosion where d >= +r, and a feather of
any shape is a function of d. So the field is computed once and the
controls are arithmetic on it.
The **field** is what reaches the GPU, not a finished alpha, and that is
the point: growing a mask or changing its falloff then costs a uniform
upload and no recomputation, which is what makes them live controls rather
than ones that stall on every drag. Only closing and opening rebuild,
because after the first threshold the shape has changed and the old
distances describe the old one.
Exact Euclidean, via Felzenszwalb's separable transform — not a chamfer
approximation, which leaves a mask visibly octagonal once grown more than
a few pixels. A test asserts the diagonal is √2 rather than 1 or 2.
It runs on the CPU, which ARCH §5.4 forbids for masks. The rule is about
brush lag — a stroke rasterised per frame — and this is a different
operation: once per mask edit, on input the model already produced here,
producing a field the GPU then samples for free. What it buys is exact
determinism, which matters because masks reach the sidecar as indices and a
field that varied by vendor would mean a mask meaning one thing on the
desktop and another on the phone.
The half-pixel in `signed_distance` is not a detail, and a test caught it.
Measuring to the nearest opposite pixel *centre* puts the smallest
magnitude at 1 either side, so the boundary is nowhere and **eroding by
less than a pixel removes nothing**. A control whose first notch does
nothing is a broken control. Half a pixel off each side puts the boundary
where it physically is, and eroding by 1 takes exactly the outermost ring.
Every falloff curve is 0.5 at the boundary by construction, asserted for
all five: changing the curve should change how the transition looks and
never where it sits.
The watershed hierarchy does not survive a photograph, so local masking
stops depending on it. A layer can now be one recognised object, and the
object's own coverage is the mask.
`Options::watershed` defaults off. It costs ~80 ms plus a full-resolution
readback to produce a ladder that collapses, and paying that on every
photograph buys a control that misleads. Kept switchable rather than
deleted: the passes and the hierarchy are correct in themselves and it is
the merge criterion that fails, which is a change to one function.
Masks now rasterise in **source** space at proxy resolution and are sampled
by the composed shader after the framing map. That fixes a real bug: they
were rasterised in output space, so zooming slid the photograph underneath
a mask that stayed pinned to the viewport, and cropping moved every
adjustment to a different part of the picture. Doing it this way also
leaves the framing map in exactly one place — a second copy in the mask
shader would have been a second thing to keep in step, failing only when
straightened.
A subject is stored as identity, not pixels: the mask is megabytes and is
reproducible by running the same model over the same image, so the sidecar
carries the index, the class and the score, and the session carries the
pixels. The class is there to be checked — if instance 3 comes back a "car"
where it was a "dog", something changed and the layer is stale rather than
silently masking the wrong thing.
The overlay now draws instances and is transparent everywhere else. The
region version covered every pixel and so hid the photograph it was drawn
over; the question it exists to answer is whether an outline follows the
subject, which you can only answer by seeing both.
`examples/local.rs` is the worked example: subject in colour with the rest
monochrome, and the subject lifted out of its background. Run on a 5472x3648
CR2 it finds two people and two cars, and the colour-pop keeps her hat and
hair while the wall and grass behind go grey.
Two things, and the second is why the first matters more than expected.
Mask layers gain a feather, a falloff curve and a morphology, all defined
against a signed distance from the boundary rather than as separate
features — one exact distance field answers "how soft" and "how far" at
once, so dilation is a threshold at -r, erosion one at +r, and closing and
opening are one of each in sequence. The compound pair costs a second
distance field, which is why they are named rather than presented as a
radius that happens to be signed. Types, defaults and sidecar round-trip
only; the field itself is next.
`edge-feather` and `edge-falloff`, not `feather` and `falloff`, because a
radial mask already writes `feather` for the fraction of its radius it
ramps over. Same word, different quantity, different units — sharing the
key would have made an existing file ambiguous.
The diagnostic that provoked this is committed as an ignored test, because
"does the ladder land on things a person means" is the question S15 exists
to answer and it should not depend on whoever still has the script. On
bus.jpg it answers badly: 35,075 regions at blur 2 over an 810x1080 frame,
and cutting that to 400 gives *one* region covering nearly the whole
picture plus 399 noise specks. Not over-segmentation — collapse. Almost
every saddle is near zero, so the merge order joins everything meaningful
before it joins anything spurious, and a global cut spends its entire
budget on grain.
So the granularity ladder does not currently work on a photograph, and the
region masks built on it inherit that. Recorded rather than worked around:
the next commits move local masking onto the model's instances, where the
edge treatment above is what makes a quarter-resolution mask usable.
docs/segmentation.md §4 priced arm B as costing a C dependency under the
NDK and treated that as most of the difference between the arms. It is not
a cost that has to be paid: `ort`'s `alternative-backend` disables its
linking entirely and `ort-tract` supplies the API from tract, which is pure
Rust. D13's "largest exception the policy would tolerate" turns out not to
be needed, and the answer generalises to the face pipeline — so D13's
runtime half is now answered and only its licensing half is open.
Three findings contradict §4 outright and are recorded as F4-F6 rather than
quietly designed around. There is no ADE20K-trained YOLO, so the shipped
vocabulary selects subjects and not stuff — "select the sky" comes from the
watershed or from nowhere. It is instance segmentation, so it partitions
nothing and two people come back as two instances. And tract cannot parse a
dynamic-shape export, which fixes the input at 640 square and makes tiling
the only route to more semantic resolution.
Arm C ships, but §8's criteria are not what decided it, and saying so
matters more than claiming the process worked. §8 asked for a two-
interaction margin over arm A on a traced corpus. That comparison was never
run: F4 and F5 changed what the arms are, and a model that recognises
subjects but has no word for sky cannot be a selection tool alone, while a
watershed cannot tell a person from the wall behind them. They stopped
being candidates and became complements.
What is *not* done is written down as plainly: the 24-image corpus is
untraced, so M1-M4 have no numbers and "this feels right" has not become
one. M5 is answered on one device only, and region ids now reach the
sidecar — so a cross-vendor divergence would mean a mask written on the
desktop meaning something else on Android. F3 stands.
The local panel sits above the adjust panel because it decides what those
sliders act on; below it, a photographer would set an exposure and only
then discover which scope it landed in. Selecting a layer re-scopes the
existing controls to that layer's chain — there is no second set of
sliders, and there must not be, or every operation added to `ops/` would
need a local twin.
The overlay is drawn over the canvas rather than blended into the render,
because it is a diagnostic and not an edit: it must not reach the
histogram, an export, or the texture handed to the compositor. Nearest-
neighbour always — the map's values are *names*, so smoothing between
region 4 and region 9 invents a colour belonging to neither and softens
exactly the edge the overlay exists to show.
Picking gets its own touch area above the pan handler. Panning wants
press-drag-release and picking wants a click; interleaving them in one
handler is how a drag ends up selecting a region the user was scrolling
past. Shift is tracked as window state because a TouchArea's click carries
no modifiers.
Three states a layer can be in are worth distinguishing, and each has a
different remedy: stale needs re-segmenting, "no adjustment yet" needs a
slider moved, and the ordinary case needs nothing said. A bare selection
renders nothing and looks identical to a broken mask, which is the first
thing a new user will hit.
Known rough edge, commented where it happens: segmentation blocks the UI
thread for about half a second. Moving it to a worker needs the develop
session — GPU resources behind a RefCell shared with every callback — to
be reachable from another thread, which is a restructuring rather than a
change to the call. The button says "Finding regions…" first so the stall
is announced rather than looking like a hang.
A mask layer holds a real develop chain, so the develop panel can edit one
with no new controls: select a layer and the same sliders read and write
its chain instead of the graph's. An operation declared in `ops/` tomorrow
becomes locally adjustable by existing, which is the payoff for making a
layer a chain rather than a handful of special-cased parameters.
`segmentation.rs` joins the two arms into the one thing the view needs.
The model reads the image through a neutral graph rather than the edited
one, so a segmentation survives an exposure change instead of being
invalidated by every slider. Arm B failing is not fatal: a missing or
unreadable model leaves a working watershed map, because refusing to
segment at all would trade a working feature for a strict one.
The overlay colours groups by a golden-angle walk over hue. Deterministic
rather than random, so a region keeps its colour across a level change and
the eye can track it; boundaries drawn black over the fill, because two
adjacent groups landing on near hues read as one region and telling them
apart is the whole reason to look at it.
Clicking the photograph creates the layer if none is selected — that is how
a local adjustment begins, and making the user press "add layer" first
would be a step with no decision in it. Shift-click extends, and clicking a
region already selected removes it, so one gesture both adds and corrects.
`segment-readback` is a new dr-gpu feature and not a loosening of
`readback`. The region-graph transfer is once per image on a worker; the
one AC-8 forbids is per frame in the render loop. Sharing a switch would
have forced a build wanting local masking to unlock the other. F3 still
stands and the feature name says so.
The sidecar is authoritative — the catalog is a disposable index and the
RAW is never written — so a mask that does not round-trip is not a
persistence bug, it is lost work.
Layers get their own `[mask <version> <id>]` blocks rather than being
flattened into dotted keys. A layer is not a scalar: it carries a
selection, a geometry and a chain of its own, and encoding a region set as
`m1.region.0 = 12` would be neither readable nor mergeable. The version
uuid is repeated in the header instead of relying on the block following
its version, because "belongs to whichever version appeared above me" is a
relationship that hand-editing, merging and older builds each break
quietly.
Region ids sort and deduplicate on read rather than being trusted from the
file. The mask's identity is the *set*, so two devices writing the same
selection in different orders must produce the same mask rather than
argue about a difference that is not one.
Masks merge by layer id under FR-NC-9, which is the disjoint-survives rule
the parameters already follow one level up: a layer added on the phone and
one added on the desktop both survive. A layer *both* sides edited resolves
wholesale to the higher revision, because half of one selection plus half
of another's opacity is a layer neither person made. A remote deletion is
honoured, or a mask the user removed returns on every sync.
An unknown mask source is skipped rather than guessed at. Applying a newer
format's mask type as the nearest one this build knows would put a
confidently wrong adjustment on the photograph, which is worse than
applying none.
29 new tests. The interesting ones are about silence: a maskless version
clearing the previous image's layers, a bare selection persisting even
though it renders nothing, and a mask naming a version that is not in the
file being dropped instead of landing on whichever block was open.
A mask layer is an ordinary develop chain plus a rule about where it
applies. Nothing in the chain knows it is being masked, so every operation
that works globally now works locally and a newly declared op in `ops/`
arrives with local support already done.
The composer emits each layer after the global chain and before the
conversion out of camera space, which is what a photographer means by "and
*then* lift the shadows on her face". Op fragments write to a `c` they
expect to own, so a layer block shadows it and copies the result back out
through a carrier — assigning the outer one from inside is impossible
precisely because it is shadowed. The fused dispatch survives: three global
adjustments and two masked ones remain one shader, one read, one write.
Masks rasterise on the GPU and never exist in CPU memory (ARCH §5.4). That
is the whole reason darktable's brush masks lag, and it is architectural
rather than tuning, so it is not a thing to inherit and fix later.
The rasteriser is a render pass rather than the compute shader it obviously
wants to be, and the format is why: R8Unorm is not a core storage format,
so a compute path has to widen masks to four bytes per pixel — 768 MB
across eight layers of a 24 MP export, against 192 MB at one byte. A colour
attachment takes R8Unorm happily. The array slice comes from the attached
view, so no slot uniform exists to disagree with where the pass writes.
Region masks index a compacted label field rather than the watershed's raw
basin roots, because a root is a sparse index into pixel space and
indexing a per-region array by one would need a table the size of the
image. Changing a selection then costs a few kilobytes, not a re-upload.
Stored as region ids, not as pixels: diffable, mergeable per-field under
FR-NC-9, and cheap in a sidecar. The ids only mean anything alongside the
segmentation that produced them, so each layer carries that signature and
is treated as stale rather than applied when it does not match — a
confidently wrong mask being much worse than an absent one.
Seven device tests render actual frames and read them back. The unit tests
either side check halves that would both pass if the two agreed with each
other and were both wrong; a mask sampled with x and y swapped satisfies
them and fails these.
Local masking needs to know where an image's regions are. The watershed
spike (S15 arm A) found the boundaries but had no idea what any of them
enclosed; its coarse levels were geometric accidents. This adds the other
half and the thing that joins them.
`core/dr-segment` is where region reasoning now lives — the hierarchy moves
out of `dr-gpu`, which keeps only the pixel passes that are genuinely
shaders. The new crate is device-free and, without its default features,
model-free too: 20 of its tests need neither an adapter nor 11 MB of
weights.
Arm B runs YOLO26n-seg through `ort`. D13 framed inference as a choice
between `ort`'s C++ runtime and the pure-Rust dependency policy; that was a
false choice. `ort`'s `alternative-backend` feature unlinks the C entirely
and `ort-tract` supplies the API from tract, which is pure Rust. Measured
before committing to it: zero unsupported operators, 420 ms for 640x640,
and correct masks on bus.jpg. No NDK problem to solve, so D13's largest
tolerated exception is not needed.
Arm C is `prior.rs`, and it ships because the two arms fail in opposite
directions. Instance membership re-weights the merge saddles, so region
pairs the model believes share an object merge early and pairs straddling
its edge merge late. No boundary moves — only the order in which they
dissolve — which is how the result stays pixel-accurate at every level
while its coarse levels become named things.
Two things the spec assumed that turned out to be false, both recorded in
models/LICENCE.md: there is no usable ADE20K-trained YOLO, so the shipped
vocabulary is COCO's 80 subjects and *stuff* like sky and foliage must come
from arm A; and tract cannot parse a dynamic-shape export, so the graph's
input is fixed and tiling is the only route to more semantic resolution.
Weights are AGPL-3.0, which GPLv3 §13 permits and which makes the combined
work effectively AGPL. Deliberate, not accidental. They live in Git LFS,
and a build script fails with an instruction rather than embedding a
pointer file when the clone lacks them.
First step of dead pixel removal, and the one that decides whether the
rest is worth building: where the map comes from.
`rawler` is no help. It knows `OpcodeList1/2/3` exist — it copies them
through when *writing* a DNG — but it never decodes them, and the
`dng_tags` map it exposes is only ever filled by callers, never by a
decoder. So the bytes are read from the IFD directly, which this module
was already walking for previews, including the SubIFDs where a DNG keeps
its raw IFD.
`OpcodeList1` specifically: lists 2 and 3 run after demosaic and after the
colour transform, so neither can carry a correction that has to happen on
the mosaic. Two opcodes describe defects — `FixBadPixelsList`, which is
explicit coordinates plus whole dead rows and columns, and
`FixBadPixelsConstant`, which names a sentinel value rather than any
coordinates and is left unimplemented until there is a stage to consume
it. A half-implementation that guessed at coordinates would be worse than
the absence, because it would look like it worked.
Two things the tests pin down because both are silent when wrong: a point
is stored (row, column) and reading it the other way round lands the
correction on the wrong photosite — invisibly, on a square crop — and
opcode payloads are big-endian whatever the container's byte order is, so
a little-endian TIFF still writes these the other way round.
An unknown opcode is stepped over using its declared length rather than
abandoning the list, because a camera that corrected its lens as well as
its sensor writes both, and losing the map whenever a warp is present
would be losing it on most files that have one.
Includes `--example defects`, because whether any of this fires is a
question about a particular library rather than about the specification.
A thumbnail comes from the file's embedded preview, which is the camera's
idea of the photograph and knows nothing about what has been done to it
since. So a frame could be cropped, turned upright and pulled two stops
back, and the grid would go on showing the original — making the library,
where a photographer spends most of their time, the one view in which an
edit is invisible.
The render is the framed output, not the sensor: `output_size` is what a
crop, a quarter turn, a flip and a straighten all act on, so a thumbnail
taken from the raw frame would be the right pixels in the wrong shape and
still the wrong way up. It is the same path an export takes, at a size
the store wants rather than at full resolution, and always sRGB — this is
a JPEG in a shard that syncs between devices and is drawn as a cell, not
a file anyone is finishing.
Both size classes are replaced. The store keys on the class, so
refreshing only the one the grid happens to be drawing leaves the other
holding the unedited preview, and a zoom across the boundary would show
the edit undoing itself. Each is rendered rather than downscaled from the
larger, which would be a second and worse resampler than the GPU has
already applied.
It runs on the way out of develop, after the sidecar write is queued and
never instead of it — the edit is what must not be lost, and a render
that failed must not take the save down with it. Two cases are worth the
work: an edit made in this sitting, which `can-undo` records even when it
ends back at neutral, and an image opened with an edit already in its
sidecar and left untouched, whose cached thumbnail has never shown that
edit at all. A neutral image nobody touched fails both and costs nothing.
Not covered: a batch paste onto a selection, which deliberately never
opens a session — there is no rendered frame to take a thumbnail from,
and downloading forty RAWs to make forty is exactly what that path exists
to avoid.
**Hover is not something a finger does, but Slint reports it anyway.**
`has-hover` goes true for any pointer event carrying a position, a touch
press included, and false again on the `Exit` that follows the release.
So the rating strip did appear on a tablet — for exactly the length of a
tap. It flashed on under the finger, vanished as it lifted, and the tap
carried on through to the cell and opened the photograph. An unjudged
frame could not be rated from the grid at all. The previous fix stopped
the strip disappearing when a *pointer* moved onto it; this is the same
symptom with a different cause, and hover was the wrong signal in the
first place.
The strip now stands open where the session is a touch one. That is
seeded from the platform rather than inferred, because inference needs a
press to reach a cell and a quick flick never delivers one — the Flickable
claims the gesture before the delay it would forward after — and a
control that only appears once the finger is down has appeared too late
to aim at. The grid still latches on the first non-zero touch id it sees,
which is what covers a touchscreen on the desktop.
One-way on purpose: a tablet with a mouse plugged in keeps the strips
once touched, which is the harmless direction to be wrong in. The
alternative is chrome that comes and goes as the user changes hands.
Two faults left over from making the gesture reach the grid at all.
**It still opened photographs.** Checking the finger id stops the
synthetic release Slint emits when the *second* finger lands, but not the
other end of the gesture: lifting one finger of two leaves the other one
down, and Slint replays that survivor as a fresh `Pressed` on whatever is
under it — which is how it hands the pointer back to ordinary handling.
Under it is a cell. So the cell was selected, and lifting that last finger
was a complete, well-formed click on the same finger that pressed. No
part of the event stream distinguishes it from a real tap, so the grid now
remembers that a pinch just happened: a latch raised when the gesture
starts and lowered a beat after it ends, during which cells take neither
presses nor clicks.
The press that *opens* a pinch is undone rather than suppressed — it has
already happened by the time a second finger makes it a pinch. Undoing it
has to be exact, or a cancel that restored the selection but left the
anchor moved would make the next shift-click select a run from a cell
nobody pointed at, so capture and restore are a tested pair.
**And it was not smooth.** Two reasons. The pinch was thresholded into ±1
steps of 25%, so the grid lurched and then sat still; it now takes the
ratio since the last update and tracks the fingers, with the drawn cell
still landing on whole column counts because the columns divide the
width. And `zoom-cells` was the one geometry change still reloading
inline — a full catalog re-read, 360-row model rebuild and thumbnail
batch per step, on the thread drawing the frame. It goes through the same
settle timer as the rest now.
Develop opens one photograph. The grid handed over a path and nothing
else, so `index` and `total` were pinned to "1 of 1" on the way in and the
only route to the next frame was back to the library, find your place,
tap again. Fine once; intolerable through a set of forty, which is the
situation the develop view exists for.
The roll is the grid's already-loaded window along the foot of the
canvas. Swipe up to bring it out, swipe down to put it away — the sheet
gesture, already in the hands of anyone who has used a phone — and a
handle is drawn at the edge so the gesture is discoverable rather than
folklore, and so a pointer, which has no swipe to make, has a way in.
`SwipeGestureHandler` wraps the strip rather than sitting over or under
it, which is what it is built for: it delays a press the way a Flickable
does, forwards it to the children if no swipe develops and claims it once
one does, so a tap reaches the thumbnail and a drag does not. It covers
only the band along the bottom — above that, a drag still belongs to the
photograph, for panning and for the crop.
Picking goes through the same path a cell click does, so the outgoing
edit is persisted before the next image loads. The strip marks what is
open and scrolls to keep the mark in view.
The position readout now says where in the *library* the open photograph
sits rather than "1 of 1". Set after the open rather than before, since
the generic open path resets it — and given as the library ordinal, not
the row in the loaded window, which is an artefact of how much has been
paged in and would jump about as the window moves.
**A Slint layout cannot be narrower than its children's minimums.** Given
less room than they need it lays them out at those minimums, lets the row
run past the edge — and reports the oversized minimum upwards.
That second half is what made this more than cosmetic. The header, the
filter chips and the grid are siblings in one VerticalLayout, so the
widest row's minimum became the whole view's minimum: `LibraryGrid` was
laid out wider than the window. The grid then measured itself against
that inflated box and sized its columns to fill space that was off the
screen, so the right-hand column was cut by the edge no matter what the
tiling arithmetic did. Fourteen filter chips do not fit across 768
logical pixels, so that was every tablet in portrait.
It also explains why opening the collections sidebar did not reflow the
grid. The view was already pinned at a minimum wider than the window, so
taking 232px away for the sidebar could not shrink it — it just clipped
more of it.
Each of those rows is now a horizontal Flickable. A Flickable's own
minimum is nothing, since it exists to be smaller than what it holds, so
none of them can inflate anything — and the controls past the edge became
reachable instead of merely absent. Applied to all four: the library
header, the filter chips, the compact action row disclosed by "More", and
the develop strip.
A pinch is not one zoom step, it is a stream of them, and every step
changes both the column count and the capacity — two reports. Each report
re-queried the catalog, rebuilt all 360 rows of the model, re-read the
badges and ratings for every one of them and spawned a thumbnail batch,
synchronously, on the thread trying to draw the frame. Twice per step.
That is why zooming juddered while scrolling the same grid is smooth: a
scroll reloads a few times per screenful, a zoom reloaded twice a frame.
None of that work is urgent, because none of it is about which
photographs are on screen. The window holds the same images however they
are laid out — the model already has them, and the cells re-flow from
`columns` and `cell-size` with Rust not involved at all. What the reload
actually recomputes is which cells begin a row, so the month headings
land correctly, and which thumbnail size class to ask for now. Both can
wait for the gesture to finish, so both are now coalesced behind a single
settle timer: replacing the timer drops the previous one, and only the
last report of a run lives long enough to fire.
The anchor is captured on the first report of a run rather than read when
the timer fires. As the grid re-flows the viewport keeps its pixel offset
while the rows move underneath it, so the view drifts and reports the
drift; reading the anchor at the end would faithfully return to wherever
it had wandered. Taking it at the start returns to the photograph the
user was looking at when they started the gesture.
The cells were drawn at whatever pixel size the class asked for and the
remainder was left as a bare strip down the right-hand side — up to one
short of a full column of nothing, which on a phone is a quarter of the
screen.
It also had the two quantities depending on each other the wrong way
round. The column count was derived from the fixed cell size, so the two
could disagree about how much room there was and the last column could
start inside the viewport and end outside it.
Solving for the cell removes both at once. Pick how many columns of
roughly the requested size fit — rounded, not floored, because the size
class is a request rather than a measurement and a width nine tenths of
the way to another column should take it — then make those columns share
the width. `columns` cells and the `columns + 1` gaps around them come to
exactly the grid's width, so there is no remainder to strand and nothing
can overhang.
The size class still decides what the user gets, since it is what the
column count is chosen from. It just no longer dictates the pixel, so the
cell flexes a few percent either way to make the row come out even.
The render backend, the layout class and the frame rate are developer
readouts, and they were sitting in the middle of the one strip that also
carries the way out of develop, the undo pair, the panel toggle and
export. A HorizontalLayout given less width than its children's minimums
does not shrink them — it runs off the end.
A tablet in portrait is 768 logical pixels, which is not enough, so
everything from the panel toggle rightwards was pushed off the right-hand
edge. Below the breakpoint the develop column is *also* closed by
default, so a toggle that could not be reached meant the column could not
be opened at all — and copy and paste live in that column. That is the
whole of "I have no idea how to copy a setting on Android and apply it to
other images": there was no way to.
The three readouts now collapse to zero width below the breakpoint. Width
and not an `if`, because this strip is inside the layout that `expanded`
feeds and a conditional child here is the shape that has already caused
binding loops in this file — and because a `visible: false` child still
takes its slot in a layout, so hiding alone would have freed nothing.
A launch does not have to discover the library. The catalog from the last
run is on disk, complete, with its thumbnails in the shards beside it —
exactly the state offline mode already leans on when the server cannot be
reached.
Every launch that *could* reach the server threw that away. The catalog
handle was only opened when the scan reported Done, so the grid sat on
"Scanning…" over an empty EmptyState for as long as a recursive WebDAV
walk of the whole tree takes. On a real library that walk is essentially
the whole startup time, and it was spent hiding a grid that was ready
before it began.
The catalog is now opened and the first window loaded before the scan
thread is spawned — before, so the schema migration cannot race the
worker opening the same file, and so the first thumbnail batch is already
in flight while the walk runs. The scan still replaces all of it the
moment it lands; it just no longer gates the first paint on the network.
A first run has nothing to open, which stays silent: `Catalog::open`
creates the file, the grid reads an empty catalog, and the empty state
goes on saying "Scanning…" — which is true, and which an error here would
contradict.
Two faults behind "the gallery randomly glitches to blank and needs a
scroll to reset it", and behind the scrolling that skips.
Cells are drawn at their absolute place in the library, so the row a
photograph sits on is `index / columns`. When `columns` changes every
cell moves — and the Flickable's `viewport-y` did not move with them. The
view was left pointing at a row that now holds entirely different
photographs, typically thousands of images from the ones the loaded
window covers, so the grid drew nothing at all. It stayed that way until
a scroll reported a first-visible row and dragged the window back under
the view, which is exactly the reset the user found. None of its triggers
are rare: a resize, the collections sidebar opening, a zoom step, or
turning the tablet over.
The last first-visible ordinal names the photograph being looked at, so
it is now sent back through `scroll-to` and the view lands on that same
photograph at whatever row it now occupies.
The second fault is the window-move test. At either end of a scope the
window is pinned — the first screenful cannot be centred further back
than zero, the last cannot start past the last full screenful — so the
margin test was unsatisfiable there and every row crossed in the first or
last quarter of a window re-read the catalog, rebuilt the model and
issued a thumbnail batch to arrive at the offset it already held. On a
library of twenty-odd thousand that is a stutter at the top and the
bottom of every collection, which is where a cull begins and ends.
The decision is now a rule with tests rather than four lines inside the
scroll handler: both of its ways of being wrong are invisible in the code
and obvious on a tablet.
Work in progress found uncommitted in the tree, committed as its own
change so the fixes that follow can be read separately. Not authored in
this session; the description below is written from the diff.
`load_window` rebuilds every row, and a scroll reloads once the view has
travelled a quarter of the loaded window — so three quarters of the cells
being rebuilt are the same photographs already on screen. Rebuilding them
empty blanked the grid to `Theme.ground` and refilled it a beat later,
once a worker had re-read and re-decoded each one from the store. That is
the black flash on every screenful of scrolling, and on a column change,
a zoom step, a filter and a return from develop.
Thumbnails are now held by `image_id` across the swap — a refcount per
cell, no pixels move — along with the "no preview" verdict, which is an
answer about the file worth keeping for the same reason. `requested` is
rebuilt from what the new model actually holds rather than cleared, so a
carried cell is not fetched again while one newly scrolled in still is.
The size class each cell's pixels came from is tracked alongside, so a
grid zoomed past that class still asks for the sharper one.
Also guards the whole `scrolled` and `columns-changed` handlers on
`show-library` rather than just the resume latch: a Flickable being torn
down passes its viewport through zero, which was indistinguishable from a
fling to the top and reloaded the window against the first rows of the
catalog every time an image was opened.
Two separate faults, both only reachable with a second finger.
The gesture never arrived. `ScaleRotateGestureHandler` was sized `100%`
inside the Flickable, which is the Flickable's own height, not its
viewport's — so the handler was one screenful tall at the top of a
viewport thousands of rows long. A pinch is delivered to whatever lies
under the midpoint of the two fingers, so it landed on the handler only
while the grid was scrolled to the very top and found nothing anywhere
else. Sized to `viewport-height` now, exactly as `zoom-catcher` above it
already is.
And the attempt opened a photograph. When a second finger lands Slint
closes the first one's gesture by synthesising a `Released` at its
position — that is how a Flickable is persuaded to let go of a scroll it
has already claimed. A TouchArea cannot tell that release from a real one
and fires `clicked`, so every pinch opened whichever image the first
finger was resting on.
The finger id separates them: the synthetic release carries the id of the
finger that *arrived*, never the one that pressed. `clicked` fires before
the pointer event that names the finger, so it now only raises a flag and
the `up` handler decides. A mouse reports 0 for both, leaving the desktop
path exactly as it was.
The rating strip was a sibling of the cell's TouchArea, declared after it
so that hit-testing reached the stars first. That half worked: a star
click set a rating without also opening the image.
The other half did not. Hover is tracked per TouchArea, and Slint sends
`Exit` to any item that drops out of the hit path. The strip taking the
pointer is exactly that — `cell-touch` left the path, `has-hover` went
false, and `show-empty` went with it. On an unrated cell the stars are
drawn *only* on hover, so they vanished as the pointer arrived at them;
the click then landed on the cell behind and opened the image. Reported
as "the star menu disappears when I click on an image", which is
precisely what it does.
Nesting the strip inside `cell-touch` keeps both halves. Children are
hit-tested before the element containing them, so a star still wins the
click and still ends the walk before `cell-clicked` runs. And an ancestor
stays on the item stack: it gets no `Exit`, and while a child holds the
grab it is handed the event filter and never the event. Hover therefore
holds for as long as the pointer is anywhere in the cell.
`cell-size`, `columns` and `visible-rows` were all derived from the
LibraryGrid's own width and height. The cells are not drawn in that box:
the capture-time axis is a sibling of the grid, 96px of it, and the
header takes another 44px off the top.
So `columns` counted the timeline as room for thumbnails and fitted one
more column than there was space for. The last column started inside the
Flickable and ended outside it — clipped, with no sideways scroll to
reach it. At the 180px size class on a phone in portrait the timeline is
a quarter of the screen, which is most of a column.
`visible-rows` was wrong the same way and fed `capacity`, so every window
over-fetched by the ratio of the header to the viewport.
Both now read `grid-area`, the layout the cells actually live in. That is
a descendant, and reading a descendant's geometry is the shape that
causes binding loops elsewhere in this UI — but not here: nothing derived
from these feeds back into the layout. The cells are placed absolutely
inside the Flickable and a Flickable's layout constraints are a bare
`stretch: 1` that its viewport cannot influence.
There was no icon anywhere, and on Android that was not a missing line in the
manifest. `aapt2 link` was being handed a manifest and nothing else, so the
APK carried no res/ and no resources.arsc — there was no table for an
`@mipmap/...` reference to resolve against even if one had been written.
Packaging now compiles the resource tree first and links the result in, which
is the two steps aapt2 insists on: link reads compiled input only, never a
directory.
That absent table is also why the launcher caption was blank, which had
looked like a second, separate bug. `android:label="DarkRoom"` was there and
correct the whole time, and Settings' App info read it fine; the launcher
could not, because resolving a label goes through the package's Resources and
there were none to open. Nothing about the label changed here. It came back
with the table under it.
`android:icon` then names one drawable for both icon generations, because the
`anydpi-v26` qualifier is what separates them. API 26 and up take the adaptive
icon and its three layers; the third of those, monochrome, is what lets
Android 13 recolour it rather than drop the app out of the themed set. Below
26 the same name lands on a density-matched PNG. `roundIcon` is deliberately
absent — a launcher old enough to read it is one that would ignore the
adaptive XML, and minSdk is 28.
The desktop icon is one `@image-url` on the window, and the only raster asset
in a UI that is otherwise entirely Path. The reasoning at the top of
icons.slint does not reach it: that is about glyphs a font might not carry,
and this image is never drawn by us at all. It goes to the window manager,
which wants pixels and composites them unmasked, so it is pre-shaped with
rounded corners rather than square the way the Android layers are.
Which exposed Slint's resource default. An `@image-url` compiles down to the
absolute path it had on the build machine, to be opened at runtime — already
wrong for Android, where the build happens under /work inside a container and
no such directory exists on the device, and wrong silently, as an image that
loads empty. `EmbedFiles` puts the bytes in the binary instead. It reaches
nothing else, since every glyph is a Path.
Verified on a device: the APK installs and the home screen draws both the
icon and "DarkRoom" under it, where before it had neither. In the link step
the adaptive icon resolves at all six densities and resources.arsc lands
uncompressed, which API 30 requires and the existing zipalign preserves. On
the desktop by reading _NET_WM_ICON off the running window — 256x256, as
handed over. Where that actually shows is narrower than it sounds, and the
comment says so: Wayland ignores the property in favour of matching app_id
against an installed .desktop file, which this repo does not install.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The colour mixer emits a code block and a uniform only for the bands that
are set, so which bands are adjusted is part of the shader's structure. The
pipeline cache key was not: it hashed the set of *active operations*, which
is "colour_mixer" whichever band that is.
So a red adjustment and a blue one hashed alike. The second render was handed
the first's compiled pipeline while its uniform was uploaded into a slot that
shader had assigned to another band — whichever band compiled first kept
acting on every subsequent move, and every other slider did nothing at all.
Red is the first band declared, and the one reported as the only one working.
The hash is now taken over the generated WGSL, because the source is what
gets compiled and therefore is the structure. A summary of what went into it
has to be kept in step with every operation's code generation by hand, and
this one had fallen out of step. Values still do not enter it: no operation
writes a parameter value into its source, so a slider drag regenerates
identical text and reuses the pipeline, and one that did inline a value would
have to recompile to be correct anyway.
`each_colour_band_gets_its_own_pipeline` in dr-gpu renders a blue pixel
through one pass with red set first and then blue, and fails on the old hash
with the reported symptom — the blue slider returning the pixel unchanged to
the byte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The framing prologue built the centred position `p` by scaling with the
source's aspect, then straightened, then permuted the quarter turns. On a
landscape frame those are one space and it worked. Once a turn has swapped
the axes — the rotate button, or a file whose EXIF tag says the camera was
held sideways — they are not: `p` was measured with the source's ruler on a
frame that is no longer that shape, stretching one axis against the other by
(w/h)², which is 2.25 on a 3:2 photograph.
The quarter-turn permutation happened to undo that stretch, so rotation alone
looked right, which is how this survived. The straighten in between did not,
and a rotation in a space whose axes carry different scales is a shear.
`p` is now built in `frame_aspect` — the frame as the user sees it — and the
permutation becomes the one place the two rulers meet: each axis divided by
the aspect it is read from, multiplied by the aspect it is written to.
Asserted on pixels rather than on the generated WGSL, because reading the
shader and reasoning about which space `p` lives in is how the wrong formula
got written in the first place: a disc, straightened by 20° on a turned 3:2
frame, must come back circular by every route to a swapped frame — the
button, the tag, and the two composed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The gesture is Slint's and cannot be driven from a test — synthetic drags
do not reach a DragArea at all — but the decision it leads to is where
this can actually go wrong, and it was buried in a callback.
`decide_drop` names the three outcomes and the order that separates them:
an image drag always fills the payload, so photographs pressed after a
row was clicked are still filed rather than read as a rearrangement. A row
dropped on itself is a no-op here rather than a cycle error from the
catalog, and an empty drag with nothing remembered — a file from another
application — leaves the tree alone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tree could be built nested but never rearranged: `set_parent` existed,
with its cycle check and its tests, and nothing in the UI called it. A
collection created in the wrong place stayed there.
Each row is already a drop target, so it becomes a `DragArea` too —
wrapped at the instantiation site the way the grid's cells are, which
keeps the row's own TouchArea nested underneath and a click still
selecting. `allow-move`, not copy: a collection has one parent, unlike a
photograph, which is filed in as many collections as you like.
The drop is handed only the target's id, so the source is remembered from
the press that precedes the drag — Slint builds the payload through a
`pure` binding, which must not have side effects. An image drag always
fills `dragging`, so an empty payload with a remembered row is
unambiguously a rearrangement; the row is taken rather than read, or a
later empty drop would move a collection nobody touched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The library could be narrowed by rating, flag and availability, but not
by when a photograph was taken — so finding a fortnight meant scrolling
to it and holding position.
The range rides on `RatingFilter` for the reason `local_only` already
does: every query path threads that one struct, so the count in the
header cannot claim a total the grid does not draw. Undated images are
excluded whenever either end is set — they cannot be inside or outside a
span, and drawing them made the range look as though it had not applied.
Taken from the timeline rather than typed into two date fields. Finding
the period is what the histogram is for, and having found it the user
should not have to read the dates off the axis and key them back in.
The histogram keeps drawing the full extent while the range is on, or
there would be nowhere to widen back out from.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Scrolling rebuilds every cell, and a fresh cell carries `selected: false`.
`sync_badges` and `sync_ratings` refilled what the rebuild cleared;
nothing refilled the selection, so the ticks vanished on every scroll.
The selection itself was never lost — it is a set of image ids and
survives untouched — which made this worse than losing it: the header
buttons still acted on forty photographs the user could no longer see
were held.
`load_window` reaches the selection through a `Weak` handle set at
wiring. Weak because the two controllers are joined only through the
window, and an `Rc` each way would leak both; absent, the grid draws
nothing selected, which is what it did before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The timeline counted the whole library whatever the grid was showing, so
opening a collection left a fortnight in Arosa as one column of a
fifteen-year axis — an axis describing photographs that were not on
screen.
Scope the buckets and the span to the same collection and rating filter
the grid uses. `timeline_range` counts `images` alone and cannot express
the membership join, so the scoped query lives beside the other scoped
readers in the UI and shares their descendants-of-scope rule.
`catalog_span` now delegates to the same scoped reader. Zoom and scrub
measured the full library while the bars were scoped, so a scrub could
land on an instant the collection did not contain and send the view
somewhere the user had not asked to go.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The merge inserted every incoming collection with `parent_id = NULL` and
never set it on update, so the hierarchy flattened on each round trip: a
collection nested on one device came back from the server at the top
level. `r.parent_id` was selected and then not read.
The id could not be copied — row ids are local, and the remote's integer
names a different collection here, or none. So carry the parent's uuid
and resolve it locally, in a second pass: rows arrive in whatever order
the query returns, and a child can precede its parent.
Guard the resolution against cycles. Each tree is acyclic alone, but the
union need not be — we may hold A above B while the remote holds B above
A — and closing that loop would make every tree walk spin.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The probe PUT its bytes first and read the outcome, which answers the
question by destroying the evidence: pointed at a real sidecar it would
replace an edit with the word "probe", and on success delete it outright.
PROPFIND first. Permissions and status usually settle create-versus-update
on their own, and a path that already exists is now reported and left
alone. `--write` still forces the update test for a file worth losing, and
the cleanup DELETE fires only for a path the probe itself created.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 403 probe read `oc:permissions` off the parent collection and warned
when `W` was missing. But Nextcloud reports `W` on files and `CK` on
collections, so a directory legitimately lacks `W`: the warning fired on
a healthy share and pointed at a mount that was fine.
Probe the file itself. Its permissions answer the question that matters,
and the status distinguishes the two cases the parent could not: a 404
means the sidecar does not exist and the refusal was about creating it,
while a 200 without `W` means it exists and cannot be updated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ratings and edits made on the tablet queue and are then refused on reconnect,
on a credential that pushes the catalog to the same library root in the same
sync pass. Chased on the device, since that is where the account lives.
A failed PUT now logs the server's own words, and on a 403 asks the parent
what rights it reports. The answer:
PUT .../PhotosRaw/2026/2026-08-03/_MG_9221.drsc -> 403
<s:exception>Sabre\DAV\Exception\Forbidden</s:exception>
parent permissions: MGNVCK
`M` mounted, `G` readable, `N` renameable, `V` moveable, `CK` create files and
folders. Absent: `W`, update an existing file, and `D`, delete. So `PhotosRaw`
is a mounted share that accepts a file once and refuses every change to it
afterwards.
That is the whole bug, and it is not one this side can retry its way out of. A
sidecar is rewritten on every rating and every edit, so the first judgement on
a photograph is written and every later one is refused — which reads as sync
being broken rather than as a share missing one permission. **The fix is to
grant update, and ideally delete, on that mount.**
`PermissionDenied` now says so, rather than "not allowed to write here", which
sent the reader to re-check a login that was working. Its test asserts the
intent — points at the folder, never at the credential — rather than a
phrase, so saying it better cannot read as a regression.
Also adds a `put_probe` example that makes the same request from a stored
session, for diagnosing this from a desktop when one is signed in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A queued sidecar fails to upload with 403 on a credential that pushes the
catalog to the same library root in the same pass. `map_status` reduces every
non-success to a typed error, which is right for the application and leaves
nothing to work from: a read-only share, a file access control rule and a lock
all arrive as `PermissionDenied`.
Sabre says which in the response body. It is now logged on any failed PUT —
the URL, the status, and the first line naming the exception or message,
capped at 300 characters because an error page can be a whole document. Only
on failure; a success has no body worth reading.
Also adds a `put_probe` example that makes the same request from a stored
session and prints the reason, for diagnosing this from a desktop rather than
from a tablet's logcat. It needs a session on the machine it runs on, which is
why the log line above exists as well.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The write path learned to say which file the server refused; the drain path —
the one that runs for work queued while offline — still reported only a count.
That is the path that matters most, because everything it carries was made
with no connection and exists on one device.
With it named, the failure on the tablet is:
draining PhotosRaw/2026/2026-08-03/_MG_9221.drsc:
permission denied
on a credential that pushes the catalog to the same library root in the same
pass. So this is not the account and not the app password: one path is
writable and another is not, which points at the server — a read-only share
over that folder, or a file access control rule on the extension — rather than
at anything this side can retry its way out of.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A queued sidecar failing to upload was reported as a count and a reason —
"1 queued sidecar(s) still undelivered: permission denied" — with the path
only at debug level, which the app filters out by default.
That is unsynced user work: a rating or an edit that exists on one device and
nowhere else. Which photograph it belongs to, and which path the server
refused, is the whole of what makes the failure actionable, and without it a
403 on a single file reads the same as a whole library failing to sync.
Observed on the tablet, where writes to the derived folder succeed on the same
credential — the catalog pushes fine — while one sidecar beside its image is
refused. That combination says the path matters and the account does not, so
the path is the thing worth printing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every clipped sky came out bright pink. Measured, not guessed: developing
_MG_8596.CR2 and looking at the export, the subject renders correctly and only
the saturated region is wrong.
A fully clipped pixel reaches the shader as (1, 1, 1) — three photosites that
stopped counting, carrying no colour at all. The as-shot multipliers are not
neutral, so balancing sends it to (1.93, 1.00, 1.68) on this body, and the
camera matrix turns that into R 2.88, G 0.51, B 2.03. Red and blue clip at
one; green, whose matrix row is far less positive-heavy, does not. Red and
blue high with green low is magenta.
Nothing upstream was at fault, which is why the two previous attempts missed
it: the white balance is correct, the matrix is correct, and the sensor
normalisation is correct. The input simply was not a colour, and correct
arithmetic on a non-colour produces a confident wrong answer.
So saturation is detected before the balance is applied — the last 1.5% of
range, smoothstepped rather than switched, because a hard threshold draws a
visible rim around every highlight and a backlit edge on skin is where that
shows. Above it the pixel is pulled to the neutral of its own brightness, so
it keeps its luminance and loses only the cast.
Verified end to end on the file itself: the sky is white, the skin, the black
dresses and the stone are unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two corrections to the sensor stage, found while chasing magenta highlights.
Neither is the cause of that — see below — but both are wrong on their own
terms.
`white_level` took the *first* of rawler's per-channel saturation points. On a
Canon 6D that reports 15070 while the data reaches 16383, so every sample
above it was treated as brighter than white. It takes the maximum now.
The normalisation clamped its floor and not its ceiling, so those over-white
samples passed through as values above 1.0. Clamped at both ends.
**This does not fix the pink.** Measured on _MG_8596.CR2, exported and looked
at: the subject renders correctly and only the blown sky is magenta. A fully
clipped pixel is (1,1,1) in raw, the as-shot balance multiplies it to
(1.93, 1.00, 1.68), and the camera matrix turns that into R 2.88, G 0.51,
B 2.03 — red and blue clip at one, green does not, and the result is magenta.
It is correct white balance applied to already-saturated data, which is the
classic highlight-clipping cast and needs highlight desaturation to fix: a
pixel at saturation carries no colour information and must be rendered
neutral, not balanced.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Exporting a real 20 MP CR2 failed every time with "readback did not
complete", while the copy itself was perfectly healthy.
The bound was 100,000 non-blocking polls. That sounds generous and is not: a
`Poll` that finds nothing returns immediately, so the loop spent its entire
budget in a few milliseconds. Small transfers — the histogram's 4 KB, a
viewport-sized frame — happened to land inside it. An 80 MB frame never could.
It is a deadline now, thirty seconds, which is the only thing the bound was
ever for: catching a lost device that will never deliver the callback. A
one-millisecond pause after the first sixty-four spins stops the loop
saturating a core for the length of the copy, while keeping a small transfer
as immediate as it was.
Found by developing /home/dtourolle/Downloads/_MG_8596.CR2 through the export
example: 5472×3648 renders in 127 ms and writes all five formats.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`sane_wb` replaced any coefficient it could not use with 1.0. That reads as a
safe default and is not one. A Bayer sensor's green photosites collect roughly
twice the signal of its red and blue, so unbalanced data is strongly green —
and the camera matrix is built assuming the data reaching it has already been
balanced. Fed green-heavy input it subtracts green as designed, overshoots,
and the frame lands in magenta. Bodies whose as-shot coefficients rawler does
not report came out pink, and nothing anywhere said why.
The fallback is now the camera's own response to daylight, which
`cam_to_srgb_from` was already computing on its way to balancing the matrix
and then discarding. `daylight_wb` exposes it, and both callers read the same
matrix through the same illuminant preference — so the multipliers neutralise
exactly the white the matrix expects to be neutral, by construction rather
than by coincidence. With no matrix either, the body is unknown and neutral is
the honest answer: uncalibrated beats wrong in a specific direction.
A test caught me returning the response rather than its reciprocal, which
inverts the correction — a sensor is *least* sensitive to the channel needing
the largest multiplier, so that version boosted precisely the wrong one. The
doc comment now says which of the two it returns, because they differ by an
inversion and look alike.
Four tests, on a real matrix (Canon 6D, D65) rather than a contrived one: the
fallback is nowhere near neutral, lifts both red and blue against green, stays
green-normalised, and — the property that makes it consistent rather than
merely plausible — balancing by it and then applying the matrix maps the
camera's white to a neutral sRGB.
`daylight_wb` is also the anchor the white-balance presets need: a preset in
kelvin requires an absolute illuminant to be a preset *of*, and the temperature
control is currently a relative offset from whatever the camera chose.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Only the sliders scrolled. The capture metadata, the histogram, the geometry
controls and copy-and-paste all sat above them in a fixed layout, so on a
280px column in portrait they took the height the sliders needed — and the
histogram, which is the instrument the sliders are judged against, could
neither be scrolled to nor scrolled past.
The Flickable moves out of `AdjustPanel` and around the whole column. Nesting
one inside the other was not an option: a slider drag already has to be won
against one scroller, and a second would give it a third thing to be lost to.
`slider-dragging` becomes an `out` property for the same reason. The
arbitration is unchanged and still necessary — a track stands the scroller
down as soon as a finger touches it, or every attempt to drag a slider would
scroll the column instead — but the scroller obeying it now lives a level up.
The scroll gutter stays where it is, as padding inside the content: it is what
guarantees somewhere to put a thumb that means "scroll" and nothing else, and
it matters more now that it serves the entire column.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Chasing a reported shear on rotate and straighten. Two tests, and what they
prove is that the pipeline is not where it comes from.
A circle is the shape that makes anisotropy unmissable: any transform scaling
the axes unequally returns an ellipse, and the ratio of its axes is the error.
Both a quarter turn and a 20° straighten, on a 3:2 frame, return a circle
within 8%.
Worth recording because I had a confident and wrong hypothesis first. The
quarter turn carries `* aspect.x` on one component and `/ aspect.x` on the
other, which reads like an anisotropy of a-squared, and the reasoning that
`p` is already isotropic is plausible enough that I changed it. The existing
`a_quarter_turn_corrects_for_aspect_across_the_swap` caught that immediately,
and these tests then showed the original was right all along: the crop rect is
expressed in the *turned* frame and the output axes swap with it, so the
factors are the conversion between those spaces rather than a mistake.
Reading the shader and reasoning about which space `p` lives in is exactly how
a plausible formula gets written twice. These assert on real pixels off a real
adapter instead, so the next person to suspect this transform can rule it out
in one command.
The shear is therefore in the display path — the fit from the framed size to
the viewport, or the crop overlay's uncropped render — and not in the geometry
the pipeline computes. Not yet fixed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three lints, all from merged work rather than from any one branch:
`terrace` and `disc` were steps on the way to the ramp the plateau test now
uses, and the reasoning that discarded them lives in docs/segmentation.md §12
rather than needing the code; two mechanical clippy suggestions in segment and
cache.
1176 tests pass, clippy and fmt clean, traceability regenerated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit moved durable data out of Android's cache directory, and
on its own that would have been an upgrade that quietly discarded work. The
app looks in the new location, finds nothing, and rescans a library of tens of
thousands of images over the network — while the old copy, including every
offline rating and edit that had not yet synced, sits in a directory the
system is free to delete.
So the account's directory is moved once at startup, before anything opens a
store. A rename rather than a copy: both are inside the app's own data on one
filesystem, so it is atomic and cannot half-finish. An existing destination
wins and the move is skipped — that covers a second run and a fresh install,
and in neither case may this overwrite live data.
A failed move is logged, not fatal. The cost is a rescan, which is
recoverable; refusing to start is not.
Two tests, against ordinary directories rather than the platform's idea of a
cache: one puts an unsynced sidecar in the old location and asserts it is
readable in the new one afterwards, the other pins that live data is never
overwritten.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things a tablet could not do. Both existed for a pointer and had no
touch form at all, which on Android meant the collection sidebar was
somewhere to look at rather than somewhere to file into.
**Selecting more than one.** Ctrl-click and shift-click are the only ways
into a multi-selection, and touch has neither. Holding a cell now enters
selection mode, where a tap toggles — reported to Rust as a ctrl-press, so
it goes through the same `apply_press` as everything else rather than
growing a second copy of the selection rules. A double tap takes the run
between where selecting began and there: the touch form of shift-click,
and the reason the anchor from *before* the double tap has to be
remembered, since both of its taps move the anchor onto the cell being
tapped. A "Select" button does the same thing where a gesture would go
undiscovered (FR-UI-4).
**Filing without a drag.** A one-finger drag beginning in the grid belongs
to the Flickable that scrolls it — that is the arbitration working, not a
bug to route around — so the selection can now be filed from a sheet
listing the sidebar's own rows. Copy by default, as the drag has always
been; moving out of the collection being shown is a switch, because it is
the one that takes something away.
**Taking a collection offline.** The machinery was there and reachable only
by scoping the grid to a collection and finding a button behind a
disclosure. Holding a collection's name now asks the question directly, and
the tray on a row and the header button ask the same one — three
affordances doing two different things is how a user comes to avoid all
three. The question is asked rather than a toggle flipped because both
answers are expensive: one downloads gigabytes, the other deletes them, and
the counts and sizes go in the buttons where they are read before the tap.
`Cache::release` is new and is the destructive half `unpin` deliberately is
not. "Remove the local copies" is asked by someone whose device is full,
and withdrawing a promise while leaving the bytes for a future eviction to
notice is not an answer to it. It unpins before forgetting, or the next pin
fetch would dutifully download everything it just deleted.
The sidebar's trays read `tier_actual`, never `tier_desired`: the question
is whether these will open on the aeroplane, and a pin whose download has
not run yet answers no.
TRACES: FR-CAT-7 | FR-NC-6a | FR-NC-6b | FR-NC-6c | FR-UI-2 | FR-UI-3 | FR-UI-4
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>