cb1d2be240cf7410b7bd96ba9a3fee0c7929431c
9
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
5e4b8de18d |
Stop the mixer's bands from dividing one budget between them
Each band's delta was scaled by the summed weight of the bands that happened
to be adjusted:
d_hue = d_hue / w_total;
d_sat = d_sat / w_total;
The divisor is the wrong quantity, and it fails in two directions.
A band adjusted on its own divides by its own weight and cancels it — w*v/w is
v — so the falloff does nothing. Green saturation at +100 hit a pixel at 179°
exactly as hard as one at 120° and not at all at 181°: full strength across the
whole window, then a cliff. That seam is the thing the overlap exists to
prevent, and it was there the moment a single band was touched.
Worse, the divisor counts a band's weight even for a channel that band says
nothing about, so the controls compete. `red_hue` at +100 shifts a pure red
pixel +30°. Set `orange_sat` as well and the same pixel shifts +20°, while
orange's saturation bleeds into pure red at a third strength. Hue and
saturation on *neighbouring* colours were trading against each other — the two
sliders behaving as though only one of them could be spent.
The real fault is upstream of the division: the falloff window was ±60° while
the bands sit 30° apart, so the twelve weights sum to 2.0 rather than 1.0 and
*something* had to correct for it. Narrowing the window to the band spacing
makes them a partition of unity, and then nothing has to. The division is gone;
`w_total` stays, demoted to what it should always have been — the test for
whether any adjusted band reaches this pixel at all.
Everything the module header claimed is now true rather than aspirational: a
band reaches zero at its neighbours' centres, a hue halfway between two gets
half of each, twelve bands at +100 equals global +100, and a band pushed alone
reaches the full 30° of travel its own comment documents instead of whatever
fraction the other sliders left it.
`overlapping_weights_are_normalised` asserted the broken arithmetic verbatim,
so it is replaced rather than repaired. In its place: no delta may be divided
by w_total, the guard must survive, two adjacent bands must emit independent
terms, and — the property the rest now rests on — the twelve weights must sum
to one, swept at 0.1° around the wheel. That last test carries a Rust mirror of
`band_weight`, so a third test pins the mirror to the shader's own constants;
a copy nothing checks is how the window and the spacing drifted apart in the
first place.
Also gone: the red branch of `rgb_to_hcl` computed its hue twice and threw the
first away.
This changes how existing edits render. Mixer adjustments are more selective,
and where they were quietly cancelling each other they no longer are, so a
saved sidecar will not come back looking the same.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
0a331c717e |
Give the controls a vocabulary, and let a node ask for one
widgets.slint set the rule — screens consume components, and a bare `Theme.*` at a call site means a component is missing — and it set it for chrome only. The controls never got the same treatment, so they were written wherever they were first needed and copied from there. **The slider was private to the develop panel.** `SliderTrack`, with the fifty-line preamble explaining how it wrests a drag away from a Flickable, lived inside adjust.slint and no other screen could reach it. It shows: export quality is a 1-to-100 value, and the settings page offered a free-text box for it, with the range written in a hint and enforced nowhere. `to-float()` answers 0 for anything it cannot parse, so a typo saved a quality of 0 and the page displayed the 0 back as though it had been asked for. The tick-box was written twice, in launch.slint and settings.slint, from the same 18px box and the same handler; the second carried a comment deferring the lift until a third caller appeared. The label-and-hint header was written three times inside settings.slint alone. controls.slint is the input layer beside widgets.slint's chrome layer, and the constraint that makes it reusable is that **nothing in it knows about `ParamRow`** — that struct is the develop panel's flattening of the capability model, and a control that imported it could only ever be used by the develop panel. The primitives take plain numbers; the ParamRow-shaped wrappers stay in the panel that owns the model. 658 lines came out of the three screens. `SliderRow` is the slider-plus-number-box ARCH §4.3 names as the pointer presentation of a bounded scalar, and quality is its first adopter. It commits on gesture end rather than on every movement, because the settings page saves to disk on change and a two-second drag is a couple of hundred writes where a text field committed once. The develop panel keeps the live stream — that is what its pipeline is for — so `SliderTrack` now reports both. **The other half is the descriptor.** FR-DEV-3a and ARCH §4.3a already specify more than was built: an ordered preference list of widgets rather than one, the demands a widget makes, and kinds beyond scalar and bool. - `Presentation.widgets` is now a list, walked by `choose`, falling back to plain sliders. Falling off the end is not an error, and there is a test asserting an operation asking only for an unimplemented widget still yields one control per parameter. - `WidgetDemand` carries what a widget inherently needs — two-dimensional dragging, precise pointing — and no pixels, breakpoints or platform names. - `WidgetKind` grows to the specified set. There is deliberately no `Colour` *kind*: a colour is three numbers, and a value type that is not an `f32` would reach through the graph, the uniform block and the sidecar format to buy what `ColourWheel` over three scalars already describes. Every widget here is a hint over ordinary scalars, which is what keeps the fallback honest. - `ParamKind::Enum` is the one new shape, and it fits because a variant index is exact in binary32. `kind: enum` with a `variants:` list works in `ops/*.yaml`, so a node declaring one gets a segmented control with no UI file edited — which is the promise ops/mod.rs already makes. The panel's dispatch was duplicated: a lone parameter and a grouped one each wrote out their own list of kinds, so `enum` would have had to be added twice and a kind added to one would appear or vanish depending on how many parameters its operation happened to declare. `ParamControl` is now the only such chain. `rows_from` is free-standing rather than a method, which is what lets the FR-DEV-3c acceptance test requirements.md asks for actually be written: an operation the frontend has never heard of, appearing in a generated panel, with no GPU in sight. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7c57f490fe |
Declare a develop operation in YAML, and generate the rest
An operation was, in the overwhelming majority of cases, four facts: what its parameters are, what uniforms they compute, what WGSL those uniforms drive, and where it sits in the chain. Written in Rust those four facts arrived wrapped in ninety lines of trait implementation — a match on parameter id to a struct field, another match back, an is_active comparing each field to its default, a Vec<Uniform> built by hand. All mechanical, and each one a place to make a silent mistake: a param() arm returning the wrong field reads perfectly and breaks the sidecar round-trip. So the four facts are the file now. core/dr-pipeline/ops/<id>.yaml is a node, build.rs compiles it into the same Operation impl as before, and the result lands in OUT_DIR — the same reasoning as style.yaml -> theme.slint, including why it does not land beside the sources it would look exactly like. Nothing downstream can tell a declared node from a hand-written one: same &'static OpDescriptor, same fused-shader composition, same sidecar. Nine nodes moved: exposure, white_balance, contrast, highlights_shadows, blacks_whites, brilliance, vibrance, saturation, and the shared WGSL helper registry. Their prose came with them, and so did their tests — set/expect/expect_active/expect_wgsl in the declaration compile to real #[test]s, so a node file carries its own proof rather than leaving it behind in a file that no longer exists. Two stayed in Rust and say so with `rust:`. The tone curve's neutral is a relationship between five interpolated points rather than a set of values; the colour mixer generates thirty-six faceted parameters from twelve computed hue bands. A schema stretched to cover either would be a worse language than Rust aimed at one caller. They still declare their position here, because the chain's *order* is the one thing a reader comes to this directory to learn, and an order written half in YAML and half in Rust would be worse than either alone. default_chain() is generated from it. Uniforms are derived by a small expression language — exp2(exposure), blacks / 100 * 0.02 — compiled to Rust rather than interpreted, so an unknown name or a wrong arity is a build error naming the file and the key and the arithmetic costs nothing at runtime. The build script refuses a duplicate order, a filename disagreeing with its id, a default outside its own range, a test value the graph would clamp before the node saw it, a helper that does not define the function it names, and a declared node colliding with a file in src/ops. Verified by adding a scratch node and removing it again: one file, no other edit, and it joined the chain at its declared order with its test running. 237 tests pass in dr-pipeline, clippy and fmt clean. .yaml joins the traceability tool's scanned suffixes, because a node's Rust now lives in OUT_DIR where a tag could never be linked from the report. Coverage 47.7% -> 48.3%. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
2ca1716a29 |
Make "Choose folder" an actual folder picker
It previously fetched the folder list and threw it away into a status line — a button that looked like it worked and did not. Now it opens a browsable picker: click a folder to descend, ".." to go back, "Use this folder" to select, "Cancel" to leave the root unchanged. Descends one level per click because that is what the backend supports: Depth: infinity is frequently disabled server-side and prohibitively expensive where it is not (ARCH §8.4). The chosen root persists immediately on confirm, so it survives a crash before the library is opened. Confirming at the account root is allowed — a user may legitimately keep everything at the top level — and cancelling leaves any previous selection untouched, which a test asserts. Verified against nextcloud.tourolle.paris at both depths: 30 folders at the root, 21 year-folders inside PhotosRaw. 19 launch tests, 38 in dr-ui. |
||
|
|
f630a3ff81 |
Wire the launch screen into the app
The app now opens on the login screen when there is nothing else to show
— no local paths and no configured library — and goes straight to the
images otherwise. Making someone click past a login they already
completed is pure friction.
launch.slint imported by app.slint, replacing the window rather
than overlaying it: there is no library to look at
until an account is configured
launch_ui.rs the Slint wiring, kept out of lib.rs so the launch
flow can change without touching the develop window
Login runs on a worker thread and posts results back through a channel,
since Slint's event loop is single-threaded and a 20-minute browser wait
cannot block it. The system browser is opened via xdg-open, never an
embedded webview (FR-NC-1).
Sign-out deletes the local credential even if server-side revocation
fails: a network error must not leave a usable secret on the machine.
Format tick-boxes persist on each toggle, so a selection survives a
crash before the library is opened.
Two things deliberately incomplete rather than faked:
- "Choose folder" lists the account's folders and reports them, but
there is no picker widget yet, so selection still happens via the
connect example.
- "Open library" logs the request. Opening a remote library needs the
scan-and-cache path, which belongs with the catalog work in flight.
Earlier I broke the other in-flight dr-ui work by calling
slint_build::compile twice, which replaces the generated module. The
correct wiring is an import inside app.slint, which is what this does.
30 dr-ui tests passing; both launch paths verified by running the app.
|
||
|
|
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.
|
||
|
|
c8bb08e661 |
Add folder scan with format selection; validate A3 on a real library
Library setup as the user described it: pick a folder, choose which RAW
types to look for, scan recursively.
dr-types::FormatFilter the tick-box selection, seeing through VFS
placeholder suffixes so a dehydrated CR2 still
matches as a CR2
dr-sync::scan recursive walk, Depth:1 per directory, pruning
unchanged subtrees where the backend propagates
directory ETags
Verified against nextcloud.tourolle.paris (34.0.2) on a real library:
browse root 32 entries, 98ms
scan PhotosRaw 17,185 RAW files in 334 directories, 34.1s
(7,836 CR2 + 9,349 DNG)
range read 262KB of a 21.5MB DNG in 119ms — 1.22% of the file,
and enough to read "Canon EOS 6D | ISO 100"
That last line is assumption A3 validated on real data. Cataloguing this
library by whole-file fetch would move roughly 370GB; the range path
moves a few MB.
Pruning is capability-gated rather than assumed: with per-entry ETags a
probe costs a request and proves nothing about children, so it is skipped
entirely. A test asserts zero probes in that case.
Still unresolved: /core/preview returns 400 for every parameter
combination tried, including on a JPEG the server reports as having a
preview. Not a request-shape bug — it fails identically bare. Recorded
rather than worked around; ARCH §6.7 already treats server previews as
opportunistic, so nothing depends on it.
|
||
|
|
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> |