a1d511fd4b0d6d2b85ad2ce8018053315f1b2275
813
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
7f60a2547c |
Merge branch 'worktree-agent-a75dc051d9bf691de' into integration
# Conflicts: # docs/traceability.md |
||
|
|
60d5504fb4 |
Prove on a device that the profile reaches the screen
The unit tests either side of the base curve check halves — that the shipped database parses and lifts its midtones, and that the generated WGSL evaluates a curve in the right place. Neither would notice if the two agreed with each other and both were wrong: a curve packed into the wrong uniform slots, or a flag read from the wrong component, satisfies both and renders nothing. So render real pixels. A flat frame through a neutral edit, once with the Canon EOS 6D's curve looked up by name from the YAML and once with the identity, asserting what a base curve is actually for — midtones lifted, black still black, white still white, monotone the whole way — plus the number an unprofiled body must still produce, so "never worse than today" is a value rather than a promise. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
7407a82aa7 |
Let an operation read the pixel next to it, and settle where sharpening belongs
The fused pass hands a fragment a colour and no coordinate. That is what buys one dispatch for a whole edit, and it is also a wall: sharpening, noise reduction, clarity, texture, dehaze and spot removal are each defined by what the neighbours are doing, and FR-DEV-3 and FR-DEV-8 ask for all six. None of them could be written at any price. So there is now a detail stage. An operation implements `Operation` for its parameters exactly as before — the panel, the sidecar, the history and the presets all work unchanged — and additionally returns `Affects::Detail` and a `DetailStage` yielding one pass per dispatch. `Affects` grows the third variant `docs/requirements.md:250` designed and nothing had cut. Where the stage sits is a colour-science decision, not an arrangement of convenience. It runs after every point operation and every mask layer, so an amount chosen against a tone curve survives the curve moving; in linear sRGB after the camera matrix, because camera RGB has no luminance to sharpen against; and before the output transform and the clip, because FR-DEV-2 allows one quantisation and a highlight clipped before a convolution grows a dark ring. The fused pass therefore ends one of two ways, and when a detail stage follows it hands on unclipped f16 and the last detail pass encodes. At render resolution rather than on the source, which is the whole of FR-DSP-1: a pass before the framing prologue would cost 24 MP to draw a 2 MP preview. `RenderScale` is what makes that survivable — a radius is stored as a fraction of the frame's shorter edge, exactly as a mask feather already is, or as a count of source pixels, and converted per render. It also reports when a radius is smaller than a proxy pixel rather than drawing a plausible lie; zooming to 1:1 makes the preview exact with no second path. `Invalidation` gives FR-DEV-3d something to mean. Moving a detail parameter leaves the colour key alone, so `AdjustPass` keeps the linear intermediate and skips the fused dispatch: dragging a sharpening slider costs a convolution. Moving exposure does re-run the detail passes, because they read what the colour pass wrote, and there is no arrangement of keys that avoids it while keeping sharpening after tone. Validated by a separable box blur that is not a develop operation, behind the `detail-probe` feature and absent from a shipping build. An abstraction with no consumer is a guess; a box blur's answer is known in closed form, so the tests assert every byte of the ramp rather than that the edge got softer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
743fefe7f1 |
Render each body through the profile its own files describe
Colour came from whichever matrix rawler happened to key `D65`, the second one was discarded, and the rendering was left linear. That is the dcraw default, and FR-DEV-3e names it as the reason people abandon a converter in the first hour: correct in the abstract, flat and poor on skin in practice. The decoder now builds a camera profile. - `ColorMatrix1/2` and `CalibrationIlluminant1/2`. rawler surfaces these as an illuminant-keyed map — for DNGs from the tags, and for native formats from its own camera database — so a Canon CR2 arrives with a tungsten matrix and a daylight matrix exactly as an Adobe DNG of the same frame would. Dual-illuminant support is therefore not a DNG feature here. - `ForwardMatrix1/2`, read straight from the root IFD, because rawler parses them and never surfaces them. Where a file carries both, they replace the inverted colour matrix: the same relationship measured in the direction rendering actually wants, rather than an inversion that amplifies the measurement error exactly where skin lives. - `AsShotNeutral`, used to estimate what the scene was lit by and to interpolate between the two calibrations in mireds. The estimate is circular — the temperature needs a matrix and the matrix needs the temperature — so it is a fixed point, three rounds, as Adobe's SDK does it. Bodies calibrated at neither D65 nor A stopped rendering uncalibrated as a side effect: a Phase One IQ3 carries D55 and D75 and used to get no matrix at all. And a base curve, applied per channel in camera RGB between the last adjustment and the conversion out of camera space — a toe, a steep midtone and a shoulder, which is the difference between a photograph and a scan of one. It is not an edit: no slider, nothing in the sidecar, because it belongs to the body rather than to anything anyone decided, and a sidecar is shared between bodies. It is not a develop node either, and `ops/README.md` now records why. It evaluates on the tone curve's own spline rather than a second copy, so a profile author placing a control point and a photographer dragging one mean the same thing by it. The curves are data. `core/dr-decode/profiles/base_curves.yaml` ships inside the binary as a floor and is superseded by any copy on disk carrying a higher `version:`, so a body can be added and distributed without a release — and, under the GPL, contributed. The comparison runs both ways: a stale pack cannot hold an upgraded binary back at last year's rendering. Canon EOS 6D and R6, Nikon Z 6 and D750, Sony A7 III and Fujifilm X-T3 ship with their own curves. Every other body gets a conservative default, which is much closer to right than the identity is for any of them. A JPEG gets none — it has already been rendered once, by the camera. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
735683b849 | wip: ingest | ||
|
|
98e0ad0537 |
Regenerate the traceability matrix
FR-CAT-10, FR-NC-7a and FR-NC-7b gained implementations; the two new requirements also raise the denominator. 46.9% to 48.6%. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
125fccbb46 |
Put an uploaded original in its dated folder
The transport was built and had nowhere to aim: put_chunked has existed since the connector landed, and the only things pushed through it are thumbnail shards and the catalog snapshot. The folder segments arrive already expanded, from dr_ingest::layout, rather than being re-derived here. That split is what makes FR-NC-7a's first consequence true — if this module worked out the folders from whatever the local library happened to look like, two machines with differently organised libraries would file the same photograph in two different places on one server. A name is resolved against one listing rather than a probe per candidate: a day folder is one PROPFIND and the answer covers every collision in it, where probing costs a round trip per attempt over a link that may be mobile data. Two cameras both produce IMG_0001.CR3, and the second must not overwrite the first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
7d1e6f724e |
Import photographs from a card
Distinct from a scan, and the distinction is the whole reason the crate exists: a scan catalogues files where they already are, where an import moves them from a card into the library. A scan that fails halfway has read nothing; an import that fails halfway has written something. So the failure paths are the design. Bytes stream at 1 MiB and are hashed on the way past, so an 80 MB RAW never sits in memory. The second destination (FR-CAT-10's backup copy) is written from the same read rather than copied from the primary afterwards — a backup made by re-reading the primary would inherit a bad write rather than catch it, and re-reading the card doubles the wear on the one copy that still exists. Verification re-reads the destination, because hashing what is still in memory would pass on a full disk, a dying card and a truncated write alike. Anything that fails past the point of creating the file takes the file back, or the next scan catalogues a truncated RAW as though it were fine. Three things the crate refuses to know. It never deletes from the card: a move-import records what is now redundant and a separate retire() does the deleting, because on a syncing library "safe" means the upload was confirmed (FR-NC-7b). It does not decode, so capture metadata arrives through a probe and a card of unreadable files costs no demosaic. And it does not know what a duplicate is, since that is a catalog query — FR-CAT-11's two tiers arrive as one closure asked twice, once before any transfer and once with the digest. Camera serial would be the stronger metadata key and is absent, because nothing in the tree reads it yet; make and model plus capture time and the original filename is what is available, and the digest tier covers the gap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
37d8744db8 |
Let storage write as well as read
Storage enumerates and reads, which is all a scan ever needed. An import writes, and there was nothing to write through. WritableStorage is separate from Storage rather than folded into it, because the two are not granted together: a card mounted read-only, or a share the user has view rights on, should fail to typecheck as a destination rather than fail with EROFS halfway through a copy. Ingest takes a &dyn Storage source and a &dyn WritableStorage destination, which is exactly the asymmetry of copying off a card. Shaped for SAF throughout, for the same reason the read side is: a document id is not composable, so every call takes a parent reference plus one name and hands back the reference the provider itself produced. create_dir is idempotent because importing a second card on the same day must land in the folder the first made, and the naive SAF call would produce "2026-08-22 (1)". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c36d4c80c8 |
Give the calendar one reading of a capture instant
The era-based conversion sat in library_ui.rs with two readers: the
timeline's month headings and, through format_date, the exporter's {date}
token. Import folder templates are a third, and three copies of a date
calculation that could drift apart is one too many — an image filed under a
date the timeline does not show it on is a file the user cannot find.
Moved to dr_types::time, which is where shared vocabulary lives, and given
the offset-aware reading an import needs: a shot taken at 23:30 in Tokyo
belongs in Tokyo's day, and filing by UTC would split one night's
photographs across two folders at whatever hour the offset happens to be.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
5945f7420a |
Say where an uploaded original lands
FR-NC-7 specifies how bytes travel and deliberately has no opinion about
where they go, and nothing else said either — so the one thing a photograph
needs on arrival, a folder, was unwritten.
FR-NC-7a gives it `{yyyy}/{yyyy}-{mm}-{dd}`, and two consequences the
transport mechanics do not supply on their own: expansion is a pure function
of capture metadata, so two devices computing a destination for the same
frame agree; and the template governs placement on upload only, because the
remote library is reachable by other clients and restructuring it under them
is not ours to do.
FR-NC-7b joins import to upload. A move-import must not erase the card until
the upload is confirmed — a card erased against an in-flight transfer is the
one failure in this application with no undo.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
610f679881 |
Reconcile the three branches
Two faults textual merge could not see. Both agents added a `mod tests` to masks_ui.rs — the module boundary was an artefact of them being written apart, and the tests do not overlap, so they fold into one. And `segment` lost its context argument when the work moved to a worker, which a test written on another branch still passed. |
||
|
|
5bcd0e0269 |
Merge branch 'worktree-agent-a22a049c461818dbe' into integration
# Conflicts: # core/dr-pipeline/tests/mask_sidecar.rs |
||
|
|
ae32ed7974 | Merge branch 'worktree-agent-afa2042c919d0d111' into integration | ||
|
|
96a7b405c2 |
Say which photograph the sliders are pointed at
Selecting a mask layer silently re-points about thirty controls at that layer's chain. Same panel, same order, same sliders, different meaning — and the only thing that said so was a sentence in the panel above, which a photographer reaching for the exposure slider has no reason to read. An exposure change lands on the whole frame when it was meant for a face, or the reverse; both are silent, and both are discovered later. `ui-navigation.md` §1.1 calls it the dangerous one and it is: the others in that document cost time, this one costs work. The remedy is the classic one for a modal fault — make the mode visible — and the application already had the pattern. Crop arms a canvas interaction, draws an overlay, gives the column one job and is left by the control that entered it. Local masking is the same animal built as a peer panel, and that is what created the ambiguity. So `crop-mode` stops being a bare boolean and becomes one value of a three-state mode, which is the point: two modes could both be on before, and now that is not a state the interface can be in rather than one it is tested against. **One strip, not two.** The mode control was going to sit beside the group strip that filters the adjustments, which is two controls above one column answering the same question — what am I working on. They are one control now, `Crop · Local │ All · Light · Colour`, which is the shape Lightroom Mobile's bottom strip has for the same reason. The two halves are different kinds of state and are drawn differently: a mode is a chip that fills with the accent when it is on, a group is a word with a rule under it. That difference is what lets both be read at once, which they routinely are — picking Light while a mask is selected filters *that layer's* chain and does not leave the mode. Dropping the scope on a group press would be the same fault coming back from the other end, and would make Light mean two things depending on where it was pressed. The strip stays pinned above the develop column rather than moving to the top of the canvas as the document proposed. The half that filters the column belongs to the column, and the photograph is the subject. The canvas keeps one button, which now names the mode it leaves rather than saying "Done" — that was unambiguous with one mode and would not be with two — because the column can be closed on a narrow window and no mode may be inescapable. Entering a mode is a side effect, so Rust owns it rather than the strip writing the property: crop drops the zoom, local turns the overlay on, and leaving clears the selection. That last one is the fix. The "Overlay" and "Select" toggles are gone because they armed things that are simply what the mode *is* — a mode that has to be switched on separately is one you can enter and have do nothing. Escape and the Android back gesture join `back_step` as one `LeaveMode` rather than a second exit concept, and the mode is left before the zoom is: it was entered later, and it is the bigger step back. The heading is where the scope goes. Not a caption beside the panel, the heading *of* the panel that changed — `ADJUST` becomes the layer's name, the same string the selected row in the stack shows. That is the difference between describing a hazard and removing it. **Handles on the photograph.** A linear or radial mask could be created and then not moved, so a radial sat at the centre of the frame at its default size for ever. Three faults stood in the way of drawing one. The first is that a gradient did not render at all until the model had run. The rasteriser was built on the way out of `segment` and the array's size was read *off* the segmentation, so a gradient added to an unsegmented photograph produced nothing — silently, in the same way exports and thumbnails once did: the shader still emits the layer's block and the empty placeholder multiplies it by zero. The proxy size is a property of the photograph. Both are derived from it now, and deliberately at the same size rather than by coincidence, because a subject's distance field is sampled against that array. The second is hit-testing. A handle is drawn in output coordinates and stored in source ones, and between them lie the crop, the zoom, the pan, the straightening and the turns. `Framing::source_at` is `wgsl_prologue` evaluated on the CPU, kept in that file beside it so that keeping the two in step is one file's problem — a handle mapped through anything less drifts off the mask the moment the view moves, which is exactly what masks are rasterised in source space to avoid. The third is that a drag is a displacement, not a destination. Each handle answers to the movement of the pointer since the press, applied to where the mask was when the press landed. Snapping the handle to the pointer instead jerks it by up to half a touch target on the first press, and the target is finger-sized because a tablet has no hover to reveal a control and no modifier to qualify it. A ramp gets three handles — centre, width, angle. An ellipse gets three too: centre and one per semi-axis, the major one carrying the direction as well as the length, because where an axis is put says both. It had a fourth, and it is gone: standing off the shape by a fixed distance, the rotation arm began outside the photograph at the size a new radial is created at, so the first thing anyone saw was a control they could not reach without first shrinking the mask. Two faults here were found by looking at the screen rather than at the source, both of the kind that cannot be found any other way. A `1px` rule with a size and no position is *centred* by Slint, so the seam between the photograph and the column was a hairline down the middle of the panel, through the histogram and every slider under it — twice, once in `app.slint` and once in `AdjustPanel`. And handing Slint a fresh model for the handles on every pointer event made the repeater rebuild its items, taking the `TouchArea` holding the gesture with them: the handle jumped once and then went dead under a finger that was still down. `develop.rs` carries the same warning about the parameter rows, where it broke slider drags; the model is rewritten in place now. The tests worth having are the ones about ambiguity and about the map. That the same row reads the frame's value, then the layer's, then the frame's again is §1.1 in one assertion. That dragging a handle onto another gradient's matching handle *produces* that gradient closes the loop between the two directions of the framing map, through a view that is cropped, zoomed, panned, straightened and quarter-turned at once — a one-legged map is invisible when the framing is neutral, because then both legs are the identity. Not done here: the histogram still reports the whole frame while the sliders edit a layer. That disagreement is real and is N3's, which this unblocks. The strip has room for a Brush entry beside Crop and Local when the painted masks land in the core, and it needs nothing here but the canvas interaction. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c75863c93f |
Give a gradient the angle it was asked for
A linear mask at 45° was not at 45°, and a radial with equal radii was an ellipse. Both on every photograph that is not square, which is all of them. The geometry is stored in normalised coordinates so that a mask survives a crop, a zoom and an export at another size — that part was right. What was wrong is that a *distance* was being measured in those coordinates too, and a fraction of the width is not the same length as a fraction of the height. So `dot(uv - centre, axis)` measured the ramp in a space one of whose axes is squashed against the other by the aspect ratio, and the iso-lines came out sheared: on a 3:2 frame a ramp asked for at 45° arrives at about 34°. Nothing announces it. The stored numbers are exactly what was written, the shader is doing exactly what it says, and the only place the fault exists is between the photographer's intent and the picture. It has been invisible so far because there is no way yet to place a gradient by eye — the handles that make it visible are what turned it up. So distances and angles move into the frame's own isotropic units: y spans `0..1` and x spans `0..aspect`, which makes a circle round and 45° a real diagonal. The centre stays a plain fraction of each axis, because it is a point and a point has no such problem — and because that is the space a click arrives in. `frame_delta` is the one conversion and must stay the only one; the mask array's own dimensions carry the aspect, so it costs no uniform. The sidecar format does not change. What changes is what the numbers mean, and the only geometry in the wild is a default that has never been movable. The two tests are at 96×64 rather than square, which is the whole point: on a square target this bug cannot be reproduced, and every existing mask test was square. Both fail without the conversion — the radial reaching 28px sideways where it reaches 19px down, and the diagonal landing on the wrong side of the line it is supposed to lie along. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
c0e1179936 |
Find the subjects without stopping the window
"Find subjects" took the UI thread for two thirds of a second on a 22 MP frame — a proxy render, a readback and a YOLO pass through `ort` — and for that time the interface was simply gone. The panel apologised for it rather than hiding it: a "Looking…" label, and a 16 ms `single_shot` so the label reached the screen before the freeze began, with a comment saying the obvious fix needed the develop session restructured and was not being taken. The obstacle was never `Send`. `DevelopSession` is `Send` — the device, the source texture and the passes all are. What cannot go to a worker is the `Rc<RefCell<Option<DevelopSession>>>` that every callback in the window reaches through, and the window has to keep reaching through it while the work runs. Handing the session over would freeze the interface exactly as thoroughly as blocking on it did. So the work takes a copy of what it needs instead. A `SegmentationJob` is the device, the demosaiced source behind an `Arc`, and the name of the session that asked. Taking one is two `Arc` bumps; running one is 495 ms on this desktop; none of it touches the session, and there is deliberately no `&mut DevelopSession` in scope for a caller to hold across it. The proxy render travels with it rather than staying behind — a `GpuContext` and a texture handle are both `Send`, and the model was never the only expensive half. So does building the mask rasteriser, which is a shader compile: adopting the result was costing 23 ms, a dropped frame on the one redraw the user is waiting for, and the rasteriser is needed exactly when the subjects arrive and never before. What is left on the UI thread is a microsecond. The answer comes back through a channel a `slint::Timer` polls, which is the shape `apply_when_ready` already uses for a sidecar fetch. **A result can outlive the photograph it describes.** Two thirds of a second is long enough to press the button, think better of it and swipe to the next frame — and the result landing then would fill the panel with subjects that are not in the picture, drawing outlines around a dog two photographs back. Nothing downstream can tell: the masks rasterise and the overlay draws either way. So every session is minted with an id, a job carries the id it was taken from, and `delivery` compares the two before anything is applied. An id rather than a counter beside the session slot, because that slot is written from four places in `lib.rs` and the fifth would be the one that forgot. A discard touches nothing on the way out. `segmenting` belongs to whichever photograph is open now, which may well have a run of its own going, and clearing it would re-enable a button that is correctly insensitive. One run at a time, and abandonment is what stops that being a trap. A job left over from a photograph the user has left is displaced rather than waited for — otherwise the next frame's "Find subjects" would do nothing for the length of a run nobody wants, which is the wait this exists to remove. `ort` offers no way into the inference, so abandoning is checked at the seams there are: before the job starts, and between the readback and the model. Abandoned early it costs nothing, abandoned mid-inference it costs the run it was already committed to, and either way the answer is dropped at the channel. `DevelopSession::segment` survives as a test-only convenience. Left public it is precisely the shape that put two thirds of a second on the UI thread in the first place, and the next caller would reach for it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
586698db00 |
Give the strip a layout to sit in
Build and test / Desktop (Linux) (push) Failing after 46s
Build and test / Layer separation (push) Successful in 26s
Traceability / Requirement traces (push) Failing after 1m2s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 3s
Build and test / Android (aarch64) (push) Failing after 9m38s
The develop column's panel is a Rectangle, not a layout — a note four lines above explains why it is not an `if`. Two children of one therefore both sit at its origin, so pinning the strip beside the Flickable overlapped them and collapsed the whole develop view to a sliver. Wrapped in a VerticalLayout. The strip is pinned by being outside the Flickable rather than by any coordinate, which is what keeps it working at any column height. Caught by screenshotting the device rather than by the build, which was clean throughout — a Slint layout fault is invisible in the source and obvious the moment anyone looks. |
||
|
|
9a7b045df4 |
Pin the group strip where it can be found
It was inside AdjustPanel, which on a tablet put it below five other panels and off the bottom of the screen: present, working, and unreachable without scrolling past everything it exists to save you scrolling past. A control that answers "where is everything else" cannot itself be somewhere else. Now a GroupStrip above the scrolling column, so it never scrolls away. It still names no group — the strings arrive resolved from whatever the operations declared themselves to be about. |
||
|
|
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. |
||
|
|
acd694c2cf |
No phone, so stop designing for one
Targets are a 12-inch tablet and a desktop (D15). That removes most of the navigation question rather than answering it. `EXPANDED_MIN_WIDTH` is 820 logical pixels and a 12-inch tablet is ~1024 across in portrait, so both orientations of both targets are the expanded class. The compact class now fires only when a desktop window is dragged narrow — graceful degradation, not a second interface. The bottom tool strip and the one-tool-at-a-time sheet were solving a phone, and there is no phone. What survives is input, not size, and the architecture had already decided it: `WidgetDemand::precise_pointing` exists for a television remote, and its own documentation says touch is fine because hit regions grow to the modality. Touch changes hit regions, not layout. The rules that fall out are worth stating because they are easy to violate by accident — no hover-only affordance and no modifier key may be the sole route to anything, since a tablet has neither. Local masking already lost its shift-click extend for exactly this reason. The guaranteed-wide viewport also pays for a better answer to the extent problem than hiding things. The complaint was never that the column is long; it is that the histogram scrolls away from the sliders it reports on. Collapsing shortens the scroll, pinning removes the problem, and ~260px of fixed height is affordable on a viewport that is never under 820 wide. |
||
|
|
85dafd78b7 |
Work out where things go, now that there are many of them
Local adjustments took the develop column from four panels to six, and the operation set is meant to keep growing — FR-DEV-3 still lists texture, clarity, sharpening and noise reduction as v1. But the count is the lesser problem. The real one is that local adjustments introduced a *mode* without introducing a way to see it. Selecting a mask silently re-points thirty sliders at that layer, and the histogram those sliders are judged against goes on reporting the whole frame. I built that, and it is the fault that loses work rather than merely slowing someone down. Decided: local becomes a mode, in the sense `crop-mode` already is. The app has the pattern, the user knows it, and it removes the ambiguity by construction instead of describing it in a caption. It also inherits the Escape/back stack that already leaves the innermost state first. Recommended: diverge on **width**, not on platform. A tablet in landscape wants what a desktop wants and a narrow desktop window wants what a phone wants, so `cfg(target_os)` would give one physical situation two answers. `apply_layout_class` already classifies on width and already remembers a per-class override; this is a second consumer of a decision the app makes anyway. What must not diverge is the controls — both layouts consume the same generated capability model, so a new operation still needs no UI edit. Left open, because it is taste: whether the wide layout eventually gains tool tabs. Recorded rather than left to be rediscovered is the *legitimate* route to them — a descriptor declaring an operation's nature, the same shape as `Affects`, with the frontend free to render it as a tab or ignore it. Deferred because ten operations do not need eight tabs and the field is easy to add later and awkward to remove. Surveys what Lightroom, Capture One, darktable and the phone editors actually do, including the thing none of them do: make a mask a panel that rewires a different panel. |
||
|
|
a881fa3693 |
Use transform-rotation, which is what Slint calls it now
rotation-angle is deprecated in 1.17 and was the only warning the build still emitted. |
||
|
|
e7dbdeb21f |
Keep the overlay on the photograph when the view moves
The overlay is a source-space picture; the canvas beside it shows whatever the crop, the zoom and the pan selected out of that same space. Drawn whole it stayed frame-sized while the photograph moved underneath, so zooming in left a map of the whole picture stretched over a detail of it. It now reports the visible rectangle as a clip, which the compositor applies for nothing. Resampling on the CPU instead would mean rebuilding a megapixel image on every frame of a drag, and putting it on the GPU would add a second texture to keep in step with the view. Pushed from the render path rather than the panel's sync: a pan changes no mask and no row, so nothing else needs to run, and rebuilding the row models on every frame of a drag would be waste. Straightening is handled by rotating the image. A quarter turn or a flip permutes the axes and a clip rectangle cannot say that — noted where it happens rather than left to be discovered. The proper fix is to run the overlay through the same shader prologue the photograph goes through, which is the right answer and a larger one than this. Four tests, and the one that matters asserts the clip *narrows* when zoomed — which is precisely what it failed to do. |
||
|
|
37f63edbf9 |
Take the watershed out of the product path
It does not work on a photograph, so nothing should offer it. `Segmentation` is now one model pass and what it recognised: no region field, no merge tree, no label upload, no granularity slider, and no readback of the whole proxy to build a graph that collapses. A click means "the object under the cursor". The region-selection path went with the hierarchy it indexed — including the shift-click add/subtract, which has no meaning for a whole object and would have been a modifier that silently did nothing. The passes, the hierarchy and the semantic prior stay in `dr-gpu` and `dr-segment`, tested and documented. It is the *merge criterion* that fails — the saddle is the minimum gradient along a boundary, so one weak pixel merges two regions and real gradient noise puts a weak pixel on every boundary. That is one function to replace, and the evidence for replacing it is worth keeping. What is gone is the wiring, the option, and the control that offered a user a choice with no outcome. `MaskSource::Regions` remains in the pipeline: it is tested, it round-trips through the sidecar, and a stored layer that names regions must still load and be reported stale rather than failing to parse. |
||
|
|
6163b63895 |
Put the edge controls where the mask is
Feather, falloff, grow/shrink/close/open and their amount, on the selected layer. All four read the one distance field, so all four are live — nothing recomputes except a compound morphology, and the session keys on that separately so a feather drag rebuilds nothing. Shown only for sources that go through the distance field. A gradient carries its own falloff in its geometry, and offering a second one would be two controls fighting over the same edge. Picking an operation seeds a small amount if none is set. Selecting "Grow" and seeing nothing happen would read as a broken control rather than as a radius of zero. |
||
|
|
ec713585a5 |
Measure the distance to the edge, and get four controls for one transform
Feathering, growing, shrinking, closing and opening are the same number read differently. With the signed distance from the boundary in hand, dilation is the set where d >= -r, erosion where d >= +r, and a feather of any shape is a function of d. So the field is computed once and the controls are arithmetic on it. The **field** is what reaches the GPU, not a finished alpha, and that is the point: growing a mask or changing its falloff then costs a uniform upload and no recomputation, which is what makes them live controls rather than ones that stall on every drag. Only closing and opening rebuild, because after the first threshold the shape has changed and the old distances describe the old one. Exact Euclidean, via Felzenszwalb's separable transform — not a chamfer approximation, which leaves a mask visibly octagonal once grown more than a few pixels. A test asserts the diagonal is √2 rather than 1 or 2. It runs on the CPU, which ARCH §5.4 forbids for masks. The rule is about brush lag — a stroke rasterised per frame — and this is a different operation: once per mask edit, on input the model already produced here, producing a field the GPU then samples for free. What it buys is exact determinism, which matters because masks reach the sidecar as indices and a field that varied by vendor would mean a mask meaning one thing on the desktop and another on the phone. The half-pixel in `signed_distance` is not a detail, and a test caught it. Measuring to the nearest opposite pixel *centre* puts the smallest magnitude at 1 either side, so the boundary is nowhere and **eroding by less than a pixel removes nothing**. A control whose first notch does nothing is a broken control. Half a pixel off each side puts the boundary where it physically is, and eroding by 1 takes exactly the outermost ring. Every falloff curve is 0.5 at the boundary by construction, asserted for all five: changing the curve should change how the transition looks and never where it sits. |
||
|
|
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. |
||
|
|
12d320cf33 |
Record what the spec got wrong about the model that exists
docs/segmentation.md §4 priced arm B as costing a C dependency under the NDK and treated that as most of the difference between the arms. It is not a cost that has to be paid: `ort`'s `alternative-backend` disables its linking entirely and `ort-tract` supplies the API from tract, which is pure Rust. D13's "largest exception the policy would tolerate" turns out not to be needed, and the answer generalises to the face pipeline — so D13's runtime half is now answered and only its licensing half is open. Three findings contradict §4 outright and are recorded as F4-F6 rather than quietly designed around. There is no ADE20K-trained YOLO, so the shipped vocabulary selects subjects and not stuff — "select the sky" comes from the watershed or from nowhere. It is instance segmentation, so it partitions nothing and two people come back as two instances. And tract cannot parse a dynamic-shape export, which fixes the input at 640 square and makes tiling the only route to more semantic resolution. Arm C ships, but §8's criteria are not what decided it, and saying so matters more than claiming the process worked. §8 asked for a two- interaction margin over arm A on a traced corpus. That comparison was never run: F4 and F5 changed what the arms are, and a model that recognises subjects but has no word for sky cannot be a selection tool alone, while a watershed cannot tell a person from the wall behind them. They stopped being candidates and became complements. What is *not* done is written down as plainly: the 24-image corpus is untraced, so M1-M4 have no numbers and "this feels right" has not become one. M5 is answered on one device only, and region ids now reach the sidecar — so a cross-vendor divergence would mean a mask written on the desktop meaning something else on Android. F3 stands. |
||
|
|
9b4f0815e5 |
Show the regions, click one, and adjust it
The local panel sits above the adjust panel because it decides what those sliders act on; below it, a photographer would set an exposure and only then discover which scope it landed in. Selecting a layer re-scopes the existing controls to that layer's chain — there is no second set of sliders, and there must not be, or every operation added to `ops/` would need a local twin. The overlay is drawn over the canvas rather than blended into the render, because it is a diagnostic and not an edit: it must not reach the histogram, an export, or the texture handed to the compositor. Nearest- neighbour always — the map's values are *names*, so smoothing between region 4 and region 9 invents a colour belonging to neither and softens exactly the edge the overlay exists to show. Picking gets its own touch area above the pan handler. Panning wants press-drag-release and picking wants a click; interleaving them in one handler is how a drag ends up selecting a region the user was scrolling past. Shift is tracked as window state because a TouchArea's click carries no modifiers. Three states a layer can be in are worth distinguishing, and each has a different remedy: stale needs re-segmenting, "no adjustment yet" needs a slider moved, and the ordinary case needs nothing said. A bare selection renders nothing and looks identical to a broken mask, which is the first thing a new user will hit. Known rough edge, commented where it happens: segmentation blocks the UI thread for about half a second. Moving it to a worker needs the develop session — GPU resources behind a RefCell shared with every callback — to be reachable from another thread, which is a restructuring rather than a change to the call. The button says "Finding regions…" first so the stall is announced rather than looking like a hang. |
||
|
|
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. |
||
|
|
0da8271836 |
Let the model say what a thing is and the watershed say where it ends
Local masking needs to know where an image's regions are. The watershed spike (S15 arm A) found the boundaries but had no idea what any of them enclosed; its coarse levels were geometric accidents. This adds the other half and the thing that joins them. `core/dr-segment` is where region reasoning now lives — the hierarchy moves out of `dr-gpu`, which keeps only the pixel passes that are genuinely shaders. The new crate is device-free and, without its default features, model-free too: 20 of its tests need neither an adapter nor 11 MB of weights. Arm B runs YOLO26n-seg through `ort`. D13 framed inference as a choice between `ort`'s C++ runtime and the pure-Rust dependency policy; that was a false choice. `ort`'s `alternative-backend` feature unlinks the C entirely and `ort-tract` supplies the API from tract, which is pure Rust. Measured before committing to it: zero unsupported operators, 420 ms for 640x640, and correct masks on bus.jpg. No NDK problem to solve, so D13's largest tolerated exception is not needed. Arm C is `prior.rs`, and it ships because the two arms fail in opposite directions. Instance membership re-weights the merge saddles, so region pairs the model believes share an object merge early and pairs straddling its edge merge late. No boundary moves — only the order in which they dissolve — which is how the result stays pixel-accurate at every level while its coarse levels become named things. Two things the spec assumed that turned out to be false, both recorded in models/LICENCE.md: there is no usable ADE20K-trained YOLO, so the shipped vocabulary is COCO's 80 subjects and *stuff* like sky and foliage must come from arm A; and tract cannot parse a dynamic-shape export, so the graph's input is fixed and tiling is the only route to more semantic resolution. Weights are AGPL-3.0, which GPLv3 §13 permits and which makes the combined work effectively AGPL. Deliberate, not accidental. They live in Git LFS, and a build script fails with an instruction rather than embedding a pointer file when the clone lacks them. |
||
|
|
ecd6df686c |
Read the defect map a raw file carries
Build and test / Desktop (Linux) (push) Failing after 54s
Build and test / Layer separation (push) Successful in 22s
Traceability / Requirement traces (push) Failing after 59s
🐳 Android image / Build and push (push) Successful in 12m59s
Build and test / android-image (push) Successful in 13m1s
Build and test / Android (aarch64) (push) Failing after 9m42s
First step of dead pixel removal, and the one that decides whether the rest is worth building: where the map comes from. `rawler` is no help. It knows `OpcodeList1/2/3` exist — it copies them through when *writing* a DNG — but it never decodes them, and the `dng_tags` map it exposes is only ever filled by callers, never by a decoder. So the bytes are read from the IFD directly, which this module was already walking for previews, including the SubIFDs where a DNG keeps its raw IFD. `OpcodeList1` specifically: lists 2 and 3 run after demosaic and after the colour transform, so neither can carry a correction that has to happen on the mosaic. Two opcodes describe defects — `FixBadPixelsList`, which is explicit coordinates plus whole dead rows and columns, and `FixBadPixelsConstant`, which names a sentinel value rather than any coordinates and is left unimplemented until there is a stage to consume it. A half-implementation that guessed at coordinates would be worse than the absence, because it would look like it worked. Two things the tests pin down because both are silent when wrong: a point is stored (row, column) and reading it the other way round lands the correction on the wrong photosite — invisibly, on a square crop — and opcode payloads are big-endian whatever the container's byte order is, so a little-endian TIFF still writes these the other way round. An unknown opcode is stepped over using its declared length rather than abandoning the list, because a camera that corrected its lens as well as its sensor writes both, and losing the map whenever a warp is present would be losing it on most files that have one. Includes `--example defects`, because whether any of this fires is a question about a particular library rather than about the specification. |
||
|
|
caf61d41a5 |
Re-thumbnail a photograph from its own edit
A thumbnail comes from the file's embedded preview, which is the camera's idea of the photograph and knows nothing about what has been done to it since. So a frame could be cropped, turned upright and pulled two stops back, and the grid would go on showing the original — making the library, where a photographer spends most of their time, the one view in which an edit is invisible. The render is the framed output, not the sensor: `output_size` is what a crop, a quarter turn, a flip and a straighten all act on, so a thumbnail taken from the raw frame would be the right pixels in the wrong shape and still the wrong way up. It is the same path an export takes, at a size the store wants rather than at full resolution, and always sRGB — this is a JPEG in a shard that syncs between devices and is drawn as a cell, not a file anyone is finishing. Both size classes are replaced. The store keys on the class, so refreshing only the one the grid happens to be drawing leaves the other holding the unedited preview, and a zoom across the boundary would show the edit undoing itself. Each is rendered rather than downscaled from the larger, which would be a second and worse resampler than the GPU has already applied. It runs on the way out of develop, after the sidecar write is queued and never instead of it — the edit is what must not be lost, and a render that failed must not take the save down with it. Two cases are worth the work: an edit made in this sitting, which `can-undo` records even when it ends back at neutral, and an image opened with an edit already in its sidecar and left untouched, whose cached thumbnail has never shown that edit at all. A neutral image nobody touched fails both and costs nothing. Not covered: a batch paste onto a selection, which deliberately never opens a session — there is no rendered frame to take a thumbnail from, and downloading forty RAWs to make forty is exactly what that path exists to avoid. |
||
|
|
3085ec4d2e |
Leave the stars on screen on a touch device
**Hover is not something a finger does, but Slint reports it anyway.** `has-hover` goes true for any pointer event carrying a position, a touch press included, and false again on the `Exit` that follows the release. So the rating strip did appear on a tablet — for exactly the length of a tap. It flashed on under the finger, vanished as it lifted, and the tap carried on through to the cell and opened the photograph. An unjudged frame could not be rated from the grid at all. The previous fix stopped the strip disappearing when a *pointer* moved onto it; this is the same symptom with a different cause, and hover was the wrong signal in the first place. The strip now stands open where the session is a touch one. That is seeded from the platform rather than inferred, because inference needs a press to reach a cell and a quick flick never delivers one — the Flickable claims the gesture before the delay it would forward after — and a control that only appears once the finger is down has appeared too late to aim at. The grid still latches on the first non-zero touch id it sees, which is what covers a touchscreen on the desktop. One-way on purpose: a tablet with a mouse plugged in keeps the strips once touched, which is the harmless direction to be wrong in. The alternative is chrome that comes and goes as the user changes hands. |
||
|
|
2fba685e16 |
Make a pinch zoom the grid and nothing else
Two faults left over from making the gesture reach the grid at all. **It still opened photographs.** Checking the finger id stops the synthetic release Slint emits when the *second* finger lands, but not the other end of the gesture: lifting one finger of two leaves the other one down, and Slint replays that survivor as a fresh `Pressed` on whatever is under it — which is how it hands the pointer back to ordinary handling. Under it is a cell. So the cell was selected, and lifting that last finger was a complete, well-formed click on the same finger that pressed. No part of the event stream distinguishes it from a real tap, so the grid now remembers that a pinch just happened: a latch raised when the gesture starts and lowered a beat after it ends, during which cells take neither presses nor clicks. The press that *opens* a pinch is undone rather than suppressed — it has already happened by the time a second finger makes it a pinch. Undoing it has to be exact, or a cancel that restored the selection but left the anchor moved would make the next shift-click select a run from a cell nobody pointed at, so capture and restore are a tested pair. **And it was not smooth.** Two reasons. The pinch was thresholded into ±1 steps of 25%, so the grid lurched and then sat still; it now takes the ratio since the last update and tracks the fingers, with the drawn cell still landing on whole column counts because the columns divide the width. And `zoom-cells` was the one geometry change still reloading inline — a full catalog re-read, 360-row model rebuild and thumbnail batch per step, on the thread drawing the frame. It goes through the same settle timer as the rest now. |
||
|
|
6ae0af3f72 |
Swipe up in develop for the photo roll
Develop opens one photograph. The grid handed over a path and nothing else, so `index` and `total` were pinned to "1 of 1" on the way in and the only route to the next frame was back to the library, find your place, tap again. Fine once; intolerable through a set of forty, which is the situation the develop view exists for. The roll is the grid's already-loaded window along the foot of the canvas. Swipe up to bring it out, swipe down to put it away — the sheet gesture, already in the hands of anyone who has used a phone — and a handle is drawn at the edge so the gesture is discoverable rather than folklore, and so a pointer, which has no swipe to make, has a way in. `SwipeGestureHandler` wraps the strip rather than sitting over or under it, which is what it is built for: it delays a press the way a Flickable does, forwards it to the children if no swipe develops and claims it once one does, so a tap reaches the thumbnail and a drag does not. It covers only the band along the bottom — above that, a drag still belongs to the photograph, for panning and for the crop. Picking goes through the same path a cell click does, so the outgoing edit is persisted before the next image loads. The strip marks what is open and scrolls to keep the mark in view. The position readout now says where in the *library* the open photograph sits rather than "1 of 1". Set after the open rather than before, since the generic open path resets it — and given as the library ordinal, not the row in the loaded window, which is an artefact of how much has been paged in and would jump about as the window moves. |
||
|
|
c72f197880 |
Stop a row of buttons deciding how wide the grid is
**A Slint layout cannot be narrower than its children's minimums.** Given less room than they need it lays them out at those minimums, lets the row run past the edge — and reports the oversized minimum upwards. That second half is what made this more than cosmetic. The header, the filter chips and the grid are siblings in one VerticalLayout, so the widest row's minimum became the whole view's minimum: `LibraryGrid` was laid out wider than the window. The grid then measured itself against that inflated box and sized its columns to fill space that was off the screen, so the right-hand column was cut by the edge no matter what the tiling arithmetic did. Fourteen filter chips do not fit across 768 logical pixels, so that was every tablet in portrait. It also explains why opening the collections sidebar did not reflow the grid. The view was already pinned at a minimum wider than the window, so taking 232px away for the sidebar could not shrink it — it just clipped more of it. Each of those rows is now a horizontal Flickable. A Flickable's own minimum is nothing, since it exists to be smaller than what it holds, so none of them can inflate anything — and the controls past the edge became reachable instead of merely absent. Applied to all four: the library header, the filter chips, the compact action row disclosed by "More", and the develop strip. |
||
|
|
d2c909414c |
Stop zooming rebuilding the grid twice a frame
A pinch is not one zoom step, it is a stream of them, and every step changes both the column count and the capacity — two reports. Each report re-queried the catalog, rebuilt all 360 rows of the model, re-read the badges and ratings for every one of them and spawned a thumbnail batch, synchronously, on the thread trying to draw the frame. Twice per step. That is why zooming juddered while scrolling the same grid is smooth: a scroll reloads a few times per screenful, a zoom reloaded twice a frame. None of that work is urgent, because none of it is about which photographs are on screen. The window holds the same images however they are laid out — the model already has them, and the cells re-flow from `columns` and `cell-size` with Rust not involved at all. What the reload actually recomputes is which cells begin a row, so the month headings land correctly, and which thumbnail size class to ask for now. Both can wait for the gesture to finish, so both are now coalesced behind a single settle timer: replacing the timer drops the previous one, and only the last report of a run lives long enough to fire. The anchor is captured on the first report of a run rather than read when the timer fires. As the grid re-flows the viewport keeps its pixel offset while the rows move underneath it, so the view drifts and reports the drift; reading the anchor at the end would faithfully return to wherever it had wandered. Taking it at the start returns to the photograph the user was looking at when they started the gesture. |
||
|
|
12a457b8d0 |
Tile the thumbnails to fill the width
The cells were drawn at whatever pixel size the class asked for and the remainder was left as a bare strip down the right-hand side — up to one short of a full column of nothing, which on a phone is a quarter of the screen. It also had the two quantities depending on each other the wrong way round. The column count was derived from the fixed cell size, so the two could disagree about how much room there was and the last column could start inside the viewport and end outside it. Solving for the cell removes both at once. Pick how many columns of roughly the requested size fit — rounded, not floored, because the size class is a request rather than a measurement and a width nine tenths of the way to another column should take it — then make those columns share the width. `columns` cells and the `columns + 1` gaps around them come to exactly the grid's width, so there is no remainder to strand and nothing can overhang. The size class still decides what the user gets, since it is what the column count is chosen from. It just no longer dictates the pixel, so the cell flexes a few percent either way to make the row come out even. |
||
|
|
90d6e551b3 |
Give the develop strip room for its controls on a tablet
The render backend, the layout class and the frame rate are developer readouts, and they were sitting in the middle of the one strip that also carries the way out of develop, the undo pair, the panel toggle and export. A HorizontalLayout given less width than its children's minimums does not shrink them — it runs off the end. A tablet in portrait is 768 logical pixels, which is not enough, so everything from the panel toggle rightwards was pushed off the right-hand edge. Below the breakpoint the develop column is *also* closed by default, so a toggle that could not be reached meant the column could not be opened at all — and copy and paste live in that column. That is the whole of "I have no idea how to copy a setting on Android and apply it to other images": there was no way to. The three readouts now collapse to zero width below the breakpoint. Width and not an `if`, because this strip is inside the layout that `expanded` feeds and a conditional child here is the shape that has already caused binding loops in this file — and because a `visible: false` child still takes its slot in a layout, so hiding alone would have freed nothing. |
||
|
|
1980fda737 |
Show the library on launch instead of scanning first
A launch does not have to discover the library. The catalog from the last run is on disk, complete, with its thumbnails in the shards beside it — exactly the state offline mode already leans on when the server cannot be reached. Every launch that *could* reach the server threw that away. The catalog handle was only opened when the scan reported Done, so the grid sat on "Scanning…" over an empty EmptyState for as long as a recursive WebDAV walk of the whole tree takes. On a real library that walk is essentially the whole startup time, and it was spent hiding a grid that was ready before it began. The catalog is now opened and the first window loaded before the scan thread is spawned — before, so the schema migration cannot race the worker opening the same file, and so the first thumbnail batch is already in flight while the walk runs. The scan still replaces all of it the moment it lands; it just no longer gates the first paint on the network. A first run has nothing to open, which stays silent: `Catalog::open` creates the file, the grid reads an empty catalog, and the empty state goes on saying "Scanning…" — which is true, and which an error here would contradict. |
||
|
|
80f1a210cc |
Keep the view pointing at the same photograph when the columns change
Two faults behind "the gallery randomly glitches to blank and needs a scroll to reset it", and behind the scrolling that skips. Cells are drawn at their absolute place in the library, so the row a photograph sits on is `index / columns`. When `columns` changes every cell moves — and the Flickable's `viewport-y` did not move with them. The view was left pointing at a row that now holds entirely different photographs, typically thousands of images from the ones the loaded window covers, so the grid drew nothing at all. It stayed that way until a scroll reported a first-visible row and dragged the window back under the view, which is exactly the reset the user found. None of its triggers are rare: a resize, the collections sidebar opening, a zoom step, or turning the tablet over. The last first-visible ordinal names the photograph being looked at, so it is now sent back through `scroll-to` and the view lands on that same photograph at whatever row it now occupies. The second fault is the window-move test. At either end of a scope the window is pinned — the first screenful cannot be centred further back than zero, the last cannot start past the last full screenful — so the margin test was unsatisfiable there and every row crossed in the first or last quarter of a window re-read the catalog, rebuilt the model and issued a thumbnail batch to arrive at the offset it already held. On a library of twenty-odd thousand that is a stutter at the top and the bottom of every collection, which is where a cull begins and ends. The decision is now a rule with tests rather than four lines inside the scroll handler: both of its ways of being wrong are invisible in the code and obvious on a tablet. |
||
|
|
deabac0923 |
Carry a photograph's thumbnail across a reload
Work in progress found uncommitted in the tree, committed as its own change so the fixes that follow can be read separately. Not authored in this session; the description below is written from the diff. `load_window` rebuilds every row, and a scroll reloads once the view has travelled a quarter of the loaded window — so three quarters of the cells being rebuilt are the same photographs already on screen. Rebuilding them empty blanked the grid to `Theme.ground` and refilled it a beat later, once a worker had re-read and re-decoded each one from the store. That is the black flash on every screenful of scrolling, and on a column change, a zoom step, a filter and a return from develop. Thumbnails are now held by `image_id` across the swap — a refcount per cell, no pixels move — along with the "no preview" verdict, which is an answer about the file worth keeping for the same reason. `requested` is rebuilt from what the new model actually holds rather than cleared, so a carried cell is not fetched again while one newly scrolled in still is. The size class each cell's pixels came from is tracked alongside, so a grid zoomed past that class still asks for the sharper one. Also guards the whole `scrolled` and `columns-changed` handlers on `show-library` rather than just the resume latch: a Flickable being torn down passes its viewport through zero, which was indistinguishable from a fling to the top and reloaded the window against the first rows of the catalog every time an image was opened. |