b1d1c47261936ffb657f213e7b647df33a095921
7
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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". |
||
|
|
c4ddcbe0f7 |
Look the lens up and say plainly whether one was found
`dr-lens` has held a complete Lensfun lookup — distortion, TCA and vignetting coefficients from a lens name, a focal length and an aperture — with no dependents anywhere in the workspace. The three corrections it feeds now exist in the graph, so this connects the two and finishes the chain. The coefficient structs stay duplicated. `dr-pipeline` is organised around having no dependencies so its codegen is testable without a device or a database (ARCH §6.5a), and `dr-lens` carries an XML parser and 5.5 MB of profile data. Neither crate can convert to the other, so the conversion goes above both, in `develop.rs`, which is the only place that sees them together. Both traits grow the same defaulted door. The optical corrections do not sit on the same side of the fetch — distortion and CA rewrite coordinates and are `Warp`s, vignetting applies a gain to the pixel already there and is an ordinary node — and fanning a profile out by which trait each happens to implement would make the caller reason about that distinction. Each correction takes its own share of the whole profile instead, and `set_lens_profile` walks both lists identically. The lookup happens in `set_source_metadata` rather than in its caller, because that is the one place a session is told which file it came from. Doing it there makes it unforgettable, in the shape `FilmRebake` already uses for the other derived thing — and, more to the point, makes *clearing* unforgettable: a session that opened a second photograph while still holding the first one's profile would correct it for the wrong optics, invisibly, in a way that looks exactly like the lens. It needs the whole shot and not just a name. Distortion is interpolated across a zoom's focal range and vignetting depends strongly on aperture — a fast prime can be two stops down in the corners wide open and clean by f/8 — so a lookup missing either returns coefficients measured for a shot nobody took. Missing any of the three refuses rather than guesses. A profile is derived, not persisted: it comes from the file's EXIF and a database, so it is not a parameter, not in the sidecar and not undoable. What is an edit is the manual trim beside it, which each correction composes with the measurement — so a photographer can lean on it, override it, or work without one. `InfoPanel` gains a lens line, and it distinguishes three cases rather than two. `dr-lens` states the rule it exists for: an automatic correction that silently did nothing is worse than one the user can see is unavailable. A session with no header draws nothing, a header naming no lens reads "Lens not recorded", and a lens the database has never heard of reads "· no profile". Collapsing the last two would send somebody hunting for a profile that was never missing — which, for third-party and adapted glass, is the ordinary case. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
0c3b8cb1c4 |
Hand out descriptors a declaration could produce
`Operation::descriptor()` returned `&'static OpDescriptor`, and that lifetime
is the whole reason a build-time node is free and a run-time node is
impossible: only a compile-time literal can satisfy it, so no amount of
reading `ops/*.yaml` at startup could ever produce a descriptor the rest of
the application would accept. FR-PLG-2 says a bundled operation and a
third-party plugin are the same kind of thing, differing only in where the
file was found — and a lifetime outsiders cannot meet is exactly the second,
weaker format that requirement forbids.
So a descriptor is now owned and handed out as `Arc<OpDescriptor>`, with `Vec`
where it held `&'static` slices. `Arc` rather than a `&self`-borrowed
reference because the callers want to *keep* it: the develop panel collects
descriptors and then mutates the graph, and a borrow would tie the
descriptor's lifetime to a borrow of the operation it came from, which is the
one thing `&'static` was doing right.
The identifier newtypes deliberately did not follow. `ParamId` is `Copy`, is
compared in `match` arms against generated constants, is a map key in the
sidecar and history, and reaches Slint model rows; an `Arc<str>` there would
cost a refcount on every one of those and would take `match id { EXPOSURE =>
.. }` away from the generated code. They gain an interner instead, which is
honest about its lifetime rather than pretending to one — the set of ids is
bounded by deduplication and is process-lifetime by construction, because the
sidecar on disk names its parameters and an id has to stay resolvable for as
long as any edit naming it can be opened.
No behaviour changes. Every descriptor that was a `static` is a `LazyLock`
initialiser now, `Operation::helpers` borrows from `self` instead of being
`'static` so a future run-time node can own its list, and `Warp` and `Framing`
follow `Operation` so there is one shape rather than two.
The one place a descriptor is read per frame is `compose_full`, which takes
`descriptor().id` to prefix each active operation's uniforms, and `dr-ui`
composes on every frame it draws. That is a dozen atomic increments beside a
composition that is already building several kilobytes of WGSL on the same
call; it is noted at the trait method rather than left for a profiler to find.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
1d7106c94d |
Apply the masks to the thumbnail and the export, not only the screen
Reported as a thumbnail bug; the export had it too, which is the serious half. You would have exported a photograph missing every local adjustment. Both called the unmasked `render`, and the failure is silent by construction: the generated shader always declares the mask binding and always emits a block per active layer, so binding the empty placeholder multiplies each of them by zero. No error, no warning, no missing texture — the adjustments are simply not there. From inside either path there is nothing to see. Every path that produces pixels now goes through one helper that binds the array, and that is the point of it being one helper rather than three correct call sites. The array is rasterised in source space at proxy size and sampled through the framing map, so one array serves every output size: a 256px thumbnail and a 24 MP export bind the same texture. Three tests, and the first is the fault stated directly — render the same edit with and without the array and assert they *differ*. If binding it ever stops mattering, the masks have stopped reaching the shader. The third checks the masked share of the frame is the same at 32px and 128px, because "both non-empty" would pass while a mask that scaled wrongly still ruined every thumbnail. |
||
|
|
7421837c8a |
Let an operation say what it is about, so the panel can group without naming
Tool tabs need a taxonomy, and the taxonomy was the problem: a table in `ui/` mapping operation to tab breaks FR-DEV-3a, and a `group:` field risks what `ui-refinement.md` condemned `starts-group` for — the core deciding where the panel draws things. `Attribute` threads the needle. It says what an operation *is* — tone, colour, detail, optics, geometry, effect — which is the same category as `ParamKind` and squarely on the core's side of ARCH §4.3a's line. What is drawn, where it sits and whether it is visible stay the frontend's. There is no attribute for "the third tab", the enum's order is declaration order rather than screen order, and a frontend may render these as tabs, as headings, or ignore them. The payoff is that a tab strip can be *derived*: the groups are the attributes present in the capability list, so the interface names no operation and needs no table to keep in step. An operation joins the right group by declaring what it is, which is the one thing its author is well placed to say. Plural, because the tone curve is genuinely both — an RGB curve is tonal and the per-channel curves are chromatic, and filing it under one would hide it from half the people looking for it. Required and non-empty, enforced in `build.rs`, and the failure was checked by removing the line rather than assumed. An operation with no attribute is invisible to a panel that groups by them; a build that stops costs ten seconds, a control nobody can find costs more. The vocabulary is closed for the same reason: a typo would otherwise invent a category holding exactly one operation, which looks like a deliberate one until somebody counts. Six tests over the real chain, including the hand-written operations that `build.rs` never sees and so cannot check. |
||
|
|
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.
|