5a500118aeda586c14abaf78b972aa3e5f99889e
463
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
e17b909d41 |
Connect the lens vignetting correction to the pipeline
`ops/vignetting.rs` has carried a complete descriptor, polynomial, helper and test suite without an entry in `ops/`, so it was never in `chain()`. It reached no photograph and no panel, and `Attribute::Optics` was an empty category in consequence — filtered out of the tab strip for having no rows, by a chain that had never been given its only member. Declaring it needs the one thing the operation was written against and which did not exist. `wgsl_body` reads `radius`, and the module claimed "the composer publishes `radius` in the shader prologue for exactly this reason". It did not. `sample_source` now does, in both sampling branches, beside the `source_px` it already published for the same class of caller. It is corner-normalised there, which is the part that is easy to leave out. `p` spans ±0.5·aspect, so its length at the corner is 0.5·length(aspect) — about 0.901 on a 3:2 frame, not 1. Lensfun's polynomials are fitted against a corner radius of 1, so passing `length(p)` straight in evaluates every one of them short of where it was measured, by a factor that changes with the aspect ratio. It would have read as a correction that is simply too weak, which is indistinguishable from a bad profile. Both `lens.rs` and `framing.rs` asserted the normalisation `p` does not have; corrected. `order: 5` puts the correction ahead of the tonal stages, and the ordering is load-bearing rather than tidy. Recovering a corner means dividing by an attenuation below one — about two stops for a fast prime wide open — so run after the highlights have been rolled off and clipped, the lift has nowhere to go and the corners posterise instead of brightening. `layer_chain` now drops `Optics` as well as the neighbourhood operations. A local vignetting slider would have worked, which is what makes it worth excluding: `radius` measures from the centre of the whole photograph and a mask cannot move the optical axis, so it would lay a frame-centred radial ramp across the picture and multiply it by the mask. The existing exclusion covers operations that move and do nothing; this one covers an operation that moves and does something its name does not promise. The rule both share is now written down. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8e7b1350bf |
Name the frame's category for the decision, not the maths
`Attribute::Geometry` becomes `Attribute::Compose`, and `film_sim` moves from `[tone, colour]` to `[effect]`. Two categories were doing the wrong job. "Geometry" describes what crop, straighten and the quarter turns do to coordinates — but it describes lens distortion correction exactly as well, and that is not a compositional choice at all. Naming the attribute for the photographer's decision is what separates it from `Optics`: one is what the lens did, the other is what they chose. The maths the two have in common is not the thing worth filing them under. A film stock declared both `tone` and `colour`, so "Kodachrome" appeared in the Light group beside exposure and again in Colour beside white balance — two places, neither of which is where anyone looks for it. It is neither: `Effect` is defined in this same file as "applied rather than corrected — a look, not a fix", which is what a stock is. That it moves tone and colour is true of every look, and is not what the attribute is for. `from_name` still accepts "geometry" on the way in. That string is persisted in `develop.copy_attributes`, and an entry it fails to parse is not an error — `presets::scope_for` logs it and drops it — so without the alias an existing settings file would have quietly narrowed what a paste carries. `name` writes the current spelling, so the file migrates itself the first time it is saved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
95e854b5a2 |
Regenerate the gesture vocabulary over the library work
Traceability / Requirement traces (push) Successful in 1m57s
Benchmarks / CPU and I/O (per commit) (push) Successful in 3m42s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / android-image (push) Successful in 5s
🐳 Android image / Build and push (push) Successful in 5s
Build and test / Android (aarch64) (push) Successful in 1h5m40s
Build and test / Layer separation (push) Successful in 48s
Build and test / Desktop (Linux) (push) Failing after 1h12m34s
`Traceability` stayed red after the matrix was regenerated, on its other gate: `gestures-check`. Same cause, second artefact. `docs/gestures.md` cites each gesture by `file:LINE`, and the library UI work moved the two selection-mode gestures down a hundred lines — 3923 -> 4032 and 3940 -> 4049 in `ui/dr-ui/ui/library.slint`. Nothing about the gestures themselves changed. `ui/dr-ui/src/gesture_book.rs` was already current, so this is the doc alone: 70 files scanned, 16 gestures, 2 places, gate PASS. Worth knowing for next time: `tools/ci-local.sh traceability` runs the self-test, the coverage gate and the matrix, but not `gestures-check`, so a clean local run does not prove this workflow green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
dabf63ed6d |
Regenerate the matrix over the watershed and sidecar work
`Traceability` has been red on master. The matrix records every tag as
`file.rs:LINE`, so it goes stale two ways at once, and both happened here:
- the `cargo fmt --all` sweep (
|
||
|
|
710fcbc1bd |
Format the face work with the workspace's own rustfmt
Not authored in this session. `cargo fmt --all` reformats every crate, so running it while working on `dr-segment` picked up five files from the recent face and library work that had been committed unformatted. Committed on its own rather than swept into the change that happened to produce it: the diff is pure whitespace, and mixed into a commit that alters an algorithm it would be noise in exactly the place someone is trying to read carefully. `cargo fmt --all -- --check` is a CI gate (tools/ci-local.sh), so this had to land somewhere regardless. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
89c4ff1820 |
Store what the model found, so a reopened photograph keeps its masks
A subject or category layer was written to the sidecar as identity alone —
which run, which instance, which category — on the reasoning that the pixels
are reproducible by running the same model over the same image. They are, but
only by *running the model*, and nothing runs one except a photographer
pressing "find subjects". So on every path that did not already have a run in
memory the layer resolved to no coverage, `MaskPass::render` logged "has no
distance field; skipping", and the adjustment was silently absent:
- reopening an edited photograph rendered it without its local adjustments,
and then saved that state back on the way out;
- a batch export from the grid could not have them at any point, because
`render_from_library` opens a session, applies a version and renders, and
there is no model anywhere on that path. Three hundred files written
without the edits their photographer made, over a log warning.
Neither failure announced itself. The generated shader still emits the layer's
block and the empty placeholder multiplies it by zero, so the result is a
well-formed frame that is simply missing an edit — `mask_is_stale` already
named the state and called it "not stale, just unrenderable".
The coverage now travels in the file, as one `coverage = w h levels payload`
line at the end of the layer's block.
Two levels, and that is not a compromise. The model hands out a byte per pixel
but `Shaped::build` measures its distance field from `coverage >= 128` and
throws the shoulder away on the first line; everything soft about the rendered
edge comes afterwards from the layer's feather and falloff, which are read off
the distance. So one bit per pixel is not an approximation of what the model
said — it is exactly the part of it that reaches a pixel, and the stored mask
renders the identical frame. Storing all 256 levels would have stored 1.7 MB
of bilinear interpolation to reconstruct a predicate, and would not even have
compressed: a model mask is a bilinear upsample of a coarse grid, so almost no
two adjacent bytes are alike. Measured on a simulated sky and a simulated
figure at 1600x1067, against 1.71 MB raw: 4.0 kB and 6.5 kB at two levels,
46 kB and 76 kB at sixteen, 835 kB and 1.43 MB at all 256. The level count is
still written into the line, so a later build that finds a use for the
shoulder can write sixteen and this one will read them rather than misreading
a stream of lengths as pairs.
The coder is hand-rolled — run-length pairs in a base-64 varint — because
`dr-pipeline` links nothing, which is the property that lets the descriptor
and codegen logic be tested without a device. `flate2` would have been fewer
lines and a dependency in the one crate that has none.
Where it lives matters more than how it is coded. The raster sits on
`MaskLayer` beside the source, not inside `MaskSource::Subject`: the source is
*identity*, which is what makes it diff as a handful of numbers and merge per
field under FR-NC-9, and a raster in there would have given the merge a binary
blob to arbitrate. It takes no part in `MaskLayer`'s equality for the same
reason — a device that has run the model and one that has not hold the same
edit, and counting the difference would raise a conflict over a cache and let
`remote_wins` answer it by discarding the only copy of the pixels.
Encoding happens in `masks_for_storage`, on the save path, rather than in
`ensure_subject_fields` where every coverage already funnels through.
`ensure_subject_fields` runs on a drag — dilating a mask with a compound
morphology rebuilds the field every frame — and encoding a megapixel raster
per frame is the kind of work NFR-P5 exists to keep off a gesture. Saving
happens once, when the photograph stops being the open one, and already costs
a network round trip.
Version skew holds both ways. A file with no `coverage` line reads exactly as
it did before, which is a layer that needs the model run; an unreadable one
costs the pixels and not the layer, because the layer is the edit and the
raster is a cache of it. An old build reading a new file drops the key it does
not understand, which costs a model run and no work. And a payload that will
not compress is refused rather than truncated: a checkerboard would encode to
twice the raster it came from, so past 64 kB nothing is stored and the
behaviour falls back to what it was — half a mask would render as a mask that
is confidently wrong, which is the failure that tells nobody.
|
||
|
|
6acc98baad |
Carry the photograph's header into an export made from develop
The same file exported from the library grid kept its camera, its lens,
its capture date and its rights statement. Exported from the develop
button it kept none of them, and `{date}` in a filename template
resolved to nothing at all. Two buttons, one photograph, two different
files -- and the develop one was the version the photographer had just
finished working on.
A session now remembers the header it was opened from, and
`open_session` takes that header rather than the orientation read out of
it, so a photograph cannot be opened for editing without saying which
file it came from. `render_open_frame` clones it onto
`Source::Rendered`; both arms of `export_one` -- the worker's own decode
and the frame handed over already rendered -- turn a header into a
`{date}` and a `SourceMetadata` through the same function, so the two
paths cannot come to different readings of one file. What of it actually
reaches the exported bytes is still decided inside `dr-export` from the
settings, which is what keeps the location-stripping option working here
rather than giving it a second implementation to disagree with.
The alternative was to hang the metadata on `Source::Rendered` alone and
keep it beside the session in the interface. That touches less, but it
makes the header and the pixels two cells to hold in step across the six
places an image is opened, replaced or fails to open, and the failure
mode of getting that pairing wrong is not a missing tag: it is one
photograph exported under another's byline and coordinates, silently.
Kept on the session, the two travel together or not at all.
The header is stored decoded rather than transcribed at open time,
deliberately. `dr-export` argues that source metadata is a parameter and
not a field on `Frame`, because two exports of one frame may legitimately
disclose different amounts; by the same reasoning a session may remember
where its pixels came from without that being a decision about what to
publish, and the allowlist that decides remains the single function in
`export.rs`.
A file with no header is left with none -- an empty `{date}` and nothing
for the encoder to copy -- rather than today's date standing in for a
capture time nobody recorded.
|
||
|
|
353382c07f |
Hand the photographer's place between devices
A place recorded on the tablet should be where the desktop opens. Exchanged through `.darkroom-derived/place.json`, beside the thumbnail shards and the catalog snapshot. Newest timestamp wins outright: unlike the catalog this is replaced rather than merged, because two devices cannot both be where the photographer is and so there is nothing of theirs inside ours to preserve. It still refuses to upload over a copy it could not read, for a smaller version of the reason `sync_catalog` does: a record we have not compared against may be the newer one, and overwriting it would move the other device's photographer without ever having seen where they were. Last in the pass, and its failures are logged rather than reported. Everything else in that folder is *derived* -- a faster way to learn what the device could work out for itself -- so losing it costs time. A place is a fact only the other device knew, and losing it costs a scroll. A sync that ran out of connectivity should spend what it had on the shards. The full pass runs after a thumbnail sweep or when Sync is pressed, neither of which happens on an ordinary launch -- so a handover would arrive one launch late, which is one too many for a feature whose whole claim is picking up where you stopped. `spawn_place_fetch` is the small half: one GET of a few hundred bytes, started beside the scan. And it can still be refused. A handover is welcome on the way in and unwelcome once the photographer has started: a grid that jumped elsewhere mid-scroll because a round trip finally landed would have lost their place to the feature meant to keep it. Any scroll, scrub, scope change, filter or opened photograph closes the latch, and a record arriving after that is written to disk and takes effect next launch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d44bffa4a8 |
Remember where the photographer was
Opening the application was always a fresh arrival at the beginning of the library, whatever you had been doing when you closed it. What is written down is the view, the scope, the rating filter and the photograph on screen -- the open one in develop, the first visible one in the grid. Not just a scroll position: a position without the filter that produced it names a row of a list that no longer exists. Restoring them has an order for the same reason -- scope, then filter, then position, then the view -- because each step changes what an ordinal *means*. Addressed by remote path and collection UUID, never by an ordinal or a row id. `images.id` and `collections.id` are local to one catalog, and a grid ordinal is local to one ordering; a record naming either would land somewhere arbitrary on a second device and after any filter change on this one. Where the ordinal is needed, `library::ordinal_of_path` computes it through the grid's own `ORDER BY`, taken verbatim by a window function rather than spelled a second time as an inequality -- which is the mistake `grid_order_for` already warns about, and which a manually ordered collection would make unreadable. Every failure degrades rather than reports. A collection this device has not merged leaves the scope at the whole library; a photograph that has since been deleted falls back to when it was taken, which puts the grid in the right week; a torn file yields no place and the library opens at the top. Reopening develop is the one thing that requires an exact match, because a canvas on a path that no longer resolves is a filename over an empty frame. The record lives in `dr-types` beside `Settings` and the store lives here beside `SettingsStore`, for the reason `dr-types`' manifest gives: a JSON serialiser in `core/` would be paid for by every crate there. Two files and two lifetimes, though -- resetting preferences must not forget where you were. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8bf5e13faf |
Centre the photo roll on the frame it opens with
The roll brought the open photograph into view by the shortest move, which is right for stepping along it and wrong for the first look: a frame near either end of the loaded window arrived hard against an edge, with nothing on that side to give it any context. It now centres on the first settle of a develop session and steps minimally after that. A one-shot request that the strip itself clears -- the only thing that knows the request has been honoured is the code honouring it -- rather than something recomputed on creation, because the strip is created far more often than a session begins: leaving develop for Settings and coming back rebuilds it, and re-centring then would undo a roll the user had scrolled by hand. Raised on the two ways into develop from the grid, and not on a pick along the roll, which is a step within a session rather than the start of one. Centring is clamped to the ends: the third photograph of a window cannot be centred without scrolling empty space in beside it, and a strip that begins with a gap reads as broken rather than as centred. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0ff1e01ec3 |
Come back from develop on the photograph you were editing
Leaving develop returned to where the *grid* was, which after a walk along the photo roll can be a thousand rows from the frame you had just finished. So the one photograph you were certainly interested in was the one the grid came back without. Two positions, and the rule is not to pick one of them. The grid seeks to the remembered position, then reveals the keyboard cursor -- which is now put on the open photograph, and which moves the viewport as little as will bring its row into view. A frame inside the remembered screenful moves nothing at all; one outside it scrolls exactly far enough. One rule, both behaviours. The cursor rather than the selection, deliberately: `place_cursor` also rewrites the selection, and a set of forty photographs assembled in the grid must survive having one of them opened. `reveal()` now also runs on the grid's `init`, since `cursor-row` is initialised rather than changed when the subtree is rebuilt and no handler would otherwise fire. Both it and the roll's centring defer while the element has no height yet -- `init` runs before layout, where a height of zero makes every row look off screen -- and a latch brings the first real height back to the cursor without letting every later resize haul the viewport around. The capture-time marker follows the same move, for the same reason: `load_window` rebuilds the axis only when the scope, the filter or the total has changed, and none of them has. It is the same library seen from a different row. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
eed27eb36d |
Keep the grid's place when another screen covers it
Opening Settings, Import or People and coming back landed at the top of the library however deep in it you had been. The grid is gated on an `if` in the markup, so every route away from it destroys the subtree and rebuilds it. A Flickable being destroyed passes its viewport through zero on the way out, and that reaches `on_library_scrolled` looking exactly like the user having flung the grid to the top. The handler already guarded against it -- but on `show-library`, which means "the library rather than develop" and stays true while any of those four screens replaces the window. So the guard covered the develop route and none of the other three: `resume_at` was overwritten with 0 on the way out, and the position was gone before anything could restore it. The condition the `if` is actually spelled with is now computed once, in `app.slint`, and Rust reads that. The two cannot drift apart again because there is only one of them. That fixes the overwrite. The second half is that nothing replayed the position on the way back in: `on_back_to_library` does it by hand, and Settings, Import, People and the launch screen do not go through it. Rather than teaching three more modules to call it, `scroll-to` is now kept current on every scroll. It is read by `seek()`, which runs on a token change and on `init`, so writing it without bumping the token cannot move the grid on screen -- and is exactly what the next grid reads when it is built. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4dc954f01a |
Take the photo roll's grab band off the buttons that end a mode
Benchmarks / CPU and I/O (per commit) (push) Successful in 3m53s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 58s
Build and test / Layer separation (push) Successful in 45s
🐳 Android image / Build and push (push) Successful in 4s
Build and test / android-image (push) Successful in 4s
Traceability / Requirement traces (push) Failing after 1m0s
Build and test / Android (aarch64) (push) Failing after 31s
"Done Cropping", "Done Repairing", "Done Masking" and "Fit" float over the foot of the canvas. So does the photo roll's swipe handler, and a gesture handler is not a layout box — it is an input surface. A press inside one is delayed, then offered to that handler's own children and to nothing else: `input_event_filter_before_children` returns `DelayForwarding`, which aborts the hit-test traversal outright, and the replay afterwards visits only the handler's subtree. Everything behind it is never asked, hover included. The band was `strip-height + reach` — 136px along the bottom — whether the roll was out or away. So the button that ends a mode was drawn, was lit, and did nothing for as long as a library was open, which is the whole time anybody is developing from one. The tool rail kept working because it is a sibling of the canvas rather than behind the roll, which is exactly why this looked like two dead buttons rather than a dead region. The band now goes where the roll goes. The handler carries the strip instead of standing still while the strip animates inside it: closed, only `reach` is on screen and the rest hangs below the window where nothing can press it; open, it still covers the thumbnails, which is what lets a swipe down anywhere across them put the roll away. The 180ms travel moved from the strip onto the handler, so the drawn positions in both states are what they were. The controls are then positioned against that band rather than against the bottom of the canvas, and ride up with the strip when it comes out. Reordering them in front of the roll would have been the other fix, and it is the wrong one — the band would become the thing that cannot be reached, and a gesture nobody can start is worse than a button with a second way out. `roll-strip` and `roll-reach` are tokens now, because two files have to agree on where that band is for either of them to keep out of it. The bottom of the photograph comes back with it: the crop's lower handles and a repair placed near the bottom edge were inside the same 136px and had the same fault. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3d248cfb79 |
Export the photograph, not the canvas
Zooming the develop view changed the exported file. `Framing::view` is kept out of the sidecar, out of `is_active` and out of `output_size` precisely so that it cannot — but those exclusions keep it out of the *edit*, and an export is a *render*. `visible_rect` deliberately folds the view into the single rect the fused shader's prologue samples, so `render_for_export` inherited it: at 4:1 it wrote the middle of the frame, magnified to fill the file at the full output size, with the detail kernels scaled four times over because `render_scale` folds the view in as well. `render_thumbnail` did the same to the grid. `render_uncropped` already suspends the view for this exact reason, so the fix is its pattern: one `render_the_file` that both file-producing paths go through, composing inside the suspension since the view reaches the shader as a uniform baked at composition time. Restored whatever happens — leaving the graph un-zoomed after a failed export would throw away where the photographer was looking. Nothing caught it because the guard checked the wrong things. `zooming_does_not_change_the_exported_image` asserted the output size and the crop; both held perfectly throughout. Renamed to `zooming_does_not_change_the_size_or_the_crop`, which is what it tests, and the pixels are now guarded where pixels exist. The new test uses a ramp rather than quadrants deliberately: a four-quadrant frame is self-similar under a centred zoom, and the first version of this test passed against the bug because of it. Traces FR-EXP-9, which asks for the full-quality pipeline "regardless of what the display was showing". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a1e361e35a |
Measure the native path against the one it replaces
Everything argued for this change so far was read out of a catalog
after the fact: crop_px across 18,671 faces the old code had already
stored. That is evidence about what the previous implementation did. It
is not evidence that the new one does better, and the difference matters
because a landmark left in the detector's coordinates, or a box filter
with an off-by-one in its source span, would both produce faces that
look entirely plausible until somebody counted the pixels behind them.
So examples/face_native.rs renders one file and indexes it twice, native
and from a 1024 proxy, changing nothing else. Fourteen originals from
the reference library, 5472x3648 CR2 and DNG:
native 9 faces, mean crop 287px
1024 proxy 5 faces, mean crop 75px
Crops 3.8x larger, and across the line that decides whether the crop is
photographed or interpolated: 75px is below ALIGNED_EDGE, so the proxy
path was upsampling into the embedder on average where this one
downsamples into it. Fourteen images and nine faces is enough to show a
direction and to catch a wrong scaling; §7b says so rather than quoting
the ratio as a library-wide figure.
It also corrects something §7b asserted two commits ago. I wrote that
detector input resolution cannot affect recall, because §4.1 letterboxes
everything to 640. Native found nine faces to the proxy's five,
including four on files where the proxy found none, so it plainly can.
The two paths differ in their resampling as well as their size, and this
experiment does not separate those, so §7b now records the result as
evidence for the double-resampling hypothesis rather than as its proof.
M4 still owns settling it.
The audit-summary test went stale when the ready/to-fetch split was
collapsed and is updated to assert the single number, including that the
old wording is gone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
4af3b93dfa |
Index faces from the native render, not from a preview of it
Implements the FR-CULL-8 written two commits ago. The sweep fetched the JPEG preview embedded in each RAW and used that one buffer for both detection and the crop; it now fetches the original, renders it through the same path export uses, reduces that for the detector, and warps the crop back out of the native frame. Three pieces, and each exists for a reason worth stating. dr_face::Pixels lets the warp sample 8-bit RGBA directly. A 24 MP native frame is 96 MB as RGBA and 288 MB converted to the f32 RGB align.rs was written against, and the warp reads about forty thousand pixels out of it. Converting the whole frame to sample 0.2% of it is NFR-RES-2's budget spent on a copy, per image, for a whole library. The variant costs one branch per sample and a test asserts both layouts produce identical crops. The detector gets a box-filtered reduction to 1600px, not the native frame and not a point-sampled one. Averaging rather than sampling because the detector's job is finding small faces and decimation is precisely the operation that removes them: at 4x, fifteen of every sixteen pixels are discarded and a 40px face survives or not depending on where it falls relative to the sample grid. 1600 rather than 640 leaves the letterbox a mild 2.5x rather than a 9x, and bounds the f32 buffer at 20 MB. Landmarks come back in the reduction's coordinates and are scaled to native in one place before any crop pixel is read. This is the failure mode that would not announce itself -- unscaled landmarks put every crop near the top-left corner, which yields faces of something else, cleanly embedded and confidently clustered. The sweep fetches SWEEP_LANES-wide and renders sequentially. Not a placeholder for a parallel version: there is one GPU, so concurrent renders queue on it regardless, and each materialises a native frame. Overlapping them would multiply the one allocation that threatens the memory budget while buying parallelism that does not exist. The chunk drops from 96 to 6 for the same reason -- 96 held 8 MB previews, this holds whole RAWs. The stored edit is deliberately not applied, which is where this departs from export::render_from_library. Face geometry is normalised to the frame, so indexing a cropped render would record boxes against a frame that changes whenever the user changes their mind, and every stored box would quietly become wrong. Orientation is applied: that is a fact about the file rather than an edit. examples/face_native.rs renders one file and indexes it both ways, so the claim behind all of this can be checked against photographs rather than re-read out of the catalog it came from. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e7b526c550 |
Specify face indexing at native resolution, and say what the proxy cost
FR-CULL-8 said detection runs against the thumbnail or proxy tier and never a full decode, and faces.md §5 said the aligned crop is sampled from that same proxy. Both are wrong in the same place: they treat detection and cropping as one resolution problem when they are two, with opposite answers. Detection does not care. §4.1 fixes the graph's input at 640x640 and letterboxes whatever arrives, so a face filling 2% of the frame reaches the model at 12px whether the buffer handed over is 1024px or 6000px. Every pixel above the detector's own input is discarded before inference. The crop cares about nothing else. §5's warp produces the fixed 112x112 ArcFace sees, so source resolution converts directly into whether those 112 pixels were photographed or interpolated. Reading crop_px across the 18,671 faces the proxy-tier implementation stored: 47.3% were upsampled to reach the embedder, 314 of them by more than 2x, the smallest from 34 source pixels. An upsampled crop does not fail loudly -- it yields a confident embedding of detail that was never there, and the damage appears three stages later as clusters that will not separate. So FR-CULL-8 now specifies four stages with the resolutions named separately: render native through FR-EXP-9's pipeline, downscale for the detector, map boxes and landmarks back to native, crop and align from the native render. The affordability the old rule bought is met instead by when the pass runs -- background, preempted, resumable -- and the requirement says plainly what it now costs on a remote library: the original rather than FR-NC-3's byte range, 412 GB across the reference library's 19,107 images, so a whole-library pass is a transfer under FR-NC-6 rather than something that may start on its own. MIN_CROP_EDGE replaces the MIN_DETECT_EDGE this branch briefly had. Same number, guarding the quantity that turned out to matter. faces.md §7b records both measurements, and marks the second as unexplained rather than dressing it as a finding. Grouped by the buffer detection ran against, faces per image was 0.078 at 1024 or below and 1.82 at 2048 or better, controlled for file type and size. That gap is real and reproducible and I cannot account for it, because the letterbox above says detector input should not matter. M4 is where it gets settled. The crop measurement does not depend on it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
144d2e4e84 |
Revert the detection floor: it guards the wrong resolution
Reverts 53f7cdf and e92d22d. The floor those added sat on Detector::detect, refusing any buffer under 1025px on the reasoning that a small buffer finds no faces. That reasoning does not survive §4.1: the detector letterboxes every input to 640x640, so a face occupying 2% of the frame presents at 12px to the model whether it is handed a 1024px buffer or a 6000px one. Detector input is precisely the quantity that does not matter. Worse than merely useless, it blocks the design FR-CULL-8 now specifies, where the detector is deliberately fed a downscale and the crop is taken from the native render. A guard on detect() rejects exactly that call. What the measurement actually supports is a floor on the *crop* source, which is where resolution converts into embedding quality, and which faces.crop_px already records: 47% of the reference library's faces were upsampled to reach 112x112. That floor is a separate change against the native-resolution path and does not belong on the detector. The 23x faces-per-image gap by source_edge that motivated the original commit is kept in faces.md §7b, restated as the unexplained observation it is rather than the causal claim it was written as. V12 stands: those runs cropped at 1024 whatever detection did, and that is reason enough to look at them again. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6783d0c723 |
Settle the images whose best preview is under the floor
The floor introduces a state the sweep had no arm for. An image whose largest embedded preview is genuinely smaller than 1025px now comes back from index_preview as an error, lands in the generic Err arm, and is counted as failed -- which means no face_index row, which means it is still outstanding, which means the next sweep fetches exactly the same bytes and refuses them again. For ever, on every run, at one range request each. The previous behaviour was wrong but at least terminated; this would not. So ProxyTooSmall gets its own arm, and it records a marker at the true edge rather than nothing. That is the difference between "we looked and found nothing" -- which would be a lie, since nothing was looked at -- and "this was examined at 900px, which is the best this file has". The first is unrecoverable; the second is a fact source_edge was added to carry, and a later floor or a bigger proxy can select on it deliberately the way V12 just did. Counted separately from failures all the way up, because they are not failures and reading them as such would misdescribe a library of small scans as a broken network. The summary line says how many and why. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0a6509de93 |
Forget the face runs made on proxies too small to see a face
The floor added in the previous commit stops this happening again; it does nothing about the 1,824 images in the reference library that already carry a face_index row written against a proxy of 1024 or less. Those rows are why the damage is permanent rather than merely past. The work list is "images with no row for this model", so an image examined against a 1024px proxy -- 0.078 faces per image, nine in ten finding nothing -- is indistinguishable from one examined properly, and no later pass will ever offer it to the detector again. V12 deletes exactly those markers, and nothing else. The faces those runs did find stay in place and keep drawing the People screen until a better pass replaces them, and record_detections re-attaches the user's confirmed names across that replacement by box overlap, so a library somebody has spent an evening naming does not lose that evening. The cost is a re-fetch of the affected images. Deleting the marker rather than teaching the work-list query to select on source_edge, which was the other option and is worse. A standing `source_edge < floor` predicate never lets go: an image whose largest embedded preview is genuinely smaller than the floor would be re-fetched on every sweep for ever, because the next pass cannot do any better than the last one did. A one-off deletion gives each affected image exactly one more attempt through the good path and then lets the ordinary "has a row" rule settle it. The threshold is written out in the SQL instead of referring to dr_face::MIN_DETECT_EDGE. A migration has to keep meaning what it meant when it ran; binding it to a constant someone may raise later would quietly change what an old catalog gets migrated to. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f87bf6ebc0 |
Charge a colour mode for its rarity, and keep the verdict
The refinement worked and could not be controlled. Pruning modes below a share threshold made the flag's removal a *discrete* event: below the line its Mahalanobis distance was enormous and nothing rescued it, above the line it sat at zero and nothing removed it. A control over that would appear dead through most of its travel and then start eating sky. So the prune is gone. A mode is charged `−ln(share × k)` nats, floored at zero, and that cost enters both tests — doubled in the chi-square, which is a squared distance, and directly in the log density. Rarity becomes a distance rather than a threshold, and the things a photographer wants to remove separate along it. Measured on the synthetic frame the tests build: a flag holding 1.6% of the sky is more than half gone by **2.95 nats** and a cloud bank holding a third of it survives to **5.75**. The whole interval between them is somewhere a control can sit. `the_flag_goes_before_the_cloud_does` pins the ordering, which is the property that makes one slider worth offering at all. Measured against an even split rather than against one, so raising `clusters` describes a category more finely without making every colour in it look rarer. Floored at zero so a dominant mode earns no *discount* — a bonus there would let the commonest colour outvote a bad chi-square, which is the one direction this must not bend. `Refinement` holds the per-pixel verdict, quantised to a byte over ±16 nats — an eighth of a nat per step, far finer than the narrowest transition the gate can be asked for, and the same size as the coverage buffer it sits beside. `apply` is then a smoothstep, and the model is never consulted again. That is `distance.rs`'s arrangement deliberately: there a signed distance field is computed once and feather, grow and shrink become arithmetic on it, "which is what makes those live controls rather than ones that stall on every drag". Same shape, different field. The blur moved with it, from the gate to the verdict. Smoothing the evidence rather than the decision means it is paid for once in `compute` instead of on every frame of a drag, and it is the better thing to smooth in any case. `apply` at `STRICTNESS_OFF` returns the weights untouched without reading the verdict at all. A control whose off position is *very nearly* the unrefined mask cannot answer "is this helping"; one whose off position is the unrefined mask can. `strictness_zero_changes_nothing` holds it to that, and `strictness_is_monotonic` holds the rest of the travel to only ever removing more — a slider that gave weight back partway up would be one whose direction nobody could predict. The synthetic sky is smooth enough to sit on `VARIANCE_FLOOR`, where a real one has noise and therefore a real spread, which moves every crossing down together. The ordering survives that; the placement is a calibration. Which is the honest argument for a control rather than a constant, and why the default sits at half scale instead of at the flag's measured crossing. The example sweeps the whole range and writes a frame per nat, because the question a photographer asks of a slider is where to put it, and that needs the travel rather than a point on it. Verified: fmt clean, clippy -D warnings clean, 60 dr-segment tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4f4abd335f |
Cut a scene category back to the pixels that agree with it
A flag in the sky came out weighted as sky, and no feather setting fixed it. The scene model's logits are `[1, 150, 80, 80]`, so one cell is eight input pixels; at the 1600px proxy the letterbox scale is 0.4 and **one cell is 20 proxy pixels**, which `rasterise`'s bilinear then spreads across one more either side. A flag is a handful of cells whose softmax is dominated by the sky around it. The information was never in the grid, so nothing downstream of the grid can recover it. Tiling is the answer for an instance and is not available here: a category has no bounding box to tile over — sky is wherever the sky is. But the photograph is at full proxy resolution even though the weights are not, and it knows exactly where the flag is. So the model says *what*, and the pixels say *which of them*, which is the division of labour arm C already draws between the instance model and the watershed. ## Seeds, and why the erosion radius is not a guess Threshold the weights high, take `signed_distance`, and keep what is more than 1.5 cells inside. One cell *is* the model's resolution and the bilinear spreads it across one more, so the band either side of the boundary is smear rather than evidence. Deriving the radius from `Scene::cell_pixels` rather than picking a pixel count means it stays right if the proxy edge or the export changes. The mirror of that set is a confident *exterior*, free from the same field. ## Dropping small modes is the step that makes it work Four k-means modes per side, not one Gaussian: sky is blue at the zenith, white where the cloud is and pale at the horizon, and one blob over all three rejects two of them. Then modes holding under 3% of a side are discarded, and without that step the whole thing fails on the case it was built for. A small flag deep in the sky has both a high weight and a large distance from the boundary, so it lands in the interior sample and teaches the model its own colour. It cannot be excluded geometrically. It can be excluded by share. Luminance is weighted at a quarter against chrominance for the same reason the watershed's gradient is. Sky's variance is dominated by luminance, so at equal weight the distribution is a long bright streak that a mid-grey flag sits comfortably inside. A flag is separated by chrominance; a cloud is separated by luminance alone. Not zero, or a dark bird against a bright sky survives. ## Two tests, because either alone is wrong Absolute — is this colour plausible under the category, as a chi-square on the Mahalanobis distance. Comparative — is it likelier inside than outside. A pixel must pass both. The absolute test is what catches the flag, whose colour is far from *both* sides and which the comparative test alone would leave at even odds. The comparative test is what stops the absolute one needing a constant tuned per category. ## What this cannot do, written down rather than left to be discovered An intruder large enough to hold its own mode is kept. By share, a flag over a fifth of the sky and a cloud bank over a fifth of the sky are the same object, and colour does not separate them either — a white cloud is as far from blue sky in chrominance as many intruders are. So `min_cluster` is not a threshold with a correct value waiting to be found; it is the trade-off itself, set where a photographic intruder falls. Both ends are pinned by tests — `a_flag_in_the_sky_is_removed` and `an_intruder_larger_than_min_cluster_survives` — so that moving the number reads as moving the trade-off rather than as fixing a bug. The case left open is a large unrecognised object in a clean category, which wants the boundary snapped to watershed basins and is a different mechanism. ## Safe to apply without a control It is subtractive: the output is the input times a factor in `0..=1`. The worst failure available to it is losing part of a real sky, never gaining a region, so a blue car below the horizon that was never in the mask cannot be pulled into it. And a factor in `0..=1` cannot raise a sum, so `scene.rs`'s partition still holds when every category is refined independently — the weight taken off the flag lands in the unlisted remainder, which is where a flag belongs, ADE20K having no class for one. Every path without the evidence to judge returns the weights untouched and says which path it took. A refinement that silently did nothing is indistinguishable from the feature being off, and an empty seed set fitted to a distribution would reject every pixel. The signature is deliberately unchanged: categories are addressed by name, not by index, so a sharper mask cannot create the stale-index hazard the signature exists to guard against. The example writes `<prefix>-<category>-refined.ppm` beside the coarse one, never instead of it — whether this is an improvement is a comparative judgement and one image cannot answer it. Verified: fmt clean, clippy -D warnings clean, 57 dr-segment tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8131706394 |
Regenerate the matrix over the merged rail work
The merge moved tagged lines in identity.rs, identity_ui.rs and the two Slint files; the matrix tracks line numbers, so it goes stale on a move alone. A merge commit does not run the pre-commit hook that would normally have staged this. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f26f1ab694 |
Withhold the empty groups that fill the People rail
`load_people` returned every live person, and on the reference library that was 14,268 rows of which 11,739 held no faces at all — 85 per cent of the rail naming nobody and able to do nothing. They are not a mystery. A regrouping pass creates a person per cluster; the next pass moves those faces elsewhere and leaves the person it emptied behind. `faces::prune_empty_unnamed` exists for exactly this and runs only at the end of a pass, so nothing clears what accumulates between them, and the Identity screen never prunes at all. Each one cost a `for_person` query and a built row on every reload. This withholds precisely the set the prune already treats as disposable — empty, unnamed, not set aside — and no more. Filtered rather than deleted: a screen is being drawn, not a catalog repaired. Nothing is lost, a sync cannot resurrect what was never removed, and the prune stays the one place that decides these can go. An empty group with a *name* still shows. That one is not debris but the symptom of a real failure — a named person whose faces were regrouped out from under them — and hiding it would take away the only way to merge them back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0845d4ee20 |
Regenerate the matrix over the merged branches
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
80f0a0eeac |
Merge branch 'master' into feat/library-toolbar
# Conflicts: # docs/gestures.md # docs/traceability.md |
||
|
|
677146883f |
Decide what the header gives up when it cannot hold everything
Three faults, all invisible in the source and all found by looking at the window. `alignment: start` on the selection bar. Slint only hands space to `horizontal-stretch` children under the default `stretch` alignment; under `start` every child takes its preferred width and the stretch is ignored without complaint. That was harmless while the bar held four controls. Now that it holds every selection verb it is the difference between a count that gives way and a row that can only scroll, so the alignment goes and the stretch does its job. The title collapsed to "…". An eliding Text has a minimum of nothing, so once the buttons had taken their own minimums the title was the cheapest thing in the row to give away — the window stopped saying which library was open while the byte counts beside it stayed. It gets a 90px floor. And the status line does not get one. After the sidebar takes its 232, a 1100pt window leaves this header about 868, and the buttons want most of that before a character is drawn — so something must degrade, and the order is the whole question. A floor here bought a readable status by pushing Settings off the right edge, reachable only by knowing to flick-scroll, which is precisely the fault the row's own Flickable comment warns about. A control you cannot see is worse than a sentence you cannot finish. The status line is also the most redundant thing in the header — the sidebar states the library's count and the filter chips state it again — so it is what gives, and it grows back the moment there is room. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9994bb4ce7 |
Regenerate the matrix over the third wave
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c48896bd95 |
Merge: touch selection and drag, from the gallery-selection branch
Verified before merge: fmt clean, clippy -D warnings clean, 563 dr-ui tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> # Conflicts: # docs/traceability.md |
||
|
|
b34e786f01 |
Give the drag a pick-up, so it stops losing to the scroll
Dragging a photograph out of the grid worked about half the time, and nothing on the screen explained the other half. `DragArea` and `Flickable` do arbitrate, but not evenly. The Flickable claims any press that travels more than eight pixels along its own axis within half a second of landing, and holds that claim until the finger lifts. So a drag toward the sidebar only ever began two ways: a flick sideways clean enough that the finger never wandered eight pixels vertically, or a wait of half a second before moving at all. Both are real gestures and neither was written down. The wait is now the gesture, and it has a mark. The long press that already turns on selection mode also picks the photograph up: a ring opens around the cell and the grid stops scrolling under it, so from that moment the drag is the only thing the finger can be doing. The cue can only arrive after the ambiguity has passed, which is the right way round — when the photograph lifts, dragging it works. Two details worth naming. The hold is now armed even when selection mode is already on; it used to be skipped there, on the grounds that there was no mode left to switch on — but that is precisely the state a forty-image drag starts from, so the one gesture that most needed a pick-up was the one with none. And the ring is drawn after the cell loop rather than on the cell: z-order inside a `for` is loop order, so a cell grown past its bounds would stand over two neighbours and be cut off by the other two. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c7e1822f77 |
Put the two collection actions in the collections panel
"Keep offline" and "Change library" were buttons in the library header, in a row that is otherwise entirely library-wide. Both refer to the tree instead. "Keep offline" could only ever mean the scoped collection, while sitting nowhere near the tree that says which that is — and while the sidebar already offered the same question twice, on each row's tray and on a held row. It now sits under the tree, in the panel whose selection decides what it acts on, in the same place and shape the trash already gives Restore and Empty. It stays a labelled control rather than being dropped for the tray: a 26px row's tray is a small thing to hit, and "Kept offline" spelt out for the collection you are looking at is the discoverable version. "Change library" was wedged between Sync and Rescan, two buttons that act on the library you already have. Reading it as one of that group is a way to lose a scan by aiming badly. It is now the last thing in the panel, under the tree it replaces wholesale, behind a rule. On a tablet both are one tap further away, behind the sidebar toggle that leads the header. That is the panel a user is already in when they scope the grid to a collection, so it is where they are when either of these becomes the thing they want. The grid keeps `pin-done`/`pin-total` and its progress bar: the transfer is worth reporting wherever it was started from, including a row the grid is not scoped to. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f4fbabf18b |
Say what the grid is showing in one line, not four
The header carried four readouts beside the title: the image count, the selection count, the scan's status and where in the library the visible window sits. Each had `horizontal-stretch: 1`, which is what actually lets a Text shrink in Slint — so between them they claimed about a third of a 768px header and left the buttons to scroll off the end of it. The selection count goes to the bar at the foot of the grid, beside the buttons that act on it. The other three are one subject and are now one sentence with separators, sharing one stretch. Two of them were also saying the same words while a sweep ran: "indexing 300 / 12 480" appeared both as the status and, redundantly, in place of the window position. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
18063152f9 |
Ask where these photographs go once, not twice
The selection bar had "Add to collection" and "New collection" side by side. Both answer the same question — where do these go? — and the bar asked it before showing the list that decides it: a user who wants a collection they already have and a user who wants a new one press different buttons before either has seen what exists. "New collection…" moves into the filing sheet, under the list of collections. That is where you look after failing to find the one you wanted, and it is the only place the choice can be made informed. It also fixes the sheet's empty state, which said "Make one with + in the sidebar" — advice that cannot be followed on a tablet, where the sidebar is instantiated but not drawn. The first collection can now be made from the sheet that noticed there were none. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
745f3a98c7 |
Merge: measure the catalog, so §8's promise stops being a promise
There was no benchmark harness of any kind -- no benches/, no criterion, no synthetic fixture -- while §8 promised a suite run per commit that fails the build on regression. Ten performance requirements could be neither passed nor failed. tools/bench builds a deterministic 50,000-row catalog over a pool of twelve generated JPEGs, about 14 MB, reproducible from a seed, with a stamp so it rebuilds rather than silently comparing against a different workload. It depends on nothing GPU or UI, which is what makes the CI job affordable. NFR-P1 and NFR-P3 are gated and tagged. NFR-P7, NFR-P8 and R2 are measured but deliberately untagged: the export gate is one-sided, the memory figure is the catalog layer's share rather than the whole, and R2's first sentence is a 60 fps scroll a catalog benchmark cannot claim. Every recorded value in the baseline is null. Nobody has run this on the reference desktop, and a fabricated figure would make every later comparison a comparison against a guess. First run on this machine: catalog opens in 70 ms against a 2 s budget, and thumbnail throughput measures 37 img/s against a target of 100 -- reported rather than asserted here, and the first evidence that the target may not hold. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8510a2f6a7 |
Give the selection one bar instead of two ends of the window
"What can I do with these twelve photographs?" was answered in two places. Six buttons in the header — Add to collection, Keywords, Presets, Paste to N, Export N, Remove from collection — and four more on the floating bar at the foot of the grid, beside the count that says what they would act on. The header half was the worse of the two. Those six appeared and disappeared from the middle of the row as photographs were picked, so Sync, Settings and everything beside them slid several hundred pixels sideways at the exact moment a hand was already travelling toward one. On a tablet the header scrolls sideways, so they were often not on screen at all. All six move to the bar. It now reads left to right as shaping the selection — Clear, Select all, Select to… — then acting on it, with a gap between the two thoughts. The header keeps only what belongs to the library, and changes only when the library does. Two consequences worth stating. The bar holds ten controls in the worst case, so it scrolls sideways like every other row in this view, for the reason set out on the header's Flickable: a layout given less width than its children need overruns rather than shrinking, and the buttons past the edge are simply gone. And the bar now stays up for a running export whatever the selection has since become, because Cancel export lived on a button that used to have its own `|| exporting` escape hatch — the grid's viewport inset follows the same condition so the last row of thumbnails is never trapped underneath. `settings-summary` went with them: threaded from the window into the grid and into HeaderActions, and never once drawn. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
7b5f62019b |
Merge branch 'master' into fix/gallery-selection
# Conflicts: # docs/traceability.md |
||
|
|
458025c607 |
Merge the scene model: a second, ADE20K-trained graph for per-category grades
Five commits. `models/` becomes one tree at the repository root so the weight the application carries is a single `du -sh`; the export script stops building its multi-gigabyte venv in RAM; `yolo26s-sem-ade20k` joins the instance model rather than replacing it; the decoder turns its logits into a partition of unity over eight photographic categories; and four packaging routes put the file somewhere each platform can find it. The instance model stays exactly where it was. A semantic model merges every pixel of a class into one region, so it cannot separate two people, and separating two people is what clicking a subject needs. The scene tab grades whole categories and does not care. docs/segmentation.md §16. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> # Conflicts: # docs/traceability.md |
||
|
|
6974604e84 |
Merge: say what each control is, so a screen reader can use one
NFR-A11Y-2 went from five accessible-* declarations in the whole interface -- all five on the colour mixer's swatch row -- to seventy-three, on twelve shared components and four screens. The one that mattered is SliderTrack: the most-used control in the application, until now unnamed, and now carrying a label, a formatted readout and increment/decrement/set-value, so it is adjustable rather than merely readable. Fixing the shared components covered library.slint's twenty-seven buttons and eleven chips without editing that file at all. NFR-A11Y-1 is scaffolded and one screen of nine is converted -- 38 @tr() calls, about a tenth of the interface's strings. Slint's translate() returns the original when no bundle is active, so a converted string and a literal behave identically today and each remaining screen is an independent commit. Two accessibility defects are recorded rather than fixed, as TD-6 and TD-7 with measurements: ink-faint reaches 4.5:1 on no surface (3.97 at best) and rule reaches 3:1 on none. Fixing either re-derives the palette beside a photograph, which wants a screenshot and an opinion. Verified: fmt, clippy -D warnings, 563 tests including a new integration test that walks the markup and fails on an unnamed control. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8e4e24ad09 |
Say what the drag payload actually is: the arming, not the cargo
`drag-payload` was documented as "called when a drag starts, so it always reflects the selection as it is at that moment". Neither half is true, and the comment is the only reason anyone would believe the drop reads it. `DragArea` tests `data.is_empty()` in its event filter — on every pointer event, the first one included, which arrives long before there is a drag. And a Slint binding that calls a callback has no dependency to be invalidated on, so it is evaluated once, when a finger first lands on that cell, and cached for the life of the cell. What it answers is therefore always an empty selection. None of the drop handlers read it; every one of them reads `dragging`, which `drag-started` fills in at the moment that matters. What this callback does is keep the `DragArea` armed, and it manages that only because `set_user_data` is called unconditionally — an empty `Vec` is still user data. Guarding that call, which reads as an obvious tidy-up, would silently stop the grid dragging at all. Comments only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
1a59fb33c1 |
Stop the press that starts a drag from deselecting what it grabbed
Dragging a selection of forty photographs onto a collection filed one. Selection mode reports every press as a ctrl-press — deliberately, so touch and pointer go through one set of rules rather than two — and ctrl toggled. So the press that took hold of one of the forty took it *out* of the selection on the way down. `drag-started` then looked at the cell under the finger, found it unselected, and did exactly what it is meant to do with an unselected cell: made it the whole selection and carried it alone. The only sign was the grabbed cell's ring blinking out at the moment the user began to move. A plain press has never had this problem, because pressing an already-selected cell has always been documented to leave the selection alone — for precisely this reason. Ctrl now does the same: adding still happens on the press, since the drag reads the selection immediately, but *removing* is handed back as `Press::Deferred` and applied by the click. Slint reports a click only for a press that stayed within `tap-slop`, so a tap still toggles and a drag never does. The unit tests now go through a `click` helper — a press and the release that follows it — because that is the only thing a user can perform, and calling `apply_press` alone would assert against half the policy. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8df6000e4b |
Decode the scene model into per-category weights
The weights landed last commit with nothing to read them. This is the decoder, and the shape of it follows from one property worth stating before the code: the categories must partition the image. ## Why a partition, and not a mask per category The scene tab applies one grade to every pixel of a category — lift the sky, desaturate foliage — and both grades meet at the horizon. If each category carried an independent mask, feathering them outward would make the boundary band belong to both, so both grades would land there and every horizon would acquire a visible seam. Feathering has to *blend* there, not accumulate. So `marginalise` takes one softmax over all 150 channels and sums within each category. Grouping cannot change a total of one, so the listed categories plus the unlisted remainder sum to one at every pixel, by construction rather than by normalising afterwards. `parse_categories` refuses a descriptor that claims a class twice, because that is the one input that would quietly make the property untrue. ## The descriptor is data, and hand-written `models/scene/categories.txt` groups ADE20K's 150 classes into the eight a photographer would recognise. It is a file rather than a table in Rust for the reason `models/LICENCE.md` predicted — a vocabulary is model metadata — and it is line-oriented with comments rather than JSON like the `.classes.json` beside it, because that file is generated and this one is argued. Why `swimming pool` is water and not architecture belongs next to the line that says so. Classes are named, not indexed. An index is silently wrong after a re-export; a name is loudly wrong, and the loader refuses one the model does not have. ## Resolution, kept visible `Scene` holds the native 80×80 logit grid and resamples on demand rather than upsampling once at load. The coarseness is real — it is what the graph produces — and a type that hides it behind an early resize invites callers to expect detail that was never there. `rasterise` is where the letterbox inverse lives, once. `Letterbox` and `Window` become `pub(crate)` and `to_proto` generalises to `to_grid`, because both dense outputs this crate reads are an even fraction of the same letterboxed square and differ only in the divisor. ## Verified by looking, which is the only way this gets verified `examples/scene.rs` writes the photograph dimmed outside each category. A transposed axis or an off-by-one in the inverse produces perfectly plausible weights over slightly the wrong pixels, and no unit test catches that. On an indoor frame the person mask lands on the person, including the outstretched arm, and sky reads ~5% against a bright ceiling. It doubles as the benchmark, because every timing quoted while this model was chosen came off a laptop compiling other things and none of them belong in a document. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e8e96eed40 |
Measure the performance targets §8 has been promising, and fail on a regression
docs/requirements.md §8 has said since it was written that performance is verified by "an automated benchmark suite against a synthetic 50k catalog, run per-commit … A regression beyond stated tolerance fails the build." There was none. No benches/, no [[bench]], no criterion, no synthetic catalog, and three CI workflows that between them measured nothing. Ten performance requirements could therefore be neither passed nor failed, and five of them carried a TRACES: tag regardless. tools/bench is the half of that promise that can be kept honestly on a runner with no GPU and no display. # The fixture Rows are cheap and pixels are not, so it builds fifty thousand catalog rows over a pool of a dozen real files, each referenced by several thousand of them. Everything the catalog half touches is rows and is exact at full scale; everything the pixel half touches is one file at a time and does not care how many rows point at it. Fourteen megabytes on disk instead of two terabytes, and neither half is flattered by the trade. It is reproducible from a seed, and a stamp beside it — seed, row count, source size, dr-catalog's schema version — rebuilds it rather than letting a run be compared against a baseline that describes a different library. # What it can now pass or fail NFR-P1, and R2's second sentence with it: Catalog::open plus the count, first window and timeline the grid cannot paint without. The interesting part turned out to be the open itself — schema::backfill runs three passes over the images table on every open, which is O(library) work on a path whose budget is stated in absolute seconds. Tagged TRACES: NFR-P1, on a gate that fails if it breaks. NFR-P3: thumbnail throughput on the embedded preview path, through the same per-image work spawn_thumbnail_sweep does and in the same shape — chunks of 96, lanes owning disjoint slices, the single thread that owns the store writing the finished chunk. Mirrored rather than called, because that function takes a RemoteBackend and would measure somebody's network. Tagged TRACES: NFR-P3. # What it deliberately does not claim NFR-P7 is the whole chain, and only the encode half of it runs without an adapter. So the export row is a one-sided gate — over two seconds in the encode alone violates the requirement; under it proves nothing — and there is no TRACES: NFR-P7 anywhere. NFR-P8 is about the application at idle, and the probe is a process holding the catalog and nothing else, so it records the catalog layer's share and carries no budget until somebody decides what that share should be. No tag there either. CONTRIBUTING.md asks that a requirement be closed by a test that would fail if the behaviour were removed, and two more plumbing tags is what this repository already has too many of. NFR-P8 also gets the answer §4.1 demands: RSS is exclusive of device-local GPU allocations and cannot be made otherwise, because such an allocation never enters the process's address space. The requirement should be restated as two figures, and docs/benchmarks.md says so. # Two gates, and why one of them steps aside off the reference desktop The budget is the requirement's own number and never moves. The baseline is what the reference desktop last measured, and drifting 15% past it fails the build even while still inside the budget — which is how performance rot actually arrives, never over the line, always a little worse. A budget written for twenty-four threads cannot be asserted on a two-core container. §8 names the reference desktop, not CI, so each metric declares whether its budget is machine-sensitive; those are asserted under --reference and reported everywhere else. Catalog open is not one of them: two seconds against an expected figure two orders of magnitude smaller is a threshold any machine can be held to. This is the trap core/dr-gpu/tests/frame_budget.rs already refuses — a red gate everybody learns to ignore. # The baseline ships with no numbers in it Every recorded field is null, because nobody has run it yet. Writing plausible-looking figures would make every later comparison a comparison against a guess, and the first real regression would be invisible. Run `dr-bench record --reference` on the reference desktop and commit the diff; until then the budget gate works and the report says the other one cannot. # CI .gitea/workflows/benchmark.yml, and its own workflow rather than a step in build-and-test.yml: a red "Build and test" says the code is wrong, a red "Benchmarks" says it got slower, and the second must not be reachable by retrying a flaky compile. The cpu job runs on every push and builds -p dr-bench alone — which is why that crate depends on no GPU and no UI crate. The gpu job is the frame budget that already exists and already skips without an adapter, on workflow_dispatch, because building wgpu on every commit to rediscover that the runner has no device is not a use of anybody's minutes. |
||
|
|
1ff52102b6 |
Drop the anchor bookkeeping the double tap took with it
`previous_anchor` existed for one gesture: a double tap in selection mode took the range from where selecting began, and both taps had already moved the anchor onto the cell being tapped, so the origin the user meant had to be remembered separately. That gesture is gone — "Select to…" says what it is about to do instead of hiding a forty-image range behind a thing a hand does by accident — and what is left is a field that four places write, `PressUndo` carries, `cancel_press` restores, and nothing at all reads. `apply_press` is `select_row`'s only call now that there is no anchor to remember, so the wrapper goes with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8848bbcff3 |
Stop drawing a hover ring on a device that cannot hover
A cell drew a 1px ring while the pointer was over it, in the same colour as the 2px ring that means "selected". On a tablet there is no pointer, and `has-hover` is not the harmless no-op that implies: Slint raises it on a touch press and lowers it on the `Exit` that normally follows the release — but the release that ends a pinch carries no `Exit` at all, and neither does a finger lifting while a second one is still down. So resizing the thumbnails, which is a pinch, left a ring around whichever cell each finger had come down on. The grid then showed boxes around photographs that were not selected, a pixel thinner than the ones that were, with nothing to tell them apart. Hover chrome has no meaning once there has been a finger, so it is not drawn: the same `touched` latch the rating strip already keys off. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
258aae71d6 |
Regenerate the matrix without the tool's own fixtures
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
bddd30fba2 |
Measure the chrome's contrast, and say why font scaling is not a multiplier
NFR-A11Y-2 has three clauses and this branch closes one of them. The other two — WCAG AA contrast on non-canvas UI, and platform font scaling honoured without clipping — are now measured and reasoned about rather than left as two sentences in the requirements register that nobody had checked. Contrast is measured, not estimated. Every ink against every surface it is drawn on, by the WCAG 2 formula, and the result is narrower and more specific than "the palette is dark": `ink` and `warn-ink` pass everywhere, the inverted cases on the near-white fills are the best-contrasting text in the application at 18.6, and exactly two tokens fail. `ink-faint` reaches 4.5:1 on no surface at all — 3.97 at its best — and it is the ink for every caption, every section name and every count. `rule` reaches 3:1 on none either, and it is the border of every button, field and panel, so an unfilled secondary button is a 1.4:1 outline on a 1.2:1 ground. Neither is an oversight, which is why they belong here rather than in a bug list. They fall out of the palette's own argument: a bright surround biases how a photograph is judged and hue is banned outright, so all the signalling is luminance and the luminance is deliberately spent on the image. "Text you are meant to skip" and "text everyone can read" are in real tension. The fix is a re-derived ink scale and a stronger rule, both of which change how the application looks beside a photograph — a screenshot and an opinion, not a patch, and not something to do blind. Font scaling is the more interesting entry because the obvious fix is wrong. It is not a multiplier on the type sizes: the layout rests on constants that are not derived from them — control-height, touch-target, rail-entry-height, panel-width, and a dozen fixed heights written at their call sites — and scaling the type alone clips against every one, silently, because Slint elides rather than errors. The colour mixer is the sharpest case, thirty-six controls in a 360px column sized so a track and a swatch and a readout share a line. So the entry sets out the four pieces in dependency order, and notes that the first is nearly built already: `live-style` makes `build.rs` emit `in-out` theme tokens that Rust writes at startup, which is exactly the mechanism a scale factor needs. Both entries carry the falsifiable condition this document asks for, and the contrast table is the baseline a later measurement compares against. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b63ce0290b |
Wrap the three lines that ran past the column the rest of these documents keep
Prose-only. Three paragraphs added in this branch ran to 105, 113 and 166 columns against a document that wraps at 100 everywhere else, the last because an edit joined a new sentence onto an existing paragraph's opening line. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f1b0634bd1 |
Stop calling NFR-COMPAT-2 unstated in the paragraph after citing what states it
outstanding.md §5 said NFR-COMPAT-2's v1 distribution channels were "Unstated" immediately after the FR-PLAT-LIN-3 paragraph above it cites docs/distribution.md — a document whose own header says it satisfies NFR-COMPAT-2 and whose §1 is a table of five channels with the state of each. The requirement asks that the channels be stated. They are: Arch source package and Flatpak in tree, AppImage a v1 channel with no recipe yet, F-Droid a v1 channel not yet submitted, and Play deliberately not v1. The deferral is part of the statement, not a gap in it. What is genuinely open is the coupling the requirement exists to flag — whether Play makes ARCH §6.9 binding — which distribution.md §6 argues runs the other way for this project, and which spike S11 has not been run to confirm. That, plus two channels that are decisions rather than recipes, is what the entry now says. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8865743db6 |
Split FR-EXP-1, so its tag stops standing for a deferral as well
FR-EXP-1 listed four things in one sentence: JPEG, PNG, TIFF at both depths, and AVIF or JPEG XL. Three of them are built, encode, embed an ICC profile, and are covered by tests that walk every offered format. The fourth is a deliberate deferral — `export` returns `FormatUnsupported` for AVIF and JPEG XL, and `the_formats_without_an_encoder_say_so` pins that behaviour in place. Fused, the requirement could only be tagged dishonestly or not at all, and not at all is worse: it would delete the register's record of the part that is finished, which is most of it. So the v1 scope is now JPEG, PNG and TIFF with the configurability clause, and AVIF and JPEG XL are stated as post-v1 with the condition that already holds — either may appear in the settings page before its encoder does, provided choosing it fails with a typed error naming the format rather than producing a file. That is what `every_offered_format_either_encodes_or_explains_itself` exists to guarantee, and it is why a format cannot be added to the picker and quietly reach an encoder that does not handle it. No intent is dropped. AVIF and JPEG XL remain wanted; they are now scheduled rather than silently outstanding. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e0da93c59a |
Reword FR-RAW-2's mechanism to the one that serves its purpose
FR-RAW-2 required RAW decoding to sit behind "a trait taking a SourceRef, not a filesystem path". The purpose is met — `dr_decode::decode` takes `&[u8]` and the crate has no path-based entry point anywhere — and the mechanism is not, because a decoder taking a SourceRef would be a worse design than the one built. A SourceRef is opaque. The only thing that turns one into bytes is `Storage`, which lives in platform/dr-plat, so a decoder taking a SourceRef must take a Storage with it: retry, permission loss and remote fetching move inside the decoder, and the decoder becomes constructible only where a Storage exists. Bytes in, image out, is narrower and more portable — the decoder cannot know where its input came from, which is the property the requirement wants. The Nextcloud case is the one a byte-oriented API looks like it should lose, and it is the clearest illustration that it does not. `dr_decode::HEADER_BYTES` declares how much of a file the decoder needs in order to read metadata, and `import.rs` fetches exactly that range through `Storage::read_range` before calling `dr_decode::metadata`. The decoder states a requirement; the storage layer satisfies it. A decoder holding its own SourceRef would have had to carry the range policy itself. The trait half of the clause is left standing and unmet. There is one decoder reached through free functions, so "a second implementation may be added without changing callers" is still outstanding work rather than a description. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |