Commit Graph
100 Commits
Author SHA1 Message Date
dtourolle 3b5bba62f1 Merge branch 'worktree-agent-aa9f4356c13893373' into master
# Conflicts:
#	docs/traceability.md
2026-08-27 19:08:32 +02:00
dtourolle 510d1a26cb Regenerate the traceability matrix
The platform layer was never scanned, so every FR-PLAT-* and NFR-PORT-* tag in
dr-plat was invisible. Coverage 51.4% -> 57.6%, almost all of it pre-existing
tags that were simply not being counted.
2026-08-27 19:08:23 +02:00
dtourolleandClaude Opus 5 2e6825ded0 Record what the measurement found, where the next person will look for it
Three places, because the finding has three audiences.

The display spec's §1 table said FR-DSP-3 was unmeasured and FR-DSP-5 untagged.
Both are now false, and §2's decision rule has fired. The body of §2 is left as
written with the verdict quoted above it: a plan overtaken by its own evidence
reads better in order than quietly edited into agreement with the outcome.

TD-4 is the stage that misses the budget. Clarity's kernel is a fraction of the
frame, so it reaches a 52-pixel radius at 4K and costs 34 ms — seven times the
entire fused chain, for one slider. It is debt rather than a bug because the
detail stage cannot yet write a target smaller than it reads, which
`local_contrast`'s own documentation has said since it was written. The entry
says plainly that tiles are the wrong tool for it, since that is exactly the
conclusion a reader arriving from ARCH §5.3 would otherwise draw.

TD-5 is the one nobody was looking for: composing the fused shader costs
2.8–5.2 ms of CPU per frame on a full chain, on the UI thread, which at
1920x1200 is more than the dispatch it precedes. The source depends only on the
graph's structure — what `structure_hash` already identifies and what does not
move during a drag — so the fix is the cache `AdjustPass` already keeps for
compiled pipelines, one level up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 19:06:19 +02:00
dtourolleandClaude Opus 5 772a69711d Prove that zooming to 1:1 reads the source, and only then tag FR-DSP-5
FR-DSP-5 has been satisfied for some time and untagged. `Framing::view` shrinks
the sampled region while the render target keeps its size, so a zoom raises the
resolution the pipeline works at rather than magnifying pixels already drawn —
there is no second full-resolution path because the zoom is that path.

Tagging it on that basis alone is what §7 of the display spec warns against:
traceability counts a requirement as covered when a comment names it, and
checks nothing about the code under the tag. So the tag goes on tests instead,
and the tests are built so that removing the behaviour breaks them. Both
failure modes were checked by hand: deleting the view from `visible_rect`
leaves the 1:1 render flat, and dropping only its offset leaves the render
exactly inverted. The assertion message names both, since those are the two
ways this can go wrong and the numbers alone do not say which.

The fixture is one-pixel black-and-white stripes — the highest frequency an
image can hold, and precisely what a proxy discards. A 1024 px source in a
128 px viewport reads source column `8x + 4` for every output column `x`, all
the same parity, so the fit render comes out uniform; that is asserted first,
because a 1:1 render showing detail proves nothing unless the proxy is known to
carry none. What remains is an equality against the source bytes rather than a
claim that something looks sharper.

The third test takes the arbitrary zoom the requirement also names, and pins
`RenderScale` beside the pixels: a zoom that moved the pixels but not the scale
would sharpen at the wrong radius, which stays invisible until somebody
compares a preview against an export.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 19:05:05 +02:00
dtourolleandClaude Opus 5 b858fc029a Record what per-display colour actually cost
The status table said FR-DSP-8 was absent and §5.2 assumed Slint
reports window moves. It does not — there is no `on_moved` on any
backend — so the position is sampled instead.

§5.4 records the two trades that are worth someone finding later: a
display profile is matched to the nearest of four spaces rather than
applied through a CMM, and on Wayland the canvas follows the first
output rather than the window, because a Wayland client is never told
where its window is and the protocol's own answer needs a `wl_surface`
that Slint does not expose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 19:03:24 +02:00
dtourolleandClaude Opus 5 13deaa2fbb Assert the frame budget, and commit the numbers behind the FR-DSP-2 verdict
FR-DSP-3 states a latency requirement and nothing checked it, which makes it a
wish. This adds the check and the measurements it guards.

`docs/frame-budget.md` is the bench's output with the reading of §2's decision
rule attached. The short version: every point-operation chain at every viewport
size, fit and at 1:1, is inside 16 ms at the 99th percentile — the widest is
4.5 ms of GPU at 4K — so FR-DSP-2 should be rewritten rather than implemented.
The measurement did find a stage that misses the budget, and it is the one §2
predicted: clarity's 52-pixel separable kernel costs 34 ms at 4K. Tiles make
that worse rather than better, since a tiled convolution reads a halo per tile;
the fix `local_contrast` already names for itself is a base computed at reduced
resolution.

The test guards the fused path and says so, at length, rather than quietly
excluding the expensive stage and letting the tag imply otherwise (§7). What it
asserts is exactly the claim the recommendation rests on: one dispatch over a
viewport-sized target, at a full chain, is comfortably inside a frame.

Two things the numbers forced:

- The two cases are one `#[test]`. As two they ran on a thread each, contended
  for the same device, and took the 1:1 case from 2.5 ms to 14.9 ms — a
  measurement of the harness that would have flickered either side of the
  budget forever.

- The CPU half of the frame is judged only in an optimised build. Composition
  is real per-frame work on the UI thread and belongs in the budget, but the
  workspace builds its own crates at `opt-level = 0` in dev and `cargo test` is
  a dev build, so measuring it there measures rustc. The GPU half is asserted
  either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 19:00:45 +02:00
dtourolleandClaude Opus 5 3e5840e413 Let a test hold the words the About page shows
FR-DSP-8 asks for a fallback that is *defined*, and a reason that
reaches the code but never the screen satisfies half of it. The three
readouts are now built by a free function over the survey rather than
written straight into the window, so a test can assert that "sRGB
assumed" arrives with the reason attached, that an approximated profile
says "nearest to" rather than claiming the space, and that the second
monitor is described on the page read from the first — which is the
display the requirement is actually about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 19:00:43 +02:00
dtourolle d4a34effe7 Merge branch 'worktree-faces-scrfd-mbf' into master
Build and test / Desktop (Linux) (push) Successful in 1h22m16s
Build and test / Layer separation (push) Successful in 41s
Traceability / Requirement traces (push) Successful in 30s
🐳 Android image / Build and push (push) Successful in 8s
Build and test / android-image (push) Successful in 8s
Build and test / Android (aarch64) (push) Failing after 33m45s
2026-08-27 18:47:28 +02:00
dtourolleandClaude Opus 5 69dccaa061 Count the platform layer's traceability tags
`platform` was missing from the traceability scanner's source roots, so
every TRACES tag in `dr-plat` — the secret store, volume discovery, and
now display-profile acquisition — was invisible to the matrix. The
FR-PLAT-* family is exactly what that crate exists to satisfy, so the
omission understated coverage by the requirements it was meant to
count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:46:59 +02:00
dtourolleandClaude Opus 5 131004393d Follow the canvas from one display to the next
The rest of FR-DSP-8. The develop session now carries the space its
canvas is encoded into, and `render` composes for it instead of for
sRGB — which is the whole of the change to the pixel path, because the
output space was always a parameter of composition and always entered
the structure hash. A display change is a recomposition.

The space is set on the way into every render rather than pushed when
the window moves, so a photograph opened while the window already sits
on the second monitor is right on its first frame instead of flashing
the wrong colour until the next poll.

Which display that is comes from sampling the window's position and
scale factor twice a second — Slint reports neither a move nor a
display change — and re-surveying only when they differ. Settings shows
what came back under ABOUT: the display, the space, why, and the other
monitors, because the failure FR-DSP-8 names is one that is invisible
from the display you are reading the page on.

Fractional scaling: the canvas is now rendered at the physical pixel
size of the box it occupies rather than the logical one, so the
compositor presents it 1:1. At 1.25 it was previously handed 1600
samples to fill 2000 device pixels, and the softness that produces
reads like a bad demosaic rather than like a scaling bug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:44:51 +02:00
dtourolleandClaude Opus 5 7235972ca4 Measure what a frame costs, so FR-DSP-2 is decided by numbers
`docs/display-and-extension.md` §2 fixes a decision rule in advance: if the
99th percentile of a frame sits inside 16 ms, tiled computation is rewritten
as a scheduling concern for export rather than built on the interactive path.
Nothing in the tree could answer that, so the rule had nothing to act on.

This is the instrument. It renders a 60 MP synthetic source through the real
`render_detailed` at three viewport sizes and four chain lengths, fit and
zoomed to 1:1, and reports nearest-rank percentiles rather than means — a
slider drag is judged by its worst frame.

Three things it does that a simpler timer would not:

- It separates the fused pass from the neighbourhood stage. "Every operation
  active" mixes one dispatch together with a chain of convolutions, and §2's
  question is about the first of those. `point` is every operation that
  contributes a fragment to the fused shader; `all` adds the four with
  kernels, and M3 times those alone by moving only a detail parameter so
  `render_detailed`'s colour reuse skips the fused dispatch. The reuse is
  reported rather than assumed — the `colour` column counts fused dispatches
  and must be zero for an M3 row to mean what it says.

- It times the CPU half separately. Composition runs per frame in
  `DevelopSession::render`, so it is inside the budget whether or not anyone
  has looked at it, and if shader assembly were the expensive half then no
  tile scheduler could help.

- It builds the "every operation" chain from `EditGraph::capabilities` rather
  than from a list, so declaring a new node does not quietly turn that row
  into a shorter chain wearing a longer chain's label.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:32:00 +02:00
dtourolleandClaude Opus 5 57c0cc0d35 Keep the name typed into a cluster when the next one is opened
Naming a cluster and moving straight to the next is the gesture this screen
exists for, and it discarded the name every time. Two causes, both in the same
four lines.

`Field.text` is two-way bound to its `TextInput`. Binding it to `selected-name`
therefore works exactly once: the first keystroke writes through the `<=>` and
**replaces** the declarative binding, after which the field follows nothing.
Switching clusters left the previous cluster's half-typed text on screen,
attached to the new person.

And `Field` only reported `accepted`, which is Enter. A name typed and then
abandoned by clicking the next face never reached Rust at all.

So `Field` gains an `edited` callback, the screen keeps the draft with the
person it was typed for, and the draft is written when the selection moves or
the screen closes. The field is then reset from a revision counter the screen
watches.

A counter rather than `changed selected-name`, because the name is not a key:
naming six clusters "Anna" in a row never changes `selected-name`, and the field
would keep the half-typed text from the cluster before. Nor `changed
selected-person`, since accepting a namesake merge lands the user back on a
person they may already have been on.

The draft carries its `PersonId`. A reload can move the selection out from under
a half-typed name — a merge arriving through a sync, a deletion — and applying
it to whoever is selected now would rename a stranger. If the person is gone
when the draft lands, it is dropped rather than resurrecting a row the rail no
longer shows.

`None` and `Some("")` are kept distinct. A user who cleared the field means to
clear the name; a user who never touched it means to leave it alone. Collapsing
those two erases names by walking past them.

An implicit commit does not raise the namesake merge offer. That question is
about a screen the user has already left, and answering it on their behalf while
they look at the next cluster is not a question at all — the offer stays on the
explicit submit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 18:32:00 +02:00
dtourolleandClaude Opus 5 e4875498ca Ask the platform what colour the screen actually is
FR-DSP-8's acquisition half. `dr_plat::display` surveys the session's
displays and reduces each one's profile to an output space the pipeline
can encode into, stating the mechanism per display server as
FR-PLAT-LIN-2 requires:

  - X11 reads the `_ICC_PROFILE` / `_ICC_PROFILE_<n>` root-window
    properties, enumerating and numbering the outputs through RandR,
    which also yields the rectangles a window move is measured against.
  - Wayland binds `wp_color_manager_v1` and asks each `wl_output` for
    its image description, accepting either an ICC profile on a file
    descriptor or primaries stated as chromaticities.
  - Where neither answers, sRGB is assumed and the reason travels with
    it as data rather than into a log, so the About page can say which
    path the session is on.

A display profile is a measurement of one panel and is none of the four
spaces the pipeline knows. Rather than grow an ICC engine, the profile
is reduced to D50-adapted colorants and matched against the four; a
match that is merely nearest is marked as such and shown as such.

Verified on this machine: mutter 50 advertises the colour-management
global and reports eDP-1 as sRGB, and the same session forced onto X11
enumerates the output through RandR and correctly finds no atom.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:20:28 +02:00
dtourolleandClaude Opus 5 16c17c349d Bump pkgrel so makepkg rebuilds instead of reusing the modelless archive
`makepkg -si` reported "A package has already been built, installing existing
package" and installed 0.7.0-1 — the archive from before the models were added,
so the install still had no model and the app still said so. The version had not
changed because the application had not changed; only what the package contains
did, which is precisely what pkgrel exists to signal.

Verified: 0.7.0-2 carries both models at /usr/share/darkroom/models/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 18:14:56 +02:00
dtourolleandClaude Opus 5 2d95807542 Package the models on every platform, not just the phone
The Android bundling landed the weights under that platform's asset directory,
which was the wrong home the moment a second packager wanted them. `makepkg -si`
produced a desktop install with no model at all — the same "no face model is
installed" the phone used to show, for the same reason: nothing put the files
anywhere the app looks.

So `models/face/` at the root is the one copy, and both packagers read it:
assemble-apk.sh bundles it as APK assets, and the PKGBUILD installs it to
/usr/share/darkroom/models. Both refuse an LFS pointer rather than shipping a
130-byte file that fails inside the graph loader on a user's machine.

`face_models` now searches three places, most specific first: the account's own
directory, the shared user directory, then $XDG_DATA_DIRS. So a packaged pair is
found automatically and a pair the user placed by hand still outranks it — which
is what keeps a deliberate choice of weights from being overridden by an
upgrade.

$XDG_DATA_DIRS rather than a hard-coded /usr/share: that is the variable a
distribution, a prefix install or a Nix-style store already sets to say where
its data went, and its documented default is exactly the two paths that would
otherwise have been hard-coded. Empty on Android, which has no such directories
— there the APK's copy is unpacked into the shared user directory instead,
because an asset inside a package is not a path anything can read from.

Verified: the APK still carries both models at assets/models/, the PKGBUILD
parses and installs from the new path, 467 tests pass.

Includes the pkgver 0.6.0 → 0.7.0 bump that was already sitting uncommitted in
the working tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 18:05:23 +02:00
dtourolleandClaude Opus 5 725f7bf77f Offer the merge when two people turn out to share a name
Over-clustering is the normal state of a freshly indexed library — FR-CULL-10
says so — which means one person arrives as several groups and the user names
each of them the same thing. Until now that produced several people called Anna
and no way to join them: `identity::merge` existed, `faces::merge_people`
existed with its redirect tombstone, and `merge-into(int)` sat in identity.slint
declared, never emitted and never wired. The screen had a split button and no
merge.

So a rename that collides now offers one. Type a name another person already
carries and a strip appears under the field: "Someone else is already called
Anna (14 faces). Merge them?"

Offered, not performed. `people.uuid` is the identity and the name is not — the
schema comment on that column is explicit that two devices naming the same
cluster independently is the case it was built for — so two people sharing a
name is legal, and folding them together on a keystroke would be the screen
making an identity decision on the user's behalf. That is the thing this screen
spends a whole button avoiding.

The rename always lands first, and declining leaves it exactly as typed. There
is nothing to undo because nothing was done.

Details that are not arbitrary:

The comparison is trimmed and case-insensitive. "anna" on a phone keyboard and
"Anna" on a desktop are one intention, and an offer that appeared only when the
capitalisation matched would read as a bug.

An empty name collides with nothing. Every unnamed cluster renders as "Unnamed
(n faces)"; if that counted as a collision the offer would appear on every
cluster in a fresh library, and accepting it would fold the library into one
person.

The newly-named person folds into the one that already held the name, not the
reverse. The older person is the one other devices have seen and the one whose
confirmations are more likely to be real. Selection follows the merge, because
landing on an empty screen after a successful action reads as a failure.

The offer is retired when the person changes, and when a refresh finds its
target gone — merged from the other side of a sync, or deleted. An offer left
standing would fold whoever happens to be selected now.

A merged-away person is not a namesake: `faces::people` already excludes
redirects, so the offer does not reappear the instant it is accepted.

Four tests over the collision rules, and the existing 467 still pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 13:33:36 +02:00
dtourolleandClaude Opus 5 8155f5276e Spec how the display contract closes, and how the pipeline opens
Two families were the weak points: FR-DSP at 37%, which is the architecture's
central performance claim, and FR-PLG at zero. They are one document because
the same property decides both — the pipeline composes its work from
declarations, which is why the display path is fast and is also, already, most
of a plugin format.

Two findings change the size of the job.

**Some of FR-DSP is done and untagged.** Zoom already meets FR-DSP-5: the
sampled region shrinks while the render target keeps its size, so zooming
raises the resolution the pipeline works at — arrived at without tiles.

**FR-DSP-2 may not be worth satisfying as written.** It predates the fused
shader and assumes a chain of passes over a large buffer, where recomputing
per frame is ruinous and tiles are the way out. What exists composes every
active operation into one dispatch over a viewport-sized target. So the spec
fixes three measurements and a decision rule *in advance*: if a frame sits
inside budget at the 99th percentile, the requirement is rewritten rather than
implemented, and tiling becomes what it actually is here — a scheduling
concern for export, which already runs off the frame path. Writing a tile
scheduler the design does not need would be the most expensive way to find
that out.

**The plugin format already exists**, resolved at build time: `ops/*.yaml`
through `build.rs` produces something indistinguishable from a hand-written
operation, and everything it produces is data plus a WGSL string. The blocker
is one line — `descriptor()` returns `&'static` — and the rest is an
interpreter over declarations, load-time WGSL validation, and namespaced ids,
because the sidecar stores parameters by id and a collision is a wrong edit
silently applied.

Recorded last and deliberately: traceability counts a tag, not a behaviour.
`FR-DEV-8` is tagged against plumbing a future spot-removal op would use and
`FR-DEV-7` against a history row for a frontend that does not exist, so 51% is
an overstatement of unknown size. Every requirement this plan closes should be
closed by a test that fails if the behaviour is removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 13:28:39 +02:00
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
dtourolleandClaude Opus 5 9997623df4 Compile the mount-table reader only where there is a mount table
`platform_volumes` has been gated to Linux since it was written, but the
eight items it is built from were not, so every Android build compiled a
`/proc/mounts` parser it could never call and then printed eight dead-code
warnings about it. Real warnings hide in that kind of noise.

Gated per item rather than moved into a module, because the file already
draws the line that way one function above and two patterns for one idea
is worse than a repeated attribute.

The tests go with them. They parse a mount table and assert on names like
`mmcblk0p1`, so they are as Linux-bound as the code they exercise, and
`Path` turns out to be too -- only the reader borrows one, where `Volume`
owns its own.

Android now builds dr-plat with no warnings at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 12:17:25 +02:00
dtourolleandClaude Opus 5 721efbc371 Drop three trait imports Android does not need
The Linux store is built on `keyring::Entry`, whose set_password,
get_password and delete_credential are inherent methods. The Android one
is built on `keyring_core::Entry`, where they are inherent too -- so the
`CredentialApi` import that each of its three methods opened with was
doing nothing, and said so on every Android build.

`CredentialStoreApi` a few lines above is a different matter and stays:
`build` really does come from that trait.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 12:16:17 +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 7e1c33ebed Draw the subjects on the photograph, not on the sensor
Build and test / Desktop (Linux) (push) Successful in 19m42s
Build and test / Layer separation (push) Successful in 27s
Traceability / Requirement traces (push) Successful in 34s
🐳 Android image / Build and push (push) Successful in 4s
Build and test / android-image (push) Successful in 4s
Build and test / Android (aarch64) (push) Failing after 33m12s
"Find subjects" would recognise a person on a portrait frame and then paint
the outline into the hillside behind them. The detection was right and the
mask was right; what was wrong was the picture drawn to show them.

Instance masks live in sensor space, and correctly so — the generated shader
samples them at `uv_src`, after the framing map, which is what keeps a mask
on its subject through a zoom, a pan and a crop. The overlay is the one
consumer that is *not* sampled by that shader. It is a flat image handed to
the compositor to lay over a photograph that has already been through the
framing map, so it has to arrive in the same space that photograph is in, and
it did not. On a frame from a camera held sideways the outlines were drawn a
quarter turn away from the subjects they described.

`overlay_clip` had the same fault one layer down, and it is the more
insidious of the two because it looks right. The crop and the viewport are
fractions of the photograph as the user sees it — the prologue maps an output
pixel through `crop_rect` *before* it unturns the frame — and they were being
measured against the sensor's width and height. Two numbers, correct type,
wrong axis.

Both now go through `Orientation::into_shown`, so the overlay and its clip
are in the photograph's space and the turn is the same one the render and the
thumbnails make.

Neither was noticeable until this week, and the reason is worth writing down:
before the detector was given an upright frame it found almost nothing on a
portrait photograph, so there was rarely an outline to be in the wrong place.
Fixing the detector is what made this visible.

Landscape frames were never affected, which is most of them, and is why an
overlay that ignored orientation entirely survived this long.

Verified on `_MG_9080.CR2`, a portrait frame of two people and a dog: the
overlay was a 1599x1066 image drawn onto a 1066x1599 canvas, with the colour
sitting in the mountainside above the subjects. It is now 1066x1599, and each
outline is on the thing it names.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 09:46:48 +02:00
dtourolleandClaude Opus 5 ce201c7dd6 Name the two spaces a photograph lives in, so a turn cannot go the wrong way
Every orientation bug this codebase has had has been the same bug: a turn of
the right size applied in the wrong direction. That failure is worth naming
precisely, because it does not look like one — a quarter turn applied
backwards lands 180 degrees from right, so the result is a plausible
transform of the picture rather than anything obviously broken, and on
landscape frames it is not wrong at all. It was the straighten shear, and it
was the segmentation overlay, and each time it was found by eye rather than
by a test.

The reason it keeps happening is that "rotate 90 degrees clockwise" cannot be
checked by reading it. The reader has to hold in their head which of the two
images is being rotated and which way the y axis runs, and there were four
hand-written copies of the permutation to hold it for: the shader prologue,
its CPU twin, the thumbnail path, and the segmentation.

So nothing added here says clockwise, anticlockwise, horizontal or vertical.
The functions say *which space they take and which space they return* —
`into_shown` and `into_stored`, `source_pixel` and `shown_pixel`,
`into_shown_rect` and `into_stored_rect` — and each takes the dimensions of
the space it reads from, so no caller has to work out which pair it is
holding. `StoredRect` and `ShownRect` are separate types because they are the
same four numbers meaning different things, which is exactly the case where a
mistake is silent: a shown rect measured against stored dimensions produces a
rectangle in the wrong place, not an error.

Underneath there is one permutation. `source_pixel` was already shared by the
prologue and the thumbnails; `source_point` is its normalised twin, written
beside it so the two cannot drift, and everything else is those two read
forwards or backwards. `Orientation::inverse` is the group inverse rather
than `4 - turns`: mirrors apply after the turn, so undoing means undoing them
first, and a mirror seen from the far side of an odd turn is about the other
axis. That is the diagonal-mirror case, tags 5 and 7, and getting it wrong
renders as — again — 180 degrees.

Three call sites lose their own copy: the thumbnail path, `dr-ui`'s
segmentation, and `dr-gpu`'s `local` example. "Upright" now means one thing
across the application rather than one thing per caller.

The gate that matters most is `the_render_and_the_orientation_map_agree`. The
shader prologue and `Orientation` answer the same question by different
routes, and until now nothing checked that they answered it the same way. It
now checks every EXIF tag against every user rotation and mirror on top of
it, because the composition is where the two could agree singly and disagree
together.

The rest earn their place by having caught something. Writing these found two
real errors in this commit's own new code before it ran anywhere: `shown_pixel`
was handed the dimensions of the wrong space and overflowed, and the rect map
turned the wrong way for the diagonal mirrors. A round trip that returns what
went in is the only check worth having here, since every wrong answer is
still a picture.

No behaviour changes. The permutations are the ones that were already being
applied; they are simply applied from one place now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 09:46:24 +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 61c4547b9c Make the Identity Manager a peer of the library and develop
It was reachable only from the library header, which made it a side trip
rather than a mode. It is now reachable from develop's header too, beside
the way back, because that is the same kind of move -- leaving this
photograph for somewhere else in the library -- and a screen you can only
reach from one of the other two is not a peer of them.

Leaving returns to whichever screen opened it, and the button says which.
A back button that read "Library" while returning to develop would be
lying about the one thing a back button has to be right about. The
develop session is only hidden, never torn down, so returning to it costs
nothing and keeps the photographer's place.

The header now matches the other two screens rather than using a close
cross: three screens whose headers disagree read as three applications.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 23:14:17 +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 b89f1cfece Snapshot the whole edit in the history, so a drawn mask can be taken back
The undo stack snapshotted a `Preset` — the parameter map — and a mask layer
is deliberately not a parameter. So drawing one changed nothing the history
could see: `record` returned `false`, no step opened, and the layer the
photographer had just painted had no way back. The interface went on calling
`record` in good faith, including from the mask controls, and nothing failed.
A film stock went missing the same way.

The snapshot is an `EditState` now, so the history is complete by
construction rather than by anyone keeping a list in their head.

`undo` and `redo` return a `Step` rather than a `bool`. Stepping is not the
only outcome a caller has to act on — a step across a change of film leaves
the graph without its tables, and only the caller can bake them — and a `bool`
would let that be dropped by writing nothing at all, which is the shape of
mistake this module had already made once. `DevelopSession` settles the debt
either way; a step that found nowhere to go is left alone, since clearing the
film because undo hit the floor would take the stock off the picture.

Five tests, all of which fail against the old snapshot: a drawn layer is
undoable and redoable, a layer's own settings are a step of their own, a
change of stock is a step and names what it needs baked back, clearing the
film is undoable, and an exposure move does not deep-copy the mask stack.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 22:54:55 +02:00
dtourolleandClaude Opus 5 93efdf27a6 Keep the film stock when an edit is saved
`Version::update` is the write path an automatic save goes through. It copied
the parameters and the masks and said nothing about the film, so a photograph
developed on a stock was written back without it and opened the next time
without its emulsion. Nothing reported a failure — the line was simply not
there.

It is the third of the three routines that captured "the edit" and the only
one that got it wrong, which is the argument for not having three. All of them
now destructure one `EditState`, so `from_graph`, `update` and `apply` cannot
disagree about what an edit consists of, and the next part of one cannot be
lost by anybody writing a line too few.

Two tests, both of which fail without the fix: the field survives `update`,
and the stock survives the round trip through the file. `apply` returns the
`FilmRebake` it always implicitly owed, so `apply_version` now reads the debt
off the call rather than off `version.film` — and pays it in both directions,
since a version with no film has to clear the adjust pass too or it keeps
textures bound that nothing will sample.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 22:53:00 +02:00
dtourolleandClaude Opus 5 d562ceaaf4 Show a face beside each name in the people rail
The rail was drawing an empty square for every person: cover was
hardcoded to a default image. That is the one place a portrait matters
most, because the rail is how the user decides which unnamed group to
open first, and a list of "Unnamed (24 faces)" rows tells them nothing.

The portrait is the person's confirmed face with the largest crop_px --
the most source pixels the face actually occupied, so the one they have
the best chance of recognising -- falling back to a suggestion so a
freshly clustered group still has a face beside it.

Cached in the controller, because every mutating action reloads the whole
screen and cutting a portrait costs a JPEG decode per person. Without the
cache, confirming one face would re-decode a proxy for every person in
the library, and the rail does not change when a suggestion is accepted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 22:52:38 +02:00
dtourolleandClaude Opus 5 5622a58ce3 Give an edit one complete state, and make omitting part of it a compile error
An edit used to be a bag of scalars. That stopped being true when the mask
stack and the film stock arrived, both deliberately held apart from `ops`
because a layer is not a scalar and a stock is not a scalar — and nothing
announced the change. What happened instead is that three routines each
captured "the edit" and each captured a different subset of it.

`EditState` is all of it: the parameter map, the masks, the stock. What keeps
it complete is not a comment. `EditGraph::state` destructures the graph
exhaustively, `EditGraph::set_state` destructures the state exhaustively, and
the fields are public so every construction site is a struct literal naming
all of them. Adding a fifth kind of graph state — FR-DEV-8's spot removal is
the one already asked for — fails to compile until somebody has decided
whether an undo has to put it back. Verified both ways round by adding a field
to each type and watching five call sites refuse to build.

A compiler error rather than a runtime check, because the failure being
prevented is silence: the missing halves produced no panic, no warning and no
failing test.

The stack is now shared rather than owned, and that is about the drag path
rather than memory: `state` runs on every parameter change, which during a
drag is once a frame, and deep-copying a painted brush sixty times a second to
record an exposure move would be a cost paid for nothing. `masks_mut` is the
one door a stack is modified through, so it clones on write.

`FilmRebake` is the one thing a caller is still owed. Restoring a stock always
needed the profile database this crate does not link (ARCH §6.5a); it was a
comment before, and it is a `#[must_use]` return value now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 22:50:11 +02:00
dtourolleandClaude Opus 5 2944c1b704 Document the run marker and cross-device face sync
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 22:42:28 +02:00
dtourolleandClaude Opus 5 96d07da15f Sync face data as sealed shards, so a second device does not re-index
Indexing 23,500 images is about two hours of CPU, and the result is
byte-identical on every device: the same model over the same proxy
produces the same embedding. Paying for it once per account rather than
once per device is the point.

Shards rather than the catalog snapshot, because the snapshot goes up
whole on every sync and a fully indexed library carries roughly 30 MB of
embeddings. That is exactly the cost the thumbnail store's 25 MB cap
exists to bound, so face shards use the same cap -- imported from
dr_thumbs rather than restated, since the number is a statement about
sync cost and the two must not drift apart.

The split follows the one already there: bulk immutable data in sealed
shards, small mutable data in the catalog snapshot. Faces, landmarks,
embeddings and run markers shard; people, names and assignments ride the
catalog and merge by uuid.

Keyed on oc:fileid throughout, never on image_id, because a row id means
nothing on another device.

The run marker travels with the faces it describes. Without it a
receiving device cannot tell an image with no faces from one never
examined, and would re-detect every landscape it had just adopted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 22:41:50 +02:00
dtourolleandClaude Opus 5 4a82753d22 Show the detector the photograph, not the sensor's scanlines
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
Build and test / Desktop (Linux) (push) Successful in 19m7s
Build and test / Layer separation (push) Successful in 25s
Traceability / Requirement traces (push) Successful in 23s
Build and test / Android (aarch64) (push) Failing after 33m10s
"Find subjects" was handed the proxy in the sensor's own orientation, so
every frame shot on a body held sideways reached the model lying on its
side — and a model trained on upright photographs is very bad at those.
Measured end to end on a 22 MP frame of two people and a dog: `person
0.36` and nothing else, against `dog 0.82, person 0.61, person 0.49` for
the same pixels stood up. Nothing failed; the panel simply offered one
poor subject where there were three good ones.

The orientation was never dropped on purpose. The proxy is deliberately
rendered through a *neutral* graph — the detection has to survive an
exposure change, or every slider would invalidate the masks built on it
— and neutral took the file's orientation with it along with everything
else. Landscape frames were unaffected, which is why it stood for as
long as it did.

The turn is `Orientation::source_pixel`, the same function the grid's
thumbnails already go through, so the detector and the thumbnailer now
agree about which way is up rather than holding two opinions. What it is
turned by is `Framing::effective_orientation` — the file's EXIF tag and
the photographer's own rotations composed into one permutation, by the
group law rather than by adding the turns, which is a distinction
`Framing` already had to make and had already tested. Rotating the
picture and pressing the button again therefore does what it looks like
it does.

The proxy stays in sensor space and the masks come back into it. That is
not a detail to be tidied later: the generated shader samples the mask
array at `uv_src`, *after* the framing map, so a mask stored upright
would sit a quarter turn off the subject it was drawn around. That is a
wrong mask rather than a weak one, and nothing announces it. So the
picture is stood up for the model and laid back down for everything
else, and `upright`/`lay_down` are returned as a pair because calling
one and forgetting the other is silent.

Both directions are the one function: `upright` gathers through
`source_pixel` and `lay_down` scatters through it. A quarter turn is a
bijection of the pixel grid, so the round trip is exact — no filter, no
resampling, and no hole to fill — and an inverse written out by hand
would be a second thing to keep in step, whose way of being wrong is a
mask mirrored about the wrong axis, which still looks like a mask.

The orientation joins the confidence and the tiling flag in the
segmentation signature, and for the same reason: turning the photograph
changes what the model recognises, so two runs either side of a rotation
are different instance lists. Two that happened to come out the same
length would otherwise share a signature and a stored layer would be
silently re-indexed from one into the other.

The refine pass had it too — it re-runs the model over a crop rendered
in the same sensor space — so it makes the same turn, and would
otherwise have handed back a worse mask than the one it was asked to
improve, on the subject the photographer had just pointed at.

`dr-gpu`'s `local` example is fixed with it. It exists to be the
shipping path with pictures attached, and a diagnostic that reproduces
the bug it is meant to catch is a trap for whoever reads it next.

Seven tests. The round trip is the identity over all eight EXIF tags on
a non-square asymmetric grid; a turn carries whole pixels rather than
shearing the channels apart; a sideways frame reaches the model
upright; a box comes back in sensor pixels, worked out by hand for the
one turn a portrait frame actually writes; a restored box still reads
low-to-high for every tag, since the rest of the pipeline takes
`x1 - x0` without checking the sign; and the eight tags cannot collapse
into one signature key. The existing composition test now runs against
`effective_orientation` itself, over all 8 x 16 baseline-and-user pairs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 22:40:58 +02:00
dtourolleandClaude Opus 5 336a1fd296 Write down what the Android renderer change cost, and what else is owed
Three compromises were taken deliberately over the last few days and none of
them was written anywhere a future reader would look. So `docs/technical-debt.md`,
and two corrections to the architecture document that the Android change made
untrue the moment it landed.

**TD-1, the Android readback.** §12/6.1 says GPU results never round-trip
through the CPU, and the develop view on Android now does exactly that. That
is worth recording as a breach with reasons rather than quietly leaving a
constraint the code no longer honours — the next person to read §6.1 and then
`DevelopSession::render` would otherwise conclude one of them is a mistake.
The entry carries the device measurements that forced it, why setting
`preTransform` is not a fix available to us, and the three separate things any
one of which would remove it.

**TD-2 and TD-3**, the serial thumbnail fetch and the unbounded drain, were
found while chasing the tearing and are still outstanding. Both have a known
shape for the fix; neither is a bug, and neither should be discovered again
from scratch.

The architecture document said the app renders "through wgpu to Vulkan on both
Linux and Android", which stopped being true at 6267802, and §12/6.1 claimed a
constraint with no exceptions. Both now say what the code does and point at
the debt entry for why.

The numbers are labelled with what they are. TD-1's readback cost has *not*
been measured on the device and says so, and TD-3's figures are from a debug
build and say so — a documented measurement that quietly turns out to be the
wrong build is worse than no measurement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 22:36:37 +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 e0cb968e04 Merge remote-tracking branch 'origin/master' into worktree-spot-removal
🐳 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 20m9s
Build and test / Layer separation (push) Successful in 39s
Traceability / Requirement traces (push) Successful in 26s
Build and test / Android (aarch64) (push) Failing after 33m10s
# Conflicts:
#	docs/traceability.md
2026-08-26 21:56:32 +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 e14bc34a9e Ask which frame this was taken on, because grain is enlargement
Build and test / Desktop (Linux) (push) Successful in 19m6s
Build and test / Layer separation (push) Successful in 28s
Traceability / Requirement traces (push) Failing after 26s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 3s
Build and test / Android (aarch64) (push) Failing after 33m7s
A crystal is a fixed size in micrometres. How grainy a photograph looks is
therefore not a property of the emulsion alone -- it is film size against
output size, and the frame is the half a digital file cannot supply.

This assumed 35 mm for everything. The same emulsion on 4x5 averages about
3,800 crystals into the pixel that holds 300 on 35 mm, so it renders roughly
3.5 times smoother at the same print; every large-format photograph was being
rendered as grainy as a half-frame.

`Format` now carries the real image widths -- the gate, not the nominal inches,
since a "4x5" exposes about 121 mm -- and the film node asks for it. It is a
genuinely fixed list, unlike the stocks, so it is a declared `enum` parameter
and gets its control, its sidecar entry and its undo step for nothing.

It is also the first enum in the develop chain, and it broke two tests by
being one. A row has to compare equal to itself across two builds or
`sync_rows` replaces it on every parameter event -- destroying the elements
built from it, including whichever TouchArea holds the current gesture, so the
format picker would have fought every slider drag in the panel. `ModelRc`
compares by identity and the row built a fresh choices model each call.

`no_choices` already shares one empty model for exactly this reason, and the
build site already said "see no_choices for why the identity matters". The fix
follows it: memoise the model per variant list. Curve rows solve the same
problem the other way, writing values through the existing model, which is not
needed here -- a variant list is fixed at compile time, so one model can serve
forever.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 21:47:19 +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 3e607222c6 Record the Identity screen in the face spec
Also notes what building it taught the design: a split has to reject
before it confirms, or the next clustering pass undoes it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 21:16:04 +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 871a0eac28 Check the image has the commands before CI finds out it does not
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
Build and test / Desktop (Linux) (push) Successful in 19m21s
Build and test / Layer separation (push) Successful in 29s
Traceability / Requirement traces (push) Successful in 23s
Build and test / Android (aarch64) (push) Failing after 33m3s
Two failures in a row were the same shape: a command the workflow calls
was not in the image. git-lfs, then file(1). Each cost a full run to
learn, and the android job is expensive to be wrong in -- the step that
fails is at the end, so every attempt paid twenty-eight minutes of
cross-compile first to reach the line that could not work.

Both were visible in ten seconds from here. `docker run <image> command -v
file` is the whole diagnosis; it just never occurred to anybody to ask
before pushing.

So the question gets asked automatically. This reads the `run:` blocks out
of the workflows, pulls the commands worth doubting -- the ones a minimal
Debian plausibly lacks, not `cd` -- and checks each against the image its
job declares. It does not run the workflow and is not a replacement for
one. It answers exactly the question that was expensive to answer.

`git lfs` is handled specially and the comment says why: it is a
subcommand, so the first word of the line is `git`, which is always there.
Taking first words alone would have missed the original bug -- and did,
in the first version of this script.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 20:31:25 +02:00
dtourolleandClaude Opus 5 d06b24c485 Regenerate the traceability matrix after the 0.7.0 work
Build and test / Desktop (Linux) (push) Successful in 19m20s
Build and test / Layer separation (push) Successful in 28s
Traceability / Requirement traces (push) Successful in 1m2s
🐳 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 33m15s
Seven more tags -- 621 against 614 -- from the print path, the film table
rebuild and the projector lamp. Coverage is unchanged at 51.4% (91/177):
the new tags land on requirements that already had one.

Nine rows move. As before, the matrix links to line numbers, so a commit
that inserts anything above a tag rewrites its row without changing what
the row says.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 20:28:27 +02:00
dtourolleandClaude Opus 5 2958444835 Let undo reach the repairs before the tool can make one
A history state was a Preset — a map of scalars — which was right while
every edit in the graph was a parameter. A spot is not one, and undo is the
first thing anyone does with a repair: place it, dislike it, take it back.
Left as it was, that press would have stepped some unrelated slider and
left the spot on the photograph, which reads as undo being broken rather
than as undo being absent.

So a state is now the pair, params and spot set. The same door is the one
the mask stack will come through: mask edits are outside undo today for
exactly this reason, and FR-DEV-5 is not finished until they are not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 20:27:34 +02:00
dtourolleandClaude Opus 5 f00b3ae924 Take the tone from the hole and the texture from beside it
A clone gets the texture right and the level wrong. Dust on a gradient sky
is copied from a patch a little lighter than the hole it fills, and the
repair reads as a disc even though every grain in it is correct — which is
why FR-DEV-8 asks for heal and not only for clone.

Heal adds the membrane: the difference between the two neighbourhoods,
sampled at twenty-four points around the rim and interpolated across the
disc by inverse square distance. Solving the Poisson problem properly is
tens of Jacobi iterations, and an iteration here is a dispatch — sixty
dispatches to remove a dust spot is not a frame budget. The closed form
costs one loop over the rim, no state, and no second pass.

The spec called for mean-value weights; inverse squares are two
transcendentals per sample cheaper and agree wherever the boundary
difference varies smoothly, which is every repair anyone makes. What
decides whether that trade holds is the measurement, so the measurement is
the test: on a ramp steep enough to leave a clone wrong by 38 levels out
of 255, the heal is wrong by 0. docs/spot-removal.md §6.1 records what
shipped and what it would take to go back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 20:22:10 +02:00
dtourolleandClaude Opus 5 5323608051 Draw the repairs, before anything sharpens what they removed
A spot set now composes detail passes of its own, one per round, and they
go ahead of every operation's kernel. That placement is the decision worth
recording: a sharpening pass reads a neighbourhood, so sharpening a dust
mark before removing it smears its edge into pixels the repair's disc does
not cover, and what survives is a faint over-sharpened ring around an
otherwise perfect patch. It also disagrees with ARCH §5.2, which draws
spot removal after clarity — docs/spot-removal.md §5.1 is where that is
argued out.

Every length reaching the shader is in render pixels, converted here where
the framing is in scope. Both the centre and the source go through
`Framing::output_at` — the same map the fused pass applies to every pixel
— so a rotated photograph rotates the offset with no trigonometry, and the
radius is found by mapping a point one radius above the centre and
measuring, rather than by multiplying by a ratio this function has no
business knowing about. The tests turn and crop the frame and expect the
mark to stay gone, which is the property that arrangement buys.

compose_full now takes the spot set, because a photograph with a repair
and no sharpening still has a detail stage: a fused pass that encoded its
own output there would quantise twice and bind to a texture of the wrong
format. compose_detail_for takes the source size for the same kind of
reason — a RenderScale describes the region on screen, and a spot is
stored against the photograph.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 20:17:51 +02:00
dtourolleandClaude Opus 5 00e78dc2ac Cluster faces into people, and calibrate what a similarity means
FR-CULL-9 forbids thresholding a bare cosine anywhere in the subsystem,
so calibrate fits P(same person) per library and reports whether the fit
is trustworthy. Two details carry most of the weight.

The fit runs against a 200-bin histogram rather than a pair list: a
25,000-face library has ~3e8 pairs and no gradient descent is running
over that. And a fresh library has no valid calibration, because the
positives have to come from user confirmations or burst siblings --
bootstrapping them from high cosine would fit the calibration to the
belief it was supposed to test.

Clustering defends against the over-merging FR-CULL-10 warns about with
constraints rather than a better threshold: two faces in one photograph
never merge, and two groups confirmed as different people never merge.
Average link rather than single link, so one strong edge cannot weld two
families together.

Calibration is defined once, in dr-face, and dr-catalog re-exports it.
Two implementations of one probability model is exactly how a number
comes to mean the wrong thing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 20:10:33 +02:00
dtourolleandClaude Opus 5 aac3136407 Store faces and the people they belong to
Schema v8: people, faces, face_person, face_person_rejected, and the
per-library calibration. Follows catalog.md 10.1 with two additions the
spec work turned up.

crop_px, because at the 1024px proxy tier a group shot reaches the
embedder at ~50 source pixels upsampled to 112 and a portrait at 340.
FR-CULL-9 names face size as an axis along which an uncalibrated
similarity misbehaves, so it is a stored feature rather than a UI hint.

face_person_rejected, because rejection is not the absence of an
assignment. Without it the next clustering pass re-suggests exactly the
face the user just pushed away, and the tool feels broken.

record_detections replaces rather than appends, since DetectFaces is
coalesced per image -- and carries confirmations across the replacement
by box overlap, so re-indexing with a better model cannot discard the
user's own labelling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 20:02:52 +02:00
dtourolleandClaude Opus 5 6997c0f7ac Let a detail pass carry a list, not only a kernel
Every neighbourhood pass so far has been a convolution, whose whole
description fits in the uniform block because its structure fixes how many
numbers it needs. Spot removal is not that shape: sixty-four repairs and
one repair are the same shader with a different buffer behind it.

So a pass may declare `storage`, which arrives at binding 3 as
`array<vec4<f32>>` with `arrayLength` in scope. The alternative — packing
the list into uniforms — needs a fixed maximum paid for on every frame, a
composer that can emit vec4 fields because a uniform array's stride is 16
whatever it holds, and it gives the next operation that wants a table
nothing to build on.

The property worth having is what stays out of the generated source: the
count is in the buffer, so placing the tenth spot uploads 512 bytes and
reuses the compiled pipeline, exactly as moving a slider does for the
fused pass. `changing_the_list_does_not_recompile` is that, asserted.

One bind group entry rather than two more layouts, and one placeholder
buffer allocated in `new` rather than sixteen bytes per pass per frame —
a zero-length storage buffer cannot be bound, and per-frame allocation is
what this module's documentation exists to refuse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 20:00:24 +02:00
dtourolleandClaude Opus 5 19981c1033 Detect, align and embed faces with SCRFD and MobileFaceNet
Ports the pipeline from the C++ reference in ../scene-actor-extraction
(MIT, same author). End to end on real portraits it separates identities
the way the reference's fitted calibration says it should: 0.596 between
distinct photographs of one person, 0.05 between different people, either
side of MBF's 0.267 boundary.

Three things are structural rather than incidental:

Aligned112 can only be built by align::warp, so Embedder::embed cannot be
handed an unaligned bounding-box crop. That mistake yields 512 plausible
unit-norm numbers and no error, so the type system refuses it instead.

Embedding carries its ModelId and cosine() returns None across models,
because a cross-model similarity is the one mistake that produces
plausible garbage rather than a failure.

The model-free half -- alignment, embedding arithmetic, f16 storage --
sits outside the inference feature and is covered by 11 tests that need
no weights on the machine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 19:57:56 +02:00
dtourolleandClaude Opus 5 44444f7768 Write the repairs down, one line each, and merge them by id
A spot is eight numbers, so it goes in the version block as a line rather
than in a block of its own the way a mask does — sixty-four blocks would
bury the rest of the file. A line per spot rather than one line for the
set, because the line is what a diff shows and what a merge resolves.

The parse arm sits ahead of the op.param arm deliberately: without it,
`spot.abc123 = 0.4 0.6 …` reads as an operation called `spot` whose value
will not parse, and the repair is dropped with a warning about a corrupt
number. The prefix is safe precisely because a spot is not an operation,
so no ops/ declaration can claim the name.

Merging is merge_masks by id, one level down, and it is where the derived
ids earn their keep: two devices that removed different marks hold
different ids and both survive, while two that removed the same piece of
dust hold the same id and the merge sees the one repair it is. A spot both
sides dragged resolves whole to the higher revision — eight numbers
describe one disc, and half of each is a repair neither photographer made.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 19:52:54 +02:00
dtourolleandClaude Opus 5 72410f39c6 Answer M1: tract loads both face graphs once their dims are pinned
Neither InsightFace export parses as shipped -- SCRFD fails at its input
node, ArcFace at the first Conv -- which is the same wall dr-segment hit
on YOLO's dynamic export. Both load cleanly with the input dims frozen,
so the pure-Rust runtime holds for the face pipeline too.

tools/fix-face-model-shapes.sh does the freezing, and exists so the
artefact is reproducible rather than a binary someone once produced. It
takes two forms because the two graphs need different ones: ArcFace's
batch is a named dim_param, SCRFD's H and W are dynamic but unnamed.

Also notes YuNet loading with no intervention, which matters for the
licence question in faces.md 2.3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 19:51:02 +02:00
dtourolleandClaude Opus 5 97479a0512 Hold the repairs a photographer makes, and say which may share a pass
A spot is a disc, a source offset and four numbers, and it lives beside
`ops` for the reason `masks` and `film` do: the operation trait is
ParamId -> f32, and a list of repairs is neither scalar nor fixed.

Two decisions here are not obvious. The id is derived from the position
rather than counted, because two devices editing offline would each mint
`spot3` for different marks and the sidecar merge would then treat two
repairs as one — from the position, two devices that removed the same
piece of dust agree, and two that removed different ones do not. And
every length is in the frame's isotropic units, not a mixture of those
and shorter-edge fractions: one unit for the radius, the feather and the
offset agrees on a landscape frame and on a portrait one, where a mixture
only agrees on the first.

`rounds` is the arithmetic that keeps a source from reading a
destination. Every spot in one pass reads the photograph as it stood
before that pass, so a spot sourcing from an earlier spot's destination
would copy the mark that spot was removing. Grouping is not a pass per
spot — that is sixty-four dispatches for a case that almost never arises
— it is a new round only when the sources actually collide.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 19:50:00 +02:00
dtourolleandClaude Opus 5 ec740115b6 Spec the face pipeline on SCRFD and MobileFaceNet
FR-CULL-8..12 specify the subsystem in terms of "a 512-dimension embedding
from a stated model" and stop there, because D13 was open. This names the
models, and grounds them in the measurements and the working C++ pipeline in
../scene-actor-extraction rather than in a literature reading.

The licensing half of D13 stays open, but with a route through it: the
InsightFace weights are non-commercial and cannot be committed, so the app
ships the code and the user fetches the model. faces.model_id already makes
that a survivable choice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 19:44:39 +02:00
dtourolleandClaude Opus 5 8d8d6491ad Say how spot removal is going to work before writing it
FR-DEV-8 is the last develop requirement with nothing behind it. The
pipeline it lands on already has most of the parts — the neighbourhood
stage, the mask crate's normalised coordinates, the gradient handles'
canvas drags, the merge-by-id rule — so the spec is mostly about the four
things that are genuinely new, and about the two places where the obvious
implementation is the wrong one: a Poisson solve is sixty dispatches per
spot, and a per-frame readback to find out what colour a sky is would undo
ARCH §6.1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 19:43:30 +02:00
dtourolleandClaude Opus 5 f82c69bc6b Say which version this is: 0.7.0
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
Build and test / Desktop (Linux) (push) Successful in 1h20m35s
Build and test / Layer separation (push) Successful in 31s
Traceability / Requirement traces (push) Failing after 1m0s
Build and test / Android (aarch64) (push) Failing after 33m5s
Film simulation is a feature, not a fix: five Ilford stocks, a grain
model that counts silver rather than adding noise, and the pipeline and
UI to drive them. 0.6.0 was tagged thirty-one commits ago and does not
describe any of that.

The Android versionCode follows from this without being restated --
package.sh packs MAJOR*10000 + MINOR*100 + PATCH, so 0.7.0 is 700, above
the 600 already installed on devices and therefore an upgrade rather than
a refusal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 18:14:23 +02:00
dtourolleandClaude Opus 5 f14176de29 Give the projector lamp a name instead of a warning
Every bake of a Vision3 stock logged:

    unknown illuminant "K75P", falling back to D55

K75P is a cinema xenon short-arc lamp, and Kodak 2383 and 2393 -- the
projection print films those stocks print onto -- name it as the light their
result is looked at under. It was never implemented, so it fell through to
the unknown branch.

The fallback was the right family: a xenon arc sits near 6000 K, close to
daylight and nothing like the tungsten enlarger above it. So the pixels do not
move. What changes is that D55 is now a documented choice rather than the
consolation prize for an unrecognised string, with the approximation stated --
an arc has line structure a Planckian curve cannot express, and the residue of
that is small here because the viewing step adapts the white point out either
way.

The test is the point of the commit. A profile naming a light nobody
implemented should fail the suite, not whisper into a log that only gets read
when somebody happens to be looking for something else -- which is how this
was found.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 17:41:34 +02:00
dtourolleandClaude Opus 5 4b50648870 Rebuild the film tables when the film's own sliders move
Build and test / Layer separation (push) Successful in 41s
Traceability / Requirement traces (push) Failing after 37s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Build and test / Desktop (Linux) (push) Failing after 41m29s
Build and test / Android (aarch64) (push) Failing after 33m5s
Push and print exposure did nothing at all. The parameter was set and the
tables were never rebuilt, so the shader went on running the stock as it had
been baked.

The check asked the wrong list:

    self.rows()                     // one entry per *parameter*, tab-filtered
        .get(op_index as usize)     // indexed by an *operation* index
        .map(|_| ())
        .and( ...the real check... )

`op_index` counts over the scoped capabilities -- it is what `lookup` resolves
a slider through -- so capabilities is the only list to ask. Indexing `rows()`
served no purpose, and past its end the `and` short-circuited to None and the
rebake silently never happened.

Both halves of that are worth saying. It was wrong, and it was convoluted, and
the convolution is what hid the wrongness: a one-line check would have been
obviously right or obviously broken.

This is a class of bug the suite cannot reach. The tables are rebuilt in the
interface layer, in response to a control, and every test either side of it
passed throughout -- dr-film computed the pushed curves correctly and the
shader rendered whatever it was handed. Only moving the slider showed it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 17:35:25 +02:00
dtourolleandClaude Opus 5 ff1a3e0e80 Print the black-and-white negatives instead of showing the scan
Choosing Ilford HP5 Plus showed an inverted grey frame. So did Double-X.
They are negatives, and upstream leaves `target_print` null on every
monochrome stock, so nothing was ever printed and the scan was all there was.

A colour negative at least announces itself -- the orange mask says plainly
that you are looking at a negative. A monochrome one just looks broken.

They print on Kodak 2302 now, which is a monochrome print film and is what
such a negative is actually printed onto; Double-X onto 2302 is the standard
cine chain. For the Ilford stocks it stands in for an Ilford paper, which
nobody has measured, and is at least the right kind of material.

The scan is still reachable through the Scanned/Printed toggle. It is a thing
to choose now rather than the only thing on offer.

`every_shipped_stock_bakes` did not catch this, and could not: it derives
"should this be inverted?" from the stock's kind *and whether it names a
paper*, so it looked at an inverted HP5, concluded that was right for an
unprinted negative, and passed. The assertion was self-consistent and the
situation was still wrong. The new test asserts the thing that actually
matters -- a camera negative must name a paper, that paper must be a printing
stock, and it must be the same kind of material, so a monochrome negative
cannot end up on colour paper.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 17:35:25 +02:00
dtourolleandClaude Opus 5 ce6458547a Develop longer, from the measurements rather than from a contrast slider
Pushing was not a thing to simulate. It was measured data being thrown
away: Double-X and 2302 each ship five characteristic curves, one per
development time, and this shipped the 6.5-minute column and discarded
four. All five now ship and interpolate.

The axis is real. Double-X runs 4 to 12 minutes, and across it the average
gradient goes 0.472 to 1.034 while Dmax goes 1.19 to 2.56.

The control is in stops, because that is what a photographer means, and one
stop is a factor of about 1.41 in time. That mapping is checked rather than
assumed: against Double-X's own axis it lands within 2% of the 9-minute
column for +1, and near 12 minutes for +2, which are the times the datasheet
gives for exactly that. There is a test.

**Pushing must not recover shadow detail, and this does not.** Across the
whole measured range the speed point moves about a third of a stop while the
gradient doubles; three stops under mid-grey, density goes from 0.008 to
0.035, which is still nothing. Developing longer multiplies what was already
recorded and cannot record what never hit the film. A push built as added
exposure or global contrast brightens those shadows instead and looks
convincing until someone who shoots film sees it, so that property has a
test of its own.

Interpolated in *log* time, because development is multiplicative: 4 to 5
minutes is the same amount of push as 9 to 12, and interpolating linearly
would bunch the control at one end. Clamped at both ends, because past the
published range there is no data and extrapolating a contrast curve invents
an emulsion nobody tested. A stock measured at one process ignores the
control entirely rather than inventing a curve for it -- Portra 800's pushes
are separate *measured* profiles, which is the honest way to offer those.

Costs nothing per pixel and changes no shader. The curves are a per-stock
table, so the interpolation happens on the CPU at bake time, where choosing a
stock and moving its sliders already rebakes. The Vulkan shader is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 17:35:25 +02:00
dtourolleandClaude Opus 5 8f863ec56f Put file(1) in the Android image too
Build and test / Desktop (Linux) (push) Successful in 1h22m29s
Build and test / Layer separation (push) Successful in 35s
Traceability / Requirement traces (push) Successful in 25s
🐳 Android image / Build and push (push) Successful in 10m19s
Build and test / android-image (push) Successful in 10m19s
Build and test / Android (aarch64) (push) Failing after 33m30s
The android job now fetches the model and cross-compiles the whole app --
28 minutes of it -- and then dies on

    file: not found        (exit 127)

`Verify minimum API level` reads the linked API out of the .so's ELF
notes with file(1), and the image has never had it. Like git-lfs, the
absence could not show until something got that far: every previous run
panicked in dr-segment's build script long before this line, so the step
that was going to fail never ran.

Audited the rest of what the remaining steps invoke against the image
rather than find the next one the same expensive way -- zip, keytool,
base64, mktemp, shred, find, sed, awk, and aapt2/zipalign/apksigner/d8
from build-tools are all present. file was the only gap left.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 14:21:05 +02:00
dtourolleandClaude Opus 5 b9ee29f3c7 Regenerate the traceability matrix for the Ilford stocks
Build and test / Desktop (Linux) (push) Successful in 1h21m23s
Build and test / Layer separation (push) Successful in 35s
Traceability / Requirement traces (push) Successful in 27s
🐳 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 33m28s
Same drift as before and for the same reason: the matrix links to line
numbers, and the grain work moved lines in files that carry tags.

The stocks bring six new files and ten new tags -- 227 scanned against
221, 614 found against 604 -- all on requirements that were already
covered, so coverage is 51.4% (91/177) either side. Twelve rows move and
nothing else changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 10:14:29 +02:00
dtourolleandClaude Opus 5 40e6334bb1 Sign the APK with a real key when one is configured
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Build and test / Desktop (Linux) (push) Successful in 21m23s
Build and test / Layer separation (push) Successful in 29s
Traceability / Requirement traces (push) Failing after 28s
Build and test / Android (aarch64) (push) Failing after 33m28s
The APK has been debug-signed with a key generated on the spot, which is
right for putting a build on a test device and useless for anything else:
a different signature every run, so nothing can ever update in place.

Four secrets now select a real signature -- ANDROID_KEYSTORE_BASE64 and
its password, alias and key password. The names are JellyTau's, because
that repo already signs its Android build this way against this same
runner and one convention across both is one thing to remember.

Absence of the secrets is not an error. A fork or a branch build has no
access to them and should still produce an installable APK, so the debug
path stays exactly as it was. The reverse is an error: if a keystore is
supplied and cannot be read, the build fails rather than quietly falling
back to a debug key, because a release that is silently debug-signed is
worse than no release.

Passwords reach apksigner and keytool as `env:`, never `pass:`. `pass:`
puts the password in the process table for anything on the box to read.
The keystore is written to a 0700 mktemp directory and never into the
workspace, which is both what actions/cache saves and what the upload
step globs.

Also: upload-artifact drops from v4 to v3. v4 was a guess about what this
Gitea supports. v3 is what JellyTau uploads its APK with on this runner
today, which makes it the version known to work rather than the one that
ought to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 10:13:50 +02:00
dtourolleandClaude Opus 5 e9b3598841 Add five Ilford stocks, and say plainly that they are constructed
Build and test / Desktop (Linux) (push) Successful in 20m48s
Build and test / Layer separation (push) Successful in 27s
Traceability / Requirement traces (push) Failing after 25s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 2s
Build and test / Android (aarch64) (push) Failing after 33m33s
They were asked for and they are here, but not on the same footing as the
Kodak profiles, and the files say so in their first line.

What I had claimed, and had to withdraw: that Delta 100 is "quoted around 9"
and HP5 "around 12". Ilford publish no such figures. The word granularity does
not occur anywhere in their technical information -- grain is described as
"fine" and "finest" and nothing more. That claim was in this crate's
documentation as though it came from a datasheet; it is corrected there too.

Two further traps found while looking:

  - Kodak colour negatives publish Print Grain Index, not RMS granularity.
    PGI is a perceptual scale from viewer surveys -- 25 is roughly the
    threshold of visibility, four units a just-noticeable difference -- and
    Kodak state it cannot be compared to RMS. So a Portra number cannot be
    dropped into the granularity field, and none has been.
  - RMS proper is published mostly for black-and-white, reversal and motion
    picture stocks. Every shipped stock therefore still carries the same
    default, which means grain does not yet tell one film from another. That
    is per-stock data, not code, and is now written down where somebody will
    find it.

So the Ilford profiles are built rather than extracted, and each part rests on
something different:

  speed        published and exact -- ISO 400/27 for HP5 is a fact
  contrast     ISO 6:1993's normal development, average gradient 0.62
  spectral     borrowed from Kodak Double-X, a *measured* panchromatic
               negative, shifted by the speed difference. Conventional
               panchromatic sensitisation is much alike across black-and-white
               films, and this is far better founded than reading pixels off a
               printed curve
  silver       neutral, which is not an approximation: developed silver
               absorbs flat, and Double-X's measurement is flat
  granularity  estimated, ordered by each film's known relative grain

They render as a film of that speed and contrast. They are not a measurement
of that emulsion, and the two stocks that share a speed differ only in the
estimated part.

`every_shipped_stock_bakes` is tightened to match, because a constructed
profile fails in a way a measured one does not: the curve parses, bakes, and
sits entirely off one end of its own exposure range, rendering every frame
black or blown while passing a finiteness check. It now asserts mid-grey lands
somewhere photographic and that the tone response runs the way the stock's
kind says it should.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 10:08:51 +02:00
dtourolleandClaude Opus 5 4b2ee0ac50 Count the silver instead of adding noise
An emulsion is a suspension of crystals. Light sensitises some; development
turns a sensitised one opaque, all or nothing. So a patch of film's density
is a *count* of developed grains, and a count of independent yes/no events
has a variance whether or not anyone wanted texture:

    mean     = D
    variance = D * (Dmax - u * D) / N

That expression is the whole feature. It peaks in the middle of the density
range and vanishes at both ends -- clear film has nothing developed to vary,
black film has nothing left to develop -- so grain lives in the midtones as a
consequence rather than as a "midtone bias" slider.

I was wrong earlier that this needs the detail stage. Nothing in it reads a
neighbouring pixel; the only reason to move it was that grain must be fixed in
film space rather than screen space, and that solves itself: N is grains *per
pixel*, so it scales with the film a pixel covers. Zoom out, each pixel
averages more grains, less variance -- correct, with nothing super-sampled and
nothing filtered. It stays in the fused pass.

Grain goes on the density and *before* the dye, which is the physical order
and not cosmetic. Perturbing the finished colour -- what an effect does --
tints highlights wrong, because that noise never passes through the dye.

Crystal habit lives in `rms_granularity`, the number every datasheet
publishes, now a profile field. It measures exactly what differs between a
cubic emulsion and a tabular one: at equal speed, tabular crystals present
more area per unit silver, so the film reads finer. Delta 100 is quoted near 9
where HP5 is near 12, and that gap *is* the habit. Adding a stock whose grain
is its whole reputation is therefore editing one line, not writing a model.

Three things this cost, all of them worth writing down:

  - The default granularity is a colour negative's, blue coarsest. Applied to
    Tri-X it put *colour* speckle on a black and white photograph. Monochrome
    stocks collapse it at parse, where every other per-layer table is already
    replicated from the one measured channel.
  - Helpers cannot read uniforms. The composer prefixes a uniform with its
    operation's id and rewrites references inside a fragment body only;
    helpers are shared and deduplicated, so a bare `gn0` names nothing.
    `film_lut` already took its size as an argument for this reason, and now
    says so.
  - The end-to-end test compares the shader against the CPU model, and grain
    is stochastic, so that comparison now runs with grain off. Which means a
    grain that never left the CPU would look exactly like a passing suite --
    hence a second test that grain off is bit-identical, one grain per pixel
    moves it, and ten thousand move it less.

Not here, deliberately: no grain slider. The parameters are physical and
`rms_granularity` is the honest place to scale one from, but its range wants
choosing rather than guessing. Nor a film format -- 35 mm is assumed, and
medium format at the same stock is far less grainy per unit of picture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 10:08:51 +02:00
dtourolleandClaude Opus 5 1d38015a7b Put git-lfs in the Android image, which never had it
Build and test / Desktop (Linux) (push) Successful in 1h21m48s
Build and test / Layer separation (push) Successful in 27s
🐳 Android image / Build and push (push) Successful in 10m40s
Build and test / android-image (push) Successful in 10m40s
Traceability / Requirement traces (push) Successful in 26s
Build and test / Android (aarch64) (push) Failing after 33m49s
The Android job has never fetched the model. Not because of the header
collision the desktop job hit -- that one is fixed and the desktop job
now pulls all 11 MB -- but because the image has no git-lfs at all:

    git: 'lfs' is not a git command. See 'git --help'.

The fetch step dies on its first line, `git lfs install --local`, before
any of the auth handling runs. The build then panics in dr-segment's
build script with a message telling you to run `git lfs install && git
lfs pull` -- advice that could not have worked, because the client it
names was never in the image to run.

Both jobs failing their fetch step at the same time made this look like
one bug with one cause. It was two, in two different images, and the
desktop one was noisier: it had a client, so it got as far as an HTTP
error worth reading. The android one had nothing to say beyond the name
of a missing command.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 06:52:50 +02:00
dtourolleandClaude Opus 5 56bdd457dd Stop the test build filling the runner's disk
🐳 Android image / Build and push (push) Successful in 4s
Build and test / android-image (push) Successful in 4s
Build and test / Desktop (Linux) (push) Successful in 1h58m2s
Build and test / Layer separation (push) Successful in 2m3s
Traceability / Requirement traces (push) Successful in 31s
Build and test / Android (aarch64) (push) Failing after 22m34s
The desktop job died mid-link with LLVM reporting "IO failure on output
stream", which reads like a compiler crash and is not one: underneath it
is `No space left on device`. The runner ran out of disk while linking.

Worth knowing what it was spending it on. `target/debug` was 24 GB
against `target/release`'s 2.6 GB -- the test build is roughly ninety
percent of the footprint -- and of that, 15 GB was debug info in
`debug/deps` and 3.6 GB was incremental state. Neither buys anything
here. Nothing attaches a debugger to a CI run, and incremental
compilation exists to make the second build in a working tree fast,
which is not a thing a fresh checkout ever has.

With both off the same tree is 3.3 GB, `debug/deps` 2.8 GB, and the test
binaries build unchanged. Backtraces keep function names and lose file
and line numbers; if a failure ever needs those, DEBUG=1 gives line
tables back for a fraction of the 15 GB.

A `df -h` either side of the expensive steps, so the next time this
happens it says so in one line rather than as an error from LLVM.

This is a mitigation and it should not be mistaken for the fix. It bounds
what this job asks for; it cannot help if the runner is full of anything
else, and 24 GB of build output is not obviously the largest thing on a
host that also keeps every cached target directory this workflow has ever
saved. If it fails here again, the disk needs looking at on draco-x86.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 01:13:32 +02:00
dtourolleandClaude Opus 5 834b219c3f Publish the Android APK as a build artefact
Build and test / Desktop (Linux) (push) Failing after 1h12m6s
Build and test / Layer separation (push) Successful in 41s
Traceability / Requirement traces (push) Successful in 23s
🐳 Android image / Build and push (push) Successful in 10m59s
Build and test / android-image (push) Successful in 11m2s
Build and test / Android (aarch64) (push) Failing after 22m52s
The android job proved the app links for aarch64 and then threw the
result away. There was no APK anywhere in CI and no upload step in the
repo at all, so a green run left nothing anybody could install -- the
artefact list was empty by construction, not by failure.

It now assembles the APK with the script package.sh uses and uploads it.
The .so comes from the build the API-level check already ran; -o only
adds a copy of it where the packaging step looks, so this costs one copy
rather than a second twenty-minute cross-compile.

The signing key is the part worth being careful about. KEYSTORE points at
a mktemp directory rather than its default under target-android, because
that directory is precisely what actions/cache saves and restores -- the
default would have written a private key into the build cache and kept it
there. Nothing but the .apk is uploaded. A fresh debug key each run is
the right trade for an artefact meant to reach a test device: the only
thing a stable key buys is installing over a previous build without
uninstalling first, and a key that survives in cache storage to buy it is
a bad exchange.

if-no-files-found: error because the failure being guarded against is a
green run with an empty artefact list, which reads as success right up
until somebody goes looking for the file.

Debug-signed, arm64-v8a only -- the ABI the job already builds. Neither
is a release story; this is a build you can install.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 23:38:20 +02:00
dtourolleandClaude Opus 5 5741ec5e00 Lift the APK assembly out of package.sh so CI can run it too
package.sh does two things: it decides how the host reaches the image, and
it assembles an APK once inside it. Only the first half is host-specific.
CI already runs in that image, so the second half was about to be copied
into a workflow step -- two copies of aapt2/zipalign/apksigner ordering,
drifting apart at whatever rate the toolchain moves.

So it moves to docker/android/assemble-apk.sh, which assumes it is inside
the image and takes its paths from the environment, because the callers
disagree about them: the container mounts the repo at /work, the runner
checks it out wherever it likes. Every default reproduces what package.sh
did, so the host path is unchanged.

Two things stop being hard-coded on the way. The build-tools version and
the compile SDK are resolved from what is installed rather than written
out as 36.0.0 and android-36 -- the versions are Dockerfile ARGs, and a
second copy is a second thing to miss when they move. --min-sdk-version
now comes from that same ARG instead of a literal 28, which is the number
the API-level check in CI already reads.

The intermediates are removed at the end. They were harmless in a cache
directory nobody looks at; beside a published artefact they are four more
files for a glob to pick up by mistake.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 23:38:08 +02:00
dtourolleandClaude Opus 5 8b07a90e71 Regenerate the traceability matrix the clippy pass moved
Build and test / Desktop (Linux) (push) Failing after 1h12m3s
Build and test / Layer separation (push) Successful in 28s
🐳 Android image / Build and push (push) Successful in 4s
Build and test / android-image (push) Successful in 5s
Traceability / Requirement traces (push) Successful in 24s
Build and test / Android (aarch64) (push) Failing after 25m11s
`traceability-check` fails on master: the committed matrix does not match
what the generator produces, so the gate that exists to keep the two in
step is the thing reporting they are not.

Nothing was traced or untraced. Coverage is 51.4% (91/177) before and
after, the same 604 tags against the same 177 requirements; every one of
the 32 changed rows is a line number that moved when the clippy warnings
were cleared -- `adjust.rs:2150` is now 2164, 632 is 651, 751 is 770.
The matrix links to lines, so touching a file above a tag rewrites its
row without changing what it says.

Regenerated with `cargo run -p traceability -- report`, which is what the
failing step tells you to run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 23:21:13 +02:00
dtourolleandClaude Opus 5 606f85df34 Send the LFS object endpoint one Authorization header, not two
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 3s
Build and test / Desktop (Linux) (push) Failing after 1h12m2s
Build and test / Layer separation (push) Successful in 36s
Traceability / Requirement traces (push) Failing after 31s
Build and test / Android (aarch64) (push) Failing after 36m17s
The model fetch has been failing on every run with

  LFS: Client error: .../info/lfs/objects/0672d7a7...

which reads like a rejected credential and is not one. The object is on
the server and downloads fine; what fails is the shape of the request.

`git lfs pull` makes two calls. The first, to `/info/lfs/objects/batch`,
succeeds -- and Gitea answers it with a short-lived `Bearer` JWT scoped
to that one object, for git-lfs to use on the second. git-lfs sends that
JWT *and* the `Authorization` header this step had installed in git
config, and two `Authorization` headers is a 400 from Gitea. Hence a
client error on the object one step after the batch call it just made
successfully, which is what made this look like an auth problem rather
than a duplication.

Confirmed directly against the server: the JWT alone on that URL is a
200, the JWT plus any second `Authorization` is a 400, and a lone token
header that is merely wrong is a 401 -- so the scheme was never the
issue. `lfs: true` on the checkout fails the same way and for the same
reason, because actions/checkout persists a header of its own; the
comment here blaming a credential the endpoint would not accept was
wrong on both counts.

So the headers are stripped -- checkout's included, since nothing later
in either job talks to the remote -- and the token is handed to git-lfs
as an ordinary credential instead. It authenticates the batch call and
leaves the per-object JWT alone.

This is what fails the Android job today: the build script sees a
133-byte pointer and panics by design, which is the message it is
supposed to give and the one nobody could act on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 22:55:26 +02:00
dtourolleandClaude Opus 5 56978fdf35 Clear the clippy warnings that were failing CI before this branch
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
Build and test / Desktop (Linux) (push) Failing after 9m6s
Build and test / Layer separation (push) Successful in 26s
Traceability / Requirement traces (push) Failing after 23s
Build and test / Android (aarch64) (push) Failing after 22m38s
Nothing here is film simulation. These are lints that fail master today,
under the -D warnings CI runs with, mostly from a toolchain that learned
new ones rather than from anybody's code -- `is_multiple_of` and the
derivable `Default` did not exist as lints when this was written.

They are fixed rather than allowed, and by hand rather than by trusting
`cargo clippy --fix` wholesale: its automatic pass split a derive in two
and left a stray blank line, which is the sort of thing that is correct
and still wrong to commit.

The four that needed a decision rather than a rewrite:

  - The distance transform's inner loop writes through its iterator now.
    `q` stays, because it is the position the parabola is evaluated at as
    well as the index it is written to -- the lint is about the write.
  - `to_source` and `to_proto` take `self` by value. Their receiver is
    `Copy`, so this is the same machine code and the honest signature.
  - The export path's return type is five levels deep and now has a name,
    plus a line saying why the `Option` wraps the `Result`: `None` is
    cancellation, which is not a failure and has no error to report.
  - A test fills a range instead of looping over one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 22:35:02 +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
dtourolle 6925aa2a86 Merge master into film-simulation
🐳 Android image / Build and push (push) Successful in 0s
Build and test / android-image (push) Successful in 1s
Build and test / Desktop (Linux) (push) Failing after 59s
Build and test / Layer separation (push) Successful in 26s
Traceability / Requirement traces (push) Successful in 1m1s
Build and test / Android (aarch64) (push) Failing after 22m44s
Master gained the library's index paging while this branch was building
the film simulation, and the two met in library_ui.rs. Only the generated
traceability matrix conflicted; it is regenerated here rather than
hand-resolved, which is what it is for.
2026-08-25 20:34:28 +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 3330f350a4 Move the timeline marker from the window the grid already read
Build and test / Desktop (Linux) (push) Failing after 1m1s
Build and test / Layer separation (push) Successful in 24s
Traceability / Requirement traces (push) Successful in 22s
🐳 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 22m19s
The last of the per-scroll queries, and the strangest of them: this one got
slower the further down the library you had scrolled.

The marker has to follow every scroll event or it advances in jerks while the
photographs beside it move smoothly — that part is right and stays. What was
wrong is that each event asked the catalog `LIMIT 1 OFFSET n`, and that is not
a seek: SQLite reaches row `n` by producing and discarding the `n` rows before
it. 0.02 ms near the top of the library, 0.7 ms at twenty thousand, per row
crossed, on the thread drawing the frame. A flick therefore got choppier the
longer it went on.

The window the grid has already read holds the answer, and since the loaded
window now covers the whole view, the row is nearly always in it. So this is a
vector index at the position the ordinal has in the window, and the query
survives only as the fallback for a row outside it — briefly, after a scrub or
a keyboard jump, before the load lands.

The fallback is also the less correct of the two, which is worth recording
rather than quietly keeping: it counts in a dated-only ordering while the
argument is a grid row, so the two disagree wherever undated frames sit in
between. It is kept because a marker about to be corrected is not worth a
second index, and because being wrong there is what it always did. The window
path has no such disagreement — it reads the very cell the row belongs to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 20:01:51 +02:00
dtourolleandClaude Opus 5 0f231afe85 Stop re-answering questions about the library every time the window moves
The timeline's bars, the filter chips' counts, the "on this device" count and
whether the scoped collection is pinned all describe the *library*. None of
them can change because the view scrolled. `load_window` recomputed all four
every time the window moved, which is several times per screenful.

Together that is a `MIN`/`MAX`, a `GROUP BY`, two counts, and under a
collection three more queries — about 8 ms of SQLite on the thread that is
trying to draw the frame, for four answers that were already on screen and
already right.

They are now keyed on what they actually depend on: the scope, the filter,
whether this is the trash, and the total. The total earns its place as the
change detector as much as for the scrollbar — a scan landing, a delete or a
restore all move it, and it was already read on every load.

What a total cannot see is a rating edited under an unchanged count. That is
covered, and deliberately not by widening the key: `apply_judgement` already
refreshes the chips itself, because it has to report what actually landed
rather than what was asked for. Same for the axis — a zoom, a pan, a scrub and
dates arriving from the thumbnail worker each call `refresh_timeline`
directly. Skipping the recompute here cannot leave anything stale on screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 20:01:39 +02:00
dtourolleandClaude Opus 5 6d6ef8d34b Page the grid along an index instead of sorting the library each time
Scrolling jittered, and this was the largest single reason. Every window the
grid loads is `ORDER BY ... LIMIT n OFFSET k`, and neither half of that was
being answered the cheap way.

**The sort.** `GRID_ORDER` leads with `captured_at IS NULL`, so undated frames
fall to the end. No ordinary index answers that — the leading term is an
expression, not a column — so SQLite sorted the whole library into a temp
b-tree on every window read, then threw away the first `k` rows of it. Schema
V7 indexes the expression exactly as the query writes it, partial on the same
`shadowed_by IS NULL AND trashed_at IS NULL` the grid filters by, so the read
becomes a walk along the index.

**The join.** `LEFT JOIN remote` was paged *after* it was joined, so reading
280 cells at offset 20,000 first seeked into `remote` for all 24,000 rows and
then discarded 23,720 of them. The file ids are now fetched for the 280 rows
that survived — the shape the badge and rating reads already use, one query for
the window rather than one per cell.

Measured together on 24,000 images at offset 20,000: **15.2 ms → 0.36 ms**,
inside a scroll handler that has 16.7 ms to draw a frame.

The test asserts on the query plan rather than on a duration, because there is
no other symptom. A `GRID_ORDER` edited out of step with the index, or a column
added back that drags `remote` in again, both still return exactly the right
cells — just after sorting the library — and the jitter would come back with
nothing to point at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 20:01:04 +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 4a2fcb6d22 Render a film stock on the GPU, and let it take over the rendering
The stock model landed in dr-film with no way to see it. This is the
pipeline node, the two texture bindings it reads, and the end-to-end test
that proves the shader agrees with the model.

The design point is that a film simulation is not an adjustment. Every
other node changes a picture; this one makes it. A stock's characteristic
curve does the camera profile's base curve's job -- from measurements
rather than from a curve somebody drew -- so running both renders the
scene twice: the camera's rendering, and then a film's rendering of that.
It looks like neither, and it reads as a colour-management bug with no
colour-management bug to find.

So `Operation::renders` is new. A node declaring it takes camera RGB and
hands back linear sRGB, and the composer emits neither the base curve nor
the conversion out of camera space. Both halves move together, and the
composer keeps them as one string precisely so that getting half of it
right is impossible.

The tables are not parameters, for the reason vignetting's coefficients
are not: they are measurements. dr-pipeline declares the layout as a plain
struct and keeps its no-dependency property; the two crates share no types
on purpose. `EditGraph::set_film_tables` offers them to every node rather
than to the one that wants them, because knowing which concrete type is
which is what the graph is organised not to know.

Bindings 4 and 5 follow the masks precedent: declared unconditionally so
one bind group layout serves every generated shader, bound to 1x1
placeholders when no stock is loaded. Both are interpolated by hand with
textureLoad -- this pipeline binds no sampler, and adding one for two
lookups would cost a binding in every shader. Uploads are keyed on content
so an unchanged stock does not push half a megabyte across the bus per
frame.

The end-to-end test earned its place immediately: it found the density
lookup being filled z-fastest while a 3D texture upload wants x-fastest,
so the red and blue axes were transposed. Green matched exactly, which is
what that bug looks like -- a plausible photograph of the wrong colour,
and one that every unit test on either side of the seam passes. dr-film
now pins the layout in a test that needs no device, and states it where
the field is declared.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 15:12:56 +02:00
dtourolleandClaude Opus 5 b6a95e1965 Simulate a film stock from its measurements, not from someone's grade
FR-DEV-3f asks for look emulation and proposes HaldCLUT import to inherit
the free film-simulation ecosystem. This takes the other road for the
stocks where the measurements exist: run the physics.

A stock here is its manufacturer's own datasheet -- spectral sensitivity,
characteristic curves, dye densities. Light exposes three emulsion layers,
the layers develop to densities, the densities are dyes that absorb, and
what is left is what reaches the eye. A colour negative comes out orange
and upside down because that is what a colour negative is; it becomes a
photograph when a paper profile prints it, with the enlarger's filtration
solved rather than dialled.

What that buys over a LUT is that the parameters stay physical. Opening up
a stop moves the picture along the film's real characteristic curve,
shoulder and all, instead of scaling a number baked at one exposure. The
data cost runs the other way too: a stock is 17 kB of published
measurements where one HaldCLUT is 800 kB of one person's grade.

It looks like it needs a spectral integration per pixel. It does not, and
that is the whole design:

  - Exposure is a 3x3 matrix. The reconstructed scene spectrum is linear
    in the sRGB triple, so the integral collapses into nine numbers,
    exactly -- no approximation.
  - The characteristic curve is three 1D functions, sampled exactly.
  - Everything after that -- dye absorption, the print through the
    negative, the paper, the viewing illuminant, the adaptation -- takes
    exactly three numbers in, so it bakes into one 32^3 lookup.

Per pixel: a matrix multiply, three curve taps, one fetch. Splitting the
curve out of the 3D lookup rather than baking one LUT over exposure is
measured, not assumed: the curve carries the sharp shape and the dye
mixing is smooth, so folding them together would need three times the
resolution for the same error. At 32^3 the worst error is 0.003 in linear
sRGB, under one 8-bit code value, and a test says so.

No wgpu dependency, deliberately, and the same isolation argument dr-lens
makes: the model is plain f32 with a documented layout, so every property
worth asserting is asserted on the CPU. Binding it to a texture is dr-gpu's
job and is not done here yet.

The expected values in tests/ came from a Python prototype running against
a different colour-science stack. Agreement to three decimals is evidence
about the model rather than about one implementation of it -- a transposed
matrix or a mispasted observer row would pass every unit test and fail
that one.

Profiles are converted from spektrafilm by Andrea Volpato, CC BY-SA 4.0.
The converter is in the tree and runnable, so what was changed from
upstream is auditable rather than taken on trust; profiles/CHANGELOG.txt
records it, including the one deliberate deviation -- Mallett & Yuksel's
1 kB basis instead of Hanatos's 4 MB table, which costs accuracy at the
gamut edge and saves four megabytes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 14:44:54 +02:00
dtourolleandClaude Opus 5 940058c78a Keep git-lfs's own hooks, now that the hook path is ours
🐳 Android image / Build and push (push) Successful in 0s
Build and test / android-image (push) Successful in 1s
Build and test / Desktop (Linux) (push) Failing after 8m57s
Build and test / Layer separation (push) Successful in 25s
Traceability / Requirement traces (push) Successful in 22s
Build and test / Android (aarch64) (push) Failing after 22m23s
`core.hooksPath` points at `.githooks` for the traceability hook, and that
redirects *every* hook — including the four git-lfs installs for itself. They
were written there by `git lfs install` and left untracked, which is the worst
of both: present for whoever ran it, absent for everyone else.

`pre-push` is the one that matters. It is what uploads LFS objects, so without
it a push can land a pointer on the server with nothing behind it — which is
exactly the failure CI has been hitting from the other side, and not a state to
risk creating by accident.

Committed rather than regenerated per clone, because `git lfs install` writes
to `.git/hooks` by default and would miss the redirect entirely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 21:24:07 +02:00
dtourolleandClaude Opus 5 dce1e66746 Show which cell a shift-click is measuring from
The gesture had a hidden operand. A range runs from the anchor — the last cell
plainly clicked — to the cell shift-clicked, and nothing on screen said which
one the anchor was. A user who could not tell where the range was being measured
from had no way to predict what it would take and no clue why a wrong one came
out wrong; often the anchor is not on screen at all, which is itself the answer
to "why did that select so much".

The anchor is marked with an inner ring, drawn inside the selection ring rather
than in a colour of its own: it has to stay legible against a thumbnail of any
brightness, and a hue would read as a second kind of selection. It is an
ordinal, so it marks a row only while the photograph it names is in the loaded
window — off screen it marks nothing, which is the honest answer, and `anchor`
rides in the cell model beside `selected` so both are pushed by the one pass
that already keeps the grid in step with the selection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 19:50:46 +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 22325415b1 Fetch what is on screen before what is not
The other half of the blank bottom row, on a library still filling its
thumbnail store: the cells were in the model, and nobody had asked the server
for them yet.

A batch is fetched one image at a time, two round trips each, and it is
abandoned wholesale the moment the window moves. Issued in model order, the
front of that queue was the quarter-window of cells sitting *above* the view —
which nobody is looking at — and the back of it was the bottom of the screen
and the screenfuls below. So the last rows of the grid waited behind three
quarters of a window's worth of fetches for photographs off screen, and every
scroll threw the queue away and started over from above the view again. For as
long as the scrolling continued, the bottom of the grid could be starved.

The rows still address the model they were built against; only the order they
are asked for in changes. On screen first, in reading order, then the rows
below the view, then the rows above it. Below before above because that is
where the view is going — scrolling back over cells already fetched is served
from the store, and from `requested` without a fetch at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 19:49:12 +02:00
dtourolleandClaude Opus 5 7c8e433911 Load the window around the whole view, not around its first cell
The bottom row of the grid was often blank, at a scroll position the user
could sit at indefinitely. Two numbers decided when the loaded window follows
the view, they lived in different languages, and they disagreed.

The grid loaded three screenfuls and Rust held the window still until the
first visible cell was three quarters of the way through them. Three quarters
of three screenfuls is 2.25, and the view itself is one screenful tall — so
the bottom of the screen had already travelled a quarter of a screenful past
the last loaded cell before anything moved. Those rows are not in the model,
so nothing is drawn for them.

The same margin was wrong upward, and exactly so. The window is placed a
quarter of itself behind the view, and the margin then declared the view too
close to the top at precisely that distance: every single row scrolled upward
re-read the catalog, rebuilt all 360 cells and re-queried their badges and
ratings, and so did the row after it.

So the grid now reports what it shows — a screenful, counting the row the
scroll position has cut in half, which `visible-rows` alone undercounts and
which is exactly the row reported missing — and Rust owns the rest: four
screenfuls loaded, placed a quarter back, and moved once the view comes within
half a screenful of an edge of them. One decision in one place, and the test
now walks the view the length of the library and asserts the window covers it
at every step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 19:48:58 +02:00
dtourolleandClaude Opus 5 33847a0bbc Open one GPU device for the tests, and stop the checkout dying over LFS
Build and test / Desktop (Linux) (push) Failing after 9m18s
Build and test / Layer separation (push) Successful in 26s
Traceability / Requirement traces (push) Failing after 25s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Build and test / Android (aarch64) (push) Failing after 22m46s
Two CI failures, unrelated except that both were mine.

**The test binary faulted under parallel threads.** Every GPU test opened its
own `GpuContext`, and `cargo test` runs on as many threads as there are cores —
so a full run asked the driver to bring up a dozen Vulkan devices at once and
died with SIGSEGV. Serially it passed, which made it look like flakiness rather
than a fault in the harness. One device now, behind a `OnceLock`: a
`GpuContext` is an `Arc<Device>` and an `Arc<Queue>`, so sharing it is a
refcount, and the losers of the race block until the winner is done. 416 tests
now pass in parallel, in half the time twenty-six devices took.

**`lfs: true` on the checkout made the checkout fail.** The intent was right —
the model is in LFS, a plain checkout writes a 133-byte pointer, and the build
script panics on it — but on this server `git lfs fetch` is rejected at
`/info/lfs/objects/<oid>` with a client error: the credential `actions/checkout`
installs for git is not one the LFS endpoint accepts. So a fetch problem
presented as a checkout problem and took the whole job with it.

The object is on the server; a clean clone over SSH with `git lfs install
--local` pulls all 11 MB of it. It is now its own step with an explicit token,
and `continue-on-error` so a credential problem cannot masquerade as a broken
checkout — if it fails, the build still runs and fails with the build script's
own message, which names the real problem.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 19:36:34 +02:00
dtourolleandClaude Opus 5 a3785ba55d Say which version this is: 0.6.0
Build and test / Desktop (Linux) (push) Failing after 2m24s
Build and test / Layer separation (push) Successful in 26s
🐳 Android image / Build and push (push) Successful in 8s
Build and test / android-image (push) Successful in 7s
Traceability / Requirement traces (push) Successful in 1m5s
Build and test / Android (aarch64) (push) Failing after 4s
Set by tools/set-version.sh, which is the only thing that should. The
workspace, the pacman package and — through Cargo.toml at link time — the
APK all state 0.6.0, so a bug report naming a version names one commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 19:59:30 +02:00
dtourolleandClaude Opus 5 6b5ffc6a93 Anchor the delete on what the view was showing, not on the window's start
Build and test / Desktop (Linux) (push) Failing after 2m33s
Build and test / Layer separation (push) Successful in 23s
Traceability / Requirement traces (push) Successful in 22s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 2s
Build and test / Android (aarch64) (push) Failing after 6s
The grid still jumped. Anchoring on `offset` was wrong for a reason this file
already states, a few hundred lines away: "the first *visible* ordinal, not the
window's start: the loaded window deliberately begins a quarter of a screen
above the view, so its first cell is one the user cannot see."

Two things made `offset` the wrong number. It is a screen-quarter above what is
being looked at, and by the point this runs it has already been re-clamped
against the new, smaller total — so seeking to it moved the view somewhere the
photographer had not been. A smaller jump than the original, and the same fault.

`resume_at` is what the grid last reported as its first visible image, which is
the photograph the person is actually looking at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 19:38:27 +02:00
dtourolleandClaude Opus 5 cd64166b15 Keep the view on the photographs after some of them are deleted
Build and test / Desktop (Linux) (push) Failing after 2m21s
Build and test / Layer separation (push) Successful in 27s
Traceability / Requirement traces (push) Successful in 27s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 3s
Build and test / Android (aarch64) (push) Failing after 4s
Deleting made the grid go blank and jump somewhere arbitrary. Two causes, both
of them the viewport being left behind by everything else that moved.

The Flickable is sized to the *whole* library so its scrollbar is a real
address into twenty thousand images. Delete some and that content gets shorter,
which leaves a view near the end scrolled past what now exists — cells sitting
above a viewport looking at empty space. Slint does not pull a Flickable back
on its own. (The clamp for that went in with the previous commit.)

The jump is the other half. Cells are drawn at their absolute place in the
library, `(i + offset) / columns`, and a delete re-clamps `offset` downward so
the loaded window still fills. Nothing touches `viewport-y`, so the same scroll
position now addresses different photographs and the grid appears to leap
somewhere unrelated.

`restore_position` re-anchors on the ordinal the view was showing, clamped into
what is left. Not on the deleted image's own position, which no longer exists,
and not on the top of the library, which would throw the scroll position away
on every delete — after removing one frame from a wall of twenty thousand, the
one you want next is the one that just moved into its place.

Only on a shrink, and the shrink is detected by reading `library-total` before
overwriting it. Re-anchoring on every load would fight a scrub, which sets
exactly this property to go where the user asked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 19:03:15 +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 b0b6dd559a Show what is selected, and let a collection be made of it
Build and test / Desktop (Linux) (push) Failing after 2m37s
Build and test / Layer separation (push) Successful in 26s
🐳 Android image / Build and push (push) Successful in 4s
Build and test / android-image (push) Successful in 5s
Traceability / Requirement traces (push) Successful in 40s
Build and test / Android (aarch64) (push) Failing after 6s
Three things a selection needed and did not have.

**Seeing it.** The count existed — "12 selected" — in the header row, which
scrolls sideways. On a tablet it sat past the right-hand edge along with every
button beside it, so a selection was something you could make and then not
see. A selection you cannot see is one you act on by accident.

**Putting it down.** The only way to clear one was "Done", which also leaves
select mode — so after filing forty photographs the next forty began by
re-entering a mode the user had not meant to leave. Clearing is now its own
action and keeps the mode.

**Filing it somewhere new.** Making a collection of a selection took four
steps: create one, find it in the tree, select the photographs again because
creating it changed the scope, then add them. It is one press, which is how a
selection is usually meant — it is gathered *because* it is going somewhere.

The new collection is created at the top level rather than inside the current
scope, unlike the tree's "+". A selection can be gathered from anywhere,
including across collections, so filing it under whichever one happens to be
open would put it somewhere its contents did not come from. It opens straight
into its name field, for the reason `collection_new` already does: the
placeholder name is nobody's choice, and making the user find the rename
afterwards is asking them to finish a job we started.

All of it on its own strip beside the date range's, appearing only while there
is a selection — the third control this session that was invisible for being
put in a row that scrolls.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 17:57:35 +02:00
dtourolleandClaude Opus 5 ffc40c42d2 Regenerate the matrix where the tags are changed, not after the push
Build and test / Desktop (Linux) (push) Failing after 2m25s
Build and test / Layer separation (push) Successful in 28s
🐳 Android image / Build and push (push) Successful in 5s
Build and test / android-image (push) Successful in 5s
Traceability / Requirement traces (push) Successful in 26s
Build and test / Android (aarch64) (push) Failing after 4s
The traceability gate has failed on six commits in a row, every time for the
same reason: someone added a `TRACES:` tag and did not regenerate
docs/traceability.md. The gate is right to fail — a matrix that disagrees with
the tree is worse than none, because it is read as current — but it says so
after a push, on a commit that is otherwise fine, and by then the tag and the
matrix are two separate things to remember instead of one.

A pre-commit hook regenerates it and stages it, so the two travel together.
Enabled with `core.hooksPath`, which is a local setting: run

    git config core.hooksPath .githooks

in a fresh clone, or the hook sits there doing nothing.

Only runs when something that can carry a tag is staged, and says nothing
unless it changed the matrix — a hook that prints on every commit is one people
start passing `--no-verify` to. If the report cannot run at all it leaves the
matrix alone and lets the commit through: refusing to commit because a build is
broken would be a worse failure than the one it prevents.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 15:01:44 +02:00
dtourolleandClaude Opus 5 f9c7aba8b6 Regenerate the traceability matrix
Build and test / Desktop (Linux) (push) Failing after 2m34s
Build and test / Layer separation (push) Successful in 23s
Traceability / Requirement traces (push) Successful in 1m6s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 3s
Build and test / Android (aarch64) (push) Failing after 4s
Six commits added nineteen TRACES tags between them and none regenerated the
matrix, so the gate failed on every one of them — including the commit the
release tag points at. The gate is doing its job: a matrix that disagrees with
the tree is worse than none, because it is read as current.

Coverage is unchanged at 50.3%; what moved is where each requirement is
tagged, which is the half of the file that is actually consulted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 15:00:45 +02:00
dtourolleandClaude Opus 5 fb4b05fb6f Let the date range be opened before there is a date range
Build and test / Desktop (Linux) (push) Failing after 2m16s
Build and test / Layer separation (push) Successful in 23s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Traceability / Requirement traces (push) Failing after 59s
Build and test / Android (aarch64) (push) Failing after 6s
Pressing "limit to range" did nothing, and the reason was two early returns
that sat above the line which shows the controls. If the catalog was not open,
or nothing in it carried a capture date, the handler returned before
`set_library_range_active`, so no state changed and nothing appeared.

Capture dates are read from EXIF as thumbnails load, so a freshly opened
library has none — the button was inert on exactly the libraries where someone
is most likely to go looking for a date, and it failed by doing nothing at all,
which is the hardest failure to report.

Underneath that was a smaller mistake with the same shape: whether the controls
showed was read from whether a range was set. The fields are how a range gets
set, so requiring one before they appear is a door locked from the inside. The
panel has its own state now, and the timeline span is a seed for the fields
rather than a precondition for them — no span means two empty fields waiting to
be typed into, which is a way to choose a range rather than a refusal to offer
one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 14:15:34 +02:00