0cd3ef1b3f6469d2fba1ac59e2996475e7e05d2b
84
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c396a22dfd |
Paint a mask without ever rasterising one on the CPU
The last line of FR-DEV-3, and the mask ARCH §5.4 was written for. darktable rasterises drawn masks on the CPU and users call the result unworkable; the architecture's answer is that a stroke arrives as *parameters* and the device draws it. This is that, from the model through the sidecar to the pixels — but not the finger: the canvas is somebody else's change, and this leaves it a seam rather than reaching into it. **A stroke is a swept disc along a polyline**, plus erase, radius, hardness and flow. `MaskSource::Brush` holds an ordered list of them, and the order is the mask: an erase after an add takes it away and the same pair reversed does not. Nothing about it is pixels, which is what makes a mask that costs a line of text, diffs by the gesture, and survives a crop, a straighten and an export at any size — the properties a stored raster has none of, and the same argument the region ids were chosen for. Two things keep the point count honest. While the finger is down, a position closer to the last than an eighth of the radius is dropped: a touch screen reports 120 a second, so a finger held still for five seconds is six hundred points in the same place, and simplification would only remove them once the gesture had ended — after every frame in between had drawn all of them. When it ends, Douglas–Peucker at an eighth of the radius removes what a disc that wide cannot express: a swept circle moved by r/8 moves its own edge by r/8, which is inside the soft part of any brush. Coordinates snap to a ten-thousandth of the frame on the way in *and* are written at that precision, so a round trip is exact rather than nearly exact — a file that drifts in the sixth decimal every save is a per-field merge conflict a day, over nothing. **Cost is why the strokes are not drawn by the full-screen triangle the other masks use.** A swept disc is the minimum distance to any of its segments, so a stroke over the whole frame costs `pixels × segments` and both terms grow together — the quadratic that is darktable's problem moved onto the GPU rather than solved. Each stroke is instead drawn over its own bounding box, grown by the radius, so the rasteriser never invokes the shader for a pixel the stroke cannot reach: `area(box) × segments`, which for a dab or a swipe is a small fraction of the frame. A gesture past 256 points continues as a second stroke for the same reason, since a shorter stroke has a smaller box. Add and erase are `dst + a(1 - dst)` and `dst(1 - a)`, which are exactly a source-over and a one-minus-source blend — so they are blend state, not arithmetic, and no pass ever reads the slice it is writing. That is what permits one draw per stroke at all. Within a stroke the coverage is the *minimum* distance over its segments rather than a sum: a path that crosses itself must not build up where it did, or every circle and every scribble would be blotchy wherever consecutive dabs overlap, which is everywhere. Not a distance field, deliberately. `dr-segment`'s transform documents the two conditions that make CPU work right there — once per mask edit, over input already CPU-side — and a stroke fails both: it changes while the finger moves, and its input is a handful of coordinates that never needed to be pixels. It also needs no transform, because the distance to a swept disc is closed form. A stroke is the one mask whose distance field is known without computing one. An unpainted brush layer is inactive rather than empty, which is not an optimisation: `invert` turns empty into everything, so a layer created with invert already set would apply its adjustment to the whole photograph before a single stroke was made. That is the loud, confident kind of wrong this codebase refuses everywhere else a mask can go missing, and there is a rendered test for it. The tests read pixels back off a device rather than checking that the two halves agree with each other. What they pin down is what is silent when wrong: the y flip between mask space and clip space, which a centred stroke would not notice; a bounding box not grown by the radius, which makes a tap draw nothing at all; an aspect ratio ignored, which makes a dab an ellipse on any frame that is not square; a stroke doubling back and building up; and an erase that lost its place in the order and put back paint the user had taken off. Not done here: the interaction. The canvas needs to begin, extend and end a stroke on the active layer, and `DevelopSession::rasterise_masks` still returns early without a segmentation — it takes the proxy size from one, and a brush needs no model to have run over the photograph first. Co-Authored-By: Claude Opus 5 (1M context) <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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
ee10097435 |
Mask the subject the model found, not the regions underneath it
The watershed hierarchy does not survive a photograph, so local masking stops depending on it. A layer can now be one recognised object, and the object's own coverage is the mask. `Options::watershed` defaults off. It costs ~80 ms plus a full-resolution readback to produce a ladder that collapses, and paying that on every photograph buys a control that misleads. Kept switchable rather than deleted: the passes and the hierarchy are correct in themselves and it is the merge criterion that fails, which is a change to one function. Masks now rasterise in **source** space at proxy resolution and are sampled by the composed shader after the framing map. That fixes a real bug: they were rasterised in output space, so zooming slid the photograph underneath a mask that stayed pinned to the viewport, and cropping moved every adjustment to a different part of the picture. Doing it this way also leaves the framing map in exactly one place — a second copy in the mask shader would have been a second thing to keep in step, failing only when straightened. A subject is stored as identity, not pixels: the mask is megabytes and is reproducible by running the same model over the same image, so the sidecar carries the index, the class and the score, and the session carries the pixels. The class is there to be checked — if instance 3 comes back a "car" where it was a "dog", something changed and the layer is stale rather than silently masking the wrong thing. The overlay now draws instances and is transparent everywhere else. The region version covered every pixel and so hid the photograph it was drawn over; the question it exists to answer is whether an outline follows the subject, which you can only answer by seeing both. `examples/local.rs` is the worked example: subject in colour with the rest monochrome, and the subject lifted out of its background. Run on a 5472x3648 CR2 it finds two people and two cars, and the colour-pop keeps her hat and hair while the wall and grass behind go grey. |
||
|
|
b1433ad4a9 |
Give a mask an edge treatment, and find out the watershed has none worth having
Two things, and the second is why the first matters more than expected. Mask layers gain a feather, a falloff curve and a morphology, all defined against a signed distance from the boundary rather than as separate features — one exact distance field answers "how soft" and "how far" at once, so dilation is a threshold at -r, erosion one at +r, and closing and opening are one of each in sequence. The compound pair costs a second distance field, which is why they are named rather than presented as a radius that happens to be signed. Types, defaults and sidecar round-trip only; the field itself is next. `edge-feather` and `edge-falloff`, not `feather` and `falloff`, because a radial mask already writes `feather` for the fraction of its radius it ramps over. Same word, different quantity, different units — sharing the key would have made an existing file ambiguous. The diagnostic that provoked this is committed as an ignored test, because "does the ladder land on things a person means" is the question S15 exists to answer and it should not depend on whoever still has the script. On bus.jpg it answers badly: 35,075 regions at blur 2 over an 810x1080 frame, and cutting that to 400 gives *one* region covering nearly the whole picture plus 399 noise specks. Not over-segmentation — collapse. Almost every saddle is near zero, so the merge order joins everything meaningful before it joins anything spurious, and a global cut spends its entire budget on grain. So the granularity ladder does not currently work on a photograph, and the region masks built on it inherit that. Recorded rather than worked around: the next commits move local masking onto the model's instances, where the edge treatment above is what makes a quarter-resolution mask usable. |
||
|
|
5ecb35864f |
Put the region map behind the sliders that were already there
A mask layer holds a real develop chain, so the develop panel can edit one with no new controls: select a layer and the same sliders read and write its chain instead of the graph's. An operation declared in `ops/` tomorrow becomes locally adjustable by existing, which is the payoff for making a layer a chain rather than a handful of special-cased parameters. `segmentation.rs` joins the two arms into the one thing the view needs. The model reads the image through a neutral graph rather than the edited one, so a segmentation survives an exposure change instead of being invalidated by every slider. Arm B failing is not fatal: a missing or unreadable model leaves a working watershed map, because refusing to segment at all would trade a working feature for a strict one. The overlay colours groups by a golden-angle walk over hue. Deterministic rather than random, so a region keeps its colour across a level change and the eye can track it; boundaries drawn black over the fill, because two adjacent groups landing on near hues read as one region and telling them apart is the whole reason to look at it. Clicking the photograph creates the layer if none is selected — that is how a local adjustment begins, and making the user press "add layer" first would be a step with no decision in it. Shift-click extends, and clicking a region already selected removes it, so one gesture both adds and corrects. `segment-readback` is a new dr-gpu feature and not a loosening of `readback`. The region-graph transfer is once per image on a worker; the one AC-8 forbids is per frame in the render loop. Sharing a switch would have forced a build wanting local masking to unlock the other. F3 still stands and the feature name says so. |
||
|
|
94cfea4748 |
Keep the mask when the app closes, and when two devices disagree
The sidecar is authoritative — the catalog is a disposable index and the RAW is never written — so a mask that does not round-trip is not a persistence bug, it is lost work. Layers get their own `[mask <version> <id>]` blocks rather than being flattened into dotted keys. A layer is not a scalar: it carries a selection, a geometry and a chain of its own, and encoding a region set as `m1.region.0 = 12` would be neither readable nor mergeable. The version uuid is repeated in the header instead of relying on the block following its version, because "belongs to whichever version appeared above me" is a relationship that hand-editing, merging and older builds each break quietly. Region ids sort and deduplicate on read rather than being trusted from the file. The mask's identity is the *set*, so two devices writing the same selection in different orders must produce the same mask rather than argue about a difference that is not one. Masks merge by layer id under FR-NC-9, which is the disjoint-survives rule the parameters already follow one level up: a layer added on the phone and one added on the desktop both survive. A layer *both* sides edited resolves wholesale to the higher revision, because half of one selection plus half of another's opacity is a layer neither person made. A remote deletion is honoured, or a mask the user removed returns on every sync. An unknown mask source is skipped rather than guessed at. Applying a newer format's mask type as the nearest one this build knows would put a confidently wrong adjustment on the photograph, which is worse than applying none. 29 new tests. The interesting ones are about silence: a maskless version clearing the previous image's layers, a bare selection persisting even though it renders nothing, and a mask naming a version that is not in the file being dropped instead of landing on whichever block was open. |
||
|
|
c6a846a1f9 |
Brighten her face without touching the sky behind her
A mask layer is an ordinary develop chain plus a rule about where it applies. Nothing in the chain knows it is being masked, so every operation that works globally now works locally and a newly declared op in `ops/` arrives with local support already done. The composer emits each layer after the global chain and before the conversion out of camera space, which is what a photographer means by "and *then* lift the shadows on her face". Op fragments write to a `c` they expect to own, so a layer block shadows it and copies the result back out through a carrier — assigning the outer one from inside is impossible precisely because it is shadowed. The fused dispatch survives: three global adjustments and two masked ones remain one shader, one read, one write. Masks rasterise on the GPU and never exist in CPU memory (ARCH §5.4). That is the whole reason darktable's brush masks lag, and it is architectural rather than tuning, so it is not a thing to inherit and fix later. The rasteriser is a render pass rather than the compute shader it obviously wants to be, and the format is why: R8Unorm is not a core storage format, so a compute path has to widen masks to four bytes per pixel — 768 MB across eight layers of a 24 MP export, against 192 MB at one byte. A colour attachment takes R8Unorm happily. The array slice comes from the attached view, so no slot uniform exists to disagree with where the pass writes. Region masks index a compacted label field rather than the watershed's raw basin roots, because a root is a sparse index into pixel space and indexing a per-region array by one would need a table the size of the image. Changing a selection then costs a few kilobytes, not a re-upload. Stored as region ids, not as pixels: diffable, mergeable per-field under FR-NC-9, and cheap in a sidecar. The ids only mean anything alongside the segmentation that produced them, so each layer carries that signature and is treated as stale rather than applied when it does not match — a confidently wrong mask being much worse than an absent one. Seven device tests render actual frames and read them back. The unit tests either side check halves that would both pass if the two agreed with each other and were both wrong; a mask sampled with x and y swapped satisfies them and fails these. |
||
|
|
ca833b6d2b |
Give every colour band its own compiled shader
🐳 Android image / Build and push (push) Successful in 5s
Build and test / android-image (push) Successful in 5s
Build and test / Desktop (Linux) (push) Failing after 57m33s
Build and test / Layer separation (push) Successful in 35s
Traceability / Requirement traces (push) Failing after 35s
Build and test / Android (aarch64) (push) Failing after 9m42s
The colour mixer emits a code block and a uniform only for the bands that are set, so which bands are adjusted is part of the shader's structure. The pipeline cache key was not: it hashed the set of *active operations*, which is "colour_mixer" whichever band that is. So a red adjustment and a blue one hashed alike. The second render was handed the first's compiled pipeline while its uniform was uploaded into a slot that shader had assigned to another band — whichever band compiled first kept acting on every subsequent move, and every other slider did nothing at all. Red is the first band declared, and the one reported as the only one working. The hash is now taken over the generated WGSL, because the source is what gets compiled and therefore is the structure. A summary of what went into it has to be kept in step with every operation's code generation by hand, and this one had fallen out of step. Values still do not enter it: no operation writes a parameter value into its source, so a slider drag regenerates identical text and reuses the pipeline, and one that did inline a value would have to recompile to be correct anyway. `each_colour_band_gets_its_own_pipeline` in dr-gpu renders a blue pixel through one pass with red set first and then blue, and fails on the old hash with the reported symptom — the blue slider returning the pixel unchanged to the byte. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f76e024f41 |
Straighten a portrait frame in the frame the user is looking at
The framing prologue built the centred position `p` by scaling with the source's aspect, then straightened, then permuted the quarter turns. On a landscape frame those are one space and it worked. Once a turn has swapped the axes — the rotate button, or a file whose EXIF tag says the camera was held sideways — they are not: `p` was measured with the source's ruler on a frame that is no longer that shape, stretching one axis against the other by (w/h)², which is 2.25 on a 3:2 photograph. The quarter-turn permutation happened to undo that stretch, so rotation alone looked right, which is how this survived. The straighten in between did not, and a rotation in a space whose axes carry different scales is a shear. `p` is now built in `frame_aspect` — the frame as the user sees it — and the permutation becomes the one place the two rulers meet: each axis divided by the aspect it is read from, multiplied by the aspect it is written to. Asserted on pixels rather than on the generated WGSL, because reading the shader and reasoning about which space `p` lives in is how the wrong formula got written in the first place: a disc, straightened by 20° on a turned 3:2 frame, must come back circular by every route to a swapped frame — the button, the tag, and the two composed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dea826811e |
Render a blown highlight white instead of magenta
Every clipped sky came out bright pink. Measured, not guessed: developing _MG_8596.CR2 and looking at the export, the subject renders correctly and only the saturated region is wrong. A fully clipped pixel reaches the shader as (1, 1, 1) — three photosites that stopped counting, carrying no colour at all. The as-shot multipliers are not neutral, so balancing sends it to (1.93, 1.00, 1.68) on this body, and the camera matrix turns that into R 2.88, G 0.51, B 2.03. Red and blue clip at one; green, whose matrix row is far less positive-heavy, does not. Red and blue high with green low is magenta. Nothing upstream was at fault, which is why the two previous attempts missed it: the white balance is correct, the matrix is correct, and the sensor normalisation is correct. The input simply was not a colour, and correct arithmetic on a non-colour produces a confident wrong answer. So saturation is detected before the balance is applied — the last 1.5% of range, smoothstepped rather than switched, because a hard threshold draws a visible rim around every highlight and a backlit edge on skin is where that shows. Above it the pixel is pulled to the neutral of its own brightness, so it keeps its luminance and loses only the cast. Verified end to end on the file itself: the sky is white, the skin, the black dresses and the stone are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1c16ca3d27 | Merge branch 'colour-managed-export' | ||
|
|
914d14ec0d |
Tell the shader which colour space it is encoding for
The generated shader ended with `encode_srgb` and a clamp, so every photograph leaving DarkRoom had been through sRGB's gamut whatever the settings page said. Export refused the other three spaces rather than tag clipped pixels with a gamut they did not contain — correct, and not something an encoder could fix. So the output space becomes a parameter of composition. `compose_for` emits a constant primaries matrix after the camera matrix and before the clip, and generates the transfer function to match: the sRGB curve for sRGB and Display P3, a pure 2.199 gamma for Adobe RGB, 1.8 with a linear toe for ProPhoto. The ordering the camera matrix depends on is untouched — operations still run in camera space — and sRGB emits no conversion at all, so the shader compiled on nearly every frame is byte-for-byte what it was. The numbers live in dr-types, derived from four chromaticity pairs per space rather than tabulated. That is not tidiness: the shader encodes the pixels and the ICC profile describes them, and a file whose profile disagrees with its own contents is worse than one with no profile. One derivation makes them agree by construction, and can be checked against the values the specifications publish. Profiles are generated here too — minimal v2 matrix/TRC, about 2 KB, pure Rust, no lcms to satisfy under the NDK. A JPEG carries it in APP2, a PNG in iCCP, a TIFF in tag 34675. sRGB gets one as well, because untagged does not mean sRGB, it means guess. The refusal survives in a sharper form. A `Frame` now carries the space it was rendered in, and export refuses to label it anything else. The develop session still composes for sRGB, so a P3 export from the interface fails with an accurate error instead of producing a file that lies — the frontend half is a separate change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7f524d2fd0 |
Give a mis-drag a way back
Develop edits now save themselves to a sidecar the moment you leave the image, so until this there was no way to undo one — the mistake was persisted and the only recourse was to remember the old number. The history is a stack of snapshots, because the edit graph is already plain data: `Preset::capture` reduces it to what differs from default and `Preset::apply` puts it back, so undo is those two calls and nothing else. A command object per action, with an inverse beside it, would have been a second thing every operation had to register — and operations are declared in YAML precisely so that a new one needs no code written for it. A snapshot cannot fall behind them. The interesting part is coalescing. A slider drag emits an event per frame and must be one step, not forty. Nothing in the interface reports a gesture boundary — the same wall the render coalescing hit, and it is answered the same way rather than by threading a "finger is down" out of every slider, curve point and crop handle. What stands in for the boundary is the control plus recency: changes to the same control within 700 ms amend one step. Which control is "the same" is asked of the graph, not listed: an operation whose declared presentation claims a parameter is one where a single gesture moves several — a curve point carries an x and a y — so those coalesce as one widget. Nothing in the history names the tone curve. The compromise, and it is a real one: a control let go of and picked up again within the window is one step rather than two. Buying the other answer costs a gesture-boundary signal on every control, which is more surface than the difference is worth. The stack is bounded at 64 states for NFR-RES-1 — a develop session stays open for hours. Sixty-four rather than a byte cap: what is being bounded is steps a photographer would want back, and a byte cap would give the elaborate edit the shallowest history, which is exactly backwards. The session owns its history and every mutator records into it, so the callbacks in `lib.rs` cannot change the edit and forget to — with a dozen generic callbacks that would have been one press of undo away from wrong every time a control was added. Opening a photograph makes its stored edit the floor rather than a step: it is not work done in this sitting, and an undo reaching behind it would discard a previous session's edit and then save that on the way out. Not yet done, from FR-DEV-5: history is per-session and in memory, and there are no named snapshots. What mattered was that a saved mis-drag had no way back at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e00c99b864 |
Let a photograph leave: an export button, and a cache to leave from
dr-export could turn a frame into bytes and nothing could ask it to. This is the button, and the place the bytes go. **Everything is staged first.** An export bound for the server is written to a local outbox and uploaded afterwards; offline is not a special case, it is the same path with a drain that finds the server absent. Doing it the other way — upload directly, stage only on failure — makes the failure path the one that is rarely exercised and always broken, and a network drop mid-batch leaves some exports existing and some not with nothing recording which. Staged first, an export is finished the moment it is written and the upload is a promise kept later. The outbox sits beside the catalog rather than under the cache. dr_catalog's cache already draws that line: passive entries are a convenience and go under LRU, pinned ones are a promise and never do. An export awaiting upload is a promise — the user was told it succeeded — and sweeping it for disk would destroy the only copy. Bytes are written before the destination record, so a kill between the two leaves an orphan the drain ignores rather than a record pointing at nothing. The status line says "Queued for Exports/2026", never "Exported to Nextcloud", until it has actually landed. There is a test asserting that wording, because the tempting shorter sentence is a claim the app cannot keep. The drain runs on the sync pass, before the shards: a thumbnail shard can be rebuilt from the originals and the catalog is an index, but a queued export exists nowhere else. `DevelopSession::render_for_export` renders the framed size rather than reusing the frame on screen, which is deliberately viewport-sized (FR-DSP-1) — encoding that would hand the user a soft, screen-sized file with nothing to say anything had been lost (FR-EXP-9). One compromise, recorded rather than hidden: the export runs synchronously on the UI thread, so the window is unresponsive for the few hundred milliseconds a full-resolution render and encode takes. Moving a DevelopSession and its GPU pass to a worker is a larger change than one button earns, and it is batch export that makes the wait intolerable rather than merely noticeable. Still missing: the Nextcloud folder *picker*. The destination is typed into Settings for now. `FolderBrowser` in launch.rs is already the reusable model for it — it browses a remote tree and nothing about it is specific to choosing a library root — but wiring it into the settings page needs a listing worker and browser UI there, which is its own piece of work. Carries in-flight work from a parallel session — presets, the develop copy and paste, and the node schema's `presentation` and `enum` support. One misplaced callback in settings_ui.rs is moved from `render` to `wire`: registered in `render` it borrowed a `&SettingsController` into a 'static closure and would not compile, and that file's own docs say render pushes properties while wire connects callbacks. 992 tests pass, clippy and fmt clean. Traceability 48.3% -> 51.0%. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
23f0c4b76a |
Let a declared node ask for a widget, and write down how
Two gaps in the control vocabulary, both found by reading back what
|
||
|
|
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>
|
||
|
|
69b12e327f |
Let framing say it wants the canvas, instead of the panel knowing
The generated panel opened with a special case:
if op.id == dr_pipeline::framing::ID { continue; }
and a paragraph explaining that framing's eight parameters are eight bad
controls — four crop edges you would have to type coordinates into, a "rotate"
slider running 0..3, two switches — so `GeometryPanel` presents them as the
gestures they are instead.
Every word of that is true, and none of it was the frontend's to know. It is a
fact about the operation, and ARCH §4.3a is explicit that a frontend deciding
things by *naming a stage* is the boundary being crossed: a second frontend
would have had to learn the same special case, and nothing in the capability
output said why it existed.
Framing now declares a `Presentation` preferring `WidgetKind::CropOverlay`,
with a demand of two-dimensional dragging and — deliberately — no precise
pointing, since FR-UI-7 grows the handles to the modality and a crop is
forgiving. The widget owns all eight parameters rather than only the rect: a
frontend taking this on takes the whole framing control surface, and leaving
rotation and the flips behind would scatter them into the generated panel
underneath a crop control that already exists.
The panel's rule is now general. `supported` answers whether a kind is
implemented *anywhere* — drawn in the panel like the tone curve, or hosted on
the canvas like the crop — and `is_on_canvas` settles which afterwards, so the
two cannot disagree about the same kind. Any stage preferring an on-canvas
widget is skipped, with nothing named. A frontend that implements neither still
gets the eight sliders: tedious, complete, and the guarantee the whole hint
mechanism rests on.
**The tests were asserting against the wrong thing.** `rows_of` was a
hand-written simulation of the row generator, complete with its own copy of the
framing skip, so the suite was checking a second implementation kept in step by
hand. It was not in step: giving framing a presentation changed the real panel
and the simulation disagreed, which is exactly how a green suite hides a
regression. It now calls `rows_from`, and `rows_of_unfiltered` is gone.
`framing_is_not_generated_as_sliders` survives but asserts by routing rather
than by counting — no row may carry framing's capability index — so it cannot
be satisfied by two miscounts cancelling out. Alongside it, an invented stage
preferring a widget this frontend lacks, falling back to one the canvas hosts,
must also be skipped: if that ever needs a name added to pass, the special case
has grown back.
Not included: moving the crop overlay's markup out of app.slint into
controls.slint. It is cosmetic next to the above and app.slint is in another
session's working set.
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> |
||
|
|
65e6a96a65 |
Say what the application is doing, in one bar and one list
Every background job reported into a window property of its own — library-thumbs-done, library-pin-total, library-syncing — which only the grid ever read. A pin download that outlived the view it was started from drew nothing at all once the user opened an image, and there was no answer anywhere to "what is this busy with", because the answer was spread across eight properties nothing collected. They report to one register now (ui/dr-ui/src/activity.rs). It publishes an aggregate, which draws a three-pixel bar across the top of the shell in every view, and a row per job, which the settings page lists: scans, thumbnail batches, pin and open downloads, sidecar uploads, the sync and the trash. Failures stay on the list until they are cleared; routine successes do not, or a scroll would bury them. The handle removes a still-running job when it drops, so a worker that dies mid-transfer takes its row with it rather than leaving the bar sweeping for the rest of the session. Also carries in-flight work from a parallel session — the drawn icon set and the dr-pipeline ops split. dr-pipeline's build script does not compile at this commit; ui/dr-ui does, with clippy clean and its tests passing. 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> |
||
|
|
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> |
||
|
|
03326242a1 |
Make the CI checks say what they mean, and format the workspace
The Android job's "Verify minimum API level" step has never verified the minimum API level. It took the first `*.so` anywhere under the target directory, which is a host proc-macro from debug/deps — an x86-64 object built by the runner's gcc, whose .comment section cannot mention Android and so can never contradict the expected value. It now reads the artifact under the target triple, compares against MIN_API parsed from the Dockerfile rather than a second copy of the number, and fails on a mismatch. Both sides are checked non-empty first: two failed parses would otherwise compare equal and pass, which is the same silent success in a new costume. The Android image installs one SDK package per layer and keeps the output. sdkmanager is a JVM program that aborts when it cannot get memory, and the single `> /dev/null` step reported that as a bare "exit code 134" while a retry re-downloaded everything that had already succeeded. tools/ci-local.sh runs all four jobs — desktop, android, layering, traceability — against the host toolchain, which is pinned to the same 1.92.0 CI installs. Its matrix check compares regeneration against the working tree rather than against HEAD: CI starts from a clean checkout, so git's answer is the right one there and reports every local run stale here. The rest is rustfmt across the workspace, and the clippy findings that surfaced once it did: manual_contains in dr-thumbs and collections_ui, a map iterated as pairs for its keys, an index loop over a slice, and two runtime assertions on a constant now made at compile time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
cd75e5a4c6 |
Run the library from local data when the server is unreachable
Also carries in-flight work that shared these files: the zoom structure-key fix in the adjust pipeline, nearest-neighbour filtering past 1:1, the timeline scrub marker correction, the 423-Locked retry in the metadata sweep, and the thumbnail size-class migration. # Offline mode (FR-CAT-9) The app previously assumed the server was reachable and treated its absence as a series of unrelated per-operation failures. A launch without a connection produced an empty grid, even with a complete catalog on disk and every thumbnail already in the shards. Reachability is now inferred from traffic the app was already making, rather than probed for. `RemoteError::indicates_offline` draws the line that makes this possible: a dead connection is offline, a 403 or a 500 is not — the server answered, so blanking the library over one forbidden file would be a worse error than the one being reported. `Reachability` turns those outcomes into a state, so a library browsing happily never issues a probe at all. Going offline takes one failure, because the user is already experiencing it. Coming back requires evidence — a completed scan or a fetched thumbnail — with a capped exponential backoff behind the manual retry, so twelve sweep lanes failing together do not schedule twelve immediate probes. What keeps working: the catalog opens even when the scan that normally provides it failed, so the grid fills from the last successful scan. Thumbnails come from the shards. Rating, flagging and collecting are catalog writes that never touched the network. What stops is opening an original that was never stored locally, and it now says so in those words instead of reporting "network error: connection refused" over a photograph. Work that is pure network is refused rather than left to fail slowly: the metadata sweep, derived sync, and sidecar writes. The sweep would otherwise spend a timeout per image across the whole library while the progress bar implied something was happening. Deferring sidecars is a real gap rather than a hidden one — a rating made offline reaches its sidecar only when that image is judged again while connected — and it is recorded as such at the call site. # The "On this device" filter A chip beside the rating filters, narrowing the grid to images whose original is held locally. It composes with the rating terms rather than replacing them, so "five-star frames I can actually edit on this train" is one filter. The predicate is SQL, like the rating terms and for the same reason: the count in the header has to agree with the cells drawn. It reads `image_cache.tier_actual`, which nothing writes yet — the next commit fills it. Until then the chip honestly reports zero. `Tier` gains an explicit on-disk encoding. The variants are ordered by generosity and the derived `Ord` invites reordering them, which would silently reinterpret every cached row; the round-trip test is what holds the two in agreement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9f5e9955d5 |
Add sidecar serialisation for the edit graph
Sidecars are the only thing in the trust path: the catalog can be rebuilt from them, so they carry the edit graph and its version in a form that survives a schema change. Assisted-by: LLM |
||
|
|
5786977a51 |
Develop a JPEG through the same pipeline as a RAW
DemosaicedImage gains a second producer, from_rgba8, alongside the CFA path. Nothing about the type is CFA-specific — it is "an image on the GPU, ready to adjust" — which is what lets develop mode work on a JPEG without the edit graph or any operation knowing the source was not a RAW file. The one real difference is the transfer function: sensor data is linear, a JPEG is gamma-encoded. Every operation assumes linear scene-referred colour (exposure is a multiply, and doubling a gamma-encoded value is not a stop), so the shader prologue linearises once, at the only point where the two source kinds still differ. The flag rides in as_shot_wb.w, which was padding. For a JPEG the white balance uniform is neutral and the colour matrix is identity, so both stay unconditional multiplies rather than becoming branches. max_dimension is exposed because it is a hardware limit the caller must plan around, not a failure to report afterwards: a 13728x8928 film scan exceeds the common 8192 texture limit, and fitting it first is the only way to develop it at all. Assisted-by: LLM |
||
|
|
050dcff5bb |
Add viewport zoom to the framing
A view rect that composes with the crop in the same normalised space: nesting one rect in the other is a multiply, so the shader needs no second rect and no extra uniform slot. Zoom is explicitly not an edit. It is excluded from is_active, from the structure hash, and from the sidecar, so a zoomed view exports exactly as an unzoomed one does. is_neutral now asks the framing whether it *edits* rather than whether it is active — otherwise merely zooming would mark a clean file dirty. Because the render target keeps its size while the sampled region shrinks, zooming raises the resolution the pipeline works at rather than magnifying already-rendered pixels, which is what makes 1:1 inspection show real detail. Assisted-by: LLM |
||
|
|
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> |