98fcf8e98ee87232bad8c175cea66abd30306f19
100
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
98fcf8e98e |
Ask which edit is newer, not which uuid sorts first
A photograph edited on two devices ends up with two `[version]` blocks in one sidecar, both marked `default = 1`. Five of the thirty-eight sidecars in the local cache are in that state right now. `default_version` answered with the first `is_default` it met in map order, and the map is keyed on uuid — so which device's work the photographer saw was decided by which randomly minted uuid happened to sort lower. On `IMG_20130625_0033` that is `0545c20a` over `679fe872`: a four-star rating from the twenty-first of August standing in front of the one-star made on the thirtieth, with nothing anywhere saying the newer judgement existed. Resolved by `(revision, modified)` instead, which is the discriminator `Version::merge` already uses — revision first so that a device with a skewed clock cannot win by claiming a later timestamp (FR-NC-8), and the timestamp only to break an exact tie. This makes the reader pick the right one. It does not make the two converge: the edit that lost is still in the file, and a device that holds disjoint work — a crop here, an exposure change there — still only contributes one of them. That is the next commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a56baa9042 |
Let rustfmt have the assertion it reflowed
The closure taken down to `&dyn Operation` needed its call site re-wrapped, and I wrapped it by hand rather than letting rustfmt decide: it fits on one line at the workspace width. `cargo clippy` was run on the change and `cargo fmt --check` was not, which is the whole of how it got through — the two catch different things and CI runs both. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f0ee53ec09 |
Merge: the optical corrections, connected at last
Four files' worth of lens correction existed, was tested, and had never touched a photograph. `compose_warps` had no callers, `dr-lens` had no dependents, and `ops/vignetting.rs` had no declaration in `ops/` — so `Attribute::Optics` was a category the tab strip could only ever filter out for having no rows in it. Distortion, chromatic aberration and lens vignetting now render, carry their parameters through the sidecar and the undo stack, and take their coefficients from the Lensfun database when the file names a lens it knows. The panel says which of "no lens recorded" and "no profile for this lens" it is, because an automatic correction that silently did nothing is worse than one visibly unavailable. One real bug on the way: `lens.rs` and `framing.rs` both documented the shader's `p` as corner-normalised, and it is not — its length at the corner is `0.5 * length(aspect)`, about 0.901 on a 3:2 frame. Every Lensfun polynomial would have been evaluated short of where it was fitted, by a factor varying with the aspect ratio, which reads as a correction that is merely too weak. The category vocabulary moved with it. `Attribute::Geometry` is `Compose` — named for the photographer's decision rather than for the maths it shares with the lens corrections — and a film stock stopped claiming to be both tone and colour, which had put "Kodachrome" in two groups it belongs to neither of. |
||
|
|
2841eaf9a1 |
Take the layer-chain test's closure down to &dyn Operation
`clippy::borrowed_box` is denied by the workspace lint set, and the closure added with the optics exclusion took `&Box<dyn Operation>` — a borrow of the box rather than of the thing in it, which says nothing the plain trait object does not. Caught by `cargo clippy --workspace --all-targets -- -D warnings`, which is what CI runs and what the workspace tests do not: a lint on test code only appears when the tests are compiled as a clippy target. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c4ddcbe0f7 |
Look the lens up and say plainly whether one was found
`dr-lens` has held a complete Lensfun lookup — distortion, TCA and vignetting coefficients from a lens name, a focal length and an aperture — with no dependents anywhere in the workspace. The three corrections it feeds now exist in the graph, so this connects the two and finishes the chain. The coefficient structs stay duplicated. `dr-pipeline` is organised around having no dependencies so its codegen is testable without a device or a database (ARCH §6.5a), and `dr-lens` carries an XML parser and 5.5 MB of profile data. Neither crate can convert to the other, so the conversion goes above both, in `develop.rs`, which is the only place that sees them together. Both traits grow the same defaulted door. The optical corrections do not sit on the same side of the fetch — distortion and CA rewrite coordinates and are `Warp`s, vignetting applies a gain to the pixel already there and is an ordinary node — and fanning a profile out by which trait each happens to implement would make the caller reason about that distinction. Each correction takes its own share of the whole profile instead, and `set_lens_profile` walks both lists identically. The lookup happens in `set_source_metadata` rather than in its caller, because that is the one place a session is told which file it came from. Doing it there makes it unforgettable, in the shape `FilmRebake` already uses for the other derived thing — and, more to the point, makes *clearing* unforgettable: a session that opened a second photograph while still holding the first one's profile would correct it for the wrong optics, invisibly, in a way that looks exactly like the lens. It needs the whole shot and not just a name. Distortion is interpolated across a zoom's focal range and vignetting depends strongly on aperture — a fast prime can be two stops down in the corners wide open and clean by f/8 — so a lookup missing either returns coefficients measured for a shot nobody took. Missing any of the three refuses rather than guesses. A profile is derived, not persisted: it comes from the file's EXIF and a database, so it is not a parameter, not in the sidecar and not undoable. What is an edit is the manual trim beside it, which each correction composes with the measurement — so a photographer can lean on it, override it, or work without one. `InfoPanel` gains a lens line, and it distinguishes three cases rather than two. `dr-lens` states the rule it exists for: an automatic correction that silently did nothing is worse than one the user can see is unavailable. A session with no header draws nothing, a header naming no lens reads "Lens not recorded", and a lens the database has never heard of reads "· no profile". Collapsing the last two would send somebody hunting for a profile that was never missing — which, for third-party and adapted glass, is the ordinary case. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a1165ef182 |
Put the coordinate-domain lens corrections into the graph
`lens.rs` has held a `Warp` trait, a composer and two implementations — distortion and lateral chromatic aberration — since they were written, and `compose_warps` was called by nothing outside its own tests. The corrections existed, were correct, and never touched a photograph. `EditGraph` now holds them, and `compose_full` emits them between the framing prologue and the fetch. Distortion first, then CA: each warp receives the position the previous one produced, and lateral CA is a magnification about the optical axis of the *undistorted* frame, so measured on a barrel-distorted one it would be fitted to a radius no profile describes. They reach the panel the way framing already does — through `capabilities`. That was the one open question and existing practice answered it: framing is also not an `Operation`, also has parameters a photographer sets, and also arrives through that list. Because `Preset::capture` walks the same list, the sidecar, the clipboard and the undo stack carry a warp's parameters with nothing registered anywhere, and no file under `ui/` names one (FR-DEV-3a). `state()` destructures `EditGraph` field by field precisely so that a new field cannot be forgotten, and it was not. Chromatic aberration is the only thing that samples per channel, and `splits_channels` is what keeps everything else from paying for it. Red and blue are fetched from positions green is not — green is the reference and never moves, so a wrong correction still leaves one channel sharp rather than softening all three. With no CA in the chain the single-fetch path is emitted instead. The interpolating sampler is now chosen by framing *or* an active warp. Asking framing alone would have nearest-neighboured a distortion correction on an unstraightened frame, and that aliasing reads as a bad profile rather than as a missing filter. The warps go in the geometry invalidation key rather than the colour one: they decide which source pixel a colour is read from, so a tile cached across a distortion change would keep drawing the previous correction. The pipeline cache needs nothing new — `hash_source` already covers the generated body, and uniform values never enter it, so arming a warp recompiles and dragging it does not. Both are asserted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e17b909d41 |
Connect the lens vignetting correction to the pipeline
`ops/vignetting.rs` has carried a complete descriptor, polynomial, helper and test suite without an entry in `ops/`, so it was never in `chain()`. It reached no photograph and no panel, and `Attribute::Optics` was an empty category in consequence — filtered out of the tab strip for having no rows, by a chain that had never been given its only member. Declaring it needs the one thing the operation was written against and which did not exist. `wgsl_body` reads `radius`, and the module claimed "the composer publishes `radius` in the shader prologue for exactly this reason". It did not. `sample_source` now does, in both sampling branches, beside the `source_px` it already published for the same class of caller. It is corner-normalised there, which is the part that is easy to leave out. `p` spans ±0.5·aspect, so its length at the corner is 0.5·length(aspect) — about 0.901 on a 3:2 frame, not 1. Lensfun's polynomials are fitted against a corner radius of 1, so passing `length(p)` straight in evaluates every one of them short of where it was measured, by a factor that changes with the aspect ratio. It would have read as a correction that is simply too weak, which is indistinguishable from a bad profile. Both `lens.rs` and `framing.rs` asserted the normalisation `p` does not have; corrected. `order: 5` puts the correction ahead of the tonal stages, and the ordering is load-bearing rather than tidy. Recovering a corner means dividing by an attenuation below one — about two stops for a fast prime wide open — so run after the highlights have been rolled off and clipped, the lift has nowhere to go and the corners posterise instead of brightening. `layer_chain` now drops `Optics` as well as the neighbourhood operations. A local vignetting slider would have worked, which is what makes it worth excluding: `radius` measures from the centre of the whole photograph and a mask cannot move the optical axis, so it would lay a frame-centred radial ramp across the picture and multiply it by the mask. The existing exclusion covers operations that move and do nothing; this one covers an operation that moves and does something its name does not promise. The rule both share is now written down. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
c1069ce07e |
Release 0.10.1
Benchmarks / CPU and I/O (per commit) (push) Successful in 13m30s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / android-image (push) Successful in 3s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / Layer separation (push) Successful in 46s
Traceability / Requirement traces (push) Failing after 1m36s
Build and test / Desktop (Linux) (push) Failing after 24h17m53s
Build and test / Android (aarch64) (push) Canceled after 0s
A bug fix release. Nothing here changes what DarkRoom is for since 0.10.0; it changes how often it does the thing it already claimed to do. The edits that went missing. A subject or category layer was stored as identity alone, on the reasoning that the pixels were reproducible by re-running the model — true, but nothing re-runs one except a photographer pressing "find subjects". So a reopened photograph rendered without its local adjustments and then saved that state back, and a batch export wrote three hundred files without the edits their photographer had made, over a log warning. The coverage now travels in the sidecar. Export also learned to write the photograph rather than the canvas, and to carry the photograph's header when the export is made from develop. Face indexing was reading previews. It ran against a proxy and then recorded the result as though it had seen the photograph, so faces smaller than the proxy could resolve were not missed, they were *concluded absent*. Indexing now runs on the native render, runs made against proxies too small to find a face are forgotten rather than trusted, and a sweep that fails everything says so instead of reporting a clean pass. Where you were. The photo roll opens on the frame it opened with, develop returns you to the photograph you were editing, the grid keeps its place when another screen covers it, and the photographer's position now travels between devices rather than being rediscovered on each. Startup. The catalog opens on a worker and the bundled models unpack on one, so a launch is no longer a page-by-page read on the way to the first frame; the app says it is starting before there is anything to say it with. The People rail builds the rows you can see, keeps portraits off the blocking path, and withholds the empty groups that used to fill it. Segmentation gained the half it was missing: the colour gate decided what belonged to a category and had no way to decide where its edge fell, so a refined sky kept the model's blocky outline no matter how the control was set. A marker-based watershed now puts each contour onto a real edge, and the refinement is a per-layer slider. Coverage 70.4% -> 70.6% (127/180). 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 (
|
||
|
|
f6c9343bcc |
Ask the pixels where the edge is, not just what belongs
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 1h18m38s
Build and test / Layer separation (push) Successful in 46s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 3s
Traceability / Requirement traces (push) Failing after 50s
Build and test / Android (aarch64) (push) Failing after 30s
The colour gate decided *what* was in a category and had no way to decide *where* its edge fell. A colour test has no notion of an edge. So a refined sky lost its flag and kept the model's twenty-pixel-blocky outline, and no setting of the control could move that outline onto the horizon. This adds the second half: a **marker-based watershed**. The mask is eroded to give two markers, and the flood runs in the ribbon left between them, meeting along the most expensive line it can find. The cost is a sum of terms exactly as docs/segmentation.md §2 specifies — the photograph's own edges, and the colour model's disagreement. ## Why this is not the watershed §15 threw away That path failed because the merge *ladder* collapsed: 45,808 basins reduced to one region plus specks. There is no ladder here. Markers prevent over-segmentation by seeding rather than by merging afterwards, so the one component that broke is the one component this does not have. The markers are also better than the textbook's. scikit-image derives them by thresholding the gradient — guessing where objects are — where these come from a model that knows what sky is. Marker selection is what normally goes wrong with this method, and it was already solved. ## The gate still runs, and it runs first A flood cannot replace the colour gate. It only refines contours that already exist, and there is no contour around a flag precisely because the model never noticed one — the flag in the tests sits forty-five pixels from the boundary against a ribbon of six. The tests caught this; the first version of this commit had the flood standing in for the gate and the flag stayed. So the gate goes first and *creates* the contour, and the flood then puts every contour — the horizon and the new hole alike — onto a real edge. ## Erosion that does not delete flagpoles Eroding by a cell and a half destroys anything thinner than three cells: a mast, a bare branch, and equally a strip of sky between two of them. Those would be left unseeded and the flood would fill them from whichever side surrounds them, so a flagpole would come back — and come back *confident*. Erosion therefore stops at the ridge of the distance transform. Whatever would otherwise vanish keeps a one-pixel seed down its centre, floored at `min_thickness` so a hot pixel does not qualify. That floor also moves the signal-versus-noise decision out of colour space, where it was a share of a fitted distribution nobody can picture, and into image space, where it is a width in pixels a photographer can see. ## Two modelling errors the outward test found Both were invisible while the refinement could only subtract, because the gate was multiplied by weights that were already zero outside the mask. The moment the boundary could move outward they decided the answer. **A diagonal covariance is wrong along a gradient.** Sky moves along all three opponent features together — luminance up, red-green drifting, blue-yellow down — so treating them as independent charges a colour two deviations along that gradient three times over. Measured: sky fifteen rows past the sample scored 11.6 against a threshold of 11.34, so the model refused the very thing it was refining. The fit now carries a full 3x3 covariance, inverted by cofactors rather than by a dependency (D13, the NDK). **Eroded seeds understate the spread, always, in a known direction.** The sample is drawn from the middle of a category and never from its edge, so for anything with a gradient the colours nearest the boundary are exactly the ones left out. The broad mode is therefore fitted wider than its sample by `SHOULDER`. Same pixel: Mahalanobis 5.9 uncorrected, 1.5 corrected — the difference between refusing the horizon and reaching it. Only the broad mode is widened; the tight ones are what discriminate. ## What was given up Strict subtractivity. It bounded the damage and kept `scene.rs`'s partition true for free, and it had to go: a mask that may only shrink can sharpen a horizon inward but never outward, so wherever the coarse contour sat inside the true edge, the error survived every setting of the control. The travel bound replaces it. Everything beyond the ribbon is already a marker, so the flood never reaches it — not "can only remove" but "can only move this far", and the distance is the model's own uncertainty. That single bound also retires the connectivity test, the reachability radius and the separate additive path that an outward-growing rule would have needed. A blue car below the horizon cannot be gained, not because a rule forbids it, but because the flood is never there. `the_colour_gate_only_removes` keeps the older property where it still holds; `the_flood_cannot_travel_further_than_the_ribbon` holds the new one across the whole travel of the control. ## Cost The flood visits only unlabelled pixels, so confining it to the ribbon is not an optimisation added on top — it is what a seeded flood does. A ribbon of a few tens of pixels around one contour is a small part of a proxy. The distance transform is no longer cached, because it has to be measured from the mask as the gate leaves it and the gate moves with the control. That is one transform plus one flood per change of the control, against a precompute that runs the model once. Verified: fmt clean, clippy --workspace -D warnings clean, 63 dr-segment tests. 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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
1d800d56b0 |
Refuse to detect faces on a proxy too small to find one
Detection was run on whatever proxy the caller happened to have. A small one does not fail -- the image is letterboxed into the detector's 640px input at any size -- so it comes back with almost nothing, and the caller then writes a face_index row saying the photograph was examined. That row is the damage. Nothing distinguishes it from "examined properly, no faces in this one", so the image is never looked at again. The measurement, on the reference library of 23,531 images. Runs against a 1024-edge proxy: 0.078 faces per image, 90% of them finding nothing at all. Runs against 2048 or better: 1.82. To rule out the obvious objection that small proxies just come from small photographs, the same comparison restricted to DNGs -- 1,592 of them averaging 21 MB against 7,724 averaging 23 MB, so the same kind of file in the same library -- gives 0.078 against 1.82 again. Twenty-three fold, on identical source material, identical weights, identical options. So the floor goes in the detector rather than in either sweep, because both of them, the example tool and any future job handler are equally entitled to get this wrong, and there is one place that sees every attempt. It is 1025, not 1024, and the odd-looking number is the point: 1024 is exactly ThumbSize::Large, the tier proxies are stored at and the tier one of the two sweeps was detecting on. A floor that admitted 1024 would admit precisely the population this exists to exclude. Written as a minimum rather than a maximum so the test at each call site is `edge < MIN_DETECT_EDGE` with no boundary left to get wrong. ProxyTooSmall is its own error variant rather than an empty result because the caller has to tell it apart from a failure: nothing is wrong with the image or the model, and the answer is to go and find better pixels, not to retry these ones. 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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
e8b072c815 |
Say the app is starting before there is anything to say it with
Nothing from our own code reached logcat on the tablet: 284 lines from the app's PID during a launch, every one of them from Hwaps, BlockMonitor, InputEventReceiver or nativeloader, and not even the version banner android_main emits three statements in. I could not find the fault in our wiring, and this commit does not claim to fix it. What it does is make the next launch say which side is at fault, and close a hole that is real whatever the answer turns out to be. What reading rules out, so nobody repeats it: install does call log::set_max_level. The Config carries an explicit tag and an explicit max level, and its env_filter is None, so android_logger's enabled() and filter_matches() both pass an Info record. Tee::enabled delegates to the console and gates nothing else. set_boxed_logger cannot have failed — its Err path drops the Tee, taking the LogFile with it, and the file on the device has content. And Tee::log calls console.log() unconditionally *before* the file write, which is itself gated on console.enabled(), so every line that reached the file proves the AndroidLogger was handed the same record. Nothing else in the graph installs a logger; android-activity and Slint's backend do not. The diff that introduced this changed the level, the tag and the Config not at all — init_once and set_boxed_logger leave the same logger installed at the same level. That leaves below __android_log_write, which no amount of reading this file can reach. So: the console logger is now built first, and one line goes through it directly, before the state directory and before the log file. Two things follow. Everything between android_main's first statement and install returning — external_data_path, create_dir_all and an open on a FUSE volume the system may still be mounting — currently has no surface at all to fail on; that window is what logcat is for, and it now has a line in it. And when the log is silent, that line separates the two cases: present with the log::info! lines below it missing is the facade, absent along with them is liblog not delivering this process's records. It goes through Log::log rather than log::info!, which is not a style choice: the facade's maximum level is Off until install sets it, so a log::info! there compiles and emits nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c62edd3317 |
Give the log a mode that the way off the device can open
The log file has been on external storage since it existed, and the
reason is stated at length in two places: /data/data/<pkg>/files needs
run-as against a debuggable build, /sdcard/Android/data/<pkg>/files is a
plain adb pull from any build, and a log nobody can retrieve is not a
diagnostic. The file was then opened 0600, which cancels that decision
out. On the tablet:
adb pull -> remote open failed: Permission denied
adb shell cat -> Permission denied
run-as -> package not debuggable
Every route off the device closed at once, on a file whose whole purpose
is to leave the device.
The mode is now per platform, because "who may read this" has two
different answers and the directory above the file is what makes them
differ. On the desktop, 0600 as before: $XDG_STATE_HOME/darkroom is in a
home directory on a machine that may have other accounts, and nothing
about that directory stops another local user reading a world-readable
file. On Android, 0644: /sdcard/Android/data is drwxrws--x
media_rw:ext_data_rw, so no other app can enter this app's subdirectory
and anyone who can traverse it is holding the unlocked tablet, which
already gets them the photographs the log merely names. What the read
bits buy is adb pull, which runs as shell — able to traverse a --x
directory, but then obliged to open the file as other.
The mode is also applied twice, and the second one is the fix rather
than belt and braces. OpenOptions::mode is a request: the kernel ANDs it
with the process umask, and an Android application process inherits
0o077 from the zygote, so asking for 0644 there creates 0600 and reports
nothing. It is ignored outright on a file that already exists, which
every launch after the first has. fchmod is subject to neither, and is
what the second call makes.
The comment claiming the mode was "ignored by the FAT-derived filesystem
Android presents as external storage" is gone with it. The device says
otherwise: the file it produced was 0600 exactly.
Two tests. One pins the literal mode per platform — only the desktop arm
can run under cargo test, and the comment says so rather than implying
the Android number is covered. The other reopens a log left behind with
the wrong mode, which is the one assertion on the host that fails if the
fchmod is deleted, since OpenOptions::mode cannot touch a file that is
already there.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
23c3155128 |
Release 0.10.0
Benchmarks / CPU and I/O (per commit) (push) Successful in 11m46s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 1h15m53s
Build and test / Layer separation (push) Successful in 40s
🐳 Android image / Build and push (push) Successful in 8s
Build and test / android-image (push) Successful in 8s
Traceability / Requirement traces (push) Successful in 1m32s
Build and test / Android (aarch64) (push) Successful in 1h3m1s
Three waves of work since 0.9.0. Culling gained the two instruments FR-CULL-3 asked for and never had: focus peaking, and a histogram that reads the sensor rather than the frame about to be displayed. Bursts group themselves. Masks can cover a whole category rather than one instance. The catalog now notices when it has been damaged and offers a way back -- restore a backup, or rebuild from the photographs, which were never at risk. A crash leaves a record. A log survives the process, on a path a tablet will hand back to adb, which is what makes the Android work debuggable at all. The APK compiles its own Java for the first time, so the app can receive a photograph from another application and hand one back. Memory pressure is answered in a stated order. A lost library root is reported rather than reported as an empty library. There is a benchmark suite now, so §8's promise that a regression fails the build is a mechanism rather than a sentence. Its first run says the catalog opens in 70ms against a 2s budget, and that thumbnail throughput does not obviously reach its target. Coverage 59.8% -> 69.8%, and it means more than it did: five requirements that were tagged on code that did not implement them are no longer, and the tool no longer counts its own test fixtures. 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> |
||
|
|
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 |
||
|
|
327b8c7839 |
Merge: one selection bar, and a header that decides what it gives up
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
df06240b2d |
Test that the category geometry means what it says
The scene tests so far checked the descriptor and the arithmetic. Neither would have noticed if `rasterise` put the sky along the bottom of the frame, because both build their own weights and never ask where those weights land. Three that do: - **Weight stays on the side it came from.** Fill the top half of the grid, read the top and bottom quarters of the image. A flipped y axis is the mistake this code is actually prone to — it is a letterbox inverse, and the numbers stay perfectly plausible when it is wrong. - **No transpose.** The vertical check alone passes under a transpose, which maps a top band onto a left band. A horizontal split is what distinguishes them, and neither test is worth much without the other. - **Coverage is a fraction.** A quarter of the cells must read 0.25. The scene tab hides a category below half a percent, so an error of a factor of the grid size would hide everything or nothing — and both look like the model failing rather than the arithmetic. `Scene::from_weights` is test-only and exists because the property under test needs weights whose correct destination is known in advance, which no real inference can provide. It uses a square window so the letterbox is the identity: any offset these find is the mapping's own rather than the padding's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
485297f0c6 |
Let a mask cover a whole category, not just one instance
`MaskSource` could say "instance 3 of that segmentation run" but had no way
to say "the sky". Adding `Category { signature, name }` beside `Subject` is
what lets a local adjustment attach to a semantic category at all.
Stored as identity like a subject, and for the same reason: the coverage is
megabytes and is reproducible by running the same model over the same image,
so the sidecar carries what finds it again and the session carries pixels.
## A name rather than an index
An index would be smaller and would match `Subject`. It would also be a bug.
The grouping lives in `models/scene/categories.txt`, which is editable by
design — adding one category to it renumbers every category after it, and
every stored layer would silently start grading something else. A name that
no longer exists is simply not found and the layer reads as stale, which is
the failure that announces itself.
Staleness is otherwise identical to a subject's: the coverage buffer is in
the session, never the sidecar, so a signature from another run points at
pixels that were never computed.
## Two tests, and the second one caught a real shape
Round-tripping the name matters more than usual here, because the whole
argument for storing a name instead of an index is worthless if the sidecar
is what drops it.
The multi-word case is the one worth having: `category = swimming pool` is
written on one line, and a reader splitting on whitespace would have
truncated it to a category no model has — a layer that silently masks
nothing. `category` is also its own key rather than a reuse of `class`,
because a file conflating them would round-trip a subject into a category.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
509c3a3e96 |
Merge: a log that survives the process, so the tablet can be debugged
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> # Conflicts: # apps/darkroom-android/Cargo.toml # apps/darkroom-desktop/Cargo.toml # platform/dr-plat/src/lib.rs |
||
|
|
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 |
||
|
|
700e592b48 |
Correct the breakpoint note, which still counted six buttons
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
a8008feaf3 |
Inline the verdict header, and take the formatter's pass
clippy::print_literal on the results table header, and rustfmt's first look at code whose author could not run cargo. The four constructs the author flagged as risky -- scoped-thread lanes, a seventeen-argument params!, is_some_and over a closure, a refutable let-else -- all compiled untouched. 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 |
||
|
|
f86438ac0b |
Merge: the formatter's pass over the accessibility test
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
158642d4ce |
Reflow the accessibility test the way rustfmt wants it
The author could not run cargo; this is the formatter's first pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
465f5a7ffa |
Keep the log after the session that produced it
Everything this application knew about a failure went to stderr on the desktop and to logcat on Android, and both are gone the moment the terminal closes or the ring buffer wraps. That is fine when the person debugging is sitting at the machine. It is useless for the case NFR-OPS-1 actually describes, and the one Android makes normal: somebody reproduces a bug on a tablet, and then sends us a file. A large amount of Android behaviour has never run on a device — image intents read over JNI, an ExportProvider, a class loaded through the activity's class loader, memory-pressure eviction, lost-root recovery — and the single most likely failure of the lot, the activity's loader not resolving our classes from android_main's thread, produces one line that scrolls past. That line is now in a file, with the thread that emitted it named beside it. dr_plat::state answers "where does this platform keep state for this app": $XDG_STATE_HOME/darkroom on Linux, and on Android whatever the entry point declares. Separate from configuration and from the catalog for the reason XDG separates them — state is the thing nobody backs up and the user may delete without consequence. dr_plat::diagnostics is the sink. Two files of 4 MiB, so the worst case is a number rather than a discovery on a full phone; one line per write with no BufWriter anywhere, because on Android processes are killed rather than ended and a buffered log loses exactly the line it was kept for; and redaction applied at the sink rather than at the call sites, since a rule every author has to remember is not a rule. It tees the platform's own logger rather than replacing it, so logcat is unchanged — losing that while debugging would have made this a downgrade. Android logs to external_data_path, not internal. Both are app-private and both survive backgrounding; what separates them is that /data/data/<pkg>/files needs run-as against a debuggable build to read and /sdcard/Android/data/<pkg>/files is a plain adb pull from any build. A log nobody can retrieve is not a diagnostic. The consequence is that anyone holding the tablet can read it, which is why the redaction is where it is, and why configuration stays on internal_data_path. What is redacted is what NFR-SEC-2 and NFR-OPS-1 name: credentials and tokens, found by the keyword that nearly always sits next to them, plus the two forms that carry one with no keyword at all — an Authorization scheme and a URL's userinfo. What is deliberately not redacted is filesystem paths and the names of the user's photographs. They are in neither requirement's list, and "failed to decode <redacted>" is not a diagnostic; the preview-and-consent step NFR-OPS-1 asks for governs those better than scrubbing would, because it lets the user look. The over-redaction failure is tested as carefully as the under-redaction one. A scrubber that eats "using basic sRGB as the fallback" makes a log useless without ever being caught. |
||
|
|
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> |
||
|
|
35954dfa1d |
Write a panic down where it can still be read, with nothing in it that names the user
NFR-OPS-2 is two sentences — local crash capture always, upload only on explicit opt-in — and what existed was one `log::error!` in the Android entry point and nothing at all on desktop. So a panic on desktop went to stderr and died with the terminal, and a panic on Android went to a logcat ring buffer that is gone long before anyone reports anything. What the user saw either way was a job that stopped or a control that went dead, with nothing to send. That matters more here than it would in most applications, because NFR-ARCH-4 says no worker error may panic the process and the code is written that way: errors are typed and attached to the image or job they belong to. A panic is therefore by construction a bug — an invariant this codebase believed and got wrong — and it was the one class of failure with no trace. `dr_plat::crash` writes a record to the XDG state directory: version, time, os, arch, thread, panic location, message, backtrace. In dr-plat rather than in either entry point because "where does this platform let an application keep state" is a platform question, and Android's answer is neither XDG nor `temp_dir` — `set_state_dir` takes it from `internal_data_path`, the same place `dr_sync::account::set_data_dir` gets its answer. The hook resolves the directory when it fires rather than when it is installed, which is what lets it go in before everything else and cover the startup it would otherwise miss. **The content rule is the substance of this, not the plumbing.** NFR-SEC-2 forbids credentials in logs and plain files; the same reasoning applies with more force to what this application is actually about, because a user's library is private and so is its shape. `/home/anna/Photos/2019 Divorce/` says something about a person, and a crash record is exactly the file someone attaches to a bug report while trying to be helpful. So `redact` runs over the message *and* the backtrace, and is deliberately blunt: anything containing a slash goes, `content://` and `primary:DCIM/...` included, since SAF names a library just as precisely as a path does; anything beside a word like `password` goes; a long opaque run with letters and digits in it goes, which is the shape of an app password nobody labelled. The one exception is a `.rs` path, which keeps its basename — a backtrace with no filenames is close to useless and `library.rs:1270` says nothing about anybody. Over-redaction costs legibility. Under-redaction costs a user something they cannot take back. Those are not comparable, so the boundary is not the place to be clever. NFR-SEC-5 — face data never in a crash report, under any configuration — is met structurally rather than by filtering: this module reads no catalog, opens no image, touches no account. A record is assembled from the panic hook's own arguments and `std::env::consts`, and there is no code path from here to an embedding. The message length cap is the backstop for a payload some other module formatted something large into. stderr is the one surface that still sees the message unredacted, deliberately: the previous hook is chained rather than replaced, so a developer watching a terminal does not lose the panic because the application started writing files. It is ephemeral, local, and never attached to a report. **No upload path, and not half of one** — no endpoint, no queue, no "send this later" flag. Opt-in upload needs a server to receive it and a consent flow stating what leaves the device (NFR-SEC-4, and the preview-and-consent step NFR-OPS-1 requires of the diagnostics bundle). Neither exists, and a transport built ahead of its consent is the shape of thing that later gets switched on by default. Leaves NFR-OPS-1 cheaper by three things it will want unchanged: `state_dir` (the log belongs at `state_dir()/log` beside `crash/`, so the diagnostics bundle has one directory to collect), `redact` (NFR-OPS-1's "automatic redaction of credentials and tokens" is this function), and `prune` (a size-capped rotation is this, counting bytes instead of files). 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> |
||
|
|
258aae71d6 |
Regenerate the matrix without the tool's own fixtures
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
677b047235 |
Merge: stop the matrix counting the tool's own test fixtures as coverage
A tag was any line containing the string, so the traceability tool scanned tools/ -- its own source -- and its test fixtures became coverage. R1 and NFR-OPS-1 were tagged on nothing but that. Four requirements had sites that were never implementations: the same fixtures also fed FR-CAT-1, FR-CAT-2 and NFR-P1, gestures.rs fed FR-UI-4 from a push_str, and build.rs fed FR-DEV-3a and FR-DEV-3c from the tag it emits into generated code. A tag is now a comment whose first word is TRACES:. Excluding cfg(test) was rejected because tags on tests are this project's recommended practice, and keying on string literals was impossible because schema.rs carries six genuine tags inside Rust strings -- the SQL it embeds is commented with --. Four tags removed as unearned: FR-CAT-13 (no XMP is parsed or written anywhere), FR-PLAT-AND-1 (SourceRef::Document is constructed only in test modules, and the second tag sat on imports_supported, which documents the absence). Two added as earned: NFR-A11Y-3, which was built and untagged, and FR-PLG-8's preservation clause. Four requirements amended rather than built, each with its reasoning: R5's tiling clauses struck, NFR-P11 naming the state that must survive, FR-RAW-2's mechanism reworded to bytes-in, FR-EXP-1's AVIF and JPEG XL stated as post-v1. Coverage falls 122 to 120 of 179, and means more than it did. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
53b04dc561 |
Let the accessibility test see a callback that takes an argument
`sets()` recognised `name:` and `name =>` and not `name(arg) =>`, which is how Slint writes a callback handler with a parameter. There is exactly one of those among the properties asserted — `accessible-action-set-value`, the action a screen reader uses to type a number into a slider — so the test reported `SliderTrack` as missing the action it declares four lines above. A false alarm rather than a false pass, and the less dangerous of the two. It is still worth fixing rather than dropping the assertion: set-value is the action that makes a slider reachable without dragging, which is most of what the actions were added for. 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> |
||
|
|
46706fe622 |
Let the tool rail be pressed, not only read
The rail is the develop view's primary navigation and reached the platform as four pieces of static text. The labels got through — a `Text` announces itself — so a screen reader could read "Photo, Crop, Local, Repair" and had no way to learn that any of them could be pressed, or which one was currently held. The one control that decides what a click on the photograph does was a caption. Each entry now declares itself a checkable button carrying the tool's own word, with the held state reported rather than left to the fill. Checkable is unconditional here, unlike `Button`'s: a rail entry is always a held-or-not state, so an unheld one should say "not pressed" rather than pass for an ordinary button. The action repeats the click handler's expression rather than calling it. A `TouchArea`'s `clicked` is raised by the pointer and cannot be raised from a binding, so the alternative is a function wrapping two lines — and the duplicated ternary sits four lines below the original where the two cannot drift out of sight of each other. Worth recording for the next control like this: `accessible-*` on an element inside a `for` does work, despite the accessibility pass skipping repeated elements. `process_repeater_components` runs first and moves the bindings into a real component whose root is not repeated; what the later pass skips is the empty placeholder left behind. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
80298403f7 |
Name the launch screen's entries, and let its folder rows be pressed
A heading above a text field names it on screen and names nothing to the platform: Slint associates the two only if something says so, and nothing did. So the four entries a user signs in through — server, username, app password, folder — reached AT-SPI as unnamed boxes with a separate piece of static text floating above each. The word is now written twice, deliberately, and the second copy is the one attached to the control being typed into. FolderRow is the more consequential half. It is a Rectangle with a TouchArea over it, which is a button to the user and a decorated box to the platform — its label got through, because the `Value` inside is a Text, while the fact that the row could be entered at all did not. The folder picker was therefore readable and not navigable, which for the screen that chooses where the whole library lives is the difference between using the application and not. 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> |
||
|
|
7409cb7767 |
Make the launch screen translatable, and say how the rest follows
`@tr(` appeared zero times in 14,482 lines of markup. Every string the user
reads was a literal, so NFR-A11Y-1 was not partly done or done badly — there
was nothing to extract and nothing a translator could have been given.
The mechanism turns out to cost almost nothing, and the reason is worth
stating because it decides the order of the work: a string written `@tr("Sign
in")` is already correct in a build with no translation at all. Slint's
`translate()` formats the original and hands it back when neither delivery
path is active, so a converted string and a literal are the same string until
someone writes a `.po`. That means the interface can be converted a screen at
a time rather than in one 14,000-line commit that nobody can review, and every
intermediate state is shippable.
So the delivery half is wired and left inert. `build.rs` asks for bundled
translations only once `lang/<lang>/LC_MESSAGES/dr-ui.po` exists — the first
catalogue anyone commits turns it on with no build-system change, and until
then a checkout with no `lang/` builds exactly as it did. Bundling rather than
the `gettext` feature because of Android: under ARCH §6.9's storage model
there is no path a `.mo` could sit at that the app can reach, and no C library
to link it against. The extraction command and the one flag that must not be
passed to it are recorded in the module docs.
The launch screen is converted whole: 32 calls covering every heading, button,
caption and placeholder. It goes first because it is the screen a user cannot
get past — an unreadable preferences page can be ignored, an unreadable sign-in
cannot. Four kinds of literal are deliberately left alone and the file says
which: the product name, example values whose shape is the message, `..`, and
the path separator.
Two things this leaves open, recorded rather than papered over. The headings
carry their own capitals, because `PanelHeading` draws what it is handed — so
a translator supplies "SERVEUR", not "Serveur", and styling in the string is a
real cost now paid rather than a surprise later. And `@tr()` is markup only:
the operation and parameter labels NFR-A11Y-1 names explicitly resolve in
`labels.rs`, in Rust, because the core may not depend on a localisation
library — those need a second mechanism, and it is not built.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
6ec6c5cbd6 |
Count the tags this rule would have cost, rather than guessing at it
The module doc said an extractor keyed on string literals would "lose six genuine tags to save two false ones". The six is right — `schema.rs` carries that many `-- TRACES:` lines inside Rust literals — but the two undercounted the false ones, which were R1 twice, FR-CAT-1 twice, FR-CAT-2, NFR-P1, one in `gestures.rs` and two emitted by `dr-pipeline/build.rs`. The comparison it was drawing does not need a number on that side to hold. 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> |
||
|
|
8e4befa727 |
Say what each control is, so a screen reader can use one
The whole interface carried five accessible-* declarations in 14,482 lines of markup, all of them on a single row of the colour mixer, and nothing said so. Every other control — every button, every tick-box, every chip, and the slider the develop panel builds thirty-six of for the mixer alone — reached AT-SPI and TalkBack as an unnamed rectangle. NFR-A11Y-2 is not a polish item for a user in that position; it is whether the application can be used at all. The annotations go on the shared components rather than on the screens, which is the same argument widgets.slint was written to make one layer down: a control named where it is used is a control unnamed everywhere it is used next. Twelve components now declare a role, a name and — where the control does something — the action assistive technology invokes to do it. Three screens were touched, and only where the component could not know the answer. SliderTrack is the one that mattered most and the one that could not be fixed from inside itself. It is handed four numbers and knows nothing about what they mean, so it takes a `label` and a formatted `readout` and every wrapper passes down what it was already drawing. A test asserts that every instantiation does, because a track added without one announces "slider, 0.35" and looks perfectly correct in a screenshot. It also gains increment, decrement and set-value. A slider that can only be dragged is a slider a pointer is a modifier for, which is the objection ui-navigation D-N2 makes about hover-only affordances with the argument run one step further; these three are what a screen reader drives a slider with, and they commit as well as change — one nudge is a whole gesture, so a caller that persists on `committed` must hear about it. Two decisions worth recording because the obvious alternative is wrong: `active` on Button and IconButton is deliberately not announced from the component. It says a toggle is on and says nothing about whether a control that is *off* is a toggle at all, so announcing it would report every button in the application as an unpressed toggle. The four call sites that mean a toggle say so themselves, which Slint permits because the role is inherited. SwatchSlider's row-level role is removed rather than kept. It was the one control that had a label, and now that the track underneath it has one too the two would nest — a slider inside a slider, the outer holding the value and the inner holding the actions that can change it. The row stands down and hands the same strings to the control that owns the gesture. The test reads the markup the way darkroom-android's manifest test reads its XML: there is no accessibility tree without a window, so what it defends is the failure that actually happens — a role or a name lost in a refactor, which compiles, renders identically, and is invisible to everyone not using a screen reader. 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> |
||
|
|
11e515a295 |
Say which state NFR-P11 forbids losing, since one kind is dropped on purpose
NFR-P11's criterion was "no dropped frames, no state loss" across a layout class transition. Read literally, the interface already fails it, and fails it by design: `apply_layout_class` clears the user's panel open/closed choices whenever the class changes, and `PanelChoices` documents why — a choice made in landscape answers a different question from the one portrait asks, and carrying it across leaves a 232 px sidebar on a screen with no room for it. A requirement that the code deliberately contradicts is worse than no requirement, because the next person to read it either "fixes" the behaviour or learns to discount the register. So the criterion now names what must survive — the open image and version, the selection, scroll position, the in-progress edit and its undo history, the current mode — and states the exemption with the reasoning attached. Panel disclosure is a default re-derived per class, not state; the user's disagreement with it is remembered within the class where it was expressed. The intent is unchanged: a resize must not cost the photographer anything they did. It is now possible to write a test for that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
99f9b5b7c5 |
Strike R5's tiling clauses, which the frame budget argues against
R5 asks that the application work on downscaled proxies for display, and its criterion stated that in two parts: the display pipeline operates at viewport resolution, and only visible tiles are computed with panning recomputing only newly exposed ones. The first is built and pixel-equality tested. The second is not, and should not be. This is the same correction FR-DSP-2 already received, applied to the requirement that FR-DSP-2's tiling clause traces back to. frame-budget.md measured the case tiling exists for: recomputing the whole 4K viewport costs 4.5 ms of a 16 ms budget, so a perfect tile cache could save at most 4.5 ms in exchange for a cache keyed by (VersionId, tile, zoom, graph_hash_prefix) that must stay correct across every parameter change in the graph. For the one stage that does exceed the budget it is worse than useless. That stage is a convolution, and a tiled convolution reads a halo per tile: at the 52 px radius measured at 4K, 256 px tiles read (256+104)² taps instead of 256². The intent is not removed — a display path that does work proportional to the source image is still forbidden, and that is what the remaining clause says. What is removed is a mechanism written in as though it were the only way to get there, and which measurement says is the wrong one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |