Commit Graph
12 Commits
Author SHA1 Message Date
dtourolleandClaude Opus 5 a1165ef182 Put the coordinate-domain lens corrections into the graph
`lens.rs` has held a `Warp` trait, a composer and two implementations —
distortion and lateral chromatic aberration — since they were written, and
`compose_warps` was called by nothing outside its own tests. The corrections
existed, were correct, and never touched a photograph.

`EditGraph` now holds them, and `compose_full` emits them between the framing
prologue and the fetch. Distortion first, then CA: each warp receives the
position the previous one produced, and lateral CA is a magnification about
the optical axis of the *undistorted* frame, so measured on a barrel-distorted
one it would be fitted to a radius no profile describes.

They reach the panel the way framing already does — through `capabilities`.
That was the one open question and existing practice answered it: framing is
also not an `Operation`, also has parameters a photographer sets, and also
arrives through that list. Because `Preset::capture` walks the same list, the
sidecar, the clipboard and the undo stack carry a warp's parameters with
nothing registered anywhere, and no file under `ui/` names one (FR-DEV-3a).

`state()` destructures `EditGraph` field by field precisely so that a new
field cannot be forgotten, and it was not.

Chromatic aberration is the only thing that samples per channel, and
`splits_channels` is what keeps everything else from paying for it. Red and
blue are fetched from positions green is not — green is the reference and
never moves, so a wrong correction still leaves one channel sharp rather than
softening all three. With no CA in the chain the single-fetch path is emitted
instead.

The interpolating sampler is now chosen by framing *or* an active warp. Asking
framing alone would have nearest-neighboured a distortion correction on an
unstraightened frame, and that aliasing reads as a bad profile rather than as
a missing filter.

The warps go in the geometry invalidation key rather than the colour one: they
decide which source pixel a colour is read from, so a tile cached across a
distortion change would keep drawing the previous correction. The pipeline
cache needs nothing new — `hash_source` already covers the generated body, and
uniform values never enter it, so arming a warp recompiles and dragging it
does not. Both are asserted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 15:03:16 +02:00
dtourolleandClaude Opus 5 e17b909d41 Connect the lens vignetting correction to the pipeline
`ops/vignetting.rs` has carried a complete descriptor, polynomial, helper
and test suite without an entry in `ops/`, so it was never in `chain()`.
It reached no photograph and no panel, and `Attribute::Optics` was an empty
category in consequence — filtered out of the tab strip for having no rows,
by a chain that had never been given its only member.

Declaring it needs the one thing the operation was written against and
which did not exist. `wgsl_body` reads `radius`, and the module claimed
"the composer publishes `radius` in the shader prologue for exactly this
reason". It did not. `sample_source` now does, in both sampling branches,
beside the `source_px` it already published for the same class of caller.

It is corner-normalised there, which is the part that is easy to leave out.
`p` spans ±0.5·aspect, so its length at the corner is 0.5·length(aspect) —
about 0.901 on a 3:2 frame, not 1. Lensfun's polynomials are fitted against
a corner radius of 1, so passing `length(p)` straight in evaluates every one
of them short of where it was measured, by a factor that changes with the
aspect ratio. It would have read as a correction that is simply too weak,
which is indistinguishable from a bad profile. Both `lens.rs` and
`framing.rs` asserted the normalisation `p` does not have; corrected.

`order: 5` puts the correction ahead of the tonal stages, and the ordering
is load-bearing rather than tidy. Recovering a corner means dividing by an
attenuation below one — about two stops for a fast prime wide open — so run
after the highlights have been rolled off and clipped, the lift has nowhere
to go and the corners posterise instead of brightening.

`layer_chain` now drops `Optics` as well as the neighbourhood operations.
A local vignetting slider would have worked, which is what makes it worth
excluding: `radius` measures from the centre of the whole photograph and a
mask cannot move the optical axis, so it would lay a frame-centred radial
ramp across the picture and multiply it by the mask. The existing exclusion
covers operations that move and do nothing; this one covers an operation
that moves and does something its name does not promise. The rule both
share is now written down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 14:34:24 +02:00
dtourolleandClaude Opus 5 8e7b1350bf Name the frame's category for the decision, not the maths
`Attribute::Geometry` becomes `Attribute::Compose`, and `film_sim` moves
from `[tone, colour]` to `[effect]`.

Two categories were doing the wrong job. "Geometry" describes what crop,
straighten and the quarter turns do to coordinates — but it describes lens
distortion correction exactly as well, and that is not a compositional
choice at all. Naming the attribute for the photographer's decision is what
separates it from `Optics`: one is what the lens did, the other is what
they chose. The maths the two have in common is not the thing worth
filing them under.

A film stock declared both `tone` and `colour`, so "Kodachrome" appeared in
the Light group beside exposure and again in Colour beside white balance —
two places, neither of which is where anyone looks for it. It is neither:
`Effect` is defined in this same file as "applied rather than corrected — a
look, not a fix", which is what a stock is. That it moves tone and colour
is true of every look, and is not what the attribute is for.

`from_name` still accepts "geometry" on the way in. That string is
persisted in `develop.copy_attributes`, and an entry it fails to parse is
not an error — `presets::scope_for` logs it and drops it — so without the
alias an existing settings file would have quietly narrowed what a paste
carries. `name` writes the current spelling, so the file migrates itself
the first time it is saved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 14:34:03 +02:00
dtourolleandClaude Opus 5 4efab496c2 Put the refinement on a slider, per layer
Benchmarks / CPU and I/O (per commit) (push) Successful in 3m27s
Benchmarks / Frame budget (on demand) (push) Skipped
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Build and test / Desktop (Linux) (push) Failing after 1h14m30s
Build and test / Layer separation (push) Successful in 43s
Traceability / Requirement traces (push) Failing after 46s
Build and test / Android (aarch64) (push) Successful in 1h8m56s
The evidence was being gathered and spent immediately at one strictness
nobody could see or change. This makes it a control.

`MaskLayer::refine` is shaping, like the feather, and it lives on the layer
for the layer's reason: two layers may sit on the same category and want
different amounts of it, and the model ran once for both.

## What the segmentation now stores

`CategorySummary` keeps the **coarse** mask and the `Refinement` beside it,
rather than a refined mask. That is what gives the control an off position
that is bit-for-bit the model's own weighting, and what stops a strictness
change from needing the model again.

`category_mask_at` borrows at zero and wherever no refinement could be
fitted, so a layer nobody has touched costs nothing over the old path.

The fit moved to the far side of the orientation permutation. The verdict is
a per-pixel field over the same grid as the mask it gates, so fitting it
upright would mean permuting a proxy-sized buffer afterwards to match — a
second rotation, and a second chance to get one wrong. One logit cell is the
same number of pixels either way: the letterbox scales by the longer edge and
a permutation does not change which edge that is.

The two frames became a `Frames` struct rather than six parameters. This
function reads the picture twice for opposite purposes — the model needs it
upright or it recognises far less, the refinement needs the sensor's grid —
and a transposed pair produces a plausible mask over slightly the wrong
pixels, which is the failure this module is most prone to.

## It rebuilds the field, and the signature says so

`refine` is mixed into `subject_signature`. Unlike a feather, which is read
off a field that is already correct, this changes which pixels are in the
mask at all — so it changes the coverage the field is measured from. Omitting
it is the bug where the slider moves and nothing happens until some unrelated
control invalidates the cache.

That puts it in the same cost class as a close or an open, which is why the
row takes `SliderRow::changed` — already once-per-gesture, since that row
takes `SliderTrack`'s `committed` internally — rather than a live stream.

The slider is offered only where there is something to move: a category
source, *and* a refinement the frame actually gave enough to fit. A control
that moves and does nothing is worse than an absent one.

## Two defaults that are deliberately different

A layer added from the panel starts at 4.0, because a category's edges are
twenty proxy pixels wide before anything is done to them and a photographer
adding a sky mask wants the sky rather than the sky plus every chimney in it.

A layer read from a sidecar with no `refine` key starts at **zero**. A file
written before this control existed has to render as it did then, and a
default of 4 on absence would quietly re-grade every stored category mask in
the catalogue. `a_categorys_refine_strictness_survives_and_defaults_off`
holds both halves, and `an_out_of_range_refine_is_clamped` holds the file to
the scale — past the top of it every colour fails and the mask deletes
itself, which reads as lost work rather than as a bad file.

`MAX_REFINE` is dr-pipeline's own constant mirroring
`dr_segment::STRICTNESS_MAX`, following `Falloff` and `Morphology`: this
crate holds the description of an edit and must not depend on the crate that
runs a model. dr-ui is where the two meet, and the only place that converts.

Verified: fmt clean, clippy --workspace -D warnings clean, 488 dr-pipeline
and 60 dr-segment tests.

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

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

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

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

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

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

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

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

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

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

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

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

Six tests, including that a nonsense index falls back to showing everything
rather than to showing nothing.
2026-08-22 10:22:47 +02:00
dtourolleandClaude Opus 5 9b2ee0d0eb Show the colour mixer as three runs of twelve, each row a colour
Build and test / Desktop (Linux) (push) Successful in 17m20s
Build and test / Layer separation (push) Successful in 33s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 2s
Traceability / Requirement traces (push) Failing after 26s
Build and test / Android (aarch64) (push) Failing after 8m59s
The mixer was thirty-six sliders reading "Hue / Sat / Lum" twelve times
over with nothing saying which band any row belonged to. The identity was
there all along — the descriptor declares param.mixer.orange.sat and BANDS
carries orange at 30° — and was discarded on the way out: labels.rs had no
mixer entries, so every key fell through to a derived label that yields the
bare channel name.

A parameter can now say which aspect it adjusts and which subject it
adjusts it on, with the subject's hue where the subject is a colour
(descriptor::Facet). That is data about what the operation does, not a
layout: the mixer genuinely weights pixels around 30°. What to draw from
30°, and in what order to stack the runs, stay in dr-ui (ARCH §4.3a) —
develop.rs brings rows sharing an aspect together and marks the first of
each, and adjust.slint names the run once and draws a swatch, a track and a
readout on one line.

Grouped by channel rather than by band because an edit is almost never
"everything about orange"; it is the saturation of the greens, made by
comparing one channel across neighbouring bands. Twelve band sections put
those twelve rows in twelve different places.

The swatch is the label, which is what makes twelve rows fit where four
did. The band name is not lost: it is the row's accessible label, so the
control is not colour-only, and labels.rs is where the mapping is written
down — including chartreuse as "Yellow-Green" and spring as "Blue-Green",
since nobody hunting foliage scans a list for "Spring".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 16:14:13 +02:00
dtourolle 09e3043f4c Add secure credential storage, sessions, and a launch screen
Login now persists properly rather than through the JSON file the test
harness was using.

  dr-plat            SecretStore trait plus a Secret Service backend.
                     Verified against the live GNOME Keyring: store,
                     retrieve, delete, confirm-gone all round-trip.
  Session/SessionStore   splits credentials from settings — the app
                     password goes to the keyring (FR-NC-2), while
                     server, login, chosen root and format selection are
                     ordinary config. A test asserts the credential never
                     appears in the config file.
  LaunchModel        the launch-screen state machine, testable without a
                     display server: sign in, approve in browser, choose
                     folder, tick formats, sign out.
  launch.slint       the screen itself, in its own file.

Absence of a secrets daemon is an explicit degraded mode, not a silent
fallback to plaintext — the screen says sign-in will not persist rather
than letting the user find out next launch. Android's Keystore backend
fails loudly for the same reason: a no-op store would look like it
worked and then lose the credential.

Two bugs caught by tests rather than by running it:

  - fail() after busy() signed the user out, because busy() had already
    discarded the session. A failed *scan* would have logged you out.
    Busy now carries the session.
  - normalise_server upgrades http:// to https:// rather than accepting
    it. NFR-SEC-3 requires TLS, and silently sending a credential in the
    clear is not a decision to make on the user's behalf.

launch.slint is not yet wired into app.slint. Calling slint_build::compile
twice replaces the generated module rather than adding to it, which broke
the other in-flight work on dr-ui; I reverted that immediately. Wiring it
needs an import inside app.slint, which is that work's file to change.

419 tests passing across ten crates.
2026-08-09 15:20:39 +02:00
dtourolleandClaude Opus 5 78e3e6b846 Add the develop pipeline: demosaic and seven raw adjustments
Decode through display, on the GPU: black/white normalisation, Bayer
demosaic, camera colour transform, and the first seven adjustment
operations — white balance, exposure, highlights/shadows, blacks/whites,
brilliance, vibrance, saturation.

Composable shaders. Each operation contributes a WGSL fragment rather
than owning a pass, and dr-pipeline fuses the *active* ones into a single
compute shader. One texture read and one write per frame regardless of
how many adjustments are in play, while the operations stay independent
in Rust — adding one is a new file, with no central shader to edit. An
operation at neutral settings contributes no code, no uniform and no
branch. Uniforms are prefixed per operation so two may both declare
`amount`; helpers dedupe by name from a single source of truth.

Pipelines cache on a structure hash covering the op-set and its order but
not the values, so dragging a slider uploads uniforms and reuses the
compiled pipeline. Measured on a 24 MP CR2: 0.60 ms re-render, one
pipeline compiled across ten slider positions.

The UI is generated, not written. EditGraph::capabilities() reports
parameters with their kinds, ranges, defaults and current values; the
panel builds one control per entry chosen by ParamKind. No file in ui/
names an operation, and dr-pipeline has no wgpu dependency, so codegen is
testable without a device (ARCH §6.5a).

Three defects found against real files, each silent:

- rawler 0.7.2's `xyz_to_cam` is all zeros — deprecated and no longer
  populated. The live matrices are in `color_matrix`, keyed by
  illuminant. Reading the old field yields no colour transform at all.
- `cam_to_xyz_normalized()` returns all NaN on any Bayer sensor: it
  divides each of four rows by its own sum, and the unused fourth
  (emerald) row sums to zero. Inverting the 3x3 ourselves avoids it.
  `wb_coeffs[3]` is NaN for the same reason and is normalised at decode.
- As-shot white balance reached the uniform block but no shader read it,
  so the first render of a real CR2 came out violently green. Green
  photosites collect roughly twice the signal of red and blue. Now
  applied unconditionally before any operation, with tests on ordering.

Demosaic is Malvar-He-Cutler rather than bilinear: gradient-corrected
interpolation at one 5x5 neighbourhood per pixel, where bilinear leaves
visible zippering on any high-contrast edge at 1:1. Two of the four
packed CFA constants were wrong on the first attempt, so all four layouts
are asserted to reconstruct the same colour. Crop origins at odd
coordinates re-phase the pattern; without that, red and blue swap.

X-Trans reports GpuError::UnsupportedCfa rather than approximating with
the Bayer path, which would look like a corrupt file.

206 tests, including GPU tests proving every operation and the full
seven-operation chain generate compilable WGSL.

Known gaps: the display path still reads back to the CPU each frame,
which ARCH §6.1 forbids and AC-8 asserts against — it is gated behind the
`readback` feature and waits on spike S1 wiring Slint's texture import.
Curve shapes are a first draft and want tuning against real photographs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 11:37:58 +02:00