Commit Graph
66 Commits
Author SHA1 Message Date
dtourolleandClaude Opus 5 eaafacc3fb Give the phone the model it had no way to obtain
Face indexing was compiled into the APK all along — dr-ui takes dr-face with
`inference` on every target, so SCRFD, alignment, MBF, calibration and
clustering were all in there. What was missing was the weights, and on Android
there was no way to supply them.

Route C (docs/faces.md §2.2) says the user obtains the model and the app loads
it. On a desktop that is a real gesture: drop two files in
~/.local/share/darkroom/models/ and indexing starts working. On Android it is
not a gesture at all. `internal_data_path` is app-private, `run-as` needs a
debuggable build, and the in-app fetch route C specifies was never built — so
the settings page reported "no face model is installed" on every launch with
nothing behind the message. Not "off until you supply weights"; off.

So the shape-fixed pair goes into LFS under the APK's assets, assemble-apk.sh
copies it into the package, and `android_main` unpacks it to the shared models
directory before anything asks whether a model is present.

Three things that are not incidental:

The models directory is now shared across accounts rather than per-account.
Weights are identified by `faces.model_id`, not by who is signed in, so two
accounts had no reason to hold two copies — and the unpack runs before any
session exists to key a per-account path off. `face_models` still prefers a
per-account directory when one is populated, so anyone mid-migration keeps the
ability to pin one library to its own pair.

The unpack writes under a temporary name and renames. `face_models` decides
availability on `is_file()` alone, so a copy truncated by the process being
killed would leave a file that passes that test and fails inside tract —
reported to the user as a broken model rather than a missing one.

assemble-apk.sh refuses an LFS pointer. At ~130 bytes it looks exactly like a
model to `cp`, and unchecked it reaches the device and fails in the graph
loader instead of telling someone to run `git lfs pull` — the same guard
dr-segment's build script applies to yolo26n-seg.onnx.

The licensing half is unchanged and recorded in §2.2a: the InsightFace grant is
research-only, this is a private repository and a self-installed build, and
these files come back out before anything is published. The weights are still
not a cargo build input — dr-face has no `models/` directory and no
`embedded-model` feature, and nothing in the build reads them. The APK assembly
step copies two files and is the only thing in the tree that knows they exist.

Verified on device: both models unpack on first launch (2524817 and 13616095
bytes) and the APK carries them at assets/models/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 13:18:07 +02:00
dtourolleandClaude Opus 5 b846b312b8 Run the formatter over the face branch before it reaches CI
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 2s
Build and test / Desktop (Linux) (push) Successful in 1h21m32s
Build and test / Layer separation (push) Successful in 37s
Traceability / Requirement traces (push) Successful in 25s
Build and test / Android (aarch64) (push) Failing after 33m58s
The merge of the SCRFD/MobileFaceNet work brought 69 rustfmt diffs across
dr-catalog, dr-face and dr-ui with it, so `cargo fmt --all -- --check` fails
on master and the Desktop job stops at its Format step — before clippy, the
tests or the release build have run at all. That makes the whole desktop
half of CI blind: a real compile error behind this would look exactly the
same from the outside. There was nothing behind it, as it turns out — with
the formatting fixed, clippy, the test suite and the release build all pass.

Every .rs hunk is `cargo fmt --all` on the pinned 1.92.0 toolchain, not a
hand edit, but it is worth being precise about what that moved, because it
is more than whitespace. Besides reflowing signatures and call chains,
rustfmt reordered the `pub mod` and `pub use` items in dr-face/src/lib.rs so
the `#[cfg(feature = "inference")]` entries sort in place, added the trailing
semicolon inside `let ... else { return }` bodies in identity_ui.rs, wrapped
a bare closure body in braces in cluster.rs, adjusted trailing commas, and
dropped a stray blank line at the end of identity_ui.rs. All of it is
semantically inert; none of it changes behaviour.

docs/traceability.md rides along because it has to. The matrix records each
TRACES tag by line number, and reflowing develop.rs, lib.rs, faces.rs,
identity.rs and identity_ui.rs moved them — FR-CAT-8, FR-CAT-9, FR-CULL-10,
FR-DEV-3, FR-DEV-3a and FR-DEV-3c all shift by a line or two. The matrix was
verified up to date on d777f7f before this commit, so this is drift these
formatting changes introduced, not pre-existing staleness being swept up.
Leaving it for a follow-up commit would hand traceability-check.yml a
failure caused entirely by a whitespace change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 13:12:49 +02:00
dtourolle d777f7f44d Merge branch 'master' into worktree-faces-scrfd-mbf
Build and test / Desktop (Linux) (push) Failing after 25s
Build and test / Layer separation (push) Successful in 22s
Traceability / Requirement traces (push) Successful in 58s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
Build and test / Android (aarch64) (push) Failing after 33m26s
# Conflicts:
#	docs/traceability.md
#	ui/dr-ui/src/develop.rs
#	ui/dr-ui/src/segmentation.rs
2026-08-27 11:57:38 +02:00
dtourolleandClaude Opus 5 25c88d9dbd Start face indexing from Settings
Beside the thumbnail sweep, because it is the same kind of thing: a job
that runs for an hour, is asked for once, and reports into the activity
list above it. It is also downstream of that sweep -- detection reads the
proxies it builds -- so the two belong in that order, and the coverage
line says how many images are waiting on a proxy rather than only how
many are left to index.

The button drives the Identity Manager's own state rather than a second
copy, so it cannot disagree with that screen about whether a pass is
running, and either place can start or stop it.

The pass now opens an activity row. The caption promises progress will
appear in the list above, and without a row it would not: the button
would be the only sign anything was happening, invisible from every
other screen.

Coverage is read when the Settings page opens. The figures live in the
catalog and this page deliberately holds no session, so they arrive
through a closure rather than being kept current -- they are only ever
looked at while the page is on screen, and the check is two counts and an
indexed scan.

Also adds DARKROOM_NO_SYNC. Redirecting XDG_DATA_HOME isolates a test
launch's catalog and thumbnails but not its server, and I found that out
by pushing a test catalog over the live one. The guard sits in
start_derived_sync rather than at its three call sites, because the sweep
firing a sync is correct and a flag checked in three places is one that
gets missed in a fourth.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 09:22:43 +02:00
dtourolleandClaude Opus 5 f00b92a0e6 Turn face boxes back into sensor space before matching regions
Faces are found on the thumbnail, which is cached the right way up --
the grid would lie on its side otherwise. Segmentation runs on a proxy
rendered through a neutral edit graph, which carries no orientation and
is therefore in sensor order. For anything shot in portrait the two
differ by a quarter turn, so a face and the person containing it were
being compared in spaces 90 degrees apart: no match, or worse, a match
against somebody else's region.

The transform goes on the face rather than on the proxy. Instance masks
are defined in the proxy's space and sampled long afterwards, so turning
that space would be a far larger change than naming a region warrants.

Also two things the first screenshot of the running app showed that no
test would have:

110 of 23,528 displayed as "0%", which reads as the feature having done
nothing. One decimal below ten percent, and a floor so real progress
never shows as none.

The rail picked some near-black covers, because the largest face in a
group is often the nearest one in a badly lit frame and a black square
beside a name identifies nobody. It now cuts the best few and takes the
first legible one, falling back to the largest when a person's every
photograph is dark -- which happens, and showing it beats showing
nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 23:28:52 +02:00
dtourolleandClaude Opus 5 b55812812a Name segmented people from the faces already recognised in them
The segmenter knows it found a person; the face index knows which person.
Joining them turns "person" in the mask list into "Anna", which is the
difference between a vocabulary of eighty COCO classes and one that
includes the user's family. Selecting a subject in a group photograph
stops being a guessing game between three identical rows.

Containment, not IoU. A face is a small part of the person it belongs to,
so a correct pairing has an IoU near zero and anything IoU-based would
reject every true match.

Confirmed names only. A suggestion is the system's guess, and printing a
guessed name onto a mask region would launder it into a fact.

Writing the tests corrected the design once: a tight head-and-shoulders
portrait, where the face fills most of the person box, is the case where
naming is most certain, not least. An earlier guard rejected exactly that
and has been removed, with the reasoning left as a test because it is
easy to get backwards a second time.

The names hang on the develop session, set when the image opens because
that is the one moment the catalog and the image id are both in reach.
Every segmentation run afterwards picks them up for free, and a library
with no face indexing behaves exactly as it did before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 23:10:46 +02:00
dtourolleandClaude Opus 5 d7a81375ee List the steps, and let a photographer step straight to one
Build and test / Desktop (Linux) (push) Successful in 19m30s
Build and test / Layer separation (push) Successful in 25s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
Traceability / Requirement traces (push) Successful in 23s
Build and test / Android (aarch64) (push) Failing after 33m5s
Undo answers "take back the last thing", which is the question asked about a
mistake just noticed. It is the wrong instrument for one noticed six
adjustments later: eight presses, each changing the picture, with no way to see
how far back the mistake is without passing through it. A step is a whole
state, so arriving from six away costs what arriving from one does — which is
what makes a row worth making clickable rather than decorative.

`Edit::Discrete` had to go for the list to be worth drawing. Seventeen call
sites recorded the same anonymous step, which is fine for deciding whether two
changes are one gesture and useless for a panel: seventeen rows reading
"Discrete" is not a history. Every variant now carries enough to name itself,
and the compiler enumerated the sites that had to start saying so. A step that
moved a parameter is still named out of the descriptor, so an operation added
as a YAML declaration appears in the history correctly named with nothing
written for it (FR-DEV-3c).

Choosing a film stock was not undoable at all. The pick went straight to
`choose_film`, which nothing on the history's path ever sees. `pick_film`
records it, and is separate because the same call is also how a *restored* edit
gets its tables back — recording that would push a step for the undo the
photographer had just asked for.

The list is rebuilt off a revision rather than off every redraw. A drag ends in
a redraw per frame while folding into one step, so the unconditional version
would tear down and recreate every row sixty times a second to arrive back at
the list already on screen. The counter is process-wide: a per-instance one
starts every photograph at the same number, so a frontend holding "the revision
I last drew" would keep the previous image's steps on screen — invisible while
every image opens with one identical row, and a wrong-photograph bug the moment
persisted history means it does not.

The step names that no descriptor can supply are constants with a roll, and a
test walks the roll rather than a second copy of it. `resolve` splits so that
"is this catalogued?" can be asked: `derive` turns `history.mask_toggled` into
"Mask Toggled", which names a field rather than an act and, being perfectly
readable, is a mistake nobody would look at twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 23:04:35 +02:00
dtourolleandClaude Opus 5 26a1eb7e28 Record that face detection has run, not just what it found
An image with no faces in it was indistinguishable from one that had
never been looked at, so every landscape, still life and document scan in
the library was re-detected on every pass, for ever. In a real library
that is most of it: on the 23,527-image test library, 64 of the first 110
images indexed contain no face at all.

Schema v9 adds face_index, a run marker per (image, model) carrying the
face count and the proxy edge it read. Keyed on the model, so a model
change puts every image back in the queue by itself.

That makes a coverage figure possible, which is the thing a user actually
wants to see. The audit also splits the outstanding set by whether a
proxy exists, because 23,417 awaiting a proxy and 110 ready to index are
different problems, and telling the user to run indexing again would not
fix the first.

The Identity screen gains Index faces, Stop, and the coverage line.
examples/face_index.rs is the same check and sweep without a window,
which is the right shape for an overnight pass.

Measured on the real library in release: 3.5 images/second, 110 images
and 125 faces in 30 seconds, and a second run correctly finds nothing
left to do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 22:13:41 +02:00
dtourolle bc07c32611 Merge branch 'master' into worktree-spot-removal
# Conflicts:
#	docs/traceability.md
2026-08-26 21:51:47 +02:00
dtourolleandClaude Opus 5 626780276d Draw with OpenGL on Android, where the driver owns the display rotation
The grid tore while scrolling on the tablet, in portrait only, and was
flawless in landscape. It was not vsync and it was not the grid.

Measured on the device, same build, only the tablet rotated:

    landscape   bufferTransform=ROT_180   composition=DEVICE (2)   clean
    portrait    bufferTransform=ROT_270   composition=CLIENT (1)   torn

The panel is mounted landscape — 1920x3000 at installOrientation 3 — so a
portrait window needs a 90 degree rotation before scanout. wgpu-hal hardcodes
the swapchain's `preTransform` to `IDENTITY` and says so in a comment beside
the line:

    // On Android 10+, libvulkan's `vkQueuePresentKHR` returns
    // `VK_SUBOPTIMAL_KHR` if not doing pre-rotation ... This is always the
    // case when the device orientation is anything other than the identity
    // one, as we unconditionally use `VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR`.

That is gfx-rs/wgpu#3345, and it cannot be fixed by setting the field:
`preTransform` is a *promise* that the content is already rotated, so keeping
it needs the renderer to rotate what it draws, which wgpu cannot do on Skia's
behalf.

We do not have to be on that swapchain. `AndroidWindowAdapter` chooses
`SkiaRenderer::default_wgpu_29` only because this crate enables
`unstable-wgpu-29`; without it `SkiaRenderer::default` resolves — through
i-slint-renderer-skia's build script, which selects OpenGL on anything that is
not Apple, Windows or wasm — to Skia over OpenGL, where the driver owns the
rotation and there is no transform to get wrong. So both renderer features
move to the desktop-only dependency, and desktop is untouched.

# The cost, stated rather than hidden

Skia over OpenGL cannot sample a `wgpu::Texture`, so the develop view's frame
comes back through memory: `AdjustPass::export_pixels`, already ungated and
already used by the export path, into a `SharedPixelBuffer`. That is the
round-trip ARCH §6.1 and AC-8 exist to forbid, and it is the right trade only
because of what the alternative actually is — not a faster develop view, but a
grid that tears in the orientation a tablet is mostly held in.

Two things keep it small. The device is still opened on Android, so demosaic
and the adjust pass are untouched on the GPU; only the last hop changes. And
`render` fits the pass to the canvas before it runs, so the readback is at
viewport resolution, a fraction of the ~7 ms at 4K the original measurement
was taken against.

Four other explanations died on the way here, each by measurement rather than
argument: the present mode (a patch confirmed in the installed binary reached
`AutoVsync`, and the rows still duplicated), our shared wgpu device (Slint
opened its own, unchanged), Skia's partial rendering (off for GPU surfaces),
and client composition itself (unavoidable in portrait on this panel, so it
cannot be what distinguishes a torn frame from a clean one).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 21:47:02 +02:00
dtourolleandClaude Opus 5 10b10569e2 Add the Identity screen
A third top-level screen beside the library and develop, because naming a
cluster and pulling a stranger out of it are tasks with their own rhythm
and need the whole window.

The screen is designed around the clustering being wrong, which is
FR-CULL-10 rather than pessimism: grouping over-merges on siblings, on
parents and children, and on the same person a decade apart. So Split off
sits next to Confirm all rather than behind a menu, the confirm/reject
pair is on the face itself, and a group the system found is drawn
differently from a person the user has vouched for.

Splitting rejects before it confirms. Without that the next clustering
pass suggests the face straight back and the user's correction becomes an
argument they keep having.

Face crops come from the proxies the grid already built, one decode per
image rather than per face -- a group photograph holding six faces of one
family is one JPEG.

Where the calibration is not fitted the screen says confidence is
unavailable instead of printing a percentage that looks measured, which
is FR-CULL-9's rule at the point it becomes visible.

The verdict controls use drawn icons, not tick and cross characters:
ui/icons.slint exists because those render as tofu on Android.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 21:05:41 +02:00
dtourolleandClaude Opus 5 8ab9440190 Put the repair tool on the photograph
A third chip beside Crop and Local, and the mode strip's own comment
predicted the shape: a mode that arms a gesture on the canvas and scopes
the column. Click a mark to cover it, drag the disc to move the repair,
drag the source circle to say where the patch comes from, Delete to remove
it. The source starts two and a half radii towards the middle of the
frame, which is FR-DEV-8's automatic placement in its cheap form — dust
sits on skies and skies are smooth, so it is usually right and always one
drag from fixed.

Two things are drawn deliberately. The circles are the size the repairs
actually are, because whether a disc covers a speck is the whole judgement
being made and a fixed-size dot would say nothing about it; the reach
around them is padded to a touch target so a spot on a dust mark can still
be picked up on a phone. And only the selected repair shows its source: a
dusty sky carries a dozen, and two dozen circles with nothing saying which
belongs to which is less information rather than more.

The panel edits what is stored while the canvas draws what is mapped, and
the two are pushed separately for that reason — a slider deriving its
value from the drawn radius would move differently at different zoom
levels. It is also the one panel built from SliderRow rather than a live
track: a repair has no OpId to coalesce a drag under, so a row that fires
once per gesture is what keeps undo one step per decision.

Verified as far as this environment allows: the strip renders and the
column re-scopes, photographed under XWayland. Synthetic clicks do not
reach this application, so the gestures are as-written rather than
as-felt, and docs/spot-removal.md says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 20:58:13 +02:00
dtourolleandClaude Opus 5 2ac069a6b3 Index the library's faces, and group them into people
Wires dr-face to dr-catalog: a background sweep that reads the proxy the
grid already built, detects, aligns, embeds and stores, then a clustering
pass that turns those embeddings into suggested people.

Detection runs on the Large thumbnail tier and nowhere else. That is what
makes the feature affordable -- a browsed library has already paid for
its proxies, so face indexing adds no RAW decode that was not already
happening -- and it is why an image whose proxy is missing is skipped
rather than fetched: requesting one here would put face indexing on the
network path FR-CULL-8 keeps it off.

The sweep keeps no cursor. It asks the catalog what is missing, so it
resumes after process death with no repeated work beyond the in-flight
image, and cancelling is dropping the receiver.

recluster writes only the suggested half. Confirmed faces go in as
anchors and come back untouched, and a cluster of one stays nameless --
naming every stray face would fill the People view with noise the user
then has to dismiss.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 20:49:30 +02:00
dtourolleandClaude Opus 5 3b5952769b Emit floats an f32 can hold, and drop the format! that formats nothing
CI runs cargo fmt --check and clippy -D warnings, and this branch had
never been through either. Both would have failed it.

The bulk was the generated colour tables: eight significant figures where
an f32 carries about 7.2, so the eighth is noise that rounds away at
compile time and clippy's excessive_precision says so 109 times over.
Fixed in the generator rather than only in the file, so it stays fixed --
and the file is trimmed in place rather than re-derived, because
regenerating it needs a colour-science stack that has nothing to do with
the defect.

The format! in the composer is mine too, from extracting the rendering
tail: the braces in it were escaped because the text used to live inside a
larger template, and once extracted the escapes are noise and the call
formats nothing.

Also here, and clearly not mine: an unused import and a shadowed binding
in dr-gpu, and an unused import in a test. They are pre-existing --
clippy has been failing on master before this branch existed, on lints
like is_multiple_of that arrived with a toolchain rather than with
anyone's code. Fixed because CI cannot go green around them, and called
out because a merge commit is a bad place to quietly edit someone else's
crate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 22:28:14 +02:00
dtourolleandClaude Opus 5 6d18517d28 Ship every stock that exists, black and white included
Three profiles was what the first cut needed to prove the model. This is
the rest of the open data: 23 camera stocks and 9 papers, which is all of
spektrafilm.

Black and white was the gap, and it turned out not to be a gap in the
data -- it was a gap in where I looked. Upstream's `main` has 28 colour
profiles and nothing monochrome; `dev` has three more, and they are
Tri-X, Double-X and the 2302 print film they go onto. So the answer to
"do we have B&W" was yes all along, and it needed the dev branch rather
than a fortnight digitising Ilford's datasheet graphs by eye. Those three
are pinned to `dev` per stock; the colour stocks stay on the released
branch.

A monochrome profile is single-channel -- one emulsion, not three -- and
spreading that one layer across all three is exact rather than an
approximation: three layers with identical sensitivity and identical
curves respond identically, which is what one layer does. The dye is the
trap. The renderer *sums* the three layers' contributions, so replicating
it unchanged renders every frame three times too dense -- neutrally, and
therefore plausibly. A third each reconstructs the single emulsion, and
two tests hold both halves: that the densities stay equal, and that they
sum to one emulsion and not three.

Double-X and 2302 ship five curves apiece, measured at five development
times -- 4 to 12 minutes for Double-X. That is push and pull processing as
measured data. The standard 6.5 minutes is what ships; the rest is in the
upstream file waiting for a control to ask for it.

Two stocks are `support: film` and are nevertheless what a negative is
printed *onto*: the cine projection films 2383 and 2393, which the
Vision3 stocks print to. Filtering the picker on support alone offered a
projection stock as something to load in a camera, so it filters on stage,
with a test saying so.

The picker had to change shape twice over. Chips were right for three
stocks and off the edge of a 280px column at twenty-four, and the column
that replaced them was a thousand pixels standing between the
photographer and every slider below. It is a disclosure now: one row
carrying the answer, opened to change it, closed again on choosing. That
is the opposite of the argument this panel used to take the lids off its
sliders, and deliberately so -- an instrument you compare wants to be
visible, and a list you consult once wants to be out of the way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 20:30:11 +02:00
dtourolleandClaude Opus 5 baa8957e80 Let a photographer choose the film, and remember which one
The stock model rendered correctly and nothing could ask for it. This is
the picker, and the sidecar key that makes the choice outlive the session.

How the choice persists was the open question, and the answer was already
written down twice in sidecar.rs: `rating` is a top-level key "because a
rating is not an edit", and `masks` are one "because a layer is not a
scalar". A stock is that kind of thing -- a choice of material, not a
number a slider moves -- so it is a top-level key too.

It stores the **id**, not an index. Stocks are files that users add, so an
index would mean installing a profile silently changed which film every
existing photograph had been developed on. A name this build has no
profile for still round-trips untouched, because the alternative is that
syncing to an older phone quietly un-develops the picture.

Only the names travel. Turning one back into tables needs the profile
database, which dr-pipeline deliberately does not link, so `Version::apply`
clears the film and the session re-bakes -- after the parameters, because
the bake reads the film's own exposure sliders and the print balance is
solved against them. That is also why moving those sliders rebuilds the
lookup where no other control in the panel does: an enlarger's filtration
depends on how the negative was exposed.

The panel keeps its rule. It still names no operation and still generates
every control from a declared parameter kind; the stock gets a bespoke
control beside those, exactly as the mask stack does, and for the same
reason. The film's exposure and print exposure arrive as ordinary
generated sliders.

Two defaults worth stating. Picking a colour negative prints it, because
an unprinted one is an orange strip and offering that as the first thing
somebody sees after choosing Portra reads as a bug rather than as a
choice -- the toggle is there for anyone who wants the scan. And a paste
carries no film: a preset is a parameter map, and a stock is not a
parameter, so pasting one would paste a choice the clipboard never took.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 19:57:41 +02:00
dtourolleandClaude Opus 5 54f9cb54fb Take the whole run a shift-click names, not the part that happens to be loaded
Shift-clicking two photographs selected only the cells between them that were
in the loaded window. The grid is a window of a hundred or so over a library of
twenty thousand, and `apply_press` resolved the range against that window —
`ids[lo_row..=hi_row]`, clamped to what was there. Everything else in the run
had no id anywhere in the UI, so it was silently dropped. The user cannot see
that: the selection count is off screen along with the photographs, and the
gesture only announces itself when the drop files a dozen images instead of two
hundred.

The two ends are *ordinals*, and only the catalog knows what lies between them.
`read_ids_span` asks it, through the same predicates, the same rating filter and
the same ordering the window itself is read with — an ordinal names a photograph
only relative to an ordering, so a run taken through any other one is a run
through a different library. That ordering is now a constant, `GRID_ORDER`,
shared by the window, the trash's own order beside it, and the run: capture time
first, with the file name breaking ties and nothing more. A card written by two
cameras interleaves names that have nothing to do with each other, and what
"everything between these two" means to a photographer is a stretch of an
afternoon.

The query is reached through a closure handed to `CollectionsController` at
wiring time rather than a catalog handle, because the scope and the filter that
bound the run belong to the grid's controller. `apply_press` stays a pure
function of what it is given, which is what keeps the selection rules testable
with no library open — and the tests pass a run that reads a plain slice. Where
there is nothing to ask, the loaded window is still used: a poorer answer than
the catalog's and a far better one than a gesture that appears to do nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 19:50:21 +02:00
dtourolleandClaude Opus 5 fa4ad6e2d6 Drag the date range on the axis it is chosen from
The range could be turned on with a finger and not aimed with one. Its two
ends were typed as `YYYY-MM-DD` into 108px fields behind a soft keyboard, to
name days already drawn on the axis a thumb away; and the chip that seeds them
takes its span from the timeline's zoom and pan, which are a wheel and a middle
button. A touch screen has neither, so on Android the filter was a switch with
no aim.

The band is now on the timeline. Two ends with grips, dragged along the bars,
released to filter — the histogram was already how a period is found, and this
makes it how a period is stated. Both ends snap to whole days, which is what
the typed fields mean, what `show_range` reads back out, and a floor under a
range dragged shut. The fields stay for what dragging cannot do: name an exact
day, and say in words what the range is.

For that to work the axis had to stop following the range. Redrawn to the band,
it moved the ground under the very handles doing the narrowing, and there was
nothing outside the range left to widen back into.

While there: a fixed number of equal bins instead of calendar buckets. Between
one calendar unit and the next the bar count is free to wander by a factor of
twelve, so zooming in halved it two steps out of three — the same picture drawn
wider until it jumped back to fine. Equal bins also include the empty ones, so
a bar's position on the track and the date under it are finally the same
quantity; before, a library with gaps drew a February six months wide and the
marker, the band and a click all pointed somewhere else. The count is a
setting, 32 or 64, because the right answer is a question about the screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 18:42:42 +02:00
dtourolleandClaude Opus 5 a8969c149e Move the instrumentation off the header and into About
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>
2026-08-23 11:41:17 +02:00
dtourolleandClaude Opus 5 e25f0ad2c6 Let the launcher find an icon for the window
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>
2026-08-23 10:26:04 +02:00
dtourolleandClaude Opus 5 d04087af83 Import into the library, which is on the server
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>
2026-08-22 21:09:26 +02:00
dtourolle 56dd3187f1 Merge integration into wip/ingest
Second pass, against the detail-stage and thumbnail work that has landed since
the first. Resolved and verified here rather than in the shared merge worktree,
so what goes back is a fast-forward.

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

# Conflicts:
#	docs/traceability.md
2026-08-22 19:44:42 +02:00
dtourolleandClaude Opus 5 28325af448 Make the thumbnails while the originals are still in hand
An import read every byte of every original, uploaded them, and then left the
grid to fetch a preview range back out of each one over the network — for
files that had been on this machine minutes earlier.

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 19:20:13 +02:00
dtourolle 0d9910efc6 Merge branch 'worktree-agent-a89309a856c8f4947' into integration 2026-08-22 19:00:56 +02:00
dtourolleandClaude Opus 5 13d003b89d Let the curve widget plot whichever curve is asked for
An operation with four curves and a panel that draws one plot needs a way to
say which. The panel finds out the way it finds out everything else: the
points are faceted with the subject they act on, consecutive parameters
sharing a subject are one curve, and a widget spanning several of them gets
a selector over their names. Nothing in ui/ contains the word "red", and an
operation that grows a fifth curve arrives with a fifth chip.

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 16:06:21 +02:00
dtourolle 1526c957cf wip: ingest 2026-08-22 15:34:43 +02:00
dtourolle 735683b849 wip: ingest 2026-08-22 14:12:40 +02:00
dtourolle 5bcd0e0269 Merge branch 'worktree-agent-a22a049c461818dbe' into integration
# Conflicts:
#	core/dr-pipeline/tests/mask_sidecar.rs
2026-08-22 13:23:34 +02:00
dtourolleandClaude Opus 5 96a7b405c2 Say which photograph the sliders are pointed at
Selecting a mask layer silently re-points about thirty controls at that layer's
chain. Same panel, same order, same sliders, different meaning — and the only
thing that said so was a sentence in the panel above, which a photographer
reaching for the exposure slider has no reason to read. An exposure change
lands on the whole frame when it was meant for a face, or the reverse; both are
silent, and both are discovered later. `ui-navigation.md` §1.1 calls it the
dangerous one and it is: the others in that document cost time, this one costs
work.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 12:26:11 +02:00
dtourolle 924a837389 Group the panel by what operations say they are about
A strip of groups over the adjust panel — Light, Colour, Detail — derived
from the attributes the operations declare. `adjust.slint` names none of
them: the strings arrive resolved and the panel only draws them, so a new
operation joins the right group by saying what it is and this file does not
change (FR-DEV-3a).

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

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

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

Six tests, including that a nonsense index falls back to showing everything
rather than to showing nothing.
2026-08-22 10:22:47 +02:00
dtourolle e7dbdeb21f Keep the overlay on the photograph when the view moves
The overlay is a source-space picture; the canvas beside it shows whatever
the crop, the zoom and the pan selected out of that same space. Drawn whole
it stayed frame-sized while the photograph moved underneath, so zooming in
left a map of the whole picture stretched over a detail of it.

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

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

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

Four tests, and the one that matters asserts the clip *narrows* when zoomed
— which is precisely what it failed to do.
2026-08-22 08:53:02 +02:00
dtourolle 9b4f0815e5 Show the regions, click one, and adjust it
The local panel sits above the adjust panel because it decides what those
sliders act on; below it, a photographer would set an exposure and only
then discover which scope it landed in. Selecting a layer re-scopes the
existing controls to that layer's chain — there is no second set of
sliders, and there must not be, or every operation added to `ops/` would
need a local twin.

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

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

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

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

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

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

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

`segment-readback` is a new dr-gpu feature and not a loosening of
`readback`. The region-graph transfer is once per image on a worker; the
one AC-8 forbids is per frame in the render loop. Sharing a switch would
have forced a build wanting local masking to unlock the other. F3 still
stands and the feature name says so.
2026-08-22 08:39:17 +02:00
dtourolleandClaude Opus 5 c9c5c43aa1 Carry the library across when its home moves
The previous commit moved durable data out of Android's cache directory, and
on its own that would have been an upgrade that quietly discarded work. The
app looks in the new location, finds nothing, and rescans a library of tens of
thousands of images over the network — while the old copy, including every
offline rating and edit that had not yet synced, sits in a directory the
system is free to delete.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

324 tests pass, clippy and fmt clean.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Also regenerates the traceability matrix over the four merged branches.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Written down as FR-DEV-3h.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 15:37:05 +02:00