Commit Graph
43 Commits
Author SHA1 Message Date
dtourolle c901fc1a0a Specify panorama merging: §3.11, D18, S15, and the design in panorama.md
A merge writes a new source file beside its sources (D18) rather than a
multi-source Version, which answers the schema question §7 had been holding
open for panorama, HDR merge and focus stacking together. The panorama is
undeferred as FR-MRG-1 … 11; the other two stay in §7 with their data model
decided.

FR-MRG-10 and 11 fix where the work runs — every per-pixel stage on the GPU,
the composite never held as one texture — because the output exceeds
max_texture_dimension_2d before it exceeds memory. panorama.md carries the
stage table, the chunked output driver, the model licences and the porting
sources. S15 gates all of it.

Coverage falls from 83.0% to 77.2%: thirteen requirements entered with no
code, and outstanding.md §11 says so.
2026-09-19 15:24:10 +02:00
dtourolle cd0ca6785f Specify eye state as a filter term, and record what was measured
FR-CULL-13, with §3.9.1's exclusion of blink detection re-read as the
exclusion of blink selection it always was: the stored fact and the chip
are built, a pass that picks the frame where everyone's eyes are open is
not. faces.md §17 has the models, the crop measurements, the four-state
rule and its floors, the native and proxy sheets read face by face, and
what remains to measure.
2026-09-19 14:05:50 +02:00
dtourolle a92ae4576f Repair the references that point at sections that moved
Eight citations named §5.1, §5.2 and a §5 selector language that
requirements.md's §5 has not contained since it became a pointer at
architecture.md; two named §9 for the golden images and the benchmark
suite, which are §8; and the three pointers into architecture.md were
each one section off. All now name the section that holds the thing.

architecture.md §12's subsections are numbered 6.1–6.13, colliding with
its real §6. That numbering is what every ARCH §6.n citation in the tree
uses, so it stays, and a note at the head of §12 says so instead of
leaving the next reader to work it out.

FR-DEV-3f's open question about persisting the film stock was answered
in sidecar.rs; the clause now says so.
2026-09-19 12:25:04 +02:00
dtourolle 7596cf9bcc State the compatibility baseline and the channels
NFR-COMPAT-1 and NFR-COMPAT-2 were instructions to write a requirement,
not requirements: "state the API level", "state the channels". Both
are now stated from what the build enforces and what exists.

The baseline is minSdk 28 / targetSdk 36 from the Android Dockerfile,
a Vulkan adapter at wgpu's default limits because compute needs storage
textures — device_from already called that the floor and is tagged for
it — with no optional feature required, since the f16 in FR-DEV-2 is a
texture format and not shader arithmetic. The reference device is the
HONOR ROD2-W09 the figures are taken on, and the second-vendor clause is
recorded as unmet rather than quietly dropped: there is no Mali or
PowerVR device, so an Android figure here is an Adreno figure.

The channels are all self-distribution — Arch package, local Flatpak,
sideloaded APK, NSIS installer — because D13's face weights rule out
every store, and the two consequences are written down: SAF stays
although a sideloaded build need not have it, and S11 becomes a
pre-publication step.
2026-09-19 12:25:04 +02:00
dtourolle 696bafa9d5 Undefer AI subject masking, which shipped, and give it a clause
§7 still listed "AI subject masking — deferred per D11" while
MaskSource::Subject and MaskSource::Category, backed by dr-segment's
instance and semantic models, had been the primary way a local
adjustment is made for weeks. The code was tagged FR-DEV-3, which
names gradients and brushes and says nothing about a model.

FR-DEV-3i now states what exists: a subject or a category found by a
local model, stored as identity with the run's signature so that it
merges per field and reads as stale rather than wrong, then treated as
any other layer by the edge, stroke, composition and reveal clauses.
The one place it departs from FR-DEV-19 — coverage written run-length
coded beside the layer, so a stored subject renders without a model —
is recorded in the clause instead of left for the next audit to find.
The segmentation crate and the UI's selection module are tagged to it.
2026-09-19 12:25:03 +02:00
dtourolle d259c0d4bb Say that FR-DSP-2 is waiting on S6, not that it was rewritten
R5's note said FR-DSP-2 "was rewritten rather than implemented". It was
not: the clause still demanded viewport tiling, the matrix listed it
unbuilt, and frame-budget.md's rewrite had been proposed and never
applied. Decided 2026-09-19 to keep it as written until S6 runs on a
mid-range Android device, because the measurement that argues against
tiling was taken on a discrete desktop GPU and the clause exists for the
device whose memory the image exceeds. Both notes now say that.
2026-09-19 12:25:03 +02:00
dtourolle 95458356da Record D13's position on the face weights
The licensing half of D13 had been open since 2026-08-09, while the
InsightFace detectors and embedder shipped in the tree and indexed real
libraries. models/face/README.md already stated the position the
project was actually taking; the register did not.

Now it does: this is non-commercial software, self-installed, and it
uses the weights under their research grant as such. The risks are
written where the decision is — the grant binds every user, it is not
GPL-compatible, it rules out every public channel, and publishing is
what reopens the decision. S14's licence search is what would close it.
2026-09-19 12:25:03 +02:00
dtourolle dc9db11033 Decide NFR-R8: no CPU pipeline, a degraded mode instead
NFR-R8 carried the words "decide explicitly" for six weeks, asking
whether v1 has a full CPU render path or whether "CPU fallback" means
staging only. The viewer had already answered it: with no adapter it
opens the library on embedded previews and cached proxies, keeps every
catalog edit available, and withholds develop and export. That is the
degraded mode, it is now the requirement, and NFR-RES-2 no longer
promises a fallback render path that was never going to be built.
2026-09-19 12:25:03 +02:00
dtourolle 3692306fd3 Say once that there is no phone
Three statements disagreed. §1.2's platform table said "phone
supported"; §3.5 said phones were out of scope and cited §1.3, which
does not mention them; D15 said "no phone" and gave the reason. D15 is
the decision, so the other two now point at it and say the same thing:
the build runs on a phone, and nothing is designed for one.
2026-09-19 12:25:03 +02:00
dtourolle c826fed605 Put the plugin API post-v1, and let the matrix count it that way
The register said two things about plugins. §7 had listed "Plugin API"
as deferred since the first draft, in a bare row; §3.10 then specified
it in 23 clauses that counted against coverage. Twenty-one of them had
no implementation of any kind, and could not have: no crate loads
anything at runtime. The coverage figure was measuring the contradiction.

Decided 2026-09-19: §7 is right. §3.10 stays as the design of record,
each of its clauses is marked "(post-v1)" on its defining line, and
NFR-SEC-6 — which exists only for plugins — goes with them, as does D16.

The traceability tool learns the marker. A deferred requirement is still
defined, so a tag naming it is not an orphan, but it leaves the
denominator and is listed in its own table rather than under "not yet
tagged". The marker must sit on the definition line; a mention of
"post-v1" in prose changes nothing, and where an ID is defined twice the
deferral on either line wins. Both are tested. Coverage moves from 72.2%
of 194 to 80.6% of 170 without a line of application code changing,
which is the honest figure: it now measures what v1 owes.
2026-09-19 12:25:03 +02:00
dtourolle c921852d89 Record D12 as settled by events, and D3 as delivered
D12 had been OPEN since the 2026-08-08 calibration, and D3 said it
depended on D12. In the meantime the milestone D3 named was delivered
and closed on 2026-08-30 and the application reached 0.12.2 with every
cluster the calibration selected at least begun. The decision the
register was waiting for had been made by building, so the register
now says so: full scope stands, v1 has no date, and "post-v1" in §7 is
the one way a clause leaves the count.

The status line also stops calling this a draft from August; it has
carried eleven dated amendments since.
2026-09-19 12:24:54 +02:00
dtourolle 7db999c1f6 Require judgement anywhere, and evidence that never becomes a verdict
Rating and flagging were reachable from the grid alone, so a photograph
opened in develop could not be judged without leaving it; FR-UI-5 said
"rating" without qualifying the view and was built as though it had.
And FR-UI-1's expanded row has said "filmstrip" since it was written
while the roll stayed on demand in both classes. Both are amended to
say what they meant: judgement follows the photograph, without
auto-advance outside the culling mode, and the roll is open by default
where there is room for it.

The larger change is a rule. Per-face signals — eye state from a
classifier, head pose from the five landmarks the detector already
yields — are worth having for culling, and §3.9.1 excluded detecting a
blink outright. The exclusion was always of judgement, not of knowing:
a blink is a fact about a frame of the same kind as a clipped
highlight. FR-CULL-8a specifies the two signals; FR-CULL-13 says what
any signal may do (be shown, filtered, sorted, propose a burst
representative) and what none may (write a rating or flag without a
user action between). R7 states the same thing as a user need.

Licensing was read before either was written. OCEC's eye-state weights
are MIT with a clean data chain; every open gaze model is trained on
Gaze360 or its peers, whose licences restrict derived models by name,
so gaze is deferred in §7 and head pose stands in for it. D13 records
both so they are not re-searched.

Replacing a closed-eyed face from a neighbouring frame was raised and
is written down as D17 rather than built: it is the multi-source schema
question §7 already defers for panorama and HDR, with its non-goals —
never automatic, provenance declared — fixed now.

Traceability regenerated: three new IDs, none yet tagged.
2026-09-19 11:47:27 +02:00
dtourolle 9cc52fd72b Bind the two develop gestures that were described and not bound
FR-DEV-16's book said resetting a control and hiding a mask layer were
reachable by pointer and by finger, and stopped there. The reason was
honest: the generated rows have no focus, so "reset the focused control"
named a thing the panel could not point at. But a photographer at the
keyboard means something narrower than focus. They mean the slider they
just dragged too far, and that is a thing the panel can remember.

So the Adjustments global keeps the last control moved — two indices,
written where the panel forwards the change and cleared when the next
photograph opens, so a reset cannot reach back into the previous edit
through an index that happens to be shared. R puts it back, through the
same callback the track's double-click takes, and is silent until
something has moved.

The mask layer needs no such notion, because the panel already has a
selection: the rows the edge controls point at. H hides or shows those,
through the path the ring at the head of the row takes, so it is an edit
and a history step exactly as the ring is. A mixed selection goes to
shown, since the layer nobody can see is the one being asked about.

Both tags now carry the key, and the book says so.
2026-09-12 01:08:09 +02:00
dtourolle b718c70b11 Specify a Windows installer built by the Linux CI
The tree is closer to Windows than a Linux-only project usually is:
every image library, the TLS stack and the inference engine are pure
Rust, and dr-plat already keeps the Linux-only code behind cfgs with a
loud fallback where none exists for another platform. What remains is
a short list above dr-plat — five XDG path lookups, an xdg-open, the
secret store's third implementation, the models' lookup beside the
executable — and none of it touches core, which is the NFR-PORT-3 test
this would be the first real run of.

docs/windows.md decides the GNU target over MSVC-via-xwin, Vulkan only
as on every other platform, a per-user NSIS installer that leaves the
library alone on uninstall, and a CI leg in the shape of the Android
one. It is explicit about what a runner with no Windows can verify —
that it links, is PE32+, starts under Wine and installs under Wine —
and what it cannot, which is everything involving a real GPU driver.
Three FR-PLAT-WIN requirements and a channel row record the decisions;
the ordering puts a first cross-compile on the developer machine before
any container exists, because the list of cfg gaps is a reading of the
source and the compiler's list will be longer.
2026-09-11 23:30:16 +02:00
dtourolle a87139b838 Give every mask an eye and a colour, and put the brush where the mask is
The first build of seeing a mask showed the selected layer's, in one global
style, from a strip at the top of the panel. It answered the wrong question and
answered it somewhere nobody looked. What a photographer asks of two masks is
how they meet — where the sky's edge sits against the building's — and that
needs both on screen at once, in colours that can be told apart.

So each row of the stack has an eye, drawn in the colour its mask is shown in,
and each mask has six swatches to choose that colour from. Several can be open
at once; a new one comes up open, in the first colour nothing else is using.
The style — tint, alpha, outline — is the one setting that stays global, above
the stack, because three styles at once are three pictures that cannot be read
against each other. Alpha now draws every shown mask, each in its colour, on
black. In the pipeline a `Reveal` is a list of `(layer, colour)` rather than
one layer, and every reveal block carries its own colour.

The brush moves too. Select, Paint and Erase and the three sliders under them
sat at the top of the panel, appeared only once a row was selected, and said
nothing about which mask they acted on — so "how do I paint" and "how do I
correct the model's outline" both had the same answer and nobody found it.
They sit under the selected mask's parts now, beside the swatches, and on a
subject or a category the hint says what a stroke there does: it becomes a
part of this mask, joined to the model's, and can be taken out again.

Eyes and colours are viewing state, on the session and not on the layer, so a
photograph reopened has every eye closed — the stored-mask round-trip test
asserts it.
2026-09-11 19:03:51 +02:00
dtourolle c045702a47 Show the photographer the mask they are shaping
Nobody can refine an edge they are not being shown. The only thing drawn on
the canvas was the region overlay — a false-coloured picture of what the model
*detected* — which knows nothing of a layer's feather, its falloff, its
morphology, its invert or its opacity, and nothing at all about a gradient, a
range or a stroke. Every control added for mask editing therefore acted on
something invisible, which is why the whole feature reads as absent rather
than as unfinished.

A layer's finished mask now draws over the photograph in one of three styles:
a tint for whether the right thing is selected, an alpha for where the edge
is, an outline for whether that edge is registered against the detail the
other two hide.

The hard part is not the shader. A selection with no adjustment on it changes
no pixel, so it is not active, so it holds no slice of the mask array and is
never rasterised — and that is exactly the layer somebody wants to look at,
for the whole of the time between choosing a subject and deciding what to do
to it. So `MaskStack::rendered` is `active()` plus the layer being looked at,
and the rasteriser, the composer and the distance-field builder all index by
position in it. Which is also why the design's "two uniforms, no recompile" is
not available: a uniform can select a slot, it cannot conjure one.

The reveal is never on the graph. It reaches the pipeline as an argument to
`compose_revealing`, and `compose_for` — which the exporter, the thumbnail and
the neutral probe all call — has no way to ask for one. A flag on the graph
would have been shorter, would have type-checked, and would have been one
forgotten reset away from a red tint baked into an exported file.

And the tools that shape a mask now arm. `Masking.tool` is an `in` property
only Rust may write, and the handler wrote nothing back, so the strip reported
"Select" however many times Paint was pressed and the paint area was never
enabled — the brush, the parts and the whole of FR-DEV-19b reachable from no
control in the application.

The region overlay stands down while a mask is being shown, and its button now
says what it hides: two overlays that look alike and mean different things is
worse than either.
2026-09-10 20:28:06 +02:00
dtourolle 2361b2d4ef Tablet portrait is the expanded layout class, not the compact one
FR-UI-1's table has put tablet portrait in the compact class since the
register was written. D-N2 settled that it does not belong there: a
12-inch tablet is about 1024 logical pixels across in portrait and
EXPANDED_MIN_WIDTH is 820, so both orientations of both targets are
expanded, and compact fires only on a desktop window dragged narrow. The
code has always agreed - apply_layout_class reads the window's width and
nothing else - so the register was the last place still saying otherwise.

What portrait actually needed was a different axis. D-N7 docks the
develop column under the photograph on a tall window, set from the
window's aspect and independent of the layout class: a 960-wide portrait
window is expanded and wants the dock, a 1500-wide landscape one is
expanded and does not. That is a placement rather than a mode, so the
clause about the transition being continuous stands as written, and the
requirement now records the side the column takes as its own property.

FR-UI-2 gains one clause for the same reason. It said modality affects
control sizing and affordances "not layout", which D-N6 reversed: under
touch the group selector leaves the strip above the column for the tool
rail, and groups-in-rail in toolrail.slint is what moves it.
2026-09-07 20:00:40 +02:00
dtourolle df741a8a49 Let one mask be built from more than one selection, and paint into it
A mask the model draws arrives approximately right — stopping inside a
shoulder, leaking into the hair — and FR-DEV-3's edge controls move the
*whole* boundary, so no value of feather or dilation fixes two errors that
go opposite ways. What fixes them is a second selection joined to the first,
and a layer that held exactly one source had nowhere to put one. The brush
the core has had all along was reachable from no control in the application.

A layer is now an ordered list of parts. Each names a source and how it
joins the mask before it — added to it, or taken out of it — and carries its
own edge treatment, because a model's soft coverage and a stroke painted
where it stopped short do not want the same feather. Invert and opacity stay
on the layer, where the composed shader already reads them.

The sidecar grows `[part]` blocks and nothing else. A layer of one part
writes exactly the bytes it always did; a mask block with no part blocks
after it reads back as one part; and a stroke, a join or a source this build
cannot read costs that part rather than the layer. So every sidecar in every
library still parses to the edit it always was.

On the device the parts fold into the layer's one slice, so eight layers
still cost eight channels: union is a `max` blend and subtraction is the
erase blend the brush already used. A part is drawn into a scratch texture
before it is joined, and that is not incidental — an erase stroke means a
hole in *that part*, not a hole in the mask, and drawn straight onto the
accumulator it would punch through the subject underneath. A layer of one
part skips all of it and takes the path it always took.

In the interface: a part list under the selected layer with a chip saying
which way each joins, Add and Subtract beside it, a Select/Paint/Erase strip
with the brush's size, hardness and flow, and a drag on the photograph that
paints. Pressing Paint on a mask that cannot hold a stroke joins a part that
can, rather than explaining that a subject is not a brush. A whole stroke is
one step in the history.

The edge controls now shape the part that is selected rather than the layer,
which is the one behaviour change to an existing control: with a correction
selected, the feather slider softens the correction and leaves the model's
mask alone.
2026-09-07 20:00:40 +02:00
dtourolle af89433aee Offer the lens profile as a tick box, since applying it silently reads as absent
The develop panel's Optics group is three manual sliders: distortion, chromatic
aberration and lens vignetting. The automatic correction was already there — the
file's EXIF lens is matched against the bundled Lensfun database on open and the
coefficients are fanned out to all three — but nothing in the interface said so
except a line of grey text under the camera reading "· corrected", and there was
no way to decline it. From the outside that is indistinguishable from the
feature not existing, which is how it was read.

`dr-lens` states the rule this breaks: an automatic correction that silently
does nothing is worse than one the user can see is unavailable. The caption
satisfied the letter of it and not the point — a photographer looking for
"apply the lens profile" found three sliders and no switch.

So the profile is now a control. It is a capability rather than a flag on the
session, because everything a photographer sets travels one road: the capability
list feeds the generated panel, `Preset` captures it, the sidecar stores it and
the undo stack replays it. A bool on the side would have needed adding to each
of those four by hand and would have been forgotten in at least one — which is
exactly how the mask stack came to be missing from the history.

It is on by default, which is what `switch_on` is for: the coefficients are a
measurement of the lens that took the photograph, so accepting them is neutral
and declining them is the edit. The sidecar therefore stores nothing for the
ordinary case and the correction still happens.

The switch appears only where a profile was matched. A tick box on a photograph
whose lens the database has never heard of would be a control that looks
available and does nothing, which is the failure the rule above names rather
than an instance of following it — those photographs are told "· no profile" in
words instead, and one whose box is unticked now says "· profile off", which is
a third fact and not either of the other two.

Two things had to be built underneath. `ParamKind::Bool` was in the core's
closed enum and mapped to a row kind here, and had no control behind it in
`adjust.slint`: a parameter declaring itself a switch was flattened into a row
that drew nothing at all. Nothing shipped had one until now, so the gap cost
nothing and was invisible. And `Check` self-toggled, which is right for a
settings page that owns its value and wrong for a panel row that is a view of
the edit graph — the click would have answered by replacing the binding with a
literal, and the next undo or pasted preset would have moved the value with the
tick left where the finger put it. It now takes `controlled`, and the generated
row uses it.

The manual sliders are unchanged and still trim whatever the profile leaves, so
switching it off is "correct this by hand" rather than "stop correcting".
2026-09-07 00:27:47 +02:00
dtourolle 68ebf5d78b Let a mask start from a tone or a colour, not only a shape
Every local adjustment began from a shape: painted, drawn with a handle, or
found by a model. So the only way to hold back a sky was to draw a line near
where it ended, and the only way to warm skin was to paint round it — both of
which put the edit's edge where the photographer put a gesture rather than
where the picture changes. A gradient across a treeline halos, and an
adjustment traced round a face stops on the outline of a hand.

MaskSource grows two variants that select by what a pixel *is*. Luminance
carries two bounds on the perceptual tone scale plus a softness; Colour carries
an arc of hue, a range of chroma, and one softness for every edge of both. Five
floats and three, so they diff, sync and merge per field under FR-NC-9 exactly
as a gradient's geometry does — the property a stored raster has none of, and
the reason the model's coverage had to sit beside its source rather than inside
it.

The pixels are the shader's business and nowhere else's. `mask.wgsl` takes the
demosaiced source as a sixth binding and two new modes read it: decode, balance,
pull a clipped photosite back to neutral, apply the camera matrix, then weigh
the band. Nothing crosses to the CPU but the numbers and the matrix, and each
mask texel averages its own footprint in the source, so a band lands on the tone
an area is rather than on whichever texel a proxy grid happened to land on.

The photograph it measures is the one the camera recorded, before this edit. A
band over the edited result would slide out from under the edit as the edit was
made — raising the highlights would change which pixels counted as highlights,
and the slider would chase its own mask.

Feather, falloff and morphology stay off a range layer, which is what
`shapeable` already meant. All three are functions of the signed distance from
a boundary, and a range has no boundary to be at a distance from; its edge is
the softness of its own band, in the band's units. Offering them would be four
controls that move and change nothing.
2026-09-06 19:01:48 +02:00
dtourolle 81b1ae8c42 Measure the haze from the picture, and divide it back out
Four files named dehaze as a member of the compositional detail family —
`detail.rs` twice, `dr-gpu`'s detail module, `ops/README.md` and
`capture_sharpen.rs` — and no such node existed. Every one of them was
describing the family by listing clarity, texture and a control the
photographer could not reach.

Haze is the one degradation the controls already in the chain cannot
remove, and the reason is spatial rather than tonal. Scattering
composites an airlight over the scene in proportion to distance, so the
lift is per-pixel: a black point that clears the mountains crushes the
foreground, and a contrast curve that clears the mountains does the
same. So the node has to estimate the transmission at every pixel, which
is the dark-channel prior — the local minimum over the channels and over
a patch is the airlight that has been added there — and then invert the
scattering model with it.

The airlight is taken as neutral and as unit, which removes the one part
of the published method this stage cannot perform. Estimating it
properly is a whole-frame reduction, and the detail chain has none: it
hands each pass the pass before it. It is also unnecessary, because
white balance is the first node in the chain and has already driven the
illuminant to grey, so only the magnitude is unknown — and an unknown
magnitude on the veil is a scale factor on the amount slider, which the
photographer is setting by eye regardless.

The patch is a fraction of the frame's shorter edge, through
`RenderScale::frame_fraction`, and never a count of pixels. It has to be
wide enough to contain something dark and narrow enough that what it
measures is still local, and both of those are statements about how much
of the composition it covers — so it must cover the same proportion of
the picture on a proxy as in the export, or the file is sharpened for a
patch three times narrower than the one that was tuned on screen.

Affording it needs an identity a Gaussian does not have. Erosions
compose by adding their structuring elements, so the minimum over a run
of d followed by the minimum over k points spaced d apart is the exact
minimum over the whole kd window. At the square root that is 16 taps
rather than 61 at 4K, and it is the same filter rather than an
approximation of one — which is the difference from the strided kernel
`local_contrast` refuses, where sampling an image that is not
band-limited aliases into the base and comes back as mottling.

It runs first among the compositional detail nodes, at order 125: after
noise reduction, because dividing by a transmission below one amplifies
the noise in the veiled distance by exactly the factor it recovers the
contrast by, and before clarity and texture, coarse before fine, so that
their base is computed on the picture the veil has left rather than on a
modelling about to be divided out.

What it cannot honour is the placement dehaze most wants. It shifts
colour — it subtracts a grey term and rescales, so saturation changes
wherever the veil is thick — and the colour work would ideally be
correcting the picture that leaves here. The detail stage runs as a
group after every point operation, because a neighbourhood pass is a
separate dispatch over a texture the fused pass has finished writing, so
an order placing this node ahead of `vibrance` would be a lie the chain
cannot tell. Interleaving would mean splitting the fused pass in half
around it, at the cost of a second full-frame dispatch and intermediate
for every edit in the catalogue whether it dehazes or not. The
declaration records that rather than leaving it to be rediscovered.

FR-DEV-18 is added to the requirements register alongside it. The tag
had nowhere to point, and an orphan tag fails the traceability gate
rather than quietly counting for nothing.
2026-09-06 19:01:47 +02:00
dtourolle 7c3e1d2c54 Let the shadows and the highlights carry a colour the picture never had
The colour mixer is the only chromatic control in the chain, and it can only
turn a hue that is already in the frame. Ask it for cool shadows against warm
highlights and it has nothing to take hold of: the shadows of a correctly
balanced photograph are near enough neutral that there is no band there to
turn, and a monochrome conversion hands it a picture with no hue in it at all.
Split toning is the oldest look in the book and every developer worth comparing
against ships it; there was no way to reach it from here.

So colour_grading, declared like any other node — a hue and a strength for the
shadows, the midtones and the highlights, and a global cast over the frame. It
targets a tonal range rather than a hue, which is the whole difference between
the two controls: it puts colour where none was rather than turning what it
finds. It sits at 105, after the mixer has had the last word on the colours
that are in the picture and before the detail stage.

The mechanism is one helper. Three cosines 120 degrees apart are the hue wheel
written directly as an RGB direction, and their sum is zero at every angle, so
exp2 turns them into three gains whose product is exactly one — a cast tilts
the balance without moving the level. A grade that doubled as an exposure
change is the failure that has the photographer chasing brightness with a
colour slider, and it is corrected with a control that cannot reach it. The
three tonal weights partition the scale rather than overlapping, the midtones
being whatever the two ends leave, so setting all three to one hue is exactly
the global cast and a split tone does not colour its own midtones as a side
effect of its halves meeting. Full strength is half a stop on the leading
channel, the ceiling white balance already holds itself to.

Neutral is declared rather than inferred, which is what `active:` is for. A hue
with no strength behind it is a direction with no distance, so under the
default rule nudging one would have put the node into every fused shader for a
change nobody can see. Summing the strengths is zero exactly when all four are,
and they cannot go negative to cancel each other. The opposite reading —
neutral as "nothing has been touched" — fails the other way round: red is hue
zero, so a grade toward red never moves a hue off its default and would never
have been applied at all.

It asks for a colour wheel, the widget the descriptor vocabulary has been
carrying with no operation behind it. Nothing draws one yet, and that is fine
by construction: the panel takes the first widget it implements and falls
through to sliders otherwise, so this arrives as eight ordinary controls that
work. Each parameter is named for its own range for exactly that reason — in a
flat list, four sliders called "Hue" are four controls nobody can tell apart.

FR-DEV-12 is written into requirements.md beside it. A TRACES tag naming a
requirement that is not defined there is an orphan, and the traceability gate
fails on those rather than quietly counting them. The label catalogue gets one
line for the operation's display name; the eight parameters derive correctly
and are left to.
2026-09-06 18:51:40 +02:00
dtourolleandClaude Opus 5 d44bffa4a8 Remember where the photographer was
Opening the application was always a fresh arrival at the beginning of
the library, whatever you had been doing when you closed it.

What is written down is the view, the scope, the rating filter and the
photograph on screen -- the open one in develop, the first visible one in
the grid. Not just a scroll position: a position without the filter that
produced it names a row of a list that no longer exists. Restoring them
has an order for the same reason -- scope, then filter, then position,
then the view -- because each step changes what an ordinal *means*.

Addressed by remote path and collection UUID, never by an ordinal or a
row id. `images.id` and `collections.id` are local to one catalog, and a
grid ordinal is local to one ordering; a record naming either would land
somewhere arbitrary on a second device and after any filter change on
this one. Where the ordinal is needed, `library::ordinal_of_path`
computes it through the grid's own `ORDER BY`, taken verbatim by a window
function rather than spelled a second time as an inequality -- which is
the mistake `grid_order_for` already warns about, and which a manually
ordered collection would make unreadable.

Every failure degrades rather than reports. A collection this device has
not merged leaves the scope at the whole library; a photograph that has
since been deleted falls back to when it was taken, which puts the grid
in the right week; a torn file yields no place and the library opens at
the top. Reopening develop is the one thing that requires an exact match,
because a canvas on a path that no longer resolves is a filename over an
empty frame.

The record lives in `dr-types` beside `Settings` and the store lives here
beside `SettingsStore`, for the reason `dr-types`' manifest gives: a JSON
serialiser in `core/` would be paid for by every crate there. Two files
and two lifetimes, though -- resetting preferences must not forget where
you were.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 20:39:50 +02:00
dtourolleandClaude Opus 5 e7b526c550 Specify face indexing at native resolution, and say what the proxy cost
FR-CULL-8 said detection runs against the thumbnail or proxy tier and
never a full decode, and faces.md §5 said the aligned crop is sampled
from that same proxy. Both are wrong in the same place: they treat
detection and cropping as one resolution problem when they are two, with
opposite answers.

Detection does not care. §4.1 fixes the graph's input at 640x640 and
letterboxes whatever arrives, so a face filling 2% of the frame reaches
the model at 12px whether the buffer handed over is 1024px or 6000px.
Every pixel above the detector's own input is discarded before inference.

The crop cares about nothing else. §5's warp produces the fixed 112x112
ArcFace sees, so source resolution converts directly into whether those
112 pixels were photographed or interpolated. Reading crop_px across the
18,671 faces the proxy-tier implementation stored: 47.3% were upsampled
to reach the embedder, 314 of them by more than 2x, the smallest from 34
source pixels. An upsampled crop does not fail loudly -- it yields a
confident embedding of detail that was never there, and the damage
appears three stages later as clusters that will not separate.

So FR-CULL-8 now specifies four stages with the resolutions named
separately: render native through FR-EXP-9's pipeline, downscale for the
detector, map boxes and landmarks back to native, crop and align from
the native render. The affordability the old rule bought is met instead
by when the pass runs -- background, preempted, resumable -- and the
requirement says plainly what it now costs on a remote library: the
original rather than FR-NC-3's byte range, 412 GB across the reference
library's 19,107 images, so a whole-library pass is a transfer under
FR-NC-6 rather than something that may start on its own.

MIN_CROP_EDGE replaces the MIN_DETECT_EDGE this branch briefly had. Same
number, guarding the quantity that turned out to matter.

faces.md §7b records both measurements, and marks the second as
unexplained rather than dressing it as a finding. Grouped by the buffer
detection ran against, faces per image was 0.078 at 1024 or below and
1.82 at 2048 or better, controlled for file type and size. That gap is
real and reproducible and I cannot account for it, because the letterbox
above says detector input should not matter. M4 is where it gets
settled. The crop measurement does not depend on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 19:41:20 +02:00
dtourolleandClaude Opus 5 b63ce0290b Wrap the three lines that ran past the column the rest of these documents keep
Prose-only. Three paragraphs added in this branch ran to 105, 113 and 166
columns against a document that wraps at 100 everywhere else, the last because
an edit joined a new sentence onto an existing paragraph's opening line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:25:57 +02:00
dtourolleandClaude Opus 5 8865743db6 Split FR-EXP-1, so its tag stops standing for a deferral as well
FR-EXP-1 listed four things in one sentence: JPEG, PNG, TIFF at both depths,
and AVIF or JPEG XL. Three of them are built, encode, embed an ICC profile, and
are covered by tests that walk every offered format. The fourth is a deliberate
deferral — `export` returns `FormatUnsupported` for AVIF and JPEG XL, and
`the_formats_without_an_encoder_say_so` pins that behaviour in place.

Fused, the requirement could only be tagged dishonestly or not at all, and not
at all is worse: it would delete the register's record of the part that is
finished, which is most of it.

So the v1 scope is now JPEG, PNG and TIFF with the configurability clause, and
AVIF and JPEG XL are stated as post-v1 with the condition that already holds —
either may appear in the settings page before its encoder does, provided
choosing it fails with a typed error naming the format rather than producing a
file. That is what `every_offered_format_either_encodes_or_explains_itself`
exists to guarantee, and it is why a format cannot be added to the picker and
quietly reach an encoder that does not handle it.

No intent is dropped. AVIF and JPEG XL remain wanted; they are now scheduled
rather than silently outstanding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:23:03 +02:00
dtourolleandClaude Opus 5 e0da93c59a Reword FR-RAW-2's mechanism to the one that serves its purpose
FR-RAW-2 required RAW decoding to sit behind "a trait taking a SourceRef, not a
filesystem path". The purpose is met — `dr_decode::decode` takes `&[u8]` and
the crate has no path-based entry point anywhere — and the mechanism is not,
because a decoder taking a SourceRef would be a worse design than the one built.

A SourceRef is opaque. The only thing that turns one into bytes is `Storage`,
which lives in platform/dr-plat, so a decoder taking a SourceRef must take a
Storage with it: retry, permission loss and remote fetching move inside the
decoder, and the decoder becomes constructible only where a Storage exists.
Bytes in, image out, is narrower and more portable — the decoder cannot know
where its input came from, which is the property the requirement wants.

The Nextcloud case is the one a byte-oriented API looks like it should lose,
and it is the clearest illustration that it does not. `dr_decode::HEADER_BYTES`
declares how much of a file the decoder needs in order to read metadata, and
`import.rs` fetches exactly that range through `Storage::read_range` before
calling `dr_decode::metadata`. The decoder states a requirement; the storage
layer satisfies it. A decoder holding its own SourceRef would have had to carry
the range policy itself.

The trait half of the clause is left standing and unmet. There is one decoder
reached through free functions, so "a second implementation may be added
without changing callers" is still outstanding work rather than a description.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:22:22 +02:00
dtourolleandClaude Opus 5 11e515a295 Say which state NFR-P11 forbids losing, since one kind is dropped on purpose
NFR-P11's criterion was "no dropped frames, no state loss" across a layout
class transition. Read literally, the interface already fails it, and fails it
by design: `apply_layout_class` clears the user's panel open/closed choices
whenever the class changes, and `PanelChoices` documents why — a choice made in
landscape answers a different question from the one portrait asks, and carrying
it across leaves a 232 px sidebar on a screen with no room for it.

A requirement that the code deliberately contradicts is worse than no
requirement, because the next person to read it either "fixes" the behaviour or
learns to discount the register.

So the criterion now names what must survive — the open image and version, the
selection, scroll position, the in-progress edit and its undo history, the
current mode — and states the exemption with the reasoning attached. Panel
disclosure is a default re-derived per class, not state; the user's
disagreement with it is remembered within the class where it was expressed.

The intent is unchanged: a resize must not cost the photographer anything they
did. It is now possible to write a test for that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:21:32 +02:00
dtourolleandClaude Opus 5 99f9b5b7c5 Strike R5's tiling clauses, which the frame budget argues against
R5 asks that the application work on downscaled proxies for display, and its
criterion stated that in two parts: the display pipeline operates at viewport
resolution, and only visible tiles are computed with panning recomputing only
newly exposed ones. The first is built and pixel-equality tested. The second is
not, and should not be.

This is the same correction FR-DSP-2 already received, applied to the
requirement that FR-DSP-2's tiling clause traces back to. frame-budget.md
measured the case tiling exists for: recomputing the whole 4K viewport costs
4.5 ms of a 16 ms budget, so a perfect tile cache could save at most 4.5 ms in
exchange for a cache keyed by (VersionId, tile, zoom, graph_hash_prefix) that
must stay correct across every parameter change in the graph.

For the one stage that does exceed the budget it is worse than useless. That
stage is a convolution, and a tiled convolution reads a halo per tile: at the
52 px radius measured at 4K, 256 px tiles read (256+104)² taps instead of 256².

The intent is not removed — a display path that does work proportional to the
source image is still forbidden, and that is what the remaining clause says.
What is removed is a mechanism written in as though it were the only way to
get there, and which measurement says is the wrong one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:21:06 +02:00
dtourolleandClaude Opus 5 da20d42d33 Merge master: pluggable storage, and a name that anchors
Conflicts were docs/traceability.md alone, and it is generated — so it
was regenerated rather than hand-merged. dr-face was untouched on the
other side; ui/dr-ui/src/faces.rs and identity_ui.rs auto-merged, the
first around recluster's anchoring and the second around load_faces.

Worth recording because the two branches met on the same problem from
different ends. Master's "Let a name hold a group together" is the fix
for the sixteen Catherines — fourteen of them empty — that this branch
found while measuring the library and reported without fixing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 12:30:01 +02:00
dtourolle c102ba9df2 Treat a placeholder as the photograph, not as a one-byte file
The folder connector was pointed at a Nextcloud VFS tree and got three
things wrong, the first of which loses work.

**A dehydrated sidecar read as absent.** `a.drsc` does not exist when the
client has dehydrated it — only `a.drsc.nextcloud` does — so `get` missed,
`.ok()` swallowed the `NotFound`, and the sidecar writer took that for
"there is no sidecar yet" and wrote a fresh document over the existing
one. Every edit another device had put there went with it. That function's
own doc comment calls this the exact loss the format's unknown-key
preservation exists to prevent.

**A stub was catalogued as a 1-byte image**, and ARCH §9.0 measured this
machine at 121,785 placeholders against 10,267 real files — so a folder
library on a synced tree was ~92% broken rows.

**Identity changed on hydration**, so downloading a photograph looked like
a delete and an add, orphaning its thumbnail and its face rows.

Entries now carry the photograph's own name and a `materialised` flag;
`get` on a stub returns the new `RemoteError::NotMaterialised`, which is
distinct from `NotFound` precisely because the sidecar writer must treat
them differently — it fetches the sidecar and merges, or leaves the entry
queued.

Hydration is a **borrow**. `BorrowPool` records what was on disk before it
asked, so `release_all` dehydrates only what a pass brought and leaves
what the user already had. Reference counted: the thumbnail pass and the
face pass meet on the same RAW, and without counting the first to finish
dehydrates the file the second is reading. A borrow against a plain folder
or a server does nothing, so a pass written for VFS runs everywhere.

Releasing means asking the client to dehydrate and never deleting: a
deletion inside a synced tree propagates to the server and removes the
photograph from every device.

Not a second backend — the capability is per *connection*, not per type,
since the same folder hydrates only while the client runs. The convention
arrives through a detector the registry supplies, so `dr-sync-folder`
still knows nothing about any client's protocol.

ARCH §9.0a records this as an amendment: finding 3 rejected hydration
because it costs 100× a range read, and that comparison assumed a
connector was available. A folder library has none.
2026-08-29 09:57:52 +02:00
dtourolle f12aece07e Make storage pluggable, and prove it with a folder backend
`RemoteBackend` existed from the first release and bought nothing it was
designed for. Seven files in `dr-ui` constructed a `NextcloudBackend`
directly, an account *was* a server URL beside a DAV user id, the local
cache directory was named after a hostname, and the launch screen knew
that signing in meant a browser handshake. The trait was real; the seam
was documentation.

A trait over operations is only a quarter of it. Pluggable storage needs
four things, and this adds the other three:

- **Capabilities** — already there, and the reason the engine can drive
  two backends at the speed each actually runs at.
- **Configuration** — `dr_sync::Account`: where a library lives, in
  whatever form its connector addresses, with no server in it. Loads
  every existing config unchanged (`backend` defaults to `nextcloud`,
  `endpoint` is stored under its historical `server` key), and
  `Account::namespace()` reproduces the old catalog directory byte for
  byte, because changing it would abandon a catalog, its thumbnail
  shards, and the sidecars holding unsynced offline work.
- **Registration** — `BackendProvider` and `BackendRegistry`.
  `ui/dr-ui/src/remote.rs` is now the only file above `dr-sync` that
  names a connector.

`Connection` (an account plus an optional `Secret`) replaces the
credentials-and-user-id pair that was threaded through fifteen
signatures in an order that could be swapped. `Secret`'s inner string is
reachable only through `expose()` and its `Debug` prints `Secret(***)`,
so the indirect leak — a `{:?}` on anything holding one — no longer
compiles into a leak.

Nextcloud is unchanged and keeps every peculiarity: propagating ETags,
chunked upload v2, `oc:fileid`, the `oc:permissions` probe on a refused
PUT, the 423 retry classification, Login Flow v2. Those are what the
capability model exists to serve, not something to hide.

`dr-sync-folder` is the second connector: a local disk, a network mount,
an external drive, or a folder a Nextcloud client already syncs. No
account, no credential — the route that works where no secrets daemon
does. It declares `LocalEtags` rather than claiming propagation a POSIX
directory cannot provide, which costs nothing because 50k `stat` calls
are not 50k PROPFINDs. Identity is a path hash, not an inode: an inode
survives a rename but differs between devices and is reused after a
delete, so two machines would disagree about which photograph a
thumbnail belonged to. Re-deriving a thumbnail is a cost; showing the
wrong one is a bug.

docs/storage.md is the contract — the traits, the four steps to add a
backend, and what each connector declares. ARCH §8.0 and §8.4a, and
FR-NC-13, say why.
2026-08-29 09:57:52 +02:00
dtourolleandClaude Opus 5 c8e05831f4 Show the confidence, and say which curve it came from
FR-CULL-9 was read as "no fit, no number", so every library without 200
confirmed positive pairs showed "Confidence unavailable" on every
suggestion — which is every library, until enough confirmations exist to
fit one. The confirmations are made on this screen, ranked by the number
it was withholding, so the degraded state was also the permanent one.

There has always been a curve: Calibration::default is the reference
implementation's fitted MBF sigmoid, which is what clustering already
operates at. It is a published operating point, not an invention, and
what the requirement forbids is presenting it *as though it were
measured on this library*. So the percentage is shown, and the screen
says once, above the grid, where the curve came from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 09:57:38 +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 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 5945f7420a Say where an uploaded original lands
FR-NC-7 specifies how bytes travel and deliberately has no opinion about
where they go, and nothing else said either — so the one thing a photograph
needs on arrival, a folder, was unwritten.

FR-NC-7a gives it `{yyyy}/{yyyy}-{mm}-{dd}`, and two consequences the
transport mechanics do not supply on their own: expansion is a pure function
of capture metadata, so two devices computing a destination for the same
frame agree; and the template governs placement on upload only, because the
remote library is reachable by other clients and restructuring it under them
is not ours to do.

FR-NC-7b joins import to upload. A move-import must not erase the card until
the upload is confirmed — a card erased against an in-flight transfer is the
one failure in this application with no undo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 14:00:13 +02:00
dtourolle 610f679881 Reconcile the three branches
Two faults textual merge could not see. Both agents added a `mod tests` to
masks_ui.rs — the module boundary was an artefact of them being written
apart, and the tests do not overlap, so they fold into one. And `segment`
lost its context argument when the work moved to a worker, which a test
written on another branch still passed.
2026-08-22 13:33:55 +02:00
dtourolle acd694c2cf No phone, so stop designing for one
Targets are a 12-inch tablet and a desktop (D15). That removes most of the
navigation question rather than answering it.

`EXPANDED_MIN_WIDTH` is 820 logical pixels and a 12-inch tablet is ~1024
across in portrait, so both orientations of both targets are the expanded
class. The compact class now fires only when a desktop window is dragged
narrow — graceful degradation, not a second interface. The bottom tool strip
and the one-tool-at-a-time sheet were solving a phone, and there is no phone.

What survives is input, not size, and the architecture had already decided
it: `WidgetDemand::precise_pointing` exists for a television remote, and its
own documentation says touch is fine because hit regions grow to the
modality. Touch changes hit regions, not layout. The rules that fall out are
worth stating because they are easy to violate by accident — no hover-only
affordance and no modifier key may be the sole route to anything, since a
tablet has neither. Local masking already lost its shift-click extend for
exactly this reason.

The guaranteed-wide viewport also pays for a better answer to the extent
problem than hiding things. The complaint was never that the column is long;
it is that the histogram scrolls away from the sliders it reports on.
Collapsing shortens the scroll, pinning removes the problem, and ~260px of
fixed height is affordable on a viewport that is never under 820 wide.
2026-08-22 09:55:09 +02:00
dtourolle 12d320cf33 Record what the spec got wrong about the model that exists
docs/segmentation.md §4 priced arm B as costing a C dependency under the
NDK and treated that as most of the difference between the arms. It is not
a cost that has to be paid: `ort`'s `alternative-backend` disables its
linking entirely and `ort-tract` supplies the API from tract, which is pure
Rust. D13's "largest exception the policy would tolerate" turns out not to
be needed, and the answer generalises to the face pipeline — so D13's
runtime half is now answered and only its licensing half is open.

Three findings contradict §4 outright and are recorded as F4-F6 rather than
quietly designed around. There is no ADE20K-trained YOLO, so the shipped
vocabulary selects subjects and not stuff — "select the sky" comes from the
watershed or from nowhere. It is instance segmentation, so it partitions
nothing and two people come back as two instances. And tract cannot parse a
dynamic-shape export, which fixes the input at 640 square and makes tiling
the only route to more semantic resolution.

Arm C ships, but §8's criteria are not what decided it, and saying so
matters more than claiming the process worked. §8 asked for a two-
interaction margin over arm A on a traced corpus. That comparison was never
run: F4 and F5 changed what the arms are, and a model that recognises
subjects but has no word for sky cannot be a selection tool alone, while a
watershed cannot tell a person from the wall behind them. They stopped
being candidates and became complements.

What is *not* done is written down as plainly: the 24-image corpus is
untraced, so M1-M4 have no numbers and "this feels right" has not become
one. M5 is answered on one device only, and region ids now reach the
sidecar — so a cross-vendor divergence would mean a mask written on the
desktop meaning something else on Android. F3 stands.
2026-08-22 08:39:17 +02:00
dtourolleandClaude Opus 5 489465faf0 Show the photograph the way it was taken
Nothing read EXIF orientation, so every frame from a body held sideways
lay on its side — in the grid, in develop, and in the read-only preview.

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

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

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

Written down as FR-DEV-3h.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 15:37:05 +02:00
dtourolleandClaude Opus 5 fe66eba87f Write down the trash requirement the code already implements
The traceability gate failed on an orphan tag: seventeen sites across
dr-catalog, dr-sync, dr-thumbs and the UI claim FR-CAT-15, and
requirements.md defines FR-CAT-1 through FR-CAT-14. Not a typo and not a
renumbering — the trash was built, designed and documented in the modules
that implement it, and the requirement itself was never written. An orphan
is the gate working: a tag naming an undefined ID would otherwise count as
covered, which is how a matrix comes to report coverage of things nobody
specified.

FR-CAT-15 now says what trash.rs does, in the terms the module already
argues: a soft delete moves the file into `.darkroom-trash/` and the
catalog records that it happened, because a flag alone would not survive
invariant 5.2.4 — the catalog is rebuildable from sources, so a rescan
would find every deleted file still in the library and re-index it. That
is also why the scanner exclusion is part of the requirement rather than
an implementation detail; the folder and the exclusion are one mechanism
and neither works alone. Permanent delete removes the file before the row,
a delete of something already gone counts as success, and the count and
bytes are shown before emptying.

docs/traceability.md is regenerated: 150 requirements, 71 covered, no
orphans.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 14:48:31 +02:00
dtourolleandClaude Opus 5 02b66ddfcf Document trash, collections, thumbnails, and the UI direction
Specs for the work that follows: soft delete via a MOVE that preserves the
remote id, the collection tree and smart collections, the thumbnail store,
and the derived-state folder.

Adds two design documents. ui-refinement.md names the structural gaps
between the v0.1 UI and something that feels like a photo editor.
view-composition.md proposes a view controller for the display layer,
against the 500-line run() that has become one by accretion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 20:36:23 +02:00
dtourolle 82a5e21ec6 Initial workspace: GPU context, compute pass, adaptive Slint shell
Establishes the v0.1 foundations on both platforms:

- dr-types: SourceRef (never a filesystem path — Android SAF has none),
  Format, Availability, Validator with ETag quote normalisation
- dr-gpu: wgpu device, compute pass writing a storage texture, resize
- dr-ui: Slint shell with FR-UI-1 adaptive layout, computed in Rust to
  avoid a binding loop
- docker/android: pinned toolchain, verified producing API 28 ARM binaries

Measured the cost of the temporary CPU readback path (dr-gpu bench):
compute is 0.06-0.28ms across sizes while readback is 0.63-7.43ms, so
readback is 90-96% of frame time and scales with area. Recorded in
ARCH §6.1 — this is why spike S1 is the priority.

Mitigations pending S1: reuse the staging buffer, apply at most one
resize per frame, and cap render resolution at 2048 on the long edge.

10 tests passing; core crates cross-compile for aarch64-linux-android.
2026-08-09 07:42:05 +02:00