Commit Graph
646 Commits
Author SHA1 Message Date
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>
v0.7.0
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