Three things a selection needed and did not have.
**Seeing it.** The count existed — "12 selected" — in the header row, which
scrolls sideways. On a tablet it sat past the right-hand edge along with every
button beside it, so a selection was something you could make and then not
see. A selection you cannot see is one you act on by accident.
**Putting it down.** The only way to clear one was "Done", which also leaves
select mode — so after filing forty photographs the next forty began by
re-entering a mode the user had not meant to leave. Clearing is now its own
action and keeps the mode.
**Filing it somewhere new.** Making a collection of a selection took four
steps: create one, find it in the tree, select the photographs again because
creating it changed the scope, then add them. It is one press, which is how a
selection is usually meant — it is gathered *because* it is going somewhere.
The new collection is created at the top level rather than inside the current
scope, unlike the tree's "+". A selection can be gathered from anywhere,
including across collections, so filing it under whichever one happens to be
open would put it somewhere its contents did not come from. It opens straight
into its name field, for the reason `collection_new` already does: the
placeholder name is nobody's choice, and making the user find the rename
afterwards is asking them to finish a job we started.
All of it on its own strip beside the date range's, appearing only while there
is a selection — the third control this session that was invisible for being
put in a row that scrolls.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pressing "limit to range" did nothing, and the reason was two early returns
that sat above the line which shows the controls. If the catalog was not open,
or nothing in it carried a capture date, the handler returned before
`set_library_range_active`, so no state changed and nothing appeared.
Capture dates are read from EXIF as thumbnails load, so a freshly opened
library has none — the button was inert on exactly the libraries where someone
is most likely to go looking for a date, and it failed by doing nothing at all,
which is the hardest failure to report.
Underneath that was a smaller mistake with the same shape: whether the controls
showed was read from whether a range was set. The fields are how a range gets
set, so requiring one before they appear is a door locked from the inside. The
panel has its own state now, and the timeline span is a seed for the fields
rather than a precondition for them — no span means two empty fields waiting to
be typed into, which is a way to choose a range rather than a refusal to offer
one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`cargo fmt --check` is a required step and the mask-refine work landed with a
test body it disagrees with. Whitespace only — kept as its own commit so it can
be skipped wholesale rather than read for a change that matters.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two related gaps in the mask panel, from the same conversation: a subject's
outline is only ever as sharp as the whole-frame pass that found it, and a
change meant for several layers had to be dragged once per layer.
## Refine mask
A "Refine mask" button on a subject layer re-runs detection on a padded crop
around that instance's own box instead of the whole frame — the subject
reaches the model at its own size rather than squeezed into the model's fixed
640x640 window alongside everything else in the photograph. `RefineJob`
mirrors `SegmentationJob`'s split (built on the session, run off it, adopted
back), and the crop itself is rendered through `Framing::set_view` — the same
ephemeral viewport the interactive zoom already uses to render a region above
proxy resolution, so no new render path and no change to the model's own
input size was needed. `dr-segment` is untouched: `Tiling::Whole` already
treats whatever buffer it is handed as the one window.
The result is still downsampled onto the shared proxy grid every instance's
mask lives on, but from a sharper source than the whole-frame pass ever saw
for that subject, which is what the edge actually reads out of.
## Multi-select
`active_mask: Option<String>` is now `active_masks: Vec<String>`. A plain
click still replaces the selection; a control- or command-click toggles one
layer in or out of it. `set_param` and `reset_op` fan out to every selected
layer, each set to the exact value the slider now shows rather than offset by
however far it already was — one slider, one reading, applied everywhere
selected. Dragging a gradient's on-canvas handle is deliberately not
extended to multi-select: several gradients have no single geometry a shared
handle could move, so `gradient_handles` stays empty unless exactly one
layer is selected.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
"Limit to range" did nothing, and the reason was not visible from the button.
It took its span from the timeline's zoom, which is zero until someone zooms —
so `zoomed_span` returned the whole library and the filter narrowed to
everything. The chip lit up and the grid did not change.
The range has ends now, shown and typed as `YYYY-MM-DD`. Seeding them from the
timeline is kept, because zooming to a fortnight and pressing the chip is the
fast path; the fields say which fortnight it landed on and let it be corrected.
Ends given backwards are swapped rather than refused — there is exactly one
range between two days — and the closing day is included, since "to the 5th"
means the whole of the 5th and a range ending at its midnight contains none of
it. `parse_date` refuses anything that is not a date rather than guessing at an
order, because the alternative is a library silently filtered to a span nobody
asked for.
The axis then follows the range. It used to keep drawing the full extent while
a range was on, because it was the only way back out; the typed ends are the
way back out now, so it is free to show what was asked about.
And bucket size is chosen by how many bars it makes rather than by fixed
cut-offs. Each zoom step halves the span, so under thresholds the bar count
halved with it until a boundary was crossed: fifteen years went 15 bars, 8,
then 46, 23, 11, and finally 6. Zooming in made the picture coarser, which is
the opposite of what zooming is for. Aiming at forty bars keeps the count in
the same neighbourhood at every level, and the test asserts the property
directly — halving a span never coarsens the bucket.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A local adjustment survived until the session ended and then did not exist.
Nothing reported it, because nothing had failed.
The sidecar format has carried masks since they were added, `Version::apply`
restores them into the graph, and `Version::update` captures them — that work
landed complete and was never called. The autosave writes `copy_settings()`,
which is a `Preset`: a map from (operation, parameter) to a number. A mask is
not a parameter. It is a rule about *where*, with a chain of its own, so it
fell outside the only thing being written, and the read path had nothing to
read.
The save now carries the stack beside the preset, on both the local and the
remote path. Deliberately not merged but replaced wholesale: this is the stack
as it stands, so a layer the user deleted has to leave the file too. Merging
two devices' stacks is `Sidecar::merge`'s job and belongs to sync (FR-NC-9).
A paste still carries no masks, and the `Option` is how that is said. Settings
travel between photographs; a mask does not, because it is drawn against one
frame and describes nothing on another — and `Scope` cannot express that,
since it filters parameters and a mask is not one.
The test fails against the old save path with "the mask must come back with
the photograph", which is the whole of the defect: not a crash, not an error,
just an edit that was not there in the morning.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A grid cell is about four words wide and `.CR2` spent one of them saying
something the photographer already knows: in a RAW library every frame ends
the same way, so the extension distinguishes nothing while taking room from
the part that does. The caption elides from the end under pressure, so an
extension can push the digits that actually identify a frame off the visible
part of its own label.
Dropped in the view, not in the model. `LibraryCell::name` keeps the true
filename and `remote_path` the full path, because both are used to find the
file again and a stem is not a filename.
The case it is wrong for, recorded rather than discovered later: a library
holding `IMG_1234.CR2` beside `IMG_1234.JPG` now shows two cells captioned
`IMG_1234`. They remain two rows with two thumbnails and two entries in the
info panel, and a RAW+JPEG pair is usually one photograph anyway — but the
caption alone no longer separates them.
Only the last dot goes, and only when something precedes it: `2026.08.23-a.dng`
keeps its dates, and `.hidden` keeps its leading dot, because that dot is how
the name starts rather than an extension.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The render backend, the layout class and the frame rate sat permanently in a
44px strip that also carries the only way out of develop, the undo pair, the
panel toggle and the export button. They cost about 200 logical pixels, and a
`HorizontalLayout` given less width than its children's minimums does not
shrink them — it runs off the end.
There was already a breakpoint hiding them below 820px, which is why a tablet
in portrait (768) looked fine and landscape (1200) did not: above the
breakpoint the readouts came back and pushed the header off the right-hand
edge. A breakpoint that hides a problem at one size and not another is a
workaround, and this removes the reason for it rather than moving it.
They are diagnostics — read once when something looks wrong, and then not
again — so they are in Settings under ABOUT, beside the version, which is
where someone goes when they have a bug to report rather than a photograph to
edit. The version is there for the same reason and comes from
`CARGO_PKG_VERSION`, so the line cannot disagree with the binary showing it.
The frame rate keeps its warning hue. It is the number that says whether the
zero-copy display path is holding up, which is the assumption the whole
display design rests on, and that is worth colour wherever it is shown.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three faults reported together, and they share a shape: each is something the
panel decided on the photographer's behalf.
**The column was 280px on every screen.** That width was chosen for a tablet,
where the column is a large fraction of the display and every pixel of it is
taken from the photograph. On a desktop window the mode strip alone — two
modes, a separator, "All", and a chip per attribute the operation set declares
— does not fit, so it scrolled sideways. A control you have to pan to reach is
one you do not know is there. 380px when the window is classed expanded, 280px
when it is not; driven off `layout-class` because reading the window width
inside the layout that sets it is a binding loop.
**The overlay could not be hidden.** An earlier "Overlay" button was removed
for a good reason — it *armed* the overlay, so local mode could be entered and
still show nothing. Hiding is the opposite need and was never served: a mask is
judged against the photograph beneath it, and that photograph is exactly what
the overlay covers. `overlay-hidden` is kept separate from `overlay-on` so a
recompute cannot switch the overlay back on under someone who just turned it
off.
**The masks were coarse because the model saw the subject small.** The graph's
input is a fixed 640x640 and every frame is letterboxed into it, so a bird
200px across in a 1600px proxy reaches the model at 80px. `Tiling::Grid` has
been implemented and tested since the segmentation spike and defaulted off,
because it costs one inference per tile — 2.8s for a 3x2 grid against 470ms.
Now offered as "Look closer (slower)", which says what it costs, rather than
spending it on every image or on none.
The tiling choice enters the segmentation signature. A mask stores the
signature its region ids index into, and a tiled run finds different instances
in a different order; sharing a signature would silently reinterpret a layer
built against the coarse pass — a wrong mask rather than a stale one, and
nothing announces it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The icon was already embedded and had been all along: `app.slint` sets it and
`build.rs` compiles it into the binary with `EmbedFiles`. That is not what a
Wayland compositor looks at. It ignores a client-set icon entirely, matches
the surface's `app_id` against installed `.desktop` files, and takes the icon
from the one whose basename agrees. Nothing set an app id and no desktop entry
existed, so GNOME had nothing to resolve and drew the placeholder.
Both halves are needed and neither substitutes for the other: the embedded
icon is what X11 and the window itself use, and the desktop entry is what the
overview, the dash and alt-tab use.
The app id, the `.desktop` basename and the installed icon's filename are one
string in three places — `paris.tourolle.darkroom`, the same reverse-DNS name
the Android manifest already uses, because it is one application. Change one
without the others and the icon disappears again with nothing logged, so each
file says so where the string appears.
The PKGBUILD builds from the checkout rather than a release tarball, so
`makepkg -si` installs what is being worked on. Install verified by inspection
of the built package: binary, desktop entry, and the icon under
hicolor/256x256 by the name the entry asks for.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
New-York gained 127 photographs from the tablet and the sidebar went on showing
no number beside it.
Two reasons, and the second is why the first was never noticed. The sync's
completion handler reloads the grid and never rebuilds the tree — the scan
already does, and the sync merges the same tables. And the condition it reloads
under was `collections_gained > 0`, which counts collections, not members: a
sync that files 127 photographs into a collection both devices already had
gains no collection at all, so the count was zero and nothing refreshed.
`SyncReport` now carries `members_gained` from the merge report, and either one
rebuilds the tree.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`cargo fmt --check` is a required step and had drifted across 45 files. Most of
it arrived this week: several operations were written in parallel worktrees and
merged by hand, and a hand-merge resolves conflicts without ever running the
formatter over the result.
No behaviour changes — this is `cargo fmt --all` and nothing else, kept as its
own commit so the next reader can skip it wholesale rather than search it for
one that matters.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a local destination, defaulting to ~/Pictures, and an "upload" switch
that could be turned off. That was wrong twice over. DarkRoom's library *is* a
folder on a Nextcloud server (FR-NC-6) — there is no local library — so a
user-chosen local destination built a second pile of photographs that no view
in the application ever lists, and made "where did my import go" a question
with two answers.
An import now has exactly one destination and the page asks nothing about it.
With no account there is nowhere to go at all, so Import is refused rather than
quietly filling a folder.
What lands on the device is a staging copy in a directory the app owns, the
same shape `export` uses for its outbox and for the reason its module docs
give: staging first is the only path, not a fallback for being offline. The
bytes have to reach disk before the network — streaming a card straight to the
server would let a move-import erase a card against an in-flight upload, and
would make importing impossible with no connection (FR-NC-10). A staged file is
removed once the server confirms it; one that is not confirmed stays queued, and
the next import drains it.
And the rule that was stated but never enforced: `retirable` was reported and
`dr_ingest::retire` was never called by anything, so a move-import silently
behaved as a copy. The card is now emptied by the worker, of exactly those
photographs the *server* has confirmed — not those merely written here, because
the staging copy is removed moments later and anything unconfirmed would then
exist nowhere at all.
FR-NC-7b said "copied locally first ... then queued for upload", which is a
staging area; it has been rewritten to say so in terms that do not also permit
what was built.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Second pass, against the detail-stage and thumbnail work that has landed since
the first. Resolved and verified here rather than in the shared merge worktree,
so what goes back is a fast-forward.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts:
# docs/traceability.md
The grid fetches a preview only for cells that are actually browsed, which is
the right posture over a link that must not be saturated to show one screen
(FR-NC-3). The cost is that the thumbnail store ends up holding the fraction of
the library someone happened to scroll past — and that store is the one derived
thing worth syncing, since a second device that downloads the shards gets a
full grid without touching a single RAW. So the complete set is worth an hour
of range fetches paid once, deliberately, on a machine that can afford it.
That is what this adds: a pass over every visible image with an `oc:fileid`,
launched from the settings page and reported in the activity register like
every other background job. The work list is what the store lacks rather than a
flag in the catalog, so it is resumable by construction and safe to press
twice.
Lane-parallel like the metadata sweep, but it does what that sweep declined to.
The store is `&mut` and cannot cross lanes, which is why dating the library
skips thumbnails entirely; here the lanes fetch, decode and *encode*, and only
the ~20 KB result crosses back to the one thread that owns the store and writes
the chunk. The parallelism is real and the single-writer rule is not bent.
Grid class only. The large class is ~860 MB of shards against ~200 MB on the
reference library, paid by every device that syncs them; a photograph looked at
closely still gets its large thumbnail from the interactive path.
Dates come free — the header a preview needs is the header EXIF lives in — and
the pass ends by pushing the shards to the server. A filled store that never
leaves this device would be most of the cost for none of the point.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An import read every byte of every original, uploaded them, and then left the
grid to fetch a preview range back out of each one over the network — for
files that had been on this machine minutes earlier.
The pixels are now made from the bytes already in memory, on the fast path
FR-CAT-3 names: locate the camera's own embedded JPEG, decode a few hundred
KB, downscale, orient. Never a demosaic. A file with no usable preview yields
none, which is not an error and simply leaves the grid to fetch one later.
The filing waits for the upload, and only the filing. The shard store is keyed
by `oc:fileid` (FR-NC-5) and that does not exist until the server has the
file — so the thumbnail is made from the local copy and held until an id can
be attached to it. One listing per folder supplies every id at once, rather
than a PROPFIND per photograph over a link that may be mobile data.
Best-effort throughout: a thumbnail that cannot be filed costs the grid one
preview fetch later, and failing an import over it would be the wrong trade.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
Three lint findings and the gap the third one was pointing at.
`Context::library_label` was dead: the page named the folder on this machine
and said nothing at all about the server, which is half of what the Import
button commits to and the half that takes minutes rather than seconds. It now
names both, and states the order — copied and verified here first, then
uploaded (FR-NC-7b) — beside the destinations rather than beside the button,
because someone watching a slow upload needs to already know the photographs
are safe on disk.
The label is cached when the page opens rather than read in `render`: reaching
it goes through the context closure to the account, and `render` runs on every
keystroke.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The era-based conversion sat in library_ui.rs with two readers: the
timeline's month headings and, through format_date, the exporter's {date}
token. Import folder templates are a third, and three copies of a date
calculation that could drift apart is one too many — an image filed under a
date the timeline does not show it on is a file the user cannot find.
Moved to dr_types::time, which is where shared vocabulary lives, and given
the offset-aware reading an import needs: a shot taken at 23:30 in Tokyo
belongs in Tokyo's day, and filing by UTC would split one night's
photographs across two folders at whatever hour the offset happens to be.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
"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>
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.
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.
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.