Nine agent branches, merged one at a time on ui-wiring and gated at each
step: develop.rs, library.rs, collections_ui.rs and library_ui.rs become
module directories; every screen's wire() and lib.rs::run() become lists
of named functions, with the develop screen's callbacks in develop_ui.rs;
the six view booleans become View and Page enums; the collections sidebar
and the library grid get their own Slint globals, taking 155 members off
AppWindow. No behaviour change: a multiset audit of code lines over every
moved region lost nothing, the workspace gate is clean, and the manual
recorded from this build and from master's from the same library snapshot
matches picture for picture, apart from a panorama stall that master
shows too when a merge starts during the engine's TensorRT compile queue.
The installer smoke test asserted seven model files, the number on the
day it was written; models/face has since gained the eye-state trio's
companions and the int8 detector forms, and the run on f71d7ba failed
with thirteen installed. The test now expects as many files as
package.sh's directories hold, so the next model needs no edit here.
package.sh also stages models/inpaint, which the APK and the Arch
package already carry and the Windows build did not: without
migan-512.onnx the panorama's border fill has no model on Windows.
xfeat needs nothing, it is embedded in the binary. windows.md §5.2
lists the result.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Moving each group of properties and callbacks out of AppWindow left
several two- and three-line gaps where a removed block's neighbours no
longer needed separating. No declarations changed.
The shard import recorded each adopted image in its own transaction:
fourteen thousand commits, and fourteen thousand turns at the write lock
that every read on the UI thread queued behind — the sync was felt as a
laggy grid and as "database is locked" from whichever writer lost the
wait. `record_detections_within` takes the caller's transaction, and the
import commits every hundred images.
The store carried every detector generation of an image — 24,123 entries
for 19,089 images on the reference library, a third of its 293 MB — when
only the strongest is ever adopted. A put now skips a pass a held one
outranks, and retires the passes it outranks from the index; sealed
shards keep their bytes, but nothing is written twice from here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The scan/opening/status/error lines, offline mode and its retry, pinning a
collection offline, the sync and thumbnail-sweep state, and the callbacks
that route the grid to a rescan, a sync, another library, a panorama merge
or an export — the last of what AppWindow still carried under the
library- prefix — move onto the `Library` global started earlier on this
branch. Rust reaches them through window.global::<Library>() rather than
window.set_/get_/on_/invoke_ on the root.
library-visible is the one name that stays: it is computed from
active-page and active-view, the shell's own routing state, which a
global cannot read. AppWindow now declares no other library- property or
callback.
The date-range fields and their band on the capture-time axis, the
timeline's bars, labels, scrub/pinch/pan/zoom callbacks and its
sweep/current-bucket/anchored state, and the filter bar's people chips,
mode, eyes-open toggle and gesture reference move from AppWindow onto the
`Library` global. Rust reaches them through window.global::<Library>()
rather than window.set_/get_/on_ on the root, as the earlier commits on
this branch did for the grid's cells, selection, ratings and keywords.
The keywording sheet's rows and its open/assign/unassign callbacks, the
star and flag callbacks a cell click or a judgement key fires, the burst
toggle and representative-chosen callbacks, the trash-selection shortcut,
and the rating/unjudged/flag filter chips with their rating-counts model
move from AppWindow onto the `Library` global started in the previous
commit. Rust reaches them through window.global::<Library>() rather than
window.set_/get_/on_ on the root.
AppWindow carried the grid's loaded window of cells, the keyboard cursor,
drag and drop, the held-row long-press state, columns and cell size, the
scroll and viewport bookkeeping, the photo roll's pick and centre-request,
and the local-only/reorder/collection-filing gestures that act on a
selection, as properties and callbacks on the root component. That state now
lives in the `Library` global declared in library.slint, next to the structs
(LibraryCell, TimelineBar, KeywordRow, PersonChip) it and the grid's other
components already share; Rust reaches it through
window.global::<Library>() instead of window.set_/get_/on_/invoke_ on the
root, the same change collections.slint's `Collections` global made for the
sidebar.
library-visible stays on AppWindow: it is computed from active-page and
active-view, the shell's own routing state, which a global cannot read.
Everything else still prefixed library- — the timeline, the filter bar,
ratings and flags, keywording, and the routes and status lines — stays on
the window for now and moves in the commits that follow.
baed1c4 landed it over rustfmt's width; `cargo fmt --check` is the
first gate the Desktop job runs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
f71d7ba and 34ac2f1 added tags without re-running the report, which
the Traceability job's "is it committed" step rejects.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two CI gates have failed on every push since 0.13.4 and both are
fixed here.
The traceability gate rejected `FR-INF-1` as an orphan: settings.slint
and dr-ui tag it, but the four register entries inference.md §12 wrote
were never carried into requirements.md, which is the only file the
extractor reads. §3.12 and §4.10 now hold FR-INF-1..3 and NFR-INF-1
verbatim, with the acceptance milestones pointed back at inference.md.
The matrix is regenerated (188 defined, 155 covered) and the README's
"where it stands" line, which the 0.13.6 release commit skipped, says
0.13.6 and the new figures.
`cargo fmt --check` failed on the `fill_border` call in dr-pano's
padding test, which is the first thing the Desktop job runs after
installing the toolchain and why it failed within a minute.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adopting an image from a peer's face shard stamped its run marker as now,
and the export reads a catalog marker newer than the shard's as a
re-index. So every adopted image went straight back out under this
device's client id: 14,100 adopted, 15,457 "newly indexed" on the next
pass, twenty-two shards of a peer's faces uploaded a second time.
merge_shard now carries the peer's indexed_at into the local index, and
the import writes that marker into face_index; where an older peer's shard
carries none, the store takes the catalog's, so the two agree either way
and the export finds nothing to send.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs/ had 26 developer documents flat beside the manual, and the two
audiences are very differently sized: most readers want the manual and
the gesture reference, a few want the register, the designs and the
measurements. The manual and gestures.md stay at the top; everything for
someone changing the code moves to docs/dev/, and the two documents that
name their own successors — the v0.1 milestone and the UI-refinement plan
— go to docs/dev/archive/ rather than being deleted, since both are still
cited. docs/README.md is the index, users first.
Every reference follows: code comments, Cargo manifests, the workflows,
the pre-commit hook, the bench and traceability tools (which locate the
repo root by docs/dev/requirements.md now), packaging, the Docker READMEs,
CLAUDE.md, CONTRIBUTING.md and the README. The matrix links one level
deeper and is regenerated. Links out of the moved documents into the tree
gain a level; a link checker over every Markdown file finds none broken.
controller holds LibraryController and the window-sizing constants every
other module reads and writes through pub(super) fields, the same shape
collections_ui and develop already use. open is the launch-to-scan cycle and
the worker that checks the catalog file before either touches it. offline is
what of a collection is on this device and the prompt that offers to change
it. window fills the grid model from the catalog and drains the thumbnail
fetch, which is the piece the catalog-reads-are-proportional-to-what-changed
rule (docs/catalog.md §1) bears on most directly. sync is the background
passes that reach beyond the loaded window: the metadata sweep, the
whole-library thumbnail pass, and the exchange with the server.
ratings_keywords applies a judgement or a keyword to a selection and queues
the sidecar and XMP writes behind it. timeline is the capture-time sidebar
and the photographer's place together, kept in one file because a restored
place ends by moving the timeline marker and a scrub is a restore of one
instant, so most calls between the two would otherwise cross a module
boundary. grid wires the grid's own callbacks — the keyboard cursor,
cell-size zoom, the routes into and out of develop — and filter_bar wires
the rating, people, date and offline-scope filters, calling back into
whichever of the above owns the work a filter change triggers.
Extracted by item rather than by line range, so every doc comment and
TRACES/GESTURE annotation stayed attached to the code it describes; the
sorted set of TRACES/GESTURE lines in the new directory is identical to the
original file's. Tests moved with the code they exercise, including the
handful of fixtures — settle, model_of, with_catalog, zoom_cell, pinch_step
— that only one target module needed and so were not worth sharing through
a test_support module the way the other splits use one. Items that crossed
a new module boundary were widened from private to pub(super), narrower
than the whole-file access the original gave them; a few items already
pub(crate) for recovery_ui or presets stayed there rather than being
narrowed, since nothing needed them tightened further.
mod.rs re-exports the same surface library_ui:: callers used before, so
lib.rs and every other caller needed no change.
AppWindow carried the sidebar's tree, its row menu, renaming, drag and
drop between rows, the trash row, and the membership sheet as ~40
properties and callbacks on the root component, in the pattern CH-1
describes and the develop screen's globals (Adjustments, Framing, Steps,
...) already replaced. Collections.* in collections.slint now holds that
state, declared next to the MembershipRow struct it and the membership
sheet both use; Rust reaches it through window.global::<Collections>()
instead of window.set_/get_/on_/invoke_ on the root.
collection-selected, collection-select, and collection-offline-menu stay
on AppWindow: library_ui.rs invokes collection-select directly and
registers collection-offline-menu's handler, and lib.rs reads
collection-selected for back-navigation, so moving them would have meant
editing library_ui.rs, which another change on this branch is splitting
into a module directory. collections-visible stays too — it is
lib.rs's panel-layout state, seeded from the saved layout before the
sidebar exists, and collections_ui never touches it. Everything prefixed
library- (the grid's drag, selection and keyword state that the sidebar's
Rust also wires for cross-feature gestures like filing a selection into
a collection) stays on the window as well, since it belongs to the
library screen, not the sidebar.
The thumbnail stage ran first and, on a device that had just adopted its
peers' shards, spent its time re-uploading hundreds of megabytes under its
own client id while faces, people, collections and dates waited behind it.
Faces go first — the catalog merge assigns identities to faces this device
holds — then the catalog, then thumbnails.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The derived sync fired only after the metadata sweep, so a fresh device
re-derived every thumbnail it scrolled past, re-detected faces and re-read
every header for hours before adopting the shards and snapshot that held
all of it. It now fires as soon as the scan completes — the first moment
the rows the merges key on exist — and the sweep starts behind it. In
steady state that pass is one listing.
The catalog merge gains a fourth half: capture metadata (captured_at,
offset, camera, lens, ISO) for images still at metadata_state < 2, matched
by oc:fileid from a remote row at 2. A date is a fact about the file's
bytes, not local state, and the snapshot already carried it. The sweep's
per-chunk query then finds nothing left, and the timeline is whole on a
fresh device without a header fetch.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
app.slint carried show-launch, show-library, show-identity, show-settings,
show-import and show-merge as separate booleans, so the root component chose
what to draw with five- and six-term conjunctions and nothing stopped two of
them being true at once. Replaced with two enums: View { develop, library,
identity, launch } for which top-level screen is showing, and Page { none,
settings, import, merge } for which page, if any, is drawn over it.
Two values rather than one, because the two questions are genuinely
different. Settings, Import and Merge are reachable from more than one View
and are drawn outermost without touching it — closing one has to return to
whichever View was already current, and today that works because the
underlying property is left alone while the page sits over it. A single
View with five or more variants would need a second field remembering what
to return to; Page needs nothing to remember, since View was never
overwritten in the first place. Identity, by contrast, genuinely replaces
the window the way Launch and Library do (see the existing "like the launch
screen" comment on its `if`), so it is a View variant, not a Page.
Every `if` chain in app.slint that used to compare four, five or six
booleans now compares active-view and active-page to at most one variant
each. library-visible collapsed from a six-term conjunction to
`active-page == Page.none && active-view == View.library`.
The Rust side follows: every set_show_*/get_show_* call in library_ui.rs,
identity_ui.rs, settings_ui.rs, merge_ui.rs, import_ui.rs, launch_ui.rs and
lib.rs now reads or writes active-view or active-page instead, including
lib.rs's startup match (View.launch vs View.develop, since a Startup that
skips the launch screen used to leave both old booleans false and fall
through the chain to develop) and identity_ui's close handler, which now
writes View.library or View.develop in one call where it used to write
show-library then show-identity separately.
back_one_step needed one deliberate adjustment beyond the mechanical
rename. Identity was never represented in NavState: back had nothing to do
when Identity was opened from the library (show-library stayed true,
unread by IdentityScreen's own condition) and could only reach ToLibrary
when opened from develop, which likewise wrote a property IdentityScreen
never read — so escaping out of Identity was invisible in both cases before
this change. With a single active-view, falling into the general case
would instead overwrite the value IdentityScreen's `if` does read and close
it as an unintended side effect. back_one_step now swallows the gesture
while View.identity is current, reproducing the same "nothing visible
happens" outcome for both origins without threading identity_ui's private
came-from-library state through lib.rs for one screen.
Verified with tools/manual/drive.py against a private Xvfb and the debug
build: launch screen to library, Settings opened and closed, Identity
opened and closed (including Escape doing nothing while it is open),
develop opened from a cell and closed both by the back button and by
Escape. Screenshots under verify/.
collections_ui.rs had grown to 4,591 lines covering the sidebar controller,
the click/drag selection policy, tree refresh, the drag gesture, the trash
worker, twelve wiring functions, and the row's rename/create/context menu,
all in one file. Split into collections_ui/ with one module per area, the
way develop/ and library/ were already split on this branch:
- controller.rs: CollectionsController and the pure drop/delete/release
decisions (decide_drop, decide_delete, decide_release, menu_detail,
delete_warning) that a test can drive without a window.
- press.rs: PressUndo and the click-and-release selection policy
(apply_press, select_row, commit_press, cancel_press).
- tree_sync.rs: rebuilding the sidebar from the catalog and pushing
catalog-derived state into the grid (refresh_tree, offline_state,
sync_lifted/sync_selection/sync_reorderable/sync_badges,
refresh_membership, direct_holdings).
- drag.rs: the cursor bitmap (compose_drag_image, blit_scaled) and the
hold/spring timers (arm_hold, arm_spring, should_spring,
collapse_spring_opened) plus their delay constants.
- trash.rs: the soft delete (start_trash, start_restore, drain_trash,
stop_trash, refresh_trash, format_bytes).
- wiring_grid.rs / wiring_tree.rs: the wire() entry point and its twelve
wire_* functions, split in two because together they were the largest
single piece (grid-facing selection/drag/trash vs. sidebar-facing
navigation/create/rename/row-drag/menu/membership).
- rename_menu.rs: creating, naming and renaming collections, and the row's
context menu (apply_rename, create_child, unique_name, open_row_menu,
close_row_menu, close_rename).
mod.rs carries the module's own top-level doc comment, the `pub use`
re-exports for the eight items the rest of the crate reaches by
`collections_ui::` path (CollectionsController, wire, refresh_tree,
sync_badges, sync_selection, select_row, commit_press, cancel_press), and a
shared `test_support` for the one fixture (`ids`) more than one file's
tests needed. Every item that only crossed a boundary within this module,
not out of it, was narrowed to `pub(super)` rather than kept at the
crate-wide `pub` a single file gave it for free.
Extracted with a brace-aware pass that kept each item's own leading doc
comment and attributes attached to it, and tests moved with the code they
exercise; every TRACES/GESTURE comment lands on the same code it did
before. No file outside the new directory changed — lib.rs's `mod
collections_ui;` resolves to the directory automatically, and every
outside caller's `collections_ui::` path still resolves through mod.rs's
re-exports.
`inference::init` listed the models from the user's shared directory
alone, while the app loads them from there or from the package's
`/usr/share/darkroom/models`. On a fresh package install the probe found
"no model to probe with", stayed on the CPU, and compiled nothing. Both
now resolve each file with the same search, `library::shared_model`.
The PKGBUILD names the ONNX Runtime packages as optional dependencies,
since the app loads one from /usr/lib if present.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The 188-line wire() registered the import page's callbacks in four
comment-delimited sections. Lift each into its own fn: wire_opening_and_closing,
wire_choosing_a_source, wire_options, wire_running. context stays generic
over C on each of the three functions that use it, matching how survey()
and start() already take it (impl Fn, implicitly Sized) rather than coercing
it to a trait object, which would have needed ?Sized added to those two
unrelated functions for no benefit.
Measured on a Radeon RX 7900 XT against Arch's onnxruntime-rocm 1.29
(docs/inference.md §1.3): MIGraphX fp16 runs the detectors at 2.4–3.4 ms
against 10–58 ms on the CPU provider, the inpainter at 8 ms against 514,
with a 15–135 s compile per graph the first time and under a second from
its cache after. A compiling rung on TensorRT's terms, wired the same way.
The ROCm execution provider is gone (removed in ONNX Runtime 1.23), so the
AMD ladder is MIGraphX then the CPU, with no non-compiling rung between.
MIGraphX is registered through the runtime's generic key/value entry
point rather than ort's builder: 1.29 reads the legacy options struct for
its precision flags only, and the compiled-program cache directory
(`migraphx_model_cache_dir`) only travels the generic way. The provider's
cache key omits the precision, so f32 and fp16 programs get their own
directories. The probe fingerprint now includes the provider libraries
beside the runtime and the ROCm version, since a distribution's CPU and
ROCm builds are the same file at the same path.
`status().failed` reports only the rungs above the selection, so an AMD
desktop's About line says why MIGraphX won rather than that the NVIDIA
providers are not in the build.
Two examples: `ep_probe` times each provider cold and from cache, and
`ladder` drives `init` as the app does to watch the first-run sequence.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
run() was 2,724 lines that built every controller, owned the develop
session and its render loop, and registered every develop-screen callback
inline — the state CH-1 in docs/dev/code-health.md describes. This is the
mechanical split CH-1 calls for, done in one pass rather than section by
section since develop_ui.rs only compiles once lib.rs stops registering
those callbacks itself.
develop_ui.rs is new: a DevelopWiring struct holding the session, the rows
model, the redraw/render closures and the other controllers' handles, and
one wire_* function per section run() used to contain — presets, export,
the adjustment panel, film, undo/redo, zoom/pan/crop, rotation/flips/
straightening, navigation and peaking — called in the order run()
registered them. window travels as its own parameter throughout rather
than living on the struct, because the generated AppWindow type is not
Clone; every other field is an owned clone so each function's body could
be pasted from run() unchanged.
lib.rs::run is now under 300 lines: construction and startup only, calling
named functions for the window and its diagnostics/inference wiring, the
launch/library/collections/identity/settings screens, import and merge,
the render loop (render_now/redraw/show), the remote open path, the
window chrome (resize, layout class, panel toggles, back gesture), and
develop_ui::wire for the rest. Every TRACES and GESTURE comment moved with
the code it annotates.
Two latent type errors surfaced while restructuring rather than being
introduced by it: activity and display were being passed around as bare
ActivityLog/DisplayWatch instead of the Rc<...> their constructors
actually return, which only worked before because nothing needed to name
the type explicitly.
The 225-line wire() registered the launch screen's callbacks in nine
comment-delimited sections. Lift them into fn's, merging a few adjacent
ones that were only a handful of lines each: wire_sign_in covers both the
browser flow and the app-password fallback (the same button in two forms),
wire_choose_folder_and_open covers opening the folder browser and the
final "Open library" press, since both are short and sit back to back.
wire_use_folder, wire_sign_out, wire_formats, wire_folder_picker_navigation
and wire_copy_url stay as they were sectioned. wire() calls each in the
original order and keeps the closing render() call, which every section
relies on having run once at startup.
The 312-line wire() registered the panorama page's callbacks in three
comment-delimited sections. Lift each into its own fn: wire_start (the
"Merge to panorama" press, its fetch, and the DARKROOM_START_MERGE dev
entry point — all three only ever used together), wire_decision (confirm,
projection and border chips, the fill knobs), and wire_stop_and_leave
(abandon, close). wire() itself now just calls the three in order; S, C
and F stay generic on wire_start since sources/context/on_done are used
nowhere else.
The 837-line wire() had almost no section comments, unlike its siblings, so
the seams had to be found by reading it rather than following markers. Lift
each into its own fn: wire_dials (the two grouping sliders), wire_navigation
(open/close/switch person — needs models() too, for the missing-model
banner), wire_rename_and_merge (a rename and the namesake offer it can
raise), wire_face_actions (pick/confirm/reject/split, the grid's own
actions), wire_grouping_preview, wire_recluster, wire_indexing (the shared
launcher behind Index/Re-index plus Stop), and wire_coverage_and_ignore.
wire() keeps the generic-to-trait-object coercions and the eyes_available
closure, since most of the above need it, and calls each function in the
original order. The reload! macro moved from inside wire() to module scope,
dedented, since macro_rules is scoped textually and every extracted function
uses it.
The 1,457-line wire() registered every collections-sidebar and grid-drag
callback in one function, sectioned only by comment. Lift each section
into its own fn: wire_selection (split further into wire_selection and
wire_selection_filing, since the original section ran to 375 lines),
wire_drag, wire_trash, wire_trash_from_grid, wire_tree_navigation,
wire_create, wire_rename, wire_remove, wire_row_drag (the tree row's own
hold-drag, which the original "remove" comment's span covered but which
is really a separate feature), wire_row_menu, and wire_membership.
wire() itself now just coerces the shared closures to trait objects and
calls each in the original order. visible_ids is coerced to
Rc<dyn Fn() -> Vec<ImageId>> at the top, alongside on_scope_changed and
session, so the new functions take plain trait objects instead of
threading a generic parameter through every one of them.
library.rs was 7,729 lines wiring together everything "open a remote
library" touches: scanning, pulling other devices' judgements out of
sidecars found along the way, writing local edits back out to the
sidecar outbox, pushing/reloading XMP by hand, fetching and prefetching
thumbnails and originals, generating thumbnails locally, the metadata
and thumbnail background sweeps, on-disk paths for the catalog and
model files, and reading the grid's cells, spans and rating filter.
Same motivation as the develop.rs split (docs/dev/code-health.md CH-1):
a pure, no-behaviour-change move into one file per area, each under
about 1,500 lines.
Tracing actual call sites rather than trusting the file's physical
layout mattered here: `persist`, `load_folder_etags`, `pull_sidecars`,
`load_sidecar_etags`, `record_sidecar_read` and `apply_judgement` sit
textually beside the XMP push/reload functions but are called only
from `run_scan` (pulling a device's own past judgements out of the
sidecars a scan just walked), so they went to scan.rs and not xmp.rs.
`cells` came out at over 1,800 lines once its tests moved with it and
split further into cells.rs (windowed reads, trash, ordinals) and
spans.rs (collection scope, manual reordering, the capture-time
histogram) -- ten submodules rather than the nine first planned.
Previously-private items reached from a sibling module became
`pub(super)`, narrower than the whole-crate reachability one file gave
them. Tests moved with the code they test; the two test fixtures used
across more than one file (`scanned`, and develop.rs's
`session_with_a_left_half_subject` in the matching commit) joined the
shared `test_support` module alongside the existing `entry`/
`with_images`/`image_ids` helpers. `mod.rs` re-exports every module's
public items under `library::`, including the `pub(crate)`
`test_support` module `repairs.rs` reads its fixtures from, so no file
outside `library` needed a change.
The previous commit split develop.rs the same way; taken alone it left
dr-ui without library.rs, so that intermediate commit does not build on
its own. This one restores it.
develop.rs had grown to 9,327 lines covering everything the develop
session does: opening a photograph, the parameter-row and curve-widget
panel model, mask viewing and editing, mask creation and the rasteriser
that turns a mask stack into GPU arrays, spot repairs, scene
segmentation, framing and zoom, white-balance sampling, rendering and
film choice, and the undo/snapshot history. docs/dev/code-health.md
CH-1 names dr-ui's lack of a view layer as the reason every feature
kept landing in a handful of files; this is the first of the two pure
splits it recommends as easy, no-behaviour-change wins independent of
that larger rework.
The boundaries follow the file's own sections (several were already
marked off with comment headers) and the seams a full read turned up
underneath them -- mask storage/rasterisation turned out to be a
distinct concern from mask viewing and editing, and rows/tabs/curves
from each other, so those split further than the headers alone
suggested. Each module stays under about 1,500 lines. Struct fields
and the handful of helper methods now called from a sibling module
became `pub(super)`, which is strictly narrower than the whole-crate
reachability a single file gave them; nothing gained visibility outside
`develop`. Tests moved with the code they test, including the few
cases where a helper one file's tests needed was itself only defined
in another's -- those became shared fixtures in `mod.rs` alongside the
`headless`/`read_back`/`grey_session` helpers that already worked that
way. `mod.rs` re-exports every item `develop::` callers outside this
module used before, so lib.rs, masks_ui.rs and the rest needed no
changes.
wire() registered every grid callback in one 1,214-line function behind
four section comments, two of which were themselves far over 300 lines
with no further markers. Each fenced section becomes its own function,
called from wire() in the original order with the section's own comment
kept as its doc comment:
- "the keyboard cursor (FR-CULL-4)" (496 lines) splits at its own topic
breaks into wire_grid_cursor_and_zoom (cursor movement, cell zoom and
pinch), wire_grid_sync_and_load (explicit sync/thumbnail requests and
the reloads a changed viewport, column count or scroll position
trigger), wire_timeline (the capture-time sidebar) and wire_grid_routes
(grid/launch/develop navigation and a manual rescan).
- "ratings and flags" and "keywords" were already under 300 lines and
become one function each.
- "the filter bar" (447 lines) splits into wire_filter_ratings_and_people
(which keeps the section's own comment), wire_filter_dates and
wire_filter_scope_and_offline.
The three callbacks registered before the first section comment
(on_settings_xmp_reload, on_library_cell_clicked, on_library_roll_pick)
and the trailing crate::recovery_ui::wire call stay directly in wire(),
since neither is inside a fenced section.
wire() registered every mask-panel callback in one 778-line function
behind section comments. Each of the seven fenced sections (computing
the region map, refining a subject's mask, dragging a gradient,
selecting on the photograph, the stack, the edge treatment, adding
layers) becomes its own function, called from wire() in the original
order with the section's own comment kept as its doc comment. The
"adding layers" section was itself over 300 lines and had no further
section markers inside it, so it is split at its own natural seam
between painting/viewing a mask (wire_layers_paint) and working the
parts and add-mask buttons (wire_layers_parts); the second half gets an
introductory doc line since there was no comment of its own to reuse.
Locals declared just for one section's closures (running, refining,
dragging) move into that section's function instead of staying in
wire().
wire() registered every settings-page callback in one 416-line function,
fenced only by section comments. Each fenced section (opening and
closing, cache, faces, export, reset) is now its own private function
that wire() calls in the same order, with the section's own comment kept
as its doc comment. on_budget_changed and on_open are coerced to trait
objects at the top of wire() so the new functions take a plain
Rc<dyn Fn> rather than needing their own generic parameter, with no
change in the closures registered or the order they are registered in.
A scene that makes a parent, nests two collections in it by drag and by
the menu, files frames into a child and opens the parent to see it count
both; stills of the tree and of the menu. The collections recording is
re-made now that the bitmap under the cursor is the photograph.
drive.py grows a multi-leg drag: a diagonal with much vertical in it is
taken by the grid's Flickable as a scroll before the DragArea can claim
it, so a drag to the sidebar goes sideways first.
docs/ had 26 developer documents flat beside the manual, and the two
audiences are very differently sized: most readers want the manual and
the gesture reference, a few want the register, the designs and the
measurements. The manual and gestures.md stay at the top; everything for
someone changing the code moves to docs/dev/, and the two documents that
name their own successors — the v0.1 milestone and the UI-refinement plan
— go to docs/dev/archive/ rather than being deleted, since both are still
cited. docs/README.md is the index, users first.
Every reference follows: code comments, Cargo manifests, the workflows,
the pre-commit hook, the bench and traceability tools (which locate the
repo root by docs/dev/requirements.md now), packaging, the Docker READMEs,
CLAUDE.md, CONTRIBUTING.md and the README. The matrix links one level
deeper and is regenerated. Links out of the moved documents into the tree
gain a level; a link checker over every Markdown file finds none broken.
`changed columns` fires on a change, and a first evaluation is not one:
a grid built after the window had settled at its size never said how
wide it was, so Rust placed month headings for the one column it was
told about at start-up — every month began a row, and was announced
wherever its first cell fell, mid-row included. Opening a collection
showed "October 2025" stranded over a row of August.
The bitmap under the cursor was a solid red rectangle. Slint's drag
overlay uploads the image as a texture, draws it and drops the texture in
one call; with the wgpu FemtoVG renderer the drop is immediate and the
draw is deferred to the flush, so the frame binds femtovg's placeholder —
which is red. An image with a cache key survives in the texture cache
until after the flush, and only a path gives one. So the composite goes
to the data directory's scratch as a PNG and comes back through
load_from_path; one file per drag, removed when the drag ends. A
workaround for Slint 1.17.1, written up as one beside the code.
The recording sampled a red brick wall and moved the sliders by three
units, which at GIF size is a click that does nothing. The scene now
drags the frame cold first and picks a white air conditioner, so the
correction is visible and the picker's being absolute - set from the
photograph, not from where the sliders were - is what the picture
shows. The text says so, and says a blown highlight is refused.
The probe's comment said a 192px render "averages a small neighbourhood
into each of its pixels". It does not: the composed shader fetches the
source at one position per output pixel - nearest for an unrotated
frame, four photosites blended otherwise - so the probe was a point
sample of a noisy sensor, and two painted-white air conditioners on the
same wall answered +37 and -50.
The tap is now narrowed to the patch of the canvas around the click, a
couple of percent of its width and square on screen, and rendered at
64x64 with interpolation forced on, which puts a sample on every sensor
pixel under it at any ordinary zoom. The samples are averaged, with the
void and clipped ones left out rather than allowed to pull the mean, and
fewer than half surviving is refused. compose_camera_probe takes the
patch; the merge's compose_camera_linear keeps its nearest sampling. The
readback shrinks from six megabytes to sixty-four kilobytes.
A frame of alternating warm and cool columns, averaging neutral, moves
the controls by at most two units; a point sample swung them to sixty.
The scene clicked 40px to the right of "pick", on "reset", so the
recording showed a neutral group being reset and a click on the wall
that panned. Re-recorded with the picker fixed: the word lights, the
sample moves temperature and tint, and Before shows what it corrected.
Sampling the overcast sky on a Canon 6D frame set tint to -100 and
temperature to -15 for a patch the canvas showed as pure white. A clipped
photosite is sensor white, not a colour: every channel stopped counting,
so what the tap hands back is the as-shot multipliers themselves, which
are strongly magenta, and the solver dutifully drove green to its stop.
The display shader already fades such a pixel to a neutral of the same
brightness before any operation runs, so the picker was balancing against
something the photographer could not see.
The probe now refuses a sample with any channel at or above the onset the
shader fades from, the way the solver already refuses black. The threshold
is one constant, CLIP_ONSET, formatted into the shader and read by the
probe, so the two cannot drift apart.
Pressing "pick" and clicking a near-neutral wall on a Canon 6D frame set
tint to -77 and turned the whole photograph green. The white balance
operation runs first in the chain, on camera RGB, before the body's base
curve and colour matrix; the probe was read off a display render after
all three, and the solve treated that sRGB triple as if the gains
multiplied it directly. On a JPEG the two spaces coincide, which is why
the existing tests passed while the picker was broken on every raw file.
The probe now reads the camera-space tap a merge stitches from, composed
under the edit's own framing so a fraction of the canvas is a fraction of
the probe, and puts the as-shot balance on itself - exactly the value the
operation's gains are about to multiply. No operations run in the tap, so
nothing has to be stripped and restored, and the display target is left
alone, so a sample that found nothing usable no longer needs a redraw.
A raw-frame test with the 6D's matrix and a typical as-shot balance
samples a warm grey and asserts the rendered pixel comes back neutral; it
fails on the previous probe.
Every face the user has ruled on entered the pass as an anchor, and the
scan is exhaustive by design (`dr_face::neighbours`), so a person with
750 confirmed faces cost 750 comparisons against every other face in
the library — and the cost of a library grew with how well it was
named. Most of those comparisons said nothing new: thirty frames from
one afternoon are one point of view, not thirty, and a face that
matches one of them matches the rest.
Each person now enters through at most 100 of their anchored faces
(`dr_face::references`). Eligible are those whose raw embedding is at
least 15 long — one above the gallery floor, since a reference speaks
for someone rather than merely being admitted — with an unmeasured
length admitted as it is everywhere else. From those, the set spanning
the greatest volume is chosen greedily: the longest vector first, then
at each step the face with the largest component orthogonal to the
chosen so far. That is pivoted Gram–Schmidt, and the product of the
residuals it picks is the Gram determinant, so the greedy step is the
exact greedy on the objective. A near-duplicate of a chosen face has
no residual and is passed over; the one profile shot among two hundred
frontal frames is taken early; faces inside the span of the chosen add
no volume and are not taken to fill the cap.
The faces not chosen keep their confirmations and are not touched by
the pass — they stay in the anchor map, so it never releases them —
they are simply not compared. A person none of whose faces is long
enough is still stood for, by their longest, rather than losing their
anchor and having their next face filed as a stranger. Under the cap
nothing changes: every eligible face stands, and the short ones stay
in as the probes they were.
At the reference library's 3,851 confirmations the scan shrinks by
about a fifth; at 15,000 it is a fifth of what it was.
The markers the previous commit stops writing are already in the
catalogs — 2 on the desktop, 429 on the tablet — and in the shards
both have exchanged. Renaming them to the faces' own id with a fresh
time is what makes the export send each image again, under an entry
newer than the empty one `held_model` would otherwise pick. Where the
old write had inserted its marker beside the right one, the wrong one
goes and the right one is refreshed for the same reason: its entry in
the shards is older than the empty one.
Images V14 left with faces and no marker are not touched. That state is
the quality pass's cue, and the fixed write marks them correctly when
it reaches them.
Checked against copies of both real catalogs: the desktop renames 2,
the tablet deletes 429, both in under 200 ms.
`record_updates` — the write behind the quality, eye and crop passes —
re-marked the image as indexed under the pipeline the pass ran as, and
left the faces it had updated under the id of the detector that found
them. On a desktop set to Thorough that put `scrfd_10g+w600k_mbf` over
faces spelled `w600k_mbf`; on the tablet, `scrfd_10g_i8+w600k_mbf` over
faces it had adopted from the desktop's thorough pass.
Every reader takes the marker and the faces to agree. `marker_under`
reads the marker as the detector having examined the image, so the
upgrade repair never revisits it. The shard store keys each face by
its pipeline id, so `export_to_shards` selects an image's faces by the
marker's id, finds none, and sends an entry that says the thorough
detector looked and found nothing — over photographs with named faces
on them. The desktop's shard index holds 54 such entries beside real
faces; the tablet's eye pass over the faces it had adopted made 430
more, and both devices have exchanged them. `held_model` takes the
newest entry for an image, which is the empty one. Nothing has been
lost yet only because the two spellings of the thorough detector rank
equal and neither side adopts the other's; a third device, or either
one after a reinstall, would adopt "nothing here" for 484 images. And
the desktop's eye pass is 4,739 images from doing the same to every
face from before V14 — which are the ones that only exist on the
desktop, and would then never reach anywhere.
The marker now takes the id the faces carry; the pass's own id is used
only when it dropped the last of them and there is no detector left to
name. A stale marker under another spelling of the same embedder is
removed in the same transaction, so one embedder has one marker.
The tablet showed a fraction of each person: 681 of the desktop's 3,851
confirmations, and none of Ian's 746, Catherine's 626 or my own 480.
Every face that existed on both devices agreed on who it was, and the
people rows were identical — the merge was fine. The missing 3,170
confirmations were on faces the tablet did not hold at all: the
desktop's 16,080 faces from the original detector, on 4,310 images,
detected before schema V14 kept the quality reading.
Those faces were in shards the tablet had already downloaded, in
August's export. `import_from_shards` looked at them on every sync pass
and declined each one, because a face without a quality reading was
"work this device cannot finish": adopting it would write the run
marker, and the marker was what stopped an image being looked at again.
That was true when it was written and has not been since the quality
repair existed — that pass lists its work by `f.quality IS NULL`, not by
the marker, exactly as the eye pass does, and faces without an eye
reading were already adopted on that reasoning.
The refusal had no exit. V14 had deleted the markers of every image
holding such faces so the quality pass would find them, and
`export_to_shards` walks the markers, so the desktop never re-exported
them either; the unmeasured August copies were the only ones there
would ever be. The tablet's answer was to queue all 17,727 images for a
re-detection of its own, a fetch of the whole library, while holding
the faces on disk.
Adopt them. The receiving device's quality pass measures them when it
reaches them, and the desktop's confirmations match onto them by box
overlap on the next catalog merge. The test that asserted the refusal
now asserts the adoption and that the image is still owed to the pass.
Eighteen declared operations, not fifteen; JPEG XL is an export format;
the grid does not filter by keyword, only the catalog's query can; and a
panorama's provenance is a sidecar beside the composite, not a history
step in it.
It said 0.9.0 against a 0.13.1 tree, listed focus peaking and burst
grouping as unbuilt when both have shipped, and opened with a page of
prose about the display path before saying what the application does.
Lead with what it is and a picture of it, say how to get it on each
platform and what state each channel is in, keep the honest account of
what is missing, and put the manual first in the documentation table.
A CLAUDE.md at the root, for anyone changing this code: the ways a redraw
and a catalog read came to cost half a second per click, what each fix
looked like, and how to measure the next one against a copy of a real
catalog.
"How many images still owe a quality reading" was a correlated EXISTS per
image over `faces`, and the face row is 8 KB of embedding and crop before
the column it looks at, so each count opened every row. Six such counts
run on every open of the Identity screen and at the end of every sweep:
160 ms on the reference library.
V19 adds three partial indexes holding only the faces still owing each
pass, keyed on the image and carrying the model id the predicate reads,
and replaces `faces_image` with `(image_id, model_id)` so "does this image
hold this embedder's faces" is answered from the index too. The planner
takes a partial index when the count is driven from `faces` and ignores it
inside the EXISTS, so `Needs::Face` carries the per-face fragment and
`repairs::count` spells the query from the faces' side; the list and the
per-image check keep the EXISTS. A test holds the two spellings to the
same answer for every repair.
Every `move_to` guaranteed its destination's parent with a `MKCOL` for
each ancestor down from the account root, and a trash folder under a
library root several levels deep meant three round trips answering
`405 Method Not Allowed` before the one `MOVE` that did anything — for
every image of a delete, on a connection built for that job.
The backend now records the collections it has confirmed exist and asks
about each once. It lives for one job, so a folder another client removes
mid-batch is the one case this misses, and the `MOVE` then reports the
`409` rather than hiding it.
`faces::people` grouped `face_person` after a LEFT JOIN over every person
and sorted the lot by name; the rail then discarded the empty, unnamed
groups a regrouping pass leaves behind — 17,000 of 19,000 rows on the
reference library. `people_in_use` filters them in the WHERE and joins
`people` to face counts aggregated first (2,000 groups), so the sort sees
only the rows that will be drawn. `count_unassigned` replaces fetching
2,400 ids to take their length. `load_people` 22 ms → 10 ms.
`confirm_all` called `faces::confirm` per face, and `split_off` called
`reject` then `confirm` per face: each opens and commits its own
transaction, so a click on a group of several hundred was several hundred
commits. `faces::confirm_all` is two statements — clear the rejections the
confirmations override, then flip the rows — and `faces::reassign` does a
split's reject-and-confirm for every face under one commit. 16 ms → 2 ms
and 22 ms → 4 ms on the largest group.
A confirm or a reject changes one row and redraws the whole grid, and the
redraw re-read every crop blob of the selected person (4 MB for the
largest) and decoded every one — 316 ms per click on the reference
library's 754-face person, to arrive at the pixels already on screen.
`load_faces` now takes the crops the previous load decoded, keyed by face,
and moves each into its new cell; the blob read is skipped when every face
is already in hand. `refresh` drains the old cells into it rather than
cloning them. The redraw is 2.6 ms.
Every click on the Identity screen's face grid — confirm, reject, split,
rename, merge — redrew the whole screen, and the redraw recomputed the
coverage line. That line lists every repair's outstanding images to count
them: six scans of the images table with a correlated EXISTS over the
8 KB face rows, an ORDER BY the job's visiting order, a Target with its
path per row, and a thumbnail-index query per image with faces. On the
reference library (24k images, 19k faces) that was ~200 ms of the
~540 ms each click cost, spent computing a figure a confirm cannot change.
`refresh` now takes what changed: `Changed::Identities` re-reads the rail
and the grid and leaves the coverage line alone; `Changed::Library` — an
open, a sweep ending or stopped, the face data deleted — re-reads it too.
For the times it does run, `repairs::counts` counts instead of building
and dropping the lists, and the thumbnail store is read once
(`ThumbStore::held`) rather than probed once per image in the audit, the
outstanding list and the proxy repair.
`identity_bench` is the measurement: the reads a click performs and the
batch writes, timed against a copy of a real catalog.
docs/manual/README.md is a tour for a photographer opening DarkRoom for
the first time — one picture per thing, moving where movement is the
point. tools/manual/ is how the pictures are made: drive.py puppeteers the
desktop build on a private Xvfb (launch, click, drag, type, screenshot,
record), scenes.py is each picture as a script, and record.sh runs them
all over a folder and writes the results into docs/manual/media/.
The media is in LFS, with the CI pulls excluding it as they exclude the
fixtures; a screenshot changes wholesale when the interface does.
Nothing in the pictures shows a person, by design: the demo library is
seventy urban and alpine frames, chosen from the catalog's rows that face
detection found nobody in.
The traceability matrix is regenerated here after the rebase that
brought this branch up to master.
Without focus the keys typed into the keywords sheet went to the grid
behind the scrim, and the Return meant for the keyword opened a
photograph. The naming sheet already takes focus on open; do the same.
An empty trash said "No images found — check the library folder and which
formats are ticked", which sends someone off to fix a library that is
fine.
An empty device destination read "Ask each time" on the settings page,
and nothing asks: an export made with the field blank is refused with
"no export folder is set". Say what will happen.
wgpu reports a device out of memory by panicking, and a twelve-frame
merge on a GPU another process is using is where that happens. The panic
unwound the worker, the sender went with it, and the page sat on "Stop"
with every control disabled and nothing to say why — the crash record on
disk was the only sign. Catch the panic and send it as a failure, and
treat a closed channel with no final event as a dead worker too.
The eyes are per layer and outlive the mode, so a photographer coming
back finds the layers they were looking at still lit. But the tint is a
way of looking at a mask, and outside Local there is no mask being looked
at: the sky stayed red through Repair and back in Photo, a mode that had
been left leaving its overlay behind — the fault ui-navigation.md D-N1
exists to prevent.
Five chips in one row declare 440px, and the develop column takes the
widest panel's request — so selecting a category mask levered the sidebar
past the window's edge, clipping the histogram, the group strip and the
subject list. The same trap ChipGrid's comment records for film formats.
A heading was drawn only on a cell that both began a month and began a
row, so at seven columns most months were never named, and the one
heading on screen — always on the window's first cell — was wrong about
every row below it. Worse, two headings drawn on the same cell overprinted
each other. Now a row carries a heading whenever its first cell's month is
not the one last announced: a month starting mid-row is named on the next
row it opens, one row late and right about everything under it.
Every shard is in WAL mode and every put opens its own connection, so
while thumbnails are being generated on several threads — which is when
the first sync pass runs — the log is never checkpointed and the main
file holds whatever the last quiet moment left in it. For a shard created
seconds earlier that is nothing: a zero-byte file with the schema still
in the log. The sync read that file and uploaded it, and every other
device merging it failed with "no such table: thumbs" on every pass.
Copy the shard through SQLite's backup API into scratch first, which
serialises against writers and carries the log, and upload that.
Confirming "/" in the folder picker set an empty root, which the launch
model read as no root at all: "Open library" stayed disabled after the
question had plainly been answered, and a folder library — whose folder
is the whole library — could never be opened without first descending
into a subfolder of it. The empty string was carrying two meanings.
Record the choice as its own fact on the account (`root_chosen`, defaulted
so existing configuration loads unchanged), treat a folder endpoint as
chosen by definition, and let the launch screen say so: a folder is shown
as a LIBRARY rather than an ACCOUNT, the second question becomes an
optional "scan only a subfolder", and the library header names the folder
instead of calling it "· whole account".
The scan reads the first 256 KB of a file for its metadata. A camera
writes its IFDs at the front, so that is the whole structure; the linear
DNG a merge writes puts its first IFD after the pixels, and rawler,
given the head alone, finds no decoder in it. The composite was
catalogued without a date and sorted to the very end of the grid, after
every dated photograph — which is where a panorama merged on the tablet
went unfound.
dr-decode's own TIFF reader now reads through a head and a tail at a
known offset; trailing_ifd says where the tail starts and
metadata_split reads the two together. The scan, when the head fails
and points beyond itself, fetches from the IFD to the end — kilobytes —
and dates the file from both. Tested against the writer's own output.
The Malvar "R at green in R row" kernel weights the two greens two
sites away along the row at -1 and the pair up and down the column at
+1/2. The shader had the two swapped, in the comment as well as the
code, so the transcription checked against itself. Both sum to zero
and reconstruct a flat patch exactly, which is all the tests fed it.
On an edge the correction at green sites is half strength and the
false colour doubles: 0.375 against 0.19 on a grey step, and a
blue/yellow zipper around every clipped highlight at 1:1. The other
three kernels and the CFA tables were right.
A grey vertical step now runs through the pass; the transposed kernel
fails it at 0.375.
The reference desktop's only system ONNX Runtime is Arch's
onnxruntime-opt-cuda: 1.29, built without TensorRT and against cuDNN 8
on a cuDNN 9 machine. The probe rejects both providers correctly and
the app runs on the CPU provider, which is right and not what anyone
wants. runtime/ beside the models is now searched ahead of /usr/lib,
tools/fetch-desktop-runtime.sh fills it with the four libraries from
the current onnxruntime-gpu wheel (cuDNN 9, TensorRT 10), and the
About caption lists every rung that lost and why, not only the first.
Verified: the app selects TensorRT from that directory with no
environment variable set.
The unpack list gained migan-512.onnx without its length following;
nothing on the desktop compiles that crate, and the first Android build
of 0.13.0 stopped there.
A fill that went wrong took a seven-minute merge to look at again. Now
DR_FILL_DUMP=dir makes the merge write what the filler was given, and
the fill example runs fill_border on that, or a crop of it, on the engine
and writes coarse, each band and the feathered result as PPMs — seconds
per attempt on TensorRT. Both examples take DARKROOM_ORT_DIR as the app
does, and --wait-engines lets a compiling rung finish before timing.
A Border choice beside the projection — crop to the picture, or fill it
— that redraws the preview filled so the invented pixels are seen before
they are confirmed (FR-MRG-1), greyed with the reason when the model is
not there. The job fills at half the composite's resolution in a
display-ish space (white balance, matrix, gamma; invertible) and samples
the result back into the linear DNG wherever no frame reached; the
sidecar's merge line says border filled and with which knobs.
Experimental because the fill is right in thin borders and wrong in deep
corners, where the model's Places2 prior puts clouds in sky and water
under grass; so its six knobs — working scale, edge erosion, coarse pass,
band width, mirror depth, seam feather — are sliders under the choice,
each committing a redraw, until the defaults are right.
dr_pano::fill owns everything the model does not — which tiles, what
context, how to blend — behind an Inpainter trait, and dr_pano::migan is
that trait over the shipped generator on the inference engine.
The known content is mirrored across the coverage edge into the hole and
a 256-px ring, the nearest 48 px folded, so the model interpolates between
real and mirrored sky rather than extrapolating into nothing. A coarse
pass at a quarter decides the structure with the whole border in a few
tiles; fine passes in 96-px bands from the edge outward texture it; the
seam is blended over a feather inside the real edge. Every knob is a
Params field, and an Observer hears each stage for whoever is looking at
why a fill went wrong.
A border fill acquires the filler once a tile, and each acquire hashed
the 28 MB model twice — 60 ms a tile, a third of the tile's run on a
throttled TensorRT. The Model keeps its hash from open.
Sargsyan et al., ICCV 2023; MIT code and weights (models/LICENCE.md),
exported by tools/export-migan.sh at a fixed 1×4×512×512 from the
authors' checkpoint — six operator types, 28 MB, in LFS like the rest.
The package installs it beside the scene model and the APK unpacks it
with the others.
The fused shader stored black with alpha 1 for a pixel whose source
coordinate left the frame, and the merge's warp averaged it in like any
other: a dark, badly interpolated fringe along every frame's edge, visible
as a seam wherever a frame ended and, later, as the edge the border fill
continued. The display keeps its opaque black; CameraLinear stores alpha 0
and the warp weights each sample by the alpha it interpolated, dropping a
sample that has none.
MI-GAN is plain convolutions, so every rung serves it and none needs a
special form; the role exists so resolve_model and the probe's fingerprint
know the model, and so the merge job can open it through the engine rather
than tract, which takes 7.4 s a tile for it.
A library's records are never all complete at once. A face found before
its quality was kept has no quality; one found before the eye models
existed has no reading; one adopted from a peer's shard has no crop; an
image the fast detector examined on a 1024 px proxy has boxes the current
detector would not have drawn; an image the scan stat'ed has no capture
date. On the reference library that is 17,762 faces under the bare
w600k_mbf id with no quality, no reading and no dense landmarks, 4,144 of
them without a crop, beside 12,217 images the fast detector examined and
found nothing in. Every one of those gaps was its own pass — V14's
measuring pass, §17.5's eye pass, the sweep's proxy repair, the sweep's
detector upgrade — with its own work list, its own count and its own idea
of done, and adding a per-face field meant adding a pass. There was no
pass at all for the case the library is actually in: boxes and landmarks
drawn by a weaker detector on a proxy, which every later per-face pass
would have read from.
dr_ui::repairs replaces them with one job over a registry. A Repair names
one thing a record can lack — the predicate that says which images still
owe it, the input its handler needs (a header, the original, or a native
render), the handler, and what to record for an image that can never be
done. The job unions the predicates into one work list, fetches each
image once at the most any claimant asks for, renders it at most once,
and runs every handler whose predicate that image still matches, checked
again before each because a detection writes every field a per-face
handler would fill. The registry today: face-proxy, face-quality,
face-eyes, face-crop, face-detection, face-upgrade, metadata — the last
there to say that this is not a face job. Adding a field is one entry.
A repair's predicate is the only definition of its work: the count the
settings page shows, the list the job fetches and the check before its
handler run are one predicate, so the job converges. That is why the
registry is cut to what the device can do rather than listing what it
skips — an entry is a count and a set of originals to fetch — and why an
eye reading that cannot be cut is not a criterion.
The catalog side is generic to match: record_updates writes whichever
fields a FaceUpdate carries and re-marks the image so the shards export
it; faces_needing and count_needing answer a predicate the caller
supplies, replacing the measuring pass's three special cases.
Two buttons on the settings page run the job and differ in one
predicate. "Index faces" converges on coverage: has anything examined
this image. "Re-index every face" converges on provenance: face-detection
claims every image with no marker under the chosen detector, in either
of its forms (FaceDetector::model_ids, so a desktop in f32 and a tablet
on the Hexagon do not re-index each other's work), and a marker saying a
weaker one looked is not that. An original over the fetch budget is left
exactly as it was under the re-index, where the sweep marks it examined:
a re-detection with nothing found would delete the faces, and "cannot
fetch" is not "no faces".
record_detections replaces an image's faces and carried only the user's
confirmations onto the new ones, by box overlap above 0.5 IoU. Everything
else on the old faces was dropped: the suggestions the last grouping pass
made, and the people the user had said a face was not. On the reference
library that is 13,011 suggestions and 77 rejections beside 3,778
confirmations — a re-detection of it would have been correct by
FR-CULL-12's letter, since suggestions are derived data, and would have
handed back a People screen of strangers.
Now every old face is read before the delete — box, vector, assignment,
rejections — and matched to the new faces one-to-one, best pair first. A
pair qualifies when the boxes overlap at all and either the overlap alone
says so (IoU above 0.5, the old rule) or the embeddings do (cosine above
SAME_FACE_COSINE, 0.45, the reference library's P≈0.95 line). The
embedding route claims the box a low-resolution pass drew badly enough
that overlap alone would not; the vector is also what breaks the tie in a
group photograph, where two neighbouring faces overlap both new boxes.
Overlap is required on both routes, because the same vector elsewhere in
the frame — a mirror, a print on the wall — is not the same face and must
not take its name. Onto the matched face go the assignment as it was,
confirmed or suggested with its probability, and every rejection.
The merge's match_faces still matches by overlap alone across devices; it
is the same question and is not changed here.
ONNX Runtime's default is already its fullest level. ort-tract maps any
level but disabled to tract's optimiser, whose slice pass divides by
zero inside the segmenter's graph — a panic across the C API and so an
abort, which is what stopped dr-ui's develop test. The app never asked
tract for that and does not start now.
On the tablet the engine compiled arcface for the NPU: the routing
compared the form a rung wants with the form on offer, and for the
embedder both are f32, so nothing said no. A rung now says which roles
it serves at all, and the Hexagon does not serve the embedder (§7 —
its vectors must compare across devices). Tested at the routing seam.
dr-segment's onnx_probe example still named ort-tract, which is what
stopped the workspace test build.
The strict flag refused the Hexagon over the ten quantise/dequantise
nodes at the graph's edges that QNN declines by policy, which cost
microseconds. A provider that hands real work to the CPU is slower than
the CPU floor and the timing already rejects it; the tablet measured
2.3 ms on the NPU against a 29.7 ms floor.
ONNX Runtime's errors open with a source path and a template signature;
the first 160 characters of a CUDA failure were all signature. The
reason now starts at the first word a person can act on.
XFeat's two exports are a Keypoints role now; the crate no longer names
tract, and the app compiles TensorRT engines for both ahead of the
first merge. The probe picks the smallest *detector* rather than the
smallest file: the tablet's first run chose the 112 KB eye classifier,
which has no int8 form, and reported the Hexagon as failed for want of
one.
The first int8 files found no faces at all, and for two reasons the
tool now guards against. The calibration set was landscape photographs
with no faces in them, so the score head's ranges had never seen the
face regime; the set is now proxies from the library itself. And ONNX
Runtime's strided and moving-average calibration modes both degrade
these graphs measurably (a quarter of the faces at eight images, none
at ninety-six), while driving the calibrator in chunks by hand gives
ranges identical to a single pass — so the tool does that, four images
at a time, and feeds quantize_static through its range cache.
Measured against f32 over 400 proxies (docs/inference.md §10.1): the
10g form finds every face above 32 px the f32 form finds; 500m and
2.5g find 96%, and what they lose sits at a median confidence of 0.52
against the 0.50 threshold. Shipped with the number on record.
The Android unpack list gains the three int8 files; without that the
tablet never saw them. D13's runtime half records the reopening.
tools/quantise-models.sh writes the QDQ form QNN's HTP backend takes
whole: opset 17, per-channel int8 weights, uint8 activations, ranges
from running the f32 graph over photographs fed exactly as the app
feeds them. The calibration is strided, four images at a time, because
every ONNX Runtime calibrator holds each image's whole set of
activations until it folds them — a gigabyte an image on the 10g
detector, and an OOM kill with no message when folded once at the end.
Release-time, never on the device (docs/inference.md §5): it needs
real photographs and a person reading the recall measurement that
gates whether each file is offered.
The desktop names where a package may have put libonnxruntime — an
override variable, beside the executable, the package's own library
directory, the Flatpak prefix, the system library directory — and
Android points at the APK's native library directory, which is also
what Qualcomm's DSP loader must be told for the Hexagon skel. Android
starts the engine at the end of the model unpack rather than at launch,
because the probe fingerprints the model files and a first launch has
none until then.
The About panel gains an Inference row beside Graphics, re-read every
two seconds while the probe runs and engines land, and faces.model_id
carries the detector's form: an int8 detector finds a different set of
faces and is a different population (docs/inference.md §7). A
low-memory signal drops every idle session with the GPU caches.
The APK assembly bundles ONNX Runtime and the Qualcomm HTP libraries
from Maven, fetched by tools/fetch-android-runtime.sh with their
published checksums; RUNTIME_DIR=none builds the tract-only APK, which
is a slower app and not a broken one. The desktop packages carry no
runtime yet.
Two probe fixes from the first desktop run: the floor must not be
built with CPU fallback disabled, and a versioned libonnxruntime.so is
a runtime too. On the reference desktop the probe now loads ONNX
Runtime 1.30, measures 30 ms on the CPU provider, and selects TensorRT
at 1.5 ms.
One crate names the runtime, the providers and the devices; dr-face and
dr-segment ask it for a session by role. It hands ort an API table once
per process — from a libonnxruntime it dlopens when the app names a
directory holding one, otherwise from tract — so the Rust build stays
free of C on every target and a package can install the runtime as a
file (docs/inference.md §3).
Sessions live in a registry behind a Model handle that holds the bytes,
not the session: every use refreshes a timestamp and a reaper unloads
whatever sat idle past the decay. A scan that runs the detector on each
image never lets it go idle; a click in the develop view lets the
segmenter go after thirty seconds; a handle used after that reloads,
and reloads on a higher rung if a compiled engine has landed meanwhile.
The probe walks the platform's ladder by building strict sessions and
timing them against the CPU provider, caches the choice against a
fingerprint of the runtime, driver, hardware and models, and compiles
engines for the selected rung in the background, smallest model first.
Nothing in this commit turns the native path on: the apps still run on
tract until they call init with a runtime directory.
tract runs every model on one core on every platform. Measured against
ONNX Runtime's providers on the MagicPad 2 and the reference desktop:
ORT CPU alone is 3-10x, the Hexagon at int8 runs the detectors in
1-3 ms, TensorRT is ~2x the CUDA provider. NNAPI, XNNPACK, WebGPU and
CUDA int8 were tried and excluded with the numbers that excluded them.
The spec keeps the build C-free: ort::set_api takes a table from a
dlopened runtime or from ort-tract, chosen once per process. Rungs
are chosen by building a real session, cached until an input changes,
and compiled engines are built in the background after the first
frame. The embedder stays f32 everywhere; int8 detectors are a
distinct model_id and are gated on a recall measurement.
Read and measured, not built. The bare 512 generator exports at a fixed
shape and loads under tract with nothing unsupported; at f32 on the
desktop CPU it takes 7.4 s per 512×512 tile, which puts a full-resolution
fill of the fixture's border at ten minutes. The three routes that would
make it viable are recorded, with the quarter-resolution fill the cheapest
and Hexagon int8 the one the model was designed for.
Picking a chip stored the choice for the merge and changed nothing on
screen — the chip did not even highlight, since the selected property
was never written back. Now the pick is reflected, and the job, waiting
for its decision, takes a Preview request, draws the alignment on the
chosen surface at proxy cost and reports again; the drain puts the new
picture and its size up. Auto is the surface the field of view suggests.
Also:
The largest rectangle inside the frames' coverage is found a row at a
time — a histogram of consecutive covered rows and a stack pass per row —
so the composite is never held to be measured (FR-MRG-11). It is written
as DefaultCropOrigin/DefaultCropSize (FR-MRG-4): the file opens on the
picture, the border is still in it, and resetting the crop shows it.
rawler reports the crop as the picture, which the test checks.
FR-MRG-4 records the question raised the same day — fill the border
rather than crop it — as open: a non-generative fill through the heal,
or a generative inpainter with its licence and weights. Neither decided.
A sweep whose frames overlap by more than half reads as one photograph,
and the page's job is to show frames. Each footprint is walked along its
border and drawn in amber where it lands, so twelve frames look like
twelve and a misplaced one is visible as such. The preview may take most
of the page's height rather than 320 px.
derived_from and merge are top-level sidecar fields (FR-MRG-6): one line
per source in order, and how the composite was made. A build that
predates them keeps the lines as unknown and writes them back. The job
writes the sidecar beside the composite and stages it with its own
record when the composite goes through the outbox.
DARKROOM_START_MERGE=a.CR2,b.CR2 lands on the merge page at startup with
the job running on local files, on the model of DARKROOM_START_IDENTITY,
for looking at the page where synthetic clicks do not reach it. The fetch
and the start are shared with the grid's button.
panorama.md §11 records what exists, the fixture's figures, and the six
things still open, auto-crop first.
dr_ui::merge is the orchestration with no interface in it: decode each
frame to sensor data and build its graph as a session would (orientation,
lens profile); render each through the camera-space tap at proxy size and
detect keypoints there, so the alignment is measured in the undistorted
frame the tiles are rendered in; align; solve one gain per frame from the
proxies' overlaps; draw the aligned set in colour for the page; then wait.
Nothing is written until a Decision arrives (FR-MRG-1). The merge writes
a linear DNG through the outbox with a destination record, so the drain
puts it beside its sources on a folder library and a server alike, and
the library rescans (FR-MRG-3).
merge.slint is the page, on the import page's model: the alignment
table with a failed frame named on its row and the button held off
(FR-MRG-5), the preview, the projection choice, Stop and Back. A
"Merge to panorama" button joins the grid's selection bar at two frames.
Headless, the example produces the fixture's 22 993 x 5 980 DNG in 45 s
on the reference desktop, exposures balanced across the stop of drift.
merge.wgsl warps one camera-space tile into one output chunk — output
pixel to direction (the projection maths of dr_pano::projection, verbatim),
direction to the frame's camera, camera to source pixel, bilinear by hand
from four textureLoads because rgba32float is not filterable — and adds it
into a storage-buffer accumulator weighted by its distance from the
frame's edge. A resolve pass divides by the weights and packs sixteen-bit
samples at the sensor's scale with a coverage bit.
MergePass::merge drives it: bands of rows, chunks across a band, and for
each chunk only the frames whose footprint meets it, each rendered as the
source rectangle the chunk needs and nothing more. The working set is one
chunk, one tile and one band (FR-MRG-11); the frame textures are the
caller's to cache. Feathered, not seamed; gain a scalar per frame — the
blend quality is panorama.md §10's step 5, after the path writes a file.
dr-export gains write_linear_dng — LinearRaw, DNG 1.4, u16 samples at
the sensor's scale, the body's matrices with their illuminants, the
as-shot neutral, the EXIF block an export writes — streamed strip by
strip through a closure so the composite is never held (FR-MRG-11). The
tiff crate's directory is a map, so PhotometricInterpretation is written
over what new_image set, which is the trick the S15.1 spike thought it
had to hand-roll around. The test reads the file back through rawler.
dr-decode's RawImage carries samples_per_pixel (a linear DNG is 3), the
body's profile with its calibrations mapped back to EXIF illuminant
codes, and the cleaned make and model. The GPU uploads a three-sample
image as it is, normalised by black and white like a photosite, through
a full f16 conversion — subnormals kept, because a 14-bit LSB sits at
f16's smallest normal and rounding it to zero would crush exactly the
shadows the file was written to keep.
compose_camera_linear composes the fused pass with an empty operation
list, the file's orientation as the baseline, a view rect for the tile,
and a store of rgba32float. On the GPU, render_camera_linear is the only
entry that accepts it: it fills the profile uniforms neutral — unit white
balance, identity matrix, curve off — so what lands in the texture is the
sensor's numbers after the lens warp and nothing else (FR-MRG-2). A third
bind-group layout carries the format, as the linear one does, and the
readback is generalised to any pixel width for the f32 copy.
Thirty-two bits because the composite is written back at the sensor's
scale: a 14-bit sensor has 16 384 steps to white and f16 keeps 2 048 of
them in the top octave.
Twelve real frames from the fixture set now align in 4.5 s — 4.4 s of
matching, 118 ms of bundle adjustment — where the first run took 51 s and
left the first two frames out.
The matcher computes each pair's similarity matrix once, across the
cores, with a dot product written to vectorise; both nearest-neighbour
directions read it. The frames that failed were portrait: fitted into the
landscape input they used 512 of 1024 px, and their thin overlap did not
survive at half resolution. The same weights are now exported at 768×1024
as well and the detector picks the shape by aspect. The example aligns
from embedded previews and draws the set on a cylinder; on the fixture the
sweep is 152° at a fitted 47.9 mm against the EXIF's 50, RMS 1.5 px, and
the overlaps show no ghosting.
A new crate holding the CPU half of a merge (FR-MRG-10): the grayscale
proxy with orientation, the XFeat decoder ported step for step from the
reference detectAndCompute, mutual-nearest-neighbour matching, a robust
pairwise homography with the focal length read off it, a hand-rolled
Levenberg–Marquardt bundle adjustment over every rotation and the focal,
the three output projections, and align(), which chains it all and names
the frames it could not place rather than guessing (FR-MRG-5).
Dependency-free without the xfeat feature — linalg.rs says why the dense
algebra is hand-rolled — and tested on synthetic sweeps whose answer is
known exactly. The noise test records the single-row degeneracy: one
pixel of noise is a tenth of a percent of focal, which is a uniform
stretch of the sweep, not a misalignment.
tools/onnx-probe-on-device.sh cross-builds dr-segment's onnx_probe
without the embedded segmentation model, pushes it with a model to the
attached device and times two runs. The 768×1024 XFeat export takes
~400 ms on the reference tablet's NEON cores against ~300 ms on the
desktop, with identical output ranges — inside NFR-MRG-1's 1 s per frame.
The blend half of S15.4 waits for a chunked blend to exist.
fixtures/pano/2025-08-05: _MG_8320 … 8331, one portrait hand-held sweep
at 50 mm with a stop of shutter drift and sky in every frame — the set
§3.11 is built against, with each of those facts named as the test it
is. fixtures/** is tracked in LFS like the models but with the opposite
default: CI's pulls exclude it, so a build never fetches 325 MB it does
not use.
Camera-linear u16 samples on the first source's black-subtracted scale
with its white level, never rescaled to fill 16 bits, with its body,
matrices, illuminants and as-shot neutral carried — so the panorama is
developed afterwards as one photograph from the sensor's own numbers.
The only thing a warp cannot preserve is the colour filter array, and
the clause says so.
The fused chain, as operation.rs's tests fix it, is warp → as-shot white
balance → operations → base curve → camera matrix → store. LinearWorking
stores after the matrix, so the existing linear tap carries the body's
base curve, and a composite stitched from it and developed as an
unprofiled body would render that curve twice.
FR-MRG-2 therefore stitches camera-linear RGB — after the warp, before
white balance, curve and matrix — and the composite carries the first
source's body, matrices and as-shot neutral so its own develop applies
the profile once. The composer already makes this a uniform question:
white balance, matrix and the curve flag are reserved uniforms, so the
tap is a compose entry with no operations and a render entry that fills
them neutral. panorama.md §5.1 states the shape and asks for f32 buffers.
tools/export-xfeat.sh exports the convolutional network alone at 768×1024
grayscale, on the pattern of export-seg-model.sh: thirteen standard
operator types, no dynamic axes, the keypoint decoding left to Rust.
examples/onnx_probe loads it through the ort-over-tract backend the app
ships with nothing unsupported and runs it in ~300 ms on the desktop CPU.
The weights are Apache-2.0, read from the repository's LICENSE, with no
grant on the checkpoint — recorded in models/LICENCE.md before they land,
as FR-MRG-8 asks. The probe stays: the next model will need the same
check.
A hand-rolled 64×48 LinearRaw DNG — one IFD, 16-bit RGB, DNGVersion,
ColorMatrix1, AsShotNeutral — comes back through rawler 0.7 with cpp 3,
the samples in the order written and the matrix parsed into the camera
definition; CameraProfile::extract builds a profile from it. ImageMagick
reads the same bytes.
dr_decode::decode currently accepts the file as CFA and passes three
times the samples on, so the cpp == 3 branch is the decode work FR-MRG-3
needs, and the only decode work. panorama.md §8 records the result.
A merge writes a new source file beside its sources (D18) rather than a
multi-source Version, which answers the schema question §7 had been holding
open for panorama, HDR merge and focus stacking together. The panorama is
undeferred as FR-MRG-1 … 11; the other two stay in §7 with their data model
decided.
FR-MRG-10 and 11 fix where the work runs — every per-pixel stage on the GPU,
the composite never held as one texture — because the output exceeds
max_texture_dimension_2d before it exceeds memory. panorama.md carries the
stage table, the chunked output driver, the model licences and the porting
sources. S15 gates all of it.
Coverage falls from 83.0% to 77.2%: thirteen requirements entered with no
code, and outstanding.md §11 says so.
Once every image has been through the detector and only readings are
left — the state an already-indexed library is in the day the eye models
arrive — the button reads "Read eye state" rather than promising to
index, and the coverage line says what the faces are waiting for.
The 106 points the eye boxes were cut from, stored beside the reading as
16-bit fixed point over the frame: 424 bytes a face, a seventh of a pixel
on a 6000-pixel frame, where f16 at the same size would have been six.
Derived data like the embedding, kept for the same reason — it cost a
fetch and a model run, and the next per-face pass should run from the
catalog. Shards carry it; a peer's shard from before it is still read.
The register grew both clauses the same day this was built: FR-CULL-8a is
the per-face state the reading is, and FR-CULL-13 is the rule that a
signal is shown and filtered and never writes a judgement. The tags,
faces.md §17 and catalog.md now say which is which; FR-CULL-8a records
what of it is built, and that its third model is under the InsightFace
grant by the same decision as the pair.
The people filter was served from faces_image without touching a row;
reading the eye columns in the same subquery touched every one, and
ALTER TABLE had put those seven floats after the embedding and the crop
blob. One count took 24 seconds on the reference library, thirteen of
them system time. faces_eyes covers the subquery again: five
milliseconds.
FR-CULL-13, with §3.9.1's exclusion of blink detection re-read as the
exclusion of blink selection it always was: the stored fact and the chip
are built, a pass that picks the frame where everyone's eyes are open is
not. faces.md §17 has the models, the crop measurements, the four-state
rule and its floors, the native and proxy sheets read face by face, and
what remains to measure.
An "Eyes open" chip beside the people chips, offered only while someone
is chosen and dropped when the last person goes, so no term narrows the
grid with nothing on the bar to say so. It compiles the rule in
dr_face::eyes into the person's face subquery — Anna, eyes open, whoever
else is blinking beside her — and drops a frame only on a closed eye that
could be read: sunglasses, eyes too small or soft to read, and faces never
read all pass, so an old library shows everything under the chip until
the measuring pass has run. A test drives the same readings through the
SQL and through the rule and requires them to agree.
The People screen badges a face "Eyes closed", "Sunglasses" or "Eyes
unclear" so the reason a frame is or is not in the grid can be read off
the face; the sweep loads the three models when they are beside the pair
and reads eyes on the indexing and measuring passes from the native
render; the coverage line counts unread faces as work to measure so an
already-indexed library keeps its Index button. The term travels with the
place.
2d106det for the eye contours, OCEC for open or closed, SGC for
sunglasses — all three pinned to a batch of one by the same script as
the pair, and installed by every packager so the eyes-open filter works
out of the box. The two classifiers are MIT, code and weights; the
README records their provenance, SGC's undocumented training set, and
the hashes as fetched and as shipped.
Per eye P(open), the pixels across its box and the sharpness of the
patch; and P(sunglasses). The verdict — open, closed, sunglasses,
unclear — stays a rule in dr_face::eyes so the floors can move without
re-measuring twenty thousand faces. Shards carry the same seven, and a
peer's shard from before any of them is still read.
SCRFD's eye point places a face, not an eye: on turned and smiling heads
the classifier's window had the eye in a corner, and two model-free ways
of re-centring it — the darkest blob, the most contrasty window — both
lost open eyes (19 → 15 and 19 → 9 of 25). Three landmark models were
then run over the same faces; Face Mesh V2 and InsightFace's 2d106det
tied at 22 of 25 and 2d106det ships, being the cheapest by far and under
the grant the detector and embedder already carry. The eye box is the
tight bounding box of its ten lid points, cut upright from the native
render, which is what the classifier was trained on.
The larger change is that the reading now carries, per eye, the source
pixels across the box and the sharpness of the patch — because the
commonest wrong answer on the reference library was a soft eye read as
closed, and a classifier shown a smear will always say something. An eye
under either floor, or narrower than six tenths of its partner (the far
eye of a turned head, whose contour collapses), is not asked; a face with
no readable eye is a fourth state, Unreadable, that no filter drops. On
twenty native renders the one real blink is caught, the laughing faces
are closed, the profiles are judged on the near eye, and the one thing
left beyond any floor is a face with a pot held over it.
Three nullable columns beside quality — P(open) for each eye and
P(sunglasses) — because the verdict is a rule with thresholds in it and a
rule belongs in code, not in rows that would have to be re-measured. NULL
is "never read": a face from before the models, or from a device without
them, and every reader treats it as unknown rather than as closed.
The measuring pass V14 built for the embedding's length is what fills
them, so the sweep's work list now also names faces with no eye reading
— but only on a device that has the models, or it would fetch every
original to do nothing to it. A peer's shard without the reading is still
adopted, unlike one without the quality: the pass finds this work by the
NULL rather than by the run marker, so adoption costs it nothing.
Two MIT classifiers from the same author as the reference pipeline's
whole-body detector: OCEC answers P(open) for one 40×24 eye, SGC
P(sunglasses) for a 48×48 head. Both load in tract once their batch
dimension is pinned by tools/fix-face-model-shapes.sh, like the embedder.
The crops come through the same fitted similarity the aligned face does,
so an eye window is a constant in template units rather than a second
warp, and a tilted head yields an upright eye. Measured on 60 proxies
from the reference library: the eye window plateaus at 22×11, the S
variant beats M and L (which overfit their own domain), and for
sunglasses the aligned face beats a head framing but the higher of the
two catches 11 of 12 pairs against 9 for either alone.
The reading keeps both eyes and the sunglasses number apart, because a
wink averages to the least informative value and a lens of dark glass
draws a confident answer from the eye classifier — over a woman in
sunglasses it read the right eye 0.97 open. Sunglasses take precedence,
and a face behind them is neither open nor a blink.
Its plugin section still asked for the contradiction to be resolved, its
render-path section still asked whether FR-DSP-2 was a requirement and
said NFR-RES-2 had no answer, and its closing section still called D12
open. Each now records what was decided and keeps the argument that was
weighed, so the document reads as the history it says it is rather than
as a plan the register has moved past.
Eight citations named §5.1, §5.2 and a §5 selector language that
requirements.md's §5 has not contained since it became a pointer at
architecture.md; two named §9 for the golden images and the benchmark
suite, which are §8; and the three pointers into architecture.md were
each one section off. All now name the section that holds the thing.
architecture.md §12's subsections are numbered 6.1–6.13, colliding with
its real §6. That numbering is what every ARCH §6.n citation in the tree
uses, so it stays, and a note at the head of §12 says so instead of
leaving the next reader to work it out.
FR-DEV-3f's open question about persisting the film stock was answered
in sidecar.rs; the clause now says so.
R5 says in its own note that zoom_resolution.rs establishes it as a
pixel equality; that file was tagged FR-DSP-5 alone. FR-DEV-19's three
sub-clauses carry eighty-three tags between them while the parent had
none; MaskLayer, which is the thing they edit, now carries it. And
NFR-R3 — a crash in decode does not take down the application, the
image is marked failed — is exactly what the decoder's panic guard and
the face sweep's unreadable mark do, tagged FR-RAW-4 and NFR-SEC-1 and
not the clause that asked for them.
NFR-COMPAT-1 and NFR-COMPAT-2 were instructions to write a requirement,
not requirements: "state the API level", "state the channels". Both
are now stated from what the build enforces and what exists.
The baseline is minSdk 28 / targetSdk 36 from the Android Dockerfile,
a Vulkan adapter at wgpu's default limits because compute needs storage
textures — device_from already called that the floor and is tagged for
it — with no optional feature required, since the f16 in FR-DEV-2 is a
texture format and not shader arithmetic. The reference device is the
HONOR ROD2-W09 the figures are taken on, and the second-vendor clause is
recorded as unmet rather than quietly dropped: there is no Mali or
PowerVR device, so an Android figure here is an Adreno figure.
The channels are all self-distribution — Arch package, local Flatpak,
sideloaded APK, NSIS installer — because D13's face weights rule out
every store, and the two consequences are written down: SAF stays
although a sideloaded build need not have it, and S11 becomes a
pre-publication step.
§7 still listed "AI subject masking — deferred per D11" while
MaskSource::Subject and MaskSource::Category, backed by dr-segment's
instance and semantic models, had been the primary way a local
adjustment is made for weeks. The code was tagged FR-DEV-3, which
names gradients and brushes and says nothing about a model.
FR-DEV-3i now states what exists: a subject or a category found by a
local model, stored as identity with the run's signature so that it
merges per field and reads as stale rather than wrong, then treated as
any other layer by the edge, stroke, composition and reveal clauses.
The one place it departs from FR-DEV-19 — coverage written run-length
coded beside the layer, so a stored subject renders without a model —
is recorded in the clause instead of left for the next audit to find.
The segmentation crate and the UI's selection module are tagged to it.
R5's note said FR-DSP-2 "was rewritten rather than implemented". It was
not: the clause still demanded viewport tiling, the matrix listed it
unbuilt, and frame-budget.md's rewrite had been proposed and never
applied. Decided 2026-09-19 to keep it as written until S6 runs on a
mid-range Android device, because the measurement that argues against
tiling was taken on a discrete desktop GPU and the clause exists for the
device whose memory the image exceeds. Both notes now say that.
The licensing half of D13 had been open since 2026-08-09, while the
InsightFace detectors and embedder shipped in the tree and indexed real
libraries. models/face/README.md already stated the position the
project was actually taking; the register did not.
Now it does: this is non-commercial software, self-installed, and it
uses the weights under their research grant as such. The risks are
written where the decision is — the grant binds every user, it is not
GPL-compatible, it rules out every public channel, and publishing is
what reopens the decision. S14's licence search is what would close it.
NFR-R8 carried the words "decide explicitly" for six weeks, asking
whether v1 has a full CPU render path or whether "CPU fallback" means
staging only. The viewer had already answered it: with no adapter it
opens the library on embedded previews and cached proxies, keeps every
catalog edit available, and withholds develop and export. That is the
degraded mode, it is now the requirement, and NFR-RES-2 no longer
promises a fallback render path that was never going to be built.
Three statements disagreed. §1.2's platform table said "phone
supported"; §3.5 said phones were out of scope and cited §1.3, which
does not mention them; D15 said "no phone" and gave the reason. D15 is
the decision, so the other two now point at it and say the same thing:
the build runs on a phone, and nothing is designed for one.
The register said two things about plugins. §7 had listed "Plugin API"
as deferred since the first draft, in a bare row; §3.10 then specified
it in 23 clauses that counted against coverage. Twenty-one of them had
no implementation of any kind, and could not have: no crate loads
anything at runtime. The coverage figure was measuring the contradiction.
Decided 2026-09-19: §7 is right. §3.10 stays as the design of record,
each of its clauses is marked "(post-v1)" on its defining line, and
NFR-SEC-6 — which exists only for plugins — goes with them, as does D16.
The traceability tool learns the marker. A deferred requirement is still
defined, so a tag naming it is not an orphan, but it leaves the
denominator and is listed in its own table rather than under "not yet
tagged". The marker must sit on the definition line; a mention of
"post-v1" in prose changes nothing, and where an ID is defined twice the
deferral on either line wins. Both are tested. Coverage moves from 72.2%
of 194 to 80.6% of 170 without a line of application code changing,
which is the honest figure: it now measures what v1 owes.
D12 had been OPEN since the 2026-08-08 calibration, and D3 said it
depended on D12. In the meantime the milestone D3 named was delivered
and closed on 2026-08-30 and the application reached 0.12.2 with every
cluster the calibration selected at least begun. The decision the
register was waiting for had been made by building, so the register
now says so: full scope stands, v1 has no date, and "post-v1" in §7 is
the one way a clause leaves the count.
The status line also stops calling this a draft from August; it has
carried eleven dated amendments since.
The sweep fetches the whole original before it can learn anything
about it, and the one file in the reference library the decoder
refuses on sight is a 521 MB stitched panorama — so every pass on the
tablet spent half a gigabyte of Wi-Fi to find that out again. The
catalog already knows the byte count, and that is enough to decide
before the fetch: originals over 256 MB are marked examined with
nothing found and a zero edge, counted as failed, and named in the
log. Below the line is every camera RAW the library holds; above it,
four files, all panoramas.
A budget and not a verdict on panoramas. The right treatment for one
is a tiled pass — read it in strips, detect in each, stitch the boxes
back — and the zero edge is what that pass would select on. Until it
exists, this is what keeps a background sweep on a phone from paying
for the decision the decoder cannot make.
Rating and flagging were reachable from the grid alone, so a photograph
opened in develop could not be judged without leaving it; FR-UI-5 said
"rating" without qualifying the view and was built as though it had.
And FR-UI-1's expanded row has said "filmstrip" since it was written
while the roll stayed on demand in both classes. Both are amended to
say what they meant: judgement follows the photograph, without
auto-advance outside the culling mode, and the roll is open by default
where there is room for it.
The larger change is a rule. Per-face signals — eye state from a
classifier, head pose from the five landmarks the detector already
yields — are worth having for culling, and §3.9.1 excluded detecting a
blink outright. The exclusion was always of judgement, not of knowing:
a blink is a fact about a frame of the same kind as a clipped
highlight. FR-CULL-8a specifies the two signals; FR-CULL-13 says what
any signal may do (be shown, filtered, sorted, propose a burst
representative) and what none may (write a rating or flag without a
user action between). R7 states the same thing as a user need.
Licensing was read before either was written. OCEC's eye-state weights
are MIT with a clean data chain; every open gaze model is trained on
Gaze360 or its peers, whose licences restrict derived models by name,
so gaze is deferred in §7 and head pose stands in for it. D13 records
both so they are not re-searched.
Replacing a closed-eyed face from a neighbouring frame was raised and
is written down as D17 rather than built: it is the multi-source schema
question §7 already defers for panorama and HDR, with its non-goals —
never automatic, provenance declared — fixed now.
Traceability regenerated: three new IDs, none yet tagged.
Choosing "Thorough" made the library look empty. The detector setting
writes under its own faces.model_id, and every reader of "the faces"
keyed on that exact id: the clustering pass, the coverage figure, the
sweep's work list, the shard export and import, and the sync merge's
face matching. On the reference library that restarted coverage at
1,834 of 19,140, drew a People rail of 36 faces for a person with 520,
queued a ~400 GB re-fetch on each device, and stranded the desktop's
3,583 confirmations under the old id: the tablet held the same faces
under the new one and the merge refused to match them. Same photograph,
same box, same embedder, two ids — that is one face, not two libraries.
The embedder half of the id is now the key. embedder_of and embedder_sql
give it to every query; writes keep the full id, so which detector drew
a box stays on record. record_detections is unchanged and is where the
generations meet: an image holds one pipeline's faces at a time, and a
re-detection carries confirmations across by box overlap. The merge's
match_faces applies the same rule within an embedder. The calibration
is keyed on the embedder too, since the similarity space did not change.
Shards travel every generation, each under its own id, and a peer adopts
whichever it is sent — including a stronger detector's pass over an
image it indexed itself with a weaker one, which is the re-detection its
own sweep would otherwise queue, already done. Never downwards: a tablet
on Fast keeps the desktop's Thorough faces. The sweep gains the same
tail — images a weaker detector indexed, after the ones nothing has —
driven by FaceDetector::supersedes, so choosing a stronger detector still
improves the library over time without first making it disappear.
A decode failure in the face sweep was counted, logged at debug where
nobody saw it, and left unmarked — so the next pass fetched the same
file and failed the same way. For the 521 MB panorama behind rawler's
panic that was half a gigabyte per sweep, on a tablet. It is now marked
examined with nothing found and a zero edge, which is what a later "try
again with a better decoder" pass would select on, and the warning
names the file. The failure count is unchanged: it did fail.
rawler panics on some input rather than returning Err — a DNG whose IFD
claims a >50000 px image, which the reference library has: a 521 MB
stitched panorama, IMG_4181-Pano.dng. On a worker thread a panic is the
end of the thread, so the face sweep that met it stopped thirteen
seconds in, three sweeps running on the tablet and three on the
desktop, with "17301 image(s) to index" as the last word. FR-RAW-4
says a malformed file must not abort a batch, and that is this crate's
promise whatever the library beneath it does: every entry point that
calls into rawler now runs under catch_unwind, and a file that panics
the decoder is one failed file with the panic's message in the error.
Verified on the panorama itself: metadata reads, decode returns the
error, the thread survives. The crash hook still records the panic,
which is right — it is a defect in a dependency and the record is how
it gets reported.
Walking the photo roll was one download per frame: every step showed
"Downloading…" over an empty canvas while tens of megabytes came down,
and moving between a pair of near-identical frames paid that a dozen
times. Now, once the opened photograph has landed, the ones around it
are fetched into the originals cache while it is being looked at, so
the next step is a disk read.
A single worker serves the latest wish only, closest first and working
outwards — next, previous, next-but-one, previous-but-one… — one file
at a time. Each open replaces the wish, so a fast walk never leaves a
trail of stale downloads competing with the one being waited on. A
process-wide in-flight registry makes a click on a photograph that is
still being fetched ahead wait for that transfer and read it from disk,
rather than start a second download of the same file.
How far each side is a setting under STORAGE — Off, 2, 5, 10 or 20,
defaulting to 5 — and it is moot while "keep originals after opening"
is off, since a fetch the cache would discard on arrival is transfer
for nothing. Nothing is fetched ahead while offline. The transfers show
in the activity list while they run and are removed when they end.
The launcher and the task bar have shown a generic tile for a working
window since the call was written. set_xdg_app_id sat at the top of
run(), on the reasoning that the app id is read when the surface is
created — true, and beside the point: the call goes through Slint's
global context, and there is no global context until something installs
a platform. That is BackendSelector inside shared_gpu, or AppWindow::new
falling back to the default, and both happen further down. Called before
either, it returned NoPlatform and did nothing at all.
It moves to just after the window is constructed, which is not the same
as shown — run() is far below — so there is a platform to talk to and
the surface does not exist yet.
The failure was logged at debug, which is why a year of grey squares went
unremarked: the whole symptom is invisible from inside the application.
It is a warning now, naming the consequence.
SQLite's busy timeout defaults to zero, and nothing ever set one: the
loser of a write race got SQLITE_BUSY at the moment it asked. WAL does
not cover this — it makes one writer and many readers free, and this
application constantly has two writers, the face sweep committing a
batch while the derived sync imports shards or reclustering reads.
The cost was not a retry but lost work. A sweep that had already paid
for the detection and the embedding — seconds per image, the expensive
part — discarded the result on "storing faces for 214: database is
locked" and moved on to the next image. Both the desktop and the tablet
logged runs of those on consecutive images, which is a face sweep
quietly failing to store the faces it had just computed.
Ten seconds, on every connection, set in configure() so that nothing
can open the catalog without it — the figure the job runner's own tests
have used for this reason since they were written. It is far longer
than any transaction here, so it bounds pathology rather than making
anyone wait.
The first CI run of the Windows leg passed every step up to packaging
and died in makensis with LicenseData: open failed
"target-windows/installer/stage\LICENSE". CI sets CARGO_TARGET_DIR to
the relative target-windows, and NSIS on a POSIX host translates the
backslash in a File path only when a leading / tells it the path is a
POSIX one; a relative name reaches it with the backslash intact.
Locally the target was always /work/…, which is why it never showed.
package.sh now resolves its directories with realpath first.
It also removes any installer already in the output directory before
writing the new one. That directory is cached between runs, so after a
version bump a glob over it finds two, and the smoke test hands Wine
both names joined by a newline as one path — which is what happened
locally the moment the version moved to 0.12.1.
fix/corrupt-remote-catalog-deadlock. The catalog on the server had been
malformed since 7 September and every device declined to overwrite it,
so collections and people stopped syncing everywhere at once; the
damage came from two devices assembling chunks in one upload directory.
Each chunked upload now has its own directory, the server keeps three
generations of the catalog behind the current one, every push is
verified before and after, a damaged copy that arrived whole is merged
from the generation before it rather than pinned in place, and the
catalog is backed up daily as NFR-R2 always asked.
NFR-R2 asks for the catalog to be backed up on a schedule and before
schema migrations. Only the second half existed: every backup on disk
was a pre-migration copy, and a library that never migrated was never
backed up at all.
A backup is now also taken at the end of a library sweep when the newest
one is more than a day old — the moment the catalog is quiet and a day's
collection and people edits have just been folded in — on its own
thread and its own connection, so the copy of a 130 MB file is not spent
on the UI. Whether one is due is read from the backup directory, not
the catalog, so the ordinary case costs nothing. An empty catalog is
skipped: there is nothing in it a rescan would not rebuild. Pruning to
KEEP_BACKUPS applies as before.
Two checks around the upload, both cheap next to what they prevent.
Before: the snapshot is quick_checked before it leaves. It is the copy
every other device merges from, and a damaged one costs each of them a
download, a failed merge and a refusal to push.
After: the staged upload's size on the server is compared to the bytes
sent before it is rotated into place. A chunked upload is assembled
server-side, and an assembly that goes wrong is a file of plausible
size no device can open — caught here, on the device that caused it,
for one listing; otherwise on every other device, after the fact. A
mismatch, or a size the server will not confirm, discards the upload
and leaves the current copy and its generations untouched.
The server held one copy of the catalog, overwritten in place on every
push. When that copy was damaged there were two answers, both bad:
refuse to touch it for ever, which is what every device did for a week,
or overwrite it with ours, which loses whatever another device had
added since — the escape hatch of the previous commit.
A push now uploads to catalog.upload.sqlite, rotates catalog.sqlite to
.1 (and .1 to .2, .2 to .3, dropping the oldest), and moves the upload
into place. Rotation is server-side renames, oldest first so that every
destination is empty when it is written to — move_to refuses to
overwrite, by design — and a failure at any step leaves a gap in the
generations and never a missing current copy. The only transfer is the
upload itself.
A damaged current copy that arrived whole now merges from the newest
readable generation before ours goes over it, which loses nothing, and
is kept as .1 by the ordinary rotation rather than by a separate 40 MB
upload. NFR-R2 asks for the catalog to be backed up; this is the half
of it that lives with the copy other devices read.
The upload directory was named from the destination path alone, on the
reasoning that two files could then not collide. Two devices uploading
the same file could, and did: both wrote 00001…00009 into one directory,
and whichever MOVEd first assembled a mix of the two — a catalog of
exactly the right size whose pages came from two different databases.
SQLite called it malformed, every client declined to overwrite it, and
collections stopped syncing on all of them for a week. A transfer that
died on a phone's link left its chunks there for the next device to
assemble in, by the same mechanism.
The name now carries a nonce as well, so no two uploads share a
directory, and a failed transfer deletes its own directory on the way
out rather than leaving 5 MB chunks for the server to sweep eventually.
"The chosen detector is not installed" was the whole of what a user
saw, on a machine where the files were three directories away from
where the lookup went. The search order was in a doc comment and
nowhere a user could read it. Now a missing pair logs the detector
file it wanted and every directory it tried, which is what the first
Windows install needed and what the next misplaced download will.
SettingsStore::open read XDG_CONFIG_HOME and HOME itself. Neither
exists on Android, so it resolved to .config/darkroom relative to a
working directory of /, and every settings edit on the tablet failed
with "read-only file system" — the page reported the error and nothing
said why. The doc comment claimed "the same resolution SessionStore
does"; now it is, by calling the same function, which honours the
directory android_main declares and takes the platform's config
directory everywhere else. settings.json sits beside sessions.json on
every platform, as the comment always said it did.
The catalog sync refuses to upload when it cannot read the server's copy,
because the upload is a read-modify-write and writing blind would discard
another device's collections. That is the right rule for a timeout, a
dropped connection or a newer schema — the remote is fine, only our view
of it failed.
A file SQLite calls malformed is not that. No device will ever read it
again, so refusing to write over it preserves nothing — and every client
declines in turn, pinning the damaged file in place for good. Collections
and people then stop crossing between devices on all of them at once,
each logging "catalog not pushed" on every pass. This library did exactly
that from 2026-09-07, on the desktop and on a freshly installed phone
alike, while 32 collections sat undelivered.
Now a copy that arrived whole and still will not open is set aside under
a dated name and replaced by ours. Whole is checked against the size the
server advertises: a truncated download will not open either, and on a
phone that is the far likelier story, so anything short — or any size the
listing cannot confirm — is treated as the transport failure it is and
the server's copy is left alone. A placeholder's size is not trusted for
the comparison, since it means nothing.
The report says when this happened, and the log line calls it "pushed
over a damaged copy" rather than folding it into an ordinary push: it is
the one push that discarded something.
The fourth leg of build-and-test.yml, in the shape of the Android one:
an image workflow that builds docker/windows and pushes it tagged by
the directory's tree id, and a job inside that image that lints the
Windows target — the only place the cfg(windows) branches are ever
compiled by CI — builds, runs the smoke tests docs/windows.md §6
specifies, packages, installs and uninstalls under Wine, and uploads
the installer. Every step was run by hand in the same container first.
The spec's open list closes with this: the four §3.2 items, the
licence page, and the leg. What remains is what Wine cannot show, and
§10 now lists it as the first real Windows run's checklist.
The repository declared GPL-3.0-or-later and carried no copy of it;
the Arch package pointed at the system's shared text and nothing else
needed one. The installer does: a licence page needs a file to show,
and the moment before installation is where the terms can still change
a decision. The standard text, at the root where every convention
looks for it, converted to CRLF at packaging time because a Windows
edit control draws a bare LF as nothing.
Login Flow v2 cannot complete without a browser, and the launcher had
a branch for xdg-open, one for Android's Intent, and an honest
Unsupported error for everything else — which on Windows stranded the
flow on "approve the sign-in in your browser". rundll32
url.dll,FileProtocolHandler is ShellExecute on the URL and needs no
crate; chosen over cmd /C start, whose quoting of & in a query string
is a known trap. Not verified: Wine has no browser to open.
The Secret Service store was keyring::Entry all the way down, and
keyring 4's v1 feature set — the one the workspace already asks for —
includes the Windows Credential Manager backend. So the Windows store
is the same implementation with its cfg widened, and the crate as a
target dependency. The one behavioural difference is that the
availability probe always succeeds there, which is correct: Credential
Manager is always present, so FR-NC-2's degraded mode does not arise.
Until now a Windows build compiled, started, and failed at sign-in
with the placeholder store's "no secret store is implemented".
Five sites each read XDG_*_HOME and fell back to $HOME/.local/… on
their own, which is fine on Linux and wrong everywhere else: Windows
sets neither variable, so every one of them degraded to a path
relative to the working directory — for a Start Menu launch,
C:\Windows\System32. The models lookup walked XDG_DATA_DIRS the same
way.
dr_plat::dirs now holds the rule per platform: XDG on Unix, the known
folders on Windows — %APPDATA% for config, which roams, and
%LOCALAPPDATA% for data and state, which do not — and the executable's
own directory as the system data dir, which is where the installer
puts the models. The Android overrides stay where they were; only the
fallback behind them moved. Both rule sets are unit-tested on either
host, and the Windows one was confirmed by running the application
under Wine: its log landed in AppData\Local\darkroom\state and nothing
was written anywhere else.
FR-CAT-13 asked for standard XMP and `core/dr-xmp` answered the file: it
has read and written `dc:subject`, `xmp:Rating`, `xmp:Label` and the IPTC
core since 5fa4c07, under an ownership rule that leaves everything else in
the document untouched. What nothing did was call it. No scan found an
`.xmp` beside a raw, no catalog row was filled from one, no judgement
wrote one back, and the "external modification detected, reload offered"
clause had no mechanism. A library imported from Lightroom came in and
could not go back out.
The scan collects `.xmp` beside `.drsc` from the listings it was already
paying for, and the pull reads each one whose ETag has moved. Both
namings resolve: darktable's `IMG_0001.CR3.xmp` names its file exactly,
Lightroom's `IMG_0001.xmp` names the stem, and under the stem the JPEG
beside a RAW is the same photograph and takes the same document, as
DarkRoom's own sidecar already does. Each is reconciled with the catalog
winning — keywords union, a rating or label taken only where the catalog
has none — because a standard XMP carries nothing that could say whether
its value is newer. A genuine disagreement is not resolved; it is written
to a table, and the settings page offers the sidecars' values against it.
That button is the reload the requirement asks to be offered, and the
ETag that moved is the detection it asks for: an `.xmp` edited elsewhere
is exactly a file the pull's ordinary incrementality re-reads.
Writing goes the other way behind a setting that starts off, since NFR-R4
makes writes beside somebody's originals theirs to switch on. With it on,
a judgement or a keyword rewrites the sidecar of whichever spelling
exists, or creates Lightroom's. The record is read from the catalog
whole at that moment rather than carried from the gesture, so a rating
and a keyword a second apart are two writes of one file that agree. And
the file's own title, caption, copyright and hierarchy come through the
rewrite: the catalog has no columns for them, `rewrite` replaces the
owned set wholesale, and a record that said nothing about them would have
deleted them from a Lightroom sidecar on every star.
The rating's two axes cross the format's one field both ways: a
rejection is Adobe's `-1` and stars are stars, and stars arriving on a
rejected frame lift the rejection, since the file said it was worth a
number. An unrated file says nothing and clears nothing, on the rule the
`.drsc` merge keeps. `versions.label` finally has a reader and a writer,
with the code table moved out of the query so the two cannot drift.
FR-DEV-5 asked for named snapshots of an edit state and FR-DEV-7 for a
comparison against a chosen one, and neither existed. The history stack
is per sitting and forgotten with it, on purpose — the gap that mattered
was an automatically saved mis-drag with no way back, and that was closed
first. What was left was the other half: a state the photographer wants
to keep *because* it is worth keeping, which is a different thing from a
step and is not served by making the steps last longer.
A snapshot is an edit state, and an edit state is exactly what a sidecar
version stores, so it is stored as one: a `[version]` block carrying
`snapshot-of = <uuid>`. The parameters, the masks and their parts, the
repairs and the film all arrive through the blocks that already carry
them, a merge keys on the uuid as it does for any version, and a build
that predates the key reads the block as a named version and keeps it —
the right failure. Only the pointer is new. The one reader that has to
know is `default_version`, which must never answer with a snapshot: a
file whose edit is missing is not a file whose edit is one of its saved
moments. The snapshots of an edit are listed by that pointer, oldest
first, the same on every device.
Writing them back removes what this sitting deleted and puts in what it
holds, and leaves standing whatever it never saw — a snapshot the other
device took since the photograph was opened here is not this device's to
remove by not knowing about it. That is the rule the version merge
already keeps, applied one level down, and it is why the save carries
the deleted ids rather than replacing the list wholesale as the masks
are. Each is re-pointed at the uuid the save settled on, because the
default may have been fused onto its canonical identity since the
snapshot was taken.
Restoring is one history step, so undo takes it back whole, as a paste
is. Taking and deleting are not steps: they change nothing about the
photograph, and an undo that removed a snapshot would be undoing a
decision to remember. Holding the eye beside one renders the snapshot
and hands the edit straight back — the same suspension "Before" uses,
against a point the photographer chose rather than the file. Two
sessions on the same photograph get ids that cannot collide, stamped
with the second and a random word, because the merge folds equal ids
into one.
NFR-OPS-1 asks for a diagnostics bundle — the log, the schema version, the
GPU and driver, the app version — "with an explicit preview-and-consent
step before anything leaves the device". The log and the crash records
have existed since August; what did not exist was any way to hand them
over that was not `adb pull` and a knowledge of where the state directory
is, which on the tablet the requirement was written for is nobody.
Nothing here sends anything, and that is the design rather than a gap:
crash.rs already says why a transport built ahead of the consent is the
shape of thing that gets switched on by default. The bundle writes one
text file to a place the user can find, so that they can attach it. That
is the moment it leaves, and it is theirs. So the consent guards the
write, not a send. Preparing gathers everything into memory and shows
what would be written — each section, its size, what was taken out, and
where the file would go — and only the second press puts bytes on disk.
A user who reads the preview and presses the other button has changed
nothing anywhere. The gathered bundle is held between the presses so what
is saved is exactly what was shown, not a second gathering that differs
by whatever was logged while they were reading.
One text file rather than an archive, because a `.txt` opens wherever
the user is sitting and pastes into an issue, and because the preview
can then be the file rather than a summary of it. Every line goes
through the blunter of the two redactions on the way in, whatever the
sink already did to it: the log's own rule keeps paths, since a path
read over `adb` is context, but a file meant to be attached to a public
report by someone who may not read it first is held to the crash
record's rule instead.
The About page's graphics line gains the driver, which the requirement
names and the adapter has always reported. And docs/outstanding.md is
corrected on both OPS requirements: it said crash reporting was a
log::error! hook and NFR-OPS-1 had nothing behind it, and neither had
been true since 2026-08-30.
A layer built from parts was missing the one control a correction most
often wants: seeing what it did. The question a subtracted gradient
raises is whether it took only the sky, and the question a stroke raises
is whether it filled the shoulder — and the only way to ask either was
to remove the part and look, which answered the question and lost the
part. The layer's own ring answers a different question, about the
adjustment, and hiding eight layers to check one correction is not an
A/B anybody performs.
So a part carries `hidden`. It is an edit and a history step, as the
layer's switch is, and it is folded into the render fingerprint because
hiding a part changes the mask as surely as removing it does. Where the
mask is built the shown parts are walked rather than the parts, which
is what makes a hidden base hand the fold to the first part that is
shown — and a revealed layer whose every part is hidden clears its slice
rather than leaving whatever the last rasterisation put there to be
read back. `covers` asks the same shown parts, so a layer whose only
adding part is hidden costs no slice at all.
In the sidecar the key is `hidden`, in the part's block or, for the
base, in the mask block — under a word that cannot be confused with the
layer's `enabled`, which has always meant the layer. Absent means shown,
so no file written before the switch existed reads any differently.
The row wears the same ring the layer does, one row down, because it is
the same question about a smaller thing.
FR-DEV-16's book said resetting a control and hiding a mask layer were
reachable by pointer and by finger, and stopped there. The reason was
honest: the generated rows have no focus, so "reset the focused control"
named a thing the panel could not point at. But a photographer at the
keyboard means something narrower than focus. They mean the slider they
just dragged too far, and that is a thing the panel can remember.
So the Adjustments global keeps the last control moved — two indices,
written where the panel forwards the change and cleared when the next
photograph opens, so a reset cannot reach back into the previous edit
through an index that happens to be shared. R puts it back, through the
same callback the track's double-click takes, and is silent until
something has moved.
The mask layer needs no such notion, because the panel already has a
selection: the rows the edge controls point at. H hides or shows those,
through the path the ring at the head of the row takes, so it is an edit
and a history step exactly as the ring is. A mixed selection goes to
shown, since the layer nobody can see is the one being asked about.
Both tags now carry the key, and the book says so.
docs/windows.md specified it; this is §9 steps 1, 2 and 4 run, and the
report in §10. A Debian trixie image with rustup, the MinGW cross
compiler, NSIS and Wine; a build.sh in the shape of the Android one;
a package.sh that stages the executable and the seven models behind
the same LFS-pointer guard every other packager carries, then runs
makensis; and the .nsi itself — per-user, no elevation, an uninstaller
that leaves the library alone.
Measured: the executable links first time once the link flags were
right, imports only Windows system DLLs, prints its version under
Wine, and the installer installs and uninstalls silently under Wine
with the registry key and the models where §5.2 says. What Wine
cannot show is the Start Menu shortcut: CreateShortcut is IShellLink
and does nothing headless.
Four claims in the spec's first draft were wrong and are corrected in
place with the reasoning kept: the whole-archive winpthread flag
breaks the link and was never needed; build scripts need a host gcc;
bookworm's Wine lacks the bcryptprimitives.dll rustc's std imports,
so the image is trixie; and NSIS's default stub is 32-bit, so the
installer says amd64-unicode and needs no i386 Wine.
Three things the Windows build showed the entry point was missing, and
that a Linux build never asks for.
`--version`, answered before the logger and the crash hook install: a
binary built on a machine that cannot run the application — the Linux
CI producing the Windows executable, checked under Wine — needs an
exit that proves it starts without opening a window or touching the
user's directories. It is the smoke test in docs/windows.md §6.
A GUI-subsystem executable in release, or Windows keeps a console
window open behind the application for the life of the process. Debug
builds keep the console, which is where their log goes.
A resource block, or Explorer, the Start Menu and the taskbar show the
generic executable icon and the Details tab is empty. build.rs wraps
the PNG every other platform uses into an .ico at build time — an ICO
entry may be a PNG, so the wrapper is a 22-byte header — and hands it
to winresource with the version cargo already knows. The crate is an
unconditional build-dependency because a cfg(windows) on one is
evaluated against the host, which here is Linux; the script itself
returns before touching it on any other target.
secrets.rs names the keyring service and desktop_client.rs the socket
timeout, and every use of both sits under a cfg that a Windows build
does not satisfy — the placeholder secret store has nothing to file
under, and the Nextcloud client's named pipe is not opened yet. The
first cross-compile reported both as dead code, which is a failed
clippy job the moment the Windows leg runs with -D warnings. Guarded
by the same cfgs as their users, with the reason beside each.
The tree is closer to Windows than a Linux-only project usually is:
every image library, the TLS stack and the inference engine are pure
Rust, and dr-plat already keeps the Linux-only code behind cfgs with a
loud fallback where none exists for another platform. What remains is
a short list above dr-plat — five XDG path lookups, an xdg-open, the
secret store's third implementation, the models' lookup beside the
executable — and none of it touches core, which is the NFR-PORT-3 test
this would be the first real run of.
docs/windows.md decides the GNU target over MSVC-via-xwin, Vulkan only
as on every other platform, a per-user NSIS installer that leaves the
library alone on uninstall, and a CI leg in the shape of the Android
one. It is explicit about what a runner with no Windows can verify —
that it links, is PE32+, starts under Wine and installs under Wine —
and what it cannot, which is everything involving a real GPU driver.
Three FR-PLAT-WIN requirements and a channel row record the decisions;
the ordering puts a first cross-compile on the developer machine before
any container exists, because the list of cfg gaps is a reading of the
source and the compiler's list will be longer.
The release keystore now exists and its four secrets are loaded into
Gitea, so CI produces an APK a device can update in place. Until now
every build, CI and local alike, was signed with a throwaway debug key
-- CI's fresh per run, the local one exactly as durable as the cache
directory it lived in -- and the night that cache was cleared, no build
anywhere could install over the tablet's copy.
package.sh forwards KEYSTORE_PASS, KEY_PASS and KEY_ALIAS into the
container and copies the keystore under the mounted target directory
for the build, so a local release-signed build is one environment line.
The doc records where the local copy of the key lives.
`a_failed_overwrite_puts_the_original_back` makes the presets directory
read-only and expects the overwrite to fail. CI's Desktop job runs in a
container as root, and root is not refused by a mode: the write succeeds,
the assertion fails, and build-and-test has been red on every push to
master since the test arrived.
The test now probes the refusal it depends on -- one write into the
directory it just locked -- and skips where that write goes through.
Probed rather than keyed on the uid, because what the test needs is the
refusal itself, and a filesystem mounted without permission checks would
pass a uid test and fail this one all the same.
faces.md §12.3 measured what the cheapest detector costs: the small
faces in every group shot, and a dog embedded a dozen times. Which
trade is right depends on the machine doing the sweep — a desktop left
overnight and a tablet on a battery want different answers — so the
detector is now a per-device setting, Fast / Balanced / Thorough on
the settings page beside the indexing button, persisted with the rest
of the settings file.
A detector is half of a model id. Every face, marker, shard and
calibration is keyed on faces.model_id precisely so that a model change
is a new id and a re-index rather than a silent change under existing
data, and a detector change is a model change: it decides which faces
exist and where the landmarks that align them land. So each choice
names its own pipeline. 500M keeps the bare "w600k_mbf" every existing
library was written under, so an upgrade disturbs nothing; the others
are qualified. Choosing one restarts coverage from zero under the new
id, the sweep re-detects, confirmed names carry across by box overlap,
and the sync shards are keyed by the same id so a peer on another
setting neither adopts nor pollutes them. The library controller
carries the id into the sync the same way it carries the cache budget,
because the sync starts from places that have no settings in reach.
All three shape-fixed exports ship — APK, Arch, Flatpak — since a
tablet has no other way to obtain the one it was not installed with;
the APK grows by twenty megabytes for the choice.
record_detections replaces every face on an image whatever model found
them, but left the other models' face_index rows standing. With one
model that was unobservable. With a second pipeline it leaves an image
marked "done" under the first with none of its faces behind the marker
— the state the V12 repair existed to undo — and a user who switched
back would find those photographs permanently empty.
An image now holds the faces of whichever pipeline looked at it last,
and only that pipeline's marker. Confirmed names still carry across by
box overlap, since they were read before the replacement.
§1 chose scrfd_500m on FLOPs and never measured the recall it gave up.
A dr-ui example now runs several detectors over the same sample of
stored proxies, matches boxes by IoU against the first, buckets the
result by face size, times each, and writes contact sheets of the
disagreements in both directions — because a count of extra faces says
nothing until someone has looked at whether they are faces.
Over 400 proxies from the reference library: 2.5G finds 14% more faces
for 12% more time, 10G a further 12% for 3.1× the time. The extras are
small real faces. The 86 faces only 500M found are a dog a dozen times,
a stop sign, a wheel and the backs of heads. Recorded in faces.md §12.3.
CI's Desktop job failed at the Format step on 16f3fb4: rustfmt puts the
`match faces_without_proxy(...)` on its own line under the `let` and
re-indents its arms, and the commit was written without running it. No
code changes; only the layout of that one match in library.rs.
Every face stored before its quality was kept holds a unit vector, and
V14 forgot the run marker of each image holding one so that the next
sweep would look again. Looking again meant detecting again: a whole
re-detection per image, with every suggestion on it thrown away and the
confirmations carried across by box overlap, to recover one number.
The sweep now has a measuring pass between the proxy repair and the
un-indexed images. It lists every image holding an unmeasured face,
fetches the original once, warps each stored face from the landmarks it
already has, embeds it, and writes the raw vector and its length over
the old row. Ids, boxes and identities are untouched; the marker is
re-written fresh so the sync exports the measured vectors. A face whose
landmarks no longer make a warp is dropped, as detection would have
refused to store it. `faces_unindexed` leaves those images to the
measuring pass, so the V14 deletion no longer costs a second detection.
The embedder's raw output has a length, and the length is a reading of
how recognisable the crop was: a blur, an occlusion or a hard profile
comes out short. Normalising threw it away. A short vector sits near
the middle of the sphere and matches a little of everyone, which is how
one bad crop bridges two people in a grouping pass.
So the length is kept — the store now holds the raw vector, re-normalised
on load, with the length beside it as `faces.quality` — and a face under
MIN_GALLERY_QUALITY (14) is a probe: measured against the gallery and
placed where it fits, but never what another face is measured against.
Two probes are never paired, and a probe is nobody's evidence for a
confidence. The People screen shows the number as "Quality 17.3", dimmed
below the floor.
Faces indexed before this stored unit vectors and have no reading; they
are admitted to the gallery, and schema V14 forgets the run marker of
every image holding one so the next indexing pass measures them. A
peer's unmeasured shard faces are not adopted, or a sync would write
that marker back.
The first build of seeing a mask showed the selected layer's, in one global
style, from a strip at the top of the panel. It answered the wrong question and
answered it somewhere nobody looked. What a photographer asks of two masks is
how they meet — where the sky's edge sits against the building's — and that
needs both on screen at once, in colours that can be told apart.
So each row of the stack has an eye, drawn in the colour its mask is shown in,
and each mask has six swatches to choose that colour from. Several can be open
at once; a new one comes up open, in the first colour nothing else is using.
The style — tint, alpha, outline — is the one setting that stays global, above
the stack, because three styles at once are three pictures that cannot be read
against each other. Alpha now draws every shown mask, each in its colour, on
black. In the pipeline a `Reveal` is a list of `(layer, colour)` rather than
one layer, and every reveal block carries its own colour.
The brush moves too. Select, Paint and Erase and the three sliders under them
sat at the top of the panel, appeared only once a row was selected, and said
nothing about which mask they acted on — so "how do I paint" and "how do I
correct the model's outline" both had the same answer and nobody found it.
They sit under the selected mask's parts now, beside the swatches, and on a
subject or a category the hint says what a stroke there does: it becomes a
part of this mask, joined to the model's, and can be taken out again.
Eyes and colours are viewing state, on the session and not on the layer, so a
photograph reopened has every eye closed — the stored-mask round-trip test
asserts it.
Every release commit in the history reads `Release X.Y.Z` and none of them
carries the message this script would have written, so nobody has ever
passed it `--commit` — and the reason is in the message it wrote: a
Co-Authored-By trailer naming an assistant, which no commit in this repository
carries and none should.
The trailer goes, and so does the paragraph above it: the script's own header
already says why it exists, and a release commit is the one place a one-line
subject is the whole convention.
Choosing a category is asking what it selected, and for a subject or a
category that question had no other answer on screen: the model's outline is
not derivable from anything visible, a fresh layer carries no adjustment to
judge it by, and the list it was chosen from says "architecture 23%" without
saying which 23%. The control that draws the mask existed but had to be found
and pressed, a panel's height away from the list the choice was made in.
So a new layer arrives with its mask showing, from the resting position only.
Somebody who has chosen the alpha or the outline keeps it, and nothing re-arms
in the background — every caller is a press that asked for a new mask.
That makes the canvas depend on how a layer arrived, which is correct and
worth stating: a session that has just made a mask draws a frame that a
session which read the same mask out of a sidecar does not. Viewing state is
not edit state and does not travel in a file, and
`a_stored_mask_renders_exactly_what_the_model_rendered` now says so at both
ends.
The brush did nothing, and neither did three other things nobody had tried
lately: clicking a subject on the photograph to select it, placing a repair,
and sampling a neutral. All four are TouchAreas over the canvas, and all four
sat behind the pan/zoom area, which is full-canvas and enabled for everything
but a crop. It took every press in the viewport and they were never offered
one.
Slint hit-tests siblings front-to-back (`send_mouse_event_to_item` visits
children `TraversalOrder::FrontToBack`), a TouchArea answers `GrabMouse` on any
press it is enabled for, and the first grab aborts the traversal. Front means
*last declared*. Each of the four carried a comment saying it sat "above the
pan/zoom area so a click reaches it first" — true of the order they were
written in, and backwards.
Nothing about the geometry decides this, so nothing about the geometry could
have fixed it. The pan area is declared first now, as the backstop it always
meant to be, and the rule it leaves behind is that the general case goes above
the specific ones. `GradientHandles` is the other end of that rule and is why
dragging a handle has worked all along while everything between it and the pan
area did not.
The order is asserted in a test, because this is a fault that compiles, passes
every other test, and silently removes four tools at once.
Every route to a layer began with a selection — a gradient, a band, a subject,
a category — and painting was reachable only by making one of those and
joining a painted part to it. So the answer to "brush a correction onto this
corner of the sky" was "add a radial gradient you do not want, then paint into
that", which is not an answer.
Paint sits beside Linear and Radial and makes a layer whose base is a brush.
It covers nothing until a stroke lands in it, so pressing it arms the brush
and shows the mask as well: a row that appeared and changed no pixel, with the
pointer still in "select", is indistinguishable from a button that did nothing.
Nobody can refine an edge they are not being shown. The only thing drawn on
the canvas was the region overlay — a false-coloured picture of what the model
*detected* — which knows nothing of a layer's feather, its falloff, its
morphology, its invert or its opacity, and nothing at all about a gradient, a
range or a stroke. Every control added for mask editing therefore acted on
something invisible, which is why the whole feature reads as absent rather
than as unfinished.
A layer's finished mask now draws over the photograph in one of three styles:
a tint for whether the right thing is selected, an alpha for where the edge
is, an outline for whether that edge is registered against the detail the
other two hide.
The hard part is not the shader. A selection with no adjustment on it changes
no pixel, so it is not active, so it holds no slice of the mask array and is
never rasterised — and that is exactly the layer somebody wants to look at,
for the whole of the time between choosing a subject and deciding what to do
to it. So `MaskStack::rendered` is `active()` plus the layer being looked at,
and the rasteriser, the composer and the distance-field builder all index by
position in it. Which is also why the design's "two uniforms, no recompile" is
not available: a uniform can select a slot, it cannot conjure one.
The reveal is never on the graph. It reaches the pipeline as an argument to
`compose_revealing`, and `compose_for` — which the exporter, the thumbnail and
the neutral probe all call — has no way to ask for one. A flag on the graph
would have been shorter, would have type-checked, and would have been one
forgotten reset away from a red tint baked into an exported file.
And the tools that shape a mask now arm. `Masking.tool` is an `in` property
only Rust may write, and the handler wrote nothing back, so the strip reported
"Select" however many times Paint was pressed and the paint area was never
enabled — the brush, the parts and the whole of FR-DEV-19b reachable from no
control in the application.
The region overlay stands down while a mask is being shown, and its button now
says what it hides: two overlays that look alike and mean different things is
worse than either.
Clicking "architecture" made a layer whose mask was gone. Every category layer
began at STRICTNESS_DEFAULT, and that constant was fitted on the synthetic sky
the refine tests build — its own note warns that a real photograph's noise
"moves every crossing down together", which turns out to be a considerable
understatement. Measured over seven ordinary frames, half scale removes 76% to
99.5% of `architecture`, 36% to 93% of `ground` and 18% to 91% of
`vegetation`. Only sky, the category the number was calibrated against,
survives it.
An empty mask is indistinguishable from a broken one: the layer is listed, the
adjustment moves, and no pixel changes. So what this looks like from outside
is that the segmentation does not make masks at all.
No smaller constant fixes it either, because a nat of evidence means different
things over a smooth sky and over a stone facade — the useful position is
above 5 on one frame and below 1 on the next. So the frame is asked instead:
`Refinement::gentle` walks down from half scale and takes the first rung whose
gate removes no more than a sixth of the category's weight, and the model's
own outline when none of them does. One `apply` on a friendly photograph and
four on an unfriendly one, paid when a layer is made rather than for eight
categories nobody masked.
The slider's reset went to 4 as well, so taking the control back to its
"default" emptied the mask. It goes to zero now, which is the one position
documented to mean something: exactly what the model weighted.
`cargo fmt --check` failed the desktop job, on three files and for one reason:
routing the mask handlers through the `Masking` global was done by substituting
the call prefix, which is a text edit rather than a Rust one. It left
`window.global::<Masking>().on_part_join_picked(...)` on a line that had been
short enough as `window.on_mask_part_join_picked(...)` and no longer was.
Formatting only. The whitespace-stripped source is identical in the two `ui/`
files; the third differs by the trailing commas rustfmt adds when it breaks a
call across lines.
The matrix moves with it, because the tags shift by a few lines and the check
compares line numbers.
Two faults in one control, both reported from the tablet.
The stock picker appeared in every group. It is not a parameter, so it is not
a row, so the filter that hides every other control when a group is chosen
never saw it — "Kodachrome" sat at the top of Light, of Colour and of Detail
alike. Three places it does not belong, and the one it does no more prominent
than the rest. The descriptor has said `Effect` and only `Effect` since the
film moved there; nothing was asking it.
So the panel now asks. It cannot ask directly — a generated panel may not know
which operation a control belongs to — so the session answers, from what the
operation declares it is about, and a stock re-declared as something else would
move on its own. The flag is recomputed when the group changes as well as when
the film does, which is the half that would have made it stale exactly when it
mattered.
And the open list was unbounded, so it made the develop column taller and the
column scrolled as one: reaching Velvia dragged every slider below it off the
screen, an answer given once pushing aside the controls used constantly. It now
scrolls within a bounded height of its own.
That viewport is counted rather than measured, for the reason the tool rail
records a few files away: a viewport that asks a layout how tall it wants to be,
while the layout takes its height from the viewport, is a cycle Slint settles by
handing back the height it was given — and the content is then clipped in
silence rather than scrolling. Every row here is one fixed height, so
multiplying is exact.
The group rule has a test. The scrolling does not, and cannot: it is a layout,
and a layout fault is invisible to the compiler and to every assertion that can
be written about it.
A node that has just been declared can be read, reasoned about and tested, and
none of that answers the question a photographer asks first: what does moving
this do to the picture. Colour grading, dehaze and the range masks were all
argued into the tree on their behaviour and none of them had been *looked* at.
So two diagnostics. `sweep` walks one operation from its minimum to its maximum
and writes a frame per step; `rangesweep` does the same for a mask band, which
is not an ordinary parameter — it lives on a layer, is rasterised by its own
pass, and only becomes visible through whatever adjustment the layer carries,
so it gets two stops down to make the selection legible.
`sweep` names no operation. The id arrives as a string and the parameters and
their ranges come from the graph's own capabilities, so a node declared
yesterday sweeps on the same terms as one that shipped a year ago — the
property `ops/README.md` promises, used rather than asserted.
Three things it learned the hard way and now records. It renders through
`render_detailed` unconditionally, because the fused path refuses a shader
composed with a detail stage rather than rendering it wrongly, and that call
falls through when there is no such stage. It takes `SWEEP_HOLD`, because a
parameter grouped under one widget is not meaningful alone: a hue with no
strength behind it renders the same frame every time, which reads as a broken
node rather than a correctly declared neutral. And it bounds the output, since
a 25 MP frame is a 75 MB PPM and a sweep is hundreds of them.
Both read a rendered file as readily as a raw one, so a JPEG can stand in where
no raw is to hand — on the terms `from_rgba8` documents, with the controls
still working and their neutral being what the camera left rather than what the
sensor recorded.
Every panel in the develop column declared its inputs and its callbacks and
had `app.slint` bind each one to a property or a callback on the window root.
That is fine while a panel is drawn once. N9 draws them a second time, in the
portrait dock, and the wiring is what would have to be copied: `MaskPanel`
alone ran to forty lines of forwarding, and a callback added to one copy and
not the other compiles, renders, and simply does nothing on the layout nobody
was looking at.
So the wiring moved to Slint globals. A panel reads the global and calls the
global; Rust hooks the global instead of the window; and the instantiation in
the column is now the panel's name and a pair of braces — every one of the ten
children of the column, with no property that differs by placement left to
supply.
There is a global per panel family rather than one for all of them, and the
reason is an import cycle. Each panel's model struct — `ParamRow`, `MaskRow`,
`HistogramView` — is declared in the panel's own file, so a single global
holding `[MaskRow]` and `[ParamRow]` would have to live in a file importing
`masks.slint` and `adjust.slint` while both imported the global back, which
Slint rejects. Breaking that needs six model declarations relocated, which is a
change to the data model and not to the plumbing this is about. A global beside
the panel it serves also lets each name drop the prefix it was carrying only
because the window root is one flat namespace: `root.spot-radius` is
`Repair.radius`, and `root.peaking-on` is `Peaking.showing`.
`session.slint` is new and holds the two facts every family needs and none of
them owns: whether there is an open photograph to edit, and which mode the view
is in, with the three readings of the mode derived once instead of at each of
the dozen places that tested one. `ViewMode` moves there from `adjust.slint`,
where it was only ever a lodger.
Nothing on screen changes. What is not here: the tool rail and the status strip
still take their properties at the instantiation, because they are drawn once
and N9 does not copy them; the preset sheet's own state stays on the window,
because the library grid opens the same sheet and a global cannot bind the
window's state — which is why `Transfer.open-presets` is handled in
`presets.rs`, beside the summary it already had to compute.
Slint cannot turn a layout on its side, and that is what D-N7 asks for. So
the HorizontalLayout holding the rail, the canvas and the develop column
becomes a plain Rectangle and each of the three states its own x, y, width
and height. With `column-below` false those come out where the layout put
them to the pixel — a HorizontalLayout has no spacing or padding of its own,
the rail and the column took their declared widths at the two edges, and the
canvas was the only child that stretched.
The alternative was the column subtree declared twice under two `if`s, which
is four hundred lines of bindings copied, in a file whose own notes record a
conditional child in a layout as the shape that has produced binding loops
here before.
The column is the same column either way: the same contents, the same
Flickable, the same toggle. `panel-visible` collapses the dock's height
exactly as it collapsed the column's width, so `column-width` and
`dock-height` are each zero unless the column is both open and on that axis,
and the canvas can subtract both without asking which case it is in. The
stack inside stretches to the dock's width on its own — a layout that is the
direct child of a Rectangle fills it, and the Flickable's viewport was
already bound to its own width. Nothing is reflowed; N9 does that.
`dock-height` is mandated in style.yaml for the reason `panel-width` beside
it is, on the other axis: a dock that sizes itself to its contents is a
photograph that changes height when a caption wraps. 480 until N6 measures
the device.
The seam follows the column round: a hairline down its left edge beside the
photograph, along its top edge under it, so it stays between the two.
D-N7 puts the develop column under the photograph on a tall window, and the
axis it turns on is aspect rather than width: a 960-wide portrait tablet is
expanded by width and wants the dock, a 1500-wide landscape desktop is
expanded by width and does not. So this cannot be folded into the layout
class, and it is not remembered per class either — closing the column in
landscape closes the dock in portrait, because it is the same column.
`window-resized` reported width alone and now reports both, from a
`shell-height` that subtracts the safe-area insets exactly as `shell-width`
subtracts them: on Android the strips the status and navigation bars occupy
are on the axis being measured, so the aspect of the window and the aspect of
the space the interface actually gets are not the same number.
`column_below` is the decision, with two thresholds rather than one. It is
read on every resize event, and a single threshold means a window dragged
along its own diagonal crosses it several times a second while the pointer is
still down. Entering at 1.25 and leaving at 1.15 is a dead band no plausible
drag re-crosses.
The comment on EXPANDED_MIN_WIDTH claimed a tablet in portrait gets the
compact layout. It does not — its panel is about 960 logical pixels across,
which clears 820 — and that mistaken example is the one D-N2 reasoned from.
Corrected in the same breath, since this is the commit that says what
portrait actually changes.
The comment explaining why both coordinate systems go on one line was
written for `log_window_metrics` and sat above `window_metrics_level`,
where it read as the start of that function's much longer note. Two
doc blocks ran into each other and the one that prints had none.
Every figure in D-N7's table was computed at a guessed scale factor. The
tablet's panel is 3000 by 1920 physical and nothing in this repository
has ever recorded the density Android reports for it, so the dock's
width is either 900 or 1037 and its available height is 200px either
way. N6 asks for the measurement; this is the line that carries it.
Beside the existing `apply_layout_class` call, because that is where the
window is already being asked for its size and its scale, and again on
every resize, so turning the tablet over records the other orientation
in the same logcat. Both coordinate systems on one line: a logical size
cannot be checked when the scale is the thing in doubt, and a physical
size that does not divide by the scale printed next to it says the
reading is of something other than the panel.
The level is not fixed, because none of the three obvious choices works.
`android_main` caps the facade at info, so debug never leaves the
device and a debug-only line answers nothing. A drag emits a resize per
frame and each accepted record is also appended to the on-disk log, so
info on every resize is not a diagnostic. And the first reading is not
the settled one: on X11 the window reports 0x0, then 360x320 at scale
1.0, then 1100x720 at scale 2.0, so reporting only the first would put a
number in logcat that is not the window's.
So the pair that decides is the scale factor and the orientation --
exactly what N6 is asking for, and exactly what a drag leaves alone. A
window with no area is not a reading and records nothing. Every later
change to either half, the scale resolving or the tablet turning over,
is a new answer and goes out at info; everything else is debug.
FR-UI-1's table has put tablet portrait in the compact class since the
register was written. D-N2 settled that it does not belong there: a
12-inch tablet is about 1024 logical pixels across in portrait and
EXPANDED_MIN_WIDTH is 820, so both orientations of both targets are
expanded, and compact fires only on a desktop window dragged narrow. The
code has always agreed - apply_layout_class reads the window's width and
nothing else - so the register was the last place still saying otherwise.
What portrait actually needed was a different axis. D-N7 docks the
develop column under the photograph on a tall window, set from the
window's aspect and independent of the layout class: a 960-wide portrait
window is expanded and wants the dock, a 1500-wide landscape one is
expanded and does not. That is a placement rather than a mode, so the
clause about the transition being continuous stands as written, and the
requirement now records the side the column takes as its own property.
FR-UI-2 gains one clause for the same reason. It said modality affects
control sizing and affordances "not layout", which D-N6 reversed: under
touch the group selector leaves the strip above the column for the tool
rail, and groups-in-rail in toolrail.slint is what moves it.
D-N2 dismissed portrait with one number: a 12-inch tablet is about 1024
logical pixels across, which clears the expanded breakpoint. That was
worked out for a 4:3 panel. The tablet's is 3000 by 1920, and on that
aspect a column beside the photograph in portrait leaves it a strip 540
wide and 1456 tall: a 3:2 frame gets 540 by 360 where a column below it
would give 900 by 600, and the portrait frame gains too.
D-N7 records the decision: a third property beside the layout class,
derived from the window's aspect with hysteresis, that lays the same
rail, canvas and column out on the other axis. Not a sheet, not a second
layout, and the rail does not move. N6 measures the device the numbers
were guessed for, N7 does the frame with the stack stretched as a
stopgap, N8 moves the develop callbacks onto a global so the panels can
be declared twice cheaply, and N9 is the three-column composition the
dock's width is actually for. N5's "no strip along the bottom" is struck
where D-N7 reverses it and kept where it does not.
A still frame cannot show what makes these tools right or wrong. What
matters is how the mask *moves*: whether a stroke lands where the finger
went, whether a subtraction takes away only what it covers, whether an erase
inside a correction punches through the selection underneath. Every one of
those is a sequence, and the test suite asserts single pixels.
So this renders the sequences. A synthetic photograph, one frame per step of
each mode — painting, erasing, joining a part and taking it out again,
inverting, and sweeping the edge controls — as PPM, which ffmpeg turns into
a GIF in one line. It runs headless, needs no RAW and no model, and takes a
few seconds.
It is also the honest answer to "show me it working" while the tools are
still being wired to a finger: this is the pipeline itself, not a mock-up of
it, and a fault in the fold shows here as a frame that looks wrong.
A mask the model draws arrives approximately right — stopping inside a
shoulder, leaking into the hair — and FR-DEV-3's edge controls move the
*whole* boundary, so no value of feather or dilation fixes two errors that
go opposite ways. What fixes them is a second selection joined to the first,
and a layer that held exactly one source had nowhere to put one. The brush
the core has had all along was reachable from no control in the application.
A layer is now an ordered list of parts. Each names a source and how it
joins the mask before it — added to it, or taken out of it — and carries its
own edge treatment, because a model's soft coverage and a stroke painted
where it stopped short do not want the same feather. Invert and opacity stay
on the layer, where the composed shader already reads them.
The sidecar grows `[part]` blocks and nothing else. A layer of one part
writes exactly the bytes it always did; a mask block with no part blocks
after it reads back as one part; and a stroke, a join or a source this build
cannot read costs that part rather than the layer. So every sidecar in every
library still parses to the edit it always was.
On the device the parts fold into the layer's one slice, so eight layers
still cost eight channels: union is a `max` blend and subtraction is the
erase blend the brush already used. A part is drawn into a scratch texture
before it is joined, and that is not incidental — an erase stroke means a
hole in *that part*, not a hole in the mask, and drawn straight onto the
accumulator it would punch through the subject underneath. A layer of one
part skips all of it and takes the path it always took.
In the interface: a part list under the selected layer with a chip saying
which way each joins, Add and Subtract beside it, a Select/Paint/Erase strip
with the brush's size, hardness and flow, and a drag on the photograph that
paints. Pressing Paint on a mask that cannot hold a stroke joins a part that
can, rather than explaining that a subject is not a brush. A whole stroke is
one step in the history.
The edge controls now shape the part that is selected rather than the layer,
which is the one behaviour change to an existing control: with a correction
selected, the feather slider softens the correction and leaves the model's
mask alone.
The auto masks arrive in a second and cannot then be changed by a pixel: no
brush, no way to cut one selection out of another, no way to drag a boundary
that stopped inside a shoulder, and no way to see the alpha a layer actually
produces — the canvas overlay draws what the model detected, not the mask.
docs/mask-editing.md is how that closes. A layer stops holding one source and
holds an ordered list of parts, each naming how it joins the mask before it,
so painting on an auto mask, subtracting, and intersecting a subject with a
luminance band are all one mechanism. Notes what the tree already has (the
whole brush is written and reachable from no control), what the set operations
actually cost (three blend states, no new texture), why the edge push is a
warp rather than a local morphology, and why painting has to draw
incrementally. Ends with the four decisions the build needs first.
Collections could be made and filled and never reorganised. Nesting had
a drag; un-nesting had nothing, in either direction — "All photographs"
refused every drop, which is right for a photograph and wrong for a
collection, which has a top level to be returned to. So a collection put
inside another was in there permanently. Right-click deleted an *empty*
collection outright and refused otherwise, which is wrong in both
directions at once: destructive with no confirmation, and no way at all
to delete a collection that held anything without emptying it by hand,
child by child. And a photograph could only leave the collection the
grid was scoped to, since that is the only one a button in the header
can name — the cell's badge says a photograph is in three collections
and never which three.
Three ways in, one vocabulary:
**Hold a row.** The tree is inside a Flickable, which claims any drag
beginning inside it, so with a finger a drag on a row is a scroll until
something says otherwise. The hold is that something. It lifts the row —
drawn before anything moves, so the gesture says it has been understood
— and then what the user does decides which of two things they meant:
move, and it is a rearrangement; let go, and it is the row menu. The
same fork the grid already uses to tell hold-to-select from drag-to-file.
`decide_release` is that fork, and it is tested, because getting it
wrong one way puts a sheet over every tidied tree and the other way
makes the menu unreachable by touch.
**The row menu.** Rename, new collection inside, move to top level,
keep offline, delete. Deleting asks once when there is anything to lose
and says what survives: the photographs stay in the library, and nested
collections move up rather than going with it — which is what the
catalog does, and what a user would never assume. An empty collection
goes on the first press, because a dialogue about losing nothing is how
people learn to dismiss dialogues.
**"Collections…" on a selection.** Every collection the selection is
filed in, each with a count — "3 of 40", so nobody takes forty
photographs out of a collection thirty-seven were never in — and a way
out of any of them without navigating there first.
The long press used to open the offline question by itself. That
question is one item in this menu now: there is one hold per row, and
while it was spent on a single action nothing else the tree can do had
a touch route at all. Nothing is lost — the tray on the row keeps its
tap, and the question gains a full-width control in place of a 30px
icon in a row shorter than the touch minimum.
The row-press handler moves to `collections_ui` with the rest of what a
collection row does; it lived in `library_ui` only because it opened
that prompt.
Slint negotiates a drag action between source and target, and the
runtime clamps whatever `can-drop` returns against the set the source
allowed: an action outside it becomes `none`. Both drop targets here
named a constant instead of echoing what was on offer, and each named
the wrong one for half its traffic.
A collection row takes two kinds of payload. Photographs come from a
DragArea allowing `copy`; a collection being nested comes from one
allowing `move`. The row asked for `copy` unconditionally, so images
filed correctly and every collection dropped on a collection was
refused — nesting by drag has never worked. The trash had the same
fault mirrored: it insisted on `move` while the grid's cells allow only
`copy`, so it refused every photograph dragged to it.
Neither failure had anything to see. A clamped action is delivered as a
refusal, which looks exactly like a target that declined on purpose, so
the drag simply did nothing and left no error to search for.
`decide_drop`'s Reparent branch was tested and passing throughout. It
tests the decision, not the negotiation, and nothing was reaching it.
`collections_for_image` answers this for one photograph and has no
counts, which is enough to badge a cell and not enough to offer a
removal: with forty selected and three of them in "Iceland", a sheet
that says only "Iceland" invites the user to take all forty out of a
collection thirty-seven were never in. `membership_of` returns the
count alongside the name so the row can say "3 of 40".
Chunked over the image list rather than one `IN (...)`, because the
list is a selection and a select-all makes it as large as the library —
past SQLite's bound-parameter cap on exactly the gesture most likely to
produce it. Counts are summed across chunks, so the answer is the one
the unchunked query would have given.
Smart collections are excluded by construction: they have no member
rows, so there is nothing a removal could do.
The develop panel's Optics group is three manual sliders: distortion, chromatic
aberration and lens vignetting. The automatic correction was already there — the
file's EXIF lens is matched against the bundled Lensfun database on open and the
coefficients are fanned out to all three — but nothing in the interface said so
except a line of grey text under the camera reading "· corrected", and there was
no way to decline it. From the outside that is indistinguishable from the
feature not existing, which is how it was read.
`dr-lens` states the rule this breaks: an automatic correction that silently
does nothing is worse than one the user can see is unavailable. The caption
satisfied the letter of it and not the point — a photographer looking for
"apply the lens profile" found three sliders and no switch.
So the profile is now a control. It is a capability rather than a flag on the
session, because everything a photographer sets travels one road: the capability
list feeds the generated panel, `Preset` captures it, the sidecar stores it and
the undo stack replays it. A bool on the side would have needed adding to each
of those four by hand and would have been forgotten in at least one — which is
exactly how the mask stack came to be missing from the history.
It is on by default, which is what `switch_on` is for: the coefficients are a
measurement of the lens that took the photograph, so accepting them is neutral
and declining them is the edit. The sidecar therefore stores nothing for the
ordinary case and the correction still happens.
The switch appears only where a profile was matched. A tick box on a photograph
whose lens the database has never heard of would be a control that looks
available and does nothing, which is the failure the rule above names rather
than an instance of following it — those photographs are told "· no profile" in
words instead, and one whose box is unticked now says "· profile off", which is
a third fact and not either of the other two.
Two things had to be built underneath. `ParamKind::Bool` was in the core's
closed enum and mapped to a row kind here, and had no control behind it in
`adjust.slint`: a parameter declaring itself a switch was flattened into a row
that drew nothing at all. Nothing shipped had one until now, so the gap cost
nothing and was invisible. And `Check` self-toggled, which is right for a
settings page that owns its value and wrong for a panel row that is a view of
the edit graph — the click would have answered by replacing the binding with a
literal, and the next undo or pasted preset would have moved the value with the
tick left where the finger put it. It now takes `controlled`, and the generated
row uses it.
The manual sliders are unchanged and still trim whatever the profile leaves, so
switching it off is "correct this by hand" rather than "stop correcting".
With the adjustment groups in the rail, a short window silently lost them.
At a 1500x680 window the rail drew Photo, Compose, Local, Repair, the seam and
"All", and then stopped: Optics, Light, Colour, Effects and Detail were not
scrolled off, they were gone, with nothing on screen to say so. The develop
view's primary navigation, unreachable by any means.
The Flickable was put here to prevent exactly that, and it could not, because
its viewport asked the layout how tall it wanted to be while the layout was
already taking its height from the viewport. Slint settles that cycle by
handing back the height it was given, so `max(self.height, preferred-height)`
could never exceed `self.height` and there was never anything to scroll.
Counting breaks the cycle. Every entry here is a fixed height by construction —
a tool is `rail-entry-height`, a group is a touch target — so the content is
six plus four fifty-fours plus a gap plus a touch target for each group and
"All", which is exact rather than an estimate and depends on nothing that
depends on it. Four tools and five groups come to 498, against the 340 a
680-pixel window leaves at 2x, and the difference is now scrollable rather
than absent.
Found by shrinking the window with the groups forced into the rail. The
interaction itself is unverified: synthetic input does not reach a Slint
window on this desktop, and the tablet was disconnected, so what is confirmed
is the arithmetic and the clipping it explains, not the scrolling it should
restore.
Dependencies were built at `opt-level = 2` even in dev, because wgpu and
image decoding are slow without it. That is still true, and it is what this
gives up: a debug run of the app, and the decode- and GPU-heavy tests, are
slower than they were.
What it buys is that nothing has to be optimised before it can be compiled.
That cost was paid on every edit, in every worktree, whether or not anything
was ever run — and there are sixteen worktrees, each with its own target
directory and no shared cache, so it was paid sixteen times over.
`[profile.release]` is untouched: `lto = "thin"` and `codegen-units = 1`
still apply where the speed is actually wanted.
If one crate turns out to be the one that makes a test unbearable, raise
that crate alone rather than restoring the blanket rule; the manifest says
how.
Incremental compilation is now on as well, but that lives in
`.cargo/config.toml`, which is untracked and per-checkout — so it is a local
change on this machine, not part of this commit.
The Detail group ended with three consecutive sliders labelled "Amount" and
nothing to tell them apart. They are dehaze, clarity and texture: each declares
exactly one parameter, and `ops/README.md` tells an author to reach for the
`amount` kind first, so all three named it the same thing.
The panel withholds a heading from a group of one, and the reasoning it gives
is sound — a lone control names itself, and the group reset it loses costs
nothing because the slider already resets on double-click. But that argument
rests on the parameter being named after what it does. It holds for exposure,
contrast, vibrance, saturation and brilliance, whose single parameter shares
the operation's name, and it fails for the three whose parameter is called
after its kind rather than its subject.
So a lone parameter now takes its operation's label — the name the withheld
heading would have carried. For the five that already agreed, nothing changes.
Found on the tablet, and only there: every row was correct, every label
resolved, and the panel was still unusable. The test that guards it asserts
over the real chain rather than a fixture, because the fault was a property of
what is actually declared — a fixture would have had to be written to
reproduce it, and would then only have proved itself.
`showing_the_original_leaves_the_edit_exactly_as_it_was` set row zero to its
maximum and then asserted the photograph was modified. It was not, and the
test failed on its own premise rather than on the thing it exists to check.
`EditGraph::capabilities` puts the lens corrections at the head of the list,
matching where they sit in the shader. Those carry profile coefficients rather
than parameters, so with no profile loaded a slider on one is a control with
nothing behind it: the graph stays neutral and the premise assertion fires.
The test was written against a panel whose first row happened to be an
adjustment, and it stopped being one.
So it now walks the rows until it finds a control that actually changes the
edit, which is what it meant by "row zero" all along. Still addressed by
index, so it still names no operation, and it no longer depends on where in
the chain the first *adjustment* happens to sit.
FR-UI-4 says a gesture with no visible counterpart is a feature only its
author knows about, and the vocabulary the application actually publishes had
two sections in it — the library grid and people. Develop had none. Every one
of its gestures was documented in the comment beside the `TouchArea` that
implements it, which is where the previous sixteen were before this scanner
existed, and unreachable to anybody not reading the source.
Thirteen now carry tags: magnify by pinch or wheel, pan a magnified frame,
fit and 1:1, hold to see the original, sample a neutral, undo and redo, step
through the folder, reset one control, show or hide a mask layer, choose a
group of adjustments, and copy and paste the settings. Each has a pointer and
a touch route, so none of them is keyboard-only.
Three bindings were genuinely missing and are added here rather than merely
described. Ctrl+C and Ctrl+V for the settings clipboard, which the Settings
panel's own comment has claimed existed for as long as the panel has and
nothing bound; and [ and ] to step through the adjustment groups. The groups
are whatever the operation set declares itself to be about, so there are as
many as the pipeline has and no key can name one of them — stepping is the
binding that survives a node being added, and "everything" is part of the
cycle rather than a way out of it.
Two are described and not bound. Resetting a control from the keyboard and
toggling a mask layer from the keyboard both need a notion of which control
or layer has focus, and the generated panel has none — the rows are a model
the repeater rebuilds, and inventing a focus ring for them is a larger change
than a keyboard shortcut. Both are reachable by pointer and by finger, and
the tags say so rather than promising a key that is not there.
FR-DEV-3 has asked for "white balance (temperature/tint, and picker)" since
it was written, and only the first half existed. `WidgetKind::WhitePoint` was
in the vocabulary and `develop::supported` answered false for it, so the node
degraded to two sliders — correct behaviour that had quietly become the only
behaviour. Sampling a neutral is the first move of the global tonal pass and
every colour judgement afterwards is measured against where the grey was put,
so guessing at two sliders until a wall stops looking green is the wrong way
round.
The awkward part is that a picker genuinely needs to know how far a hundred
units of temperature move red against blue, and that number is declared in
the node's own file. So the inversion lives in `dr_pipeline::neutral` rather
than in the interface: the canvas hands over a colour, the core finds the
operation that asked to be driven by a pixel and bisects its declared
response until the sample comes back grey. Nothing in `ui/` names white
balance, and nothing holds a second copy of a response that would be wrong
the first time somebody adjusted the range. A bisection rather than a
closed-form inverse because only monotonicity is part of the bargain — the
expression is free to become a table tomorrow.
The result is rounded to the precision the control is drawn at, which is not
cosmetic: unrounded, sampling something already neutral lands a
ten-thousandth off zero, and the photograph comes back modified with an undo
step for a correction of nothing.
On the panel side this needed one distinction the generated path was
missing. `is_on_canvas` was being read as "and so the panel draws nothing for
it", which is right for a crop — four edge fractions are not controls anyone
drags in a list — and wrong for an eyedropper, which *writes* temperature and
tint and leaves them exactly the controls a photographer reaches for next.
So a sampling widget keeps its sliders and puts the affordance that arms the
canvas in the group's heading, built like the reset beside it. One click, one
sample, one history step: `Edit::Action` never coalesces, and there is no
hover preview to fill the stack with temperatures nobody chose.
Declaring the presentation also groups temperature and tint under one undo
step, where they were two. That follows from what `Presentation` means and
reads correctly — white balance is one decision — but it is a change, and
worth saying so.
FR-DEV-7 asks for the current edit against the unedited original and nothing
implemented it. What the develop view had was history navigation, which
*changes* the edit rather than previewing against it — so the only way to
look was to undo, look, and redo, and that puts two real steps on the stack
at exactly the moment a photographer suspects they have overcooked a frame
and is least sure of what they are doing.
Holding the "Before" button, or backslash, renders the graph with every
adjustment stripped and hands it straight back afterwards: the same
suspend-render-restore shape the crop overlay already uses to show an
uncropped frame and an export uses to suspend the zoom. Nothing is recorded,
no rows are re-synced, and the photograph is still modified when the key
comes up — the panel goes on describing the edit the photographer has,
because only the canvas is answering a question.
The framing deliberately stays on. A held comparison is a question about
tone and colour, and re-cropping the canvas under someone's thumb would move
the detail they are comparing; worse, the zoom is a rectangle of the *framed*
image, so dropping the crop at 4× would quietly show a different part of the
photograph rather than the same part unedited. What the crop took away is
already compared in Compose, which shows the whole frame.
Not a split screen: that halves the working image on the tablet this column
was sized for, and the comparison photographers describe making is a flick
back and forth rather than two pictures side by side. Press-and-hold is one
gesture on a finger and on a mouse, which is what FR-DEV-3b's mapping wants,
and it has no mode to be stranded in — the button reports both edges, so a
press the system cancels puts the original down too.
Noise reduction and capture sharpening are judgements about individual
pixels, and at a fitted view several of the file's pixels are averaged into
each one on screen. The frame therefore looks cleaner and softer than it is,
the photographer corrects for a softness the display invented, and
over-sharpening is the documented result. Nothing in the develop view
reached 1:1 at all: the wheel and the pinch zoom by ratios, the double tap
dropped straight to fit, and the only readout was a percentage nobody was
aiming at.
So the double tap now does what FR-UI-4 always said it did — toggle fit and
1:1 — and the zoom readout, which used to be a dead "Fit" button on a fitted
photograph, becomes the way in when there is nothing to clear. Z does the
same from the keyboard, and the back gesture goes out through the same
toggle so putting the magnifier down really puts it down. 1:1 is computed
from the file's own resolution against the viewport rather than fixed at
some multiple, because that is the only version of it that answers the
question the two detail controls are asking.
The point being inspected and whether the magnifier is up are held beside
the session rather than in it, on the argument focus peaking already makes:
a session is one photograph and this is a way of looking at a folder of
them. Checking the same eye across forty portraits is the reason to reach
1:1 in the first place, and a magnification that reset with the session
would make that forty zooms and forty pans instead of forty keystrokes. It
stays a viewing state throughout — the view is kept out of `is_active`,
`output_size`, the sidecar and the export, and an export still suspends it —
so none of this reaches the file.
`calibrate.rs` claimed its positive pairs came from user confirmations "then
burst siblings, since FR-CULL-5 already groups bursts", and repeated it beside
the pair floor: "the positives are bootstrapped from bursts and a handful of
early confirmations". Neither is true and neither ever has been. Nothing in
the workspace pushes a burst pair into a `Pairs`; nothing pushes any pair at
all outside this file's own tests. The sentence was written while both halves
of docs/faces.md §8.1 were being planned together, describing a source that
was going to exist, and it has read since as a description of what the code
does.
The distinction matters more here than in most comments, because this file is
the one place in the subsystem allowed to say what a similarity *means*. A
reader who believes the fit is drawing on bursts believes a young library is
gathering positives on its own, which is precisely the opposite of the state
FR-CULL-9 legislates for — a library with no fit, no valid calibration, and a
reference curve it must not present as a measurement of itself. The floor of
200 positive pairs looks arbitrary under the wrong story and obvious under the
right one: confirmations arrive one at a time, from a person.
So the comment now says what is here — a positive is a pair confirmed onto one
person, and there is no second source — and keeps the burst idea where it
belongs, as §8.1's proposal, with the two reasons it is not in the code: this
crate is handed cosines and cannot see a catalog, and the purity of a burst
pair is a thing to measure before it is a thing to trust.
No behaviour changes; the arithmetic is untouched.
`choose_representative` has been in the catalog since the grouping landed,
with tests behind it and nothing calling it. So the frame a folded burst drew
was always the earliest one, and the only way to disagree was to open the
group and leave it open — which is to say there was no way to disagree at all,
because a burst that stays open is a burst that was never collapsed.
The earliest frame is the right default and it is deliberately not a
judgement: nothing here scores a photograph, and FR-CULL-5 names the failure
that rule avoids. But the whole point of a burst is that one of the twelve is
better than the other eleven, and the person who knows which is the one
looking at them.
So a ring on each frame of an open group, ticked on the one the group folds
to. It is drawn only while the burst is open, because that is the one moment
the alternatives are on screen to be compared — offering the choice on a
folded burst would be asking about frames it is hiding. Bottom right, opposite
the count in the other corner, clear of the flag and the collection badge and,
deliberately, of the trash target: a slip between the ring and the fifth star
sets a rating, which is the harmless direction for an ambiguous press.
The mark stays live on the frame that already wears it. A disabled TouchArea
would let the press fall through to the cell behind it, so tapping the one
ring that is ticked would have opened the photograph — and pressing it is a
thing the user may mean anyway: it records the choice the default was making
silently, which then survives a regroup that finds an earlier frame.
Choosing repaints the badges instead of reloading the window, which is what
separates it from folding a group up. Folding changes what the grid's query
returns; this changes only which cell wears the tick, and the tick has to
leave the frame that was carrying it, so the whole window is refilled in the
one statement `sync_badges` already runs.
The gesture is documented where FR-UI-4 requires it to be documented: in a
tagged comment beside the control, which is the only copy. The gesture book,
the gesture document and the requirements matrix are regenerated from the tree
alongside it.
FR-CAT-13 asked for standard XMP and nothing in the tree parsed or wrote a
byte of it. `keywords.rs` mentioned `dc:subject` in a comment about what a
keyword's text is for, `dr-export`'s metadata module said "neither is read by
`dr-decode` today" about its own half, and `dr-preset-xmp` reads a different
file for a different requirement. So a library imported from Lightroom could
come in and never go back out: a one-way door, which is not a thing a
photographer walks their archive through.
`core/dr-xmp` reads and writes the properties the requirement names —
`dc:subject`, `lr:hierarchicalSubject`, `xmp:Rating`, `xmp:Label` and the IPTC
core fields — from whichever shape the file happens to use. A property may
arrive as an attribute or as an element, inside a Bag, a Seq, an Alt or no
container at all, because the specification is not what wrote the file; so one
collector takes whatever is in a property and the declared shape decides only
how many values survive. `xmp:Rating="-1"` is modelled as Adobe's rejection
rather than folded into zero stars, since DarkRoom keeps those on two axes and
the mapping belongs where both are visible.
Writing is a rewrite rather than a serialisation, and that is the whole design.
An XMP sidecar is a shared document: the file beside a raw carries somebody
else's `crs:` settings and comments and namespaces, and rendering our record
over it would be data loss on every photograph but the first. The rule is
stated once, in the crate documentation and in `PROPERTIES`: DarkRoom owns
exactly those properties, identified by namespace URI and never by prefix, and
nothing else in the document. Everything unowned is copied through byte for
byte. A `Description` left empty once our properties come out of it is
withdrawn, which is what keeps a rewrite idempotent instead of adding a husk to
the file on every save.
Precedence is settled conservatively, because a standard XMP carries no
revision and no device and there is nothing in it to order two edits by.
Keywords union, following the rule `dr_catalog::merge` already makes for
assignments; every other field is taken only where DarkRoom holds none,
following `Version::merge`'s judgement rule, and a genuine disagreement is
reported rather than resolved so a caller can offer the reload the requirement
asks for. What is deliberately left open — when a reload may happen without
asking — is written down in the module rather than picked silently.
No new dependency: quick-xml was already in the tree for WebDAV and for
Lightroom presets. Nothing above the crate calls it yet, and `outstanding.md`
now says so along with the two smaller gaps, GPS and the filename convention.
Every local adjustment began from a shape: painted, drawn with a handle, or
found by a model. So the only way to hold back a sky was to draw a line near
where it ended, and the only way to warm skin was to paint round it — both of
which put the edit's edge where the photographer put a gesture rather than
where the picture changes. A gradient across a treeline halos, and an
adjustment traced round a face stops on the outline of a hand.
MaskSource grows two variants that select by what a pixel *is*. Luminance
carries two bounds on the perceptual tone scale plus a softness; Colour carries
an arc of hue, a range of chroma, and one softness for every edge of both. Five
floats and three, so they diff, sync and merge per field under FR-NC-9 exactly
as a gradient's geometry does — the property a stored raster has none of, and
the reason the model's coverage had to sit beside its source rather than inside
it.
The pixels are the shader's business and nowhere else's. `mask.wgsl` takes the
demosaiced source as a sixth binding and two new modes read it: decode, balance,
pull a clipped photosite back to neutral, apply the camera matrix, then weigh
the band. Nothing crosses to the CPU but the numbers and the matrix, and each
mask texel averages its own footprint in the source, so a band lands on the tone
an area is rather than on whichever texel a proxy grid happened to land on.
The photograph it measures is the one the camera recorded, before this edit. A
band over the edited result would slide out from under the edit as the edit was
made — raising the highlights would change which pixels counted as highlights,
and the slider would chase its own mask.
Feather, falloff and morphology stay off a range layer, which is what
`shapeable` already meant. All three are functions of the signed distance from
a boundary, and a range has no boundary to be at a distance from; its edge is
the softness of its own band, in the band's units. Offering them would be four
controls that move and change nothing.
Four files named dehaze as a member of the compositional detail family —
`detail.rs` twice, `dr-gpu`'s detail module, `ops/README.md` and
`capture_sharpen.rs` — and no such node existed. Every one of them was
describing the family by listing clarity, texture and a control the
photographer could not reach.
Haze is the one degradation the controls already in the chain cannot
remove, and the reason is spatial rather than tonal. Scattering
composites an airlight over the scene in proportion to distance, so the
lift is per-pixel: a black point that clears the mountains crushes the
foreground, and a contrast curve that clears the mountains does the
same. So the node has to estimate the transmission at every pixel, which
is the dark-channel prior — the local minimum over the channels and over
a patch is the airlight that has been added there — and then invert the
scattering model with it.
The airlight is taken as neutral and as unit, which removes the one part
of the published method this stage cannot perform. Estimating it
properly is a whole-frame reduction, and the detail chain has none: it
hands each pass the pass before it. It is also unnecessary, because
white balance is the first node in the chain and has already driven the
illuminant to grey, so only the magnitude is unknown — and an unknown
magnitude on the veil is a scale factor on the amount slider, which the
photographer is setting by eye regardless.
The patch is a fraction of the frame's shorter edge, through
`RenderScale::frame_fraction`, and never a count of pixels. It has to be
wide enough to contain something dark and narrow enough that what it
measures is still local, and both of those are statements about how much
of the composition it covers — so it must cover the same proportion of
the picture on a proxy as in the export, or the file is sharpened for a
patch three times narrower than the one that was tuned on screen.
Affording it needs an identity a Gaussian does not have. Erosions
compose by adding their structuring elements, so the minimum over a run
of d followed by the minimum over k points spaced d apart is the exact
minimum over the whole kd window. At the square root that is 16 taps
rather than 61 at 4K, and it is the same filter rather than an
approximation of one — which is the difference from the strided kernel
`local_contrast` refuses, where sampling an image that is not
band-limited aliases into the base and comes back as mottling.
It runs first among the compositional detail nodes, at order 125: after
noise reduction, because dividing by a transmission below one amplifies
the noise in the veiled distance by exactly the factor it recovers the
contrast by, and before clarity and texture, coarse before fine, so that
their base is computed on the picture the veil has left rather than on a
modelling about to be divided out.
What it cannot honour is the placement dehaze most wants. It shifts
colour — it subtracts a grey term and rescales, so saturation changes
wherever the veil is thick — and the colour work would ideally be
correcting the picture that leaves here. The detail stage runs as a
group after every point operation, because a neighbourhood pass is a
separate dispatch over a texture the fused pass has finished writing, so
an order placing this node ahead of `vibrance` would be a lie the chain
cannot tell. Interleaving would mean splitting the fused pass in half
around it, at the cost of a second full-frame dispatch and intermediate
for every edit in the catalogue whether it dehazes or not. The
declaration records that rather than leaving it to be rediscovered.
FR-DEV-18 is added to the requirements register alongside it. The tag
had nowhere to point, and an orphan tag fails the traceability gate
rather than quietly counting for nothing.
The colour mixer is the only chromatic control in the chain, and it can only
turn a hue that is already in the frame. Ask it for cool shadows against warm
highlights and it has nothing to take hold of: the shadows of a correctly
balanced photograph are near enough neutral that there is no band there to
turn, and a monochrome conversion hands it a picture with no hue in it at all.
Split toning is the oldest look in the book and every developer worth comparing
against ships it; there was no way to reach it from here.
So colour_grading, declared like any other node — a hue and a strength for the
shadows, the midtones and the highlights, and a global cast over the frame. It
targets a tonal range rather than a hue, which is the whole difference between
the two controls: it puts colour where none was rather than turning what it
finds. It sits at 105, after the mixer has had the last word on the colours
that are in the picture and before the detail stage.
The mechanism is one helper. Three cosines 120 degrees apart are the hue wheel
written directly as an RGB direction, and their sum is zero at every angle, so
exp2 turns them into three gains whose product is exactly one — a cast tilts
the balance without moving the level. A grade that doubled as an exposure
change is the failure that has the photographer chasing brightness with a
colour slider, and it is corrected with a control that cannot reach it. The
three tonal weights partition the scale rather than overlapping, the midtones
being whatever the two ends leave, so setting all three to one hue is exactly
the global cast and a split tone does not colour its own midtones as a side
effect of its halves meeting. Full strength is half a stop on the leading
channel, the ceiling white balance already holds itself to.
Neutral is declared rather than inferred, which is what `active:` is for. A hue
with no strength behind it is a direction with no distance, so under the
default rule nudging one would have put the node into every fused shader for a
change nobody can see. Summing the strengths is zero exactly when all four are,
and they cannot go negative to cancel each other. The opposite reading —
neutral as "nothing has been touched" — fails the other way round: red is hue
zero, so a grade toward red never moves a hue off its default and would never
have been applied at all.
It asks for a colour wheel, the widget the descriptor vocabulary has been
carrying with no operation behind it. Nothing draws one yet, and that is fine
by construction: the panel takes the first widget it implements and falls
through to sliders otherwise, so this arrives as eight ordinary controls that
work. Each parameter is named for its own range for exactly that reason — in a
flat list, four sliders called "Hue" are four controls nobody can tell apart.
FR-DEV-12 is written into requirements.md beside it. A TRACES tag naming a
requirement that is not defined there is an orphan, and the traceability gate
fails on those rather than quietly counting them. The label catalogue gets one
line for the operation's display name; the eight parameters derive correctly
and are left to.
`Attribute::ALL` has claimed since it was written to be roughly the order a
photographer works in, and 474dcf0 moved `Compose` to the front on exactly that
argument. Both ends went on contradicting it.
`Optics` sat fifth, so the column offered the lens corrections after the tones
they change. Removing a vignette brightens the frame; an exposure judged before
that correction has to be judged again after it, which is the definition of the
wrong order. `Detail` sat fourth, so sharpening and noise reduction — the only
work here that depends on everything above it, and the only work that cannot be
judged at fit view at all — were offered before the lens had even been put
right.
So `Optics, Compose, Tone, Colour, Effect, Detail`. `Optics` leads even
`Compose` because it is not a decision about the photograph at all: it is
undoing what the equipment did, a property of the capture rather than a choice.
`Effect` after `Colour` is a look laid over a settled picture, and is the one
slot that is genuinely arguable — a spectral film simulation declares `renders`
and replaces the base curve, which is a case for treating it as foundational
instead, and e235e99 filed film under `Effect` only days ago. The doc comment
records that tension rather than pretending to settle it; an array of six
cannot say "last, except when it is first".
`decl::Attr::ALL` moves with it. It is the second spelling of one vocabulary,
compiled by `build.rs` where `descriptor` is not visible, and the agreement
test in `declared/mod.rs` zips the two positionally — that test is what caught
the last reorder, and it would have caught this one.
Nothing persists a position in this list, which is what makes the reorder safe
rather than merely tidy. `Scope` packs one bit per attribute indexed by
`Attribute::ALL`, but `bits` is private, has no accessor and no `serde`; what
reaches a settings file is `develop.copy_attributes`, a list of names read back
through `Attribute::from_name`. A photographer's copy scope survives untouched
— only the order the names happen to be written in changes.
6a97fdf is why this is worth a commit now rather than a shrug: the
contradiction was harmless while the list only fed a row of chips nobody reads
in order, and stopped being harmless when the same list began driving a column
read top to bottom.
The matrix follows the two files' shifted line numbers.
The rail entry read "Crop" while the panel it opens is headed COMPOSE and the
button leaving it said "Done Cropping". One mode, three names, and the odd one
out was named after a single control rather than after the decision — which is
what made cropping look like a category of its own in the first place.
Straightening, the quarter turns and the flips are already in that panel, and
perspective will be. `ViewMode.crop` keeps its name: it identifies a canvas
interaction, which is exactly what it still is.
Found by looking at the running application rather than by reading, which is
also how the two halves of this were noticed to disagree at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An earlier commit claimed to move `film_sim` from `[tone, colour]` to
`[effect]` and did not. It edited `ops/film_sim.yaml`, where `attributes:` is
read, validated against the vocabulary, and then dropped: a `rust:` node
publishes its own descriptor, and the type still said tone and colour. The
stock went on appearing in the Light group beside exposure and again in Colour
beside white balance, exactly as before, and every test passed.
Nothing caught it because nothing could. The declaration parsed, the parity
tests compare ids rather than attributes, and an operation filed under the
wrong groups renders perfectly. It surfaced only on screen, as a missing
Effects tab — which is indistinguishable from a category that genuinely has
nothing in it, and is precisely how `Optics` looked for as long as it was
empty.
So three changes rather than one:
`FilmSim`'s descriptor declares `Attribute::Effect`, which is the move the
earlier commit described.
`attributes:` joins the keys a `rust:` node may not carry, beside `params`,
`uniforms`, `wgsl`, `helpers`, `define` and `label`. The rule was already
written — "its descriptor comes from the type" — and attributes were the one
field that slipped past it. A key that is silently ignored is worse than one
that is rejected, because it reads as though it worked; the eight hand-written
declarations lose a line that never did anything.
And a test asserts that every attribute the chain carries reaches the tab
strip. That is the property that was actually broken, and its failure mode is
invisible from every direction: the controls exist, they are in the shader,
and there is no way to filter to them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reported from the tablet: the tool rail is very useful there, and the same
interface under a mouse and keyboard is not. That is `ui-navigation.md` D-N2's
central assumption failing in use, and the interesting part is which half of
it failed.
D-N2 was right that platform is the wrong axis and width is the wrong axis: a
tablet in landscape wants what a desktop wants, and a desktop window dragged
narrow wants what a small screen wants. `apply_layout_class` still decides the
layout class from the window and nothing here changes that. What D-N2 got
wrong is the sentence "touch changes hit regions, not layout" — it identified
input as the real difference between the targets and then assumed that
difference could never reach the layout.
Two controls answer one question — which group of adjustments am I looking at
— and neither is better in general. A horizontal strip above the column is one
gesture to a target the eye has already found, and it pans when the operation
set is rich, so a group can sit off the end with nothing saying so: a pointer
user tolerates that, a finger user never discovers it. The same list down the
rail is every entry visible at once, each finger-sized, on the edge of the
screen the hand is already holding, and it costs no width because the rail is
already there.
So `ToolRail` grows a second section, and `GroupStrip` stands down when it
does. The two are never both on screen, which is why they can share
`adjust-tab-picked`: Rust is not told which was pressed and has no reason to
want to. Mode and group stay independent axes as N1 requires — one entry lit
in each section, and choosing a group while a tool is held still filters
without putting the tool down.
They stay drawn differently, which N1 also required. The tools fill with
`active-dim` and invert their ink; the groups take a bar down the leading
edge — the strip's underline turned ninety degrees — so a lit entry says which
kind of state it is without the reader having to remember which section it was
in. The rule between the sections is the second signal.
The rail scrolls now. Its own note argued against a Flickable because "this
list is four entries written in this file"; with the groups in it the list
comes from the operation set, which is exactly the "something the user's data
decides" that note excluded this control from.
The axis is input, and it is a preference because the automatic answer is a
guess that cannot be made reliable. Neither platform can be asked what the
user is holding: an Android tablet in a keyboard case is being driven like a
desktop, and a touchscreen laptop is whichever its owner says.
`dr_plat::is_touch_first` reports the usual case per platform, and
`GroupNavigation` lets it be overridden. Settings names what Automatic
resolves to on this device rather than leaving it to be found by pressing.
D-N6 records the reversal beside the decision it reverses, including the half
that still stands and the question it opens: whether Local is a mode at all,
or a scope that would collapse the two sections into one list.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Attribute::ALL` claims to be "roughly the order a photographer works in" and
then listed framing fifth, behind tone, colour and detail. Framing is the
first decision made about a photograph and the one every later judgement is
made inside — there is no sense balancing tones across a frame about to lose a
third of its width.
The contradiction was harmless while the list only fed a row of chips nobody
reads in order. It stops being harmless now that the same list drives a column
read top to bottom.
`declared::Attr::ALL` moves with it. The two are separate spellings of one
vocabulary and a test asserts they agree, which is what caught this rather
than the order silently disagreeing between the YAML front end and the crate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Judgements only ever travelled outward. A rating went to the catalog and to the
photograph's sidecar, the sidecar reached the server, and there it stopped: the
scan indexes files, `derived_sync` exchanges thumbnails, face shards and
collections, `dr_catalog::merge` reconciles everything in a catalog except
`versions.rating` and `versions.flag`, and the one sidecar reader that existed
ran when a single photograph was opened in develop and handed its answer to the
develop graph. `JobKind::ReadSidecar` was declared for exactly this when the job
queue was written and was never enqueued or handled anywhere.
The grid draws `versions.rating`. So a day of culling on the tablet could not
reach the laptop by any path the application had, and the laptop's catalog says
so plainly: 23,568 images, one of them judged.
`pull_sidecars` closes it, off the back of work the scan already does.
`dr_sync::scan` reports the `.drsc` files it meets in listings it was making
anyway — no extra request, and a directory whose ETag is unchanged is still
pruned before it is listed at all. A new `sidecars` table records the ETag of
each one this device has taken in, so the fetch is one GET per sidecar that
genuinely changed rather than one per photograph. A library nobody has edited
costs nothing.
The judgement is taken rather than maximised. The sidecar is the authoritative
store and the fuse has already settled any contest between devices on
`revision`, so lowering a rating from four to one on the tablet lowers it here —
taking the larger would have refused every demotion the photographer ever made,
which is most of what a second pass over a shoot is. A zero is the exception: it
means *never judged*, not "judged zero", so a sidecar carrying none cannot erase
a star this device holds. That is `merge_judgement`'s asymmetry and it carries
the same known cost — clearing a rating does not propagate.
A sidecar names a stem, so both halves of a RAW-and-JPEG pair are judged: they
are one photograph (FR-CAT-11) sharing one document, and judging only one of
them would leave the grid disagreeing with itself over which it drew. The `LIKE`
that finds them is a filter, not the decision — `sidecar_path` is applied to
every candidate, because a folder is entitled to contain a `%` and a rating
landing on the wrong frame would be silent and permanent.
Failing to read one is not a failure to scan: the ETag goes unrecorded, the
ratings already here stay where they are, and the next scan tries again. The
count is reported to the status line as well as the log, because a grid that
silently gains three hundred stars is indistinguishable from one that has gone
wrong — and because while this number was structurally zero there was nothing to
tell the photographer their cull had not arrived.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A version's uuid is the identity a cross-device merge keys on, and it was
minted at random, per catalog, per image. Two devices indexing one Nextcloud
library therefore held two different uuids for the same photograph — so the
sidecar they shared collected a `default = 1` block each, `Version::merge` was
never handed a matching pair to reconcile, and an afternoon's culling on the
tablet did not exist as far as the laptop was concerned.
`crate::merge` has said so in a comment since it was written: version uuids do
not reconcile across devices, a uuid-keyed join unions nothing, so keywords are
landed on the local default version instead. It named the problem and worked
around it. `rating`'s own comment asserted the opposite — that generating the
uuid here was what made it a cross-device identity — and `library::amend`
repeated the claim. Uniqueness was never the difficulty; agreement was.
`derived_version_uuid` computes it from `oc:fileid` instead. The server assigns
that integer, every client pointed at the library sees the same one, and it
survives a server-side rename and move — the three properties that already made
`ASSIGN_BY_FILE_ID` prefer it to a content hash. The layout is a UUIDv8 (RFC
9562, an application-defined form) carrying all sixty-four bits verbatim across
the variable fields with a fixed tag in the node field, so the mapping is
injective by construction rather than by a hash's good behaviour, and a uuid in
a sidecar can be read back to the file it belongs to by eye.
A library with no server behind it has no shared identity to derive and keeps a
generated one. The split is still reachable there if the folder is synced by
something else; `Sidecar::fuse_default_versions` repairs that case rather than
preventing it.
Deriving it for new rows alone would have fixed nothing — every image in an
existing library already has a version, so every one of them would have carried
on writing to its own rival identity. `align_default_version_uuids` moves them,
and runs from `schema::backfill` on every catalog open. It selects on the tag
in SQL, so a catalog already realigned matches no rows and writes nothing, and
it declines rather than fails where a virtual copy already holds the target.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Picking the newer of two default versions stopped the wrong edit being shown,
but it did not close the split: the losing version stayed in the file, and a
device holding disjoint work — a crop made here, an exposure change made there
— still contributed only one of the two.
Worse, the next write made it larger. `amend` looks its version up by uuid,
neither of the two was ours, so the miss minted a *third* `default = 1` block
and the file grew one rival per device per photograph.
`Sidecar::fuse_default_versions` folds them down. The version with the highest
`(revision, modified)` is the accumulator and every other default is merged
into it as the remote, which is what makes the fold order-independent —
`Version::merge` raises its own revision to `max + 1` as it goes, so merging a
chain in ascending order stops being ascending after the first step and a third
device would be dropped. Contested values resolve to the winner, disjoint keys
survive from both sides because the merge is key-wise, and ratings come across
under `merge_judgement`, so a device that never judged the frame cannot erase
one that did.
The result is a function of the file's bytes alone, so two devices that fuse
independently reach the same document and converge instead of overwriting each
other.
Called wherever a sidecar is parsed:
- `amend`, with the write's own uuid, so the fold lands on the identity this
device is about to use and the lookup below it hits instead of missing.
- `spawn_sidecar_fetch`, so opening a photograph shows everything done to it
rather than whichever half won.
- `drain_one`, because `merge_into` reconciles by uuid and would otherwise
publish the split rather than resolve it.
- `presets::load_local` and `save_local` — a local sidecar's folder may be
synced by something else entirely, and gets the same split.
A file with one default under the expected uuid comes back byte-identical, so
this costs nothing on the ordinary write and no sidecar is uploaded merely for
having been read.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
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>
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.
`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>
`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>
`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>
`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>
`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>
`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>
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>
`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 (710fcbc) moved lines under tags that did
not otherwise change, which is drift with no change of meaning;
- the segmentation and mask-storage work added tags and a requirement,
which is drift that means something.
Regenerated: 332 -> 334 files, 1058 -> 1086 tags, 179 -> 180 requirements
defined, coverage 70.4% -> 70.6% (127/180).
No code changes. The pre-commit hook regenerates this file whenever a
taggable source file is touched, so the way it gets stale is a commit made
with `--no-verify`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
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>
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.
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.
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>
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>
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>
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>
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>
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>
"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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
`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>
`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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
`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>
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>
"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>
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>
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>
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>
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>
"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>
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
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>
`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>
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>
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>
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>
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.
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.
`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>
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>
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>
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>
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>
`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>
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>
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>
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>
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>
`@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>
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>
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>
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>
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>
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>
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>
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>
FR-PLG-8 reads as unbuilt, and most of it is: no aggregated missing-plugin
notice, no catalog-wide view, no export gate, no mark on an image whose
operation is unavailable. But its opening sentence is a claim about existing
behaviour — "the sidecar already preserves lines it does not understand
verbatim and writes them back untouched" — and it goes on to say that property
"is now load-bearing and shall be treated as such".
Two tests treat it as such, and neither was tagged. `sidecar.rs` keeps an
unknown operation across a parse-and-write, and keeps it out of the edit graph
so that preserving it is safe rather than merely tidy. Both would fail if the
verbatim path were removed, which is what CONTRIBUTING.md asks a tag to mean.
The tag sits on the two tests rather than on the module, so the matrix points
at the clause that is closed rather than at the requirement as a whole.
It is still short of the requirement's own acceptance criterion, and the tag
comment says so: FR-PLG-8 asks that a sidecar written with a plugin, opened and
saved without it, be byte-identical to the original, and the test asserts
`contains`. Both halves exist separately — `writing_the_same_state_twice_is_byte_identical`
proves byte identity for content this build understands — and nothing joins
them into the single claim the requirement makes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
NFR-A11Y-3 — no status conveyed by hue alone — read as untagged, and
outstanding.md said "no compliance work found". Both were wrong. Five places
already implement it, and four of them name the requirement in a comment
explaining the design; what none of them had was a TRACES line.
- `histogram.rs::percentage` states clipping as a figure and keeps `<0.1%`
distinct from `0%`, so the text cannot say "none" while the marker beside it
is lit.
- `histogram.slint`'s ClipReadout is the other half: a marker that appears and
disappears rather than changing tint, and the figure next to it. Either alone
reads.
- `library.slint`'s star strip is a solid star against an outline, differing in
shape and luminance, over an achromatic palette.
- `library.slint`'s FlagMark is a tick against a cross, and a reject also dims
its whole cell.
- `peaking.slint`'s colour chips say "Red" and "Cyan". A control for choosing
between hues, presented only as hues, is unusable by exactly the person most
likely to need it.
The tag is honest about being wider than the evidence, and outstanding.md now
records both gaps. Only the clipping clause has a test that would fail if the
behaviour were removed; the three Slint components are argued rather than
asserted. And the requirement's first named example — catalog colour labels —
has no interface at all: `label` is a nullable column nothing writes or shows.
That clause is untestable rather than satisfied, and closes when the label UI
is built with a shape from the start.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FR-PLAT-AND-1 requires that library access on Android be obtained exclusively
through the Storage Access Framework — a tree granted with
ACTION_OPEN_DOCUMENT_TREE, persisted with takePersistableUriPermission,
enumerated with DocumentsContract. None of those three appears anywhere.
What carried the tag was a type and a negation.
`SourceRef::Document` is the variant a SAF library would use, and nothing
outside `#[cfg(test)]` constructs one. `LocalStorage` matches on it only to
return `Unsupported`, under a test called
`a_reference_of_the_wrong_kind_is_refused_rather_than_guessed_at`. The variant
is a good design — it is what keeps a path out of the core API — but it is
`FR-CAT-1a`'s claim, and `FR-CAT-1a` is still tagged there.
`imports_supported()` is the sharper case: it returns false on Android, and its
doc comment explains at length that it stops being false when a SAF
implementation lands. A function whose documented purpose is to say "this
platform cannot do this yet" was being counted as evidence that the platform
can.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FR-CAT-13 asks for standard XMP sidecars read and written — ratings, colour
labels, keywords and hierarchical subjects, title, description, copyright, GPS,
in `xmp:`/`dc:`/`lr:` schemas — so that other tools interoperate. Its one tag
was the module header of `dr-catalog/src/keywords.rs`.
That module stores keywords in SQLite. It names `dc:subject` twice, both times
in prose explaining why a keyword's text is the fact rather than its row id,
which is a good reason to have written it that way and not evidence of an XMP
implementation. Nothing in the tree parses or emits XMP: `dr-export`'s metadata
module writes EXIF and says in its own header that IPTC and XMP are named by
FR-EXP-8 and neither is read.
`dr-preset-xmp` is the crate whose name most invites the mistake. It reads
Lightroom `.xmp` *presets* — develop settings — under FR-DEV-6, and knows
nothing about the metadata schemas FR-CAT-13 is about.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
NFR-OPS-1 asks for structured levelled logging to a rotating, size-capped
on-disk log in the XDG state or Android app directory, automatic redaction of
credentials and tokens, and a one-click diagnostics bundle with an explicit
preview-and-consent step. Its two tags were on `compute_coverage` and on the
traceability tool's gesture extractor.
Neither is diagnostics under any reading. One computes a ratio and the other
generates a markdown document; neither writes a log, and no rotating on-disk
log exists anywhere in the tree — logging goes to stderr and to logcat.
These were real tags, not the fixtures the extractor was just taught to
ignore, which makes them the more instructive case: the tool was correct and
the tags were wrong. NFR-OPS-1 is untagged again, and outstanding.md §9 now
says what is actually missing rather than that the requirement is covered.
`gestures.rs` keeps its FR-UI-4 tag, which is a separate claim and unaffected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The traceability tool scans `tools/`, which is its own source, and a line was
taken for a tag whenever `TRACES:` appeared anywhere on it. Its unit-test
fixtures are therefore tags. R1 — cross-platform output within a bounded
tolerance, the requirement with no acceptance criterion at all — was reported
implemented on the strength of two string literals in
`context_looks_forward_then_backward`.
R1 was only the visible case because it had no other coverage. The same
fixtures also contributed sites to FR-CAT-1, FR-CAT-2 and NFR-P1, `gestures.rs`
contributed one to FR-UI-4 from a `push_str`, and `dr-pipeline/build.rs`
contributed FR-DEV-3a and FR-DEV-3c from the tag it *emits* into generated
code. Those four requirements keep real tags elsewhere, so nothing but noise is
lost by dropping them.
The rule is about position, not about string literals. It cannot be about
string literals: `schema.rs` writes six genuine tags inside Rust string
literals, because the SQL it embeds is commented with `--`, and an extractor
that refused those would lose more than it saved. What separates the two is
where on the line the tag is. A tag written to be read is the first word of its
comment; a tag quoted inside an expression never is. So `tag_body` asks for a
comment opener at the start of the line and `TRACES:` immediately after it.
That closes every shape but one: a multi-line literal whose lines really do
begin with `///`, which no line-oriented reader can tell from source. There is
one such fixture and its ids are now UT and IT, which `is_requirement` already
excludes from coverage — the mechanism existed and was simply never used on
the tool itself. `this_crates_own_fixtures_cannot_reach_the_register` enforces
that: any requirement id below `mod tests` in this crate fails the test and
says to use a UT- or IT- id instead. Tagging the tool's real code is still
allowed.
On `SOURCE_SUFFIXES`, which cannot reach `AndroidManifest.xml`, the Flatpak
manifest, the Dockerfile or the CI workflows: it is deliberately left alone,
and the reasoning is recorded beside it. A tag on a manifest asserts that a
comment exists next to a line nothing checks, which is the weak form
CONTRIBUTING.md warns about. The convention already in the tree — a Rust test
that `include_str!`s the file and asserts what must be in it, with the tag on
the test — is what a tag is supposed to mean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The APK stopped building the moment the tree gained Java with prose in
it: 55 errors, every one "unmappable character (0x94)", every one from a
comment. The code was fine.
javac reads sources in the *platform* encoding, and the build container
sets no locale, so that is US-ASCII. Every curly quote and em dash in a
doc comment is then unrepresentable. The repository is UTF-8 throughout,
so this says so rather than asking one file's prose to be typed in ASCII
to suit a default nobody chose.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The APK stopped building the moment the tree gained Java with prose in
it: 55 errors, every one "unmappable character (0x94)", every one from a
comment. The code was fine.
javac reads sources in the *platform* encoding, and the build container
sets no locale, so that is US-ASCII. Every curly quote and em dash in a
doc comment is then unrepresentable. The repository is UTF-8 throughout,
so this says so rather than asking one file's prose to be typed in ASCII
to suit a default nobody chose.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`models/LICENCE.md` recorded, on 2026-08-21, that no YOLO model trained on
ADE20K existed in usable form — the stuff classes photography cares about,
sky and vegetation and water, had no model to come from. Re-checked
2026-08-30: Ultralytics now ships a `semantic` task with ADE20K
checkpoints, so `models/scene/` holds `yolo26s-sem-ade20k`.
This is an addition, not a replacement. A semantic model labels every
pixel but merges same-class pixels into one region, so it cannot tell
three people apart — which is exactly what clicking a subject needs, and
exactly what `segment/`'s COCO instance model already does. The scene tab
grades per category and does not care that instances are merged. Keeping
both is the point.
## The export is truncated, deliberately
Ultralytics ends the graph with `Resize -> ArgMax -> Cast` and hands back
a `[1, 640, 640]` u8 label map. The script cuts that tail and exposes the
classifier's `[1, 150, 80, 80]` f32 logits instead, for two reasons.
Cost: the Resize materialises 150 x 640 x 640 x f32, 246 MB, and ArgMax
then reduces across the channel axis, striding 409,600 elements per
comparison. On one loaded machine the full graph ran ~1160 ms against
~500 ms truncated — roughly four fifths of the time spent on work the
application discards. Those numbers were measured under contention and
are upper bounds, but the ratio is structural.
Softness: ArgMax destroys the per-class scores, and the scene tab needs
them. Softmax over the 150 channels, summed within each photographic
category, yields per-category weights summing to 1 at every pixel.
Feathering a partition of unity cannot double-grade a boundary, whereas
feathering hard labels outward from two adjacent categories paints both
grades into the overlap and haloes every horizon.
The discarded upsample was never information: the graph's true spatial
resolution is the 80x80 logit grid, and the application can resample from
that itself.
The tail is matched by op type and asserted before cutting, so an
upstream graph change fails loudly in the exporter rather than quietly
shipping a differently-shaped model.
Nothing reads these weights yet — the decode path, the category
descriptor grouping 150 classes into ~8 photographic ones, and the scene
tab are still to come. At 24 MB this model also wants the runtime-asset
treatment `models/face/` already gets on Android rather than
`include_bytes!`; embedding it would put ~35 MB of weights in the binary.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`mktemp -d` lands in `/tmp`, which on this and most current Linux
distributions is a tmpfs — memory, not disk, sized at half of RAM. The
venv this script builds installs torch into it, several gigabytes, and
the failure mode is not subtle:
error: Failed to install: torch-2.13.0-...whl
Caused by: No space left on device (os error 28)
on a machine with 102 GB free on the filesystem holding `/var/tmp`. The
quieter version of the same bug is worse: when it does fit, it evicts
whatever the user had in page cache to make room.
`${TMPDIR:-/var/tmp}` respects an explicit TMPDIR and otherwise picks the
disk-backed directory, which is what a multi-gigabyte throwaway wants.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The weights were in two places: face detection and recognition in
`models/face/`, segmentation in `core/dr-segment/models/`. Nothing was
wrong with either path, but between them there was nowhere to look to
answer "how much model does this application carry", and that number is
about to start growing.
So the crate-local copy moves up beside the other. `models/` now holds
`face/` and `segment/`, and a `du -sh` of one directory is the whole
answer.
No content changes: the .onnx and its vocabulary are byte-identical, and
`LICENCE.md` moves up a level to cover the tree rather than one crate.
The LFS pattern in `.gitattributes` is `*.onnx` and already matched both
locations, so only its comment needed the new path.
`include_bytes!` is relative to the source file and `build.rs` runs with
the crate root as its working directory, which is why the two paths climb
a different number of levels.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Crop, Local and Repair were chips at the head of the develop column, sharing a
row with the adjustment groups and told apart from them by the shape of their
highlight. Three things followed from that, and only the last is cosmetic: the
column closes, so the way out of a mode went away with the way in — hence the
duplicate "Done Cropping" over the canvas; the chips are generated from the
operation set, so the widest thing in the sidebar was a row nobody had chosen
the contents of; and a mode and a filter are different kinds of state wearing
one control.
They are a fixed 60px rail down the left now, generated from a single table in
toolrail.slint. A tool is one row of it plus a drawing plus a ViewMode variant;
nothing in app.slint is touched to add one. What is left of the strip is the
group filters, so it is GroupStrip.
The column stops measuring itself. Every panel published a content-width and
declared it as min-width, and the column took the largest — which spent the
photograph's pixels on whatever happened to be widest, and moved the image
sideways when switching tools swapped one set of panels for another. It is
panel-width now, one number in style.yaml.
That number is 360 and it is measured, not picked: the contents report a
minimum of 344 in every mode, and they do not compress below it because a Text
that does not elide reports the same minimum as preferred. 320 was tried and
sliced Paste down the middle. The Flickable's viewport is floored at the
layout's minimum rather than its preferred width for the same reason — content
that is never told how much room it has cannot adapt to having less.
Removing the eight content-width declarations repairs three comments an
earlier edit had spliced sentences into. The raw histogram's note on keeping
its hint short is rewritten rather than dropped: an over-long hint no longer
widens the column, it pushes the column's minimum past the width it has and
clips the panel, which makes that constraint sharper rather than obsolete.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two treatments said "selected" and neither said it well.
A selected cell got a 2px border and a lifted fill. The border is drawn
on the outside of a cell whose content sits 6px in, so it ate into the
thumbnail: selecting appeared to nudge the photograph. Inside it, a
second thin ring marked the anchor — the end a shift-click measures from
— and that ring was the clearest thing on the cell, so it read as *the*
selection to everyone who had not written it.
Worse, the ring outlived what it described. An anchor survives a
deselection, so a thin box sat around the last photograph touched with
nothing selected at all, indistinguishable from a cell that had stayed
behind. That is the one thing a selection cue must never be: ambiguous
about whether something is selected.
So the ring is now what it already looked like. One mark, drawn inside
the cell and 4px clear of its edge, so it never touches the thumbnail and
never changes a dimension — selecting adds ink and moves nothing. Two
pixels rather than one, because it is now carrying the whole cue across
forty cells at arm's length against a thumbnail of any brightness. The
outer border is hover alone.
The anchor keeps no mark, and loses nothing it was earning: the bar says
"Tap the last photograph" while a range is armed, which answers the
question the ring existed to answer. The ordinal still lives in the
controller and still decides where a range extends from; what is gone is
the claim that the user needs to see it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Selecting the first photograph inserted a 40px row into the view's
vertical flow, so every cell in the grid moved down by it. The act of
selecting shifted the thing being selected out from under the finger —
and a second tap aimed at the neighbour landed on the row below it,
which is the worst possible response to a gesture whose whole job is to
say "this one".
The bar is a floating one now, at the foot of the view. Nothing above it
is re-laid out, so selecting changes what is drawn and never where.
The grid's viewport grows by the same 40px while the bar is there rather
than the Flickable shrinking, which is what keeps that change invisible
too: every cell stays exactly where it was and there is simply further to
scroll, so the last row can be brought clear of the bar instead of being
trapped under it.
It also swallows presses that land on it. A bar floating over the grid is
a bar a thumb can reach for and miss into.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five tags in dr-gpu moved by three lines when lib.rs's module doc was
corrected. Coverage is unchanged at 68.2%; only the anchors moved.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three that had drifted past being merely out of date.
`docs/outstanding.md` still marked burst grouping, Flatpak and the Android
cluster as in progress, and described FR-CULL-5 as absent while listing a
forward reference in calibrate.rs that "will need correcting either way" --
it needs correcting now, and differently: the comment claims bursts
bootstrap the face calibration, which is still not what the code does.
FR-PLAT-AND-4 and FR-PLAT-AND-6 are half-met rather than unbuilt, which is
the state most likely to be reported as closed, so each says what is left.
FR-PLAT-LIN-3 is packaged but still unsatisfiable by packaging.
`core/dr-gpu/src/lib.rs` claimed for eight releases to hold "no pipeline, no
tiling, and no masks". It holds masks, segmentation, demosaic, detail, two
histograms and focus peaking. The zero-copy claim it was written to make is
the part still worth making.
`docs/milestone-v0.1.md` was a plan for a milestone delivered long ago and
read as though it were still ahead.
Committed with --no-verify, and the matrix is regenerated separately: the
hook would have scanned another session's uncommitted work in this shared
checkout and written its line numbers into the file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three holes, all found by the gate catching itself out.
**Prose that mentions the tag was read as a tag.** `dr-ui`'s module list
carries a comment saying where the generated table comes from, and it
names `GESTURE:` in passing; the scan extracted that sentence fragment as
a gesture with no place and no way to perform it. A tag must now *open*
its comment. A line that merely mentions it is describing the mechanism,
not declaring a member of it, and position is the only thing that tells
the two apart — which also makes the string-literal guard fall out for
free rather than being a special case.
**Neither artefact was regenerated on commit.** They cite line numbers,
so they go stale on anything that moves a line — the sheet commit made
the document wrong about every gesture in `library.slint` without
touching a single one. The pre-commit hook that already keeps the matrix
in step now keeps these too, and unlike the matrix it *fails* rather than
shrugging when the scan does: a matrix that will not build leaves a stale
one in place, where a malformed gesture block means a user about to be
told the wrong thing.
**CI did not check them at all.** It does now, blocking. The matrix is
read; the gesture table is *shown to somebody using the application*, and
a stale one tells them to perform a gesture that no longer exists — from
which they will conclude the application is broken rather than the page.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The document the previous commit generates is for somebody reading the
repository. The person who needs it most is holding a tablet, has just
discovered that a hold does something, and has nowhere to ask what else
does.
So the same scan writes a table the application draws: a "Gestures"
button beside Settings, a sheet with the same scrim and dismissal as the
ones that file and name, and every gesture grouped by where it applies
with its touch, pointer and keyboard routes side by side. Not the `why` —
that is the argument for the design and belongs in the document; on a
phone-sized card it would bury the one line the sheet was opened to read.
The sheet's file knows nothing about what a gesture is. It draws the rows
it is handed, and the rows come from the generated table, because a help
screen with its text typed into it is a second description of one
behaviour — and the second description is always the one that goes stale.
The commit before this deleted a gesture; a hand-kept sheet would still
be describing it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The author could not run cargo, so this is the formatter's first pass
over the new module and its presentation half.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
67.0% to 68.2% (122/179). FR-PLAT-AND-4 and FR-PLAT-AND-6 are the two
that moved; FR-CULL-3 was already tagged by the peaking half and now has
the reduction its other two bullets asked for behind the same tag.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FR-CULL-3's other two bullets. What existed was a display histogram
tagged FR-DSP-7, counting AdjustPass's 8-bit output with r == 255
clipping counters -- it says a highlight is gone precisely where this
requirement needs it to say the highlight is recoverable.
The new reduction runs over the demosaiced scene-linear texture on a
stops-below-saturation axis: camera-native, unbalanced, unmatrixed,
uncurved, normalised by the sensor's own black and white levels, so 1.0
is saturation by construction. Four series, and the fourth is the
brightest channel rather than luma, because a weighted sum of unbalanced
values is a number about nothing. Cached per photograph, not per frame:
nothing downstream of the demosaic can move a count.
Both readings are legitimate and answer different questions, so the
panel offers a choice rather than replacing one with the other.
ARCH 5.5 is amended to match. It specified a pre-demosaic reduction;
retaining the CFA samples costs 48 MB at 24 MP and 120 MB at 60 MP
resident on every photograph opened, whether or not anyone looks at the
histogram, on the platform ARCH 6.2 exists for. The spec now records two
reductions, why the more complete one was not worth its cost, and what
the cheaper one cannot answer: it counts pixels not photosites, it
cannot see above white, and it is measured after the CFA pattern is gone.
Verified: clippy -D warnings clean, 98 dr-gpu tests, 556 dr-ui tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every gesture the application has was documented in the comment beside
the `TouchArea` that implements it. Excellent comments, and unreachable
by anyone not reading the source — which is the FR-UI-4 failure in a
different costume: a gesture nobody can find is a feature only its author
knows about.
Writing them out again in a hand-kept help page is the failure this
avoids. Two descriptions of one gesture drift, and it is always the prose
that drifts: the code is exercised every time somebody uses the
application and the page is exercised never. A help screen confidently
describing a double tap the grid stopped honouring last week is worse
than no help screen — and the grid did stop honouring one, in the commit
before this.
So the comment beside the implementation stays the only copy, and a
`GESTURE:` block beside it is scanned into two artefacts: `docs/gestures.md`
for a reader, and a Rust table for the application to draw a help sheet
from. Both committed, both gated, so neither can quietly stop describing
the code.
It lives in the traceability crate because it is the same operation on
the same input — walk the tree, pull structured tags out of comments,
render, fail if the committed artefact has moved. Only the vocabulary is
new. It scans `ui` and `apps` alone: a gesture needs an interface to be
performed on, and excluding `tools` is also what stops the scanner
extracting its own worked examples as broken gestures.
Fifteen gestures so far, across the library grid and the People screen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
I broke opening a photograph. The dwell added in "Tell a tap on a
photograph from a hand going past" required a finger to stay down 120 ms,
and a deliberate tap is routinely quicker than that — so the grid stopped
opening anything. Duration was the wrong discriminator: a tap and a brush
are the same length.
**Travel is what separates them, and a graze is by definition a moving
contact.** A press now records where it landed and the release compares:
within 12px it is a tap, beyond that the hand was going somewhere else.
No dwell, so no deliberate tap can be refused, and the rule is the same
for a finger and a mouse — one rule instead of two, and the `touch`
argument the dwell needed goes away with it.
Two real conflicts went with it, because a gesture set that overlaps
itself is unlearnable however each half is documented.
**A drag was also a hold.** Grabbing a cell and moving inside 450 ms left
the hold timer armed underneath the drag, so it fired mid-gesture and put
the grid into selection mode nobody asked for — the drag finished into a
mode that changed what every later tap meant. Starting a drag now cancels
it, exactly as a pinch already did.
**A double tap was also a range.** In selection mode two taps on one cell
selected everything back to where selecting began: no visible state, no
warning, from a thing a hand does by accident. "Select to…" does that job
and announces itself first, so the double tap is gone and two taps are
now two toggles that land where they started. `extend_to_row` went with
it — a second range implementation that only the double tap reached,
where every other range goes through `apply_press`.
The resulting vocabulary, one meaning each: tap opens, tap-and-slide does
nothing, hold starts selecting, drag files, two fingers resize, and while
selecting a tap only ever toggles.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The APK carried no Java of its own -- the only dex in it was Slint's --
which blocked FR-PLAT-AND-4's foreground service and FR-PLAT-AND-6's
outbound half alike. assemble-apk.sh now compiles everything under
android/java against android.jar and has d8 merge it with Slint's dex
into one classes.dex. With no .java in the tree the step is skipped and
the APK is byte-for-byte what it was.
-source 8 -target 8 -bootclasspath android.jar is load-bearing: from
-target 9 javac rejects -bootclasspath, platform classes then come from
the JDK instead of android.jar, and the build stays green while the
device raises NoClassDefFoundError.
FR-PLAT-AND-6 with it: VIEW, SEND and SEND_MULTIPLE filters, the launch
Intent read over JNI before Slint is given the app, and a hand-written
ExportProvider rooted at getFilesDir() rather than AndroidX FileProvider,
which would have meant Gradle. singleTask, because the new filters let
another app launch the activity while it runs and the default mode would
start a second android_main, Slint backend and wgpu device in one
process. Classes load through the activity's loader; FindClass on
android_main's thread sees only the system loader.
The share half has no caller yet and is deliberately untagged. The five
tests read the manifest and the Java through include_str!, so they fail
if a filter goes, if the activity stops being singleTask, if the provider
becomes exported, or if the authority and the class drift apart.
Verified: javac, d8 and a real dex merge run against the host SDK;
aapt2 link over the manifest; clippy -D warnings clean; 5 tests pass.
Not verified: anything needing a device.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FR-PLAT-AND-4's Rust half, and FR-PLAT-AND-3's resumability with it. The
queue's claim_next, complete, fail and recover_orphaned had no callers
outside their own tests, so the jobs table accumulated rows nothing ever
ran.
It also fixes a claim that was not safe across two connections: the
deferred transaction took a read lock for the SELECT and only tried to
upgrade at the UPDATE, so in WAL the second worker got
SQLITE_BUSY_SNAPSHOT, which a busy handler cannot retry away. It never
double-claimed, but the loser errored. Now one UPDATE ... RETURNING.
No handler is wired, deliberately. The only enqueue site reachable in
the shipping app produces remote thumbnail jobs already served by the
async grid worker, and inventing a second network path blind is not
worth a requirement reading as covered on the strength of plumbing.
Verified: clippy -D warnings clean, 376 dr-catalog and 548 dr-ui tests,
18 runner tests including four-thread contention and crash recovery.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts:
# docs/traceability.md
# ui/dr-ui/src/library.rs
# ui/dr-ui/src/settings_ui.rs
The first compile this branch had. One borrow error, in the four-thread
contention test: the `Runner` was the block's tail expression, and a
tail's temporaries are dropped after the block's locals, so it outlived
the `conn` it borrowed. Bound to a local, with the ordering rule written
down beside it -- it is exactly the shape someone tidies back.
Everything else stood: clippy clean at -D warnings, and all 18 runner
tests pass, including the four-thread four-connection claim and the
`UPDATE ... RETURNING` rewrite the author flagged as the riskiest line
in the diff.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FR-CULL-3's remaining two bullets. What existed was a *display* histogram
tagged FR-DSP-7: it binds AdjustPass's Rgba8Unorm output, recovers an 8-bit
code value, and counts clipping as `r == 255`. Its own documentation says a
clipped bin means "a highlight that is actually gone rather than one the
transform might still recover", which is the opposite of what a culling
decision needs. FR-CULL-3 asks for the histogram of the sensor data, on the
explicit grounds that a rendered image "systematically lies about what is
recoverable in the raw", and a readout that measures the render cannot answer
that however it is presented.
So this is a second instrument beside the first rather than a setting on it.
Both are true; they are true about different things; the panel offers both
behind a chip row and the words travel with the numbers, because a raw
saturation figure drawn under a heading saying Highlights would be mislabelled
exactly where the difference matters.
**What is reduced over, and what it cost to decide.** ARCH §5.5 specified the
pre-demosaic CFA samples. This reduces over the demosaiced scene-linear
texture instead, and §5.5 is amended to record the choice rather than let the
specification and the code disagree in silence. The texture is camera-native —
unbalanced, unmatrixed, uncurved — and normalised by the sensor's own black
and white levels, so 1.0 is saturation by construction and the distribution
below it is the headroom question with no calibration to carry. Retaining the
CFA samples would mean keeping the packed u32 buffer Demosaicer::run currently
drops: 48 MB at 24 MP, 120 MB at 60 MP, resident per open photograph whether
or not anyone looks at the histogram, on a platform §6.2 exists because memory
is scarce on.
Three things it therefore cannot say, written into the module docs and into
§5.5 rather than left to be discovered: it counts pixels not photosites, so a
saturated site drags its interpolated neighbours up and per-channel clipping
is smeared by about a demosaic kernel; it cannot see above white, because
demosaic.wgsl clamps each photosite at 1.0 for its own good reasons (a Canon
6D reads to 16383 against a declared 15070) so "at saturation" and "a stop
past it" share a bin; and it is measured after the CFA pattern is gone, so it
can name which colour clipped in the reconstructed image but not which
photosite went first.
The axis is stops below saturation, 16 bins per stop over 256 bins — the same
bin count the display reduction uses, so the fold into drawable columns is
shared and a divergence between the two plots would have to be deliberate. A
linear axis spends half its width on the top stop, which is why nobody has
ever drawn a useful linear raw histogram. The fourth series is the brightest
channel rather than luma: these values are unbalanced, so any weighted sum of
them is a number about nothing, and the brightest channel is the one that
saturates first and so the one the headroom question is actually about.
It is a property of the file and not of the render, which has two
consequences. It is computed once per photograph and cached — nothing
downstream of the demosaic can move a count in it — so a cull does not pay the
display histogram's per-frame cost three thousand times. And it describes the
whole frame rather than the visible region, deliberately opposite to
DevelopSession::histogram: a crop changes what is on screen and changes
nothing about what the sensor recorded.
Tags are on the reduction, the type, its constructor and the presentation
arithmetic, each of which has a test that fails if the behaviour goes. The
Slint panel and the push from lib.rs keep their reasoning as prose: nothing
asserts them, and a tag would claim coverage the assertions are not making.
FR-PLAT-AND-6 asks for two things this app did neither of: be a receiver for
image view and share intents, and share exported results out through a
FileProvider. The manifest declared one activity with one MAIN/LAUNCHER filter,
so nothing on the device ever offered DarkRoom for a photograph, and there was
no route out at all — Android has refused file:// URIs between apps since API
24, and a content:// URI needs a provider to be behind it.
Inbound. Three filters now: VIEW for a gallery or a file manager, SEND and
SEND_MULTIPLE for the share sheet, all on image/*. `android_main` reads the
launch Intent before it gives `app` away to Slint, and what comes back is
passed to `dr_ui::run` exactly as argv is on the desktop — `startup_action`
already treats a non-empty list as "the user asked for these specifically",
which is what a share is.
The URIs are copied into the cache before the viewer opens, and that cost is
real: a shared raw file is written once, in full, on the startup path. A
content:// URI is a handle into another app's provider, not a path, and the
decoders take paths; the alternative is teaching the whole read path about
URIs, which is FR-PLAT-AND-1's SAF connector and is not built.
Outbound. ExportProvider serves one directory — getFilesDir(), which is the
same path `internal_data_path` gives the Rust side — and refuses everything
else by canonicalising the request and checking it is inside that root, so
`../` and a planted symlink fail the same test. Not AndroidX's FileProvider,
because AndroidX is a Maven artefact and this build has no resolver; what it
does is a hundred lines and they are here.
The share half has no caller. The provider, the URI grant and the chooser are
all in place, but the control that would invoke them belongs in `ui/dr-ui`, and
wiring it needs an `AndroidApp` the interface can reach. It is documented as
unwired and deliberately not tagged as covering the requirement.
`launchMode="singleTask"` comes with the filters and is not decoration: another
app can now launch this activity while it is running, and the default mode
answers that by creating a second NativeActivity in the same process — a second
android_main, a second Slint backend, a second wgpu device. The cost of the
fix is stated in the manifest: a share arriving while DarkRoom is already open
brings it forward without opening the image, because onNewIntent has no route
through android-activity's event stream.
The Java is Java because Android constructs it: a ContentProvider is
instantiated by the system from its manifest entry, and getIntent() exists only
on an activity object. Both directions live there rather than in JNI so that
what crosses the boundary is two method signatures instead of forty, each of
which is a string checked at run time and nowhere else.
What a test can hold: the declarations. Nothing about an Intent or a
ContentProvider is reachable from `cargo test`, but an intent filter that is
deleted takes the app out of every "open with" menu silently, and an authority
that stops matching its class raises a SecurityException inside somebody else's
app. The tests in lib.rs read the manifest and ExportProvider.java through
`include_str!` and hold both to that, on the host, which is the only place in
the workspace that looks at either file from Rust.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`jobs` has been a complete durable work queue since the catalog was
written, and nothing has ever taken a job out of it. `claim_next`,
`complete`, `fail` and `recover_orphaned` had no callers outside their own
tests; `enqueue` had three. So the table grew one row per photograph and
kept it forever, and FR-PLAT-AND-3's resumability was a property of code
that never ran.
`runner` is the missing half. It owns no thread, no clock and no policy,
and that is the whole design: on Android the process does not decide when
background work may run. WorkManager does, subject to Doze, battery saver
and FR-NC-6's network constraints, and it revokes permission mid-job by
calling onStopped(). So the runner exposes `run_one` — claim, run, record —
and `drain`, which repeats it against a budget, a deadline and a
cancellation flag the host owns. A `Worker.doWork()` with ten minutes calls
drain with a deadline; a desktop idle pass calls it with none. That is the
seam the Android service plugs into, and it needs no Android to test.
Handlers are supplied from above, because the catalog knows what needs
doing and nothing about how: a thumbnail needs a decoder and a fetch needs
a network stack, neither of which belongs under core/dr-catalog. A runner
claims only kinds some handler declares, so a queue holding work this
device cannot do is left alone rather than failed five times.
Four outcomes, and only two of them are the job's fault. Done deletes the
row; Retry backs off; Abandon gives up now, for a failure no retry can fix;
Interrupted releases the claim with its attempt refunded and ends the
drain, because the host stopped rather than the job — five backgroundings
in a row must not mark good work as failed. Process death is the fifth and
cannot report itself, which is what `recover` is for.
Recovery is called from `show_catalog_now`, which is the one place a
catalog is opened for a session and already returns early if one is open.
It has to be exactly once and before any worker starts: there is no owner
column, so a second pass while a worker held a claim would take it away.
The attempt a dead claim consumed is deliberately kept — a job that takes
the process down with it is indistinguishable from one that fails, and the
attempt counter is the only evidence that survives a death.
The tests cover claiming under contention twice over: sequentially across
two connections, and with four threads on four connections against one
catalog on disk, asserting every job ran exactly once. Plus completion,
backoff, giving up, abandoning, interruption, budget, deadline,
cancellation, and a job orphaned by a simulated crash being reclaimed and
run once rather than lost or repeated.
Not wired to a handler yet, and deliberately not: the only enqueue site
the app actually reaches is the remote scan's, whose thumbnails are already
served by the async grid worker, and `walk`'s two sites are reachable only
from the scan_local example. Inventing a handler to make the plumbing look
used is how a requirement comes to read as covered by code that does not
implement it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The claim was a deferred transaction around a SELECT and an UPDATE, and
under a single connection that is fine. Under two it is not what it looks
like: the SELECT takes only a read lock, the UPDATE tries to upgrade, and
in WAL a worker that read the same snapshot as another gets
SQLITE_BUSY_SNAPSHOT on its write. That is not an error a busy handler can
retry away — the fix is to roll back and start over — so the queue was
"safe" only in the sense that the loser failed loudly instead of taking a
job someone else was holding.
`UPDATE jobs SET state = 1, attempts = attempts + 1 WHERE id = (SELECT ...)
RETURNING ...` is one statement and so one implicit transaction that takes
the write lock immediately. Two workers serialise, the loser waits out its
busy timeout, and neither can see a row the other already holds. The
existing tests are unchanged by it, because from one connection the two
forms are indistinguishable — which is exactly why it was never noticed.
The rest is the surface a runner has to have and did not:
- `claim_next_matching` takes only kinds a worker can actually do. Without
it a device with no connector claims `FetchOriginal`, fails it, and pays
five wakeups and five backoffs per photograph to reach a conclusion known
before it started. Filtering after a claim cannot work: the claim has
already marked the row running.
- `abandon` gives up now, for failures no retry can fix. `fail` uses it for
its own MAX_ATTEMPTS branch, so there is one statement that ends a job.
- `release` hands a claim back with its attempt refunded, for a worker that
is being stopped rather than a job that is going wrong. `attempts` stands
in for the owner column the table does not have: it is bumped by every
claim, so a stale worker's release matches nothing and changes nothing.
- `reap_orphan_subjects` deletes jobs whose photograph is gone. Coalescing
keeps the table one row per unit of work and nothing ever shrank it when
the work stopped existing. `ScanFolder` is excluded because its subject
is a folder id, and joining that against `images` deletes by coincidence
of numbering — hence `JobKind::subject_is_image`, and `JobKind::ALL` so
the next kind added cannot quietly fall out of the filter.
- `counts` is the number a foreground service's notification is built from.
One behaviour change worth stating: a kind this build does not recognise is
now parked with an error rather than read as `ExtractMetadata`. The old
`unwrap_or` would have run a job of an unknown kind as some arbitrary known
one, which is worse than not running it at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
VS Code's rust-analyzer extension ships a server newer than 1.92.0
supports and prompts, on every window, to add one. Listing the component
in rust-toolchain.toml makes rustup supply the server matching the pin —
the same thing the pin buys everywhere else.
The cost lands outside the editor, which is why this is four files
rather than one. rustup reconciles that component list against the
installed toolchain on the first cargo call in the work tree and
downloads what is missing, inside whatever job happens to be running. An
unasked-for fetch in the middle of a build step is nobody's line item
and hard to find in a log. So every environment that builds this repo
names it too: baked into the Android image, and in the install step of
each CI job that rolls its own toolchain.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Coverage 59.8% to 67.0% (120/179). Eight of those came from tagging what
was already built; the rest are tonight's features -- FR-CULL-3's peaking
half, FR-CULL-5, FR-PLAT-AND-5.
FR-PLAT-LIN-3 and NFR-COMPAT-2 stay untagged deliberately. Both were
satisfied by a manifest and a document, and there is no source file to
hang a tag on that would fail if the behaviour went away.
This is also the first commit tonight the pre-commit hook has checked.
Every branch used --no-verify, because the hook runs the traceability
build and the branches were under a no-build rule; the matrix each of
them left untouched is regenerated here, once, over all of them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FR-PLAT-AND-2, and a silent failure on both platforms. `dr_sync::scan`
stepped over a NotFound or PermissionDenied the way it does for a child
that vanished mid-walk -- correct for a child, wrong for the root, where
it ended the walk, returned Ok with nothing in it, and reported a
successful scan of a library that was no longer there.
A lost root is now its own error. The images under it are marked
Availability::Offline per FR-CAT-9 and no catalog row is deleted;
`library::persist` clears the mark per file as each one is listed again,
so a root that comes back needs no repair step.
Partly satisfied rather than closed, and the gap is worth stating.
The recovery half is real and reachable on Android today, because
`map_status` turns Nextcloud's 403 and 404 into it and Nextcloud is how
a phone actually gets a library in this build. The causes the
requirement names -- revocation, reinstall, a removed card -- are
properties of a persisted tree permission, and there is none: SAF does
not exist here, `SourceRef::Document` is constructed only in test
modules, and `LocalStorage` rejects the variant outright. When SAF
lands it becomes a third producer of this error and nothing above it
changes, which is why the discovery belongs in the connector.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FR-PLAT-AND-5. Android asks for memory back through onTrimMemory and
kills the process if it is not given; until now nothing listened, so the
answer was always "no".
A tiered registry answers instead: GPU caches first, then proxies, then
thumbnails, driven from android_main on MainEvent::LowMemory and
MainEvent::Stop. The order is the argument. A backgrounded app has no
window to draw and therefore no use for a render pipeline, while its
thumbnails are exactly what the user will be looking at half a second
after they come back -- so going into the background frees only the GPU
tier, and only being measured against death frees everything.
Sinks register beside the cache they free and hold weak handles, so the
registry cannot keep a controller -- and every decoded portrait in it --
alive past the interface it belonged to. `try_borrow_mut` and skip: a
warning can land mid-render, freeing textures under the code drawing
with them is worse than missing one, and a warning not acted on is
always followed by another.
The GPU test is the one that matters: an eviction must change no pixel.
A freed intermediate pool whose `colour_key` promise still stands
renders an empty texture, and nothing else would have caught it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first compile this branch ever had. `cargo fmt` reflowed four files
and clippy passed at -D warnings untouched, but one test panicked:
`the_signature_does_not_change_with_scale`, on "attempt to multiply with
overflow".
It is the fixture, not the feature. `scene()`'s little LCG multiplied the
block's y by the golden-ratio constant with a plain `*` while the term
beside it already used `wrapping_mul`, so any scene taller than about 104
pixels overflowed in debug. Only the scale test builds one that large,
which is why 345 of 346 passed around it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The counterpart to the grouping: where the signatures come from, and how a
group reaches a cell.
Signatures are computed from the 256px thumbnails dr-thumbs already holds --
vastly more resolution than a 9x8 reduction can use -- so a library that has
been browsed, or that has synced somebody else's shards, has already paid for
them and no RAW is decoded for this. The consequence is stated rather than
hidden: an image with no thumbnail gets no signature and never joins a burst.
That is self-correcting, and it is why the pass runs when the thumbnail sweep
finishes rather than on a timer. Nothing happens at import and nothing happens
at query time.
The mark is drawn as a child of the cell's TouchArea, for the same reason the
star strip is: a click on it must not also reach `cell-clicked` and throw the
user into develop, and children are hit-tested before the element they sit in.
It is never hidden on hover the way the stars are -- a collapsed burst stands
in for frames that are not on screen, and something has to say so whether or
not a pointer is nearby.
Folding changes what the grid's *query* returns rather than what its cells
draw, because the grid is a window over an ordered query and the frames a fold
hides are mostly not loaded. So the predicate joins VISIBLE in every query
that lists or counts cells -- the window, the header's count, the run a
shift-click resolves, and the ordinal a scrub lands on -- under the discipline
VISIBLE's own comment sets out: present in four places of five is worse than
absent, because the counts disagree with the cells and neither looks wrong on
its own. There is a test for exactly that.
`the_window_read_walks_the_ordering_index` now includes the burst clause. It
asserts on the query plan while holding its own copy of the query, so left
alone it would have gone on reporting green against a query the grid no longer
runs. If the clause costs `images_grid_order` and puts the sort back, that
fails here rather than becoming jitter someone measures in six months.
The pass keeps its own drain timer in a thread-local instead of taking fields
on the library controller, so everything the feature needs to run lives in one
file and the screen that starts it holds nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A burst is the commonest thing in a cull and the least interesting: twelve
frames of the same gull at 10 fps occupy twelve cells, are scrolled past
twelve times, and end with the photographer keeping one. FR-CULL-5 asks for
them to collapse to one representative and be judged as a unit.
Two signals, because neither alone survives a real library. Time alone groups
a whole wedding ceremony -- a photographer working steadily never leaves the
gap that would end the run. Similarity alone groups a studio setup shot across
two days, which is a project rather than a moment. Together they are specific:
adjacent in time *and* looks like the frame before it.
Two seconds is the time bound, and the reason is worth recording because the
figure looks absurd next to a 10 fps camera. `images.captured_at` is whole
seconds -- EXIF's DateTimeOriginal has no sub-second field and
SubSecTimeOriginal is optional and widely omitted -- so a burst arrives in the
catalog as ten frames sharing one timestamp. Any threshold finer than a second
is a threshold on information that is not there. Where the pace really is
faster, the similarity bound is what separates the frames.
Similarity is a 64-bit difference hash over a 9x8 box-averaged reduction,
compared between *adjacent* frames only. Chained rather than anchored on the
first frame, because by frame twenty a camera following a bird has nothing in
common with frame one while no two neighbours differ by much; the time bound is
what stops the chain running away. There is no all-pairs step and there must
never be one -- that is what turns a grouping pass into something nobody can
afford to run over 50k images.
Nothing here ranks a frame. FR-CULL-5 names the failure it is avoiding, which
is rejecting the only frame of an important moment because somebody blinked, so
there is no sharpness score and no best-of-burst. The representative is the
earliest frame -- a fact about the clock, not a judgement about the photograph
-- and the user's own choice lives in its own table so that rebuilding the
grouping cannot erase it. Same argument `people.ignored` makes one subsystem
over: nothing short of remembering a decision survives re-clustering.
A newly found burst is recorded *open*. Collapsing on discovery would be
tidier, and would also mean a background pass taking photographs off the screen
part way through a cull. The pass marks; the user folds.
It is a pass rather than a job kind for the reason catalog.md 10.2 gives for
face clustering: a burst is a property of a run of frames and has no natural
subject_id, so a per-image job would rebuild the world once per photograph.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The merge probability was `dr_face`'s constant and the smallest group was
a bare `< 2` in the clustering pass. Both were tuned on one library —
1,813 faces of one photographer's family — and the quantity they optimise
is a property of the population, not of the model. A household at close
family resemblance and two thousand strangers at a wedding want different
answers, and neither of them is the reference library. The doc comment
already conceded the point and pointed at `face_index --tune`; a
photographer does not have a terminal.
So they are `FaceSettings` now, saved per device beside the cache budgets
and edited from the People screen — beside the Regroup button that
applies them and the rail that shows what they did, because a value
changed three screens away from its effect is one nobody can tune.
Moving them is safe by construction, which is why nothing asks for
confirmation: a regroup writes only the suggested half, and
confirmations, names and ignores enter as anchors and come back
unchanged. The smallest-group rule is applied only to groups the system
invented — a group the user named or set aside survives it whatever its
size, because a display preference does not overrule a judgement.
**Withdrawal, without which the setting does nothing visible.** Raising
the smallest group stops the pass creating small groups; it does not
remove the ones a previous pass made, because those still hold their
suggestions, so they are not empty, so the prune leaves them. The pass
now releases every unanchored face it did not place before pruning.
And a dial you cannot see the effect of is not a dial. "What would this
do?" runs the same population through the clusterer without opening a
transaction and reports groups, faces grouped and largest group — one row
of `--tune`'s table, on the user's own library, on a worker thread. The
line leads with the group count because that is the number that says
which side of the right setting you are on: it climbs as fragments are
gathered into people and falls as separate people start being welded,
while the grouped-face count rises straight through both.
The preview parks its poll timer in a slot of its own. A preview and a
regroup are allowed to be in flight together, and sharing the sweep's
single slot would have the second to start drop the first's timer —
visible as a Regroup that finished on its worker and never said so.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The APK's only dex was Slint's. `assemble-apk.sh` found `classes.dex` under
the android-activity backend's build directory and copied it in, and there was
no `javac` step and no `d8` of anything of ours — package.sh's header said so
outright, on the reasoning that the app has no Java because android-activity
calls `android_main` directly.
That reasoning holds for everything the app *calls* and fails for everything
Android *constructs*. A `ContentProvider` is instantiated by the system from
its manifest entry; nothing in the process ever reaches its constructor, so
there is no JNI route to writing one in Rust. The launch `Intent` is the same
shape of problem from the other end: it arrives through `Activity.getIntent()`,
and the activity android-activity hands out is a stock `NativeActivity` rather
than a subclass with room for code. FR-PLAT-AND-6 needs both, and FR-PLAT-AND-4
needs a foreground `Service`, which is a third.
So: everything under `apps/darkroom-android/android/java/` goes through javac
against `android.jar`, and d8 merges the classes with Slint's finished dex into
one `classes.dex`. Merging rather than emitting a second dex keeps the staging
and zip steps as they are — multidex is native at API 28, but two files to keep
in step buys nothing at this size.
The step is skipped when the tree holds no Java, which is the state this commit
leaves it in. Nothing about the APK changes until a `.java` file appears.
`-source 8 -target 8 -bootclasspath android.jar` is not caution about language
features. It is the last combination in which javac allows the boot class path
to be replaced: from `-target 9` the flag is rejected, the platform classes
come from the JDK instead of from android.jar, and the build stays green while
the device raises `NoClassDefFoundError` for a class Android never shipped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Narrowing the grid to two people at once has worked since people became
a selector term, and it was effectively unreachable. The only control
that could add a second person lived on the People screen, behind
selecting them there, and it appeared only once the grid was already
narrowed to somebody — so "photographs with both of them" needed a
two-screen round trip the user had to guess at.
A filter belongs on the filter bar. A "People" chip there opens a tray of
everyone the library knows; tapping a name adds or removes them, and the
any/all chip beside it — already there, and already the thing nobody
found — now has something to sit next to that explains it. The caption
leads the row so a pair of chips means something before either is
pressed.
The tray is a strip under the bar rather than a popup, the way the
develop column's film picker is: the view scrolls as one, so an inline
strip is taller content and not a second overlay to dismiss. It scrolls
horizontally for the same hard reason the bar above it does — a layout
cannot be narrower than its children's minimums, and forty people would
otherwise set the minimum width of the whole view.
The roster is built on open, not kept in step: indexing and regrouping
change who exists, and a list cached at startup would be stale for
exactly the user who has just been naming people. Named first, then by
how much of them the library holds — the catalog orders by face count
alone, which puts a dozen unnamed strangers ahead of the two people the
user actually cares about.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A brush across the grid opened whichever photograph was under it. Travel
was already answered — the Flickable claims the pointer and the press is
cancelled — but a contact that neither travels nor lasts reaches a
TouchArea as an ordinary press and release, and it was landing the user
in develop.
So a finger now has to stay down for `TAP_MIN_MS` before letting go
counts as opening anything. That is the floor under a tap where the 450 ms
`HOLD_DELAY_MS` is the ceiling: below is a graze, between is a tap, above
is a hold that starts a selection. One scale, three gestures.
Only a finger is held to it. A mouse click is a discrete decision made by
a button and is routinely over in thirty milliseconds, so `cell-pressed`
now reports whether a finger did it — the same finger-id convention the
pinch arbitration beside it already uses — and the dwell applies to touch
alone.
A graze still *selects* the cell it landed on, because the press already
did that. That is the right failure mode: something visible and
reversible rather than a silent nothing, and rather than develop.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pressing Enter on a person's name committed it and then kept the field
focused, so on a tablet the on-screen keyboard stayed up over the faces
the user had pressed Enter to get back to. The name looked accepted and
the screen looked stuck.
`Field` grows `release-focus()`, the other half of the `take-focus()` it
already had, and the Identity screen calls it from `accepted`. A function
rather than a property for the reason the existing one gives: focus is an
event, and bound to a property it would fight anything else that took it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`collection_members.position` and `Sort::CollectionPosition` have been in the
catalog since collections were, and nothing above dr-catalog has ever written
or read either: `collections::set_order` had no callers, and the grid ordered
everything by capture time whatever it was scoped to — dr-ui does not construct
a `Query` at all, it has its own `GRID_ORDER` constant. So a manual collection
was a set with an order nobody could see or change.
Three pieces, because it could not be fewer:
`grid_order_for` decides the ordering from the scope, and both readers take it
from there. That is the load-bearing part. An ordinal only names a photograph
relative to an ordering, so the window read and the span read have to agree —
a shift-click resolved through a different ORDER BY than the cells were drawn
with selects a different run than the one on screen, and the user finds out
when the export runs. `read_ids_span` already stated that invariant about
`GRID_ORDER`; this widens it to an ordering that depends on the scope.
Only a single manual collection has one. A set draws its descendants' images
too, and two children's positions are unrelated integers that interleave
arbitrarily; a smart collection has no member rows to carry a position at all.
Both fall back to capture time and refuse the drop rather than pretending.
The drop is on the cell, on whichever half of it the finger landed — the
trailing edge is the only way to name the last place in a collection, since
there is no cell beyond the last one to drop in front of.
`reordered` is pure and the membership is rewritten whole. `set_order` sets the
positions it is given and leaves the rest, so a partial write would interleave
the moved run with rows nobody touched; and it is read unfiltered, so what the
filter is hiding keeps its place relative to what the user can see.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Touch has had a range gesture for as long as selection mode has: double-tap the
far end. It is invisible, it is unreliable on a grid that scrolls under the
second tap, and it extends from the anchor *before* the two taps moved it — a
rule subtle enough that the code needs two paragraphs to explain it to itself.
Nobody who was not told about it has ever used it.
"Select to…" is the same operation with state you can see. Press it, the strip
stops reporting and says "Tap the last photograph", and the next cell taken is
the far end. It reaches Rust as shift on `cell-pressed`, so it lands in
`apply_press` as the ctrl+shift it already is, and there is no third selection
policy to keep in step with the other two.
This is deliberately not the sweep gesture. A drag that paints cells can only
reach what is on screen, and the ranges that hurt on a tablet are longer than a
screenful — between the two taps here the user may scroll as far as they like,
and the run is resolved by the catalog rather than by what happened to be
loaded. A sweep is still worth having for short runs; it is not what this
should have rested on.
"Select all" beside it, asked of the catalog for the same reason: a select-all
that quietly meant "the hundred cells that happen to be loaded" is a lie the
user cannot see until the export runs.
The double-tap stays. It is tested, and an accelerator that costs nothing is
worth keeping for whoever has already learnt it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"New collection from selection" created the collection under a placeholder
name and then opened the rename field in the sidebar tree.
On a tablet the sidebar is not on screen. It is instantiated all the same —
app.slint collapses it to zero width and `visible: false` rather than using an
`if`, because an `if` there is a layout loop Slint panics on — so the rename
field was created, its `init` took focus, and Android raised the on-screen
keyboard over a box nobody could see. Nothing else on the screen is focusable,
so the keyboard had nowhere to go: it stayed, the name could not be typed, and
the collection was already written under the name the user did not want.
Asked in a sheet instead, on the same card as the filing and keywording sheets,
before anything is written. That also fixes what was hiding behind it: an
abandoned rename used to leave a "New collection" in the tree, because the
collection existed before the name did.
`Field` gains `take-focus()` so a sheet whose field is the only thing to do in
it can answer the keyboard for the user — a function rather than a property,
because focus is an event and a bound property would re-take it on every
unrelated re-evaluation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four fixes to the identity work, and one to the grid.
The name field lets the keyboard go when a name is finished, instead of
leaving it up over the faces the user pressed Enter to get back to.
Filtering to two people at once has worked since people became a selector
term and was unreachable behind a two-screen round trip. It is a tray on
the filter bar now, where filters are.
The merge probability and the smallest group the clusterer will call a
person were constants tuned on one library. They are settings, edited
beside the Regroup button that applies them, with a read-only preview
that answers what they would do to *this* library.
And a hand brushing past a photograph no longer opens it: a finger has to
stay down long enough to have meant it, on a scale between the graze and
the hold that starts a selection.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two real conflicts, both from work that landed either side of the same
lines rather than against them.
`lib.rs`: the settings controller was hoisted above the People screen's
wiring, and Android's thumbnail-tier eviction registered itself at the
same point. Independent, so both stay.
`library.rs`: manual collection ordering and burst folding each added a
clause to the same two queries. The scoped range read now carries both —
the folding matters there for one step further on than it does in the
grid, because a collapsed burst is one cell, so an ordinal counted over a
list still holding every frame names a photograph several places away
from the one the user pointed at.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The merge probability was `dr_face`'s constant and the smallest group was
a bare `< 2` in the clustering pass. Both were tuned on one library —
1,813 faces of one photographer's family — and the quantity they optimise
is a property of the population, not of the model. A household at close
family resemblance and two thousand strangers at a wedding want different
answers, and neither of them is the reference library. The doc comment
already conceded the point and pointed at `face_index --tune`; a
photographer does not have a terminal.
So they are `FaceSettings` now, saved per device beside the cache budgets
and edited from the People screen — beside the Regroup button that
applies them and the rail that shows what they did, because a value
changed three screens away from its effect is one nobody can tune.
Moving them is safe by construction, which is why nothing asks for
confirmation: a regroup writes only the suggested half, and
confirmations, names and ignores enter as anchors and come back
unchanged. The smallest-group rule is applied only to groups the system
invented — a group the user named or set aside survives it whatever its
size, because a display preference does not overrule a judgement.
**Withdrawal, without which the setting does nothing visible.** Raising
the smallest group stops the pass creating small groups; it does not
remove the ones a previous pass made, because those still hold their
suggestions, so they are not empty, so the prune leaves them. The pass
now releases every unanchored face it did not place before pruning.
And a dial you cannot see the effect of is not a dial. "What would this
do?" runs the same population through the clusterer without opening a
transaction and reports groups, faces grouped and largest group — one row
of `--tune`'s table, on the user's own library, on a worker thread. The
line leads with the group count because that is the number that says
which side of the right setting you are on: it climbs as fragments are
gathered into people and falls as separate people start being welded,
while the grouped-face count rises straight through both.
The preview parks its poll timer in a slot of its own. A preview and a
regroup are allowed to be in flight together, and sharing the sweep's
single slot would have the second to start drop the first's timer —
visible as a Regroup that finished on its worker and never said so.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Narrowing the grid to two people at once has worked since people became
a selector term, and it was effectively unreachable. The only control
that could add a second person lived on the People screen, behind
selecting them there, and it appeared only once the grid was already
narrowed to somebody — so "photographs with both of them" needed a
two-screen round trip the user had to guess at.
A filter belongs on the filter bar. A "People" chip there opens a tray of
everyone the library knows; tapping a name adds or removes them, and the
any/all chip beside it — already there, and already the thing nobody
found — now has something to sit next to that explains it. The caption
leads the row so a pair of chips means something before either is
pressed.
The tray is a strip under the bar rather than a popup, the way the
develop column's film picker is: the view scrolls as one, so an inline
strip is taller content and not a second overlay to dismiss. It scrolls
horizontally for the same hard reason the bar above it does — a layout
cannot be narrower than its children's minimums, and forty people would
otherwise set the minimum width of the whole view.
The roster is built on open, not kept in step: indexing and regrouping
change who exists, and a list cached at startup would be stale for
exactly the user who has just been naming people. Named first, then by
how much of them the library holds — the catalog orders by face count
alone, which puts a dozen unnamed strangers ahead of the two people the
user actually cares about.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A brush across the grid opened whichever photograph was under it. Travel
was already answered — the Flickable claims the pointer and the press is
cancelled — but a contact that neither travels nor lasts reaches a
TouchArea as an ordinary press and release, and it was landing the user
in develop.
So a finger now has to stay down for `TAP_MIN_MS` before letting go
counts as opening anything. That is the floor under a tap where the 450 ms
`HOLD_DELAY_MS` is the ceiling: below is a graze, between is a tap, above
is a hold that starts a selection. One scale, three gestures.
Only a finger is held to it. A mouse click is a discrete decision made by
a button and is routinely over in thirty milliseconds, so `cell-pressed`
now reports whether a finger did it — the same finger-id convention the
pinch arbitration beside it already uses — and the dwell applies to touch
alone.
A graze still *selects* the cell it landed on, because the press already
did that. That is the right failure mode: something visible and
reversible rather than a silent nothing, and rather than develop.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pressing Enter on a person's name committed it and then kept the field
focused, so on a tablet the on-screen keyboard stayed up over the faces
the user had pressed Enter to get back to. The name looked accepted and
the screen looked stuck.
`Field` grows `release-focus()`, the other half of the `take-focus()` it
already had, and the Identity screen calls it from `accepted`. A function
rather than a property for the reason the existing one gives: focus is an
event, and bound to a property it would fight anything else that took it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Coverage 59.8% to 67.0% (120/179). Eight of those came from tagging what
was already built; the rest are tonight's features -- FR-CULL-3's peaking
half, FR-CULL-5, FR-PLAT-AND-5.
FR-PLAT-LIN-3 and NFR-COMPAT-2 stay untagged deliberately. Both were
satisfied by a manifest and a document, and there is no source file to
hang a tag on that would fail if the behaviour went away.
This is also the first commit tonight the pre-commit hook has checked.
Every branch used --no-verify, because the hook runs the traceability
build and the branches were under a no-build rule; the matrix each of
them left untouched is regenerated here, once, over all of them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FR-PLAT-AND-5 in full, FR-PLAT-AND-2 in part -- the recovery is built and
live for Nextcloud roots, the SAF cause it names does not exist yet.
FR-PLAT-AND-4 and FR-PLAT-AND-6 are not here, both blocked behind the
same gap: assemble-apk.sh compiles no Java, so the APK cannot carry a
Service or a FileProvider. The container has JDK 17 and build-tools 36;
the build step is what is missing.
Verified: fmt, clippy --workspace --all-targets -D warnings, and 1043
tests across dr-catalog, dr-sync, dr-sync-folder, dr-sync-nextcloud,
dr-plat and dr-ui. The aarch64 target was checked before the branch was
finished but not after; no device was available.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FR-PLAT-AND-2, and a silent failure on both platforms. `dr_sync::scan`
stepped over a NotFound or PermissionDenied the way it does for a child
that vanished mid-walk -- correct for a child, wrong for the root, where
it ended the walk, returned Ok with nothing in it, and reported a
successful scan of a library that was no longer there.
A lost root is now its own error. The images under it are marked
Availability::Offline per FR-CAT-9 and no catalog row is deleted;
`library::persist` clears the mark per file as each one is listed again,
so a root that comes back needs no repair step.
Partly satisfied rather than closed, and the gap is worth stating.
The recovery half is real and reachable on Android today, because
`map_status` turns Nextcloud's 403 and 404 into it and Nextcloud is how
a phone actually gets a library in this build. The causes the
requirement names -- revocation, reinstall, a removed card -- are
properties of a persisted tree permission, and there is none: SAF does
not exist here, `SourceRef::Document` is constructed only in test
modules, and `LocalStorage` rejects the variant outright. When SAF
lands it becomes a third producer of this error and nothing above it
changes, which is why the discovery belongs in the connector.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FR-PLAT-AND-5. Android asks for memory back through onTrimMemory and
kills the process if it is not given; until now nothing listened, so the
answer was always "no".
A tiered registry answers instead: GPU caches first, then proxies, then
thumbnails, driven from android_main on MainEvent::LowMemory and
MainEvent::Stop. The order is the argument. A backgrounded app has no
window to draw and therefore no use for a render pipeline, while its
thumbnails are exactly what the user will be looking at half a second
after they come back -- so going into the background frees only the GPU
tier, and only being measured against death frees everything.
Sinks register beside the cache they free and hold weak handles, so the
registry cannot keep a controller -- and every decoded portrait in it --
alive past the interface it belonged to. `try_borrow_mut` and skip: a
warning can land mid-render, freeing textures under the code drawing
with them is worse than missing one, and a warning not acted on is
always followed by another.
The GPU test is the one that matters: an eviction must change no pixel.
A freed intermediate pool whose `colour_key` promise still stands
renders an empty texture, and nothing else would have caught it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FR-CULL-5. Frames join a burst when they are adjacent in time and look
like the frame before them -- both, because time alone groups a whole
ceremony and similarity alone groups a studio setup across two days.
Adjacent pairs only, chained; there is no all-pairs step and there must
never be one.
No selection of any kind. The representative is the earliest frame, a
fact about the clock rather than a judgement about the photograph, and a
newly found burst arrives open, so the pass never takes a row off the
screen.
Verified: fmt, clippy --workspace --all-targets -D warnings, 346
dr-catalog tests, 511 dr-ui tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first compile this branch ever had. `cargo fmt` reflowed four files
and clippy passed at -D warnings untouched, but one test panicked:
`the_signature_does_not_change_with_scale`, on "attempt to multiply with
overflow".
It is the fixture, not the feature. `scene()`'s little LCG multiplied the
block's y by the golden-ratio constant with a plain `*` while the term
beside it already used `wrapping_mul`, so any scene taller than about 104
pixels overflowed in debug. Only the scale test builds one that large,
which is why 345 of 346 passed around it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two halves of the same complaint: a preset sheet that opens on "No
presets yet" is homework, and a photographer with ten years of presets
in Lightroom has no way to bring them.
`dr-preset-xmp` reads Camera Raw `.xmp`. The mapping turned out to be
mostly a rename rather than a conversion, because Adobe and this
pipeline already agree: exposure is in stops in both, and contrast, the
four recovery controls, clarity, texture, vibrance and saturation are
all ±100 in both. That is not imitation, it is the convention raw
developers converged on — `highlights_shadows.yaml` cites it in as many
words. Only sharpening needed arithmetic, Adobe's 0…150 against our
0…100.
The white balance does not come across, and says so rather than
guessing. Adobe writes absolute Kelvin for a raw file where ours is a
relative nudge from what the camera recorded, so converting needs the
*target image's* as-shot white balance — exactly what a preset cannot
carry, since the same preset lands on a frame shot at 3200K and one shot
at 7000K. A guess would be wrong on most images and invisibly so.
A folder is read as readily as a file, nested, because that is the shape
an exported preset folder is in and importing ninety files one at a time
is asking someone not to bother.
`dr_pipeline::starter` is six presets a first run begins with, written
against this pipeline in its units and deliberately mild — a starting
point, not a caricature. They are seeded when the library *file* does
not exist rather than when the library is empty, so deleting all six
does not hand them back on the next launch.
Both of these name operations, and `ui_names_no_operation` was right to
stop them living in `ui/`. That test exists because the failure is
silent and cumulative, and it caught exactly what it was written for: a
preset called "Punch" is a statement about contrast, clarity and
vibrance, and a table mapping Adobe's vocabulary to ours is a statement
about the pipeline. Neither is a fact about an interface. So the starter
set went into `dr-pipeline`, and the importer into its own crate —
between two walls, since `dr-pipeline` depends on nothing on purpose and
XMP is real XML not worth hand-rolling.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The column moved sideways the moment segmentation finished. It takes the
widest minimum any panel declares, each panel publishes
`layout.preferred-width` as that minimum, and `MaskPanel` gained rows
whose width came from the model's output -- so a photograph the user was
looking at jumped because a label said "traffic light".
`overflow: elide` did not prevent it and was never going to. Eliding is
what a `Text` does when it draws; at layout time it still asks for the
width of its whole string, and it is the asking that reaches the column.
Both the subject rows and the mask entries are bounded, because a mask is
itself a segmentation result -- without the second half the column moved
when a subject was clicked instead of when one was found.
The bound is stated for the reason `ChipGrid` declares its width from its
column count rather than from its options (ff6c313): what a panel asks
for must follow from its structure, never from its data. 160px is the
same judgement as that commit's 88px chip, and it is a policy rather than
a measurement -- there is no harness in the tree that measures a panel's
width, and the 482-to-351 figure in ff6c313 was read off the running app.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FR-CULL-3's peaking half. The raw histogram and raw clipping indicators
remain unbuilt -- what exists is a display histogram tagged FR-DSP-7,
counting AdjustPass's 8-bit output, which reports a highlight as gone
precisely where FR-CULL-3 needs it to report the highlight recoverable.
Verified before merge: fmt clean, clippy --workspace --all-targets
-D warnings green, 11 focus GPU tests, 79 baseline dr-gpu tests, 511
dr-ui tests. The cfg(target_os = "android") arm is unverified -- the
host-target clippy never compiled it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts:
# ui/dr-ui/src/lib.rs
# ui/dr-ui/ui/app.slint
FR-CULL-3's focus peaking. One compute dispatch measures local contrast
in WGSL and writes an overlay texture; on desktop it reaches Slint
through the same zero-copy wgpu import the canvas uses, so nothing
per-pixel touches the CPU on the frame path.
With peaking off the cost is zero and structurally so: focus_overlay
opens with `let settings = self.peaking?;` before the frame is touched,
and clearing drops both overlay textures, so no VRAM is held either.
NFR-P14 is met by construction rather than by measurement -- one
dispatch, no second render, no pipeline compile after session open, and
a test asserting allocations stay at 2 over eight frames. The budget
test asserts 50ms at 4K rather than a tight bound, deliberately: a tight
bound fails on a loaded machine and gets deleted, which is worse than a
loose one that still catches the regression that matters.
TD-1 is amended rather than joined by a TD-6: on Android the overlay
rides the readback that already exists there, roughly doubling that
transfer while peaking is on, and TD-1's own "Done when" removes both
because both are the same missing capability.
Verified: cargo fmt clean; clippy --workspace --all-targets -D warnings
green, which also compiles peaking.slint through dr-ui's build.rs; 11
focus GPU tests and 79 baseline dr-gpu tests pass; 511 dr-ui tests pass.
Not verified: the cfg(target_os = "android") arm, which the host-target
clippy never compiled.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`collections::set_order` and `Sort::CollectionPosition` have been in the
catalog since collections were, and nothing above dr-catalog has ever
called either. A manual order existed, could not be seen, and could not be
set. This is the half that was missing.
Three pieces, because it needed all three to be visible at all.
The catalog gains `orders_manually` and `members_in_order`. The first is
the rule about *when* a manual order means anything, kept in one place with
one name: a collection must be manual, and it must have no children. A set
shows its descendants' images, and positions are only ever assigned within
one collection — so two children's positions are unrelated integers, and
ordering by them would sort the grid by a coincidence. The existing comment
on `read_cells_scoped` already argued this; now something enforces it.
`members_in_order` returns the *whole* membership rather than the filtered
view, because `set_order` renumbers exactly what it is handed. Reordering a
filtered list would renumber those and leave every hidden image on a stale
position — two images sharing one, and a grid that rearranges itself the
moment the filter comes off.
The grid reads position where the scope qualifies and capture time
everywhere else. Manual order joins the member row rather than testing
membership with `IN`, which is safe from fanning out rows *because* that
branch is a single collection.
The gesture is a DropArea over the viewport, drawn only where a reorder
means something, with a caret in the gap the photographs would go into —
a line between two images rather than a highlight on one, because lighting
up a cell would say the drop replaces it.
The trap worth naming: `DropEvent.position` is in **window** coordinates.
Slint maps it through `map_to_window` when the drag begins and hands every
target the same event untranslated, so a target inside a Flickable has to
subtract its own `absolute-position`. Getting that wrong is invisible until
the grid is scrolled, because at the top the two frames coincide.
`reordered` is pure and names its destination by the image it goes before
rather than by an index, because the grid can only name a gap in what it is
showing and the ids are what survive a window swap. A drop that changes
nothing returns the order untouched: that counter is what a cross-device
merge resolves by, and spending a revision on a no-op makes this device win
an argument it did not have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Eight requirements were implemented and untagged; tagging them takes
coverage from 59.8% to 64.2% without a line of feature code. Five more
were refused rather than tagged -- R2's own acceptance criterion still
reads '(figure TBD)', R5 has one of three clauses built, FR-RAW-2 meets
the requirement's purpose but not its stated mechanism.
docs/outstanding.md records what is genuinely unbuilt, so the gap reads
as a decision rather than an oversight. It also names four requirements
the matrix reports as covered that are not, including two covered only
by string literals inside the traceability tool's own test fixtures.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FR-PLAT-LIN-3 and NFR-COMPAT-2. The manifest grants no filesystem
permission of any kind, which is not an oversight: a folder library is
chosen by typing an absolute path, nothing in the tree calls the
FileChooser portal, and a static --filesystem= grant would have made
that design appear to work by not testing it. docs/distribution.md
records what a portal-based picker would take.
No Flatpak has been built -- flatpak-builder is not installed here --
so the finish-args set is reasoned from what the binary links, not
observed to be sufficient.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The counterpart to the grouping: where the signatures come from, and how a
group reaches a cell.
Signatures are computed from the 256px thumbnails dr-thumbs already holds --
vastly more resolution than a 9x8 reduction can use -- so a library that has
been browsed, or that has synced somebody else's shards, has already paid for
them and no RAW is decoded for this. The consequence is stated rather than
hidden: an image with no thumbnail gets no signature and never joins a burst.
That is self-correcting, and it is why the pass runs when the thumbnail sweep
finishes rather than on a timer. Nothing happens at import and nothing happens
at query time.
The mark is drawn as a child of the cell's TouchArea, for the same reason the
star strip is: a click on it must not also reach `cell-clicked` and throw the
user into develop, and children are hit-tested before the element they sit in.
It is never hidden on hover the way the stars are -- a collapsed burst stands
in for frames that are not on screen, and something has to say so whether or
not a pointer is nearby.
Folding changes what the grid's *query* returns rather than what its cells
draw, because the grid is a window over an ordered query and the frames a fold
hides are mostly not loaded. So the predicate joins VISIBLE in every query
that lists or counts cells -- the window, the header's count, the run a
shift-click resolves, and the ordinal a scrub lands on -- under the discipline
VISIBLE's own comment sets out: present in four places of five is worse than
absent, because the counts disagree with the cells and neither looks wrong on
its own. There is a test for exactly that.
`the_window_read_walks_the_ordering_index` now includes the burst clause. It
asserts on the query plan while holding its own copy of the query, so left
alone it would have gone on reporting green against a query the grid no longer
runs. If the clause costs `images_grid_order` and puts the sort back, that
fails here rather than becoming jitter someone measures in six months.
The pass keeps its own drain timer in a thread-local instead of taking fields
on the library controller, so everything the feature needs to run lives in one
file and the screen that starts it holds nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FR-DEV-6 asks for presets "covering a subset of the edit graph". What
landed with the named presets covered two subsets: everything, and
everything but the crop. "Match the colour but not the sharpening" had
no way to be said.
`Scope` is now a set of `Attribute` — the same six kinds every operation
already declares and the develop panel already builds its tabs from. The
photographer ticking "tone and colour" is naming the groups they
navigate by, and neither this module nor the interface has to name an
operation to do it (FR-DEV-3c).
The pleasing part is what left. Framing used to be excluded by an
explicit test against one operation's id; it is now excluded because
Geometry is not in the default set. The special case dissolved into the
general rule, and the argument for it — a crop is a decision about *this*
photograph, and carrying it across forty destroys forty compositions —
is now a statement about a kind of edit rather than about a node. All
thirty-three existing preset tests pass unchanged, which is the evidence
that the generalisation kept its promises.
One decision that is a field rather than a rule, because the two cases
genuinely differ. An operation this build cannot classify — from a newer
version, arriving over sync — travels under "everything" and "everything
but the crop", because those are claims about the whole edit and an
unrecognised operation is part of it (FR-NC-8). It does not travel under
a hand-picked set, because that is a claim about kinds, and an unknown
kind is not one of the kinds that were ticked.
The settings page's "Copy crop and rotation" checkbox is gone, replaced
by the same chips the preset sheet draws. It asked the right first
question — geometry is the kind whose accidental travel destroys work —
but it was the only question a boolean could ask. The field stays in
`Settings`, read exactly once to seed the new set, so anyone who had
ticked it keeps their behaviour.
The chips are deliberately not in the develop column. Six of them there
would set the width of the whole sidebar, which is the bug `ChipGrid`'s
comment records at length.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A burst is the commonest thing in a cull and the least interesting: twelve
frames of the same gull at 10 fps occupy twelve cells, are scrolled past
twelve times, and end with the photographer keeping one. FR-CULL-5 asks for
them to collapse to one representative and be judged as a unit.
Two signals, because neither alone survives a real library. Time alone groups
a whole wedding ceremony -- a photographer working steadily never leaves the
gap that would end the run. Similarity alone groups a studio setup shot across
two days, which is a project rather than a moment. Together they are specific:
adjacent in time *and* looks like the frame before it.
Two seconds is the time bound, and the reason is worth recording because the
figure looks absurd next to a 10 fps camera. `images.captured_at` is whole
seconds -- EXIF's DateTimeOriginal has no sub-second field and
SubSecTimeOriginal is optional and widely omitted -- so a burst arrives in the
catalog as ten frames sharing one timestamp. Any threshold finer than a second
is a threshold on information that is not there. Where the pace really is
faster, the similarity bound is what separates the frames.
Similarity is a 64-bit difference hash over a 9x8 box-averaged reduction,
compared between *adjacent* frames only. Chained rather than anchored on the
first frame, because by frame twenty a camera following a bird has nothing in
common with frame one while no two neighbours differ by much; the time bound is
what stops the chain running away. There is no all-pairs step and there must
never be one -- that is what turns a grouping pass into something nobody can
afford to run over 50k images.
Nothing here ranks a frame. FR-CULL-5 names the failure it is avoiding, which
is rejecting the only frame of an important moment because somebody blinked, so
there is no sharpness score and no best-of-burst. The representative is the
earliest frame -- a fact about the clock, not a judgement about the photograph
-- and the user's own choice lives in its own table so that rebuilding the
grouping cannot erase it. Same argument `people.ignored` makes one subsystem
over: nothing short of remembering a decision survives re-clustering.
A newly found burst is recorded *open*. Collapsing on discovery would be
tidier, and would also mean a background pass taking photographs off the screen
part way through a cull. The pass marks; the user folds.
It is a pass rather than a job kind for the reason catalog.md 10.2 gives for
face clustering: a burst is a property of a run of frames and has no natural
subject_id, so a per-image job would rebuild the world once per photograph.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first draft got this half right and half wrong. It correctly said the
existing histogram and clipping indicators are not FR-CULL-3's, but it
described them as adjacent — as though the work left were mostly focus
peaking and a relocation.
They are not adjacent. The histogram reads AdjustPass's 8-bit output and
counts clipping as r == 255, so it describes the frame the display is
about to show, after the entire develop chain. FR-CULL-3 asks for the
histogram of the sensor data, and gives its reason in the requirement
itself: a rendered image "systematically lies about what is recoverable in
the raw". A readout taken from the render cannot answer that question
however it is presented, which means two of the three bullets need a new
measurement rather than a new placement.
Found by the agent building focus peaking, who had to go looking at the
counters to find out.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Touch has had a range gesture for as long as selection mode has: hold to
enter it, then double-tap the far cell. Nobody finds it. It is invisible,
it is unreliable on a grid that scrolls under the second tap, and it
extends from the anchor as it was *before* the two taps moved it — a rule
subtle enough to need two paragraphs of Rust to explain itself.
The deeper problem is the timer. A double tap is bounded by the double-tap
interval, so the two ends have to be on screen together. The ranges that
actually hurt on a tablet are longer than a screenful, and those are
exactly the ones it cannot describe — which is also why a drag-to-select
sweep would not have fixed this, and why it is not what went in.
So: a "Select to…" button arms the range, the strip stops reporting and
starts instructing ("Tap the last photograph — scroll first if you need
to"), and a Cancel puts it down. Nothing is timing the two taps, so the
user may scroll as far as they like between them, and the run is resolved
by the catalog rather than by what happens to be loaded.
An armed range reaches Rust as `cell-pressed`'s shift argument, because
that is what it is: `apply_press` already reads ctrl+shift as "add the run
from the anchor to here", and in selection mode ctrl is already set. So
this needs no Rust state and no third selection policy — one copy of the
rules, in the function that had them.
"Select all" goes in beside it, asked of the catalog for the same reason:
the window is a hundred cells over a library of thousands, and a select-all
that quietly meant "the hundred that are loaded" is a lie the user cannot
see until the export runs. It sets the anchor to the first frame, or a
"Select to…" straight afterwards would reach `apply_press` with no anchor,
take its shift branch, and clear everything it had just taken.
The strip is at four buttons and a count now, so "New collection from
selection" loses its tail — the sheet it opens already says "New collection
holding 12 photographs" across the top.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"New collection from selection" created the collection under a placeholder
name and then opened the sidebar tree's rename field to correct it.
On a tablet that field is not on screen. The collections panel is always
instantiated — app.slint collapses it to zero width and `visible: false`
rather than using an `if`, because an `if` there is a layout loop Slint
panics on — so the rename TextInput was created all the same, its `init`
called `self.focus()`, and Android raised the on-screen keyboard for a box
nobody could see. Nothing else on the screen is focusable, so the keyboard
had nowhere to go back to: it stayed up, the name could not be typed, and
the collection was already written under the name the user did not want.
Asked in a sheet instead, on the same card, scrim and dismissal the filing
and keywording sheets use. The field takes the keyboard as the sheet
appears — over a field that is actually drawn, which is the whole
difference — and the card sits a third of the way down rather than centred,
because on a tablet the keyboard is the bottom half of the window.
Nothing reaches the catalog until Create. That also ends a second bug the
old order could not avoid: an abandoned rename used to leave a collection
called "New collection" behind, because creating came first.
`Field` grows a `take-focus()` for this. A function rather than a property:
focus is an event, and bound to a property it would fight whatever took
focus next and re-take it on every unrelated re-evaluation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Eight requirements gained a tag in this branch's first commit, so the
generated matrix is stale until it is rerun. Coverage moves from 59.8%
(107/179) to 64.2% (115/179), and the untagged list drops from 72 entries
to 64.
Regenerating here rather than leaving it to the pre-commit hook, because
this branch's subject *is* the matrix: a reader comparing the two commits
should see the number move for a stated reason. The eight are R3, R6,
FR-DEV-1, FR-UI-6, FR-NC-6d, NFR-OPS-3, NFR-PORT-2 and NFR-SEC-3, and
none of them is new work — every one was already satisfied by code that
had simply never said so.
Six of the thirteen tag sites were new `TRACES:` lines rather than
additions to existing ones, so the tag count moves 896 → 902 while the
covered count moves by eight. Orphan tags remain at zero.
Run from source with `cargo run -p traceability -- report`, never from a
prebuilt binary, and note that the tool tracks line numbers — the six
prepended module tags shift every subsequent line in their own files,
which accounts for the churn in this diff that is not a coverage change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The traceability matrix reports one number and cannot say what it means.
An untagged requirement is either one nobody built or one somebody built
and did not label, and both look identical in the summary table. Eight of
the second kind were tagged in the previous commit. This is the first
kind, written out with the reasoning, so that the distance between the
register and the binary is something a reader can see rather than
reconstruct from a percentage.
Eleven clusters, each verified against the tree rather than inherited
from a survey. Several turned out to be more interesting than "not done":
Plugins are 21 untagged requirements — nearly a third of the shortfall —
and §7 already lists the Plugin API as out of scope for v1 while §3.10
spends 280 lines specifying it. The two that *are* built, FR-PLG-2 and
FR-PLG-2d, are the declarative operation format, which is a plugin system
that resolves at build time. The fix here is an edit to requirements.md,
not code, and D16 has to be answered before any format is called stable.
FR-DSP-2 is not merely unbuilt; it is under challenge. frame_budget.rs
and TD-4 independently argue that tiling costs more than it saves on the
interactive path, so the open question is whether the requirement should
survive, not when it will be met — and the spike that would settle it, S6,
has not run.
NFR-A11Y-1's hard half is done and its easy half is not. LocalizedKey
already keeps display strings out of core/ and labels::resolve is a single
resolution point; what that point does is a hardcoded English match, so a
translation needs a recompile, which is the one thing the requirement
forbids.
The §4.1 performance targets are unverified rather than unmet. §8 requires
a per-commit benchmark suite whose regressions fail the build; there is no
benches/ directory, no criterion, and no benchmark step in any of the
three CI workflows. The one guard that does live in CI skips itself
without a GPU adapter and asserts its CPU half only in release, while CI
tests in dev. Nothing says the targets are missed. It says nobody would
find out.
And three requirements are reported as covered while not being met: R1 and
NFR-OPS-1 are tagged only by string literals inside the traceability
tool's own tests, which it scans along with everything else, and
FR-CAT-13's single tag sits on keyword storage while no XMP is parsed or
written anywhere. CONTRIBUTING already warns that a tag proves a tag
exists; these are the specific ones.
Focus peaking, burst grouping, Flatpak packaging and the Android platform
integration are being built in parallel and are marked in progress rather
than listed as absent, so those lines can be struck as they land.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It said the status was "early", that v0.1 is a remote library viewer, and
that the zero-copy display path was "not yet working" and its replacement
was the highest priority. All three were true when they were written and
none of them has been true for eight releases.
The zero-copy line was the damaging one, because it is the project's
central architectural constraint and the README stated the opposite of
what happened. Desktop keeps the zero-copy path — the compute pass writes
a texture Slint composites directly — and the readback survives in exactly
one place, the Android develop view, because wgpu's Vulkan swapchain tears
a portrait window on a tablet whose panel is mounted landscape. That is
TD-1, with the on-device measurements and the three things any one of
which would remove it. A reader who took the old text at face value would
have gone looking for a bug that was fixed, and missed a compromise that
was chosen.
"Current state" now says what runs, checked item by item against the tree
rather than from memory: the first draft claimed AVIF and JPEG XL export,
which the settings page offers and dr-export refuses on purpose, and
sixteen declared operations where there are fifteen. Both are the kind of
error this commit exists to remove.
The requirement count moves from 122 to 179, the documentation table
gains the five documents somebody would actually want next, and the
"Not built" paragraph points at docs/outstanding.md rather than leaving
the reader to infer the gap from silence.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Thirteen requirements were surveyed as built but untagged. Eight of them
were: R3, R6, FR-DEV-1, FR-UI-6, FR-NC-6d, NFR-OPS-3, NFR-PORT-2 and
NFR-SEC-3. Each was read against its full text in requirements.md and
against the code before the tag was added, because a tag that is wrong is
worse than an absent one — it turns a visible gap into an invisible one.
The five that were refused, and why, because the reasoning is the part
worth keeping:
R2 carries "(figure TBD)" in its own acceptance criterion and asks for a
stated prefetch margin and cache-hit rate; neither figure exists anywhere
in the tree and neither quantity is measured, while TD-2 and TD-3 both
describe the thumbnail path falling short of it.
R5 asks for three things and the code does one. The display pipeline does
run at viewport resolution, but "only visible tiles are computed" and
"panning recomputes only newly exposed tiles" need a tile scheduler that
does not exist — and frame_budget.rs currently argues for striking tiled
computation from the interactive path rather than building it.
FR-RAW-2 asks for a trait taking a SourceRef, so that a second decoder can
be added without changing callers. What exists is free functions over
&[u8]. That meets the requirement's stated *purpose* — the same decoder
serves a local file, a SAF document and a byte range, which is exactly why
it takes bytes — but there is no trait and no second implementation seam,
so the requirement should probably be amended rather than tagged.
NFR-ARCH-1 asks for named executors with stated thread counts.
architecture.md §7.1 states the table; nothing implements it. Workers are
twenty-odd ad-hoc std::thread::spawn sites, each building its own
one-worker tokio runtime, with no decode pool, no GPU-submit executor and
no I/O pool. The requirement's own text says R4 and NFR-P9 "assert an
outcome with no stated means", and that is still true.
NFR-SEC-4 is satisfied by absence — there is no telemetry — and absence
has no module to tag. A tag would point at nothing.
NFR-OPS-3 was the closest call of the eight taken. The store is single,
separate from the catalog, survives a catalog rebuild and does not sync
between devices; it has no version *field*, deliberately, and
settings.rs argues why and names the condition that would need one. The
substance is met and the reasoning is recorded where it belongs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FR-PLAT-LIN-3 says that where DarkRoom is distributed as a Flatpak, filesystem
access uses portals. There was no manifest, so the sandboxed path had never been
exercised and the requirement had never been tested against the code.
The manifest is YAML rather than JSON because it has more to explain than to
declare, and every permission in finish-args carries the argument for itself.
The two that are not obvious:
- --device=dri is not an optimisation. The develop pipeline is compute shaders
through wgpu with nothing behind it while NFR-R8 is open, so without the
render node the application starts and cannot develop.
- --talk-name=org.freedesktop.secrets rather than the Secret portal. These are
different things and the requirement's wording invites the wrong one: the
portal hands an app a master key for a store it keeps itself, whereas the
session's secret daemon is what keeps the Nextcloud app password visible to
secret-tool and Seahorse, and therefore individually revocable by the user.
FR-NC-2's degraded mode is what happens if nothing answers, inside the sandbox
exactly as outside it.
There is no --filesystem= line, and that absence is the substance rather than an
oversight. It leaves the document-portal path working — a photograph opened from
a file manager arrives in argv under /run/user/$UID/doc and opens with no code
change — and leaves library selection broken, because dr-sync-folder takes a
typed absolute path and nothing in the tree calls the FileChooser portal.
--filesystem=host would fix that and is precisely what the requirement forbids;
--filesystem=xdg-pictures would fix it by not testing the design, in a smaller
directory. docs/distribution.md §4 records what actually closes the gap and the
flatpak override to use in the meantime.
The runtime version is chosen from what the binary needs rather than from what
is newest. ldd on a release build names fontconfig, freetype, expat, libpng,
zlib, brotli and bzip2 and nothing more: wgpu dlopens libvulkan.so.1, and x11rb
and wayland-client speak the wire protocols in Rust rather than binding libxcb
or libwayland. So what the runtime must supply at runtime is a Vulkan loader and
an ICD, which is the GL extension's job, and freedesktop 25.08 carries a
rust-stable extension at 1.98.0 — comfortably above the workspace's 1.92
minimum. That extension is not rustup, so rust-toolchain.toml's pin is ignored
here; the manifest says why that is correct rather than a violation of
CONTRIBUTING.md, since the pin exists to make fmt and clippy agree and neither
runs in a packaging build.
Like the PKGBUILD, it builds from the local checkout, so a build is of what you
are working on. That needs network for cargo, which Flathub forbids — the
comment says what a submission there would need instead and why generating
30,000 lines of vendored sources buys nothing yet. The LFS pointer check is
carried over from the PKGBUILD for the same reason it exists there: a dir source
copies a 130-byte pointer in without complaint, and the failure would land on a
user's machine rather than the packager's.
Not verified: nothing has been built. flatpak-builder is not installed here and
the machine is under a build embargo. The manifest parses, its keys are the ones
flatpak-builder reads, and the desktop entry and metainfo it installs both
validate — but no Flatpak has been produced from it and no permission has been
observed to be sufficient.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A desktop entry gives a software centre a name and a one-line Comment, and
nothing else — no description, no licence, no age rating, no statement of what
hardware the interface was laid out for. GNOME Software and Discover both show
an application with no metainfo as an unexplained icon, and both packages this
repository produces were in that position.
packaging/paris.tourolle.darkroom.metainfo.xml is the AppStream component, and
it is installed by the PKGBUILD as well as by the Flatpak manifest because the
description, the licence fields and the OARS rating are facts about the
application rather than about how it was packaged. Writing it twice is how the
two packages start disagreeing.
Two things in it are easy to get wrong and are commented in place. The two
licence fields differ on purpose: metadata_license covers the file itself and
has to permit the unconditional redistribution and reformatting a catalogue
does, which GPLv3 does not, so it is CC0-1.0; project_license is the
application's own and reads GPL-3.0-or-later to match D8. And the component id
is not a fifth name but the same string as the desktop basename, the Flatpak
application id, and the app_id dr_ui::run sets — a rename that misses one costs
the icon or the association, and neither failure announces itself.
D15's decision that the target devices are a tablet and a desktop is stated as
a display_length requirement rather than left implicit, so a software centre
does not offer this on hardware where the photograph and the parameter panel
cannot both be on screen.
Validates clean under appstream-util validate-relax; appstreamcli --pedantic
reports only that the gitea URLs are unreachable from a machine that cannot
see that host.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
NFR-COMPAT-2 asks for the v1 channels to be stated, and says why in its own
second sentence: the channel decision and the storage design are coupled. There
was nowhere that statement lived. packaging/ held a PKGBUILD and a desktop
entry, which is a recipe rather than a decision, and the coupling the
requirement points at was therefore invisible.
docs/distribution.md states five channels and, more usefully, which two of them
exist only as promises. It also records what every channel has to get right
independently of format — the one identifier that appears in four places, the
metainfo, Vulkan being a requirement rather than a preference while NFR-R8 is
open, a secrets daemon being optional rather than required, and the LFS pointer
check that stops a package shipping 130 bytes where an 11 MB model should be.
§4 is the part worth reading. Preparing a Flatpak is what surfaced that
FR-PLAT-LIN-3 is not satisfied and cannot be satisfied by packaging alone: a
folder library is chosen by typing an absolute path into an EndpointOnly field
that checks it with std::fs, and nothing in the tree calls the FileChooser
portal. Inside a sandbox that path does not exist, so the launch screen refuses
it. Import fails one step earlier, because a sandboxed process reads its own
mount namespace and a card mounted on the host is not in it.
That is written down rather than fixed with --filesystem=host, and the argument
for not fixing it that way is §2: the Arch package and an AppImage both hand the
application the same unrestricted process the developer runs it in, so Flatpak
is the only Linux channel that tests whether a design assumed unrestricted
access. Granting the permission removes the only reason to ship it.
The reverse coupling on Android is recorded too. NFR-COMPAT-2 says Play
distribution is what makes ARCH §6.9 binding; §6.9 is verified rather than
assumed, so SAF is already unconditional and a sideloaded build would gain
nothing by asking for more. Play is deferred over the GPLv3 question, which is
a licence-reading exercise and blocks nothing in the storage design.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FR-DEV-6 asks for three things — named presets, copy/paste between
images, and batch-apply to a selection. The last two have been here for
a while; this is the first.
The format is the sidecar's, deliberately. A preset *is* the non-default
half of a version, so the lines are the same lines keyed the same way,
which makes the two files diffable against each other and lets someone
debugging an edit paste a block from one into the other. One file rather
than one per preset: a preset per file makes the name a path, and every
name then has to survive a filesystem — a `/` becomes a directory, a
name differing only in case collides on one platform and not another,
and renaming becomes two operations that can half-fail. As a key in a
document it is none of those.
Unknown *parameters* needed no machinery. `Preset` already holds
whatever keys it is given and resolves them against the descriptors only
at apply time, so one written by a newer build survives by being stored.
Only lines that are not `op.param = float` at all are preserved
verbatim, which is the sidecar's version-skew promise made here too.
Applying is the paste path with a different source, so a preset reaches
a selection through the sidecar read-modify-write that was already
there: no graph, no decode, no GPU, forty files or one.
Two smaller decisions worth the record. A library that fails to parse is
held empty in memory and *not* written back over — settings regenerate
themselves and this is work, so a parse failure must not be the moment
it is destroyed. And every save persists immediately and rolls the
in-memory copy back if the write fails, so the sheet never lists a
preset the file does not have.
The grid's "Presets" button is gated on the selection alone, unlike the
"Paste to 40" beside it. That button needs a clipboard armed this
session; the preset list is whatever was saved last month, and hiding it
behind an unrelated action is what makes a feature only its author knows
about.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`collection_members.position` and `Sort::CollectionPosition` have been in the
catalog since collections were, and nothing above dr-catalog has ever written
or read either: `collections::set_order` had no callers, and the grid ordered
everything by capture time whatever it was scoped to — dr-ui does not construct
a `Query` at all, it has its own `GRID_ORDER` constant. So a manual collection
was a set with an order nobody could see or change.
Three pieces, because it could not be fewer:
`grid_order_for` decides the ordering from the scope, and both readers take it
from there. That is the load-bearing part. An ordinal only names a photograph
relative to an ordering, so the window read and the span read have to agree —
a shift-click resolved through a different ORDER BY than the cells were drawn
with selects a different run than the one on screen, and the user finds out
when the export runs. `read_ids_span` already stated that invariant about
`GRID_ORDER`; this widens it to an ordering that depends on the scope.
Only a single manual collection has one. A set draws its descendants' images
too, and two children's positions are unrelated integers that interleave
arbitrarily; a smart collection has no member rows to carry a position at all.
Both fall back to capture time and refuse the drop rather than pretending.
The drop is on the cell, on whichever half of it the finger landed — the
trailing edge is the only way to name the last place in a collection, since
there is no cell beyond the last one to drop in front of.
`reordered` is pure and the membership is rewritten whole. `set_order` sets the
positions it is given and leaves the rest, so a partial write would interleave
the moved run with rows nobody touched; and it is read unfiltered, so what the
filter is hiding keeps its place relative to what the user can see.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Touch has had a range gesture for as long as selection mode has: double-tap the
far end. It is invisible, it is unreliable on a grid that scrolls under the
second tap, and it extends from the anchor *before* the two taps moved it — a
rule subtle enough that the code needs two paragraphs to explain it to itself.
Nobody who was not told about it has ever used it.
"Select to…" is the same operation with state you can see. Press it, the strip
stops reporting and says "Tap the last photograph", and the next cell taken is
the far end. It reaches Rust as shift on `cell-pressed`, so it lands in
`apply_press` as the ctrl+shift it already is, and there is no third selection
policy to keep in step with the other two.
This is deliberately not the sweep gesture. A drag that paints cells can only
reach what is on screen, and the ranges that hurt on a tablet are longer than a
screenful — between the two taps here the user may scroll as far as they like,
and the run is resolved by the catalog rather than by what happened to be
loaded. A sweep is still worth having for short runs; it is not what this
should have rested on.
"Select all" beside it, asked of the catalog for the same reason: a select-all
that quietly meant "the hundred cells that happen to be loaded" is a lie the
user cannot see until the export runs.
The double-tap stays. It is tested, and an accelerator that costs nothing is
worth keeping for whoever has already learnt it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"New collection from selection" created the collection under a placeholder
name and then opened the rename field in the sidebar tree.
On a tablet the sidebar is not on screen. It is instantiated all the same —
app.slint collapses it to zero width and `visible: false` rather than using an
`if`, because an `if` there is a layout loop Slint panics on — so the rename
field was created, its `init` took focus, and Android raised the on-screen
keyboard over a box nobody could see. Nothing else on the screen is focusable,
so the keyboard had nowhere to go: it stayed, the name could not be typed, and
the collection was already written under the name the user did not want.
Asked in a sheet instead, on the same card as the filing and keywording sheets,
before anything is written. That also fixes what was hiding behind it: an
abandoned rename used to leave a "New collection" in the tree, because the
collection existed before the name did.
`Field` gains `take-focus()` so a sheet whose field is the only thing to do in
it can answer the keyboard for the user — a function rather than a property,
because focus is an event and a bound property would re-take it on every
unrelated re-evaluation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The auto-crop only ever shrank. Straighten to 20 degrees and the corners
are cropped away correctly; come back to 3, or all the way to zero, and
the crop stays at the size 20 degrees demanded. Nothing on screen explains
why the photograph is still small, and the only way back was undo.
The cause was that each correction was computed from the previous
correction's output, so it accumulated: every angle the slider rested at
took its cut and none was ever returned. The fix is to stop accumulating
and recompute. The applied crop is now always the user's own rectangle
fitted into the current angle's safe area, so as the angle falls and that
area opens up the crop grows back — and stops, exactly, at the rectangle
they chose. At zero the safe area is the whole frame and the fit is the
identity, which is what carries it the last of the way home. There is
deliberately no early exit for the upright case now: that exit is
precisely what would strand the crop small.
**The intent is remembered as a pair, so it repairs itself.** The session
keeps `(applied, intended)` — what the correction wrote, and what it was
derived from — and trusts the remembered intent only while the graph still
holds `applied`. Every other route to the crop leaves something else
there: a handle dragged, a ratio chosen, a sidecar loaded, a paste, an
undo. That mismatch is the signal the memory is stale, and the current
rectangle becomes the new intent. The alternative was a write into this
field from each of those paths, which is the kind of bookkeeping that is
correct until someone adds a seventh path.
Dragging a handle therefore *is* the user choosing, including at a
non-zero angle: the correction will not later grow the crop past what they
dragged it to.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The develop column asked for 482px. Every comment in it, a dozen of them,
describes it as a 280px column — and on a tablet it was taking 40% of the
screen from the photograph it exists to serve.
Measured rather than guessed, because none of it is visible in the source:
the column takes the widest width any panel declares, `AdjustPanel` wanted
482 of it, its rows wanted 458, and after the 44px scroll gutter the
widest single row was 414. That row is `film_sim`'s `format` — six film
formats from 35mm to 8x10, laid out by `Segmented` as six 64px chips in a
`HorizontalLayout` that cannot wrap. 6 x 64 + 5 x 6 = 414, exactly.
Nothing about that is the film simulation's fault. An operation declares
its parameters and the panel decides how to draw them (FR-DEV-3a), so a
node is entitled to offer six choices; it is the drawing that has to cope.
Any future operation with a five-choice enum would have done the same
thing, silently, to every screen in the application.
`ChipGrid` is the general answer: chips placed by index arithmetic inside
a plain `Rectangle`, wrapping at a column count, declaring a width that
depends on the columns rather than on the number of choices. `Segmented`
takes a `columns` property and uses it when asked — zero, one row however
many chips, stays the settings page's behaviour, where the page is
full-width and reading the alternatives side by side is the whole argument
for chips over a dropdown.
The generated enum rows and the curve-channel picker now wrap at three,
and the crop ratio chips use the shared grid instead of the private copy
of it they shipped with last week.
The column measures 351 now, down from 482, and what sets it is the mode
strip rather than a parameter — which is a control the user chose to have
on screen rather than an accident of one node's variant list.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Thirty-five commits since 0.8.0, and two of them are the reason this is a
minor rather than a patch.
**Storage became pluggable.** A folder backend now sits beside the
Nextcloud one behind the same seam, proved by a test rather than by a
trait, and the launch screen offers three routes into a library instead
of one.
**Faces gained a confidence that means something.** A suggestion is
scored against the people the user has actually named, the curve it came
from is stated rather than implied, and a regroup runs roughly three
times faster — the similarity scan and the merge engine both use the
machine's own SIMD kernel now, measured on the tablet where the NEON path
is the one that runs.
Alongside those, the framing tools got the quality-of-life pass this
release is named for: a crop can be held to a ratio, a straighten crops
away the corners it exposed, an export can be pinned to an exact
resolution with the common panel sizes offered as buttons, and dragging
the crop rectangle no longer tracks the pointer at half speed.
`pkgrel` returns to 1: a new `pkgver` is a new archive name, so there is
nothing left for a release number to disambiguate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The box modes put two numeric fields one above the other, and on screen
they are two anonymous numbers: `TextRow` draws its label behind the
field rather than above it, so "Width" and "Height" never appear. That
fault is older than this feature — the storage panel's cache sizes have
the same missing labels, and the single "Size value" row always did — and
it belongs in its own change rather than being fixed under cover of this
one.
But one unlabelled number is survivable and two are not, so the axis goes
where the page does render it: the unit. It reads "3840 px wide" above
"2160 px high", which is the sentence the user is trying to write anyway.
The `label` bindings stay correct and stay where they are, so this
becomes redundant rather than wrong the day `TextRow` is fixed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The straighten auto-crop worked once and then stopped. It was keyed on
`PlainSlider::drag-changed`, whose name is a lie inherited from what it
forwards: `SliderTrack` defines `engaged` as `has-hover || claimed`, and
it has to — a `Flickable` withholds the press for 100ms, so hover is the
only signal that arrives in time to stand the scrolling ancestor down.
That is the right definition for the job it was written for and the wrong
one for this.
Keyed on hover, the correction fires when the pointer first crosses the
track — before anything has been dragged — and then does not fire again
for as long as the pointer stays on it, however many times the angle is
changed. Which is exactly what "it only works once" looks like.
`SliderTrack` already publishes the signal this wants. `committed` fires
on release, once per gesture, after the final `changed`, and its doc
comment says so in as many words. It was simply not forwarded through
`PlainSlider`, so it now is, and the geometry panel's callback is a
`committed(float)` rather than a `drag-changed(bool)`.
The angle needs no re-applying here: the track emits its last `changed`
before it commits, so the value is already in the graph by the time this
runs. What is left is the correction that has to happen exactly once.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ratio chips shipped inside a `GridLayout` with `row` and `col`
computed from the repeater's index. It compiles. On screen every chip
piles into a single row that runs off the panel, and the terminal fills
with
Internal error in Slint: RepeatedItemTree::grid_layout_input_data()
not implemented
once per frame. A `for` inside a `GridLayout` is not supported, and
nothing says so until it is running.
A `HorizontalLayout` is not the answer either: six chips side by side
need over 400px, and this column's width is the largest width any panel
declares — so one row would widen every other panel in the application to
fit a control that is on screen only while cropping.
So the grid is arithmetic over the index inside a plain `Rectangle`. It
costs the layout engine nothing, it wraps a seventh ratio onto a third
row by itself, and — because a bare `Rectangle` declares no preferred
width — it takes the width the column already has instead of setting it.
The Portrait chip gets a container of its own for the same reason: a
`ChoiceChip` dropped straight into a `VerticalLayout` is stretched the
full width of the panel and reads as a button for the section rather than
as one more chip.
The three conditional pieces are also now individually-conditional
children of the one layout rather than a nested layout under a single
`if`, which is the convention `app.slint` and `masks.slint` already carry
notes about: a conditional nested layout under-reports its height here
and the panels below it draw on top of one another.
All of this was invisible in the source and obvious in a screenshot.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A quarter turn carries the crop with it — that is what makes turning a
photograph keep its composition rather than sliding the selection onto a
different part of the picture. So a rect locked to 16:9 comes out of the
turn at 9:16, of a frame whose axes have also swapped, and the lock was
left claiming landscape over a portrait rect. The next drag would then
snap it back upright and undo what the turn had just done.
The orientation switch now turns with it, on odd numbers of quarters.
`Original` is deliberately excluded, and getting that wrong flips it
twice: it is resolved against the framed size every time it is asked
for, and the turn has already swapped that frame's axes — so it has
turned by the time anything asks. `turns_with_the_frame` is the one
predicate that separates the two cases, with a test that pins both.
`CropAspect` arrived without tests of its own; it has them now, including
the round trip this fixes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Export sizing could bound an image but not fix it. Long edge, short edge
and percentage all preserve the aspect ratio by letting one dimension
fall where it may, which is right for most work and useless against a
display that accepts one resolution and rejects everything else — a
television's art mode, a digital frame, a wallpaper slot.
FR-EXP-3 has always listed both halves of the answer, and this adds them.
**Fit box** scales to fit inside a width and height, so nothing is thrown
away and the result is smaller than the box on one axis unless the crop
already matches it. **Fill box** scales to cover the box and cuts the
overhang off the middle, so the file is exactly the pixels asked for.
Fill is the only mode in the file that discards image data, so two things
about it are worth stating. The overhang comes off symmetrically: the
crop tool is where a photographer decides which part of a frame survives,
and this stage having an opinion of its own would fight it. And locking
the crop to the same ratio leaves nothing here to cut, which is the
workflow the two features are meant to be used in.
With upscaling off and a source too small to cover, a fill box keeps its
*shape* rather than falling back to the source's: exporting a 3:2 file
where 16:9 was asked for is silently wrong in exactly the way the mode
exists to prevent, so the box shrinks instead. The existing rule — clamp,
never fail — is otherwise unchanged.
Four panel sizes are offered as buttons beside the fields. Getting 3840 x
2160 by typing four digits twice is a step at which the mistake is
discovered after the upload rather than before it. They fill in the
numbers and nothing else, in particular not the fit/fill choice: both are
legitimate against a screen, and guessing would discard the edges of a
photograph for a user who wanted them. The list is panels rather than
platforms, because a screen has one exact pixel count for ever where
"what a photo site wants" would rot in the file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Turning a rectangle inside its own bounds exposes its corners: there is no
source pixel out there, and the shader renders it black. Nothing in the
render prevents that, deliberately — a free angle does not change the
output size, which is what leaves the frame where the user put it while
the slider moves. Correct during the drag; four black wedges on the
finished photograph.
Letting go of the slider now pulls the crop inside the area the angle
leaves defined. `Framing::max_inscribed_crop` already computed that
bound and had no caller; this is the caller its doc comment described.
**Once, at the end of the gesture.** Applied per frame it would shrink
the crop on every step of the slider and never grow it back, so a user
who overshot to 20° and came back to 3° would be left with a crop
ratcheted down by the excursion rather than by the angle they settled on.
Per gesture it is bounded by the angles actually rested at, and undo steps
back through them.
**The crop is fitted into the bound, not replaced by it.** A crop placed
deliberately off-centre is a decision, and an automatic correction that
recentred it would undo the user's work to fix a problem they did not
have. `CropRect::fitted_into` scales only as far as the bound demands and
then slides the rect the shortest distance needed to be inside — so a
ratio locked in the crop panel survives the straighten too, since the
shape is never touched.
It returns the rect unchanged, bit for bit, when nothing needed to move.
That matters more than it looks: this runs on every release of the
slider, including releases at zero, and a rect that drifted by a rounding
error each time would be an edit recorded for no reason.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A photographer cropping for a print, a phone wallpaper or a 16:9 frame is
not choosing four edges — they are choosing one edge and a known shape.
Free-dragging every corner made them do that arithmetic by eye on every
drag, and get it slightly wrong.
The panel now offers Free, Original, 1:1, 3:2, 4:3 and 16:9, with a
Portrait switch for the ones that have two orientations. Original follows
the frame rather than naming a number, so it stays right on the next
photograph from another body and after a quarter turn.
**The ratio is of output pixels, and the rect is not.** `CropRect` is
stored in fractions of a frame that is not itself square, so holding a
shape needs the frame's size — `ratio * height / width` of the frame.
Skipping that gives a "1:1" crop that is square only on a square
photograph, which is the one case nobody would test on, so the conversion
lives in `CropRect::with_aspect` where it is explained and pinned by a
test that asserts the fractions are *not* equal.
Two decisions worth recording:
The reshaped rect **grows** onto the ratio rather than shrinking onto it,
then scales down only as far as the frame's edge demands. Fitting inside
instead makes a one-axis drag do nothing at all — the other axis clamps
the first straight back, and the handle simply refuses to move.
The overlay now reports **which corner the drag is holding**, because
reshaping onto a ratio has to know which corner is nailed down and only
the handle that took the press knows that. A move reports no corner and
keeps its shape: reshaping about a centre would pull an over-moved rect
smaller instead of sliding it along the edge.
The lock lives with the window rather than the session. A `DevelopSession`
is per image, and cropping a set of frames to one shape is exactly when
the lock earns its place. It is not an edit and reaches no sidecar — what
is saved is the rectangle it produced.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dragging the crop rectangle tracked the pointer at half speed: the rect
slid out from under the cursor, and the handle being held stopped being
the one under the finger. On a photograph where the whole point is to
place an edge by eye, that made the tool close to unusable.
The cause is that a `TouchArea` reports `mouse-x` relative to itself, and
every area in the overlay is positioned *by the very rect the drag is
editing*. Rust echoes the applied rect back on each step, so the area
moves under the pointer and the reported position falls by exactly the
amount it had just risen. `mouse-x - pressed-x` therefore subtracts the
drag from itself, and the fixed point of that feedback is a rect that
moves half as far as the pointer does — which is why it looked like
sluggish tracking rather than like a coordinate bug.
Both terms are now taken in the frame's own coordinates: `parent.x +
mouse-x`, where `parent.x` tracks precisely the movement `mouse-x` lost,
with the press captured in those same coordinates on the way down. The
two movements cancel and the rect follows the pointer exactly.
`GradientHandles` in masks.slint was already written this way, and for
the same reason — its handles are placed by the mask they drag. The
header note here now says why the shape matters, since the wrong version
compiles, looks plausible, and is only wrong once the rect starts moving.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The before/after published a p99 ratio of 6.0x at 4K. It is not supported and
the correction is worth more than the number was.
The baseline run's `fit` rows spread 2.3x between median and 99th percentile
while every row of the after run spreads about 1.1x. A stage costing
`radius x pixels` has no reason to be bimodal, and `fit` is the memory-bound
configuration — it walks the whole 482 MB source on a stride where `1:1` reads
a contiguous window. Something else had the machine.
The merge brings in the cross-check that settles it: "Try every GPU, not only
the fastest one" measured the same baseline code on the same card and reports
4.67 ms p99 for M3 clarity fit at 2560x1600, against 10.40 ms here. Two
measurements of one thing differing by 2.2x mean the noisier one is wrong.
So both percentiles are now published and the p50 column is the claim: 2.8x at
4K rather than 6.0x. The p99 improvement is real and larger; this run cannot
say by how much, and says so.
What the caveat does not touch: every after figure is inside the 16 ms budget
with a p99 within 26% of its median at every size and both views, and clarity
against all-four is a within-run comparison.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TD-4 asked for the measurement as well as the change, and this is it: 25.05 ms
to 4.17 ms at 3840 x 2160, six times faster, with clarity no longer dominating
the neighbourhood stage it used to be 97% of.
Measured before and after on the same machine and the same adapter minutes
apart, baseline at the branch's merge-base, so the only variable is the change.
That adapter is not the RTX 3050 the rest of this document was measured on, so
the new table says to read it on its own rather than against the ones above —
the before/after is comparable, the absolute figures are not, and quietly
replacing the existing tables would have changed the instrument.
Also recorded: the declared halo is now quantised to multiples of the output
scale, because the 2-sigma truncation rounds on the reduced grid. 29 px becomes
28 at 1920x1200 and 38 becomes 40 at 2560x1600. It is inside what the cross-form
test holds — 0.03 stops of peak, 2% of reach — but it is a change in reach and
not only in cost, and a tile scheduler would be handed it. Better written down
now than found later as a seam.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Clarity's Gaussian sigma is 1.2% of the frame's shorter edge, so its radius
is a property of the viewport: 52 render pixels at 4K, two separable passes
of 105 taps each over 8.3 M pixels. That measured 33.9 ms — seven times the
entire fused point chain, for one slider — and is docs/technical-debt.md TD-4.
A detail pass may now declare `output_scale`, and clarity's base is computed
on a grid a quarter the size on each axis.
The pass that combines needs the blur *and* the full-resolution colour, and a
colour that has been through a quarter-scale target is no longer full
resolution. So a scaled pass cannot simply join the ping-pong: there are two
chains now. The full-resolution one carries the colour and no scaled pass
touches it; the reduced one carries the base and reaches the combining pass
through a second binding as `reduced_at()`.
The reduce is a dispatch of its own rather than something the first blur half
does on the way past, and that is the whole difference between this and the
strided kernel the module documentation rules out. A stride samples an image
that is not band-limited and aliases high-frequency content down into the
base, which is then subtracted, and arrives in the output as mottling across
smooth gradients. This band-limits first and samples after. What is discarded
is content the base could not represent at any resolution, because a Gaussian
at sigma = 26 px holds nothing above one cycle per 26 px and the quarter-scale
grid carries one per 8 — so the reduced base is not an approximation of the
full-resolution one, it is the same function sampled where it is still
determined.
Which is also why the scale belongs to the band rather than to the stage.
Texture's sigma is a decade finer, so the reduce pass's own box would be wider
than the Gaussian it was prefiltering; texture never reduces. And clarity
steps 4 -> 2 -> 1 as sigma falls, because a quarter of a small sigma is not a
Gaussian either — the case that gives up is the one that was already cheap.
`radius` stays in each pass's own pixels and `ComposedDetail::radius` multiplies
it back up, so 13 reduced pixels at scale 4 still report the 52 render pixels a
tile would have to be grown by. The halo a scheduler sees does not move.
The halo tests pass unchanged, which was TD-4's stated bar; they render at
1024 px and so exercise the reduced path rather than stepping around it. Added
`crossing_the_reduction_threshold_does_not_change_the_picture`, because
nothing yet compared the reduced form against a *less* reduced one — every
other test measures one form against itself. It renders the same edit either
side of the 4 -> 2 step-down and holds the peak excursion to 0.03 stops and
the reach to 2% of the frame.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`request_adapter` with `HighPerformance` returns one adapter and no
second chance. That is right on a healthy machine and wrong on one with
a sick GPU, which is not rare: observed 2026-08-29 on a laptop whose
discrete card had hit an NVRM assertion failure and a fullchip reset.
The driver still advertised it, wgpu dutifully picked it as the highest
performing, and the process died on it — while a working integrated GPU
and a working external card sat unused in the same enumeration. A photo
editor that will not start because the *fastest* GPU is broken, on a
machine holding two that are not, is worse than a slow one.
So: enumerate, order by preference, take the first that yields a device.
The ordering reproduces what `HighPerformance` meant, so a healthy
machine picks what it always picked and pays one enumeration for it. A
CPU adapter sorts last rather than being excluded — software rendering
is a poor experience and a working one.
Which GPU to prefer is now a policy rather than an assumption, because
the fastest is not obviously the right one. A 24 MP frame is ~96 MB of
RGBA and every upload and export readback crosses PCIe on a discrete
card, where an integrated GPU shares memory and crosses nothing — and
does not empty a battery.
Measured before choosing a default, on this machine's Iris Xe against
its RX 5700 XT. The fused colour pass is within 1.5x, which is the
shape shared memory suits. The neighbourhood stage is 5-8x slower, and
that decides it: clarity at 1920x1200 costs 20 ms on the iGPU, over the
budget on its own at the smallest size tested. So `Performance` stays
the default and `Efficiency` is offered rather than chosen
(`DARKROOM_GPU=integrated`).
docs/frame-budget.md carries the table, and says what it does *not*
show: the harness renders from a resident texture and never uploads or
reads back, so the transfer cost an iGPU avoids appears in none of it.
Import, export and the thumbnail sweeps may well go the other way.
What this cannot fix: a GPU sick enough to accept `request_device` and
segfault afterwards, which arrives as a driver crash rather than an
error. It moves the boundary from "the preferred adapter is unusable" to
"unusable and dishonest about it".
Two things the People screen was getting wrong, and then the arithmetic
underneath it.
It refused to show a confidence at all until the library had fitted its own
calibration, which needs 200 confirmed positive pairs — made on the screen that
was withholding the number used to rank them. The default curve is the
reference implementation's fitted one, a published operating point rather than
an invention, so the percentage is shown and the screen says which curve it
came from.
The number itself was the mean similarity between a face and every other member
of its group, which punished well-photographed people and never asked who else
the face might be. It is now the mean of the best ten matches into the identity,
times that identity's share of the evidence against every other identity the
user has ruled on. Leave-one-out over this library's 2,702 confirmations across
54 named people: 99.33% placed on the right person, and the number shown for the
right person moves from a median of 90.4% to 99.3%.
Both of those were measured rather than argued, on a copy of a real 18,143-face
library, and the instrument is committed with them.
Measuring is also what found the rest. A regroup went from 10.0s to 3.1s: the
similarity scan was walking the whole embedding array once per row and not
vectorising at all, and the agglomeration was spending 3.06s having every
component scan the entire pair list for its own pairs. The scan now picks its
kernel per machine — AVX2 where the CPU has it, NEON on the tablet, where the
whole suite and a full regroup have both now been run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
assign's denominator is the identities the user has ruled on, and it
reads Cluster::person to find them. Master's "Let a name hold a group
together" widened what sets that field: a confirmation, a name, or an
ignore, where before it was a confirmation alone.
The behaviour is right either way — a named person is exactly the
identity a suggestion should be discounted against — but the module note
and faces.md §9.1 both said "a confirmation", which is now too narrow.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Conflicts were docs/traceability.md alone, and it is generated — so it
was regenerated rather than hand-merged. dr-face was untouched on the
other side; ui/dr-ui/src/faces.rs and identity_ui.rs auto-merged, the
first around recluster's anchoring and the second around load_faces.
Worth recording because the two branches met on the same problem from
different ends. Master's "Let a name hold a group together" is the fix
for the sixteen Catherines — fourteen of them empty — that this branch
found while measuring the library and reported without fixing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The GPU question needed a number nobody had: how a regroup divides on
the hardware whose CPU is weakest. dr-face carries no weights and
touches no display, and dr-catalog's example needs only a catalog file,
so both run under adb shell against a copy of a real library.
On the same 18,143 faces — desktop against the tablet — scan 0.96s /
2.61s, agglomerate 1.69s / 2.16s, score 0.26s / 0.40s. The scan is half
the pass on the tablet and under a third on the desktop, because twenty
cores of AVX2 pull ahead of NEON much further than the merge engine's
single-threaded hashing does. So a GPU GEMM is worth roughly 2× a
regroup on the tablet and 1.5× here, and it is the tablet that should
decide whether it is built.
The two architectures agree exactly: the same 1,531,969 evidence pairs,
the same 2,518 groups holding the same 16,246 faces, the same
reliability table. That is a better check on the NEON kernel than the
unit test can be.
Two instruments, both read-only: the example now prints its phases, and
dr-face gains scan_bench, which needs no library at all and so can
answer "how fast is this machine" on a device with nothing on it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The similarity scan picks its dot product per machine, and the NEON one
is the kernel that ships to the phone and the tablet — and the one a
desktop cargo test never executes. A wrong lane index or a mishandled
tail there is a silent wrong answer on exactly the devices nobody runs
the suite on, which is a poor place for the only untested code path.
dr-face carries no weights and touches no display, so its tests are a
plain ARM64 binary that runs under adb shell with nothing installed.
The script builds it against the SDK's newest NDK, pushes it, runs it
and cleans up. It checks for the device first, so a tablet that is not
plugged in costs a second rather than the two minutes it takes to
compile for it.
Not wired into CI, which has no device attached.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The §9 note said the scan was half a regroup and implied the
agglomeration was an irreducible sequential walk. Both halves of that
are now wrong, and the numbers are the point of the section.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Engine::cross is the one place a dot product is computed during
agglomeration — when two groups become adjacent through a third and
their sub-threshold pairs, never summed because they were never
interesting, have to be accounted for. It was calling the portable loop
while the scan beside it had AVX2 or NEON, which on the reference
library was 1,753,514 dot products taking 0.54s.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Seeding the merge heaps was 3.06s of a 5.93s regroup on the reference
18,143-face library — more than half the pass, spent before a single
merge was considered.
Every component scanned the whole pair list looking for the pairs that
were its own: 475 components against 804,499 pairs, 382 million set
lookups to place 804,499 of them. A pair can only ever join two faces of
one component, since that is what a component is, so the union-find that
finds the components can file the pairs at the same time and hand each
agglomeration the list it needs.
The membership set inside agglomerate goes with it — it existed only to
run that filter — and the heap can be sized up front now that the pair
count is known.
Ordering is preserved deliberately: pairs are filed in the order they
arrive, which is the global (i, j) order, so the seeded heap breaks its
ties exactly as before and the merge order is unchanged. Same 2,518
groups holding the same 16,246 faces on the reference library, at 3.5s
rather than 5.9s.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The scan is O(n²) dot products and nothing else, so its speed is the
face subsystem's speed — and it was running at 0.7 flops per cycle.
Two separate faults, both measured over the reference 18,143-face
library on twenty cores. It walked the whole embedding array once per
row, ~336 GB of traffic, where a column tile that fits in L2 is read
once per tile of rows: 4.64s → 2.81s. And the workspace builds for
baseline x86-64 — SSE2, no FMA — into which the portable loop was not
being vectorised at all: 2.81s → 0.86s, 195 GFLOP/s.
So the dot product is now chosen per machine. AVX2 + FMA where
is_x86_feature_detected! finds it; NEON unconditionally on aarch64,
since Advanced SIMD is in that baseline and every Android device the app
builds for has it — with the explicit vfmaq, because LLVM will not fuse
a multiply and an add without being told to. The portable loop stays as
the definition the others are tested against, and
the_fastest_kernel_agrees_with_the_portable_one is the only check the
NEON path gets on a machine that is not aarch64.
Faces::embeddings is one flat buffer rather than a Vec per face: the
pointer chase defeated both the prefetcher and the tiling, and it is
also the layout a GPU pass would want.
Behaviour is unchanged and that is checked rather than asserted — the
same 1,531,969 pairs from all three kernels, and on the real library the
same 2,518 groups holding the same 16,246 faces with the same confidence
distribution. A full regroup there goes from 10.0s to 5.9s; the rest is
the agglomeration, which is a sequential heap walk and is where the next
look should go.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The number beside a suggestion was the mean calibrated probability
between the face and the rest of its group, which measures the wrong
thing twice. It punishes coverage: a person with two hundred faces over
fifteen years is *meant* to have members a given photograph is
orthogonal to, so a correct suggestion onto a well-photographed person
scored low for being well photographed. And it never asked who else the
face might be — a face matching Anna at 0.95 and nobody else, and one
matching Anna at 0.95 and her sister at 0.93, came out identical, when
the second is the only one worth the user's attention.
dr_face::assign answers both, and multiplies them: the mean of the best
ten calibrated matches into the identity (the old mean, capped, which is
what stops coverage counting against it), times that identity's share of
the evidence against every *named* rival.
Only named people compete, and per person rather than per group. Both
halves of that had to be measured on a real 18,000-face library rather
than reasoned about. Normalising across every group made the number
useless — median suggestion 21%, four in five under half — because
clustering leaves one person spread over many groups, so a face competed
against itself; and keying rivals by group left Catherine competing with
Catherine, median 39%. Per named person: median 99.5%.
Rivals are gathered below the merge threshold, down to even odds: a
named person matching at 0.6 will never be merged into but is exactly
the competition to discount for. That would be a second similarity scan,
the expensive half of regrouping a library, so cluster_scored scans once
at the looser floor and hands the merge engine the subset at or above
the threshold — pair for pair what it would have scanned for itself,
held to that by a test.
Leave-one-out over that library's 2,702 confirmations across 54 named
people: 99.33% of faces placed on the right person against the old
mean's 99.15%, and the number shown for the right person moves from a
median of 90.4% to 99.3%. It errs low — 100% correct wherever it states
80% or more — which is the safe direction, and docs/faces.md §9.1 says
plainly that the low bands are not calibrated.
The example that measures it comes too: this is a claim about a
library's numbers, and nobody should have to take it on faith.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The catalog clobber existed because `if let Ok(bytes)` had a failure arm
that was never exercised. Fixing it without covering that arm leaves the
next person free to collapse it back.
Three tests against a backend whose read fails in a chosen way, counting
writes — because what went wrong was not a wrong value but a write that
should not have happened at all:
- a dehydrated snapshot uploads nothing
- an unreadable one uploads nothing either, since "refused" is no more
"absent" than "not downloaded" is
- and a genuine first sync still uploads, which is the half that keeps
`NotFound` distinct from `NotMaterialised` rather than merely cautious
Checked against the original shape: the first two fail on it and the
third passes. A guard test that cannot tell the bug from the fix is
decoration.
`async-trait` joins dev-dependencies to stand the double up behind
`dyn RemoteBackend`.
`sync_catalog` is a read-modify-write over a file another device also
writes: take theirs, merge, push the union. It was shaped
if let Ok(bytes) = backend.get(&RemoteId::Path(target), None).await {
which folds *every* failure into "there is no remote catalog" and carries
straight on to the upload. On a placeholder library the snapshot in
`.darkroom-derived/` is dehydrated like anything else, so the read failed
every time and each sync pushed our catalog over theirs unmerged —
taking the other device's collections and their members with it.
The same shape as the sidecar bug, and the same fix: a read that fails
for anything other than `NotFound` stops the upload and says why. An
unreadable or unopenable snapshot stops it too — "will not parse" is not
"is not there". This is what `NotFound` and `NotMaterialised` being
separate errors is *for*: one means ours is the whole truth, the other
means do not dare.
Shard downloads go through the same fetch-on-demand read. They logged
and skipped before, which on a library the client keeps dehydrated is
every shard, every pass, and a peer's thumbnails and faces silently
never arriving.
And `put` over a placeholder now replaces it rather than refusing.
Refusing was over-cautious of me: derived state lives inside the library
folder, so a folder the client had dehydrated could never be written to
again. An unconditional write replaces the whole file, so there is
nothing in the stub to keep — content first, then the placeholder, since
in a synced tree an absence is a deletion that propagates. `IfMatch`
still refuses, because a stub's validator describes the stub; `IfAbsent`
fails, because the file is there and only its content is not.
The claim that hydration-as-a-borrow makes peak disk the working set
rather than the library was so far an argument. `--example vfs_cycle`
runs it: 100 photographs of 25 MB, 90 dehydrated, a pass over all of
them through the real engine.
Peak 275 MB — the resting set plus one photograph — against 2,500 MB
had the pass simply fetched everything. Back to 250 MB afterwards, and
all ten files the user already kept still there, which is the half of
the contract that matters more.
Recorded in docs/storage.md §6.3 and ARCH §9.0a, because a bound argued
from a number nobody measured is one that gets quietly lost.
The passes that need every photograph's bytes — thumbnails, face
indexing — now borrow each one and release it at the end. On a
placeholder library that is the difference between peak disk being the
working set and being the whole library.
Including on cancellation, which was nearly missed: the face sweep
returns mid-loop when the user presses Stop, and without releasing there
the disk is spent and nothing is delivered for it.
`materialise` now answers whether *it* fetched the content. The pool used
to work that out by listing a file's parent directory — one listing per
file across a library — when the backend already had to `stat` it to
decide whether to ask. One syscall instead of a directory walk, and it
removes the bug class the tests found earlier: a file at the library root
has no `parent()`, so every one of them read as already-downloaded.
**Pinning is the retention control**, and it drives the model the catalog
already had rather than a second one. `tier_desired` is what the user
asked to keep hydrated, `pending_pins` is the resumable work list, and a
pinned collection is never dehydrated for the same reason it was never
evicted. It was in fact *broken* here before: `get` on a stub failed, and
the pin worker logged "one unreadable file must not abandon the whole
pin" and silently did nothing.
Pinned originals on such a library are recorded with `path = NULL`
(`Cache::record_in_place`) rather than copied under `originals/`. Two
reasons, and the second is the important one. A copy would hold every
pinned photograph twice, with the budget able to evict the half that was
not costing the disk. And `release` deletes the file a row names — so a
row that names none cannot delete anything, which puts the one
catastrophic operation out of reach by construction rather than by
remembering not to call it. Deleting a materialised file inside a synced
tree removes the photograph from the server and every other device.
Handing disk back is `spawn_dehydrate`, which asks the client.
Two gaps written down rather than papered over (docs/storage.md §7): a
hydrating pass cannot yet quote its cost, because a stub reports no size;
and the two sweeps hold separate pools, so a library indexed for both
fetches twice.
The folder connector was pointed at a Nextcloud VFS tree and got three
things wrong, the first of which loses work.
**A dehydrated sidecar read as absent.** `a.drsc` does not exist when the
client has dehydrated it — only `a.drsc.nextcloud` does — so `get` missed,
`.ok()` swallowed the `NotFound`, and the sidecar writer took that for
"there is no sidecar yet" and wrote a fresh document over the existing
one. Every edit another device had put there went with it. That function's
own doc comment calls this the exact loss the format's unknown-key
preservation exists to prevent.
**A stub was catalogued as a 1-byte image**, and ARCH §9.0 measured this
machine at 121,785 placeholders against 10,267 real files — so a folder
library on a synced tree was ~92% broken rows.
**Identity changed on hydration**, so downloading a photograph looked like
a delete and an add, orphaning its thumbnail and its face rows.
Entries now carry the photograph's own name and a `materialised` flag;
`get` on a stub returns the new `RemoteError::NotMaterialised`, which is
distinct from `NotFound` precisely because the sidecar writer must treat
them differently — it fetches the sidecar and merges, or leaves the entry
queued.
Hydration is a **borrow**. `BorrowPool` records what was on disk before it
asked, so `release_all` dehydrates only what a pass brought and leaves
what the user already had. Reference counted: the thumbnail pass and the
face pass meet on the same RAW, and without counting the first to finish
dehydrates the file the second is reading. A borrow against a plain folder
or a server does nothing, so a pass written for VFS runs everywhere.
Releasing means asking the client to dehydrate and never deleting: a
deletion inside a synced tree propagates to the server and removes the
photograph from every device.
Not a second backend — the capability is per *connection*, not per type,
since the same folder hydrates only while the client runs. The convention
arrives through a detector the registry supplies, so `dr-sync-folder`
still knows nothing about any client's protocol.
ARCH §9.0a records this as an amendment: finding 3 rejected hydration
because it costs 100× a range read, and that comparison assumed a
connector was available. A folder library has none.
The signed-out screen was a `VerticalLayout { alignment: center }` with
no scroll. That was already tight with two routes; the folder option
made the column taller than a 900x560 window, and a centred layout that
overflows clips at *both* ends — so the masthead and the last route
disappear together, with nothing on screen to suggest either existed.
A Flickable whose viewport follows the content, and a top padding that
centres the column only while it fits. Both cases checked against the
running app: centred at 1000x1800, top-aligned and scrollable at
900x560.
The whole of a folder library's sign-in lived inside a Slint callback,
which cannot run without a display server — so the one path that decides
whether a mistyped folder becomes a *stored* account had no test at all.
That failure is quiet and lasting: an account for a directory that is
not there skips the launch screen on the next start and reads as a
library that has lost its photographs.
`open_folder_library` is that logic, lifted out whole. Four tests: it
stores an account with no credential and reaches the keyring for
nothing, a typo is refused before anything is written, the messages read
as instructions because they go straight to the screen's error line, and
a file is not a library.
The folder connector declares `LocalEtags`, which means the engine walks
the whole library on every scan with no pruning. That is the honest
capability, and the argument for it being affordable was so far an
assertion about `stat` versus `PROPFIND`.
`--example scan` runs the real path — `dr_sync::scan` over the connector,
then a ranged read of the kind the thumbnail worker makes. Read-only; it
never writes into the folder it is pointed at.
2,299 images across 233 directories in 137 ms, and 380 across 13 in
29 ms. Against 34.1 s for 17,185 RAWs over WebDAV *with* pruning
available. Recorded in docs/storage.md §5.2 and ARCH §8.4a, because a
capability trade-off argued from a number nobody measured is the kind
that gets quietly reversed later.
`RemoteBackend` existed from the first release and bought nothing it was
designed for. Seven files in `dr-ui` constructed a `NextcloudBackend`
directly, an account *was* a server URL beside a DAV user id, the local
cache directory was named after a hostname, and the launch screen knew
that signing in meant a browser handshake. The trait was real; the seam
was documentation.
A trait over operations is only a quarter of it. Pluggable storage needs
four things, and this adds the other three:
- **Capabilities** — already there, and the reason the engine can drive
two backends at the speed each actually runs at.
- **Configuration** — `dr_sync::Account`: where a library lives, in
whatever form its connector addresses, with no server in it. Loads
every existing config unchanged (`backend` defaults to `nextcloud`,
`endpoint` is stored under its historical `server` key), and
`Account::namespace()` reproduces the old catalog directory byte for
byte, because changing it would abandon a catalog, its thumbnail
shards, and the sidecars holding unsynced offline work.
- **Registration** — `BackendProvider` and `BackendRegistry`.
`ui/dr-ui/src/remote.rs` is now the only file above `dr-sync` that
names a connector.
`Connection` (an account plus an optional `Secret`) replaces the
credentials-and-user-id pair that was threaded through fifteen
signatures in an order that could be swapped. `Secret`'s inner string is
reachable only through `expose()` and its `Debug` prints `Secret(***)`,
so the indirect leak — a `{:?}` on anything holding one — no longer
compiles into a leak.
Nextcloud is unchanged and keeps every peculiarity: propagating ETags,
chunked upload v2, `oc:fileid`, the `oc:permissions` probe on a refused
PUT, the 423 retry classification, Login Flow v2. Those are what the
capability model exists to serve, not something to hide.
`dr-sync-folder` is the second connector: a local disk, a network mount,
an external drive, or a folder a Nextcloud client already syncs. No
account, no credential — the route that works where no secrets daemon
does. It declares `LocalEtags` rather than claiming propagation a POSIX
directory cannot provide, which costs nothing because 50k `stat` calls
are not 50k PROPFINDs. Identity is a path hash, not an inode: an inode
survives a rename but differs between devices and is reused after a
delete, so two machines would disagree about which photograph a
thumbnail belonged to. Re-deriving a thumbnail is a cost; showing the
wrong one is a bug.
docs/storage.md is the contract — the traits, the four steps to add a
backend, and what each connector declares. ARCH §8.0 and §8.4a, and
FR-NC-13, say why.
FR-CULL-9 was read as "no fit, no number", so every library without 200
confirmed positive pairs showed "Confidence unavailable" on every
suggestion — which is every library, until enough confirmations exist to
fit one. The confirmations are made on this screen, ranked by the number
it was withholding, so the degraded state was also the permanent one.
There has always been a curve: Calibration::default is the reference
implementation's fitted MBF sigmoid, which is what clustering already
operates at. It is a published operating point, not an invention, and
what the requirement forbids is presenting it *as though it were
measured on this library*. So the percentage is shown, and the screen
says once, above the grid, where the curve came from.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sixteen people called Catherine, fourteen of them holding no faces at all, and
her actual photographs split across the two that did. That is not a sync fault;
it is this function, and it has been quietly doing it on every Regroup.
Naming a cluster does not confirm its faces. They stay *suggestions* — and only
confirmed faces anchored here, so the next pass cut them loose, regrouped them
into a brand new person, and left the named one holding nothing.
`prune_empty_unnamed` will not clean that up, because it has a name. Name the new
group the same thing, and it happens again. Repeat over a few sessions and you
have sixteen of her.
A name is a judgement about *this group*, of exactly the kind FR-CULL-12 says
travels and inference does not — the same argument that already anchors a group
the user set aside. So all three kinds of ruling anchor now: confirmed, ignored,
and named.
It also fixes the quieter half of the same fault. A face indexed later that
matches a named person now merges *into* them, rather than arriving as a rival
group the user has to name all over again.
Two tests, and the first fails without the change — it reports "Catherine"
finishing the pass with zero faces while a fresh unnamed person holds the two
she was named for.
This does not retro-fit an existing library: the fourteen empty Catherines stay
until they are merged by hand, and the two holding faces are separate identities
that only the user can say are one person. What it stops is making more.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The tablet showed 442 of a person's 648 faces and could never catch up. Its
shard ledger said why: it had adopted the laptop's shard 0 at 2,711,552 bytes,
and that shard is 20,402,176 bytes on disk.
The upload skipped it. A sealed shard the server already had under that name was
taken to be "byte-identical by construction", so its mere presence was proof
enough and the size was never consulted. That premise is false, and this library
is the counter-example: an *open* shard is uploaded on every pass as it fills —
that is how a growing shard reaches the other devices — so the server ordinarily
holds a partial copy of a shard that is later completed and sealed. Sealing then
froze that partial copy in place for ever.
Nothing downstream could recover from it. The tablet's `has_adopted` check is
keyed on name *and* size and would have re-downloaded a changed shard gladly; it
was never offered one, because the sending side had stopped looking.
So the comparison is on size, sealed or not. The client id is in the name, so
nobody else can have written the file and size is a sound test. A sealed shard
whose size already matches is still skipped on the first comparison, which is
all the original cheapness was worth.
The thumbnail upload had the identical test and the identical hole, and is fixed
with it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`adb shell run-as` refuses on a release build — "package not debuggable" — and
the app's private storage is then unreachable from the host. That storage is
where the face shards, the thumbnail store and the catalog live, so when a
device disagrees with the desktop about what it has synced there is no way to
find out which of them is right. An evening was spent guessing at exactly that.
`DARKROOM_DEBUGGABLE=1 ./docker/android/package.sh --install` now sets
`android:debuggable` through aapt2's `--debug-mode`, and nothing else changes.
Set through aapt2 rather than written into `AndroidManifest.xml` on purpose: the
flag then exists only for the build that asked for it, and a release build
cannot inherit it because somebody forgot to take it out again. A debuggable APK
lets any process on the device read this app's files, so it belongs on a test
tablet and nowhere else.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A tablet showed 200 of a person's 611 faces after syncing, and the obvious
suspects — that suggestions deliberately do not travel, that the cross-device
face match was too strict — were both wrong. Finding that out meant reading a
catalog on a release-signed Android build, which cannot be done.
So this stands the second device up locally: an empty catalog, given the images
a scan would have found, the shards adopted into it exactly as a sync does, and
the real catalog merged in as the remote. Then it counts, per person, against
what the source holds.
person source here
Catherine 611 611
Me 242 242
Ian 219 219
Which settled it: the merge carries everything, and the shortfall was transfer —
shards that never finished arriving. Worth keeping, because "did the sync lose
this or has it not got here yet" is a question that will come up again, and
guessing at it cost most of an evening.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three faults between a laptop that had indexed a library and a tablet that only
ever received part of it.
The face store was the one part of the catalog still on the rollback journal at
synchronous=FULL — 21.3 ms a commit against the 0.05 ms everything else pays,
four commits per photograph, on the order of fourteen minutes of pure fsync for
ten thousand images. It now writes the way the catalog and the thumbnail store
do, with a checkpoint before upload so the file that goes to the server is
complete without its write-ahead log.
Every idle pass read 94 MB of shards to decide it had nothing to send; the name
and the size answer that.
And the 60-second total request timeout was a floor on link speed rather than a
hang detector: a 25 MB shard needed a sustained 425 KB/s or it failed, and then
retried and failed again indefinitely. It now times out on inactivity.
The merge itself was never at fault — a probe that stands up an empty catalog,
adopts the shards and merges the real one in gets all 611 of a person's faces,
not 200.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The HTTP client had a 60-second *total* request timeout. That is not a hang
detector; it is a floor on link speed. A face shard runs to 25 MB, so it
demanded a sustained 425 KB/s or the transfer failed — and having failed it was
retried on the next pass and failed again, for ever.
A tablet on ordinary wifi could therefore never finish taking in a library's
faces, and nothing said why: each attempt looked like a network blip rather than
an arithmetic impossibility. The catalog snapshot is 36 MB and has the same
problem.
`read_timeout` fires when no bytes arrive for the period, which is the condition
actually worth failing on. A slow transfer that is still moving now finishes,
however long it takes; a connection that has genuinely died is still caught in a
minute. The connect timeout is unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The upload read every shard off disk and only then asked whether it needed
sending. For a library with 94 MB of face shards already on the server, every
idle sync read 94 MB to conclude it had nothing to do.
The question does not need the bytes. A sealed shard the server already has is
byte-identical by construction, and the client id is in the name, so nobody else
could have written it — the name settles it. The open shard is compared on size,
which `stat` answers.
The progress line moves after the skip for the same reason: announced before it,
an idle pass claimed to be sending five shards and sent none.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The face store was the one part of the catalog still on SQLite's default
rollback journal at `synchronous = FULL`. The catalog itself runs WAL at
`NORMAL` (`schema::configure`) and so does the thumbnail store; nothing decided
this one should differ, it was simply never set.
Measured on this project's own filesystem, that is **21.3 ms per commit against
0.05 ms** — four hundred times. And an export commits four times per
photograph: the shard's transaction, then three separate autocommitting writes
to the index. Ten thousand images is on the order of fourteen minutes spent
doing nothing but waiting for fsync, before a byte goes to the server. That is
the "checking faces…" that appeared to hang.
So: WAL and `synchronous = NORMAL`, matching the rest, and the three index
writes fold into one transaction. `NORMAL` is the same trade the catalog makes —
a shard is derived data, and losing the last commit to a power cut costs one
image re-exported.
WAL brings an obligation with it, because **a shard is uploaded by reading its
file**: the newest commits live in a `-wal` sidecar that no upload sends, so
without a checkpoint the server would receive a database missing exactly the
faces just written, and a peer would adopt it and see nothing wrong. `checkpoint`
folds the logs back in, with `TRUNCATE` rather than the default passive mode,
which gives up when a reader holds the log and would leave the same gap while
reporting success.
Two tests: that the store is in WAL like everything else, and — the one that
matters — that a checkpointed shard copied *without* its `-wal` still holds
every face. That second one fails without the checkpoint, which is how it was
confirmed to be testing something.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every push has failed the Android job for weeks — back through fourteen runs —
with "FAIL: linked for Android 'unknown', expected 28", on a .so that was linked
perfectly correctly at API 28 the whole time.
The check ran `file` on the linked object and pulled the level out of its
description with a regex. `file` only prints "for Android 28" when its magic
database is new enough to decode `.note.android.ident`, and this image's is not
— it stops at "dynamically linked, not stripped". The regex matched nothing, and
`${API:-unknown}` turned that silence into a confident-looking failure about the
artefact rather than about the tool inspecting it.
The API level is the first word of that note, little-endian, so it is read
straight out of the ELF with `readelf` — which is in the image via
build-essential, and cannot go out of date the way a magic database can. An
absent note is now its own message rather than being folded into the mismatch
case, since "nothing states an API level" and "states the wrong one" are
different faults.
`file` stays in the image and in the log: it names the NDK that built the
object, which is worth having when this does go wrong. Nothing depends on it.
Verified inside the real container against the linked .so: the note reads
1c 00 00 00, and the step prints "OK: linked for Android 28".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A hundred commits since 0.7.0, and the face subsystem in them is not a set of
fixes — it is the difference between a feature that was shipped and one that
works.
Clustering finished at all for the first time: the old agglomeration rescanned
every live pair and recomputed average link from scratch after every merge, and
on a real library it did not return. A sparse above-threshold graph, connected
components and Lance-Williams sums put 1,813 faces at 0.28s, and the merge
threshold moved to 0.80 because the old 0.90 was measured — not guessed — to
leave a third of the library ungrouped.
Faces are no longer indexed at any size or any sharpness. Both floors were
measured over the real library with `face_index --quality`: 32 source pixels
across the aligned crop, and a contrast-invariant sharpness that catches the
large-but-blurred face whose confident, wrong embedding used to weld two people
together.
The crop is cut once and kept, so the People screen is no longer a derivative of
a thumbnail cache entitled to evict anything at any moment. The screen itself
became usable: the faces wrap into a grid instead of running off the edge, the
header fits a phone, a group can be set aside, and a person's photographs are a
button away — as a union or an intersection of several people.
And face sync now reaches the other device. Re-indexed images re-export, shards
written before the crop and index-time columns are repaired rather than failing
every insert, people and the user's judgements about them cross the wire at all,
and the pass says what it is doing while it does it.
The Android versionCode follows without being restated — package.sh packs
MAJOR*10000 + MINOR*100 + PATCH, so 0.8.0 is 800, above the 700 already on
devices and therefore an upgrade rather than a refusal. `pkgrel` returns to 1,
since this is a new version rather than a rebuild of the last one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The face pass announced itself once and then went quiet for the whole export,
upload and adopt. On a first export after a re-index that is eight minutes of a
motionless progress bar, which reads as a hang. It now reports the images it is
preparing, the shards it is sending and their size, and the shards it is taking
in.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The face pass set the status to "checking faces…" once and then said nothing
until it was finished. On a library whose first export after a re-index is 9,849
photographs and 85 MB of shards, that is eight minutes of a progress bar sitting
still — which is indistinguishable from a hang, and was reported as one twice.
Nothing was wrong with the sync. The only fault was that it was silent.
Three places now report, which are the three that take real time:
- **Preparing**, per image with a count, since this is the long one and the only
one whose length the user cannot guess from anything on screen.
- **Sending**, per shard with its size, because a face shard carries crops and
runs to tens of megabytes — one of them is a visible wait on any connection.
Announced before the upload rather than after, since the wait *is* the upload.
- **Taking in** a peer's shard, which is a download and then a row-by-row merge.
The export reports every 25 images rather than every one, so the channel behind
it stays lost in the write it accompanies. `export_to_shards` keeps its old
signature and delegates, so the callers that do not want progress do not grow a
parameter for it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The export asked whether each image was already in the shard store at its
current index time, and answered by opening the shard database — schema batch,
pragma probes and all — once per photograph. 9,849 opens per sync pass for this
library, before any face was written, which showed up as a sync stuck on
"checking faces…" and never coming back.
The index time moves to the store's own index, which is already open, and the
write handle is held across a run instead of reopened per image.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Checking faces…" never finished. `export_to_shards` asks, for every indexed
image in the library, whether the shard store already holds that image at that
index time — and `indexed_at` answered by opening the shard database, running
its six-statement schema batch and two `pragma_table_info` queries, then
querying. Once per image. 9,849 times for this library, on every sync pass,
before a single face had been written.
The index time now lives in the store's `index.sqlite` alongside the shard
number, so the question is one indexed lookup on a connection that is already
open. It stays in the shard as well — that copy is the one that travels — but
nothing reads it from there on the hot path.
The write side had the same shape: `put_image` opened the shard afresh for each
image, which mattered little when exports were a handful of new photographs and
matters a great deal now that a re-index sends thousands. The handle is kept and
reused, invalidated by shard id so sealing a full one and moving to the next
drops it without anything having to remember to.
`INDEX_SCHEMA` is `CREATE ... IF NOT EXISTS` like the shard schema, so the new
column is added on open for an index already on disk — the same trap, caught the
same way.
Two tests: that the index time survives reopening the store, and that an index
written before the column can still be opened and written to.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The schema batch is all CREATE IF NOT EXISTS, so two columns added to it never
reached any shard already on disk — and every export into one failed on "no
such column", quietly, logged at warn while the sync reported success. That,
rather than anything in the export logic, is why this library's shard stayed at
1,807 faces while the catalog reached 15,194.
Shards are now upgraded when opened, and a peer's read-only shard is read as it
stands.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`SHARD_SCHEMA` is entirely `CREATE ... IF NOT EXISTS`, which does exactly
nothing to a table that already exists. So `crop` and `indexed_at`, both added
to that batch, never appeared in any shard that had been written before — and
the `INSERT` naming them failed with "no such column".
Which took face export down completely, on every library that had ever synced a
face. Silently: `export_to_shards` returns the error, `sync_face_shards` logs it
at warn, and the sync goes on looking successful while the catalog fills with
faces no other device will ever see. This library's shard sat frozen at 1,807
faces with 15,194 in the catalog, and the reason was this rather than anything
in the export logic.
Shards are upgraded on open now: both columns are additive and nullable, so
catching up is one `ALTER` each. There is deliberately no version counter —
"does this column exist" is the question actually being asked, and asking it
directly cannot fall out of step the way a counter can.
A peer's shard is opened read-only and cannot be repaired, so one written before
crops is read as it stands, with a `NULL` standing in for the column. An adopted
face simply has no crop, which is the truth about it.
Four tests, built against the pre-crop schema written out in full rather than
derived from the current one — the point being that it is *not* the current
schema and must not track it. Verified against the real 1,807-face shard on this
machine: the ALTERs apply, writes succeed, and nothing already in it is lost.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two independent faults, both of which had to be fixed before a tablet could show
what a laptop had indexed.
An image was exported to the face shards exactly once, so a re-index updated the
catalog and nothing else — this library went from 1,807 faces to 15,194 and kept
syncing the original 1,807.
And people never crossed a device boundary at all: the catalog merge handled
collections and keywords only, so the far end received every face and no groups,
and drew an empty People screen over a full catalog. Names, confirmations,
rejections and set-aside groups now merge by uuid, with faces matched across
devices by photograph and box overlap.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The face shards carry boxes, landmarks and embeddings. What they deliberately
do not carry is who anybody **is** — the person rows, their names, and the
assignments joining the two. Those travel in the catalog snapshot, which is a
whole-file copy and does contain them.
But the snapshot is *merged*, not adopted, and this merge only ever looked at
collections and keywords. `face_shard`'s own module note says people travel in
the snapshot; nothing implemented it. So a second device received every face and
no people at all, and drew an empty People screen over a full catalog. Exactly
what a tablet showed after syncing thousands of faces from a laptop.
What travels is what the user decided, following the rule the rest of this
module already follows — judgements travel, inference is rebuilt:
- **People**, by uuid on `revision`, exactly as a collection is: the name, and
whether the group was set aside.
- **Confirmations**, and **rejections** — "this is not her" is a fact too, and
is why re-clustering does not put it back.
- **The suggestions inside an ignored group**, which are otherwise ordinary
inference but are what anchors the ignore. Without them a group set aside on
one device reappears on the other, the same fault that made "Not interested"
not stick locally.
Ordinary suggestions are not carried. Both devices hold the same embeddings and
clustering is deterministic, so each recomputes them and arrives at the same
answer; shipping them would double the merge for no new information.
**A face has no cross-device identity**, and unlike a collection there is no
uuid to give it one. Both devices do agree on `oc:fileid` and roughly on the
box, so a remote face is matched to the local face on the same photograph whose
box overlaps it most, above 0.5 IoU. That is not a new rule — it is the one
`record_detections` already uses to carry a confirmation across a re-index, and
it is loose on purpose: the question is "the same face in the frame", not "the
same rectangle".
A local confirmation is never overwritten. Two devices confirming one face as
different people is a real disagreement and an assignment carries no revision to
settle it with; taking the remote's answer would let a sync undo what the user
just did on the device in their hands.
The remote's schema is probed rather than assumed: `remote_is_mergeable` admits
any catalog at or below this version, so one written before faces existed, or
before V10 added `ignored`, is ordinary. An absent table skips this half instead
of aborting a merge that would otherwise have succeeded.
Nine tests, including that the name lands on the overlapping face and not its
neighbour in the same frame, that a set-aside group stays set aside, that an
ordinary suggestion does not travel, and that merging twice changes nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`export_to_shards` asked `store.contains(file_id)` and skipped anything the
shard store had already heard of. So an image was exported exactly once, and
re-indexing it updated the catalog and nothing else — every other device kept
the first answer for ever.
That is not hypothetical. This library was re-indexed after the detection floors
changed and crops were added, going from 1,807 faces to 15,194; the shard store
still held the original 1,807, written before any of it. Nothing the re-index
produced could reach another device.
The shard's `indexed` table now carries the catalog's own `indexed_at`, and the
export compares against it. A re-indexed image goes again; an unchanged one
still costs nothing. Copied from the catalog rather than stamped when the shard
is written, because a shard-local write time advances even when nothing changed
and could not answer the question.
The column is nullable so a shard written before it still reads: absent means
"cannot vouch for it", which forces one re-export and then settles.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The size floor comes down to 32 source pixels with the blur floor moved to match
— they are coupled, since an upsampled face scores low on sharpness whatever its
original quality, and dropping one without the other would have undone itself.
The Identity header stops pinning its rows shorter than the controls in them, so
the name field no longer draws through the buttons below it.
And a group set aside now survives Regroup: its faces anchor the way
confirmations do, so they stay where the user put them instead of regrouping
into a fresh person with no ignore flag.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Not interested" hid a group, and the next Regroup brought it straight back.
Reclustering anchors the faces the user has ruled on so a pass cannot move them.
It took *confirmations* as the only kind of ruling — but setting a group aside is
a ruling too, and the faces it covers are only ever suggestions. So an ignored
group's faces entered clustering loose, regrouped into a fresh person that
carried no ignore flag, and reappeared in the rail. The original group was left
behind holding nothing, hidden and empty.
Anchoring them on `ignored` as well as on `confirmed` fixes it, and does one
better: a face indexed later that matches a group which was set aside now merges
*into* it, so a stranger photographed again stays set aside instead of arriving
as somebody new. That is the case that would otherwise have made the feature
feel like it only half worked.
Three tests, and the first fails without the change — it reports the group
coming back with its two faces while the original sits ignored and empty.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Identity header was two rows pinned to 32px and 36px. Neither number was big
enough for what the row held: a `Field` is `Theme.touch-target` — 44px — and a
`Button` is `Theme.control-height`.
Slint honours a child's own height and lets it overflow the box the layout gave
it, so the name field drew 44px from the top of a 32px row while the button
strip began at 38px. The overlap was 6px of text box sitting on top of "Confirm
all".
Neither row states a height any more. The first takes the height of what is in
it, and the strip takes the height of a control — read from the theme rather
than from the row inside it, since `actions` sizes itself from the Flickable's
viewport and measuring it back would be a binding loop.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
64 source pixels was too strict: it threw away 70% of everything the detector
finds, and plenty of what it took were faces a person could name.
Lowering it is not a one-line change, because the two floors are coupled. A face
under 112 pixels is *upsampled* to reach the embedder and upsampling invents no
edges, so a small face scores low on sharpness however crisp the original was.
Re-measured over the reference library with `face_index --quality`:
min crop min sharp size cut blur cut kept
32 0.000 44% 0% 56%
32 0.002 44% 3% 53%
32 0.005 44% 8% 48%
32 0.010 44% 16% 40%
32 0.020 44% 27% 30%
64 0.020 70% 7% 23%
Holding the blur floor at 0.020 while dropping the size floor to 32 would have
rejected a further 27% — for being small rather than for being blurred — and
kept only 30%, barely more than the 23% the strict pair kept. Most of the point
of lowering the size floor would have gone straight back out through the other
gate.
0.005 removes 8% of what the size floor leaves, which is the same job 0.020 was
doing at 64 (7%): the large-but-soft face this gate exists for. Together they
now keep 48% of what the detector finds, against 23% before.
The box pre-filter follows down to 24, staying below what the real floor accepts
so it cannot reject a face that would have cleared 32.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two things the People screen was missing.
Faces were being indexed at any size and any sharpness — 40 pixels on the box
and no blur gate at all — so most of what the library held was background
strangers and motion-blurred passers-by, and the blurred ones were quietly
bridging unrelated clusters. Both floors are now measured on the real library
with `face_index --quality` rather than guessed: 64 source pixels across the
aligned crop, and a contrast-invariant sharpness of 0.020.
And the grid could only ever be narrowed to one person, which cannot express
"the pictures the two of them are in together". The filter now holds a set with
a union/intersection mode, built a person at a time from the Identity screen and
taken apart chip by chip on the filter bar.
fmt, clippy -D warnings and the full workspace suite pass on the merge.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Show photos" could only ever mean one person. The two questions a photographer
actually asks are "every picture of Anna or Bob" and "the pictures they are both
in", and the second is not reachable by any sequence of single-person filters —
no amount of switching between one person and another finds the frame they
share.
So the filter holds a *set* of people and a mode. `RatingFilter` was already the
right home, as its own doc says: every query path threads it, so the count in
the header and the cells in the grid are narrowed by the same thing, and this
composes with stars, flags and the date range for free.
The union is `EXISTS ... person_id IN (...)`. The intersection counts
**distinct** people per image and compares against the size of the selection —
one subquery rather than one per person, and it does not grow the statement with
the selection. `DISTINCT` is what makes it correct: three faces of Anna in one
frame must not satisfy a filter asking for Anna and Bob, and there is a test
that says so.
Any rather than All is the default. With one person the modes are the same
filter, and adding a second to a union can only ever show more — so a user who
has not noticed the toggle never ends up staring at an empty grid wondering what
they broke. The toggle only appears at two people, because a control that
demonstrably does nothing is a control that teaches the user to ignore it.
Building the set needs no picker of its own: the Identity screen gains "And
also…" beside "Show photos", offered only once the grid is already narrowed to
somebody. Each person is a chip on the filter bar and each chip removes just
that person, so a selection of three can be taken apart one at a time rather
than only cleared wholesale.
`RatingFilter` stops being `Copy`, since it now holds a `Vec`. Every query path
already took it by reference; the casualties were two struct updates and one
`Cell` that becomes a `RefCell`.
484 dr-ui tests pass, including the union, the intersection, that one person
reads the same in both modes, and the repeated-faces trap.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The library was storing faces at 52 source pixels and embedding whatever came
back. There was a size floor, but it was 40 pixels on the *bounding box*, and
there was no blur gate at all — so a subject walking through a half-second
exposure detected confidently, aligned cleanly, and produced a perfectly
ordinary-looking 512-vector. Nothing downstream can tell that apart from a real
face, and because blurs resemble each other more than they resemble the people
they were, they cluster together and weld unrelated identities into one group.
Two floors, both measured rather than guessed. `face_index --quality` runs the
detector over real proxies with both gates disabled and prints the distribution;
over 1,503 faces in 600 images of the reference library:
percentile crop px sharpness
1% 16 0.0006
25% 23 0.0025
50% 38 0.0071
75% 76 0.0284
99% 352 0.4282
The median face in a personal library is 38 pixels. Most of what the detector
finds is background: people across a square, a face on a poster, a stranger at
the next table. They are real detections and useless identifications.
**Size, on the crop rather than the box.** "At least 64x64" has to mean the
pixels the *embedder* sees, and the box is not that — the ArcFace template
reaches past it for forehead and chin, so the aligned crop spans roughly 1.3x
the box's shorter edge. The floor is therefore `min_source_px` on the aligned
crop, applied after the warp fixes the scale, and `min_face_px` drops to 48 as
what it always really was: a cheap pre-filter set low enough that it cannot
reject a face the real floor would have kept.
**Sharpness.** Variance of the Laplacian divided by the variance of the luma it
was taken over. The division is the part that matters: raw Laplacian variance
scales with contrast, so a threshold on it would quietly discard every backlit
portrait in the library. The ratio asks how much of the crop's variation is
edges rather than broad gradients, and is invariant to exposure.
What each pair removes, cumulatively, of everything the detector finds:
min crop min sharp size cut blur cut kept
64 0.000 70% 0% 30%
64 0.010 70% 3% 27%
64 0.020 70% 7% 23%
80 0.010 76% 2% 21%
64 and 0.020. The size floor does most of the work, and the blur floor removing
only 7% on top of it is the point rather than a disappointment: at 64 pixels
most faces are already sharp, and what it takes out is the large-but-soft one —
precisely the face that would otherwise contribute a confident, wrong embedding.
The two gates are not independent and the doc comments say so: a face under 112
pixels was upsampled to reach the embedder, and upsampling invents no edges, so
small faces score low on sharpness even when the original was crisp. That is why
`--quality` prints them together.
**This will re-index.** Around 70% of what the current settings store falls below
the new floors — faces between 20 and 40 pixels that nobody could identify. The
People screen gets shorter and every group in it gets better.
66 dr-face tests pass, including that a blurred crop scores below a sharp one,
that halving the contrast does not move the score, and that an upsampled face
scores below the same face at full size.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The crop rectangle, the gradient handles and the repair discs were 480
lines inside `canvas-area` in app.slint, while the panels that drive them
already lived in adjust.slint, masks.slint and spots.slint. spots.slint
even opens by describing "what is drawn over the photograph, and what a
finger can take hold of" -- which was not in it.
They stayed behind because all three are positioned against `shown-*`,
the fitted image rect the develop view derives because Slint does not
report it. That is now the interface rather than the obstacle: each
overlay is *given* that rect as its own bounds, so every position inside
is a plain fraction of `root.width`, and none of them reaches out to
`canvas-area` for an origin any more.
GradientHandles joins MaskPanel in masks.slint and SpotHandles joins
SpotPanel in spots.slint, each file now holding one domain's panel and
its canvas overlay together, matching masks_ui.rs and spots_ui.rs.
CropOverlay gets crop.slint of its own rather than growing adjust.slint.
Arithmetic is unchanged: the old fraction-x subtracted shown-x from a
coordinate measured relative to canvas-area, and the new one measures
from an origin that already is shown-x. The handles stay unconditional
rather than gaining an emptiness guard, so the repeater identity that
spots_ui::sync_handles warns about is untouched.
app.slint: 2834 -> 2490 lines. Verified by running the desktop app on a
photograph, with the crop overlay's guard temporarily forced open so all
three instantiate -- no binding loop, no panic.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
StatusBar and InfoPanel sat above AppWindow in app.slint, which read as
though they were part of the application shell. They are not: neither is
instantiated anywhere but the develop view, and the shell's actual job --
choosing which of the five screens is up -- is easier to follow without
two unrelated components standing in front of it.
Moved verbatim to develop.slint, matching src/develop.rs. No behaviour
change; app.slint loses 232 lines and gains one import.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The column was 280px, a number chosen for a tablet, with 380px bolted on
later for a desktop. Both were guesses at how much room the widest row
inside needs, and a guess is what cannot work here: the mode strip is one
chip per attribute the *operation set declares*, so the row is generated
and no constant in app.slint can track it.
When the guess came up short the failure was not a tidy clip. The
Flickable inside the column never had its `viewport-width` set, so the
viewport took its content's preferred width, and a viewport wider than
its Flickable is *centred* in it — the same rule the note on the seam's
`x: 0` already records a few lines below. So the column lost half of each
edge rather than one of them: "HISTOGRAM" read "ISTOGRAM", "Straighten"
read "aighten", Copy sat centred while Paste ran off the far side. It
looked like a rendering fault and it was an alignment one.
So the column asks instead of guessing. Every panel that can appear in it
— image, histogram, geometry, settings transfer, masks, repairs, adjust,
history — now publishes a `content-width`: how wide it has to be before
it starts clipping itself, read off its own layout rather than asserted.
Each declares that as its `min-width` too, and that is what makes the
aggregation automatic: `column` is a layout, so it already reports the
largest minimum among its children, and it does so for the panels that
come and go with the mode as well, which live inside `if`s and cannot be
named from outside. Grep `content-width` in ui/dr-ui/ui to see every
panel with a say in the answer. The mode strip is named explicitly only
because it is pinned outside that layout, so nothing else measures it.
There is no floor left. A floor is one more guess and the panels state
their own minimums now. The only thing still above the measurement is
`panel-max-width`, which is not a size but a policy — a column may not
take the window from the photograph it exists to serve — and it comes
from Rust beside `layout-class` because a width read from `root.width`
inside the layout that `root.width` depends on is a binding loop.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five faults, all on one screen, and the Slint and Rust halves of each have to
land together.
**Regroup froze the window.** It ran inside the Slint callback, on the UI
thread. It is much faster now, but fast is not bounded — the work grows with the
library, and the one thing that must not grow with the library is how long the
window stops answering. It runs on a worker thread with an mpsc channel and a
250ms poll, like every other long pass in this module, and the button says what
it is doing instead of the window going quiet. Cancellation is dropping the
receiver. Reclustering also prunes the empty groups the previous pass left, so
pressing the button twice no longer fills the rail with "Unnamed (0 faces)".
**The faces were a single row running off the screen.** The comment on the
layout claimed to be a wrapping row; Slint has no flow layout and a
HorizontalLayout does not wrap, so a person with forty faces was a person whose
faces could not be reviewed past the fifth. It is now laid out the way the
library grid lays out thumbnails, with the same arithmetic: choose how many
columns of roughly the requested size fit, then divide the width between them so
the cells fill the row exactly and nothing overhangs.
**The header did not fit a phone.** A 240px name field beside five buttons is
wider than an Android screen — and worse than not fitting, a layout cannot be
narrower than its children's minimums, so the row reported that oversized
minimum upwards and inflated the whole screen. The faces grid is its sibling, so
it would have been measured against a width that was never on the display. The
header is now two rows, the actions sit in a Flickable that scrolls rather than
overflowing, and the rail narrows to 132px on the compact class.
**Strangers crowded out the people who matter.** Most clusters in a real library
are passers-by and other people's guests. "Not interested" sets a group aside;
the rail hides it and says how many are hidden, with one button to bring them
back. Reversible, and never a deletion — see the catalog commit for why.
**A face was a dead end.** Identifying someone and then having no way to see
their photographs is a filing cabinet with no drawer handles. "Show photos"
narrows the library grid to that person and leaves a chip on the filter bar
saying so, which is also how it is cleared. It is a term on `RatingFilter`
rather than a grid scope of its own, exactly as that struct's own doc says new
narrowing terms should be — so the count and the cells are narrowed by the same
thing, and it composes with the others for free. Suggested faces count, not only
confirmed ones, or a freshly grouped person would show an empty grid.
Crops are read from where they are now stored, falling back to cutting one out
of the proxy for faces indexed before that existed.
480 tests pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A face was drawn by decoding the 1024px proxy it was found on and cutting the
box out again, every time the People screen opened. That made the screen a
derivative of the thumbnail cache: evict a proxy — which the cache may do at any
moment — and the cell goes blank, with no way back short of re-fetching the
original over the network and re-detecting it. It also cost a full JPEG decode
per image, per visit, to show a 96px cell.
So the crop is cut once, when the pixels are already in hand at detection time,
and kept. A 160px JPEG is a few KB against the ~250 KB proxy it replaces reading.
Where it lives is the interesting part. The catalog snapshot is uploaded *whole*
on every sync and downloaded by every device, so a crop column there would put
tens of MB on every round trip — the exact cost `face_shard`'s 25 MB cap exists
to bound, and the reason bulk per-face data lives in shards already. Crops
therefore travel in the face shards, beside the embeddings, and
`snapshot_for_upload` strips them from the copy it writes. Nothing reads a crop
out of a merged remote catalog — the merge touches collections and keywords only
— so a receiving device loses nothing. A shard carrying crops holds around 3,500
faces rather than 22,000, which is the price of a second device showing People
immediately instead of re-fetching every proxy.
The column is nullable and the reader falls back to the proxy, so a face indexed
before this still works and the next indexing pass fills it in.
V10 also adds `people.ignored`, for a person the user has looked at and does not
want to identify. Most clusters in a real library are strangers — passers-by,
other people's guests, a face on a poster — and there is no way to tell "not yet
looked at" from "looked at, don't care" without recording the second. It is a
column rather than a deletion because a deleted cluster comes straight back on
the next Regroup: the faces are still there and still similar, and nothing short
of remembering the judgement survives re-clustering. Same argument
`face_person_rejected` makes one level down.
And `prune_empty_unnamed`, for what clustering leaves behind. Regroup creates a
person per unanchored group and never removed the previous run's now-empty ones,
so pressing it twice added a rail entry per group it no longer believed in.
Named people are never touched however empty — a name is user data — nor is a
merge tombstone, which must outlive its faces to keep redirecting.
298 tests pass, including that the snapshot carries no crops while the live
catalog keeps them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
0.90 left a third of the reference library ungrouped: 1,213 of 1,813 faces in a
group, and the rest sitting alone in a screen that had nothing to offer for
them.
"Is 0.90 too tight" is not answerable from the number. It is a probability, and
which cosine it lands on depends on the calibration — so the first half of this
is a way to ask the question properly. `face_index --tune` runs the real
clusterer over the real embeddings at ten thresholds and prints what each one
produces. It writes nothing; comparing thresholds by applying them would have
each one pollute the next.
On the reference library:
P cosine groups grouped largest
0.95 0.449 311 62% 51
0.90 0.403 316 67% 51
0.85 0.374 318 70% 57
0.80 0.353 328 74% 69
0.75 0.335 327 77% 69
0.70 0.319 326 79% 81
0.50 0.267 303 85% 90
The count of *groups* is the signal, not the count of grouped faces. Loosening
from 0.95 makes it climb: real people are being assembled out of fragments. It
peaks at 0.80 and then falls — and a falling group count while the grouped faces
keep rising is the shape of over-merging, separate identities being welded
together. That is the FR-CULL-10 failure, and the one the user cannot undo by
hand.
So 0.80: the loosest setting still building people rather than melting them
together. A third more of the library gets grouped than at 0.90, and the largest
group grows by eighteen faces rather than by forty.
The table is one library, and the doc comment says so — `--tune` reruns it on
any other.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pressing Regroup on a real library did not come back. Clustering 1,813 faces is
the textbook agglomeration — compute every pairwise cosine, then repeatedly scan
all live group pairs, score each with average link, and merge the best — and the
scan is inside the loop. Each merge rescans every surviving pair, and each score
is recomputed from scratch over every cross pair. Some 1.6 million pair scores
per merge, some 700 merges to do.
Three changes, none of which alter the answer.
Only above-threshold pairs can ever matter. An average that reaches the
threshold must have at least one term at or above it, so two groups with no
qualifying pair between them can never merge — not now, and not after any
sequence of merges, since merging only adds terms. The new `neighbours` module
produces exactly that sparse list: 7,875 pairs rather than 1.6 million on the
reference library. It also means the n^2 matrix is never materialised, so memory
goes from O(n^2) to O(edges) — 2.5 GB to a few hundred KB at 25,000 faces.
Merges cannot cross components, so the connected components of that graph are
independent problems: four hundred small agglomerations instead of one large one.
Average link is additive — sum(A u B, C) = sum(A, C) + sum(B, C) — so a merged
group's scores follow by addition. Kept as running (sum, count) per adjacent
pair, a score costs one division instead of a nested loop, and a heap with lazy
invalidation replaces the rescan.
Measured on the reference library: 0.28s, release, for all 1,813 faces.
An exact ANN index was tried and removed, and neighbours.rs records why so it is
not rediscovered as a good idea. IVF with a triangle-inequality bound is exact
and prunes beautifully on synthetic clusters; on real embeddings it prunes
*nothing* — 946 of 946 cell pairs survive. Median pair angle is 88.5 degrees and
the merge threshold is 66.2, so the bound needs cells of radius under ~10
degrees, but two photographs of the same person sit 36-60 degrees apart. No
ball-based partition of a 512-d near-orthogonal space can be tight enough. So
the scan stayed exhaustive and got an unrolled dot product and its blocks spread
across cores instead.
Correctness is held by keeping the old implementation as an oracle: three tests
run both engines over the same population — plain, under co-occurrence and
anchor constraints, and with a size-weighted calibration — and assert the
clusters are identical. Determinism is asserted at a size where the threaded
path is in play.
62 tests pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Master moved 24 commits while this branch was building the face sweep, and one
of them changed how the interface reaches a server: `remote.rs` is now the only
file that names a connector, and everything else takes `&dyn RemoteBackend`.
The face sweep was written before that landed and built a `NextcloudBackend`
directly. Git merged the text without complaint and the result did not compile,
which is the useful kind of conflict — it goes through `remote::connect` now,
like every other pass.
Two documentation conflicts, both resolved toward master. `code-health.md` was
an add/add: master's copy carries the CH resolution for the backend seam and a
better provenance note, so it wins outright, with its measured figures re-taken
against the merged tree rather than either side's — `run()` is 1,855 lines now,
2,042 tests, 793 traceability tags. `traceability.md` is generated, so it was
regenerated rather than hand-merged.
The seam grades in code-health.md are unchanged by this merge. That is worth
noticing rather than glossing: the face work went into the seams that already
existed — `run()`, `library.rs`, `AppWindow` — which is exactly the pressure
CH-1 describes rather than evidence against it.
All four CI jobs pass: desktop (fmt, clippy, test, build), layering, traceability,
and the Android cross-build.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Master moved sixteen commits while these fixes were being written — per-display
colour, the frame-budget measurement that decides FR-DSP-2, and a declared
node running without being compiled. Merged here rather than on master so the
conflicts are resolved where they can be tested.
Two files overlapped and neither was interesting. `lib.rs` gained `mod remote`
from this branch and `mod display_ui` from master, which git resolved on its
own. `docs/traceability.md` is generated, so it was regenerated from the merged
tree rather than hand-resolved — hand-editing a generated matrix produces one
that agrees with neither side. Coverage reads 59.9% (106/177), up from 55.4%,
entirely from master's tagging.
The check worth having run is `the_interface_names_no_operation` against
master's new `display_ui.rs` and its 195 changed lines of `develop.rs`: a new
UI module written without knowledge of this gate passes it. That is the
evidence the gate is not merely satisfiable by the code that shipped with it.
fmt clean, clippy clean at -D warnings, 2087 tests pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The repair added in 051447b was appended to the work list:
wanted.extend(repair);
Behind every un-indexed image in the library. On this library that is position
23,000-odd — roughly two hours of fetching before the first repair is reached,
which inside one session is indistinguishable from the feature not existing.
The log said it had found them and the screen stayed empty, which is the worst
combination of the two.
They go first now. There are a few hundred of them against tens of thousands of
un-indexed images, and they are precisely the images the People screen is
failing to draw at this moment — so the ordering costs nothing and is the
difference between the grid filling in within a minute and not filling in at
all.
A failure to build the un-indexed half no longer discards the repairs either:
the pass runs with whatever it has rather than returning empty.
Verified on the live catalog: 455 images hold faces, 251 already have a proxy
from the fixed sweep, and the remaining 204 are what now sits at the front of
the queue. The stored proxies check out — a valid JPEG at the large class,
49 KB — so the storing half of 051447b was already working.
471 tests pass, including one that the orphan is ordered ahead of the library.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`dr-sync` defines `RemoteBackend` and a capability model the engine adapts
to, so a second backend can be added without touching the code that uses
one. That boundary was documentation. Seven files in `dr-ui` constructed a
`NextcloudBackend` directly, ten functions took one by concrete type, and
exactly two call sites in the tree — both inside `dr-sync` itself — ever held
the trait object. A WebDAV or local-folder backend would have had a
well-written trait to implement and nowhere to go afterwards.
The change is smaller than the finding suggests, because the trait was
already right. Every method the UI has ever called on a backend — `get`,
`put`, `list`, `delete`, `create_dir`, `move_to` — was already on it, so
nothing had to be added and no behaviour moved. Ten signatures widened to
`&dyn RemoteBackend`, sixteen constructions became `remote::connect`, and
`remote.rs` is now the only file in the interface that names a connector.
`connect` returns `Result<Box<dyn RemoteBackend>, RemoteError>`. The error
type is `dr-sync`'s rather than the connector's, which is why every call site
kept its shape — the `match`, the `let Ok(..) else`, and
`.map_err(ScanFailure::local)?` all still read as they did.
One wrinkle worth recording: `&Box<dyn Trait>` does not reach `&dyn Trait` on
its own. The compiler reaches for unsizing, which wants
`Box<dyn RemoteBackend>: RemoteBackend`, and reports a confusing missing impl
rather than suggesting a deref. Twelve call sites therefore say `&*backend`,
and two say `let backend: &dyn RemoteBackend = &*backend` where a borrow is
shared across lanes.
What this does *not* do is abstract credentials. `AppCredentials` is an app
password from Login Flow v2 — a Nextcloud protocol, not a general notion of
authenticating to a remote — and seven files still name it. An OAuth token, a
bucket key pair and an app password have no useful common shape, so deciding
what an account is across backends before a second one exists would be a
confident guess. code-health.md CH-2 now records that as the remaining half,
and it should wait for the backend that forces it.
Verified: fmt clean, clippy clean at -D warnings, 2041 tests pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every cell on the People screen read "no preview" while the sweep was happily
reporting 893 faces found. Both were true. Faces were being detected and stored
correctly; there was simply nothing left to draw them from.
A face is stored normalised and drawn by cropping the proxy it was found on —
`identity::decode_proxy` reads `FACE_TIER` out of the thumbnail store. The
fetching sweep fetched a preview, detected on it, wrote the faces and dropped
the pixels. So every face it found pointed at a proxy that had never been
stored, and the grid had nothing to cut.
Worse, that state could not repair itself: the image has its `face_index` row,
so it is not outstanding work and no later pass would look at it again.
Two fixes, and the first is nearly free. The sweep now keeps the proxy — it has
already paid the round trip and the decode, and the crop needs those same pixels
the moment the user opens the person. Kept **only where a face was found**:
two thirds of a personal library is landscapes and documents (docs/faces.md
§7a), those will never be cropped, and skipping them keeps this well clear of
the whole-library cost `SWEEP_THUMB_SIZE` deliberately avoids. The downscale to
the large class happens after detection, which is the last use of the full
buffer.
Second, the sweep now picks up images that have faces with no proxy, whatever
put them in that state — this bug, or an ordinary cache eviction, which would
have produced exactly the same empty grid. Re-running detection repairs it and
loses nothing: `record_detections` replaces rather than appends and carries the
user's confirmations across the replacement. That makes the screen
self-healing rather than dependent on nobody ever evicting a thumbnail.
The proxy is stored *before* the detections. A kill between the two then leaves
a proxy with no faces — which the next pass simply re-indexes — rather than
faces with no proxy, which is the state that cannot recover.
Note for the library already part way through a sweep: the 986 images indexed
before this will be picked up by the repair route on the next run.
470 tests pass, including one that a face whose proxy is gone becomes work again.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was none. 14 documents, 177 numbered requirements, and all of it
written for someone who has already decided to work on this — nothing that
tells a newcomer which door is unlocked, what a first build needs, or why it
takes so long.
CONTRIBUTING.md points the first door at the operation format, because
"add a develop node" is a genuinely one-file contribution and the best first
experience this codebase can offer: no Rust, no shader edit, no UI change,
and its tests declared in the same file. It teaches the architecture's
central idea on the way through, which is why the invariant test added in
the previous commit is named there rather than left to be discovered.
Three things that were folklore are now written down: Git LFS is a
prerequisite, the first build resolves 826 crates and is not hanging, and
Slint needs pkg-config, libfontconfig1-dev and libxkbcommon-dev. The LFS one
proved itself while writing this — a fresh worktree hit exactly the failure
`dr-segment`'s build script is written to catch, which is the argument for
saying so before it happens rather than after.
rust-toolchain.toml pins 1.92.0 because `build-and-test.yml` already does
and says why: a floating toolchain turns an unrelated push into a mystery
failure. The two checks that gate every push are the two most sensitive to
compiler version — rustfmt's output changes between releases, so a
contributor on a newer stable can produce a diff nobody wrote on a line
nobody touched, and `clippy -D warnings` is the same story with new lints.
`rust-version = "1.92"` in the manifest stays where it is; it is a minimum,
and this is the upper bound it cannot express.
Also states the convention the tooling cannot enforce, from code-health.md
CH-4: close a requirement with a test that would fail if the behaviour were
removed. Coverage that moves slowly and means something beats coverage that
moves quickly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FR-DEV-3a is the property the declarative pipeline rests on: a node in
`ops/` is one file because nothing in `ui/` has to learn about it. It was
true — all fifteen ids grepped across `ui/` yield one hit, a localisation
test — and it was held by discipline alone.
That is the wrong mechanism for it. The failure is silent and cumulative:
special-casing one operation to fix a layout problem is defensible on its
own, and by the fifth the panel names half the chain and "a new operation is
one file" has stopped being true without any single commit having broken it.
Nothing would have told us.
The ids are read from `ops/*.yaml` rather than listed, so a node added
tomorrow is covered without anyone remembering this file — the same reason
`traceability` parses its denominators from `requirements.md` at run time.
Two decisions worth recording, because both are the difference between a
test that holds and one that gets deleted:
**Only string literals count.** `texture`, `contrast` and `clarity` are also
ordinary graphics and English terms, and `texture` appears throughout `dr-ui`
meaning a GPU texture. Matching bare words would fail constantly for reasons
unrelated to the invariant.
**`#[cfg(test)]` items are exempt, and finding them needs more care than it
looks.** The first version cut each file at the first textual match of
`#[cfg(test)]`, which in `develop.rs` is a *doc comment discussing the
attribute* at line 969 — it read 18% of the most important file in the scan
and passed. It now matches the attribute only as a whole line and skips the
item by brace depth, and `MIN_SHIPPING_FRACTION` fails the test outright if
the scan ever swallows the file again. The dangerous failure here is not a
false alarm, which someone investigates; it is examining nothing and
reporting success.
Verified both ways: it passes on the tree, and an `"exposure"` planted at
develop.rs:3635 — past two `#[cfg(test)]` attributes, exactly where the
first version was blind — fails with the file, the line and the reason.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two agents worked in parallel and neither could see this. The frame-budget
instrument matches `ParamKind::Enum { variants }` by value, which was free when
a descriptor was `&'static` and everything in it was borrowed for the life of
the program. Descriptors are owned now — a declaration parsed at run time
cannot hand out a `&'static` — so `variants` is a `Vec` and the arm was moving
out of a shared reference.
Bound by reference instead. The arm only ever reads the length.
The kind of conflict that survives a clean textual merge: git had nothing to
report, and the two changes are only incompatible once they are in the same
tree.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Whitespace only. `cargo fmt --check` is a required step and the FR-DSP-5 test
arrived disagreeing with it — kept as its own commit so it can be skipped
wholesale rather than read for a change that matters.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An audit of code quality and extensibility, written because the answer to
"how hard is it to add a feature here?" splits cleanly in two and the split
is not where it looks.
The develop pipeline is genuinely open: a new operation is one file in
`ops/`, and the claim that no code in `ui/` names an operation turns out to
be true — all fifteen ids grepped across every .rs and .slint file in `ui/`
yield one hit, a localisation test. `dr-ui` is the opposite: every feature
lands in `run()`, one of the `wire()` functions, and a root component
carrying 472 members, so contributors collide by construction.
Five findings, CH-1 to CH-5, each with a falsifiable "done when" in the
idiom technical-debt.md already uses. The distinction from that document is
deliberate and stated: it records compromises that were chosen and are
load-bearing until their criterion is met; this records friction nobody
chose. A section listing what must NOT be tidied comes before the findings
for the same reason.
CH-1 is not a new diagnosis. view-composition.md specified the fix on
2026-08-09, when `run()` was 500 lines; it is 1,810 now, the two view
booleans it described are five, and the conditional chains it predicted in
app.slint are five-term conjunctions. The entry references that spec rather
than restating it, and quantifies the cost of the delay.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ops/*.yaml` plus `build.rs` has been the class-1 plugin format since the
declarative nodes landed — it was simply resolved at build time. Nothing about
a declaration requires the compiler: everything it produces is data plus a
WGSL string, and the composer already assembles WGSL at run time from whatever
operations are active. So this is not a new mechanism. It is the existing one,
loaded later (FR-PLG-2).
`DeclaredOp` implements `Operation` from an owned `Declaration` — one
interpreter over many declarations, where `build.rs` emits generated code per
node. The generated path stays, as FR-PLG-2 says it should: a generated
`match` is faster than an interpreted one, the built-ins' declared `tests:`
have to run under `cargo test`, and generated source is inspectable in a way
an interpreter's state is not.
**The reader is now one file, read by both.** `src/declared/decl.rs` and
`src/declared/expr.rs` are `#[path]`-included by `build.rs` as well as being
modules of the crate, and they produce a neutral `Declaration` that names no
Rust type. The build script's job is reduced to *rendering* that declaration
as Rust; `DeclaredOp` converts the same declaration into descriptors and
`Expr::eval` walks the same tree the renderer writes out. There is one
grammar, one set of validations and one set of error messages, so "a plugin is
the same kind of thing as a built-in" is structural rather than aspirational.
What remains genuinely written twice is the pair of backends — an arithmetic
node rendered as Rust here and evaluated there — and that is what the parity
test stands between. `tests/declared_parity.rs` parses every built-in
declaration at run time and asserts the composed WGSL is byte-for-byte what
the generated implementation produces, with the uniform block bit-for-bit
identical, at both ends of every parameter's range and at four interior
points; then again over the whole develop chain with the declared nodes
swapped in, which is what covers uniform slot ordering and helper
de-duplication between operations. A third test asserts the declared and
hand-written nodes partition `ops/` between them, so coverage cannot shrink
silently.
Bit-for-bit rather than within a tolerance, because a tolerance is where a
real divergence hides. The one thing that had to be got right for that to hold
is number literals: `expr::as_f32` rounds a decimal exactly once, through the
same shortest-round-trip text the compiler is handed, rather than rounding an
`f64` a second time.
Not in scope, and deliberately untagged: load-time WGSL validation
(FR-PLG-11), id namespacing, a plugin directory read at startup, and pass
nodes (FR-PLG-2a). Those are separate work, and tagging them from here would
be the overstatement the spec's own §7 warns about.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The platform layer was never scanned, so every FR-PLAT-* and NFR-PORT-* tag in
dr-plat was invisible. Coverage 51.4% -> 57.6%, almost all of it pre-existing
tags that were simply not being counted.
Three places, because the finding has three audiences.
The display spec's §1 table said FR-DSP-3 was unmeasured and FR-DSP-5 untagged.
Both are now false, and §2's decision rule has fired. The body of §2 is left as
written with the verdict quoted above it: a plan overtaken by its own evidence
reads better in order than quietly edited into agreement with the outcome.
TD-4 is the stage that misses the budget. Clarity's kernel is a fraction of the
frame, so it reaches a 52-pixel radius at 4K and costs 34 ms — seven times the
entire fused chain, for one slider. It is debt rather than a bug because the
detail stage cannot yet write a target smaller than it reads, which
`local_contrast`'s own documentation has said since it was written. The entry
says plainly that tiles are the wrong tool for it, since that is exactly the
conclusion a reader arriving from ARCH §5.3 would otherwise draw.
TD-5 is the one nobody was looking for: composing the fused shader costs
2.8–5.2 ms of CPU per frame on a full chain, on the UI thread, which at
1920x1200 is more than the dispatch it precedes. The source depends only on the
graph's structure — what `structure_hash` already identifies and what does not
move during a drag — so the fix is the cache `AdjustPass` already keeps for
compiled pipelines, one level up.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
FR-DSP-5 has been satisfied for some time and untagged. `Framing::view` shrinks
the sampled region while the render target keeps its size, so a zoom raises the
resolution the pipeline works at rather than magnifying pixels already drawn —
there is no second full-resolution path because the zoom is that path.
Tagging it on that basis alone is what §7 of the display spec warns against:
traceability counts a requirement as covered when a comment names it, and
checks nothing about the code under the tag. So the tag goes on tests instead,
and the tests are built so that removing the behaviour breaks them. Both
failure modes were checked by hand: deleting the view from `visible_rect`
leaves the 1:1 render flat, and dropping only its offset leaves the render
exactly inverted. The assertion message names both, since those are the two
ways this can go wrong and the numbers alone do not say which.
The fixture is one-pixel black-and-white stripes — the highest frequency an
image can hold, and precisely what a proxy discards. A 1024 px source in a
128 px viewport reads source column `8x + 4` for every output column `x`, all
the same parity, so the fit render comes out uniform; that is asserted first,
because a 1:1 render showing detail proves nothing unless the proxy is known to
carry none. What remains is an equality against the source bytes rather than a
claim that something looks sharper.
The third test takes the arbitrary zoom the requirement also names, and pins
`RenderScale` beside the pixels: a zoom that moved the pixels but not the scale
would sharpen at the wrong radius, which stays invisible until somebody
compares a preview against an export.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The status table said FR-DSP-8 was absent and §5.2 assumed Slint
reports window moves. It does not — there is no `on_moved` on any
backend — so the position is sampled instead.
§5.4 records the two trades that are worth someone finding later: a
display profile is matched to the nearest of four spaces rather than
applied through a CMM, and on Wayland the canvas follows the first
output rather than the window, because a Wayland client is never told
where its window is and the protocol's own answer needs a `wl_surface`
that Slint does not expose.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Operation::descriptor()` returned `&'static OpDescriptor`, and that lifetime
is the whole reason a build-time node is free and a run-time node is
impossible: only a compile-time literal can satisfy it, so no amount of
reading `ops/*.yaml` at startup could ever produce a descriptor the rest of
the application would accept. FR-PLG-2 says a bundled operation and a
third-party plugin are the same kind of thing, differing only in where the
file was found — and a lifetime outsiders cannot meet is exactly the second,
weaker format that requirement forbids.
So a descriptor is now owned and handed out as `Arc<OpDescriptor>`, with `Vec`
where it held `&'static` slices. `Arc` rather than a `&self`-borrowed
reference because the callers want to *keep* it: the develop panel collects
descriptors and then mutates the graph, and a borrow would tie the
descriptor's lifetime to a borrow of the operation it came from, which is the
one thing `&'static` was doing right.
The identifier newtypes deliberately did not follow. `ParamId` is `Copy`, is
compared in `match` arms against generated constants, is a map key in the
sidecar and history, and reaches Slint model rows; an `Arc<str>` there would
cost a refcount on every one of those and would take `match id { EXPOSURE =>
.. }` away from the generated code. They gain an interner instead, which is
honest about its lifetime rather than pretending to one — the set of ids is
bounded by deduplication and is process-lifetime by construction, because the
sidecar on disk names its parameters and an id has to stay resolvable for as
long as any edit naming it can be opened.
No behaviour changes. Every descriptor that was a `static` is a `LazyLock`
initialiser now, `Operation::helpers` borrows from `self` instead of being
`'static` so a future run-time node can own its list, and `Warp` and `Framing`
follow `Operation` so there is one shape rather than two.
The one place a descriptor is read per frame is `compose_full`, which takes
`descriptor().id` to prefix each active operation's uniforms, and `dr-ui`
composes on every frame it draws. That is a dozen atomic increments beside a
composition that is already building several kilobytes of WGSL on the same
call; it is noted at the trait method rather than left for a profiler to find.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
FR-DSP-3 states a latency requirement and nothing checked it, which makes it a
wish. This adds the check and the measurements it guards.
`docs/frame-budget.md` is the bench's output with the reading of §2's decision
rule attached. The short version: every point-operation chain at every viewport
size, fit and at 1:1, is inside 16 ms at the 99th percentile — the widest is
4.5 ms of GPU at 4K — so FR-DSP-2 should be rewritten rather than implemented.
The measurement did find a stage that misses the budget, and it is the one §2
predicted: clarity's 52-pixel separable kernel costs 34 ms at 4K. Tiles make
that worse rather than better, since a tiled convolution reads a halo per tile;
the fix `local_contrast` already names for itself is a base computed at reduced
resolution.
The test guards the fused path and says so, at length, rather than quietly
excluding the expensive stage and letting the tag imply otherwise (§7). What it
asserts is exactly the claim the recommendation rests on: one dispatch over a
viewport-sized target, at a full chain, is comfortably inside a frame.
Two things the numbers forced:
- The two cases are one `#[test]`. As two they ran on a thread each, contended
for the same device, and took the 1:1 case from 2.5 ms to 14.9 ms — a
measurement of the harness that would have flickered either side of the
budget forever.
- The CPU half of the frame is judged only in an optimised build. Composition
is real per-frame work on the UI thread and belongs in the budget, but the
workspace builds its own crates at `opt-level = 0` in dev and `cargo test` is
a dev build, so measuring it there measures rustc. The GPU half is asserted
either way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
FR-DSP-8 asks for a fallback that is *defined*, and a reason that
reaches the code but never the screen satisfies half of it. The three
readouts are now built by a free function over the survey rather than
written straight into the window, so a test can assert that "sRGB
assumed" arrives with the reason attached, that an approximated profile
says "nearest to" rather than claiming the space, and that the second
monitor is described on the page read from the first — which is the
display the requirement is actually about.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Index faces in the whole library" could not. Its work list was intersected
with the thumbnail store at `ThumbSize::Large`, and nothing fills that class for
a whole library — `SWEEP_THUMB_SIZE` is deliberately `Grid`, because the large
class is ~860 MB of shards against ~200 MB and every syncing device pays it. So
the only images with a large proxy were the ones the user had personally zoomed
into or opened in the loupe. On this library that was 220 of 23,529.
The comment defending it misread the requirement:
// Requesting one here would put face indexing on the network path,
// which FR-CULL-8 explicitly keeps it off.
FR-CULL-8 keeps indexing off the **full decode**, not the network, and then says
the opposite in the same paragraph: "where no proxy exists, the job requests one
at background priority rather than decoding inline". faces.md §7 repeats it.
Neither was implemented.
So the pass fetches. Same two-stage route the thumbnail sweep uses — the header,
then the located preview's own byte range (FR-NC-3) — so no whole file is pulled
and no RAW is decoded, because an embedded preview is a JPEG. The work list is
now every visible image with no `face_index` row for the model: 23,308 here,
against nearly none before.
**It indexes at the resolution the preview actually has**, not the 1024 the old
tier would have given. `locate_preview` already picks the largest embedded
preview, and the thumbnail sweep was decoding it and throwing the detail away at
`downscale_to(256)`. A face 2% across the frame is 5 px on a grid thumbnail and
~61 px at the cap here — and 112 is what the embedder samples, so this is the
difference between an upsampled crop and a real one. `crop_px` records which,
per face, as §7 intended.
Capped at 3072 rather than truly full: `index_proxy` needs packed `f32` RGB at
12 bytes a pixel, so a 24 MP frame is ~288 MB and the fetch lanes hold one each.
The constant is named and sits next to the reason.
Orientation is applied **before** detection, not after downscaling. That costs a
permutation of a larger buffer — ~15 ms against a ~150 ms decode — and buys the
entire class of bug this codebase keeps having: detection then runs on the
photograph rather than the sensor, so every box and landmark is already in the
space the catalog stores and the overlay draws, with no second mapping to get
backwards.
One detector and one embedder serve every lane. The lanes are concurrent futures
on a single thread, not threads, and inference contains no await, so a `RefCell`
borrow never overlaps another — a pair per lane would duplicate ~16 MB of
weights for no parallelism.
Images with no face in them are recorded too. `face_index` records that
detection *ran*, and zero is its most valuable value: without the row every
landscape and document scan returns on every pass, for ever, and in a personal
library that is most of it (§7a).
The old store-only pass survives as `spawn_store_face_sweep` for
`examples/face_index.rs`, which indexes a local store with no network. The
settings copy no longer claims indexing reads "the photographs already
thumbnailed above", and the audit line says "to fetch" rather than "awaiting a
proxy", which had become a blocker that no longer blocks.
Verified against the real catalog: the new work list returns 23,308 where the
old one returned effectively nothing. 469 tests pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`platform` was missing from the traceability scanner's source roots, so
every TRACES tag in `dr-plat` — the secret store, volume discovery, and
now display-profile acquisition — was invisible to the matrix. The
FR-PLAT-* family is exactly what that crate exists to satisfy, so the
omission understated coverage by the requirements it was meant to
count.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rest of FR-DSP-8. The develop session now carries the space its
canvas is encoded into, and `render` composes for it instead of for
sRGB — which is the whole of the change to the pixel path, because the
output space was always a parameter of composition and always entered
the structure hash. A display change is a recomposition.
The space is set on the way into every render rather than pushed when
the window moves, so a photograph opened while the window already sits
on the second monitor is right on its first frame instead of flashing
the wrong colour until the next poll.
Which display that is comes from sampling the window's position and
scale factor twice a second — Slint reports neither a move nor a
display change — and re-surveying only when they differ. Settings shows
what came back under ABOUT: the display, the space, why, and the other
monitors, because the failure FR-DSP-8 names is one that is invisible
from the display you are reading the page on.
Fractional scaling: the canvas is now rendered at the physical pixel
size of the box it occupies rather than the logical one, so the
compositor presents it 1:1. At 1.25 it was previously handed 1600
samples to fill 2000 device pixels, and the softness that produces
reads like a bad demosaic rather than like a scaling bug.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`docs/display-and-extension.md` §2 fixes a decision rule in advance: if the
99th percentile of a frame sits inside 16 ms, tiled computation is rewritten
as a scheduling concern for export rather than built on the interactive path.
Nothing in the tree could answer that, so the rule had nothing to act on.
This is the instrument. It renders a 60 MP synthetic source through the real
`render_detailed` at three viewport sizes and four chain lengths, fit and
zoomed to 1:1, and reports nearest-rank percentiles rather than means — a
slider drag is judged by its worst frame.
Three things it does that a simpler timer would not:
- It separates the fused pass from the neighbourhood stage. "Every operation
active" mixes one dispatch together with a chain of convolutions, and §2's
question is about the first of those. `point` is every operation that
contributes a fragment to the fused shader; `all` adds the four with
kernels, and M3 times those alone by moving only a detail parameter so
`render_detailed`'s colour reuse skips the fused dispatch. The reuse is
reported rather than assumed — the `colour` column counts fused dispatches
and must be zero for an M3 row to mean what it says.
- It times the CPU half separately. Composition runs per frame in
`DevelopSession::render`, so it is inside the budget whether or not anyone
has looked at it, and if shader assembly were the expensive half then no
tile scheduler could help.
- It builds the "every operation" chain from `EditGraph::capabilities` rather
than from a list, so declaring a new node does not quietly turn that row
into a shorter chain wearing a longer chain's label.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Naming a cluster and moving straight to the next is the gesture this screen
exists for, and it discarded the name every time. Two causes, both in the same
four lines.
`Field.text` is two-way bound to its `TextInput`. Binding it to `selected-name`
therefore works exactly once: the first keystroke writes through the `<=>` and
**replaces** the declarative binding, after which the field follows nothing.
Switching clusters left the previous cluster's half-typed text on screen,
attached to the new person.
And `Field` only reported `accepted`, which is Enter. A name typed and then
abandoned by clicking the next face never reached Rust at all.
So `Field` gains an `edited` callback, the screen keeps the draft with the
person it was typed for, and the draft is written when the selection moves or
the screen closes. The field is then reset from a revision counter the screen
watches.
A counter rather than `changed selected-name`, because the name is not a key:
naming six clusters "Anna" in a row never changes `selected-name`, and the field
would keep the half-typed text from the cluster before. Nor `changed
selected-person`, since accepting a namesake merge lands the user back on a
person they may already have been on.
The draft carries its `PersonId`. A reload can move the selection out from under
a half-typed name — a merge arriving through a sync, a deletion — and applying
it to whoever is selected now would rename a stranger. If the person is gone
when the draft lands, it is dropped rather than resurrecting a row the rail no
longer shows.
`None` and `Some("")` are kept distinct. A user who cleared the field means to
clear the name; a user who never touched it means to leave it alone. Collapsing
those two erases names by walking past them.
An implicit commit does not raise the namesake merge offer. That question is
about a screen the user has already left, and answering it on their behalf while
they look at the next cluster is not a question at all — the offer stays on the
explicit submit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FR-DSP-8's acquisition half. `dr_plat::display` surveys the session's
displays and reduces each one's profile to an output space the pipeline
can encode into, stating the mechanism per display server as
FR-PLAT-LIN-2 requires:
- X11 reads the `_ICC_PROFILE` / `_ICC_PROFILE_<n>` root-window
properties, enumerating and numbering the outputs through RandR,
which also yields the rectangles a window move is measured against.
- Wayland binds `wp_color_manager_v1` and asks each `wl_output` for
its image description, accepting either an ICC profile on a file
descriptor or primaries stated as chromaticities.
- Where neither answers, sRGB is assumed and the reason travels with
it as data rather than into a log, so the About page can say which
path the session is on.
A display profile is a measurement of one panel and is none of the four
spaces the pipeline knows. Rather than grow an ICC engine, the profile
is reduced to D50-adapted colorants and matched against the four; a
match that is merely nearest is marked as such and shown as such.
Verified on this machine: mutter 50 advertises the colour-management
global and reports eDP-1 as sRGB, and the same session forced onto X11
enumerates the output through RandR and correctly finds no atom.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`makepkg -si` reported "A package has already been built, installing existing
package" and installed 0.7.0-1 — the archive from before the models were added,
so the install still had no model and the app still said so. The version had not
changed because the application had not changed; only what the package contains
did, which is precisely what pkgrel exists to signal.
Verified: 0.7.0-2 carries both models at /usr/share/darkroom/models/.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Android bundling landed the weights under that platform's asset directory,
which was the wrong home the moment a second packager wanted them. `makepkg -si`
produced a desktop install with no model at all — the same "no face model is
installed" the phone used to show, for the same reason: nothing put the files
anywhere the app looks.
So `models/face/` at the root is the one copy, and both packagers read it:
assemble-apk.sh bundles it as APK assets, and the PKGBUILD installs it to
/usr/share/darkroom/models. Both refuse an LFS pointer rather than shipping a
130-byte file that fails inside the graph loader on a user's machine.
`face_models` now searches three places, most specific first: the account's own
directory, the shared user directory, then $XDG_DATA_DIRS. So a packaged pair is
found automatically and a pair the user placed by hand still outranks it — which
is what keeps a deliberate choice of weights from being overridden by an
upgrade.
$XDG_DATA_DIRS rather than a hard-coded /usr/share: that is the variable a
distribution, a prefix install or a Nix-style store already sets to say where
its data went, and its documented default is exactly the two paths that would
otherwise have been hard-coded. Empty on Android, which has no such directories
— there the APK's copy is unpacked into the shared user directory instead,
because an asset inside a package is not a path anything can read from.
Verified: the APK still carries both models at assets/models/, the PKGBUILD
parses and installs from the new path, 467 tests pass.
Includes the pkgver 0.6.0 → 0.7.0 bump that was already sitting uncommitted in
the working tree.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Over-clustering is the normal state of a freshly indexed library — FR-CULL-10
says so — which means one person arrives as several groups and the user names
each of them the same thing. Until now that produced several people called Anna
and no way to join them: `identity::merge` existed, `faces::merge_people`
existed with its redirect tombstone, and `merge-into(int)` sat in identity.slint
declared, never emitted and never wired. The screen had a split button and no
merge.
So a rename that collides now offers one. Type a name another person already
carries and a strip appears under the field: "Someone else is already called
Anna (14 faces). Merge them?"
Offered, not performed. `people.uuid` is the identity and the name is not — the
schema comment on that column is explicit that two devices naming the same
cluster independently is the case it was built for — so two people sharing a
name is legal, and folding them together on a keystroke would be the screen
making an identity decision on the user's behalf. That is the thing this screen
spends a whole button avoiding.
The rename always lands first, and declining leaves it exactly as typed. There
is nothing to undo because nothing was done.
Details that are not arbitrary:
The comparison is trimmed and case-insensitive. "anna" on a phone keyboard and
"Anna" on a desktop are one intention, and an offer that appeared only when the
capitalisation matched would read as a bug.
An empty name collides with nothing. Every unnamed cluster renders as "Unnamed
(n faces)"; if that counted as a collision the offer would appear on every
cluster in a fresh library, and accepting it would fold the library into one
person.
The newly-named person folds into the one that already held the name, not the
reverse. The older person is the one other devices have seen and the one whose
confirmations are more likely to be real. Selection follows the merge, because
landing on an empty screen after a successful action reads as a failure.
The offer is retired when the person changes, and when a refresh finds its
target gone — merged from the other side of a sync, or deleted. An offer left
standing would fold whoever happens to be selected now.
A merged-away person is not a namesake: `faces::people` already excludes
redirects, so the offer does not reappear the instant it is accepted.
Four tests over the collision rules, and the existing 467 still pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two families were the weak points: FR-DSP at 37%, which is the architecture's
central performance claim, and FR-PLG at zero. They are one document because
the same property decides both — the pipeline composes its work from
declarations, which is why the display path is fast and is also, already, most
of a plugin format.
Two findings change the size of the job.
**Some of FR-DSP is done and untagged.** Zoom already meets FR-DSP-5: the
sampled region shrinks while the render target keeps its size, so zooming
raises the resolution the pipeline works at — arrived at without tiles.
**FR-DSP-2 may not be worth satisfying as written.** It predates the fused
shader and assumes a chain of passes over a large buffer, where recomputing
per frame is ruinous and tiles are the way out. What exists composes every
active operation into one dispatch over a viewport-sized target. So the spec
fixes three measurements and a decision rule *in advance*: if a frame sits
inside budget at the 99th percentile, the requirement is rewritten rather than
implemented, and tiling becomes what it actually is here — a scheduling
concern for export, which already runs off the frame path. Writing a tile
scheduler the design does not need would be the most expensive way to find
that out.
**The plugin format already exists**, resolved at build time: `ops/*.yaml`
through `build.rs` produces something indistinguishable from a hand-written
operation, and everything it produces is data plus a WGSL string. The blocker
is one line — `descriptor()` returns `&'static` — and the rest is an
interpreter over declarations, load-time WGSL validation, and namespaced ids,
because the sidecar stores parameters by id and a collision is a wrong edit
silently applied.
Recorded last and deliberately: traceability counts a tag, not a behaviour.
`FR-DEV-8` is tagged against plumbing a future spot-removal op would use and
`FR-DEV-7` against a history row for a frontend that does not exist, so 51% is
an overstatement of unknown size. Every requirement this plan closes should be
closed by a test that fails if the behaviour is removed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Face indexing was compiled into the APK all along — dr-ui takes dr-face with
`inference` on every target, so SCRFD, alignment, MBF, calibration and
clustering were all in there. What was missing was the weights, and on Android
there was no way to supply them.
Route C (docs/faces.md §2.2) says the user obtains the model and the app loads
it. On a desktop that is a real gesture: drop two files in
~/.local/share/darkroom/models/ and indexing starts working. On Android it is
not a gesture at all. `internal_data_path` is app-private, `run-as` needs a
debuggable build, and the in-app fetch route C specifies was never built — so
the settings page reported "no face model is installed" on every launch with
nothing behind the message. Not "off until you supply weights"; off.
So the shape-fixed pair goes into LFS under the APK's assets, assemble-apk.sh
copies it into the package, and `android_main` unpacks it to the shared models
directory before anything asks whether a model is present.
Three things that are not incidental:
The models directory is now shared across accounts rather than per-account.
Weights are identified by `faces.model_id`, not by who is signed in, so two
accounts had no reason to hold two copies — and the unpack runs before any
session exists to key a per-account path off. `face_models` still prefers a
per-account directory when one is populated, so anyone mid-migration keeps the
ability to pin one library to its own pair.
The unpack writes under a temporary name and renames. `face_models` decides
availability on `is_file()` alone, so a copy truncated by the process being
killed would leave a file that passes that test and fails inside tract —
reported to the user as a broken model rather than a missing one.
assemble-apk.sh refuses an LFS pointer. At ~130 bytes it looks exactly like a
model to `cp`, and unchecked it reaches the device and fails in the graph
loader instead of telling someone to run `git lfs pull` — the same guard
dr-segment's build script applies to yolo26n-seg.onnx.
The licensing half is unchanged and recorded in §2.2a: the InsightFace grant is
research-only, this is a private repository and a self-installed build, and
these files come back out before anything is published. The weights are still
not a cargo build input — dr-face has no `models/` directory and no
`embedded-model` feature, and nothing in the build reads them. The APK assembly
step copies two files and is the only thing in the tree that knows they exist.
Verified on device: both models unpack on first launch (2524817 and 13616095
bytes) and the APK carries them at assets/models/.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The merge of the SCRFD/MobileFaceNet work brought 69 rustfmt diffs across
dr-catalog, dr-face and dr-ui with it, so `cargo fmt --all -- --check` fails
on master and the Desktop job stops at its Format step — before clippy, the
tests or the release build have run at all. That makes the whole desktop
half of CI blind: a real compile error behind this would look exactly the
same from the outside. There was nothing behind it, as it turns out — with
the formatting fixed, clippy, the test suite and the release build all pass.
Every .rs hunk is `cargo fmt --all` on the pinned 1.92.0 toolchain, not a
hand edit, but it is worth being precise about what that moved, because it
is more than whitespace. Besides reflowing signatures and call chains,
rustfmt reordered the `pub mod` and `pub use` items in dr-face/src/lib.rs so
the `#[cfg(feature = "inference")]` entries sort in place, added the trailing
semicolon inside `let ... else { return }` bodies in identity_ui.rs, wrapped
a bare closure body in braces in cluster.rs, adjusted trailing commas, and
dropped a stray blank line at the end of identity_ui.rs. All of it is
semantically inert; none of it changes behaviour.
docs/traceability.md rides along because it has to. The matrix records each
TRACES tag by line number, and reflowing develop.rs, lib.rs, faces.rs,
identity.rs and identity_ui.rs moved them — FR-CAT-8, FR-CAT-9, FR-CULL-10,
FR-DEV-3, FR-DEV-3a and FR-DEV-3c all shift by a line or two. The matrix was
verified up to date on d777f7f before this commit, so this is drift these
formatting changes introduced, not pre-existing staleness being swept up.
Leaving it for a follow-up commit would hand traceability-check.yml a
failure caused entirely by a whitespace change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`platform_volumes` has been gated to Linux since it was written, but the
eight items it is built from were not, so every Android build compiled a
`/proc/mounts` parser it could never call and then printed eight dead-code
warnings about it. Real warnings hide in that kind of noise.
Gated per item rather than moved into a module, because the file already
draws the line that way one function above and two patterns for one idea
is worse than a repeated attribute.
The tests go with them. They parse a mount table and assert on names like
`mmcblk0p1`, so they are as Linux-bound as the code they exercise, and
`Path` turns out to be too -- only the reader borrows one, where `Volume`
owns its own.
Android now builds dr-plat with no warnings at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Linux store is built on `keyring::Entry`, whose set_password,
get_password and delete_credential are inherent methods. The Android one
is built on `keyring_core::Entry`, where they are inherent too -- so the
`CredentialApi` import that each of its three methods opened with was
doing nothing, and said so on every Android build.
`CredentialStoreApi` a few lines above is a different matter and stays:
`build` really does come from that trait.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Find subjects" would recognise a person on a portrait frame and then paint
the outline into the hillside behind them. The detection was right and the
mask was right; what was wrong was the picture drawn to show them.
Instance masks live in sensor space, and correctly so — the generated shader
samples them at `uv_src`, after the framing map, which is what keeps a mask
on its subject through a zoom, a pan and a crop. The overlay is the one
consumer that is *not* sampled by that shader. It is a flat image handed to
the compositor to lay over a photograph that has already been through the
framing map, so it has to arrive in the same space that photograph is in, and
it did not. On a frame from a camera held sideways the outlines were drawn a
quarter turn away from the subjects they described.
`overlay_clip` had the same fault one layer down, and it is the more
insidious of the two because it looks right. The crop and the viewport are
fractions of the photograph as the user sees it — the prologue maps an output
pixel through `crop_rect` *before* it unturns the frame — and they were being
measured against the sensor's width and height. Two numbers, correct type,
wrong axis.
Both now go through `Orientation::into_shown`, so the overlay and its clip
are in the photograph's space and the turn is the same one the render and the
thumbnails make.
Neither was noticeable until this week, and the reason is worth writing down:
before the detector was given an upright frame it found almost nothing on a
portrait photograph, so there was rarely an outline to be in the wrong place.
Fixing the detector is what made this visible.
Landscape frames were never affected, which is most of them, and is why an
overlay that ignored orientation entirely survived this long.
Verified on `_MG_9080.CR2`, a portrait frame of two people and a dog: the
overlay was a 1599x1066 image drawn onto a 1066x1599 canvas, with the colour
sitting in the mountainside above the subjects. It is now 1066x1599, and each
outline is on the thing it names.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every orientation bug this codebase has had has been the same bug: a turn of
the right size applied in the wrong direction. That failure is worth naming
precisely, because it does not look like one — a quarter turn applied
backwards lands 180 degrees from right, so the result is a plausible
transform of the picture rather than anything obviously broken, and on
landscape frames it is not wrong at all. It was the straighten shear, and it
was the segmentation overlay, and each time it was found by eye rather than
by a test.
The reason it keeps happening is that "rotate 90 degrees clockwise" cannot be
checked by reading it. The reader has to hold in their head which of the two
images is being rotated and which way the y axis runs, and there were four
hand-written copies of the permutation to hold it for: the shader prologue,
its CPU twin, the thumbnail path, and the segmentation.
So nothing added here says clockwise, anticlockwise, horizontal or vertical.
The functions say *which space they take and which space they return* —
`into_shown` and `into_stored`, `source_pixel` and `shown_pixel`,
`into_shown_rect` and `into_stored_rect` — and each takes the dimensions of
the space it reads from, so no caller has to work out which pair it is
holding. `StoredRect` and `ShownRect` are separate types because they are the
same four numbers meaning different things, which is exactly the case where a
mistake is silent: a shown rect measured against stored dimensions produces a
rectangle in the wrong place, not an error.
Underneath there is one permutation. `source_pixel` was already shared by the
prologue and the thumbnails; `source_point` is its normalised twin, written
beside it so the two cannot drift, and everything else is those two read
forwards or backwards. `Orientation::inverse` is the group inverse rather
than `4 - turns`: mirrors apply after the turn, so undoing means undoing them
first, and a mirror seen from the far side of an odd turn is about the other
axis. That is the diagonal-mirror case, tags 5 and 7, and getting it wrong
renders as — again — 180 degrees.
Three call sites lose their own copy: the thumbnail path, `dr-ui`'s
segmentation, and `dr-gpu`'s `local` example. "Upright" now means one thing
across the application rather than one thing per caller.
The gate that matters most is `the_render_and_the_orientation_map_agree`. The
shader prologue and `Orientation` answer the same question by different
routes, and until now nothing checked that they answered it the same way. It
now checks every EXIF tag against every user rotation and mirror on top of
it, because the composition is where the two could agree singly and disagree
together.
The rest earn their place by having caught something. Writing these found two
real errors in this commit's own new code before it ran anywhere: `shown_pixel`
was handed the dimensions of the wrong space and overflowed, and the rect map
turned the wrong way for the diagonal mirrors. A round trip that returns what
went in is the only check worth having here, since every wrong answer is
still a picture.
No behaviour changes. The permutations are the ones that were already being
applied; they are simply applied from one place now.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Beside the thumbnail sweep, because it is the same kind of thing: a job
that runs for an hour, is asked for once, and reports into the activity
list above it. It is also downstream of that sweep -- detection reads the
proxies it builds -- so the two belong in that order, and the coverage
line says how many images are waiting on a proxy rather than only how
many are left to index.
The button drives the Identity Manager's own state rather than a second
copy, so it cannot disagree with that screen about whether a pass is
running, and either place can start or stop it.
The pass now opens an activity row. The caption promises progress will
appear in the list above, and without a row it would not: the button
would be the only sign anything was happening, invisible from every
other screen.
Coverage is read when the Settings page opens. The figures live in the
catalog and this page deliberately holds no session, so they arrive
through a closure rather than being kept current -- they are only ever
looked at while the page is on screen, and the check is two counts and an
indexed scan.
Also adds DARKROOM_NO_SYNC. Redirecting XDG_DATA_HOME isolates a test
launch's catalog and thumbnails but not its server, and I found that out
by pushing a test catalog over the live one. The guard sits in
start_derived_sync rather than at its three call sites, because the sweep
firing a sync is correct and a flag checked in three places is one that
gets missed in a fourth.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Faces are found on the thumbnail, which is cached the right way up --
the grid would lie on its side otherwise. Segmentation runs on a proxy
rendered through a neutral edit graph, which carries no orientation and
is therefore in sensor order. For anything shot in portrait the two
differ by a quarter turn, so a face and the person containing it were
being compared in spaces 90 degrees apart: no match, or worse, a match
against somebody else's region.
The transform goes on the face rather than on the proxy. Instance masks
are defined in the proxy's space and sampled long afterwards, so turning
that space would be a far larger change than naming a region warrants.
Also two things the first screenshot of the running app showed that no
test would have:
110 of 23,528 displayed as "0%", which reads as the feature having done
nothing. One decimal below ten percent, and a floor so real progress
never shows as none.
The rail picked some near-black covers, because the largest face in a
group is often the nearest one in a badly lit frame and a black square
beside a name identifies nobody. It now cuts the best few and takes the
first legible one, falling back to the largest when a person's every
photograph is dark -- which happens, and showing it beats showing
nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It was reachable only from the library header, which made it a side trip
rather than a mode. It is now reachable from develop's header too, beside
the way back, because that is the same kind of move -- leaving this
photograph for somewhere else in the library -- and a screen you can only
reach from one of the other two is not a peer of them.
Leaving returns to whichever screen opened it, and the button says which.
A back button that read "Library" while returning to develop would be
lying about the one thing a back button has to be right about. The
develop session is only hidden, never torn down, so returning to it costs
nothing and keeps the photographer's place.
The header now matches the other two screens rather than using a close
cross: three screens whose headers disagree read as three applications.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The segmenter knows it found a person; the face index knows which person.
Joining them turns "person" in the mask list into "Anna", which is the
difference between a vocabulary of eighty COCO classes and one that
includes the user's family. Selecting a subject in a group photograph
stops being a guessing game between three identical rows.
Containment, not IoU. A face is a small part of the person it belongs to,
so a correct pairing has an IoU near zero and anything IoU-based would
reject every true match.
Confirmed names only. A suggestion is the system's guess, and printing a
guessed name onto a mask region would launder it into a fact.
Writing the tests corrected the design once: a tight head-and-shoulders
portrait, where the face fills most of the person box, is the case where
naming is most certain, not least. An earlier guard rejected exactly that
and has been removed, with the reasoning left as a test because it is
easy to get backwards a second time.
The names hang on the develop session, set when the image opens because
that is the one moment the catalog and the image id are both in reach.
Every segmentation run afterwards picks them up for free, and a library
with no face indexing behaves exactly as it did before.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Undo answers "take back the last thing", which is the question asked about a
mistake just noticed. It is the wrong instrument for one noticed six
adjustments later: eight presses, each changing the picture, with no way to see
how far back the mistake is without passing through it. A step is a whole
state, so arriving from six away costs what arriving from one does — which is
what makes a row worth making clickable rather than decorative.
`Edit::Discrete` had to go for the list to be worth drawing. Seventeen call
sites recorded the same anonymous step, which is fine for deciding whether two
changes are one gesture and useless for a panel: seventeen rows reading
"Discrete" is not a history. Every variant now carries enough to name itself,
and the compiler enumerated the sites that had to start saying so. A step that
moved a parameter is still named out of the descriptor, so an operation added
as a YAML declaration appears in the history correctly named with nothing
written for it (FR-DEV-3c).
Choosing a film stock was not undoable at all. The pick went straight to
`choose_film`, which nothing on the history's path ever sees. `pick_film`
records it, and is separate because the same call is also how a *restored* edit
gets its tables back — recording that would push a step for the undo the
photographer had just asked for.
The list is rebuilt off a revision rather than off every redraw. A drag ends in
a redraw per frame while folding into one step, so the unconditional version
would tear down and recreate every row sixty times a second to arrive back at
the list already on screen. The counter is process-wide: a per-instance one
starts every photograph at the same number, so a frontend holding "the revision
I last drew" would keep the previous image's steps on screen — invisible while
every image opens with one identical row, and a wrong-photograph bug the moment
persisted history means it does not.
The step names that no descriptor can supply are constants with a roll, and a
test walks the roll rather than a second copy of it. `resolve` splits so that
"is this catalogued?" can be asked: `derive` turns `history.mask_toggled` into
"Mask Toggled", which names a field rather than an act and, being perfectly
readable, is a mistake nobody would look at twice.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The undo stack snapshotted a `Preset` — the parameter map — and a mask layer
is deliberately not a parameter. So drawing one changed nothing the history
could see: `record` returned `false`, no step opened, and the layer the
photographer had just painted had no way back. The interface went on calling
`record` in good faith, including from the mask controls, and nothing failed.
A film stock went missing the same way.
The snapshot is an `EditState` now, so the history is complete by
construction rather than by anyone keeping a list in their head.
`undo` and `redo` return a `Step` rather than a `bool`. Stepping is not the
only outcome a caller has to act on — a step across a change of film leaves
the graph without its tables, and only the caller can bake them — and a `bool`
would let that be dropped by writing nothing at all, which is the shape of
mistake this module had already made once. `DevelopSession` settles the debt
either way; a step that found nowhere to go is left alone, since clearing the
film because undo hit the floor would take the stock off the picture.
Five tests, all of which fail against the old snapshot: a drawn layer is
undoable and redoable, a layer's own settings are a step of their own, a
change of stock is a step and names what it needs baked back, clearing the
film is undoable, and an exposure move does not deep-copy the mask stack.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Version::update` is the write path an automatic save goes through. It copied
the parameters and the masks and said nothing about the film, so a photograph
developed on a stock was written back without it and opened the next time
without its emulsion. Nothing reported a failure — the line was simply not
there.
It is the third of the three routines that captured "the edit" and the only
one that got it wrong, which is the argument for not having three. All of them
now destructure one `EditState`, so `from_graph`, `update` and `apply` cannot
disagree about what an edit consists of, and the next part of one cannot be
lost by anybody writing a line too few.
Two tests, both of which fail without the fix: the field survives `update`,
and the stock survives the round trip through the file. `apply` returns the
`FilmRebake` it always implicitly owed, so `apply_version` now reads the debt
off the call rather than off `version.film` — and pays it in both directions,
since a version with no film has to clear the adjust pass too or it keeps
textures bound that nothing will sample.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rail was drawing an empty square for every person: cover was
hardcoded to a default image. That is the one place a portrait matters
most, because the rail is how the user decides which unnamed group to
open first, and a list of "Unnamed (24 faces)" rows tells them nothing.
The portrait is the person's confirmed face with the largest crop_px --
the most source pixels the face actually occupied, so the one they have
the best chance of recognising -- falling back to a suggestion so a
freshly clustered group still has a face beside it.
Cached in the controller, because every mutating action reloads the whole
screen and cutting a portrait costs a JPEG decode per person. Without the
cache, confirming one face would re-decode a proxy for every person in
the library, and the rail does not change when a suggestion is accepted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An edit used to be a bag of scalars. That stopped being true when the mask
stack and the film stock arrived, both deliberately held apart from `ops`
because a layer is not a scalar and a stock is not a scalar — and nothing
announced the change. What happened instead is that three routines each
captured "the edit" and each captured a different subset of it.
`EditState` is all of it: the parameter map, the masks, the stock. What keeps
it complete is not a comment. `EditGraph::state` destructures the graph
exhaustively, `EditGraph::set_state` destructures the state exhaustively, and
the fields are public so every construction site is a struct literal naming
all of them. Adding a fifth kind of graph state — FR-DEV-8's spot removal is
the one already asked for — fails to compile until somebody has decided
whether an undo has to put it back. Verified both ways round by adding a field
to each type and watching five call sites refuse to build.
A compiler error rather than a runtime check, because the failure being
prevented is silence: the missing halves produced no panic, no warning and no
failing test.
The stack is now shared rather than owned, and that is about the drag path
rather than memory: `state` runs on every parameter change, which during a
drag is once a frame, and deep-copying a painted brush sixty times a second to
record an exposure move would be a cost paid for nothing. `masks_mut` is the
one door a stack is modified through, so it clones on write.
`FilmRebake` is the one thing a caller is still owed. Restoring a stock always
needed the profile database this crate does not link (ARCH §6.5a); it was a
comment before, and it is a `#[must_use]` return value now.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Indexing 23,500 images is about two hours of CPU, and the result is
byte-identical on every device: the same model over the same proxy
produces the same embedding. Paying for it once per account rather than
once per device is the point.
Shards rather than the catalog snapshot, because the snapshot goes up
whole on every sync and a fully indexed library carries roughly 30 MB of
embeddings. That is exactly the cost the thumbnail store's 25 MB cap
exists to bound, so face shards use the same cap -- imported from
dr_thumbs rather than restated, since the number is a statement about
sync cost and the two must not drift apart.
The split follows the one already there: bulk immutable data in sealed
shards, small mutable data in the catalog snapshot. Faces, landmarks,
embeddings and run markers shard; people, names and assignments ride the
catalog and merge by uuid.
Keyed on oc:fileid throughout, never on image_id, because a row id means
nothing on another device.
The run marker travels with the faces it describes. Without it a
receiving device cannot tell an image with no faces from one never
examined, and would re-detect every landscape it had just adopted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Find subjects" was handed the proxy in the sensor's own orientation, so
every frame shot on a body held sideways reached the model lying on its
side — and a model trained on upright photographs is very bad at those.
Measured end to end on a 22 MP frame of two people and a dog: `person
0.36` and nothing else, against `dog 0.82, person 0.61, person 0.49` for
the same pixels stood up. Nothing failed; the panel simply offered one
poor subject where there were three good ones.
The orientation was never dropped on purpose. The proxy is deliberately
rendered through a *neutral* graph — the detection has to survive an
exposure change, or every slider would invalidate the masks built on it
— and neutral took the file's orientation with it along with everything
else. Landscape frames were unaffected, which is why it stood for as
long as it did.
The turn is `Orientation::source_pixel`, the same function the grid's
thumbnails already go through, so the detector and the thumbnailer now
agree about which way is up rather than holding two opinions. What it is
turned by is `Framing::effective_orientation` — the file's EXIF tag and
the photographer's own rotations composed into one permutation, by the
group law rather than by adding the turns, which is a distinction
`Framing` already had to make and had already tested. Rotating the
picture and pressing the button again therefore does what it looks like
it does.
The proxy stays in sensor space and the masks come back into it. That is
not a detail to be tidied later: the generated shader samples the mask
array at `uv_src`, *after* the framing map, so a mask stored upright
would sit a quarter turn off the subject it was drawn around. That is a
wrong mask rather than a weak one, and nothing announces it. So the
picture is stood up for the model and laid back down for everything
else, and `upright`/`lay_down` are returned as a pair because calling
one and forgetting the other is silent.
Both directions are the one function: `upright` gathers through
`source_pixel` and `lay_down` scatters through it. A quarter turn is a
bijection of the pixel grid, so the round trip is exact — no filter, no
resampling, and no hole to fill — and an inverse written out by hand
would be a second thing to keep in step, whose way of being wrong is a
mask mirrored about the wrong axis, which still looks like a mask.
The orientation joins the confidence and the tiling flag in the
segmentation signature, and for the same reason: turning the photograph
changes what the model recognises, so two runs either side of a rotation
are different instance lists. Two that happened to come out the same
length would otherwise share a signature and a stored layer would be
silently re-indexed from one into the other.
The refine pass had it too — it re-runs the model over a crop rendered
in the same sensor space — so it makes the same turn, and would
otherwise have handed back a worse mask than the one it was asked to
improve, on the subject the photographer had just pointed at.
`dr-gpu`'s `local` example is fixed with it. It exists to be the
shipping path with pictures attached, and a diagnostic that reproduces
the bug it is meant to catch is a trap for whoever reads it next.
Seven tests. The round trip is the identity over all eight EXIF tags on
a non-square asymmetric grid; a turn carries whole pixels rather than
shearing the channels apart; a sideways frame reaches the model
upright; a box comes back in sensor pixels, worked out by hand for the
one turn a portrait frame actually writes; a restored box still reads
low-to-high for every tag, since the rest of the pipeline takes
`x1 - x0` without checking the sign; and the eight tags cannot collapse
into one signature key. The existing composition test now runs against
`effective_orientation` itself, over all 8 x 16 baseline-and-user pairs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three compromises were taken deliberately over the last few days and none of
them was written anywhere a future reader would look. So `docs/technical-debt.md`,
and two corrections to the architecture document that the Android change made
untrue the moment it landed.
**TD-1, the Android readback.** §12/6.1 says GPU results never round-trip
through the CPU, and the develop view on Android now does exactly that. That
is worth recording as a breach with reasons rather than quietly leaving a
constraint the code no longer honours — the next person to read §6.1 and then
`DevelopSession::render` would otherwise conclude one of them is a mistake.
The entry carries the device measurements that forced it, why setting
`preTransform` is not a fix available to us, and the three separate things any
one of which would remove it.
**TD-2 and TD-3**, the serial thumbnail fetch and the unbounded drain, were
found while chasing the tearing and are still outstanding. Both have a known
shape for the fix; neither is a bug, and neither should be discovered again
from scratch.
The architecture document said the app renders "through wgpu to Vulkan on both
Linux and Android", which stopped being true at 6267802, and §12/6.1 claimed a
constraint with no exceptions. Both now say what the code does and point at
the debt entry for why.
The numbers are labelled with what they are. TD-1's readback cost has *not*
been measured on the device and says so, and TD-3's figures are from a debug
build and say so — a documented measurement that quietly turns out to be the
wrong build is worse than no measurement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An image with no faces in it was indistinguishable from one that had
never been looked at, so every landscape, still life and document scan in
the library was re-detected on every pass, for ever. In a real library
that is most of it: on the 23,527-image test library, 64 of the first 110
images indexed contain no face at all.
Schema v9 adds face_index, a run marker per (image, model) carrying the
face count and the proxy edge it read. Keyed on the model, so a model
change puts every image back in the queue by itself.
That makes a coverage figure possible, which is the thing a user actually
wants to see. The audit also splits the outstanding set by whether a
proxy exists, because 23,417 awaiting a proxy and 110 ready to index are
different problems, and telling the user to run indexing again would not
fix the first.
The Identity screen gains Index faces, Stop, and the coverage line.
examples/face_index.rs is the same check and sweep without a window,
which is the right shape for an overnight pass.
Measured on the real library in release: 3.5 images/second, 110 images
and 125 faces in 30 seconds, and a second run correctly finds nothing
left to do.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A crystal is a fixed size in micrometres. How grainy a photograph looks is
therefore not a property of the emulsion alone -- it is film size against
output size, and the frame is the half a digital file cannot supply.
This assumed 35 mm for everything. The same emulsion on 4x5 averages about
3,800 crystals into the pixel that holds 300 on 35 mm, so it renders roughly
3.5 times smoother at the same print; every large-format photograph was being
rendered as grainy as a half-frame.
`Format` now carries the real image widths -- the gate, not the nominal inches,
since a "4x5" exposes about 121 mm -- and the film node asks for it. It is a
genuinely fixed list, unlike the stocks, so it is a declared `enum` parameter
and gets its control, its sidecar entry and its undo step for nothing.
It is also the first enum in the develop chain, and it broke two tests by
being one. A row has to compare equal to itself across two builds or
`sync_rows` replaces it on every parameter event -- destroying the elements
built from it, including whichever TouchArea holds the current gesture, so the
format picker would have fought every slider drag in the panel. `ModelRc`
compares by identity and the row built a fresh choices model each call.
`no_choices` already shares one empty model for exactly this reason, and the
build site already said "see no_choices for why the identity matters". The fix
follows it: memoise the model per variant list. Curve rows solve the same
problem the other way, writing values through the existing model, which is not
needed here -- a variant list is fixed at compile time, so one model can serve
forever.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The grid tore while scrolling on the tablet, in portrait only, and was
flawless in landscape. It was not vsync and it was not the grid.
Measured on the device, same build, only the tablet rotated:
landscape bufferTransform=ROT_180 composition=DEVICE (2) clean
portrait bufferTransform=ROT_270 composition=CLIENT (1) torn
The panel is mounted landscape — 1920x3000 at installOrientation 3 — so a
portrait window needs a 90 degree rotation before scanout. wgpu-hal hardcodes
the swapchain's `preTransform` to `IDENTITY` and says so in a comment beside
the line:
// On Android 10+, libvulkan's `vkQueuePresentKHR` returns
// `VK_SUBOPTIMAL_KHR` if not doing pre-rotation ... This is always the
// case when the device orientation is anything other than the identity
// one, as we unconditionally use `VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR`.
That is gfx-rs/wgpu#3345, and it cannot be fixed by setting the field:
`preTransform` is a *promise* that the content is already rotated, so keeping
it needs the renderer to rotate what it draws, which wgpu cannot do on Skia's
behalf.
We do not have to be on that swapchain. `AndroidWindowAdapter` chooses
`SkiaRenderer::default_wgpu_29` only because this crate enables
`unstable-wgpu-29`; without it `SkiaRenderer::default` resolves — through
i-slint-renderer-skia's build script, which selects OpenGL on anything that is
not Apple, Windows or wasm — to Skia over OpenGL, where the driver owns the
rotation and there is no transform to get wrong. So both renderer features
move to the desktop-only dependency, and desktop is untouched.
# The cost, stated rather than hidden
Skia over OpenGL cannot sample a `wgpu::Texture`, so the develop view's frame
comes back through memory: `AdjustPass::export_pixels`, already ungated and
already used by the export path, into a `SharedPixelBuffer`. That is the
round-trip ARCH §6.1 and AC-8 exist to forbid, and it is the right trade only
because of what the alternative actually is — not a faster develop view, but a
grid that tears in the orientation a tablet is mostly held in.
Two things keep it small. The device is still opened on Android, so demosaic
and the adjust pass are untouched on the GPU; only the last hop changes. And
`render` fits the pass to the canvas before it runs, so the readback is at
viewport resolution, a fraction of the ~7 ms at 4K the original measurement
was taken against.
Four other explanations died on the way here, each by measurement rather than
argument: the present mode (a patch confirmed in the installed binary reached
`AutoVsync`, and the rows still duplicated), our shared wgpu device (Slint
opened its own, unchanged), Skia's partial rendering (off for GPU surfaces),
and client composition itself (unavoidable in portrait on this panel, so it
cannot be what distinguishes a torn frame from a clean one).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Also notes what building it taught the design: a split has to reject
before it confirms, or the next clustering pass undoes it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A third top-level screen beside the library and develop, because naming a
cluster and pulling a stranger out of it are tasks with their own rhythm
and need the whole window.
The screen is designed around the clustering being wrong, which is
FR-CULL-10 rather than pessimism: grouping over-merges on siblings, on
parents and children, and on the same person a decade apart. So Split off
sits next to Confirm all rather than behind a menu, the confirm/reject
pair is on the face itself, and a group the system found is drawn
differently from a person the user has vouched for.
Splitting rejects before it confirms. Without that the next clustering
pass suggests the face straight back and the user's correction becomes an
argument they keep having.
Face crops come from the proxies the grid already built, one decode per
image rather than per face -- a group photograph holding six faces of one
family is one JPEG.
Where the calibration is not fitted the screen says confidence is
unavailable instead of printing a percentage that looks measured, which
is FR-CULL-9's rule at the point it becomes visible.
The verdict controls use drawn icons, not tick and cross characters:
ui/icons.slint exists because those render as tofu on Android.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A third chip beside Crop and Local, and the mode strip's own comment
predicted the shape: a mode that arms a gesture on the canvas and scopes
the column. Click a mark to cover it, drag the disc to move the repair,
drag the source circle to say where the patch comes from, Delete to remove
it. The source starts two and a half radii towards the middle of the
frame, which is FR-DEV-8's automatic placement in its cheap form — dust
sits on skies and skies are smooth, so it is usually right and always one
drag from fixed.
Two things are drawn deliberately. The circles are the size the repairs
actually are, because whether a disc covers a speck is the whole judgement
being made and a fixed-size dot would say nothing about it; the reach
around them is padded to a touch target so a spot on a dust mark can still
be picked up on a phone. And only the selected repair shows its source: a
dusty sky carries a dozen, and two dozen circles with nothing saying which
belongs to which is less information rather than more.
The panel edits what is stored while the canvas draws what is mapped, and
the two are pushed separately for that reason — a slider deriving its
value from the drawn radius would move differently at different zoom
levels. It is also the one panel built from SliderRow rather than a live
track: a repair has no OpId to coalesce a drag under, so a row that fires
once per gesture is what keeps undo one step per decision.
Verified as far as this environment allows: the strip renders and the
column re-scopes, photographed under XWayland. Synthetic clicks do not
reach this application, so the gestures are as-written rather than
as-felt, and docs/spot-removal.md says so.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Wires dr-face to dr-catalog: a background sweep that reads the proxy the
grid already built, detects, aligns, embeds and stores, then a clustering
pass that turns those embeddings into suggested people.
Detection runs on the Large thumbnail tier and nowhere else. That is what
makes the feature affordable -- a browsed library has already paid for
its proxies, so face indexing adds no RAW decode that was not already
happening -- and it is why an image whose proxy is missing is skipped
rather than fetched: requesting one here would put face indexing on the
network path FR-CULL-8 keeps it off.
The sweep keeps no cursor. It asks the catalog what is missing, so it
resumes after process death with no repeated work beyond the in-flight
image, and cancelling is dropping the receiver.
recluster writes only the suggested half. Confirmed faces go in as
anchors and come back untouched, and a cluster of one stays nameless --
naming every stray face would fill the People view with noise the user
then has to dismiss.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two failures in a row were the same shape: a command the workflow calls
was not in the image. git-lfs, then file(1). Each cost a full run to
learn, and the android job is expensive to be wrong in -- the step that
fails is at the end, so every attempt paid twenty-eight minutes of
cross-compile first to reach the line that could not work.
Both were visible in ten seconds from here. `docker run <image> command -v
file` is the whole diagnosis; it just never occurred to anybody to ask
before pushing.
So the question gets asked automatically. This reads the `run:` blocks out
of the workflows, pulls the commands worth doubting -- the ones a minimal
Debian plausibly lacks, not `cd` -- and checks each against the image its
job declares. It does not run the workflow and is not a replacement for
one. It answers exactly the question that was expensive to answer.
`git lfs` is handled specially and the comment says why: it is a
subcommand, so the first word of the line is `git`, which is always there.
Taking first words alone would have missed the original bug -- and did,
in the first version of this script.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Seven more tags -- 621 against 614 -- from the print path, the film table
rebuild and the projector lamp. Coverage is unchanged at 51.4% (91/177):
the new tags land on requirements that already had one.
Nine rows move. As before, the matrix links to line numbers, so a commit
that inserts anything above a tag rewrites its row without changing what
the row says.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A history state was a Preset — a map of scalars — which was right while
every edit in the graph was a parameter. A spot is not one, and undo is the
first thing anyone does with a repair: place it, dislike it, take it back.
Left as it was, that press would have stepped some unrelated slider and
left the spot on the photograph, which reads as undo being broken rather
than as undo being absent.
So a state is now the pair, params and spot set. The same door is the one
the mask stack will come through: mask edits are outside undo today for
exactly this reason, and FR-DEV-5 is not finished until they are not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A clone gets the texture right and the level wrong. Dust on a gradient sky
is copied from a patch a little lighter than the hole it fills, and the
repair reads as a disc even though every grain in it is correct — which is
why FR-DEV-8 asks for heal and not only for clone.
Heal adds the membrane: the difference between the two neighbourhoods,
sampled at twenty-four points around the rim and interpolated across the
disc by inverse square distance. Solving the Poisson problem properly is
tens of Jacobi iterations, and an iteration here is a dispatch — sixty
dispatches to remove a dust spot is not a frame budget. The closed form
costs one loop over the rim, no state, and no second pass.
The spec called for mean-value weights; inverse squares are two
transcendentals per sample cheaper and agree wherever the boundary
difference varies smoothly, which is every repair anyone makes. What
decides whether that trade holds is the measurement, so the measurement is
the test: on a ramp steep enough to leave a clone wrong by 38 levels out
of 255, the heal is wrong by 0. docs/spot-removal.md §6.1 records what
shipped and what it would take to go back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A spot set now composes detail passes of its own, one per round, and they
go ahead of every operation's kernel. That placement is the decision worth
recording: a sharpening pass reads a neighbourhood, so sharpening a dust
mark before removing it smears its edge into pixels the repair's disc does
not cover, and what survives is a faint over-sharpened ring around an
otherwise perfect patch. It also disagrees with ARCH §5.2, which draws
spot removal after clarity — docs/spot-removal.md §5.1 is where that is
argued out.
Every length reaching the shader is in render pixels, converted here where
the framing is in scope. Both the centre and the source go through
`Framing::output_at` — the same map the fused pass applies to every pixel
— so a rotated photograph rotates the offset with no trigonometry, and the
radius is found by mapping a point one radius above the centre and
measuring, rather than by multiplying by a ratio this function has no
business knowing about. The tests turn and crop the frame and expect the
mark to stay gone, which is the property that arrangement buys.
compose_full now takes the spot set, because a photograph with a repair
and no sharpening still has a detail stage: a fused pass that encoded its
own output there would quantise twice and bind to a texture of the wrong
format. compose_detail_for takes the source size for the same kind of
reason — a RenderScale describes the region on screen, and a spot is
stored against the photograph.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FR-CULL-9 forbids thresholding a bare cosine anywhere in the subsystem,
so calibrate fits P(same person) per library and reports whether the fit
is trustworthy. Two details carry most of the weight.
The fit runs against a 200-bin histogram rather than a pair list: a
25,000-face library has ~3e8 pairs and no gradient descent is running
over that. And a fresh library has no valid calibration, because the
positives have to come from user confirmations or burst siblings --
bootstrapping them from high cosine would fit the calibration to the
belief it was supposed to test.
Clustering defends against the over-merging FR-CULL-10 warns about with
constraints rather than a better threshold: two faces in one photograph
never merge, and two groups confirmed as different people never merge.
Average link rather than single link, so one strong edge cannot weld two
families together.
Calibration is defined once, in dr-face, and dr-catalog re-exports it.
Two implementations of one probability model is exactly how a number
comes to mean the wrong thing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Schema v8: people, faces, face_person, face_person_rejected, and the
per-library calibration. Follows catalog.md 10.1 with two additions the
spec work turned up.
crop_px, because at the 1024px proxy tier a group shot reaches the
embedder at ~50 source pixels upsampled to 112 and a portrait at 340.
FR-CULL-9 names face size as an axis along which an uncalibrated
similarity misbehaves, so it is a stored feature rather than a UI hint.
face_person_rejected, because rejection is not the absence of an
assignment. Without it the next clustering pass re-suggests exactly the
face the user just pushed away, and the tool feels broken.
record_detections replaces rather than appends, since DetectFaces is
coalesced per image -- and carries confirmations across the replacement
by box overlap, so re-indexing with a better model cannot discard the
user's own labelling.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every neighbourhood pass so far has been a convolution, whose whole
description fits in the uniform block because its structure fixes how many
numbers it needs. Spot removal is not that shape: sixty-four repairs and
one repair are the same shader with a different buffer behind it.
So a pass may declare `storage`, which arrives at binding 3 as
`array<vec4<f32>>` with `arrayLength` in scope. The alternative — packing
the list into uniforms — needs a fixed maximum paid for on every frame, a
composer that can emit vec4 fields because a uniform array's stride is 16
whatever it holds, and it gives the next operation that wants a table
nothing to build on.
The property worth having is what stays out of the generated source: the
count is in the buffer, so placing the tenth spot uploads 512 bytes and
reuses the compiled pipeline, exactly as moving a slider does for the
fused pass. `changing_the_list_does_not_recompile` is that, asserted.
One bind group entry rather than two more layouts, and one placeholder
buffer allocated in `new` rather than sixteen bytes per pass per frame —
a zero-length storage buffer cannot be bound, and per-frame allocation is
what this module's documentation exists to refuse.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ports the pipeline from the C++ reference in ../scene-actor-extraction
(MIT, same author). End to end on real portraits it separates identities
the way the reference's fitted calibration says it should: 0.596 between
distinct photographs of one person, 0.05 between different people, either
side of MBF's 0.267 boundary.
Three things are structural rather than incidental:
Aligned112 can only be built by align::warp, so Embedder::embed cannot be
handed an unaligned bounding-box crop. That mistake yields 512 plausible
unit-norm numbers and no error, so the type system refuses it instead.
Embedding carries its ModelId and cosine() returns None across models,
because a cross-model similarity is the one mistake that produces
plausible garbage rather than a failure.
The model-free half -- alignment, embedding arithmetic, f16 storage --
sits outside the inference feature and is covered by 11 tests that need
no weights on the machine.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A spot is eight numbers, so it goes in the version block as a line rather
than in a block of its own the way a mask does — sixty-four blocks would
bury the rest of the file. A line per spot rather than one line for the
set, because the line is what a diff shows and what a merge resolves.
The parse arm sits ahead of the op.param arm deliberately: without it,
`spot.abc123 = 0.4 0.6 …` reads as an operation called `spot` whose value
will not parse, and the repair is dropped with a warning about a corrupt
number. The prefix is safe precisely because a spot is not an operation,
so no ops/ declaration can claim the name.
Merging is merge_masks by id, one level down, and it is where the derived
ids earn their keep: two devices that removed different marks hold
different ids and both survive, while two that removed the same piece of
dust hold the same id and the merge sees the one repair it is. A spot both
sides dragged resolves whole to the higher revision — eight numbers
describe one disc, and half of each is a repair neither photographer made.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Neither InsightFace export parses as shipped -- SCRFD fails at its input
node, ArcFace at the first Conv -- which is the same wall dr-segment hit
on YOLO's dynamic export. Both load cleanly with the input dims frozen,
so the pure-Rust runtime holds for the face pipeline too.
tools/fix-face-model-shapes.sh does the freezing, and exists so the
artefact is reproducible rather than a binary someone once produced. It
takes two forms because the two graphs need different ones: ArcFace's
batch is a named dim_param, SCRFD's H and W are dynamic but unnamed.
Also notes YuNet loading with no intervention, which matters for the
licence question in faces.md 2.3.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A spot is a disc, a source offset and four numbers, and it lives beside
`ops` for the reason `masks` and `film` do: the operation trait is
ParamId -> f32, and a list of repairs is neither scalar nor fixed.
Two decisions here are not obvious. The id is derived from the position
rather than counted, because two devices editing offline would each mint
`spot3` for different marks and the sidecar merge would then treat two
repairs as one — from the position, two devices that removed the same
piece of dust agree, and two that removed different ones do not. And
every length is in the frame's isotropic units, not a mixture of those
and shorter-edge fractions: one unit for the radius, the feather and the
offset agrees on a landscape frame and on a portrait one, where a mixture
only agrees on the first.
`rounds` is the arithmetic that keeps a source from reading a
destination. Every spot in one pass reads the photograph as it stood
before that pass, so a spot sourcing from an earlier spot's destination
would copy the mark that spot was removing. Grouping is not a pass per
spot — that is sixty-four dispatches for a case that almost never arises
— it is a new round only when the sources actually collide.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FR-CULL-8..12 specify the subsystem in terms of "a 512-dimension embedding
from a stated model" and stop there, because D13 was open. This names the
models, and grounds them in the measurements and the working C++ pipeline in
../scene-actor-extraction rather than in a literature reading.
The licensing half of D13 stays open, but with a route through it: the
InsightFace weights are non-commercial and cannot be committed, so the app
ships the code and the user fetches the model. faces.model_id already makes
that a survivable choice.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FR-DEV-8 is the last develop requirement with nothing behind it. The
pipeline it lands on already has most of the parts — the neighbourhood
stage, the mask crate's normalised coordinates, the gradient handles'
canvas drags, the merge-by-id rule — so the spec is mostly about the four
things that are genuinely new, and about the two places where the obvious
implementation is the wrong one: a Poisson solve is sixty dispatches per
spot, and a per-frame readback to find out what colour a sky is would undo
ARCH §6.1.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Film simulation is a feature, not a fix: five Ilford stocks, a grain
model that counts silver rather than adding noise, and the pipeline and
UI to drive them. 0.6.0 was tagged thirty-one commits ago and does not
describe any of that.
The Android versionCode follows from this without being restated --
package.sh packs MAJOR*10000 + MINOR*100 + PATCH, so 0.7.0 is 700, above
the 600 already installed on devices and therefore an upgrade rather than
a refusal.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every bake of a Vision3 stock logged:
unknown illuminant "K75P", falling back to D55
K75P is a cinema xenon short-arc lamp, and Kodak 2383 and 2393 -- the
projection print films those stocks print onto -- name it as the light their
result is looked at under. It was never implemented, so it fell through to
the unknown branch.
The fallback was the right family: a xenon arc sits near 6000 K, close to
daylight and nothing like the tungsten enlarger above it. So the pixels do not
move. What changes is that D55 is now a documented choice rather than the
consolation prize for an unrecognised string, with the approximation stated --
an arc has line structure a Planckian curve cannot express, and the residue of
that is small here because the viewing step adapts the white point out either
way.
The test is the point of the commit. A profile naming a light nobody
implemented should fail the suite, not whisper into a log that only gets read
when somebody happens to be looking for something else -- which is how this
was found.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Push and print exposure did nothing at all. The parameter was set and the
tables were never rebuilt, so the shader went on running the stock as it had
been baked.
The check asked the wrong list:
self.rows() // one entry per *parameter*, tab-filtered
.get(op_index as usize) // indexed by an *operation* index
.map(|_| ())
.and( ...the real check... )
`op_index` counts over the scoped capabilities -- it is what `lookup` resolves
a slider through -- so capabilities is the only list to ask. Indexing `rows()`
served no purpose, and past its end the `and` short-circuited to None and the
rebake silently never happened.
Both halves of that are worth saying. It was wrong, and it was convoluted, and
the convolution is what hid the wrongness: a one-line check would have been
obviously right or obviously broken.
This is a class of bug the suite cannot reach. The tables are rebuilt in the
interface layer, in response to a control, and every test either side of it
passed throughout -- dr-film computed the pushed curves correctly and the
shader rendered whatever it was handed. Only moving the slider showed it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Choosing Ilford HP5 Plus showed an inverted grey frame. So did Double-X.
They are negatives, and upstream leaves `target_print` null on every
monochrome stock, so nothing was ever printed and the scan was all there was.
A colour negative at least announces itself -- the orange mask says plainly
that you are looking at a negative. A monochrome one just looks broken.
They print on Kodak 2302 now, which is a monochrome print film and is what
such a negative is actually printed onto; Double-X onto 2302 is the standard
cine chain. For the Ilford stocks it stands in for an Ilford paper, which
nobody has measured, and is at least the right kind of material.
The scan is still reachable through the Scanned/Printed toggle. It is a thing
to choose now rather than the only thing on offer.
`every_shipped_stock_bakes` did not catch this, and could not: it derives
"should this be inverted?" from the stock's kind *and whether it names a
paper*, so it looked at an inverted HP5, concluded that was right for an
unprinted negative, and passed. The assertion was self-consistent and the
situation was still wrong. The new test asserts the thing that actually
matters -- a camera negative must name a paper, that paper must be a printing
stock, and it must be the same kind of material, so a monochrome negative
cannot end up on colour paper.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pushing was not a thing to simulate. It was measured data being thrown
away: Double-X and 2302 each ship five characteristic curves, one per
development time, and this shipped the 6.5-minute column and discarded
four. All five now ship and interpolate.
The axis is real. Double-X runs 4 to 12 minutes, and across it the average
gradient goes 0.472 to 1.034 while Dmax goes 1.19 to 2.56.
The control is in stops, because that is what a photographer means, and one
stop is a factor of about 1.41 in time. That mapping is checked rather than
assumed: against Double-X's own axis it lands within 2% of the 9-minute
column for +1, and near 12 minutes for +2, which are the times the datasheet
gives for exactly that. There is a test.
**Pushing must not recover shadow detail, and this does not.** Across the
whole measured range the speed point moves about a third of a stop while the
gradient doubles; three stops under mid-grey, density goes from 0.008 to
0.035, which is still nothing. Developing longer multiplies what was already
recorded and cannot record what never hit the film. A push built as added
exposure or global contrast brightens those shadows instead and looks
convincing until someone who shoots film sees it, so that property has a
test of its own.
Interpolated in *log* time, because development is multiplicative: 4 to 5
minutes is the same amount of push as 9 to 12, and interpolating linearly
would bunch the control at one end. Clamped at both ends, because past the
published range there is no data and extrapolating a contrast curve invents
an emulsion nobody tested. A stock measured at one process ignores the
control entirely rather than inventing a curve for it -- Portra 800's pushes
are separate *measured* profiles, which is the honest way to offer those.
Costs nothing per pixel and changes no shader. The curves are a per-stock
table, so the interpolation happens on the CPU at bake time, where choosing a
stock and moving its sliders already rebakes. The Vulkan shader is untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The android job now fetches the model and cross-compiles the whole app --
28 minutes of it -- and then dies on
file: not found (exit 127)
`Verify minimum API level` reads the linked API out of the .so's ELF
notes with file(1), and the image has never had it. Like git-lfs, the
absence could not show until something got that far: every previous run
panicked in dr-segment's build script long before this line, so the step
that was going to fail never ran.
Audited the rest of what the remaining steps invoke against the image
rather than find the next one the same expensive way -- zip, keytool,
base64, mktemp, shred, find, sed, awk, and aapt2/zipalign/apksigner/d8
from build-tools are all present. file was the only gap left.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Same drift as before and for the same reason: the matrix links to line
numbers, and the grain work moved lines in files that carry tags.
The stocks bring six new files and ten new tags -- 227 scanned against
221, 614 found against 604 -- all on requirements that were already
covered, so coverage is 51.4% (91/177) either side. Twelve rows move and
nothing else changes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The APK has been debug-signed with a key generated on the spot, which is
right for putting a build on a test device and useless for anything else:
a different signature every run, so nothing can ever update in place.
Four secrets now select a real signature -- ANDROID_KEYSTORE_BASE64 and
its password, alias and key password. The names are JellyTau's, because
that repo already signs its Android build this way against this same
runner and one convention across both is one thing to remember.
Absence of the secrets is not an error. A fork or a branch build has no
access to them and should still produce an installable APK, so the debug
path stays exactly as it was. The reverse is an error: if a keystore is
supplied and cannot be read, the build fails rather than quietly falling
back to a debug key, because a release that is silently debug-signed is
worse than no release.
Passwords reach apksigner and keytool as `env:`, never `pass:`. `pass:`
puts the password in the process table for anything on the box to read.
The keystore is written to a 0700 mktemp directory and never into the
workspace, which is both what actions/cache saves and what the upload
step globs.
Also: upload-artifact drops from v4 to v3. v4 was a guess about what this
Gitea supports. v3 is what JellyTau uploads its APK with on this runner
today, which makes it the version known to work rather than the one that
ought to.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
They were asked for and they are here, but not on the same footing as the
Kodak profiles, and the files say so in their first line.
What I had claimed, and had to withdraw: that Delta 100 is "quoted around 9"
and HP5 "around 12". Ilford publish no such figures. The word granularity does
not occur anywhere in their technical information -- grain is described as
"fine" and "finest" and nothing more. That claim was in this crate's
documentation as though it came from a datasheet; it is corrected there too.
Two further traps found while looking:
- Kodak colour negatives publish Print Grain Index, not RMS granularity.
PGI is a perceptual scale from viewer surveys -- 25 is roughly the
threshold of visibility, four units a just-noticeable difference -- and
Kodak state it cannot be compared to RMS. So a Portra number cannot be
dropped into the granularity field, and none has been.
- RMS proper is published mostly for black-and-white, reversal and motion
picture stocks. Every shipped stock therefore still carries the same
default, which means grain does not yet tell one film from another. That
is per-stock data, not code, and is now written down where somebody will
find it.
So the Ilford profiles are built rather than extracted, and each part rests on
something different:
speed published and exact -- ISO 400/27 for HP5 is a fact
contrast ISO 6:1993's normal development, average gradient 0.62
spectral borrowed from Kodak Double-X, a *measured* panchromatic
negative, shifted by the speed difference. Conventional
panchromatic sensitisation is much alike across black-and-white
films, and this is far better founded than reading pixels off a
printed curve
silver neutral, which is not an approximation: developed silver
absorbs flat, and Double-X's measurement is flat
granularity estimated, ordered by each film's known relative grain
They render as a film of that speed and contrast. They are not a measurement
of that emulsion, and the two stocks that share a speed differ only in the
estimated part.
`every_shipped_stock_bakes` is tightened to match, because a constructed
profile fails in a way a measured one does not: the curve parses, bakes, and
sits entirely off one end of its own exposure range, rendering every frame
black or blown while passing a finiteness check. It now asserts mid-grey lands
somewhere photographic and that the tone response runs the way the stock's
kind says it should.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An emulsion is a suspension of crystals. Light sensitises some; development
turns a sensitised one opaque, all or nothing. So a patch of film's density
is a *count* of developed grains, and a count of independent yes/no events
has a variance whether or not anyone wanted texture:
mean = D
variance = D * (Dmax - u * D) / N
That expression is the whole feature. It peaks in the middle of the density
range and vanishes at both ends -- clear film has nothing developed to vary,
black film has nothing left to develop -- so grain lives in the midtones as a
consequence rather than as a "midtone bias" slider.
I was wrong earlier that this needs the detail stage. Nothing in it reads a
neighbouring pixel; the only reason to move it was that grain must be fixed in
film space rather than screen space, and that solves itself: N is grains *per
pixel*, so it scales with the film a pixel covers. Zoom out, each pixel
averages more grains, less variance -- correct, with nothing super-sampled and
nothing filtered. It stays in the fused pass.
Grain goes on the density and *before* the dye, which is the physical order
and not cosmetic. Perturbing the finished colour -- what an effect does --
tints highlights wrong, because that noise never passes through the dye.
Crystal habit lives in `rms_granularity`, the number every datasheet
publishes, now a profile field. It measures exactly what differs between a
cubic emulsion and a tabular one: at equal speed, tabular crystals present
more area per unit silver, so the film reads finer. Delta 100 is quoted near 9
where HP5 is near 12, and that gap *is* the habit. Adding a stock whose grain
is its whole reputation is therefore editing one line, not writing a model.
Three things this cost, all of them worth writing down:
- The default granularity is a colour negative's, blue coarsest. Applied to
Tri-X it put *colour* speckle on a black and white photograph. Monochrome
stocks collapse it at parse, where every other per-layer table is already
replicated from the one measured channel.
- Helpers cannot read uniforms. The composer prefixes a uniform with its
operation's id and rewrites references inside a fragment body only;
helpers are shared and deduplicated, so a bare `gn0` names nothing.
`film_lut` already took its size as an argument for this reason, and now
says so.
- The end-to-end test compares the shader against the CPU model, and grain
is stochastic, so that comparison now runs with grain off. Which means a
grain that never left the CPU would look exactly like a passing suite --
hence a second test that grain off is bit-identical, one grain per pixel
moves it, and ten thousand move it less.
Not here, deliberately: no grain slider. The parameters are physical and
`rms_granularity` is the honest place to scale one from, but its range wants
choosing rather than guessing. Nor a film format -- 35 mm is assumed, and
medium format at the same stock is far less grainy per unit of picture.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Android job has never fetched the model. Not because of the header
collision the desktop job hit -- that one is fixed and the desktop job
now pulls all 11 MB -- but because the image has no git-lfs at all:
git: 'lfs' is not a git command. See 'git --help'.
The fetch step dies on its first line, `git lfs install --local`, before
any of the auth handling runs. The build then panics in dr-segment's
build script with a message telling you to run `git lfs install && git
lfs pull` -- advice that could not have worked, because the client it
names was never in the image to run.
Both jobs failing their fetch step at the same time made this look like
one bug with one cause. It was two, in two different images, and the
desktop one was noisier: it had a client, so it got as far as an HTTP
error worth reading. The android one had nothing to say beyond the name
of a missing command.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The desktop job died mid-link with LLVM reporting "IO failure on output
stream", which reads like a compiler crash and is not one: underneath it
is `No space left on device`. The runner ran out of disk while linking.
Worth knowing what it was spending it on. `target/debug` was 24 GB
against `target/release`'s 2.6 GB -- the test build is roughly ninety
percent of the footprint -- and of that, 15 GB was debug info in
`debug/deps` and 3.6 GB was incremental state. Neither buys anything
here. Nothing attaches a debugger to a CI run, and incremental
compilation exists to make the second build in a working tree fast,
which is not a thing a fresh checkout ever has.
With both off the same tree is 3.3 GB, `debug/deps` 2.8 GB, and the test
binaries build unchanged. Backtraces keep function names and lose file
and line numbers; if a failure ever needs those, DEBUG=1 gives line
tables back for a fraction of the 15 GB.
A `df -h` either side of the expensive steps, so the next time this
happens it says so in one line rather than as an error from LLVM.
This is a mitigation and it should not be mistaken for the fix. It bounds
what this job asks for; it cannot help if the runner is full of anything
else, and 24 GB of build output is not obviously the largest thing on a
host that also keeps every cached target directory this workflow has ever
saved. If it fails here again, the disk needs looking at on draco-x86.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The android job proved the app links for aarch64 and then threw the
result away. There was no APK anywhere in CI and no upload step in the
repo at all, so a green run left nothing anybody could install -- the
artefact list was empty by construction, not by failure.
It now assembles the APK with the script package.sh uses and uploads it.
The .so comes from the build the API-level check already ran; -o only
adds a copy of it where the packaging step looks, so this costs one copy
rather than a second twenty-minute cross-compile.
The signing key is the part worth being careful about. KEYSTORE points at
a mktemp directory rather than its default under target-android, because
that directory is precisely what actions/cache saves and restores -- the
default would have written a private key into the build cache and kept it
there. Nothing but the .apk is uploaded. A fresh debug key each run is
the right trade for an artefact meant to reach a test device: the only
thing a stable key buys is installing over a previous build without
uninstalling first, and a key that survives in cache storage to buy it is
a bad exchange.
if-no-files-found: error because the failure being guarded against is a
green run with an empty artefact list, which reads as success right up
until somebody goes looking for the file.
Debug-signed, arm64-v8a only -- the ABI the job already builds. Neither
is a release story; this is a build you can install.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
package.sh does two things: it decides how the host reaches the image, and
it assembles an APK once inside it. Only the first half is host-specific.
CI already runs in that image, so the second half was about to be copied
into a workflow step -- two copies of aapt2/zipalign/apksigner ordering,
drifting apart at whatever rate the toolchain moves.
So it moves to docker/android/assemble-apk.sh, which assumes it is inside
the image and takes its paths from the environment, because the callers
disagree about them: the container mounts the repo at /work, the runner
checks it out wherever it likes. Every default reproduces what package.sh
did, so the host path is unchanged.
Two things stop being hard-coded on the way. The build-tools version and
the compile SDK are resolved from what is installed rather than written
out as 36.0.0 and android-36 -- the versions are Dockerfile ARGs, and a
second copy is a second thing to miss when they move. --min-sdk-version
now comes from that same ARG instead of a literal 28, which is the number
the API-level check in CI already reads.
The intermediates are removed at the end. They were harmless in a cache
directory nobody looks at; beside a published artefact they are four more
files for a glob to pick up by mistake.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`traceability-check` fails on master: the committed matrix does not match
what the generator produces, so the gate that exists to keep the two in
step is the thing reporting they are not.
Nothing was traced or untraced. Coverage is 51.4% (91/177) before and
after, the same 604 tags against the same 177 requirements; every one of
the 32 changed rows is a line number that moved when the clippy warnings
were cleared -- `adjust.rs:2150` is now 2164, 632 is 651, 751 is 770.
The matrix links to lines, so touching a file above a tag rewrites its
row without changing what it says.
Regenerated with `cargo run -p traceability -- report`, which is what the
failing step tells you to run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The model fetch has been failing on every run with
LFS: Client error: .../info/lfs/objects/0672d7a7...
which reads like a rejected credential and is not one. The object is on
the server and downloads fine; what fails is the shape of the request.
`git lfs pull` makes two calls. The first, to `/info/lfs/objects/batch`,
succeeds -- and Gitea answers it with a short-lived `Bearer` JWT scoped
to that one object, for git-lfs to use on the second. git-lfs sends that
JWT *and* the `Authorization` header this step had installed in git
config, and two `Authorization` headers is a 400 from Gitea. Hence a
client error on the object one step after the batch call it just made
successfully, which is what made this look like an auth problem rather
than a duplication.
Confirmed directly against the server: the JWT alone on that URL is a
200, the JWT plus any second `Authorization` is a 400, and a lone token
header that is merely wrong is a 401 -- so the scheme was never the
issue. `lfs: true` on the checkout fails the same way and for the same
reason, because actions/checkout persists a header of its own; the
comment here blaming a credential the endpoint would not accept was
wrong on both counts.
So the headers are stripped -- checkout's included, since nothing later
in either job talks to the remote -- and the token is handed to git-lfs
as an ordinary credential instead. It authenticates the batch call and
leaves the per-object JWT alone.
This is what fails the Android job today: the build script sees a
133-byte pointer and panics by design, which is the message it is
supposed to give and the one nobody could act on.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing here is film simulation. These are lints that fail master today,
under the -D warnings CI runs with, mostly from a toolchain that learned
new ones rather than from anybody's code -- `is_multiple_of` and the
derivable `Default` did not exist as lints when this was written.
They are fixed rather than allowed, and by hand rather than by trusting
`cargo clippy --fix` wholesale: its automatic pass split a derive in two
and left a stray blank line, which is the sort of thing that is correct
and still wrong to commit.
The four that needed a decision rather than a rewrite:
- The distance transform's inner loop writes through its iterator now.
`q` stays, because it is the position the parabola is evaluated at as
well as the index it is written to -- the lint is about the write.
- `to_source` and `to_proto` take `self` by value. Their receiver is
`Copy`, so this is the same machine code and the honest signature.
- The export path's return type is five levels deep and now has a name,
plus a line saying why the `Option` wraps the `Result`: `None` is
cancellation, which is not a failure and has no error to report.
- A test fills a range instead of looping over one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI runs cargo fmt --check and clippy -D warnings, and this branch had
never been through either. Both would have failed it.
The bulk was the generated colour tables: eight significant figures where
an f32 carries about 7.2, so the eighth is noise that rounds away at
compile time and clippy's excessive_precision says so 109 times over.
Fixed in the generator rather than only in the file, so it stays fixed --
and the file is trimmed in place rather than re-derived, because
regenerating it needs a colour-science stack that has nothing to do with
the defect.
The format! in the composer is mine too, from extracting the rendering
tail: the braces in it were escaped because the text used to live inside a
larger template, and once extracted the escapes are noise and the call
formats nothing.
Also here, and clearly not mine: an unused import and a shadowed binding
in dr-gpu, and an unused import in a test. They are pre-existing --
clippy has been failing on master before this branch existed, on lints
like is_multiple_of that arrived with a toolchain rather than with
anyone's code. Fixed because CI cannot go green around them, and called
out because a merge commit is a bad place to quietly edit someone else's
crate.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Master gained the library's index paging while this branch was building
the film simulation, and the two met in library_ui.rs. Only the generated
traceability matrix conflicted; it is regenerated here rather than
hand-resolved, which is what it is for.
Three profiles was what the first cut needed to prove the model. This is
the rest of the open data: 23 camera stocks and 9 papers, which is all of
spektrafilm.
Black and white was the gap, and it turned out not to be a gap in the
data -- it was a gap in where I looked. Upstream's `main` has 28 colour
profiles and nothing monochrome; `dev` has three more, and they are
Tri-X, Double-X and the 2302 print film they go onto. So the answer to
"do we have B&W" was yes all along, and it needed the dev branch rather
than a fortnight digitising Ilford's datasheet graphs by eye. Those three
are pinned to `dev` per stock; the colour stocks stay on the released
branch.
A monochrome profile is single-channel -- one emulsion, not three -- and
spreading that one layer across all three is exact rather than an
approximation: three layers with identical sensitivity and identical
curves respond identically, which is what one layer does. The dye is the
trap. The renderer *sums* the three layers' contributions, so replicating
it unchanged renders every frame three times too dense -- neutrally, and
therefore plausibly. A third each reconstructs the single emulsion, and
two tests hold both halves: that the densities stay equal, and that they
sum to one emulsion and not three.
Double-X and 2302 ship five curves apiece, measured at five development
times -- 4 to 12 minutes for Double-X. That is push and pull processing as
measured data. The standard 6.5 minutes is what ships; the rest is in the
upstream file waiting for a control to ask for it.
Two stocks are `support: film` and are nevertheless what a negative is
printed *onto*: the cine projection films 2383 and 2393, which the
Vision3 stocks print to. Filtering the picker on support alone offered a
projection stock as something to load in a camera, so it filters on stage,
with a test saying so.
The picker had to change shape twice over. Chips were right for three
stocks and off the edge of a 280px column at twenty-four, and the column
that replaced them was a thousand pixels standing between the
photographer and every slider below. It is a disclosure now: one row
carrying the answer, opened to change it, closed again on choosing. That
is the opposite of the argument this panel used to take the lids off its
sliders, and deliberately so -- an instrument you compare wants to be
visible, and a list you consult once wants to be out of the way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The last of the per-scroll queries, and the strangest of them: this one got
slower the further down the library you had scrolled.
The marker has to follow every scroll event or it advances in jerks while the
photographs beside it move smoothly — that part is right and stays. What was
wrong is that each event asked the catalog `LIMIT 1 OFFSET n`, and that is not
a seek: SQLite reaches row `n` by producing and discarding the `n` rows before
it. 0.02 ms near the top of the library, 0.7 ms at twenty thousand, per row
crossed, on the thread drawing the frame. A flick therefore got choppier the
longer it went on.
The window the grid has already read holds the answer, and since the loaded
window now covers the whole view, the row is nearly always in it. So this is a
vector index at the position the ordinal has in the window, and the query
survives only as the fallback for a row outside it — briefly, after a scrub or
a keyboard jump, before the load lands.
The fallback is also the less correct of the two, which is worth recording
rather than quietly keeping: it counts in a dated-only ordering while the
argument is a grid row, so the two disagree wherever undated frames sit in
between. It is kept because a marker about to be corrected is not worth a
second index, and because being wrong there is what it always did. The window
path has no such disagreement — it reads the very cell the row belongs to.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The timeline's bars, the filter chips' counts, the "on this device" count and
whether the scoped collection is pinned all describe the *library*. None of
them can change because the view scrolled. `load_window` recomputed all four
every time the window moved, which is several times per screenful.
Together that is a `MIN`/`MAX`, a `GROUP BY`, two counts, and under a
collection three more queries — about 8 ms of SQLite on the thread that is
trying to draw the frame, for four answers that were already on screen and
already right.
They are now keyed on what they actually depend on: the scope, the filter,
whether this is the trash, and the total. The total earns its place as the
change detector as much as for the scrollbar — a scan landing, a delete or a
restore all move it, and it was already read on every load.
What a total cannot see is a rating edited under an unchanged count. That is
covered, and deliberately not by widening the key: `apply_judgement` already
refreshes the chips itself, because it has to report what actually landed
rather than what was asked for. Same for the axis — a zoom, a pan, a scrub and
dates arriving from the thumbnail worker each call `refresh_timeline`
directly. Skipping the recompute here cannot leave anything stale on screen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Scrolling jittered, and this was the largest single reason. Every window the
grid loads is `ORDER BY ... LIMIT n OFFSET k`, and neither half of that was
being answered the cheap way.
**The sort.** `GRID_ORDER` leads with `captured_at IS NULL`, so undated frames
fall to the end. No ordinary index answers that — the leading term is an
expression, not a column — so SQLite sorted the whole library into a temp
b-tree on every window read, then threw away the first `k` rows of it. Schema
V7 indexes the expression exactly as the query writes it, partial on the same
`shadowed_by IS NULL AND trashed_at IS NULL` the grid filters by, so the read
becomes a walk along the index.
**The join.** `LEFT JOIN remote` was paged *after* it was joined, so reading
280 cells at offset 20,000 first seeked into `remote` for all 24,000 rows and
then discarded 23,720 of them. The file ids are now fetched for the 280 rows
that survived — the shape the badge and rating reads already use, one query for
the window rather than one per cell.
Measured together on 24,000 images at offset 20,000: **15.2 ms → 0.36 ms**,
inside a scroll handler that has 16.7 ms to draw a frame.
The test asserts on the query plan rather than on a duration, because there is
no other symptom. A `GRID_ORDER` edited out of step with the index, or a column
added back that drags `remote` in again, both still return exactly the right
cells — just after sorting the library — and the jitter would come back with
nothing to point at.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The stock model rendered correctly and nothing could ask for it. This is
the picker, and the sidecar key that makes the choice outlive the session.
How the choice persists was the open question, and the answer was already
written down twice in sidecar.rs: `rating` is a top-level key "because a
rating is not an edit", and `masks` are one "because a layer is not a
scalar". A stock is that kind of thing -- a choice of material, not a
number a slider moves -- so it is a top-level key too.
It stores the **id**, not an index. Stocks are files that users add, so an
index would mean installing a profile silently changed which film every
existing photograph had been developed on. A name this build has no
profile for still round-trips untouched, because the alternative is that
syncing to an older phone quietly un-develops the picture.
Only the names travel. Turning one back into tables needs the profile
database, which dr-pipeline deliberately does not link, so `Version::apply`
clears the film and the session re-bakes -- after the parameters, because
the bake reads the film's own exposure sliders and the print balance is
solved against them. That is also why moving those sliders rebuilds the
lookup where no other control in the panel does: an enlarger's filtration
depends on how the negative was exposed.
The panel keeps its rule. It still names no operation and still generates
every control from a declared parameter kind; the stock gets a bespoke
control beside those, exactly as the mask stack does, and for the same
reason. The film's exposure and print exposure arrive as ordinary
generated sliders.
Two defaults worth stating. Picking a colour negative prints it, because
an unprinted one is an orange strip and offering that as the first thing
somebody sees after choosing Portra reads as a bug rather than as a
choice -- the toggle is there for anyone who wants the scan. And a paste
carries no film: a preset is a parameter map, and a stock is not a
parameter, so pasting one would paste a choice the clipboard never took.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The stock model landed in dr-film with no way to see it. This is the
pipeline node, the two texture bindings it reads, and the end-to-end test
that proves the shader agrees with the model.
The design point is that a film simulation is not an adjustment. Every
other node changes a picture; this one makes it. A stock's characteristic
curve does the camera profile's base curve's job -- from measurements
rather than from a curve somebody drew -- so running both renders the
scene twice: the camera's rendering, and then a film's rendering of that.
It looks like neither, and it reads as a colour-management bug with no
colour-management bug to find.
So `Operation::renders` is new. A node declaring it takes camera RGB and
hands back linear sRGB, and the composer emits neither the base curve nor
the conversion out of camera space. Both halves move together, and the
composer keeps them as one string precisely so that getting half of it
right is impossible.
The tables are not parameters, for the reason vignetting's coefficients
are not: they are measurements. dr-pipeline declares the layout as a plain
struct and keeps its no-dependency property; the two crates share no types
on purpose. `EditGraph::set_film_tables` offers them to every node rather
than to the one that wants them, because knowing which concrete type is
which is what the graph is organised not to know.
Bindings 4 and 5 follow the masks precedent: declared unconditionally so
one bind group layout serves every generated shader, bound to 1x1
placeholders when no stock is loaded. Both are interpolated by hand with
textureLoad -- this pipeline binds no sampler, and adding one for two
lookups would cost a binding in every shader. Uploads are keyed on content
so an unchanged stock does not push half a megabyte across the bus per
frame.
The end-to-end test earned its place immediately: it found the density
lookup being filled z-fastest while a 3D texture upload wants x-fastest,
so the red and blue axes were transposed. Green matched exactly, which is
what that bug looks like -- a plausible photograph of the wrong colour,
and one that every unit test on either side of the seam passes. dr-film
now pins the layout in a test that needs no device, and states it where
the field is declared.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
FR-DEV-3f asks for look emulation and proposes HaldCLUT import to inherit
the free film-simulation ecosystem. This takes the other road for the
stocks where the measurements exist: run the physics.
A stock here is its manufacturer's own datasheet -- spectral sensitivity,
characteristic curves, dye densities. Light exposes three emulsion layers,
the layers develop to densities, the densities are dyes that absorb, and
what is left is what reaches the eye. A colour negative comes out orange
and upside down because that is what a colour negative is; it becomes a
photograph when a paper profile prints it, with the enlarger's filtration
solved rather than dialled.
What that buys over a LUT is that the parameters stay physical. Opening up
a stop moves the picture along the film's real characteristic curve,
shoulder and all, instead of scaling a number baked at one exposure. The
data cost runs the other way too: a stock is 17 kB of published
measurements where one HaldCLUT is 800 kB of one person's grade.
It looks like it needs a spectral integration per pixel. It does not, and
that is the whole design:
- Exposure is a 3x3 matrix. The reconstructed scene spectrum is linear
in the sRGB triple, so the integral collapses into nine numbers,
exactly -- no approximation.
- The characteristic curve is three 1D functions, sampled exactly.
- Everything after that -- dye absorption, the print through the
negative, the paper, the viewing illuminant, the adaptation -- takes
exactly three numbers in, so it bakes into one 32^3 lookup.
Per pixel: a matrix multiply, three curve taps, one fetch. Splitting the
curve out of the 3D lookup rather than baking one LUT over exposure is
measured, not assumed: the curve carries the sharp shape and the dye
mixing is smooth, so folding them together would need three times the
resolution for the same error. At 32^3 the worst error is 0.003 in linear
sRGB, under one 8-bit code value, and a test says so.
No wgpu dependency, deliberately, and the same isolation argument dr-lens
makes: the model is plain f32 with a documented layout, so every property
worth asserting is asserted on the CPU. Binding it to a texture is dr-gpu's
job and is not done here yet.
The expected values in tests/ came from a Python prototype running against
a different colour-science stack. Agreement to three decimals is evidence
about the model rather than about one implementation of it -- a transposed
matrix or a mispasted observer row would pass every unit test and fail
that one.
Profiles are converted from spektrafilm by Andrea Volpato, CC BY-SA 4.0.
The converter is in the tree and runnable, so what was changed from
upstream is auditable rather than taken on trust; profiles/CHANGELOG.txt
records it, including the one deliberate deviation -- Mallett & Yuksel's
1 kB basis instead of Hanatos's 4 MB table, which costs accuracy at the
gamut edge and saves four megabytes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`core.hooksPath` points at `.githooks` for the traceability hook, and that
redirects *every* hook — including the four git-lfs installs for itself. They
were written there by `git lfs install` and left untracked, which is the worst
of both: present for whoever ran it, absent for everyone else.
`pre-push` is the one that matters. It is what uploads LFS objects, so without
it a push can land a pointer on the server with nothing behind it — which is
exactly the failure CI has been hitting from the other side, and not a state to
risk creating by accident.
Committed rather than regenerated per clone, because `git lfs install` writes
to `.git/hooks` by default and would miss the redirect entirely.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The gesture had a hidden operand. A range runs from the anchor — the last cell
plainly clicked — to the cell shift-clicked, and nothing on screen said which
one the anchor was. A user who could not tell where the range was being measured
from had no way to predict what it would take and no clue why a wrong one came
out wrong; often the anchor is not on screen at all, which is itself the answer
to "why did that select so much".
The anchor is marked with an inner ring, drawn inside the selection ring rather
than in a colour of its own: it has to stay legible against a thumbnail of any
brightness, and a hue would read as a second kind of selection. It is an
ordinal, so it marks a row only while the photograph it names is in the loaded
window — off screen it marks nothing, which is the honest answer, and `anchor`
rides in the cell model beside `selected` so both are pushed by the one pass
that already keeps the grid in step with the selection.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Shift-clicking two photographs selected only the cells between them that were
in the loaded window. The grid is a window of a hundred or so over a library of
twenty thousand, and `apply_press` resolved the range against that window —
`ids[lo_row..=hi_row]`, clamped to what was there. Everything else in the run
had no id anywhere in the UI, so it was silently dropped. The user cannot see
that: the selection count is off screen along with the photographs, and the
gesture only announces itself when the drop files a dozen images instead of two
hundred.
The two ends are *ordinals*, and only the catalog knows what lies between them.
`read_ids_span` asks it, through the same predicates, the same rating filter and
the same ordering the window itself is read with — an ordinal names a photograph
only relative to an ordering, so a run taken through any other one is a run
through a different library. That ordering is now a constant, `GRID_ORDER`,
shared by the window, the trash's own order beside it, and the run: capture time
first, with the file name breaking ties and nothing more. A card written by two
cameras interleaves names that have nothing to do with each other, and what
"everything between these two" means to a photographer is a stretch of an
afternoon.
The query is reached through a closure handed to `CollectionsController` at
wiring time rather than a catalog handle, because the scope and the filter that
bound the run belong to the grid's controller. `apply_press` stays a pure
function of what it is given, which is what keeps the selection rules testable
with no library open — and the tests pass a run that reads a plain slice. Where
there is nothing to ask, the loaded window is still used: a poorer answer than
the catalog's and a far better one than a gesture that appears to do nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The other half of the blank bottom row, on a library still filling its
thumbnail store: the cells were in the model, and nobody had asked the server
for them yet.
A batch is fetched one image at a time, two round trips each, and it is
abandoned wholesale the moment the window moves. Issued in model order, the
front of that queue was the quarter-window of cells sitting *above* the view —
which nobody is looking at — and the back of it was the bottom of the screen
and the screenfuls below. So the last rows of the grid waited behind three
quarters of a window's worth of fetches for photographs off screen, and every
scroll threw the queue away and started over from above the view again. For as
long as the scrolling continued, the bottom of the grid could be starved.
The rows still address the model they were built against; only the order they
are asked for in changes. On screen first, in reading order, then the rows
below the view, then the rows above it. Below before above because that is
where the view is going — scrolling back over cells already fetched is served
from the store, and from `requested` without a fetch at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bottom row of the grid was often blank, at a scroll position the user
could sit at indefinitely. Two numbers decided when the loaded window follows
the view, they lived in different languages, and they disagreed.
The grid loaded three screenfuls and Rust held the window still until the
first visible cell was three quarters of the way through them. Three quarters
of three screenfuls is 2.25, and the view itself is one screenful tall — so
the bottom of the screen had already travelled a quarter of a screenful past
the last loaded cell before anything moved. Those rows are not in the model,
so nothing is drawn for them.
The same margin was wrong upward, and exactly so. The window is placed a
quarter of itself behind the view, and the margin then declared the view too
close to the top at precisely that distance: every single row scrolled upward
re-read the catalog, rebuilt all 360 cells and re-queried their badges and
ratings, and so did the row after it.
So the grid now reports what it shows — a screenful, counting the row the
scroll position has cut in half, which `visible-rows` alone undercounts and
which is exactly the row reported missing — and Rust owns the rest: four
screenfuls loaded, placed a quarter back, and moved once the view comes within
half a screenful of an edge of them. One decision in one place, and the test
now walks the view the length of the library and asserts the window covers it
at every step.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two CI failures, unrelated except that both were mine.
**The test binary faulted under parallel threads.** Every GPU test opened its
own `GpuContext`, and `cargo test` runs on as many threads as there are cores —
so a full run asked the driver to bring up a dozen Vulkan devices at once and
died with SIGSEGV. Serially it passed, which made it look like flakiness rather
than a fault in the harness. One device now, behind a `OnceLock`: a
`GpuContext` is an `Arc<Device>` and an `Arc<Queue>`, so sharing it is a
refcount, and the losers of the race block until the winner is done. 416 tests
now pass in parallel, in half the time twenty-six devices took.
**`lfs: true` on the checkout made the checkout fail.** The intent was right —
the model is in LFS, a plain checkout writes a 133-byte pointer, and the build
script panics on it — but on this server `git lfs fetch` is rejected at
`/info/lfs/objects/<oid>` with a client error: the credential `actions/checkout`
installs for git is not one the LFS endpoint accepts. So a fetch problem
presented as a checkout problem and took the whole job with it.
The object is on the server; a clean clone over SSH with `git lfs install
--local` pulls all 11 MB of it. It is now its own step with an explicit token,
and `continue-on-error` so a credential problem cannot masquerade as a broken
checkout — if it fails, the build still runs and fails with the build script's
own message, which names the real problem.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Set by tools/set-version.sh, which is the only thing that should. The
workspace, the pacman package and — through Cargo.toml at link time — the
APK all state 0.6.0, so a bug report naming a version names one commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The grid still jumped. Anchoring on `offset` was wrong for a reason this file
already states, a few hundred lines away: "the first *visible* ordinal, not the
window's start: the loaded window deliberately begins a quarter of a screen
above the view, so its first cell is one the user cannot see."
Two things made `offset` the wrong number. It is a screen-quarter above what is
being looked at, and by the point this runs it has already been re-clamped
against the new, smaller total — so seeking to it moved the view somewhere the
photographer had not been. A smaller jump than the original, and the same fault.
`resume_at` is what the grid last reported as its first visible image, which is
the photograph the person is actually looking at.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deleting made the grid go blank and jump somewhere arbitrary. Two causes, both
of them the viewport being left behind by everything else that moved.
The Flickable is sized to the *whole* library so its scrollbar is a real
address into twenty thousand images. Delete some and that content gets shorter,
which leaves a view near the end scrolled past what now exists — cells sitting
above a viewport looking at empty space. Slint does not pull a Flickable back
on its own. (The clamp for that went in with the previous commit.)
The jump is the other half. Cells are drawn at their absolute place in the
library, `(i + offset) / columns`, and a delete re-clamps `offset` downward so
the loaded window still fills. Nothing touches `viewport-y`, so the same scroll
position now addresses different photographs and the grid appears to leap
somewhere unrelated.
`restore_position` re-anchors on the ordinal the view was showing, clamped into
what is left. Not on the deleted image's own position, which no longer exists,
and not on the top of the library, which would throw the scroll position away
on every delete — after removing one frame from a wall of twenty thousand, the
one you want next is the one that just moved into its place.
Only on a shrink, and the shrink is detected by reading `library-total` before
overwriting it. Re-anchoring on every load would fight a scrub, which sets
exactly this property to go where the user asked.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The range could be turned on with a finger and not aimed with one. Its two
ends were typed as `YYYY-MM-DD` into 108px fields behind a soft keyboard, to
name days already drawn on the axis a thumb away; and the chip that seeds them
takes its span from the timeline's zoom and pan, which are a wheel and a middle
button. A touch screen has neither, so on Android the filter was a switch with
no aim.
The band is now on the timeline. Two ends with grips, dragged along the bars,
released to filter — the histogram was already how a period is found, and this
makes it how a period is stated. Both ends snap to whole days, which is what
the typed fields mean, what `show_range` reads back out, and a floor under a
range dragged shut. The fields stay for what dragging cannot do: name an exact
day, and say in words what the range is.
For that to work the axis had to stop following the range. Redrawn to the band,
it moved the ground under the very handles doing the narrowing, and there was
nothing outside the range left to widen back into.
While there: a fixed number of equal bins instead of calendar buckets. Between
one calendar unit and the next the bar count is free to wander by a factor of
twelve, so zooming in halved it two steps out of three — the same picture drawn
wider until it jumped back to fine. Equal bins also include the empty ones, so
a bar's position on the track and the date under it are finally the same
quantity; before, a library with gaps drew a February six months wide and the
marker, the band and a click all pointed somewhere else. The count is a
setting, 32 or 64, because the right answer is a question about the screen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three things a selection needed and did not have.
**Seeing it.** The count existed — "12 selected" — in the header row, which
scrolls sideways. On a tablet it sat past the right-hand edge along with every
button beside it, so a selection was something you could make and then not
see. A selection you cannot see is one you act on by accident.
**Putting it down.** The only way to clear one was "Done", which also leaves
select mode — so after filing forty photographs the next forty began by
re-entering a mode the user had not meant to leave. Clearing is now its own
action and keeps the mode.
**Filing it somewhere new.** Making a collection of a selection took four
steps: create one, find it in the tree, select the photographs again because
creating it changed the scope, then add them. It is one press, which is how a
selection is usually meant — it is gathered *because* it is going somewhere.
The new collection is created at the top level rather than inside the current
scope, unlike the tree's "+". A selection can be gathered from anywhere,
including across collections, so filing it under whichever one happens to be
open would put it somewhere its contents did not come from. It opens straight
into its name field, for the reason `collection_new` already does: the
placeholder name is nobody's choice, and making the user find the rename
afterwards is asking them to finish a job we started.
All of it on its own strip beside the date range's, appearing only while there
is a selection — the third control this session that was invisible for being
put in a row that scrolls.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The traceability gate has failed on six commits in a row, every time for the
same reason: someone added a `TRACES:` tag and did not regenerate
docs/traceability.md. The gate is right to fail — a matrix that disagrees with
the tree is worse than none, because it is read as current — but it says so
after a push, on a commit that is otherwise fine, and by then the tag and the
matrix are two separate things to remember instead of one.
A pre-commit hook regenerates it and stages it, so the two travel together.
Enabled with `core.hooksPath`, which is a local setting: run
git config core.hooksPath .githooks
in a fresh clone, or the hook sits there doing nothing.
Only runs when something that can carry a tag is staged, and says nothing
unless it changed the matrix — a hook that prints on every commit is one people
start passing `--no-verify` to. If the report cannot run at all it leaves the
matrix alone and lets the commit through: refusing to commit because a build is
broken would be a worse failure than the one it prevents.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six commits added nineteen TRACES tags between them and none regenerated the
matrix, so the gate failed on every one of them — including the commit the
release tag points at. The gate is doing its job: a matrix that disagrees with
the tree is worse than none, because it is read as current.
Coverage is unchanged at 50.3%; what moved is where each requirement is
tagged, which is the half of the file that is actually consulted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pressing "limit to range" did nothing, and the reason was two early returns
that sat above the line which shows the controls. If the catalog was not open,
or nothing in it carried a capture date, the handler returned before
`set_library_range_active`, so no state changed and nothing appeared.
Capture dates are read from EXIF as thumbnails load, so a freshly opened
library has none — the button was inert on exactly the libraries where someone
is most likely to go looking for a date, and it failed by doing nothing at all,
which is the hardest failure to report.
Underneath that was a smaller mistake with the same shape: whether the controls
showed was read from whether a range was set. The fields are how a range gets
set, so requiring one before they appear is a door locked from the inside. The
panel has its own state now, and the timeline span is a seed for the fields
rather than a precondition for them — no span means two empty fields waiting to
be typed into, which is a way to choose a range rather than a refusal to offer
one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Third attempt at one control, and each failure was a different reason it could
not be seen.
It narrowed to the whole library, because it took its span from a timeline zoom
that is zero until someone zooms. The fields that fixed that went into the chip
row, which scrolls sideways, so they sat past the right-hand edge. And moving
them "below the chips" put them inside the same `Rectangle` — which stacks its
children at the origin rather than laying them out, so they were drawn over the
chips inside a strip 34px tall, unconstrained in width and clipped in height.
A `Rectangle` is not a layout. The row is a sibling of the chips' strip in the
header's `VerticalLayout` now, with a height of its own and the width of the
window: two 108px fields, a "to", and a warning when what was typed is not a
date — about 354px, against 768 on a tablet in portrait.
Left-aligned and inset by the same gap the chips use, so the two rows begin on
one vertical line instead of a few pixels apart.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Limit to range" looked broken a second time, for a second reason. The ends
were added to the filter chip row, and that row scrolls: its own comment
records that fourteen chips do not fit across 768 logical pixels, "so that is
every tablet in portrait". Two date fields and a caption went straight past the
right-hand edge, into the part of the row that has to be panned to.
So the fix for a control that appeared to do nothing was itself invisible, and
pressing the chip still looked like it did nothing.
They have their own line now, below the chips and outside the Flickable, and it
exists only while a range does. Nothing competes with it for width, and nothing
has to be panned to reach it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`cargo fmt --check` is a required step and the mask-refine work landed with a
test body it disagrees with. Whitespace only — kept as its own commit so it can
be skipped wholesale rather than read for a change that matters.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two related gaps in the mask panel, from the same conversation: a subject's
outline is only ever as sharp as the whole-frame pass that found it, and a
change meant for several layers had to be dragged once per layer.
## Refine mask
A "Refine mask" button on a subject layer re-runs detection on a padded crop
around that instance's own box instead of the whole frame — the subject
reaches the model at its own size rather than squeezed into the model's fixed
640x640 window alongside everything else in the photograph. `RefineJob`
mirrors `SegmentationJob`'s split (built on the session, run off it, adopted
back), and the crop itself is rendered through `Framing::set_view` — the same
ephemeral viewport the interactive zoom already uses to render a region above
proxy resolution, so no new render path and no change to the model's own
input size was needed. `dr-segment` is untouched: `Tiling::Whole` already
treats whatever buffer it is handed as the one window.
The result is still downsampled onto the shared proxy grid every instance's
mask lives on, but from a sharper source than the whole-frame pass ever saw
for that subject, which is what the edge actually reads out of.
## Multi-select
`active_mask: Option<String>` is now `active_masks: Vec<String>`. A plain
click still replaces the selection; a control- or command-click toggles one
layer in or out of it. `set_param` and `reset_op` fan out to every selected
layer, each set to the exact value the slider now shows rather than offset by
however far it already was — one slider, one reading, applied everywhere
selected. Dragging a gradient's on-canvas handle is deliberately not
extended to multi-select: several gradients have no single geometry a shared
handle could move, so `gradient_handles` stays empty unless exactly one
layer is selected.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Comparing a larger model against a larger input size meant re-exporting at
resolutions other than the shipped 640, and the script only ever wrote that
one number. `IMGSZ` is now a second positional argument, defaulted to 640 so
every existing call is unchanged.
The experiment this was built for found bigger input a net loss on its own
merits — yolo26n-seg and yolo26s-seg at 1280 both lost track of large,
frame-filling subjects (a bus's box shrank and its score nearly halved)
in exchange for catching small or partially-occluded ones tiling already
handles. Nothing shipped from it, but the ability to re-run that comparison
is worth keeping.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
"Limit to range" did nothing, and the reason was not visible from the button.
It took its span from the timeline's zoom, which is zero until someone zooms —
so `zoomed_span` returned the whole library and the filter narrowed to
everything. The chip lit up and the grid did not change.
The range has ends now, shown and typed as `YYYY-MM-DD`. Seeding them from the
timeline is kept, because zooming to a fortnight and pressing the chip is the
fast path; the fields say which fortnight it landed on and let it be corrected.
Ends given backwards are swapped rather than refused — there is exactly one
range between two days — and the closing day is included, since "to the 5th"
means the whole of the 5th and a range ending at its midnight contains none of
it. `parse_date` refuses anything that is not a date rather than guessing at an
order, because the alternative is a library silently filtered to a span nobody
asked for.
The axis then follows the range. It used to keep drawing the full extent while
a range was on, because it was the only way back out; the typed ends are the
way back out now, so it is free to show what was asked about.
And bucket size is chosen by how many bars it makes rather than by fixed
cut-offs. Each zoom step halves the span, so under thresholds the bar count
halved with it until a boundary was crossed: fifteen years went 15 bars, 8,
then 46, 23, 11, and finally 6. Zooming in made the picture coarser, which is
the opposite of what zooming is for. Aiming at forty bars keeps the count in
the same neighbourhood at every level, and the test asserts the property
directly — halving a span never coarsens the bucket.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A local adjustment survived until the session ended and then did not exist.
Nothing reported it, because nothing had failed.
The sidecar format has carried masks since they were added, `Version::apply`
restores them into the graph, and `Version::update` captures them — that work
landed complete and was never called. The autosave writes `copy_settings()`,
which is a `Preset`: a map from (operation, parameter) to a number. A mask is
not a parameter. It is a rule about *where*, with a chain of its own, so it
fell outside the only thing being written, and the read path had nothing to
read.
The save now carries the stack beside the preset, on both the local and the
remote path. Deliberately not merged but replaced wholesale: this is the stack
as it stands, so a layer the user deleted has to leave the file too. Merging
two devices' stacks is `Sidecar::merge`'s job and belongs to sync (FR-NC-9).
A paste still carries no masks, and the `Option` is how that is said. Settings
travel between photographs; a mask does not, because it is drawn against one
frame and describes nothing on another — and `Scope` cannot express that,
since it filters parameters and a mask is not one.
The test fails against the old save path with "the mask must come back with
the photograph", which is the whole of the defect: not a crash, not an error,
just an edit that was not there in the morning.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Set by tools/set-version.sh, which is the only thing that should. The
workspace, the pacman package and — through Cargo.toml at link time — the
APK all state 0.5.0, so a bug report naming a version names one commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A grid cell is about four words wide and `.CR2` spent one of them saying
something the photographer already knows: in a RAW library every frame ends
the same way, so the extension distinguishes nothing while taking room from
the part that does. The caption elides from the end under pressure, so an
extension can push the digits that actually identify a frame off the visible
part of its own label.
Dropped in the view, not in the model. `LibraryCell::name` keeps the true
filename and `remote_path` the full path, because both are used to find the
file again and a stem is not a filename.
The case it is wrong for, recorded rather than discovered later: a library
holding `IMG_1234.CR2` beside `IMG_1234.JPG` now shows two cells captioned
`IMG_1234`. They remain two rows with two thumbnails and two entries in the
info panel, and a RAW+JPEG pair is usually one photograph anyway — but the
caption alone no longer separates them.
Only the last dot goes, and only when something precedes it: `2026.08.23-a.dng`
keeps its dates, and `.hidden` keeps its leading dot, because that dot is how
the name starts rather than an extension.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The render backend, the layout class and the frame rate sat permanently in a
44px strip that also carries the only way out of develop, the undo pair, the
panel toggle and the export button. They cost about 200 logical pixels, and a
`HorizontalLayout` given less width than its children's minimums does not
shrink them — it runs off the end.
There was already a breakpoint hiding them below 820px, which is why a tablet
in portrait (768) looked fine and landscape (1200) did not: above the
breakpoint the readouts came back and pushed the header off the right-hand
edge. A breakpoint that hides a problem at one size and not another is a
workaround, and this removes the reason for it rather than moving it.
They are diagnostics — read once when something looks wrong, and then not
again — so they are in Settings under ABOUT, beside the version, which is
where someone goes when they have a bug to report rather than a photograph to
edit. The version is there for the same reason and comes from
`CARGO_PKG_VERSION`, so the line cannot disagree with the binary showing it.
The frame rate keeps its warning hue. It is the number that says whether the
zero-copy display path is holding up, which is the assumption the whole
display design rests on, and that is worth colour wherever it is shown.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Look closer" tiles the frame so a small subject reaches a fixed 640x640 model
at its own size. A tile sees only the part of an object inside it, so an object
on a seam produces two *partial* masks — neither of them the object.
This kept the higher-scoring one and discarded the other, which quietly threw
away what tiling had just been paid 2.8 seconds for: a bird with its tail cut
off at a tile edge, described by whichever tile happened to hold more of the
bird. Both halves existed; one survived.
They are unioned now, and the overlap is what makes that sound. At 25% every
pixel is seen by at least one tile at full resolution and pixels near a seam by
two, so the pointwise maximum is the better estimate everywhere rather than a
compromise: where one tile saw a pixel its opinion is the only one there is,
and where both did, the higher value came from the tile with more context
around it. A maximum of soft coverage also stays soft, which is what `prior.rs`
weights merges by and what a mask layer's edge treatment needs.
Each quantity gets the operation that suits it: maximum for coverage, union
for the box, and the higher score rather than a blend — the score is shown to a
photographer and means "how sure the model is this is a bird", so averaging in
a tile that saw a wingtip would make a confident detection look doubtful for
straddling a seam.
The test fails against the old rule with "pixel 4 was seen by a tile and must
survive the merge", which is the whole defect in one line: not a crash, not a
duplicate, just a plausible mask missing half its subject.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The workspace read 0.1.0 for two releases, so every desktop binary reported a
version two releases stale. The APK was worse: `AndroidManifest.xml` states no
version at all, so a device showed `versionName=null` and `versionCode=0` while
the library inside the APK knew exactly what it was. A version edited by hand
in several files is a version that is wrong in at least one of them.
`tools/set-version.sh` is now the only thing that sets one. It takes the
version from the latest git tag, or is told, and writes the two files that must
state it before anything is built: the workspace `Cargo.toml`, from which every
crate inherits, and `packaging/PKGBUILD`, which pacman reads before a build
exists. It refreshes `Cargo.lock`, because members appear there by version and
CI builds `--locked`. `--commit` commits the result.
Android is not in that list on purpose. `package.sh` reads the version out of
`Cargo.toml` and hands it to `aapt2 link`, so the APK cannot drift from the
binary it contains — there is no third file to forget. `versionCode` has to be
one increasing integer, which a semantic version is not, so it is packed as
MAJOR*10000 + MINOR*100 + PATCH: ordered the way Android requires, and readable
at a glance.
A version that is not MAJOR.MINOR.PATCH is refused rather than coerced. It is
a contract with whoever reads a bug report, and silently turning "0.4" into
something else is worse than being asked to type it again.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three faults reported together, and they share a shape: each is something the
panel decided on the photographer's behalf.
**The column was 280px on every screen.** That width was chosen for a tablet,
where the column is a large fraction of the display and every pixel of it is
taken from the photograph. On a desktop window the mode strip alone — two
modes, a separator, "All", and a chip per attribute the operation set declares
— does not fit, so it scrolled sideways. A control you have to pan to reach is
one you do not know is there. 380px when the window is classed expanded, 280px
when it is not; driven off `layout-class` because reading the window width
inside the layout that sets it is a binding loop.
**The overlay could not be hidden.** An earlier "Overlay" button was removed
for a good reason — it *armed* the overlay, so local mode could be entered and
still show nothing. Hiding is the opposite need and was never served: a mask is
judged against the photograph beneath it, and that photograph is exactly what
the overlay covers. `overlay-hidden` is kept separate from `overlay-on` so a
recompute cannot switch the overlay back on under someone who just turned it
off.
**The masks were coarse because the model saw the subject small.** The graph's
input is a fixed 640x640 and every frame is letterboxed into it, so a bird
200px across in a 1600px proxy reaches the model at 80px. `Tiling::Grid` has
been implemented and tested since the segmentation spike and defaulted off,
because it costs one inference per tile — 2.8s for a 3x2 grid against 470ms.
Now offered as "Look closer (slower)", which says what it costs, rather than
spending it on every image or on none.
The tiling choice enters the segmentation signature. A mask stores the
signature its region ids index into, and a tiled run finds different instances
in a different order; sharing a signature would silently reinterpret a layer
built against the coarse pass — a wrong mask rather than a stale one, and
nothing announces it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both failing jobs failed for the same reason, and it was not the reason either
of them appeared to fail. Desktop reported a Clippy failure and Android
reported the API-level check; both were `dr-segment`'s build script panicking
because `models/yolo26n-seg.onnx` was a 133-byte LFS pointer rather than the
11 MB model.
No checkout step asked for LFS objects. `.gitattributes` predicted this exactly
— "a clone without git-lfs gets a ~130-byte pointer file where the model should
be" — and the build script fails loudly by design rather than embedding a
pointer and failing at inference. That design worked; nothing was reading the
message.
It stayed hidden because the Android job built `-p dr-gpu`, which never reaches
dr-segment. Building the app crate does, which is how one fix surfaced another.
Only the two jobs that compile get `lfs: true`. `layering` runs `cargo tree`
and builds nothing, so it has no reason to fetch 11 MB.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The gate regenerates it and fails if the result differs from what is
committed, which is what it is for: a matrix that disagrees with the tree is
worse than none, because it is read as current.
Stale since the detail stage landed — 160 files and 436 tags then against 180
and 521 now. Coverage 49.1% to 50.3%, and no orphan tags.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The icon was already embedded and had been all along: `app.slint` sets it and
`build.rs` compiles it into the binary with `EmbedFiles`. That is not what a
Wayland compositor looks at. It ignores a client-set icon entirely, matches
the surface's `app_id` against installed `.desktop` files, and takes the icon
from the one whose basename agrees. Nothing set an app id and no desktop entry
existed, so GNOME had nothing to resolve and drew the placeholder.
Both halves are needed and neither substitutes for the other: the embedded
icon is what X11 and the window itself use, and the desktop entry is what the
overview, the dash and alt-tab use.
The app id, the `.desktop` basename and the installed icon's filename are one
string in three places — `paris.tourolle.darkroom`, the same reverse-DNS name
the Android manifest already uses, because it is one application. Change one
without the others and the icon disappears again with nothing logged, so each
file says so where the string appears.
The PKGBUILD builds from the checkout rather than a release tarball, so
`makepkg -si` installs what is being worked on. Install verified by inspection
of the built package: binary, desktop entry, and the icon under
hicolor/256x256 by the name the entry asks for.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Not a patch: this carries a data-loss fix and a feature.
Collection membership merged on `images.content_hash`, which the schema
computes only for import dedup and reconnect — never in a scan. On a synced
library it is NULL for every image, so the union matched nothing and the
catalog push that follows a merge overwrote whatever the other device had
uploaded. Collections arrived named and empty on every device, and each sync
destroyed the other's membership. Keyed on `oc:fileid` now, which every scanned
image has. Verified on a 23,000-image library: 247 memberships recovered, and a
round trip between tablet and desktop confirmed in both directions.
Card import (FR-CAT-10, FR-CAT-11, FR-NC-7a, FR-NC-7b) arrives with it: the
ingest engine, the write half of the storage abstraction, removable-volume
detection, duplicate detection in two tiers, dated folders on both sides, and
thumbnails made while the originals are still in hand. Uploads always go to the
library on the server; what lands on this machine is a staging copy the app
removes once the upload is confirmed, and a move-import empties the card only
of photographs the server has acknowledged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found uncommitted in the working tree and committed as its own change rather
than folded into the sync work beside it: the three header readouts gain
`overflow: elide` and `horizontal-stretch: 1`, which is what actually lets a
Text shrink under pressure in Slint. Without the stretch they kept their full
natural width and pushed the sidebar toggle and the More button past the right
edge on a narrow window, reachable only by knowing to flick-scroll.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
New-York gained 127 photographs from the tablet and the sidebar went on showing
no number beside it.
Two reasons, and the second is why the first was never noticed. The sync's
completion handler reloads the grid and never rebuilds the tree — the scan
already does, and the sync merges the same tables. And the condition it reloads
under was `collections_gained > 0`, which counts collections, not members: a
sync that files 127 photographs into a collection both devices already had
gains no collection at all, so the count was zero and nothing refreshed.
`SyncReport` now carries `members_gained` from the merge report, and either one
rebuilds the tree.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The note above the core-crate check said the UI and app crates would join
"once the Android shell exists". They already had. Building `darkroom-android`
for aarch64 takes 4m23s and produces `libdarkroom.so`, linked for Android 28 —
which is what the step below now checks, and what a device installs.
Rewritten to say what the core check is actually for: a fast, link-free gate
that fails early and names a smaller suspect, ahead of the full build.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
window.run() returning unwinds through main, which races a still-live
zbus/keyring background thread's TLS teardown and aborts with
"thread local ... during or after destruction". Skip that unwind with
a hard exit once the run succeeds; dr_ui::run itself is left alone
since the Android entry point also calls it and must not be exited.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Collection membership merged on a column that is NULL on every scanned
library, so collections synced their names and arrived empty everywhere.
Keyed on oc:fileid now, as keyword assignment already was.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The step built `-p dr-gpu` and then looked for a `*.so` to read the API level
out of with `file`. dr-gpu declares no `crate-type`, so it produces an rlib —
an archive of object files no linker has touched, and about which `file` has
nothing to say. The step could therefore only ever reach its own "no aarch64
.so was produced" branch, whatever the linker did, which is the one thing it
exists to rule out.
Builds `darkroom-android` instead: the workspace's only
`crate-type = ["cdylib"]`, and the artefact that ships in the APK. The API
level verified is now the one a device will refuse to install against.
The neighbouring comment claiming only core crates cross-compile is left
alone until the build proves otherwise; it is either stale or this commit is
wrong, and the same run answers both.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Collections synced their names and arrived empty on every device. The names are
keyed on a uuid and worked; the membership union was keyed on
`images.content_hash`, and the schema says plainly what that column is:
"computed only when something needs it (import dedup, reconnect-by-hash), never
in a scan". A library that has only ever been scanned has one for no image at
all, so the join matched nothing and `WHERE ri.content_hash IS NOT NULL`
discarded whatever survived. The union could never have moved a single row.
Measured on a real catalog: 23,174 images, content hashes for 0 of them,
`oc:fileid` for all 23,174, twelve collections, zero members.
So membership now resolves through the file id first, exactly as keyword
assignment already did — `ASSIGN_BY_FILE_ID` was added for this same reason and
its doc comment even notes that membership was still on the hash. It is
recorded for every image the moment a remote scan sees it, survives server-side
rename and move (FR-NC-5), is the same integer on every device pointed at one
Nextcloud, and is already what the thumbnail shards are keyed by.
The content-hash union is kept rather than replaced: a local-only library has no
`remote` rows, and where a hash has been computed it is a true identity that
survives a library moving between servers. Both statements run; `INSERT OR
IGNORE` against the primary key makes the overlap free.
This repairs the merge. It cannot invent membership that no device recorded —
where the rows were never written, collections stay empty until they are filled
in again.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`cargo fmt --check` is a required step and had drifted across 45 files. Most of
it arrived this week: several operations were written in parallel worktrees and
merged by hand, and a hand-merge resolves conflicts without ever running the
formatter over the result.
No behaviour changes — this is `cargo fmt --all` and nothing else, kept as its
own commit so the next reader can skip it wholesale rather than search it for
one that matters.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a local destination, defaulting to ~/Pictures, and an "upload" switch
that could be turned off. That was wrong twice over. DarkRoom's library *is* a
folder on a Nextcloud server (FR-NC-6) — there is no local library — so a
user-chosen local destination built a second pile of photographs that no view
in the application ever lists, and made "where did my import go" a question
with two answers.
An import now has exactly one destination and the page asks nothing about it.
With no account there is nowhere to go at all, so Import is refused rather than
quietly filling a folder.
What lands on the device is a staging copy in a directory the app owns, the
same shape `export` uses for its outbox and for the reason its module docs
give: staging first is the only path, not a fallback for being offline. The
bytes have to reach disk before the network — streaming a card straight to the
server would let a move-import erase a card against an in-flight upload, and
would make importing impossible with no connection (FR-NC-10). A staged file is
removed once the server confirms it; one that is not confirmed stays queued, and
the next import drains it.
And the rule that was stated but never enforced: `retirable` was reported and
`dr_ingest::retire` was never called by anything, so a move-import silently
behaved as a copy. The card is now emptied by the worker, of exactly those
photographs the *server* has confirmed — not those merely written here, because
the staging copy is removed moments later and anything unconfirmed would then
exist nowhere at all.
FR-NC-7b said "copied locally first ... then queued for upload", which is a
staging area; it has been rewritten to say so in terms that do not also permit
what was built.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 21:09:26 +02:00
561 changed files with 179670 additions and 24686 deletions
command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-checkout' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; }
command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-commit' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; }
command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-merge' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; }
command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'pre-push' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; }
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for software and other kinds of works.
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.
Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and modification follow.
TERMS AND CONDITIONS
0. Definitions.
“This License” refers to version 3 of the GNU General Public License.
“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations.
To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work.
A “covered work” means either the unmodified Program or a work based on the Program.
To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
1. Source Code.
The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.
A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
The Corresponding Source for a work in source code form is that same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
7. Additional Terms.
“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
11. Patents.
A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.
A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.
If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”.
You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see <https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read <https://www.gnu.org/philosophy/why-not-lgpl.html>.
# The characteristic curves: density against log10 exposure, sampled
# uniformly over [-3, 4].
log_exposure_min:-3
log_exposure_max:4
density_curves:
- [-0.00082207,-0.00082089,-0.0008278]
- [-0.00070251,-0.00068042,-0.00074299]
- [-0.00050646,-0.00043818,-0.00059421]
- [-0.00029511,-0.00018684,-0.00040366]
- [-0.00012342,-3.2448e-05,-0.00017405]
- [-5.7059e-06,-1.3791e-05,8.2719e-05]
- [9.1999e-05,-5.3052e-05,0.00030861]
- [0.00021437,-1.8094e-05,0.00041835]
- [0.00036418,0.00014486,0.00037072]
- [0.00048967,0.00034766,0.00021435]
- [0.00052986,0.00044217,4.9948e-05]
- [0.00046977,0.0003613,-5.334e-05]
- [0.00032883,0.00015162,-7.5506e-05]
- [0.00015441,-7.0724e-05,-3.2627e-05]
- [-2.3977e-06,-0.00019632,4.8881e-05]
- [-0.00010783,-0.00018362,0.00014965]
- [-0.00014853,-6.686e-05,0.00025336]
- [-0.00012144,8.2629e-05,0.00033274]
- [-2.6361e-05,0.00020353,0.00034796]
- [0.00012767,0.00026558,0.00026816]
- [0.00030747,0.00026076,0.00010248]
- [0.00045058,0.00018801,-8.7828e-05]
- [0.00048483,5.4486e-05,-0.00021447]
- [0.00036793,-0.00010806,-0.0002141]
- [0.00012262,-0.00023675,-9.1985e-05]
- [-0.00016103,-0.00026207,7.6502e-05]
- [-0.00036566,-0.00015576,0.00019237]
- [-0.00040922,3.4806e-05,0.0001975]
- [-0.00029184,0.00020223,0.0001122]
- [-9.5598e-05,0.0002456,1.8958e-05]
- [6.4346e-05,0.00014515,3.3838e-06]
- [0.00010726,-1.3323e-05,9.4186e-05]
- [3.073e-05,-9.0986e-05,0.00024356]
- [-9.5183e-05,4.539e-06,0.00035985]
- [-0.00017677,0.0002432,0.00036739]
- [-0.00015513,0.00047913,0.00025196]
- [-3.7734e-05,0.00054189,6.3882e-05]
- [0.00011276,0.00035217,-0.00011721]
- [0.00021999,-1.8483e-05,-0.00022712]
- [0.00023667,-0.00038648,-0.00024212]
- [0.00016448,-0.00056744,-0.00017543]
- [4.5715e-05,-0.00048498,-5.8608e-05]
- [-6.119e-05,-0.00020712,7.2192e-05]
- [-0.00010547,0.00010637,0.00017789]
- [-5.8445e-05,0.00030226,0.00021564]
- [7.9804e-05,0.00031088,0.00014984]
- [0.0002772,0.00016015,-2.392e-05]
- [0.00047129,-6.5726e-05,-0.00025896]
- [0.00058294,-0.00028097,-0.0004647]
- [0.00054167,-0.00042321,-0.00055092]
- [0.00032533,-0.00047618,-0.00049915]
- [7.4273e-06,-0.0004415,-0.00034297]
- [-0.00025803,-0.00030073,-0.00010895]
- [-0.00033966,-6.2029e-05,0.00016366]
- [-0.00023718,0.00017103,0.00037563]
- [-7.8604e-05,0.00021867,0.00040893]
- [-4.1517e-06,-3.777e-05,0.00024995]
- [-5.7852e-05,-0.00051403,4.6868e-05]
- [-0.00019034,-0.00093458,-1.1987e-06]
- [-0.00034209,-0.0010364,0.00015494]
- [-0.00044062,-0.00072963,0.00038534]
- [-0.00042311,-0.00014631,0.00050689]
- [-0.00026217,0.00044323,0.00041616]
- [1.3631e-05,0.00080257,0.00015012]
- [0.00031251,0.00086828,-0.00016031]
- [0.00050193,0.00075815,-0.00039378]
- [0.00046661,0.00064937,-0.00051125]
- [0.00018033,0.00062574,-0.00054594]
- [-0.00025373,0.00061614,-0.00053421]
- [-0.0006331,0.0004726,-0.00046026]
- [-0.00075578,0.00012357,-0.00027068]
- [-0.00053728,-0.00032577,5.2511e-05]
- [-6.9703e-05,-0.00063604,0.00042974]
- [0.00042435,-0.00059579,0.00069927]
- [0.00072301,-0.00018137,0.00071484]
- [0.00074158,0.00041242,0.00045833]
- [0.00058511,0.00090217,8.0137e-05]
- [0.00048186,0.0011128,-0.00017103]
- [0.00064265,0.001095,-8.5217e-05]
- [0.0011418,0.0010742,0.00039796]
- [0.0018989,0.0012749,0.001178]
- [0.0027679,0.0017568,0.0020814]
- [0.0036641,0.0023949,0.0029792]
- [0.0046355,0.0030187,0.0038544]
- [0.0058292,0.0036015,0.0047845]
- [0.0073855,0.0043467,0.0058766]
- [0.0093438,0.0055956,0.0072205]
- [0.011639,0.0076272,0.0089067]
- [0.014231,0.01045,0.011053]
- [0.017215,0.013789,0.013813]
- [0.020852,0.017333,0.017383]
- [0.025491,0.021031,0.021998]
- [0.031442,0.025223,0.027871]
- [0.038884,0.030505,0.035093]
- [0.047872,0.037443,0.043587]
- [0.058428,0.046338,0.053237]
- [0.070658,0.057203,0.064149]
- [0.084846,0.069946,0.076763]
- [0.10145,0.084618,0.091703]
- [0.12098,0.1016,0.10958]
- [0.14385,0.12159,0.13086]
- [0.17036,0.14533,0.15586]
- [0.20083,0.17331,0.18489]
- [0.23576,0.20574,0.2184]
- [0.27596,0.24263,0.25696]
- [0.32229,0.28416,0.30099]
- [0.37531,0.33086,0.35056]
- [0.43486,0.38341,0.40532]
- [0.50006,0.44229,0.46477]
- [0.56963,0.50732,0.52865]
- [0.64249,0.57769,0.59707]
- [0.71816,0.65224,0.67022]
- [0.79663,0.72983,0.7478]
- [0.87788,0.80949,0.82864]
- [0.96141,0.8903,0.91087]
- [1.0462,0.97141,0.99232]
- [1.131,1.052,1.0711]
- [1.2144,1.1312,1.1465]
- [1.295,1.2074,1.2186]
- [1.3718,1.2797,1.2878]
- [1.4447,1.3482,1.3539]
- [1.5141,1.4137,1.4154]
- [1.5801,1.4763,1.4709]
- [1.6421,1.535,1.5201]
- [1.6995,1.5883,1.5636]
- [1.7519,1.6355,1.6025]
- [1.7997,1.6771,1.6372]
- [1.8439,1.7145,1.6681]
- [1.8853,1.7492,1.6947]
- [1.9243,1.7818,1.7173]
- [1.9607,1.812,1.7364]
- [1.9939,1.8393,1.7528]
- [2.0238,1.8632,1.7672]
- [2.0507,1.8838,1.78]
- [2.0748,1.9018,1.7913]
- [2.0968,1.918,1.8008]
- [2.1167,1.9328,1.8086]
- [2.1346,1.9462,1.8151]
- [2.1506,1.9581,1.8207]
- [2.1647,1.9682,1.8258]
- [2.177,1.9767,1.8302]
- [2.1879,1.9838,1.8338]
- [2.1975,1.9896,1.8362]
- [2.2061,1.9944,1.8376]
- [2.2137,1.9985,1.8384]
- [2.2201,2.0019,1.8389]
- [2.2254,2.0047,1.8397]
- [2.2299,2.007,1.8407]
- [2.2338,2.009,1.8418]
- [2.2371,2.0108,1.8427]
- [2.24,2.0123,1.8432]
- [2.2424,2.0136,1.8434]
- [2.2443,2.0146,1.8434]
- [2.2458,2.0154,1.8435]
- [2.247,2.0159,1.8437]
- [2.248,2.0164,1.8438]
- [2.2488,2.0168,1.8438]
- [2.2493,2.0171,1.8436]
- [2.2495,2.0173,1.8432]
- [2.2496,2.0174,1.8429]
- [2.2499,2.0174,1.8428]
- [2.2505,2.0175,1.843]
- [2.2512,2.0177,1.8434]
- [2.2518,2.0181,1.8438]
- [2.2521,2.0185,1.8441]
- [2.2518,2.0189,1.8442]
- [2.2513,2.019,1.8442]
- [2.251,2.0187,1.8443]
- [2.2512,2.0183,1.8444]
- [2.2516,2.0179,1.8445]
- [2.252,2.0177,1.8444]
- [2.2521,2.0177,1.8442]
- [2.2517,2.0178,1.8438]
- [2.2514,2.018,1.8435]
- [2.2513,2.0182,1.8433]
- [2.2516,2.0183,1.8433]
- [2.252,2.0182,1.8435]
- [2.2522,2.018,1.8438]
- [2.2518,2.0177,1.8439]
- [2.2511,2.0174,1.8439]
- [2.2506,2.0173,1.8437]
- [2.2507,2.0175,1.8435]
- [2.2512,2.0179,1.8435]
- [2.2519,2.0182,1.8437]
- [2.252,2.0183,1.8439]
- [2.2515,2.0182,1.8441]
- [2.2506,2.0179,1.8441]
- [2.2499,2.0177,1.8439]
- [2.2499,2.0178,1.8436]
- [2.2507,2.0181,1.8434]
- [2.2517,2.0183,1.8435]
- [2.2524,2.0183,1.8437]
- [2.2523,2.0181,1.844]
- [2.2516,2.0178,1.8441]
- [2.251,2.0176,1.8441]
- [2.2508,2.0178,1.8438]
- [2.2512,2.0183,1.8436]
- [2.2519,2.0188,1.8435]
- [2.2524,2.0189,1.8436]
- [2.2523,2.0185,1.8438]
- [2.2518,2.0177,1.844]
- [2.2512,2.017,1.8442]
- [2.2508,2.0166,1.8443]
- [2.2506,2.0168,1.8444]
- [2.2506,2.0172,1.8444]
- [2.2509,2.0177,1.8443]
- [2.2513,2.0181,1.8441]
- [2.2516,2.0183,1.8439]
- [2.2518,2.0184,1.8438]
- [2.2519,2.0185,1.8438]
- [2.2519,2.0185,1.844]
- [2.2518,2.0186,1.8441]
- [2.2517,2.0188,1.8439]
- [2.2516,2.0188,1.8436]
- [2.2515,2.0186,1.8432]
- [2.2513,2.0183,1.843]
- [2.2513,2.0179,1.843]
- [2.2514,2.0176,1.8432]
- [2.2516,2.0174,1.8435]
- [2.2517,2.0175,1.8438]
- [2.2518,2.0176,1.844]
- [2.2517,2.0178,1.8441]
- [2.2515,2.0178,1.8441]
- [2.2513,2.0178,1.8442]
- [2.2512,2.0177,1.8441]
- [2.2511,2.0177,1.844]
- [2.2512,2.0179,1.8438]
- [2.2514,2.0181,1.8437]
- [2.2515,2.0182,1.8437]
- [2.2516,2.0183,1.8438]
- [2.2516,2.0183,1.844]
- [2.2515,2.0182,1.8441]
- [2.2514,2.0182,1.844]
- [2.2513,2.0181,1.8437]
- [2.2513,2.0181,1.8435]
- [2.2514,2.0181,1.8434]
- [2.2516,2.018,1.8436]
- [2.2518,2.0178,1.8438]
- [2.2519,2.0176,1.844]
- [2.2519,2.0175,1.844]
- [2.2517,2.0176,1.8437]
- [2.2514,2.0179,1.8435]
- [2.2511,2.0181,1.8435]
- [2.2509,2.0183,1.8438]
- [2.2509,2.0183,1.8443]
- [2.2511,2.018,1.8447]
- [2.2515,2.0177,1.8448]
- [2.2517,2.0175,1.8445]
- [2.2517,2.0175,1.8442]
- [2.2516,2.0176,1.844]
- [2.2514,2.0178,1.8439]
- [2.2513,2.018,1.8439]
- [2.2512,2.0182,1.8438]
- [2.2511,2.0183,1.8436]
- [2.2511,2.0184,1.8435]
- [2.251,2.0184,1.8434]
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.