Commit Graph
414 Commits
Author SHA1 Message Date
dtourolleandClaude Opus 5 67f15bffb7 Key collection membership on the identity that exists
Collections synced their names and arrived empty on every device. The names are
keyed on a uuid and worked; the membership union was keyed on
`images.content_hash`, and the schema says plainly what that column is:
"computed only when something needs it (import dedup, reconnect-by-hash), never
in a scan". A library that has only ever been scanned has one for no image at
all, so the join matched nothing and `WHERE ri.content_hash IS NOT NULL`
discarded whatever survived. The union could never have moved a single row.

Measured on a real catalog: 23,174 images, content hashes for 0 of them,
`oc:fileid` for all 23,174, twelve collections, zero members.

So membership now resolves through the file id first, exactly as keyword
assignment already did — `ASSIGN_BY_FILE_ID` was added for this same reason and
its doc comment even notes that membership was still on the hash. It is
recorded for every image the moment a remote scan sees it, survives server-side
rename and move (FR-NC-5), is the same integer on every device pointed at one
Nextcloud, and is already what the thumbnail shards are keyed by.

The content-hash union is kept rather than replaced: a local-only library has no
`remote` rows, and where a hash has been computed it is a true identity that
survives a library moving between servers. Both statements run; `INSERT OR
IGNORE` against the primary key makes the overlap free.

This repairs the merge. It cannot invent membership that no device recorded —
where the rows were never written, collections stay empty until they are filled
in again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 21:19:13 +02:00
dtourolleandClaude Opus 5 c75849040c Format the tree the way the gate asks for it
`cargo fmt --check` is a required step and had drifted across 45 files. Most of
it arrived this week: several operations were written in parallel worktrees and
merged by hand, and a hand-merge resolves conflicts without ever running the
formatter over the result.

No behaviour changes — this is `cargo fmt --all` and nothing else, kept as its
own commit so the next reader can skip it wholesale rather than search it for
one that matters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 21:16:34 +02:00
dtourolleandClaude Opus 5 d04087af83 Import into the library, which is on the server
There was a local destination, defaulting to ~/Pictures, and an "upload" switch
that could be turned off. That was wrong twice over. DarkRoom's library *is* a
folder on a Nextcloud server (FR-NC-6) — there is no local library — so a
user-chosen local destination built a second pile of photographs that no view
in the application ever lists, and made "where did my import go" a question
with two answers.

An import now has exactly one destination and the page asks nothing about it.
With no account there is nowhere to go at all, so Import is refused rather than
quietly filling a folder.

What lands on the device is a staging copy in a directory the app owns, the
same shape `export` uses for its outbox and for the reason its module docs
give: staging first is the only path, not a fallback for being offline. The
bytes have to reach disk before the network — streaming a card straight to the
server would let a move-import erase a card against an in-flight upload, and
would make importing impossible with no connection (FR-NC-10). A staged file is
removed once the server confirms it; one that is not confirmed stays queued, and
the next import drains it.

And the rule that was stated but never enforced: `retirable` was reported and
`dr_ingest::retire` was never called by anything, so a move-import silently
behaved as a copy. The card is now emptied by the worker, of exactly those
photographs the *server* has confirmed — not those merely written here, because
the staging copy is removed moments later and anything unconfirmed would then
exist nowhere at all.

FR-NC-7b said "copied locally first ... then queued for upload", which is a
staging area; it has been rewritten to say so in terms that do not also permit
what was built.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 21:09:26 +02:00
dtourolleandClaude Opus 5 ce666c768e Let a binary name the release it came from
Build and test / Desktop (Linux) (push) Failing after 28s
Build and test / Layer separation (push) Successful in 27s
Traceability / Requirement traces (push) Successful in 25s
🐳 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 9m49s
The workspace version has read 0.1.0 since before v0.2.0 was tagged, so every
binary built from this tree has reported itself two releases stale. The cost
lands on whoever reads a bug report quoting it: the version names a commit
from before either release, and they go looking in the wrong place.

Sets the workspace to 0.3.0. The tag stays the release identity; this only
makes the tree agree with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
v0.3.0
2026-08-22 20:54:34 +02:00
dtourolle 56dd3187f1 Merge integration into wip/ingest
Second pass, against the detail-stage and thumbnail work that has landed since
the first. Resolved and verified here rather than in the shared merge worktree,
so what goes back is a fast-forward.

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

# Conflicts:
#	docs/traceability.md
2026-08-22 19:44:42 +02:00
dtourolle 42f1f55fd3 Regenerate the traceability matrix
The detail stage and its four kernels, the camera profiles, per-channel tone
curves, keywords and export metadata all landed since the last regeneration.
2026-08-22 19:37:56 +02:00
dtourolle 36e03258d8 Let texture on a thumbnail render, now that the seam it named is closed
`texture_contributes_nothing_where_its_scale_does_not_exist` asserted that the
detail chain composed *nothing* when texture's kernel rounded away, and its
comment recorded that empty chain as a gap: the fused pass had already decided
to hand on linear working values, so an empty chain left the output transform
undone and the render was rejected. It said fixing it meant composing both
halves together, at the composition boundary rather than in that file.

Noise reduction closed it there in the same round, by emitting a bodyless
`detail/resolve` pass for exactly this case. So the assertion was describing a
defect that no longer exists, and failing because the defect was fixed.

Now asserts the property it was always about — texture contributes no kernel,
`radius() == 0` — while the chain carries the one pass that finishes the
render. Two agents working in parallel each saw one half of this; it is only
visible with both merged.
2026-08-22 19:36:34 +02:00
dtourolleandClaude Opus 5 d91f1ec277 Thumbnail the whole library on request, and send the shards up
The grid fetches a preview only for cells that are actually browsed, which is
the right posture over a link that must not be saturated to show one screen
(FR-NC-3). The cost is that the thumbnail store ends up holding the fraction of
the library someone happened to scroll past — and that store is the one derived
thing worth syncing, since a second device that downloads the shards gets a
full grid without touching a single RAW. So the complete set is worth an hour
of range fetches paid once, deliberately, on a machine that can afford it.

That is what this adds: a pass over every visible image with an `oc:fileid`,
launched from the settings page and reported in the activity register like
every other background job. The work list is what the store lacks rather than a
flag in the catalog, so it is resumable by construction and safe to press
twice.

Lane-parallel like the metadata sweep, but it does what that sweep declined to.
The store is `&mut` and cannot cross lanes, which is why dating the library
skips thumbnails entirely; here the lanes fetch, decode and *encode*, and only
the ~20 KB result crosses back to the one thread that owns the store and writes
the chunk. The parallelism is real and the single-writer rule is not bent.

Grid class only. The large class is ~860 MB of shards against ~200 MB on the
reference library, paid by every device that syncs them; a photograph looked at
closely still gets its large thumbnail from the interactive path.

Dates come free — the header a preview needs is the header EXIF lives in — and
the pass ends by pushing the shards to the server. A filled store that never
leaves this device would be most of the cost for none of the point.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 19:34:59 +02:00
dtourolle ec4283e37f Finish reconciling the whole-chain tests the three kernels each rewrote
Sharpening, noise reduction and clarity were written in parallel and each
rewrote the same two tests, which had counted one fused block per operation —
true only while every operation was a point function.

Kept the exclusive-or formulation: each operation must reach exactly one of
the two stages. A count cannot tell "moved to the detail stage" from
"vanished from both", and that ambiguity is what broke these tests three
times over.

The merge left two fragments of the versions it replaced — a loop over a set
that no longer exists, and the tail of an assertion whose head was gone.
The loop is not restored: `point ^ neighbourhood` already asserts per
operation what it checked over the set. The assertion is, because it catches
a different fault from the exclusive-or — a block in the shader that nothing
in the chain asked for, rather than an operation in the wrong stage.
2026-08-22 19:34:24 +02:00
dtourolle 35b126449b Order the detail stage so the repair runs before the enhancements
Capture sharpening and noise reduction were written in parallel and both
claimed `order: 110`; the codegen refuses that, which is the guard working —
two nodes at one order is an ambiguous pipeline and operation order changes
the result.

Resolved in noise reduction's favour, for the reason its own `placement:`
block already gives: denoising is a repair and everything else in this stage
is an enhancement. Sharpening or adding clarity to a noisy frame amplifies the
grain along with the detail, and no later pass can separate them again. So the
detail stage now runs noise reduction, capture sharpening, clarity, texture,
and the other three shift up a slot to keep the multiple-of-ten convention the
rest of the chain uses.

Noise reduction was also missing the required `attributes:` key. The order
collision aborted the build before the attribute check could report it, so it
arrived looking like one fault and was two.
2026-08-22 19:32:59 +02:00
dtourolle 5d15b753f7 Regenerate the traceability matrix
Five branches merged since the last regeneration. Generated file, so the only
correct version is the one produced once, here, from the merged source.
2026-08-22 19:31:22 +02:00
dtourolle d800af049b Merge branch 'worktree-agent-acd27f9b2974c67eb' into integration
# Conflicts:
#	core/dr-gpu/src/adjust.rs
#	core/dr-pipeline/src/detail.rs
#	core/dr-pipeline/src/ops/mod.rs
2026-08-22 19:31:15 +02:00
dtourolle 5852e14a5c Merge branch 'worktree-agent-a75c901968abfa183' into integration
# Conflicts:
#	core/dr-gpu/src/adjust.rs
#	core/dr-pipeline/ops/README.md
#	core/dr-pipeline/src/lib.rs
#	core/dr-pipeline/src/ops/mod.rs
#	ui/dr-ui/src/develop.rs
2026-08-22 19:29:39 +02:00
dtourolle d92cfcbd8c Merge branch 'worktree-agent-a86b971be6ee42cf1' into integration 2026-08-22 19:27:18 +02:00
dtourolle 66eb5312c5 Merge branch 'worktree-agent-a1c8fdfa4258709aa' into integration 2026-08-22 19:24:11 +02:00
dtourolle 7031352e85 Keep neighbourhood operations out of a mask layer's chain
A layer holds a full chain and fuses it into the colour dispatch, so the
panel - which names no operation - would have offered a noise reduction
slider inside a local adjustment. It could not have worked: the detail stage
is its own dispatch, running after the masks are already applied, with
nowhere to be handed one layer's mask. The control would have moved and done
nothing. Filter the layer's chain to the operations that can honour it.
2026-08-22 19:23:43 +02:00
dtourolle 690e76a51f Let the fully-active chain test account for both stages
Activating every operation now activates a kernel too, and a kernel emits no
block in the fused shader. Assert that each operation reaches exactly one of
the fused pass and the detail chain, rather than counting fused blocks
against the length of the chain.
2026-08-22 19:21:59 +02:00
dtourolle eb229051ef Teach the whole-chain GPU tests about neighbourhood operations
Both tests composed only the fused half and rendered it through the plain
path. That was correct while every operation was a point operation; with a
kernel in the chain the fused pass stops short of the output transform, so
the render was rejected and the operation-block count was one too high.

Compose both halves and dispatch them together, and assert that each
operation reaches exactly one of the two stages rather than counting blocks
- so the next kernel added extends the coverage instead of breaking it.
2026-08-22 19:20:22 +02:00
dtourolleandClaude Opus 5 28325af448 Make the thumbnails while the originals are still in hand
An import read every byte of every original, uploaded them, and then left the
grid to fetch a preview range back out of each one over the network — for
files that had been on this machine minutes earlier.

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 19:20:13 +02:00
dtourolleandClaude Opus 5 e3dd1526e0 Recognise a card whose photographs are already held
Re-inserting a card that has already been imported produced a folder full of
`-1` copies. Both halves of the placement logic treated a taken name as a
collision to rename around, which is right for the case they were written for
— two cameras both writing IMG_0001.CR3 — and exactly wrong for the far more
common one, where the taken name is the same photograph.

Locally this cannot be answered from the catalog. On a library whose catalog
describes a *server*, a file sitting in the local destination has no row to be
found by, so the only way to know whether it has already been copied is to
look. The destination folder is listed once per folder rather than probed per
file: a card is two thousand frames landing in a handful of days.

Remotely the same question is one PROPFIND that was already being made to
resolve the name, so `upload_original` now returns `Placed::AlreadyThere`
instead of inventing a second copy of work that is already safe. The count is
reported apart from `uploaded`, because "12 already on the server" and "12
uploaded" are different answers to whether this run backed anything up — and
apart from the local duplicate count, because these files *were* copied here.

Name plus length decides it, not a digest: this runs before any transfer, and
hashing to answer it would read the whole card to avoid reading the whole card.
A camera reusing a filename after IMG_9999 writes a different number of bytes
essentially always, which leaves the rename for the case it is really for. The
digest tier still catches the same frame under a different name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 19:19:59 +02:00
dtourolleandClaude Opus 5 b1bf68022d Do not offer an import where one cannot be done
The Import button went into the library header unconditionally, so an Android
user got a page that opens, finds nothing, and cannot be pressed — worse than
no page at all, because it reads as broken rather than as absent.

`dr_plat::imports_supported()` answers the question the interface actually has,
which is not "did we find a volume". An empty list on Linux means plug one in;
false here means it cannot be done on this device however hard the user tries.
It is false on Android for two reasons that both have to be fixed before it
changes: there is no mount table to read and no path to type, and nothing
implements WritableStorage except LocalStorage.

The button is hidden rather than disabled. The buttons beside it come and go
with the selection — unavailable now, available in a moment — where this one
never will be here, and a permanently disabled control teaches the reader that
the row lies.

What this gates is the interface, not the engine. dr-ingest takes storage
traits and never a path, and cross-compiles to aarch64-linux-android today; it
should need no changes when a SAF implementation lands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 19:19:40 +02:00
dtourolleandClaude Opus 5 2459a759af Compile the detail stage in the whole-chain GPU tests
every_operation_generates_compilable_wgsl and the_whole_chain_at_once_compiles
both rendered through the fused half only. A neighbourhood operation
contributes no fused fragment, so its kernels went uncompiled — and once one
is active the fused pass hands on linear working values, which plain render
refuses. Both now compose both halves from the one graph, and the fused
block count excludes the operations the detail chain names.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 19:18:56 +02:00
dtourolleandClaude Opus 5 c9305fd0e6 Write out the derivation behind every kernel width these tests assert
A test that cannot be run cannot be checked by running it, and a number
copied out of a test run agrees with whatever the code did on the day.
Each asserted kernel width, tolerance and overshoot bound now carries the
arithmetic that produces it -- the shorter edge, the sigma, the
truncation at two sigmas, and where the rounding falls -- so a reader can
verify the expectation against the recipe without a GPU or a compiler.

Also records the two places where a bound is a bound and not a
measurement: the tolerance in the frame-fraction test is exactly what
rounding a kernel to a whole pixel costs on the smallest frame it uses,
and the halo test's floor and ceiling bracket a peak derived from the
step, the soft limit and the midtone taper rather than from a run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 19:18:13 +02:00
dtourolleandClaude Opus 5 e44c929afc Guard the sharpening kernel against WGSL reserved keywords
The fused-fragment check in lib.rs cannot see a detail pass: it is a
separate shader composed at a resolution compose() never knows. The
kernel's own test now scans the block the composer wrapped, with comments
stripped so prose about the keyword cannot fail a test about the code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 19:17:05 +02:00
dtourolleandClaude Opus 5 88ce89428b Teach the whole-chain shader test about neighbourhood operations
`the_whole_chain_at_once_compiles` counted one `---- ` block per
operation in the chain. That was true while every operation was a point
operation, and stopped being true the moment a neighbourhood one existed:
clarity and texture are active in that test, and still emit no fused
block, because `compose_full` filters them out and the detail stage
dispatches them separately.

Counted now by asking each operation whether it has a detail stage --
the same question the composer's own filter asks -- rather than by
subtracting a number someone has to remember to update. Capture
sharpening and noise reduction are covered by this without another edit.

The render at the end is now `render_detailed`, which is not a
concession but the stronger test: with a neighbourhood operation active
the fused pass hands on linear working values and the last detail pass
performs the output transform, so rendering the fused half alone is the
mismatch `render_detailed` exists to reject -- and the detail passes are
generated WGSL with uniform blocks of their own, which is exactly what
"everything at once" is here to collide. It renders at 512 rather than
32 because a compositional radius is a fraction of the frame, and on a
32-pixel target every detail kernel rounds away to nothing.

Also records, in `texture_contributes_nothing_where_its_scale_does_not_exist`,
the seam this uncovered: an active detail operation whose kernel rounds
away composes an empty chain while the fused pass has already been
composed to hand on linear values, and nothing can then encode the
result. That test now asserts the property on the composed chain instead
of driving the unrenderable configuration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 19:16:52 +02:00
dtourolle 69fb510c50 Measure the chroma tests on colour difference, not on red
A colour square wave built from equal, opposite swings of red and blue is
not a pure colour pattern: Rec. 709 weights them 0.2126 and 0.0722, so it
carries a luminance square wave of about a seventh of the swing underneath.
Correlating the raw red channel therefore reads a constant floor that the
chroma filter is not meant to remove, which compressed every ratio towards
one - enough that the resolution test could no longer tell a correct kernel
from one twice the size. Correlate the colour difference instead, and write
the derivation of each expected value into the test.
2026-08-22 19:16:43 +02:00
dtourolleandClaude Opus 5 59362fcecf Let the copyright survive the export, and the GPS not
Carries the source's metadata all the way to the file the user hands over,
and proves in bytes that the coordinates do not come with it.

The privacy test was the piece that mattered and the piece that was wrong.
It searched the whole file for the two-byte hemisphere reference "N\0" or
"E\0", which is not a fingerprint of a GPS directory at all: sample 14 of the
sRGB tone curve inside the ICC profile every export embeds is 69, written as
`00 45`, and the next sample is below 256, so its high byte is `00`. Every
format would have failed a test about a colour profile. The needle is now the
twenty-four bytes a coordinate actually serialises to — three rationals, both
byte orders, since exif.rs writes little-endian and the tiff crate writes in
the host's — which cannot match by accident, and the retaining test asserts
the same needle is *present* so a search that could never find anything
cannot make the stripping test pass by being useless.

The batch exporter now hands the decoder's reading on to the encoder. It
already read the metadata for the orientation and the {date} token; passing
it through is what puts the camera, the lens and the rights statement into
the file. Nothing about privacy is decided there — dr-export takes that
decision once, from the settings.

The example passes it too, because it is the only place in the tree that
produces files a person can open in exiftool. A unit test can prove a GPS
directory is absent from a byte slice; only a real export proves a real
photograph comes out the far end still knowing which camera took it.

TRACES: FR-EXP-8

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 19:15:06 +02:00
dtourolle df3fe660e5 Finish the render when a kernel is too small to draw
An active detail operation may emit no pass at a given render scale - the
honest answer for a sensor-sized radius on a heavy proxy. The fused composer
cannot see that, having no resolution to consult, so it had already stopped
short of the output transform and the frame died on a storage-format
mismatch. Compose a bodyless resolve pass in that case so the output
transform still happens exactly once.
2026-08-22 19:14:57 +02:00
dtourolleandClaude Opus 5 00663a870b Teach the chain test that a detail node has no fused fragment
every_operation_can_be_activated_together counted one block per operation
in the chain, which was true only while every operation was a point
function. A neighbourhood operation is a dispatch of its own and emits no
fused block, so the count now excludes the operations the detail chain
names, and each of them is separately asserted absent rather than the
comparison being loosened.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 19:14:54 +02:00
dtourolleandClaude Opus 5 b321556dbe Sharpen the capture with a separable unsharp mask
Capture sharpening as a two-pass unsharp mask in the detail stage: blur
along x, then along y, each pass applying a one-dimensional high-pass to
luminance so the composite preserves a flat field exactly and matches the
textbook kernel on any locally one-dimensional edge.

The radius is stated in source pixels and converted once per render, so a
radius tuned on a fit view is the radius the exported file gets. Below one
render pixel the operation declines to draw rather than showing sharpening
the file will not contain, and emits a single pass-through that still
carries the output transform.

The develop session now renders through render_detailed, which is what
lets an active neighbourhood operation reach the screen at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 19:13:00 +02:00
dtourolle 3846c277c8 Sync with integration 2026-08-22 19:04:40 +02:00
dtourolle a9baebc396 Sync with integration 2026-08-22 19:04:27 +02:00
dtourolle 5db234bdf3 Sync with integration 2026-08-22 19:04:24 +02:00
dtourolle f78c8b6959 Sync with integration 2026-08-22 19:04:13 +02:00
dtourolle 45214d3ca8 Regenerate the traceability matrix
Four branches merged, each of which had regenerated this file against its own
tree. Those versions all disagreed and none was right for the union, which is
why the merges took whichever side was to hand and deferred to this: the matrix
is generated, so the only correct version is the one produced once, here, from
the merged source.
2026-08-22 19:01:39 +02:00
dtourolle cd8750462f WIP: EXIF metadata on export
Checkpoint committed by the coordinator, not by the authoring agent: the
session hit its API limit mid-task and left this work uncommitted. Committed
so it survives, NOT because it is finished - expect failing tests and
half-applied changes. The agent resumes from here.
2026-08-22 19:01:18 +02:00
dtourolle 97d4bd9061 WIP: clarity and texture
Checkpoint committed by the coordinator, not by the authoring agent: the
session hit its API limit mid-task and left this work uncommitted. Committed
so it survives, NOT because it is finished - expect failing tests and
half-applied changes. The agent resumes from here.
2026-08-22 19:01:18 +02:00
dtourolle b4e55b47c1 WIP: noise reduction
Checkpoint committed by the coordinator, not by the authoring agent: the
session hit its API limit mid-task and left this work uncommitted. Committed
so it survives, NOT because it is finished - expect failing tests and
half-applied changes. The agent resumes from here.
2026-08-22 19:01:18 +02:00
dtourolle 8ea427de3c WIP: capture sharpening
Checkpoint committed by the coordinator, not by the authoring agent: the
session hit its API limit mid-task and left this work uncommitted. Committed
so it survives, NOT because it is finished - expect failing tests and
half-applied changes. The agent resumes from here.
2026-08-22 19:01:18 +02:00
dtourolle 0d9910efc6 Merge branch 'worktree-agent-a89309a856c8f4947' into integration 2026-08-22 19:00:56 +02:00
dtourolle 21500b45be Merge branch 'worktree-agent-abfe489c84c337e7c' into integration 2026-08-22 19:00:56 +02:00
dtourolleandClaude Opus 5 13d003b89d Let the curve widget plot whichever curve is asked for
An operation with four curves and a panel that draws one plot needs a way to
say which. The panel finds out the way it finds out everything else: the
points are faceted with the subject they act on, consecutive parameters
sharing a subject are one curve, and a widget spanning several of them gets
a selector over their names. Nothing in ui/ contains the word "red", and an
operation that grows a fifth curve arrives with a fifth chip.

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 16:06:21 +02:00
dtourolleandClaude Opus 5 d64a61d677 Give the tone curve a curve for each colour channel
The declaration in ops/tone_curve.yaml has claimed per-channel curves since
it was written — it is the justification for the operation carrying both
`tone` and `colour`. Only the master curve existed. This is the other three.

The master runs first and the channels grade its result. Both orders are
real images and they differ visibly, so the choice is made and written down
rather than left to the loop: a point placed on the blue curve should act on
the tone the photographer can see, which is what the master has already
produced. The other order anchors the grade to tones the master is about to
move, so adjusting contrast slides a warm shadow up into the midtones.

Every id that existed before today is spelled exactly as it was. The master
curve keeps `p2_y` and the new curves take `r_`, `g_` and `b_` prefixes, so
a sidecar written when there was one curve loads, means what it meant, and
renders the same shader — asserted on the generated source, not on the
parameter values. Nothing needed a version check because nothing was
renamed.

Each curve reaches the shader only when it has been moved off the diagonal,
so an S-curve and no colour work generates what it generated when this
operation held ten parameters instead of forty, down to the uniform names.
The monotonicity guarantee is enforced per curve: a coincident pair on blue
divides by zero exactly as thoroughly as one on the master.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 16:06:13 +02:00
dtourolleandClaude Opus 5 f4c21c4e2c Regenerate the traceability matrix after the merge
Both sides added tags. 49.7%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 15:53:25 +02:00
dtourolleandClaude Opus 5 49be1fe354 Give the library somewhere to type a keyword
A sheet over the grid, opened from the header beside "Add to collection" —
deliberately the same card, scrim and dismissal as the filing sheet, because
they are the same gesture applied to two kinds of label: pick the photographs,
then say what they are. A user who has filed a selection already knows how this
works.

A word the whole selection carries, a word only some of it carries, and a word
none of it carries are three visibly different marks. Half-applied shown as
applied would be a lie about photographs the user cannot see from here, so a
partial keyword draws a dash and says "3 of 12" beside it. Tapping a dash
completes the keyword rather than removing it, which is what it means nine times
in ten, and the tenth is one more tap away.

The vocabulary is answered against the selection in Rust and pulled when the
sheet opens rather than pushed on every selection change — the selection moves
on each arrow key and the sheet is shut for almost all of them.

Assign and unassign travel by name, so a word typed into the field and a word
tapped in the list are one path rather than two, and the sheet never has to
invent an identity for a keyword that does not exist yet.

One gap, commented at the call site: unlike a star or a flag, a keyword is not
queued to the image's sidecar, because the sidecar format has no field for one.
So it reaches the user's other devices through the catalog merge, and a deleted
catalog loses keywords where it would keep ratings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 15:51:11 +02:00
dtourolleandClaude Opus 5 62188ec740 Keep both devices' keywords when the catalogs meet
Keywords are catalog state, and the catalog syncs. Without this, two devices
keywording the same library would resolve to whichever synced last, and an
afternoon of work would vanish with no sign it had ever happened.

The vocabulary merges per row on the rule collections already use: revision
first, timestamp only to break a tie, so a device with a skewed clock cannot win
by having the wrong idea of the time. Assignments merge as a set union, which is
FR-NC-9's principle applied to metadata instead of edit nodes — disjoint work
survives on both sides.

Three things needed care and are commented where they happen:

A deletion travels *by name*, not by identity. Both devices may have minted
their own uuid for one word before they ever synced, so deleting by uuid would
tombstone a row nothing was assigned to and leave every photograph still
carrying the word. The union then refuses to readmit a word a winning tombstone
has just removed — without that filter the remote's live assignments would
resurrect it on the very same pass.

Images are resolved by the server's file id first and the content hash second.
Membership has always used the hash alone, but the hash is computed only when
import dedup or a reconnect asks for it, which for most libraries is never — so
a hash-only union would have quietly done nothing for the ordinary photograph.

A word lands on the local default version. Version uuids do not reconcile in the
catalog at all: ensure_default_versions mints a fresh one per device, so a
uuid-keyed join would have unioned nothing.

Removal still does not propagate. That is the trade collection membership
already makes, for the same reason — an unwanted keyword is removed again in a
second, and a silently lost afternoon is not recoverable at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 15:51:00 +02:00
dtourolleandClaude Opus 5 2147eaa6a5 Put a keyword on a photograph, not only search for one
The catalog has been able to *find* by keyword since v1 — query.rs joins the
keywords table, matches it exactly, and substring-matches it for free text — and
nothing anywhere could ever put a word there. A user could filter to a keyword
they had no way to apply.

This is the missing half: create, rename, delete, list, assign, unassign, and
the two reads a panel needs. Bulk-only for assignment, because keywording a
selection is the common case rather than the exception — the photographer picks
out the frames with the puffin in them and applies "puffin" once, in one
transaction.

Schema v6 adds `keyword_terms`, and deliberately does *not* touch the v1 join.
The assignment keeps the word as text because the catalog is a rebuildable index
and the durable copies of that fact — the sidecar, XMP dc:subject — both carry a
string; a foreign key would mean a catalog rebuilt from sidecars had to invent
identity rows before it could record anything, and would break the query path
that already works. So the text is the fact, and the new table is only the
identity a rename and a deletion can be keyed on.

`keyword_terms.name` carries no unique index, which looks like an oversight and
is not: two devices that each type "Iceland" are both right until they meet, and
a constraint would abort the merge at that moment. Uniqueness is converged upon
instead — create resolves an existing name, fuse_duplicates collapses a
cross-device pair onto the smaller uuid.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 15:50:49 +02:00
dtourolleandClaude Opus 5 b1e56877aa Merge integration into wip/ingest
Brings in the lens-profile and neighbourhood-operation work so the card
import is verified against what it will actually be merged into, rather
than against the tree it was written on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 15:49:51 +02:00
dtourolleandClaude Opus 5 939f33a3a3 Say on the import page where the photographs end up
Three lint findings and the gap the third one was pointing at.

`Context::library_label` was dead: the page named the folder on this machine
and said nothing at all about the server, which is half of what the Import
button commits to and the half that takes minutes rather than seconds. It now
names both, and states the order — copied and verified here first, then
uploaded (FR-NC-7b) — beside the destinations rather than beside the button,
because someone watching a slow upload needs to already know the photographs
are safe on disk.

The label is cached when the page opens rather than read in `render`: reaching
it goes through the context closure to the account, and `render` runs on every
keystroke.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 15:49:02 +02:00
dtourolle c963dafd09 Merge branch 'worktree-agent-afd449f5e7a01e341' into integration
# Conflicts:
#	core/dr-gpu/src/adjust.rs
#	core/dr-pipeline/ops/README.md
#	core/dr-pipeline/src/lib.rs
#	docs/traceability.md
2026-08-22 15:35:29 +02:00