0aff5e6c8c1a5bd4841b48f3ff8fb403c5bfa2da
389
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
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> |
||
|
|
53dc2d171e |
Say where the grid is on the capture-time axis from the first frame
The sidebar's marker rested greyed at mid-track until the first scroll or scrub. The reasoning was that anchoring it would imply a choice the user had not made -- but that reads the marker as reporting an intention, and it does not. The sidebar's whole claim is to say *when* you are, and that is known from the first frame: the grid is at the top of the library, or wherever it was last left. So a launch opened with the marker halfway down an axis whose visible photographs were all from the wrong end of it. Dimmed rather than absent, which made it look like a reading rather than the absence of one. Seeded in `refresh_timeline` -- the one place that decides what the marker says, and the one that runs on every route which builds the axis -- and only when nothing has claimed it, so a scroll or a scrub still speaks for itself. 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> |
||
|
|
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> |
||
|
|
9ddc1273c0 |
Make a sweep that fails everything say so
A run over 169 images failed all 169, in fourteen seconds, and reported
"0 face(s) in 0 image(s)" -- the same sentence a run that indexed
nothing because there was nothing to index produces. Three separate
places dropped the information on its way to the screen.
The progress count only moved on success. FaceSweepMessage had no
failure variant at all, so a pass where every image failed sat at 0/169
from the first tick to the last: the receiver was told the total, told
nothing, and told the pass had ended. That is indistinguishable from a
hung job, and it is what it was taken for.
Finished already carried a failed count and identity_ui matched it with
`Finished { .. }`, throwing the number away and printing the tidy
success line regardless.
And the reason each image failed was logged at debug, which is off, so
169 consecutive failures left no trace of why anywhere.
Failed { images } now carries the count back per lane batch, the
progress counter advances on it, and both the running status line and
the finishing activity row say how many could not be read. A batch
rather than one message per image because failures come back lane-sized
and the useful number is how many.
Also renames the store sweep's guard to MIN_CROP_EDGE with the rest of
that constant's move, since the two touch the same lines.
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> |
||
|
|
41c655c176 |
Stop the local-store pass pretending it can still index
spawn_store_face_sweep detects on what the thumbnail store holds, and the store's largest tier is FACE_TIER -- 1024, which the floor now refuses. Left alone it would list every outstanding image, decode every proxy it had, and report every single one as failed: twenty thousand refusals all saying the same thing, with the real explanation buried at debug level. There is no repair available inside this pass. It has no larger tier to read; the pixels the detector needs have to come off the server, which is spawn_face_sweep's job and always was. So the honest behaviour is to check the tier against the floor once, say plainly which pass to use instead, and stop. It is only reached from examples/face_index.rs, so this costs the tool its --run mode and no shipped behaviour. FACE_TIER keeps its value and loses its meaning. It is now the tier a stored crop is *cut from*, which 1024 is entirely adequate for -- the face has already been located and the crop only has to be looked at -- and no longer the tier faces are *found* on, which is the thing that was returning 0.078 faces per image. IndexAudit's ready/awaiting_proxy split goes the same way. It existed because one of the two passes could only do images that already had a proxy; now that detection refuses that proxy's size, both halves cost the same fetch, and a status line reading "169 ready to index" implies a distinction that no longer decides anything. One number. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d0ebc9f571 |
Count the same images in the progress figure that the sweeps index
The Identity screen said 4,593 images were left to index and stayed there for hours across repeated runs, which is what a stuck job looks like. It was not stuck. 4,424 of those 4,593 are shadowed -- the JPEG half of a RAW+JPEG pair -- and no sweep will ever index one, because every work list is built on VISIBLE, which excludes them. They are not separate photographs and the grid does not show them either. But faces::coverage counted them: its denominator was "images WHERE trashed_at IS NULL", with no shadowed_by clause. So the outstanding figure had a floor of 4,424 that no amount of work could bring down, and Coverage::is_complete could never once return true no matter how completely the library had been indexed. A progress number that cannot reach its own target is worse than no progress number. The fix is to count the population the sweeps actually draw from, in all three places that were describing it differently: coverage's denominator and its indexed join, and audit's split of the outstanding set, which had the same gap and fed the same status line. On the reference library the denominator goes from 23,531 to 19,107 and outstanding from 4,593 to 169 -- the second of which is a number the user can watch go down, and which turns out to be a real and separate fetch failure worth chasing. 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> |
||
|
|
7981718d83 |
Time the phases of a launch, because the tablet has no profiler
Benchmarks / CPU and I/O (per commit) (push) Successful in 3m8s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 1h19m46s
Build and test / Layer separation (push) Successful in 47s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Traceability / Requirement traces (push) Failing after 46s
Build and test / Android (aarch64) (push) Successful in 1h1m21s
Two things are now off the launch path and the rest of it is unmeasured. There is no way to attach a profiler to an Android launch, and the window that matters — from `android_main` to the first `poll_events` — is over before anything on the device can be asked a question about it. So a line in the log file is the only measurement anybody gets. Three of them: the GPU open, the window build, and the total to the event loop. The last is the one that matters, because it is the figure the input dispatcher is counting against — anything approaching five seconds there is the next ANR whatever the phases above it say. The GPU open is timed rather than moved. It is a Vulkan instance, an adapter enumeration and a device request, and on the desktop it cannot be deferred at all: it selects the Slint backend, and creating a window selects one for us. On Android it could be, because nothing shares that device with the compositor (TD-1) — but "could be deferred" is not "costs enough to be worth deferring", and there is no number yet that says which. This is the line that will produce one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d48e9f6033 |
Open the catalog on a worker, so a launch is not a page-by-page read
The second thing standing between `android_main` and the first `poll_events`, and the one that grows with the library rather than with the APK. `library_ui::open` called `show_catalog_now`, which called `Catalog::open_verified`. That runs `PRAGMA quick_check`, which reads every page of the database, and then `Catalog::open`, which takes a full SQLite backup of the file before a migration and rewrites its structure afterwards. On a 50,000-image library that is tens of megabytes of I/O on a tablet's flash, and it happened before the window had painted anything — so on Android it was counted against the five seconds the input dispatcher allows, and on the desktop it was a launch that sat on a blank window. `dr_catalog::recovery`'s own module documentation says the check is affordable "at startup, where a failure has a user in front of it who can answer a question". That was the intent and it was not true: there was no interface yet in which to ask. Now there is, because the open happens on a worker and the answer arrives on a channel drained by a timer — the same shape the scan, the thumbnails and the login already use. The gate the synchronous call provided is kept, and is the reason the scan moved with it. `Catalog::open` succeeds on a damaged file whose header survived, so a scan running beside an unanswered recovery question writes ETags and image rows into damaged pages and turns a catalog that had a backup into one where the backup is the only copy left. So the scan now starts from the drain, on the two answers that permit it, and not at all on `Corrupt`. `library-scanning` stays true throughout, which hides the Rescan button and stops the gate being merely advisory. What the user sees while it runs is a third empty state. The grid already refused to conflate "still scanning" with "scanned, found nothing"; "opening the library" is a third answer and it gets its own sentence, because a grid saying "Scanning…" while nothing is on the network is the same kind of lie the other two were separated to avoid. `show_catalog_now` stays, unchanged and blocking, for `recovery_ui`. That call site has the event loop running, has just replaced the file under a `forget_catalog`, and has `recovery-busy` on screen — the same reasoning `recovery_ui::answer` already gives for doing its file copy in place. The part both paths share is now `adopt_catalog`. One consequence worth naming: the cache-usage figure on the settings page was read at startup from a catalog that is no longer open by then. It moves to the page's `on_open` closure, beside the face coverage, which is read there for exactly the same reason — it is only ever looked at while that page is on screen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
1c5849ebe8 |
Unpack the bundled models on a worker, not on the way to the first frame
Launching v0.10.0 on the tablet produces an ANR: "Waited 5000ms for MotionEvent", 5,827 ms on one input sequence, over a window that has never painted. The app recovers and sits at 0% afterwards, so it is a startup cost rather than a hang. The structural fact behind it is that `android_main` runs with the activity's input channel unserviced. Nothing drains it until Slint reaches `poll_events`, and Slint does not reach `poll_events` until `dr_ui::run` calls `window.run()` on its last line. Every millisecond before that is a millisecond the input dispatcher waits on, so five thousand of them is an ANR whatever the work happens to be. The largest single piece of that work was here. `install_bundled_models` copies 41 MB on the first launch after an install — 24.9 MB of scene model, 13.6 MB of embedder, 2.5 MB of detector — each read whole out of the APK into a `Vec` and written to `/data`, in a loop, on that thread. v0.10.0 is the release that added the scene model, which is 60% of that total, and it is the release the ANR appeared in. The 8,010 minor faults in the report are about what 41 MB of freshly touched pages costs. So it moves to a detached thread and the function returns as soon as the thread is running. Nothing on the launch path wanted the result: the only two things that read these files are the People screen and the scene tab, both of which are reached by hand, minutes later, from workers of their own. What that costs is a window in which a model looks absent. `library::face_models` and `library::scene_model` decide availability on `is_file()`, so during the copy both report their feature unavailable — which is the same answer they give a build shipping no weights at all, the ordinary case both were written around. Briefly pessimistic rather than wrong, and the temporary-name-then-rename that was already there is what keeps it from being worse than that: a lookup never sees a half-written file, only an absent one. Both call sites now say so. A completion line reports the bytes copied and the milliseconds taken, including when it is zero, so the second launch after an install can be told from the first in a log rather than by inference. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4efab496c2 |
Put the refinement on a slider, per layer
Benchmarks / CPU and I/O (per commit) (push) Successful in 3m27s
Benchmarks / Frame budget (on demand) (push) Skipped
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Build and test / Desktop (Linux) (push) Failing after 1h14m30s
Build and test / Layer separation (push) Successful in 43s
Traceability / Requirement traces (push) Failing after 46s
Build and test / Android (aarch64) (push) Successful in 1h8m56s
The evidence was being gathered and spent immediately at one strictness nobody could see or change. This makes it a control. `MaskLayer::refine` is shaping, like the feather, and it lives on the layer for the layer's reason: two layers may sit on the same category and want different amounts of it, and the model ran once for both. ## What the segmentation now stores `CategorySummary` keeps the **coarse** mask and the `Refinement` beside it, rather than a refined mask. That is what gives the control an off position that is bit-for-bit the model's own weighting, and what stops a strictness change from needing the model again. `category_mask_at` borrows at zero and wherever no refinement could be fitted, so a layer nobody has touched costs nothing over the old path. The fit moved to the far side of the orientation permutation. The verdict is a per-pixel field over the same grid as the mask it gates, so fitting it upright would mean permuting a proxy-sized buffer afterwards to match — a second rotation, and a second chance to get one wrong. One logit cell is the same number of pixels either way: the letterbox scales by the longer edge and a permutation does not change which edge that is. The two frames became a `Frames` struct rather than six parameters. This function reads the picture twice for opposite purposes — the model needs it upright or it recognises far less, the refinement needs the sensor's grid — and a transposed pair produces a plausible mask over slightly the wrong pixels, which is the failure this module is most prone to. ## It rebuilds the field, and the signature says so `refine` is mixed into `subject_signature`. Unlike a feather, which is read off a field that is already correct, this changes which pixels are in the mask at all — so it changes the coverage the field is measured from. Omitting it is the bug where the slider moves and nothing happens until some unrelated control invalidates the cache. That puts it in the same cost class as a close or an open, which is why the row takes `SliderRow::changed` — already once-per-gesture, since that row takes `SliderTrack`'s `committed` internally — rather than a live stream. The slider is offered only where there is something to move: a category source, *and* a refinement the frame actually gave enough to fit. A control that moves and does nothing is worse than an absent one. ## Two defaults that are deliberately different A layer added from the panel starts at 4.0, because a category's edges are twenty proxy pixels wide before anything is done to them and a photographer adding a sky mask wants the sky rather than the sky plus every chimney in it. A layer read from a sidecar with no `refine` key starts at **zero**. A file written before this control existed has to render as it did then, and a default of 4 on absence would quietly re-grade every stored category mask in the catalogue. `a_categorys_refine_strictness_survives_and_defaults_off` holds both halves, and `an_out_of_range_refine_is_clamped` holds the file to the scale — past the top of it every colour fails and the mask deletes itself, which reads as lost work rather than as a bad file. `MAX_REFINE` is dr-pipeline's own constant mirroring `dr_segment::STRICTNESS_MAX`, following `Falloff` and `Morphology`: this crate holds the description of an edit and must not depend on the crate that runs a model. dr-ui is where the two meet, and the only place that converts. Verified: fmt clean, clippy --workspace -D warnings clean, 488 dr-pipeline and 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> |
||
|
|
bfdb6d5e4b |
Cut the People rail's portraits off the blocking path
Opening Identity cut a portrait for every person before the screen was allowed to appear. Measured against the reference library: 3.6 seconds, of which 3.2 is 931 full 1024px proxy decodes — the fallback for faces indexed before crops were stored beside them. The rest of the wait was the rail building 14,268 rows, fixed in the commit before this. `refresh` now draws with the portraits already cached and hands the rest to `fill_covers`, which cuts them 8ms at a time — under half a frame — behind a screen that is already up. Blocking work on that path goes from 4,965ms to 16ms on this library. A timer rather than a thread. The work is a catalog query and a decode against a `ThumbStore`, and both handles live on the UI thread; a worker would need its own connection to the same file, which is what the sweep and the regrouping pass do because they run for minutes and would otherwise be unbounded. This is seconds of small, independent pieces, so slicing answers the same question more cheaply. `pending` is in rail order, and the rail is sorted by confirmed faces, so the portraits the user is looking at are cut first. It patches single rows rather than reloading: a reload would rebuild the model on every tick, and a model replaced underneath the `ListView` is what the slicing exists to avoid. Each patch checks the row still holds the person it was started for — a stale index would draw a face beside somebody else's name — and stops the fill when it does not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d7b851f622 |
Build the People rail rows you can see, not the ones you cannot
`Flickable { VerticalLayout { for ... } }` instantiates every row. On the
reference library that is 14,268 row subtrees, which measures at 4.2
seconds before a pixel is drawn — and it was paid again on every action,
because every action reloads the model. Roughly half the wait between
clicking "Identity" and the screen appearing was this.
Slint's compiler has a virtualising path for a `for`; it is what makes
`std-widgets`' `ListView` cheap. It keys on the parent element's base
being *named* `ListView` and exposing the five lengths its layouting code
writes back, and a custom base is explicitly allowed. So `widgets.slint`
grows one, and `std-widgets` stays out of the file that establishes our
style. Measured on the real screen: 200,000 rail rows now render in 62ms.
Three things had to be true together, and each was silent on its own.
**The row height must be constant.** The rail hid a set-aside person with
`height: cond ? 52px : 0px`, a height that reads the model — so the
layout cannot place row N without building rows 0..N, and Slint builds
them all. The filtering moves to Rust, where the toggle was already
reloading anyway.
**The list must not be wrapped.** It carries its own stretch and preferred
size, having no natural height to offer; a Rectangle in between hands the
layout that Rectangle's constraints, which are taken from the list and
are therefore nothing.
**The panel must let it fill.** `Panel` lays its children out with
`alignment: start`, which gives each its preferred height — right for a
column of sliders, wrong for anything that scrolls. Hence `Panel.fill`.
Get any of them wrong and the rail renders empty, with no error and a
model full of people. All three were, in turn, before a headless render
of the real screen showed a blank rail.
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> |
||
|
|
94b1b9e0cc |
Reconcile what four branches each built separately
Three seams, found by the first compile after the merge. Two branches implemented 'can this scope be reordered' independently: one from the collections model, one from the catalog through orders_manually, on every re-read and excluding the trash. The second is the better answer and is what survives; it only needed to set the property app.slint declares. Two lints from scene-mask-ui, which was merged mid-flight and had never been through -D warnings: an is_none check spelled out where clippy wants ?, and a return in a cfg block's tail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
19b56ad85c |
Merge: collection ordering, and a range that says where it ends
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> # Conflicts: # docs/traceability.md # ui/dr-ui/src/collections_ui.rs # ui/dr-ui/src/library.rs # ui/dr-ui/ui/app.slint # ui/dr-ui/ui/library.slint # ui/dr-ui/ui/widgets.slint |
||
|
|
328fda6f7c |
Merge: mask a whole category, not just one instance
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> # Conflicts: # apps/darkroom-desktop/Cargo.toml # docs/traceability.md |
||
|
|
86260b5028 |
Bind a category layer to its own distance field
A category mask showed nothing and its adjustment covered the whole
photograph. Both from one line: the loop in `MaskPass::rasterise` picks a
distance field by matching `layer.source`, that match named only `Subject`,
and a `Category` layer fell through to `_ => (&self.empty_subject, 0)` — a
1x1 placeholder. No field, so nothing to draw and nothing to confine the
adjustment.
The comment three lines above the arm I missed describes the failure I then
shipped:
an absent mask that defaults to "everything" would apply the
adjustment to the whole photograph
There are *two* matches on `layer.source` in that loop — one choosing the
field, one building the params. Adding the category to the second and not
the first compiles, runs, and is wrong in exactly the way the first one
warns about.
## Also: a missing mask must still be the right size
Both model-backed arms of `ensure_subject_fields` used `unwrap_or_default`,
which yields an empty `Vec` when the coverage is gone. `SubjectMasks::upload`
rejects a wrong-sized field and fails the whole batch, so `self.subjects`
becomes `None` and *every* layer in the stack loses its mask — one stale
reference silently unmasking the others.
Pre-existing, and it mattered less when the only model-backed source was a
subject: an instance index goes missing rarely. A category name goes missing
whenever the descriptor is edited, which is a thing the descriptor exists to
allow. A full-size empty field costs one layer instead of all of them.
Neither of these is reachable from a test on this machine — both live past a
GPU adapter and a real segmentation — so they surfaced the only way they
could, by someone opening the app and looking.
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> |
||
|
|
7f350e8c4c |
Offer the categories in the masks panel
Everything before this was reachable only from an example that writes PPMs. This is the part a photographer can touch: a list under the subjects, click one, get a mask layer for every pixel of that category. ## Under the subjects, and the order is the argument Clicking the photograph is how a local adjustment usually starts, so the things the model *found* come first. A category is the move you reach for deliberately — grade the sky, not this one bird — and putting it second says so without a word of explanation. Coverage is shown for the same reason a subject's score is: it tells the photographer whether a category is worth a click before they spend one finding out. ## Not a new tab, which is what was asked for A develop tab is derived from `Attribute`, not declared — the tabs exist because operations claim an attribute, and no amount of Slint adds one. A seventh attribute would have meant duplicating every adjustment once per category, and the combinatorics get silly by the third. Reached through masks instead, a category composes with every adjustment that already exists, and inherits feather and falloff rather than needing its own. What was described as "per-category sliders with feather and decay" is exactly what this is; only the door is different. ## Verified as far as it can be here Compiles, populates, round-trips, 560 dr-ui tests green. **Not clicked** — synthetic input is blocked on this setup, so how it looks and feels is unverified and wants a human at the window. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
763dfd353a |
Weigh the categories in the same precompute, and mask with them
The scene model shipped with a decoder and no caller. This runs it. ## Beside the instance pass, not instead of it `compute` now does both on the same upright frame and lays both back down the same way, so instance masks and category masks index into one grid — the sensor's. A failure in the scene half is logged and dropped rather than propagated: no scene model is an ordinary state, and a photograph that can still be masked by subject should not become unopenable because the categories are missing. Categories under half a percent of the frame never reach the cache. A control that does nothing when moved is worse than an absent one, and each one it skips is a proxy-sized buffer not allocated. ## The shader needed nothing A category reaches `dr-gpu` as a soft coverage buffer at proxy resolution, turned into a distance field — which is exactly what a subject is. So they share `MODE_SUBJECT`. That is not a shortcut taken for speed: the shader has no way to tell them apart and no reason to want one. What differs is only which model produced the coverage, and that has already happened by then. Feather, falloff, dilation and erosion therefore work on a category on the day it arrives, because they were never subject-specific. ## Where the weights come from `scene-model` compiles the graph in and the desktop app takes it; Android leaves it off and reads the copy `install_bundled_models` unpacks, because 24 MB of constant is worth avoiding in a mobile install and not worth the plumbing to avoid on a desktop one. Embedded is tried first — a build that has the weights compiled in should not be silently overridden by a stale file in a data directory. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d70dcf78d1 |
Merge: recover a damaged catalog, and capture a crash locally
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> |
||
|
|
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 |
||
|
|
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> |
||
|
|
7312aceded |
Get the scene model onto the devices that need it
The decoder can load from a path; nothing yet put a file at one. Four packaging routes, and one lookup that finds the result. ## Not `include_bytes!`, unlike the instance model The instance model is 11 MB and compiled in, which was the right call for it: Android hands the app no filesystem path (ARCH §6.9) and 11 MB is tolerable. The scene model is 24 MB, and 35 MB of constants in the binary is paid by every install whether or not the tab is ever opened. So it follows `models/face/` instead — carried as an APK asset, unpacked once at first launch into the shared directory a desktop install already uses, after which every lookup finds it where it finds a desktop user's. Assets are stored rather than deflated in the APK, so unpacking is a copy rather than an inflate. `embedded-scene-model` exists for the desktop build with nowhere else to read from, and for tests wanting the real graph. Off by default, which is the asymmetry with `embedded-model` and the reason for a separate feature. ## Three files, all or none `scene_model` insists on the graph, its vocabulary and the category descriptor together, for the reason `face_models` insists on its pair: a graph alone decodes to 150 anonymous channels. Reporting the set missing beats starting and failing at the first inference. ## The two model sets are not the same kind of thing `install_bundled_models` now carries both, and the distinction is worth keeping in view. Face weights are absent from the repository *by design* — the InsightFace grant is research-only (docs/faces.md §2) — so a build carrying none is ordinary. The scene model is committed, so a build carrying none means a checkout without `git lfs pull`. Neither is fatal. A photo editor that refuses to start over a missing grading feature is worse than one that starts without it, so both report themselves unavailable exactly as face indexing already did. The LFS-pointer guards apply to the `.onnx` only. The vocabulary and the descriptor are legitimately a few kilobytes, and a size check that fails on them would be a guard against the wrong thing. `scene_model` is exported ahead of the tab that will consume it so the packaging added here has something to be verified against — assets written where no lookup looks would be a silent mistake for as long as the tab took to arrive. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
8eeb9ba0f6 |
Offer the backup, and then the rebuild, when the index turns out to be damaged
NFR-R6 asks for an integrity check at startup and two offers behind it, and none of it existed. `PRAGMA integrity_check` appeared nowhere in the tree, `Catalog::open` was `open` → `configure` → `migrate` → `backfill` and nothing else, and corruption therefore surfaced as whatever rusqlite error the first unlucky query happened to produce — "database disk image is malformed" attached to a thumbnail refresh, elided into a 34px banner, over an empty grid saying "No images found · Check the library folder". Two messages that disagreed, and no way forward but deleting catalog.sqlite by hand. The property that makes the second offer real was already here and load- bearing: the catalog is an index, not a source of truth, rebuildable from sources plus sidecars (invariant §5.2.4, cited by schema.rs, trash.rs and lib.rs). And sync.rs already knew how to take a coherent snapshot of a WAL database. What was missing was the check, the type, and the conversation. Four pieces: **The type.** `CatalogError::Corrupt`, and — the part that makes it worth having — a hand-written `From<rusqlite::Error>` that classifies rather than wraps. `SQLITE_CORRUPT` and `SQLITE_NOTADB` become `Corrupt` wherever they arise, so a background job that trips over the damage first reports the same thing the startup check would have. `SQLITE_IOERR` and `SQLITE_BUSY` deliberately do not: a dropped network mount is a different problem, and telling someone to rebuild their index would be a wrong answer delivered confidently. **The check.** `Catalog::open_verified`, `quick_check` before the open rather than after, because opening runs migrations and a damaged catalog with an intact header would otherwise have structure rewritten on top of structure that is already wrong. Bound to `open_verified` and not to `open`: the check reads every page, which is affordable once at startup where a user can answer a question, and not affordable on the dozens of opens a session's background tasks make. **The backup.** NFR-R2's second clause, taken between `configure` and `migrate` in `Catalog::open`. A migration is the one routine operation that rewrites table structure, so it is the likeliest way this file becomes unreadable, and it is the last moment the pre-migration state exists to be copied. Three generations, through SQLite's backup API after a TRUNCATE checkpoint — never `fs::copy`, which on a WAL database backs up a state older than the catalog and possibly torn. A failure to take the copy is logged, not raised: a full disk must not be what makes a library unopenable. **The conversation.** The first line of the dialogue is that the photographs and the edits are safe, before the diagnosis, because that is the question the user is actually asking. Then the two offers, which are *not* interchangeable and are not presented as if they were: a restore keeps collections, and a rebuild cannot, because a manual collection is a set of images assembled by hand and nothing in the filesystem records it (docs/catalog.md §8.1). The labels say so, and the rebuild does not take the affirmative styling while a restore is on the table. One thing that is a fix rather than a feature: `show_catalog_now` now gates the scan. `Catalog::open` succeeds on a file whose header survived, so the scan that used to start immediately afterwards would write folder ETags and image rows into damaged pages in the seconds while the user was still reading the question — turning a file that had a backup into one where the backup is the only copy left. Restore also deletes the damaged catalog's `-wal` and `-shm`. That step is easy to leave out and fatal to leave out: a journal belonging to the old file, sitting beside the new one under the same name, is replayed into it on the next open. That is not a restore, it is a fresh corruption with the evidence gone. Tested by corrupting a fixture catalog — 500 images and a collection, then every page past the second overwritten — and driving both branches. The restore is asserted on the collection, because a collection is precisely what distinguishes the two paths; the rebuild on the damaged file being kept and the next open producing an empty catalog at the current schema. Plus the `SQLITE_NOTADB` presentation, a damaged backup being refused rather than installed, and a v1 catalog whose pre-migration backup comes back reading v1 rather than v11. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e22945a62d |
Tag the colour-independent status that was already built and unrecorded
NFR-A11Y-3 — no status conveyed by hue alone — read as untagged, and outstanding.md said "no compliance work found". Both were wrong. Five places already implement it, and four of them name the requirement in a comment explaining the design; what none of them had was a TRACES line. - `histogram.rs::percentage` states clipping as a figure and keeps `<0.1%` distinct from `0%`, so the text cannot say "none" while the marker beside it is lit. - `histogram.slint`'s ClipReadout is the other half: a marker that appears and disappears rather than changing tint, and the figure next to it. Either alone reads. - `library.slint`'s star strip is a solid star against an outline, differing in shape and luminance, over an achromatic palette. - `library.slint`'s FlagMark is a tick against a cross, and a reject also dims its whole cell. - `peaking.slint`'s colour chips say "Red" and "Cyan". A control for choosing between hues, presented only as hues, is unusable by exactly the person most likely to need it. The tag is honest about being wider than the evidence, and outstanding.md now records both gaps. Only the clipping clause has a test that would fail if the behaviour were removed; the three Slint components are argued rather than asserted. And the requirement's first named example — catalog colour labels — has no interface at all: `label` is a nullable column nothing writes or shows. That clause is untestable rather than satisfied, and closes when the label UI is built with a shape from the start. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ef07e6ca3e |
Give the canvas tools a rail of their own, and the column one width
Build and test / Desktop (Linux) (push) Failing after 1h14m38s
Build and test / Layer separation (push) Successful in 48s
🐳 Android image / Build and push (push) Successful in 16m30s
Build and test / android-image (push) Successful in 16m31s
Traceability / Requirement traces (push) Successful in 1m47s
Build and test / Android (aarch64) (push) Successful in 1h0m21s
Crop, Local and Repair were chips at the head of the develop column, sharing a row with the adjustment groups and told apart from them by the shape of their highlight. Three things followed from that, and only the last is cosmetic: the column closes, so the way out of a mode went away with the way in — hence the duplicate "Done Cropping" over the canvas; the chips are generated from the operation set, so the widest thing in the sidebar was a row nobody had chosen the contents of; and a mode and a filter are different kinds of state wearing one control. They are a fixed 60px rail down the left now, generated from a single table in toolrail.slint. A tool is one row of it plus a drawing plus a ViewMode variant; nothing in app.slint is touched to add one. What is left of the strip is the group filters, so it is GroupStrip. The column stops measuring itself. Every panel published a content-width and declared it as min-width, and the column took the largest — which spent the photograph's pixels on whatever happened to be widest, and moved the image sideways when switching tools swapped one set of panels for another. It is panel-width now, one number in style.yaml. That number is 360 and it is measured, not picked: the contents report a minimum of 344 in every mode, and they do not compress below it because a Text that does not elide reports the same minimum as preferred. 320 was tried and sliced Paste down the middle. The Flickable's viewport is floored at the layout's minimum rather than its preferred width for the same reason — content that is never told how much room it has cannot adapt to having less. Removing the eight content-width declarations repairs three comments an earlier edit had spliced sentences into. The raw histogram's note on keeping its hint short is rewritten rather than dropped: an over-long hint no longer widens the column, it pushes the column's minimum past the width it has and clips the panel, which makes that constraint sharper rather than obsolete. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9e519eb8a6 |
Make the thin ring the only mark a selected photograph carries
Two treatments said "selected" and neither said it well. A selected cell got a 2px border and a lifted fill. The border is drawn on the outside of a cell whose content sits 6px in, so it ate into the thumbnail: selecting appeared to nudge the photograph. Inside it, a second thin ring marked the anchor — the end a shift-click measures from — and that ring was the clearest thing on the cell, so it read as *the* selection to everyone who had not written it. Worse, the ring outlived what it described. An anchor survives a deselection, so a thin box sat around the last photograph touched with nothing selected at all, indistinguishable from a cell that had stayed behind. That is the one thing a selection cue must never be: ambiguous about whether something is selected. So the ring is now what it already looked like. One mark, drawn inside the cell and 4px clear of its edge, so it never touches the thumbnail and never changes a dimension — selecting adds ink and moves nothing. Two pixels rather than one, because it is now carrying the whole cue across forty cells at arm's length against a thumbnail of any brightness. The outer border is hover alone. The anchor keeps no mark, and loses nothing it was earning: the bar says "Tap the last photograph" while a range is armed, which answers the question the ring existed to answer. The ordinal still lives in the controller and still decides where a range extends from; what is gone is the claim that the user needs to see it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b120ce48ec |
Float the selection bar over the grid instead of above it
Selecting the first photograph inserted a 40px row into the view's vertical flow, so every cell in the grid moved down by it. The act of selecting shifted the thing being selected out from under the finger — and a second tap aimed at the neighbour landed on the row below it, which is the worst possible response to a gesture whose whole job is to say "this one". The bar is a floating one now, at the foot of the view. Nothing above it is re-laid out, so selecting changes what is drawn and never where. The grid's viewport grows by the same 40px while the bar is there rather than the Flickable shrinking, which is what keeps that change invisible too: every cell stays exactly where it was and there is simply further to scroll, so the last row can be brought clear of the bar instead of being trapped under it. It also swallows presses that land on it. A bar floating over the grid is a bar a thumb can reach for and miss into. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f41edc03ff |
Merge master into wave-2
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> # Conflicts: # docs/traceability.md |
||
|
|
1cd5ab6815 |
Put the gesture reference in the application
The document the previous commit generates is for somebody reading the repository. The person who needs it most is holding a tablet, has just discovered that a hold does something, and has nowhere to ask what else does. So the same scan writes a table the application draws: a "Gestures" button beside Settings, a sheet with the same scrim and dismissal as the ones that file and name, and every gesture grouped by where it applies with its touch, pointer and keyboard routes side by side. Not the `why` — that is the argument for the design and belongs in the document; on a phone-sized card it would bury the one line the sheet was opened to read. The sheet's file knows nothing about what a gesture is. It draws the rows it is handed, and the rows come from the generated table, because a help screen with its text typed into it is a second description of one behaviour — and the second description is always the one that goes stale. The commit before this deleted a gesture; a hand-kept sheet would still be describing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |