Compare commits

...
255 Commits
Author SHA1 Message Date
dtourolle 5baaf9bac2 Release 0.17.0
Benchmarks / CPU and I/O (per commit) (push) Successful in 5m22s
Benchmarks / Frame budget (on demand) (push) Skipped
Traceability / Requirement traces (push) Successful in 1m1s
Build and test / Android (aarch64) (push) Successful in 32m18s
Build and test / android-image (push) Successful in 1s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / Desktop (Linux) (push) Successful in 50m25s
Build and test / windows-image (push) Successful in 1s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / Layer separation (push) Successful in 31s
Build and test / Windows (x86_64, cross) (push) Successful in 38m0s
Build and test / Publish the release (push) Successful in 50s
2026-09-26 16:18:18 -04:00
dtourolle 7428f6f845 Re-record the manual for 0.17.0, with albums and the new preset sheet
Every scene is recorded again on the 0.17.0 build, because the header
(Export to Exports), the sidebar (Albums) and the develop column had
all moved. The launch pictures showed the typed folder field, and the
export settings "Export to"; the presets picture predated the sections.

New scenes: presets_film scrolls the sheet down through the shipped
sections and applies Ilford HP5 Plus, a look that changes the film and
nothing else; albums exports four New York frames to an album, selects
it to show the originals behind its files, and opens a new album's
sheet before deleting what it made. record.sh now points the profile's
old export folder at DR_HOME/Exports, which the app turns into the
album "Exports" on first open - the one way to have an album without
the portal's dialogue, which Xvfb cannot show. The launch scene signs
out of a remembered folder instead of typing one, so it shows
"Open folder" beside the folder used last.

Not recorded: the server browser's New folder, which needs a Nextcloud
server, and the download screen, which a folder library never reaches
(the original is a local read, over before the first poll). The manual
says so where it describes each. film_reach and duplicates passed;
inference was pinned to the CPU.
2026-09-26 15:15:51 -04:00
dtourolle 9f95d23ff4 Say that no workflow builds the Flatpak yet
NFR-COMPAT-2's table has CI building every channel, the Flatpak
included. No workflow in .gitea/workflows/ builds it, and the sweep
before this one records that none has been built by hand either. A
status note under the table says so, so the decision and the state are
not read as the same thing.
2026-09-26 14:58:46 -04:00
dtourolle 1b8d0e740f Say the benchmark's second open no longer pays the backfill
benchmarks.md and catalog_open.rs both said schema::backfill runs on
every Catalog::open. Since ffdd640 it runs on the first open of a path
in a process and is skipped while the stamp matches, so in dr-bench
catalog_open_ms still includes it and catalog_open_warm_ms, the second
open in the same process, no longer does. That is what a library
reopened in one session costs, and both now say which figure is which.
The comment keeps its line count, so no tag below it moves.
2026-09-26 14:58:20 -04:00
dtourolle d2b99bb8c1 Note the folder dialogues in windows.md
Since 6683c14 every folder on the desktop is chosen through rfd, which
on Windows is the common item dialogue. windows.md's list of what is
already handled now says so, and that nothing has opened one under
Wine or on Windows. "The import flow's path picker" is now the import
page's Browse… button.
2026-09-26 14:57:35 -04:00
dtourolle f39b88b005 Bring the README, the docs index and CONTRIBUTING up to 0.17.0
The README said export went "to a folder here or back into the
library", which albums replaced and forbid, and called presets "named
presets" beside a shipped collection of film looks and Lightroom
imports. It now says both, and that a photograph only on the server
opens on its thumbnail with its download's progress. The requirement
count comes from traceability.md's summary: 192, 84% claimed; the
version and release count are left to the release commit.

docs/README.md's manual row gains presets and albums. CONTRIBUTING
said 177 requirements and 826 crates, and the Flatpak manifest 826
crates; the lockfile now holds 849 packages, 822 from the registry.
2026-09-26 14:56:12 -04:00
dtourolle 2f5f2041ab Record dehaze's two passes in frame-budget.md
The figures are bee5c58's, quoted as that commit measured them: five
passes to two, 22.9 ms to 9.1 ms at 2560 x 1600 fit and 54.1 ms to
28.1 ms at 4K, the output bit-identical. Under its own heading and
status line, like the 2026-09-25 section, since this file was not
re-run for them.
2026-09-26 14:55:18 -04:00
dtourolle 0f7ea741d8 Record the open's backfill stamp and the first-use indexes in catalog.md
#75 and the develop-landing work changed how the catalog is opened and
queried without touching its design document. §2 now lists the two
indexes made on first use, keywords_term_version and faces_box, beside
the tables made that way, and says why each exists. A paragraph states
what the backfill stamp in backfilled.rs holds, why it records the
newest rows by content rather than by id, and when the backfill still
runs.

§6.2's example of coalescing was a thumbnail job, a kind nothing
enqueues since #73.
2026-09-26 14:55:08 -04:00
dtourolle dee509c6ef Describe albums, the folder pickers and download progress in the designs
storage.md's trait listing stopped at get; it now has get_reporting,
what the default and the Nextcloud override do, and where develop reads
the figures. A new §5.3 says how folders are chosen — the portal or
Windows dialogue, the server browser whose New folder is create_dir,
SAF on Android — and where an album's files go: a server folder relative
to the account root, with the outbox's third .dest line, or a device
folder that never syncs.

catalog.md §8.2 said only collections merge, which had not been true
since keywords, people and capture metadata joined them, and is less
true with albums; it now lists what merges and why album_folders does
not. §2 records that the album tables, like dedup_probes, are made on
first use rather than by a migration.

outstanding.md said there was no SAF code on Android. There is now,
for album folders only, and it carries TRACES: FR-PLAT-AND-1, which
the entry says overstates a requirement about the library; FR-PLAT-AND-2
and S10's row follow from that.
2026-09-26 14:54:46 -04:00
dtourolle caaae11d98 Say the folder dialogue is the portal, and what the Flatpak has not proved
distribution.md §4, outstanding.md's FR-PLAT-LIN-3 entry, the Flatpak
manifest's comment and the README all said a library was chosen by
typing a path and that nothing in the tree called the FileChooser
portal. Since 6683c14 every folder the desktop asks for is chosen
through rfd's xdg-portal backend, so those sentences were false.

What they now say instead is narrower than "it works in the sandbox":
no Flatpak has been built here, so whether the portal's path opens a
library, holds across a restart and takes a sidecar is unobserved, and
volumes() still cannot see a host card. The chooser also landed in ui/
rather than behind the dr-plat seam distribution.md had proposed, and
both documents say so. FR-PLAT-LIN-3 gets a status note to the same
effect.
2026-09-26 14:52:44 -04:00
dtourolle e57c5b8182 Regenerate the traceability matrix after the rebase 2026-09-26 14:26:10 -04:00
dtourolle 02ddce8d80 Stamp the backfill on the newest rows, not only their ids
The backfill stamp read max(id) of images and versions and max(rowid)
of keywords. None of those tables is AUTOINCREMENT, so SQLite hands a
freed newest id out again: empty the trash of the newest photograph and
scan a new one, or let a local folder's walk delete a renamed file's row
and insert the new name in the same pass, and the new image takes the
old id. max(id) does not move, nor does count(*), and when a newer
version elsewhere keeps max(versions.id) still too, the stamp matched
and the open skipped the backfill.

That row is exactly one that needs it. Neither scan path creates the
default version: scan::persist and walk insert the image and leave the
version, the RAW/JPEG pairing and the keyword terms to the next open.
Skipped, the image went without them until the app restarted, so a
rating or a pulled sidecar judgement had no version to land on and a
JPEG beside its RAW showed twice.

The stamp now carries the newest row's content: the newest image's id,
path, added time and whether it has a version; the newest version's id
and image; the newest assignment's rowid, version and word. Whether the
newest image has a version is the part that cannot be fooled - after a
backfill every image has one, and a row that has just taken a freed id
has none - so the two stamps differ even when the same file comes back
at the same id in the same second. Still one statement: three reverse
rowid scans that stop at the first row, and one probe of versions_image.
An open that skips still costs ~1 ms on the reference catalog copy.

This closes the hole in the stamp itself rather than by a forget() at
each delete site, so a delete path added later, or one in another
process, cannot reopen it. Two tests delete the newest image and insert
another at the freed id on a separate connection, with a newer version
elsewhere holding max(versions.id); both fail against the old stamp.
2026-09-26 14:26:10 -04:00
dtourolle faf52f6dbd Ask the prefetch's cache questions on one held connection
holds_original, the prefetch worker's check that a neighbour's original
is already cached, opened the catalog for every neighbour it asked about.
Its own comment called it a row check; the open around it was four of
the five opens a develop landing made.

The worker now keeps one Catalog for the batch it is serving, opened at
the first check and reopened only if the batch names another catalog
file. The connection runs in autocommit, so each check still sees what
fetch_original committed in between. fetch_original is unchanged.

With the backfill no longer run on every open, a landing whose
neighbours are all cached goes from five opens to two, and from ~80 ms
of CPU to ~1-2 ms on a copy of the reference catalog.
2026-09-26 14:26:10 -04:00
dtourolle ffdd640170 Backfill the catalog once per state, not on every open
Catalog::open ran schema::backfill every time, and every worker thread
opens its own connection. A develop landing made five opens, and each
paid the RAW/JPEG pairing, the default-version anti-join over every
image, the uuid pass over every default version and the keyword check:
17 ms of CPU an open on a copy of the reference catalog, ~80 ms a
landing, to confirm that nothing had changed since the open before.

Everything the backfill repairs is a row some write added: an image a
scan inserted, a version or keyword assignment a merge brought in. So
the open now reads a stamp - user_version, max(id) of images and
versions, max(rowid) of keywords, and the file's device and inode - and
skips the backfill when the stamp matches the one recorded at this
path's last backfill in this process. The maxima are each the last page
of a b-tree; an open that skips costs ~1 ms.

The backfill still runs:
- on the first open in a process (nothing recorded yet);
- on any open that migrated the schema, unconditionally;
- after a pull: merge_remote forgets the path, so the next open
  backfills even when every incoming row collided and nothing moved;
- when the file is replaced under its name: the inode is in the stamp,
  and recovery::set_aside, the first step of a restore and a rebuild,
  forgets the path;
- when another process or thread adds rows, because the stamp is read
  from the file, not from anything this process did.

The stamp is taken before the backfill, not after. Read after, it would
describe the backfill's own inserts, and could record an image another
connection inserted in between as covered when it was not. Read before,
the worst case is one redundant pass after a backfill that did real work.

Kept in memory rather than in the catalog: a stamp row would need a
table an older build does not have and would travel in the sync
snapshot, where a flag from another device's catalog says nothing about
this one. No schema version bump, so the tablet on 0.16.0 still reads
the snapshot. Tests cover the skip, a scan's new image, a migration, a
pull and a replaced file.
2026-09-26 14:26:10 -04:00
dtourolle 2226d543f9 Time a develop landing in catalog_bench
Landing on a photograph in develop opens the catalog once to fetch the
original and once more per prefetched neighbour to ask whether the cache
already holds it: five opens, each running the whole backfill. The bench
timed one open but not the landing, so the cost of the shape was not
visible and a fix to it could not be measured.

Two figures now, both against an empty cache so the question is asked the
same way whatever the answer: the five-open shape the app had, and the
two-open shape where the prefetch worker keeps one connection for its
batch. On a copy of the reference catalog (23,582 images) under load, the
five-open landing costs ~80 ms of CPU.
2026-09-26 14:25:46 -04:00
dtourolle bee5c5866f Erode dehaze's window in one pass per axis, and recover in the second
Dehaze cost 22.9 ms of a 2560x1600 frame on the reference laptop RTX 3050,
and 54.1 ms at 3840x2160, with the memory clock held at 810 MHz by the power
cap (graphics 1762 MHz). It ran five passes: a run and a span erosion along
x, the same along y, and the recovery. At those clocks a detail pass costs
what it reads and writes, not what it taps: a pass with an empty body -
one render-sized rgba16float read and write - measured 4.0 ms, and each
dehaze pass 4.4-4.6 ms, so the taps were about 2 ms of the 22 and the four
hand-offs between passes were the rest.

Each axis is now one pass that takes the minimum over the whole window
directly, and the recovery rides in the y pass, which already holds the
veil and the pixel's own colour. That is 36 texture reads per pixel at
2560x1600 in place of 12, nearly all of them cache hits, and two passes in
place of five.

The picture is the same bits. A minimum is exact in any order, and the
window is the one Split always covered, the surplus pixel on the far side
included (Split::first and Split::width). The veil crossing the removed
hand-offs was already exactly representable in rgba16float - a minimum of
channels read from rgba16float, floored at zero - so storing it between
passes never rounded anything that the fused form now keeps unrounded.

Measured with a scratch probe that renders the synthetic 60 MP frame from
examples/frame_budget.rs, only a detail parameter moving so the fused pass
is reused, 30 frames per scene after six of warm-up, five runs of each
binary alternated, median of the per-run p50:

  scene                   before     after
  dehaze      2560 fit    22.88 ms    9.06 ms
  dehaze      2560 1:1    23.41 ms    9.52 ms
  dehaze      3840 fit    54.09 ms   28.12 ms
  all detail  2560 fit    53.11 ms   39.97 ms  (NR, sharpen, clarity,
  all detail  2560 1:1    67.48 ms   56.42 ms   texture, dehaze)
  every op    2560 fit    57.59 ms   44.19 ms  (with film)
  every op    2560 1:1    71.83 ms   57.93 ms
  controls without dehaze (NR, sharpen, clarity, texture): within +-2%

The rgba8 output hashed identically before and after for every scene -
dehaze alone, all five detail operations, every operation with film, and
each other detail operation alone - at fit and 1:1, at 2560x1600,
3840x2160, 1917x1203 and 333x211: 64 of 64.
2026-09-26 14:18:42 -04:00
dtourolle 9b580c3720 Satisfy rustfmt and clippy on the album and folder picker changes
rustfmt over the files the albums work touched, and the album merge's
incoming row as a named struct rather than an eight-field tuple, which
clippy's type_complexity refused.
2026-09-26 14:13:54 -04:00
dtourolle 1abb18d972 Specify albums and pointing at folders, and describe them in the manual
FR-EXP-10 is the album: a named export destination beneath the
collections, whose folder holds only the exported files while the
catalog links each back to its original; how albums sync, why a device
folder does not, and why the tables are made on first use rather than
by a migration. FR-EXP-6 now says the destination is an album, never
inside the library, and that folders are chosen by pointing — the
portal or Windows dialogue, SAF's tree picker, the server browser —
each able to make a folder.

The manual's launch and export sections say the same in the words on
screen. Its pictures still show the 0.16.0 launch screen and export
settings; they are re-recorded with the rig, not edited by hand.
2026-09-26 14:13:53 -04:00
dtourolle 92d4b23bed Give an album a folder on the tablet, through Android's folder picker
Android's only export destination was the library on the server
(ExportTarget::available), because writing to the device goes through
the Storage Access Framework and nothing did. An album's folder on the
tablet is now chosen in the system's tree picker — which has its own
"Create new folder" — and exports are written into it with
DocumentsContract.

The picker answers through onActivityResult, and the main activity is
NativeActivity, whose result is not ours. FolderPicker is a translucent
activity that only asks: it starts ACTION_OPEN_DOCUMENT_TREE, takes a
persistable grant (a folder is chosen once and exported to for months),
leaves the URI in a static, and finishes. Rust polls it from a Slint
timer — one static call, rather than a registered native method and a
thread to deliver on.

Two things the first build on the tablet got wrong, recorded where they
are fixed:

- Our classes must be loaded through Context.getClassLoader(). The
  class of what ndk_context holds is a framework class from the boot
  loader, which reports every class in the APK as not found.
- What ndk_context holds is the application context, not the activity,
  and starting an activity from it throws without FLAG_ACTIVITY_NEW_TASK.

Saf.write creates the document (or, under Overwrite, reopens the one of
that name with "wt" so a shorter file does not keep the old tail) and
returns the name the provider actually gave it, since SAF renames on a
collision by itself; the album records that name. A tree URI reads in
the sidebar as its folder ("Pictures/Web"), not as a content:// string.
2026-09-26 14:13:53 -04:00
dtourolle 7cbcacc02e Export to an album instead of a folder in the settings
Export took a path typed into the settings page, or a folder inside the
library on the server. The first is how exports end up somewhere nobody
looks; the second put JPEGs into the tree a scan catalogues, where they
came back as photographs beside the RAWs they were made from.

The destination is now an album (FR-EXP-10), chosen by name in the
export sheet. Albums are listed under the collections in the sidebar;
"+" there, or "New album…" in the sheet, opens a sheet for its name and
its folder — on this device through the platform's dialogue, or on the
server through the browser with "New folder". A server folder inside
the library is refused, and the sheet says why. Selecting an album
narrows the grid to the photographs behind its files: library::Scope
is Collection or Album, and scope_clause is the one place the two are
spelled, which also retires the two copies of the collection predicate
total_images_scoped and read_cells_scoped had inlined.

A batch resolves the album when it starts, and refuses in words when
none is chosen, it has gone, or its folder is local to another device.
Each item reports the image it came from, and the files written are
recorded against the album in one transaction when the batch ends.

A server album lives outside the library, so its queued uploads are
relative to the account root. That is a third line in the outbox's
.dest record rather than a leading slash, because a record written
before albums may carry a stray slash and must keep the meaning it was
written with.

An export folder set before albums becomes an album called "Exports"
on first open, so upgrading does not lose where exports were going.
The old destination fields stay in ExportSettings so older settings
files still read.
2026-09-26 14:13:53 -04:00
dtourolle 2eb06b1064 Make a folder from the server browser
The in-app browser that chooses a library folder on the server could
only open folders that already existed, so a library, or an export
destination, that was not on the server yet had to be made in the
Nextcloud web page first. It now has "New folder": a name, then MKCOL,
then the parent listed again and the new folder walked into — a folder
somebody has just named is the one they mean to choose.

The listing is the server's rather than the name inserted locally: the
server may have normalised or refused it. A name with a slash, "..",
or nothing at all is refused before any request, because a folder
typed with a slash in it is a path the user did not mean.

remote_folders holds the two WebDAV round trips (list, make) off the UI
thread, with the answer delivered through a Slint timer, so the album
sheet can use the same browser.
2026-09-26 14:13:53 -04:00
dtourolle 6683c14b40 Choose folders in the platform's dialogue, not by typing a path
Every folder the desktop asked for was a text field: the library folder
at launch, an import's source and second copy, a preset folder brought
over from Lightroom. A typed path is how a destination silently becomes
a new folder nobody meant — one wrong letter three levels down and the
write succeeds somewhere the photographer will never look — and a field
cannot make the folder that is not there yet.

They now open the platform's own dialogue through rfd: the XDG desktop
portal on Linux, the common item dialogue on Windows. The portal rather
than GTK because it reaches the user's files from inside the Flatpak and
needs no GTK in a Slint application, and it draws whichever desktop's
chooser is running, "New folder" included. It is awaited on Slint's
event loop (spawn_local), so the window keeps drawing while it is open,
and parented to the window so it opens over it.

PathRow shows what is chosen, read-only, beside the button. Android has
no filesystem dialogue — only SAF, which returns document trees, not
paths — so there the same rows stay typed fields (Pickers.local-paths).

The launch screen keeps the folder used last on screen with "Open
folder" beside it, so reopening is one press. Presets get two buttons,
a folder and a single .xmp file, because no platform dialogue picks
"a file or a folder" in one go.
2026-09-26 14:13:53 -04:00
dtourolle 94542371f6 Keep albums in the catalog: export folders and what went into them
An album is a named export destination. Its folder holds only the
exported files; the catalog records, per file, the image it was
rendered from, so an album can show the originals behind its JPEGs
(FR-EXP-10).

The tables are created on first use (CREATE TABLE IF NOT EXISTS), the
way dedup_probes is, rather than by a schema migration: a new
user_version makes every older build refuse this catalog's snapshot at
sync, and the 0.16.0 tablet would stop merging collections, keywords
and people for a feature it does not have.

Albums merge as collections do: by uuid and revision, tombstones on
delete, exports as a set union keyed on the server's file id (content
hash for a folder library). A folder on the server lives on the album
row and syncs; a folder on this device lives in album_folders, which
the merge never reads and the upload snapshot drops, because a path or
a SAF grant on one device means nothing on another.

Exports are keyed on the file name, not the image: two crops of one
photograph are two files and two rows, and an overwrite re-points the
name at whatever wrote it last.
2026-09-26 14:13:28 -04:00
dtourolle 715fcf8512 Regenerate the traceability matrix after the rebase 2026-09-26 14:03:27 -04:00
dtourolle 49b7bc2f9d Build the upload snapshot without the face crops instead of stripping them
Each sync pass spent 0.8-2.0 s of CPU and 1.0-5.4 s wall on the upload
snapshot of the reference catalog (24k images, 18,871 faces), ahead of the
rest of the pass. The upload itself had been crop-less since the crops
moved to the face shards. The cost was in how it got that way. The backup
API copied all 158 MB of the catalog, 96 MB of it the ~5 KB JPEG crop on
every faces row. Then `UPDATE faces SET crop = NULL` rewrote 18.9k rows
and freed their overflow chains, and VACUUM rebuilt the file again. That
wrote the catalog about three times over to upload 50 MB.

The snapshot is now built rather than copied. An empty file attaches the
catalog, creates each table from the catalog's own sqlite_master and fills
it with INSERT ... SELECT, with faces.crop selected as NULL. Indexes,
triggers and views follow, and user_version, application_id, page size and
the WAL header flag are carried over. It all runs in one transaction on the
snapshot's connection, so the catalog is read as of one moment and
concurrent writers are serialised, not raced, as the backup API did. The
build journal is in memory with synchronous off, because the file is
scratch that is rebuilt every pass and quick_check'd before upload. Foreign
keys are off on that connection. The bundled SQLite enables them, and then
a multi-row INSERT into images scans images for children of each new row
(shadowed_by is a self-reference with no index), which cost 1.2 s alone.

Measured on a .backup copy of the reference catalog with catalog_bench,
old and new binaries back to back on a loaded machine:
  before  best 1.0-5.4 s wall, 0.84-1.98 s cpu, 49.8 MB
  after   best 0.40-2.1 s wall, 0.39-0.96 s cpu, 50.4 MB
With the machine quiet the new build takes 0.31-0.43 s.

What a receiving device gets is unchanged. It is the same schema, the same
rows and a NULL crop, which is what 0.16.0 already uploads and merges. The
merge reads only a remote face's box and model (merge::match_faces) and
never writes a local crop. No device adopts a downloaded catalog as its
own, and a fresh one takes faces and crops from the shards. There is no
schema bump, so older builds still merge it. NFR-R2 backups keep using the
backup API and keep their crops.

Tests: the snapshot matches the catalog in schema, row counts, pragmas and
WAL header. A leftover file is replaced. Merging a crop-less snapshot
carries a confirmed name across by box and leaves the local crop
untouched, and does so idempotently.
2026-09-26 14:03:27 -04:00
dtourolle a59f14c797 Describe shipped presets and looks in the manual and FR-DEV-6
The presets sheet now lists the shipped collection beside the
photographer's own, a copy under a shipped name overrides it, and
shipped and imported presets are looks that leave a photograph's own
corrections alone. The manual says so where it introduces the sheet,
and FR-DEV-6 states the rule. The sheet's screenshot (media/presets.png)
predates the sections and needs re-recording.
2026-09-26 13:44:32 -04:00
dtourolle e82190fdf2 Import Lightroom presets as looks
A Lightroom preset changes the settings it was saved with and leaves every
other one where the photograph had it. Imported as a whole edit, a preset
holding only a grade reset the exposure, white balance and noise reduction
it was put on top of — the opposite of what the photographer had in
Lightroom.

Imported presets now reach only the operations they name
(`Reach::Named`), the rule the shipped presets already follow.
2026-09-26 13:44:32 -04:00
dtourolle a7b090cf36 Ship presets with the application instead of seeding them
The six starter presets were copied into the photographer's own library
on a first run and were theirs from then on. That cannot grow into a
real collection: a copy is frozen at the release that wrote it, so an
improved preset reaches nobody who had the old one, and re-seeding would
overwrite a preset someone had tuned.

`dr_pipeline::bundled` now holds the shipped presets as `.drpl` files
compiled into the binary, in sections — Essentials (the former six) and
three sections of film presets, one per measured stock in dr-film,
printed on the paper its profile names — and never writes them to the
user's file. Every shipped preset is a look (`Reach::Named`), so applying
one keeps the corrections a photograph already has.

A name links a photographer's copy to a shipped preset. Saving over a
shipped name makes their version the one that name applies; it is listed
in the shipped section, marked as changed, and deleting it reverts to the
shipped one. Renaming it makes it one of their own and the shipped preset
reappears. Keyed on the name because that is what the photographer sees
and chooses by.

Copies an older first run seeded are forgotten on load where they are
still exactly as seeded — otherwise all six would list as changed and
stay frozen at their old values. A tuned one is kept and now overrides.

The sheet lists "Yours" first, then each shipped section, with headings.
Shipped rows apply and nothing else; a changed row offers Revert where
the photographer's own offer Delete. A dr-ui test checks every shipped
film names a stock this build can bake, on that stock's own paper,
because dr-pipeline does not link the profile database.

The film presets name stocks by id; the measurements behind them are
spektrafilm's (CC BY-SA 4.0), attributed in each file as in dr-film.
2026-09-26 13:44:32 -04:00
dtourolle 90c0695c05 Let a preset name its film, and let a look reach only what it names
A preset could not choose a film stock. The stock is a choice of material
rather than a parameter, so `Preset` — a map of `op.param = value` — had
nowhere to hold it, and "Portra 400, printed" could not be saved, copied
or shipped as a look. Worse, the film node's own sliders *were*
parameters: a paste moved one stock's exposure and push onto whatever
stock the target was on, and left the target's tables baked from the
values it had just replaced.

A preset now carries a `FilmRef` beside its parameters. It travels under
whichever scope carries the film node, so the stock and its sliders are
never split, and by the replacement rule every other parameter follows:
applied at that scope, a preset without a film develops the target
without one. `Preset::apply` returns the `FilmRebake` it owes, as
`EditGraph::set_state` already did, because this crate cannot bake a
stock; the develop session pays it before recording the step, and the
batch paste writes the stock into each sidecar through `film_for`. The
library file spells it `film =` / `film_print =`, as a sidecar does, and
an older build keeps those lines as ones it does not understand.

`EditState` keeps the film in its own field only: the parameters it
captures leave it out, so one edit has one place to say which stock it
is on.

Second, a preset now has a reach. Replacement is right for a copy of a
whole edit — "make these match" — and wrong for a look: a stock-only
"Portra 400" applied that way would put the photograph's exposure, white
balance and noise reduction back to default. `Reach::Named` replaces only
the operations a preset names (whole operations, so a look that sets the
blacks resets the whites beside them) and the film only if it names one.
Saved edits and the clipboard keep `Reach::Whole`; the line `reach =
named` is written only for the other, so existing libraries write the
same bytes.
2026-09-26 13:44:32 -04:00
dtourolle c6d4c1ba62 Regenerate the traceability matrix after the rebase 2026-09-26 13:29:34 -04:00
dtourolle ce6705be89 Merge synced face assignments against what is held, read once per pass
After 9cff677 the loop over the other device's confirmed and ignored faces
(13,000 on the reference library) still asked three cached statements per
face -- the person by uuid, the face's current assignment, and whether this
pair was rejected here. It was 51 ms of a steady-state merge.

The person is now resolved in the statement that reads the incoming rows,
by the local `people.uuid` key:

  SCAN fp
  SEARCH p USING INTEGER PRIMARY KEY (rowid=?)
  SEARCH lp USING COVERING INDEX sqlite_autoindex_people_1 (uuid=?)

and the local `face_person` (16,800 rows) and `face_person_rejected` are
each read once into memory and looked up there. A write goes to the table
and to the map, so a second remote face matched to the same local face sees
what the first left, as it did when each face re-read the table. The
incoming rows are ordered by face id -- the order the table was already
walked in -- since which of two such faces is applied last decides the
answer. An inner join to `people` drops the rows the old loop skipped for
want of a local person, and the counts in the report are unchanged.

After: the loop 10-12 ms. The merge as a whole, with the two changes before
this, went from 228-231 ms to 135 ms best of 5, and every catalog table
checksums the same after the bench as after the old build's run.
2026-09-26 13:28:50 -04:00
dtourolle ae0281fedd Match synced faces from an index of their boxes, not from their rows
`merge::match_faces` reads every local face's box and model to pair the
other device's faces with ours. It took 54 ms of a steady-state merge on the
reference library (19,000 faces).

A `faces` row is eight kilobytes -- the embedding, the crop, the dense
landmarks -- and `model_id` sits past the embedding, so reading it opened
each row's overflow pages:

  SCAN f
  SEARCH r USING INTEGER PRIMARY KEY (rowid=?)

`faces_box (image_id, model_id, x, y, w, h)` holds every column the scan
asks for:

  SCAN f USING COVERING INDEX faces_box
  SEARCH r USING INTEGER PRIMARY KEY (rowid=?)

The local scan went from 38 ms to 8 ms (sqlite3 on a copy, aggregated so
output formatting is not timed), and `match_faces` from 54 ms to 30-37 ms;
what remains is the other device's half. That is read from its snapshot,
which has whatever indexes its build made -- this one will carry
`faces_box` in its uploads -- and whose rows have had their crops stripped.
The bench merges a full copy with crops, so it overstates that half.

Created on first use in `match_faces`, with CREATE INDEX IF NOT EXISTS,
rather than by a migration, for the reason `keywords::ensure_term_index`
gives: a schema version bump makes older builds refuse the snapshot, and an
extra index is invisible to them. The first merge after the upgrade builds
it (about a second, once). Its prefix duplicates `faces_image_model`, which
is left alone; the planner takes either for an (image_id, model_id) probe.
Tables checksum the same after the bench run as after the old build's.
2026-09-26 13:28:50 -04:00
dtourolle 981022ab1d Ask a synced keyword's tombstone once per merge, not once per assignment
`merge_remote_catalog` on the reference library (catalog_bench, a copy
merged with itself: the steady state of a sync pass) cost 228-231 ms best
of 5. Timing its phases put 91 ms in the keyword half, not in the faces the
issue named.

Both assignment unions refuse a word this device holds only as a tombstone,
with a correlated `NOT EXISTS (... deleted = 1) OR EXISTS (... deleted = 0)`
per incoming assignment. The `deleted = 1` half has no index to use --
`keyword_terms_name` is partial on `deleted = 0` -- so it scanned the whole
vocabulary for each of the 10,800 rows:

  SCAN rk
  CORRELATED SCALAR SUBQUERY 1
    SCAN t
  CORRELATED SCALAR SUBQUERY 2
    SEARCH t USING COVERING INDEX keyword_terms_name (name=?)

The refused words are one set for the whole statement, so it is asked once:
`rk.keyword NOT IN (tombstoned names EXCEPT live names)`, which is the same
condition -- refused exactly when deleted under some identity and live under
none -- and which SQLite builds as a list before the walk:

  SCAN rk
  LIST SUBQUERY 2
    MERGE (EXCEPT) ...

The file-id union alone went from 72 ms to 11 ms (sqlite3 on a copy), and
the keyword phase of the merge from 91 ms to 28-35 ms. Every table of the
catalog checksums the same after the bench as after the old build's run,
and the merge tests for tombstones and renames pass unchanged.
2026-09-26 13:28:50 -04:00
dtourolle 87badb6f99 Count the grid by subtracting the hidden burst frames, not probing per image
The grid's total is read on every scroll reload (`load_window` compares it
to notice a delete). On the reference library it cost 1.3-1.5 ms best-of-50
by catalog_bench, 2-3.6 ms on a busy machine, and the issue measured 4 ms.

`uncollapsed` asked every visible image whether a collapsed burst stands in
for it -- two primary-key probes per image, 19,000 times, on a library with
no bursts at all:

  SCAN i USING INDEX images_grid_order
  CORRELATED SCALAR SUBQUERY
    SEARCH bm USING INTEGER PRIMARY KEY (rowid=?)
    CORRELATED SCALAR SUBQUERY
      SEARCH be USING INTEGER PRIMARY KEY (rowid=?)

`total_images_filtered` now counts what the filter keeps and subtracts the
frames `bursts::collapsed_away_frames` lists, under the same filter:

  SCALAR SUBQUERY: SCAN i USING INDEX images_grid_order
  SCALAR SUBQUERY: SCAN bm; SEARCH be ...; SEARCH i USING INTEGER PRIMARY KEY

The second half walks only `burst_members`. Each image is in it at most
once (it is the key), and the filter is applied to both halves, so the
subtraction removes exactly the rows the predicate used to drop. The new
fragment sits beside `not_collapsed_away` in bursts.rs, and a test holds
the two to the same rows with bursts open and closed.

After: 0.3 ms, the same count (19,152). The cells query keeps the predicate:
it is a window with a LIMIT and needs the rows, not their number. The
rated grid count (3.5-4 ms with a one-star filter) is unchanged: its cost
is the rating subquery per image, and changing how `RatingFilter` spells
it changes every grid and timeline query, which is left for its own change.
2026-09-26 13:28:50 -04:00
dtourolle d537e4a965 Serve the keyword counts from an index that carries the version
`keywords::list` is the vocabulary with a per-word photograph count, and
`keywords::for_images` calls it on every selection change to redraw the
keyword panel. On the reference library (58 words, 10,800 assignments) it
cost 3.0-3.5 ms best-of-50 by catalog_bench, `for_images` 3.1-3.6 ms (6 and
5 ms on a busy machine).

Per word, the count walks `keywords_term (keyword)` and, for each
assignment, reads the `keywords` row to learn its version before probing
`versions` for the image:

  SEARCH k USING INDEX keywords_term (keyword=?)
  SEARCH v USING INTEGER PRIMARY KEY (rowid=?)

With `keywords_term_version (keyword, version_id)` the first step is
index-only:

  SEARCH k USING COVERING INDEX keywords_term_version (keyword=?)
  SEARCH v USING INTEGER PRIMARY KEY (rowid=?)

After: `list` 1.3 ms, `for_images` 1.5 ms, with the same answers (digests of
both outputs compared on the reference library).

The index is created on first use by `list`, with CREATE INDEX IF NOT EXISTS,
not by a migration: a new schema version makes every older build refuse this
catalog's snapshot at sync (`sync::remote_is_mergeable` compares
`user_version` and nothing else), and a build that meets an extra index
ignores it. Once the index exists the statement is a schema lookup, 8 us. A
failure to create it -- a read-only or busy catalog -- is logged and the
list is read without it, as before.
2026-09-26 13:28:50 -04:00
dtourolle fe6e523443 Count the originals on this device from the cache, not from every image
`library::local_original_count` feeds the "On this device" chip and runs
beside the rating counts on every star keystroke. On the reference library
it cost 1.3-1.4 ms best-of-50 (3 ms on a busy machine) to find 254
originals among 19,000 visible images.

It was a correlated EXISTS per visible image:

  SCAN i USING INDEX images_grid_order
  SEARCH ic EXISTS USING INTEGER PRIMARY KEY (rowid=?)

`image_cache` holds a row only for what has been fetched, so the question
is driven from it: `i.id IN (SELECT image_id FROM image_cache WHERE
tier_actual >= Original)`, which SQLite plans as the list first and a probe
of `images` by id for each entry:

  SEARCH i USING INTEGER PRIMARY KEY (rowid=?)
  LIST SUBQUERY 1
    SCAN image_cache

`image_id` is the cache's primary key, so each image is in the list at most
once and the count is the one the EXISTS gave (254). After: 0.05 ms. On a
library whose every original is cached this is as much work as before,
which is the proportion the rule asks for.

The count stays on the keystroke path: dropping it there would leave the
chip stale after a background download until something else refreshed it,
and at this cost there is nothing left to save. catalog_bench spells the
query as dr-ui does, so its copy changes with it.
2026-09-26 13:28:50 -04:00
dtourolle 73059f2656 Count the label chips from the labelled versions, not from every image
`label_histogram` runs on every label keystroke and after every batch of
judgements is saved. On the reference library it cost 7.2-8.4 ms best-of-50
by catalog_bench (13 ms on a busy machine), to report that none of 23,500
images carried a label.

The join was the rating histogram's, with one thing worse: the index does
not carry `label`, so each probe went on to read the version's row.

  SCAN i USING COVERING INDEX images_folder
  SEARCH v USING INDEX versions_judgement (image_id=?) LEFT-JOIN
  USE TEMP B-TREE FOR GROUP BY

It now takes the rating histogram's shape: only labelled default versions
are grouped, and the unlabelled slot is what is left of `judged_rows`.

  SCAN versions USING INDEX versions_judgement
  USE TEMP B-TREE FOR GROUP BY          (the labelled rows only)

That pass still reads each default version's row for `label`, but in the
index's order, which follows the table's; a partial index on the labelled
rows would make it index-only, and was not worth a new index for the
remaining 1 ms. After: 1.7-2.0 ms, the same answer on the reference library,
and a test that compares it with the old join over the awkward states the
rating test uses (a second default's label counted, unknown codes and zero
folded into unlabelled).
2026-09-26 13:28:50 -04:00
dtourolle 81118728f4 Count the rating chips from the rated versions, not from every image
`rating_histogram` runs on every star keystroke. On the reference library
(24k images, 1,200 of them rated) it cost 6.3 ms best-of-50 by
catalog_bench, and up to 10-14 ms when the machine is busy.

It was `images LEFT JOIN versions ON ... AND is_default = 1 GROUP BY
rating`. The plan:

  SCAN i USING COVERING INDEX images_folder
  SEARCH v USING COVERING INDEX versions_judgement (image_id=?) LEFT-JOIN
  USE TEMP B-TREE FOR GROUP BY

A probe of the index per image, then a sort of all 23,500 rows, to put
22,000 of them in slot zero.

Now the rated rows are grouped on their own (`rating != 0`: one pass over
`versions_judgement`, a sort of 1,200 rows), and slot zero is what is left
of the join's row count. That count is three index-only aggregates -- the
library size, the default versions, and the images holding one -- so an
image with no version is still unrated, and an image with two default
versions still counts twice, exactly as the join counted it:

  SCAN versions USING COVERING INDEX versions_judgement      (x3)
  SCAN images USING COVERING INDEX images_folder

`count(DISTINCT image_id)` has its own statement because alone it reads the
distinct values off the index order; beside other aggregates SQLite builds a
temporary b-tree for it.

After: 1.2 ms. The histogram is the same on the reference library
([22364, 663, 19, 47, 115, 374]), and a new test compares it with the old
join on a catalog holding every state the schema allows: no version, only a
virtual copy, two defaults, ratings below zero and above five.
2026-09-26 13:28:50 -04:00
dtourolle 408f189019 Measure what the library screen reads on each keystroke and scroll
Issue #75 lists catalog reads paid on interactive paths rather than once:
the rating and label chip counts on every judgement keystroke, the "On this
device" count beside them, the keyword panel's vocabulary on every
selection change, and the grid's total on every scroll reload. catalog_bench
now times each of them against a real catalog and prints their answers, so
a change to any of them can be checked for giving the same numbers.

Two of them live in dr-ui's private `library` module; their SQL is spelled
in the bench as it is spelled there, which the module comment says.

Reference library (24k images), best of 50, CPU, on a loaded machine:
rating_histogram 9.0 ms, local_original_count 2.0, label_histogram 10.0,
keywords::list 4.0, keywords::for_images 5.0, grid count 1.9, grid count
with a one-star filter 4.0.
2026-09-26 13:28:50 -04:00
dtourolle fabc1c5b56 Regenerate the traceability matrix after the rebase 2026-09-26 13:22:08 -04:00
dtourolle 441f6f1404 Record why thumbnails are not queued
catalog.md §6 still described the design of 2026-08-09, where the grid
enqueued Thumbnail jobs at Interactive and a runner drained them. It was
never built that way: the grid asks a worker directly and the sweep's
work list is what the thumbnail store lacks. The one enqueue that did
exist fed a queue nobody claimed (#73).

§6.1 now states the decision and its evidence: the store is shared
between devices and is the only record that knows a thumbnail exists,
metadata is owed through metadata_state the same way, the retired rows
are dropped at open rather than by a migration so no older device loses
the synced catalog, and every_queued_kind_has_a_consumer holds the rule.
§6.3 notes that the priority ordering is had without the queue.

outstanding.md's FR-PLAT-AND-4 paragraph said the scan's thumbnail jobs
were the one reachable enqueue; it now says nothing enqueues, and that
feeding the runner means a handler and its enqueue in the same change.

Refs #73
2026-09-26 13:15:18 -04:00
dtourolle b3dbf4a039 Refuse a job kind that is enqueued with nothing to claim it
The queue coalesces, so a producer with no consumer never fails: it
leaves one row per subject for ever. That is how 23,582 Thumbnail jobs
accumulated unnoticed (#73), and nothing at runtime would have said so.

every_queued_kind_has_a_consumer reads the shipping sources of every
crate under core/, ui/, apps/ and platform/ (cfg(test) items dropped)
and pairs the JobKind named at each enqueue( call with the kinds named
in a fn kinds( body or a claim_next_matching( call. An enqueue that does
not spell its kind is refused, since the pairing could not be checked.

It guards against passing over nothing: the queue's own files and the
scan must have been read. A second test runs the reader over fixed
snippets so a parsing bug shows up as a failure. Run against master's
scan.rs and walk.rs it names all three orphan enqueues.

Refs #73
2026-09-26 13:15:18 -04:00
dtourolle 6e67ef4467 Drop retired Thumbnail jobs whenever a catalog is opened
Stopping the enqueue leaves the rows already queued: 23,582 on the
reference catalog, about 1 MB of table and indexes that every query over
jobs pays for.

A migration would be the usual tool and is the wrong one here. A schema
bump makes an older build refuse the synced catalog snapshot, and the
tablet is on 0.16.0. So the rows are dropped at runtime instead, by
jobs::drop_retired over a new JobKind::RETIRED list, from runner::recover
- which already runs exactly once per catalog open, before any worker.

It runs every open rather than once because an older build sharing the
catalog queues them again on its next scan. kind leads the
UNIQUE(kind, subject_id) index, so with nothing left it is one index
probe. Measured on a copy of the reference catalog: 23,582 rows dropped
in 40 ms on the first open, 0.07 ms after.

Thumbnail stays in the enum so its number is never reused for a kind
that would then inherit old rows. The runner tests that call recover
move to a live kind; the jobs.rs tests of queue mechanics never call
it and are unchanged.

Refs #73
2026-09-26 13:15:18 -04:00
dtourolle 5fcd3752d7 Stop the local walk queueing work nothing claims
walk::scan_root enqueued an ExtractMetadata and a Thumbnail job for every
image it inserted or found changed. No handler claims either kind. The
walk is only reachable from the scan_local example today, so no real
catalog holds these rows, but it is the same leftover the remote scan
carried (#73) and it is what a local library would inherit.

Both debts are already recorded where their consumers look: an inserted
or changed image is written at metadata_state 1, which is the metadata
sweep's work list, and the thumbnail store answers for itself.

The tests that used job rows as the measure of "this image owes work"
now read metadata_state, which is the record the sweep actually uses;
the no-requeue test marks the first image read before the second scan,
so it still proves an unchanged neighbour is not put back in debt.

Refs #73
2026-09-26 13:15:17 -04:00
dtourolle 5da28584a4 Stop the scan queueing a thumbnail job per photograph
The reference catalog held 23,582 Thumbnail jobs, one per image, and
every scan re-coalesced all of them. Nothing has ever claimed that kind:
no JobHandler is registered for it on desktop or Android, and
dr_catalog::sync never merges another device's jobs in.

Thumbnails are owed by the store, not the queue. The grid's worker and
the thumbnail sweep both find their work by asking ThumbStore what it
lacks, and the store is shared between devices, so it is the only record
that knows another device already made one. A queue row was a second,
staler copy of that debt that grew with the library and was read by
nothing.

persist still writes the images and their remote identities in the one
transaction; it just no longer adds a row to jobs for each of them. The
two tests that asserted the rows existed become one that asserts a
repeated scan queues nothing.

Refs #73
2026-09-26 13:09:44 -04:00
dtourolle 8a1d9c8642 Find the canvas tools by the condition they are gated on now
The download fix renamed the canvas gates to root.has-photo, and the
canvas-order test still searched for the old spelling, so it panicked
before checking anything. The order it guards is unchanged.
2026-09-26 11:58:30 -04:00
dtourolle c3b10ed372 Format the download description test 2026-09-26 11:23:09 -04:00
dtourolle 4bec01eaf1 Say a photograph is downloading, and how far, instead of failing
The develop view reported a remote original on its way through the
error message, so it read "Could not load image" over "Downloading…".
It did so on every step along the roll, including a cached frame that
was ready within a tick, so each step flashed the error.

Waiting is now its own state. On the step, the grid's thumbnail of the
photograph stands in at once. Only when a transfer is really on the
wire does it dim under "Not on this device yet", with a line like
"Downloading — 12.4 of 38.0 MB" and a progress bar.

The bytes come from a new RemoteBackend::get_reporting. The Nextcloud
backend overrides it to read the body chunk by chunk; the default
reports once at the end. Progress is kept in the in-flight registry by
path, because a step usually lands on a frame the prefetcher is already
fetching. The catalog's file length stands in when the server sends no
Content-Length.
2026-09-26 11:02:11 -04:00
dtourolle 3b97195b37 Keep a stepped-past download from replacing the open photograph
Opening a photograph from the library starts a download and a timer that
polls for it. Every step along the roll started another, and each one put
its result on screen when it landed, so a frame stepped past earlier
could arrive last and replace the one whose name was showing. Each open
now takes a generation number; a download that lands for an older
generation is recorded in the activity list (its bytes are cached) and
goes no further.

The outgoing session also stayed live until the new download landed.
Its sliders kept working, and a second step before the first landed
saved that session's edit under the new photograph's identity. The
session is now dropped as soon as its edit is saved.
2026-09-26 11:00:36 -04:00
dtourolle 5794f8c6ea Release 0.16.0
Benchmarks / CPU and I/O (per commit) (push) Successful in 5m57s
Benchmarks / Frame budget (on demand) (push) Skipped
Traceability / Requirement traces (push) Successful in 47s
Build and test / Android (aarch64) (push) Successful in 31m41s
Build and test / android-image (push) Successful in 4s
🐳 Android image / Build and push (push) Successful in 4s
Build and test / Desktop (Linux) (push) Successful in 47m44s
Build and test / windows-image (push) Successful in 4s
🐳 Windows image / Build and push (push) Successful in 3s
Build and test / Layer separation (push) Successful in 45s
Build and test / Windows (x86_64, cross) (push) Successful in 36m16s
Build and test / Publish the release (push) Successful in 1m35s
2026-09-26 09:46:32 -04:00
dtourolle b1d36b9143 Picture the duplicate originals review in the manual
The manual described the review (7c9a4ee) without a picture, because the
demo library holds no duplicates. The new duplicates scene makes two:
it copies two New York frames into a bck folder beside their own,
restarts the app so the scan finds them, waits for the sidebar row the
sweep's dating brings (54aee50), opens the review from it, presses
Check and takes the page. It deletes the copies and restarts on the
library as it was; record.sh's snapshot restore would remove them too.
It is registered last, so no other scene sees the copies.

The picture shows both groups proved the same file, the camera-named
copy outside bck marked Stays, and "Move 2 copies to trash" ready.
2026-09-26 08:02:51 -04:00
dtourolle 048d48f532 Re-record the manual on the 0.16.0 interface
Every scene was recorded again on a release build of this commit with
the automation feature. Master changed what nearly every picture shows
after they were taken: scrollbars on the develop column, the grid, the
sidebar and Settings; a "?" beside Settings in develop's top bar, with
its controls regrouped; the Film row opening its list as a popup.

The film scene pressed the list's rows through the develop column, which
no longer holds them: the list is a popup, and the automation hook
reports its contents relative to it. The scene now opens it with
film_list_open, turns the wheel down it and back so the popup and its
scrollbar are seen scrolling, and clicks Velvia at its popup position
plus the popup's origin. The caption says so. film_reach passed in the
same run: the last stock was reached by the wheel, a drag, the scrollbar
and the keys.

Looked at as contact sheets of each GIF's middle and last frames and
each PNG. launch, launch-folder, library-nesting.png and
library-collection-menu came out byte-identical after optipng and are
unchanged. develop-zoom's deepest frames are smooth on Xvfb as before;
its caption does not claim blocks. Settings shows version 0.15.0,
the build's own, until the release commit bumps it.
2026-09-26 08:02:39 -04:00
dtourolle 35d0696b66 Show upgrade_endpoint and the https-only client in the storage design
storage.md's BackendProvider listing and its notes predated #65: the
trait gained upgrade_endpoint (core/dr-sync/src/provider.rs:95), run at
launch by AccountStore::upgrade_endpoints to move an http:// account to
https:// with its keyring entry, and the Nextcloud client refuses plain
http below every URL it sends (adade27, ea31791, 5569a06). The listing
gains the method and a note says why it is not normalise_endpoint again.
2026-09-26 07:49:39 -04:00
dtourolle 9e099a07ab Record in the catalog design how duplicate originals are proved
catalog.md said content_hash is computed only for import duplicate
detection and reconnection, and left "the same image catalogued twice"
as unspecified. FR-CAT-11a now handles the within-root case: it proves a
group by content_hash where every copy has one, otherwise by first and
last megabyte digests kept in dedup_probes, a table created on first use
rather than by migration (core/dr-catalog/src/duplicates.rs:296). The
cross-root case stays open, and the bullet now says which half is done.
2026-09-26 07:49:19 -04:00
dtourolle a76e3bdd02 Describe flagging, scrollbars, Help and long steps in the manual
The manual described what the pictures show and missed what this round
changed around them:

- Rating and flagging gave stars only. The keys (0-5, P, X, U), Flag on
  the selection bar, judging in develop without moving on, and the flag
  and stars on the roll's cells had no sentence.
- Nothing said the desktop draws scrollbars on the grid, the sidebar,
  the develop column and Settings, or how the grid's bar is used.
- Nothing said how to open Help: the header's Help or F1, and in
  develop the "?" beside Settings (d9f2596, 380cfda), with its See it
  links and its Manual button.
- Stepping along the roll now goes on past what the roll has loaded
  (a030bfd); Settings gained the duplicate originals line and the
  Manual row.

index.html is regenerated from the README.
2026-09-26 07:48:46 -04:00
dtourolle 0103200fc5 Point the docs index at the help sheet, the bundled manual and its page
The index described the manual's contents as they were before colour
labels and the duplicates review, and did not say the application
carries the manual or where the in-app copy of the gesture book is
(Help or F1 in the grid, "?" or F1 in develop since d9f2596 and
380cfda). Its conventions named two generated files; manual/index.html
is a third, with its own CI check.
2026-09-26 07:47:00 -04:00
dtourolle 7c838691e9 Bring the README's feature list and "Where it stands" up to the tree
The requirement count is 191, as traceability.md's summary now reads;
the 84% it quotes still rounds from 83.8%.

"Not built" left out two things the matrix lists as untagged and the
outstanding register describes: importing a Lightroom or darktable
catalog (FR-CAT-14) and translations past the launch screen
(NFR-A11Y-1).

The feature paragraphs predated this round: the library now filters and
sets colour labels and consolidates duplicate originals, develop corrects
converging verticals and intersects mask parts, and the keyboard
vocabulary, the help sheet (F1, or "?" in develop) and the bundled
manual it links into had no mention. The version line is left for the
release commit.
2026-09-26 07:46:46 -04:00
dtourolle 6979b1a2b8 Tell contributors about the key and manual gates
CONTRIBUTING named four CI commands and the traceability tag, and
nothing about the three checks added since 0.14.1 that a first UI change
is most likely to meet: gestures-check, which fails on a key bound
without a GESTURE block or a block naming an unbound key (9d1e31f);
manual-check, which fails when index.html is not the render of the
manual (1021635); and record.sh --check, which fails when the manual
shows a picture no scene makes (70583b9). Two short paragraphs say what
each holds and where the recording tools are.
2026-09-26 07:46:17 -04:00
dtourolle 9253a16fed Say which packages carry the manual, and that NFR-R8 is decided
distribution.md's list of what every channel must get right had the
face models as the one LFS trap. Since 0.15.0 (d8f26fb) the Arch
package, the Windows installer and the APK also carry the rendered
manual and its pictures, and refuse LFS pointers for the same reason;
the Flatpak manifest does not install it.

The Vulkan bullet still called NFR-R8 open. It was decided on
2026-09-19: no CPU pipeline, and a viewer on embedded previews with
develop and export withheld.
2026-09-26 07:45:57 -04:00
dtourolle 9b1f74e6d5 Say in the architecture what the decoder trait became
§3.2 sketches a RawDecoder over a seekable reader, and the crate map
named it. FR-RAW-2 was built in 0.15.0 as dr_decode::Decoder over bytes
(core/dr-decode/src/decoder.rs:33): the decoder states how much it needs
and where its preview is, and the storage layer fetches it. The sketch
stays as the argument for four entry points; a note under it says what
shipped, and the crate map uses the built name.
2026-09-26 07:45:42 -04:00
dtourolle e31990550f Record progressive refinement as built, and the fit view's two speed-ups
display-and-extension.md still called FR-DSP-4 absent, and
frame-budget.md ended its reading at "satisfied vacuously". 0.15.0 built
it (refine.rs, the provisional histogram, the fade), so the table row
says so, frame-budget.md gains a note under its FR-DSP-4 section, and the
register gains a status note like FR-RAW-2's and FR-UI-5's.

frame-budget.md also gains a short section with the figures 1dc7b45
(the fit view's gather cached per framing) and d430ec9 (an identity
detail pass dropped) measured, since its fit rows no longer describe the
fused path. They are quoted from those commits, on the machine they name,
and are marked as not re-run here.
2026-09-26 07:45:26 -04:00
dtourolle 79c051e8a6 Bring the outstanding register up to 0.16.0
Five things in it were no longer true of the tree:

- FR-DSP-4 said "Unbuilt" and that nothing tracked a provisional frame.
  0.15.0 finished it: the settle debounce in ui/dr-ui/src/refine.rs,
  the draft flag reaching the histogram (canvas-draft,
  Levels.provisional), and the 150 ms fade of the last draft
  (app.slint's canvas-previous).
- The note that dedup.rs is re-import detection stood alone. FR-CAT-11a
  is now built beside it: dr_catalog::duplicates, dr_ui::duplicates and
  the Duplicate originals review.
- Culling counted three unbuilt clauses and named two.
- Section 6 still counted zero @tr( and five accessible-* lines, figures
  from before 2026-08-30. The launch screen is converted (build.rs holds
  the mechanism, no .po exists), 127 accessible-* lines sit across eleven
  files, and ui_controls_are_accessible.rs holds the structure.
- Section 11 said nothing of the panorama was built. It was, the same
  week; FR-MRG-9, NFR-MRG-1 and NFR-MRG-2 are what carry no tag.

A new section 4a lists what the develop, mask and keyboard work left
open, so that its closed clauses are not looked for: FR-UI-5's wheel on
sliders, FR-RAW-2's second decoder, mask-editing M2's remainder, and
FR-DEV-17's deliberate blind spot for range and region parts. The
Android paragraph now says develop is zero-copy there since TD-1 was
paid off, and that the APK carries the manual.
2026-09-26 07:45:00 -04:00
dtourolle ea44aba110 Put a scrollbar on the library grid
The timeline beside the grid says *when* the view is. It does not say
how far through the library the view is, and scrubbing it jumps by
date. The bar gives the plain desktop answer: a thumb in proportion to
one screen of the whole grid, dragged or paged by clicking the track.
It reads the viewport that the rows are counted into, so it spans every
photograph and not only the loaded window of cells.

The Flickable keeps its id, its `interactive` arbitration with the
hold-to-pick-up and its pinch and zoom catchers. It now fills a
Rectangle that takes its place, and its stretch, in the grid's layout.
2026-09-26 07:25:46 -04:00
dtourolle cc905fa2ed Put a scrollbar on the controls and shortcuts sheet
The gesture book runs to several screens inside a card that otherwise
looks complete. Same wrapping as the develop column. The bar is drawn
over the list's right edge, so it overlaps the last few pixels of each
"See it" button.
2026-09-26 07:25:45 -04:00
dtourolle f7df276295 Put a scrollbar on the settings page
The page is several screens long, and nothing said so until something
had been scrolled. Same wrapping as the develop column; the bar sits at
the window's right edge, clear of the capped 680px form.
2026-09-26 07:25:45 -04:00
dtourolle 005dc3a835 Put a scrollbar on the collections sidebar
A tree longer than the panel looked, to a mouse, like the whole tree.
Same shape as the develop column: the Flickable fills a Rectangle that
takes its stretch, and a ScrollBar is drawn over its right edge. It is
drawn only when the tree overflows.
2026-09-26 07:25:07 -04:00
dtourolle 825015a58e Put a scrollbar on the develop column
The column (histogram, compose, every adjustment group, history) runs
several screens past the window. A mouse without a wheel could only drag
the column's contents, and a drag that starts on a slider moves the
slider.

The Flickable now sits in a plain Rectangle with a ScrollBar beside it,
bound to its viewport. Nothing about how the column sizes itself
changes: the Flickable fills the Rectangle, and the Rectangle takes the
stretch the Flickable had in the layout under the group strip. The bar
is drawn over the column's right-hand 10px, so the mandated panel width
is not reduced. When the pointer is on the bar, the lit track covers
the value labels of the compose rows, which already sit flush against
the column's edge.
2026-09-26 07:25:06 -04:00
dtourolle 2ef971bf37 Fail when the last film stock cannot be reached
The film list bug gave no failure anywhere: the data was right, the
markup compiled, and the list rendered. Two guards now check that the
list can be walked to its end, both by driving input rather than by
reading markup.

tests/film_list_reaches_every_stock.rs runs in CI and needs no display.
It builds the real AppWindow on Slint's testing backend and gives it 28
stocks. It dispatches window events through the same routing a window
uses: popup, Flickables, arbitration. It then checks that the last stock
is on screen, that is, not clipped away:
- after Down past the end, and that Enter chooses it;
- after drags on the list;
- after a run of wheel events with a still pointer;
- after dragging the scrollbar thumb.

Element queries need the Slint compiler's debug tables, which build.rs
emitted only for the `automation` feature. It now emits them for every
debug build too. Release builds, the ones that ship, are unchanged. The
testing backend is a dev-dependency at the same pinned version the
automation feature already uses, so no new crate enters the lockfile.
The test is compiled out of release test runs.

film_reach in tools/manual/scenes.py is the same check on the recording
rig: a real X pointer from xdotool, the release build, and the demo
library. It makes no picture, so it adds nothing to the manual. It runs
with every recording, or alone with `record.sh LIBRARY film_reach`, and
fails the run if the last stock (Ilford HP5 Plus) is out of reach by
the wheel, a drag, the scrollbar or the keys.

Both have to add the popup's position back. The testing backend reports
anything inside a popup relative to the popup, and so does the
automation hook built on it. They take the popup's position from the
Film row and Slint's clamp into the window.
2026-09-26 07:24:46 -04:00
dtourolle e957d483fc Give the film list a scrollbar on the desktop
A list cut off at its edge looks, to a mouse, like a list that ends
there. The open film list shows ten rows of twenty-eight, and nothing
said there were more. The user asked for visible scrollbars on every
platform but Android.

ScrollBar (widgets.slint) is a vertical bar drawn over a Flickable's
right-hand edge. It shows the share on screen and the position, it can
be dragged by the thumb from wherever it was grabbed, a click on the
track moves a page toward the click, and the wheel over it scrolls. It
is the Flickable's sibling rather than a wrapper, bound to
`viewport-y <=> flick.viewport-y` and the two heights, so a scroller
keeps its own sizing. It is drawn over the content rather than beside
it, so the mandated column widths are not reduced. With nothing to
scroll it is not drawn and takes no input.

Whether to draw it is Scrolling.bars, which Rust sets from
dr_plat::is_touch_first(). That is the same function that puts the
develop groups in the rail, and it answers the same question: what is
the user pointing with? On Android, lists still scroll by flick only.
Placing the bar inside another scroller would put it back in that
scroller's arbitration. The film list can have one because it is now a
popup.
2026-09-26 07:24:45 -04:00
dtourolle 80938b0527 Open the film list as a popup, so every stock can be scrolled to
The open stock list showed ten rows, None to Kodak Kodachrome 64, and
the other eighteen - all seven black-and-white stocks among them - could
not be reached. The data was whole; the list could not be scrolled.

Reproduced on the manual rig (Xvfb, xdotool, the automation hook):

- a drag on the list scrolled the develop column, never the list;
- a wheel run over the list scrolled the column past it, whenever the
  column had scrolled under that pointer in the last 800 ms - which is
  how the list is reached, by wheeling the column down to it. After a
  pause and a pointer move the wheel did reach the list;
- no key did anything.

The cause is Slint's routing, not the list. Since 2d878c2 the list was a
Flickable inside the develop column's Flickable, and Slint offers every
pointer event to the outermost Flickable first
(input_event_filter_before_children, i-slint-core 1.17.1 flickable.rs).
The column holds a press back (DelayForwarding) and intercepts the first
move past 8 px on the axis it can scroll, so the list never saw a drag.
For the wheel it intercepts while its own last wheel event is under
800 ms old and within 2 px, and always for a touchpad gesture that opens
with TouchPhase::Started - so on a touchpad the list could get no wheel
at all.

The list is now a PopupWindow under the Film row. A popup is its own
item tree: while it is open, events go to it and to nothing beneath it,
so the list scrolls by wheel, drag and flick however the panel is nested
and whatever the column did last. The alternative, standing the column
down while the pointer is over the list (the sliders' hover trick), fixes
the drag but not the wheel - `interactive: false` does not gate wheel
interception - so it would have left the bug for touchpad users.

The reason 2d878c2 bounded the list still holds: it is at most 320 px
and never lengthens the column, and now it covers the sliders instead of
pushing them down. The column cannot be scrolled while it is open, which
suits a one-click question; it closes on choosing, on Escape or Back, or
on a press outside it.

Keys, with the list open: Up and Down move along it from the chosen
stock and scroll it into view, Enter chooses, Escape or Back closes it
unchanged. A popup is its own focus tree, so the keys are taken when it
opens and Slint returns focus to the develop view's scope when it closes.
The Film row gains a button role, so a screen reader and the automation
hook can name it.
2026-09-26 07:24:28 -04:00
dtourolle 6640ce0ca8 Regenerate the matrix, the gesture book and the manual page for the duplicates review 2026-09-26 07:20:19 -04:00
dtourolle 7c9a4ee358 Describe consolidating duplicate originals in the manual and the register
FR-CAT-11a records what #67 built: groups of the same file listed on
demand, proved the same before anything moves, merged onto one survivor
and the rest trashed a group at a time. The manual's new section under
The library says how to reach the page, how the copy that stays is
chosen, what the check reads and what merges.
2026-09-26 07:19:14 -04:00
dtourolle 54aee50539 Count duplicate originals once the sweep has dated them
A copy becomes a duplicate only once its capture time is read, and on a
fresh library that is the sweep, not the scan: the sidebar row stayed
hidden until the next launch. The count is refreshed when a sweep that
dated anything finishes.

Also says on a group left out of the plan that nothing will move, drops
an unused method, and names the review's completion callback type for
clippy.
2026-09-26 07:19:13 -04:00
dtourolle 220e9af222 Add the duplicate originals review, from the sidebar and from Settings
"Duplicate originals" appears under the trash in the collections
sidebar while the catalog holds any, and Settings says how many there
are beside the other whole-library passes. Both open one page: every
group with its picture and paths, the copy that stays (tap another path
to change it), a per-group Include box, what the survivor will gain and
any flag, label or face conflict, and why a group was skipped.

The summary is the dry run -- "N groups, M files to trash, K skipped" --
and nothing moves until "Check" has read the copies and "Move M copies
to trash" is pressed. Both run on workers with progress on the page, in
the activity register and, for the move, on the library status line;
Stop ends a job between groups. When it ends the grid, the sidebar and
the trash are refreshed and the survivors' judgements are written to
their sidecars and XMP the way a rating keystroke writes them.

The page is paginated at 30 groups, so a redraw decodes 30 thumbnails
and previews 30 merges whatever the size of the library. Back and
Escape leave it like its own Back button.
2026-09-26 07:19:13 -04:00
dtourolle 8d4ecb75c1 Prove duplicate originals the same and consolidate them on workers
dr_ui::duplicates is the half of #67 that touches files. The check reads
each copy's first and last megabyte by range through the backend and
hashes them (or compares stored content hashes where every copy has one),
keeps the probes in the catalog, and reads each copy's sidecar: a group
whose bytes differ, whose copies cannot be read, or whose develop edits
differ is left out and the review says why.

Consolidating a group carries the one edit onto the survivor's sidecar
where it has none, moves the other copies into the trash, and then
commits dr_catalog::duplicates::consolidate. A failure after the first
move puts the files and the sidecar back; a run that died between the
moves and the commit is finished by the next one, which finds each moved
file at its trash path.

Tested end to end on a folder library of real files: the copies land in
.darkroom-trash, the skipped groups are untouched, the edit reaches the
survivor, a catalog failure moves everything back, and restore returns
the copies byte for byte.
2026-09-26 07:19:13 -04:00
dtourolle 3c2eacbf3f Find catalog duplicates and fold a group onto one copy in one transaction
The library holds the same RAW in several folders: a dated folder, a
bck/ beside it, a renamed Darktable export tree. dr_catalog::duplicates
is the catalog half of consolidating them (#67).

candidates() is one grouped query over root, camera, capture instant and
size, joined back for the rows; count() is the same grouping under
COUNT. On a copy of the reference catalog (23,582 images) both take
10-35 ms and find 1,836 groups holding 3,379 spare copies.

survivor() prefers a copy outside a backup-looking folder, then one
still named the way the camera named it, then the oldest, then the
lowest id.

consolidate() re-checks the plan against the catalog, merges the copies'
judgements onto the survivor (highest rating, keywords unioned,
collections unioned with the survivor keeping its place, a flag or label
the copies agree on, faces via faces::carry_onto_copy) and records the
copies as trashed, all in one transaction, so a failure part way leaves
the group untouched. preview() runs the same code and rolls it back.

Sameness probes are kept in dedup_probes, created on first use rather
than by a migration: a schema bump would make older builds refuse this
catalog's snapshot at sync. trash::record_trashed_within lets the trash
write share the merge's transaction.
2026-09-26 07:19:13 -04:00
dtourolle d430ec9528 Drop a detail pass that changes nothing when it sits between two others
Capture sharpening at a scale too coarse to draw its radius emits one pass
with an empty body (`nothing_to_sharpen`), so that a chain still ends in
something that performs the output transform. At fit on any modern sensor
that is most of the time. When another neighbourhood operation follows it
- dehaze, clarity, texture - the pass is not last and does nothing: it
reads the rgba16float intermediate and writes the same texels to the other
one. It still cost a full render-sized read and write every frame:

  scene                           before     after
  sharpen+clarity 2560x1600 fit   18.66 ms   14.16 ms
  sharpen+clarity 3840x2160 fit   37.93 ms   27.88 ms
  every operation 2560x1600 fit   71.80 ms   67.24 ms
  clarity alone   2560x1600 fit   14.23 ms   14.20 ms  (control)
  sharpen+clarity 2560x1600 1:1   23.01 ms   22.97 ms  (control: resolves)

(Laptop RTX 3050 held at 420/810 MHz by its power cap, synthetic 60 MP
source, median of five alternated runs of forty frames each.)

`compose_detail_with` now drops such a pass where dropping it is exact:
not the last pass, whose output transform would otherwise move onto the
previous pass's f32 result and round differently; and not a pass right
after a reduced one, because a full-resolution pass is what closes the
reduced chain for the operation after it. `DetailPass::is_identity` says
what "changes nothing" means: full size, nothing bound at binding 3, and a
body with no code in it.

The rgba8 output is bit-identical: every scene above hashed the same
before and after, and the sharpen+clarity frame hashes the same as clarity
on its own, which is the claim in one line. A new dr-pipeline test pins
the three cases - dropped ahead of another operation, kept when last, kept
when alone.
2026-09-26 07:10:41 -04:00
dtourolle 1dc7b45cfe Read the fit view's source gather once per framing, not once per frame
At fit, every output pixel of the fused pass loads one texel from a source
three or four times its width, on a stride. The memory system fetches the
texels it skips along with the one it wanted, so on a 60 MP rgba16float
source that gather was most of what the fused pass cost: 10.6 ms of a
2560x1600 frame against 3.8 ms for the same shader reading a contiguous
window (the 1:1 view). At 3840x2160 it was 21.1 ms. Those are the laptop
RTX 3050 with its clocks held at 420/810 MHz by the power cap; unthrottled
the same frames were about 2.0 and 3.2 ms, and the gather is the same
share of them.

Which texel an output pixel reads depends only on the framing prologue,
the framing and warp uniforms, the source and the render size. None of
those move during a slider drag, so the gather is the same work every
frame. The fused shader now takes a render-sized rgba16float cache of it
(bindings 6 and 7, declared in every generated shader like the masks) and
a pair of uniform flags: write what was gathered, or read it back at the
pixel's own coordinate. AdjustPass keeps the cache and decides per
dispatch. The composer supplies `ComposedShader::sample_key`, a hash of
the prologue and those uniforms, and AdjustPass adds the image and the
size; an image gets a process-unique id for this rather than being held
alive by the key.

The picture is bit-for-bit the same. The source is rgba16float and so is
the cache, so the stored texel is the texel, and only the path that reads
a texel whole takes part: an interpolated sample (straightening, lens
warps, CA) is a blend that f16 could not hold exactly, so the composer
gives it no key and it reads directly as before.

The cache is written on the second frame with a given key, not the first:
a crop or zoom drag changes the key every frame, and writing then would
add a render-sized write to exactly the gestures that can afford it least.
It is kept only up to 3840x2400, so an export never parks a full-frame
copy on the device, and `release_caches` drops it.

Measured with a scratch probe rendering the synthetic 60 MP frame from
examples/frame_budget.rs, forty frames per run after six warm-up, five
runs of each binary alternated, median of the per-run p50 (GPU idle apart
from the power cap):

  scene                    before     after
  neutral   2560x1600 fit  10.62 ms   3.88 ms
  exposure  2560x1600 fit  10.83 ms   3.87 ms
  nr chroma 2560x1600 fit  19.84 ms  12.69 ms
  neutral   3840x2160 fit  21.05 ms   7.11 ms
  exposure  3840x2160 fit  21.08 ms   6.94 ms
  clarity   3840x2160 fit  42.20 ms  27.88 ms
  neutral   2560x1600 1:1   3.83 ms   3.84 ms  (control: nothing to gain)

The rgba8 output of every scene hashed identically before and after, in
isolated runs and across all 38 scene/size/view combinations of the
probe. New tests walk a pass through direct, write and read frames, a
slider move, a neighbourhood operation and a framing change, and compare
every frame with a fresh pass that can only have read directly.
2026-09-26 07:10:35 -04:00
dtourolle 284fc4a456 Regenerate the traceability matrix after the rebase 2026-09-25 23:25:08 -04:00
dtourolle 9de37bed81 Hold develop's judgement keys to the open photograph, and to staying on it
Rating and flagging in develop (FR-UI-5, amended 2026-09-19) landed with
the keyboard audit: 0-5, P, X and U in develop's key scope, stars and
Pick/Reject in its top bar, and flag and stars on the roll's cells. Two
of the amendment's rules were held by nothing. The keys must judge the
photograph on screen and never a selection left behind in the grid, and
judging must not move on to the next frame, which is culling's
auto-advance and not develop's.

The rating, flag and label callbacks that take a row each spelled the
row-to-image lookup themselves. It is now one function, `image_at_row`,
which answers None for a negative row as well as one past the end: the
roll passes -1 when the open photograph is outside the loaded window,
and the right answer then is to judge nothing. A test says so. The key
bindings live in Slint, where no test can press them, so a second test
reads develop's handler as the gestures gate and the canvas-order test
do, and checks that each judgement key calls the row callback on
`library-roll-current` and that none of them steps the roll or the
cursor.

The writes themselves go through `apply_judgement`, the grid's own path:
one catalog statement, then the sidecar and XMP writes behind it.
2026-09-25 23:24:52 -04:00
dtourolle 380cfda695 Make develop's Help a "?" beside Settings, so Settings fits at 1600
Adding a labelled Help button to develop's top bar made the strip about
100 pixels wider than a 1600-pixel window. The strip scrolls, so nothing
became unreachable, but Settings was off the right-hand end until the
bar was dragged.

Help is now a square IconButton with a drawn question mark (a new "help"
icon, drawn rather than typed for the reason icons.slint gives about
Android's fonts), and screen readers still hear it as "Help". That saves
60 pixels, which was not enough alone: the strip had fit with 3 to spare
before. The rest comes from the spacing. Controls that belong together
now sit in groups at gap-sm with gap between groups: Pick and Reject,
Undo and Redo, Copy, Paste and Presets, and "?" and Settings. The empty
export-status caption no longer takes a slot and two spacings while it
has nothing to say. At 1600 wide with the panel open the whole bar now
shows, Settings included.

The grid's header keeps its worded Help button; it has room for it.
The develop "Open this list" gesture now names the "?" button, and the
gesture book is regenerated from it.
2026-09-25 23:24:52 -04:00
dtourolle d9f259656a Open Help from develop as well as from the grid
The "Controls and shortcuts" sheet was drawn by LibraryGrid, so only the
grid's Help button and its F1 could open it. Develop, where most of the
keys it lists are bound (Ctrl+E, Ctrl+Shift+C, A/D, Z, R, H, [ ]), had no
way to it: a photographer who wanted to look a shortcut up had to leave
the photograph they wanted it for.

The sheet now hangs off the shell beside the export and copy sheets, on a
`help-open` property both views set. The grid's Help button and F1 raise
it through a callback, and its keys stand down through the `sheet-open`
they already honour for the export sheet, so Escape falls through to the
shell, which closes it. Develop gains a Help button beside Settings in
its top bar, as in the library header, and F1 in its key scope; its keys
decline while the sheet is up, as they do for the other two sheets, so
nothing behind it is rated or stepped. The book already begins with the
Develop section, so from develop it opens where the reader wants it.

Tests hold the shape: the sheet is drawn by the shell and not the grid,
and develop's opening guard names all three sheets and its F1 opens this
one. The new GESTURE: block puts the develop route in the book.
2026-09-25 23:24:52 -04:00
dtourolle 19dd3257e3 Release the offset borrow before bring_window_to reloads the window
Holding D in develop across the edge of the loaded window panicked with
"RefCell already borrowed" at the first step that had to move it. The
`*ctl.offset.borrow()` written inside the `if let` condition is a
temporary that lives to the end of the `if let` block (edition 2021),
and the block calls `load_window`, which borrows the offset mutably.
The unit tests drive the placement arithmetic, not the RefCells, so
they could not see it; stepping 400 frames in the app did.

The offset is copied out before the test. `follow_open` gets the same
treatment for `image_ids`: the lookup's result is bound first, so no
borrow is held while it writes properties back to the window.
2026-09-25 23:06:24 -04:00
dtourolle a030bfd239 Step along the roll by library ordinal, and keep its mark on the open photograph
In a library's develop view the arrows, space, A and D opened
`library-roll-pick(library-roll-current ± 1)`. `roll-current` is a row
of the loaded window, set when a photograph was opened and never again.
Two things followed on any library larger than one window:

- Holding D stopped dead at the end of the loaded window: the pick of a
  row past `ctl.paths` found no path and did nothing, a few screenfuls
  into a library of thousands. A stopped at its start the same way.
- Any reload of the window — a background sync, a judgement that drops
  the open frame out of a filter — left `roll-current` on a row that now
  held another photograph. The roll marked it, develop's rating keys
  judged it, and the next step walked on from it.

The keys now call `library-roll-step(delta)`, and Rust steps as
`move_cursor` does in the grid: from the open photograph's ordinal
(`index`, which `report_position` already keeps), clamped to the
library, loading the window around the target only when it lies
outside. The window-bringing half of `place_cursor` is shared for this
as `bring_window_to`, so the grid cursor and the roll move the window
the same way. Catalog reads stay proportional to the window: one
`load_window` per window crossed, none per step within it.

The open photograph is also remembered by image id. Every
`load_window` finds it again in the rows it just read (a scan of one
window, no query), puts `roll-current` and `index` back on it, or takes
the mark off when it is not there. When it is missing but its ordinal
is still inside the window, it has left the grid and its successor
moved up into its place, so the next step forward lands on that ordinal
instead of skipping the successor.

A click on the roll still reports a row; it is right because the cells
it is drawn from and `ctl.paths` are replaced together, and it now goes
through the same open path as a step.

Fixes #64.
2026-09-25 23:00:55 -04:00
dtourolle ce72fe49a0 Record the catalog lessons of the 2026-09-25 performance pass
Three habits the pass found broken, in the style of the 2026-09-19 notes:
SQL text passed to `execute`/`query_row` in a loop is a prepare per row
(the merge, `persist` and the shard sync all paid it), a case-insensitive
`LIKE` cannot use the `source_ref` key where a range can, and the backfill
inside `Catalog::open` is paid by every worker thread, the develop view's
fetches included. And where the two new benches are and how to run them.
2026-09-25 22:06:58 -04:00
dtourolle 4583048595 Find a sidecar's photographs by an index range, not a case-insensitive LIKE
When a scan pull takes in sidecars another device wrote, `apply_judgement`
finds the photographs each one describes with
`source_ref LIKE '{stem}.%'`. SQLite's LIKE folds ASCII case, and nothing
indexes `source_ref` case-insensitively, so every lookup read all 24,000
names of the root through the `(root_id, source_ref)` index: 1.5-2 ms per
sidecar, 520-690 ms for the 342 `.drsc` files the reference catalog has
read. Another device culling a shoot is several hundred of them.

Every name beginning `{stem}.` lies in the half-open range
`[{stem}., {stem}/)` -- `/` is the byte after `.` -- which the unique key
serves as a seek. The rows LIKE matched beyond these differed only in
case, and the check that decides, `sidecar_path(source) == sidecar`, has
always compared exactly and refused them; the escaping of `%` and `_`
goes too, since a range has no wildcards.

persist_bench: 342 lookups 634-691 ms -> 9 ms CPU. Checked against the
reference catalog directly as well: for all 18,430 distinct sidecar
names its images imply, the range and the old LIKE, each filtered by
`sidecar_path`, pick the same photographs. A test pins the neighbours of
the range: a case variant, a longer stem, a subfolder named like the
stem, and a folder whose name holds `%` and `_`.

The XMP reader's LIKE (`xmp_sync::images_for`) is left alone: its check
is case-insensitive, so the range would not be a superset there, and it
only runs when the exact darktable-style name is not found.
2026-09-25 22:06:58 -04:00
dtourolle 32b2a5e317 Write a scan's findings with prepared statements, and its jobs in the same commit
`persist` runs after every scan, for every photograph the scan listed. On
a settled library that is the folders whose ETag changed -- a sidecar
written there by a rating is enough -- so one relisted folder of 1,600
images is an ordinary pass, and a first scan is all 24,000.

Per photograph it prepared four statements from their SQL (a folder
lookup, the image upsert, the id read-back, the remote upsert) and then,
after the commit, found the image again by path and enqueued its
thumbnail job as an autocommitting statement of its own -- a commit per
photograph, for rows that were almost all already queued.

Now the statements are prepared once per pass, a folder's id is looked up
once per folder rather than once per photograph in it, and the job is
enqueued inside the transaction with the id already in hand. That also
makes the job atomic with the row it points at, which is what the old
ordering after the commit was trying to guarantee. `jobs::enqueue` uses a
cached statement for the same reason.

persist_bench on a copy of the reference catalog, CPU, best of runs:

  largest folder (1,589 images)   102-118 ms ->  10-13 ms
  whole library (23,582 images)   1.55-2.19 s -> 188-192 ms

The fingerprint of images, remote, jobs and folders after the run is the
same for both builds.
2026-09-25 22:06:58 -04:00
dtourolle 9e786532a1 Look for orphaned keywords once per word, not once per assignment
`adopt_orphan_terms` runs in the backfill on every catalog open. Its
check -- is there a vocabulary row for this word, tombstones included --
cannot use `keyword_terms_name`, which is partial on `deleted = 0`, so the
correlated subquery scanned the vocabulary once for each of the 10,800
assignment rows before `DISTINCT` threw the repeats away: 3.5 ms per open
on the reference library.

The distinct words are taken first and the check runs once per word -- a
few dozen scans of a few dozen rows. Same rows out, since `DISTINCT` over
the assignments is exactly the set of words.

catalog_bench, best of 20: 3.45 ms -> 0.30 ms.
2026-09-25 22:06:58 -04:00
dtourolle 1f26e1e627 Pair RAW and JPEG from the unpaired JPEGs, not from every RAW
`Catalog::open` runs the backfill every time, and every worker thread
opens its own catalog: the develop view does it to fetch each original and
again for each neighbour it prefetches, and the sync, sweep, burst and
thumbnail workers each do it too. On the reference library (24k images)
an open cost 26 ms of CPU, and most of it was `pair_raw_and_jpeg` reading
all 17,000 RAWs into a map of lowercased stems to find partners for the
1,900 JPEGs that have none -- the same 1,900 on every open.

It now starts from the small side. The unpaired JPEGs are read first, and
it stops there if there are none; otherwise it reads the RAWs in the
folders those JPEGs sit in (plus the unfiled ones when an unfiled JPEG is
waiting), which is 142 on the reference library. A pair is same-folder by
definition, so no pairing is lost; the RAWs are read in id order, so where
two share a stem the later one still wins as it did in the table scan; and
a pass with nothing to pair no longer opens and commits an empty write
transaction.

catalog_bench, best of 20, CPU: `Catalog::open` 26 ms -> 12 ms together
with the next commit (the backfill 24 ms -> 11 ms; this step is ~10 ms of
that). A test covers pairs found among other folders and unfiled images.
2026-09-25 22:06:58 -04:00
dtourolle 2fd1b0b8ce Read the face shard index once per sync pass, not once per image
Every sync pass exports this device's faces to the shard store and imports
what peers sent, and both walked the whole library asking the store's index
about one image at a time: the export 19,000 `indexed_at` lookups (one per
face marker), the import 23,000 `held_model` lookups (one per image with a
server id), each a statement prepared and run against the index. With
nothing new either way -- the usual pass -- that was all they did.

Measured with catalog_bench against copies of the reference catalog and
face store, best of 5, CPU:

  export_to_shards (steady)   119 ms ->  27 ms
  import_from_shards (steady) 250 ms ->  87 ms

Each now reads the index in one statement into a map. The import's query
is `held_model`'s, ordered the same way, keeping the first row per file,
and nothing in the loop changes which pipeline a file is held under
(`set_indexed_at` touches only a file already decided; candidates are
distinct files). The export's `put_image_at` does rewrite entries -- but
only its own file's, its generation and the siblings it supersedes -- so a
file already written in this pass is asked of the store again, and every
other answer is the one the lookup would have given. An index that cannot
be read gives an empty map, which is what each failed lookup returned.

The store index and the catalog are identical after the old and new
builds' runs.
2026-09-25 22:06:58 -04:00
dtourolle 9cff677392 Merge a synced catalog's face assignments without re-preparing per face
A sync pass that brought nothing new cost 450-540 ms of CPU in
`merge_remote_catalog` on the reference library (24k images, 19k faces),
measured by catalog_bench merging a copy of the catalog with itself.

Most of it was the loop over the other device's confirmed faces and the
faces under its ignored groups -- 13,000 rows. For each one it prepared
three statements from scratch (`query_row`/`execute` with a SQL string
compile the statement every call) and then rewrote the `face_person` row
with the values it already held, dirtying a page per face on every pass.
The rejection loop prepared three more per row.

The statements are now `prepare_cached`, the local assignment is read once
per face (whether it is confirmed, and what it holds, come from the same
row), and the upsert is skipped when the row already says exactly that.
`faces_assigned` is still counted for those rows, so the report is the one
the old code gave, and nothing else reads the difference: the row is
byte-for-byte what the upsert would have written.

After: 279 ms (best of 5, CPU), with every catalog table identical after
the run to the old build's.
2026-09-25 22:06:58 -04:00
dtourolle 454375243c Measure what opening the catalog, a sync pass and a scan cost on a real library
Two benches for reading side by side before and after a change, against a
copy of a real catalog, in the manner of identity_bench:

- `dr-catalog --example catalog_bench CATALOG [FACES_DIR]` times
  `Catalog::open` and the backfill inside it step by step, the upload
  snapshot, a merge of the catalog with a copy of itself, and the face
  shard export and import in the steady state where nothing is new.

- `persist_bench`, an ignored test in dr-ui's scan module because
  `persist` and `apply_judgement` are private to it, replays the
  catalog's own rows through `persist` (the largest folder, and the whole
  library) and looks up every `.drsc` sidecar the catalog has read. It
  works on a scratch copy and prints a fingerprint of what `persist` left,
  so two builds can be shown to agree.

Both print best, median and CPU time; the CPU figure is the one to compare
while other builds share the machine.
2026-09-25 22:06:58 -04:00
dtourolle c559d31dad Size TextRow's field box from the field's height, so its label shows
Every TextRow drew a field and a hint but no name: "Filename template"
and "Destination" on the settings page and in the export sheet, the two
storage budgets, the export size fields. The label was there, painted
behind the entry, with a clipped "ws" of "Thumbnails and previews"
poking out beside the thumbnail field.

The field sits in a Rectangle so the row can dim it and watch it lose
focus, and that Rectangle took its height from `field.preferred-height`.
`Field` sets its own `height` outright and has no layout inside it, so
its preferred height is zero. The box was zero tall, the row around it
took its height from the unit label beside it, and the field, centred
on an empty box, sat half its own height above the row, on top of the
FieldRow. Segmented never showed it because its chips live in a layout
that reports a real height.

Reading `field.height` instead gives the box the height the field
actually draws at, so the row reserves it and the label sits above. The
note on `Field.label` that recorded the fault now records the trap.
2026-09-25 20:43:00 -04:00
dtourolle 058286750b Say the develop view skips the readback on Android too
Benchmarks / CPU and I/O (per commit) (push) Successful in 2m4s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 12h40m50s
Build and test / Layer separation (push) Successful in 42s
Traceability / Requirement traces (push) Successful in 1m28s
🐳 Android image / Build and push (push) Successful in 5s
Build and test / android-image (push) Successful in 5s
🐳 Windows image / Build and push (push) Successful in 2s
Build and test / windows-image (push) Successful in 2s
Build and test / Android (aarch64) (push) Successful in 31m32s
Build and test / Windows (x86_64, cross) (push) Successful in 36m3s
Build and test / Publish the release (push) Skipped
"On desktop the develop view draws the compute pass's texture directly"
was the README's way of marking Android as the exception. Since TD-1 was
paid off in 0.15.0 the tablet draws the same texture, so the sentence
now names both.
2026-09-25 19:41:14 -04:00
dtourolle 548caa733c Drop the README's note that Android reads its frame back through the CPU
Benchmarks / CPU and I/O (per commit) (push) Successful in 2m10s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Successful in 46m51s
Build and test / Layer separation (push) Successful in 50s
Traceability / Requirement traces (push) Successful in 31s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / windows-image (push) Successful in 1s
Build and test / Android (aarch64) (push) Successful in 31m39s
Build and test / Windows (x86_64, cross) (push) Successful in 36m12s
Build and test / Publish the release (push) Skipped
"Where it stands" still named TD-1 as the one deliberate compromise worth
knowing about: the Android develop view reading its frame back through the
CPU because wgpu's swapchain tore in portrait. 0.15.0 paid that off — the
swapchain is pre-rotated and the frame reaches the compositor as a texture,
as on desktop, and architecture.md §6.1 now reads "No exceptions since
0.15.0". The release commit updated the version line above and left this
paragraph describing a state the release had just ended.
2026-09-25 19:40:33 -04:00
dtourolle cf84cec96f Release 0.15.0
Benchmarks / CPU and I/O (per commit) (push) Successful in 5m19s
Benchmarks / Frame budget (on demand) (push) Skipped
Traceability / Requirement traces (push) Successful in 49s
Build and test / Android (aarch64) (push) Successful in 31m25s
Build and test / android-image (push) Successful in 1s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / Desktop (Linux) (push) Successful in 48m26s
Build and test / windows-image (push) Successful in 5s
🐳 Windows image / Build and push (push) Successful in 4s
Build and test / Layer separation (push) Successful in 34s
Build and test / Windows (x86_64, cross) (push) Successful in 19m57s
Build and test / Publish the release (push) Successful in 1m6s
2026-09-25 07:51:31 -04:00
dtourolle f0e7b8e11c Re-record the manual on the keyboard layout, and stop the zoom caption promising blocks
Every scene was recorded again on a build of this branch rebased onto the
keyboard work and TD-1, since nearly every scene depends on files those
changed: the develop top bar now carries a star strip and Pick/Reject, the
roll shows flags and stars, and the grid's selection bar gains Label and
Flag. `--changed` could not be trusted to find them, because the rebase
made each picture's commit newer than the sources it was recorded from.

The develop-zoom caption said the wheel goes on "until the pixels are
blocks". On Xvfb the deepest frames come out smooth even though the app
draws past 1:1 nearest-neighbour on a real display (confirmed by eye on
the desktop), and a GIF shrunk to 960 wide could not show 3-pixel blocks
anyway. The caption now says what the clip shows; the prose above it,
which describes what the app does, stays.
2026-09-25 07:26:37 -04:00
dtourolle 90c7cb65d3 Regenerate the manual page after the rebase onto the keyboard work
The rebase combined this branch's README additions with master's, and
index.html is generated from the README; manual-check passes on the
regenerated page.
2026-09-25 07:26:37 -04:00
dtourolle 70583b9b2f Fail CI when the manual shows a picture no scene makes
The traceability job now runs `tools/manual/record.sh --check`: every
picture docs/manual/README.md shows must be made by a scene in
tools/manual/scenes.py, and every picture a scene makes must be shown.
It reads the two files and nothing else, so it needs no app, display or
LFS pull.

--changed now dates a scene by the newest commit among its pictures
rather than each picture alone. A scene that also makes a picture which
re-records byte for byte (panorama-aligned beside panorama.gif) no
longer stays listed for ever. A scene all of whose pictures come out
identical (launch) stays listed until one differs, which costs one
harmless re-run.
2026-09-25 07:26:37 -04:00
dtourolle f426bb903a Picture this round's features in the manual
Four scenes, recorded and looked at frame by frame:
- library-labels: 6, 7, 8 and 9 over four New York frames, 7 again to
  take one off, then the Green chip narrowing the grid and back.
- compose-perspective: two towers shot from below, stood upright with
  Vertical at about +58, then held against Before.
- crop-orphan: a stroke in the top-left corner, a crop that leaves it
  outside, the notice with Undo crop and Keep crop, and Undo crop.
- local-intersect: a linear gradient over the lower half, then
  Intersect and two strokes that survive only where it is.

develop-zoom already ends on hard-edged pixels (previous commit). The
text added is a sentence or two under the existing headings, so the
other branch's structure and anchors are left alone.
2026-09-25 07:26:36 -04:00
dtourolle c41f99ea52 Record the manual's scenes by name, and each against what it depends on
scenes.py aimed every press at window pixels, and the develop column had
already moved under it: Compose now sits above Adjust, so the old
exposure coordinate lands on a straighten slider. Every scene now names
what it presses by its accessible label through the automation hook,
places points on the photograph relative to the canvas, and opens its
photographs by file name. Each starts from a known place and undoes what
it did, so one can be recorded alone; the few that continue another's
state name it, and running one runs that first into a scratch folder.

Each scene also declares the pictures it makes and the sources they
depend on. `record.sh --check` fails when the manual shows a picture no
scene makes, or a scene makes one it does not show; it reads two files.
`record.sh --changed` re-records the scenes whose sources, or own code,
changed since the commit that last touched their pictures. record.sh
builds with the automation feature, restores the library from
DR_LIBRARY_SNAPSHOT, starts from a fresh profile and pins inference to
the CPU; the launch screen is recorded from an empty profile of its own.

Re-recorded with the ported scenes, and looked at frame by frame. What
differs from the pictures they replace:
- develop, presets, settings, local, compose, film, wb, light: the
  current develop column (Compose with Vertical and Horizontal above
  Adjust, the Label button), otherwise the same moments.
- library pictures: the filter bar's colour-label chips; no collection
  left over from an earlier run in the sidebar; library-selection is the
  twelve alpine frames rather than eight of them and four New York ones.
- library-rating rates two frames nobody had rated, so the stars are set
  and not cleared.
- develop-zoom goes on past 1:1 with the wheel and ends on the file's
  pixels as hard-edged blocks.
- repair covers a real mark on the road, with a size that fits it; film
  is shown on the Chinatown frame instead of the road.
- panorama tries Perspective, Spherical and Cylindrical before filling.
- launch, launch-folder and panorama-aligned came out byte-identical.
2026-09-25 07:26:36 -04:00
dtourolle a6ea6ba83f Let the manual's scripts find a control by its name
Every scene in tools/manual aimed at window pixels written in by hand, so
a panel that gained a row moved every slider under it and the recording
went on dragging where the slider used to be. The develop column has
already moved that way (Compose now sits above Adjust), and nothing said.

A build with the `automation` feature listens on the Unix socket named
by DR_AUTOMATION and answers where an element is: by its accessible
label, the name a screen reader reads, or by its markup id for the few
things that are not controls (the canvas, the crop rectangle). It uses
Slint's element queries, which need the compiler's debug tables, so the
feature also turns those on in build.rs. It only answers questions; the
input is still xdotool's real pointer. No default build has the feature,
and one that has it listens only when the variable is set.

drive.py gains click-on, drag-on, hold-on, wait-for, wait-gone, labels
and ids. The grid's cells are now named by their file, each rating star
by its value, the sidebar's + as "New collection", and the Adjust
heading's reset as "Reset all adjustments" - controls a screen reader
could not reach before either.
2026-09-25 07:26:36 -04:00
dtourolle b480de5bff Record TD-1 as paid off, checked by eye on the tablet
The pre-rotation patches landed in the previous four commits. This
records how the debt was paid, by a fourth route its own list missed:
patching wgpu-hal and Slint's Skia surface locally rather than waiting
for either upstream. It also updates architecture.md §1 and §6.1, which
still said Android draws with OpenGL behind a readback.

The verification is stated as what it was: the user found the
release-signed build clean on the tablet in portrait. No dumpsys
composition or bufferTransform readings were taken, because adb would
not hold the device that morning, and no frame times were measured.
2026-09-25 07:26:00 -04:00
dtourolle a8043e6827 Hand Android's develop frame to the compositor as a texture again
With Skia drawing pre-rotated on wgpu's Vulkan swapchain, Android no
longer needs to draw with Skia over OpenGL, which was the only reason
the develop view read its frame back through memory (TD-1).

So `unstable-wgpu-29` moves back to the common slint dependency. The
android-activity backend then builds `SkiaRenderer::default_wgpu_29`,
and `shared_gpu` loses its Android arm. The one wgpu device is handed to
Slint through `BackendSelector::require_wgpu_29` on both platforms.
`slint::android::init_with_event_listener` runs before `dr_ui::run`, so
the selector reaches the Android adapter before its window exists.
`renderer-femtovg-wgpu` stays desktop-only, since Android has no FemtoVG.

The two `#[cfg(target_os = "android")]` readbacks in `develop::render`
(the frame through `export_pixels` and the focus overlay through
`read_overlay`) are gone. `read_overlay` stays for the tests that check
what the overlay marks.

Built for arm64 and release-signed. Not yet run on the tablet.
2026-09-25 04:20:19 -04:00
dtourolle 4b4c9e2e6d Pre-rotate Slint's Skia drawing on the wgpu swapchain on Android
The other half of the wgpu-hal patch: that one lets a caller promise a
pre-rotated swapchain, and this is the caller keeping the promise.

On configure, `WGPUSurface` reads the surface's `currentTransform`,
sizes the swapchain in the panel's orientation (swapped for a quarter
turn), tells wgpu-hal to use that transform, and before each frame
concatenates the matching rotation onto the Skia canvas. Everything
Slint draws goes through that one matrix, so an imported wgpu texture is
rotated with the rest of the window. Input is not rotated, and must not
be, because Android delivers it in window coordinates.

Three details that would each have been a visible bug:

- `resize_event` compared the new size against the swapchain's. The
  swapchain is transposed while a quarter turn is in effect, so the
  comparison now uses the window's size, kept beside it.
- A half turn, landscape to reverse landscape, changes the transform
  without resizing the window, and wgpu-hal hides the SUBOPTIMAL that
  would report it. So the transform is re-read before every frame. That
  costs one query into the native window.
- The item renderer snapped the origin to the pixel grid only when the
  canvas matrix was a pure translation. Under a rotation that is never
  true, so portrait would have lost pixel alignment everywhere. The check
  now accepts right-angle rotations and flips without scaling.

The direction of each rotation follows the Vulkan spec's reading of
preTransform (the image is drawn already rotated clockwise by the
transform). It has not been confirmed on the device yet.
2026-09-25 04:19:05 -04:00
dtourolle dc9da52651 Let a wgpu-hal caller choose the Vulkan swapchain's preTransform
wgpu-hal creates every swapchain with `preTransform = IDENTITY` (#3345).
On a tablet whose panel is mounted landscape, a portrait window then
hands Android an unrotated buffer: SurfaceFlinger falls back to rotating
it on the GPU (composition CLIENT), and on this device those frames tear.
That is why Android draws with Skia over OpenGL today, and why the
develop view pays a readback (TD-1).

The field cannot just be set to `currentTransform` inside wgpu. It is a
promise that the image is already drawn rotated and sized in the panel's
orientation, and only the renderer above wgpu can keep it. So the patch
is the smallest thing that lets that renderer ask:
`vulkan::Surface::current_transform` reads the surface's transform, and
`set_pre_transform` makes the next swapchain use it. The default stays
IDENTITY, so desktop and any caller that does not opt in behave exactly
as upstream.
2026-09-25 04:18:39 -04:00
dtourolle dc1add9dbb Vendor wgpu-hal 29.0.4 and i-slint-renderer-skia 1.17.1, unmodified
The Android develop view reads its frame back through memory (TD-1)
because wgpu's Vulkan swapchain never pre-rotates, and a portrait window
on this tablet's landscape panel then tears. The fix is a small patch to
each of these two crates, and this commit is only the ground it lands on:
both are byte-for-byte the crates.io sources the lockfile already
resolved, so the commits that follow are the patch and nothing else.

third_party/ is excluded from the workspace, or every path dependency
under the root would become a member and `--workspace` would test and
lint upstream code as ours. The README says how to carry the patches
across a Slint or wgpu bump, which matters because a stale version here
does not fail the build — cargo just warns and uses the unpatched crate.
2026-09-25 04:18:39 -04:00
dtourolle b5ae5c2be1 Record FR-DEV-16 as met and where FR-UI-5 stands
FR-DEV-16's promise that the gesture book cannot describe a binding the
application lacks is now enforced by the gestures gate in both directions,
and every binding it names is bound and tagged, so it is marked met, with
what develop answers beyond the list.

FR-UI-5 is not met in full: its keyboard half and the 2026-09-19 amendment
are, but no develop slider takes the scroll wheel, so its status says that
rather than rounding up.
2026-09-24 23:42:28 -04:00
dtourolle aa2a88f655 Show each frame's flag and stars on the develop roll
FR-UI-5's 2026-09-19 amendment asks for the rating and flag wherever they
can be set and on the roll's cells, so that stepping along a set in develop
shows what has been judged. The roll drew thumbnails only, so the keys that
now judge the open photograph left no trace on its neighbours.

Each roll cell carries a small badge with a tick or a cross and a star
count, drawn only when there is something to show, in shapes and a number
rather than colours (NFR-A11Y-3).
2026-09-24 23:42:28 -04:00
dtourolle f8737e3fda Close the help sheet with Escape, and keep keys from acting behind it
F1 opens the help sheet from the grid, and nothing on the keyboard closed
it: Escape fell through to the shell, and every other key went on judging,
labelling and keywording the photographs hidden behind the sheet.

Escape and Back now close the sheet first, ahead of the grid's other
sheets, since it is drawn over all of them. While it is open the grid's
handler declines every other key, so a stray P or Ctrl+K changes nothing
the reader cannot see.
2026-09-24 23:42:27 -04:00
dtourolle d489a34190 Drive develop, the grid, the sidebar and People from the keyboard
An audit of every action by view against the keys the handlers bind left
develop without zoom, pan, fit or a way back to the grid, the grid without
select-none, thumbnail size or keywording, People with no key at all, and
the export and copy sheets without Enter. It also found the reverse gap
FR-UI-5 forbids: pick and reject had no route but P, X and U, and the
2026-09-19 amendment's judging in develop had not been built.

Develop: Ctrl+= and Ctrl+Plus zoom in and Ctrl+- out about the middle of the
view, Ctrl+0 fits and Ctrl+1 goes to 1:1, Shift and an arrow pan a magnified
view, G goes back to the grid, Ctrl+Y redoes, and Enter keeps a crop that hid
a mask. 0-5, P, X and U rate and flag the open photograph without moving on,
with stars and Pick/Reject in the top bar as the pointer and touch route.
= and - nudge the control last moved by a hundredth of its travel; the
framing sliders, perspective included, now count as "last moved", so R puts
them back as well. J turns the selected mask part's join chip.

Grid: Ctrl+D and Ctrl+Shift+A clear the selection, = and - resize the
thumbnails, Ctrl+K opens keywording, and Flag in the selection bar gives
pick and reject a pointer and touch route. Sidebar: Enter commits a
collection's name, and Enter or Escape hands the keyboard back to the grid,
where it used to go nowhere until something was clicked. People: Up and Down
walk the rail, F2 puts the name field under the keys, and Escape or Back now
leave the screen the way its back button does instead of doing nothing.
Sheets: Enter does what the export or copy sheet's button does.

The choices follow Lightroom where it has one. No new key steals typing: the
grid's and People's keys live on focus holders that are not ancestors of any
text field, and the sheets' Enter comes after a focused field has had it.
Every binding is tagged beside its handler, and the gate added in the
previous commit holds the two to each other.
2026-09-24 23:42:26 -04:00
dtourolle 9d1e31ffbb Fail CI when a key is bound but not in the gesture book, or listed but not bound
The gesture book is generated from GESTURE tags, so it could not describe a
gesture nobody tagged, but nothing made anyone tag one. The arrow keys, Enter,
P, X, U, Delete, F1 and F2 all worked in the grid with no line in the help
sheet, and a tag could name a key whose handler had gone.

Key handlers now compare one canonical string, Keys.chord(event) == "Ctrl+Z",
instead of reading event.text and the modifiers themselves. keys.slint folds
the key and its modifiers into that spelling, so the literal in the handler is
the whole binding and the checker reads exactly what the handler dispatches
on. Each handler carries a KEYMAP comment naming the gesture-book section its
keys belong to, and a tag's keys field names its keys between backticks.
gestures-check now fails when a handler binds a key no tag in that section
names, when a tag names a key no handler there binds, when any .slint file
other than keys.slint reads event.text, when a compared literal is not
canonical, and when keys.slint's named keys drift from the Rust list.

Spellings are normalised in one place, chord.rs: Ctrl+z, Control+Z and
LeftArrow all mean what the handler's "Ctrl+Z" and "Left" mean. Shift and Alt
count only for letters and named keys, because on the French layout every
digit needs shift and a 6 has to be a 6 however it was typed.

A Rust keymap that both dispatched and was read by the generator was the
alternative. It would have moved the handlers' decisions away from the Slint
state they depend on, and a window that forgot to install it would have had
no working keys at all.

The keys that were already bound and undocumented are now tagged.
2026-09-24 23:42:25 -04:00
dtourolle 150e53e878 Link fifty gestures on the help sheet to the manual section that shows them
The sheet could now offer "See it", but no gesture said where to look.

Every GESTURE tag whose move the manual describes names that section:
the white-balance picker, zoom and pan, masks, undo and snapshots,
export, colour labels and ratings, selection, collections, the People
page, thumbnail size. Fifty of the fifty-one; the one left, putting a
single control back to its default, has no section and is too small to
earn one.

Three important gestures had nowhere to land, so the manual gains three
short sections, without pictures for now: Bursts (opening a folded
burst, choosing the frame it shows, and the eyes-open filter), Moving
between photographs (the roll, the arrows and A/D in develop) and
Copying settings (Copy, Paste, Paste to N, and choosing what a copy
carries). The page, the gesture book and docs/gestures.md are
regenerated from them.
2026-09-24 23:25:37 -04:00
dtourolle 7591738c73 Let a gesture name the manual section that shows it
The help sheet says which move does a thing, and the manual has a
picture of the thing being done, but nothing joined the two: a user
reading "Pinch it with two fingers" had no way from there to the GIF of
it.

A GESTURE tag takes an optional `manual:` field naming a heading of
docs/manual/README.md by its anchor. The scan checks every one against
the anchors the bundled page is rendered with and fails when the manual
has no such heading, so renaming a section cannot leave the sheet
linking to the top of the page; gestures-check carries the same failure
into CI. The anchor goes into gesture_book.rs as a new field, and into
docs/gestures.md as a "See it" link to manual/README.md#anchor. The help
sheet draws a "See it" button beside the title of each gesture that has
one, which opens the bundled manual at that section.

The field is additive: a tag without it is unchanged, and no gesture
carries one yet.
2026-09-24 23:24:17 -04:00
dtourolle 352e59498b Open the bundled manual from Help and from Settings
The packages now carry the manual, but nothing in the application opened
it: the help sheet listed gestures and stopped there.

The help sheet gains a Manual button beside Done, and Settings a Manual
row under About beside the version. Both go through dr_ui::manual, which
finds the installed page through dr_plat::system_data_dirs (the package's
share directory on Linux, the executable's directory on Windows), and a
development build also in the checkout it was compiled from. A copy with
no manual says so on the status line rather than doing nothing.

On the desktop the page goes to the system browser. A section is a URL
fragment, and xdg-open's generic mode and Windows' FileProtocolHandler
both turn a file: URL into a path and drop the fragment, so a section is
opened through a one-line redirect page written to the data directory:
the opener gets a plain path, which every opener keeps, and the browser
follows the redirect to index.html#section itself. The launcher behind
the sign-in's open_in_browser is split out so both share it; the https
check stays with the sign-in.

Android has no path to give a browser: an asset is not a file, a copy in
private storage is unreadable to other apps, a file: URI across apps is
refused, and a content: URI leaves the browser resolving every picture
against the provider. So ManualActivity, a WebView reading
file:///android_asset/manual/index.html straight out of the APK, shows
it, started by class name with the section as an extra. JavaScript is
off, links off the page go to the browser, and the theme is day-night so
the page's own light and dark follow the system. A test checks that the
manifest, the Java class and dr_ui agree on the name and the extra.
2026-09-24 22:56:09 -04:00
dtourolle d8f26fb5cd Ship the manual with the Arch package, the Windows installer and the APK
The rendered manual was in the repository and nowhere else, so an
installed application still had nothing to open.

Each packager now carries docs/manual/index.html and its pictures, to
where the application will look for them: /usr/share/darkroom/manual on
Arch, manual\ beside darkroom.exe on Windows (where the models already
are, and where dr_plat::system_data_dirs points), and assets/manual in
the APK, stored rather than deflated since a GIF or PNG is already
compressed. The manual is about 27 MB, which the APK and the installer
both grow by; the pictures are 1600x1100 screenshots and short GIFs,
and against an APK that already carries 170 MB of inference runtime and
70 MB of models they are not worth re-encoding for.

The pictures are LFS objects, so each packager refuses a pointer where a
picture should be, as it already does for the models: shipped, a pointer
is a manual of broken images that nothing reports. The Android and
Windows CI legs therefore fetch docs/manual/media, which they excluded
while nothing they built read it, and the installer smoke test checks
that the page and every picture were installed.
2026-09-24 22:33:41 -04:00
dtourolle 10216355c1 Render the manual as one HTML page the application can carry
The manual existed only as docs/manual/README.md, which the forge renders
and nothing else does. An installed copy of the application, on a laptop
with no network or on a tablet, had no manual it could open.

`traces manual` renders the README to docs/manual/index.html with
pulldown-cmark (already in the tree as Slint's Markdown parser, so this
adds a dependency edge and no crate). The page is one file with an inline
stylesheet that follows the system's light or dark preference, a
contents list of every section and subsection, and the pictures by their
relative media/ paths. Each heading carries the id the forge gives it, so
README.md#rating-and-flagging and index.html#rating-and-flagging are the
same link. A picture alone in its paragraph becomes a figure whose alt
text is shown as the caption, and every picture reserves its 16:11 box
before it loads, so a jump into the middle of the page lands where it
aimed rather than a screenful above. Links to design documents, which the
installed page has no copy of, point at the forge.

The page is committed rather than rendered at build time, as the gesture
book is: it is user-facing text reviewed in the diff, and the three
packagers then only copy it. `traces manual-check` fails in CI when the
committed page is not the render of the README, and the pre-commit hook
regenerates it when the README is staged.
2026-09-24 22:32:30 -04:00
dtourolle d8e031888e Regenerate the gesture book, which four rebases left with conflict markers
docs/gestures.md on master carried 162 lines of <<<<<<< / ======= / >>>>>>>
from 4642c77, ade627a, c3d1f83 and 46f5b95. Their branches were rebased
onto each other, the generated files conflicted, and the resolution
regenerated the requirements matrix with `traceability -- report` and then
staged gestures.md as it stood, on the assumption that the same command
writes it. It does not: the gesture book has its own `gestures` mode. The
source tags were never in conflict, so nothing is lost; this is the file
regenerated from them, and `gestures-check` passes on it.
2026-09-24 22:24:41 -04:00
dtourolle 114d979397 Add Vertical and Horizontal perspective sliders to Compose
The keystone existed in framing but nothing in develop could reach it:
framing is presented by its own Compose panel rather than generated, so
new framing parameters get no control until the panel names them.

Compose now has Vertical and Horizontal sliders under Straighten,
mirrored from the session like the angle, recorded as parameter steps
("Vertical Perspective" in the history), cleared by the Compose reset
and by opening the next photograph. Releasing either slider refits the
crop the way releasing the straighten slider does: a keystone alone
needs no crop, but it moves the empty corners of a straightened frame,
so the crop that avoided them before may not after, or may have room
to grow back.
2026-09-24 22:13:12 -04:00
dtourolle 5a500118ae Correct converging verticals with a keystone in framing
There was no perspective transform anywhere in the pipeline: framing
offered a ±45° straighten, quarter turns and flips, and a building shot
looking up kept its leaning walls.

Framing gains a vertical and a horizontal keystone (-100..100). They are
parameters of framing rather than a new stage, so they carry its Compose
attribute, persist in the sidecar under framing, and are withheld from a
default paste exactly as the crop is. In the prologue the keystone runs
after the crop and the straightening and before the stored orientation
and the lens warp, so "vertical" is the photograph's displayed height and
the lens still sees its whole frame.

The map takes the output frame onto a trapezoid inside the source, built
as a homography from four corners and uploaded as three columns in the
framing uniform block (which grows from two vec4s to five). A keystone on
its own therefore never exposes an empty corner and leaves any crop valid.
Combined with a straightening angle the empty area is a pulled-back
quadrilateral the closed-form inscribed rectangle cannot describe, so
max_inscribed_crop searches for the largest centred rectangle whose
corners all have a source pixel behind them. source_at and output_at
apply the same map, so masks, gradients and spot handles follow it.
2026-09-24 22:13:11 -04:00
dtourolle ff89a4fa21 Specify perspective correction as FR-DEV-20
Issue #13 asks for a vertical and horizontal keystone, and its number was
renumbered from FR-DEV-19 when the spec gave that to mask editing. The
clause was never written into the register, so the work had nothing to
trace to.

It is written as part of framing: after the crop and the straightening,
before the stored orientation and the lens warp, carrying framing's
Compose attribute, and with the inscribed crop accounting for it.
2026-09-24 22:13:11 -04:00
dtourolle 04495afbfd Regenerate the traceability matrix for the colour-label work
The pre-commit hook left the matrix as it stood on two of the commits before this one, so it named neither the new test file's NFR-A11Y-3 tag nor the line numbers the label code moved. Regenerated from the tree as it now is.
2026-09-24 21:52:23 -04:00
dtourolle 96f1d5c896 Say in the manual and the register that colour labels exist
The manual's library section described rating and flagging only, and
the outstanding register still said colour labels were "set and shown
nowhere" and that three NFR-A11Y-3 clauses had no test. Both are now
untrue: the manual gives the keys, the Label button and the chips, and
the register names the test file and what it can and cannot vouch for.
2026-09-24 21:52:23 -04:00
dtourolle f1db919b9d Fail a test when a star, a flag or a label differs by colour alone
NFR-A11Y-3 was argued in comments beside the rating strip, the
pick/reject mark and the focus-peaking chips, and nothing would have
failed if an edit made a set star differ from an unset one only in tint.
Only the clipping readout had a test.

These read the markup, as the accessibility-name tests do, since a
rendered window cannot be asked what a colour-blind reader sees. The
star and the flag must choose their glyph from their state, and the
glyphs they choose must be different drawings in icons.slint. The peaking
chips must be distinct words that reach the screen as text. Each colour
label must carry its own letter and the name the catalog's code stands
for, the mark must draw the letter, and the grid cell, the filter chips
and develop must draw the mark or the name. Breaking any of these by
hand makes the matching test fail.
2026-09-24 21:52:23 -04:00
dtourolle 46f5b95828 Show and set colour labels in the grid and develop, and filter by them
Colour labels could be read from a Lightroom sidecar and queried by the
selector, but nothing drew one or set one, so the only labels a library
held were ones another program had written.

Every mark carries its label's initial on its colour — R, Y, G, B, P —
so a label is read without telling red from green, which is what
NFR-A11Y-3 asks of colour labels by name. A grid cell shows the mark
before its filename. In the grid, 6, 7, 8 and 9 set red, yellow, green and
blue as Lightroom's keys do, on the photograph under the pointer or on
the selection by the rule the star keys follow; the same key again takes
the label off, and over a mixed selection it sets it on all. The
selection bar gains Label, which opens the six choices — each a mark and
a name — and purple, which has no key, is there. In develop the top bar
says "Label: Green" beside the mark, opens the same choices, and 6-9
label the open photograph.

Each gesture is one catalog transaction, then the grid, the counts and
both sidecars are written as a rating's are. The filter bar gains a chip
per label, its mark and its name with a count, one at a time; the filter
is one SQL term, travels in the place record, and "All" clears it.
2026-09-24 21:52:23 -04:00
dtourolle d748527a4c Keep colour labels in the sidecar so they survive and travel
A rating and a flag are written to DarkRoom's sidecar as well as the
catalog, because the catalog is a disposable index and the sidecar is
how a judgement reaches the photographer's other devices. A label had no
place there, so once labels could be set, one would have lived only in
the catalog of the device it was set on and gone with it.

The sidecar version now carries `label` (0 none, 1-5 as the catalog
codes it), written only when set. It merges under the rating's rule, so a
device that never labelled a frame cannot clear another device's label,
and a code this build does not know reads as none rather than as some
other colour. A judgement write carries the catalog's label with the
stars, and the scan takes a sidecar's label into the catalog when it has
one. An older build keeps the line as an unknown key and writes it back.
2026-09-24 21:52:23 -04:00
dtourolle 89859d39d1 Let the catalog set colour labels, toggle them, and count them
Colour labels reached `versions.label` only from an XMP sidecar: nothing
in the catalog could set one, clear one, or read it back alongside the
stars, so there was nothing for an interface to call.

`set_label` and `set_label_many` write it the way ratings are written,
the bulk form in one transaction so a key over a selection is one commit.
`toggled_label` holds Lightroom's rule for a label key: it clears only
when every image already carries that label, and otherwise sets it on all
of them, so a half-red selection comes out red rather than inverted.
`Judgement` carries the label, so the grid's one window query brings it
with the stars, and `label_histogram` counts each label in one grouped
statement for the filter chips. A label does not make a frame "judged":
it is a pile of the photographer's own, not a cull decision.

The doc comment for `default_version_id` had been stranded above
`label_code` when that was inserted; it is back on its function.
2026-09-24 21:52:23 -04:00
dtourolle ade627a5d0 Fade the draft into the sharp frame when a drag settles
When a gesture stopped, the half-resolution draft was replaced by the
full-resolution frame in one step, a visible jump from soft to sharp.
FR-DSP-4 asks for a refinement that is smooth, not a jarring swap.

The canvas now keeps the last draft frame (`canvas-previous`) and draws
it over the sharp one, fading it out over 150 ms when the draft flag
clears. The fade costs no render: the draft is a refcount on the texture
it was drawn into, and the adjust pass ping-pongs between two output
targets, so the sharp frame is written into the other one. While a
gesture is drafting the layer is hidden and snapped opaque, so a new
drag shows its draft at once; past the fade it is hidden again, and a
settled canvas composites one image as before.
2026-09-24 21:52:23 -04:00
dtourolle 4642c77e18 Dim the histogram while the canvas shows a draft
The histogram is measured on settled frames only, so during a drag it
describes the frame from before the gesture while the canvas shows
something newer, and nothing said so. The draft flag stopped at the
render closure.

It now reaches the interface: `canvas-draft` on the window and
`Levels.provisional` for the readouts, both set on every canvas render
from the flag that chose the frame's resolution, and cleared when a
render fails. The histogram panel dims its display reading to half
while a draft is up and brings it back when the frame settles. Dimmed
rather than captioned, because a caption appearing on every drag would
move the column; the raw reading has no frame to lag and is left alone.
2026-09-24 21:52:23 -04:00
dtourolle 7d0870c3fb Keep a drag in draft until it stops, then render sharp once
During any drag longer than 120 ms the canvas rendered a full-resolution
frame every 128 ms under the finger. The settle timer was armed by the
first draft of a burst and not re-armed by later ones, so it counted from
the start of the gesture rather than from its last movement, fired
mid-drag, and the next coalesced event armed it again. Each of those
frames is the most expensive one the canvas draws, landing where the
frame budget is tightest.

The draft/sharp decision now lives in `refine::Refine`, apart from the
timers that carry it out. Every draft frame arms a settle timer carrying
a generation token and only the newest token is honoured, so the sharp
frame lands SETTLE_DELAY after the last movement. A request arriving
while a settle is still owed also counts as part of the gesture, so a
slow stretch of a drag (one event per frame, nothing to coalesce) no
longer renders sharp between drafts. A timer that fires with a render
already posted defers to it.

The tests drive the state machine through simulated timelines; the
long-drag case reproduced the four mid-drag sharp frames before the fix.
2026-09-24 21:52:23 -04:00
dtourolle c3d1f83b96 Say so when a crop leaves a mask outside the frame
Cropping tighter past a mask layer made it invisible without a word:
the layer stayed in the panel and the sidecar, and its adjustment went
on landing on pixels nobody would see again.

When a crop is let go, develop now measures what the gesture did to the
mask stack (dr_pipeline::orphan) and, if any layer is now entirely or
mostly outside the frame, shows a notice over the photograph: how many
layers, their names, "Undo crop" and "Keep crop". The crop is already
applied and nothing waits on the answer.

The crop overlay gains a release callback carrying the rect the press
began from, so the measurement runs once per gesture and never on the
drag's per-frame changes. Choosing a ratio is measured the same way,
being a crop committed in one click.

"Undo crop" is the ordinary undo, and the notice is tied to the history
revision it was raised at: the redraw that follows any history move
clears it, so the crop and its warning go back as one step. A second
drag folded into the same step is measured from where that step began.
A crop that strands nothing shows nothing.
2026-09-24 21:52:03 -04:00
dtourolle fc0ea8824d Format the crop-orphan measurement
rustfmt wraps two tuples in dr_pipeline::orphan that the previous commit
left on one line past the width limit. No change in behaviour.
2026-09-24 21:52:02 -04:00
dtourolle 9772785f81 Measure which mask layers a crop takes out of the frame
Mask geometry is stored in source coordinates, so re-cropping tighter
never destroys a layer. It makes it invisible: the layer stays in the
panel and the sidecar, its adjustment lands on pixels nobody will see,
and nothing says so. The spec had no clause for this; FR-DEV-17 now
states it, under the ID issue #10 reserved.

dr_pipeline::orphan samples each layer's mask on a 64x64 lattice over
the source, with the gradient, radial, brush and model-raster geometry
the mask shader uses, folds the parts by their joins and inversions, and
maps the samples through the framing to see how much of the coverage
the crop keeps. `hidden_by_crop` reports the layers whose share fell
below a tenth, and only those the change newly hid, so an already
stranded layer is not announced again on every later adjustment.

Ranges follow the picture and region selections need a label map this
crate does not hold, so a layer that adds either is never reported: a
false alarm on the common path would teach the notice to be dismissed
unread.
2026-09-24 21:52:02 -04:00
dtourolle 733a033274 Test that a stub decoder reaches the scan, the ladder and export
FR-RAW-2's "without changing callers" needs a test that would fail if a
caller named the concrete decoder; passing a real RAW through rawler
cannot tell the two apart, because both routes give the same answer.

The decoder_seam tests hand a stub decoder, for a container no real
decoder reads, to the catalog scan (read_metadata_only over a folder
backend), the preview ladder (the remote two-stage fetch, an import's
thumbnail and the viewer's no-GPU fallback) and export (open_for_export,
skipped without an adapter). Each assertion is on something only the
stub produces: its camera and date, a header fetched at its 64-byte
budget rather than HEADER_BYTES, preview and sensor sizes turned by its
orientation. Switching collect_metadata or make_thumbnail back to the
free functions fails two of the three tests.

The develop test_support module is widened to the crate so the export
test shares the one headless GPU context the other tests use. The
requirements note for FR-RAW-2 now records the trait as built and the
second decoder as not.
2026-09-24 21:33:14 -04:00
dtourolle 414094bd38 Route dr-ui's decoding through the Decoder trait
With the trait in place the claim still meant nothing while every caller
named dr_decode's free functions: a second decoder would have had to be
threaded through the scan, the thumbnail ladder, import, the viewer,
export, merge and repairs at the moment it arrived.

Each of those now takes a &dyn Decoder and reads headers, previews,
orientation and sensor data through it, including the header budget a
remote fetch asks for (header_bytes) and where it finds the embedded
preview (locate_preview). Only the places that start a job name
dr_decode::default(): the thumbnail, sweep and thumbnail-sweep threads,
the viewer's open handlers, and the request structs a job is handed
(BatchRequest, MergeRequest, the import Request, the repairs Toolkit),
so a caller can be given another decoder by changing what it is handed.

The default is rawler through the same free functions as before, so
nothing a user sees changes. The trait gains Debug as a supertrait so
request structs that derive Debug can carry one.
2026-09-24 21:33:14 -04:00
dtourolle d8fb382ce9 Put the RAW decoder behind a Decoder trait
FR-RAW-2 says a second decoder may be added for broader camera coverage
without changing callers, and D2 names LibRaw as that second decoder.
Nothing tested the claim: dr_decode was one decoder reached through free
functions, so adding another would have meant editing every caller at
the moment there was most pressure not to.

Decoder is an object-safe trait over bytes: header_bytes, metadata,
orientation, locate_preview, preview and decode. Rawler implements it by
delegating to the existing free functions, so behaviour is unchanged,
and dr_decode::default() hands it out as a &'static dyn Decoder, which
is what the places that start work will name. JPEG recognition, decoding
and completeness checks stay free functions: they are not a RAW
decoder's to vary.

Nothing in the trait takes a path or a SourceRef; the decoder states how
much of a file it needs and where its preview sits, and the caller's
storage fetches that.
2026-09-24 21:26:17 -04:00
dtourolle e8f68a92f8 Record intersection as built in the requirement and the mask plan
FR-DEV-19a said a part is added to the mask or taken out of it, and
mask-editing.md listed Intersect as an M2 item with nothing built. Both
now say what shipped: the requirement names the third join and why it is
a product rather than a minimum, and how old and new sidecars read across
it; the plan marks the blend-table row built, names the tests that hold
the GPU to the definition, and splits M2 into what is done and what is
still outstanding (joining non-painted parts from the panel, per-part
distance fields, folding two layers).
2026-09-24 21:25:48 -04:00
dtourolle 8f3df7b68d Format the intersect panel test as rustfmt lays it out
The chained lookup in the_intersect_button_joins_a_part_that_intersects was
one line past rustfmt's width, so fmt --check failed on the branch. Split
as rustfmt wants it; no behaviour changes.
2026-09-24 21:25:48 -04:00
dtourolle 5f0b7ac799 Offer intersection in the mask panel: an Intersect button and a third chip state
The pipeline could now keep only where two selections agree, but the panel
had no way to ask for it: the part row's chip flipped between + and -, and
the buttons under the parts joined an added or a subtracted correction.

An "∩ Intersect" button joins a painted part that intersects, and the chip
on a part row cycles + -> - -> ∩ and round, so an existing part can be
turned into an intersection without being repainted. Both go through the
same session calls as before, indexing Join::ALL, whose first two entries
kept their places. The chip is now a tagged gesture, so it is in the
gesture book.
2026-09-24 21:25:48 -04:00
dtourolle 8cdad3863d Keep only where two selections agree, as a third way to join a mask part
A layer's parts could be added to the mask or taken out of it, and nothing
else. The selections that need composing most are the ones that are
neither: the sky that is also bright, the subject that is also skin. With
union and subtract alone, "this and that" had to be spelled as "this minus
everything that is not that", which needs a second part that selects the
complement and rarely exists.

Join gains Intersect, stored as "intersect" in the part block of a sidecar.
It is the product of the two coverages, dst * src, which is one more
fixed-function blend state beside union's max and subtract's
dst * (1 - src) (mask-editing.md 5.2): the same scratch texture, the same
three vertices, no shader arithmetic. The product equals the minimum
wherever either side is fully in or out, and is the softer reading where
two soft edges overlap. Join::apply spells the three operations on the CPU
so the GPU tests can be held to one definition.

A layer that intersects with a part covering nothing now reports that it
covers nothing, so it is not rasterised as an empty slice. Old sidecars
never contain the word, so they read as before; a build from before this
reads "intersect" as a union, the existing unknown-join fallback, which
keeps the part visible rather than dropping it. Join::ALL keeps union and
subtract at indices 0 and 1 so a stored panel index still means the same
join.
2026-09-24 21:25:48 -04:00
dtourolle 229def0afc Show the file's own pixels at 1:1 and beyond
Zoomed to 1:1 or past it, the develop canvas showed a smoothed blur
rather than the photograph's pixels, so focus and noise could not be
judged at the magnification meant for judging them.

Two things caused it. The canvas only switched to nearest-neighbour
strictly past 1:1, with a margin, so the 1:1 inspection itself stayed
smooth. And the switch mostly had nothing to act on: the pipeline
rendered a viewport-sized frame at every zoom, so past 1:1 it was the
pipeline doing the enlarging - bilinearly whenever a straightening angle
or lens correction was in the chain - and the detail stage then sharpened
and denoised those invented pixels at radii scaled up to match. The
texture reached the canvas already blurred and was presented 1:1.

Now, from 1:1 on, the visible region is rendered at the source's own
resolution (render::render_size) and the canvas enlarges it with
nearest-neighbour, so the blocks on screen are the pixels an export would
have; it is also less shading. The decision lives in two small
functions, render::magnification and render::shows_source_pixels,
measured in physical pixels like one_to_one_zoom, with a half-percent
tolerance so the inspection zoom counts as 1:1 even where fit() rounded
the other edge. Below 1:1 the render and the smooth filter are unchanged.
2026-09-24 21:24:57 -04:00
dtourolle 5569a066ff Upgrade accounts saved as http:// to https on launch
Benchmarks / CPU and I/O (per commit) (push) Successful in 1m53s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Successful in 45m7s
Build and test / Layer separation (push) Successful in 41s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / windows-image (push) Successful in 2s
Traceability / Requirement traces (push) Successful in 40s
Build and test / Android (aarch64) (push) Successful in 28m49s
Build and test / Windows (x86_64, cross) (push) Successful in 17m9s
Build and test / Publish the release (push) Skipped
Before the previous commit, browser sign-in could store an account as
http://, and the client now refuses to send to one. Left alone, such a
library would fail to open with a configuration error, so its stored
endpoint is rewritten before anything reads it.

The endpoint is half of two keys, and both are handled:

- The keyring entry is filed under it. Rewriting only the record would
  strand the app password under the old key and sign the user out, so
  AccountStore::move_endpoint copies the secret across first, rewrites
  the record in place (the last record is the one resumed), and deletes
  the old entry only once nothing refers to it.
- namespace() is built from it and names the catalog directory. For an
  http to https rewrite it does not change, because the namespace strips
  either scheme. A move that would change it is refused, not performed,
  so no later rewrite can abandon a catalog either.

The rewrite is a new BackendProvider::upgrade_endpoint hook, which does
nothing by default, and not a second call to normalise_endpoint. The
folder connector's normalise_endpoint canonicalises the path and needs
it to exist, so running it on every launch would fail a library on an
unplugged disk, or rename one whose path now resolves differently. Only
Nextcloud implements the hook.

If the move fails (for example, a locked keyring), it is logged, the
account is left as it was, and the move is tried again on the next
launch.

Closes #65.
2026-09-24 20:44:19 -04:00
dtourolle ea31791388 Keep the typed server after browser sign-in, and open only https
Login Flow v2 saved the account under the `server` field of the poll
response, not under the address the person typed. That field is the
server's idea of its own URL. Behind a TLS-terminating proxy without
`overwriteprotocol` (a common setup) it says http://, and the account then
sent its app password in the clear on every request after that. The
typed address, already upgraded to https by normalise_endpoint, has just
carried the whole flow, so it is the one kept.

The flow's other two URLs come from the server as well, and are now
upgraded from http to https, and refused if they use any other scheme:

- The login URL is handed to the OS to open. On Windows that is
  `rundll32 url.dll,FileProtocolHandler`, which runs a file: or UNC path
  rather than showing a web page, so a hostile server could launch a
  program when the user starts signing in. open_in_browser also refuses
  anything that is not https, as the last check before a process starts.
- The poll endpoint is where the app password comes back from.

The host is not checked. A server reached by its LAN address can answer
with its public name, and refusing that would break a working setup
without protecting anything: the account is stored under the typed
address whatever the server says.

Part of #65.
2026-09-24 20:44:19 -04:00
dtourolle adade27de4 Refuse plain http in the Nextcloud client, below every URL it sends
NFR-SEC-3 held only for the address a person types: normalise_endpoint
upgrades it to https, and nothing else was checked. The login flow's poll
endpoint, an account an older build saved and a redirect all come from
somewhere else, and any of them naming http:// would send the app
password in Basic auth in the clear.

http_client now sets https_only. reqwest checks it before connecting and
again on each redirect, so a refused request never opens a socket, which
the new test checks with a listener that nothing may reach.

A refused scheme is reported as a Configuration error, not Network. The
request never left the process, and Network puts the app into offline
mode over a connection that is working. Other builder errors (a URL that
does not parse) go the same way, for the same reason.

Part of #65.
2026-09-24 20:44:19 -04:00
dtourolle a3f3e188e1 Move the people tray's ticks in place instead of rebuilding it per press
Benchmarks / CPU and I/O (per commit) (push) Successful in 1m52s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Successful in 45m4s
Build and test / Layer separation (push) Successful in 41s
Traceability / Requirement traces (push) Successful in 29s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 2s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / windows-image (push) Successful in 1s
Build and test / Android (aarch64) (push) Successful in 29m25s
Build and test / Windows (x86_64, cross) (push) Successful in 34m4s
Build and test / Publish the release (push) Skipped
Filtering the grid by a face crawled on the reference library. The SQL
is not it — the person predicate counts in ~20 ms, the eyes-open term in
~60 — but every press on the tray ran `push_people_chips`, which read the
whole people table (26,362 rows, nearly all empty groups a regrouping
pass left behind) and then called `push_people_roster`, which read it
again and replaced the roster model. The roster is every person holding
a face, 1,581 chips, in a row Slint does not virtualise: a new model
tore down and re-created all of them and laid the row out again, to
move one tick.

A press now walks the roster model and sets `picked` on the rows whose
tick changed; the roster is built only when the tray opens. Both reads
use `people_in_use` (2,140 rows) rather than `people`. A picked person
the in-use query leaves out — emptied by a split while the filter held
them — still gets a chip, since a term with no chip cannot be removed,
and without one the in-place update would fall back to a rebuild on
every press.
2026-09-24 20:21:50 -04:00
dtourolle 031315bdb6 Run cargo fmt over the develop shortcuts and the star-range filter
Benchmarks / CPU and I/O (per commit) (push) Successful in 2m8s
Benchmarks / Frame budget (on demand) (push) Skipped
Traceability / Requirement traces (push) Successful in 33s
Build and test / Android (aarch64) (push) Successful in 14m21s
Build and test / android-image (push) Successful in 1s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / Desktop (Linux) (push) Successful in 47m57s
Build and test / windows-image (push) Successful in 1s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / Layer separation (push) Successful in 51s
Build and test / Windows (x86_64, cross) (push) Successful in 34m3s
Build and test / Publish the release (push) Successful in 1m3s
bddf325 and 00c028c went in unformatted, so the Desktop job's
`cargo fmt --check` step failed on master (run 1693) and the release job
that needs it was skipped. Whitespace only.
2026-09-24 20:05:02 -04:00
dtourolle 00c028c8c8 Rate under the pointer, filter a star range, and name Help as help
Benchmarks / CPU and I/O (per commit) (push) Successful in 1m53s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 2m50s
Build and test / Layer separation (push) Successful in 33s
Traceability / Requirement traces (push) Successful in 40s
🐳 Android image / Build and push (push) Successful in 4s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 4s
Build and test / windows-image (push) Successful in 1s
Build and test / Android (aarch64) (push) Successful in 14m19s
Build and test / Windows (x86_64, cross) (push) Successful in 17m50s
Build and test / Publish the release (push) Skipped
Rating keys in the grid follow darktable's rule: with the pointer over a
photograph outside the selection, 0-5, P, X and U judge that photograph
alone; over one inside it, the whole selection, as before; off the grid,
the selection. The hover is cleared when the grid scrolls, so a key after
a wheel turn cannot judge whatever used to be under the pointer.

Holding F and tapping digits filters by stars: one digit for exactly
that many, two for everything between them, F alone to show every
rating again. The filter gains a ceiling to do it (`max_rating`, one
BETWEEN in the query). The place record carries it, and a record from an
older build reads as having none. The star chips light across a capped
range and the bar says "2-3★ only" beside them.

The grid also takes Ctrl+E and Ctrl+Shift+E for the selection, Ctrl+V to
paste onto it and Ctrl+A to select all. The "Gestures" button is now
"Help", its sheet "Controls and shortcuts", and F1 opens it.
2026-09-24 05:11:36 +02:00
dtourolle bddf3250c5 Add Lightroom's export and copy shortcuts to develop
Ctrl+E opens an export sheet: the export defaults on their own, over the
photograph, with an Export button. Ctrl+Shift+E exports straight away on
those defaults. There is no per-export copy of the settings, so what is
chosen in the sheet is saved as it is on the settings page, and the next
Ctrl+Shift+E uses it.

To make that one set of controls in two places, the export options move
out of the settings page into export.slint: an `ExportOptions` global
that Rust writes once, and two panels that read it. The window no longer
forwards forty `settings-*` properties to the page.

Ctrl+Shift+C opens a copy sheet with the edit-kind chips the preset
sheet already uses and a Copy button, which is how a paste leaves each
photograph's crop and rotation alone (Compose off). A and D step along
the roll beside the arrows. While either sheet is up the develop keys
stand down, so A cannot change the photograph behind the form, and
Escape closes it.
2026-09-24 05:11:16 +02:00
dtourolle 41486bd59b Step to the next photograph from the keyboard in a library's develop
The arrow keys and space in develop called `next-image` and
`prev-image`, which walk the files given on the command line. A
photograph opened from a library leaves that list empty, so the keys did
nothing and only a click on the photo roll moved on.

With a library open the step now goes through the roll: it opens the
neighbouring frame exactly as clicking it would, and saves the outgoing
edit the same way.
2026-09-24 05:09:39 +02:00
dtourolle d6d27fb062 Publish a Gitea Release from CI on every v* tag
Benchmarks / CPU and I/O (per commit) (push) Successful in 2m28s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Successful in 45m9s
Build and test / Layer separation (push) Successful in 38s
Traceability / Requirement traces (push) Successful in 44s
🐳 Android image / Build and push (push) Successful in 5s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 5s
Build and test / windows-image (push) Successful in 1s
Build and test / Android (aarch64) (push) Successful in 14m25s
Build and test / Windows (x86_64, cross) (push) Successful in 34m30s
Build and test / Publish the release (push) Skipped
Nothing made a release. CI built the APK and the installer on the master
push and kept them as workflow artefacts, the Linux binary was not kept
at all, and most tags went out with no downloads until they were
attached by hand.

build-and-test now also runs on v* tags. On a tag the desktop job keeps
its release binary, and a release job that needs desktop, Android and
Windows collects the three, names them with the version and runs
tools/publish-release.sh. The script titles and describes the release
from the annotated tag's message as the server holds it, writes
SHA256SUMS, and attaches what is not already there, so a re-run after
an interrupted upload finishes the job instead of duplicating it. The
same script is how a release is made or finished by hand.

Tried on v0.14.1, whose release was made by hand with the same files:
it found the release, reported all four files attached, and changed
nothing.
2026-09-24 03:43:42 +02:00
dtourolle 317a2f40bd Release 0.14.1
Benchmarks / CPU and I/O (per commit) (push) Successful in 5m19s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Successful in 43m6s
Build and test / Layer separation (push) Successful in 37s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 3s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / windows-image (push) Successful in 1s
Traceability / Requirement traces (push) Successful in 46s
Build and test / Android (aarch64) (push) Successful in 29m7s
Build and test / Windows (x86_64, cross) (push) Successful in 34m31s
2026-09-23 19:07:22 -04:00
dtourolle 949fe40d5b Pin "Export N" to the right of the library's selection bar
It was the last of a dozen buttons in a row that scrolls sideways once
it outgrows the window, which at a desktop width it does. The button
sat past the right edge and nothing says the row scrolls to a mouse,
so batch export of a selection looked like a feature the library did
not have. The rest of the row still scrolls; Export, which is also the
cancel for a running batch, now sits beside it and is always visible.
2026-09-23 19:05:42 -04:00
dtourolle 2be80d4203 Show "Paste to N" on every selection, disabled until something is copied
It appeared only once settings had been copied this session, so with an
empty clipboard nothing on the selection bar said pasting onto a
selection was possible. It is one of the things a selection can have
done to it, like filing it in a collection, and now sits with them
beside Presets.
2026-09-22 21:37:41 -04:00
dtourolle 9ebaa15099 Move copy, paste and presets into the develop top bar
They sat in the develop column under a "SETTINGS" heading, which read as
application settings, and went away with the panel toggle and in the
mask and spot modes. The strip is where undo already is for the same
reason: these act on the whole edit, not on any one panel.

The paste button still names what it would apply. The TransferPanel
component is gone; the Transfer global and its Rust wiring are
unchanged.
2026-09-22 21:37:31 -04:00
dtourolle aee355fada Make the in-flight claim test wait for the waiter to park
a_second_claim_waits_for_the_first_to_be_released failed on CI: the
second claim came back Some. The waiter thread signalled the main thread
before calling claim, so the main thread could drop the first guard
before the waiter reached the lock. The path was free by then, and the
waiter claimed it outright.

The registry now keeps a test-only count of threads parked in claim,
bumped under the lock just before the condvar wait. The test spins until
that count is one before releasing. The release needs the same lock, so
it can only reach a waiter that is already waiting. Passed 500 runs in a
row.
2026-09-22 21:12:45 -04:00
dtourolle 7fa3176f88 Release 0.14.0
Benchmarks / Frame budget (on demand) (push) Skipped
Benchmarks / CPU and I/O (per commit) (push) Successful in 8m25s
Build and test / Desktop (Linux) (push) Failing after 26m11s
Build and test / Layer separation (push) Successful in 33s
🐳 Android image / Build and push (push) Successful in 10m41s
Build and test / android-image (push) Successful in 10m42s
🐳 Windows image / Build and push (push) Successful in 4m17s
Build and test / windows-image (push) Successful in 4m17s
Traceability / Requirement traces (push) Successful in 59s
Build and test / Android (aarch64) (push) Successful in 40m53s
Build and test / Windows (x86_64, cross) (push) Successful in 46m51s
2026-09-21 11:32:41 +02:00
dtourolle af162dd010 Merge: origin's sync ordering and adoption work, its scan-complete trigger ported into library_ui/ 2026-09-21 11:32:05 +02:00
dtourolle f5300a9f43 Merge: the UI crate restructured so a feature owns a function, not a region
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.
2026-09-20 23:41:25 +02:00
dtourolle b19d470189 Merge: the library grid on its own Slint global, the last of the screen state off the root 2026-09-20 22:43:09 +02:00
dtourolleandClaude Opus 5 bfadd9c409 Ship the border filler in the Windows installer, and count what is staged
Benchmarks / CPU and I/O (per commit) (push) Successful in 8m2s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 25m17s
Build and test / Layer separation (push) Failing after 0s
Traceability / Requirement traces (push) Failing after 0s
🐳 Android image / Build and push (push) Failing after 0s
Build and test / android-image (push) Failing after 0s
Build and test / Android (aarch64) (push) Skipped
🐳 Windows image / Build and push (push) Failing after 0s
Build and test / windows-image (push) Failing after 0s
Build and test / Windows (x86_64, cross) (push) Skipped
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>
2026-09-20 22:35:04 +02:00
dtourolle 050b2a3914 Collapse the blank runs the library global's move left behind
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.
2026-09-20 22:32:47 +02:00
dtourolleandClaude Opus 5 195388b2e3 Adopt faces a hundred images per commit, and hold one generation per image in the shards
Benchmarks / CPU and I/O (per commit) (push) Successful in 8m16s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 4h42m34s
Build and test / Layer separation (push) Successful in 59s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 2s
🐳 Windows image / Build and push (push) Successful in 2s
Build and test / windows-image (push) Successful in 2s
Traceability / Requirement traces (push) Successful in 50s
Build and test / Android (aarch64) (push) Failing after 0s
Build and test / Windows (x86_64, cross) (push) Failing after 0s
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>
2026-09-20 22:31:47 +02:00
dtourolle e166b64ee9 Format fill.rs, which the last release left past the width limit 2026-09-20 22:29:42 +02:00
dtourolle a773ad5c27 Merge: the developer docs under docs/dev, and the folder indexed for users first 2026-09-20 22:27:48 +02:00
dtourolle 1e472fd251 Move the library's routes and status onto its own Slint global
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.
2026-09-20 22:22:55 +02:00
dtourolle 6bf67cefc4 Move the filter bar and the timeline onto the library's Slint global
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.
2026-09-20 22:07:12 +02:00
dtourolle 00e2fe6aaf Move ratings, flags and keywording onto the library's Slint global
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.
2026-09-20 21:57:40 +02:00
dtourolle 402dcdc24c Move the library grid's cells and selection onto their own Slint global
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.
2026-09-20 21:42:48 +02:00
dtourolle 94b39410bc Tidy the ported drag block, and format fill.rs as master left it 2026-09-20 21:40:20 +02:00
dtourolle 2014c80e62 Merge: master at 0.13.6, with the drag-ghost file and the shared model lookup ported into the split modules 2026-09-20 21:32:21 +02:00
dtourolleandClaude Opus 5 6fd342680b Format set_indexed_at's signature
Benchmarks / CPU and I/O (per commit) (push) Successful in 8m15s
Benchmarks / Frame budget (on demand) (push) Skipped
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 2s
Build and test / Desktop (Linux) (push) Successful in 1h8m29s
🐳 Windows image / Build and push (push) Successful in 5s
Build and test / windows-image (push) Successful in 5s
Build and test / Layer separation (push) Successful in 34s
Traceability / Requirement traces (push) Successful in 50s
Build and test / Android (aarch64) (push) Failing after 0s
Build and test / Windows (x86_64, cross) (push) Failing after 0s
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>
2026-09-20 21:31:07 +02:00
dtourolleandClaude Opus 5 78acc73dad Regenerate the traceability matrix for the sync commits
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>
2026-09-20 21:28:55 +02:00
dtourolleandClaude Opus 5 954246b969 Register the inference requirements, and format the fill test
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>
2026-09-20 21:28:55 +02:00
dtourolleandClaude Opus 5 baed1c4782 Keep the peer's run marker on adopted faces, so they are not re-exported as ours
Benchmarks / CPU and I/O (per commit) (push) Successful in 8m16s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 32s
Build and test / Layer separation (push) Successful in 29s
Traceability / Requirement traces (push) Failing after 42s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 2s
🐳 Windows image / Build and push (push) Successful in 2s
Build and test / windows-image (push) Successful in 2s
Build and test / Android (aarch64) (push) Failing after 0s
Build and test / Windows (x86_64, cross) (push) Failing after 0s
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>
2026-09-20 21:25:53 +02:00
dtourolle fbfa891296 Regenerate the traceability matrix after the rebase 2026-09-20 21:23:23 +02:00
dtourolle aee62dc7f2 Wrap the line the docs move pushed past the width limit 2026-09-20 21:16:03 +02:00
dtourolle 84fade99ec Put the developer docs under docs/dev and index the folder for users 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.
2026-09-20 21:16:03 +02:00
dtourolle 3bfa73d1e1 Merge: the collections sidebar on its own Slint global 2026-09-20 21:14:00 +02:00
dtourolle 4616cb0a23 Merge: library_ui split into a module directory 2026-09-20 21:12:36 +02:00
dtourolle cc73ea3153 Split library_ui.rs into a module directory by area of behaviour
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.
2026-09-20 21:10:02 +02:00
dtourolle f6ff5eabd9 Give the collections sidebar its own Slint global
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.
2026-09-20 21:07:25 +02:00
dtourolleandClaude Opus 5 34ac2f14d3 Sync faces and the catalog before thumbnails, so a fresh device sees its names and collections first
Benchmarks / CPU and I/O (per commit) (push) Successful in 8m24s
Benchmarks / Frame budget (on demand) (push) Skipped
🐳 Android image / Build and push (push) Successful in 4s
Build and test / android-image (push) Successful in 4s
Build and test / Desktop (Linux) (push) Failing after 35s
🐳 Windows image / Build and push (push) Successful in 2s
Build and test / windows-image (push) Successful in 2s
Build and test / Layer separation (push) Successful in 30s
Traceability / Requirement traces (push) Failing after 48s
Build and test / Android (aarch64) (push) Failing after 0s
Build and test / Windows (x86_64, cross) (push) Failing after 0s
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>
2026-09-20 21:04:10 +02:00
dtourolleandClaude Opus 5 f71d7bacc6 Take the server's shards and dates when the scan completes, not after the sweep
Benchmarks / CPU and I/O (per commit) (push) Successful in 8m28s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 36s
Build and test / Layer separation (push) Successful in 28s
Traceability / Requirement traces (push) Failing after 39s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 2s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / windows-image (push) Successful in 1s
Build and test / Android (aarch64) (push) Successful in 43m11s
Build and test / Windows (x86_64, cross) (push) Failing after 41m4s
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>
2026-09-20 20:37:59 +02:00
dtourolle 14ed1dc410 Merge: View and Page enums in place of the six view booleans 2026-09-20 20:32:41 +02:00
dtourolle 38819da222 Replace the six view booleans with View and Page enums
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/.
2026-09-20 20:30:56 +02:00
dtourolle d6e9c7dc94 Merge: collections_ui split into a module directory 2026-09-20 20:30:12 +02:00
dtourolle 9c8f21b754 Split collections_ui.rs into a module directory by area of behaviour
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.
2026-09-20 20:29:21 +02:00
dtourolle bd7d75522d Merge: the format fix from docs-layout 2026-09-20 20:26:56 +02:00
dtourolle 4ef1b74f2f Wrap the line the docs move pushed past the width limit 2026-09-20 20:26:54 +02:00
dtourolle 681486196e Release 0.13.6
Benchmarks / CPU and I/O (per commit) (push) Failing after 6m17s
Benchmarks / Frame budget (on demand) (push) Skipped
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 2s
Build and test / Desktop (Linux) (push) Failing after 29s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / windows-image (push) Successful in 1s
Build and test / Layer separation (push) Successful in 26s
Traceability / Requirement traces (push) Failing after 42s
Build and test / Android (aarch64) (push) Failing after 2m48s
Build and test / Windows (x86_64, cross) (push) Failing after 4m22s
2026-09-20 20:19:48 +02:00
dtourolle f5d0d57574 Regenerate the traceability matrix after the rebase 2026-09-20 20:19:43 +02:00
dtourolle c96e670356 Re-record the panorama for the trained filler; the scene waits for the preview and for the DNG instead of guessing 2026-09-20 20:19:01 +02:00
dtourolle 9c556364fa Pad an open void's canvas to a tile: the merge page's preview is shorter than one, and filled nothing 2026-09-20 20:19:01 +02:00
dtourolle 8d72cabff5 Ship the border filler trained against MI-GAN's own discriminator: texture in the deep bands, level with stock on LPIPS 2026-09-20 20:19:01 +02:00
dtourolleandClaude Opus 5 8a90d888d5 Probe and compile for a package's models, not only the user's
Benchmarks / CPU and I/O (per commit) (push) Failing after 6m30s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 53s
Build and test / Layer separation (push) Successful in 26s
Traceability / Requirement traces (push) Failing after 40s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / windows-image (push) Successful in 1s
Build and test / Android (aarch64) (push) Failing after 2m19s
Build and test / Windows (x86_64, cross) (push) Failing after 3m5s
`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>
2026-09-20 19:39:05 +02:00
dtourolle e86edef47c Merge: run() reduced to construction and startup, the develop wiring in its own module 2026-09-20 19:28:23 +02:00
dtourolle 0aff5e6c8c Merge: the collections, identity, merge, launch and import wiring lifted into named functions 2026-09-20 19:28:12 +02:00
dtourolle 5458314083 Split import_ui::wire into one function per section
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.
2026-09-20 19:23:21 +02:00
dtourolleandClaude Opus 5 39a22875b1 Add the MIGraphX rung for AMD GPUs
Benchmarks / CPU and I/O (per commit) (push) Failing after 6m20s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 45s
Build and test / Layer separation (push) Successful in 26s
Traceability / Requirement traces (push) Failing after 46s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / windows-image (push) Successful in 1s
Build and test / Android (aarch64) (push) Failing after 2m19s
Build and test / Windows (x86_64, cross) (push) Failing after 3m2s
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>
2026-09-20 19:23:00 +02:00
dtourolle cb7b9d71c4 Split lib.rs::run into named construction and wiring functions
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.
2026-09-20 19:17:22 +02:00
dtourolle beb7a5eac0 Split launch_ui::wire into one function per section
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.
2026-09-20 19:11:43 +02:00
dtourolle 262ed2553c Split merge_ui::wire into one function per section
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.
2026-09-20 19:00:31 +02:00
dtourolle 8d08ffd7b7 Split identity_ui::wire into one function per feature
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.
2026-09-20 18:49:43 +02:00
dtourolle 8540518022 Split collections_ui::wire into one function per section
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.
2026-09-20 18:36:18 +02:00
dtourolle b952f5976a Merge: the wire sections lifted into named functions, beside the module splits 2026-09-20 18:26:24 +02:00
dtourolle a1d511fd4b Split library.rs into library/ by area of behaviour
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.
2026-09-20 18:21:43 +02:00
dtourolle 050c2c9d16 Split develop.rs into develop/ by area of behaviour
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.
2026-09-20 18:21:26 +02:00
dtourolle bd3b993b90 Split library_ui::wire into one function per section
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.
2026-09-20 17:25:00 +02:00
dtourolle 59605f9fbb Split masks_ui::wire into one function per 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().
2026-09-20 17:24:42 +02:00
dtourolle 04949741c1 Split settings_ui::wire into one function per section
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.
2026-09-20 17:24:28 +02:00
dtourolle 5b4ad11853 Manual: nested collections, and the ghost drawn as it should be
Benchmarks / CPU and I/O (per commit) (push) Failing after 6m16s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 42s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / windows-image (push) Successful in 2s
Build and test / Layer separation (push) Successful in 26s
Traceability / Requirement traces (push) Failing after 38s
Build and test / Android (aarch64) (push) Failing after 2m20s
Build and test / Windows (x86_64, cross) (push) Failing after 3m3s
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.
2026-09-20 16:27:18 +02:00
dtourolle 6b1aac477d Put the developer docs under docs/dev and index the folder for users 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.
2026-09-20 16:20:15 +02:00
dtourolle 2afc2a7890 Report the grid's column count on creation, not only on change
`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.
2026-09-20 15:58:44 +02:00
dtourolle 6507593715 Hand the drag ghost to the renderer through a file, so it draws
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.
2026-09-20 15:58:44 +02:00
dtourolle 08727cff5a Release 0.13.5
Benchmarks / CPU and I/O (per commit) (push) Failing after 6m16s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 45s
Build and test / Layer separation (push) Successful in 25s
Traceability / Requirement traces (push) Failing after 38s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 2s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / windows-image (push) Successful in 1s
Build and test / Android (aarch64) (push) Failing after 2m20s
Build and test / Windows (x86_64, cross) (push) Failing after 3m1s
2026-09-20 15:24:47 +02:00
dtourolle f4c3f425dd Show the picker correcting something in the manual
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.
2026-09-20 15:24:27 +02:00
dtourolle 12f8990e09 Average a patch under the white balance picker, not one photosite
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.
2026-09-20 15:24:25 +02:00
dtourolle 8a897bbc01 Release 0.13.4
Benchmarks / CPU and I/O (per commit) (push) Failing after 6m22s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 46s
Build and test / Layer separation (push) Successful in 29s
Traceability / Requirement traces (push) Failing after 40s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / windows-image (push) Successful in 1s
Build and test / Android (aarch64) (push) Failing after 2m22s
Build and test / Windows (x86_64, cross) (push) Failing after 3m5s
2026-09-20 14:03:58 +02:00
dtourolle a03e082fe2 Point the manual's white balance scene at "pick", and record it again
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.
2026-09-20 13:41:17 +02:00
dtourolle 4576499c3b Refuse a clipped highlight as a neutral
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.
2026-09-20 13:41:17 +02:00
dtourolle 2f47087223 Measure the white balance probe in camera RGB, where the gains multiply
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.
2026-09-20 13:41:11 +02:00
dtourolle 764ad55ead Stand each person in the grouping pass by at most 100 references
Benchmarks / CPU and I/O (per commit) (push) Failing after 6m23s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 55s
Build and test / Layer separation (push) Successful in 27s
Traceability / Requirement traces (push) Failing after 54s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / windows-image (push) Successful in 1s
Build and test / Android (aarch64) (push) Failing after 2m21s
Build and test / Windows (x86_64, cross) (push) Failing after 3m5s
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.
2026-09-20 13:29:16 +02:00
dtourolle 301e6f3828 Release 0.13.3
Benchmarks / CPU and I/O (per commit) (push) Failing after 6m21s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 46s
Build and test / Layer separation (push) Successful in 26s
Traceability / Requirement traces (push) Failing after 39s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / windows-image (push) Successful in 1s
Build and test / Android (aarch64) (push) Failing after 2m21s
Build and test / Windows (x86_64, cross) (push) Failing after 3m8s
2026-09-20 13:01:41 +02:00
dtourolle a437363bd6 Schema V20: put the mis-spelled run markers right
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.
2026-09-20 13:00:59 +02:00
dtourolle 065872bec5 Keep a run marker under the detector that found the faces
`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.
2026-09-20 13:00:58 +02:00
dtourolle 0ed38ada28 Adopt a peer's unmeasured faces instead of refusing them
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.
2026-09-20 12:58:41 +02:00
dtourolle 695d5ec304 Correct four claims in the README against the tree
Benchmarks / CPU and I/O (per commit) (push) Failing after 6m26s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 46s
Build and test / Layer separation (push) Successful in 28s
Traceability / Requirement traces (push) Failing after 40s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 2s
🐳 Windows image / Build and push (push) Successful in 2s
Build and test / windows-image (push) Successful in 2s
Build and test / Android (aarch64) (push) Failing after 2m21s
Build and test / Windows (x86_64, cross) (push) Failing after 3m5s
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.
2026-09-20 11:57:17 +02:00
dtourolle ef1afc254d Release 0.13.2
Benchmarks / CPU and I/O (per commit) (push) Failing after 6m36s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 46s
Build and test / Layer separation (push) Successful in 34s
Traceability / Requirement traces (push) Failing after 40s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 2s
🐳 Windows image / Build and push (push) Successful in 2s
Build and test / windows-image (push) Successful in 2s
Build and test / Android (aarch64) (push) Failing after 2m26s
Build and test / Windows (x86_64, cross) (push) Failing after 3m7s
2026-09-20 11:06:54 +02:00
dtourolle 103c6e7fc0 Rewrite the README for someone arriving, not someone already here
Benchmarks / CPU and I/O (per commit) (push) Failing after 30s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 53s
Build and test / Layer separation (push) Successful in 28s
Traceability / Requirement traces (push) Failing after 41s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / windows-image (push) Successful in 1s
Build and test / Android (aarch64) (push) Failing after 2m25s
Build and test / Windows (x86_64, cross) (push) Failing after 3m11s
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.
2026-09-20 11:06:10 +02:00
dtourolle e9398de9c1 Ship the fine-tuned border filler: MI-GAN 512 trained on projection-shaped voids from the maintainer's library 2026-09-20 10:56:38 +02:00
dtourolle b1c99b5796 Let the fill show the model an open void: mirror depth 0 means no ring and nothing known beyond the band 2026-09-20 10:56:38 +02:00
dtourolle 3de109fbd1 Write down the catalog and screen-refresh patterns the Identity fixes exposed
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.
2026-09-20 10:56:37 +02:00
dtourolle 388bda6af3 Count the outstanding repairs from the faces, on partial indexes
"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.
2026-09-20 10:56:37 +02:00
dtourolle 73845d8a77 Ask the server about a collection once per job, not once per file
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.
2026-09-20 10:56:37 +02:00
dtourolle b4821ee1ab Filter the people rail in the query, and count the unassigned faces
`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.
2026-09-20 10:56:36 +02:00
dtourolle 9d1aa5735b Confirm a group, and split one, in one transaction
`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.
2026-09-20 10:56:36 +02:00
dtourolle 97a854833d Reuse the face grid's decoded crops across a redraw
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.
2026-09-20 10:56:36 +02:00
dtourolle e0e193efb4 Do not recount face coverage on every confirm, and count it without listing
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.
2026-09-20 10:56:36 +02:00
dtourolle d790961b28 Add the manual: every feature pictured from the application itself
Benchmarks / CPU and I/O (per commit) (push) Failing after 30s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 47s
Build and test / Layer separation (push) Successful in 27s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 4s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / windows-image (push) Successful in 1s
Traceability / Requirement traces (push) Failing after 38s
Build and test / Android (aarch64) (push) Failing after 2m24s
Build and test / Windows (x86_64, cross) (push) Failing after 3m21s
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.
2026-09-20 00:26:00 +02:00
dtourolle 14ac41bee0 Give the keyword sheet's field focus, and the empty trash its own words
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.
2026-09-20 00:21:14 +02:00
dtourolle 86410def88 Title the gesture book for the whole application, not the grid 2026-09-20 00:21:14 +02:00
dtourolle df8be10c7d Stop promising to ask for an export folder
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.
2026-09-20 00:21:14 +02:00
dtourolle f176043632 Report a merge worker that dies rather than leaving the page on Stop
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.
2026-09-20 00:21:14 +02:00
dtourolle 9c2cd73337 Show a mask's tint only while masking
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.
2026-09-20 00:21:14 +02:00
dtourolle e3acbdf4a3 Wrap the falloff and edge chips so a category mask cannot widen the column
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.
2026-09-20 00:21:14 +02:00
dtourolle eddaa44cd3 Announce a month at the next row it opens, not only if it begins one
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.
2026-09-20 00:21:14 +02:00
dtourolle 7fba28f7d8 Upload a snapshot of a thumbnail shard, never the live file
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.
2026-09-20 00:21:14 +02:00
dtourolle 0fa9003e54 Let the top level be chosen as the library root
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".
2026-09-20 00:21:13 +02:00
dtourolle e750bcdb8c Date the composite at the mean of its frames' capture times
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 31m52s
Build and test / Layer separation (push) Successful in 47s
Traceability / Requirement traces (push) Failing after 55s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / windows-image (push) Successful in 1s
Benchmarks / CPU and I/O (per commit) (push) Failing after 32s
Build and test / Android (aarch64) (push) Failing after 2m37s
Build and test / Windows (x86_64, cross) (push) Failing after 3m10s
It carried the first frame's, so it sorted before the sweep it was made
from. The middle of the sweep puts it among them.
2026-09-19 22:36:09 +02:00
dtourolle 48c4b403d2 Date a DNG whose IFDs follow its pixels: read the head and the tail
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.
2026-09-19 22:35:58 +02:00
dtourolle 9d04ff2154 Release 0.13.1
Benchmarks / CPU and I/O (per commit) (push) Successful in 12m24s
Benchmarks / Frame budget (on demand) (push) Skipped
🐳 Android image / Build and push (push) Successful in 4s
Build and test / android-image (push) Successful in 5s
Build and test / Desktop (Linux) (push) Failing after 31m25s
🐳 Windows image / Build and push (push) Successful in 4s
Build and test / windows-image (push) Successful in 4s
Build and test / Layer separation (push) Successful in 46s
Build and test / Android (aarch64) (push) Failing after 6h25m37s
Build and test / Windows (x86_64, cross) (push) Failing after 3m17s
Traceability / Requirement traces (push) Failing after 48s
2026-09-19 22:06:06 +02:00
dtourolle c6cfb2a02a Put the -1 on the greens along the chroma axis, not across it
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.
2026-09-19 22:05:39 +02:00
dtourolle b83f192847 Package release 2 of 0.13.0: the inference engine and the user runtime directory 2026-09-19 21:20:53 +02:00
dtourolle ecb648818b Search the user's own runtime directory before the system library
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.
2026-09-19 21:15:55 +02:00
dtourolle 5fbf8944d7 Count the filler in the APK's bundled-model array
Benchmarks / CPU and I/O (per commit) (push) Successful in 3m35s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 31m59s
Build and test / Layer separation (push) Successful in 40s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 2s
🐳 Windows image / Build and push (push) Successful in 2s
Build and test / windows-image (push) Successful in 2s
Traceability / Requirement traces (push) Failing after 57s
Build and test / Android (aarch64) (push) Failing after 54m6s
Build and test / Windows (x86_64, cross) (push) Failing after 1h4m55s
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.
2026-09-19 20:55:38 +02:00
461 changed files with 129961 additions and 37197 deletions
+9
View File
@@ -20,3 +20,12 @@
# reasoning as the models, with the opposite default: the model is not # reasoning as the models, with the opposite default: the model is not
# optional and the fixtures are. # optional and the fixtures are.
fixtures/** filter=lfs diff=lfs merge=lfs -text fixtures/** filter=lfs diff=lfs merge=lfs -text
# The manual's pictures live in LFS for the same reason the models do: a
# screenshot or a GIF changes wholesale when the interface it shows changes,
# and every re-recording would otherwise stay in every clone for good. The
# desktop and benchmark legs exclude the directory, since nothing they build
# or test reads it; the Android and Windows legs fetch it, because the APK
# and the installer carry the manual (docs/manual/index.html) with its
# pictures, and their packagers refuse a pointer.
docs/manual/media/** filter=lfs diff=lfs merge=lfs -text
+4 -4
View File
@@ -1,6 +1,6 @@
name: Benchmarks name: Benchmarks
# The suite docs/requirements.md §8 has been promising since it was written: # The suite docs/dev/requirements.md §8 has been promising since it was written:
# "an automated benchmark suite against a synthetic 50k catalog, run per-commit # "an automated benchmark suite against a synthetic 50k catalog, run per-commit
# … A regression beyond stated tolerance fails the build." # … A regression beyond stated tolerance fails the build."
# #
@@ -29,7 +29,7 @@ name: Benchmarks
# every commit to establish, every time, that this runner has no GPU. It # every commit to establish, every time, that this runner has no GPU. It
# runs on demand (Actions → Run workflow) so that a runner that *does* # runs on demand (Actions → Run workflow) so that a runner that *does*
# have one can be pointed at it, and the numbers it produces belong in # have one can be pointed at it, and the numbers it produces belong in
# docs/frame-budget.md by hand, as they already are. # docs/dev/frame-budget.md by hand, as they already are.
on: on:
push: push:
@@ -148,7 +148,7 @@ jobs:
| while read -r key; do git config --local --unset-all "$key"; done || true | while read -r key; do git config --local --unset-all "$key"; done || true
git config --local lfs.url \ git config --local lfs.url \
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs" "https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
git lfs pull --exclude="fixtures/**" git lfs pull --exclude="fixtures/**,docs/manual/media/**"
- name: Cache cargo - name: Cache cargo
uses: actions/cache@v4 uses: actions/cache@v4
@@ -183,7 +183,7 @@ jobs:
- name: Frame budget (FR-DSP-3) - name: Frame budget (FR-DSP-3)
run: cargo test --release -p dr-gpu --test frame_budget -- --nocapture run: cargo test --release -p dr-gpu --test frame_budget -- --nocapture
# The instrument behind docs/frame-budget.md. It exits non-zero with no # The instrument behind docs/dev/frame-budget.md. It exits non-zero with no
# adapter, which is right for a tool a person runs deliberately and wrong # adapter, which is right for a tool a person runs deliberately and wrong
# for a job that usually has none — hence continue-on-error. Its table is # for a job that usually has none — hence continue-on-error. Its table is
# in the log for whoever asked for this run; the committed numbers are # in the log for whoever asked for this run; the committed numbers are
+77 -3
View File
@@ -7,6 +7,10 @@ name: Build and test
on: on:
push: push:
branches: [main, master, develop] branches: [main, master, develop]
# A release tag builds again and publishes what it built (the `release`
# job at the end). The master push of the same commit has usually filled
# the caches, so the second run is the warm one.
tags: ['v*']
pull_request: pull_request:
branches: [main, master, develop] branches: [main, master, develop]
@@ -96,6 +100,8 @@ jobs:
| while read -r key; do git config --local --unset-all "$key"; done || true | while read -r key; do git config --local --unset-all "$key"; done || true
git config --local lfs.url \ git config --local lfs.url \
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs" "https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
# The manual's pictures too: the APK carries the manual, and
# assemble-apk.sh refuses a pointer where a picture should be.
git lfs pull --exclude="fixtures/**" git lfs pull --exclude="fixtures/**"
ls -lR models/ ls -lR models/
@@ -154,6 +160,16 @@ jobs:
- name: Build - name: Build
run: cargo build --workspace --release run: cargo build --workspace --release
# Only on a release tag: the binary is 150 MB and nothing but the
# release job wants it.
- name: Upload the desktop binary
if: startsWith(github.ref, 'refs/tags/v')
uses: actions/upload-artifact@v3
with:
name: darkroom-desktop-x86_64-linux
path: target/release/darkroom-desktop
if-no-files-found: error
- name: Disk after - name: Disk after
if: always() if: always()
run: df -h /workspace 2>/dev/null || df -h . run: df -h /workspace 2>/dev/null || df -h .
@@ -213,6 +229,8 @@ jobs:
| while read -r key; do git config --local --unset-all "$key"; done || true | while read -r key; do git config --local --unset-all "$key"; done || true
git config --local lfs.url \ git config --local lfs.url \
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs" "https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
# The manual's pictures too: the APK carries the manual, and
# assemble-apk.sh refuses a pointer where a picture should be.
git lfs pull --exclude="fixtures/**" git lfs pull --exclude="fixtures/**"
ls -lR models/ ls -lR models/
@@ -322,7 +340,7 @@ jobs:
env: env:
CARGO_TARGET_DIR: target-android CARGO_TARGET_DIR: target-android
# Absent secrets mean a debug signature, which is what a fork or a # Absent secrets mean a debug signature, which is what a fork or a
# branch build should get. Set all three (see docs/android-signing.md) # branch build should get. Set all three (see docs/dev/android-signing.md)
# and the same job produces a release-signed APK instead. # and the same job produces a release-signed APK instead.
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
KEYSTORE_PASS: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} KEYSTORE_PASS: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
@@ -370,7 +388,7 @@ jobs:
# TRACES: FR-PLAT-WIN-3 # TRACES: FR-PLAT-WIN-3
# The Windows executable and its installer, cross-built from Linux # The Windows executable and its installer, cross-built from Linux
# (docs/windows.md §7). No Windows machine anywhere in this job: what it # (docs/dev/windows.md §7). No Windows machine anywhere in this job: what it
# can prove is that the binary links, is a Windows executable with no # can prove is that the binary links, is a Windows executable with no
# MinGW runtime imports, starts under Wine, and that the installer installs # MinGW runtime imports, starts under Wine, and that the installer installs
# and uninstalls under Wine. What it cannot prove — a Vulkan device, a # and uninstalls under Wine. What it cannot prove — a Vulkan device, a
@@ -406,6 +424,8 @@ jobs:
| while read -r key; do git config --local --unset-all "$key"; done || true | while read -r key; do git config --local --unset-all "$key"; done || true
git config --local lfs.url \ git config --local lfs.url \
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs" "https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
# The manual's pictures too: the installer carries the manual, and
# package.sh refuses a pointer where a picture should be.
git lfs pull --exclude="fixtures/**" git lfs pull --exclude="fixtures/**"
ls -l models/face models/scene ls -l models/face models/scene
@@ -454,7 +474,17 @@ jobs:
wine "$SETUP" /S 2>/dev/null wine "$SETUP" /S 2>/dev/null
INST=$(echo "$HOME"/.wine/drive_c/users/*/AppData/Local/Programs/DarkRoom) INST=$(echo "$HOME"/.wine/drive_c/users/*/AppData/Local/Programs/DarkRoom)
ls "$INST" ls "$INST"
[ "$(ls "$INST/models" | wc -l)" = 7 ] || { echo "FAIL: expected 7 model files"; exit 1; } # As many files as package.sh stages: everything but the READMEs in
# the directories it copies. A literal here went stale the first
# time a model was added.
WANT=$(find models/face models/scene models/inpaint -maxdepth 1 -type f ! -name README.md | wc -l)
GOT=$(ls "$INST/models" | wc -l)
[ "$GOT" = "$WANT" ] || { echo "FAIL: expected $WANT model files, installed $GOT"; exit 1; }
# The manual, and every picture it shows, counted the same way.
[ -f "$INST/manual/index.html" ] || { echo "FAIL: no manual installed"; exit 1; }
WANT=$(ls docs/manual/media | wc -l)
GOT=$(ls "$INST/manual/media" | wc -l)
[ "$GOT" = "$WANT" ] || { echo "FAIL: expected $WANT manual pictures, installed $GOT"; exit 1; }
wine reg query 'HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\DarkRoom' 2>/dev/null \ wine reg query 'HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\DarkRoom' 2>/dev/null \
| grep -q DisplayVersion || { echo "FAIL: no uninstall registry key"; exit 1; } | grep -q DisplayVersion || { echo "FAIL: no uninstall registry key"; exit 1; }
wine "$INST/darkroom.exe" --version 2>/dev/null | grep -q '^darkroom-desktop ' \ wine "$INST/darkroom.exe" --version 2>/dev/null | grep -q '^darkroom-desktop ' \
@@ -514,3 +544,47 @@ jobs:
fi fi
done done
exit $FAILED exit $FAILED
# A v* tag becomes a Gitea Release carrying the three builds and their
# SHA256SUMS, titled and described by the tag's message. Until this job
# existed every release was made by hand, and most tags never got one.
#
# It needs all three platform jobs, so a tag whose tests fail publishes
# nothing; re-run the failed job and this one follows. The work is
# tools/publish-release.sh, which is also how a release is finished by hand.
release:
if: startsWith(github.ref, 'refs/tags/v')
needs: [desktop, android, windows]
runs-on: linux/amd64
name: Publish the release
container:
image: catthehacker/ubuntu:act-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Fetch the builds
uses: actions/download-artifact@v3
with:
path: dist
# Named for the download page, with the version in each name the way
# the hand-made releases had them. The installer already carries its
# version from package.sh.
- name: Publish
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN || github.token }}
TAG: ${{ github.ref_name }}
run: |
set -e
V="${TAG#v}"
ls -lR dist
mkdir -p out
cp dist/darkroom-arm64-v8a-apk/darkroom.apk "out/darkroom-${V}-arm64-v8a.apk"
cp dist/darkroom-desktop-x86_64-linux/darkroom-desktop "out/darkroom-desktop-${V}-x86_64-linux"
chmod +x "out/darkroom-desktop-${V}-x86_64-linux"
cp dist/darkroom-windows-x86_64-setup/DarkRoom-${V}-x86_64-setup.exe out/
bash tools/publish-release.sh "$TAG" out/*
+21 -6
View File
@@ -7,7 +7,7 @@ name: Traceability
# fail its own threshold. Two rules follow, and the extractor's own tests # fail its own threshold. Two rules follow, and the extractor's own tests
# enforce both: # enforce both:
# #
# 1. Denominators are parsed from docs/requirements.md at run time. # 1. Denominators are parsed from docs/dev/requirements.md at run time.
# 2. Coverage is |traced ∩ defined| / |defined|, never a raw traced count. # 2. Coverage is |traced ∩ defined| / |defined|, never a raw traced count.
# #
# This job is static analysis of source comments plus markdown parsing, so it # This job is static analysis of source comments plus markdown parsing, so it
@@ -67,6 +67,12 @@ jobs:
# threshold: zero requirements parsed, zero files scanned, a ratio above # threshold: zero requirements parsed, zero files scanned, a ratio above
# 100%, or any orphan tag all fail the build. A misconfigured run must not # 100%, or any orphan tag all fail the build. A misconfigured run must not
# report a plausible-looking 0%. # report a plausible-looking 0%.
# Every picture the manual shows is made by a scene in
# tools/manual/scenes.py, and every picture a scene makes is shown.
# Two files read; no app, no display.
- name: Manual pictures have scenes
run: tools/manual/record.sh --check
- name: Traceability gate - name: Traceability gate
run: cargo run -q -p traceability -- check run: cargo run -q -p traceability -- check
@@ -74,11 +80,11 @@ jobs:
run: | run: |
set -e set -e
cargo run -q -p traceability -- report cargo run -q -p traceability -- report
if ! git diff --quiet docs/traceability.md; then if ! git diff --quiet docs/dev/traceability.md; then
echo "" echo ""
echo "docs/traceability.md is out of date." echo "docs/dev/traceability.md is out of date."
echo "Run: cargo run -p traceability -- report" echo "Run: cargo run -p traceability -- report"
git diff --stat docs/traceability.md git diff --stat docs/dev/traceability.md
exit 1 exit 1
fi fi
@@ -91,10 +97,19 @@ jobs:
# they will conclude the application is broken rather than the page. # they will conclude the application is broken rather than the page.
# #
# This also fails on a malformed tag, so a typo costs a gesture its # This also fails on a malformed tag, so a typo costs a gesture its
# desktop half loudly rather than silently. # desktop half loudly rather than silently — and on a key a Slint
# handler binds that no tag names, or a key a tag names that no handler
# binds (tools/traceability/src/keymap.rs).
- name: Regenerate the gesture vocabulary and check it is committed - name: Regenerate the gesture vocabulary and check it is committed
run: cargo run -q -p traceability -- gestures-check run: cargo run -q -p traceability -- gestures-check
# The manual's page, which the packages carry and the help sheet links
# into. Blocking for the gesture book's reason: it is shown to the user,
# and a page that disagrees with the README is a manual describing an
# application that no longer exists.
- name: Regenerate the manual page and check it is committed
run: cargo run -q -p traceability -- manual-check
# Advisory, not blocking: not every file implements a requirement, and a # Advisory, not blocking: not every file implements a requirement, and a
# tag on every function is noise that rots faster than it helps. Tag the # tag on every function is noise that rots faster than it helps. Tag the
# unit that decides. # unit that decides.
@@ -127,4 +142,4 @@ jobs:
- name: Summary - name: Summary
if: always() if: always()
run: head -30 docs/traceability.md || true run: head -30 docs/dev/traceability.md || true
+18 -4
View File
@@ -26,7 +26,7 @@ fi
# The artefacts are generated from the tree, so regenerating them because one # The artefacts are generated from the tree, so regenerating them because one
# was itself edited would be circular. # was itself edited would be circular.
case "$(tr -d '[:space:]' <<< "${staged}")" in case "$(tr -d '[:space:]' <<< "${staged}")" in
docs/traceability.md | docs/gestures.md | ui/dr-ui/src/gesture_book.rs) docs/dev/traceability.md | docs/gestures.md | ui/dr-ui/src/gesture_book.rs | docs/manual/index.html)
exit 0 exit 0
;; ;;
esac esac
@@ -41,9 +41,9 @@ if ! cargo run -q -p traceability -- report >/dev/null 2>&1; then
exit 0 exit 0
fi fi
if ! git diff --quiet -- docs/traceability.md; then if ! git diff --quiet -- docs/dev/traceability.md; then
git add docs/traceability.md git add docs/dev/traceability.md
echo "pre-commit: regenerated docs/traceability.md and staged it" echo "pre-commit: regenerated docs/dev/traceability.md and staged it"
fi fi
# The gesture vocabulary, same discipline. # The gesture vocabulary, same discipline.
@@ -65,3 +65,17 @@ for f in docs/gestures.md ui/dr-ui/src/gesture_book.rs; do
echo "pre-commit: regenerated ${f} and staged it" echo "pre-commit: regenerated ${f} and staged it"
fi fi
done done
# The manual's page, when its source is part of the commit. Rendered from
# nothing but the README, so there is no reason to pay for it otherwise.
if grep -qx 'docs/manual/README.md' <<< "${staged}"; then
if ! out="$(cargo run -q -p traceability -- manual 2>&1)"; then
echo "pre-commit: the manual would not render" >&2
echo "${out}" >&2
exit 1
fi
if ! git diff --quiet -- docs/manual/index.html; then
git add docs/manual/index.html
echo "pre-commit: regenerated docs/manual/index.html and staged it"
fi
fi
+165
View File
@@ -0,0 +1,165 @@
# Working in this repository
Notes for anyone — person or agent — changing this code. They record what
went wrong once and what the fix looked like, so the same shape is not
written again. Requirements live in `docs/dev/requirements.md`; this file is
about habits, not features.
## Catalog reads: work is proportional to what changed, never to library size
`docs/dev/catalog.md §1` states the rule. These are the ways it was broken on
the Identity screen, found when every confirm click cost half a second on a
24k-image library (2026-09-19), and what each fix looked like.
**A redraw must know what changed.** A click handler that calls "refresh
everything" pays for everything. `identity_ui::refresh` takes a `Changed`:
a confirm re-reads the rail and the grid and *not* the coverage line,
because moving a face between people cannot alter how many images are
indexed. Before adding a read to a shared refresh, ask which events can
change its answer, and gate it on those.
**Count with `COUNT(*)`, never with `.len()` on a list you then drop.**
`repairs::counts` used to build every repair's work list — a `Target` with
its path per row, sorted into visiting order — to report its length. Six
repairs, 350 ms, nothing kept. If the caller wants a number, the query
returns a number.
**One query, not one per row.** `ThumbStore::contains` in a filter over
5,000 rows is 5,000 prepared statements; `ThumbStore::held(size)` reads the
index once into a set. The same applies to any `query_row` inside a loop
over a result set — including `deep_count` per sidebar row, which is fine
at sidebar scale and would not be at grid scale. Aggregate in one
statement and look up in memory.
**SQL text in a loop is a prepare in a loop.** rusqlite's `execute` and
`query_row` compile their statement on every call. A loop that calls them
per row pays a prepare per row even when each query is a primary-key seek:
the merge of a synced catalog prepared four statements for each of 13,000
incoming faces (450 ms of a pass that changed nothing), `persist` four per
photograph a scan listed (1.5 s for a first scan), the shard sync one per
image each way. Hoist the statement, use `prepare_cached`, or — better, when
the loop asks the same table about every row — read that table once into a
map. And do not rewrite a row with what it already holds: an upsert of
identical values still dirties the page.
**A `LIKE` is case-insensitive, and no index here serves that.**
`source_ref LIKE 'stem.%'` read every name of the root per sidecar a pull
took in. When the check that decides is exact, spell the prefix as a range
(`>= 'stem.' AND < 'stem/'`, `/` being the byte after `.`), which the
`(root_id, source_ref)` key answers with a seek.
**`Catalog::open` is not free, and every worker thread calls it.** The
backfill runs on every open, and the develop view opens a catalog to fetch
each original and again for each neighbour it prefetches. Keep each
backfill step's no-op case to a read of the small side — the unpaired
JPEGs, not every RAW; the distinct keywords, not every assignment — and
measure an open with `catalog_bench` after adding one.
**Filter and aggregate in SQL, and aggregate the small side first.**
`faces::people` read 19,000 rows, grouped, sorted them by name, and the
screen threw 17,000 away (empty unnamed groups). `people_in_use` filters in
the `WHERE`, and joins `people` to a pre-aggregated `face_person` (2,000
groups) rather than grouping after a `LEFT JOIN` over every person. The
sort then sees only the rows that will be drawn.
**Wide rows make "just check one column" a table scan.** A `faces` row is
~8 KB (a 1 KB embedding and a ~5 KB crop, then the columns added later).
Any predicate that reads `quality`, `crop` or an eye column for every face
reads every row. V17 learned this for the eye filter; V19 applies it to the
repair counts with partial indexes (`faces_owed_*`) that hold only the rows
still owing, keyed on what the predicate joins on and carrying `model_id`
because the predicate reads it. Two things to know about them:
- **Drive the count from the small side.** SQLite uses a partial index
when the query starts from `faces` (`repairs::count`, `Needs::Face`) and
ignores it inside a correlated `EXISTS (... WHERE f.image_id = i.id ...)`.
That is why `Needs::Face` carries the per-face fragment and spells it two
ways.
- **Spell the predicate as the index's `WHERE` is spelled.** `NEEDS_EYES`
is `(f.eye_right IS NULL OR f.landmarks_dense IS NULL)` because
`faces_owed_eyes` is `WHERE eye_right IS NULL OR landmarks_dense IS NULL`.
Change one, change both, and `counts_are_the_sizes_of_the_lists` will
tell you if they drift.
Check a query's plan with `EXPLAIN QUERY PLAN` against a copy of a real
catalog before trusting an index exists for it: "SEARCH ... USING COVERING
INDEX" is the answer you want, "SEARCH f USING INDEX faces_image" on a wide
table means every probe opens a row.
## Catalog writes: one transaction per user action
`faces::confirm` opens a transaction. Calling it in a loop over a group is
a commit per face; `faces::confirm_all` is two statements and one commit,
`faces::reassign` one transaction for a whole split. When a UI action
touches N rows, give the catalog a function that takes the N, not a loop
that calls the one-row function N times — `unchecked_transaction` cannot
nest, so this has to be designed in at the catalog layer, not wrapped
from above.
## Screens: keep what is already decoded
`identity::load_faces` takes the crops the grid is currently showing and
hands them back into the new cells. Before that, a click re-read 4 MB of
crop blobs and decoded 700 JPEGs to produce the pixels already on screen.
When a redraw replaces a model, the expensive parts of the old model — a
decoded image, a cut portrait — are the first thing to reuse; only the row
that changed needs new work. Drain the old cells rather than cloning them.
## Remote calls: one round trip per file, not one per ancestor
`NextcloudBackend::move_to` guaranteed its destination's parent with a
`MKCOL` per ancestor from the account root, on every file of a batch —
three `405`s before each `MOVE`. The backend now remembers the collections
it has confirmed (`known_dirs`) for its lifetime, which is one job. When a
per-file operation has a per-batch precondition, satisfy it once.
## Providers: read the runtime's source for the version on disk, not the binding
Two things the MIGraphX rung (2026-09-20) got wrong before it was measured
right, both because `ort`'s builder was trusted to mean what its method
names say.
**A binding's option builder may fill a struct the runtime no longer
reads.** `ep::MIGraphX::with_save_model` sets fields of the legacy
`OrtMIGraphXProviderOptions`; ONNX Runtime 1.29 reads that struct for the
precision flags and ignores the rest, so every session compiled for 40 s
and the cache directory went nowhere. The option that works
(`migraphx_model_cache_dir`) exists only in the generic key/value
registration, which `session::migraphx` calls on the API table directly.
Before wiring a provider option, fetch the provider's source at the
runtime's exact version and find where the option is *read*.
**A provider's cache key may leave out what you are varying.** MIGraphX
keys a compiled program on graph, GPU and its own version — not precision.
The first fp16 measurement built in 0.3 s and matched f32 to the tenth of a
millisecond, because it had loaded the f32 program. A "from cache" build
that is suspiciously fast on the first run of a new configuration is a key
collision, not a fast provider; give each precision its own directory (the
engine does) and check the cache directory gained a file.
## Measuring
`cargo run --release -p dr-ui --example identity_bench -- CATALOG THUMBS`
times what one click on the Identity screen reads and what the batch
operations write. Run it against a **copy** of a real catalog (it writes),
never the library's own file; `sqlite3 catalog.sqlite ".backup copy.sqlite"`
takes a consistent one while the app runs. Compare the `cpu` column when
other builds are running on the machine — the wall clock doubles under
load, the CPU figure does not. Keep the binary from before the change and
run both back to back rather than trusting numbers taken an hour apart.
`cargo run --release -p dr-catalog --example catalog_bench -- CATALOG
[FACES_DIR]` does the same for opening the catalog (the backfill step by
step), the upload snapshot, a merge, and the face shard export and import;
`persist_bench`, an ignored test in `dr-ui`'s scan module, replays a scan's
`persist` and a sidecar pull (`DR_BENCH_CATALOG=copy.sqlite cargo test
--release -p dr-ui --lib persist_bench -- --ignored --nocapture
--test-threads=1`). Both take copies; hand `catalog_bench` a copy of the
face store directory too.
Reference figures from the 2026-09-19 fixes, largest person (754 faces),
24k images, 19k faces, before → after. What one click read: `load_people`
22 ms → 12 ms, `load_faces` 316 ms → 2.4 ms, `audit` 190 ms → not run
(66 ms when it is, on open and at the end of a sweep). What one click
wrote: `confirm_all` 16 ms → 2 ms, `split_off` 23 ms → 4.5 ms. A click on
the face grid went from ~530 ms of catalog work to ~15 ms.
+31 -13
View File
@@ -1,6 +1,6 @@
# Contributing to DarkRoom # Contributing to DarkRoom
There is a lot of documentation here — 14 documents and 177 numbered There is a lot of documentation here — twenty-odd documents and 192 numbered
requirements — and almost all of it is written for someone who has already requirements — and almost all of it is written for someone who has already
decided to work on this. This file is the other thing: how to get a first decided to work on this. This file is the other thing: how to get a first
change landed without reading any of it. change landed without reading any of it.
@@ -62,7 +62,7 @@ sudo apt-get install pkg-config libfontconfig1-dev libxkbcommon-dev
cargo run -p darkroom-desktop cargo run -p darkroom-desktop
``` ```
The first build resolves 826 crates and takes a while — on a laptop, long The first build resolves some 850 crates and takes a while — on a laptop, long
enough to look like a hang. It is not one. enough to look like a hang. It is not one.
Android is a containerised toolchain and is not needed for most work; see Android is a containerised toolchain and is not needed for most work; see
@@ -90,18 +90,18 @@ break it by accident:
cargo run --release -p dr-bench -- check cargo run --release -p dr-bench -- check
``` ```
That is the benchmark suite (`docs/requirements.md` §8), which builds a That is the benchmark suite (`docs/dev/requirements.md` §8), which builds a
synthetic 50,000-image catalog and fails the build if a performance target is synthetic 50,000-image catalog and fails the build if a performance target is
missed or a measurement has drifted past its tolerance. It runs on every push in missed or a measurement has drifted past its tolerance. It runs on every push in
its own workflow. [`docs/benchmarks.md`](docs/benchmarks.md) says what it its own workflow. [`docs/dev/benchmarks.md`](docs/dev/benchmarks.md) says what it
measures, what it deliberately does not, and how to read a failure. If you have measures, what it deliberately does not, and how to read a failure. If you have
touched the catalog, the decoder, the thumbnail store or the exporter, run it touched the catalog, the decoder, the thumbnail store or the exporter, run it
before you send. before you send.
## Requirements and traceability ## Requirements and traceability
[`requirements.md`](docs/requirements.md) is the register of record. [`requirements.md`](docs/dev/requirements.md) is the register of record.
[`traceability.md`](docs/traceability.md) is generated from `TRACES:` tags in [`traceability.md`](docs/dev/traceability.md) is generated from `TRACES:` tags in
the source and must never be hand-edited: the source and must never be hand-edited:
```rust ```rust
@@ -124,12 +124,30 @@ Note that it tracks line numbers, so a change that only moves code still moves
the matrix. Never regenerate it with a stale prebuilt binary. the matrix. Never regenerate it with a stale prebuilt binary.
**One convention that the tooling cannot enforce.** A tag proves that a tag **One convention that the tooling cannot enforce.** A tag proves that a tag
exists, not that the code under it does the thing — `docs/code-health.md` exists, not that the code under it does the thing — `docs/dev/code-health.md`
CH-4 has the details, and two requirements currently read as covered on the CH-4 has the details, and two requirements currently read as covered on the
strength of plumbing a future feature would use. So: **close a requirement strength of plumbing a future feature would use. So: **close a requirement
with a test that would fail if the behaviour were removed.** Coverage that with a test that would fail if the behaviour were removed.** Coverage that
moves slowly and means something beats coverage that moves quickly. moves slowly and means something beats coverage that moves quickly.
**Keys and gestures are held the same way.** A key handler in Slint compares
one canonical chord, `Keys.chord(event) == "Ctrl+Z"`, under a `// KEYMAP:`
comment naming its section of the gesture book, and every key it binds must be
named by a `GESTURE:` block beside it. `cargo run -p traceability -- gestures`
regenerates [`docs/gestures.md`](docs/gestures.md) and the in-app help sheet
from those blocks; `-- gestures-check` fails when a handler binds a key no tag
names, or a tag names a key no handler binds. A `manual:` field in a block
links the gesture to a section of the manual, and a heading that is not there
fails the scan.
**The manual is checked too.** `docs/manual/index.html` is rendered from
`docs/manual/README.md` by `-- manual` and `-- manual-check` fails when they
differ; `tools/manual/record.sh --check` fails when the manual shows a picture
no scene in `tools/manual/scenes.py` makes. If you change what a pictured
screen looks like, [`tools/manual`](tools/manual/README.md) says how to record
it again. The pre-commit hook regenerates the matrix, the gesture book and the
page; CI runs all three checks.
## Two invariants the build defends ## Two invariants the build defends
Worth knowing before you trip one, because both failures name a requirement Worth knowing before you trip one, because both failures name a requirement
@@ -163,12 +181,12 @@ One commit per change. If you fixed two things, that is two commits.
| Document | Read it when | | Document | Read it when |
|---|---| |---|---|
| [`core/dr-pipeline/ops/README.md`](core/dr-pipeline/ops/README.md) | Adding or changing a develop operation — start here regardless | | [`core/dr-pipeline/ops/README.md`](core/dr-pipeline/ops/README.md) | Adding or changing a develop operation — start here regardless |
| [`docs/architecture.md`](docs/architecture.md) | Anything touching the render path, catalog or sync | | [`docs/dev/architecture.md`](docs/dev/architecture.md) | Anything touching the render path, catalog or sync |
| [`docs/code-health.md`](docs/code-health.md) | Deciding what to work on; grades each seam by what it costs | | [`docs/dev/code-health.md`](docs/dev/code-health.md) | Deciding what to work on; grades each seam by what it costs |
| [`docs/benchmarks.md`](docs/benchmarks.md) | A change that could plausibly cost time or memory | | [`docs/dev/benchmarks.md`](docs/dev/benchmarks.md) | A change that could plausibly cost time or memory |
| [`docs/technical-debt.md`](docs/technical-debt.md) | Something looks wrong — check it was not chosen | | [`docs/dev/technical-debt.md`](docs/dev/technical-debt.md) | Something looks wrong — check it was not chosen |
| [`docs/distribution.md`](docs/distribution.md) | Packaging a build, or adding a permission to one | | [`docs/dev/distribution.md`](docs/dev/distribution.md) | Packaging a build, or adding a permission to one |
| [`docs/requirements.md`](docs/requirements.md) | Reference, not reading | | [`docs/dev/requirements.md`](docs/dev/requirements.md) | Reference, not reading |
`technical-debt.md` is the one to check before "fixing" anything surprising. `technical-debt.md` is the one to check before "fixing" anything surprising.
It records compromises that were deliberate, each with the reasoning and a It records compromises that were deliberate, each with the reasoning and a
Generated
+199 -30
View File
@@ -347,6 +347,28 @@ dependencies = [
"libloading", "libloading",
] ]
[[package]]
name = "ashpd"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39"
dependencies = [
"async-fs",
"async-net",
"enumflags2",
"futures-channel",
"futures-util",
"rand 0.9.5",
"raw-window-handle",
"serde",
"serde_repr",
"url",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"zbus",
]
[[package]] [[package]]
name = "async-broadcast" name = "async-broadcast"
version = "0.7.2" version = "0.7.2"
@@ -385,6 +407,17 @@ dependencies = [
"slab", "slab",
] ]
[[package]]
name = "async-fs"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5"
dependencies = [
"async-lock",
"blocking",
"futures-lite",
]
[[package]] [[package]]
name = "async-io" name = "async-io"
version = "2.6.0" version = "2.6.0"
@@ -414,6 +447,17 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "async-net"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7"
dependencies = [
"async-io",
"blocking",
"futures-lite",
]
[[package]] [[package]]
name = "async-process" name = "async-process"
version = "2.5.0" version = "2.5.0"
@@ -1221,7 +1265,7 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]] [[package]]
name = "darkroom-android" name = "darkroom-android"
version = "0.13.0" version = "0.17.0"
dependencies = [ dependencies = [
"android_logger", "android_logger",
"dr-plat", "dr-plat",
@@ -1234,7 +1278,7 @@ dependencies = [
[[package]] [[package]]
name = "darkroom-desktop" name = "darkroom-desktop"
version = "0.13.0" version = "0.17.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"dr-plat", "dr-plat",
@@ -1356,6 +1400,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.1",
"block2 0.6.2",
"libc",
"objc2 0.6.4", "objc2 0.6.4",
] ]
@@ -1408,7 +1454,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]] [[package]]
name = "dr-bench" name = "dr-bench"
version = "0.13.0" version = "0.17.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"dr-catalog", "dr-catalog",
@@ -1425,7 +1471,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-catalog" name = "dr-catalog"
version = "0.13.0" version = "0.17.0"
dependencies = [ dependencies = [
"dr-face", "dr-face",
"dr-plat", "dr-plat",
@@ -1440,7 +1486,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-decode" name = "dr-decode"
version = "0.13.0" version = "0.17.0"
dependencies = [ dependencies = [
"dr-types", "dr-types",
"env_logger", "env_logger",
@@ -1454,7 +1500,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-export" name = "dr-export"
version = "0.13.0" version = "0.17.0"
dependencies = [ dependencies = [
"dr-decode", "dr-decode",
"dr-gpu", "dr-gpu",
@@ -1473,7 +1519,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-face" name = "dr-face"
version = "0.13.0" version = "0.17.0"
dependencies = [ dependencies = [
"dr-inference-engine", "dr-inference-engine",
"env_logger", "env_logger",
@@ -1486,7 +1532,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-film" name = "dr-film"
version = "0.13.0" version = "0.17.0"
dependencies = [ dependencies = [
"log", "log",
"serde", "serde",
@@ -1495,7 +1541,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-gpu" name = "dr-gpu"
version = "0.13.0" version = "0.17.0"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"dr-decode", "dr-decode",
@@ -1513,8 +1559,9 @@ dependencies = [
[[package]] [[package]]
name = "dr-inference-engine" name = "dr-inference-engine"
version = "0.13.0" version = "0.17.0"
dependencies = [ dependencies = [
"env_logger",
"libloading", "libloading",
"log", "log",
"ort", "ort",
@@ -1527,7 +1574,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-ingest" name = "dr-ingest"
version = "0.13.0" version = "0.17.0"
dependencies = [ dependencies = [
"dr-plat", "dr-plat",
"dr-types", "dr-types",
@@ -1539,7 +1586,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-lens" name = "dr-lens"
version = "0.13.0" version = "0.17.0"
dependencies = [ dependencies = [
"lensfun", "lensfun",
"log", "log",
@@ -1547,7 +1594,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-pano" name = "dr-pano"
version = "0.13.0" version = "0.17.0"
dependencies = [ dependencies = [
"dr-decode", "dr-decode",
"dr-inference-engine", "dr-inference-engine",
@@ -1561,7 +1608,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-pipeline" name = "dr-pipeline"
version = "0.13.0" version = "0.17.0"
dependencies = [ dependencies = [
"dr-types", "dr-types",
"log", "log",
@@ -1570,7 +1617,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-plat" name = "dr-plat"
version = "0.13.0" version = "0.17.0"
dependencies = [ dependencies = [
"android-native-keyring-store", "android-native-keyring-store",
"dr-types", "dr-types",
@@ -1586,7 +1633,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-preset-xmp" name = "dr-preset-xmp"
version = "0.13.0" version = "0.17.0"
dependencies = [ dependencies = [
"dr-pipeline", "dr-pipeline",
"log", "log",
@@ -1596,7 +1643,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-segment" name = "dr-segment"
version = "0.13.0" version = "0.17.0"
dependencies = [ dependencies = [
"dr-inference-engine", "dr-inference-engine",
"env_logger", "env_logger",
@@ -1609,7 +1656,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-sync" name = "dr-sync"
version = "0.13.0" version = "0.17.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"dr-plat", "dr-plat",
@@ -1623,7 +1670,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-sync-folder" name = "dr-sync-folder"
version = "0.13.0" version = "0.17.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"dr-sync", "dr-sync",
@@ -1635,7 +1682,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-sync-nextcloud" name = "dr-sync-nextcloud"
version = "0.13.0" version = "0.17.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"dr-decode", "dr-decode",
@@ -1657,7 +1704,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-thumbs" name = "dr-thumbs"
version = "0.13.0" version = "0.17.0"
dependencies = [ dependencies = [
"dr-types", "dr-types",
"jpeg-encoder", "jpeg-encoder",
@@ -1669,7 +1716,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-types" name = "dr-types"
version = "0.13.0" version = "0.17.0"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
@@ -1678,7 +1725,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-ui" name = "dr-ui"
version = "0.13.0" version = "0.17.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@@ -1703,24 +1750,30 @@ dependencies = [
"dr-types", "dr-types",
"dr-xmp", "dr-xmp",
"env_logger", "env_logger",
"i-slint-backend-testing",
"jni 0.22.4", "jni 0.22.4",
"log", "log",
"ndk-context", "ndk-context",
"png",
"pollster", "pollster",
"raw-window-handle",
"reqwest", "reqwest",
"rfd",
"rusqlite", "rusqlite",
"serde_json", "serde_json",
"serde_norway", "serde_norway",
"sha2",
"slint", "slint",
"slint-build", "slint-build",
"thiserror 2.0.20", "thiserror 2.0.20",
"tokio", "tokio",
"url",
"wgpu", "wgpu",
] ]
[[package]] [[package]]
name = "dr-xmp" name = "dr-xmp"
version = "0.13.0" version = "0.17.0"
dependencies = [ dependencies = [
"dr-types", "dr-types",
"log", "log",
@@ -2778,6 +2831,18 @@ dependencies = [
"i-slint-renderer-skia", "i-slint-renderer-skia",
] ]
[[package]]
name = "i-slint-backend-testing"
version = "1.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521e901e3d47ab829c0ef500c63155776208707cd93259e6a7803ed627fa2786"
dependencies = [
"cfg_aliases",
"i-slint-common",
"i-slint-core",
"vtable",
]
[[package]] [[package]]
name = "i-slint-backend-winit" name = "i-slint-backend-winit"
version = "1.17.1" version = "1.17.1"
@@ -2958,8 +3023,6 @@ dependencies = [
[[package]] [[package]]
name = "i-slint-renderer-skia" name = "i-slint-renderer-skia"
version = "1.17.1" version = "1.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b6eed7f3f0a9a3d3ca6e8b9d4ca233371d989351fdb2a7ab88ec368b99e7b57"
dependencies = [ dependencies = [
"ash", "ash",
"bytemuck", "bytemuck",
@@ -5653,6 +5716,30 @@ dependencies = [
"zune-jpeg 0.5.15", "zune-jpeg 0.5.15",
] ]
[[package]]
name = "rfd"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"ashpd",
"block2 0.6.2",
"dispatch2",
"js-sys",
"log",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"pollster",
"raw-window-handle",
"urlencoding",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.60.2",
]
[[package]] [[package]]
name = "rgb" name = "rgb"
version = "0.8.53" version = "0.8.53"
@@ -6271,6 +6358,7 @@ dependencies = [
"num-traits", "num-traits",
"once_cell", "once_cell",
"pin-weak", "pin-weak",
"raw-window-handle",
"slint-macros", "slint-macros",
"unicode-segmentation", "unicode-segmentation",
"vtable", "vtable",
@@ -7021,9 +7109,10 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]] [[package]]
name = "traceability" name = "traceability"
version = "0.13.0" version = "0.17.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"pulldown-cmark",
"serde", "serde",
"serde_json", "serde_json",
] ]
@@ -7431,8 +7520,15 @@ dependencies = [
"idna", "idna",
"percent-encoding", "percent-encoding",
"serde", "serde",
"serde_derive",
] ]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]] [[package]]
name = "usvg" name = "usvg"
version = "0.47.0" version = "0.47.0"
@@ -7921,8 +8017,6 @@ dependencies = [
[[package]] [[package]]
name = "wgpu-hal" name = "wgpu-hal"
version = "29.0.4" version = "29.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97ace1c17727311c22a46e4e3faf56ea6de81af99dcc839bdfb54857b94d448d"
dependencies = [ dependencies = [
"android_system_properties", "android_system_properties",
"arrayvec", "arrayvec",
@@ -8155,6 +8249,15 @@ dependencies = [
"windows-targets 0.52.6", "windows-targets 0.52.6",
] ]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.61.2" version = "0.61.2"
@@ -8203,13 +8306,30 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6", "windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6", "windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6", "windows_i686_gnu 0.52.6",
"windows_i686_gnullvm", "windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6", "windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6", "windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6", "windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6", "windows_x86_64_msvc 0.52.6",
] ]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
]
[[package]] [[package]]
name = "windows-threading" name = "windows-threading"
version = "0.2.1" version = "0.2.1"
@@ -8237,6 +8357,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.42.2" version = "0.42.2"
@@ -8255,6 +8381,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.42.2" version = "0.42.2"
@@ -8273,12 +8405,24 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]] [[package]]
name = "windows_i686_gnullvm" name = "windows_i686_gnullvm"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.42.2" version = "0.42.2"
@@ -8297,6 +8441,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.42.2" version = "0.42.2"
@@ -8315,6 +8465,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.42.2" version = "0.42.2"
@@ -8333,6 +8489,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.42.2" version = "0.42.2"
@@ -8351,6 +8513,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]] [[package]]
name = "winit" name = "winit"
version = "0.30.13" version = "0.30.13"
@@ -8816,6 +8984,7 @@ dependencies = [
"endi", "endi",
"enumflags2", "enumflags2",
"serde", "serde",
"url",
"winnow 1.0.4", "winnow 1.0.4",
"zvariant_derive", "zvariant_derive",
"zvariant_utils", "zvariant_utils",
+18 -2
View File
@@ -27,9 +27,12 @@ members = [
"tools/bench", "tools/bench",
"tools/traceability", "tools/traceability",
] ]
# Patched copies of upstream crates, not our code: see third_party/README.md.
# Excluded so `--workspace` does not test, lint or format them as ours.
exclude = ["third_party"]
[workspace.package] [workspace.package]
version = "0.13.0" version = "0.17.0"
edition = "2021" edition = "2021"
rust-version = "1.92" rust-version = "1.92"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
@@ -48,7 +51,7 @@ dr-export = { path = "core/dr-export" }
dr-face = { path = "core/dr-face", default-features = false } dr-face = { path = "core/dr-face", default-features = false }
dr-film = { path = "core/dr-film" } dr-film = { path = "core/dr-film" }
# `tract` on by default so a test binary can open a session with nothing # `tract` on by default so a test binary can open a session with nothing
# installed; the apps add `native` to look for a runtime file (docs/inference.md §3). # installed; the apps add `native` to look for a runtime file (docs/dev/inference.md §3).
dr-inference-engine = { path = "core/dr-inference-engine" } dr-inference-engine = { path = "core/dr-inference-engine" }
dr-ingest = { path = "core/dr-ingest" } dr-ingest = { path = "core/dr-ingest" }
dr-gpu = { path = "core/dr-gpu" } dr-gpu = { path = "core/dr-gpu" }
@@ -127,6 +130,10 @@ url = "2.5"
async-trait = "0.1" async-trait = "0.1"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
# The manual's HTML rendering (tools/traceability). Already in the tree as
# Slint's Markdown parser, so this adds a dependency edge and no crate; only
# the HTML writer is needed, not the command-line front end.
pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] }
base64 = "0.23" base64 = "0.23"
# Display-server clients, for FR-DSP-8's per-display profile acquisition. # Display-server clients, for FR-DSP-8's per-display profile acquisition.
@@ -262,3 +269,12 @@ opt-level = 0
[profile.release] [profile.release]
lto = "thin" lto = "thin"
codegen-units = 1 codegen-units = 1
# Two upstream crates carry a local patch so that the Android build can draw
# with wgpu on a rotated display (technical-debt.md TD-1). Both are exact
# copies of the version the lockfile already resolves, plus that patch;
# third_party/README.md says what was changed and how to carry it forward
# when Slint or wgpu moves.
[patch.crates-io]
wgpu-hal = { path = "third_party/wgpu-hal-29.0.4" }
i-slint-renderer-skia = { path = "third_party/i-slint-renderer-skia-1.17.1" }
+121 -59
View File
@@ -1,84 +1,146 @@
# DarkRoom # DarkRoom
A cross-platform, non-destructive RAW photo editor for Linux and Android. A non-destructive RAW photo editor and library for Linux and Android, with a
GPU develop pipeline, a catalog that syncs between devices, and no account,
no telemetry and no cloud of its own.
**Status:** 0.9.0, and no longer a spike. A library opens, culls, develops and [![The library: seventy frames, the timeline beside them, the filter bar above](docs/manual/media/library.png)](docs/manual/README.md)
exports on both platforms, across eight tagged releases. What is *not*
built is written down rather than merely absent — see
[docs/outstanding.md](docs/outstanding.md) for the requirements that have no
implementation and why, and [docs/technical-debt.md](docs/technical-debt.md)
for the compromises that were chosen.
## Documentation **[The manual](docs/manual/README.md)** shows every feature, pictured from
the application itself. This page says what it is, how to get it, and what
is still missing.
| Document | Contents | ## What it does
|---|---|
| [CONTRIBUTING.md](CONTRIBUTING.md) | How to land a first change without reading the rest |
| [requirements.md](docs/requirements.md) | What the software must do — 179 numbered requirements |
| [architecture.md](docs/architecture.md) | How it is built — crates, GPU pipeline, data model, sync |
| [technical-debt.md](docs/technical-debt.md) | Compromises taken deliberately, each with the condition that retires it |
| [outstanding.md](docs/outstanding.md) | What is not built, and whether that is a decision or a gap |
| [code-health.md](docs/code-health.md) | What a contribution costs, per seam, measured |
| [traceability.md](docs/traceability.md) | Generated: which requirement is claimed by which file |
| [faces.md](docs/faces.md) | Face detection and identity — the models, the licence problem, and what S14 measured |
## Building **A library.** Point it at a folder — on this machine, on a network mount,
or one a Nextcloud client keeps in virtual-files mode, where a placeholder
is treated as the photograph rather than as a one-byte file — or at a
Nextcloud account directly; a photograph that is only on the server opens on
its thumbnail with the download's progress over it. The grid is virtualised,
ordered by capture time with a timeline beside it, and filtered by rating,
flag, colour label, person and whether the file is here. Ratings, colour
labels, keywords, collections and a trash that survives a crash
mid-operation. Card ingest. Bursts fold. The same RAW catalogued twice — a
dated folder and a backup beside it — is found, proved the same, and folded
onto one copy with the spares in the trash. Face detection and identity,
with the index syncing between devices.
Desktop: **Developing.** Eighteen declared operations fused into one compute
dispatch, plus the neighbourhood work that cannot be: clarity, texture,
capture sharpening, noise reduction, lens correction, spectral film
simulation. Crop, straighten and correct converging verticals, spot repair,
and local adjustments over masks the model draws — click a subject or a
category, then paint, subtract a gradient or keep only where two selections
agree, grow or shrink the edge. Focus peaking and a raw histogram for judging
what is recoverable. Presets, with a collection shipped in the application —
everyday corrections, and a look for each measured colour, cinema and
black-and-white stock — and Lightroom presets imported as looks that leave a
photograph's own corrections alone. XMP sidecars other editors read.
[![Segmenting an urban scene and choosing the sky as a mask](docs/manual/media/local-segment.png)](docs/manual/README.md#local-adjustments)
**Panoramas.** Select the frames, align, choose a projection, fill the
ragged border rather than crop it, and the composite lands beside its
sources as a DNG, with a sidecar recording what it was merged from.
[![Twelve hand-held frames aligned on a cylinder](docs/manual/media/panorama-aligned.png)](docs/manual/README.md#merging-a-panorama)
**From the keyboard, and with its manual.** Rating, flagging and labelling
have keys in the grid and in develop, as do zoom, undo and stepping through a
shoot in develop, and none of them is keyboard-only. The help sheet (`F1`, or
`?` in develop) lists every key and gesture, generated from the code that
binds it, and links them to the sections of the manual that show them — the
manual ships with the application and opens offline.
**Export.** JPEG, PNG, AVIF, JPEG XL, 8- and 16-bit TIFF, with resize, output
sharpening, a naming template and a colour space — into albums: named export
folders on this machine or on the server, never inside the library, which
remember the photograph behind each file and sync between devices as
collections do.
**On both platforms.** The same core runs on a desktop and a 12-inch
tablet; the interface is one layout, tuned for a wide viewport with touch
targets throughout. On both, the develop view draws the compute pass's
texture directly — no readback between the GPU and the screen.
## Getting it
| Platform | How | State |
|---|---|---|
| Arch Linux | [`packaging/PKGBUILD`](packaging/PKGBUILD) — `makepkg -si` | Built from every release |
| Android | The APK from each CI run, or `./docker/android/package.sh --install` | Runs on a tablet; F-Droid not yet submitted |
| Windows | `DarkRoom-<version>-x86_64-setup.exe`, cross-built by CI ([windows.md](docs/dev/windows.md)) | Verified under Wine only; unsigned |
| Flatpak | [`packaging/flatpak/`](packaging/flatpak/) | Manifest in tree; folders are chosen through the portal, but no Flatpak has been built to prove it |
Or build it. Git LFS is required for the model weights, and the toolchain
pins itself to 1.92.0:
```bash ```bash
cargo run -p darkroom-desktop git lfs install && git lfs pull
cargo run --release -p darkroom-desktop
``` ```
Android (containerised toolchain, see [docker/android](docker/android/README.md)): Android, through the containerised toolchain ([docker/android](docker/android/README.md)):
```bash ```bash
./docker/android/build.sh cargo ndk -t arm64-v8a build --release ./docker/android/build.sh cargo ndk -t arm64-v8a build --release
``` ```
Git LFS is required for the model weights, and the toolchain pins itself. [CONTRIBUTING.md](CONTRIBUTING.md) has the system packages, the four
[CONTRIBUTING.md](CONTRIBUTING.md) has the details and the four commands CI commands CI runs against what you send, and the shortest useful
will run against what you send. contribution — a develop operation is one YAML file, and it arrives with its
controls, its place in the chain and its tests.
## Current state ## Where it stands
**Working.** A catalog over a local folder, a Nextcloud account, or a folder a **0.17.0**, twenty-five tagged releases in. 192 numbered requirements in
sync client keeps in virtual-files mode — where a placeholder is treated as the scope, 84% of them claimed by code and [traced to it](docs/dev/traceability.md);
photograph rather than as a one-byte file. A virtualised library grid with a the rest are written down rather than merely absent.
capture-time timeline, ratings, labels, keywords, collections and a trash that
survives a crash mid-operation. Card ingest. Face detection and identity, with
the index syncing between devices. A develop pipeline of fifteen declared
operations fused into a single compute dispatch, plus the neighbourhood
operations that cannot be — clarity, texture, capture sharpening, noise
reduction, lens correction, spectral film simulation. Crop, straighten, spot
removal, gradient and subject-segmentation masks, named presets, and a
generated panel that no operation in `ui/` is allowed to name. Export to JPEG,
PNG and 8- or 16-bit TIFF with resize and output sharpening.
**The zero-copy display path works on desktop.** The compute pass writes a **Not built:** plugins (post-v1, [D12](docs/dev/requirements.md)), compare and
texture that Slint composites directly, which is what survey culling, AI denoise, tiled rendering, HDR merge and
[ARCH §6.1](docs/architecture.md) requires; the readback it forbids costs 96% focus stacking, importing a Lightroom or darktable catalog, translations
of frame time at 4K, and beyond the launch screen, most of the Android platform integration beyond
running, and a Flatpak actually built and run in its sandbox. The
performance targets are half verified: the per-commit benchmark suite §8
requires exists for everything that does not need a frame — the catalog,
the scan, the thumbnails — and not yet for the render path, so a regression
there fails nothing.
[outstanding.md](docs/dev/outstanding.md) is the list, with the reasoning for
each.
```bash ## Documentation
cargo run -p dr-gpu --example bench --features readback
```
still reproduces that measurement. **The one exception is the Android develop [docs/README.md](docs/README.md) is the index. The short version, for someone using it:
view**, which reads the frame back through the CPU because zero-copy there
needs wgpu's Vulkan swapchain, and that tears a portrait window on a tablet
whose panel is mounted landscape. It is debt, not a revision of the rule: the
reasoning, the on-device measurements that forced it, and the three separate
things any one of which would remove it are in
[technical-debt.md TD-1](docs/technical-debt.md).
**Not built.** Plugins, compare and survey culling, focus peaking, burst | | |
grouping, AI denoise, tiled and progressive rendering, and most of the Android |---|---|
platform integration beyond running. The performance targets in §4.1 are | [manual](docs/manual/README.md) | Every feature, pictured |
unverified rather than unmet — the per-commit benchmark suite §8 requires does | [gestures.md](docs/gestures.md) | How it is driven — generated from the code, so it cannot describe a gesture that does not exist |
not exist, so nothing fails a build on a regression.
[docs/outstanding.md](docs/outstanding.md) is the list, with the reasoning. For someone changing it:
| | |
|---|---|
| [CONTRIBUTING.md](CONTRIBUTING.md) | How to land a first change without reading the rest |
| [requirements.md](docs/dev/requirements.md) | What the software must do — the numbered register, and the decisions |
| [architecture.md](docs/dev/architecture.md) | How it is built — crates, the GPU pipeline, the data model, sync |
| [technical-debt.md](docs/dev/technical-debt.md) | Compromises taken deliberately, each with the condition that retires it |
| [outstanding.md](docs/dev/outstanding.md) | What is not built, and whether that is a decision or a gap |
| [code-health.md](docs/dev/code-health.md) | What a contribution costs, per seam, measured |
| [traceability.md](docs/dev/traceability.md) | Generated: which requirement is claimed by which file |
Designs, one per subsystem:
[segmentation](docs/dev/segmentation.md) and [mask editing](docs/dev/mask-editing.md) ·
[spot removal](docs/dev/spot-removal.md) · [panorama](docs/dev/panorama.md) ·
[faces](docs/dev/faces.md) · [inference](docs/dev/inference.md) ·
[storage and sync](docs/dev/storage.md) · [catalog](docs/dev/catalog.md) ·
[display and extension](docs/dev/display-and-extension.md) ·
[navigation](docs/dev/ui-navigation.md) · [distribution](docs/dev/distribution.md) ·
[windows](docs/dev/windows.md) · [benchmarks](docs/dev/benchmarks.md).
## Licence ## Licence
GPL-3.0-or-later. GPL-3.0-or-later. The photographs in the manual and the test fixtures are
the author's and are there to show and test this project, nothing else.
The model weights carry their own licences — [models/LICENCE.md](models/LICENCE.md).
@@ -141,6 +141,35 @@
</intent-filter> </intent-filter>
</activity> </activity>
<!-- The manual (dr_ui::manual): a WebView over the copy the APK
carries in assets/manual. See ManualActivity.java for why it is
not the browser.
Not exported: nothing outside this app has a reason to start it,
and dr_ui starts it by class name, which needs no intent filter.
Its own task entry is not wanted either — it is a page over the
app, and Back returns to the photograph it was opened from.
configChanges so a rotation reflows the page rather than
reloading it at the top. -->
<activity
android:name="paris.tourolle.darkroom.ManualActivity"
android:exported="false"
android:label="DarkRoom manual"
android:theme="@style/ManualTheme"
android:configChanges="orientation|keyboardHidden|screenSize|screenLayout|uiMode" />
<!-- FR-EXP-10: the system's folder picker, for an album's folder on
this device. NativeActivity's onActivityResult is not ours, so
this activity exists only to ask and hand the answer back (see
FolderPicker.java). Translucent and without a title so nothing
of it shows but the system chooser; not exported, and started by
class name from dr_ui::saf. -->
<activity
android:name="paris.tourolle.darkroom.FolderPicker"
android:exported="false"
android:theme="@android:style/Theme.Translucent.NoTitleBar"
android:configChanges="orientation|keyboardHidden|screenSize|screenLayout|uiMode" />
<!-- FR-PLAT-AND-6, outbound. Android has refused file:// URIs <!-- FR-PLAT-AND-6, outbound. Android has refused file:// URIs
between apps since API 24 — handing one out raises between apps since API 24 — handing one out raises
FileUriExposedException in *this* process — so an exported JPEG FileUriExposedException in *this* process — so an exported JPEG
@@ -0,0 +1,117 @@
package paris.tourolle.darkroom;
import android.app.Activity;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.util.Log;
/**
* The system's folder picker, for an album's folder on this device (FR-EXP-10).
*
* <h2>Why an activity of its own</h2>
*
* <p>{@code ACTION_OPEN_DOCUMENT_TREE} answers through
* {@code onActivityResult}, and the main activity is {@code NativeActivity},
* whose result callback is not ours to override. So this one exists only to
* ask: it starts the picker, takes the answer, and finishes — no layout, a
* translucent theme, nothing on screen but the system's own chooser, which has
* its own "New folder".
*
* <p>The answer is left in a static for Rust to poll ({@link #poll}), rather
* than called back into native code: a callback would need a registered
* native method and a thread to deliver on, and a poll from the Slint timer
* that is already running is one static call.
*
* <h2>The grant</h2>
*
* <p>A tree URI is usable only while its permission is held, and a plain
* result grants it until the process dies. {@code takePersistableUriPermission}
* keeps it across restarts — an album's folder is chosen once and exported to
* for months.
*/
public final class FolderPicker extends Activity {
private static final String TAG = "DarkRoom";
private static final int REQUEST = 0x5AF;
/** The last answer: a tree URI, "" for a cancel, null while none has come. */
private static volatile String answer = null;
/**
* Start asking. Clears any answer left from before.
*
* <p>Takes a {@code Context} rather than an {@code Activity}, because what
* native code holds (ndk_context's handle) is the application context,
* and starting an activity from one that is not an activity needs
* {@code FLAG_ACTIVITY_NEW_TASK} — without it the call throws. The picker
* shares the app's task affinity, so it still opens over the app and Back
* still returns to it.
*/
public static void start(Context from) {
answer = null;
Intent intent = new Intent(from, FolderPicker.class);
if (!(from instanceof Activity)) {
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
}
from.startActivity(intent);
}
/**
* The answer, once: a tree URI, "" if the user backed out, or null while
* the picker is still open. Reading it clears it, so a second poll after a
* cancel does not see the cancel again.
*/
public static String poll() {
String a = answer;
if (a != null) {
answer = null;
}
return a;
}
@Override
protected void onCreate(Bundle state) {
super.onCreate(state);
// Recreated after a rotation with the picker already up: asking again
// would stack a second chooser over the first.
if (state != null) {
return;
}
Intent pick = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
pick.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION
| Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION);
try {
startActivityForResult(pick, REQUEST);
} catch (ActivityNotFoundException e) {
Log.w(TAG, "no folder picker on this device", e);
answer = "";
finish();
}
}
@Override
protected void onActivityResult(int request, int result, Intent data) {
if (request != REQUEST) {
return;
}
Uri tree = (result == RESULT_OK && data != null) ? data.getData() : null;
if (tree == null) {
answer = "";
} else {
try {
getContentResolver().takePersistableUriPermission(tree,
Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
} catch (SecurityException e) {
// Still usable this session; said in the log so a folder that
// stops working after a restart has an explanation.
Log.w(TAG, "the folder grant could not be kept: " + tree, e);
}
answer = tree.toString();
}
finish();
}
}
@@ -0,0 +1,105 @@
package paris.tourolle.darkroom;
import android.app.Activity;
import android.content.ActivityNotFoundException;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.webkit.WebResourceRequest;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
/**
* The manual that ships in the APK, shown in a WebView.
*
* <h2>Why an activity of our own rather than the browser</h2>
*
* <p>The desktop hands the manual to the system browser. Android leaves no
* way to do the same: the page is an asset inside the APK, which is not a
* file; an unpacked copy in app-private storage is a file no browser may
* read; a {@code file:} URI handed to another app is refused since API 24;
* and a {@code content:} URI serves the page but leaves the browser to fetch
* every picture by a relative URL against the provider, which browsers do not
* reliably do. A WebView reads {@code file:///android_asset/} straight from
* the APK, pictures and section anchor included, and nothing is unpacked.
*
* <h2>What it is not</h2>
*
* <p>A browser. JavaScript stays off (the page has none), and a link that
* leaves the manual — the design documents are on the forge — goes to the
* user's browser rather than opening inside this view, so the only thing ever
* shown here is the page the APK carries.
*
* <p>Started by {@code dr_ui::manual} with {@code Intent.setClassName}, so the
* name here and there must agree; a test in lib.rs checks the manifest
* declares it.
*/
public final class ManualActivity extends Activity {
/** The section to open at, a heading's anchor. Absent opens the top. */
public static final String EXTRA_ANCHOR = "anchor";
private static final String PAGE = "file:///android_asset/manual/index.html";
private WebView web;
@Override
protected void onCreate(Bundle saved) {
super.onCreate(saved);
setTitle("DarkRoom manual");
web = new WebView(this);
WebSettings settings = web.getSettings();
settings.setJavaScriptEnabled(false);
// Pinch to zoom into a screenshot, which is 1600 pixels wide and drawn
// at the width of a phone.
settings.setBuiltInZoomControls(true);
settings.setDisplayZoomControls(false);
web.setWebViewClient(new WebViewClient() {
@Override
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
Uri uri = request.getUrl();
if ("file".equals(uri.getScheme())) {
return false;
}
try {
startActivity(new Intent(Intent.ACTION_VIEW, uri));
} catch (ActivityNotFoundException e) {
// No browser on the device: the link does nothing, which
// is all it could do.
}
return true;
}
});
setContentView(web);
if (saved != null) {
web.restoreState(saved);
} else {
String anchor = getIntent().getStringExtra(EXTRA_ANCHOR);
web.loadUrl(anchor == null || anchor.isEmpty() ? PAGE : PAGE + "#" + anchor);
}
}
@Override
protected void onSaveInstanceState(Bundle out) {
super.onSaveInstanceState(out);
web.saveState(out);
}
/** Back walks back through the sections visited, then leaves. */
@Override
public void onBackPressed() {
if (web.canGoBack()) {
web.goBack();
} else {
super.onBackPressed();
}
}
@Override
protected void onDestroy() {
web.destroy();
super.onDestroy();
}
}
@@ -0,0 +1,114 @@
package paris.tourolle.darkroom;
import android.content.ContentResolver;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.provider.DocumentsContract;
import android.util.Log;
import java.io.IOException;
import java.io.OutputStream;
/**
* Writing an export into a folder the user granted through
* {@link FolderPicker} — the Storage Access Framework, which is the only way
* this app reaches a folder on the device (FR-PLAT-AND-1).
*
* <p>A tree URI is not a path: a child is found by listing the folder and
* matching its display name, and created through the provider, which may
* rename it on a collision. So the name that was actually written is handed
* back, and the album records that one.
*
* <p>Two static calls, strings and a byte array in, a string out, for the
* reason {@link Intents} gives: every call here would be a signature typed as
* a string on the Rust side, and the fewer of those the better.
*/
public final class Saf {
private static final String TAG = "DarkRoom";
private Saf() {
}
/** Whether {@code name} already exists in the folder. False on any error. */
public static boolean exists(Context context, String tree, String name) {
try {
return find(context.getContentResolver(), Uri.parse(tree), name) != null;
} catch (RuntimeException e) {
Log.w(TAG, "checking " + name + " in " + tree, e);
return false;
}
}
/**
* Write {@code bytes} as {@code name} in the folder, replacing a file of
* that name when {@code replace} is set.
*
* @return the name the file has in the folder — the provider may have
* added " (1)" — or null on failure, with the reason in the log.
*/
public static String write(Context context, String tree, String name, String mime,
byte[] bytes, boolean replace) {
ContentResolver resolver = context.getContentResolver();
Uri treeUri = Uri.parse(tree);
try {
Uri target = replace ? find(resolver, treeUri, name) : null;
if (target == null) {
Uri folder = DocumentsContract.buildDocumentUriUsingTree(treeUri,
DocumentsContract.getTreeDocumentId(treeUri));
target = DocumentsContract.createDocument(resolver, folder, mime, name);
}
if (target == null) {
Log.w(TAG, "the folder refused to create " + name + " in " + tree);
return null;
}
// "wt": truncate. A replacement shorter than what it replaces
// must not keep the old file's tail.
try (OutputStream out = resolver.openOutputStream(target, "wt")) {
if (out == null) {
Log.w(TAG, "no stream for " + target);
return null;
}
out.write(bytes);
}
String written = displayName(resolver, target);
return written != null ? written : name;
} catch (IOException | RuntimeException e) {
Log.w(TAG, "writing " + name + " to " + tree, e);
return null;
}
}
/** The document for {@code name} directly in the tree's folder, or null. */
private static Uri find(ContentResolver resolver, Uri tree, String name) {
String folderId = DocumentsContract.getTreeDocumentId(tree);
Uri children = DocumentsContract.buildChildDocumentsUriUsingTree(tree, folderId);
String[] columns = {
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
};
try (Cursor c = resolver.query(children, columns, null, null, null)) {
if (c == null) {
return null;
}
while (c.moveToNext()) {
if (name.equals(c.getString(1))) {
return DocumentsContract.buildDocumentUriUsingTree(tree, c.getString(0));
}
}
}
return null;
}
private static String displayName(ContentResolver resolver, Uri document) {
String[] columns = {DocumentsContract.Document.COLUMN_DISPLAY_NAME};
try (Cursor c = resolver.query(document, columns, null, null, null)) {
if (c != null && c.moveToFirst()) {
return c.getString(0);
}
} catch (RuntimeException e) {
Log.w(TAG, "reading the name of " + document, e);
}
return null;
}
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Day or night as the system is; see values/themes.xml. -->
<resources>
<style name="ManualTheme" parent="@android:style/Theme.DeviceDefault.DayNight" />
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
The manual's theme (ManualActivity). Light below API 29, which has no
day-night theme in the platform; values-v29 follows the system from there.
The WebView takes prefers-color-scheme from whether this theme is light, and
the manual's stylesheet takes its colours from that.
-->
<resources>
<style name="ManualTheme" parent="@android:style/Theme.DeviceDefault.Light" />
</resources>
+37 -6
View File
@@ -240,7 +240,7 @@ fn android_main(app: slint::android::AndroidApp) {
/// ///
/// **Face weights are absent from the repository by design.** The InsightFace /// **Face weights are absent from the repository by design.** The InsightFace
/// grant is research-only and incompatible with this project's licence /// grant is research-only and incompatible with this project's licence
/// (docs/faces.md §2), so a desktop user fetches them, runs /// (docs/dev/faces.md §2), so a desktop user fetches them, runs
/// `tools/fix-face-model-shapes.sh` over them, and drops the result in. A build /// `tools/fix-face-model-shapes.sh` over them, and drops the result in. A build
/// that carries none is the ordinary case and face indexing simply stays off. /// that carries none is the ordinary case and face indexing simply stays off.
/// ///
@@ -321,19 +321,19 @@ fn unpack_bundled_models(app: &slint::android::AndroidApp) {
// before it reports the tab available. // before it reports the tab available.
// //
// Three detectors, because which one runs is a setting // Three detectors, because which one runs is a setting
// (`FaceDetector`, docs/faces.md §12.3) and a tablet has no other way to // (`FaceDetector`, docs/dev/faces.md §12.3) and a tablet has no other way to
// obtain the one it was not shipped with. Twenty megabytes of APK for // obtain the one it was not shipped with. Twenty megabytes of APK for
// the choice; the embedder is the same for all three. // the choice; the embedder is the same for all three.
// //
// Then the three eye-state models (docs/faces.md §17): landmarks, open // Then the three eye-state models (docs/dev/faces.md §17): landmarks, open
// or closed, sunglasses. The app indexes without them; with them the // or closed, sunglasses. The app indexes without them; with them the
// eyes-open filter has something to read, and a tablet has no other way // eyes-open filter has something to read, and a tablet has no other way
// to get them either. // to get them either.
// //
// The int8 forms beside the three detectors are what the Hexagon runs // The int8 forms beside the three detectors are what the Hexagon runs
// (docs/inference.md §5); the engine loads the sibling when the probe // (docs/dev/inference.md §5); the engine loads the sibling when the probe
// chose that rung and ignores it otherwise. // chose that rung and ignores it otherwise.
const BUNDLED: [(&std::ffi::CStr, &str); 13] = [ const BUNDLED: [(&std::ffi::CStr, &str); 14] = [
(c"models/scrfd_500m_640.onnx", "scrfd_500m_640.onnx"), (c"models/scrfd_500m_640.onnx", "scrfd_500m_640.onnx"),
( (
c"models/scrfd_500m_640.int8.onnx", c"models/scrfd_500m_640.int8.onnx",
@@ -420,7 +420,7 @@ fn unpack_bundled_models(app: &slint::android::AndroidApp) {
// on a first launch they were not on disk until this line. The runtime // on a first launch they were not on disk until this line. The runtime
// is in the APK's native library directory beside `libdarkroom.so`, // is in the APK's native library directory beside `libdarkroom.so`,
// which is also where Qualcomm's DSP loader has to be pointed for the // which is also where Qualcomm's DSP loader has to be pointed for the
// Hexagon skel (docs/inference.md §3, §8). // Hexagon skel (docs/dev/inference.md §3, §8).
dr_ui::inference::init(native_library_dir().into_iter().collect()); dr_ui::inference::init(native_library_dir().into_iter().collect());
} }
@@ -581,6 +581,37 @@ mod tests {
); );
} }
/// `dr_ui::manual` starts the manual by class name. A name the manifest
/// does not declare is an `ActivityNotFoundException` on the device and a
/// Manual button that does nothing, so the three spellings — dr_ui's, the
/// manifest's and the Java file's — are checked to be one.
#[test]
fn the_manual_activity_dr_ui_starts_is_declared() {
let manifest = manifest();
let wanted = dr_ui::manual::ANDROID_ACTIVITY;
let element = manifest
.split("<activity")
.skip(1)
.find(|a| attribute(a, "android:name").as_deref() == Some(wanted))
.unwrap_or_else(|| panic!("the manifest declares no activity {wanted}"));
assert_eq!(
attribute(element, "android:exported").as_deref(),
Some("false"),
"the manual activity has no reason to be startable by another app"
);
let java = include_str!("../android/java/paris/tourolle/darkroom/ManualActivity.java");
let (package, class) = wanted.rsplit_once('.').expect("unqualified class name");
assert!(java.contains(&format!("package {package};")));
assert!(java.contains(&format!("class {class} ")));
assert!(
java.contains(&format!(
"EXTRA_ANCHOR = \"{}\"",
dr_ui::manual::ANDROID_EXTRA_ANCHOR
)),
"ManualActivity reads the section from a different extra than dr_ui writes"
);
}
#[test] #[test]
fn the_provider_hands_out_one_file_at_a_time_and_nothing_by_itself() { fn the_provider_hands_out_one_file_at_a_time_and_nothing_by_itself() {
let manifest = manifest(); let manifest = manifest();
+3
View File
@@ -25,3 +25,6 @@ winresource = "0.1"
[features] [features]
default = [] default = []
# The manual's recording hook (dr-ui's `automation`); tools/manual/record.sh
# builds with it, nothing else does.
automation = ["dr-ui/automation"]
+12 -7
View File
@@ -21,7 +21,7 @@ fn main() -> anyhow::Result<()> {
// build made on a machine that cannot run the application — the Linux CI // build made on a machine that cannot run the application — the Linux CI
// producing the Windows binary, checked under Wine — has an exit that // producing the Windows binary, checked under Wine — has an exit that
// proves the executable starts without opening a window or touching the // proves the executable starts without opening a window or touching the
// user's directories (docs/windows.md §6). // user's directories (docs/dev/windows.md §6).
if std::env::args().nth(1).as_deref() == Some("--version") { if std::env::args().nth(1).as_deref() == Some("--version") {
println!("darkroom-desktop {}", env!("CARGO_PKG_VERSION")); println!("darkroom-desktop {}", env!("CARGO_PKG_VERSION"));
return Ok(()); return Ok(());
@@ -63,7 +63,7 @@ fn main() -> anyhow::Result<()> {
// Before the window: the probe runs on its own thread and the first // Before the window: the probe runs on its own thread and the first
// frame does not wait for it, but the models a background job asks for // frame does not wait for it, but the models a background job asks for
// should already know where the runtime is (docs/inference.md §4). // should already know where the runtime is (docs/dev/inference.md §4).
dr_ui::inference::init(runtime_dirs()); dr_ui::inference::init(runtime_dirs());
dr_ui::run(paths)?; dr_ui::run(paths)?;
@@ -78,15 +78,19 @@ fn main() -> anyhow::Result<()> {
/// Where a desktop package may have put `libonnxruntime`, most specific /// Where a desktop package may have put `libonnxruntime`, most specific
/// first. None of these existing is the tract build, which is a complete /// first. None of these existing is the tract build, which is a complete
/// application and not an error (docs/inference.md §3). /// application and not an error (docs/dev/inference.md §3).
/// ///
/// `DARKROOM_ORT_DIR` is for a developer pointing at a runtime that is not /// `DARKROOM_ORT_DIR` is for a developer pointing at a runtime that is not
/// installed — the wheel's `capi` directory, say. Then beside the executable /// installed — the wheel's `capi` directory, say. Then beside the executable
/// and in the package's private library directory, for a package that /// and in the package's private library directory, for a package that
/// bundles its own; then the Flatpak prefix; then the system library /// bundles its own; then the user's own `runtime/` beside the models, where
/// directory, for a distribution that ships ONNX Runtime as a package of its /// `tools/fetch-desktop-runtime.sh` puts one; then the Flatpak prefix; then
/// own. A system copy whose GPU providers do not load is not a problem: the /// the system library directory, for a distribution that ships ONNX Runtime
/// probe builds a real session before believing a provider. /// as a package of its own. The user's copy outranks the system's because
/// the system's is the one most likely to be built without the GPU
/// providers, or against the wrong cuDNN — and a system copy whose providers
/// do not load is not a problem, only a slower app: the probe builds a real
/// session before believing a provider.
fn runtime_dirs() -> Vec<PathBuf> { fn runtime_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new(); let mut dirs = Vec::new();
if let Some(dir) = std::env::var_os("DARKROOM_ORT_DIR") { if let Some(dir) = std::env::var_os("DARKROOM_ORT_DIR") {
@@ -98,6 +102,7 @@ fn runtime_dirs() -> Vec<PathBuf> {
dirs.push(bin.join("../lib/darkroom")); dirs.push(bin.join("../lib/darkroom"));
} }
} }
dirs.push(dr_ui::inference::user_runtime_dir());
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
dirs.extend([ dirs.extend([
PathBuf::from("/app/lib/darkroom"), PathBuf::from("/app/lib/darkroom"),
+266
View File
@@ -0,0 +1,266 @@
//! What the catalog's routine reads cost on a real library, off the GUI.
//!
//! cargo run --release -p dr-catalog --example catalog_bench -- CATALOG.sqlite [FACES_DIR]
//!
//! Times `Catalog::open` — which every worker thread pays, including the
//! develop view's fetch of each original and each neighbour it prefetches —
//! and the backfill that runs inside it, step by step. Run it against a
//! *copy* of a real catalog: opening migrates and backfills, which write.
//!
//! Then what the library screen reads on every keystroke and scroll: the
//! filter chips' counts, the keyword panel, and the grid's total. Two of
//! those live in `dr-ui` (`library::local_original_count` and the grid
//! count), whose `library` module is private; their SQL is spelled here as
//! it is spelled there, and has to be kept in step by hand.
//!
//! The figures are for reading side by side before and after a change; they
//! are not a gate. Compare the `cpu` column when the machine is busy. The
//! answers are printed too, so two builds can be checked for agreeing.
use std::path::PathBuf;
use std::time::{Duration, Instant};
use dr_catalog::{keywords, rating, schema, Catalog};
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let Some(path) = args.first().map(PathBuf::from) else {
eprintln!("usage: catalog_bench CATALOG.sqlite");
std::process::exit(2);
};
// Once untimed, so a migration or a first backfill is not in the figures.
drop(Catalog::open(&path).expect("catalog"));
time("Catalog::open", 20, || {
drop(Catalog::open(&path).unwrap());
});
// One develop landing, in the two shapes the app has had. Five opens is
// what `fetch_original` and a `holds_original` per prefetched neighbour
// cost when each asked on a connection of its own; two is the fetch plus
// one connection the prefetch worker keeps for its batch's row checks.
// Five images from the library, against an empty cache: the question is
// asked the same way whatever the answer.
let images: Vec<dr_types::ImageId> = {
let c = Catalog::open(&path).unwrap();
let mut stmt = c
.connection()
.prepare("SELECT id FROM images ORDER BY id LIMIT 5 OFFSET 1000")
.unwrap();
let ids = stmt
.query_map([], |r| r.get::<_, i64>(0))
.unwrap()
.map(|id| dr_types::ImageId(id.unwrap() as u64))
.collect();
ids
};
let cache_dir = path.with_extension("bench-cache");
let budget = dr_catalog::Budget::default();
time("landing: 5 opens (fetch + 4 row checks)", 20, || {
let store = dr_catalog::Cache::open(&cache_dir, budget).unwrap();
let c = Catalog::open(&path).unwrap();
let _ = store.load(c.connection(), images[0], 0).unwrap();
for &image in &images[1..] {
let store = dr_catalog::Cache::open(&cache_dir, budget).unwrap();
let c = Catalog::open(&path).unwrap();
let _ = store.holds_original(c.connection(), image);
}
});
time("landing: 2 opens (fetch + held row checks)", 20, || {
let store = dr_catalog::Cache::open(&cache_dir, budget).unwrap();
let c = Catalog::open(&path).unwrap();
let _ = store.load(c.connection(), images[0], 0).unwrap();
let held = Catalog::open(&path).unwrap();
for &image in &images[1..] {
let store = dr_catalog::Cache::open(&cache_dir, budget).unwrap();
let _ = store.holds_original(held.connection(), image);
}
});
let _ = std::fs::remove_dir_all(&cache_dir);
let catalog = Catalog::open(&path).unwrap();
let conn = catalog.connection();
time("schema::backfill (all steps)", 20, || {
schema::backfill(conn).unwrap();
});
time(" rating::ensure_default_versions", 20, || {
rating::ensure_default_versions(conn).unwrap();
});
time(" rating::align_default_version_uuids", 20, || {
rating::align_default_version_uuids(conn).unwrap();
});
time(" keywords::adopt_orphan_terms", 20, || {
keywords::adopt_orphan_terms(conn).unwrap();
});
interactive(conn);
// A sync pass: the upload snapshot, then a merge of the catalog with a
// copy of itself — every row a match, which is the steady state.
let scratch = path.with_extension("bench-snapshot");
time("snapshot_for_upload", 3, || {
let _ = std::fs::remove_file(&scratch);
catalog.snapshot_for_upload(&scratch).unwrap();
});
println!(
" snapshot size {:.1} MB",
std::fs::metadata(&scratch).map(|m| m.len()).unwrap_or(0) as f64 / 1e6
);
let remote = path.with_extension("bench-remote");
let _ = std::fs::remove_file(&remote);
conn.execute("VACUUM INTO ?1", [remote.to_string_lossy().as_ref()])
.unwrap();
time("merge_remote_catalog (self)", 5, || {
catalog.merge_remote_catalog(&remote).unwrap();
});
let _ = std::fs::remove_file(&scratch);
let _ = std::fs::remove_file(&remote);
// The face half of a sync pass, against a copy of the face store: both
// directions in the steady state, where nothing is new either way.
if let Some(faces) = args.get(1).map(PathBuf::from) {
let model = "scrfd_10g+w600k_mbf";
let mut store = dr_catalog::FaceShardStore::open(&faces).unwrap();
println!(
" first export sent {}, first import adopted {}",
dr_catalog::face_shard::export_to_shards(conn, &mut store, model).unwrap(),
dr_catalog::face_shard::import_from_shards(conn, &store, model).unwrap()
);
time("face_shard::export_to_shards (steady)", 5, || {
dr_catalog::face_shard::export_to_shards(conn, &mut store, model).unwrap();
});
time("face_shard::import_from_shards (steady)", 5, || {
dr_catalog::face_shard::import_from_shards(conn, &store, model).unwrap();
});
}
}
/// What one click in the library reads: a rating or label keystroke
/// refreshes the chips, a selection change redraws the keyword panel, and
/// every scroll reload counts the grid.
fn interactive(conn: &rusqlite::Connection) {
println!(
" rating_histogram {:?}, label_histogram {:?}, local originals {}, grid {} / rated {}",
rating::rating_histogram(conn).unwrap(),
rating::label_histogram(conn).unwrap(),
local_original_count(conn),
grid_count(conn, ""),
grid_count(conn, RATED_AT_LEAST_ONE),
);
let words = keywords::list(conn).unwrap();
println!(
" keywords::list {} terms, digest {:016x}",
words.len(),
digest(&format!("{words:?}"))
);
// A selection the size of a grid window, from the start of the library.
let selection: Vec<dr_types::ImageId> = conn
.prepare("SELECT id FROM images ORDER BY id LIMIT 120")
.unwrap()
.query_map([], |r| Ok(dr_types::ImageId(r.get::<_, i64>(0)? as u64)))
.unwrap()
.collect::<Result<_, _>>()
.unwrap();
println!(
" keywords::for_images digest {:016x}",
digest(&format!(
"{:?}",
keywords::for_images(conn, &selection).unwrap()
))
);
time("rating::rating_histogram", 50, || {
rating::rating_histogram(conn).unwrap();
});
time("library::local_original_count", 50, || {
local_original_count(conn);
});
time("rating::label_histogram", 50, || {
rating::label_histogram(conn).unwrap();
});
time("keywords::list", 50, || {
keywords::list(conn).unwrap();
});
time("keywords::for_images (120)", 50, || {
keywords::for_images(conn, &selection).unwrap();
});
time("grid count", 50, || {
grid_count(conn, "");
});
time("grid count, rated >= 1", 50, || {
grid_count(conn, RATED_AT_LEAST_ONE);
});
}
/// `dr_ui::library::local_original_count`, spelled as it is there.
fn local_original_count(conn: &rusqlite::Connection) -> i64 {
conn.query_row(
"SELECT count(*) FROM images i
WHERE i.shadowed_by IS NULL AND i.trashed_at IS NULL
AND i.id IN (SELECT ic.image_id FROM image_cache ic
WHERE ic.tier_actual >= 2)",
[],
|r| r.get(0),
)
.unwrap()
}
/// `RatingFilter::sql` for one star and up.
const RATED_AT_LEAST_ONE: &str = " AND coalesce((SELECT dv.rating FROM versions dv
WHERE dv.image_id = i.id AND dv.is_default = 1
LIMIT 1), 0) >= 1";
/// `dr_ui::library::total_images_filtered`, spelled as it is there.
fn grid_count(conn: &rusqlite::Connection, rated: &str) -> i64 {
let visible = "i.shadowed_by IS NULL AND i.trashed_at IS NULL";
let hidden = dr_catalog::bursts::collapsed_away_frames("i");
conn.query_row(
&format!(
"SELECT (SELECT count(*) FROM images i WHERE {visible}{rated})
- (SELECT count(*) FROM {hidden} AND {visible}{rated})"
),
[],
|r| r.get(0),
)
.unwrap()
}
/// FNV-1a, to print a long answer as something two runs can compare.
fn digest(s: &str) -> u64 {
s.bytes().fold(0xcbf29ce484222325, |h, b| {
(h ^ u64::from(b)).wrapping_mul(0x100000001b3)
})
}
/// Run `f` a few times and print the best wall-clock, the median, and the
/// best CPU time — the figure to compare across runs on a busy machine.
fn time(label: &str, runs: usize, mut f: impl FnMut()) {
let mut wall: Vec<Duration> = Vec::with_capacity(runs);
let mut cpu: Vec<Duration> = Vec::with_capacity(runs);
for _ in 0..runs {
let c = cpu_now();
let t = Instant::now();
f();
wall.push(t.elapsed());
cpu.push(cpu_now().saturating_sub(c));
}
wall.sort();
cpu.sort();
println!(
"{label:42} best {:8.2} ms median {:8.2} ms cpu {:8.2} ms",
wall[0].as_secs_f64() * 1e3,
wall[runs / 2].as_secs_f64() * 1e3,
cpu[0].as_secs_f64() * 1e3
);
}
/// This thread's time on a CPU so far, from `/proc/self/schedstat`; zero where
/// the file is missing, which only makes the CPU column useless.
fn cpu_now() -> Duration {
std::fs::read_to_string("/proc/self/schedstat")
.ok()
.and_then(|s| s.split_whitespace().next()?.parse::<u64>().ok())
.map(Duration::from_nanos)
.unwrap_or_default()
}
+1 -1
View File
@@ -315,7 +315,7 @@ fn full_library(
// The three phases, separately, because "a regroup takes n seconds" does // The three phases, separately, because "a regroup takes n seconds" does
// not tell anyone which half to optimise — and the answer differs between // not tell anyone which half to optimise — and the answer differs between
// a desktop and a tablet (docs/faces.md §9). // a desktop and a tablet (docs/dev/faces.md §9).
{ {
let dim = candidates.first().map(|c| c.embedding.len()).unwrap_or(0); let dim = candidates.first().map(|c| c.embedding.len()).unwrap_or(0);
let flat: Vec<f32> = candidates let flat: Vec<f32> = candidates
+516
View File
@@ -0,0 +1,516 @@
//! TRACES: FR-EXP-10 | FR-EXP-6 | FR-CAT-7
//! Albums: named export folders, and which photographs went into each.
//!
//! An album is where finished pictures go — a folder of JPEGs somebody else
//! looks at — as opposed to a collection, which is a set of originals the
//! photographer works on. The folder holds only the exported files. What the
//! catalog adds is the link back: each export is recorded against the image
//! it was rendered from, so opening an album in the library shows the RAWs
//! behind its JPEGs, and re-exporting after an edit is one selection away.
//!
//! # Where the folder is, and why that is two tables
//!
//! An album's folder is either on the library's server or on this device.
//!
//! A **server folder** is one path on the account, the same from every device
//! signed in to it, so it lives on the album row and syncs with it.
//!
//! A **local folder** — a filesystem path on a desktop, a Storage Access
//! Framework tree on Android — means nothing on any other device. It lives in
//! `album_folders`, which the merge never reads and the upload snapshot drops
//! ([`crate::sync::snapshot_for_upload`]). An album made on the desktop with a
//! local folder therefore reaches the tablet as an album with no folder there
//! yet, which is true, and which the tablet can fix by choosing one.
//!
//! # Created on first use, not by a migration
//!
//! A new schema version makes every older build refuse this catalog's
//! snapshot at sync (`crate::sync::remote_is_mergeable`), so the tablet would
//! stop merging collections, keywords and people until it was updated — for
//! a feature it does not have. The tables are created by [`ensure_tables`]
//! instead, the way `dedup_probes` is; an older build that meets them ignores
//! them, and its merge keeps working.
//!
//! # Sync
//!
//! Albums merge by uuid and revision with tombstones, and their exports as a
//! set union keyed on the image's server file id — the rules
//! [`crate::merge`] applies to collections, for the same reasons.
use rusqlite::{Connection, OptionalExtension};
use dr_types::ImageId;
use crate::error::CatalogError;
/// Identifies an album within one catalog. Local, like every integer id here;
/// the uuid is what crosses devices.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct AlbumId(pub u64);
/// Where an album's files go.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Place {
/// A folder on the library's server, relative to the account root, with no
/// leading slash. The same on every device.
Server(String),
/// A folder on this device: a filesystem path, or on Android a SAF tree
/// URI. Never synced.
Local(String),
}
/// One album, as the sidebar and the export sheet show it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Album {
pub id: AlbumId,
pub uuid: String,
pub name: String,
/// Where exports go from this device, or `None` for an album whose folder
/// is local to another device and has not been chosen here.
pub place: Option<Place>,
/// Distinct photographs exported into it — what the grid shows when the
/// album is opened.
pub sources: usize,
}
/// Create the album tables if this catalog does not have them yet.
///
/// Cheap when they exist: `IF NOT EXISTS` is answered from the schema, and
/// every function below calls this first so no caller has to remember to.
pub fn ensure_tables(conn: &Connection) -> Result<(), CatalogError> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS albums (
id INTEGER PRIMARY KEY,
-- The merge identity; the integer id is local.
uuid TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
-- A folder on the server, relative to the account root. NULL for
-- an album whose folder is local to some device.
server_path TEXT,
created INTEGER NOT NULL,
revision INTEGER NOT NULL DEFAULT 1,
modified INTEGER NOT NULL,
deleted INTEGER NOT NULL DEFAULT 0
);
-- One row per file written into an album. Keyed on the file, not the
-- image: a photograph exported twice — two crops, or once before an
-- edit and once after — is two files in the folder and two rows here.
CREATE TABLE IF NOT EXISTS album_exports (
album_id INTEGER NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
file_name TEXT NOT NULL,
image_id INTEGER NOT NULL REFERENCES images(id) ON DELETE CASCADE,
exported_at INTEGER NOT NULL,
PRIMARY KEY (album_id, file_name)
);
CREATE INDEX IF NOT EXISTS album_exports_image ON album_exports(image_id);
-- This device's folder for an album. Never merged, never uploaded.
CREATE TABLE IF NOT EXISTS album_folders (
album_id INTEGER PRIMARY KEY REFERENCES albums(id) ON DELETE CASCADE,
folder TEXT NOT NULL
);",
)?;
Ok(())
}
/// Make an album.
///
/// The name is trimmed and must not be empty; two albums may share one, as
/// two collections may, because the uuid is the identity and refusing a
/// duplicate name here would refuse it on one device and not another.
pub fn create(conn: &Connection, name: &str, place: &Place) -> Result<AlbumId, CatalogError> {
ensure_tables(conn)?;
let name = name.trim();
if name.is_empty() {
return Err(CatalogError::EmptyName);
}
let now = now_secs();
let tx = conn.unchecked_transaction()?;
tx.execute(
"INSERT INTO albums(uuid, name, server_path, created, revision, modified)
VALUES (?1, ?2, ?3, ?4, 1, ?4)",
rusqlite::params![
crate::collections::new_uuid(),
name,
server_path(place),
now
],
)?;
let id = AlbumId(tx.last_insert_rowid() as u64);
if let Place::Local(folder) = place {
tx.execute(
"INSERT INTO album_folders(album_id, folder) VALUES (?1, ?2)",
rusqlite::params![id.0 as i64, folder],
)?;
}
tx.commit()?;
Ok(id)
}
/// Rename an album. The folder keeps its name: the album is what the
/// photographer calls it, the folder is what is already out there.
pub fn rename(conn: &Connection, id: AlbumId, name: &str) -> Result<(), CatalogError> {
ensure_tables(conn)?;
let name = name.trim();
if name.is_empty() {
return Err(CatalogError::EmptyName);
}
let n = conn.execute(
"UPDATE albums SET name = ?2, revision = revision + 1, modified = ?3
WHERE id = ?1 AND deleted = 0",
rusqlite::params![id.0 as i64, name, now_secs()],
)?;
if n == 0 {
return Err(CatalogError::NoSuchAlbum(id.0));
}
Ok(())
}
/// Point an album at a different folder, from this device.
///
/// A server folder replaces the synced path, and bumps the revision so the
/// move reaches every device. A local folder is recorded for this device
/// only; it also clears a server path, because an album goes to one place and
/// the photographer has just said which.
pub fn set_place(conn: &Connection, id: AlbumId, place: &Place) -> Result<(), CatalogError> {
ensure_tables(conn)?;
let tx = conn.unchecked_transaction()?;
let n = tx.execute(
"UPDATE albums SET server_path = ?2, revision = revision + 1, modified = ?3
WHERE id = ?1 AND deleted = 0",
rusqlite::params![id.0 as i64, server_path(place), now_secs()],
)?;
if n == 0 {
return Err(CatalogError::NoSuchAlbum(id.0));
}
match place {
Place::Local(folder) => tx.execute(
"INSERT INTO album_folders(album_id, folder) VALUES (?1, ?2)
ON CONFLICT(album_id) DO UPDATE SET folder = excluded.folder",
rusqlite::params![id.0 as i64, folder],
)?,
Place::Server(_) => tx.execute(
"DELETE FROM album_folders WHERE album_id = ?1",
[id.0 as i64],
)?,
};
tx.commit()?;
Ok(())
}
/// Delete an album, leaving a tombstone. The files in its folder are not
/// touched: they are finished work somebody may already have been sent a
/// link to, and the album was only ever this catalog's note of them.
pub fn delete(conn: &Connection, id: AlbumId) -> Result<(), CatalogError> {
ensure_tables(conn)?;
let tx = conn.unchecked_transaction()?;
let n = tx.execute(
"UPDATE albums SET deleted = 1, revision = revision + 1, modified = ?2
WHERE id = ?1 AND deleted = 0",
rusqlite::params![id.0 as i64, now_secs()],
)?;
if n == 0 {
return Err(CatalogError::NoSuchAlbum(id.0));
}
tx.execute(
"DELETE FROM album_exports WHERE album_id = ?1",
[id.0 as i64],
)?;
tx.execute(
"DELETE FROM album_folders WHERE album_id = ?1",
[id.0 as i64],
)?;
tx.commit()?;
Ok(())
}
/// Every live album, by name, with how many photographs each holds.
///
/// One statement: the counts are aggregated from `album_exports` first and
/// joined to the (few) albums, not counted per row.
pub fn list(conn: &Connection) -> Result<Vec<Album>, CatalogError> {
ensure_tables(conn)?;
let mut stmt = conn.prepare(
"SELECT a.id, a.uuid, a.name, a.server_path, f.folder, coalesce(e.n, 0)
FROM albums a
LEFT JOIN album_folders f ON f.album_id = a.id
LEFT JOIN (SELECT album_id, count(DISTINCT image_id) AS n
FROM album_exports GROUP BY album_id) e
ON e.album_id = a.id
WHERE a.deleted = 0
ORDER BY a.name COLLATE NOCASE, a.id",
)?;
let rows = stmt
.query_map([], album_from_row)?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
}
/// One album, or `None` if it is gone.
pub fn get(conn: &Connection, id: AlbumId) -> Result<Option<Album>, CatalogError> {
ensure_tables(conn)?;
Ok(conn
.query_row(
"SELECT a.id, a.uuid, a.name, a.server_path, f.folder,
(SELECT count(DISTINCT image_id) FROM album_exports WHERE album_id = a.id)
FROM albums a
LEFT JOIN album_folders f ON f.album_id = a.id
WHERE a.id = ?1 AND a.deleted = 0",
[id.0 as i64],
album_from_row,
)
.optional()?)
}
/// The album with this uuid, if this catalog holds it live.
pub fn id_for_uuid(conn: &Connection, uuid: &str) -> Result<Option<AlbumId>, CatalogError> {
ensure_tables(conn)?;
Ok(conn
.query_row(
"SELECT id FROM albums WHERE uuid = ?1 AND deleted = 0",
[uuid],
|r| r.get::<_, i64>(0),
)
.optional()?
.map(|id| AlbumId(id as u64)))
}
/// Record the files one export wrote into an album, and which image each
/// came from. One transaction for the batch, however many files it placed.
///
/// A file name already recorded is re-pointed at the image that wrote it
/// last: an export that overwrote `IMG_0001.jpg` replaced the picture in the
/// folder, and the link must say what is there now.
pub fn record_exports(
conn: &Connection,
id: AlbumId,
files: &[(ImageId, String)],
) -> Result<(), CatalogError> {
ensure_tables(conn)?;
if files.is_empty() {
return Ok(());
}
let tx = conn.unchecked_transaction()?;
let now = now_secs();
{
let mut insert = tx.prepare(
"INSERT INTO album_exports(album_id, file_name, image_id, exported_at)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(album_id, file_name) DO UPDATE SET
image_id = excluded.image_id, exported_at = excluded.exported_at",
)?;
for (image, name) in files {
insert.execute(rusqlite::params![id.0 as i64, name, image.0 as i64, now])?;
}
}
tx.commit()?;
Ok(())
}
/// The photographs behind an album's files, most recently exported first —
/// what the grid shows when the album is opened.
pub fn sources(conn: &Connection, id: AlbumId) -> Result<Vec<ImageId>, CatalogError> {
ensure_tables(conn)?;
let mut stmt = conn.prepare(
"SELECT image_id FROM album_exports
WHERE album_id = ?1
GROUP BY image_id
ORDER BY max(exported_at) DESC, image_id",
)?;
let rows = stmt
.query_map([id.0 as i64], |r| r.get::<_, i64>(0))?
.map(|r| r.map(|i| ImageId(i as u64)))
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
}
/// The names of the files an image left in an album — the "which JPEG is
/// this" half of the link.
pub fn files_of(
conn: &Connection,
id: AlbumId,
image: ImageId,
) -> Result<Vec<String>, CatalogError> {
ensure_tables(conn)?;
let mut stmt = conn.prepare(
"SELECT file_name FROM album_exports
WHERE album_id = ?1 AND image_id = ?2
ORDER BY exported_at DESC, file_name",
)?;
let rows = stmt
.query_map(rusqlite::params![id.0 as i64, image.0 as i64], |r| r.get(0))?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
}
fn album_from_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<Album> {
let server: Option<String> = r.get(3)?;
let local: Option<String> = r.get(4)?;
Ok(Album {
id: AlbumId(r.get::<_, i64>(0)? as u64),
uuid: r.get(1)?,
name: r.get(2)?,
// A server path wins: `set_place` clears the local folder when it
// sets one, so both being present means a merge brought a server
// path in over a local choice — and the newer revision decided that.
place: server.map(Place::Server).or(local.map(Place::Local)),
sources: r.get::<_, i64>(5)? as usize,
})
}
fn server_path(place: &Place) -> Option<&str> {
match place {
Place::Server(p) => Some(p.trim_matches('/')),
Place::Local(_) => None,
}
}
fn now_secs() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
/// A catalog, and the connection to it. The `Catalog` has to outlive the
/// connection it hands out, so tests hold both.
fn catalog() -> crate::Catalog {
let cat = crate::Catalog::in_memory().unwrap();
cat.connection()
.execute(
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
[],
)
.unwrap();
cat
}
fn image(conn: &Connection, path: &str) -> ImageId {
conn.execute(
"INSERT INTO images(root_id, source_ref, added_at) VALUES (1, ?1, 0)",
[path],
)
.unwrap();
ImageId(conn.last_insert_rowid() as u64)
}
#[test]
fn an_album_lists_with_its_place_and_no_photographs() {
let cat = catalog();
let conn = cat.connection();
let web = create(conn, " Web ", &Place::Server("Shared/Web/".into())).unwrap();
let print = create(conn, "Print", &Place::Local("/mnt/print".into())).unwrap();
let all = list(conn).unwrap();
assert_eq!(all.len(), 2);
assert_eq!(all[0].id, print, "sorted by name");
assert_eq!(all[0].place, Some(Place::Local("/mnt/print".into())));
assert_eq!(all[1].id, web);
assert_eq!(all[1].name, "Web", "trimmed");
assert_eq!(all[1].place, Some(Place::Server("Shared/Web".into())));
assert_eq!(all[1].sources, 0);
}
#[test]
fn an_empty_name_is_refused() {
let cat = catalog();
let conn = cat.connection();
assert!(matches!(
create(conn, " ", &Place::Local("/x".into())),
Err(CatalogError::EmptyName)
));
}
#[test]
fn exports_link_files_back_to_their_images() {
let cat = catalog();
let conn = cat.connection();
let album = create(conn, "Web", &Place::Local("/out".into())).unwrap();
let a = image(conn, "a.cr3");
let b = image(conn, "b.cr3");
record_exports(
conn,
album,
&[
(a, "a.jpg".into()),
(a, "a (1).jpg".into()),
(b, "b.jpg".into()),
],
)
.unwrap();
let got = get(conn, album).unwrap().unwrap();
assert_eq!(got.sources, 2, "two photographs, three files");
let mut s = sources(conn, album).unwrap();
s.sort();
assert_eq!(s, vec![a, b]);
assert_eq!(files_of(conn, album, a).unwrap().len(), 2);
}
#[test]
fn an_overwritten_file_points_at_what_wrote_it_last() {
let cat = catalog();
let conn = cat.connection();
let album = create(conn, "Web", &Place::Local("/out".into())).unwrap();
let a = image(conn, "a.cr3");
let b = image(conn, "b.cr3");
record_exports(conn, album, &[(a, "x.jpg".into())]).unwrap();
record_exports(conn, album, &[(b, "x.jpg".into())]).unwrap();
assert_eq!(sources(conn, album).unwrap(), vec![b]);
}
#[test]
fn moving_to_the_server_forgets_the_local_folder() {
let cat = catalog();
let conn = cat.connection();
let album = create(conn, "Web", &Place::Local("/out".into())).unwrap();
set_place(conn, album, &Place::Server("Web".into())).unwrap();
assert_eq!(
get(conn, album).unwrap().unwrap().place,
Some(Place::Server("Web".into()))
);
set_place(conn, album, &Place::Local("/again".into())).unwrap();
assert_eq!(
get(conn, album).unwrap().unwrap().place,
Some(Place::Local("/again".into()))
);
}
#[test]
fn a_deleted_album_is_gone_and_its_uuid_no_longer_resolves() {
let cat = catalog();
let conn = cat.connection();
let album = create(conn, "Web", &Place::Local("/out".into())).unwrap();
let uuid = get(conn, album).unwrap().unwrap().uuid;
let a = image(conn, "a.cr3");
record_exports(conn, album, &[(a, "a.jpg".into())]).unwrap();
delete(conn, album).unwrap();
assert!(list(conn).unwrap().is_empty());
assert_eq!(id_for_uuid(conn, &uuid).unwrap(), None);
assert!(matches!(
rename(conn, album, "Again"),
Err(CatalogError::NoSuchAlbum(_))
));
}
#[test]
fn a_rename_bumps_the_revision_the_merge_compares() {
let cat = catalog();
let conn = cat.connection();
let album = create(conn, "Web", &Place::Local("/out".into())).unwrap();
rename(conn, album, "Website").unwrap();
let rev: i64 = conn
.query_row(
"SELECT revision FROM albums WHERE id = ?1",
[album.0 as i64],
|r| r.get(0),
)
.unwrap();
assert_eq!(rev, 2);
}
}
+417
View File
@@ -0,0 +1,417 @@
//! TRACES: NFR-P9
//! Which catalog files this process has already backfilled, and as of what.
//!
//! # Why this exists
//!
//! [`crate::schema::backfill`] used to run inside every [`crate::Catalog::open`],
//! and every worker thread opens its own connection. Landing on a photograph
//! in develop opened the catalog five times — the fetch of the original and a
//! cache check per prefetched neighbour — and each open paid the whole
//! backfill: an anti-join of every image against its versions, a pass over
//! every default version's uuid, the unpaired JPEGs and the keyword
//! vocabulary. On the reference library that was ~12 ms an open and ~60 ms of
//! CPU a landing, spent confirming that nothing had changed since the open
//! before.
//!
//! # What makes skipping it safe
//!
//! Everything the backfill repairs is a row some write *added*: an image
//! inserted by a scan or an import has no default version and may be the RAW
//! beside an unpaired JPEG; a version merged or restored from an older build
//! may carry a minted uuid; a keyword assignment merged from a remote may name
//! a word with no term. So the question "is any work owed?" is answered by
//! whether those tables have gained rows since the last backfill, and that is
//! a read of each table's last row — the last page of its b-tree — rather than
//! a scan.
//!
//! # Why the last row, and not only its id
//!
//! None of these tables is `AUTOINCREMENT`, so SQLite hands out the largest
//! rowid plus one, and an id freed by deleting the newest row is handed out
//! again. That is an ordinary sequence, not a contrived one: emptying the
//! trash of the newest photograph and then scanning a new one, or a local
//! folder's walk removing a renamed file's row and inserting the new name in
//! the same pass. `max(id)` does not move, and neither does `count(*)`. And
//! the row that took the id is exactly one that needs the backfill, because
//! neither scan creates default versions — `persist` and the walk insert the
//! image and leave the version, the pairing and the keyword terms to the next
//! open. Skipped, it would go without them until the app restarted: a rating
//! or a keyword with nowhere to land, a JPEG beside its RAW shown twice.
//!
//! So the stamp carries the last row's content as well as its id: the newest
//! image's path, when it was added, and **whether it has a version**; the
//! newest version's image; the newest assignment's word and version. Whether
//! the newest image has a version is the part that cannot be fooled: once the
//! backfill has run, every image has one, and a row that has just taken a
//! freed id has none, so the two stamps differ whatever the path and the time
//! say. The others make the newest version or assignment a different row
//! whenever a different one took its id; one that is the same content at the
//! same id is the same row as far as the backfill is concerned.
//!
//! The [`Stamp`] is those, the schema version, and the file's identity.
//! An open whose stamp matches the one recorded at the last backfill of the
//! same path skips it; anything else runs it. That covers the cases that must
//! run it:
//!
//! - **The first open in a process.** Nothing is recorded yet.
//! - **A migration.** `user_version` is in the stamp, and [`crate::Catalog::open`]
//! also runs the backfill unconditionally whenever `migrate` moved the
//! schema, because that is what the backfill was written for.
//! - **A pulled catalog.** The merge inserts assignments, which moves the
//! stamp; and [`crate::sync::merge_remote`] [`forget`]s the path as well, so
//! the next open backfills even when every incoming row collided.
//! - **A file replaced underneath the path** — a restore from backup, a
//! rebuild, a catalog copied in. On unix the device and inode are in the
//! stamp, and a replacement is a new inode; [`crate::recovery::set_aside`],
//! the first step of both a restore and a rebuild, forgets the path too.
//! - **Another process writing.** The stamp is read from the file, not from
//! anything this process did, so a scan in a second instance moves it just
//! the same.
//!
//! # Why the stamp is taken before the backfill
//!
//! The backfill adds versions and terms itself, so a stamp read afterwards
//! would describe its own writes. Read afterwards it could also describe an
//! image another connection inserted between the backfill's read and the
//! stamp's — and record that image as covered when it was not. Read before,
//! the worst case is the reverse: the backfill's own inserts move the stamp,
//! and the next open runs one more backfill that finds nothing. That costs one
//! redundant pass after a backfill that did real work, and never misses a row.
//!
//! # What it does not see
//!
//! An `UPDATE` that creates work without adding a row. None of this build's
//! writers does: a scan's move of a file is a new `source_ref` and so a new
//! image, and uuids are only rewritten by the backfill itself. Should one
//! appear, the cost is that its repair waits for the next insert or the next
//! start of the app — which is exactly where the backfill ran before it ran on
//! every open.
//!
//! Kept in memory rather than in the catalog on purpose: a row in the file
//! would travel in the sync snapshot and would need a table an older build
//! does not have, and a flag that another device's catalog carried in would
//! say nothing about this one.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use rusqlite::Connection;
use crate::error::CatalogError;
/// What a catalog looked like, as far as the backfill cares.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Stamp {
/// Device and inode, so a file swapped in under the same name is a new
/// catalog. `None` where the platform has no such thing.
file: Option<(u64, u64)>,
user_version: i64,
/// The newest image: id, path, when added, and whether it has a version.
last_image: Option<String>,
/// The newest version: id and the image it belongs to.
last_version: Option<String>,
/// The newest keyword assignment: rowid, version and word.
last_keyword: Option<String>,
}
/// The stamp recorded at the last backfill, per catalog file.
fn done() -> &'static Mutex<HashMap<PathBuf, Stamp>> {
static DONE: OnceLock<Mutex<HashMap<PathBuf, Stamp>>> = OnceLock::new();
DONE.get_or_init(Default::default)
}
/// One name per file, whichever spelling of its path the caller used.
fn key(path: &Path) -> PathBuf {
std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
/// Read the stamp of the catalog behind `conn`, which was opened from `path`.
///
/// One statement: the last row of each of three tables, each found by
/// descending its rowid b-tree to the last page, plus one probe of
/// `versions_image` for the newest image — and a `stat` of the file.
pub(crate) fn stamp(conn: &Connection, path: &Path) -> Result<Stamp, CatalogError> {
let (user_version, last_image, last_version, last_keyword) = conn.query_row(
"SELECT (SELECT user_version FROM pragma_user_version),
(SELECT printf('%d|%d|%d|%s', i.id, i.added_at,
EXISTS (SELECT 1 FROM versions v WHERE v.image_id = i.id),
i.source_ref)
FROM images i ORDER BY i.id DESC LIMIT 1),
(SELECT printf('%d|%d', id, image_id)
FROM versions ORDER BY id DESC LIMIT 1),
(SELECT printf('%d|%d|%s', rowid, version_id, keyword)
FROM keywords ORDER BY rowid DESC LIMIT 1)",
[],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
)?;
Ok(Stamp {
file: file_identity(path),
user_version,
last_image,
last_version,
last_keyword,
})
}
#[cfg(unix)]
fn file_identity(path: &Path) -> Option<(u64, u64)> {
use std::os::unix::fs::MetadataExt;
std::fs::metadata(path).ok().map(|m| (m.dev(), m.ino()))
}
#[cfg(not(unix))]
fn file_identity(_path: &Path) -> Option<(u64, u64)> {
None
}
/// Whether the catalog at `path` was last backfilled at exactly `stamp`.
pub(crate) fn is_current(path: &Path, stamp: &Stamp) -> bool {
done()
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(&key(path))
== Some(stamp)
}
/// Record that the catalog at `path` has been backfilled as of `stamp`.
pub(crate) fn record(path: &Path, stamp: Stamp) {
done()
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(key(path), stamp);
}
/// Make the next open of `path` backfill, whatever its stamp says.
///
/// For the writers that know they have changed the catalog wholesale — a
/// merge of a pulled catalog, a restore from backup — so their correctness
/// does not rest on the stamp happening to move.
pub(crate) fn forget(path: &Path) {
done()
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(&key(path));
}
#[cfg(test)]
mod tests {
use crate::rating::derived_version_uuid;
use crate::Catalog;
use std::path::PathBuf;
/// A catalog file of its own, holding one image the server has named,
/// backfilled and settled.
///
/// Opened three times on the way: to create it; after the image went in,
/// which gives the image its default version; and once more, because that
/// version moved the stamp and the next open runs the one redundant pass
/// the module header describes. After that the stamp stands still.
fn catalog(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"dr-backfilled-{tag}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("catalog.sqlite");
{
let cat = Catalog::open(&path).unwrap();
let c = cat.connection();
c.execute_batch(
"INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'Photos');
INSERT INTO images(id, root_id, source_ref, added_at)
VALUES (1, 1, 'Photos/a.CR3', 0);
INSERT INTO remote(image_id, file_id) VALUES (1, 77);",
)
.unwrap();
}
assert_eq!(uuid(&path, 1), Some(derived_version_uuid(77)));
assert_eq!(uuid(&path, 1), Some(derived_version_uuid(77)));
path
}
/// The default version's uuid for `image`, read through an ordinary open.
fn uuid(path: &std::path::Path, image: i64) -> Option<String> {
let cat = Catalog::open(path).unwrap();
cat.connection()
.query_row(
"SELECT uuid FROM versions WHERE image_id = ?1 AND is_default = 1",
[image],
|r| r.get(0),
)
.ok()
}
/// Put the one row back into the state the backfill repairs, with an
/// `UPDATE` — which moves none of the stamp's maxima, so only the stamp's
/// other parts or an explicit `forget` can bring the backfill back.
fn unalign(path: &std::path::Path) {
rusqlite::Connection::open(path)
.unwrap()
.execute("UPDATE versions SET uuid = 'minted' WHERE image_id = 1", [])
.unwrap();
}
#[test]
fn an_unchanged_catalog_is_not_backfilled_again() {
let path = catalog("unchanged");
unalign(&path);
assert_eq!(
uuid(&path, 1).as_deref(),
Some("minted"),
"nothing was added since the last backfill, so the open skipped it"
);
}
#[test]
fn an_image_a_scan_added_is_backfilled_on_the_next_open() {
let path = catalog("scanned");
rusqlite::Connection::open(&path)
.unwrap()
.execute(
"INSERT INTO images(id, root_id, source_ref, added_at)
VALUES (2, 1, 'Photos/b.CR3', 0)",
[],
)
.unwrap();
assert!(uuid(&path, 2).is_some(), "the new image got its version");
}
/// The id of a deleted newest row is handed out again, so `max(id)` is
/// the same before and after — the sequence emptying the trash and then
/// scanning makes. The image that took the id still needs its version.
///
/// A virtual copy on the older image holds the newest version id, so
/// the deletion does not move `max(versions.id)` either: nothing the old
/// stamp read changes, which is the case that went unrepaired.
#[test]
fn an_image_that_reuses_a_deleted_id_is_backfilled_on_the_next_open() {
let path = catalog("reused");
rusqlite::Connection::open(&path)
.unwrap()
.execute(
"INSERT INTO images(id, root_id, source_ref, added_at)
VALUES (2, 1, 'Photos/b.CR3', 0)",
[],
)
.unwrap();
assert!(uuid(&path, 2).is_some());
rusqlite::Connection::open(&path)
.unwrap()
.execute(
"INSERT INTO versions(image_id, uuid, name, is_default)
VALUES (1, 'copy', 'Crop', 0)",
[],
)
.unwrap();
// Settle: one open backfills after the new version, one more runs
// the redundant pass and records the stamp that stands.
assert!(uuid(&path, 2).is_some());
assert!(uuid(&path, 2).is_some());
let c = rusqlite::Connection::open(&path).unwrap();
let before: (i64, i64) = c
.query_row(
"SELECT (SELECT max(id) FROM images), (SELECT max(id) FROM versions)",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
c.execute("DELETE FROM images WHERE id = 2", []).unwrap();
c.execute(
"INSERT INTO images(root_id, source_ref, added_at)
VALUES (1, 'Photos/c.CR3', 0)",
[],
)
.unwrap();
let after: (i64, i64) = c
.query_row(
"SELECT (SELECT max(id) FROM images), (SELECT max(id) FROM versions)",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(before, after, "SQLite handed the freed id out again");
drop(c);
assert!(uuid(&path, 2).is_some(), "the new image got its version");
}
/// The same, with the same file coming back at the same id in the same
/// second: path and time match, and only the missing version tells.
#[test]
fn the_same_file_back_at_the_same_id_is_backfilled_on_the_next_open() {
let path = catalog("returned");
let c = rusqlite::Connection::open(&path).unwrap();
// As above: a newer version on another image keeps the deletion
// from moving `max(versions.id)`.
c.execute_batch(
"INSERT INTO images(id, root_id, source_ref, added_at)
VALUES (0, 1, 'Photos/0.CR3', 0);
INSERT INTO versions(image_id, uuid, name, is_default)
VALUES (0, 'copy', 'Crop', 0);",
)
.unwrap();
drop(c);
assert!(uuid(&path, 1).is_some());
assert!(uuid(&path, 1).is_some());
let c = rusqlite::Connection::open(&path).unwrap();
c.execute_batch(
"DELETE FROM images WHERE id = 1;
INSERT INTO images(id, root_id, source_ref, added_at)
VALUES (1, 1, 'Photos/a.CR3', 0);
INSERT INTO remote(image_id, file_id) VALUES (1, 77);",
)
.unwrap();
drop(c);
assert_eq!(uuid(&path, 1), Some(derived_version_uuid(77)));
}
#[test]
fn the_first_open_after_a_migration_backfills() {
let path = catalog("migrated");
unalign(&path);
rusqlite::Connection::open(&path)
.unwrap()
.pragma_update(None, "user_version", crate::schema::SCHEMA_VERSION - 1)
.unwrap();
assert_eq!(uuid(&path, 1), Some(derived_version_uuid(77)));
}
#[test]
fn the_first_open_after_a_pulled_catalog_backfills() {
let path = catalog("pulled");
// The remote is this catalog as it stands, so every row the merge
// offers collides and nothing in the stamp moves: only the merge
// saying so can make the next open backfill.
let remote = path.with_file_name("remote.sqlite");
rusqlite::Connection::open(&path)
.unwrap()
.execute("VACUUM INTO ?1", [remote.to_string_lossy().as_ref()])
.unwrap();
unalign(&path);
assert_eq!(uuid(&path, 1).as_deref(), Some("minted"));
Catalog::open(&path)
.unwrap()
.merge_remote_catalog(&remote)
.unwrap();
assert_eq!(uuid(&path, 1), Some(derived_version_uuid(77)));
}
#[cfg(unix)]
#[test]
fn a_catalog_replaced_under_the_same_name_backfills() {
let path = catalog("replaced");
unalign(&path);
assert_eq!(uuid(&path, 1).as_deref(), Some("minted"));
// Every connection is closed, so the WAL is folded in and the main
// file is the whole catalog. A copy renamed over it is the same rows
// in a new file — which is what a restore or a copied-in catalog is.
let copy = path.with_file_name("copy.sqlite");
std::fs::copy(&path, &copy).unwrap();
std::fs::rename(&copy, &path).unwrap();
assert_eq!(uuid(&path, 1), Some(derived_version_uuid(77)));
}
}
+66 -1
View File
@@ -82,7 +82,7 @@
//! Grouping has no natural `subject_id`: it is a property of a *run* of frames, //! Grouping has no natural `subject_id`: it is a property of a *run* of frames,
//! so a per-image job would rebuild the world once per photograph. It is //! so a per-image job would rebuild the world once per photograph. It is
//! therefore a debounced library-level pass, for exactly the reasons //! therefore a debounced library-level pass, for exactly the reasons
//! docs/catalog.md §10.2 gives for face clustering, and [`regroup`] is the whole //! docs/dev/catalog.md §10.2 gives for face clustering, and [`regroup`] is the whole
//! of it — one ordered walk, no per-pair comparison beyond adjacent frames. //! of it — one ordered walk, no per-pair comparison beyond adjacent frames.
//! //!
//! # Grouping is not hiding //! # Grouping is not hiding
@@ -722,6 +722,31 @@ pub fn not_collapsed_away(image: &str) -> String {
) )
} }
/// SQL for the rows [`not_collapsed_away`] drops, as a `FROM ... WHERE`
/// joining each such frame to its image under the alias `image`.
///
/// For counting. A count that applies [`not_collapsed_away`] to every row
/// pays two primary-key probes per image to find the handful a collapsed
/// burst hides; counting everything and subtracting what this lists walks
/// only `burst_members`, which is empty on a library without bursts. The
/// caller appends its own conditions on `image` with `AND`, the same ones
/// it counted the whole with, so the subtraction takes away only rows the
/// whole included. `image_id` is `burst_members`' key, so no image is
/// listed twice.
///
/// The two must describe the same rows: change one, change both, and
/// `the_collapsed_frames_are_what_the_predicate_drops` will say if they drift.
///
/// Never interpolate anything user-supplied as `image`.
pub fn collapsed_away_frames(image: &str) -> String {
format!(
"burst_members bm CROSS JOIN images {image} ON {image}.id = bm.image_id
WHERE bm.representative = 0
AND NOT EXISTS (SELECT 1 FROM burst_expanded be
WHERE be.burst_id = bm.burst_id)"
)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -1235,6 +1260,46 @@ mod tests {
assert_eq!(visible(cat.connection()), vec![1, 2, 3, 4]); assert_eq!(visible(cat.connection()), vec![1, 2, 3, 4]);
} }
#[test]
fn the_collapsed_frames_are_what_the_predicate_drops() {
// `collapsed_away_frames` is `not_collapsed_away` turned inside out
// for counting; the two must name the same rows, open or closed.
let cat = seeded(&[
(1, 1000, Some(0xFF00)),
(2, 1001, Some(0xFF00)),
(3, 1002, Some(0xFF00)),
(4, 9000, Some(0xAA00)),
(5, 9001, Some(0xAA00)),
(6, 20000, Some(0xFF00)),
]);
regroup(cat.connection(), Rules::default()).unwrap();
let ids = |sql: String| -> Vec<i64> {
let c = cat.connection();
let mut stmt = c.prepare(&sql).unwrap();
let rows = stmt.query_map([], |r| r.get::<_, i64>(0)).unwrap();
rows.collect::<Result<Vec<_>, _>>().unwrap()
};
let dropped = || {
ids(format!(
"SELECT id FROM images i WHERE NOT {} ORDER BY id",
not_collapsed_away("i")
))
};
let listed = || {
ids(format!(
"SELECT i.id FROM {} ORDER BY i.id",
collapsed_away_frames("i")
))
};
assert_eq!(listed(), dropped());
set_expanded(cat.connection(), ImageId(1), false).unwrap();
assert_eq!(dropped(), vec![2, 3]);
assert_eq!(listed(), dropped());
set_expanded(cat.connection(), ImageId(4), false).unwrap();
assert_eq!(listed(), dropped());
}
#[test] #[test]
fn a_library_with_no_bursts_hides_nothing() { fn a_library_with_no_bursts_hides_nothing() {
// The predicate is in every grid query, so its cost and its effect on a // The predicate is in every grid query, so its cost and its effect on a
File diff suppressed because it is too large Load Diff
+17
View File
@@ -65,6 +65,16 @@ pub enum CatalogError {
#[error("no such collection: {0}")] #[error("no such collection: {0}")]
NoSuchCollection(u64), NoSuchCollection(u64),
/// An album the caller named is gone — deleted here, or by a merge while
/// its id sat in a UI model.
#[error("no such album: {0}")]
NoSuchAlbum(u64),
/// A name that is empty once trimmed. Refused rather than stored, because
/// a row with no name is one the sidebar cannot draw and nobody can pick.
#[error("a name is required")]
EmptyName,
/// A keyword the caller named is gone — deleted, or fused into another by a /// A keyword the caller named is gone — deleted, or fused into another by a
/// merge while its id sat in a UI model. /// merge while its id sat in a UI model.
/// ///
@@ -96,6 +106,13 @@ pub enum CatalogError {
#[error("io: {0}")] #[error("io: {0}")]
Io(String), Io(String),
/// TRACES: FR-CAT-11a
/// A duplicate group planned earlier no longer holds: a copy was trashed,
/// rescanned or changed since the review was drawn. The group is left
/// untouched rather than consolidated on a stale plan.
#[error("no longer a duplicate: {0}")]
StaleDuplicate(String),
} }
impl From<rusqlite::Error> for CatalogError { impl From<rusqlite::Error> for CatalogError {
+284 -38
View File
@@ -2,7 +2,7 @@
//! Face data as sealed shards, so a second device does not re-index the library. //! Face data as sealed shards, so a second device does not re-index the library.
//! //!
//! Indexing a 23,500-image library is on the order of two hours of CPU //! Indexing a 23,500-image library is on the order of two hours of CPU
//! (docs/faces.md §12.2). It is also **byte-identical on every device**: the //! (docs/dev/faces.md §12.2). It is also **byte-identical on every device**: the
//! same model over the same proxy produces the same embedding. Paying for it //! same model over the same proxy produces the same embedding. Paying for it
//! once per account rather than once per device is the whole point of this //! once per account rather than once per device is the whole point of this
//! module, and it is the same bargain the thumbnail store already makes. //! module, and it is the same bargain the thumbnail store already makes.
@@ -30,6 +30,7 @@
//! identity every client agrees on (FR-NC-5), and it survives a server-side //! identity every client agrees on (FR-NC-5), and it survives a server-side
//! move, so a shard written before a reorganisation still applies after it. //! move, so a shard written before a reorganisation still applies after it.
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use rusqlite::{Connection, OptionalExtension}; use rusqlite::{Connection, OptionalExtension};
@@ -155,6 +156,45 @@ impl FaceShardStore {
.flatten() .flatten()
} }
/// [`indexed_at`](Self::indexed_at) for every entry at once.
///
/// What a pass over the whole library asks instead of one lookup per image:
/// the export compares every marker in the catalog with this, and a lookup
/// each was 19,000 statements prepared and run on every sync pass that had
/// nothing to send.
fn all_indexed_at(&self) -> Result<HashMap<(u64, String), Option<i64>>, CatalogError> {
let mut q = self
.index
.prepare("SELECT file_id, model_id, indexed_at FROM entries")?;
let rows = q.query_map([], |r| {
Ok((
(r.get::<_, i64>(0)? as u64, r.get::<_, String>(1)?),
r.get::<_, Option<i64>>(2)?,
))
})?;
Ok(rows.collect::<Result<_, _>>()?)
}
/// [`held_model`](Self::held_model) for every file at once: one statement,
/// ordered exactly as that one is, keeping the first row per file.
fn all_held_models(&self, model_id: &str) -> Result<HashMap<u64, String>, CatalogError> {
let mut q = self.index.prepare(&format!(
"SELECT file_id, model_id FROM entries
WHERE {} = ?1
ORDER BY file_id, indexed_at DESC NULLS LAST, model_id",
crate::faces::embedder_sql("model_id")
))?;
let rows = q.query_map([crate::faces::embedder_of(model_id)], |r| {
Ok((r.get::<_, i64>(0)? as u64, r.get::<_, String>(1)?))
})?;
let mut out = HashMap::new();
for row in rows {
let (file, model) = row?;
out.entry(file).or_insert(model);
}
Ok(out)
}
/// The pipeline this store holds an image under, among those sharing /// The pipeline this store holds an image under, among those sharing
/// `model_id`'s embedder — the most recently indexed where a peer has /// `model_id`'s embedder — the most recently indexed where a peer has
/// sent more than one. /// sent more than one.
@@ -178,6 +218,67 @@ impl FaceShardStore {
.flatten() .flatten()
} }
/// The other pipelines this file is held under that share `model_id`'s
/// embedder — the generations a put of `model_id` may supersede.
fn siblings(&self, file_id: u64, model_id: &str) -> Vec<String> {
let mut stmt = match self.index.prepare(&format!(
"SELECT model_id FROM entries
WHERE file_id = ?1 AND model_id != ?2 AND {} = ?3",
crate::faces::embedder_sql("model_id")
)) {
Ok(s) => s,
Err(_) => return Vec::new(),
};
stmt.query_map(
rusqlite::params![
file_id as i64,
model_id,
crate::faces::embedder_of(model_id)
],
|r| r.get::<_, String>(0),
)
.map(|rows| rows.filter_map(|r| r.ok()).collect())
.unwrap_or_default()
}
/// Whether a pass this file is already held under outranks `model_id`,
/// so a put of `model_id` would add a generation nobody would adopt.
pub fn outranked(&self, file_id: u64, model_id: &str) -> bool {
use dr_types::FaceDetector;
let Some(incoming) = FaceDetector::for_model_id(model_id) else {
return false;
};
self.siblings(file_id, model_id)
.iter()
.filter_map(|m| FaceDetector::for_model_id(m))
.any(|held| held.outranks(incoming))
}
/// Forget the index entries for generations of this file that `model_id`
/// outranks. The bytes stay where they are — a sealed shard is
/// immutable — but the store stops offering them, and a later export or
/// merge writes nothing for them again.
fn supersede(&self, file_id: u64, model_id: &str) -> Result<(), CatalogError> {
use dr_types::FaceDetector;
let Some(incoming) = FaceDetector::for_model_id(model_id) else {
return Ok(());
};
for held in self.siblings(file_id, model_id) {
let weaker = FaceDetector::for_model_id(&held).is_some_and(|h| incoming.outranks(h));
if weaker {
self.index.execute(
"DELETE FROM entries WHERE file_id = ?1 AND model_id = ?2",
rusqlite::params![file_id as i64, held],
)?;
self.index.execute(
"DELETE FROM faces_meta WHERE file_id = ?1 AND model_id = ?2",
rusqlite::params![file_id as i64, held],
)?;
}
}
Ok(())
}
pub fn contains(&self, file_id: u64, model_id: &str) -> bool { pub fn contains(&self, file_id: u64, model_id: &str) -> bool {
self.index self.index
.query_row( .query_row(
@@ -233,6 +334,15 @@ impl FaceShardStore {
faces: &[SharedFace], faces: &[SharedFace],
indexed_at: Option<i64>, indexed_at: Option<i64>,
) -> Result<u32, CatalogError> { ) -> Result<u32, CatalogError> {
// One generation per image per embedder. A store carried every pass
// — 24,123 entries for 19,089 images on the reference library, a
// third of its 293 MB — and only the strongest was ever adopted.
// A weaker pass arriving after a stronger one is not written; a
// stronger one arriving retires the weaker from the index.
if self.outranked(file_id, model_id) {
return Ok(0);
}
self.supersede(file_id, model_id)?;
let incoming = faces let incoming = faces
.iter() .iter()
.map(|f| BYTES_PER_FACE + if f.crop.is_empty() { 0 } else { BYTES_PER_CROP }) .map(|f| BYTES_PER_FACE + if f.crop.is_empty() { 0 } else { BYTES_PER_CROP })
@@ -474,15 +584,28 @@ impl FaceShardStore {
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
)?; )?;
let mut q = // The peer's marker travels with the image: it is what lets
src.prepare("SELECT file_id, model_id, faces_found, source_edge FROM indexed")?; // `import_from_shards` record the adoption under the time the peer
let images: Vec<(i64, String, i64, i64)> = q // indexed it, and so what keeps `export_to_shards` from reading the
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))? // adoption as a re-index and sending the peer's faces back out under
// this device's name. A shard from before the column has none.
let mut q = src.prepare(&format!(
"SELECT file_id, model_id, faces_found, source_edge, {} FROM indexed",
match has_column(&src, "indexed", "indexed_at") {
Ok(true) => "indexed_at",
_ => "NULL",
}
))?;
let images: Vec<(i64, String, i64, i64, Option<i64>)> = q
.query_map([], |r| {
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?))
})?
.collect::<Result<_, _>>()?; .collect::<Result<_, _>>()?;
let mut adopted = 0; let mut adopted = 0;
for (file_id, model_id, _found, edge) in images { for (file_id, model_id, _found, edge, indexed_at) in images {
if self.contains(file_id as u64, &model_id) { if self.contains(file_id as u64, &model_id) || self.outranked(file_id as u64, &model_id)
{
continue; continue;
} }
let mut fq = src.prepare(&format!( let mut fq = src.prepare(&format!(
@@ -501,12 +624,27 @@ impl FaceShardStore {
let faces: Vec<SharedFace> = fq let faces: Vec<SharedFace> = fq
.query_map(rusqlite::params![file_id, &model_id], read_shared_face)? .query_map(rusqlite::params![file_id, &model_id], read_shared_face)?
.collect::<Result<_, _>>()?; .collect::<Result<_, _>>()?;
self.put_image(file_id as u64, &model_id, edge as u32, &faces)?; self.put_image_at(file_id as u64, &model_id, edge as u32, &faces, indexed_at)?;
adopted += 1; adopted += 1;
} }
Ok(adopted) Ok(adopted)
} }
/// Record when the catalog indexed a held image, for an entry that
/// arrived without a marker — a peer's shard from before the column.
pub fn set_indexed_at(
&self,
file_id: u64,
model_id: &str,
at: i64,
) -> Result<(), CatalogError> {
self.index.execute(
"UPDATE entries SET indexed_at = ?3 WHERE file_id = ?1 AND model_id = ?2",
rusqlite::params![file_id as i64, model_id, at],
)?;
Ok(())
}
/// Read back everything held for one image. /// Read back everything held for one image.
pub fn get_image( pub fn get_image(
&self, &self,
@@ -686,6 +824,18 @@ pub fn export_to_shards_reporting(
/// enough that the reporting is lost in the write it accompanies. /// enough that the reporting is lost in the write it accompanies.
const REPORT_EVERY: usize = 25; const REPORT_EVERY: usize = 25;
// What the store holds, read once. A put below rewrites only its own
// file's entries -- its generation, and siblings it supersedes -- so a
// file already written in this pass is asked of the store again and every
// other answer is the one a lookup would have given.
//
// An index that cannot be read answers as each lookup did: nothing held.
let held = store.all_indexed_at().unwrap_or_else(|e| {
log::debug!("reading the shard index: {e}");
HashMap::new()
});
let mut written: HashSet<u64> = HashSet::new();
let total = rows.len(); let total = rows.len();
let mut exported = 0; let mut exported = 0;
for (seen, (file_id, image_id, edge, indexed_at, model_id)) in rows.into_iter().enumerate() { for (seen, (file_id, image_id, edge, indexed_at, model_id)) in rows.into_iter().enumerate() {
@@ -702,10 +852,14 @@ pub fn export_to_shards_reporting(
// //
// The comparison is against when the *catalog* indexed it, so a // The comparison is against when the *catalog* indexed it, so a
// re-index is visible and an unchanged image still costs nothing. // re-index is visible and an unchanged image still costs nothing.
if store let was = if written.contains(&(file_id as u64)) {
.indexed_at(file_id as u64, model_id) store.indexed_at(file_id as u64, model_id)
.is_some_and(|was| was >= indexed_at) } else {
{ held.get(&(file_id as u64, model_id.to_string()))
.copied()
.flatten()
};
if was.is_some_and(|was| was >= indexed_at) {
continue; continue;
} }
let mut fq = conn.prepare( let mut fq = conn.prepare(
@@ -742,6 +896,7 @@ pub fn export_to_shards_reporting(
&faces, &faces,
Some(indexed_at), Some(indexed_at),
)?; )?;
written.insert(file_id as u64);
exported += 1; exported += 1;
} }
progress(total, total); progress(total, total);
@@ -798,9 +953,34 @@ pub fn import_from_shards(
})? })?
.collect::<Result<_, _>>()?; .collect::<Result<_, _>>()?;
/// Images per write transaction. Large enough that fourteen thousand
/// adoptions are a hundred and forty commits rather than fourteen
/// thousand; small enough that a read on the UI thread, queued behind
/// the lock, waits a fraction of a second and not the whole import.
const CHUNK: usize = 100;
// Every file's held pipeline, read once rather than asked per candidate --
// 23,000 prepared lookups on every pass, nearly all of them for images
// this device already holds. Nothing below changes which pipeline the
// store holds a file under (`set_indexed_at` touches only a file already
// decided), and each candidate is a different file, so these are the
// answers the lookups gave.
// An index that cannot be read answers as each lookup did: nothing held.
let held_models = store.all_held_models(model_id).unwrap_or_else(|e| {
log::debug!("reading the shard index: {e}");
HashMap::new()
});
let mut adopted = 0; let mut adopted = 0;
let mut tx = conn.unchecked_transaction()?;
let mut in_chunk = 0;
for (file_id, image_id, local) in candidates { for (file_id, image_id, local) in candidates {
let Some(held) = store.held_model(file_id as u64, model_id) else { if in_chunk == CHUNK {
tx.commit()?;
tx = conn.unchecked_transaction()?;
in_chunk = 0;
}
let Some(held) = held_models.get(&(file_id as u64)).cloned() else {
continue; continue;
}; };
if let Some(local) = local { if let Some(local) = local {
@@ -820,20 +1000,22 @@ pub fn import_from_shards(
let Some((faces, edge)) = store.get_image(file_id as u64, &held)? else { let Some((faces, edge)) = store.get_image(file_id as u64, &held)? else {
continue; continue;
}; };
// A peer that embedded before the quality was kept has done work this // A face the peer embedded before its quality was kept (schema V14)
// device cannot finish: the number exists only at embedding time, and // is adopted with the reading missing, exactly as one without an eye
// adopting the faces would write the run marker that keeps them from // reading is. The measuring passes find their work by the NULL
// ever being measured (schema V14). Left for this device's own pass — // column, not by the run marker (`dr_ui::repairs`, `faces_needing`),
// or for the peer's, whose re-export replaces these. // so adopting costs the reading nothing and this device's own pass
// fills it.
// //
// A missing *eye* reading is not the same case and is adopted. The // This used to refuse such faces, on the reasoning that the marker
// measuring pass finds those by the NULL, not by the marker, so // would stop them ever being measured — true before the quality
// adopting the faces costs the reading nothing (schema V16) — and a // repair existed, and wrong after. What it cost: V14 had dropped the
// peer that has no eye models may be the only one that has done the // markers of every image holding such faces, so the peer never
// detection at all. // re-exported them, and the only copies in the shards were the
if faces.iter().any(|f| f.quality.is_none()) { // unmeasured ones. A tablet holding shards with 4,310 of the
continue; // desktop's images and 3,170 of its confirmations declined every one
} // of them, showed a fraction of each person, and queued the whole
// library for a re-detection of its own instead.
let local: Vec<crate::faces::DetectedFace> = faces let local: Vec<crate::faces::DetectedFace> = faces
.into_iter() .into_iter()
.map(|f| crate::faces::DetectedFace { .map(|f| crate::faces::DetectedFace {
@@ -856,15 +1038,42 @@ pub fn import_from_shards(
}) })
.collect(); .collect();
crate::faces::record_detections( crate::faces::record_detections_within(
conn, &tx,
dr_types::ImageId(image_id as u64), dr_types::ImageId(image_id as u64),
&held, &held,
edge, edge,
&local, &local,
)?; )?;
// The peer's marker, not this moment. `record_detections` stamps the
// run as now, and `export_to_shards` reads a marker newer than the
// shard's as a re-index — so every adopted image went straight back
// out as this device's own work: 14,100 adopted, 15,457 "newly
// indexed" on the next pass, and twenty-two shards of a peer's faces
// uploaded again under a second name. Where the peer's shard carried
// no marker, the store takes the catalog's, so the two agree either
// way and the export sees nothing to send.
match store.indexed_at(file_id as u64, &held) {
Some(theirs) => {
tx.execute(
"UPDATE face_index SET indexed_at = ?3
WHERE image_id = ?1 AND model_id = ?2",
rusqlite::params![image_id, held, theirs],
)?;
}
None => {
let ours: i64 = tx.query_row(
"SELECT indexed_at FROM face_index WHERE image_id = ?1 AND model_id = ?2",
rusqlite::params![image_id, held],
|r| r.get(0),
)?;
store.set_indexed_at(file_id as u64, &held, ours)?;
}
}
adopted += 1; adopted += 1;
in_chunk += 1;
} }
tx.commit()?;
Ok(adopted) Ok(adopted)
} }
@@ -1100,6 +1309,32 @@ mod tests {
assert!(!s.contains(1, "lvface")); assert!(!s.contains(1, "lvface"));
} }
/// One generation per image per embedder: a stronger detector's pass
/// retires a weaker one from the index, and a weaker pass arriving after
/// a stronger is not written at all.
#[test]
fn a_stronger_pass_retires_a_weaker_one_and_a_weaker_is_not_added() {
let dir = tempdir();
let mut s = FaceShardStore::open(&dir).unwrap();
s.put_image(1, "w600k_mbf", 1024, &[face(1, 1)]).unwrap();
s.put_image(1, "scrfd_10g+w600k_mbf", 1024, &[face(1, 2)])
.unwrap();
assert!(s.contains(1, "scrfd_10g+w600k_mbf"));
assert!(!s.contains(1, "w600k_mbf"), "the fast pass was not retired");
assert_eq!(s.len(), 1, "faces_meta still counts the retired pass");
s.put_image(1, "scrfd_2.5g+w600k_mbf", 1024, &[face(1, 3)])
.unwrap();
assert!(
!s.contains(1, "scrfd_2.5g+w600k_mbf"),
"a weaker pass was added"
);
assert_eq!(
s.held_model(1, "w600k_mbf").as_deref(),
Some("scrfd_10g+w600k_mbf")
);
}
#[test] #[test]
fn re_storing_an_image_replaces_rather_than_doubling_it() { fn re_storing_an_image_replaces_rather_than_doubling_it() {
let dir = tempdir(); let dir = tempdir();
@@ -1320,6 +1555,11 @@ mod catalog_round_trip {
assert!((got[0].landmarks[2].0 - 0.15).abs() < 1e-5); assert!((got[0].landmarks[2].0 - 0.15).abs() < 1e-5);
let emb = faces::embeddings(&b, "w600k_mbf").unwrap(); let emb = faces::embeddings(&b, "w600k_mbf").unwrap();
assert!(emb.iter().any(|e| e.embedding[0] == 1)); assert!(emb.iter().any(|e| e.embedding[0] == 1));
// And what B adopted is not B's work: its next export sends nothing.
// Adopting used to stamp the run as now, so every adopted image went
// back out under B's name as a re-index.
assert_eq!(export_to_shards(&b, &mut store_b, "w600k_mbf").unwrap(), 0);
} }
/// The desktop switched to a stronger detector part-way through the /// The desktop switched to a stronger detector part-way through the
@@ -1365,11 +1605,12 @@ mod catalog_round_trip {
); );
} }
/// A face a peer embedded without measuring it is work this device /// A face a peer embedded without measuring it is adopted all the same,
/// cannot finish, and adopting it would write the marker that stops it /// and left on this device's quality pass by its missing reading. Refusing
/// ever being measured. The image stays outstanding instead. /// it was what stranded every confirmation the desktop had made on faces
/// from before V14: the tablet held the shards and would not use them.
#[test] #[test]
fn a_peers_unmeasured_faces_are_left_for_this_device_to_index() { fn a_peers_unmeasured_faces_are_adopted_and_left_for_the_quality_pass() {
let b = device(&[(90, 5001), (91, 5002)]); let b = device(&[(90, 5001), (91, 5002)]);
let mut store = FaceShardStore::open(&tempdir("unmeasured")).unwrap(); let mut store = FaceShardStore::open(&tempdir("unmeasured")).unwrap();
let shared = |file_id: u64, quality: Option<f32>| SharedFace { let shared = |file_id: u64, quality: Option<f32>| SharedFace {
@@ -1395,13 +1636,18 @@ mod catalog_round_trip {
.put_image(5002, "w600k_mbf", 2560, &[shared(5002, Some(19.0))]) .put_image(5002, "w600k_mbf", 2560, &[shared(5002, Some(19.0))])
.unwrap(); .unwrap();
assert_eq!(import_from_shards(&b, &store, "w600k_mbf").unwrap(), 1); assert_eq!(import_from_shards(&b, &store, "w600k_mbf").unwrap(), 2);
let cov = faces::coverage(&b, "w600k_mbf").unwrap(); let cov = faces::coverage(&b, "w600k_mbf").unwrap();
assert_eq!(cov.indexed, 1); assert_eq!(cov.indexed, 2);
assert_eq!(cov.outstanding(), 1, "the unmeasured image was adopted"); assert_eq!(cov.outstanding(), 0, "the unmeasured image was refused");
assert!(faces::for_image(&b, dr_types::ImageId(90)) let got = faces::for_image(&b, dr_types::ImageId(90)).unwrap();
.unwrap() assert_eq!(got.len(), 1);
.is_empty()); assert_eq!(got[0].quality, None, "a reading was invented");
// Still owed to the measuring pass, which lists by the column.
assert_eq!(
faces::count_needing(&b, "w600k_mbf", "f.quality IS NULL").unwrap(),
1
);
} }
#[test] #[test]
+416 -19
View File
@@ -1,7 +1,7 @@
//! TRACES: FR-CULL-8 | FR-CULL-9 | FR-CULL-10 | FR-CULL-11 | FR-CULL-12 | NFR-SEC-5 //! TRACES: FR-CULL-8 | FR-CULL-9 | FR-CULL-10 | FR-CULL-11 | FR-CULL-12 | NFR-SEC-5
//! People and faces: what was detected, who it is, and who said so. //! People and faces: what was detected, who it is, and who said so.
//! //!
//! The storage half of docs/faces.md. `dr-face` finds faces and turns them into //! The storage half of docs/dev/faces.md. `dr-face` finds faces and turns them into
//! 512 numbers; this module is where those numbers acquire an identity, and //! 512 numbers; this module is where those numbers acquire an identity, and
//! where the user's corrections outrank the model's guesses. //! where the user's corrections outrank the model's guesses.
//! //!
@@ -110,7 +110,7 @@ pub struct DetectedFace {
/// Raw rather than unit length, so the length ([`Self::quality`]) is in /// Raw rather than unit length, so the length ([`Self::quality`]) is in
/// the blob and not only beside it. Readers re-normalise on load. /// the blob and not only beside it. Readers re-normalise on load.
pub embedding: Vec<u8>, pub embedding: Vec<u8>,
/// Source pixels across the aligned crop (docs/faces.md §7). /// Source pixels across the aligned crop (docs/dev/faces.md §7).
pub crop_px: f32, pub crop_px: f32,
/// Length of the raw embedding before normalisation — the model's own /// Length of the raw embedding before normalisation — the model's own
/// reading of how recognisable the crop was, and the gate on whether /// reading of how recognisable the crop was, and the gate on whether
@@ -211,7 +211,7 @@ pub use dr_face::Calibration;
/// the same face in the same photograph, for carrying an identity across a /// the same face in the same photograph, for carrying an identity across a
/// re-detection. /// re-detection.
/// ///
/// Set at the reference library's P≈0.95 line (docs/faces.md §9's table: /// Set at the reference library's P≈0.95 line (docs/dev/faces.md §9's table:
/// 0.449), which is far above anything two different people in one frame /// 0.449), which is far above anything two different people in one frame
/// reach and below what one face re-embedded from a better crop of itself /// reach and below what one face re-embedded from a better crop of itself
/// does. The number is only ever asked about *overlapping* boxes on *one* /// does. The number is only ever asked about *overlapping* boxes on *one*
@@ -279,7 +279,25 @@ pub fn record_detections(
faces: &[DetectedFace], faces: &[DetectedFace],
) -> Result<Vec<FaceId>, CatalogError> { ) -> Result<Vec<FaceId>, CatalogError> {
let tx = conn.unchecked_transaction()?; let tx = conn.unchecked_transaction()?;
let ids = record_detections_within(&tx, image_id, model_id, source_edge, faces)?;
tx.commit()?;
Ok(ids)
}
/// [`record_detections`] inside a transaction the caller owns.
///
/// For a caller recording many images at once — the shard import adopts
/// fourteen thousand in one pass — where a commit per image is fourteen
/// thousand fsyncs and fourteen thousand turns at the write lock that every
/// read on the UI thread queues behind. `unchecked_transaction` cannot nest,
/// so the batching has to be offered here rather than wrapped from above.
pub fn record_detections_within(
tx: &Connection,
image_id: ImageId,
model_id: &str,
source_edge: u32,
faces: &[DetectedFace],
) -> Result<Vec<FaceId>, CatalogError> {
// Everything the old faces knew, so it can be carried across the // Everything the old faces knew, so it can be carried across the
// replacement. Read only when there is something to carry it onto: a // replacement. Read only when there is something to carry it onto: a
// pass that found nothing has nothing to match, and decoding a vector // pass that found nothing has nothing to match, and decoding a vector
@@ -287,7 +305,7 @@ pub fn record_detections(
let prior = if faces.is_empty() { let prior = if faces.is_empty() {
Vec::new() Vec::new()
} else { } else {
read_priors(&tx, image_id)? read_priors(tx, image_id)?
}; };
tx.execute("DELETE FROM faces WHERE image_id = ?1", [image_id.0 as i64])?; tx.execute("DELETE FROM faces WHERE image_id = ?1", [image_id.0 as i64])?;
@@ -389,7 +407,6 @@ pub fn record_detections(
], ],
)?; )?;
tx.commit()?;
Ok(ids) Ok(ids)
} }
@@ -556,6 +573,19 @@ impl FaceUpdate {
/// bookkeeping: `face_shard::export_to_shards` re-exports an image whose /// bookkeeping: `face_shard::export_to_shards` re-exports an image whose
/// marker is newer than the store's copy, which is how what was written /// marker is newer than the store's copy, which is how what was written
/// here reaches the other devices. /// here reaches the other devices.
///
/// It is re-written under the pipeline id the **faces carry**, not the one
/// this pass ran as. `model_id` names the pass only through its embedder;
/// the detector half of a marker is a statement about who drew the boxes,
/// and this pass drew none. Every reader takes the two to agree: the export
/// selects an image's faces by the marker's id, `marker_under` takes a
/// marker as proof the detector has been over the image, and the shard
/// store keys each face by it. When the marker was written as
/// `scrfd_10g+w600k_mbf` over faces still spelled `w600k_mbf`, the export
/// found no faces under it and sent the other devices an entry saying the
/// thorough detector had looked and found nothing — over photographs with
/// named faces on them. With no faces left, the pass's own id is the only
/// one there is, and the marker says so.
pub fn record_updates( pub fn record_updates(
conn: &Connection, conn: &Connection,
image_id: ImageId, image_id: ImageId,
@@ -602,13 +632,25 @@ pub fn record_updates(
for f in dropped { for f in dropped {
tx.execute("DELETE FROM faces WHERE id = ?1", [f.0 as i64])?; tx.execute("DELETE FROM faces WHERE id = ?1", [f.0 as i64])?;
} }
let remaining: i64 = tx.query_row( let (remaining, found_by): (i64, Option<String>) = tx.query_row(
&format!( &format!(
"SELECT COUNT(*) FROM faces WHERE image_id = ?1 AND {} = ?2", "SELECT COUNT(*), MIN(model_id) FROM faces WHERE image_id = ?1 AND {} = ?2",
embedder_sql("model_id") embedder_sql("model_id")
), ),
rusqlite::params![image_id.0 as i64, embedder_of(model_id)], rusqlite::params![image_id.0 as i64, embedder_of(model_id)],
|r| r.get(0), |r| Ok((r.get(0)?, r.get(1)?)),
)?;
let marker = found_by.as_deref().unwrap_or(model_id);
// One marker per embedder: a stale one under another spelling would
// keep saying that detector had been here, which is the claim the
// faces' own id is now making in its place.
tx.execute(
&format!(
"DELETE FROM face_index
WHERE image_id = ?1 AND model_id != ?2 AND {} = ?3",
embedder_sql("model_id")
),
rusqlite::params![image_id.0 as i64, marker, embedder_of(model_id)],
)?; )?;
tx.execute( tx.execute(
"INSERT INTO face_index (image_id, model_id, indexed_at, faces_found, source_edge) "INSERT INTO face_index (image_id, model_id, indexed_at, faces_found, source_edge)
@@ -619,7 +661,7 @@ pub fn record_updates(
source_edge = excluded.source_edge", source_edge = excluded.source_edge",
rusqlite::params![ rusqlite::params![
image_id.0 as i64, image_id.0 as i64,
model_id, marker,
now_secs(), now_secs(),
remaining, remaining,
source_edge as i64, source_edge as i64,
@@ -838,6 +880,22 @@ pub fn unassigned(conn: &Connection, model_id: &str) -> Result<Vec<FaceId>, Cata
rows.collect::<Result<_, _>>().map_err(Into::into) rows.collect::<Result<_, _>>().map_err(Into::into)
} }
/// How many faces have no identity yet — [`unassigned`] counted rather than
/// listed, for a screen that only shows the number.
pub fn count_unassigned(conn: &Connection, model_id: &str) -> Result<u64, CatalogError> {
let n: i64 = conn.query_row(
&format!(
"SELECT COUNT(*) FROM faces f
LEFT JOIN face_person fp ON fp.face_id = f.id
WHERE fp.face_id IS NULL AND {} = ?1",
embedder_sql("f.model_id")
),
[embedder_of(model_id)],
|r| r.get(0),
)?;
Ok(n as u64)
}
/// One face's stored embedding, as the clustering pass consumes it. /// One face's stored embedding, as the clustering pass consumes it.
/// ///
/// A struct rather than a tuple because it crosses a crate boundary and "the /// A struct rather than a tuple because it crosses a crate boundary and "the
@@ -910,17 +968,46 @@ pub fn rename_person(conn: &Connection, person: PersonId, name: &str) -> Result<
/// Merged-away people are excluded: they exist as redirects so a sync does not /// Merged-away people are excluded: they exist as redirects so a sync does not
/// resurrect them, not as entries in a list. /// resurrect them, not as entries in a list.
pub fn people(conn: &Connection) -> Result<Vec<Person>, CatalogError> { pub fn people(conn: &Connection) -> Result<Vec<Person>, CatalogError> {
let mut q = conn.prepare( people_where(conn, false)
}
/// Everyone who holds a face, carries a name, or was set aside — the people a
/// screen has a row for.
///
/// The rest are the empty, unnamed groups a regrouping pass leaves behind
/// (`prune_empty_unnamed`), and on the reference library they were 17,000 of
/// 19,000 rows: read, counted, sorted by name and then thrown away by the
/// caller on every redraw. Filtered here, in the query, they are never
/// sorted. The filter is SQL's `trim`, which strips spaces and not every
/// whitespace character, so a name that is only a tab is listed rather than
/// hidden — the safe direction for a row the user typed something into.
pub fn people_in_use(conn: &Connection) -> Result<Vec<Person>, CatalogError> {
people_where(conn, true)
}
/// [`people`], with face counts aggregated once per person *before* the join
/// rather than grouped after it: the face table is joined to the 2,000
/// people it names, not the 19,000 rows of the people table.
fn people_where(conn: &Connection, in_use: bool) -> Result<Vec<Person>, CatalogError> {
let filter = if in_use {
"AND (c.person_id IS NOT NULL OR trim(p.name) <> '' OR p.ignored)"
} else {
""
};
let mut q = conn.prepare(&format!(
"SELECT p.id, p.uuid, p.name, "SELECT p.id, p.uuid, p.name,
COALESCE(SUM(fp.confirmed = 1), 0), COALESCE(c.confirmed, 0),
COALESCE(SUM(fp.confirmed = 0), 0), COALESCE(c.suggested, 0),
p.ignored p.ignored
FROM people p FROM people p
LEFT JOIN face_person fp ON fp.person_id = p.id LEFT JOIN (SELECT person_id,
WHERE p.merged_into IS NULL SUM(confirmed = 1) AS confirmed,
GROUP BY p.id SUM(confirmed = 0) AS suggested
ORDER BY 4 DESC, 5 DESC, p.name", FROM face_person
)?; GROUP BY person_id) c ON c.person_id = p.id
WHERE p.merged_into IS NULL {filter}
ORDER BY 4 DESC, 5 DESC, p.name"
))?;
let rows = q.query_map([], |r| { let rows = q.query_map([], |r| {
Ok(Person { Ok(Person {
id: PersonId(r.get::<_, i64>(0)? as u64), id: PersonId(r.get::<_, i64>(0)? as u64),
@@ -1057,6 +1144,72 @@ pub fn confirm(conn: &Connection, face: FaceId, person: PersonId) -> Result<(),
Ok(()) Ok(())
} }
/// The user says every suggested face of this person is right.
///
/// What "Confirm all" runs, and the reason it is not a loop over [`confirm`]:
/// that is a transaction per face, and on a group of several hundred it was
/// several hundred commits for one click. Two statements, one commit, and the
/// same two rules `confirm` applies face by face — an earlier rejection of
/// the pair is overridden, and a face already confirmed is left alone.
///
/// Returns how many suggestions became confirmations.
pub fn confirm_all(conn: &Connection, person: PersonId) -> Result<u64, CatalogError> {
let tx = conn.unchecked_transaction()?;
tx.execute(
"DELETE FROM face_person_rejected
WHERE person_id = ?1
AND face_id IN (SELECT face_id FROM face_person
WHERE person_id = ?1 AND confirmed = 0)",
[person.0 as i64],
)?;
let n = tx.execute(
"UPDATE face_person SET confirmed = 1, probability = 1.0
WHERE person_id = ?1 AND confirmed = 0",
[person.0 as i64],
)?;
tx.commit()?;
Ok(n as u64)
}
/// The user says these faces are `to`, not `from`.
///
/// [`reject`] from one and [`confirm`] onto the other, for every face, in one
/// transaction — what a split commits. Rejecting first is what stops the
/// split being undone: without it the next pass sees a face that looks like
/// `from` and suggests it straight back. Confirmed rather than suggested on
/// `to`, because the user has just asserted these belong together.
pub fn reassign(
conn: &Connection,
faces: &[FaceId],
from: PersonId,
to: PersonId,
) -> Result<(), CatalogError> {
let tx = conn.unchecked_transaction()?;
{
let mut reject = tx.prepare(
"INSERT OR IGNORE INTO face_person_rejected (face_id, person_id)
VALUES (?1, ?2)",
)?;
let mut unrejected =
tx.prepare("DELETE FROM face_person_rejected WHERE face_id = ?1 AND person_id = ?2")?;
let mut confirm = tx.prepare(
"INSERT INTO face_person (face_id, person_id, probability, confirmed)
VALUES (?1, ?2, 1.0, 1)
ON CONFLICT(face_id) DO UPDATE SET
person_id = excluded.person_id,
probability = 1.0,
confirmed = 1",
)?;
for face in faces {
reject.execute(rusqlite::params![face.0 as i64, from.0 as i64])?;
unrejected.execute(rusqlite::params![face.0 as i64, to.0 as i64])?;
confirm.execute(rusqlite::params![face.0 as i64, to.0 as i64])?;
}
}
tx.commit()?;
Ok(())
}
/// The user says this face is **not** this person. /// The user says this face is **not** this person.
/// ///
/// Stored rather than implied by removal, so the next clustering pass does not /// Stored rather than implied by removal, so the next clustering pass does not
@@ -1446,7 +1599,171 @@ fn iou(a: (f32, f32, f32, f32), b: (f32, f32, f32, f32)) -> f32 {
} }
} }
fn now_secs() -> i64 { /// TRACES: FR-CAT-11a | FR-CULL-10
/// What [`carry_onto_copy`] did with one byte-identical copy's faces.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct FaceCarry {
/// Faces moved onto the survivor outright, because it had none.
pub moved: usize,
/// Names and suggestions put on a survivor's face that lacked one.
pub named: usize,
/// Rejections added to a survivor's face.
pub rejections: usize,
/// Faces named one person on the copy and another on the survivor. The
/// survivor's name is kept; the copy keeps its own, in the trash.
pub conflicts: usize,
/// Named faces on the copy that match nothing on the survivor. Left with
/// the copy rather than mixed into another pipeline's faces.
pub unmatched_named: usize,
}
/// TRACES: FR-CAT-11a | FR-CULL-10
/// Bring what one copy of a photograph knows about its faces onto another
/// copy of the same bytes, inside the caller's transaction.
///
/// Faces are per image, so two copies of one file indexed separately hold
/// two sets of the same boxes, and a name confirmed on one is invisible on
/// the other. The rule is the one [`record_detections_within`] keeps: an
/// image holds one pipeline's faces at a time, and the user's judgements
/// are what must survive.
///
/// - **The survivor has no faces at all.** The copy's faces and its run
/// markers move over wholesale — nothing is duplicated, and the survivor
/// is spared a detection pass it would only repeat. Its own markers go
/// first, because a marker saying "examined, nothing found" over an image
/// that now holds faces is the V12 state.
/// - **The survivor has faces.** Each of the copy's faces is paired with the
/// survivor face its box overlaps most (IoU above one half — the bytes are
/// the same, so the boxes coincide). A name or suggestion is carried onto
/// a survivor face that has none, a confirmation outranks a suggestion,
/// and two different confirmed names are a conflict the survivor wins.
/// Rejections are unioned. The copy's faces stay where they are, with the
/// copy.
pub fn carry_onto_copy(
tx: &Connection,
from: ImageId,
to: ImageId,
) -> Result<FaceCarry, CatalogError> {
let mut out = FaceCarry::default();
let boxes = |image: ImageId| -> Result<Vec<CopyFace>, CatalogError> {
let mut q = tx.prepare(
"SELECT f.id, f.x, f.y, f.w, f.h, fp.person_id, fp.probability, fp.confirmed
FROM faces f
LEFT JOIN face_person fp ON fp.face_id = f.id
WHERE f.image_id = ?1
ORDER BY f.id",
)?;
let rows = q.query_map([image.0 as i64], |r| {
let person: Option<i64> = r.get(5)?;
Ok(CopyFace {
id: r.get(0)?,
rect: (
r.get::<_, f64>(1)? as f32,
r.get::<_, f64>(2)? as f32,
r.get::<_, f64>(3)? as f32,
r.get::<_, f64>(4)? as f32,
),
assignment: match person {
Some(p) => Some((p, r.get::<_, f64>(6)?, r.get::<_, i64>(7)? != 0)),
None => None,
},
})
})?;
Ok(rows.collect::<Result<Vec<_>, _>>()?)
};
let theirs = boxes(from)?;
if theirs.is_empty() {
return Ok(out);
}
let ours = boxes(to)?;
if ours.is_empty() {
tx.execute("DELETE FROM face_index WHERE image_id = ?1", [to.0 as i64])?;
tx.execute(
"UPDATE face_index SET image_id = ?2 WHERE image_id = ?1",
rusqlite::params![from.0 as i64, to.0 as i64],
)?;
out.moved = tx.execute(
"UPDATE faces SET image_id = ?2 WHERE image_id = ?1",
rusqlite::params![from.0 as i64, to.0 as i64],
)?;
return Ok(out);
}
let mut taken = vec![false; ours.len()];
for face in &theirs {
let best = ours
.iter()
.enumerate()
.filter(|(i, _)| !taken[*i])
.map(|(i, o)| (i, iou(face.rect, o.rect)))
.filter(|(_, overlap)| *overlap > 0.5)
.max_by(|a, b| a.1.total_cmp(&b.1));
let Some((at, _)) = best else {
if face.assignment.is_some_and(|(_, _, confirmed)| confirmed) {
out.unmatched_named += 1;
}
continue;
};
taken[at] = true;
let target = &ours[at];
out.rejections += tx.execute(
"INSERT OR IGNORE INTO face_person_rejected (face_id, person_id)
SELECT ?2, person_id FROM face_person_rejected WHERE face_id = ?1",
rusqlite::params![face.id, target.id],
)?;
let Some((person, probability, confirmed)) = face.assignment else {
continue;
};
let carry = match target.assignment {
None => true,
// The same person: only a confirmation upgrades a suggestion.
Some((p, _, theirs_confirmed)) if p == person => confirmed && !theirs_confirmed,
// Another person, only suggested there: the user's word wins.
Some((_, _, false)) => confirmed,
// Another person, confirmed there: the survivor keeps its name.
Some((_, _, true)) => {
if confirmed {
out.conflicts += 1;
}
false
}
};
if carry {
tx.execute(
"INSERT INTO face_person (face_id, person_id, probability, confirmed)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(face_id) DO UPDATE SET
person_id = excluded.person_id,
probability = excluded.probability,
confirmed = excluded.confirmed",
rusqlite::params![target.id, person, probability, confirmed],
)?;
// A name the user gave outranks a rejection of the same pair made
// on the survivor — the same order `confirm` applies.
if confirmed {
tx.execute(
"DELETE FROM face_person_rejected WHERE face_id = ?1 AND person_id = ?2",
rusqlite::params![target.id, person],
)?;
}
out.named += 1;
}
}
Ok(out)
}
/// One face as [`carry_onto_copy`] pairs it.
struct CopyFace {
id: i64,
rect: (f32, f32, f32, f32),
assignment: Option<(i64, f64, bool)>,
}
pub(crate) fn now_secs() -> i64 {
std::time::SystemTime::now() std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64) .map(|d| d.as_secs() as i64)
@@ -1779,6 +2096,86 @@ mod tests {
assert!(at >= marked_at, "the marker was not refreshed"); assert!(at >= marked_at, "the marker was not refreshed");
} }
/// The marker a per-face pass leaves names the detector that drew the
/// boxes, whatever pipeline the pass itself ran as. A marker under the
/// pass's id over faces spelled another way is one the export finds no
/// faces under — and it sent every other device "nothing here".
#[test]
fn an_update_keeps_the_marker_under_the_detector_that_found_the_faces() {
let c = db();
let img = image(&c, 1);
let ids = record_detections(
&c,
img,
"w600k_mbf",
1024,
&[DetectedFace {
quality: None,
..face(1)
}],
)
.unwrap();
// The state V14 leaves: the faces, and no marker at all.
c.execute("DELETE FROM face_index", []).unwrap();
record_updates(
&c,
img,
"scrfd_10g+w600k_mbf",
6000,
&[FaceUpdate {
embedding: Some((vec![9; 1024], 21.5)),
..FaceUpdate::for_face(ids[0])
}],
&[],
)
.unwrap();
let markers: Vec<(String, i64)> = c
.prepare("SELECT model_id, faces_found FROM face_index")
.unwrap()
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
.unwrap()
.map(Result::unwrap)
.collect();
assert_eq!(markers, vec![("w600k_mbf".to_string(), 1)]);
// A marker already there under the pass's own id is replaced, not
// kept beside the right one.
c.execute(
"INSERT INTO face_index(image_id, model_id, indexed_at, faces_found, source_edge)
VALUES (1, 'scrfd_10g+w600k_mbf', 0, 0, 6000)",
[],
)
.unwrap();
record_updates(
&c,
img,
"scrfd_10g+w600k_mbf",
6000,
&[FaceUpdate {
crop: Some(vec![1, 2, 3]),
..FaceUpdate::for_face(ids[0])
}],
&[],
)
.unwrap();
let n: i64 = c
.query_row("SELECT COUNT(*) FROM face_index", [], |r| r.get(0))
.unwrap();
assert_eq!(n, 1, "a second marker survived");
// With every face dropped there is no detector left to name, and
// the pass's own id records that it looked.
record_updates(&c, img, "scrfd_10g+w600k_mbf", 6000, &[], &ids).unwrap();
let marker: (String, i64) = c
.query_row("SELECT model_id, faces_found FROM face_index", [], |r| {
Ok((r.get(0)?, r.get(1)?))
})
.unwrap();
assert_eq!(marker, ("scrfd_10g+w600k_mbf".to_string(), 0));
}
/// Re-detection is coalesced per image, so it must replace rather than /// Re-detection is coalesced per image, so it must replace rather than
/// append — otherwise every re-index doubles the library's face count. /// append — otherwise every re-index doubles the library's face count.
#[test] #[test]
@@ -2253,7 +2650,7 @@ mod tests {
} }
/// The reference implementation's fitted MBF curve puts the P=0.5 boundary /// The reference implementation's fitted MBF curve puts the P=0.5 boundary
/// at cosine 0.267 (docs/faces.md §1). Our own first end-to-end run scored /// at cosine 0.267 (docs/dev/faces.md §1). Our own first end-to-end run scored
/// 0.596 between distinct photographs of one person and 0.05 between /// 0.596 between distinct photographs of one person and 0.05 between
/// different people, so those two must land either side. /// different people, so those two must land either side.
#[test] #[test]
+53 -5
View File
@@ -29,7 +29,11 @@ pub enum JobKind {
ScanFolder = 0, ScanFolder = 0,
/// Promote an image from stat-only to full EXIF. /// Promote an image from stat-only to full EXIF.
ExtractMetadata = 1, ExtractMetadata = 1,
/// Build or rebuild a thumbnail. /// Build or rebuild a thumbnail. **Retired** — see [`JobKind::RETIRED`].
///
/// Kept so the number stays taken: a catalog written by 0.16.0 or earlier
/// holds rows of kind 2, and reusing it would hand them to whatever took
/// its place.
Thumbnail = 2, Thumbnail = 2,
/// A sidecar on disk is newer than what the catalog read. /// A sidecar on disk is newer than what the catalog read.
ReadSidecar = 3, ReadSidecar = 3,
@@ -69,6 +73,18 @@ impl JobKind {
JobKind::DetectFaces, JobKind::DetectFaces,
]; ];
/// Kinds that are no longer queued by anything, whose rows are deleted on
/// sight by [`drop_retired`].
///
/// `Thumbnail` is here because thumbnails are owed by the store, not by
/// the queue. The grid's worker and the thumbnail sweep both find their
/// work by asking `ThumbStore` what it lacks, and the store is shared
/// between devices, so it is the only thing that can say another device
/// already made one. Up to 0.16.0 every scan enqueued a job per
/// photograph anyway and no handler ever claimed one: the reference
/// catalog held 23,582 of them (#73; catalog.md §6.1).
pub const RETIRED: [JobKind; 1] = [JobKind::Thumbnail];
fn from_i64(v: i64) -> Option<Self> { fn from_i64(v: i64) -> Option<Self> {
Some(match v { Some(match v {
0 => JobKind::ScanFolder, 0 => JobKind::ScanFolder,
@@ -185,7 +201,8 @@ pub fn enqueue(
priority: Priority, priority: Priority,
payload: Option<&str>, payload: Option<&str>,
) -> Result<(), CatalogError> { ) -> Result<(), CatalogError> {
conn.execute( // Cached: a scan enqueues one per photograph it lists.
conn.prepare_cached(
"INSERT INTO jobs(kind, subject_id, priority, state, payload) "INSERT INTO jobs(kind, subject_id, priority, state, payload)
VALUES (?1, ?2, ?3, 0, ?4) VALUES (?1, ?2, ?3, 0, ?4)
ON CONFLICT(kind, subject_id) DO UPDATE SET ON CONFLICT(kind, subject_id) DO UPDATE SET
@@ -195,8 +212,13 @@ pub fn enqueue(
state = CASE WHEN jobs.state = 2 THEN 0 ELSE jobs.state END, state = CASE WHEN jobs.state = 2 THEN 0 ELSE jobs.state END,
attempts = CASE WHEN jobs.state = 2 THEN 0 ELSE jobs.attempts END, attempts = CASE WHEN jobs.state = 2 THEN 0 ELSE jobs.attempts END,
not_before = CASE WHEN jobs.state = 2 THEN 0 ELSE jobs.not_before END", not_before = CASE WHEN jobs.state = 2 THEN 0 ELSE jobs.not_before END",
rusqlite::params![kind as i64, subject_id, priority as i64, payload], )?
)?; .execute(rusqlite::params![
kind as i64,
subject_id,
priority as i64,
payload
])?;
Ok(()) Ok(())
} }
@@ -393,7 +415,7 @@ pub fn recover_orphaned(conn: &Connection) -> Result<usize, CatalogError> {
/// ///
/// Coalescing keeps the table one row per unit of work, but nothing shrinks it /// Coalescing keeps the table one row per unit of work, but nothing shrinks it
/// when the work stops existing: a library that has been culled carries a /// when the work stops existing: a library that has been culled carries a
/// thumbnail job for every photograph deleted since the last time anything /// job for every photograph deleted since the last time anything
/// looked. Each one would be claimed, run, and failed five times. /// looked. Each one would be claimed, run, and failed five times.
/// ///
/// Only kinds whose subject really is an image ([`JobKind::subject_is_image`]) /// Only kinds whose subject really is an image ([`JobKind::subject_is_image`])
@@ -425,6 +447,32 @@ pub fn reap_orphan_subjects(conn: &Connection) -> Result<usize, CatalogError> {
Ok(n) Ok(n)
} }
/// Delete every row of a [`JobKind::RETIRED`] kind.
///
/// Not a migration, deliberately. A schema bump makes an older build refuse
/// the synced catalog snapshot, and a device still on 0.16.0 would lose the
/// catalog to save a megabyte. So this runs where the queue is readied —
/// [`crate::runner::recover`], at every open — and has to be cheap when there
/// is nothing to do: `kind` leads the `UNIQUE(kind, subject_id)` index, so an
/// empty answer is one index probe, not a table scan.
///
/// Every open rather than once, because once is not enough: an older build
/// opening the same catalog enqueues them again on its next scan.
///
/// Rows in any state go. Nothing claims these kinds, so none can be running,
/// and a failed one would be a report about work nobody was going to do.
pub fn drop_retired(conn: &Connection) -> Result<usize, CatalogError> {
let kinds: Vec<i64> = JobKind::RETIRED.iter().map(|k| *k as i64).collect();
let placeholders = std::iter::repeat_n("?", kinds.len())
.collect::<Vec<_>>()
.join(",");
let n = conn.execute(
&format!("DELETE FROM jobs WHERE kind IN ({placeholders})"),
rusqlite::params_from_iter(kinds.iter()),
)?;
Ok(n)
}
/// How much is left, by state. /// How much is left, by state.
/// ///
/// One query rather than a listing, because the caller is a progress line: a /// One query rather than a listing, because the caller is a progress line: a
+29 -2
View File
@@ -244,7 +244,8 @@ pub fn delete(conn: &Connection, id: KeywordId) -> Result<usize, CatalogError> {
/// every assignment, and a query per keyword would be one statement per word /// every assignment, and a query per keyword would be one statement per word
/// in the library. /// in the library.
pub fn list(conn: &Connection) -> Result<Vec<Keyword>, CatalogError> { pub fn list(conn: &Connection) -> Result<Vec<Keyword>, CatalogError> {
let mut stmt = conn.prepare( ensure_term_index(conn);
let mut stmt = conn.prepare_cached(
// DISTINCT image, not row: a word on two versions of one frame is one // DISTINCT image, not row: a word on two versions of one frame is one
// photograph, and reporting two is the kind of small lie that makes a // photograph, and reporting two is the kind of small lie that makes a
// user stop trusting the counts. // user stop trusting the counts.
@@ -269,6 +270,27 @@ pub fn list(conn: &Connection) -> Result<Vec<Keyword>, CatalogError> {
Ok(rows) Ok(rows)
} }
/// The index [`list`]'s per-word count is served from: `keywords_term`
/// with the version beside the word, so the count reads no `keywords` row.
///
/// `keywords_term` alone gave the row id, and each of the 10,800 assignments
/// on the reference library cost a probe of the table for its version --
/// 4 ms of the keyword panel's redraw, on every selection change.
///
/// Created on first use rather than by a migration, for the reason
/// `duplicates::ensure_probe_table` gives: a new schema version makes every
/// older build refuse this catalog's snapshot at sync, and an older build
/// that meets an extra index ignores it. Once it exists, the statement is a
/// lookup in the schema (microseconds). A failure to create it is logged and
/// the list read without it: the index is a speed-up, never an answer.
fn ensure_term_index(conn: &Connection) {
if let Err(e) = conn.execute_batch(
"CREATE INDEX IF NOT EXISTS keywords_term_version ON keywords(keyword, version_id);",
) {
log::warn!("keywords: could not create keywords_term_version: {e}");
}
}
/// Assign a keyword to images, creating the keyword if it is new. /// Assign a keyword to images, creating the keyword if it is new.
/// ///
/// The bulk form is the *only* form, because keywording a selection is the /// The bulk form is the *only* form, because keywording a selection is the
@@ -491,7 +513,12 @@ pub fn adopt_orphan_terms(conn: &Connection) -> Result<usize, CatalogError> {
// quietly readmitted to the vocabulary; it stays visible as an // quietly readmitted to the vocabulary; it stays visible as an
// orphan in [`for_images`] instead, which is a state someone can // orphan in [`for_images`] instead, which is a state someone can
// see and act on rather than one that silently undoes a deletion. // see and act on rather than one that silently undoes a deletion.
"SELECT DISTINCT k.keyword FROM keywords k //
// The distinct words first, then the check: the vocabulary has no
// index a tombstone-inclusive lookup can use, so checking once per
// assignment scanned it 10,000 times on every open. Once per word
// is a few dozen scans of a few dozen rows.
"SELECT k.keyword FROM (SELECT DISTINCT keyword FROM keywords) k
WHERE NOT EXISTS (SELECT 1 FROM keyword_terms t WHERE NOT EXISTS (SELECT 1 FROM keyword_terms t
WHERE t.name = k.keyword)", WHERE t.name = k.keyword)",
)?; )?;
+16 -2
View File
@@ -36,10 +36,13 @@ use std::path::Path;
use dr_types::{Availability, ImageId}; use dr_types::{Availability, ImageId};
use rusqlite::Connection; use rusqlite::Connection;
pub mod albums;
mod backfilled;
pub mod bursts; pub mod bursts;
pub mod cache; pub mod cache;
pub mod collections; pub mod collections;
pub mod dedup; pub mod dedup;
pub mod duplicates;
pub mod error; pub mod error;
pub mod face_shard; pub mod face_shard;
pub mod faces; pub mod faces;
@@ -56,6 +59,7 @@ pub mod sync;
pub mod trash; pub mod trash;
pub mod walk; pub mod walk;
pub use albums::{Album, AlbumId, Place};
pub use cache::{Budget, Cache, DEFAULT_BUDGET_BYTES}; pub use cache::{Budget, Cache, DEFAULT_BUDGET_BYTES};
pub use collections::{Collection, CollectionKind, TreeRow}; pub use collections::{Collection, CollectionKind, TreeRow};
pub use dedup::{seen_by_content, seen_by_metadata, set_content_hash}; pub use dedup::{seen_by_content, seen_by_metadata, set_content_hash};
@@ -246,8 +250,18 @@ impl Catalog {
// A migration adds a column; it cannot know what the value should be // A migration adds a column; it cannot know what the value should be
// for rows that already existed. Backfilling on open is what stops // for rows that already existed. Backfilling on open is what stops
// those rows being silently partial. // those rows being silently partial.
for (what, n) in schema::backfill(&conn)? { //
log::info!("backfilled {what} for {n} row(s) (schema was v{from})"); // Once per catalog state rather than once per open (NFR-P9): every
// worker thread opens its own connection, and a develop landing made
// five, each paying the whole backfill to confirm nothing had changed.
// [`backfilled`] says what "changed" means and why it is enough. A
// migration always backfills, stamp or no stamp.
let stamp = backfilled::stamp(&conn, path)?;
if from < schema::SCHEMA_VERSION || !backfilled::is_current(path, &stamp) {
for (what, n) in schema::backfill(&conn)? {
log::info!("backfilled {what} for {n} row(s) (schema was v{from})");
}
backfilled::record(path, stamp);
} }
Ok(Catalog { conn }) Ok(Catalog { conn })
} }
+372 -58
View File
@@ -119,6 +119,17 @@ pub struct MergeReport {
pub keywords_fused: usize, pub keywords_fused: usize,
/// Keyword assignments taken from the remote. /// Keyword assignments taken from the remote.
pub keywords_assigned: usize, pub keywords_assigned: usize,
/// Images whose capture metadata was taken from the remote.
pub metadata_adopted: usize,
/// Albums the remote had and this device did not, or had renamed, moved
/// or deleted with the higher revision.
pub albums_taken: usize,
/// Albums where this device's revision was at least as high.
pub albums_kept_local: usize,
/// Exported files the remote had recorded into an album and this device
/// had not.
pub album_exports_added: usize,
} }
impl MergeReport { impl MergeReport {
@@ -133,12 +144,18 @@ impl MergeReport {
|| self.keywords_deleted > 0 || self.keywords_deleted > 0
|| self.keywords_fused > 0 || self.keywords_fused > 0
|| self.keywords_assigned > 0 || self.keywords_assigned > 0
|| self.metadata_adopted > 0
|| self.albums_taken > 0
|| self.album_exports_added > 0
} }
/// Whether the local catalog holds anything the remote did not, and so /// Whether the local catalog holds anything the remote did not, and so
/// must be uploaded even if nothing was taken from the remote. /// must be uploaded even if nothing was taken from the remote.
pub fn should_upload(&self) -> bool { pub fn should_upload(&self) -> bool {
self.kept_local > 0 || self.keywords_kept_local > 0 || self.local_changed() self.kept_local > 0
|| self.keywords_kept_local > 0
|| self.albums_kept_local > 0
|| self.local_changed()
} }
} }
@@ -193,10 +210,209 @@ pub fn merge_all(conn: &Connection) -> Result<MergeReport, CatalogError> {
merge_collections_within(&tx, &mut report)?; merge_collections_within(&tx, &mut report)?;
merge_keywords_within(&tx, &mut report)?; merge_keywords_within(&tx, &mut report)?;
merge_people_within(&tx, &mut report)?; merge_people_within(&tx, &mut report)?;
merge_metadata_within(&tx, &mut report)?;
merge_albums_within(&tx, &mut report)?;
tx.commit()?; tx.commit()?;
Ok(report) Ok(report)
} }
/// Merge albums and what was exported into them, from an attached catalog.
pub fn merge_albums(conn: &Connection) -> Result<MergeReport, CatalogError> {
let tx = conn.unchecked_transaction()?;
let mut report = MergeReport::default();
merge_albums_within(&tx, &mut report)?;
tx.commit()?;
Ok(report)
}
/// The album half: rows by [`verdict`], exports as a set union.
///
/// `album_folders` is not read. It is this device's choice of a local folder
/// and means nothing on the device that sent the snapshot — which has in any
/// case dropped it before uploading (see [`crate::albums`]).
fn merge_albums_within(tx: &Connection, report: &mut MergeReport) -> Result<(), CatalogError> {
// A snapshot from a build before albums, or from a device that never
// made one, has no tables to read. Ours are made on demand so the
// statements below have somewhere to write.
if !remote_has(tx, "albums")? {
return Ok(());
}
crate::albums::ensure_tables(tx)?;
/// One album as the remote has it, and what the verdict made of it.
struct IncomingAlbum {
uuid: String,
name: String,
server_path: Option<String>,
created: i64,
revision: i64,
modified: i64,
deleted: bool,
verdict: MergeVerdict,
}
let rows: Vec<IncomingAlbum> = {
let mut stmt = tx.prepare(
"SELECT r.uuid, r.name, r.server_path, r.created, r.revision, r.modified,
r.deleted, l.revision, l.modified
FROM remote_cat.albums r
LEFT JOIN main.albums l ON l.uuid = r.uuid",
)?;
let rows = stmt
.query_map([], |r| {
let revision: i64 = r.get(4)?;
let modified: i64 = r.get(5)?;
let deleted: bool = r.get::<_, i64>(6)? != 0;
let local_rev: Option<i64> = r.get(7)?;
let local_mod: Option<i64> = r.get(8)?;
Ok(IncomingAlbum {
uuid: r.get(0)?,
name: r.get(1)?,
server_path: r.get(2)?,
created: r.get(3)?,
revision,
modified,
deleted,
verdict: verdict(local_rev.zip(local_mod), (revision, modified), deleted),
})
})?
.collect::<Result<Vec<_>, _>>()?;
rows
};
{
// One upsert covers insert, update and tombstone: the verdict has
// already decided the remote row wins, so its fields are the answer
// whichever of the three it is.
let mut take = tx.prepare(
"INSERT INTO main.albums
(uuid, name, server_path, created, revision, modified, deleted)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
ON CONFLICT(uuid) DO UPDATE SET
name = excluded.name, server_path = excluded.server_path,
revision = excluded.revision, modified = excluded.modified,
deleted = excluded.deleted",
)?;
let mut forget = tx.prepare(
"DELETE FROM main.album_exports
WHERE album_id = (SELECT id FROM main.albums WHERE uuid = ?1)",
)?;
let mut unfold = tx.prepare(
"DELETE FROM main.album_folders
WHERE album_id = (SELECT id FROM main.albums WHERE uuid = ?1)",
)?;
for row in rows {
if row.verdict == MergeVerdict::KeptLocal {
report.albums_kept_local += 1;
continue;
}
take.execute(rusqlite::params![
row.uuid,
row.name,
row.server_path,
row.created,
row.revision,
row.modified,
row.deleted as i64
])?;
if row.deleted {
forget.execute([&row.uuid])?;
unfold.execute([&row.uuid])?;
} else if row.server_path.is_some() {
// Another device moved the album to the server with the newer
// revision; a local folder chosen here is no longer where it
// goes.
unfold.execute([&row.uuid])?;
}
report.albums_taken += 1;
}
}
report.album_exports_added = tx.execute(ALBUM_EXPORTS_BY_FILE_ID, [])?
+ tx.execute(ALBUM_EXPORTS_BY_CONTENT_HASH, [])?;
Ok(())
}
/// Exported files, matched to local images by the server's file id — the
/// identity [`MEMBERS_BY_FILE_ID`] explains. Tombstoned albums are excluded,
/// or a merge would refill an album it had just deleted.
const ALBUM_EXPORTS_BY_FILE_ID: &str = "
INSERT OR IGNORE INTO main.album_exports(album_id, file_name, image_id, exported_at)
SELECT la.id, re.file_name, li.id, re.exported_at
FROM remote_cat.album_exports re
JOIN remote_cat.albums ra ON ra.id = re.album_id
JOIN main.albums la ON la.uuid = ra.uuid AND la.deleted = 0
JOIN remote_cat.remote rr ON rr.image_id = re.image_id
JOIN main.remote lr ON lr.file_id = rr.file_id
JOIN main.images li ON li.id = lr.image_id";
/// The same union by content hash, for a library with no server behind it.
const ALBUM_EXPORTS_BY_CONTENT_HASH: &str = "
INSERT OR IGNORE INTO main.album_exports(album_id, file_name, image_id, exported_at)
SELECT la.id, re.file_name, li.id, re.exported_at
FROM remote_cat.album_exports re
JOIN remote_cat.albums ra ON ra.id = re.album_id
JOIN main.albums la ON la.uuid = ra.uuid AND la.deleted = 0
JOIN remote_cat.images ri ON ri.id = re.image_id
JOIN main.images li ON li.content_hash = ri.content_hash
WHERE ri.content_hash IS NOT NULL";
/// Adopt capture metadata from an attached catalog, on its own.
pub fn merge_metadata(conn: &Connection) -> Result<MergeReport, CatalogError> {
let tx = conn.unchecked_transaction()?;
let mut report = MergeReport::default();
merge_metadata_within(&tx, &mut report)?;
tx.commit()?;
Ok(report)
}
/// Capture metadata a peer's sweep already read, for images this device has
/// not dated yet.
///
/// The `images` table is local state and the merge leaves it alone — except
/// for these columns, which are not: a capture time, an offset, a camera, a
/// lens and an ISO are facts about the file's bytes, identical on every
/// device, and read by fetching a header per image across the whole library
/// (`dr_ui::library::spawn_sweep`). A fresh device inherits its peers'
/// thumbnails and faces from the shards and then spent hours re-reading
/// every header for the timeline; the snapshot it had just merged held
/// every one of those dates.
///
/// Matched by `oc:fileid`, as collection membership is. Only rows still at
/// `metadata_state < 2` take anything, and only from a remote row at 2: a
/// date this device read for itself is never overwritten, and a peer that
/// has not read one has nothing to give. The sweep's own query
/// (`metadata_state < 2`) then finds nothing left to do for them.
const METADATA_BY_FILE_ID: &str = "
UPDATE main.images
SET captured_at = r.captured_at,
captured_offset = coalesce(main.images.captured_offset, r.captured_offset),
camera = coalesce(main.images.camera, r.camera),
lens = coalesce(main.images.lens, r.lens),
iso = coalesce(main.images.iso, r.iso),
metadata_state = 2
FROM (SELECT lr.image_id, ri.captured_at, ri.captured_offset,
ri.camera, ri.lens, ri.iso
FROM remote_cat.images ri
JOIN remote_cat.remote rr ON rr.image_id = ri.id
JOIN main.remote lr ON lr.file_id = rr.file_id
WHERE ri.metadata_state >= 2 AND ri.captured_at IS NOT NULL) AS r
WHERE main.images.id = r.image_id
AND main.images.metadata_state < 2";
fn merge_metadata_within(tx: &Connection, report: &mut MergeReport) -> Result<(), CatalogError> {
// A snapshot from before these columns, or from a library with no server
// behind it, has nothing to join on.
if !remote_has(tx, "remote")?
|| !remote_has_column(tx, "images", "metadata_state")?
|| !remote_has_column(tx, "images", "captured_offset")?
{
return Ok(());
}
report.metadata_adopted = tx.execute(METADATA_BY_FILE_ID, [])?;
Ok(())
}
/// Merge people and identity judgements from an attached catalog. /// Merge people and identity judgements from an attached catalog.
/// ///
/// The people half of [`merge_all`], on its own, for the same reason the other /// The people half of [`merge_all`], on its own, for the same reason the other
@@ -665,6 +881,15 @@ fn merge_keywords_within(tx: &Connection, report: &mut MergeReport) -> Result<()
/// version arrives through there, the default version is both where /// version arrives through there, the default version is both where
/// [`crate::keywords::assign`] writes and where the panel reads — so it is the /// [`crate::keywords::assign`] writes and where the panel reads — so it is the
/// one place the word can land and be seen. /// one place the word can land and be seen.
///
/// A word is refused when this device holds it only as a tombstone: deleted
/// under some identity and live under none. That is a set of words, the same
/// for every row, so it is asked once -- a list SQLite builds before the walk
/// -- rather than as a correlated `NOT EXISTS ... OR EXISTS` per incoming
/// assignment. The `deleted = 1` half of that could use no index
/// (`keyword_terms_name` holds only live rows) and scanned the whole
/// vocabulary for each of 10,800 assignments: 70 ms of a sync pass that
/// changed nothing, on the reference library, against 11 ms now.
const ASSIGN_BY_FILE_ID: &str = " const ASSIGN_BY_FILE_ID: &str = "
INSERT OR IGNORE INTO main.keywords(version_id, keyword) INSERT OR IGNORE INTO main.keywords(version_id, keyword)
SELECT lv.id, rk.keyword SELECT lv.id, rk.keyword
@@ -673,10 +898,9 @@ const ASSIGN_BY_FILE_ID: &str = "
JOIN remote_cat.remote rr ON rr.image_id = rv.image_id JOIN remote_cat.remote rr ON rr.image_id = rv.image_id
JOIN main.remote lr ON lr.file_id = rr.file_id JOIN main.remote lr ON lr.file_id = rr.file_id
JOIN main.versions lv ON lv.image_id = lr.image_id AND lv.is_default = 1 JOIN main.versions lv ON lv.image_id = lr.image_id AND lv.is_default = 1
WHERE NOT EXISTS (SELECT 1 FROM main.keyword_terms t WHERE rk.keyword NOT IN (SELECT name FROM main.keyword_terms WHERE deleted = 1
WHERE t.name = rk.keyword AND t.deleted = 1) EXCEPT
OR EXISTS (SELECT 1 FROM main.keyword_terms t SELECT name FROM main.keyword_terms WHERE deleted = 0)";
WHERE t.name = rk.keyword AND t.deleted = 0)";
/// The same union for a library with no server behind it. /// The same union for a library with no server behind it.
/// ///
@@ -693,10 +917,9 @@ const ASSIGN_BY_CONTENT_HASH: &str = "
JOIN main.images li ON li.content_hash = ri.content_hash JOIN main.images li ON li.content_hash = ri.content_hash
JOIN main.versions lv ON lv.image_id = li.id AND lv.is_default = 1 JOIN main.versions lv ON lv.image_id = li.id AND lv.is_default = 1
WHERE ri.content_hash IS NOT NULL WHERE ri.content_hash IS NOT NULL
AND (NOT EXISTS (SELECT 1 FROM main.keyword_terms t AND rk.keyword NOT IN (SELECT name FROM main.keyword_terms WHERE deleted = 1
WHERE t.name = rk.keyword AND t.deleted = 1) EXCEPT
OR EXISTS (SELECT 1 FROM main.keyword_terms t SELECT name FROM main.keyword_terms WHERE deleted = 0)";
WHERE t.name = rk.keyword AND t.deleted = 0))";
/// Whether an attached database holds a table of this name. /// Whether an attached database holds a table of this name.
/// ///
@@ -729,7 +952,7 @@ fn attached_has_table(conn: &Connection, schema: &str, table: &str) -> Result<bo
/// # What travels, and what is recomputed /// # What travels, and what is recomputed
/// ///
/// The rule this module already follows for the rest of the catalog: user /// The rule this module already follows for the rest of the catalog: user
/// judgements travel, inference is rebuilt. Concretely (docs/faces.md, and the /// judgements travel, inference is rebuilt. Concretely (docs/dev/faces.md, and the
/// asymmetry `crate::faces` opens with): /// asymmetry `crate::faces` opens with):
/// ///
/// - **People** — uuid, name, and whether the user set them aside. Merged by /// - **People** — uuid, name, and whether the user set them aside. Merged by
@@ -860,27 +1083,51 @@ fn merge_people_within(tx: &Connection, report: &mut MergeReport) -> Result<(),
// ---- confirmations, and the anchors under an ignored group ----------- // ---- confirmations, and the anchors under an ignored group -----------
{ {
// The person is resolved in the same statement, by the local
// `people.uuid` key, and what this device already holds is read once
// for the whole pass and looked up in memory. A steady-state pass
// walks every confirmed and every ignored face the other device
// holds -- 13,000 on the reference library -- and three statements
// per face, even cached, were 52 ms of it. In the key's order, which
// is the order the table is walked in anyway: when two of its faces
// match one of ours, which one is applied last decides the answer.
let mut stmt = tx.prepare(&format!( let mut stmt = tx.prepare(&format!(
"SELECT fp.face_id, p.uuid, fp.probability, fp.confirmed "SELECT fp.face_id, lp.id, fp.probability, fp.confirmed
FROM remote_cat.face_person fp FROM remote_cat.face_person fp
JOIN remote_cat.people p ON p.id = fp.person_id JOIN remote_cat.people p ON p.id = fp.person_id
WHERE fp.confirmed = 1 OR {} = 1", JOIN main.people lp ON lp.uuid = p.uuid
WHERE fp.confirmed = 1 OR {} = 1
ORDER BY fp.face_id",
if ignored_col { "p.ignored" } else { "0" } if ignored_col { "p.ignored" } else { "0" }
))?; ))?;
let incoming: Vec<(i64, String, f64, bool)> = stmt let incoming: Vec<(i64, i64, f64, bool)> = stmt
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))? .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))?
.collect::<Result<_, _>>()?; .collect::<Result<_, _>>()?;
for (remote_face, uuid, probability, confirmed) in incoming { // Kept current as the loop writes: two of the other device's faces
// can match one of ours, and the second must see what the first left.
let mut held: std::collections::HashMap<i64, (i64, f64, i64)> = tx
.prepare("SELECT face_id, person_id, probability, confirmed FROM main.face_person")?
.query_map([], |r| Ok((r.get(0)?, (r.get(1)?, r.get(2)?, r.get(3)?))))?
.collect::<Result<_, _>>()?;
let rejected: std::collections::HashSet<(i64, i64)> = tx
.prepare("SELECT face_id, person_id FROM main.face_person_rejected")?
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
.collect::<Result<_, _>>()?;
let mut assign = tx.prepare_cached(
"INSERT INTO face_person (face_id, person_id, probability, confirmed)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(face_id) DO UPDATE SET
person_id = excluded.person_id,
probability = excluded.probability,
confirmed = excluded.confirmed",
)?;
for (remote_face, person, probability, confirmed) in incoming {
let Some(&local_face) = face_map.get(&remote_face) else { let Some(&local_face) = face_map.get(&remote_face) else {
continue; continue;
}; };
let person: Option<i64> = tx let current = held.get(&local_face).copied();
.query_row("SELECT id FROM people WHERE uuid = ?1", [&uuid], |r| {
r.get(0)
})
.optional()?;
let Some(person) = person else { continue };
// A local confirmation is never overwritten, in either direction. // A local confirmation is never overwritten, in either direction.
// Two devices confirming the same face as different people is a // Two devices confirming the same face as different people is a
@@ -888,13 +1135,7 @@ fn merge_people_within(tx: &Connection, report: &mut MergeReport) -> Result<(),
// settle it with; silently taking the remote's answer would let a // settle it with; silently taking the remote's answer would let a
// sync undo something the user did here. It stays as it is, and the // sync undo something the user did here. It stays as it is, and the
// user can change it on the device they are looking at. // user can change it on the device they are looking at.
let locally_confirmed: bool = tx.query_row( if current.is_some_and(|(_, _, confirmed)| confirmed == 1) {
"SELECT EXISTS(SELECT 1 FROM face_person
WHERE face_id = ?1 AND confirmed = 1)",
[local_face],
|r| r.get(0),
)?;
if locally_confirmed {
report.faces_kept_local += 1; report.faces_kept_local += 1;
continue; continue;
} }
@@ -903,25 +1144,23 @@ fn merge_people_within(tx: &Connection, report: &mut MergeReport) -> Result<(),
// this user's judgement about this pair, and re-suggesting what // this user's judgement about this pair, and re-suggesting what
// they pushed away is the behaviour that makes the feature feel // they pushed away is the behaviour that makes the feature feel
// broken. // broken.
let rejected: bool = tx.query_row( if rejected.contains(&(local_face, person)) {
"SELECT EXISTS(SELECT 1 FROM face_person_rejected
WHERE face_id = ?1 AND person_id = ?2)",
[local_face, person],
|r| r.get(0),
)?;
if rejected {
continue; continue;
} }
tx.execute( // Written only when it differs. Rewriting a row with the values it
"INSERT INTO face_person (face_id, person_id, probability, confirmed) // already holds dirtied a page per face, every pass, for nothing;
VALUES (?1, ?2, ?3, ?4) // the report still counts it, as it always has.
ON CONFLICT(face_id) DO UPDATE SET let wanted = (person, probability, i64::from(confirmed));
person_id = excluded.person_id, if current != Some(wanted) {
probability = excluded.probability, assign.execute(rusqlite::params![
confirmed = excluded.confirmed", local_face,
rusqlite::params![local_face, person, probability, confirmed], person,
)?; probability,
confirmed
])?;
held.insert(local_face, wanted);
}
report.faces_assigned += 1; report.faces_assigned += 1;
} }
} }
@@ -937,32 +1176,30 @@ fn merge_people_within(tx: &Connection, report: &mut MergeReport) -> Result<(),
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))? .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
.collect::<Result<_, _>>()?; .collect::<Result<_, _>>()?;
let mut person_of = tx.prepare_cached("SELECT id FROM people WHERE uuid = ?1")?;
let mut reject = tx.prepare_cached(
"INSERT OR IGNORE INTO face_person_rejected (face_id, person_id)
VALUES (?1, ?2)",
)?;
let mut unsuggest = tx.prepare_cached(
"DELETE FROM face_person
WHERE face_id = ?1 AND person_id = ?2 AND confirmed = 0",
)?;
for (remote_face, uuid) in incoming { for (remote_face, uuid) in incoming {
let Some(&local_face) = face_map.get(&remote_face) else { let Some(&local_face) = face_map.get(&remote_face) else {
continue; continue;
}; };
let person: Option<i64> = tx let person: Option<i64> = person_of.query_row([&uuid], |r| r.get(0)).optional()?;
.query_row("SELECT id FROM people WHERE uuid = ?1", [&uuid], |r| {
r.get(0)
})
.optional()?;
let Some(person) = person else { continue }; let Some(person) = person else { continue };
let n = tx.execute( let n = reject.execute([local_face, person])?;
"INSERT OR IGNORE INTO face_person_rejected (face_id, person_id)
VALUES (?1, ?2)",
[local_face, person],
)?;
report.faces_rejected += n; report.faces_rejected += n;
// A rejection that lands on a face currently *suggested* to be // A rejection that lands on a face currently *suggested* to be
// that person has to take the suggestion with it, or the screen // that person has to take the suggestion with it, or the screen
// keeps offering exactly what the other device just refused. // keeps offering exactly what the other device just refused.
tx.execute( unsuggest.execute([local_face, person])?;
"DELETE FROM face_person
WHERE face_id = ?1 AND person_id = ?2 AND confirmed = 0",
[local_face, person],
)?;
} }
} }
@@ -1005,6 +1242,8 @@ fn match_faces(tx: &Connection) -> Result<std::collections::HashMap<i64, i64>, C
type Boxed = (i64, f32, f32, f32, f32); type Boxed = (i64, f32, f32, f32, f32);
ensure_face_box_index(tx);
// Local faces, grouped by the photograph's cross-device id. // Local faces, grouped by the photograph's cross-device id.
let mut local: std::collections::HashMap<(i64, String), Vec<Boxed>> = let mut local: std::collections::HashMap<(i64, String), Vec<Boxed>> =
std::collections::HashMap::new(); std::collections::HashMap::new();
@@ -1077,6 +1316,30 @@ fn match_faces(tx: &Connection) -> Result<std::collections::HashMap<i64, i64>, C
Ok(map) Ok(map)
} }
/// The index the local half of [`match_faces`] is read from: every column
/// it asks of a face, so the walk touches no `faces` row.
///
/// A face row is eight kilobytes and `model_id` sits past the embedding, so
/// reading it opened the row's overflow pages: 38 ms of every sync pass on
/// the reference library, to read 19,000 boxes. From this index, 8 ms. The
/// other device's half has no such index to use -- it is a snapshot made by
/// whatever build that device runs -- but its rows have had their crops
/// stripped, which is most of their width.
///
/// Created on first use rather than by a migration, like
/// `keywords::ensure_term_index` and for the reason given there: a new
/// schema version makes older builds refuse this catalog's snapshot, and an
/// extra index is invisible to them. The first merge after an upgrade pays
/// for building it, once. A failure is logged and the merge goes on reading
/// rows, as it did before.
fn ensure_face_box_index(tx: &Connection) {
if let Err(e) = tx.execute_batch(
"CREATE INDEX IF NOT EXISTS main.faces_box ON faces(image_id, model_id, x, y, w, h);",
) {
log::warn!("merge: could not create faces_box: {e}");
}
}
/// Intersection over union of two `(x, y, w, h)` boxes. /// Intersection over union of two `(x, y, w, h)` boxes.
fn iou(a: (f32, f32, f32, f32), b: (f32, f32, f32, f32)) -> f32 { fn iou(a: (f32, f32, f32, f32), b: (f32, f32, f32, f32)) -> f32 {
let x0 = a.0.max(b.0); let x0 = a.0.max(b.0);
@@ -1168,6 +1431,57 @@ mod tests {
// ---- integration over two real catalogs ------------------------------ // ---- integration over two real catalogs ------------------------------
/// A fresh device takes the capture dates a peer's sweep read, matched by
/// `oc:fileid`, and never overwrites a date it read for itself.
#[test]
fn capture_metadata_arrives_for_undated_images_only() {
let c = two_catalogs();
// Three photographs on both devices: 1 undated here and dated there;
// 2 dated on both, differently; 3 undated on both.
for id in 1..=3 {
add_image_without_hash(&c, "main", id);
add_image_without_hash(&c, "remote_cat", id + 10);
add_remote_id(&c, "main", id, 100 + id);
add_remote_id(&c, "remote_cat", id + 10, 100 + id);
}
c.execute(
"UPDATE remote_cat.images
SET captured_at = 1000, captured_offset = 60, camera = 'X', metadata_state = 2
WHERE id = 11",
[],
)
.unwrap();
c.execute(
"UPDATE remote_cat.images SET captured_at = 2000, metadata_state = 2 WHERE id = 12",
[],
)
.unwrap();
c.execute(
"UPDATE main.images SET captured_at = 2222, metadata_state = 2 WHERE id = 2",
[],
)
.unwrap();
let report = merge_metadata(&c).unwrap();
assert_eq!(report.metadata_adopted, 1);
let row = |id: i64| -> (Option<i64>, Option<i64>, Option<String>, i64) {
c.query_row(
"SELECT captured_at, captured_offset, camera, metadata_state
FROM main.images WHERE id = ?1",
[id],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
)
.unwrap()
};
assert_eq!(row(1), (Some(1000), Some(60), Some("X".into()), 2));
assert_eq!(row(2), (Some(2222), None, None, 2));
assert_eq!(row(3), (None, None, None, 0));
// Idempotent: a second pass finds nothing left to take.
assert_eq!(merge_metadata(&c).unwrap().metadata_adopted, 0);
}
fn two_catalogs() -> Connection { fn two_catalogs() -> Connection {
attached_remote(schema::for_attached("remote_cat")) attached_remote(schema::for_attached("remote_cat"))
} }
+318 -22
View File
@@ -49,6 +49,12 @@ pub struct Judgement {
/// 0..=5. Zero means *unrated*, which is a state in its own right. /// 0..=5. Zero means *unrated*, which is a state in its own right.
pub rating: u8, pub rating: u8,
pub flag: FlagState, pub flag: FlagState,
/// TRACES: FR-CAT-5
/// The colour label, or `None`. Not part of [`Judgement::is_judged`]:
/// a label sorts photographs into piles of the photographer's own
/// meaning — "to print", "send to Anna" — and says nothing about whether
/// a frame has been culled, which is the question "unjudged" asks.
pub label: Option<ColourLabel>,
} }
impl Judgement { impl Judgement {
@@ -267,14 +273,6 @@ pub fn align_default_version_uuids(conn: &Connection) -> Result<usize, CatalogEr
Ok(moved) Ok(moved)
} }
/// The default version's row id for an image, creating one if it has none.
///
/// Every write path goes through this rather than assuming a version exists.
/// An image can arrive without one in two ways that are not worth trying to
/// prevent: a row inserted by a build predating this module, and a scan whose
/// version pass was interrupted between the image insert and the commit.
/// Failing a rating because of either would be the wrong answer — the user
/// pressed a key and expects a star.
/// TRACES: FR-CAT-13 /// TRACES: FR-CAT-13
/// How `versions.label` encodes a colour label, and back. /// How `versions.label` encodes a colour label, and back.
/// ///
@@ -304,6 +302,14 @@ pub fn label_from_code(code: Option<i64>) -> Option<ColourLabel> {
}) })
} }
/// The default version's row id for an image, creating one if it has none.
///
/// Every write path goes through this rather than assuming a version exists.
/// An image can arrive without one in two ways that are not worth trying to
/// prevent: a row inserted by a build predating this module, and a scan whose
/// version pass was interrupted between the image insert and the commit.
/// Failing a rating because of either would be the wrong answer — the user
/// pressed a key and expects a star.
pub fn default_version_id(conn: &Connection, image: ImageId) -> Result<i64, CatalogError> { pub fn default_version_id(conn: &Connection, image: ImageId) -> Result<i64, CatalogError> {
let existing: Option<i64> = conn let existing: Option<i64> = conn
.query_row( .query_row(
@@ -387,6 +393,97 @@ pub fn set_flag_many(
apply_many(conn, images, |conn, id| set_flag(conn, id, flag)) apply_many(conn, images, |conn, id| set_flag(conn, id, flag))
} }
/// TRACES: FR-CAT-5
/// Set or clear the colour label for one image.
pub fn set_label(
conn: &Connection,
image: ImageId,
label: Option<ColourLabel>,
) -> Result<(), CatalogError> {
let version = default_version_id(conn, image)?;
conn.execute(
"UPDATE versions SET label = ?2 WHERE id = ?1",
rusqlite::params![version, label.map(label_code)],
)?;
Ok(())
}
/// TRACES: FR-CAT-5
/// Set or clear a label on many images in one transaction — one keystroke
/// over a selection is one commit, as for [`set_rating_many`].
pub fn set_label_many(
conn: &Connection,
images: &[ImageId],
label: Option<ColourLabel>,
) -> Result<usize, CatalogError> {
apply_many(conn, images, |conn, id| set_label(conn, id, label))
}
/// TRACES: FR-CAT-5
/// What a label key does to a set of images: Lightroom's toggle.
///
/// Pressing the key for the label every one of them already carries takes it
/// off; otherwise every one of them gets it. Decided over the whole set
/// rather than per image, so a selection that was half red comes out all red
/// rather than inverted — the photographer pressed "red", and a key that
/// turned half of them red and the other half plain would be two answers to
/// one question.
pub fn toggled_label(
current: impl IntoIterator<Item = Option<ColourLabel>>,
pressed: ColourLabel,
) -> Option<ColourLabel> {
let mut any = false;
for label in current {
any = true;
if label != Some(pressed) {
return Some(pressed);
}
}
if any {
None
} else {
Some(pressed)
}
}
/// TRACES: FR-CAT-5 | FR-CAT-6
/// How the library divides by colour label, for the filter chips' counts.
///
/// Index 0 is unlabelled and index `n` the label whose code is `n`. The
/// same shape as [`rating_histogram`], and for the same reason the
/// unlabelled slot is what is left of [`judged_rows`]: an image without a
/// version row is unlabelled, not missing.
///
/// Only labelled rows are grouped. The join this replaced (2026-09-26)
/// probed `versions_judgement` per image and then read each version's row
/// for `label`, which the index does not carry -- 10 ms on the reference
/// library, on every label keystroke, to find that none of 23,500 images
/// had one. This walks the default versions in the index's order, which
/// is close to the table's, and groups the few that are labelled.
pub fn label_histogram(conn: &Connection) -> Result<[usize; 6], CatalogError> {
let mut out = [0usize; 6];
let mut stmt = conn.prepare_cached(
"SELECT label, count(*) FROM versions
WHERE is_default = 1 AND label IS NOT NULL
GROUP BY label",
)?;
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?;
let mut counted = 0usize;
for (code, count) in rows.flatten() {
// A code this build does not know counts as unlabelled, which is how
// `label_from_code` reads it everywhere else.
let slot = if label_from_code(Some(code)).is_some() {
code as usize
} else {
0
};
out[slot] += count as usize;
counted += count as usize;
}
out[0] += judged_rows(conn)?.saturating_sub(counted);
Ok(out)
}
/// Shared bulk wrapper, so the two axes cannot drift in their commit /// Shared bulk wrapper, so the two axes cannot drift in their commit
/// behaviour — a partially-committed rating and a fully-committed flag from /// behaviour — a partially-committed rating and a fully-committed flag from
/// the same keystroke would be hard to explain and harder to notice. /// the same keystroke would be hard to explain and harder to notice.
@@ -411,21 +508,22 @@ fn apply_many(
/// An image with no version reads as unrated and unflagged rather than as an /// An image with no version reads as unrated and unflagged rather than as an
/// error: that is exactly what it is. /// error: that is exactly what it is.
pub fn judgement(conn: &Connection, image: ImageId) -> Result<Judgement, CatalogError> { pub fn judgement(conn: &Connection, image: ImageId) -> Result<Judgement, CatalogError> {
let row: Option<(i64, i64)> = conn let row: Option<(i64, i64, Option<i64>)> = conn
.query_row( .query_row(
"SELECT rating, flag FROM versions "SELECT rating, flag, label FROM versions
WHERE image_id = ?1 WHERE image_id = ?1
ORDER BY is_default DESC, id ASC ORDER BY is_default DESC, id ASC
LIMIT 1", LIMIT 1",
[image.0 as i64], [image.0 as i64],
|r| Ok((r.get(0)?, r.get(1)?)), |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
) )
.optional()?; .optional()?;
Ok(match row { Ok(match row {
Some((rating, flag)) => Judgement { Some((rating, flag, label)) => Judgement {
rating: rating.clamp(0, MAX_RATING as i64) as u8, rating: rating.clamp(0, MAX_RATING as i64) as u8,
flag: flag_from_code(flag), flag: flag_from_code(flag),
label: label_from_code(label),
}, },
None => Judgement::default(), None => Judgement::default(),
}) })
@@ -452,7 +550,7 @@ pub fn judgements(
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(","); .join(",");
let sql = format!( let sql = format!(
"SELECT image_id, rating, flag FROM versions "SELECT image_id, rating, flag, label FROM versions
WHERE image_id IN ({placeholders}) AND is_default = 1" WHERE image_id IN ({placeholders}) AND is_default = 1"
); );
@@ -467,15 +565,17 @@ pub fn judgements(
r.get::<_, i64>(0)?, r.get::<_, i64>(0)?,
r.get::<_, i64>(1)?, r.get::<_, i64>(1)?,
r.get::<_, i64>(2)?, r.get::<_, i64>(2)?,
r.get::<_, Option<i64>>(3)?,
)) ))
})?; })?;
for (image, rating, flag) in rows.flatten() { for (image, rating, flag, label) in rows.flatten() {
out.insert( out.insert(
ImageId(image as u64), ImageId(image as u64),
Judgement { Judgement {
rating: rating.clamp(0, MAX_RATING as i64) as u8, rating: rating.clamp(0, MAX_RATING as i64) as u8,
flag: flag_from_code(flag), flag: flag_from_code(flag),
label: label_from_code(label),
}, },
); );
} }
@@ -491,25 +591,61 @@ pub fn judgements(
pub fn rating_histogram(conn: &Connection) -> Result<[usize; 6], CatalogError> { pub fn rating_histogram(conn: &Connection) -> Result<[usize; 6], CatalogError> {
let mut out = [0usize; 6]; let mut out = [0usize; 6];
// LEFT JOIN, so an image whose version row is missing still counts as // Only the rated rows are grouped; the unrated slot is what is left of
// unrated rather than vanishing from the totals. The histogram has to sum // [`judged_rows`]. So an image whose version row is missing still counts
// to the library size or it is not believable. // as unrated rather than vanishing from the totals -- the histogram has
let mut stmt = conn.prepare( // to sum to the library size or it is not believable.
"SELECT coalesce(v.rating, 0) AS r, count(*) //
FROM images i // It was one `images LEFT JOIN versions ... GROUP BY` until 2026-09-26:
LEFT JOIN versions v ON v.image_id = i.id AND v.is_default = 1 // a probe of `versions_judgement` per image and a sort of every row, to
GROUP BY r", // put 22,000 of 23,500 in slot zero. 9 ms on every star keystroke on the
// reference library; this is a pass over the index that sorts only the
// rated few, and [`judged_rows`] is three index-only counts.
let mut stmt = conn.prepare_cached(
"SELECT rating, count(*) FROM versions
WHERE is_default = 1 AND rating != 0
GROUP BY rating",
)?; )?;
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?; let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?;
let mut counted = 0usize;
for (rating, count) in rows.flatten() { for (rating, count) in rows.flatten() {
if let Some(slot) = out.get_mut(rating.clamp(0, MAX_RATING as i64) as usize) { if let Some(slot) = out.get_mut(rating.clamp(0, MAX_RATING as i64) as usize) {
*slot += count as usize; *slot += count as usize;
counted += count as usize;
} }
} }
out[0] += judged_rows(conn)?.saturating_sub(counted);
Ok(out) Ok(out)
} }
/// How many rows `images LEFT JOIN versions ON ... AND is_default = 1` has:
/// one per image with no default version, and one per default version for
/// the rest. The total both histograms divide up, and the unjudged slot is
/// what is left of it once the judged rows are counted.
///
/// Spelled as three counts rather than as that join because the join probes
/// `versions_judgement` once per image, where each count here is one pass
/// over an index without reading a row: the library size, the default
/// versions, and the images holding one. An image with two default versions
/// -- nothing prevents it -- is two rows of the join and one image of the
/// third count, so it adds one here exactly as it did there. A version
/// always belongs to an image; `foreign_keys` is on and deletes cascade.
///
/// `count(DISTINCT image_id)` alone in its statement: that is what lets
/// SQLite read the distinct values off the index's order instead of
/// building a temporary b-tree of them.
fn judged_rows(conn: &Connection) -> Result<usize, CatalogError> {
let n: i64 = conn
.prepare_cached(
"SELECT (SELECT count(*) FROM images)
+ (SELECT count(*) FROM versions WHERE is_default = 1)
- (SELECT count(DISTINCT image_id) FROM versions WHERE is_default = 1)",
)?
.query_row([], |r| r.get(0))?;
Ok(n.max(0) as usize)
}
/// How many images carry each flag: `(picks, rejects)`. /// How many images carry each flag: `(picks, rejects)`.
pub fn flag_counts(conn: &Connection) -> Result<(usize, usize), CatalogError> { pub fn flag_counts(conn: &Connection) -> Result<(usize, usize), CatalogError> {
let picks: i64 = conn.query_row( let picks: i64 = conn.query_row(
@@ -686,6 +822,72 @@ mod tests {
assert_eq!(distinct, 200); assert_eq!(distinct, 200);
} }
#[test]
fn a_label_round_trips_and_clears() {
// TRACES: FR-CAT-5
let cat = with_images(1);
let id = ids(&cat)[0];
set_label(cat.connection(), id, Some(ColourLabel::Green)).unwrap();
assert_eq!(
judgement(cat.connection(), id).unwrap().label,
Some(ColourLabel::Green)
);
set_label(cat.connection(), id, None).unwrap();
assert_eq!(judgement(cat.connection(), id).unwrap().label, None);
}
#[test]
fn a_label_is_not_a_judgement() {
// "Unjudged" is the cull's resume point; a label is a pile of the
// photographer's own, and labelling a frame must not hide it there.
let cat = with_images(1);
let id = ids(&cat)[0];
set_label(cat.connection(), id, Some(ColourLabel::Red)).unwrap();
assert!(!judgement(cat.connection(), id).unwrap().is_judged());
}
#[test]
fn labelling_a_selection_is_one_commit_and_reaches_every_image() {
// TRACES: FR-CAT-5
let cat = with_images(4);
let all = ids(&cat);
assert_eq!(
set_label_many(cat.connection(), &all, Some(ColourLabel::Blue)).unwrap(),
4
);
let found = judgements(cat.connection(), &all).unwrap();
assert!(all
.iter()
.all(|id| found[id].label == Some(ColourLabel::Blue)));
assert_eq!(
label_histogram(cat.connection()).unwrap(),
[0, 0, 0, 0, 4, 0]
);
}
#[test]
fn a_label_key_toggles_only_when_every_image_already_has_it() {
// TRACES: FR-CAT-5
use ColourLabel::*;
assert_eq!(toggled_label([Some(Red), Some(Red)], Red), None);
assert_eq!(toggled_label([Some(Red), None], Red), Some(Red));
assert_eq!(toggled_label([Some(Blue)], Red), Some(Red));
assert_eq!(toggled_label([], Red), Some(Red));
}
#[test]
fn the_label_histogram_sums_to_the_library() {
// TRACES: FR-CAT-6
// Images without a version row count as unlabelled rather than
// vanishing, as the rating histogram's do.
let cat = with_images(3);
let first = ids(&cat)[0];
set_label(cat.connection(), first, Some(ColourLabel::Purple)).unwrap();
let h = label_histogram(cat.connection()).unwrap();
assert_eq!(h, [2, 0, 0, 0, 0, 1]);
assert_eq!(h.iter().sum::<usize>(), 3);
}
#[test] #[test]
fn a_rating_round_trips() { fn a_rating_round_trips() {
let cat = with_images(1); let cat = with_images(1);
@@ -830,6 +1032,100 @@ mod tests {
assert_eq!(h.iter().sum::<usize>(), 4); assert_eq!(h.iter().sum::<usize>(), 4);
} }
/// The rows of the join the histograms used to be spelled as, grouped the
/// way `rating_histogram` groups them. What the counts must still agree
/// with, in the states nothing in the schema prevents.
fn by_join(cat: &Catalog, column: &str) -> Vec<(i64, i64)> {
cat.connection()
.prepare(&format!(
"SELECT coalesce(v.{column}, 0) AS c, count(*)
FROM images i
LEFT JOIN versions v ON v.image_id = i.id AND v.is_default = 1
GROUP BY c ORDER BY c"
))
.unwrap()
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
.unwrap()
.map(Result::unwrap)
.collect()
}
/// A library in every awkward state at once: an image with no version,
/// one with only a virtual copy, one with two default versions, and
/// values out of range on both axes.
fn awkward() -> Catalog {
let cat = with_images(8);
ensure_default_versions(cat.connection()).unwrap();
let all = ids(&cat);
let c = cat.connection();
set_rating(c, all[0], 5).unwrap();
set_rating(c, all[1], 2).unwrap();
set_label(c, all[1], Some(ColourLabel::Blue)).unwrap();
c.execute(
"DELETE FROM versions WHERE image_id = ?1",
[all[2].0 as i64],
)
.unwrap();
c.execute(
"UPDATE versions SET is_default = 0 WHERE image_id = ?1",
[all[3].0 as i64],
)
.unwrap();
c.execute(
"INSERT INTO versions(image_id, uuid, name, is_default, rating, label)
VALUES (?1, 'second-default', 'Copy', 1, 4, 3)",
[all[4].0 as i64],
)
.unwrap();
c.execute(
"UPDATE versions SET rating = -1, label = 9 WHERE image_id = ?1",
[all[5].0 as i64],
)
.unwrap();
c.execute(
"UPDATE versions SET rating = 7, label = 0 WHERE image_id = ?1",
[all[6].0 as i64],
)
.unwrap();
cat
}
/// Fold the join's rows into slots the way the old code did.
fn folded(rows: &[(i64, i64)], slot: impl Fn(i64) -> usize) -> [usize; 6] {
let mut out = [0usize; 6];
for &(code, n) in rows {
out[slot(code)] += n as usize;
}
out
}
#[test]
fn the_rating_histogram_agrees_with_the_join_it_replaced() {
let cat = awkward();
let expected = folded(&by_join(&cat, "rating"), |r| {
r.clamp(0, MAX_RATING as i64) as usize
});
assert_eq!(rating_histogram(cat.connection()).unwrap(), expected);
// Nine rows for eight images: the doubled default counts twice, as
// it always has.
assert_eq!(expected.iter().sum::<usize>(), 9);
}
#[test]
fn the_label_histogram_agrees_with_the_join_it_replaced() {
let cat = awkward();
let expected = folded(&by_join(&cat, "label"), |code| {
if label_from_code(Some(code)).is_some() {
code as usize
} else {
0
}
});
assert_eq!(label_histogram(cat.connection()).unwrap(), expected);
assert_eq!(expected[3], 1, "the second default's label is counted");
assert_eq!(expected.iter().sum::<usize>(), 9);
}
#[test] #[test]
fn flag_counts_separate_picks_from_rejects() { fn flag_counts_separate_picks_from_rejects() {
let cat = with_images(5); let cat = with_images(5);
+6 -2
View File
@@ -16,7 +16,7 @@
//! # The one thing a rebuild does not recover //! # The one thing a rebuild does not recover
//! //!
//! **Collections.** A manual collection is a set of images the user assembled //! **Collections.** A manual collection is a set of images the user assembled
//! by hand and nothing in the filesystem records it (`docs/catalog.md` §8.1) — //! by hand and nothing in the filesystem records it (`docs/dev/catalog.md` §8.1) —
//! which is the whole reason the catalog file itself syncs. So the two offers //! which is the whole reason the catalog file itself syncs. So the two offers
//! are not interchangeable, and the interface must not present them as if they //! are not interchangeable, and the interface must not present them as if they
//! were: a restore keeps the user's collections, a rebuild does not. //! were: a restore keeps the user's collections, a rebuild does not.
@@ -322,6 +322,10 @@ pub fn restore(catalog: &Path, backup: &Path) -> Result<(), CatalogError> {
/// to move — a caller may be recovering from a file SQLite could not open /// to move — a caller may be recovering from a file SQLite could not open
/// because it was never created. /// because it was never created.
pub fn set_aside(catalog: &Path) -> Result<Option<PathBuf>, CatalogError> { pub fn set_aside(catalog: &Path) -> Result<Option<PathBuf>, CatalogError> {
// Whatever takes this name next — a rebuild or a restored backup — is not
// the file this process last backfilled. Forgotten while the path still
// resolves, so it is the same key the open recorded.
crate::backfilled::forget(catalog);
let moved = if catalog.exists() { let moved = if catalog.exists() {
let dest = with_suffix(catalog, DAMAGED_SUFFIX); let dest = with_suffix(catalog, DAMAGED_SUFFIX);
// An earlier damaged copy is replaced rather than accumulating: two of // An earlier damaged copy is replaced rather than accumulating: two of
@@ -570,7 +574,7 @@ mod tests {
// The first NFR-R6 branch, asserted on the thing that distinguishes it // The first NFR-R6 branch, asserted on the thing that distinguishes it
// from the second: a collection exists nowhere but the catalog, so it // from the second: a collection exists nowhere but the catalog, so it
// is the evidence that the *contents* came back and not merely a // is the evidence that the *contents* came back and not merely a
// readable file (docs/catalog.md §8.1). // readable file (docs/dev/catalog.md §8.1).
let dir = tempdir("restore"); let dir = tempdir("restore");
let path = dir.join("catalog.sqlite"); let path = dir.join("catalog.sqlite");
fixture(&path, 500); fixture(&path, 500);
+57 -11
View File
@@ -77,7 +77,7 @@ pub enum Outcome {
/// Something that can actually do the work a job describes. /// Something that can actually do the work a job describes.
/// ///
/// The catalog knows what needs doing and nothing about how — a thumbnail /// The catalog knows what needs doing and nothing about how — face detection
/// needs a decoder, a fetch needs a network stack, and neither belongs under /// needs a decoder, a fetch needs a network stack, and neither belongs under
/// `core/dr-catalog` (ARCH §4.1: calls go downward). So the queue lives here /// `core/dr-catalog` (ARCH §4.1: calls go downward). So the queue lives here
/// and the handlers are supplied from above. /// and the handlers are supplied from above.
@@ -187,17 +187,19 @@ pub struct Recovered {
pub reclaimed: usize, pub reclaimed: usize,
/// Jobs deleted because the photograph they name no longer exists. /// Jobs deleted because the photograph they name no longer exists.
pub reaped: usize, pub reaped: usize,
/// Jobs deleted because their kind is retired ([`JobKind::RETIRED`]).
pub retired: usize,
} }
impl Recovered { impl Recovered {
pub fn did_anything(&self) -> bool { pub fn did_anything(&self) -> bool {
self.reclaimed > 0 || self.reaped > 0 self.reclaimed > 0 || self.reaped > 0 || self.retired > 0
} }
} }
/// Ready the queue for a fresh run, before any worker touches it. /// Ready the queue for a fresh run, before any worker touches it.
/// ///
/// Two distinct cleanups, and both are startup-only: /// Three distinct cleanups, and all are startup-only:
/// ///
/// - **Reclaim.** A `Running` row has no owner; the process that claimed it is /// - **Reclaim.** A `Running` row has no owner; the process that claimed it is
/// gone. On Android that is a routine morning, not a crash (FR-PLAT-AND-3). /// gone. On Android that is a routine morning, not a crash (FR-PLAT-AND-3).
@@ -205,19 +207,27 @@ impl Recovered {
/// process down with it three times running should not be retried forever, /// process down with it three times running should not be retried forever,
/// and the attempt counter is the only evidence of that we have. /// and the attempt counter is the only evidence of that we have.
/// - **Reap.** Jobs naming an image the catalog no longer has. A library that /// - **Reap.** Jobs naming an image the catalog no longer has. A library that
/// has been culled leaves thumbnail jobs for photographs that were deleted /// has been culled leaves jobs for photographs that were deleted
/// months ago, and every one of them would be claimed, run and failed. /// months ago, and every one of them would be claimed, run and failed.
/// - **Retire.** Rows of a kind nothing enqueues or claims any more
/// ([`jobs::drop_retired`]). Here rather than in a migration so that no
/// schema bump locks an older device out of the synced catalog, and every
/// time rather than once because an older build sharing the catalog will
/// queue them again.
/// ///
/// Reclaim runs first so its count is the honest number of interrupted jobs, /// Retiring runs first, so the other two never touch rows about to go.
/// Reclaim runs next so its count is the honest number of interrupted jobs,
/// before reaping removes whichever of them pointed at nothing. /// before reaping removes whichever of them pointed at nothing.
/// ///
/// **Call this exactly once per catalog, at startup.** It cannot distinguish a /// **Call this exactly once per catalog, at startup.** It cannot distinguish a
/// job a dead process was holding from one a live runner is holding right now, /// job a dead process was holding from one a live runner is holding right now,
/// because there is no owner column — the queue is durable, not distributed. /// because there is no owner column — the queue is durable, not distributed.
pub fn recover(conn: &Connection) -> Result<Recovered, CatalogError> { pub fn recover(conn: &Connection) -> Result<Recovered, CatalogError> {
let retired = jobs::drop_retired(conn)?;
Ok(Recovered { Ok(Recovered {
reclaimed: jobs::recover_orphaned(conn)?, reclaimed: jobs::recover_orphaned(conn)?,
reaped: jobs::reap_orphan_subjects(conn)?, reaped: jobs::reap_orphan_subjects(conn)?,
retired,
}) })
} }
@@ -729,7 +739,7 @@ mod tests {
// which is the window a durable queue exists to survive: no `complete`, // which is the window a durable queue exists to survive: no `complete`,
// no `fail`, just a row marked `Running` with nobody holding it. // no `fail`, just a row marked `Running` with nobody holding it.
let c = db(); let c = db();
queued(&c, JobKind::Thumbnail, 1); queued(&c, JobKind::ContentHash, 1);
// The dead process. It claimed the job and never came back. // The dead process. It claimed the job and never came back.
let claimed = jobs::claim_next(&c, 0).unwrap().expect("claimable"); let claimed = jobs::claim_next(&c, 0).unwrap().expect("claimable");
@@ -738,7 +748,7 @@ mod tests {
// A fresh runner, before it starts, finds the queue empty — the row is // A fresh runner, before it starts, finds the queue empty — the row is
// `Running` and no claim will touch it. // `Running` and no claim will touch it.
let seen = Arc::new(Mutex::new(Vec::new())); let seen = Arc::new(Mutex::new(Vec::new()));
let mut runner = Runner::new(&c).with(recording(vec![JobKind::Thumbnail], seen.clone())); let mut runner = Runner::new(&c).with(recording(vec![JobKind::ContentHash], seen.clone()));
assert_eq!( assert_eq!(
runner.drain_all(0).unwrap().ran(), runner.drain_all(0).unwrap().ran(),
0, 0,
@@ -766,7 +776,7 @@ mod tests {
// the only evidence we keep across a death. Without this a poison-pill // the only evidence we keep across a death. Without this a poison-pill
// job would be reclaimed and re-run forever. // job would be reclaimed and re-run forever.
let c = db(); let c = db();
queued(&c, JobKind::Thumbnail, 1); queued(&c, JobKind::ContentHash, 1);
for _ in 0..MAX_ATTEMPTS { for _ in 0..MAX_ATTEMPTS {
jobs::claim_next(&c, 0).unwrap().expect("claimable"); jobs::claim_next(&c, 0).unwrap().expect("claimable");
@@ -782,13 +792,49 @@ mod tests {
assert_eq!(state, JobState::Failed as i64); assert_eq!(state, JobState::Failed as i64);
} }
#[test]
fn recovery_drops_retired_kinds_every_time_and_nothing_else() {
// What 0.16.0 left behind: a thumbnail job per photograph that nothing
// would ever claim, beside live work that must survive.
let c = db();
queued(&c, JobKind::Thumbnail, 1);
queued(&c, JobKind::Thumbnail, 2);
enqueue(
&c,
JobKind::DetectFaces,
Some(1),
Priority::Background,
None,
)
.unwrap();
let first = recover(&c).unwrap();
assert_eq!(first.retired, 2);
assert!(first.did_anything());
let kinds: Vec<i64> = c
.prepare("SELECT kind FROM jobs")
.unwrap()
.query_map([], |r| r.get(0))
.unwrap()
.map(Result::unwrap)
.collect();
assert_eq!(kinds, vec![JobKind::DetectFaces as i64]);
// An older build opening the same catalog queues them again on its
// next scan. The next open by this one clears them again.
enqueue(&c, JobKind::Thumbnail, Some(1), Priority::Background, None).unwrap();
assert_eq!(recover(&c).unwrap().retired, 1);
assert_eq!(recover(&c).unwrap(), Recovered::default());
}
#[test] #[test]
fn recovery_drops_jobs_whose_photograph_is_gone() { fn recovery_drops_jobs_whose_photograph_is_gone() {
// A culled library leaves thumbnail jobs for images deleted months // A culled library leaves thumbnail jobs for images deleted months
// ago. Every one would be claimed, run and failed. // ago. Every one would be claimed, run and failed.
let c = db(); let c = db();
queued(&c, JobKind::Thumbnail, 1); queued(&c, JobKind::ContentHash, 1);
queued(&c, JobKind::Thumbnail, 2); queued(&c, JobKind::ContentHash, 2);
c.execute("DELETE FROM images WHERE id = 2", []).unwrap(); c.execute("DELETE FROM images WHERE id = 2", []).unwrap();
let recovered = recover(&c).unwrap(); let recovered = recover(&c).unwrap();
@@ -804,7 +850,7 @@ mod tests {
#[test] #[test]
fn a_quiet_startup_recovers_nothing() { fn a_quiet_startup_recovers_nothing() {
let c = db(); let c = db();
queued(&c, JobKind::Thumbnail, 1); queued(&c, JobKind::ContentHash, 1);
assert_eq!(recover(&c).unwrap(), Recovered::default()); assert_eq!(recover(&c).unwrap(), Recovered::default());
assert!(!recover(&c).unwrap().did_anything()); assert!(!recover(&c).unwrap().did_anything());
} }
+304 -26
View File
@@ -15,7 +15,7 @@ use rusqlite::Connection;
use crate::error::CatalogError; use crate::error::CatalogError;
/// Schema version this build writes and understands. /// Schema version this build writes and understands.
pub const SCHEMA_VERSION: i64 = 18; pub const SCHEMA_VERSION: i64 = 20;
/// Apply migrations up to [`SCHEMA_VERSION`]. /// Apply migrations up to [`SCHEMA_VERSION`].
/// ///
@@ -181,9 +181,105 @@ pub fn migrate(conn: &Connection) -> Result<i64, CatalogError> {
tx.commit()?; tx.commit()?;
} }
if from < 19 {
let tx = conn.unchecked_transaction()?;
tx.execute_batch(V19)?;
tx.pragma_update(None, "user_version", 19)?;
tx.commit()?;
}
if from < 20 {
let tx = conn.unchecked_transaction()?;
v20_markers_name_the_detector_that_found_the_faces(&tx)?;
tx.pragma_update(None, "user_version", 20)?;
tx.commit()?;
}
Ok(from) Ok(from)
} }
// V20 -- TRACES: FR-CAT-7
//
// Run markers that named the wrong detector, put right.
//
// `faces::record_updates` -- the write behind the quality, eye and crop
// passes -- re-marked an image under the pipeline the pass ran as, while
// the faces it had updated kept the id of the detector that found them.
// A marker of `scrfd_10g+w600k_mbf` over faces spelled `w600k_mbf` reads,
// to every consumer, as the thorough detector having examined the image:
// the upgrade repair skips it, and `face_shard::export_to_shards` selects
// its faces by the marker's id, finds none, and tells every other device
// that the thorough detector found nothing there. The desktop's shard index
// held 54 such entries over photographs with named faces, and the tablet's
// eye pass over faces it had adopted from the desktop had made 430 more.
//
// The write is fixed to keep the marker under the faces' own id. This puts
// the markers already written right, with a fresh time so the export sends
// each image again under an entry newer than the empty one -- which is what
// `held_model` orders by. Where the right marker is still there beside the
// wrong one (the old write inserted rather than replaced), 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, and a device that has neither
// would take the empty one. An image V14 left with faces and no marker at
// all is not touched: that state is the quality pass's cue, and the fixed
// write marks it correctly when the pass reaches it.
//
// Restated in Rust rather than SQL because the embedder half of a pipeline
// id is `faces::embedder_sql`, which this must agree with.
fn v20_markers_name_the_detector_that_found_the_faces(tx: &Connection) -> Result<(), CatalogError> {
let fi = crate::faces::embedder_sql("face_index.model_id");
let f = crate::faces::embedder_sql("f.model_id");
// A marker is wrong when the image holds faces of its embedder under
// another id. First the wrong ones that sit beside a right one -- the
// update below would collide with it -- then the rest are renamed.
let wrong = format!(
"EXISTS (SELECT 1 FROM faces f
WHERE f.image_id = face_index.image_id
AND {f} = {fi}
AND f.model_id != face_index.model_id)"
);
let found_by = format!(
"(SELECT MIN(f.model_id) FROM faces f
WHERE f.image_id = face_index.image_id AND {f} = {fi})"
);
let now = crate::faces::now_secs();
tx.execute(
&format!(
"UPDATE face_index
SET indexed_at = ?1
WHERE model_id = {found_by}
AND EXISTS (SELECT 1 FROM face_index w
WHERE w.image_id = face_index.image_id
AND w.model_id != face_index.model_id
AND {} = {fi})",
crate::faces::embedder_sql("w.model_id")
),
[now],
)?;
tx.execute(
&format!(
"DELETE FROM face_index
WHERE {wrong}
AND EXISTS (SELECT 1 FROM face_index o
WHERE o.image_id = face_index.image_id
AND o.model_id = {found_by})"
),
[],
)?;
tx.execute(
&format!(
"UPDATE face_index
SET model_id = {found_by},
faces_found = (SELECT COUNT(*) FROM faces f
WHERE f.image_id = face_index.image_id AND {f} = {fi}),
indexed_at = ?1
WHERE {wrong}"
),
[now],
)?;
Ok(())
}
/// The seven columns V16 adds to `faces`, in the order the readers name them. /// The seven columns V16 adds to `faces`, in the order the readers name them.
/// ///
/// Named once because three places have to agree on them: this migration, /// Named once because three places have to agree on them: this migration,
@@ -205,8 +301,11 @@ pub const EYE_COLUMNS: [&str; 7] = [
/// silently partial — present, queryable, and wrong — which is worse than /// silently partial — present, queryable, and wrong — which is worse than
/// missing, because nothing signals that they need attention. /// missing, because nothing signals that they need attention.
/// ///
/// Cheap enough to run on every open: each pass is one indexed UPDATE, and /// Idempotent: re-running it is a no-op once the values are already right.
/// re-running it is a no-op once the values are already right. /// [`crate::Catalog::open`] runs it once per catalog state rather than on
/// every open — each pass scans a whole table, and together they were most of
/// what an open cost — and `backfilled` in this crate says what counts as a
/// new state.
/// ///
/// Returns how many rows each backfill touched, for logging. /// Returns how many rows each backfill touched, for logging.
pub fn backfill(conn: &Connection) -> Result<Vec<(&'static str, usize)>, CatalogError> { pub fn backfill(conn: &Connection) -> Result<Vec<(&'static str, usize)>, CatalogError> {
@@ -398,15 +497,51 @@ fn rewrite_for_attached(sql: &str, schema_name: &str) -> String {
fn pair_raw_and_jpeg(conn: &Connection) -> Result<usize, CatalogError> { fn pair_raw_and_jpeg(conn: &Connection) -> Result<usize, CatalogError> {
use std::collections::HashMap; use std::collections::HashMap;
// (folder, lowercase stem) -> RAW id, built in one pass over the RAWs. // The small side first: the JPEGs not yet paired. On a settled library
// these are the ones with no RAW beside them -- 1,900 of 24,000 on the
// reference library -- and this runs on every open, including the ones
// the develop view makes for each photograph it fetches. Reading every
// RAW to find the handful that share a folder with one of them was most
// of what opening the catalog cost.
let jpegs: Vec<(i64, Option<i64>, String)> = {
let mut stmt = conn.prepare(
"SELECT id, folder_id, source_ref FROM images
WHERE lower(format) IN ('jpg','jpeg') AND shadowed_by IS NULL",
)?;
let rows = stmt.query_map([], |r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, Option<i64>>(1)?,
r.get::<_, String>(2)?,
))
})?;
rows.filter_map(Result::ok).collect()
};
if jpegs.is_empty() {
return Ok(0);
}
// (folder, lowercase stem) -> RAW id, over the folders those JPEGs are in
// and no others: a pair is same-folder by definition. Ordered by id so
// that where two RAWs share a stem the later one wins, as it did when this
// read every RAW in table order.
let mut folders: Vec<i64> = jpegs.iter().filter_map(|(_, f, _)| *f).collect();
folders.sort_unstable();
folders.dedup();
let unfiled = jpegs.iter().any(|(_, f, _)| f.is_none());
let folders = serde_json::to_string(&folders).unwrap_or_else(|_| "[]".to_string());
let mut raws: HashMap<(Option<i64>, String), i64> = HashMap::new(); let mut raws: HashMap<(Option<i64>, String), i64> = HashMap::new();
{ {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT id, folder_id, source_ref FROM images "SELECT id, folder_id, source_ref FROM images
WHERE lower(format) IN WHERE lower(format) IN
('cr2','cr3','nef','arw','raf','rw2','orf','dng')", ('cr2','cr3','nef','arw','raf','rw2','orf','dng')
AND (folder_id IN (SELECT value FROM json_each(?1))
OR (?2 AND folder_id IS NULL))
ORDER BY id",
)?; )?;
let rows = stmt.query_map([], |r| { let rows = stmt.query_map(rusqlite::params![folders, unfiled], |r| {
Ok(( Ok((
r.get::<_, i64>(0)?, r.get::<_, i64>(0)?,
r.get::<_, Option<i64>>(1)?, r.get::<_, Option<i64>>(1)?,
@@ -422,25 +557,16 @@ fn pair_raw_and_jpeg(conn: &Connection) -> Result<usize, CatalogError> {
return Ok(0); return Ok(0);
} }
let pairs: Vec<(i64, i64)> = { let pairs: Vec<(i64, i64)> = jpegs
let mut stmt = conn.prepare( .iter()
"SELECT id, folder_id, source_ref FROM images .filter_map(|(id, folder, path)| {
WHERE lower(format) IN ('jpg','jpeg') AND shadowed_by IS NULL", let raw = raws.get(&(*folder, stem_of(path).to_ascii_lowercase()))?;
)?; Some((*id, *raw))
let rows = stmt.query_map([], |r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, Option<i64>>(1)?,
r.get::<_, String>(2)?,
))
})?;
rows.filter_map(|row| {
let (id, folder, path) = row.ok()?;
let raw = raws.get(&(folder, stem_of(&path).to_ascii_lowercase()))?;
Some((id, *raw))
}) })
.collect() .collect();
}; if pairs.is_empty() {
return Ok(0);
}
let tx = conn.unchecked_transaction()?; let tx = conn.unchecked_transaction()?;
for (jpeg, raw) in &pairs { for (jpeg, raw) in &pairs {
@@ -766,6 +892,44 @@ CREATE TABLE IF NOT EXISTS xmp_conflicts (
); );
"#; "#;
// V19 -- TRACES: NFR-P9
//
// The indexes the repair counts are served from, and V17's lesson applied
// to the rest of the face columns.
//
// "How many images still owe a quality reading" was answered per image: a
// correlated EXISTS over `faces` that had to open each face's row to look
// at one nullable column -- the row being eight kilobytes of embedding and
// crop. Six such counts run every time the Identity screen opens and every
// time a sweep ends, 160 ms of them on the reference library. Three
// partial indexes hold only the faces still owing each pass, keyed by the
// image and carrying the model id the predicate also reads, so the count
// walks a few thousand index entries and touches no row at all -- and each
// index shrinks to nothing as its pass completes. The planner takes them
// when the count is driven from `faces` (`repairs::count`) and ignores
// them inside the per-image EXISTS, which is why that function has two
// spellings of the same predicate.
//
// `faces_image_model` replaces `faces_image`: the same key with the model
// id beside it, so "does this image hold this embedder's faces" -- asked in
// the audit, the proxy repair and the outstanding-detection count -- is an
// index-only probe where it used to read the row for the model id. Every
// lookup that used `faces_image` is served by its prefix.
//
// Not applied to attached catalogs, like V7 and V17: an index is a local
// concern, and a merge never runs these queries across an attachment.
const V19: &str = r#"
CREATE INDEX IF NOT EXISTS faces_image_model ON faces(image_id, model_id);
DROP INDEX IF EXISTS faces_image;
CREATE INDEX IF NOT EXISTS faces_owed_quality ON faces(image_id, model_id)
WHERE quality IS NULL;
CREATE INDEX IF NOT EXISTS faces_owed_crop ON faces(image_id, model_id)
WHERE crop IS NULL;
CREATE INDEX IF NOT EXISTS faces_owed_eyes ON faces(image_id, model_id)
WHERE eye_right IS NULL OR landmarks_dense IS NULL;
"#;
// V18 -- TRACES: FR-CULL-8a | FR-CULL-12 // V18 -- TRACES: FR-CULL-8a | FR-CULL-12
// //
// The 106 dense landmarks the eye pass reads its eye boxes from, kept beside // The 106 dense landmarks the eye pass reads its eye boxes from, kept beside
@@ -875,7 +1039,7 @@ CREATE INDEX face_index_model ON face_index(model_id);
const V8: &str = r#" const V8: &str = r#"
-- TRACES: FR-CULL-8 | FR-CULL-9 | FR-CULL-10 | FR-CULL-11 | FR-CULL-12 | NFR-SEC-5 -- TRACES: FR-CULL-8 | FR-CULL-9 | FR-CULL-10 | FR-CULL-11 | FR-CULL-12 | NFR-SEC-5
-- People and faces (docs/faces.md, docs/catalog.md §10). -- People and faces (docs/dev/faces.md, docs/dev/catalog.md §10).
-- --
-- Everything here is **derived data** except one column. Faces, landmarks, -- Everything here is **derived data** except one column. Faces, landmarks,
-- embeddings, cluster assignments and suggestions are all reproducible by -- embeddings, cluster assignments and suggestions are all reproducible by
@@ -912,7 +1076,7 @@ CREATE TABLE faces (
landmarks BLOB NOT NULL, -- 5 x (x, y) f32, normalised likewise landmarks BLOB NOT NULL, -- 5 x (x, y) f32, normalised likewise
detector_confidence REAL NOT NULL, detector_confidence REAL NOT NULL,
embedding BLOB NOT NULL, -- 512 x f16; unit length until V14, raw since embedding BLOB NOT NULL, -- 512 x f16; unit length until V14, raw since
-- Source pixels across the aligned 112x112 crop (docs/faces.md §7). -- Source pixels across the aligned 112x112 crop (docs/dev/faces.md §7).
-- --
-- Not cosmetic: it is the honest quality signal for the UI, a feature in -- Not cosmetic: it is the honest quality signal for the UI, a feature in
-- the §8 calibration -- FR-CULL-9 names face size as an axis along which an -- the §8 calibration -- FR-CULL-9 names face size as an axis along which an
@@ -1448,6 +1612,36 @@ mod tests {
assert_eq!(backfilled(&c, "shadowed_by"), 0); assert_eq!(backfilled(&c, "shadowed_by"), 0);
} }
#[test]
fn a_pair_is_found_among_other_folders_and_unfiled_images() {
// The RAWs are read only from the folders an unpaired JPEG is in,
// plus the unfiled ones when an unfiled JPEG is waiting: each JPEG
// must still find its own sibling, and only its own.
let c = with_root();
let raw_a = image(&c, Some(1), "a/IMG_7.CR2", "cr2");
image(&c, Some(2), "b/IMG_7.CR2", "cr2");
image(&c, Some(2), "b/IMG_8.CR2", "cr2");
let raw_unfiled = image(&c, None, "IMG_9.DNG", "dng");
let jpeg_a = image(&c, Some(1), "a/IMG_7.JPG", "jpg");
let jpeg_unfiled = image(&c, None, "IMG_9.jpg", "jpg");
image(&c, Some(1), "a/IMG_9.jpg", "jpg");
assert_eq!(backfilled(&c, "shadowed_by"), 2);
let of = |id: i64| -> Option<i64> {
c.query_row("SELECT shadowed_by FROM images WHERE id = ?1", [id], |r| {
r.get(0)
})
.unwrap()
};
assert_eq!(of(jpeg_a), Some(raw_a));
assert_eq!(of(jpeg_unfiled), Some(raw_unfiled));
assert_eq!(
backfilled(&c, "shadowed_by"),
0,
"settled on the second pass"
);
}
#[test] #[test]
fn a_raw_is_never_shadowed_by_a_jpeg() { fn a_raw_is_never_shadowed_by_a_jpeg() {
// The relationship is one-way: the RAW is the photograph. // The relationship is one-way: the RAW is the photograph.
@@ -1809,6 +2003,90 @@ mod tests {
assert_eq!(faces, 2); assert_eq!(faces, 2);
} }
#[test]
fn v20_renames_markers_to_the_detector_that_found_the_faces() {
let c = mem();
c.pragma_update(None, "user_version", 0).unwrap();
migrate(&c).unwrap();
c.execute(
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'test')",
[],
)
.unwrap();
c.execute(
"INSERT INTO images(id, root_id, source_ref, added_at)
VALUES (1,1,'a',0),(2,1,'b',0),(3,1,'c',0),(4,1,'d',0),(5,1,'e',0)",
[],
)
.unwrap();
// 1: the desktop's case -- old faces, re-marked as thorough.
// 2: the tablet's case -- adopted thorough faces, re-marked int8,
// and the right marker still beside it (refreshed, so it is
// exported again over the empty entry).
// 3: right already. 4: examined and empty. 5: V14's state, faces
// and no marker.
for (image, model) in [
(1, "scrfd_10g+w600k_mbf"),
(2, "scrfd_10g_i8+w600k_mbf"),
(2, "scrfd_10g+w600k_mbf"),
(3, "scrfd_10g+w600k_mbf"),
(4, "scrfd_10g+w600k_mbf"),
] {
c.execute(
"INSERT INTO face_index(image_id, model_id, indexed_at, faces_found, source_edge)
VALUES (?1, ?2, 100, 0, 6000)",
rusqlite::params![image, model],
)
.unwrap();
}
for (image, model) in [
(1, "w600k_mbf"),
(1, "w600k_mbf"),
(2, "scrfd_10g+w600k_mbf"),
(3, "scrfd_10g+w600k_mbf"),
(5, "w600k_mbf"),
] {
c.execute(
"INSERT INTO faces
(image_id, x, y, w, h, landmarks, detector_confidence, embedding,
crop_px, model_id, detected_at)
VALUES (?1, 0.1, 0.1, 0.2, 0.2, X'00', 0.9, X'00', 180.0, ?2, 0)",
rusqlite::params![image, model],
)
.unwrap();
}
c.pragma_update(None, "user_version", 19).unwrap();
migrate(&c).unwrap();
let markers: Vec<(i64, String, i64, bool)> = c
.prepare(
"SELECT image_id, model_id, faces_found, indexed_at > 100
FROM face_index ORDER BY image_id, model_id",
)
.unwrap()
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))
.unwrap()
.map(Result::unwrap)
.collect();
assert_eq!(
markers,
vec![
(1, "w600k_mbf".to_string(), 2, true),
(2, "scrfd_10g+w600k_mbf".to_string(), 0, true),
(3, "scrfd_10g+w600k_mbf".to_string(), 0, false),
(4, "scrfd_10g+w600k_mbf".to_string(), 0, false),
]
);
// Re-enterable: nothing left to rename.
c.pragma_update(None, "user_version", 19).unwrap();
migrate(&c).unwrap();
let n: i64 = c
.query_row("SELECT count(*) FROM face_index", [], |r| r.get(0))
.unwrap();
assert_eq!(n, 4);
}
#[test] #[test]
fn job_uniqueness_coalesces_rather_than_duplicating() { fn job_uniqueness_coalesces_rather_than_duplicating() {
let c = mem(); let c = mem();
+481 -45
View File
@@ -46,18 +46,210 @@ pub fn checkpoint(conn: &Connection) -> Result<(), CatalogError> {
Ok(()) Ok(())
} }
/// Write a consistent snapshot of the catalog to `dest`, ready to upload. /// Write a consistent snapshot of the catalog to `dest`, ready to upload,
/// without the face crops.
/// ///
/// Uses the backup API rather than a filesystem copy so the snapshot is /// Built rather than copied. The snapshot is the *whole catalog* bar the
/// coherent even with writers active. Callers should still prefer a quiet /// crops, uploaded on every sync and downloaded by every device. The crops are
/// moment — this competes with background jobs for the write lock. /// most of the file (96 MB of a 158 MB reference catalog), and copying them in
/// only to delete them was most of the cost. A backup-API copy followed by
/// `UPDATE faces SET crop = NULL` and `VACUUM` wrote the file roughly three
/// times over to produce 50 MB (#71). So this creates the schema in an empty
/// file and copies every table into it with `crop` left NULL. That is one
/// pass, with nothing written that is not uploaded.
///
/// Consistency comes from doing the whole copy inside one transaction on the
/// snapshot's connection, which holds a single read snapshot of the source for
/// its duration. A writer committing meanwhile lands in the source's WAL and is
/// simply not seen, the same serialisation the backup API gave.
///
/// Crops are not lost by this: they travel in the face shards
/// ([`crate::face_shard::export_to_shards`]), which are written once and
/// downloaded once. Nothing reads a crop out of a merged remote catalog. The
/// merge reads a remote face's box and model to match it to a local one, and
/// no more. So leaving them out costs a receiving device nothing it would
/// otherwise have had. A device never adopts a downloaded catalog as its own,
/// so a fresh one gets its crops from the shards too.
///
/// The result must stay what every earlier build already merges: same schema,
/// same `user_version`, same page size and the same WAL flag in the header.
/// The `the_snapshot_*` tests pin those.
pub fn snapshot_for_upload(conn: &Connection, dest: &Path) -> Result<(), CatalogError> { pub fn snapshot_for_upload(conn: &Connection, dest: &Path) -> Result<(), CatalogError> {
let out = copy_to(conn, dest)?; // The source is read through a second connection, attached to the
strip_face_crops(&out)?; // snapshot's, so it needs to be a file. Every catalog is one.
let source = conn
.path()
.filter(|p| !p.is_empty())
.map(PathBuf::from)
.ok_or_else(|| CatalogError::Io("the catalog to snapshot has no file".into()))?;
// Not needed for consistency, since the read transaction below sees the
// WAL, but it keeps the live WAL from growing across syncs, as before.
checkpoint(conn)?;
// A leftover from a pass that died mid-build would otherwise be built on.
for stale in [
dest.to_path_buf(),
sidecar_of(dest, "-wal"),
sidecar_of(dest, "-journal"),
sidecar_of(dest, "-shm"),
] {
match std::fs::remove_file(&stale) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(CatalogError::Io(format!("{}: {e}", stale.display()))),
}
}
let out = Connection::open(dest)?;
build_snapshot(conn, &source, &out)?;
// This device's local album folders are paths and SAF grants nobody
// else can use; the merge never reads them, and the snapshot is what
// a fresh device would otherwise adopt whole.
out.execute_batch("DROP TABLE IF EXISTS album_folders")?;
verify_snapshot(&out)?; verify_snapshot(&out)?;
Ok(()) Ok(())
} }
/// `catalog.sqlite` + `-wal` → `catalog.sqlite-wal`.
fn sidecar_of(path: &Path, suffix: &str) -> PathBuf {
let mut s = path.as_os_str().to_owned();
s.push(suffix);
PathBuf::from(s)
}
/// Schema name the source catalog is attached under while a snapshot is built.
const SOURCE_SCHEMA: &str = "snap_src";
/// The body of [`snapshot_for_upload`]: fill the empty database `out` from
/// the catalog at `source`.
fn build_snapshot(conn: &Connection, source: &Path, out: &Connection) -> Result<(), CatalogError> {
// Settings that only take on an empty file, copied from the source so the
// result is the file a backup would have been.
let page_size: i64 = conn.query_row("PRAGMA main.page_size", [], |r| r.get(0))?;
let auto_vacuum: i64 = conn.query_row("PRAGMA main.auto_vacuum", [], |r| r.get(0))?;
out.pragma_update(None, "page_size", page_size)?;
out.pragma_update(None, "auto_vacuum", auto_vacuum)?;
// A scratch file, rebuilt whole on every pass and checked before upload:
// durability during the build buys nothing. MEMORY rather than OFF keeps
// ROLLBACK defined on the failure path.
out.pragma_update(None, "journal_mode", "MEMORY")?;
out.pragma_update(None, "synchronous", "OFF")?;
// The rows were checked when they were written, and the copy has them
// all by the end. The bundled SQLite turns foreign keys on by default,
// and with them a multi-row INSERT into `images` scans `images` for
// children of every row it adds (`shadowed_by` refers to the same table
// and has no index): 1.2 s of a 1.7 s snapshot on 24k images.
out.pragma_update(None, "foreign_keys", false)?;
// Bound as a parameter, so a path containing a quote cannot break out.
out.execute(
&format!("ATTACH DATABASE ?1 AS {SOURCE_SCHEMA}"),
[source.to_string_lossy().as_ref()],
)?;
let result = copy_schema_and_rows(out);
if let Err(e) = out.execute(&format!("DETACH DATABASE {SOURCE_SCHEMA}"), []) {
log::warn!("failed to detach the catalog from its snapshot: {e}");
}
result?;
// Last, and outside any transaction, which is the only place it can be
// set: the header says WAL, as every snapshot uploaded so far has.
out.pragma_update(None, "journal_mode", "WAL")?;
Ok(())
}
fn copy_schema_and_rows(out: &Connection) -> Result<(), CatalogError> {
let tx = out.unchecked_transaction()?;
// The first read of the source opens its read snapshot. Everything from
// here, schema included, is as of that one moment.
let objects: Vec<(String, String, String)> = {
let mut stmt = tx.prepare(&format!(
"SELECT type, name, sql FROM {SOURCE_SCHEMA}.sqlite_master
WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\'
ORDER BY rowid"
))?;
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?;
rows.collect::<Result<_, _>>()?
};
let user_version: i64 =
tx.query_row(&format!("PRAGMA {SOURCE_SCHEMA}.user_version"), [], |r| {
r.get(0)
})?;
let application_id: i64 =
tx.query_row(&format!("PRAGMA {SOURCE_SCHEMA}.application_id"), [], |r| {
r.get(0)
})?;
// Tables and their rows first, then indexes, triggers and views, so that
// an index is built once over the data rather than maintained per row,
// and no trigger fires on the copy. Foreign keys are off on this
// connection, so the order tables are filled in does not matter.
for (_, name, sql) in objects.iter().filter(|(k, _, _)| k == "table") {
// Verbatim: an unqualified CREATE lands in `main`, the snapshot.
tx.execute_batch(sql)?;
let columns: Vec<String> = {
let mut stmt = tx.prepare("SELECT name FROM pragma_table_info(?1, 'main')")?;
let rows = stmt.query_map([name], |r| r.get::<_, String>(0))?;
rows.collect::<Result<_, _>>()?
};
let select = columns
.iter()
.map(|c| {
if name == "faces" && c == "crop" {
"NULL".to_string()
} else {
quote_ident(c)
}
})
.collect::<Vec<_>>()
.join(", ");
let insert = columns
.iter()
.map(|c| quote_ident(c))
.collect::<Vec<_>>()
.join(", ");
let table = quote_ident(name);
tx.execute(
&format!(
"INSERT INTO main.{table} ({insert})
SELECT {select} FROM {SOURCE_SCHEMA}.{table}"
),
[],
)?;
}
// AUTOINCREMENT's counters live in a table the filter above skips; the
// CREATE of such a table makes an empty one here.
let has_sequence: bool = tx.query_row(
&format!(
"SELECT EXISTS(SELECT 1 FROM {SOURCE_SCHEMA}.sqlite_master
WHERE name = 'sqlite_sequence')"
),
[],
|r| r.get(0),
)?;
if has_sequence {
tx.execute_batch(&format!(
"DELETE FROM main.sqlite_sequence;
INSERT INTO main.sqlite_sequence SELECT * FROM {SOURCE_SCHEMA}.sqlite_sequence;"
))?;
}
for (_, _, sql) in objects.iter().filter(|(k, _, _)| k != "table") {
tx.execute_batch(sql)?;
}
tx.pragma_update(None, "user_version", user_version)?;
tx.pragma_update(None, "application_id", application_id)?;
tx.commit()?;
Ok(())
}
/// `name` as an SQL identifier, whatever it contains.
fn quote_ident(name: &str) -> String {
format!("\"{}\"", name.replace('"', "\"\""))
}
/// TRACES: NFR-R2 /// TRACES: NFR-R2
/// Refuse to hand over a snapshot that will not pass `quick_check`. /// Refuse to hand over a snapshot that will not pass `quick_check`.
/// ///
@@ -82,12 +274,12 @@ fn verify_snapshot(snapshot: &Connection) -> Result<(), CatalogError> {
/// Checkpoint, then copy the whole database to `dest`, and hand back the /// Checkpoint, then copy the whole database to `dest`, and hand back the
/// connection to the copy. /// connection to the copy.
/// ///
/// Split out from [`snapshot_for_upload`] because [`crate::recovery`] wants /// What [`crate::recovery`] takes its NFR-R2 backups with. A backup is the
/// exactly this and none of what follows it there: an NFR-R2 backup is the /// file the user may have to *live on*, so it keeps the face crops that
/// file the user may have to *live on*, so it keeps the face crops that an /// [`snapshot_for_upload`] leaves out, and a byte-for-byte page copy is the
/// upload strips. Sharing the copy rather than reimplementing it is what keeps /// right tool. Keeping it here beside the upload keeps the WAL discipline in
/// the WAL discipline in one place — a backup taken with `fs::copy` would be /// one place: a backup taken with `fs::copy` would be the torn snapshot this
/// the torn snapshot this module's header exists to warn about. /// module's header exists to warn about.
pub(crate) fn copy_to(conn: &Connection, dest: &Path) -> Result<Connection, CatalogError> { pub(crate) fn copy_to(conn: &Connection, dest: &Path) -> Result<Connection, CatalogError> {
checkpoint(conn)?; checkpoint(conn)?;
@@ -103,39 +295,6 @@ pub(crate) fn copy_to(conn: &Connection, dest: &Path) -> Result<Connection, Cata
Ok(out) Ok(out)
} }
/// Drop the stored face crops from a snapshot before it is uploaded.
///
/// The snapshot is the *whole catalog*, uploaded on every sync and downloaded
/// by every device. Face crops are a few KB each and a fully indexed library
/// holds tens of thousands of them, so leaving them in would put tens of MB on
/// every round trip — the exact cost `face_shard`'s 25 MB cap exists to bound,
/// and the reason the bulk per-face data lives in shards in the first place.
///
/// Crops are not lost by this: they travel in the face shards
/// ([`crate::face_shard::export_to_shards`]), which are written once and
/// downloaded once. Nothing reads a crop out of a merged remote catalog —
/// [`merge_all`] touches collections and keywords only — so removing them here
/// costs a receiving device nothing it would otherwise have had.
///
/// `VACUUM` afterwards because SQLite does not return freed pages to the file
/// on its own, and an upload sized by the file rather than by its contents
/// would keep paying for bytes that are no longer there.
fn strip_face_crops(snapshot: &Connection) -> Result<(), CatalogError> {
// A catalog older than the crop column is a legitimate input here — a
// snapshot taken mid-migration, or a test fixture built from an earlier
// schema — so an absent column is nothing to fail over.
let has_crop = snapshot
.prepare("SELECT crop FROM faces LIMIT 1")
.map(|_| true)
.unwrap_or(false);
if !has_crop {
return Ok(());
}
snapshot.execute("UPDATE faces SET crop = NULL WHERE crop IS NOT NULL", [])?;
snapshot.execute_batch("VACUUM")?;
Ok(())
}
/// Whether a downloaded remote catalog is worth merging. /// Whether a downloaded remote catalog is worth merging.
/// ///
/// Cheap guard before attaching: a remote written by a newer build may contain /// Cheap guard before attaching: a remote written by a newer build may contain
@@ -171,6 +330,13 @@ pub fn merge_remote(conn: &Connection, remote: &Path) -> Result<MergeReport, Cat
let result = merge::merge_all(conn); let result = merge::merge_all(conn);
// The merge brings in rows the backfill exists for — assignments whose
// word this device has no term for, from a remote older than v6 — so the
// next open must run it, whether or not the stamp happened to move.
if let Some(path) = conn.path().filter(|p| !p.is_empty()) {
crate::backfilled::forget(Path::new(path));
}
// Detach even if the merge failed, or the next attempt errors with // Detach even if the merge failed, or the next attempt errors with
// "database remote_cat is already in use". // "database remote_cat is already in use".
let detach = conn.execute(&format!("DETACH DATABASE {REMOTE_SCHEMA}"), []); let detach = conn.execute(&format!("DETACH DATABASE {REMOTE_SCHEMA}"), []);
@@ -288,6 +454,92 @@ mod tests {
assert_eq!(n, 2); assert_eq!(n, 2);
} }
/// One image, known to the server by `file_id`, in a catalog.
fn with_image(c: &Connection, file_id: i64) -> dr_types::ImageId {
c.execute(
"INSERT OR IGNORE INTO roots(id, kind, label) VALUES (1, 'remote', 'lib')",
[],
)
.unwrap();
c.execute(
"INSERT INTO images(root_id, source_ref, added_at) VALUES (1, ?1, 0)",
[format!("IMG_{file_id}.CR3")],
)
.unwrap();
let id = c.last_insert_rowid();
c.execute(
"INSERT INTO remote(image_id, file_id) VALUES (?1, ?2)",
[id, file_id],
)
.unwrap();
dr_types::ImageId(id as u64)
}
#[test]
fn an_album_and_its_exports_reach_another_device_but_its_folder_does_not() {
use crate::albums::{self, Place};
let dir = tempdir();
let remote_path = dir.join("remote.sqlite");
let snap = dir.join("snap.sqlite");
{
// The desktop: two albums, one on the server and one on its own
// disk, each with an export of the same photograph.
let r = seeded(&dir.join("desktop.sqlite"));
let img = with_image(&r, 4242);
let web = albums::create(&r, "Web", &Place::Server("Shared/Web".into())).unwrap();
let print = albums::create(&r, "Print", &Place::Local("/mnt/print".into())).unwrap();
albums::record_exports(&r, web, &[(img, "IMG_4242.jpg".into())]).unwrap();
albums::record_exports(&r, print, &[(img, "IMG_4242.tif".into())]).unwrap();
snapshot_for_upload(&r, &snap).unwrap();
std::fs::rename(&snap, &remote_path).unwrap();
}
// The tablet knows the same file under its own image id.
let local = seeded(&dir.join("tablet.sqlite"));
with_image(&local, 1);
let img = with_image(&local, 4242);
let report = merge_remote(&local, &remote_path).unwrap();
assert_eq!(report.albums_taken, 2);
assert_eq!(report.album_exports_added, 2);
assert!(report.local_changed());
let all = albums::list(&local).unwrap();
let print = all.iter().find(|a| a.name == "Print").unwrap();
let web = all.iter().find(|a| a.name == "Web").unwrap();
assert_eq!(print.place, None, "the desktop's disk is not the tablet's");
assert_eq!(web.place, Some(Place::Server("Shared/Web".into())));
assert_eq!(albums::sources(&local, web.id).unwrap(), vec![img]);
// Nothing changed on either side, so a second pass takes nothing.
let again = merge_remote(&local, &remote_path).unwrap();
assert_eq!(again.albums_taken, 0);
assert_eq!(again.album_exports_added, 0);
}
#[test]
fn a_device_that_never_made_an_album_still_uploads_this_ones() {
use crate::albums::{self, Place};
let dir = tempdir();
let remote_path = dir.join("remote.sqlite");
{
// A snapshot from a build that predates albums altogether.
let r = seeded(&remote_path);
checkpoint(&r).unwrap();
}
let local = seeded(&dir.join("local.sqlite"));
albums::create(&local, "Web", &Place::Server("Web".into())).unwrap();
let report = merge_remote(&local, &remote_path).unwrap();
assert_eq!(report.albums_taken, 0);
// Its albums table is absent, so nothing was compared — and the local
// album has still to reach the server.
assert!(
albums::list(&local).unwrap().len() == 1,
"the local album survives a merge with a catalog that has none"
);
}
#[test] #[test]
fn the_remote_can_be_merged_twice_without_attach_conflict() { fn the_remote_can_be_merged_twice_without_attach_conflict() {
// Detach must happen even on the failure path, or the second attempt // Detach must happen even on the failure path, or the second attempt
@@ -380,4 +632,188 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(kept, 1, "stripping the snapshot damaged the live catalog"); assert_eq!(kept, 1, "stripping the snapshot damaged the live catalog");
} }
/// Device-side setup for the snapshot tests: an image both devices know by
/// its cross-device file id, and one face on it carrying `crop`.
fn with_a_face(c: &Connection, face_id: i64, x: f64, crop: &[u8]) {
c.execute(
"INSERT OR IGNORE INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
[],
)
.unwrap();
c.execute(
"INSERT INTO images(id, root_id, source_ref, added_at) VALUES (1, 1, 'a.CR3', 0)",
[],
)
.unwrap();
c.execute("INSERT INTO remote(image_id, file_id) VALUES (1, 5000)", [])
.unwrap();
c.execute(
"INSERT INTO faces
(id, image_id, x, y, w, h, landmarks, detector_confidence, embedding,
crop_px, model_id, detected_at, crop)
VALUES (?1, 1, ?2, 0.2, 0.2, 0.2, X'00', 0.9, X'00', 150.0, 'w600k_mbf', 0, ?3)",
rusqlite::params![face_id, x, crop],
)
.unwrap();
}
fn crop_of(c: &Connection, face_id: i64) -> Option<Vec<u8>> {
c.query_row("SELECT crop FROM faces WHERE id = ?1", [face_id], |r| {
r.get(0)
})
.unwrap()
}
/// The snapshot is built table by table rather than copied, so what has to
/// hold is that it is still the same database bar the crops: every table,
/// index and row, and the header fields an older build checks before it
/// will merge (`user_version`) or open it the way it always has (the WAL
/// flag and page size a backup-API copy carried).
#[test]
fn the_snapshot_is_the_catalog_bar_the_crops() {
let dir = tempdir();
let live = dir.join("catalog.sqlite");
let snap = dir.join("snap.sqlite");
let c = seeded(&live);
with_a_face(&c, 7, 0.3, &[7u8; 4096]);
c.execute(
"INSERT INTO collections(uuid, name, kind, created, revision, modified)
VALUES ('u1', 'Iceland', 0, 0, 1, 1)",
[],
)
.unwrap();
// Created on first use rather than by a migration: the copy must not
// depend on the migrations knowing every table.
crate::duplicates::ensure_probe_table(&c).unwrap();
snapshot_for_upload(&c, &snap).unwrap();
let out = Connection::open(&snap).unwrap();
let objects = |conn: &Connection| -> Vec<(String, String)> {
let mut stmt = conn
.prepare("SELECT type, name FROM sqlite_master ORDER BY type, name")
.unwrap();
let rows = stmt
.query_map([], |r| Ok((r.get(0).unwrap(), r.get(1).unwrap())))
.unwrap();
rows.map(Result::unwrap).collect()
};
assert_eq!(objects(&out), objects(&c), "the snapshot's schema differs");
for (kind, table) in objects(&c) {
if kind != "table" {
continue;
}
let count = |conn: &Connection| -> i64 {
conn.query_row(&format!("SELECT COUNT(*) FROM \"{table}\""), [], |r| {
r.get(0)
})
.unwrap()
};
assert_eq!(count(&out), count(&c), "rows differ in {table}");
}
for pragma in [
"user_version",
"application_id",
"page_size",
"journal_mode",
] {
let read = |conn: &Connection| -> String {
conn.query_row(&format!("PRAGMA {pragma}"), [], |r| {
r.get::<_, rusqlite::types::Value>(0)
})
.map(|v| format!("{v:?}"))
.unwrap()
};
assert_eq!(read(&out), read(&c), "{pragma} differs");
}
drop(out);
// Bytes 18 and 19 of the header are 2 for a WAL database, which is
// what every snapshot uploaded before this one said.
let header = std::fs::read(&snap).unwrap();
assert_eq!(&header[18..20], &[2, 2], "the snapshot is not WAL-flagged");
// The face is there; its pixels are not.
let out = Connection::open(&snap).unwrap();
assert_eq!(crop_of(&out, 7), None);
}
/// A pass that died mid-build leaves a file behind; the next one must
/// build afresh rather than on top of it.
#[test]
fn a_leftover_snapshot_is_replaced_not_built_on() {
let dir = tempdir();
let live = dir.join("catalog.sqlite");
let snap = dir.join("snap.sqlite");
let c = seeded(&live);
std::fs::write(&snap, b"not a database").unwrap();
snapshot_for_upload(&c, &snap).unwrap();
// And twice over a good one, which is the steady state.
snapshot_for_upload(&c, &snap).unwrap();
let out = Connection::open(&snap).unwrap();
let v: i64 = out
.query_row("PRAGMA user_version", [], |r| r.get(0))
.unwrap();
assert_eq!(v, schema::SCHEMA_VERSION);
}
/// The merge side of a crop-less snapshot: the other device's names still
/// cross over — the match is by box, not by pixels — and this device's
/// own crop is left exactly as it was, never replaced by the snapshot's
/// NULL.
#[test]
fn merging_a_crop_less_snapshot_keeps_local_crops_and_takes_the_names() {
let dir = tempdir();
let snap = dir.join("snap.sqlite");
let desktop = seeded(&dir.join("desktop.sqlite"));
with_a_face(&desktop, 42, 0.31, &[1u8; 3000]);
desktop
.execute(
"INSERT INTO people(id, uuid, name, ignored, created, revision, modified)
VALUES (3, 'u-anna', 'Anna', 0, 0, 1, 1)",
[],
)
.unwrap();
desktop
.execute(
"INSERT INTO face_person(face_id, person_id, probability, confirmed)
VALUES (42, 3, 0.9, 1)",
[],
)
.unwrap();
snapshot_for_upload(&desktop, &snap).unwrap();
// The tablet found the same face itself, under its own row id, and has
// its own crop of it — from its own detection or from the shards.
let tablet = seeded(&dir.join("tablet.sqlite"));
let mine = vec![9u8; 2500];
with_a_face(&tablet, 7, 0.30, &mine);
let report = merge_remote(&tablet, &snap).unwrap();
assert_eq!(report.people_inserted, 1);
assert_eq!(report.faces_assigned, 1);
let named: (String, bool) = tablet
.query_row(
"SELECT p.name, fp.confirmed
FROM face_person fp JOIN people p ON p.id = fp.person_id
WHERE fp.face_id = 7",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(named, ("Anna".to_string(), true));
assert_eq!(
crop_of(&tablet, 7),
Some(mine),
"the merge touched a local crop"
);
// Idempotent over a crop-less remote too.
let again = merge_remote(&tablet, &snap).unwrap();
assert!(!again.local_changed());
}
} }
+30 -18
View File
@@ -129,28 +129,40 @@ pub fn record_trashed(
return Ok(0); return Ok(0);
} }
let tx = conn.unchecked_transaction()?; let tx = conn.unchecked_transaction()?;
let mut n = 0; let n = record_trashed_within(&tx, moved, now)?;
{
let mut stmt = tx.prepare(
"UPDATE images
SET trashed_from = CASE
WHEN trashed_at IS NULL THEN source_ref
ELSE trashed_from
END,
source_ref = ?2,
trashed_at = coalesce(trashed_at, ?3)
WHERE id = ?1",
)?;
for (image, path) in moved {
n += stmt.execute(rusqlite::params![image.0 as i64, path, now])?;
}
}
tx.commit()?; tx.commit()?;
Ok(n) Ok(n)
} }
/// [`record_trashed`] inside a transaction the caller owns.
///
/// For a caller whose trash is one half of a larger write that must land
/// whole or not at all — consolidating duplicates (`crate::duplicates`)
/// merges a copy's judgements onto the survivor and trashes the copy in one
/// commit. `unchecked_transaction` cannot nest, so this is offered here
/// rather than wrapped from above.
pub fn record_trashed_within(
tx: &Connection,
moved: &[(ImageId, String)],
now: i64,
) -> Result<usize, CatalogError> {
let mut n = 0;
let mut stmt = tx.prepare(
"UPDATE images
SET trashed_from = CASE
WHEN trashed_at IS NULL THEN source_ref
ELSE trashed_from
END,
source_ref = ?2,
trashed_at = coalesce(trashed_at, ?3)
WHERE id = ?1",
)?;
for (image, path) in moved {
n += stmt.execute(rusqlite::params![image.0 as i64, path, now])?;
}
Ok(n)
}
/// Record that images have been moved back out of the trash. /// Record that images have been moved back out of the trash.
/// ///
/// Call after the move succeeds, for the same reason as [`record_trashed`]. /// Call after the move succeeds, for the same reason as [`record_trashed`].
+21 -53
View File
@@ -48,7 +48,6 @@ use dr_types::{Availability, FormatFilter, RootId, SourceRef};
use rusqlite::{Connection, OptionalExtension}; use rusqlite::{Connection, OptionalExtension};
use crate::error::CatalogError; use crate::error::CatalogError;
use crate::jobs::{self, JobKind, Priority};
use crate::query::availability_code; use crate::query::availability_code;
use crate::scan::{ use crate::scan::{
classify_dir, classify_entry, DirAction, DirState, EntryAction, KnownFile, ScanOutcome, classify_dir, classify_entry, DirAction, DirState, EntryAction, KnownFile, ScanOutcome,
@@ -336,7 +335,7 @@ pub fn scan_root(
} }
} }
EntryAction::Insert => { EntryAction::Insert => {
let id = insert_image( insert_image(
&tx, &tx,
root, root,
folder_id, folder_id,
@@ -345,11 +344,10 @@ pub fn scan_root(
entry.meta.mtime, entry.meta.mtime,
now, now,
)?; )?;
queue_reading_it(&tx, id)?;
report.inserted += 1; report.inserted += 1;
} }
EntryAction::Changed => { EntryAction::Changed => {
let id = update_image( update_image(
&tx, &tx,
root, root,
folder_id, folder_id,
@@ -357,7 +355,6 @@ pub fn scan_root(
entry.meta.size, entry.meta.size,
entry.meta.mtime, entry.meta.mtime,
)?; )?;
queue_reading_it(&tx, id)?;
report.updated += 1; report.updated += 1;
} }
EntryAction::Ignored => unreachable!("returned above"), EntryAction::Ignored => unreachable!("returned above"),
@@ -640,7 +637,7 @@ fn insert_image(
size: u64, size: u64,
mtime: i64, mtime: i64,
now: i64, now: i64,
) -> Result<i64, CatalogError> { ) -> Result<(), CatalogError> {
// `metadata_state = 1`: the scan knows the name, the size and the mtime, // `metadata_state = 1`: the scan knows the name, the size and the mtime,
// and has read no EXIF. Claiming otherwise would make a date filter // and has read no EXIF. Claiming otherwise would make a date filter
// silently wrong on a freshly scanned library. // silently wrong on a freshly scanned library.
@@ -664,7 +661,7 @@ fn insert_image(
now, now,
], ],
)?; )?;
image_id(conn, root, src) Ok(())
} }
fn update_image( fn update_image(
@@ -674,7 +671,7 @@ fn update_image(
src: &SourceRef, src: &SourceRef,
size: u64, size: u64,
mtime: i64, mtime: i64,
) -> Result<i64, CatalogError> { ) -> Result<(), CatalogError> {
// The content hash is dropped, not recomputed: it described bytes that no // The content hash is dropped, not recomputed: it described bytes that no
// longer exist, and leaving it would let reconnect-by-hash match this image // longer exist, and leaving it would let reconnect-by-hash match this image
// to a file it is no longer a copy of. `metadata_state` goes back to 1 for // to a file it is no longer a copy of. `metadata_state` goes back to 1 for
@@ -692,37 +689,7 @@ fn update_image(
src.key(), src.key(),
], ],
)?; )?;
image_id(conn, root, src) Ok(())
}
fn image_id(conn: &Connection, root: RootId, src: &SourceRef) -> Result<i64, CatalogError> {
Ok(conn.query_row(
"SELECT id FROM images WHERE root_id = ?1 AND source_ref = ?2",
rusqlite::params![root.0 as i64, src.key()],
|r| r.get(0),
)?)
}
/// Queue the work that turns a stat-only row into a usable grid cell.
///
/// Enqueued inside the scan's transaction, so a folder's rows and the jobs that
/// finish them land together — a crash between the two would otherwise leave
/// images no worker was ever told about.
fn queue_reading_it(conn: &Connection, image_id: i64) -> Result<(), CatalogError> {
jobs::enqueue(
conn,
JobKind::ExtractMetadata,
Some(image_id),
Priority::Background,
None,
)?;
jobs::enqueue(
conn,
JobKind::Thumbnail,
Some(image_id),
Priority::Background,
None,
)
} }
/// TRACES: FR-CAT-9 /// TRACES: FR-CAT-9
@@ -1096,7 +1063,7 @@ mod tests {
} }
#[test] #[test]
fn a_resaved_file_is_queued_for_rereading_and_loses_its_stale_hash() { fn a_resaved_file_owes_a_reread_and_loses_its_stale_hash() {
let lib = Library::new("resaved"); let lib = Library::new("resaved");
lib.file("IMG.CR3", b"raw"); lib.file("IMG.CR3", b"raw");
lib.scan(); lib.scan();
@@ -1121,9 +1088,8 @@ mod tests {
"a hash of bytes that no longer exist would match this image to the \ "a hash of bytes that no longer exist would match this image to the \
wrong file on reconnect" wrong file on reconnect"
); );
assert_eq!(lib.count("SELECT metadata_state FROM images"), 1);
assert_eq!( assert_eq!(
lib.count("SELECT count(*) FROM jobs WHERE kind = 1"), lib.count("SELECT metadata_state FROM images"),
1, 1,
"EXIF must be re-read" "EXIF must be re-read"
); );
@@ -1137,7 +1103,11 @@ mod tests {
let lib = Library::new("no-requeue"); let lib = Library::new("no-requeue");
lib.file("a.CR3", b"raw"); lib.file("a.CR3", b"raw");
lib.scan(); lib.scan();
lib.conn().execute("DELETE FROM jobs", []).unwrap(); // As if the metadata sweep had read it: what is owed is recorded in
// `metadata_state`, and the thumbnail store answers for itself.
lib.conn()
.execute("UPDATE images SET metadata_state = 2", [])
.unwrap();
lib.file("b.CR3", b"raw"); lib.file("b.CR3", b"raw");
let r = lib.scan(); let r = lib.scan();
@@ -1145,9 +1115,9 @@ mod tests {
assert_eq!(r.inserted, 1); assert_eq!(r.inserted, 1);
assert_eq!(r.unchanged, 1); assert_eq!(r.unchanged, 1);
assert_eq!( assert_eq!(
lib.count("SELECT count(*) FROM jobs"), lib.count("SELECT count(*) FROM images WHERE metadata_state < 2"),
2, 1,
"EXIF and a thumbnail for the new image, and nothing for the old one" "EXIF owed for the new image, and nothing for the old one"
); );
} }
@@ -1171,17 +1141,15 @@ mod tests {
} }
#[test] #[test]
fn a_new_image_is_queued_for_a_thumbnail_and_for_exif() { fn a_new_image_owes_its_exif_and_queues_nothing() {
// The sweeps find their work from `metadata_state` and the thumbnail
// store. A queued job would be a second record of the same debt, and
// no handler claims one (#73).
let lib = Library::new("queued"); let lib = Library::new("queued");
lib.file("IMG.CR3", b"raw"); lib.file("IMG.CR3", b"raw");
lib.scan(); lib.scan();
assert_eq!( assert_eq!(lib.count("SELECT count(*) FROM jobs"), 0);
lib.count("SELECT count(*) FROM jobs WHERE kind = 2"),
1,
"no thumbnail job means an empty grid cell forever"
);
assert_eq!(lib.count("SELECT count(*) FROM jobs WHERE kind = 1"), 1);
assert_eq!( assert_eq!(
lib.count("SELECT metadata_state FROM images"), lib.count("SELECT metadata_state FROM images"),
1, 1,
@@ -0,0 +1,341 @@
// TRACES: FR-PLAT-AND-3
//! No job kind is enqueued without something that claims it.
//!
//! The queue coalesces, so a producer with no consumer does not fail — it
//! just leaves a row per subject for ever. That is how the reference catalog
//! came to hold 23,582 `Thumbnail` jobs, one per photograph, re-coalesced on
//! every scan, with no handler for the kind anywhere in the tree (#73). Nothing
//! at runtime notices: the rows are cheap one at a time and invisible in the
//! interface. So the pairing is checked here, over the source, instead.
//!
//! ## What counts
//!
//! In shipping code under `core/`, `ui/`, `apps/` and `platform/` — every
//! `src/` tree, with `#[cfg(test)]` items dropped:
//!
//! - **Enqueued**: the `JobKind::X` named in the arguments of a call to
//! `enqueue(`. An enqueue whose kind is not spelled there — passed in a
//! variable — is refused outright, because this scan could not say what it
//! queues.
//! - **Claimed**: the `JobKind::X` in the body of a `fn kinds(` (what a
//! `JobHandler` declares, and all a `Runner` claims), or named in a call to
//! `claim_next_matching(`. A call to `claim_next(` claims every kind.
//! `JobKind::ALL` in either place means every kind.
//!
//! Tests and examples are left out on purpose: a unit test of the queue's
//! mechanics enqueues and claims whatever it likes, and proves nothing about
//! the app.
use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
/// Every `.rs` file under each `src/` of each crate in `group`.
fn crate_sources(group: &Path, out: &mut Vec<PathBuf>) {
let Ok(crates) = fs::read_dir(group) else {
return;
};
for krate in crates {
let src = krate.expect("read dir entry").path().join("src");
if src.is_dir() {
rust_files(&src, out);
}
}
}
fn rust_files(dir: &Path, out: &mut Vec<PathBuf>) {
for entry in fs::read_dir(dir).unwrap_or_else(|e| panic!("cannot read {}: {e}", dir.display()))
{
let path = entry.expect("read dir entry").path();
if path.is_dir() {
rust_files(&path, out);
} else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
out.push(path);
}
}
}
/// Blank out string literals and line comments, so neither a brace nor a
/// `JobKind::` inside prose is read as code.
fn strip_literals_and_comments(line: &str) -> String {
let mut out = String::with_capacity(line.len());
let mut chars = line.chars().peekable();
let mut in_string = false;
while let Some(c) = chars.next() {
if in_string {
match c {
'\\' => {
chars.next();
}
'"' => in_string = false,
_ => {}
}
continue;
}
match c {
'"' => in_string = true,
'/' if chars.peek() == Some(&'/') => break,
_ => out.push(c),
}
}
out
}
/// The shipping code of a file, comments and strings blanked, with every
/// `#[cfg(test)]` item dropped. The attribute must be the whole line, so a
/// doc comment mentioning it is not mistaken for one.
fn shipping_code(text: &str) -> String {
let lines: Vec<String> = text.lines().map(strip_literals_and_comments).collect();
let mut out = String::new();
let mut i = 0;
while i < lines.len() {
if lines[i].trim() != "#[cfg(test)]" {
out.push_str(&lines[i]);
out.push('\n');
i += 1;
continue;
}
let (mut j, mut depth, mut opened) = (i + 1, 0i32, false);
while j < lines.len() {
depth += lines[j].matches('{').count() as i32;
depth -= lines[j].matches('}').count() as i32;
opened |= lines[j].contains('{');
if (opened && depth <= 0) || (!opened && lines[j].contains(';')) {
break;
}
j += 1;
}
i = j + 1;
}
out
}
/// The text from `start` (just past an opening delimiter) to its matching
/// close.
fn balanced(code: &str, start: usize, open: char, close: char) -> &str {
let mut depth = 1;
for (i, c) in code[start..].char_indices() {
if c == open {
depth += 1;
} else if c == close {
depth -= 1;
if depth == 0 {
return &code[start..start + i];
}
}
}
&code[start..]
}
/// Each call of `name(` in `code` that is a call rather than the function's
/// own definition or a longer name ending in it, as its argument text.
fn calls<'a>(code: &'a str, name: &str) -> Vec<&'a str> {
let needle = format!("{name}(");
let mut found = Vec::new();
for (at, _) in code.match_indices(&needle) {
let before = &code[..at];
let prev = before.chars().next_back();
if prev.is_some_and(|c| c.is_alphanumeric() || c == '_') {
continue;
}
if before.trim_end().ends_with("fn") {
continue;
}
found.push(balanced(code, at + needle.len(), '(', ')'));
}
found
}
/// Bodies of every `fn kinds(` that has one — a trait declaration ending in
/// `;` has none.
fn kinds_bodies(code: &str) -> Vec<&str> {
let mut found = Vec::new();
for (at, _) in code.match_indices("fn kinds(") {
let rest = &code[at..];
let (Some(brace), semi) = (rest.find('{'), rest.find(';')) else {
continue;
};
if semi.is_some_and(|s| s < brace) {
continue;
}
found.push(balanced(code, at + brace + 1, '{', '}'));
}
found
}
/// The `X` of each `JobKind::X` in `text`.
fn kinds_named(text: &str) -> Vec<String> {
text.match_indices("JobKind::")
.map(|(at, m)| {
text[at + m.len()..]
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect()
})
.collect()
}
#[derive(Default, Debug)]
struct Ledger {
/// Kind → where it is enqueued.
enqueued: Vec<(String, String)>,
/// Enqueue calls whose kind could not be read.
unreadable: Vec<String>,
claimed: BTreeSet<String>,
claims_everything: bool,
}
fn read(files: &[(String, String)]) -> Ledger {
let mut ledger = Ledger::default();
for (name, text) in files {
let code = shipping_code(text);
for args in calls(&code, "enqueue") {
let kinds = kinds_named(args);
if kinds.is_empty() {
ledger
.unreadable
.push(format!("{name}: enqueue({})", args.trim()));
}
for k in kinds {
ledger.enqueued.push((k, name.clone()));
}
}
let claimed = kinds_bodies(&code)
.into_iter()
.chain(calls(&code, "claim_next_matching"));
for text in claimed {
for k in kinds_named(text) {
if k == "ALL" {
ledger.claims_everything = true;
} else {
ledger.claimed.insert(k);
}
}
}
if !calls(&code, "claim_next").is_empty() {
ledger.claims_everything = true;
}
}
ledger
}
#[test]
fn every_kind_enqueued_is_claimed_by_something() {
let repo = Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(Path::parent)
.expect("core/dr-catalog has a grandparent");
let mut paths = Vec::new();
for group in ["core", "ui", "apps", "platform"] {
crate_sources(&repo.join(group), &mut paths);
}
let files: Vec<(String, String)> = paths
.iter()
.map(|p| {
let text = fs::read_to_string(p)
.unwrap_or_else(|e| panic!("cannot read {}: {e}", p.display()));
let name = p.strip_prefix(repo).unwrap_or(p).display().to_string();
(name, text)
})
.collect();
// A scan over nothing passes for the wrong reason. The queue's own file
// and the scan that used to feed it must both have been read, and the
// queue's definitions found in them.
for must in [
"core/dr-catalog/src/jobs.rs",
"core/dr-catalog/src/runner.rs",
"ui/dr-ui/src/library/scan.rs",
] {
assert!(
files.iter().any(|(n, _)| n == must),
"{must} was not scanned — the source walk is wrong, not the code"
);
}
let jobs = &files
.iter()
.find(|(n, _)| n == "core/dr-catalog/src/jobs.rs")
.unwrap()
.1;
assert!(shipping_code(jobs).contains("pub fn enqueue("));
let ledger = read(&files);
assert!(
ledger.unreadable.is_empty(),
"\n\nThese enqueue calls do not name their JobKind, so this test cannot \
check that anything claims it. Spell the kind at the call:\n {}\n",
ledger.unreadable.join("\n ")
);
if ledger.claims_everything {
return;
}
let orphans: Vec<String> = ledger
.enqueued
.iter()
.filter(|(k, _)| !ledger.claimed.contains(k))
.map(|(k, at)| format!("JobKind::{k}, enqueued in {at}"))
.collect();
assert!(
orphans.is_empty(),
"\n\nEnqueued, and claimed by nothing (claimed: {:?}):\n {}\n\n\
A kind nobody claims is a row per subject that stays for ever — the \
queue coalesces, so it never fails, it only grows (#73). Register a \
JobHandler for the kind, or stop enqueueing it and add it to \
JobKind::RETIRED so the rows already queued are dropped.\n",
ledger.claimed,
orphans.join("\n ")
);
}
/// The reader itself, on code whose answer is known — so a parsing bug shows
/// up as this failing, rather than the real check quietly finding nothing.
#[test]
fn the_reader_sees_producers_and_consumers() {
let producer = r#"
use dr_catalog::jobs;
fn persist(tx: &Connection, id: i64) {
// jobs::enqueue(tx, JobKind::ContentHash, ...) in a comment is not a call
let _ = dr_catalog::jobs::enqueue(
tx,
JobKind::Thumbnail,
Some(id),
Priority::Background,
None,
);
jobs::enqueue(tx, kind, Some(id), Priority::Background, None)?;
}
pub fn enqueue(conn: &Connection, kind: JobKind) {}
#[cfg(test)]
mod tests {
fn t() { enqueue(&c, JobKind::FetchOriginal, None, P, None); }
}
"#;
let consumer = r#"
impl JobHandler for Faces {
fn kinds(&self) -> &[JobKind] {
&[JobKind::DetectFaces]
}
fn run(&mut self) {}
}
trait JobHandler { fn kinds(&self) -> &[JobKind]; }
fn pull(c: &Connection) { claim_next_matching(c, 0, &[JobKind::FetchPreview]); }
"#;
let ledger = read(&[
("producer.rs".into(), producer.into()),
("consumer.rs".into(), consumer.into()),
]);
let enqueued: Vec<&str> = ledger.enqueued.iter().map(|(k, _)| k.as_str()).collect();
assert_eq!(enqueued, vec!["Thumbnail"]);
assert_eq!(ledger.unreadable.len(), 1, "{:?}", ledger.unreadable);
assert_eq!(
ledger.claimed,
BTreeSet::from(["DetectFaces".to_string(), "FetchPreview".to_string()])
);
assert!(!ledger.claims_everything);
}
+121
View File
@@ -0,0 +1,121 @@
//! TRACES: FR-RAW-2
//! The seam a second decoder plugs into.
//!
//! D2 keeps LibRaw as the fallback for bodies rawler does not cover. Adding
//! it later should be a new `impl Decoder`, not an edit to every caller that
//! reads a header, cuts a thumbnail or opens a photograph for export — which
//! is what the free functions alone would have made it. So the callers take a
//! `&dyn Decoder`, and only the places that start a job name [`default`].
//!
//! Bytes in, always. Nothing here takes a path or a `SourceRef`: resolving a
//! file to bytes is `Storage`'s job at the caller, so the same decoder serves a
//! local file, an Android document and a range fetched from Nextcloud. The
//! decoder's part in that is to say how much of a file it needs
//! ([`Decoder::header_bytes`]) and where its preview sits
//! ([`Decoder::locate_preview`]); the storage layer fetches exactly that.
//!
//! What stays a free function is what is not a decoder's to vary: recognising
//! a JPEG ([`crate::probe`]), decoding one ([`crate::decode_jpeg`]) and
//! checking one is whole ([`crate::is_complete_jpeg`]). A second RAW decoder
//! would not read a JPEG differently.
use dr_types::Orientation;
use crate::{DecodeError, Metadata, Preview, PreviewLocation, PreviewSize, RawImage};
/// TRACES: FR-RAW-2
/// A RAW decoder, over bytes.
///
/// Object-safe so a caller can hold `&dyn Decoder` without becoming generic,
/// `Send + Sync` because the callers that need one most — the thumbnail
/// lanes, the export worker — run off the UI thread, and `Debug` so a job
/// description that carries one can still be printed.
pub trait Decoder: Send + Sync + std::fmt::Debug {
/// How much of the start of a file [`Self::metadata`] and
/// [`Self::locate_preview`] need. A caller reading over a network fetches
/// this range and no more.
fn header_bytes(&self) -> u64;
/// Capture metadata, from a header or a whole file, without touching
/// sensor data.
fn metadata(&self, bytes: &[u8]) -> Result<Metadata, DecodeError>;
/// How the stored pixels are turned, from a header. `None` where the file
/// does not say, which callers take as upright.
fn orientation(&self, header: &[u8]) -> Option<Orientation>;
/// Where the embedded preview best suited to a thumbnail sits in the file,
/// from its header, so a remote caller can fetch that range alone.
fn locate_preview(&self, header: &[u8], file_len: u64) -> Option<PreviewLocation>;
/// The embedded preview at the size asked for, falling through the ladder
/// to the next size where the file lacks it.
fn preview(&self, bytes: &[u8], size: PreviewSize) -> Result<Preview, DecodeError>;
/// Sensor data, for develop and export. The expensive path.
fn decode(&self, bytes: &[u8]) -> Result<RawImage, DecodeError>;
}
/// TRACES: FR-RAW-2
/// The decoder the application ships: rawler for sensor data and the
/// previews it knows, DarkRoom's own container walk for headers and ranges.
///
/// Its methods are the crate's free functions, unchanged. They stay public
/// for the tools and examples that read one file and have no caller to keep
/// decoder-agnostic.
#[derive(Debug, Clone, Copy, Default)]
pub struct Rawler;
impl Decoder for Rawler {
fn header_bytes(&self) -> u64 {
crate::HEADER_BYTES
}
fn metadata(&self, bytes: &[u8]) -> Result<Metadata, DecodeError> {
crate::metadata(bytes)
}
fn orientation(&self, header: &[u8]) -> Option<Orientation> {
crate::orientation(header)
}
fn locate_preview(&self, header: &[u8], file_len: u64) -> Option<PreviewLocation> {
crate::locate_preview(header, file_len)
}
fn preview(&self, bytes: &[u8], size: PreviewSize) -> Result<Preview, DecodeError> {
crate::extract_preview(bytes, size)
}
fn decode(&self, bytes: &[u8]) -> Result<RawImage, DecodeError> {
crate::decode(bytes)
}
}
/// TRACES: FR-RAW-2
/// The decoder a job uses unless it was handed another.
///
/// Named by the places that start work — a thread, a UI handler — and by
/// nothing below them. Returning `&'static dyn Decoder` rather than `Rawler`
/// is the point: a caller that only has this cannot reach past the trait.
pub fn default() -> &'static dyn Decoder {
static RAWLER: Rawler = Rawler;
&RAWLER
}
#[cfg(test)]
mod tests {
use super::*;
/// The default is the shipped decoder, reached through the trait: same
/// header budget, and the same answer to bytes neither can read.
#[test]
fn the_default_is_rawler_behind_the_trait() {
let d = default();
assert_eq!(d.header_bytes(), crate::HEADER_BYTES);
let junk = [0u8; 64];
assert_eq!(d.metadata(&junk).is_err(), crate::metadata(&junk).is_err());
assert!(d.decode(&junk).is_err());
assert_eq!(d.orientation(&junk), crate::orientation(&junk));
}
}
+26
View File
@@ -11,14 +11,20 @@
//! //!
//! Fusing them would force a full decode where a header read suffices, which //! Fusing them would force a full decode where a header read suffices, which
//! is exactly why Lightroom stalls ~2 s per image during culling. //! is exactly why Lightroom stalls ~2 s per image during culling.
//!
//! Callers reach these through the [`Decoder`] trait rather than by name, so a
//! second decoder can be put behind them without changing any of them
//! (FR-RAW-2). [`Rawler`] is the one that ships; [`default`] hands it out.
pub mod base_curve; pub mod base_curve;
mod decoder;
mod error; mod error;
mod locate; mod locate;
mod preview; mod preview;
pub mod profile; pub mod profile;
pub use base_curve::BaseCurve; pub use base_curve::BaseCurve;
pub use decoder::{default, Decoder, Rawler};
pub use error::DecodeError; pub use error::DecodeError;
pub use locate::{ pub use locate::{
defects, is_complete_jpeg, jpeg_metadata, locate_preview, tiff_metadata, BadLine, BadPixel, defects, is_complete_jpeg, jpeg_metadata, locate_preview, tiff_metadata, BadLine, BadPixel,
@@ -304,6 +310,26 @@ pub fn metadata(bytes: &[u8]) -> Result<Metadata, DecodeError> {
error::guarded("metadata", || metadata_unguarded(bytes)) error::guarded("metadata", || metadata_unguarded(bytes))
} }
/// TRACES: FR-CAT-5
/// Where a TIFF-shaped file keeps its first IFD, when the head handed to
/// [`metadata`] does not reach it — the linear DNG a merge writes puts its
/// IFDs after the pixels, and rawler, given the head alone, finds no
/// decoder in it. The caller fetches from this offset to the end and
/// reads the two ranges with [`metadata_split`].
pub fn trailing_ifd(head: &[u8]) -> Option<u64> {
locate::trailing_ifd(head)
}
/// TRACES: FR-CAT-5
/// [`metadata`] for a file read in two ranges: `head` from offset 0 and
/// `tail` from `tail_at`. The EXIF sub-IFD such a file wrote before its
/// pixels is in the head; the first IFD and its values are in the tail.
pub fn metadata_split(head: &[u8], tail: &[u8], tail_at: u64) -> Result<Metadata, DecodeError> {
error::guarded("metadata", || {
locate::tiff_metadata_split(head, tail, tail_at)
})
}
fn metadata_unguarded(bytes: &[u8]) -> Result<Metadata, DecodeError> { fn metadata_unguarded(bytes: &[u8]) -> Result<Metadata, DecodeError> {
use rawler::rawsource::RawSource; use rawler::rawsource::RawSource;
+82 -13
View File
@@ -183,22 +183,65 @@ struct Entry {
value: u32, value: u32,
} }
/// The bytes a [`TiffReader`] reads: the head of a file, and optionally a
/// second range from further in, at a known offset.
///
/// A camera writes its IFDs at the front, so the first 256 KB of a file
/// is the whole structure. A file written strip by strip — the linear
/// DNG a merge produces — has its first IFD at the *end*, after the
/// pixels, and a reader that only has the head sees a pointer into
/// nothing. Rather than fetch 800 MB to read a date, the caller fetches
/// the head, asks [`crate::trailing_ifd`] where the IFD is, fetches that
/// tail, and reads through both. Offsets are the file's own throughout;
/// a read that falls in neither range is simply absent.
#[derive(Clone, Copy)]
struct Src<'a> {
head: &'a [u8],
tail: &'a [u8],
/// Where `tail` starts in the file.
tail_at: usize,
}
impl<'a> Src<'a> {
fn whole(data: &'a [u8]) -> Self {
Src {
head: data,
tail: &[],
tail_at: 0,
}
}
fn get(&self, start: usize, len: usize) -> Option<&'a [u8]> {
let end = start.checked_add(len)?;
if let Some(b) = self.head.get(start..end) {
return Some(b);
}
let s = start.checked_sub(self.tail_at)?;
self.tail.get(s..s.checked_add(len)?)
}
}
/// A minimal TIFF structure reader. /// A minimal TIFF structure reader.
/// ///
/// Deliberately not a general TIFF parser: it reads the IFD chain and entry /// Deliberately not a general TIFF parser: it reads the IFD chain and entry
/// values and nothing else, because that is all locating a preview needs. /// values and nothing else, because that is all locating a preview needs.
struct TiffReader<'a> { struct TiffReader<'a> {
data: &'a [u8], data: Src<'a>,
little_endian: bool, little_endian: bool,
first_ifd: u32, first_ifd: u32,
} }
impl<'a> TiffReader<'a> { impl<'a> TiffReader<'a> {
fn new(data: &'a [u8]) -> Option<Self> { fn new(data: &'a [u8]) -> Option<Self> {
if data.len() < 8 { Self::over(Src::whole(data))
}
fn over(data: Src<'a>) -> Option<Self> {
let head = data.head;
if head.len() < 8 {
return None; return None;
} }
let little_endian = match &data[0..2] { let little_endian = match &head[0..2] {
b"II" => true, b"II" => true,
b"MM" => false, b"MM" => false,
_ => return None, _ => return None,
@@ -362,9 +405,7 @@ impl<'a> TiffReader<'a> {
}; };
raw[..len.min(4)].to_vec() raw[..len.min(4)].to_vec()
} else { } else {
self.data self.data.get(e.value as usize, len)?.to_vec()
.get(e.value as usize..e.value as usize + len)?
.to_vec()
}; };
let s = String::from_utf8_lossy(&bytes); let s = String::from_utf8_lossy(&bytes);
@@ -396,8 +437,7 @@ impl<'a> TiffReader<'a> {
// same way to recover the original byte order. // same way to recover the original byte order.
return None; return None;
} }
let start = e.value as usize; self.data.get(e.value as usize, len)
self.data.get(start..start.checked_add(len)?)
} }
fn offsets(&self, e: &Entry) -> Vec<u32> { fn offsets(&self, e: &Entry) -> Vec<u32> {
@@ -420,8 +460,8 @@ impl<'a> TiffReader<'a> {
} }
} }
fn read_u16(data: &[u8], at: usize, le: bool) -> Option<u16> { fn read_u16(data: Src<'_>, at: usize, le: bool) -> Option<u16> {
let b = data.get(at..at + 2)?; let b = data.get(at, 2)?;
Some(if le { Some(if le {
u16::from_le_bytes([b[0], b[1]]) u16::from_le_bytes([b[0], b[1]])
} else { } else {
@@ -429,8 +469,8 @@ fn read_u16(data: &[u8], at: usize, le: bool) -> Option<u16> {
}) })
} }
fn read_u32(data: &[u8], at: usize, le: bool) -> Option<u32> { fn read_u32(data: Src<'_>, at: usize, le: bool) -> Option<u32> {
let b = data.get(at..at + 4)?; let b = data.get(at, 4)?;
Some(if le { Some(if le {
u32::from_le_bytes([b[0], b[1], b[2], b[3]]) u32::from_le_bytes([b[0], b[1], b[2], b[3]])
} else { } else {
@@ -460,7 +500,36 @@ pub fn jpeg_metadata(bytes: &[u8]) -> Result<crate::Metadata, crate::DecodeError
/// no `DateTimeOriginal` for some DNGs whose tag sits plainly at byte 826 — /// no `DateTimeOriginal` for some DNGs whose tag sits plainly at byte 826 —
/// and without this fallback those images are silently undated. /// and without this fallback those images are silently undated.
pub fn tiff_metadata(tiff_data: &[u8]) -> Result<crate::Metadata, crate::DecodeError> { pub fn tiff_metadata(tiff_data: &[u8]) -> Result<crate::Metadata, crate::DecodeError> {
let reader = TiffReader::new(tiff_data) tiff_metadata_over(Src::whole(tiff_data))
}
/// Where a TIFF-shaped file's first IFD is, when the head does not reach
/// it: the offset to fetch from, so [`tiff_metadata_split`] can read it.
/// `None` for a file that is not TIFF, or whose IFD the head already holds.
pub fn trailing_ifd(head: &[u8]) -> Option<u64> {
let r = TiffReader::new(head)?;
let at = r.first_ifd as u64;
(at >= head.len() as u64).then_some(at)
}
/// [`tiff_metadata`] over a head and a tail fetched separately: the head
/// from offset 0, the tail from `tail_at`. For the file whose IFDs follow
/// its pixels.
pub fn tiff_metadata_split(
head: &[u8],
tail: &[u8],
tail_at: u64,
) -> Result<crate::Metadata, crate::DecodeError> {
tiff_metadata_over(Src {
head,
tail,
tail_at: usize::try_from(tail_at)
.map_err(|_| crate::DecodeError::Metadata("tail offset out of range".into()))?,
})
}
fn tiff_metadata_over(src: Src<'_>) -> Result<crate::Metadata, crate::DecodeError> {
let reader = TiffReader::over(src)
.ok_or_else(|| crate::DecodeError::Metadata("malformed EXIF header".into()))?; .ok_or_else(|| crate::DecodeError::Metadata("malformed EXIF header".into()))?;
let mut md = crate::Metadata::default(); let mut md = crate::Metadata::default();
+28
View File
@@ -241,6 +241,8 @@ mod tests {
let source = SourceMetadata { let source = SourceMetadata {
make: Some("Canon".into()), make: Some("Canon".into()),
model: Some("Canon EOS 6D".into()), model: Some("Canon EOS 6D".into()),
captured_at: Some(1_754_398_664),
captured_offset: Some(120),
..Default::default() ..Default::default()
}; };
write_linear_dng( write_linear_dng(
@@ -301,6 +303,32 @@ mod tests {
assert_eq!((crop.p.x, crop.p.y, crop.d.w, crop.d.h), (2, 1, 15, 10)); assert_eq!((crop.p.x, crop.p.y, crop.d.w, crop.d.h), (2, 1, 15, 10));
} }
#[test]
fn the_catalog_reads_the_date_from_a_head_and_a_tail() {
// TRACES: FR-CAT-5
// The IFDs follow the pixels, so a scan that has the first bytes of
// the file has a pointer into nothing; rawler finds no decoder in
// that, and the composite would sit undated at the end of the grid.
// The scan's second range — from the first IFD to the end — with
// the head is enough to date it, and to name the camera.
let bytes = write(640, 400, 64);
let head = &bytes[..4096];
assert!(
dr_decode::metadata(head).is_err(),
"the head alone must not read"
);
let at = dr_decode::trailing_ifd(head).expect("the IFD is beyond the head");
assert!(at as usize > head.len());
let tail = &bytes[at as usize..];
assert!(tail.len() < 4096, "the tail is the IFD, not the pixels");
let md = dr_decode::metadata_split(head, tail, at).expect("read from two ranges");
assert_eq!(md.captured_at, Some(1_754_398_664));
assert_eq!(md.captured_offset, Some(120));
assert_eq!(md.model.as_deref(), Some("Canon EOS 6D"));
// A head that holds everything is not a trailing-IFD file.
assert_eq!(dr_decode::trailing_ifd(&bytes), None);
}
#[test] #[test]
fn a_strip_of_the_wrong_length_is_refused() { fn a_strip_of_the_wrong_length_is_refused() {
let mut bytes = std::io::Cursor::new(Vec::new()); let mut bytes = std::io::Cursor::new(Vec::new());
+2 -2
View File
@@ -11,7 +11,7 @@ log.workspace = true
# Inference. `ort` is the API; **what runs it is `dr-inference-engine`'s # Inference. `ort` is the API; **what runs it is `dr-inference-engine`'s
# business** — tract, or an ONNX Runtime the app found on disk, on whichever # business** — tract, or an ONNX Runtime the app found on disk, on whichever
# provider the device has (docs/inference.md). This crate never names either. # provider the device has (docs/dev/inference.md). This crate never names either.
ort = { workspace = true, optional = true } ort = { workspace = true, optional = true }
dr-inference-engine = { workspace = true, optional = true } dr-inference-engine = { workspace = true, optional = true }
ndarray = { workspace = true, optional = true } ndarray = { workspace = true, optional = true }
@@ -37,7 +37,7 @@ required-features = ["inference"]
[features] [features]
# Nothing on by default, and in particular **no `embedded-model`**: the weights # Nothing on by default, and in particular **no `embedded-model`**: the weights
# are not a build input and never become one (docs/faces.md §2.2). A feature # are not a build input and never become one (docs/dev/faces.md §2.2). A feature
# flag that *could* embed them is a flag someone eventually sets in a packaging # flag that *could* embed them is a flag someone eventually sets in a packaging
# script, and the InsightFace grant does not survive that. # script, and the InsightFace grant does not survive that.
default = [] default = []
+1 -1
View File
@@ -1,4 +1,4 @@
//! Detect the faces in a JPEG and read each one's eyes (docs/faces.md §17). //! Detect the faces in a JPEG and read each one's eyes (docs/dev/faces.md §17).
//! //!
//! The thing worth looking at is whether the eye boxes land on eyes and //! The thing worth looking at is whether the eye boxes land on eyes and
//! whether soft ones are refused — so with `--dump DIR` the crops the //! whether soft ones are refused — so with `--dump DIR` the crops the
+1 -1
View File
@@ -7,7 +7,7 @@
//! DET.onnx EMB.onnx photo.jpg [photo.jpg ...] //! DET.onnx EMB.onnx photo.jpg [photo.jpg ...]
//! //!
//! The models must have had their input dims frozen first; see //! The models must have had their input dims frozen first; see
//! `tools/fix-face-model-shapes.sh` and docs/faces.md §12 M1. //! `tools/fix-face-model-shapes.sh` and docs/dev/faces.md §12 M1.
use std::time::Instant; use std::time::Instant;
+1 -1
View File
@@ -1,4 +1,4 @@
//! M1 (docs/faces.md §12) — will tract load these graphs at all? //! M1 (docs/dev/faces.md §12) — will tract load these graphs at all?
//! //!
//! The one measurement everything else in the face subsystem is conditional //! The one measurement everything else in the face subsystem is conditional
//! on. `det_500m.onnx` has a dynamic H/W input, which is exactly what tract //! on. `det_500m.onnx` has a dynamic H/W input, which is exactly what tract
+1 -1
View File
@@ -9,7 +9,7 @@
//! //!
//! # What it is for //! # What it is for
//! //!
//! docs/faces.md §9 has the desktop numbers and the question they leave open: //! docs/dev/faces.md §9 has the desktop numbers and the question they leave open:
//! a GPU GEMM is worth roughly 1.5× of a regroup on a twenty-core desktop, //! a GPU GEMM is worth roughly 1.5× of a regroup on a twenty-core desktop,
//! because the scan is under a third of the pass there. On a tablet the CPU is //! because the scan is under a third of the pass there. On a tablet the CPU is
//! several times slower and the GPU is not, so the same optimisation is worth //! several times slower and the GPU is not, so the same optimisation is worth
+5 -5
View File
@@ -1,4 +1,4 @@
//! Five-point face alignment (docs/faces.md §5). //! Five-point face alignment (docs/dev/faces.md §5).
//! //!
//! ArcFace embeddings are trained on faces warped to a canonical 112×112 //! ArcFace embeddings are trained on faces warped to a canonical 112×112
//! arrangement. Feeding the model a plain bounding-box crop *works* — it //! arrangement. Feeding the model a plain bounding-box crop *works* — it
@@ -208,7 +208,7 @@ impl Similarity {
/// ///
/// # Why least squares and not RANSAC /// # Why least squares and not RANSAC
/// ///
/// The reference C++ implementation (docs/faces.md §1.1) fits this with /// The reference C++ implementation (docs/dev/faces.md §1.1) fits this with
/// OpenCV's `estimateAffinePartial2D` under RANSAC. RANSAC over five points is /// OpenCV's `estimateAffinePartial2D` under RANSAC. RANSAC over five points is
/// a strange fit: the minimal sample for a similarity is two, so it can discard /// a strange fit: the minimal sample for a similarity is two, so it can discard
/// landmarks it judges outliers and solve from a subset — and on a profile face /// landmarks it judges outliers and solve from a subset — and on a profile face
@@ -427,7 +427,7 @@ fn sample_window(
// ── eyes ────────────────────────────────────────────────────────────────── // ── eyes ──────────────────────────────────────────────────────────────────
/// Width of an eye crop as the classifier reads it, in pixels. Fixed by the /// Width of an eye crop as the classifier reads it, in pixels. Fixed by the
/// OCEC input (`docs/faces.md` §17): 40 wide, 24 high. /// OCEC input (`docs/dev/faces.md` §17): 40 wide, 24 high.
pub const EYE_PATCH_WIDTH: usize = 40; pub const EYE_PATCH_WIDTH: usize = 40;
/// Height of an eye crop as the classifier reads it, in pixels. /// Height of an eye crop as the classifier reads it, in pixels.
pub const EYE_PATCH_HEIGHT: usize = 24; pub const EYE_PATCH_HEIGHT: usize = 24;
@@ -438,7 +438,7 @@ pub const EYE_PATCH_HEIGHT: usize = 24;
/// The classifier was trained on a whole-body detector's *eye* boxes — tight /// The classifier was trained on a whole-body detector's *eye* boxes — tight
/// round the palpebral fissure — and measured on 25 open-eyed faces from the /// round the palpebral fissure — and measured on 25 open-eyed faces from the
/// reference library, a tight box is what it wants: 22 of 25 read open at /// reference library, a tight box is what it wants: 22 of 25 read open at
/// 0 and 0.1, 18 at 0.4, 14 at 0.6 (docs/faces.md §17.2). A tenth, so a /// 0 and 0.1, 18 at 0.4, 14 at 0.6 (docs/dev/faces.md §17.2). A tenth, so a
/// contour landing a pixel short of the lashes still holds them. /// contour landing a pixel short of the lashes still holds them.
pub const EYE_BOX_MARGIN: f32 = 0.1; pub const EYE_BOX_MARGIN: f32 = 0.1;
@@ -571,7 +571,7 @@ pub const SUNGLASSES_EDGE: usize = 48;
/// clear glasses, at 0.68. Erring towards "sunglasses" is the safe direction /// clear glasses, at 0.68. Erring towards "sunglasses" is the safe direction
/// for what this feeds: a face called sunglasses is left alone by the /// for what this feeds: a face called sunglasses is left alone by the
/// eyes-open filter, where a pair of sunglasses missed hands the eye /// eyes-open filter, where a pair of sunglasses missed hands the eye
/// classifier a lens to guess at (docs/faces.md §17). /// classifier a lens to guess at (docs/dev/faces.md §17).
pub const SUNGLASSES_WINDOWS: [(f32, f32, f32, f32); 2] = pub const SUNGLASSES_WINDOWS: [(f32, f32, f32, f32); 2] =
[(0.0, 0.0, 112.0, 112.0), (-5.0, -14.0, 122.0, 122.0)]; [(0.0, 0.0, 112.0, 112.0), (-5.0, -14.0, 122.0, 122.0)];
+3 -3
View File
@@ -1,4 +1,4 @@
//! Cosine to probability (docs/faces.md §8, FR-CULL-9). //! Cosine to probability (docs/dev/faces.md §8, FR-CULL-9).
//! //!
//! FR-CULL-9 is a hard requirement rather than an implementation detail: no //! FR-CULL-9 is a hard requirement rather than an implementation detail: no
//! code path may threshold a bare cosine, every threshold in the subsystem is //! code path may threshold a bare cosine, every threshold in the subsystem is
@@ -28,7 +28,7 @@
//! calibration to the belief it was supposed to test — and that is the whole //! calibration to the belief it was supposed to test — and that is the whole
//! of the alternative. //! of the alternative.
//! //!
//! docs/faces.md §8.1 names one more that would cost no labelling at all: two //! docs/dev/faces.md §8.1 names one more that would cost no labelling at all: two
//! faces in adjacent frames of one burst are near-certainly the same person, //! faces in adjacent frames of one burst are near-certainly the same person,
//! and FR-CULL-5's grouping is sitting there. Nothing draws on it. This crate //! and FR-CULL-5's grouping is sitting there. Nothing draws on it. This crate
//! cannot see a catalog, let alone the bursts in one — it is handed cosines by //! cannot see a catalog, let alone the bursts in one — it is handed cosines by
@@ -83,7 +83,7 @@ pub struct Calibration {
} }
impl Default for Calibration { impl Default for Calibration {
/// The reference implementation's fitted MBF curve (docs/faces.md §1): /// The reference implementation's fitted MBF curve (docs/dev/faces.md §1):
/// steepness 16.2, P=0.5 at cosine 0.267. /// steepness 16.2, P=0.5 at cosine 0.267.
/// ///
/// **`valid` is false**, and that is the point. It is a documented /// **`valid` is false**, and that is the point. It is a documented
+1 -1
View File
@@ -1,5 +1,5 @@
//! TRACES: FR-CULL-8a //! TRACES: FR-CULL-8a
//! The two small classifiers behind a face's eye state (docs/faces.md §17). //! The two small classifiers behind a face's eye state (docs/dev/faces.md §17).
//! //!
//! **OCEC** — *open closed eyes classification*, Hyodo 2025 — reads one //! **OCEC** — *open closed eyes classification*, Hyodo 2025 — reads one
//! 40×24 eye and answers P(open). **SGC** — *sunglasses classification*, //! 40×24 eye and answers P(open). **SGC** — *sunglasses classification*,
+1 -1
View File
@@ -1,4 +1,4 @@
//! Grouping faces into people (docs/faces.md §9, FR-CULL-10). //! Grouping faces into people (docs/dev/faces.md §9, FR-CULL-10).
//! //!
//! Model-free: this is arithmetic over embeddings, and it is where the //! Model-free: this is arithmetic over embeddings, and it is where the
//! subsystem's accuracy actually lives, so it is testable with no weights on //! subsystem's accuracy actually lives, so it is testable with no weights on
+4 -4
View File
@@ -1,4 +1,4 @@
//! SCRFD face detection (docs/faces.md §4). //! SCRFD face detection (docs/dev/faces.md §4).
//! //!
//! One forward pass produces a box, a confidence and **five landmarks** per //! One forward pass produces a box, a confidence and **five landmarks** per
//! face — the landmarks being the reason for this detector rather than a //! face — the landmarks being the reason for this detector rather than a
@@ -138,7 +138,7 @@ impl Detection {
pub struct Detector { pub struct Detector {
session: Model, session: Model,
/// f32 or int8 — the int8 form finds a different set of faces and is a /// f32 or int8 — the int8 form finds a different set of faces and is a
/// different detector in `model_id` (docs/inference.md §7). /// different detector in `model_id` (docs/dev/inference.md §7).
form: Form, form: Form,
/// Feature-map count: 3 for strides {8,16,32}, 4 for {8,16,32,64}. /// Feature-map count: 3 for strides {8,16,32}, 4 for {8,16,32,64}.
/// ///
@@ -346,7 +346,7 @@ fn iou(a: &(f32, f32, f32, f32), b: &(f32, f32, f32, f32)) -> f32 {
/// How the image is fitted into the graph's fixed square input. /// How the image is fitted into the graph's fixed square input.
/// ///
/// The forward and inverse mappings live in one struct on purpose: /// The forward and inverse mappings live in one struct on purpose:
/// docs/faces.md §4.1 notes that what matters is not *where* the padding goes /// docs/dev/faces.md §4.1 notes that what matters is not *where* the padding goes
/// but that the two agree. A mismatch offsets every box and landmark by the /// but that the two agree. A mismatch offsets every box and landmark by the
/// padding, producing detections that look plausible and embeddings that /// padding, producing detections that look plausible and embeddings that
/// quietly cluster badly three stages later. /// quietly cluster badly three stages later.
@@ -372,7 +372,7 @@ impl Letterbox {
/// ///
/// `(x·255 − 127.5) / 128` — note `/128`, not `/127.5`. The reference /// `(x·255 − 127.5) / 128` — note `/128`, not `/127.5`. The reference
/// implementation this is ported from uses `/128` for both models, and /// implementation this is ported from uses `/128` for both models, and
/// every measured number in docs/faces.md §1 came from it. /// every measured number in docs/dev/faces.md §1 came from it.
/// ///
/// Padding is grey, matching the reference's `114`: the value the network /// Padding is grey, matching the reference's `114`: the value the network
/// reads least as an edge, where black would draw a hard border across the /// reads least as an edge, where black would draw a hard border across the
+2 -2
View File
@@ -1,4 +1,4 @@
//! ArcFace / MobileFaceNet inference (docs/faces.md §6). //! ArcFace / MobileFaceNet inference (docs/dev/faces.md §6).
//! //!
//! Takes an aligned crop and returns 512 L2-normalised floats. The alignment is //! Takes an aligned crop and returns 512 L2-normalised floats. The alignment is
//! not optional and cannot be skipped by accident: [`Embedder::embed`] takes an //! not optional and cannot be skipped by accident: [`Embedder::embed`] takes an
@@ -66,7 +66,7 @@ impl Embedder {
pub fn from_bytes(bytes: &[u8], model: ModelId) -> Result<Self, FaceError> { pub fn from_bytes(bytes: &[u8], model: ModelId) -> Result<Self, FaceError> {
// Always the f32 form: an embedding must compare across devices // Always the f32 form: an embedding must compare across devices
// (docs/inference.md §7), and the engine pins this role to it. // (docs/dev/inference.md §7), and the engine pins this role to it.
let loaded = dr_inference_engine::open(Role::Embedder, Form::F32, bytes)?; let loaded = dr_inference_engine::open(Role::Embedder, Form::F32, bytes)?;
let acquired = loaded.acquire()?; let acquired = loaded.acquire()?;
let session = acquired.lock(); let session = acquired.lock();
+2 -2
View File
@@ -1,4 +1,4 @@
//! What an embedder produces, and how it is stored (docs/faces.md §6). //! What an embedder produces, and how it is stored (docs/dev/faces.md §6).
//! //!
//! Deliberately **model-free**: the vector, its identity, its comparison and //! Deliberately **model-free**: the vector, its identity, its comparison and
//! its storage encoding are arithmetic, and `calibrate` and `cluster` are built //! its storage encoding are arithmetic, and `calibrate` and `cluster` are built
@@ -273,7 +273,7 @@ mod tests {
); );
} }
/// The claim docs/faces.md §6 makes about the storage format: the f16 /// The claim docs/dev/faces.md §6 makes about the storage format: the f16
/// round-trip costs ~1e-3 of cosine, three orders below the separation /// round-trip costs ~1e-3 of cosine, three orders below the separation
/// between a match and a non-match. /// between a match and a non-match.
#[test] #[test]
+3 -3
View File
@@ -67,14 +67,14 @@ pub const SUNGLASSES_THRESHOLD: f32 = 0.5;
/// The classifier was trained on eyes down to about a dozen pixels wide /// The classifier was trained on eyes down to about a dozen pixels wide
/// (its reference footage averaged 15–21); below that the 40-pixel patch is /// (its reference footage averaged 15–21); below that the 40-pixel patch is
/// an interpolation of nothing, and the answer is noise that reads as /// an interpolation of nothing, and the answer is noise that reads as
/// "closed". docs/faces.md §17.3 has the measurement behind the number. /// "closed". docs/dev/faces.md §17.3 has the measurement behind the number.
pub const MIN_EYE_PX: f32 = 12.0; pub const MIN_EYE_PX: f32 = 12.0;
/// Least [`Eye::sharpness`] for the eye to be read. /// Least [`Eye::sharpness`] for the eye to be read.
/// ///
/// The same measure as the face's `min_sharpness`, over the eye patch, and /// The same measure as the face's `min_sharpness`, over the eye patch, and
/// chosen the same way: the value under which the open-eyed faces of the /// chosen the same way: the value under which the open-eyed faces of the
/// reference sample were being called closed. docs/faces.md §17.3. /// reference sample were being called closed. docs/dev/faces.md §17.3.
pub const MIN_EYE_SHARPNESS: f32 = 0.02; pub const MIN_EYE_SHARPNESS: f32 = 0.02;
/// An eye narrower than this fraction of its partner is the far eye of a /// An eye narrower than this fraction of its partner is the far eye of a
@@ -82,7 +82,7 @@ pub const MIN_EYE_SHARPNESS: f32 = 0.02;
/// ///
/// A landmark model's contour for a hidden eye collapses towards the nose. /// A landmark model's contour for a hidden eye collapses towards the nose.
/// Measured on twenty native renders of the reference library /// Measured on twenty native renders of the reference library
/// (docs/faces.md §17.4): profiles put the far eye at 0.02–0.43 of the near /// (docs/dev/faces.md §17.4): profiles put the far eye at 0.02–0.43 of the near
/// one, two three-quarter faces whose far eye read closed sat at 0.54, and /// one, two three-quarter faces whose far eye read closed sat at 0.54, and
/// every face looking at the camera — winks included, since a shut eye's /// every face looking at the camera — winks included, since a shut eye's
/// box keeps its width — sat at 0.78 or more. 0.6 splits the gap. /// box keeps its width — sat at 0.78 or more. 0.6 splits the gap.
+1 -1
View File
@@ -1,5 +1,5 @@
//! TRACES: FR-CULL-8a //! TRACES: FR-CULL-8a
//! Dense facial landmarks — InsightFace's `2d106det` (docs/faces.md §17.2). //! Dense facial landmarks — InsightFace's `2d106det` (docs/dev/faces.md §17.2).
//! //!
//! SCRFD's five points place a face; they do not place an eye. Its eye //! SCRFD's five points place a face; they do not place an eye. Its eye
//! point is loose enough that a window centred on it left the eye in a //! point is loose enough that a window centred on it left the eye in a
+5 -4
View File
@@ -1,4 +1,4 @@
//! Faces and identity (S14, docs/faces.md). //! Faces and identity (S14, docs/dev/faces.md).
//! //!
//! Two models, run over the native render, producing per face a box, five //! Two models, run over the native render, producing per face a box, five
//! landmarks, a confidence and a 512-d embedding (FR-CULL-8) — and then the //! landmarks, a confidence and a 512-d embedding (FR-CULL-8) — and then the
@@ -21,9 +21,9 @@
//! for a packaging script to switch on. The application obtains a model at //! for a packaging script to switch on. The application obtains a model at
//! runtime; this crate takes bytes and never fetches anything. //! runtime; this crate takes bytes and never fetches anything.
//! //!
//! docs/faces.md §2 is the full reading, including what would have to change //! docs/dev/faces.md §2 is the full reading, including what would have to change
//! for that to stop being true. The eye-state models are the exception: MIT, //! for that to stop being true. The eye-state models are the exception: MIT,
//! weights and all, and shipped in `models/face/` (docs/faces.md §17). //! weights and all, and shipped in `models/face/` (docs/dev/faces.md §17).
//! //!
//! # Why the runtime is split behind a feature //! # Why the runtime is split behind a feature
//! //!
@@ -50,11 +50,12 @@ pub mod eyes;
pub mod landmarks; pub mod landmarks;
pub mod naming; pub mod naming;
pub mod neighbours; pub mod neighbours;
pub mod references;
/// Smallest long edge a face crop may be sampled from. /// Smallest long edge a face crop may be sampled from.
/// ///
/// **A floor on the crop source, not on the detector input.** The distinction /// **A floor on the crop source, not on the detector input.** The distinction
/// is the whole of FR-CULL-8 and `docs/faces.md` §7: detection letterboxes /// is the whole of FR-CULL-8 and `docs/dev/faces.md` §7: detection letterboxes
/// every buffer into 640×640, so its input resolution decides nothing, while /// every buffer into 640×640, so its input resolution decides nothing, while
/// [`warp`] samples the 112×112 the embedder sees and so converts source /// [`warp`] samples the 112×112 the embedder sees and so converts source
/// resolution directly into embedding quality. FR-CULL-8 requires that crop to /// resolution directly into embedding quality. FR-CULL-8 requires that crop to
+1 -1
View File
@@ -115,7 +115,7 @@ pub struct Faces<'a> {
/// Source pixels across the aligned crop, for the calibration's size term. /// Source pixels across the aligned crop, for the calibration's size term.
pub crop_px: &'a [f32], pub crop_px: &'a [f32],
/// Which photograph each face came from. Two faces in one frame are not /// Which photograph each face came from. Two faces in one frame are not
/// the same person, so those pairs are never returned (docs/faces.md §9). /// the same person, so those pairs are never returned (docs/dev/faces.md §9).
pub images: &'a [u64], pub images: &'a [u64],
/// Which faces may be compared *against* — the gallery /// Which faces may be compared *against* — the gallery
/// ([`crate::embedding::MIN_GALLERY_QUALITY`]). /// ([`crate::embedding::MIN_GALLERY_QUALITY`]).
+232
View File
@@ -0,0 +1,232 @@
//! TRACES: FR-CULL-10 | NFR-P9
//! Which of a person's faces stand for them in a grouping pass.
//!
//! # Why not all of them
//!
//! Every face the user has ruled on enters [`crate::cluster`] as an anchor,
//! and the pass compares every face against every other
//! ([`crate::neighbours`] is exhaustive by design). So a person with 750
//! confirmed faces costs 750 comparisons against each of the library's other
//! faces, and the cost of naming a library well grows with how well it is
//! named: a fully confirmed library of 25,000 faces spends almost the whole
//! scan re-comparing faces whose identity is already settled against each
//! other.
//!
//! Most of those comparisons say nothing new. A person's confirmed faces are
//! heavily redundant — thirty frames from one afternoon are one point of
//! view, not thirty — and a new face that matches one of them matches the
//! others too. What a new face needs to be measured against is the person's
//! *range*: the angles, ages and lights they have been photographed in, each
//! represented once.
//!
//! # The choice: the most diverse of the good ones
//!
//! Two rules, in order.
//!
//! **Good enough to vouch.** Only faces whose raw embedding was at least
//! [`MIN_REFERENCE_QUALITY`] long are eligible — a stricter floor than the
//! gallery's ([`crate::embedding::MIN_GALLERY_QUALITY`]), because a reference
//! is asked to speak *for* a person rather than merely be admitted to the
//! comparison. A face whose length was never recorded is admitted, as it is
//! everywhere else: a rule that cannot be checked admits rather than excludes.
//!
//! **As far apart as possible.** From the eligible pool, up to
//! [`MAX_REFERENCES`] faces are chosen to maximise the volume they span —
//! the determinant of their Gram matrix — greedily: start from the longest
//! vector, and at each step add the face with the largest component
//! orthogonal to everything chosen so far. That is Gram–Schmidt with a
//! pivot, and the product of the squared residuals it picks *is* the
//! determinant, so the greedy step is the exact greedy on the objective.
//! The effect is that a near-duplicate of a chosen face has almost no
//! residual and is passed over, while the one profile shot among two
//! hundred frontal frames is taken early.
//!
//! What is not chosen still belongs to the person. Those faces keep their
//! confirmations and are not touched by the pass; they are simply not
//! compared, which is the whole saving.
/// The most faces that stand for one person.
///
/// A hundred is far more points of view than a person has. What it bounds
/// is the cost: with every person at the cap, a scan against the named part
/// of a library is `people × 100` comparisons per face rather than
/// `confirmations`, and the two part company as soon as a library is used.
pub const MAX_REFERENCES: usize = 100;
/// The shortest raw embedding that may stand for a person.
///
/// One above the gallery floor: a reference vouches for someone, and the
/// margin keeps the faces that only just cleared the gallery — the ones
/// nearest the middle of the sphere — out of the set that speaks for a
/// person.
pub const MIN_REFERENCE_QUALITY: f32 = 15.0;
/// Whether a face of this quality may stand for a person.
///
/// `None` is "never measured" and is admitted, as in
/// [`crate::embedding::in_gallery`].
pub fn eligible(quality: Option<f32>) -> bool {
quality.is_none_or(|q| q >= MIN_REFERENCE_QUALITY)
}
/// Choose which of one person's faces stand for them.
///
/// `embeddings` and `quality` are one entry per face, the embeddings unit
/// length and all of one dimension. Returns the indices chosen, in the order
/// chosen — the first is the longest eligible vector, and each after it is
/// the one furthest from the span of those before. Every eligible face is
/// returned when there are `max` or fewer of them, so a person under the
/// cap loses nothing.
///
/// Deterministic: equal residuals break on the longer vector, then the lower
/// index, so two devices holding the same faces choose the same references
/// and group the same way (`cluster::clustering_is_deterministic`).
pub fn select(embeddings: &[&[f32]], quality: &[Option<f32>], max: usize) -> Vec<usize> {
debug_assert_eq!(embeddings.len(), quality.len());
let mut pool: Vec<usize> = (0..embeddings.len())
.filter(|&i| eligible(quality[i]))
.collect();
if pool.len() <= max {
return pool;
}
// Longest first, so the seed is the pool's front and a tie on residual
// resolves to the earlier position. A missing reading ranks below any
// measured one for this purpose only: it is admitted, but a face that
// was measured and found long is the better seed.
pool.sort_by(|&a, &b| {
let qa = quality[a].unwrap_or(0.0);
let qb = quality[b].unwrap_or(0.0);
qb.total_cmp(&qa).then(a.cmp(&b))
});
// Residuals: what remains of each pool vector outside the span of the
// chosen ones. Copied, since they are rewritten in place.
let mut residual: Vec<Vec<f32>> = pool.iter().map(|&i| embeddings[i].to_vec()).collect();
let mut taken = vec![false; pool.len()];
let mut chosen = Vec::with_capacity(max);
while chosen.len() < max {
// The face with the most left outside the span. The seed is the
// pool's front by construction: every unit vector has the same
// residual before anything is chosen, up to rounding, and rounding
// is not a reason to prefer one. After that `> best` and not `>=`,
// so a genuine tie keeps the earlier (longer) candidate.
let mut pick = None;
let mut best = 0.0_f32;
if chosen.is_empty() {
pick = Some(0);
best = residual[0].iter().map(|x| x * x).sum();
} else {
for (k, r) in residual.iter().enumerate() {
if taken[k] {
continue;
}
let n2: f32 = r.iter().map(|x| x * x).sum();
if n2 > best {
best = n2;
pick = Some(k);
}
}
}
// Nothing left outside the span: every remaining face is a
// combination of the chosen ones and adds no volume.
let Some(k) = pick.filter(|_| best > 1e-6) else {
break;
};
taken[k] = true;
chosen.push(pool[k]);
// Project the chosen direction out of every remaining residual.
let inv = best.sqrt().recip();
let q: Vec<f32> = residual[k].iter().map(|x| x * inv).collect();
for (j, r) in residual.iter_mut().enumerate() {
if taken[j] {
continue;
}
let d: f32 = r.iter().zip(&q).map(|(a, b)| a * b).sum();
for (x, y) in r.iter_mut().zip(&q) {
*x -= d * y;
}
}
}
chosen
}
#[cfg(test)]
mod tests {
use super::*;
fn unit(v: &[f32]) -> Vec<f32> {
let n = v.iter().map(|x| x * x).sum::<f32>().sqrt();
v.iter().map(|x| x / n).collect()
}
#[test]
fn a_person_under_the_cap_keeps_every_eligible_face() {
let e = [unit(&[1.0, 0.0]), unit(&[0.0, 1.0]), unit(&[1.0, 1.0])];
let refs: Vec<&[f32]> = e.iter().map(Vec::as_slice).collect();
let q = [Some(20.0), None, Some(16.0)];
assert_eq!(select(&refs, &q, 100), vec![0, 1, 2]);
}
#[test]
fn a_short_vector_never_stands_for_a_person() {
let e = [unit(&[1.0, 0.0]), unit(&[0.0, 1.0])];
let refs: Vec<&[f32]> = e.iter().map(Vec::as_slice).collect();
let q = [Some(20.0), Some(MIN_REFERENCE_QUALITY - 0.01)];
assert_eq!(select(&refs, &q, 100), vec![0]);
}
/// Two hundred frames from one afternoon and one profile shot: the
/// profile is the second choice, not the two-hundred-and-first.
#[test]
fn the_odd_one_out_is_chosen_before_any_duplicate() {
let mut e: Vec<Vec<f32>> = Vec::new();
let mut q = Vec::new();
for i in 0..200 {
// Near-duplicates of one direction, with a little noise.
let t = (i as f32) * 1e-3;
e.push(unit(&[1.0, t, t * 0.5]));
q.push(Some(20.0 + (i % 7) as f32));
}
e.push(unit(&[0.0, 0.0, 1.0]));
q.push(Some(16.0));
let refs: Vec<&[f32]> = e.iter().map(Vec::as_slice).collect();
let chosen = select(&refs, &q, 3);
assert_eq!(chosen.len(), 3);
assert_eq!(
chosen[1], 200,
"the profile shot was not second: {chosen:?}"
);
// Seeded on the longest vector.
assert_eq!(q[chosen[0]], Some(26.0));
}
/// Faces inside the span of the chosen ones add no volume and are not
/// taken to fill the cap.
#[test]
fn the_cap_is_not_filled_from_inside_the_span() {
let e = [
unit(&[1.0, 0.0]),
unit(&[0.0, 1.0]),
unit(&[1.0, 1.0]),
unit(&[2.0, -1.0]),
];
let refs: Vec<&[f32]> = e.iter().map(Vec::as_slice).collect();
let q = [Some(20.0); 4];
assert_eq!(select(&refs, &q, 3).len(), 2);
}
#[test]
fn the_choice_is_deterministic() {
let e: Vec<Vec<f32>> = (0..50)
.map(|i| {
let a = (i as f32) * 0.37;
unit(&[a.cos(), a.sin(), (a * 3.0).sin(), 0.2])
})
.collect();
let refs: Vec<&[f32]> = e.iter().map(Vec::as_slice).collect();
let q = vec![Some(18.0); 50];
assert_eq!(select(&refs, &q, 5), select(&refs, &q, 5));
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
//! What a frame actually costs — the measurement FR-DSP-2 is waiting on. //! What a frame actually costs — the measurement FR-DSP-2 is waiting on.
//! //!
//! `docs/display-and-extension.md` §2 argues that tiled computation predates //! `docs/dev/display-and-extension.md` §2 argues that tiled computation predates
//! the fused-shader design and may not need to exist: the composer folds every //! the fused-shader design and may not need to exist: the composer folds every
//! active operation into **one dispatch over a viewport-sized target**, so the //! active operation into **one dispatch over a viewport-sized target**, so the
//! problem tiles were invented to solve may already be solved. That argument //! problem tiles were invented to solve may already be solved. That argument
@@ -28,7 +28,7 @@
//! the per-frame CPU half is dominated by shader-source assembly, which is //! the per-frame CPU half is dominated by shader-source assembly, which is
//! string formatting and is several times slower unoptimised. //! string formatting and is several times slower unoptimised.
//! //!
//! The committed numbers live in `docs/frame-budget.md`. Rerun this and diff //! The committed numbers live in `docs/dev/frame-budget.md`. Rerun this and diff
//! that file; a regression should be a diff rather than somebody's memory. //! that file; a regression should be a diff rather than somebody's memory.
//! //!
//! # Why the 99th percentile and not the mean //! # Why the 99th percentile and not the mean
+1 -1
View File
@@ -1,6 +1,6 @@
//! Segment an image and write the granularity ladder as false-coloured PPMs. //! Segment an image and write the granularity ladder as false-coloured PPMs.
//! //!
//! The whole point of S15 step 2 (docs/segmentation.md §11): look at the //! The whole point of S15 step 2 (docs/dev/segmentation.md §11): look at the
//! ladder and decide whether clicking through it would land on the things a //! ladder and decide whether clicking through it would land on the things a
//! person means. No amount of design settles that — the pictures do. //! person means. No amount of design settles that — the pictures do.
//! //!
+462 -5
View File
@@ -124,6 +124,9 @@ pub struct AdjustPass {
/// Cleared by any render that does not write it, so a stale intermediate /// Cleared by any render that does not write it, so a stale intermediate
/// cannot survive a change of image and be handed to a later detail chain. /// cannot survive a change of image and be handed to a later detail chain.
colour_key: Option<(u64, u32, u32)>, colour_key: Option<(u64, u32, u32)>,
/// The source texels the fused pass read on an earlier frame, kept so a
/// slider drag at fit reads them contiguously. See [`SampleCache`].
sample: SampleCache,
/// Fused dispatches actually encoded. Exposed so a test can see the reuse /// Fused dispatches actually encoded. Exposed so a test can see the reuse
/// above happening rather than take it on trust. /// above happening rather than take it on trust.
colour_dispatches: usize, colour_dispatches: usize,
@@ -147,6 +150,205 @@ struct Target {
const FILM_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float; const FILM_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float;
/// TRACES: FR-DEV-3f /// TRACES: FR-DEV-3f
/// What a cached sample is valid for: the composer's
/// [`ComposedShader::sample_key`], the source image, and the render size.
type SampleKey = (u64, u64, u32, u32);
/// The largest render the sample cache is kept for, in pixels.
///
/// 4K and a little over, which is every develop view there is. An export
/// renders a whole sensor once and gains nothing from a cache it will not
/// read again; without a ceiling, two exports of the same frame in a row
/// would park a full-resolution copy of it on the device.
const SAMPLE_CACHE_MAX_PIXELS: u64 = 3840 * 2400;
/// How the fused dispatch gets its source colour this frame.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum SampleUse {
/// From the source, as it always did.
Direct,
/// From the source, and stored in the cache for the frames after.
Write,
/// From the cache.
Read,
}
/// TRACES: NFR-P5
/// The fused pass's gather from the source, remembered across frames.
///
/// At fit, each output pixel of the fused pass reads one texel of a source
/// three or four times its width, on a stride, and the memory system fetches
/// the neighbours it skips along with it. On a 60 MP source that gather was
/// most of the fused pass: 10.9 ms of a 2560 x 1600 frame against 3.8 ms for
/// the same work reading contiguously (RTX 3050, clocks held down). But which
/// texel a pixel reads depends on the framing and nothing else, and a slider
/// drag does not move the framing. So the shader writes what it gathered to a
/// render-sized texture on one frame and reads it back contiguously on every
/// frame after, until the framing, the image or the size changes.
///
/// Bit-for-bit the same picture: the source is `rgba16float` and so is the
/// cache, so the stored texel is the texel. Only the whole-texel sampling path
/// takes part — [`ComposedShader::sample_key`] is `None` when the sample is
/// interpolated.
///
/// **Written on the second frame with a key, not the first.** A drag of the
/// crop or of a zoom changes the key on every frame, and a cache written then
/// is never read — it would add a write per frame to exactly the gestures that
/// can least afford one. Waiting for the key to repeat once costs a slider drag
/// one uncached frame and costs a crop drag nothing.
struct SampleCache {
/// The cache itself, `rgba16float`, sampled and written as storage.
target: Option<Target>,
/// What `target` holds, once a dispatch has written it.
holds: Option<SampleKey>,
/// The key the previous fused dispatch had, cached or not.
last: Option<SampleKey>,
/// What the most recent plan decided. Read by the tests, which have no
/// other way to tell a cached frame from an uncached one — the point being
/// that the pictures are identical.
last_use: SampleUse,
/// Bound at `@binding(6)` when the cache is not being read.
no_sampled: wgpu::TextureView,
/// Bound at `@binding(7)` when the cache is not being written.
no_sample_out: wgpu::TextureView,
}
impl SampleCache {
const FORMAT: wgpu::TextureFormat = DemosaicedImage::FORMAT;
fn new(ctx: &GpuContext) -> Self {
let placeholder = |label, usage| {
ctx.device
.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: Self::FORMAT,
usage,
view_formats: &[],
})
.create_view(&Default::default())
};
Self {
target: None,
holds: None,
last: None,
last_use: SampleUse::Direct,
no_sampled: placeholder("adjust-no-sampled", wgpu::TextureUsages::TEXTURE_BINDING),
no_sample_out: placeholder(
"adjust-no-sample-out",
wgpu::TextureUsages::STORAGE_BINDING,
),
}
}
/// Decide how this dispatch samples, on the assumption that it will be
/// submitted — call it after anything that can still fail.
fn plan(
&mut self,
ctx: &GpuContext,
source: &DemosaicedImage,
shader: &ComposedShader,
width: u32,
height: u32,
) -> SampleUse {
self.last_use = self.decide(ctx, source, shader, width, height);
self.last_use
}
fn decide(
&mut self,
ctx: &GpuContext,
source: &DemosaicedImage,
shader: &ComposedShader,
width: u32,
height: u32,
) -> SampleUse {
let key = shader
.sample_key
.filter(|_| u64::from(width) * u64::from(height) <= SAMPLE_CACHE_MAX_PIXELS)
.map(|k| (k, source.id(), width, height));
let last = std::mem::replace(&mut self.last, key);
let Some(key) = key else {
return SampleUse::Direct;
};
if self.holds == Some(key) {
return SampleUse::Read;
}
if last != Some(key) {
return SampleUse::Direct;
}
if !self
.target
.as_ref()
.is_some_and(|t| t.width == width && t.height == height)
{
let texture = ctx.device.create_texture(&wgpu::TextureDescriptor {
label: Some("adjust-sample-cache"),
size: wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: Self::FORMAT,
usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
self.target = Some(Target {
texture,
view,
width,
height,
});
}
// Marked as held now: the dispatch that writes it is submitted before
// any that could read it, and one queue orders the two.
self.holds = Some(key);
SampleUse::Write
}
/// Write the flags for `usage` into a fused uniform block.
fn flag(usage: SampleUse, uniforms: &mut [f32]) {
let o = dr_pipeline::SAMPLE_CACHE_UNIFORM_OFFSET;
uniforms[o] = if usage == SampleUse::Read { 1.0 } else { 0.0 };
uniforms[o + 1] = if usage == SampleUse::Write { 1.0 } else { 0.0 };
}
/// The views for `@binding(6)` and `@binding(7)`.
fn views(&self, usage: SampleUse) -> (wgpu::TextureView, wgpu::TextureView) {
let cache = || {
self.target
.as_ref()
.expect("planned with a target")
.view
.clone()
};
match usage {
SampleUse::Direct => (self.no_sampled.clone(), self.no_sample_out.clone()),
SampleUse::Write => (self.no_sampled.clone(), cache()),
SampleUse::Read => (cache(), self.no_sample_out.clone()),
}
}
/// Forget everything; the next render starts over.
fn release(&mut self) {
self.target = None;
self.holds = None;
self.last = None;
}
}
/// A baked film stock, resident on the GPU. /// A baked film stock, resident on the GPU.
struct FilmTextures { struct FilmTextures {
curves: wgpu::TextureView, curves: wgpu::TextureView,
@@ -440,6 +642,7 @@ impl AdjustPass {
camera_pipeline_layout, camera_pipeline_layout,
camera_target: None, camera_target: None,
colour_key: None, colour_key: None,
sample: SampleCache::new(ctx),
colour_dispatches: 0, colour_dispatches: 0,
detail_dispatches: 0, detail_dispatches: 0,
} }
@@ -537,6 +740,30 @@ impl AdjustPass {
}, },
count: None, count: None,
}, },
// The sample cache, read and written. Present in every
// layout for the reason the masks are, and bound to
// placeholders whenever the flags leave it alone. See
// `SampleCache`.
wgpu::BindGroupLayoutEntry {
binding: 6,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 7,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::StorageTexture {
access: wgpu::StorageTextureAccess::WriteOnly,
format: SampleCache::FORMAT,
view_dimension: wgpu::TextureViewDimension::D2,
},
count: None,
},
], ],
}) })
} }
@@ -716,7 +943,15 @@ impl AdjustPass {
let (width, height) = (width.max(1), height.max(1)); let (width, height) = (width.max(1), height.max(1));
self.ensure_target(width, height); self.ensure_target(width, height);
let uniforms = Self::fused_uniforms(source, shader); // Compile first: `pipeline` takes &mut self, and the sample cache's
// plan below assumes the dispatch it plans for is submitted, so nothing
// after it may fail.
let _ = self.pipeline(shader)?;
let mut uniforms = Self::fused_uniforms(source, shader);
let sampling = self.sample.plan(&self.ctx, source, shader, width, height);
SampleCache::flag(sampling, &mut uniforms);
let (sampled, sample_out) = self.sample.views(sampling);
let params_buf = self let params_buf = self
.ctx .ctx
@@ -727,8 +962,6 @@ impl AdjustPass {
usage: wgpu::BufferUsages::UNIFORM, usage: wgpu::BufferUsages::UNIFORM,
}); });
// Borrow order: compile first, since `pipeline` takes &mut self.
let _ = self.pipeline(shader)?;
let pipeline = self let pipeline = self
.cache .cache
.get(&shader.structure_hash) .get(&shader.structure_hash)
@@ -768,6 +1001,14 @@ impl AdjustPass {
binding: 5, binding: 5,
resource: wgpu::BindingResource::TextureView(self.film_lut_view()), resource: wgpu::BindingResource::TextureView(self.film_lut_view()),
}, },
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::TextureView(&sampled),
},
wgpu::BindGroupEntry {
binding: 7,
resource: wgpu::BindingResource::TextureView(&sample_out),
},
], ],
}); });
@@ -885,7 +1126,12 @@ impl AdjustPass {
label: Some("adjust-detail-encoder"), label: Some("adjust-detail-encoder"),
}); });
let mut sampling = SampleUse::Direct;
if !reuse { if !reuse {
let mut uniforms = uniforms;
sampling = self.sample.plan(&self.ctx, source, shader, width, height);
SampleCache::flag(sampling, &mut uniforms);
let (sampled, sample_out) = self.sample.views(sampling);
let params_buf = let params_buf =
self.ctx self.ctx
.device .device
@@ -927,6 +1173,14 @@ impl AdjustPass {
binding: 5, binding: 5,
resource: wgpu::BindingResource::TextureView(&film_lut), resource: wgpu::BindingResource::TextureView(&film_lut),
}, },
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::TextureView(&sampled),
},
wgpu::BindGroupEntry {
binding: 7,
resource: wgpu::BindingResource::TextureView(&sample_out),
},
], ],
}); });
let pipeline = self let pipeline = self
@@ -954,9 +1208,20 @@ impl AdjustPass {
.expect("ensured above") .expect("ensured above")
.view .view
.clone(); .clone();
let ran = self let ran = match self
.detail .detail
.encode(&mut enc, detail, &target_view, width, height)?; .encode(&mut enc, detail, &target_view, width, height)
{
Ok(ran) => ran,
Err(e) => {
// Nothing is submitted, so a cache this frame was to write
// holds nothing, and must not be read as though it did.
if sampling == SampleUse::Write {
self.sample.release();
}
return Err(e);
}
};
self.ctx.queue.submit(Some(enc.finish())); self.ctx.queue.submit(Some(enc.finish()));
self.detail_dispatches += ran; self.detail_dispatches += ran;
self.colour_key = Some((key, width, height)); self.colour_key = Some((key, width, height));
@@ -1090,6 +1355,7 @@ impl AdjustPass {
self.detail.release_caches(); self.detail.release_caches();
self.targets = [None, None]; self.targets = [None, None];
self.colour_key = None; self.colour_key = None;
self.sample.release();
} }
/// How many distinct pipelines are compiled. Exposed for tests asserting /// How many distinct pipelines are compiled. Exposed for tests asserting
@@ -1244,6 +1510,16 @@ impl AdjustPass {
binding: 5, binding: 5,
resource: wgpu::BindingResource::TextureView(self.film_lut_view()), resource: wgpu::BindingResource::TextureView(self.film_lut_view()),
}, },
// The sample cache is the display's; a camera-space tap
// reads its source directly (its flags are zero).
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::TextureView(&self.sample.no_sampled),
},
wgpu::BindGroupEntry {
binding: 7,
resource: wgpu::BindingResource::TextureView(&self.sample.no_sample_out),
},
], ],
}); });
@@ -1898,6 +2174,62 @@ mod tests {
); );
} }
/// TRACES: FR-DEV-20
#[test]
fn a_keystone_reshapes_the_frame_without_exposing_a_corner() {
// The shader half of perspective correction, end to end. A top-bright
// frame with a full vertical keystone spreads its top across the
// output, so the bright half reaches further down than the middle;
// and since the frame is mapped onto a trapezoid *inside* the source,
// no corner is left without a pixel behind it.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = split_image(&ctx, true);
let plain = EditGraph::default_chain().compose();
let tex = pass.render(&img, &plain, 32, 32).expect("render");
let below_middle = read_pixel(&ctx, tex, 16, 19)[0];
assert!(
below_middle < 90,
"unkeyed, row 19 is the dark half: {below_middle}"
);
let mut g = EditGraph::default_chain();
g.set_param(
dr_pipeline::framing::ID,
dr_pipeline::framing::KEYSTONE_V,
100.0,
);
g.set_param(
dr_pipeline::framing::ID,
dr_pipeline::framing::KEYSTONE_H,
100.0,
);
let shader = g.compose();
let tex = pass.render(&img, &shader, 32, 32).expect("render");
for (x, y) in [(0, 0), (31, 0), (0, 31), (31, 31)] {
assert_ne!(
read_pixel(&ctx, tex, x, y),
[0, 0, 0, 255],
"corner ({x},{y}) has no source pixel behind it"
);
}
let mut g = EditGraph::default_chain();
g.set_param(
dr_pipeline::framing::ID,
dr_pipeline::framing::KEYSTONE_V,
100.0,
);
let tex = pass.render(&img, &g.compose(), 32, 32).expect("render");
let keyed = read_pixel(&ctx, tex, 16, 19)[0];
assert!(
keyed > 128,
"the spread top half must reach row 19: {keyed}"
);
}
#[test] #[test]
fn dragging_the_crop_does_not_recompile() { fn dragging_the_crop_does_not_recompile() {
// The cache contract for framing, which is what makes an interactive // The cache contract for framing, which is what makes an interactive
@@ -2568,6 +2900,131 @@ mod tests {
/// A flat RGBA8 image on the JPEG path — already gamma-encoded, as a /// A flat RGBA8 image on the JPEG path — already gamma-encoded, as a
/// decoded JPEG is. /// decoded JPEG is.
/// A frame with a different value at every pixel, several times the size
/// of the renders below, so that a fit view reads it on a stride and a
/// texel read from the wrong place cannot go unnoticed.
fn busy_image(ctx: &GpuContext) -> DemosaicedImage {
let (w, h) = (97u32, 61u32);
let mut data = Vec::with_capacity((w * h * 4) as usize);
for y in 0..h {
for x in 0..w {
let n = (x.wrapping_mul(2_654_435_761) ^ y.wrapping_mul(1_640_531_527)) >> 7;
data.extend_from_slice(&[n as u8, (n >> 8) as u8, (x * 2 + y) as u8, 255]);
}
}
DemosaicedImage::from_rgba8(ctx, &data, w, h).expect("upload")
}
/// Render `g` with a pass that has never seen it, which reads the source
/// directly by construction: the reference a cached frame must equal.
fn fresh(ctx: &GpuContext, img: &DemosaicedImage, g: &EditGraph) -> Vec<u8> {
let mut pass = AdjustPass::new(ctx);
let detail = g.compose_detail(img.size(), (23, 15));
pass.render_detailed(img, &g.compose(), 23, 15, None, &detail, 1)
.expect("render");
assert_eq!(pass.sample.last_use, SampleUse::Direct);
pass.export_pixels().expect("read").0
}
#[test]
fn a_cached_sample_is_the_same_picture() {
// TRACES: NFR-P5
// The sample cache's whole claim: a frame that reads the source through
// it is bit-for-bit the frame that reads the source directly. Walked
// through each state — direct, writing, reading, reading after a
// slider moved, and direct again once the framing moves — against a
// fresh pass each time.
let Some(ctx) = ctx() else { return };
let img = busy_image(&ctx);
let mut pass = AdjustPass::new(&ctx);
let mut g = EditGraph::default_chain();
let frame = |pass: &mut AdjustPass, g: &EditGraph, key: u64| {
let detail = g.compose_detail(img.size(), (23, 15));
pass.render_detailed(&img, &g.compose(), 23, 15, None, &detail, key)
.expect("render");
(pass.sample.last_use, pass.export_pixels().expect("read").0)
};
for (i, (expected, value)) in [
(SampleUse::Direct, 0.3),
(SampleUse::Write, 0.4),
(SampleUse::Read, 0.5),
(SampleUse::Read, -0.7),
]
.into_iter()
.enumerate()
{
g.set_param(exposure::ID, exposure::EXPOSURE, value);
let (used, pixels) = frame(&mut pass, &g, i as u64);
assert_eq!(used, expected, "frame {i}");
assert_eq!(pixels, fresh(&ctx, &img, &g), "frame {i} ({used:?})");
}
// A neighbourhood operation: the fused pass writes the linear
// intermediate instead, through the same sampling.
g.set_param(
dr_pipeline::ops::noise_reduction::ID,
dr_pipeline::ops::noise_reduction::CHROMA,
60.0,
);
for i in 10..13 {
g.set_param(exposure::ID, exposure::EXPOSURE, i as f32 * 0.01);
let (used, pixels) = frame(&mut pass, &g, i);
assert_eq!(used, SampleUse::Read, "detail frame {i}");
assert_eq!(pixels, fresh(&ctx, &img, &g), "detail frame {i}");
}
// The framing moves: what was cached is for the old framing.
g.set_param(dr_pipeline::framing::ID, dr_pipeline::framing::CROP_W, 0.6);
let (used, pixels) = frame(&mut pass, &g, 20);
assert_eq!(used, SampleUse::Direct, "a new framing reads directly");
assert_eq!(pixels, fresh(&ctx, &img, &g));
let (used, _) = frame(&mut pass, &g, 21);
assert_eq!(used, SampleUse::Write, "and caches once it holds still");
let (used, pixels) = frame(&mut pass, &g, 22);
assert_eq!(used, SampleUse::Read);
assert_eq!(pixels, fresh(&ctx, &img, &g));
}
#[test]
fn a_cache_is_not_read_for_another_image_or_size() {
// The key is the composer's half plus the two things only this side
// knows. A second photograph with the same edit and the same framing
// must not be shown the first one's texels.
let Some(ctx) = ctx() else { return };
let (a, b) = (busy_image(&ctx), grey_image(&ctx, 8000));
let mut pass = AdjustPass::new(&ctx);
let shader = EditGraph::default_chain().compose();
for _ in 0..3 {
pass.render(&a, &shader, 23, 15).expect("render");
}
assert_eq!(pass.sample.last_use, SampleUse::Read);
pass.render(&b, &shader, 23, 15).expect("render");
assert_eq!(pass.sample.last_use, SampleUse::Direct, "another image");
pass.render(&b, &shader, 23, 15).expect("render");
pass.render(&b, &shader, 24, 15).expect("render");
assert_eq!(pass.sample.last_use, SampleUse::Direct, "another size");
}
#[test]
fn a_straightened_frame_samples_directly() {
// Interpolated: the sample is a blend of four texels, which the cache's
// format could not hold exactly, so the composer offers no key.
let Some(ctx) = ctx() else { return };
let img = busy_image(&ctx);
let mut pass = AdjustPass::new(&ctx);
let mut g = EditGraph::default_chain();
g.set_param(dr_pipeline::framing::ID, dr_pipeline::framing::ANGLE, 3.0);
let shader = g.compose();
assert!(shader.sample_key.is_none());
for _ in 0..3 {
pass.render(&img, &shader, 23, 15).expect("render");
assert_eq!(pass.sample.last_use, SampleUse::Direct);
}
}
fn jpeg_image(ctx: &GpuContext, rgb: [u8; 3]) -> DemosaicedImage { fn jpeg_image(ctx: &GpuContext, rgb: [u8; 3]) -> DemosaicedImage {
let size = 16u32; let size = 16u32;
let mut data = Vec::with_capacity((size * size) as usize * 4); let mut data = Vec::with_capacity((size * size) as usize * 4);
+71
View File
@@ -95,6 +95,15 @@ pub struct DemosaicedImage {
base_curve: BaseCurve, base_curve: BaseCurve,
/// Whether the texture holds gamma-encoded rather than linear values. /// Whether the texture holds gamma-encoded rather than linear values.
non_linear: bool, non_linear: bool,
/// Which upload this is, unique for the life of the process. See
/// [`Self::id`].
id: u64,
}
/// The next [`DemosaicedImage::id`].
fn next_image_id() -> u64 {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
} }
impl DemosaicedImage { impl DemosaicedImage {
@@ -112,6 +121,18 @@ impl DemosaicedImage {
(self.width, self.height) (self.width, self.height)
} }
/// Which texture this is, as a number that is never reused.
///
/// For a cache that has to know it is still looking at the same pixels
/// (`AdjustPass`'s sample cache) without holding the texture alive to find
/// out: keeping a handle would keep half a gigabyte of a closed photograph
/// on the device, and comparing addresses would mistake a new upload for an
/// old one the moment the allocator reused the slot. A texture here is
/// never written after it is built, so the same id is the same pixels.
pub(crate) fn id(&self) -> u64 {
self.id
}
/// Camera RGB → linear sRGB, row-major. Identity where the body is /// Camera RGB → linear sRGB, row-major. Identity where the body is
/// uncalibrated, so the image renders uncalibrated rather than black. /// uncalibrated, so the image renders uncalibrated rather than black.
pub fn color_matrix(&self) -> [f32; 9] { pub fn color_matrix(&self) -> [f32; 9] {
@@ -246,6 +267,7 @@ impl DemosaicedImage {
// the highlights of an image that was already finished. // the highlights of an image that was already finished.
base_curve: BaseCurve::IDENTITY, base_curve: BaseCurve::IDENTITY,
non_linear: true, non_linear: true,
id: next_image_id(),
}) })
} }
} }
@@ -322,6 +344,7 @@ impl DemosaicedImage {
as_shot_wb: [raw.wb_coeffs[0], raw.wb_coeffs[1], raw.wb_coeffs[2]], as_shot_wb: [raw.wb_coeffs[0], raw.wb_coeffs[1], raw.wb_coeffs[2]],
base_curve: raw.base_curve, base_curve: raw.base_curve,
non_linear: false, non_linear: false,
id: next_image_id(),
}) })
} }
} }
@@ -660,6 +683,7 @@ impl Demosaicer {
// normalises against black and white levels and applies no // normalises against black and white levels and applies no
// transfer function. // transfer function.
non_linear: false, non_linear: false,
id: next_image_id(),
}) })
} }
} }
@@ -1347,6 +1371,53 @@ mod tests {
} }
} }
#[test]
fn a_grey_step_edge_stays_grey() {
// A flat patch cannot tell the Malvar kernels from any other set of
// weights that sum to zero. An edge can. A grey vertical step, so
// every photosite records the same profile, must come back with the
// three channels close together on both sides; any spread is false
// colour from interpolating across the edge.
//
// The bound is set by the paper's kernels, which peak at 0.19 here.
// With the ±2 terms of the green-site kernels transposed — the bug
// this test was written against — the peak is 0.375.
let Some(ctx) = ctx() else { return };
let d = Demosaicer::new(&ctx).expect("demosaicer");
let size = 32u32;
let white = 16383u16;
let mut raw = flat_cfa(CfaPattern::Rggb, size, [0, 0, 0], 0, white);
for y in 0..size {
for x in size / 2..size {
raw.data[(y * size + x) as usize] = white;
}
}
let img = d.run(&raw).expect("demosaic");
let px = read_rgba(&ctx, &img);
let (w, _) = img.size();
let mut worst = (0.0f32, 0u32, 0u32);
for y in 2..size - 2 {
for x in 2..size - 2 {
let p = px[(y * w + x) as usize];
let spread = (p[0] - p[1]).abs().max((p[2] - p[1]).abs());
if spread > worst.0 {
worst = (spread, x, y);
}
}
}
assert!(
worst.0 < 0.25,
"false colour of {} at ({}, {}) on a grey edge — the green-site \
kernels are interpolating across the edge",
worst.0,
worst.1,
worst.2
);
}
#[test] #[test]
fn output_is_free_of_nan_and_negatives() { fn output_is_free_of_nan_and_negatives() {
// f16 NaN propagates silently through every later stage; a negative // f16 NaN propagates silently through every later stage; a negative
+6 -13
View File
@@ -470,7 +470,7 @@ impl FocusPeakPass {
// TEXTURE_BINDING to be sampled by the compositor. // TEXTURE_BINDING to be sampled by the compositor.
// RENDER_ATTACHMENT is not used by anything here and is required // RENDER_ATTACHMENT is not used by anything here and is required
// anyway: Slint rejects an imported texture without it. COPY_SRC // anyway: Slint rejects an imported texture without it. COPY_SRC
// is for `read_overlay` and its two callers. // is for `read_overlay` and the tests that call it.
usage: wgpu::TextureUsages::STORAGE_BINDING usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::RENDER_ATTACHMENT
@@ -490,18 +490,11 @@ impl FocusPeakPass {
/// TRACES: AC-8 /// TRACES: AC-8
/// Copy the overlay to the CPU, as RGBA8 rows with no padding. /// Copy the overlay to the CPU, as RGBA8 rows with no padding.
/// ///
/// **Two callers, and neither is the desktop display path.** The tests /// **The tests below are the only caller, and never the display path.** An
/// below are one: an overlay is a claim about which pixels are sharp, and /// overlay is a claim about which pixels are sharp, and there is no way to
/// there is no way to check that claim without looking at the pixels. The /// check that claim without looking at the pixels. On screen, on desktop
/// other is the Android develop view, which reads the *frame* back for the /// and Android alike, the overlay reaches the compositor as a texture and
/// reasons `technical-debt.md` TD-1 records — wgpu's Android swapchain /// ARCH §6.1 holds. (Android read it back here until TD-1 was paid off.)
/// tears a portrait window, so Slint is not drawing with wgpu there and no
/// texture can be handed over. An overlay that stayed on the device on a
/// platform where the picture underneath it does not would simply never be
/// seen.
///
/// On desktop nothing calls this, and ARCH §6.1 holds on the path that
/// matters: the overlay reaches the compositor as a texture.
pub fn read_overlay(&self) -> Result<(Vec<u8>, u32, u32), GpuError> { pub fn read_overlay(&self) -> Result<(Vec<u8>, u32, u32), GpuError> {
let Some(layer) = self.layers[self.current].as_ref() else { let Some(layer) = self.layers[self.current].as_ref() else {
return Err(GpuError::Readback("no overlay has been rendered".into())); return Err(GpuError::Readback("no overlay has been rendered".into()));
+1 -1
View File
@@ -6,7 +6,7 @@
//! module doc said for eight releases that it held no pipeline and no masks. //! module doc said for eight releases that it held no pipeline and no masks.
//! It holds both now, plus demosaic, detail, segmentation masks, two //! It holds both now, plus demosaic, detail, segmentation masks, two
//! histograms and focus peaking. The zero-copy claim is still the one that //! histograms and focus peaking. The zero-copy claim is still the one that
//! matters, and TD-1 records the one platform where it does not hold. //! matters, and since TD-1 was paid off it holds on Android too.
//! //!
//! Deliberately free of UI dependencies (ARCH §6.5a). The texture is handed //! Deliberately free of UI dependencies (ARCH §6.5a). The texture is handed
//! out as a `wgpu::Texture`; who composites it is not this crate's concern. //! out as a `wgpu::Texture`; who composites it is not this crate's concern.
+13
View File
@@ -395,6 +395,7 @@ pub struct MaskPass {
combine_layout: wgpu::BindGroupLayout, combine_layout: wgpu::BindGroupLayout,
combine_union: wgpu::RenderPipeline, combine_union: wgpu::RenderPipeline,
combine_subtract: wgpu::RenderPipeline, combine_subtract: wgpu::RenderPipeline,
combine_intersect: wgpu::RenderPipeline,
/// Where a part is drawn before it is joined. /// Where a part is drawn before it is joined.
/// ///
/// One texture for the whole stack rather than one per layer, because /// One texture for the whole stack rather than one per layer, because
@@ -651,6 +652,16 @@ impl MaskPass {
"mask-combine-subtract", "mask-combine-subtract",
blend_state(wgpu::BlendFactor::Zero, wgpu::BlendFactor::OneMinusSrc), blend_state(wgpu::BlendFactor::Zero, wgpu::BlendFactor::OneMinusSrc),
); );
// TRACES: FR-DEV-19a
// `dst * src`: what the mask had, kept only in proportion to how much
// of it this part also covers. The same three vertices and the same
// scratch, so a third set operation is a third blend state and
// nothing more — which is what `Join::apply` states on the CPU and
// `the_joins_match_their_definition` holds this to.
let combine_intersect = combine(
"mask-combine-intersect",
blend_state(wgpu::BlendFactor::Zero, wgpu::BlendFactor::Src),
);
// The same, with the deposit thrown away: coverage is only ever taken // The same, with the deposit thrown away: coverage is only ever taken
// off what earlier strokes on this layer put down. There is no negative // off what earlier strokes on this layer put down. There is no negative
@@ -681,6 +692,7 @@ impl MaskPass {
combine_layout, combine_layout,
combine_union, combine_union,
combine_subtract, combine_subtract,
combine_intersect,
scratch: None, scratch: None,
array: None, array: None,
allocations: 0, allocations: 0,
@@ -1376,6 +1388,7 @@ impl MaskPass {
pass.set_pipeline(match join { pass.set_pipeline(match join {
Join::Union => &self.combine_union, Join::Union => &self.combine_union,
Join::Subtract => &self.combine_subtract, Join::Subtract => &self.combine_subtract,
Join::Intersect => &self.combine_intersect,
}); });
pass.set_bind_group(0, &bind_group, &[]); pass.set_bind_group(0, &bind_group, &[]);
pass.draw(0..3, 0..1); pass.draw(0..3, 0..1);
+7 -7
View File
@@ -1,4 +1,4 @@
//! Watershed segmentation — arm A's GPU half (S15, docs/segmentation.md). //! Watershed segmentation — arm A's GPU half (S15, docs/dev/segmentation.md).
//! //!
//! Runs the five passes in `shaders/watershed.wgsl` over a demosaiced image //! Runs the five passes in `shaders/watershed.wgsl` over a demosaiced image
//! and leaves a basin label per pixel on the GPU. The hierarchy built from //! and leaves a basin label per pixel on the GPU. The hierarchy built from
@@ -20,7 +20,7 @@
//! one AC-8 forbids is per frame in the render loop, and sharing a switch //! one AC-8 forbids is per frame in the render loop, and sharing a switch
//! would force a build wanting local masking to unlock the other. //! would force a build wanting local masking to unlock the other.
//! //!
//! It is still a real cost and still unfinished. F3 in docs/segmentation.md //! It is still a real cost and still unfinished. F3 in docs/dev/segmentation.md
//! §12 stands: the adjacency accumulation belongs GPU-side with atomics, and //! §12 stands: the adjacency accumulation belongs GPU-side with atomics, and
//! until it moves there every segmentation pays a full-resolution transfer. //! until it moves there every segmentation pays a full-resolution transfer.
//! Read the feature name as a description of a known gap rather than as //! Read the feature name as a description of a known gap rather than as
@@ -36,7 +36,7 @@ pub struct SegmentOptions {
/// Longest proxy edge. The segmentation runs here, not at sensor /// Longest proxy edge. The segmentation runs here, not at sensor
/// resolution: a 24 MP watershed costs 12× the memory to place boundaries /// resolution: a 24 MP watershed costs 12× the memory to place boundaries
/// a person cannot see, and the boundary refinement that matters at 1:1 /// a person cannot see, and the boundary refinement that matters at 1:1
/// is a separate stage (docs/segmentation.md §4). /// is a separate stage (docs/dev/segmentation.md §4).
pub max_edge: u32, pub max_edge: u32,
/// Pre-smoothing radius in proxy pixels. The caller's to raise with ISO — /// Pre-smoothing radius in proxy pixels. The caller's to raise with ISO —
/// this is the single knob that decides whether a noisy file segments /// this is the single knob that decides whether a noisy file segments
@@ -69,7 +69,7 @@ impl Default for SegmentOptions {
w_chroma: 0.5, w_chroma: 0.5,
// **Zero: the pass is off.** It is implemented, dispatched // **Zero: the pass is off.** It is implemented, dispatched
// correctly and measurably changes nothing — see the ignored test // correctly and measurably changes nothing — see the ignored test
// below and §12 of docs/segmentation.md. Until that is understood, // below and §12 of docs/dev/segmentation.md. Until that is understood,
// running it would buy 64 dispatches per segmentation and no // running it would buy 64 dispatches per segmentation and no
// improvement, so the default declines to pay. // improvement, so the default declines to pay.
plateau_iterations: 0, plateau_iterations: 0,
@@ -486,7 +486,7 @@ impl Segmentation {
/// a region graph of a few thousand nodes that every later interaction /// a region graph of a few thousand nodes that every later interaction
/// reads from the CPU anyway. /// reads from the CPU anyway.
/// ///
/// What it is *not* is finished. F3 in docs/segmentation.md §12 stands: /// What it is *not* is finished. F3 in docs/dev/segmentation.md §12 stands:
/// the adjacency accumulation belongs on the GPU with atomics, and until /// the adjacency accumulation belongs on the GPU with atomics, and until
/// it moves there a segmentation costs one full-resolution transfer of the /// it moves there a segmentation costs one full-resolution transfer of the
/// label and gradient buffers. That is a real cost on a phone and the /// label and gradient buffers. That is a real cost on a phone and the
@@ -724,7 +724,7 @@ mod tests {
px px
} }
#[test] #[test]
#[ignore = "the plateau pass is a measured no-op; see docs/segmentation.md §12"] #[ignore = "the plateau pass is a measured no-op; see docs/dev/segmentation.md §12"]
fn lower_completion_drains_a_plateau_instead_of_shattering_it() { fn lower_completion_drains_a_plateau_instead_of_shattering_it() {
// F1, asserted rather than eyeballed, and asserted at the level where // F1, asserted rather than eyeballed, and asserted at the level where
// it matters. // it matters.
@@ -741,7 +741,7 @@ mod tests {
// with no exit anywhere — cannot be drained by a distance that has // with no exit anywhere — cannot be drained by a distance that has
// nowhere to descend to, and collapsing it fully would need connected // nowhere to descend to, and collapsing it fully would need connected
// component labelling rather than a local rule. It is not worth it: // component labelling rather than a local rule. It is not worth it:
// see docs/segmentation.md §12. // see docs/dev/segmentation.md §12.
let Some(ctx) = ctx() else { return }; let Some(ctx) = ctx() else { return };
let (w, h) = (96u32, 96u32); let (w, h) = (96u32, 96u32);
let src = DemosaicedImage::from_rgba8(&ctx, &ramp(w, h), w, h).expect("source"); let src = DemosaicedImage::from_rgba8(&ctx, &ramp(w, h), w, h).expect("source");
+10 -4
View File
@@ -160,12 +160,18 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
// Green is measured. Red and blue are interpolated from their own // Green is measured. Red and blue are interpolated from their own
// axis, with a correction from the green Laplacian. // axis, with a correction from the green Laplacian.
// //
// Malvar "G at R/B locations" kernels, transposed per axis: // Malvar "R at green in R row" kernel, and its transpose:
// chroma along the row: (5c + 4(w1+e1) - (nw+ne+sw+se) - (n2+s2) + 0.5(w2+e2)) / 8 // chroma along the row: (5c + 4(w1+e1) - (nw+ne+sw+se) - (w2+e2) + 0.5(n2+s2)) / 8
//
// The -1 goes on the two greens *along* the chroma axis and the +0.5
// on the pair across it. Transposed, both kernels still sum to zero
// and reconstruct a flat patch exactly, but on an edge the correction
// at green sites is half strength and the false colour doubles: a
// blue/yellow zipper around every clipped highlight.
let along_row = let along_row =
(5.0 * c + 4.0 * (w1 + e1) - diag1 - vert2 + 0.5 * horiz2) * 0.125; (5.0 * c + 4.0 * (w1 + e1) - diag1 - horiz2 + 0.5 * vert2) * 0.125;
let along_col = let along_col =
(5.0 * c + 4.0 * (n1 + s1) - diag1 - horiz2 + 0.5 * vert2) * 0.125; (5.0 * c + 4.0 * (n1 + s1) - diag1 - vert2 + 0.5 * horiz2) * 0.125;
let red_horizontal = red_is_horizontal(gid.x, gid.y); let red_horizontal = red_is_horizontal(gid.x, gid.y);
let r = select(along_col, along_row, red_horizontal); let r = select(along_col, along_row, red_horizontal);
+2 -2
View File
@@ -1,4 +1,4 @@
// Watershed segmentation — the passes behind arm A of S15 (docs/segmentation.md). // Watershed segmentation — the passes behind arm A of S15 (docs/dev/segmentation.md).
// //
// Seven entry points forming one chain: // Seven entry points forming one chain:
// //
@@ -197,7 +197,7 @@ fn gradient(@builtin(global_invocation_id) gid: vec3<u32>) {
// lowest-indexed neighbour, which is up and to the left. Each pixel therefore // lowest-indexed neighbour, which is up and to the left. Each pixel therefore
// walks diagonally until it falls off the plateau, and one flat region becomes // walks diagonally until it falls off the plateau, and one flat region becomes
// a fan of diagonal chains rather than one basin — visible as hatching across // a fan of diagonal chains rather than one basin — visible as hatching across
// what should be a single area (docs/segmentation.md §12, F1). // what should be a single area (docs/dev/segmentation.md §12, F1).
// //
// The fix is the standard lower-completion: give each plateau pixel its // The fix is the standard lower-completion: give each plateau pixel its
// geodesic distance to the nearest pixel that *does* have a lower neighbour, // geodesic distance to the nearest pixel that *does* have a lower neighbour,
+5 -5
View File
@@ -2,11 +2,11 @@
//! //!
//! FR-DSP-3 says a slider updates the visible region within one frame budget at //! FR-DSP-3 says a slider updates the visible region within one frame budget at
//! proxy resolution. Until this file existed nothing checked it, which made it //! proxy resolution. Until this file existed nothing checked it, which made it
//! a wish — `docs/display-and-extension.md` §3 is blunt about that, and §7 is //! a wish — `docs/dev/display-and-extension.md` §3 is blunt about that, and §7 is
//! blunt about what tagging an unchecked requirement does to the coverage //! blunt about what tagging an unchecked requirement does to the coverage
//! figure. //! figure.
//! //!
//! The measurements this guards are in [`docs/frame-budget.md`], produced by //! The measurements this guards are in [`docs/dev/frame-budget.md`], produced by
//! `examples/frame_budget.rs`. This file is the part of them that has to keep //! `examples/frame_budget.rs`. This file is the part of them that has to keep
//! being true: it renders the **whole point-operation chain** through the real //! being true: it renders the **whole point-operation chain** through the real
//! `render_detailed` for a hundred frames, moving a slider between each, and //! `render_detailed` for a hundred frames, moving a slider between each, and
@@ -17,7 +17,7 @@
//! **The neighbourhood stage is deliberately not in the asserted chain.** It is //! **The neighbourhood stage is deliberately not in the asserted chain.** It is
//! over the budget today — clarity alone is 34 ms at 4K, because its kernel is //! over the budget today — clarity alone is 34 ms at 4K, because its kernel is
//! a fraction of the frame and reaches a 52-pixel radius there — and //! a fraction of the frame and reaches a 52-pixel radius there — and
//! `docs/frame-budget.md` records that, names the fix (a base computed at //! `docs/dev/frame-budget.md` records that, names the fix (a base computed at
//! reduced resolution) and does not pretend otherwise. Asserting a budget the //! reduced resolution) and does not pretend otherwise. Asserting a budget the
//! code does not meet would produce a red suite that everyone learns to ignore; //! code does not meet would produce a red suite that everyone learns to ignore;
//! asserting it on a chain that quietly excluded the expensive stage *without //! asserting it on a chain that quietly excluded the expensive stage *without
@@ -81,7 +81,7 @@ const SOURCE: (u32, u32) = (6000, 4000);
/// The viewport the budget is asserted at: a 16:10 desktop display. /// The viewport the budget is asserted at: a 16:10 desktop display.
/// ///
/// Not 4K, and the reason is worth stating. At 4K the fused chain still passes /// Not 4K, and the reason is worth stating. At 4K the fused chain still passes
/// with room to spare (4.5 ms of GPU; see `docs/frame-budget.md`), but a test /// with room to spare (4.5 ms of GPU; see `docs/dev/frame-budget.md`), but a test
/// that renders 8.3 M pixels a hundred times twice over is four seconds of /// that renders 8.3 M pixels a hundred times twice over is four seconds of
/// suite time to re-establish a conclusion 4.1 M pixels already establishes. /// suite time to re-establish a conclusion 4.1 M pixels already establishes.
const VIEWPORT: (u32, u32) = (2560, 1600); const VIEWPORT: (u32, u32) = (2560, 1600);
@@ -166,7 +166,7 @@ impl Run {
judged <= BUDGET_MS, judged <= BUDGET_MS,
"{case} at {}x{}: p99 of {FRAMES} frames was {judged:.2} ms, over the \ "{case} at {}x{}: p99 of {FRAMES} frames was {judged:.2} ms, over the \
{BUDGET_MS:.0} ms budget (cpu {:.2} ms, gpu {:.2} ms, total {:.2} ms). \ {BUDGET_MS:.0} ms budget (cpu {:.2} ms, gpu {:.2} ms, total {:.2} ms). \
FR-DSP-3 is what this violates; docs/frame-budget.md holds the \ FR-DSP-3 is what this violates; docs/dev/frame-budget.md holds the \
numbers it used to be.", numbers it used to be.",
viewport.0, viewport.0,
viewport.1, viewport.1,
+113
View File
@@ -790,6 +790,119 @@ fn the_order_parts_are_joined_in_is_the_mask() {
); );
} }
/// TRACES: FR-DEV-19a
/// The truth table `docs/dev/mask-editing.md` §13 asks for: one base, one
/// part that half-covers it, joined each of the three ways. The base is the
/// left half of the frame and the part a dab in the middle, so the four
/// quarters of the table are four pixels.
#[test]
fn a_part_unioned_subtracted_and_intersected_gives_the_three_fields() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let field = split_field(&ctx);
let joined = |join: Join| {
let mut layer = brighten(MaskSource::Regions {
signature: 1,
level: 2,
ids: vec![0],
});
assert!(layer.push_part(MaskPart::painted("p2", join)));
paint(&mut layer, 1, false, &[(0.5, 0.5)]);
let mut stack = MaskStack::new();
stack.push(layer);
render(&ctx, &stack, Some(&field))
};
// (base, part): left outside the dab, left inside, right inside, right
// outside.
let cells = [(4, 16), (14, 16), (18, 16), (27, 16)];
let lit = |pixels: &[u8]| cells.map(|(x, y)| luma_at(pixels, x, y) > 200);
assert_eq!(
lit(&joined(Join::Union)),
[true, true, true, false],
"union: either"
);
assert_eq!(
lit(&joined(Join::Subtract)),
[true, false, false, false],
"subtract: the base without the dab"
);
assert_eq!(
lit(&joined(Join::Intersect)),
[false, true, false, false],
"intersect: only where both are"
);
}
/// TRACES: FR-DEV-19a
/// Intersection on soft coverage is the product `Join::apply` defines, on
/// either side of the join: a gradient intersected with a region it fills is
/// the gradient there and nothing elsewhere, and a region intersected with a
/// gradient is the gradient wherever the region is.
#[test]
fn the_joins_match_their_definition() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let field = split_field(&ctx);
let ramp = || MaskSource::Linear {
centre: (0.5, 0.5),
angle: 0.0,
width: 1.0,
};
let right_half = || MaskSource::Regions {
signature: 1,
level: 2,
ids: vec![1],
};
let draw = |layer: MaskLayer| {
let mut stack = MaskStack::new();
stack.push(layer);
render(&ctx, &stack, Some(&field))
};
let alone = draw(brighten(ramp()));
let mut ramp_then_region = brighten(ramp());
assert!(ramp_then_region.push_part(MaskPart::new("p2", Join::Intersect, right_half())));
let ramp_then_region = draw(ramp_then_region);
let mut region_then_ramp = brighten(whole_frame());
assert!(region_then_ramp.push_part(MaskPart::new("p2", Join::Intersect, ramp())));
let region_then_ramp = draw(region_then_ramp);
for x in 0..SIZE {
let y = SIZE / 2;
let want = luma_at(&alone, x, y);
// dst · 1 = dst on the right; dst · 0 = 0 on the left. Pixels
// within two of the seam are left out: the region's own edge
// is soft there, so neither side of the table is 0 or 1.
let got = luma_at(&ramp_then_region, x, y);
if x.abs_diff(SIZE / 2) <= 2 {
// The seam.
} else if x > SIZE / 2 {
assert!(
got.abs_diff(want) <= 1,
"x={x}: the ramp survives where the region is ({got} vs {want})"
);
} else {
assert_eq!(got, 128, "x={x}: and nothing survives where it is not");
}
// 1 · src = src everywhere.
let got = luma_at(&region_then_ramp, x, y);
assert!(
got.abs_diff(want) <= 1,
"x={x}: a full base intersected with the ramp is the ramp ({got} vs {want})"
);
}
}
// --- seeing the mask (FR-DEV-19c) ------------------------------------------ // --- seeing the mask (FR-DEV-19c) ------------------------------------------
/// A radial that covers the middle of the frame and nothing near the corners. /// A radial that covers the middle of the frame and nothing near the corners.
+1 -1
View File
@@ -326,7 +326,7 @@ fn a_proxy_and_an_export_agree_about_the_effect() {
#[test] #[test]
fn crossing_the_reduction_threshold_does_not_change_the_picture() { fn crossing_the_reduction_threshold_does_not_change_the_picture() {
// TRACES: FR-DSP-3 — `docs/technical-debt.md` TD-4, held in pixels. // TRACES: FR-DSP-3 — `docs/dev/technical-debt.md` TD-4, held in pixels.
// //
// Clarity's base is computed on a reduced grid, and how reduced depends on // Clarity's base is computed on a reduced grid, and how reduced depends on
// the viewport: `LocalContrast::reduction` steps 4 -> 2 -> 1 as sigma // the viewport: `LocalContrast::reduction` steps 4 -> 2 -> 1 as sigma
+1 -1
View File
@@ -10,7 +10,7 @@
//! code path — the zoom is the full-resolution path — which is why the //! code path — the zoom is the full-resolution path — which is why the
//! requirement has been satisfied for some time without anyone tagging it. //! requirement has been satisfied for some time without anyone tagging it.
//! //!
//! `docs/display-and-extension.md` §7 is the reason this file exists rather //! `docs/dev/display-and-extension.md` §7 is the reason this file exists rather
//! than a tag on `framing.rs`: a requirement counts as covered when a `TRACES` //! than a tag on `framing.rs`: a requirement counts as covered when a `TRACES`
//! comment names it, and nothing checks that the code under the tag does the //! comment names it, and nothing checks that the code under the tag does the
//! thing. `FR-DEV-8` is tagged against plumbing a future operation would use. //! thing. `FR-DEV-8` is tagged against plumbing a future operation would use.
+10 -2
View File
@@ -6,7 +6,7 @@ rust-version.workspace = true
license.workspace = true license.workspace = true
# The one crate that names a runtime, a provider, a vendor library or a # The one crate that names a runtime, a provider, a vendor library or a
# device (docs/inference.md §8). `dr-face` and `dr-segment` ask it for a # device (docs/dev/inference.md §8). `dr-face` and `dr-segment` ask it for a
# session by role and never see which of these answered. # session by role and never see which of these answered.
[dependencies] [dependencies]
@@ -28,7 +28,10 @@ ort-sys = { version = "2.0.0-rc.13", default-features = false, features = ["disa
# The NVIDIA rungs exist on the desktop only. These features add `ort`'s # The NVIDIA rungs exist on the desktop only. These features add `ort`'s
# option builders and nothing else — no linking under `alternative-backend` — # option builders and nothing else — no linking under `alternative-backend` —
# but an Android binary has no business carrying even the option names, and # but an Android binary has no business carrying even the option names, and
# the packaging must never be tempted to (§2, §3.1). # the packaging must never be tempted to (§2, §3.1). The AMD rung needs no
# feature: MIGraphX is registered through the runtime's generic key/value
# entry point (`session::migraphx`), because `ort`'s own builder cannot
# name the compiled-program cache.
[target.'cfg(not(target_os = "android"))'.dependencies] [target.'cfg(not(target_os = "android"))'.dependencies]
ort = { workspace = true, features = ["cuda", "tensorrt"] } ort = { workspace = true, features = ["cuda", "tensorrt"] }
@@ -42,3 +45,8 @@ default = ["tract"]
tract = ["dep:ort-tract"] tract = ["dep:ort-tract"]
# Look for `libonnxruntime` on disk and hand its table to `ort`. # Look for `libonnxruntime` on disk and hand its table to `ort`.
native = ["dep:libloading", "dep:ort-sys"] native = ["dep:libloading", "dep:ort-sys"]
[dev-dependencies]
# The `ep_probe` example prints the provider's own diagnostics, which is most
# of what a failed rung tells you.
env_logger.workspace = true
@@ -0,0 +1,207 @@
//! Time each execution provider a runtime offers, on the models this
//! repository ships — the measurement docs/inference.md §1 requires before a
//! rung is added to §2's ladder.
//!
//! DARKROOM_ORT_DIR=/usr/lib \
//! cargo run --release -p dr-inference-engine --features native,tract \
//! --example ep_probe -- models/face/scrfd_500m_640.onnx ...
//!
//! Prints one row per (model, provider): the median of timed runs after
//! warm-ups, and the build time, which for a compiling provider is the
//! number that decides whether it needs an engine cache. MIGraphX is built
//! twice per precision — cold, then again from the cache it just wrote —
//! so both numbers are on the page.
//!
//! The ROCm provider is not in the list: ONNX Runtime removed it in 1.23,
//! and 1.29's `onnxruntime-rocm` ships `libonnxruntime_providers_migraphx.so`
//! and nothing else for AMD.
use std::path::{Path, PathBuf};
use std::time::Instant;
#[derive(Clone, Copy, PartialEq)]
enum Ep {
Cpu,
MiGraphX,
MiGraphXFp16,
}
impl Ep {
fn label(self) -> &'static str {
match self {
Ep::Cpu => "CPU",
Ep::MiGraphX => "MIGraphX f32",
Ep::MiGraphXFp16 => "MIGraphX fp16",
}
}
}
fn build(ep: Ep, bytes: &[u8], threads: usize, cache: &Path) -> ort::Result<ort::session::Session> {
let mut b = ort::session::Session::builder()?.with_intra_threads(threads)?;
match ep {
Ep::Cpu => {}
Ep::MiGraphX => migraphx(&mut b, false, &cache.join("f32"))?,
Ep::MiGraphXFp16 => migraphx(&mut b, true, &cache.join("fp16"))?,
}
b.commit_from_memory(bytes)
}
/// Register MIGraphX through the generic key/value API. `ort`'s own
/// builder fills the legacy `OrtMIGraphXProviderOptions`, which 1.29 reads
/// for its precision flags and nothing else: the model cache directory —
/// the difference between a 40 s load and a 0.3 s one — only travels this
/// way. The cache key is the graph, the GPU and the MIGraphX version, not
/// the precision, so each precision gets its own directory.
fn migraphx(
b: &mut ort::session::builder::SessionBuilder,
fp16: bool,
cache: &Path,
) -> ort::Result<()> {
use ort::AsPointer;
use std::ffi::CString;
std::fs::create_dir_all(cache).map_err(|e| ort::Error::new(e.to_string()))?;
let keys = [c"migraphx_fp16_enable", c"migraphx_model_cache_dir"];
let values = [
CString::new(if fp16 { "1" } else { "0" }).unwrap(),
CString::new(cache.to_string_lossy().as_bytes()).unwrap(),
];
let key_ptrs: Vec<_> = keys.iter().map(|k| k.as_ptr()).collect();
let value_ptrs: Vec<_> = values.iter().map(|v| v.as_ptr()).collect();
// SAFETY: the documented C call, over arrays that outlive it; the
// runtime copies the strings into its own options map.
unsafe {
let status = (ort::api().SessionOptionsAppendExecutionProvider)(
b.ptr_mut(),
c"MIGraphX".as_ptr(),
key_ptrs.as_ptr(),
value_ptrs.as_ptr(),
keys.len(),
);
ort::Error::result_from_status(status)
}
}
/// Median of `runs` timed runs over zeros, in milliseconds, after warm-ups.
fn time(session: &mut ort::session::Session, warmups: usize, runs: usize) -> Result<f64, String> {
let shape: Vec<usize> = session.inputs()[0]
.dtype()
.tensor_shape()
.ok_or("input is not a tensor")?
.iter()
.map(|&d| if d > 0 { d as usize } else { 1 })
.collect();
let zeros = vec![0f32; shape.iter().product()];
let once = |s: &mut ort::session::Session| -> Result<f64, String> {
let input = ort::value::Tensor::from_array((shape.clone(), zeros.clone()))
.map_err(|e| e.to_string())?;
let t = Instant::now();
let out = s.run(ort::inputs![input]).map_err(|e| e.to_string())?;
let _ = out[0]
.try_extract_tensor::<f32>()
.map_err(|e| e.to_string())?;
Ok(t.elapsed().as_secs_f64() * 1e3)
};
for _ in 0..warmups {
once(session)?;
}
let mut times = Vec::with_capacity(runs);
for _ in 0..runs {
times.push(once(session)?);
}
times.sort_by(|a, b| a.partial_cmp(b).unwrap());
Ok(times[times.len() / 2])
}
fn first_line(s: &str) -> String {
s.lines().next().unwrap_or("").chars().take(120).collect()
}
fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let models: Vec<PathBuf> = std::env::args_os().skip(1).map(PathBuf::from).collect();
if models.is_empty() {
eprintln!("usage: ep_probe MODEL.onnx [MODEL.onnx ...]");
std::process::exit(2);
}
dr_inference_engine::ensure_runtime();
let runtime = dr_inference_engine::status().runtime;
println!("runtime: {}", runtime.label());
if !runtime.is_native() {
println!("(tract: no provider to compare; set DARKROOM_ORT_DIR)");
}
let threads = std::thread::available_parallelism()
.map(|n| n.get().saturating_sub(2).max(1))
.unwrap_or(1);
println!("intra-op threads: {threads}");
let cache = std::env::temp_dir().join("darkroom-ep-probe");
let _ = std::fs::remove_dir_all(&cache);
println!("compiled-program cache: {}\n", cache.display());
println!(
"{:<28} {:<15} {:>10} {:>10}",
"model", "provider", "build s", "median ms"
);
for model in &models {
let bytes = match std::fs::read(model) {
Ok(b) => b,
Err(e) => {
println!("{:<28} read failed: {e}", name(model));
continue;
}
};
// A compiling provider is built twice: the second build reads the
// program the first wrote, and its time is what a launch after the
// first costs.
let plan = [
(Ep::Cpu, false),
(Ep::MiGraphX, false),
(Ep::MiGraphX, true),
(Ep::MiGraphXFp16, false),
(Ep::MiGraphXFp16, true),
];
for (ep, cached) in plan {
let started = Instant::now();
match build(ep, &bytes, threads, &cache) {
Ok(mut session) => {
let built = started.elapsed().as_secs_f64();
match time(&mut session, 3, 15) {
Ok(ms) => println!(
"{:<28} {:<15} {:>10.1} {:>10.1}{}",
name(model),
ep.label(),
built,
ms,
if cached { " (from cache)" } else { "" }
),
Err(e) => println!(
"{:<28} {:<15} {:>10.1} {:>10} {}",
name(model),
ep.label(),
built,
"ran ✗",
first_line(&e)
),
}
}
Err(e) => println!(
"{:<28} {:<15} {:>21} {}",
name(model),
ep.label(),
"build ✗",
first_line(&e.to_string())
),
}
}
println!();
}
}
fn name(p: &Path) -> String {
p.file_name()
.unwrap_or(p.as_os_str())
.to_string_lossy()
.into_owned()
}
+100
View File
@@ -0,0 +1,100 @@
//! Walk the ladder as the app does — probe, engines, then a session — and
//! say what each step chose. The M5 check of docs/inference.md §6 without
//! the app around it.
//!
//! DARKROOM_ORT_DIR=/usr/lib \
//! cargo run --release -p dr-inference-engine --features native,tract \
//! --example ladder -- CACHE_DIR models/face/scrfd_500m_640.onnx [MODEL.onnx ...]
//!
//! Every model named is a `Detector` for the config's purposes, which is
//! enough to see the rung taken, the engines compiled and a session land
//! on it. Delete `CACHE_DIR` to see the first run again; keep it to see the
//! second.
use std::path::PathBuf;
use std::time::{Duration, Instant};
fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let mut args = std::env::args_os().skip(1).map(PathBuf::from);
let (Some(cache_dir), models) = (args.next(), args.collect::<Vec<_>>()) else {
eprintln!("usage: ladder CACHE_DIR MODEL.onnx [MODEL.onnx ...]");
std::process::exit(2);
};
if models.is_empty() {
eprintln!("usage: ladder CACHE_DIR MODEL.onnx [MODEL.onnx ...]");
std::process::exit(2);
}
let runtime_dirs: Vec<PathBuf> = std::env::var_os("DARKROOM_ORT_DIR")
.map(PathBuf::from)
.into_iter()
.collect();
let started = Instant::now();
dr_inference_engine::init(dr_inference_engine::Config {
runtime_dirs,
cache_dir: cache_dir.clone(),
models: models
.iter()
.map(|p| (dr_inference_engine::Role::Detector, p.clone()))
.collect(),
embedded: Vec::new(),
ceiling: None,
threads: 0,
decay: Duration::ZERO,
});
let mut last = String::new();
loop {
let s = dr_inference_engine::status();
let line = format!(
"{} · {} · engines {}/{}{}",
s.line(),
if s.probing {
"probing"
} else {
s.reason.as_str()
},
s.engines.0,
s.engines.1,
if s.failed.is_empty() {
String::new()
} else {
format!(
" · tried {}",
s.failed
.iter()
.map(|(r, why)| format!("{}: {why}", r.label()))
.collect::<Vec<_>>()
.join(" · ")
)
}
);
if line != last {
println!("{:>6.1} s {line}", started.elapsed().as_secs_f64());
last = line;
}
if !s.probing && s.engines.0 >= s.engines.1 {
break;
}
std::thread::sleep(Duration::from_millis(500));
}
for path in &models {
let bytes = std::fs::read(path).expect("read model");
let t = Instant::now();
let model = dr_inference_engine::open(
dr_inference_engine::Role::Detector,
dr_inference_engine::Form::F32,
&bytes,
)
.expect("open model");
let acquired = model.acquire().expect("acquire session");
println!(
"{} on {} in {:.2} s",
path.file_name().unwrap().to_string_lossy(),
acquired.rung().label(),
t.elapsed().as_secs_f64()
);
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
//! The API table `ort` runs on, chosen once (docs/inference.md §3). //! The API table `ort` runs on, chosen once (docs/dev/inference.md §3).
//! //!
//! `ort` with `alternative-backend` links no runtime and asks, on first use, //! `ort` with `alternative-backend` links no runtime and asks, on first use,
//! for an `OrtApi` — a struct of function pointers. Two things can fill it: //! for an `OrtApi` — a struct of function pointers. Two things can fill it:
+1 -1
View File
@@ -1,5 +1,5 @@
//! Compiled engines: what a rung builds once per device, and the thread that //! Compiled engines: what a rung builds once per device, and the thread that
//! builds them before anyone asks (docs/inference.md §5, §6). //! builds them before anyone asks (docs/dev/inference.md §5, §6).
//! //!
//! TensorRT keeps its own engine cache keyed by graph hash; QNN writes a //! TensorRT keeps its own engine cache keyed by graph hash; QNN writes a
//! context model. Both are opaque to this crate, which tracks only *that* a //! context model. Both are opaque to this crate, which tracks only *that* a
+58 -13
View File
@@ -1,10 +1,10 @@
//! Which runtime, which provider and which model form — decided once per //! Which runtime, which provider and which model form — decided once per
//! device, and the only crate that knows the answer (docs/inference.md). //! device, and the only crate that knows the answer (docs/dev/inference.md).
//! //!
//! Consumers ask for a session by [`Role`] and get `ort`'s `Session` back; //! Consumers ask for a session by [`Role`] and get `ort`'s `Session` back;
//! what built it — tract on one core, ONNX Runtime's CPU pool, a TensorRT //! what built it — tract on one core, ONNX Runtime's CPU pool, a TensorRT
//! engine, the Hexagon — is this crate's business and shows up in //! engine, a MIGraphX program, the Hexagon — is this crate's business and
//! [`status`] for the settings row and nowhere else. //! shows up in [`status`] for the settings row and nowhere else.
//! //!
//! The shape follows §3 of the spec: `ort` links nothing (`alternative-backend`), //! The shape follows §3 of the spec: `ort` links nothing (`alternative-backend`),
//! and the first call hands it an API table from either a `libonnxruntime` //! and the first call hands it an API table from either a `libonnxruntime`
@@ -35,13 +35,13 @@ pub enum Role {
Embedder, Embedder,
Segmenter, Segmenter,
Scene, Scene,
/// The dense landmark model behind the eye reading (docs/faces.md §7c). /// The dense landmark model behind the eye reading (docs/dev/faces.md §7c).
Landmarks, Landmarks,
/// The eye-state and sunglasses classifiers, a few hundred kilobytes. /// The eye-state and sunglasses classifiers, a few hundred kilobytes.
EyeClassifier, EyeClassifier,
/// XFeat, the panorama keypoint detector (docs/panorama.md). /// XFeat, the panorama keypoint detector (docs/dev/panorama.md).
Keypoints, Keypoints,
/// MI-GAN, the panorama border filler (docs/panorama.md §12). Plain /// MI-GAN, the panorama border filler (docs/dev/panorama.md §12). Plain
/// convolutions, so any rung serves it; fp16 on TensorRT and int8 on /// convolutions, so any rung serves it; fp16 on TensorRT and int8 on
/// the Hexagon are the point of it. /// the Hexagon are the point of it.
Inpainter, Inpainter,
@@ -60,7 +60,9 @@ pub enum Form {
/// A rung of the ladder (§2). Ordered: a user override names the highest rung /// A rung of the ladder (§2). Ordered: a user override names the highest rung
/// the probe may take, and a compiling rung falls back to the one below it /// the probe may take, and a compiling rung falls back to the one below it
/// until its engine exists. /// until its engine exists. The order is within a vendor's ladder — a
/// machine has NVIDIA rungs or an AMD rung, never both — so a ceiling is
/// read as "no higher than this on whichever ladder the device has".
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub enum Rung { pub enum Rung {
/// ONNX Runtime's CPU provider, or tract when no runtime file was found. /// ONNX Runtime's CPU provider, or tract when no runtime file was found.
@@ -69,6 +71,11 @@ pub enum Rung {
Cuda, Cuda,
/// NVIDIA, through a TensorRT engine compiled on this device. Desktop only. /// NVIDIA, through a TensorRT engine compiled on this device. Desktop only.
TensorRt, TensorRt,
/// AMD, through a MIGraphX program compiled on this device. Desktop
/// only. ONNX Runtime's ROCm provider, the CUDA provider's twin, was
/// removed in ONNX Runtime 1.23, so there is no non-compiling AMD rung
/// to fall back to: this one falls back to the CPU.
MiGraphX,
/// Qualcomm's Hexagon NPU through QNN, int8 models only. Android only. /// Qualcomm's Hexagon NPU through QNN, int8 models only. Android only.
Hexagon, Hexagon,
} }
@@ -79,6 +86,7 @@ impl Rung {
Rung::Cpu => "CPU", Rung::Cpu => "CPU",
Rung::Cuda => "CUDA", Rung::Cuda => "CUDA",
Rung::TensorRt => "TensorRT", Rung::TensorRt => "TensorRT",
Rung::MiGraphX => "MIGraphX",
Rung::Hexagon => "Hexagon NPU", Rung::Hexagon => "Hexagon NPU",
} }
} }
@@ -88,13 +96,13 @@ impl Rung {
fn fallback(self) -> Rung { fn fallback(self) -> Rung {
match self { match self {
Rung::TensorRt => Rung::Cuda, Rung::TensorRt => Rung::Cuda,
Rung::Hexagon | Rung::Cuda | Rung::Cpu => Rung::Cpu, Rung::MiGraphX | Rung::Hexagon | Rung::Cuda | Rung::Cpu => Rung::Cpu,
} }
} }
/// Whether a session on this rung needs an engine built first. /// Whether a session on this rung needs an engine built first.
fn compiles(self) -> bool { fn compiles(self) -> bool {
matches!(self, Rung::TensorRt | Rung::Hexagon) matches!(self, Rung::TensorRt | Rung::MiGraphX | Rung::Hexagon)
} }
/// The model form this rung wants for a role. /// The model form this rung wants for a role.
@@ -155,6 +163,8 @@ pub struct Status {
/// Engines compiled and engines wanted, for a compiling rung; `(0, 0)` /// Engines compiled and engines wanted, for a compiling rung; `(0, 0)`
/// otherwise. /// otherwise.
pub engines: (usize, usize), pub engines: (usize, usize),
/// Every rung above the selected one that was tried, and why it lost.
pub failed: Vec<(Rung, String)>,
} }
impl Status { impl Status {
@@ -162,7 +172,7 @@ impl Status {
pub fn line(&self) -> String { pub fn line(&self) -> String {
let form = match self.rung { let form = match self.rung {
Rung::Hexagon => " · int8", Rung::Hexagon => " · int8",
Rung::TensorRt => " · fp16", Rung::TensorRt | Rung::MiGraphX => " · fp16",
_ => "", _ => "",
}; };
format!("{}{} · {}", self.rung.label(), form, self.runtime.label()) format!("{}{} · {}", self.rung.label(), form, self.runtime.label())
@@ -267,8 +277,8 @@ fn acquire(role: Role, form: Form, bytes: &Arc<[u8]>, hash: u64) -> Result<Acqui
return Ok(Acquired { entry }); return Ok(Acquired { entry });
} }
// Built outside the registry lock: a TensorRT engine load is long enough // Built outside the registry lock: a TensorRT or MIGraphX engine load
// that another role's acquire should not wait on it. // is long enough that another role's acquire should not wait on it.
let session = session::build(rung, role, bytes, &cfg)?; let session = session::build(rung, role, bytes, &cfg)?;
log::debug!("inference: {role:?} loaded on {}", rung.label()); log::debug!("inference: {role:?} loaded on {}", rung.label());
let entry = Arc::new(Loaded { let entry = Arc::new(Loaded {
@@ -399,6 +409,16 @@ pub fn status() -> Status {
runtime: api::runtime(), runtime: api::runtime(),
rung, rung,
reason: s.cache.reason.clone(), reason: s.cache.reason.clone(),
// Only what explains the selection: on an AMD machine the NVIDIA
// rungs "not enabled in this build" say nothing about why MIGraphX
// was taken. With the floor selected, everything tried is above it.
failed: s
.cache
.failed
.iter()
.filter(|(r, _)| *r > rung)
.cloned()
.collect(),
probing: s.probing, probing: s.probing,
engines: if rung.compiles() { engines: if rung.compiles() {
(s.cache.compiled.len(), s.wanted) (s.cache.compiled.len(), s.wanted)
@@ -496,7 +516,7 @@ mod tests {
/// The smallest shipped graph, if this checkout has the weights; a test /// The smallest shipped graph, if this checkout has the weights; a test
/// suite that needs a research-licensed download is one that does not /// suite that needs a research-licensed download is one that does not
/// run in CI (docs/faces.md §3), so absence is a skip. /// run in CI (docs/dev/faces.md §3), so absence is a skip.
fn probe_bytes() -> Option<Vec<u8>> { fn probe_bytes() -> Option<Vec<u8>> {
let path = concat!( let path = concat!(
env!("CARGO_MANIFEST_DIR"), env!("CARGO_MANIFEST_DIR"),
@@ -611,8 +631,33 @@ mod tests {
); );
} }
#[test]
fn the_status_reports_only_the_rungs_above_the_selection() {
let _serial = serial();
let failed = vec![
(Rung::TensorRt, "not enabled".to_string()),
(Rung::Cuda, "not enabled".to_string()),
];
let before = state().lock().unwrap().cache.clone();
state().lock().unwrap().cache = Cache {
rung: Some(Rung::MiGraphX),
failed: failed.clone(),
..Cache::default()
};
// An AMD desktop: the NVIDIA rungs below MIGraphX are not the story.
assert!(status().failed.is_empty());
// An NVIDIA desktop on the CUDA provider: TensorRT's failure is.
state().lock().unwrap().cache.rung = Some(Rung::Cuda);
assert_eq!(status().failed, vec![failed[0].clone()]);
// The floor: everything tried explains it.
state().lock().unwrap().cache.rung = Some(Rung::Cpu);
assert_eq!(status().failed.len(), 2);
state().lock().unwrap().cache = before;
}
#[test] #[test]
fn the_status_line_reads_as_the_floor_before_init() { fn the_status_line_reads_as_the_floor_before_init() {
let _serial = serial();
let s = status(); let s = status();
assert_eq!(s.rung, Rung::Cpu); assert_eq!(s.rung, Rung::Cpu);
assert!(s.line().starts_with("CPU"), "{}", s.line()); assert!(s.line().starts_with("CPU"), "{}", s.line());
+46 -8
View File
@@ -1,4 +1,4 @@
//! Walk the ladder, once, by building real sessions (docs/inference.md §4). //! Walk the ladder, once, by building real sessions (docs/dev/inference.md §4).
//! //!
//! A rung is taken when a session builds on it, runs, and is faster than //! A rung is taken when a session builds on it, runs, and is faster than
//! the floor. Both halves matter: a provider can register and then fail at //! the floor. Both halves matter: a provider can register and then fail at
@@ -16,8 +16,11 @@ use crate::{api::Runtime, state, Cache, Config, Form, Role, Rung};
fn ladder(ceiling: Option<Rung>) -> Vec<Rung> { fn ladder(ceiling: Option<Rung>) -> Vec<Rung> {
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
let all = [Rung::Hexagon]; let all = [Rung::Hexagon];
// A desktop has one vendor's GPU; the other vendor's providers are
// "not enabled in this build" or a library that fails to load, and
// either answer arrives in milliseconds.
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
let all = [Rung::TensorRt, Rung::Cuda]; let all = [Rung::TensorRt, Rung::Cuda, Rung::MiGraphX];
all.into_iter() all.into_iter()
.filter(|r| ceiling.is_none_or(|c| *r <= c)) .filter(|r| ceiling.is_none_or(|c| *r <= c))
.collect() .collect()
@@ -206,15 +209,22 @@ fn first_line(s: &str) -> String {
line[start..].chars().take(200).collect() line[start..].chars().take(200).collect()
} }
/// Everything a change of which should re-probe: the runtime and where it /// Everything a change of which should re-probe: the runtime, where it
/// came from, this crate, the platform, the driver or SoC, and the models. /// came from and which providers sit beside it, this crate, the platform,
/// the driver or SoC, and the models.
fn fingerprint(runtime: &Runtime, cfg: &Config) -> String { fn fingerprint(runtime: &Runtime, cfg: &Config) -> String {
let mut parts = vec![ let mut parts = vec![
format!("engine {}", env!("CARGO_PKG_VERSION")), format!("engine {}", env!("CARGO_PKG_VERSION")),
format!("{} {}", std::env::consts::OS, std::env::consts::ARCH), format!("{} {}", std::env::consts::OS, std::env::consts::ARCH),
match runtime { match runtime {
Runtime::Tract => "tract".to_string(), Runtime::Tract => "tract".to_string(),
Runtime::OnnxRuntime { path, version } => format!("ort {version} {}", path.display()), Runtime::OnnxRuntime { path, version } => {
format!(
"ort {version} {} [{}]",
path.display(),
providers_beside(path)
)
}
}, },
device_identity(), device_identity(),
]; ];
@@ -237,13 +247,41 @@ fn fingerprint(runtime: &Runtime, cfg: &Config) -> String {
parts.join("\n") parts.join("\n")
} }
/// The `libonnxruntime_providers_*.so` files in the runtime's directory.
/// A distribution's CPU-only and ROCm builds are the same version at the
/// same path; the provider libraries beside them are what differs.
fn providers_beside(runtime: &Path) -> String {
let Some(dir) = runtime.parent() else {
return String::new();
};
let mut names: Vec<String> = std::fs::read_dir(dir)
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.filter_map(|e| e.file_name().into_string().ok())
.filter(|n| {
n.starts_with("libonnxruntime_providers_") || n.starts_with("onnxruntime_providers_")
})
.collect();
names.sort();
names.join(" ")
}
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn device_identity() -> String { fn device_identity() -> String {
// The NVIDIA driver's version line; absent means no NVIDIA driver. // The NVIDIA driver's version line, or the ROCm release the AMD stack
std::fs::read_to_string("/proc/driver/nvidia/version") // came from (`rocm-core` writes it; the kernel driver has no version
// of its own). Absent means neither.
if let Some(line) = std::fs::read_to_string("/proc/driver/nvidia/version")
.ok() .ok()
.and_then(|s| s.lines().next().map(str::to_string)) .and_then(|s| s.lines().next().map(str::to_string))
.unwrap_or_else(|| "no nvidia driver".into()) {
return line;
}
if let Ok(rocm) = std::fs::read_to_string("/opt/rocm/.info/version") {
return format!("rocm {}", rocm.trim());
}
"no nvidia driver, no rocm".into()
} }
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
+59 -2
View File
@@ -1,4 +1,4 @@
//! One session builder per rung (docs/inference.md §2, §7, §9). //! One session builder per rung (docs/dev/inference.md §2, §7, §9).
use ort::session::Session; use ort::session::Session;
@@ -83,10 +83,65 @@ fn providers(
ep::CUDA::default().build(), ep::CUDA::default().build(),
])?) ])?)
} }
Rung::MiGraphX => {
// fp16 on the same terms as TensorRT (§7). MIGraphX compiles a
// program per graph — 20–60 s here — and keeps it in the cache
// directory, keyed on the graph, the GPU and its own version
// but not the precision: hence one directory per precision.
// The CPU takes any node it declines.
let fp16 = role != Role::Embedder;
let cache = cfg
.cache_dir
.join("migraphx")
.join(if fp16 { "fp16" } else { "f32" });
let _ = std::fs::create_dir_all(&cache);
let mut b = b;
migraphx(&mut b, fp16, &cache)?;
Ok(b)
}
Rung::Hexagon => unreachable!("the Hexagon rung is not on a desktop ladder"), Rung::Hexagon => unreachable!("the Hexagon rung is not on a desktop ladder"),
} }
} }
/// Register MIGraphX through ONNX Runtime's generic key/value entry point.
///
/// `ort`'s own builder (`ep::MIGraphX`) fills the legacy
/// `OrtMIGraphXProviderOptions`, and 1.29 reads that struct for its
/// precision flags and nothing else — the compiled-program cache directory
/// is only a key in the generic map (`migraphx_model_cache_dir`), and
/// without it every session is a full compile. Registration through the
/// generic entry point needs no `ort` feature: it is one call on the API
/// table, which is why the crate's `ort` dependency names no AMD feature.
#[cfg(not(target_os = "android"))]
fn migraphx(
b: &mut ort::session::builder::SessionBuilder,
fp16: bool,
cache: &std::path::Path,
) -> ort::Result<()> {
use ort::AsPointer;
use std::ffi::CString;
let keys = [c"migraphx_fp16_enable", c"migraphx_model_cache_dir"];
let values = [
CString::new(if fp16 { "1" } else { "0" }).unwrap(),
CString::new(cache.to_string_lossy().as_bytes())
.map_err(|e| ort::Error::new(e.to_string()))?,
];
let key_ptrs: Vec<_> = keys.iter().map(|k| k.as_ptr()).collect();
let value_ptrs: Vec<_> = values.iter().map(|v| v.as_ptr()).collect();
// SAFETY: the documented C call over arrays that outlive it; the
// runtime copies the strings into its own options map before returning.
unsafe {
let status = (ort::api().SessionOptionsAppendExecutionProvider)(
b.ptr_mut(),
c"MIGraphX".as_ptr(),
key_ptrs.as_ptr(),
value_ptrs.as_ptr(),
keys.len(),
);
ort::Error::result_from_status(status)
}
}
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
fn providers( fn providers(
b: ort::session::builder::SessionBuilder, b: ort::session::builder::SessionBuilder,
@@ -120,6 +175,8 @@ fn providers(
.build() .build()
.error_on_failure()])?) .error_on_failure()])?)
} }
Rung::Cuda | Rung::TensorRt => unreachable!("no NVIDIA rung on Android"), Rung::Cuda | Rung::TensorRt | Rung::MiGraphX => {
unreachable!("no desktop GPU rung on Android")
}
} }
} }
+1 -1
View File
@@ -13,7 +13,7 @@ log.workspace = true
# Inference for the learned keypoint detector, on the same footing as # Inference for the learned keypoint detector, on the same footing as
# `dr-segment`: `ort` is the API, `dr-inference-engine` decides what runs # `dr-segment`: `ort` is the API, `dr-inference-engine` decides what runs
# it (docs/inference.md), and both are optional so that the geometry — # it (docs/dev/inference.md), and both are optional so that the geometry —
# matching, the rotation solve, the projections — is a dependency-free crate # matching, the rotation solve, the projections — is a dependency-free crate
# that tests without a model. # that tests without a model.
ort = { workspace = true, optional = true } ort = { workspace = true, optional = true }
+142 -18
View File
@@ -89,12 +89,19 @@ pub struct Params {
pub coarse: usize, pub coarse: usize,
/// The fine passes' band width. /// The fine passes' band width.
pub band: usize, pub band: usize,
/// How deep into the picture the mirrored context reaches. A plain /// How deep into the picture the mirrored context reaches, or **zero
/// reflection of a deep hole pulls in whatever is that far from the /// for no mirrored context at all**: the void is then shown to the
/// edge — a ridge, a peak — and the model, told that is what lies /// model as it is — reaching the picture's edge with nothing beyond,
/// beyond, paints it upside down. Folding the reflection within this /// and, beyond the band being filled, still unknown. That is what the
/// band keeps the ring looking like the edge it continues (sky beside /// shipped model was trained on (a fine-tune of MI-GAN on voids cut
/// sky, grass beside grass) and nothing further away. /// from photographs the way a cylindrical merge cuts them, see
/// `docs/dev/panorama.md` §14); a ring would give it a fold to continue.
///
/// Non-zero is the stock model's crutch: a plain reflection of a deep
/// hole pulls in whatever is that far from the edge — a ridge, a peak —
/// and the model, told that is what lies beyond, paints it upside down.
/// Folding the reflection within this band keeps the ring looking like
/// the edge it continues and nothing further away.
pub mirror_depth: usize, pub mirror_depth: usize,
/// How far inside the real edge the fill also regenerates, the two /// How far inside the real edge the fill also regenerates, the two
/// blended by distance. A hard cut between real pixels and invented /// blended by distance. A hard cut between real pixels and invented
@@ -108,9 +115,9 @@ pub struct Params {
impl Default for Params { impl Default for Params {
fn default() -> Self { fn default() -> Self {
Params { Params {
coarse: 4, coarse: 1,
band: 96, band: 192,
mirror_depth: 48, mirror_depth: 0,
feather: 24, feather: 24,
stride: 384, stride: 384,
} }
@@ -141,7 +148,10 @@ pub fn fill_border(
} = params; } = params;
let q = q.max(1); let q = q.max(1);
let band = band.max(8); let band = band.max(8);
let mirror_depth = mirror_depth.max(1); // No ring: the void beyond the band stays unknown, as in the model's
// training; with a ring the far side is the coarse fill, presented as
// known, which the stock model needed to see something there.
let open = mirror_depth == 0;
if width == 0 || height == 0 || rgb.len() != width * height * 3 || known.len() != width * height if width == 0 || height == 0 || rgb.len() != width * height * 3 || known.len() != width * height
{ {
return Err(PanoError::Input("fill: buffer sizes disagree".into())); return Err(PanoError::Input("fill: buffer sizes disagree".into()));
@@ -227,7 +237,11 @@ pub fn fill_border(
let mut any = false; let mut any = false;
for i in 0..width * height { for i in 0..width * height {
let in_band = !known[i] && dist[i] > lo && dist[i] <= hi; let in_band = !known[i] && dist[i] > lo && dist[i] <= hi;
band_known[i] = !in_band; band_known[i] = if open {
known[i] || dist[i] <= lo
} else {
!in_band
};
any |= in_band; any |= in_band;
} }
if !any { if !any {
@@ -289,7 +303,7 @@ fn fill_once(
} }
// The padded canvas with mirrored context, and the hole within it. // The padded canvas with mirrored context, and the hole within it.
let ctx = MirroredContext::build(rgb, width, height, known, mirror_depth); let ctx = MirroredContext::build(rgb, width, height, known, mirror_depth, t);
let (pw, ph) = (ctx.width, ctx.height); let (pw, ph) = (ctx.width, ctx.height);
// Tiles that touch the hole, on a grid that reaches both far edges. // Tiles that touch the hole, on a grid that reaches both far edges.
@@ -369,7 +383,7 @@ fn fill_once(
if known[i] { if known[i] {
continue; continue;
} }
let p = (yy + RING) * pw + (xx + RING); let p = (yy + ctx.ring) * pw + (xx + ctx.ring);
if wsum[p] > 0.0 { if wsum[p] > 0.0 {
for ch in 0..3 { for ch in 0..3 {
rgb[i * 3 + ch] = (acc[p * 3 + ch] / wsum[p]).clamp(0.0, 1.0); rgb[i * 3 + ch] = (acc[p * 3 + ch] / wsum[p]).clamp(0.0, 1.0);
@@ -478,12 +492,45 @@ fn fold(d: usize, depth: usize) -> usize {
struct MirroredContext { struct MirroredContext {
width: usize, width: usize,
height: usize, height: usize,
/// The padding on every side: `RING` with mirrored context, 0 without.
ring: usize,
rgb: Vec<f32>, rgb: Vec<f32>,
hole: Vec<bool>, hole: Vec<bool>,
} }
impl MirroredContext { impl MirroredContext {
fn build(rgb: &[f32], width: usize, height: usize, known: &[bool], depth: usize) -> Self { fn build(
rgb: &[f32],
width: usize,
height: usize,
known: &[bool],
depth: usize,
tile: usize,
) -> Self {
if depth == 0 {
// Open: the picture as it is, the hole as it is. What the hole
// holds does not matter — the model masks it out. A picture
// smaller than a tile (the merge page's preview) sits at the
// origin of a tile-sized canvas whose rest is hole: still the
// void as it is, and the only way a tile fits at all.
let (pw, ph) = (width.max(tile), height.max(tile));
let mut canvas = vec![0.0f32; pw * ph * 3];
let mut hole = vec![true; pw * ph];
for y in 0..height {
canvas[y * pw * 3..(y * pw + width) * 3]
.copy_from_slice(&rgb[y * width * 3..(y + 1) * width * 3]);
for x in 0..width {
hole[y * pw + x] = !known[y * width + x];
}
}
return MirroredContext {
width: pw,
height: ph,
ring: 0,
rgb: canvas,
hole,
};
}
let fold = |d: usize| fold(d, depth); let fold = |d: usize| fold(d, depth);
let (pw, ph) = (width + 2 * RING, height + 2 * RING); let (pw, ph) = (width + 2 * RING, height + 2 * RING);
let mut canvas = vec![0.0f32; pw * ph * 3]; let mut canvas = vec![0.0f32; pw * ph * 3];
@@ -534,6 +581,7 @@ impl MirroredContext {
MirroredContext { MirroredContext {
width: pw, width: pw,
height: ph, height: ph,
ring: RING,
rgb: canvas, rgb: canvas,
hole, hole,
} }
@@ -634,7 +682,10 @@ mod tests {
200, 200,
&known, &known,
&mut model, &mut model,
test_params(0), Params {
mirror_depth: 48,
..test_params(0)
},
&mut |_, _| {}, &mut |_, _| {},
) )
.unwrap(); .unwrap();
@@ -648,6 +699,74 @@ mod tests {
} }
} }
#[test]
fn an_open_void_reaches_the_tile_edge_and_stays_unknown_beyond_the_band() {
// A 150-tall hole above and below; bands of 96. With no ring the
// first band's tiles sit at the picture's edge, so a tile's top
// row is unknown, and the rows deeper than the band are unknown
// too — not "known" coarse fill — exactly as the model was trained.
let (mut rgb, known) = picture(200, 500, 150);
let mut model = Flat {
tile: 64,
seen: Vec::new(),
};
fill_border(
&mut rgb,
200,
500,
&known,
&mut model,
Params {
band: 96,
..test_params(0)
},
&mut |_, _| {},
)
.unwrap();
// The first pass's tile at the picture's top edge is unknown
// through and through: the hole is 150 deep, the tile 64, and
// nothing beyond the band was presented as known. With a ring, or
// with the far side shown as coarse fill, no tile is ever all hole.
assert!(model.seen.iter().any(|(_, k)| k.iter().all(|&v| !v)));
for i in 0..200 * 500 {
if !known[i] {
assert!((rgb[i * 3] - 0.5).abs() < 1e-4, "pixel {i}");
}
}
}
#[test]
fn a_picture_smaller_than_the_tile_is_still_filled_when_the_void_is_open() {
// The merge page's preview is 1600 wide and a few hundred tall —
// shorter than a 512 tile. With no ring the canvas is padded to a
// tile, the padding hole, and the border is still filled.
let (mut rgb, known) = picture(300, 40, 8);
let mut model = Flat {
tile: 64,
seen: Vec::new(),
};
let tiles = fill_border(
&mut rgb,
300,
40,
&known,
&mut model,
test_params(0),
&mut |_, _| {},
)
.unwrap();
assert!(tiles > 0, "no tile fitted a picture shorter than the tile");
for i in 0..300 * 40 {
if !known[i] {
assert!((rgb[i * 3] - 0.5).abs() < 1e-4, "pixel {i}");
}
}
// And the model saw the padding as hole, never as black content.
for (_, k) in &model.seen {
assert_eq!(k.len(), 64 * 64);
}
}
#[test] #[test]
fn the_fine_passes_run_in_bands_after_the_coarse_one() { fn the_fine_passes_run_in_bands_after_the_coarse_one() {
// A 150-tall hole above and below a picture: the coarse pass sees // A 150-tall hole above and below a picture: the coarse pass sees
@@ -663,7 +782,12 @@ mod tests {
500, 500,
&known, &known,
&mut model, &mut model,
test_params(0), Params {
coarse: 4,
band: 96,
mirror_depth: 48,
..test_params(0)
},
&mut |_, _| {}, &mut |_, _| {},
) )
.unwrap(); .unwrap();
@@ -752,7 +876,7 @@ mod tests {
#[test] #[test]
fn the_context_mirrors_the_top_rows_upward() { fn the_context_mirrors_the_top_rows_upward() {
let (rgb, known) = picture(40, 30, 5); let (rgb, known) = picture(40, 30, 5);
let ctx = MirroredContext::build(&rgb, 40, 30, &known, 48); let ctx = MirroredContext::build(&rgb, 40, 30, &known, 48, 64);
let x = RING + 10; let x = RING + 10;
let first = RING + 5; let first = RING + 5;
for k in 1..=4 { for k in 1..=4 {
@@ -774,7 +898,7 @@ mod tests {
for x in 0..40 { for x in 0..40 {
rgb[(ridge * 40 + x) * 3..(ridge * 40 + x) * 3 + 3].copy_from_slice(&[0.9, 0.1, 0.1]); rgb[(ridge * 40 + x) * 3..(ridge * 40 + x) * 3 + 3].copy_from_slice(&[0.9, 0.1, 0.1]);
} }
let ctx = MirroredContext::build(&rgb, 40, 400, &known, 48); let ctx = MirroredContext::build(&rgb, 40, 400, &known, 48, 64);
let x = RING + 10; let x = RING + 10;
for y in 0..RING + 5 { for y in 0..RING + 5 {
let p = (y * ctx.width + x) * 3; let p = (y * ctx.width + x) * 3;
+1 -1
View File
@@ -9,7 +9,7 @@
//! on every rung, and what they cost is the whole story of whether a fill //! on every rung, and what they cost is the whole story of whether a fill
//! is interactive: 7.4 s a tile under tract, 0.4 s under ONNX Runtime's //! is interactive: 7.4 s a tile under tract, 0.4 s under ONNX Runtime's
//! CPU pool, 23 ms in fp16 and 13 ms in int8 on a laptop's TensorRT //! CPU pool, 23 ms in fp16 and 13 ms in int8 on a laptop's TensorRT
//! (2026-09-19, docs/panorama.md §12). //! (2026-09-19, docs/dev/panorama.md §12).
//! //!
//! The model's contract, from the reference `export_inference_model.py`: //! The model's contract, from the reference `export_inference_model.py`:
//! input `1×4×512×512` float — channel 0 is `mask − 0.5` with 1 where the //! input `1×4×512×512` float — channel 0 is `mask − 0.5` with 1 where the
+2 -2
View File
@@ -4,7 +4,7 @@
//! Apache-2.0 weights (`models/LICENCE.md`), exported at a fixed shape by //! Apache-2.0 weights (`models/LICENCE.md`), exported at a fixed shape by
//! `tools/export-xfeat.sh` and loaded through the same `dr-inference-engine` //! `tools/export-xfeat.sh` and loaded through the same `dr-inference-engine`
//! `dr-segment` and `dr-face` use, so this adds no runtime and no C to the //! `dr-segment` and `dr-face` use, so this adds no runtime and no C to the
//! tree; what runs it is the device's business (docs/inference.md). ~300 ms //! tree; what runs it is the device's business (docs/dev/inference.md). ~300 ms
//! per frame on tract on the reference desktop, ~400 ms on the tablet //! per frame on tract on the reference desktop, ~400 ms on the tablet
//! (S15.2, S15.4). //! (S15.2, S15.4).
@@ -37,7 +37,7 @@ pub struct XFeat {
} }
/// The bytes of both exports compiled into the binary, for whoever compiles /// The bytes of both exports compiled into the binary, for whoever compiles
/// engines ahead of the first request (docs/inference.md §6). /// engines ahead of the first request (docs/dev/inference.md §6).
#[cfg(feature = "embedded-model")] #[cfg(feature = "embedded-model")]
pub fn embedded_model_bytes() -> [&'static [u8]; 2] { pub fn embedded_model_bytes() -> [&'static [u8]; 2] {
[EMBEDDED_LANDSCAPE, EMBEDDED_PORTRAIT] [EMBEDDED_LANDSCAPE, EMBEDDED_PORTRAIT]
+37
View File
@@ -0,0 +1,37 @@
drpl 1
# Black and white film: each preset is one measured stock, developed and — for a
# negative — printed on the paper its profile names, with every other
# control left where it was. The stock is the look; a grade on top is the
# photographer's to add.
#
# The measurements are spektrafilm's (Andrea Volpato, CC BY-SA 4.0), as
# converted in core/dr-film/profiles. A preset names a stock by id, so
# these lines are that attribution's reach: nothing of the data is here.
[preset Ilford Delta 100]
film = ilford_delta_100
film_print = kodak_2302
[preset Ilford Delta 400]
film = ilford_delta_400
film_print = kodak_2302
[preset Ilford FP4 Plus]
film = ilford_fp4_plus
film_print = kodak_2302
[preset Ilford HP5 Plus]
film = ilford_hp5_plus
film_print = kodak_2302
[preset Ilford Pan F Plus]
film = ilford_pan_f_plus
film_print = kodak_2302
[preset Kodak Double-X 5222]
film = kodak_doublex
film_print = kodak_2302
[preset Kodak Tri-X Reversal 7266]
film = kodak_trix
+30
View File
@@ -0,0 +1,30 @@
drpl 1
# Cinema film: each preset is one measured stock, developed and — for a
# negative — printed on the paper its profile names, with every other
# control left where it was. The stock is the look; a grade on top is the
# photographer's to add.
#
# The measurements are spektrafilm's (Andrea Volpato, CC BY-SA 4.0), as
# converted in core/dr-film/profiles. A preset names a stock by id, so
# these lines are that attribution's reach: nothing of the data is here.
[preset Kodak Verita 200D]
film = kodak_verita_200d
film_print = kodak_2383
[preset Kodak Vision3 200T]
film = kodak_vision3_200t
film_print = kodak_2383
[preset Kodak Vision3 250D]
film = kodak_vision3_250d
film_print = kodak_2383
[preset Kodak Vision3 500T]
film = kodak_vision3_500t
film_print = kodak_2383
[preset Kodak Vision3 50D]
film = kodak_vision3_50d
film_print = kodak_2383
+66
View File
@@ -0,0 +1,66 @@
drpl 1
# Colour film: each preset is one measured stock, developed and — for a
# negative — printed on the paper its profile names, with every other
# control left where it was. The stock is the look; a grade on top is the
# photographer's to add.
#
# The measurements are spektrafilm's (Andrea Volpato, CC BY-SA 4.0), as
# converted in core/dr-film/profiles. A preset names a stock by id, so
# these lines are that attribution's reach: nothing of the data is here.
[preset Fujifilm C200]
film = fujifilm_c200
film_print = fujifilm_crystal_archive_typeii
[preset Fujifilm Pro 400H]
film = fujifilm_pro_400h
film_print = fujifilm_crystal_archive_typeii
[preset Fujifilm Provia 100F]
film = fujifilm_provia_100f
[preset Fujifilm Velvia 100]
film = fujifilm_velvia_100
[preset Fujifilm X-Tra 400]
film = fujifilm_xtra_400
film_print = fujifilm_crystal_archive_typeii
[preset Kodak Ektachrome 100]
film = kodak_ektachrome_100
[preset Kodak Ektar 100]
film = kodak_ektar_100
film_print = kodak_portra_endura
[preset Kodak Gold 200]
film = kodak_gold_200
film_print = kodak_portra_endura
[preset Kodak Kodachrome 64]
film = kodak_kodachrome_64
[preset Kodak Portra 160]
film = kodak_portra_160
film_print = kodak_portra_endura
[preset Kodak Portra 400]
film = kodak_portra_400
film_print = kodak_portra_endura
[preset Kodak Portra 800]
film = kodak_portra_800
film_print = kodak_portra_endura
[preset Kodak Portra 800 pushed one stop]
film = kodak_portra_800_push1
film_print = kodak_portra_endura
[preset Kodak Portra 800 pushed two stops]
film = kodak_portra_800_push2
film_print = kodak_portra_endura
[preset Kodak Ultramax 400]
film = kodak_ultramax_400
film_print = kodak_portra_endura
+41
View File
@@ -0,0 +1,41 @@
drpl 1
# Essentials: small, general corrections written against this pipeline.
# These were the first-run starter set; they ship here now so that a
# new release can improve them without rewriting anybody's own presets.
# Deliberately mild — see bundled.rs.
[preset Crisp detail]
capture_sharpen.amount = 35
clarity.amount = 10
texture.amount = 20
[preset Lift the shadows]
blacks_whites.blacks = 12
contrast.contrast = -5
highlights_shadows.shadows = 40
[preset Muted]
contrast.contrast = -10
highlights_shadows.shadows = 12
saturation.saturation = -30
vibrance.vibrance = 10
[preset Punch]
blacks_whites.blacks = -8
clarity.amount = 12
contrast.contrast = 18
vibrance.vibrance = 18
[preset Recover the sky]
blacks_whites.whites = -10
highlights_shadows.highlights = -55
highlights_shadows.shadows = 35
[preset Soft portrait]
clarity.amount = -10
contrast.contrast = -8
highlights_shadows.highlights = -20
highlights_shadows.shadows = 15
saturation.saturation = -5
vibrance.vibrance = 10
+444
View File
@@ -0,0 +1,444 @@
//! TRACES: FR-DEV-6
//! The presets that ship with the application.
//!
//! # Shipped, not seeded
//!
//! The first six used to be *copied* into the photographer's own library on
//! the first run and were theirs from then on. That was the right answer for
//! six, and it cannot grow: a copy is frozen at the version that made it, so a
//! better "Portra" in the next release would reach nobody who already had the
//! old one, and re-seeding would overwrite a preset someone had tuned. So the
//! shipped set is now read from the binary every time, never written to the
//! user's file, and changes when the application does.
//!
//! # Your copy wins, as long as it keeps the name
//!
//! Saving over a shipped preset's name makes the photographer's version the
//! one that name means, here and in every apply. It is still *that* preset —
//! listed where the shipped one was, marked as changed — and deleting it
//! reveals the shipped one again, which is what "revert" means to the person
//! pressing it. Renaming it cuts the link: it becomes one of their own, and
//! the shipped preset reappears beside it. The lookup is by name because the
//! name is what the photographer sees and chooses by; an id they never see
//! would link two presets they believe are different.
//!
//! # Looks, not whole edits
//!
//! Every shipped preset reaches only the operations it names
//! ([`Reach::Named`]): a look applied to a corrected photograph must keep the
//! correction. A photographer's own saved edits keep [`Reach::Whole`], which
//! is what saving an edit has always meant.
//!
//! # Why the data is text files
//!
//! The same format the user's library is written in, so a shipped preset can
//! be read, diffed and copied into one's own library by hand, and each file
//! can say in a comment where its looks came from — the attribution a licence
//! may require travels with the data it covers.
//!
//! # Why this lives in the core
//!
//! It names operations — "Punch" is a statement about contrast and clarity —
//! and nothing in `ui/` may (`ui_names_no_operation.rs`, ARCH §4.3a). The
//! frontend asks for the listing and applies what it is handed.
use crate::preset::{Preset, PresetLibrary, Reach};
/// One group of shipped presets, as the sheet lists it.
pub struct Section {
/// A stable identifier, for a frontend that remembers which sections a
/// photographer folded away. Never shown.
pub id: &'static str,
/// What the section is called on screen.
pub title: &'static str,
/// The presets in it, every one reaching only what it names.
pub presets: PresetLibrary,
}
/// The files, in the order the sheet lists them.
const SECTIONS: &[(&str, &str, &str)] = &[
(
"essentials",
"Essentials",
include_str!("../presets/essentials.drpl"),
),
(
"colour_film",
"Colour film",
include_str!("../presets/colour_film.drpl"),
),
(
"cinema_film",
"Cinema film",
include_str!("../presets/cinema_film.drpl"),
),
(
"bw_film",
"Black and white film",
include_str!("../presets/bw_film.drpl"),
),
];
/// Every shipped section, parsed.
///
/// Parsed on each call rather than held: it is a few kilobytes read when the
/// preset sheet is drawn, and a static would be one more thing to keep
/// consistent with the files in a test. A file that fails to parse costs its
/// section, with a warning, rather than the sheet — and the tests below make
/// sure none does.
pub fn sections() -> Vec<Section> {
SECTIONS
.iter()
.filter_map(|(id, title, text)| match PresetLibrary::parse(text) {
Ok(library) => Some(Section {
id,
title,
presets: as_looks(library),
}),
Err(e) => {
log::warn!("shipped preset section {id} is unreadable ({e}); skipping");
None
}
})
.collect()
}
/// Mark every preset in `library` as a look.
///
/// Here rather than as a `reach = named` line in every block of every file:
/// the rule is about where a preset came from, and a file that forgot the
/// line would ship a preset that wiped a photographer's corrections.
fn as_looks(library: PresetLibrary) -> PresetLibrary {
let mut looks = PresetLibrary::default();
for (name, preset) in library.iter() {
let _ = looks.insert(name, preset.clone().with_reach(Reach::Named));
}
looks
}
/// Where a listed preset comes from.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Origin {
/// The photographer's own, with no shipped preset of that name.
Yours,
/// Shipped, and not overridden.
Shipped,
/// Shipped, and overridden by the photographer's copy under the same
/// name. The copy is what applies; deleting it reverts to the shipped one.
Changed,
}
/// One row of the preset sheet.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Listed {
pub name: String,
pub origin: Origin,
}
/// One group of rows: the photographer's own, then each shipped section.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListedSection {
/// `None` for the photographer's own presets.
pub id: Option<&'static str>,
pub title: &'static str,
pub rows: Vec<Listed>,
}
/// Everything the sheet lists, in order, with the photographer's copies
/// standing in for the shipped presets they override.
///
/// Their own presets come first, because a photographer reaches for their own
/// work more than for anybody's defaults, and a copy of a shipped preset is
/// listed in the shipped section rather than among their own — it is still
/// that preset, changed, and belongs where they would look for it.
pub fn listing(yours: &PresetLibrary) -> Vec<ListedSection> {
let shipped = sections();
let is_shipped = |name: &str| shipped.iter().any(|section| section.presets.contains(name));
let mut out = vec![ListedSection {
id: None,
title: "Yours",
rows: yours
.names()
.filter(|name| !is_shipped(name))
.map(|name| Listed {
name: name.to_string(),
origin: Origin::Yours,
})
.collect(),
}];
out.extend(shipped.iter().map(|section| {
ListedSection {
id: Some(section.id),
title: section.title,
rows: section
.presets
.names()
.map(|name| Listed {
name: name.to_string(),
origin: if yours.contains(name) {
Origin::Changed
} else {
Origin::Shipped
},
})
.collect(),
}
}));
out
}
/// The preset a name means: the photographer's if they have one, the shipped
/// one otherwise.
pub fn lookup(yours: &PresetLibrary, name: &str) -> Option<Preset> {
yours.get(name).cloned().or_else(|| {
sections()
.into_iter()
.find_map(|section| section.presets.get(name).cloned())
})
}
/// Whether `name` is a shipped preset's.
pub fn is_shipped(name: &str) -> bool {
sections()
.iter()
.any(|section| section.presets.contains(name))
}
/// Remove the copies a first run used to seed, where they are still exactly
/// as seeded. Returns how many went.
///
/// Those copies would otherwise all list as changed — overriding a shipped
/// preset with an identical one — and would freeze the six at their old
/// values forever. One that differs in any way was tuned by somebody and is
/// kept: it is theirs, and it now overrides the shipped one, which is the
/// rule above doing what it is for.
///
/// Compared on the parameters and the film and not on [`Reach`]: the seeded
/// copies were whole edits, the shipped ones are looks, and that difference
/// is the thing this migration exists to deliver.
pub fn forget_unchanged_copies(yours: &mut PresetLibrary) -> usize {
let shipped = sections();
let stale: Vec<String> = yours
.iter()
.filter(|(name, preset)| {
shipped.iter().any(|section| {
section
.presets
.get(name)
.is_some_and(|s| s.params() == preset.params() && s.film() == preset.film())
})
})
.map(|(name, _)| name.to_string())
.collect();
for name in &stale {
yours.remove(name);
}
stale.len()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{EditGraph, Scope};
fn all() -> Vec<(&'static str, String, Preset)> {
sections()
.into_iter()
.flat_map(|s| {
let id = s.id;
s.presets
.iter()
.map(|(n, p)| (id, n.to_string(), p.clone()))
.collect::<Vec<_>>()
})
.collect()
}
#[test]
fn every_file_parses_and_every_line_is_understood() {
// A misspelt key would otherwise be kept as a line this build does
// not understand — preserved faithfully, and doing nothing.
assert_eq!(
sections().len(),
SECTIONS.len(),
"a section failed to parse"
);
for (id, _, text) in SECTIONS {
let library = PresetLibrary::parse(text).unwrap();
assert_eq!(library.unread_lines(), 0, "{id} has lines nobody reads");
assert!(!library.is_empty(), "{id} is empty");
}
}
#[test]
fn every_shipped_name_is_unique_across_sections() {
// A name is what the lookup and the override key on. Two shipped
// presets sharing one would make one of them unreachable.
let mut names: Vec<String> = all().into_iter().map(|(_, n, _)| n).collect();
let before = names.len();
names.sort();
names.dedup();
assert_eq!(names.len(), before);
}
#[test]
fn every_shipped_preset_is_a_look() {
for (id, name, preset) in all() {
assert_eq!(preset.reach(), Reach::Named, "{id}/{name}");
}
}
#[test]
fn every_shipped_preset_names_parameters_this_build_actually_has() {
// A renamed parameter must break the build rather than ship a preset
// that quietly does nothing.
let graph = EditGraph::default_chain();
let capabilities = graph.capabilities();
for (id, name, preset) in all() {
for (op, param) in preset.params().keys() {
let capability = capabilities
.iter()
.find(|c| c.id.0 == op)
.unwrap_or_else(|| panic!("{id}/{name}: no operation {op:?}"));
assert!(
capability.params.iter().any(|p| p.id.0 == param),
"{id}/{name}: operation {op:?} has no parameter {param:?}"
);
}
}
}
#[test]
fn every_shipped_preset_changes_something() {
// A preset that applies to nothing teaches the photographer that the
// list does not work.
for (id, name, preset) in all() {
let mut graph = EditGraph::default_chain();
let rebake = preset.apply(&mut graph, Scope::adjustments());
assert!(
rebake.wanted().is_some() || Preset::capture(&graph) != Preset::default(),
"{id}/{name} left the graph at its defaults"
);
}
}
#[test]
fn no_shipped_preset_carries_a_crop() {
for (id, name, preset) in all() {
assert!(!preset.touches_framing(), "{id}/{name} carries framing");
}
}
#[test]
fn the_values_stay_inside_what_the_controls_accept() {
// Clamping happens on apply, so an out-of-range literal would be
// silently trimmed and the preset would not be the one written.
for (id, name, preset) in all() {
let mut graph = EditGraph::default_chain();
preset
.clone()
.with_film(None)
.apply(&mut graph, Scope::everything())
.expect_no_film();
for ((op, param), value) in preset.params() {
let (op_id, param_id) = crate::preset::resolve(&graph, op, param).unwrap();
assert_eq!(
graph.param(op_id, param_id),
Some(*value),
"{id}/{name}: {op}.{param} = {value} was clamped"
);
}
}
}
// --- the listing and the override ------------------------------------
fn yours_with(names: &[(&str, Preset)]) -> PresetLibrary {
let mut lib = PresetLibrary::default();
for (n, p) in names {
lib.insert(n, p.clone()).unwrap();
}
lib
}
fn first_shipped() -> (String, Preset) {
let (_, name, preset) = all().into_iter().next().unwrap();
(name, preset)
}
#[test]
fn your_copy_under_a_shipped_name_is_what_that_name_applies() {
let (name, shipped) = first_shipped();
let mine = Preset::default();
let yours = yours_with(&[(&name, mine.clone())]);
assert_eq!(lookup(&yours, &name), Some(mine));
assert_eq!(lookup(&PresetLibrary::default(), &name), Some(shipped));
}
#[test]
fn your_copy_is_listed_in_the_shipped_section_as_changed() {
let (name, _) = first_shipped();
let yours = yours_with(&[(&name, Preset::default()), ("Mine", Preset::default())]);
let listing = listing(&yours);
assert_eq!(listing[0].id, None);
assert_eq!(
listing[0].rows,
vec![Listed {
name: "Mine".into(),
origin: Origin::Yours
}],
"an override must not be listed twice"
);
let row = listing[1..]
.iter()
.flat_map(|s| &s.rows)
.find(|r| r.name == name)
.unwrap();
assert_eq!(row.origin, Origin::Changed);
}
#[test]
fn deleting_your_copy_reverts_to_the_shipped_one() {
let (name, shipped) = first_shipped();
let mut yours = yours_with(&[(&name, Preset::default())]);
yours.remove(&name);
assert_eq!(lookup(&yours, &name), Some(shipped));
}
#[test]
fn renaming_your_copy_cuts_the_link() {
let (name, shipped) = first_shipped();
let mut yours = yours_with(&[(&name, Preset::default())]);
yours.rename(&name, "My version").unwrap();
assert_eq!(lookup(&yours, &name), Some(shipped));
assert_eq!(lookup(&yours, "My version"), Some(Preset::default()));
assert_eq!(listing(&yours)[0].rows[0].name, "My version");
}
#[test]
fn the_old_seeded_copies_are_forgotten_and_tuned_ones_kept() {
// The six as a first run wrote them: the same parameters, as whole
// edits, because that is what a seeded copy was.
let essentials = sections().into_iter().next().unwrap().presets;
let mut yours = PresetLibrary::default();
for (name, preset) in essentials.iter() {
yours
.insert(name, preset.clone().with_reach(Reach::Whole))
.unwrap();
}
let tuned = essentials.names().next().unwrap().to_string();
yours.insert(&tuned, Preset::default()).unwrap();
yours.insert("Mine", Preset::default()).unwrap();
let forgotten = forget_unchanged_copies(&mut yours);
assert_eq!(forgotten, essentials.len() - 1);
assert!(yours.contains(&tuned), "a tuned copy was thrown away");
assert!(yours.contains("Mine"));
assert_eq!(yours.len(), 2);
}
}
+104 -2
View File
@@ -315,7 +315,7 @@ pub struct DetailPass {
/// 52 render pixels at 4K — holds no spatial frequency a quarter-scale /// 52 render pixels at 4K — holds no spatial frequency a quarter-scale
/// grid cannot represent. Computing it at the render size therefore buys /// grid cannot represent. Computing it at the render size therefore buys
/// nothing and costs everything: 105 taps over 8.3 M pixels, twice, which /// nothing and costs everything: 105 taps over 8.3 M pixels, twice, which
/// measured at 34 ms and is where `docs/technical-debt.md` TD-4 came from. /// measured at 34 ms and is where `docs/dev/technical-debt.md` TD-4 came from.
/// At a quarter it is a sixteenth of the pixels at a quarter of the /// At a quarter it is a sixteenth of the pixels at a quarter of the
/// radius, and the result is not an approximation of the full-resolution /// radius, and the result is not an approximation of the full-resolution
/// base — it is the same band-limited function, sampled where it is still /// base — it is the same band-limited function, sampled where it is still
@@ -431,6 +431,24 @@ pub struct DetailPass {
pub storage: Vec<[f32; 4]>, pub storage: Vec<[f32; 4]>,
} }
impl DetailPass {
/// Whether this pass hands on exactly what it was given: a full-size pass
/// with nothing to bind and a body with no code in it, only comments.
///
/// The composer drops such a pass where that is exact (see
/// [`compose_detail_with`]); the operation still emits it, because whether
/// dropping it is exact depends on what is around it in the chain.
pub fn is_identity(&self) -> bool {
self.output_scale <= 1
&& self.storage.is_empty()
&& self
.wgsl
.lines()
.map(|l| l.split("//").next().unwrap_or("").trim())
.all(str::is_empty)
}
}
/// TRACES: FR-DEV-3 | FR-DEV-8 /// TRACES: FR-DEV-3 | FR-DEV-8
/// An operation that reads pixels other than the one it is writing. /// An operation that reads pixels other than the one it is writing.
/// ///
@@ -568,7 +586,7 @@ pub fn compose_detail(
/// photograph the photographer thinks they are sharpening. /// photograph the photographer thinks they are sharpening.
/// ///
/// It also means ARCH §5.2's stage list, which draws spot removal after /// It also means ARCH §5.2's stage list, which draws spot removal after
/// texture and clarity, is not what this does — see `docs/spot-removal.md` /// texture and clarity, is not what this does — see `docs/dev/spot-removal.md`
/// §5.1, which is where the disagreement is written down. /// §5.1, which is where the disagreement is written down.
pub fn compose_detail_with( pub fn compose_detail_with(
ops: &[Box<dyn Operation>], ops: &[Box<dyn Operation>],
@@ -646,6 +664,40 @@ pub fn compose_detail_with(
}; };
} }
// TRACES: NFR-P5
// A pass whose body is empty changes nothing but where the pixels are: it
// reads the intermediate and writes the same values to the other one.
// Capture sharpening emits exactly that at a scale too coarse to draw its
// radius (`nothing_to_sharpen`), and at fit on any modern sensor that is
// most of the time — so an edit with sharpening *and* another kernel paid
// a full render-sized read and write for it on every frame, 4.4 ms of a
// 2560 x 1600 frame on the reference laptop with its clocks held down.
//
// Dropped here, where the chain is still a list, and only where dropping
// it is exact:
//
// - **Not the last pass.** The last pass performs the output transform on
// what it read from an `rgba16float` intermediate. Moving that transform
// onto the pass before would apply it to that pass's `f32` result
// instead, which is a different rounding of the same picture.
// - **Not after a reduced pass.** A full-resolution pass ends the reduced
// chain (see `DetailRunner::encode`), so one that follows a scaled pass
// is what stops the next operation reading the last one's base. None of
// today's operations leave a reduced chain open, but a declared one may.
//
// Everywhere else the pass before and the pass after exchange the same
// `rgba16float` texels either way, `aux` included.
let mut kept: Vec<(&str, &[Helper], DetailPass, usize)> = Vec::with_capacity(planned.len());
let total = planned.len();
for (position, entry) in planned.into_iter().enumerate() {
let after_full = kept.last().is_none_or(|(_, _, p, _)| p.output_scale <= 1);
let droppable = position + 1 < total && after_full && entry.2.is_identity();
if !droppable {
kept.push(entry);
}
}
let planned = kept;
let last = planned.len().saturating_sub(1); let last = planned.len().saturating_sub(1);
let passes = planned let passes = planned
.into_iter() .into_iter()
@@ -1211,6 +1263,56 @@ mod tests {
assert_eq!(fused(&ops).output_mode, OutputMode::LinearWorking); assert_eq!(fused(&ops).output_mode, OutputMode::LinearWorking);
} }
#[test]
fn a_pass_that_changes_nothing_is_dropped_where_that_is_exact() {
// TRACES: NFR-P5
// Capture sharpening at a scale too coarse to draw its radius emits a
// pass with an empty body. Between two other passes it costs a
// render-sized read and write and changes no texel, so it goes; as the
// last pass it performs the output transform on the intermediate, and
// moving that onto the pass before would round differently, so it
// stays.
use crate::ops::{capture_sharpen, CaptureSharpen, NoiseReduction};
let sharpen = || -> Box<dyn Operation> {
let mut op = CaptureSharpen::new();
op.set_param(capture_sharpen::AMOUNT, 60.0);
Box::new(op)
};
let chroma = || -> Box<dyn Operation> { Box::new(NoiseReduction::with_amounts(0.0, 60.0)) };
// A 24 MP frame fitted to a panel: a one-source-pixel radius is a
// quarter of a render pixel.
let scale = RenderScale::new((1500, 1000), (6000, 4000));
let unresolved = sharpen().detail().expect("a detail stage").passes(scale);
assert!(
unresolved.len() == 1 && unresolved[0].is_identity(),
"the premise: sharpening at this scale is one pass that does nothing"
);
let labels = |ops: &[Box<dyn Operation>]| -> Vec<String> {
compose_detail(ops, scale, dr_types::ColourSpace::Srgb)
.passes
.iter()
.map(|p| p.label.clone())
.collect()
};
// First, ahead of the chroma passes: dropped.
let first = labels(&[sharpen(), chroma()]);
assert_eq!(
first,
[
"noise_reduction/chroma-horizontal",
"noise_reduction/chroma-vertical"
]
);
// Last, after them: kept, and it is the pass that encodes.
let last = labels(&[chroma(), sharpen()]);
assert_eq!(last.len(), 3);
assert_eq!(last[2], "capture_sharpen/unresolved");
// Alone: kept, because the fused pass stopped short and something has
// to finish the frame.
assert_eq!(labels(&[sharpen()]), ["capture_sharpen/unresolved"]);
}
#[test] #[test]
fn a_pass_can_hand_a_scalar_to_the_next_one_alongside_the_colour() { fn a_pass_can_hand_a_scalar_to_the_next_one_alongside_the_colour() {
// What makes an unsharp mask — sharpening, clarity, texture, dehaze — // What makes an unsharp mask — sharpening, clarity, texture, dehaze —
+681 -9
View File
@@ -27,6 +27,33 @@
//! chain exactly the space it documents: normalised, centred, `r == 1` at the //! chain exactly the space it documents: normalised, centred, `r == 1` at the
//! corner. Neither stage needs to know the other exists. //! corner. Neither stage needs to know the other exists.
//! //!
//! # Perspective sits inside framing (FR-DEV-20)
//!
//! A keystone correction is composition too — straightening converging
//! verticals reframes the photograph — so it is a step *of* framing rather
//! than a stage beside it, and inherits framing's `Compose` attribute, its
//! place in the sidecar and its exclusion from a default paste. Expanded, the
//! chain reads:
//!
//! ```text
//! output pixel → crop → straighten → perspective → orientation → warp (lens) → sample
//! ```
//!
//! After the straightening, because the angle is a nudge applied to the
//! corrected picture: the verticals are made parallel and *then* the whole is
//! levelled. Before the stored orientation, because "vertical" means vertical
//! in the photograph as it is shown — a portrait frame the camera stored on
//! its side must converge along its displayed height, not along the sensor's
//! rows. And before the lens warp, which still sees the whole frame it
//! corrects, for the reason given above.
//!
//! The correction maps the output frame onto a trapezoid **inside** the
//! source rather than pulling the source edges in. So a keystone on its own
//! never exposes an empty corner, and the crop the user drew is still valid
//! after it; only in combination with a straightening angle does the
//! inscribed crop have anything to account for — see
//! [`Framing::max_inscribed_crop`].
//!
//! # Why sampling changes with the angle //! # Why sampling changes with the angle
//! //!
//! At 90° steps and flips, output pixels land exactly on source pixels, so //! At 90° steps and flips, output pixels land exactly on source pixels, so
@@ -58,6 +85,13 @@ pub const CROP_X: ParamId = ParamId("crop_x");
pub const CROP_Y: ParamId = ParamId("crop_y"); pub const CROP_Y: ParamId = ParamId("crop_y");
pub const CROP_W: ParamId = ParamId("crop_w"); pub const CROP_W: ParamId = ParamId("crop_w");
pub const CROP_H: ParamId = ParamId("crop_h"); pub const CROP_H: ParamId = ParamId("crop_h");
/// TRACES: FR-DEV-20
/// Vertical keystone. Positive spreads the top of the frame — the correction
/// for a building photographed looking up, whose verticals lean together.
pub const KEYSTONE_V: ParamId = ParamId("keystone_v");
/// TRACES: FR-DEV-20
/// Horizontal keystone. Positive spreads the right-hand side of the frame.
pub const KEYSTONE_H: ParamId = ParamId("keystone_h");
/// Widest straightening the control offers, in degrees either way. /// Widest straightening the control offers, in degrees either way.
/// ///
@@ -66,12 +100,28 @@ pub const CROP_H: ParamId = ParamId("crop_h");
/// the edits actually are. /// the edits actually are.
pub const MAX_STRAIGHTEN: f32 = 45.0; pub const MAX_STRAIGHTEN: f32 = 45.0;
/// The keystone sliders' travel either way.
///
/// A plain amount rather than degrees of tilt: the angle a camera was tilted
/// by depends on a focal length the correction does not know, and a number
/// that claimed to be one would be wrong for every lens but one.
pub const MAX_KEYSTONE: f32 = 100.0;
/// How far a full keystone narrows the far edge of the frame, as a fraction
/// of its width: at `MAX_KEYSTONE` the source trapezoid's short side is half
/// its long one.
///
/// Enough for a tall building from its own pavement, and short of the point
/// where the stretched edge is so magnified that the correction reads as a
/// fault of its own.
const KEYSTONE_REACH: f64 = 0.5;
/// The parameters the framing widget owns — every one of them. /// The parameters the framing widget owns — every one of them.
/// ///
/// In the order the widget expects: the rect first, then the angle it is /// In the order the widget expects: the rect first, then the angle it is
/// straightened by, then the exact reorientations. /// straightened by, then the exact reorientations.
static FRAMING_PARAMS: [ParamId; 8] = [ static FRAMING_PARAMS: [ParamId; 10] = [
CROP_X, CROP_Y, CROP_W, CROP_H, ANGLE, ROTATION, FLIP_H, FLIP_V, CROP_X, CROP_Y, CROP_W, CROP_H, ANGLE, KEYSTONE_V, KEYSTONE_H, ROTATION, FLIP_H, FLIP_V,
]; ];
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| { static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
@@ -117,6 +167,30 @@ static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
ParamDescriptor::fraction("crop_y", "param.crop_y", 0.0), ParamDescriptor::fraction("crop_y", "param.crop_y", 0.0),
ParamDescriptor::fraction("crop_w", "param.crop_w", 1.0), ParamDescriptor::fraction("crop_w", "param.crop_w", 1.0),
ParamDescriptor::fraction("crop_h", "param.crop_h", 1.0), ParamDescriptor::fraction("crop_h", "param.crop_h", 1.0),
// TRACES: FR-DEV-20
// Perspective. Last so every sidecar written before these existed
// reads exactly as it did: a missing parameter is its default, and
// the default is no correction.
ParamDescriptor::scalar(
"keystone_v",
"param.keystone_v",
-MAX_KEYSTONE,
MAX_KEYSTONE,
0.0,
Unit::None,
Scale::Linear,
0,
),
ParamDescriptor::scalar(
"keystone_h",
"param.keystone_h",
-MAX_KEYSTONE,
MAX_KEYSTONE,
0.0,
Unit::None,
Scale::Linear,
0,
),
], ],
}) })
}); });
@@ -311,6 +385,86 @@ fn finite(v: f32, fallback: f32) -> f32 {
} }
} }
/// TRACES: FR-DEV-20
/// A plane projective map, row-major, acting on `(x, y, 1)`.
///
/// Held in `f64` because it is built by solving for four corners and then
/// inverted for [`Framing::output_at`]; the shader gets `f32` copies of the
/// forward map only.
#[derive(Debug, Clone, Copy, PartialEq)]
struct Homography([[f64; 3]; 3]);
impl Homography {
/// The map taking the square `[-0.5, 0.5]²` onto the quadrilateral whose
/// corners are `q`, listed top-left, top-right, bottom-right, bottom-left.
///
/// Heckbert's closed form for the unit square, composed with the shift
/// from the centred square onto it.
fn square_to_quad(q: [(f64, f64); 4]) -> Self {
let [(x0, y0), (x1, y1), (x2, y2), (x3, y3)] = q;
let (dx1, dx2, dx3) = (x1 - x2, x3 - x2, x0 - x1 + x2 - x3);
let (dy1, dy2, dy3) = (y1 - y2, y3 - y2, y0 - y1 + y2 - y3);
let den = dx1 * dy2 - dx2 * dy1;
let (g, h) = if den.abs() < 1e-12 {
(0.0, 0.0)
} else {
((dx3 * dy2 - dx2 * dy3) / den, (dx1 * dy3 - dx3 * dy1) / den)
};
// Unit square (u, v) -> quad.
let unit = [
[x1 - x0 + g * x1, x3 - x0 + h * x3, x0],
[y1 - y0 + g * y1, y3 - y0 + h * y3, y0],
[g, h, 1.0],
];
// Centred square -> unit square is `u = x + 0.5`, so fold the shift
// into the constant column.
let mut m = unit;
for row in &mut m {
row[2] += 0.5 * (row[0] + row[1]);
}
Self(m)
}
/// Where `(x, y)` lands, or `None` past the line the map sends to
/// infinity — a point with no image, which the caller treats as outside
/// the source.
fn apply(&self, (x, y): (f64, f64)) -> Option<(f64, f64)> {
let m = &self.0;
let w = m[2][0] * x + m[2][1] * y + m[2][2];
if w <= 1e-9 {
return None;
}
Some((
(m[0][0] * x + m[0][1] * y + m[0][2]) / w,
(m[1][0] * x + m[1][1] * y + m[1][2]) / w,
))
}
/// The inverse map, by the adjugate. Scale is irrelevant to a projective
/// map, so the determinant is only divided out to keep `w` positive and
/// near one — which is what [`Self::apply`]'s horizon test relies on.
fn inverse(&self) -> Self {
let m = &self.0;
let c = |r0: usize, c0: usize, r1: usize, c1: usize| {
m[r0][c0] * m[r1][c1] - m[r0][c1] * m[r1][c0]
};
let adj = [
[c(1, 1, 2, 2), -c(0, 1, 2, 2), c(0, 1, 1, 2)],
[-c(1, 0, 2, 2), c(0, 0, 2, 2), -c(0, 0, 1, 2)],
[c(1, 0, 2, 1), -c(0, 0, 2, 1), c(0, 0, 1, 1)],
];
let det = m[0][0] * adj[0][0] + m[0][1] * adj[1][0] + m[0][2] * adj[2][0];
let det = if det.abs() < 1e-12 { 1.0 } else { det };
let mut inv = adj;
for row in &mut inv {
for v in row.iter_mut() {
*v /= det;
}
}
Self(inv)
}
}
/// TRACES: FR-DEV-3 | FR-DEV-3d /// TRACES: FR-DEV-3 | FR-DEV-3d
/// Crop, straighten, rotation and flips for one image. /// Crop, straighten, rotation and flips for one image.
/// ///
@@ -325,6 +479,10 @@ pub struct Framing {
quarter_turns: u8, quarter_turns: u8,
flip_h: bool, flip_h: bool,
flip_v: bool, flip_v: bool,
/// TRACES: FR-DEV-20
/// Vertical and horizontal keystone, each `-MAX_KEYSTONE..=MAX_KEYSTONE`.
keystone_v: f32,
keystone_h: f32,
/// TRACES: FR-DEV-3h /// TRACES: FR-DEV-3h
/// How the file's pixels were stored, from its EXIF orientation. /// How the file's pixels were stored, from its EXIF orientation.
/// ///
@@ -376,6 +534,8 @@ impl Default for Framing {
quarter_turns: 0, quarter_turns: 0,
flip_h: false, flip_h: false,
flip_v: false, flip_v: false,
keystone_v: 0.0,
keystone_h: 0.0,
baseline: dr_types::Orientation::NORMAL, baseline: dr_types::Orientation::NORMAL,
crop: CropRect::default(), crop: CropRect::default(),
view: CropRect::default(), view: CropRect::default(),
@@ -396,7 +556,7 @@ impl Framing {
/// How framing would like to be presented. /// How framing would like to be presented.
/// ///
/// **This is what stops a frontend having to name this stage.** Rendered /// **This is what stops a frontend having to name this stage.** Rendered
/// generically these eight parameters are eight bad controls: four crop /// generically these ten parameters are ten bad controls: four crop
/// edges the photographer would have to type coordinates into, a "rotate" /// edges the photographer would have to type coordinates into, a "rotate"
/// slider running 0..3, and two switches. Every one of them is a worse /// slider running 0..3, and two switches. Every one of them is a worse
/// control than the gesture it stands for — a crop is dragged on the /// control than the gesture it stands for — a crop is dragged on the
@@ -407,10 +567,10 @@ impl Framing {
/// a second frontend would have had to learn the same special case, and /// a second frontend would have had to learn the same special case, and
/// nothing in the capability output said why. Now the preference is /// nothing in the capability output said why. Now the preference is
/// declared, the demand says what the widget needs, and a frontend that /// declared, the demand says what the widget needs, and a frontend that
/// cannot meet it falls back to the eight sliders — tedious, but complete, /// cannot meet it falls back to the ten sliders — tedious, but complete,
/// which is the guarantee the whole hint mechanism rests on. /// which is the guarantee the whole hint mechanism rests on.
/// ///
/// The widget owns **all eight** parameters rather than only the rect: a /// The widget owns **all ten** parameters rather than only the rect: a
/// frontend that takes this on is taking on the whole framing control /// frontend that takes this on is taking on the whole framing control
/// surface, and leaving rotation and the flips behind would scatter them /// surface, and leaving rotation and the flips behind would scatter them
/// into the generated panel underneath a crop control that already exists. /// into the generated panel underneath a crop control that already exists.
@@ -476,6 +636,52 @@ impl Framing {
(self.flip_h, self.flip_v) (self.flip_h, self.flip_v)
} }
/// TRACES: FR-DEV-20
/// The vertical and horizontal keystone, as the sliders show them.
pub fn keystone(&self) -> (f32, f32) {
(self.keystone_v, self.keystone_h)
}
/// Whether a perspective correction is applied at all.
pub fn has_keystone(&self) -> bool {
self.keystone_v != 0.0 || self.keystone_h != 0.0
}
/// TRACES: FR-DEV-20
/// The perspective map, from the straightened output frame to the upright
/// source frame, both measured as the centred square `[-0.5, 0.5]²`.
///
/// **Measured in fractions of the frame, not in the aspect-scaled space
/// the rest of the prologue works in**, so the map is the same for every
/// frame shape and the uniforms need no image size. The prologue divides
/// `p.x` by the frame's aspect on the way in and multiplies it back on
/// the way out.
///
/// The output frame's corners go to a trapezoid inside the source: a
/// positive vertical keystone brings the top corners in, so the top of
/// the source is spread across the full width of the output and lines
/// that converged upward come out parallel. Nothing is ever mapped from
/// outside the source, which is why a keystone alone needs no crop.
fn keystone_map(&self) -> Option<Homography> {
if !self.has_keystone() {
return None;
}
let amount = |v: f32| f64::from(v / MAX_KEYSTONE).clamp(-1.0, 1.0) * KEYSTONE_REACH;
let (tv, th) = (amount(self.keystone_v), amount(self.keystone_h));
// How much of each edge survives: the top and bottom rows' widths,
// the left and right columns' heights.
let top = 1.0 - tv.max(0.0);
let bottom = 1.0 + tv.min(0.0);
let right = 1.0 - th.max(0.0);
let left = 1.0 + th.min(0.0);
Some(Homography::square_to_quad([
(-0.5 * top, -0.5 * left),
(0.5 * top, -0.5 * right),
(0.5 * bottom, 0.5 * right),
(-0.5 * bottom, 0.5 * left),
]))
}
/// Add quarter turns, wrapping. The rotate-left/right buttons. /// Add quarter turns, wrapping. The rotate-left/right buttons.
pub fn rotate_quarters(&mut self, turns: i32) { pub fn rotate_quarters(&mut self, turns: i32) {
self.quarter_turns = (i32::from(self.quarter_turns) + turns).rem_euclid(4) as u8; self.quarter_turns = (i32::from(self.quarter_turns) + turns).rem_euclid(4) as u8;
@@ -544,6 +750,7 @@ impl Framing {
pub fn is_active(&self) -> bool { pub fn is_active(&self) -> bool {
let (turns, flip_h, flip_v) = self.effective(); let (turns, flip_h, flip_v) = self.effective();
self.angle != 0.0 self.angle != 0.0
|| self.has_keystone()
// Effective, not the user's: a file stored sideways needs the // Effective, not the user's: a file stored sideways needs the
// prologue emitted even on an untouched image, or it renders // prologue emitted even on an untouched image, or it renders
// through the identity map and lies on its side. // through the identity map and lies on its side.
@@ -572,6 +779,7 @@ impl Framing {
/// edited, the file was merely read correctly. /// edited, the file was merely read correctly.
pub fn edits_image(&self) -> bool { pub fn edits_image(&self) -> bool {
self.angle != 0.0 self.angle != 0.0
|| self.has_keystone()
|| self.quarter_turns != 0 || self.quarter_turns != 0
|| self.flip_h || self.flip_h
|| self.flip_v || self.flip_v
@@ -591,7 +799,9 @@ impl Framing {
/// warp being active forces interpolation regardless, which is the /// warp being active forces interpolation regardless, which is the
/// composer's call to make rather than this stage's. /// composer's call to make rather than this stage's.
pub fn needs_interpolation(&self) -> bool { pub fn needs_interpolation(&self) -> bool {
self.angle != 0.0 // A keystone stretches the frame by a different amount at every row,
// so it lands between pixels everywhere but on its centre line.
self.angle != 0.0 || self.has_keystone()
} }
pub fn set_param(&mut self, id: ParamId, value: f32) { pub fn set_param(&mut self, id: ParamId, value: f32) {
@@ -629,6 +839,8 @@ impl Framing {
} }
.normalised() .normalised()
} }
KEYSTONE_V => self.keystone_v = finite(value, 0.0).clamp(-MAX_KEYSTONE, MAX_KEYSTONE),
KEYSTONE_H => self.keystone_h = finite(value, 0.0).clamp(-MAX_KEYSTONE, MAX_KEYSTONE),
_ => log::warn!("framing: unknown parameter {id}"), _ => log::warn!("framing: unknown parameter {id}"),
} }
} }
@@ -643,6 +855,8 @@ impl Framing {
CROP_Y => self.crop.y, CROP_Y => self.crop.y,
CROP_W => self.crop.width, CROP_W => self.crop.width,
CROP_H => self.crop.height, CROP_H => self.crop.height,
KEYSTONE_V => self.keystone_v,
KEYSTONE_H => self.keystone_h,
_ => 0.0, _ => 0.0,
} }
} }
@@ -707,8 +921,22 @@ impl Framing {
/// ///
/// The standard largest-inscribed-rectangle result for a rotated /// The standard largest-inscribed-rectangle result for a rotated
/// rectangle of the same aspect ratio. /// rectangle of the same aspect ratio.
///
/// TRACES: FR-DEV-20
/// **With a keystone the closed form no longer applies**: the area with a
/// source pixel behind it is the source rectangle pulled back through the
/// perspective map and then turned, a quadrilateral no textbook result
/// describes. That case is searched instead — see
/// [`Self::inscribed_by_search`]. A keystone alone never needs a crop, so
/// the search returns the whole frame for it, exactly.
pub fn max_inscribed_crop(&self, width: u32, height: u32) -> CropRect { pub fn max_inscribed_crop(&self, width: u32, height: u32) -> CropRect {
if self.angle == 0.0 || width == 0 || height == 0 { if width == 0 || height == 0 {
return CropRect::default();
}
if self.has_keystone() {
return self.inscribed_by_search(width, height);
}
if self.angle == 0.0 {
return CropRect::default(); return CropRect::default();
} }
@@ -750,6 +978,123 @@ impl Framing {
.normalised() .normalised()
} }
/// TRACES: FR-DEV-20
/// The largest centred crop with a source pixel behind every point, found
/// by search rather than by formula.
///
/// The area that has a source pixel behind it is convex — the source
/// rectangle pulled back through a projective map whose horizon lies
/// outside it, then turned — and a rectangle lies inside a convex region
/// exactly when its four corners do. For a given width the tallest
/// rectangle that fits is therefore found by bisection, and the area
/// `width × tallest(width)` is unimodal in the width (a positive concave
/// function times a line), so a golden-section search finds the best
/// width. Every rectangle returned has been tested corner by corner, so
/// the answer errs inside, never outside.
///
/// A few hundred corner tests, on a gesture's release, is nothing next to
/// the render that follows it.
fn inscribed_by_search(&self, width: u32, height: u32) -> CropRect {
let (w, h) = if self.swaps_axes() {
(f64::from(height), f64::from(width))
} else {
(f64::from(width), f64::from(height))
};
let fa = w / h;
let rad = f64::from(self.angle).to_radians();
let (sn, cs) = (rad.sin(), rad.cos());
let map = self.keystone_map();
// Whether the output point `(fx, fy)`, in fractions of the frame from
// its centre, has a source pixel behind it. The prologue's steps, in
// its order, stopping short of the turns: those are a permutation of
// the frame and cannot move a point across its edge.
let defined = |fx: f64, fy: f64| {
let p = (fx * fa, fy);
let q = (p.0 * cs - p.1 * sn, p.0 * sn + p.1 * cs);
let n = (q.0 / fa, q.1);
let src = match &map {
Some(m) => m.apply(n),
None => Some(n),
};
const EDGE: f64 = 0.5 + 1e-9;
src.is_some_and(|(x, y)| x.abs() <= EDGE && y.abs() <= EDGE)
};
let fits = |hw: f64, hh: f64| {
[(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)]
.iter()
.all(|(sx, sy)| defined(sx * hw, sy * hh))
};
if fits(0.5, 0.5) {
return CropRect::default();
}
// Half the tallest height that fits at half-width `hw`.
let tallest = |hw: f64| {
if fits(hw, 0.5) {
return 0.5;
}
if !fits(hw, 0.0) {
return 0.0;
}
let (mut lo, mut hi) = (0.0, 0.5);
for _ in 0..40 {
let mid = 0.5 * (lo + hi);
if fits(hw, mid) {
lo = mid;
} else {
hi = mid;
}
}
lo
};
let area = |hw: f64| hw * tallest(hw);
let ratio = (5.0_f64.sqrt() - 1.0) * 0.5;
let (mut a, mut b) = (0.0, 0.5);
let mut c = b - ratio * (b - a);
let mut d = a + ratio * (b - a);
let (mut fc, mut fd) = (area(c), area(d));
for _ in 0..48 {
if fc < fd {
a = c;
c = d;
fc = fd;
d = a + ratio * (b - a);
fd = area(d);
} else {
b = d;
d = c;
fd = fc;
c = b - ratio * (b - a);
fc = area(c);
}
}
let hw = 0.5 * (a + b);
let hh = tallest(hw);
// Tested at `hw` as returned, so a width that the search's last step
// nudged past the boundary cannot come back with a height that no
// longer fits it.
let (hw, hh) = if hh > 0.0 && fits(hw, hh) {
(hw, hh)
} else {
(c.min(d), tallest(c.min(d)))
};
let fw = (2.0 * hw) as f32;
let fh = (2.0 * hh) as f32;
let fw = fw.clamp(CropRect::MIN_EXTENT, 1.0);
let fh = fh.clamp(CropRect::MIN_EXTENT, 1.0);
CropRect {
x: (1.0 - fw) * 0.5,
y: (1.0 - fh) * 0.5,
width: fw,
height: fh,
}
.normalised()
}
/// TRACES: FR-DEV-3 /// TRACES: FR-DEV-3
/// Where an output point comes from in the source, both in normalised /// Where an output point comes from in the source, both in normalised
/// `0..1` coordinates. /// `0..1` coordinates.
@@ -782,6 +1127,15 @@ impl Framing {
p = (p.0 * c - p.1 * s, p.0 * s + p.1 * c); p = (p.0 * c - p.1 * s, p.0 * s + p.1 * c);
} }
if let Some(m) = self.keystone_map() {
p = match m.apply((f64::from(p.0 / fx), f64::from(p.1))) {
Some((x, y)) => (x as f32 * fx, y as f32),
// Beyond the map's horizon: no source point at all, reported
// as one far outside the frame rather than as a NaN.
None => (1e6, 1e6),
};
}
let (turns, flip_h, flip_v) = self.effective(); let (turns, flip_h, flip_v) = self.effective();
p = match turns { p = match turns {
1 => (p.1 * ax, -p.0 / fx), 1 => (p.1 * ax, -p.0 / fx),
@@ -824,6 +1178,13 @@ impl Framing {
_ => p, _ => p,
}; };
if let Some(m) = self.keystone_map() {
p = match m.inverse().apply((f64::from(p.0 / fx), f64::from(p.1))) {
Some((x, y)) => (x as f32 * fx, y as f32),
None => (1e6, 1e6),
};
}
if self.angle != 0.0 { if self.angle != 0.0 {
let rad = -self.angle * PI / 180.0; let rad = -self.angle * PI / 180.0;
let (s, c) = (rad.sin(), rad.cos()); let (s, c) = (rad.sin(), rad.cos());
@@ -865,6 +1226,19 @@ impl Framing {
// identical whether or not the user is zoomed in, and costs no extra // identical whether or not the user is zoomed in, and costs no extra
// uniform slot. // uniform slot.
let rect = self.visible_rect(); let rect = self.visible_rect();
// The perspective map by columns, so the prologue can apply it as
// three multiply-adds. The identity when there is none: the slots
// exist either way and the prologue does not read them.
let m = self
.keystone_map()
.unwrap_or(Homography([
[1.0, 0.0, 0.0],
[0.0, 1.0, 0.0],
[0.0, 0.0, 1.0],
]))
.0;
let col = |c: usize| [m[0][c] as f32, m[1][c] as f32, m[2][c] as f32, 0.0];
let [c0, c1, c2] = [col(0), col(1), col(2)];
[ [
rect.x, rect.x,
rect.y, rect.y,
@@ -874,6 +1248,18 @@ impl Framing {
rad.cos(), rad.cos(),
0.0, 0.0,
0.0, 0.0,
c0[0],
c0[1],
c0[2],
c0[3],
c1[0],
c1[1],
c1[2],
c1[3],
c2[0],
c2[1],
c2[2],
c2[3],
] ]
} }
@@ -972,6 +1358,28 @@ impl Framing {
); );
} }
if self.has_keystone() {
// TRACES: FR-DEV-20
// After the straightening and before the turns, so the keystone
// acts on the photograph as it is shown. The map is measured in
// fractions of the frame (see `Framing::keystone_map`), hence the
// aspect divided out and put back. A point past the map's horizon
// has no source at all and is sent far outside it, where the
// sampler's bounds test renders it void.
s.push_str(
"
// Perspective: the straightened frame onto a trapezoid of the source.
let key_n = vec2<f32>(p.x / frame_aspect.x, p.y);
let key_h = u.keystone_c0.xyz * key_n.x + u.keystone_c1.xyz * key_n.y + u.keystone_c2.xyz;
p = select(
vec2<f32>(1.0e6),
vec2<f32>(key_h.x / key_h.z * frame_aspect.x, key_h.y / key_h.z),
key_h.z > 1.0e-6,
);
",
);
}
// The user's turns and mirrors composed with the file's stored // The user's turns and mirrors composed with the file's stored
// orientation. One permutation covers both, so honouring the EXIF tag // orientation. One permutation covers both, so honouring the EXIF tag
// adds no per-pixel work over an untagged file. // adds no per-pixel work over an untagged file.
@@ -1040,13 +1448,15 @@ impl Framing {
| u64::from(flip_v) << 3 | u64::from(flip_v) << 3
| u64::from(turns) << 4 | u64::from(turns) << 4
| u64::from(self.is_active()) << 6 | u64::from(self.is_active()) << 6
| u64::from(self.has_keystone()) << 7
} }
} }
/// Floats the framing block occupies in the generated uniform struct. /// Floats the framing block occupies in the generated uniform struct.
/// ///
/// Two `vec4`s: the crop rect, and the angle's sin/cos with padding. /// Five `vec4`s: the crop rect, the angle's sin/cos with padding, and the
pub const FRAMING_UNIFORM_FIELDS: usize = 8; /// perspective map's three columns, each padded.
pub const FRAMING_UNIFORM_FIELDS: usize = 20;
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
@@ -2025,6 +2435,8 @@ mod tests {
(CROP_Y, 0.2), (CROP_Y, 0.2),
(CROP_W, 0.5), (CROP_W, 0.5),
(CROP_H, 0.4), (CROP_H, 0.4),
(KEYSTONE_V, 35.0),
(KEYSTONE_H, -20.0),
] { ] {
f.set_param(id, v); f.set_param(id, v);
assert_eq!(f.param(id), v, "{id} did not round-trip"); assert_eq!(f.param(id), v, "{id} did not round-trip");
@@ -2241,6 +2653,8 @@ mod tests {
f.rotate_quarters(turns); f.rotate_quarters(turns);
f.set_param(FLIP_H, 1.0); f.set_param(FLIP_H, 1.0);
f.set_param(FLIP_V, 1.0); f.set_param(FLIP_V, 1.0);
f.set_param(KEYSTONE_V, 60.0);
f.set_param(KEYSTONE_H, -25.0);
for out in [(0.0, 0.0), (0.5, 0.5), (0.2, 0.9), (0.95, 0.05)] { for out in [(0.0, 0.0), (0.5, 0.5), (0.2, 0.9), (0.95, 0.05)] {
let src = f.source_at(out, SRC.0, SRC.1); let src = f.source_at(out, SRC.0, SRC.1);
@@ -2316,6 +2730,27 @@ mod tests {
"the three-turn permutation moved; `source_at` must move with it" "the three-turn permutation moved; `source_at` must move with it"
); );
// The perspective step: the map applied in fractions of the frame,
// which is what `source_at` divides the aspect out for.
let mut f = Framing::new();
f.set_param(KEYSTONE_V, 40.0);
let prologue = f.wgsl_prologue();
assert!(
prologue.contains("let key_n = vec2<f32>(p.x / frame_aspect.x, p.y);")
&& prologue.contains("key_h.x / key_h.z * frame_aspect.x"),
"the perspective step moved; `source_at` must move with it"
);
// After the straightening and before the turns, as `source_at` has it.
let mut f = Framing::new();
f.set_param(KEYSTONE_V, 40.0);
f.set_param(ANGLE, 3.0);
f.rotate_quarters(1);
let prologue = f.wgsl_prologue();
let straighten = prologue.find("// Straighten").unwrap();
let keystone = prologue.find("// Perspective").unwrap();
let turn = prologue.find("90° clockwise").unwrap();
assert!(straighten < keystone && keystone < turn, "{prologue}");
// And the sampler's last step, which lives in `operation.rs` and is // And the sampler's last step, which lives in `operation.rs` and is
// the half of the map this file does not emit. // the half of the map this file does not emit.
assert!( assert!(
@@ -2323,4 +2758,241 @@ mod tests {
"the sampler's return to texture coordinates moved" "the sampler's return to texture coordinates moved"
); );
} }
// ---- perspective (FR-DEV-20) -----------------------------------------
/// A grid over the whole output frame, edges included.
fn grid() -> impl Iterator<Item = (f32, f32)> {
(0..=10).flat_map(|j| (0..=10).map(move |i| (i as f32 / 10.0, j as f32 / 10.0)))
}
fn inside(p: (f32, f32)) -> bool {
(-1e-4..=1.0 + 1e-4).contains(&p.0) && (-1e-4..=1.0 + 1e-4).contains(&p.1)
}
#[test]
fn a_keystone_is_an_edit_and_a_resample() {
let mut f = Framing::new();
let neutral = f.structure_key();
f.set_param(KEYSTONE_V, 30.0);
assert!(f.is_active());
assert!(f.edits_image(), "a keystone must light the modified dot");
assert!(f.needs_interpolation());
assert_ne!(f.structure_key(), neutral);
assert!(f.wgsl_prologue().contains("u.keystone_c0"));
// Neither the output size nor the crop moves: the frame is reshaped
// inside itself, so what the user cropped stays cropped.
assert_eq!(f.output_size(6000, 4000), (6000, 4000));
assert!(f.crop().is_full());
}
#[test]
fn the_keystone_magnitude_does_not_reach_the_structure_key() {
// Dragging the slider is a uniform upload, never a shader build.
let mut f = Framing::new();
f.set_param(KEYSTONE_V, 10.0);
let key = f.structure_key();
for (v, h) in [(80.0, 0.0), (-45.0, 30.0), (1.0, -100.0)] {
f.set_param(KEYSTONE_V, v);
f.set_param(KEYSTONE_H, h);
assert_eq!(f.structure_key(), key, "{v}/{h} forced a recompile");
}
}
#[test]
fn the_keystone_is_clamped_to_its_travel() {
let mut f = Framing::new();
f.set_param(KEYSTONE_V, 1e9);
f.set_param(KEYSTONE_H, f32::NAN);
assert_eq!(f.keystone(), (MAX_KEYSTONE, 0.0));
assert!(f.uniforms().iter().all(|v| v.is_finite()));
}
#[test]
fn a_keystone_alone_never_reaches_outside_the_source() {
// The design decision the crop relies on: the output frame is mapped
// onto a trapezoid *inside* the source, so no corner goes empty and
// a crop drawn before the keystone is still a crop of the picture.
for (v, h) in [
(100.0, 0.0),
(-100.0, 0.0),
(0.0, 100.0),
(0.0, -100.0),
(100.0, 100.0),
(-100.0, 100.0),
(37.0, -64.0),
] {
for turns in 0..4 {
let mut f = Framing::new();
f.rotate_quarters(turns);
f.set_param(KEYSTONE_V, v);
f.set_param(KEYSTONE_H, h);
for out in grid() {
let src = f.source_at(out, SRC.0, SRC.1);
assert!(inside(src), "{v}/{h}, {turns} turn(s): {out:?} -> {src:?}");
}
assert!(f.max_inscribed_crop(SRC.0, SRC.1).is_full());
}
}
}
#[test]
fn a_vertical_keystone_makes_upward_converging_lines_parallel() {
// What the control is for. Output columns are straight verticals;
// with a positive keystone each must come from a straight source line
// that leans in toward the centre as it rises — the shape a building
// has when photographed looking up.
let mut f = Framing::new();
f.set_param(KEYSTONE_V, 60.0);
for x in [0.1f32, 0.3, 0.7, 0.9] {
let bottom = f.source_at((x, 1.0), SRC.0, SRC.1);
let middle = f.source_at((x, 0.5), SRC.0, SRC.1);
let top = f.source_at((x, 0.0), SRC.0, SRC.1);
// Straight: the middle sits on the line through the two ends.
let cross = (top.0 - bottom.0) * (middle.1 - bottom.1)
- (top.1 - bottom.1) * (middle.0 - bottom.0);
assert!(cross.abs() < 1e-4, "column {x} is not a straight line");
// Leaning in: the top is nearer the centre than the bottom.
assert!(
(top.0 - 0.5).abs() < (bottom.0 - 0.5).abs(),
"column {x}: top {top:?} is not inside bottom {bottom:?}"
);
}
// The bottom row is left where it was; the top row is the one spread.
close(
f.source_at((0.0, 1.0), SRC.0, SRC.1),
(0.0, 1.0),
"bottom-left",
);
close(
f.source_at((0.0, 0.0), SRC.0, SRC.1),
(0.15, 0.0),
"top-left",
);
}
#[test]
fn a_horizontal_keystone_spreads_the_right_hand_side() {
let mut f = Framing::new();
f.set_param(KEYSTONE_H, 100.0);
// The right-hand column comes from half the source's height.
close(
f.source_at((1.0, 0.0), SRC.0, SRC.1),
(1.0, 0.25),
"top-right",
);
close(
f.source_at((1.0, 1.0), SRC.0, SRC.1),
(1.0, 0.75),
"bottom-right",
);
close(
f.source_at((0.0, 0.0), SRC.0, SRC.1),
(0.0, 0.0),
"top-left",
);
}
#[test]
fn the_keystone_acts_on_the_frame_as_shown() {
// A portrait frame the camera stored on its side: "vertical" is the
// frame's displayed height, so the same keystone must move the same
// *displayed* points whatever the file's stored orientation.
let mut upright = Framing::new();
upright.set_param(KEYSTONE_V, 50.0);
let mut sideways = Framing::new();
sideways.set_baseline(dr_types::Orientation::from_exif(6));
sideways.set_param(KEYSTONE_V, 50.0);
// Compare in the displayed frame: map the sideways result back
// through the orientation alone.
let mut turn_only = Framing::new();
turn_only.set_baseline(dr_types::Orientation::from_exif(6));
for out in grid() {
let a = upright.source_at(out, SRC.1, SRC.0);
let b = turn_only.output_at(sideways.source_at(out, SRC.0, SRC.1), SRC.0, SRC.1);
close(a, b, "the keystone turned with the file");
}
}
#[test]
fn the_inscribed_crop_accounts_for_the_keystone() {
// Straightening a keystoned frame: the empty area is no longer the
// rotated rectangle's, and the crop must avoid the area that is.
for (angle, v, h) in [
(5.0f32, 50.0f32, 0.0f32),
(-8.0, -70.0, 20.0),
(12.0, 100.0, 100.0),
(2.0, 0.0, -40.0),
] {
for turns in [0, 1] {
let mut f = Framing::new();
f.rotate_quarters(turns);
f.set_param(ANGLE, angle);
f.set_param(KEYSTONE_V, v);
f.set_param(KEYSTONE_H, h);
let c = f.max_inscribed_crop(SRC.0, SRC.1);
assert!(
c.width > 0.3 && c.height > 0.3 && !c.is_full(),
"{angle}°/{v}/{h}: {c:?}"
);
assert!(
((c.x + c.width * 0.5) - 0.5).abs() < 1e-4
&& ((c.y + c.height * 0.5) - 0.5).abs() < 1e-4,
"{c:?} is not centred"
);
// Every point of it, edges included, has a source pixel.
f.set_crop(c);
for out in grid() {
let src = f.source_at(out, SRC.0, SRC.1);
assert!(inside(src), "{angle}°/{v}/{h}: {out:?} -> {src:?}");
}
}
}
}
#[test]
fn the_inscribed_crop_with_a_keystone_is_not_needlessly_small() {
// The search must find the best rectangle, not merely a safe one. A
// tenth larger in either direction has to reach outside the source.
let mut f = Framing::new();
f.set_param(ANGLE, 6.0);
f.set_param(KEYSTONE_V, 60.0);
let c = f.max_inscribed_crop(SRC.0, SRC.1);
for (gw, gh) in [(1.1, 1.0), (1.0, 1.1)] {
let mut g = f;
let (w, h) = (c.width * gw, c.height * gh);
g.set_crop(CropRect {
x: 0.5 - w * 0.5,
y: 0.5 - h * 0.5,
width: w,
height: h,
});
let spills = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]
.into_iter()
.any(|out| !inside(g.source_at(out, SRC.0, SRC.1)));
// Either the grown rect spills, or it could not grow at all
// because the crop was already at the frame's edge on that axis.
assert!(
spills
|| (gw > 1.0 && c.width >= 1.0 - 1e-4)
|| (gh > 1.0 && c.height >= 1.0 - 1e-4),
"{c:?} grown by {gw}x{gh} still fits"
);
}
}
#[test]
fn reset_clears_the_keystone() {
let mut f = Framing::new();
f.set_param(KEYSTONE_V, 30.0);
f.set_param(KEYSTONE_H, -30.0);
f.reset();
assert_eq!(f.keystone(), (0.0, 0.0));
assert!(!f.is_active());
}
} }
+43 -4
View File
@@ -113,7 +113,7 @@ pub struct EditGraph {
/// a sidecar comes to name one stock while the shader draws another. /// a sidecar comes to name one stock while the shader draws another.
film: Option<Film>, film: Option<Film>,
/// TRACES: FR-DEV-8 /// TRACES: FR-DEV-8
/// The repairs (`docs/spot-removal.md`). /// The repairs (`docs/dev/spot-removal.md`).
/// ///
/// Apart from `ops` for the third time and the same reason: a spot is not /// Apart from `ops` for the third time and the same reason: a spot is not
/// a scalar, and a list of them is not a slider. It sits beside the masks /// a scalar, and a list of them is not a slider. It sits beside the masks
@@ -122,7 +122,7 @@ pub struct EditGraph {
/// photograph comes from. /// photograph comes from.
spots: SpotSet, spots: SpotSet,
/// The lens corrections that rewrite coordinates: distortion and lateral /// The lens corrections that rewrite coordinates: distortion and lateral
/// chromatic aberration (`docs/architecture.md` §5.2). /// chromatic aberration (`docs/dev/architecture.md` §5.2).
/// ///
/// Apart from `ops` for the fourth time, and this one is not about shape /// Apart from `ops` for the fourth time, and this one is not about shape
/// but about direction. Every [`Operation`] is a function from colour to /// but about direction. Every [`Operation`] is a function from colour to
@@ -623,7 +623,9 @@ impl EditGraph {
} = self; } = self;
EditState { EditState {
params: Preset::capture(self), // Without the film: it has a field of its own below, and one
// edit must not have two places to disagree about its stock.
params: Preset::capture_params(self),
// A refcount bump. See `EditState::masks` for why that matters on // A refcount bump. See `EditState::masks` for why that matters on
// a path called once a frame. // a path called once a frame.
masks: Arc::clone(masks), masks: Arc::clone(masks),
@@ -663,7 +665,11 @@ impl EditGraph {
// At full scope. `Scope` is a question about what a paste carries // At full scope. `Scope` is a question about what a paste carries
// *between* photographs; this is one photograph's own edit being put // *between* photographs; this is one photograph's own edit being put
// back, so there is nothing to leave behind. // back, so there is nothing to leave behind.
params.apply(self, Scope::everything()); //
// What the parameters say about the film is discarded: the stock is
// `film`'s to decide, below, and clearing it is what happens there
// either way.
let _ = params.apply(self, Scope::everything());
self.masks = Arc::clone(masks); self.masks = Arc::clone(masks);
self.spots = spots.clone(); self.spots = spots.clone();
@@ -857,6 +863,39 @@ impl EditGraph {
crate::operation::compose_camera_linear(&self.warps, self.framing.baseline(), view) crate::operation::compose_camera_linear(&self.warps, self.framing.baseline(), view)
} }
/// TRACES: FR-DEV-3
/// The camera-space tap over one patch of what is on the canvas.
///
/// `patch` is in fractions of the visible region — the coordinates a
/// click on the canvas arrives in — and is laid over this edit's own
/// framing: crop, view, rotation and all, so a fraction of the canvas is
/// a fraction of the probe. Nothing else of the edit: no operation, no
/// mask, no repair. Rendering only the patch is what lets a small target
/// cover every sensor pixel under it rather than sampling one in fifty;
/// see [`crate::operation::compose_camera_probe`] for why the white
/// balance picker reads from here and not from the display.
///
/// The patch is centred where asked and held to the view's own minimum
/// extent: at a deep zoom a patch a fraction of the view would be
/// smaller than a view may be, and letting `set_view` widen it from one
/// corner would move the sample off the point that was clicked.
pub fn compose_camera_probe(&self, patch: crate::framing::CropRect) -> ComposedShader {
use crate::framing::CropRect;
let mut framing = self.framing;
let view = framing.view();
let width = (patch.width * view.width).max(CropRect::MIN_EXTENT);
let height = (patch.height * view.height).max(CropRect::MIN_EXTENT);
let cx = view.x + (patch.x + patch.width * 0.5) * view.width;
let cy = view.y + (patch.y + patch.height * 0.5) * view.height;
framing.set_view(CropRect {
x: (cx - width * 0.5).clamp(0.0, 1.0 - width),
y: (cy - height * 0.5).clamp(0.0, 1.0 - height),
width,
height,
});
crate::operation::compose_camera_probe(&self.warps, &framing)
}
/// TRACES: FR-DEV-19c /// TRACES: FR-DEV-19c
/// [`Self::compose_for`], with one layer's mask drawn over the picture. /// [`Self::compose_for`], with one layer's mask drawn over the picture.
/// ///
+5 -3
View File
@@ -32,6 +32,7 @@
//! single multiply and white balance a per-channel scale; on gamma-encoded //! single multiply and white balance a per-channel scale; on gamma-encoded
//! data neither would be physically meaningful (ARCH §5.2). //! data neither would be physically meaningful (ARCH §5.2).
pub mod bundled;
pub mod coverage; pub mod coverage;
pub mod declared; pub mod declared;
pub mod descriptor; pub mod descriptor;
@@ -44,10 +45,10 @@ pub mod mask;
pub mod neutral; pub mod neutral;
pub mod operation; pub mod operation;
pub mod ops; pub mod ops;
pub mod orphan;
pub mod preset; pub mod preset;
pub mod sidecar; pub mod sidecar;
pub mod spot; pub mod spot;
pub mod starter;
pub mod state; pub mod state;
pub use coverage::Coverage; pub use coverage::Coverage;
@@ -65,9 +66,10 @@ pub use history::{Edit, Entry as HistoryEntry, History, Step};
pub use lens::{compose_warps, ComposedWarp, LensProfile, Tca, Warp}; pub use lens::{compose_warps, ComposedWarp, LensProfile, Tca, Warp};
pub use operation::{ pub use operation::{
compose, compose_with_framing, Affects, ComposedShader, Helper, Invalidation, Operation, compose, compose_with_framing, Affects, ComposedShader, Helper, Invalidation, Operation,
OutputMode, Uniform, BASE_CURVE_POINTS, BASE_CURVE_UNIFORM_OFFSET, RESERVED_UNIFORM_FIELDS, OutputMode, Uniform, BASE_CURVE_POINTS, BASE_CURVE_UNIFORM_OFFSET, CLIP_ONSET,
RESERVED_UNIFORM_FIELDS, SAMPLE_CACHE_UNIFORM_OFFSET,
}; };
pub use preset::{LibraryParseError, NameError, Preset, PresetLibrary, Scope}; pub use preset::{LibraryParseError, NameError, Preset, PresetLibrary, Reach, Scope};
pub use sidecar::{Sidecar, Version}; pub use sidecar::{Sidecar, Version};
pub use spot::{Spot, SpotMode, SpotSet}; pub use spot::{Spot, SpotMode, SpotSet};
pub use state::{EditState, FilmRebake, FilmRef}; pub use state::{EditState, FilmRebake, FilmRef};
+116 -8
View File
@@ -27,7 +27,7 @@
//! [`MaskSource::Regions`] stores integers naming regions in the segmentation //! [`MaskSource::Regions`] stores integers naming regions in the segmentation
//! hierarchy (`dr-segment`). That choice is what makes a mask diffable, cheap //! hierarchy (`dr-segment`). That choice is what makes a mask diffable, cheap
//! in a sidecar, and mergeable per-field under FR-NC-9 — three properties a //! in a sidecar, and mergeable per-field under FR-NC-9 — three properties a
//! stored raster has none of (docs/segmentation.md §1). Two devices that //! stored raster has none of (docs/dev/segmentation.md §1). Two devices that
//! select the same subject produce the same small sorted list, and a sync //! select the same subject produce the same small sorted list, and a sync
//! conflict between them is resolvable rather than a binary blob fight. //! conflict between them is resolvable rather than a binary blob fight.
//! //!
@@ -564,7 +564,7 @@ pub enum MaskSource {
/// This is what the watershed and the semantic model exist to produce. /// This is what the watershed and the semantic model exist to produce.
/// Selecting a subject means "the regions the model's instance covers", /// Selecting a subject means "the regions the model's instance covers",
/// and the resulting edge is the watershed's, which is to say the image's /// and the resulting edge is the watershed's, which is to say the image's
/// own (docs/segmentation.md §5). /// own (docs/dev/segmentation.md §5).
Regions { Regions {
/// Which segmentation these ids index into. /// Which segmentation these ids index into.
/// ///
@@ -590,7 +590,7 @@ pub enum MaskSource {
/// **The primary way a local adjustment is made.** The watershed hierarchy /// **The primary way a local adjustment is made.** The watershed hierarchy
/// this crate was first built around does not survive a photograph: its /// this crate was first built around does not survive a photograph: its
/// saddles are near zero almost everywhere, so a global cut collapses the /// saddles are near zero almost everywhere, so a global cut collapses the
/// frame into one region plus noise (docs/segmentation.md §15). A model /// frame into one region plus noise (docs/dev/segmentation.md §15). A model
/// instance is a whole object, found as one thing, and needs no ladder. /// instance is a whole object, found as one thing, and needs no ladder.
/// ///
/// The trade is that the boundary is the model's — a quarter-resolution /// The trade is that the boundary is the model's — a quarter-resolution
@@ -625,7 +625,7 @@ pub enum MaskSource {
/// reason both exist. A subject is *one* instance — this dog, not that one /// reason both exist. A subject is *one* instance — this dog, not that one
/// — found by a COCO-trained instance model. A category is *all* the sky, /// — found by a COCO-trained instance model. A category is *all* the sky,
/// or all the foliage, from an ADE20K-trained semantic model that has no /// or all the foliage, from an ADE20K-trained semantic model that has no
/// notion of instances at all (docs/segmentation.md §16). /// notion of instances at all (docs/dev/segmentation.md §16).
/// ///
/// So this is what a global grade attaches to: lift the sky, desaturate /// So this is what a global grade attaches to: lift the sky, desaturate
/// the vegetation, warm the architecture. Asking it for "that person /// the vegetation, warm the architecture. Asking it for "that person
@@ -926,6 +926,21 @@ pub enum Join {
/// erase stroke is a hole in the part it was painted into and reads as /// erase stroke is a hole in the part it was painted into and reads as
/// nothing at all. /// nothing at all.
Subtract, Subtract,
/// TRACES: FR-DEV-19a
/// Only where both agree. "Keep the part of this mask that is also that."
///
/// The join that makes cheap criteria precise: a sky is a category *and*
/// a luminance band, skin is a subject *and* a hue. Neither alone is the
/// selection, and no feather on either makes it one.
///
/// The product of the two coverages rather than their minimum, because
/// that is what one fixed-function blend gives on the device
/// (`dst · src`, `docs/dev/mask-editing.md` §5.2) and it agrees with the
/// minimum wherever either side is fully in or fully out. Between two soft
/// edges it is the softer of the two readings, which is the right way to
/// be wrong: an overlap of two partial selections is less certainly
/// selected than either.
Intersect,
} }
impl Join { impl Join {
@@ -933,6 +948,7 @@ impl Join {
match self { match self {
Self::Union => "union", Self::Union => "union",
Self::Subtract => "subtract", Self::Subtract => "subtract",
Self::Intersect => "intersect",
} }
} }
@@ -940,12 +956,30 @@ impl Join {
Some(match name { Some(match name {
"union" => Self::Union, "union" => Self::Union,
"subtract" => Self::Subtract, "subtract" => Self::Subtract,
"intersect" => Self::Intersect,
_ => return None, _ => return None,
}) })
} }
/// Every variant, for a UI building a choice control. /// TRACES: FR-DEV-19a
pub const ALL: [Join; 2] = [Join::Union, Join::Subtract]; /// The coverage this join leaves at one point, given what the mask had
/// there (`dst`) and what the part covers (`src`), both in `0..=1`.
///
/// The definition the device's blend states implement, spelled out once
/// on the CPU so a test can hold the GPU to it and a reader can see the
/// three set operations side by side without reading `wgpu` enums.
pub fn apply(self, dst: f32, src: f32) -> f32 {
match self {
Self::Union => dst.max(src),
Self::Subtract => dst * (1.0 - src),
Self::Intersect => dst * src,
}
}
/// Every variant, for a UI building a choice control. The order is the
/// panel's: a chip cycles through it and a stored index names a place in
/// it, so a new join goes on the end.
pub const ALL: [Join; 3] = [Join::Union, Join::Subtract, Join::Intersect];
} }
/// One selection inside a layer's mask. /// One selection inside a layer's mask.
@@ -1580,9 +1614,20 @@ impl MaskLayer {
// A hidden part is not in the build, whichever way it joins — and a // A hidden part is not in the build, whichever way it joins — and a
// hidden base hands its role to the first part that is shown, which // hidden base hands its role to the first part that is shown, which
// is why "adds" is asked of the shown parts rather than of index 0. // is why "adds" is asked of the shown parts rather than of index 0.
//
// Folded rather than asked with `any`, because an intersection can
// take away everything the parts before it added: a subject
// intersected with an unpainted brush covers nothing. An inverted
// part is taken to cover, whatever its source — an inverted empty
// brush is the whole frame, and saying "covers nothing" of a layer
// that does would hide an adjustment the photographer made.
self.shown_parts() self.shown_parts()
.enumerate() .enumerate()
.any(|(i, p)| (i == 0 || p.join == Join::Union) && p.covers()) .fold(false, |acc, (i, p)| match (i, p.join) {
(0, _) | (_, Join::Union) => acc || p.covers(),
(_, Join::Subtract) => acc,
(_, Join::Intersect) => acc && (p.covers() || p.invert),
})
} }
/// TRACES: FR-DEV-19a /// TRACES: FR-DEV-19a
@@ -2231,7 +2276,7 @@ fn reveal_block(slot: usize, layer: &MaskLayer, style: RevealStyle, colour: [f32
/// **not** a hash of the label field: that would be a readback on a path that /// **not** a hash of the label field: that would be a readback on a path that
/// must not have one (ARCH §6.1), and would also make the signature depend on /// must not have one (ARCH §6.1), and would also make the signature depend on
/// float arithmetic whose cross-vendor determinism is exactly the open /// float arithmetic whose cross-vendor determinism is exactly the open
/// question (docs/segmentation.md §6, M5). /// question (docs/dev/segmentation.md §6, M5).
pub fn segmentation_signature(width: u32, height: u32, regions: u32, tuning: u64) -> u64 { pub fn segmentation_signature(width: u32, height: u32, regions: u32, tuning: u64) -> u64 {
// FNV-1a over the four fields. Small, dependency-free, and adequate: this // FNV-1a over the four fields. Small, dependency-free, and adequate: this
// guards against accidental mismatch, not against a forged sidecar. // guards against accidental mismatch, not against a forged sidecar.
@@ -3037,6 +3082,69 @@ mod tests {
); );
} }
/// TRACES: FR-DEV-19a
/// The three joins, pointwise, on every pair of coverages a part and a
/// mask can meet at: fully in, fully out, and each soft edge. Intersection
/// is the product, which agrees with the minimum wherever either side is
/// decided and is the softer reading where both are not.
#[test]
fn the_joins_are_max_cut_and_product() {
let levels = [0.0f32, 0.25, 0.5, 0.75, 1.0];
for &dst in &levels {
for &src in &levels {
assert_eq!(Join::Union.apply(dst, src), dst.max(src));
assert_eq!(Join::Subtract.apply(dst, src), dst * (1.0 - src));
let meet = Join::Intersect.apply(dst, src);
assert_eq!(meet, dst * src);
assert!(meet <= dst.min(src), "never more than either side");
if dst == 0.0 || dst == 1.0 || src == 0.0 || src == 1.0 {
assert_eq!(meet, dst.min(src), "the minimum where either is decided");
}
}
}
}
/// A stored index names a place in [`Join::ALL`], and a sidecar names a
/// join by word: both have to survive a third join arriving, which means
/// the first two keep their places and every name reads back as itself.
#[test]
fn every_join_reads_back_by_name_and_keeps_its_place() {
assert_eq!(Join::ALL[0], Join::Union);
assert_eq!(Join::ALL[1], Join::Subtract);
for join in Join::ALL {
assert_eq!(Join::from_name(join.name()), Some(join));
}
assert_eq!(Join::from_name("intersect"), Some(Join::Intersect));
}
/// TRACES: FR-DEV-19a
/// An intersection can empty a mask the parts before it filled: a range
/// meeting an unpainted brush selects nothing, and rasterising it would
/// spend a slice to draw an empty field. Painting the brush, or inverting
/// it, gives the intersection something to keep.
#[test]
fn an_intersection_with_nothing_covers_nothing() {
let mut layer = MaskLayer::new("m1", MaskSource::highlights());
layer.set_param("exposure", ParamId("exposure"), 1.0);
layer.push_part(MaskPart::painted("p2", Join::Intersect));
assert!(
!layer.is_active(),
"a range intersected with an unpainted brush selects nothing"
);
layer.part_mut(1).expect("p2").invert = true;
assert!(layer.is_active(), "inverted, the empty brush is everywhere");
layer.part_mut(1).expect("p2").invert = false;
layer.begin_stroke(1, false, 0.1, 0.5, 1.0);
layer.extend_stroke(1, 0.5, 0.5);
layer.end_stroke(1);
assert!(layer.is_active(), "and painted, it keeps what it covers");
layer.part_mut(1).expect("p2").hidden = true;
assert!(layer.is_active(), "hidden, it is out of the build");
}
/// One stale part is a stale layer: the mask is the fold over all of them, /// One stale part is a stale layer: the mask is the fold over all of them,
/// so a part that would draw a confidently wrong shape makes the result /// so a part that would draw a confidently wrong shape makes the result
/// wrong whichever way it joins. /// wrong whichever way it joins.
+15 -9
View File
@@ -69,20 +69,26 @@ const ROUNDS: u32 = 3;
/// Below this a channel carries no ratio worth balancing. /// Below this a channel carries no ratio worth balancing.
/// ///
/// A sample in the deep shadows, or one taken on a blown highlight where a /// A sample in the deep shadows has no white balance in it: the logarithms
/// channel has already clipped to nothing, has no white balance in it: the /// below would run away and the picker would slam a slider to its stop.
/// logarithms below would run away and the picker would slam a slider to its /// Refusing is the honest answer, and the caller reports that the point was
/// stop. Refusing is the honest answer, and the caller reports that the point /// not usable rather than moving the photograph. The other end — a blown
/// was not usable rather than moving the photograph. /// highlight, where every channel has stopped counting — is refused by the
/// caller before the sample is taken, because only the caller can see the
/// sensor value; see [`crate::operation::CLIP_ONSET`].
const FLOOR: f32 = 1e-4; const FLOOR: f32 = 1e-4;
/// TRACES: FR-DEV-3 /// TRACES: FR-DEV-3
/// Move the graph so that `sample` renders neutral. /// Move the graph so that `sample` renders neutral.
/// ///
/// `sample` is linear RGB, as the operation's own gains multiply it — that is, /// `sample` is the linear triple the operation's own gains multiply — camera
/// measured with the sampling operation at its defaults. Returns whether the /// RGB with the camera's as-shot balance on, *before* the body's base curve
/// graph was moved: `false` where the chain offers no white point widget, or /// and matrix, and with the sampling operation at its defaults. Not the
/// where the colour has no balance in it to correct. /// pixel on the screen: the matrix mixes the channels on the way there, so
/// a colour read after it does not answer to these gains, and a solve over
/// one lands somewhere no sample asked for. Returns whether the graph was
/// moved: `false` where the chain offers no white point widget, or where the
/// colour has no balance in it to correct.
/// ///
/// **Absolute, not relative.** The values written depend on the colour and not /// **Absolute, not relative.** The values written depend on the colour and not
/// on where the sliders happened to be, so sampling the same wall twice lands /// on where the sliders happened to be, so sampling the same wall twice lands
+144 -13
View File
@@ -54,7 +54,7 @@ pub enum Affects {
/// A pixel's *neighbourhood* — sharpening, noise reduction, clarity, /// A pixel's *neighbourhood* — sharpening, noise reduction, clarity,
/// texture, dehaze, spot removal. /// texture, dehaze, spot removal.
/// ///
/// The seam `docs/requirements.md` §3.3 designed and nothing cut until /// The seam `docs/dev/requirements.md` §3.3 designed and nothing cut until
/// [`crate::detail`] existed. It is a separate variant rather than a flavour /// [`crate::detail`] existed. It is a separate variant rather than a flavour
/// of `Colour` because it is a separate *dispatch*: a fragment in the fused /// of `Colour` because it is a separate *dispatch*: a fragment in the fused
/// pass is handed a colour and has no way back to a coordinate, so a /// pass is handed a colour and has no way back to a coordinate, so a
@@ -431,9 +431,11 @@ pub enum OutputMode {
/// no operations, and the caller fills the reserved uniforms neutral — /// no operations, and the caller fills the reserved uniforms neutral —
/// unit white balance, identity matrix, base curve off — so what is /// unit white balance, identity matrix, base curve off — so what is
/// stored is the sensor's own numbers, demosaiced and undistorted. Only /// stored is the sensor's own numbers, demosaiced and undistorted. Only
/// [`compose_camera_linear`] produces it, and only /// [`compose_camera_probe`] produces it — for a merge through
/// `AdjustPass::render_camera_linear` accepts it, so the neutral /// [`compose_camera_linear`], and for the white balance picker under the
/// uniforms cannot be forgotten by a caller that composed it by mistake. /// edit's own framing — and only `AdjustPass::render_camera_linear`
/// accepts it, so the neutral uniforms cannot be forgotten by a caller
/// that composed it by mistake.
/// ///
/// Thirty-two bits rather than sixteen because the composite is written /// Thirty-two bits rather than sixteen because the composite is written
/// back as a RAW at the sensor's scale (FR-MRG-3): a 14-bit sensor has /// back as a RAW at the sensor's scale (FR-MRG-3): a 14-bit sensor has
@@ -454,6 +456,25 @@ pub struct ComposedShader {
pub structure_hash: u64, pub structure_hash: u64,
/// What this shader writes. See [`OutputMode`]. /// What this shader writes. See [`OutputMode`].
pub output_mode: OutputMode, pub output_mode: OutputMode,
/// What decides which source texel each output pixel reads, when that
/// texel is read whole — `None` when it is interpolated.
///
/// A fit view reads one texel in every three or four of a 60 MP source,
/// on a stride, and that gather is most of what the fused pass costs
/// there: the texel it wants shares a cache line with neighbours nobody
/// reads. But the gather depends on the framing and nothing else, so it
/// is the same on every frame of a slider drag. The shader can therefore
/// write what it gathered to a viewport-sized texture once and read it
/// back contiguously thereafter; the flags in the uniform block at
/// [`SAMPLE_CACHE_UNIFORM_OFFSET`] say which, and `dr-gpu` decides.
///
/// This key is the half of that decision only the composer can make: a
/// hash of the generated prologue and the framing and warp uniforms, which
/// together are everything that maps an output pixel to a source texel.
/// The caller mixes in the source image and the render size. `None` for
/// the interpolating paths, whose sample is a blend of four texels and
/// not representable exactly in the source's own format.
pub sample_key: Option<u64>,
} }
/// Fields the generated uniform struct always carries, before op uniforms. /// Fields the generated uniform struct always carries, before op uniforms.
@@ -464,7 +485,19 @@ pub struct ComposedShader {
/// Twelve of the twenty-eight are the camera profile's base curve /// Twelve of the twenty-eight are the camera profile's base curve
/// ([`BASE_CURVE_UNIFORM_FIELDS`]); the rest are the matrix, the as-shot /// ([`BASE_CURVE_UNIFORM_FIELDS`]); the rest are the matrix, the as-shot
/// balance and framing's own block. /// balance and framing's own block.
const BASE_UNIFORM_FIELDS: usize = 16 + BASE_CURVE_UNIFORM_FIELDS; const BASE_UNIFORM_FIELDS: usize = 16 + SAMPLE_CACHE_UNIFORM_FIELDS + BASE_CURVE_UNIFORM_FIELDS;
/// Slots the sample cache's two flags occupy: read, write, and two spare to
/// keep the block a whole `vec4`. See [`ComposedShader::sample_key`].
const SAMPLE_CACHE_UNIFORM_FIELDS: usize = 4;
/// Where the sample cache's flags sit in the generated uniform block: `x` says
/// read the source colour from the cache, `y` says write it there.
///
/// Exported for the reason [`BASE_CURVE_UNIFORM_OFFSET`] is — `dr-gpu` writes
/// these by index — and zero in every block the composer hands out, so a
/// caller that never heard of the cache gets the direct read it always had.
pub const SAMPLE_CACHE_UNIFORM_OFFSET: usize = 16;
/// TRACES: FR-DEV-3e /// TRACES: FR-DEV-3e
/// Slots the base curve occupies: five `(x, y)` points and an active flag. /// Slots the base curve occupies: five `(x, y)` points and an active flag.
@@ -482,7 +515,8 @@ const BASE_CURVE_UNIFORM_FIELDS: usize = 12;
/// Exported for the same reason [`RESERVED_UNIFORM_FIELDS`] is: `dr-gpu` /// Exported for the same reason [`RESERVED_UNIFORM_FIELDS`] is: `dr-gpu`
/// writes these by index, and an offset computed independently at both ends is /// writes these by index, and an offset computed independently at both ends is
/// an offset that will eventually disagree with itself. /// an offset that will eventually disagree with itself.
pub const BASE_CURVE_UNIFORM_OFFSET: usize = 16; pub const BASE_CURVE_UNIFORM_OFFSET: usize =
SAMPLE_CACHE_UNIFORM_OFFSET + SAMPLE_CACHE_UNIFORM_FIELDS;
/// How many control points a base curve carries. /// How many control points a base curve carries.
/// ///
@@ -490,6 +524,19 @@ pub const BASE_CURVE_UNIFORM_OFFSET: usize = 16;
/// selection in [`compose_full`]. /// selection in [`compose_full`].
pub const BASE_CURVE_POINTS: usize = 5; pub const BASE_CURVE_POINTS: usize = 5;
/// Where the highlight desaturation begins: the fraction of the white level
/// above which a photosite is treated as clipped.
///
/// A photosite this close to saturation has stopped counting, so its ratio
/// to its neighbours is not a colour. The generated prologue fades a pixel
/// above this toward a neutral of the same brightness before any operation
/// runs, and the white balance probe refuses to sample one: a blown sky is
/// sensor white, which the as-shot multipliers make magenta, and a solve
/// over that slams tint to its stop. One number, so the two cannot drift
/// apart — a probe that accepted what the shader had already desaturated
/// would be balancing against a pixel the photographer cannot see.
pub const CLIP_ONSET: f32 = 0.985;
/// Where an operation's own uniforms begin in the generated block. /// Where an operation's own uniforms begin in the generated block.
/// ///
/// The base fields, then framing's. Exported because `dr-gpu` writes the /// The base fields, then framing's. Exported because `dr-gpu` writes the
@@ -589,7 +636,9 @@ pub fn compose_full_revealing(
warps: &[Box<dyn crate::lens::Warp>], warps: &[Box<dyn crate::lens::Warp>],
reveal: Option<&crate::mask::Reveal>, reveal: Option<&crate::mask::Reveal>,
) -> ComposedShader { ) -> ComposedShader {
compose_inner(ops, framing, output, masks, spots, warps, reveal, None) compose_inner(
ops, framing, output, masks, spots, warps, reveal, None, false,
)
} }
/// TRACES: FR-MRG-2 /// TRACES: FR-MRG-2
@@ -614,15 +663,50 @@ pub fn compose_camera_linear(
// upright too, and `view` is a fraction of the upright frame. // upright too, and `view` is a fraction of the upright frame.
framing.set_baseline(baseline); framing.set_baseline(baseline);
framing.set_view(view); framing.set_view(view);
compose_camera_tap(warps, &framing, false)
}
/// TRACES: FR-DEV-3
/// The same tap under a framing the caller chose: the white balance probe.
///
/// A neutral picked off the canvas has to be measured in the space the
/// white balance gains multiply, and that is camera RGB — the operation
/// runs before the body's matrix, and a probe read after the matrix would
/// be solving the wrong equation on any body whose matrix mixes the
/// channels, which is every body. It also has to be measured at the pixel
/// the canvas is showing, which is why this takes the edit's own framing
/// where a merge passes the file's orientation and a tile.
///
/// **Interpolated whatever the framing says.** The point of rendering a
/// patch is to average what is under it, and the nearest sampling an
/// unrotated frame otherwise gets is a comb: at two source pixels per
/// probe pixel it lands on the same column of any pattern every time, and
/// the average of a thousand samples is then the average of nothing.
pub fn compose_camera_probe(
warps: &[Box<dyn crate::lens::Warp>],
framing: &Framing,
) -> ComposedShader {
compose_camera_tap(warps, framing, true)
}
/// The camera-space tap proper: no operations, `rgba32float`, and the
/// profile uniforms left for the GPU side to fill neutral. `smooth` forces
/// the interpolating sampler; see the two callers for who wants it and why.
fn compose_camera_tap(
warps: &[Box<dyn crate::lens::Warp>],
framing: &Framing,
smooth: bool,
) -> ComposedShader {
compose_inner( compose_inner(
&[], &[],
&framing, framing,
ColourSpace::Srgb, ColourSpace::Srgb,
&MaskStack::new(), &MaskStack::new(),
&crate::spot::SpotSet::new(), &crate::spot::SpotSet::new(),
warps, warps,
None, None,
Some(OutputMode::CameraLinear), Some(OutputMode::CameraLinear),
smooth,
) )
} }
@@ -636,6 +720,7 @@ fn compose_inner(
warps: &[Box<dyn crate::lens::Warp>], warps: &[Box<dyn crate::lens::Warp>],
reveal: Option<&crate::mask::Reveal>, reveal: Option<&crate::mask::Reveal>,
forced: Option<OutputMode>, forced: Option<OutputMode>,
smooth: bool,
) -> ComposedShader { ) -> ComposedShader {
// The lens corrections, composed into one coordinate transform. Beside // The lens corrections, composed into one coordinate transform. Beside
// `framing` because they are the other half of the same stage: framing // `framing` because they are the other half of the same stage: framing
@@ -668,7 +753,7 @@ fn compose_inner(
// twice and be bound to a texture of the wrong format. // twice and be bound to a texture of the wrong format.
// //
// `forced` is the one exception, and it is not a caller flag in the // `forced` is the one exception, and it is not a caller flag in the
// sense above: `compose_camera_linear` is the only function that passes // sense above: `compose_camera_probe` is the only function that passes
// it, with an empty operation list, and the mode it forces has its own // it, with an empty operation list, and the mode it forces has its own
// storage format and its own render entry on the GPU side. // storage format and its own render entry on the GPU side.
let output_mode = forced.unwrap_or( let output_mode = forced.unwrap_or(
@@ -703,6 +788,10 @@ fn compose_inner(
\x20 // gamma-encoded JPEG, 0.0 for demosaiced sensor data), which the\n\ \x20 // gamma-encoded JPEG, 0.0 for demosaiced sensor data), which the\n\
\x20 // prologue reads to decide whether to linearise.\n\ \x20 // prologue reads to decide whether to linearise.\n\
\x20 as_shot_wb: vec4<f32>,\n\ \x20 as_shot_wb: vec4<f32>,\n\
\x20 // The sample cache (see `ComposedShader::sample_key`): `.x` reads\n\
\x20 // the source colour from `sampled`, `.y` writes it to\n\
\x20 // `sample_out`. Zero for both is the direct read.\n\
\x20 sample_cache: vec4<f32>,\n\
\x20 // The camera profile's base curve (FR-DEV-3e): five points on a\n\ \x20 // The camera profile's base curve (FR-DEV-3e): five points on a\n\
\x20 // monotone spline, packed as x0..x3, y0..y3, then (x4, y4, on).\n\ \x20 // monotone spline, packed as x0..x3, y0..y3, then (x4, y4, on).\n\
\x20 // `.z` of the last is the flag, not padding — it is 0 for a\n\ \x20 // `.z` of the last is the flag, not padding — it is 0 for a\n\
@@ -748,7 +837,11 @@ fn compose_inner(
\x20 // angle as sin/cos — a trig call per pixel would recompute a\n\ \x20 // angle as sin/cos — a trig call per pixel would recompute a\n\
\x20 // value that is constant across the dispatch.\n\ \x20 // value that is constant across the dispatch.\n\
\x20 crop_rect: vec4<f32>,\n\ \x20 crop_rect: vec4<f32>,\n\
\x20 framing_angle: vec4<f32>,\n", \x20 framing_angle: vec4<f32>,\n\
\x20 // The perspective map (FR-DEV-20) by columns, `.w` unused.\n\
\x20 keystone_c0: vec4<f32>,\n\
\x20 keystone_c1: vec4<f32>,\n\
\x20 keystone_c2: vec4<f32>,\n",
); );
uniform_values.extend_from_slice(&framing.uniforms()); uniform_values.extend_from_slice(&framing.uniforms());
@@ -842,7 +935,10 @@ fn compose_inner(
// framing alone — which is what this did before the warps existed — would // framing alone — which is what this did before the warps existed — would
// have nearest-neighboured a distortion correction on an unstraightened // have nearest-neighboured a distortion correction on an unstraightened
// frame, and the aliasing would have looked like a bad profile. // frame, and the aliasing would have looked like a bad profile.
let interpolate = framing.needs_interpolation() || warp.is_active(); // `smooth` is the third reason, and the only one a caller states: the
// white balance probe averages a patch and cannot do that through a
// nearest-neighbour comb (see `compose_camera_probe`).
let interpolate = smooth || framing.needs_interpolation() || warp.is_active();
// Declared ahead of the warp block, which assigns to them. They enter // Declared ahead of the warp block, which assigns to them. They enter
// equal to `p` so that a chain mixing a splitting warp with a // equal to `p` so that a chain mixing a splitting warp with a
@@ -876,6 +972,19 @@ fn compose_inner(
); );
let sampler_helper = if interpolate { BILINEAR_HELPER } else { "" }; let sampler_helper = if interpolate { BILINEAR_HELPER } else { "" };
// Everything that decides which texel an output pixel reads: the code that
// computes `coord`, and the uniforms that code reads. Only on the path that
// reads a texel whole — see `ComposedShader::sample_key`.
let sample_key = (!interpolate && !warp.splits_channels).then(|| {
framing
.uniforms()
.iter()
.chain(&warp.uniforms)
.fold(hash_source(&prologue), |h, v| {
mix(h, u64::from(v.to_bits()))
})
});
// The tail, and it is the whole of the difference between the two output // The tail, and it is the whole of the difference between the two output
// modes. Everything above — the prologue, the fragments, the mask layers, // modes. Everything above — the prologue, the fragments, the mask layers,
// the camera matrix — is emitted identically either way, so an operation // the camera matrix — is emitted identically either way, so an operation
@@ -997,6 +1106,9 @@ fn compose_inner(
.to_string() .to_string()
}; };
// Formatted with Rust's `Display` so the shader reads the same threshold
// the probe checks against; see `CLIP_ONSET`.
let clip_onset = CLIP_ONSET;
let source = format!( let source = format!(
"// GENERATED — do not edit. "// GENERATED — do not edit.
// //
@@ -1024,6 +1136,12 @@ struct Params {{
// stock is loaded, which costs eight bytes and no branch. // stock is loaded, which costs eight bytes and no branch.
@group(0) @binding(4) var film_curves: texture_2d<f32>; @group(0) @binding(4) var film_curves: texture_2d<f32>;
@group(0) @binding(5) var film_lut_texture: texture_3d<f32>; @group(0) @binding(5) var film_lut_texture: texture_3d<f32>;
// The sample cache: the source texel each output pixel read on an earlier
// frame with this framing, and where this frame writes it when asked. See
// `ComposedShader::sample_key`. Declared unconditionally, like the masks, and
// bound to 1x1 placeholders whenever the flags say not to touch them.
@group(0) @binding(6) var sampled: texture_2d<f32>;
@group(0) @binding(7) var sample_out: texture_storage_2d<rgba16float, write>;
{sampler_helper}{helper_src}{encode_output} {sampler_helper}{helper_src}{encode_output}
// Display-encoded sRGB back to linear, for sources that arrive that way. // Display-encoded sRGB back to linear, for sources that arrive that way.
@@ -1067,7 +1185,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
// A photosite at its white level carries no colour information — every // A photosite at its white level carries no colour information — every
// channel simply stopped counting — so the balance below must not be // channel simply stopped counting — so the balance below must not be
// allowed to tint it. // allowed to tint it.
let clipped = smoothstep(0.985, 1.0, max(c.r, max(c.g, c.b))); let clipped = smoothstep({clip_onset}, 1.0, max(c.r, max(c.g, c.b)));
c = c * u.as_shot_wb.rgb; c = c * u.as_shot_wb.rgb;
@@ -1133,6 +1251,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
uniforms: uniform_values, uniforms: uniform_values,
structure_hash, structure_hash,
output_mode, output_mode,
sample_key,
} }
} }
@@ -1352,7 +1471,19 @@ pub(crate) fn sample_source(interpolate: bool, splits_channels: bool) -> &'stati
// amount that changes with the aspect ratio. It reads as a correction that // amount that changes with the aspect ratio. It reads as a correction that
// is simply too weak, which is indistinguishable from a bad profile. // is simply too weak, which is indistinguishable from a bad profile.
let radius = length(p) / (0.5 * length(aspect)); let radius = length(p) / (0.5 * length(aspect));
var c = textureLoad(source, coord, 0).rgb; // The texel itself, from the source or from the cache of it an earlier
// frame wrote (see `ComposedShader::sample_key`). Both branches yield the
// same bits: the source is `rgba16float` and so is the cache. The flags
// are uniforms, so the whole dispatch takes one branch.
var c: vec3<f32>;
if (u.sample_cache.x > 0.5) {
c = textureLoad(sampled, vec2<i32>(gid.xy), 0).rgb;
} else {
c = textureLoad(source, coord, 0).rgb;
if (u.sample_cache.y > 0.5) {
textureStore(sample_out, vec2<i32>(gid.xy), vec4<f32>(c, 1.0));
}
}
" "
} }
} }
+119 -145
View File
@@ -99,7 +99,7 @@
//! A minimum over a patch is separable, as a Gaussian is: minimum along x, //! A minimum over a patch is separable, as a Gaussian is: minimum along x,
//! then along y. That alone is not enough. The patch is 1% of the shorter edge //! then along y. That alone is not enough. The patch is 1% of the shorter edge
//! — 61 taps across at 4K — and two passes of 61 taps is the arithmetic that //! — 61 taps across at 4K — and two passes of 61 taps is the arithmetic that
//! measured 34 ms for clarity and became `docs/technical-debt.md` TD-4. //! measured 34 ms for clarity and became `docs/dev/technical-debt.md` TD-4.
//! //!
//! A minimum has a property a Gaussian does not: **erosions compose by adding //! A minimum has a property a Gaussian does not: **erosions compose by adding
//! their structuring elements**. The minimum over a contiguous run of `d` //! their structuring elements**. The minimum over a contiguous run of `d`
@@ -116,12 +116,34 @@
//! mottling across smooth gradients. This decomposition samples nothing — it //! mottling across smooth gradients. This decomposition samples nothing — it
//! evaluates the exact minimum over every pixel of the window, in two steps. //! evaluates the exact minimum over every pixel of the window, in two steps.
//! //!
//! # Why it is now two passes and not five
//!
//! The decomposition was run as four erosion passes — run and span along x,
//! then along y — and a fifth for the recovery. On the reference laptop the
//! taps turned out not to be what a pass costs: with the memory clock held at
//! 810 MHz by the power cap, a pass that only reads the render-sized
//! `rgba16float` intermediate and writes the other one costs about 4 ms at
//! 2560 x 1600, and dehaze's five came to 22 ms, of which the taps were about
//! 2. So each axis is now one pass that takes the minimum over the whole
//! window directly — 36 texture reads per pixel at that size instead of 12,
//! nearly all of them served by the cache — and the recovery rides in the y
//! pass, which already has the veil and this pixel's colour in hand. Two
//! passes: 9.2 ms.
//!
//! It is the same picture, bit for bit. A minimum is exact in any order, so
//! the minimum over the window's pixels is one value however it is grouped,
//! and the window is the one [`Split`] always covered, surplus pixel
//! included. The veil reaching the recovery was always exactly representable
//! in the `rgba16float` lane it crossed — a minimum of channel values that
//! were themselves read from `rgba16float` — so no rounding was lost by not
//! storing it between passes.
//!
//! It is also why this operation does not use the reduced chain that TD-4 gave //! It is also why this operation does not use the reduced chain that TD-4 gave
//! clarity. The runner holds one reduced buffer, so every scaled pass in a //! clarity. The runner holds one reduced buffer, so every scaled pass in a
//! chain must declare the same `output_scale`; clarity's steps down with the //! chain must declare the same `output_scale`; clarity's steps down with the
//! viewport, so a second operation choosing its own would disagree with it at //! viewport, so a second operation choosing its own would disagree with it at
//! some window sizes and not others. Four cheap full-resolution passes cost //! some window sizes and not others. Two full-resolution passes cost less than
//! less than that coupling, and the decomposition is what makes them cheap. //! that coupling.
//! //!
//! # The artefact this does not fix //! # The artefact this does not fix
//! //!
@@ -275,22 +297,23 @@ impl Dehaze {
/// ///
/// See the module documentation: eroding by a contiguous run and then by a set /// See the module documentation: eroding by a contiguous run and then by a set
/// of points spaced one run apart erodes by the sum of the two, which is the /// of points spaced one run apart erodes by the sum of the two, which is the
/// whole window. This is the arithmetic of that split, in one place, because /// whole window. The passes no longer run the two stages apart (see "Why it is
/// both axes need it and a second copy is a second chance to get the centring /// now two passes"), but the window they read is still the one this split
/// wrong. /// covers — [`Self::first`] and [`Self::width`] — surplus pixel included,
/// because that is the window every edit made so far was tuned against.
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
pub struct Split { pub struct Split {
/// Length of the contiguous run the first pass takes the minimum over. /// Length of the contiguous run the first pass takes the minimum over.
pub run: u32, pub run: u32,
/// How many runs the second pass chains together, spaced `run` apart. /// How many runs the second pass chains together, spaced `run` apart.
pub span: u32, pub span: u32,
/// What the second pass subtracts from its offsets to centre the window. /// How far before the pixel being written the window starts.
/// ///
/// The composite covers `run * span` pixels, which is at least the window /// The composite covers `run * span` pixels, which is at least the window
/// asked for and can be one or two more; the surplus falls on the far side /// asked for and can be one or two more; the surplus falls on the far side
/// rather than being trimmed, because trimming it would need a third pass /// rather than being trimmed, because trimming it would have needed a
/// and a patch a pixel wider on one side is not a visible difference in a /// third pass and a patch a pixel wider on one side is not a visible
/// field this smooth. /// difference in a field this smooth.
pub shift: i32, pub shift: i32,
} }
@@ -311,14 +334,25 @@ impl Split {
} }
} }
/// The furthest the second pass reads, in pixels. /// The window's first offset from the pixel being written: `-shift`.
pub fn first(&self) -> i32 {
-self.shift
}
/// How many pixels the window covers, `run * span` — the patch asked for
/// and the one or two surplus pixels on the far side the split leaves.
pub fn width(&self) -> u32 {
self.run * self.span
}
/// The furthest the window reads from the pixel being written, in pixels.
/// ///
/// Stated rather than assumed symmetric: the composite window is centred /// Stated rather than assumed symmetric: the window is centred to within a
/// to within a pixel and not exactly, so the two directions can differ by /// pixel and not exactly, so the two directions can differ by one. An
/// one. An understated radius is a seam at every tile boundary (ARCH /// understated radius is a seam at every tile boundary (ARCH §5.3), which
/// §5.3), which is the kind of artefact that looks like a driver bug. /// is the kind of artefact that looks like a driver bug.
pub fn reach(&self) -> u32 { pub fn extent(&self) -> u32 {
let far = (self.span.saturating_sub(1) * self.run) as i32 - self.shift; let far = self.width() as i32 - 1 - self.shift;
self.shift.max(far).max(0) as u32 self.shift.max(far).max(0) as u32
} }
} }
@@ -367,83 +401,50 @@ impl DetailStage for Dehaze {
fn passes(&self, scale: RenderScale) -> Vec<DetailPass> { fn passes(&self, scale: RenderScale) -> Vec<DetailPass> {
let split = Split::of(self.patch(scale)); let split = Split::of(self.patch(scale));
// The run stage's uniforms are the same on both axes, and so are the // Both axes erode over the same window; only the offset expression
// span stage's. Only the offset expression differs, which is what // differs, which is what `erode` takes as an argument — one filter
// `erode_run` and `erode_span` take as an argument — each filter
// written once, so the two axes cannot drift into being different // written once, so the two axes cannot drift into being different
// filters. // filters.
let run = vec![Uniform { let window = vec![
name: "run",
value: split.run as f32,
}];
let span = vec![
Uniform { Uniform {
name: "span", name: "first",
value: split.span as f32, value: split.first() as f32,
}, },
Uniform { Uniform {
name: "stride", name: "width",
value: split.run as f32, value: split.width() as f32,
},
Uniform {
name: "shift",
value: split.shift as f32,
}, },
]; ];
let mut recovery = window.clone();
recovery.extend([
Uniform {
name: "omega",
value: self.omega(),
},
Uniform {
name: "min_transmission",
value: MIN_TRANSMISSION,
},
]);
vec![ vec![
DetailPass { DetailPass {
output_scale: 1, output_scale: 1,
label: "veil-run-x", label: "veil-x",
// The run starts at this pixel and walks forward, so it reads radius: split.extent(),
// `run - 1` beyond itself and nothing behind.
radius: split.run.saturating_sub(1),
storage: Vec::new(), storage: Vec::new(),
uniforms: run.clone(), uniforms: window,
wgsl: erode_run(Axis::X), wgsl: erode(Axis::X),
}, },
DetailPass { DetailPass {
output_scale: 1, output_scale: 1,
label: "veil-span-x", label: "veil-y-clear",
radius: split.reach(), radius: split.extent(),
storage: Vec::new(), storage: Vec::new(),
uniforms: span.clone(), uniforms: recovery,
wgsl: erode_span(Axis::X), // The erosion in a block of its own, so its locals do not
}, // collide with the recovery's.
DetailPass { wgsl: format!("{{\n{}\n}}\n\n{CLEAR}", erode(Axis::Y)),
output_scale: 1,
label: "veil-run-y",
radius: split.run.saturating_sub(1),
storage: Vec::new(),
uniforms: run,
wgsl: erode_run(Axis::Y),
},
DetailPass {
output_scale: 1,
label: "veil-span-y",
radius: split.reach(),
storage: Vec::new(),
uniforms: span,
wgsl: erode_span(Axis::Y),
},
DetailPass {
output_scale: 1,
label: "clear",
// Reads only the pixel it writes: the veil arrived in the
// scratch lane four passes ago.
radius: 0,
storage: Vec::new(),
uniforms: vec![
Uniform {
name: "omega",
value: self.omega(),
},
Uniform {
name: "min_transmission",
value: MIN_TRANSMISSION,
},
],
wgsl: CLEAR.to_string(),
}, },
] ]
} }
@@ -466,33 +467,33 @@ impl Axis {
} }
} }
/// The first stage: the minimum over a contiguous run. /// The minimum over the window along one axis.
/// ///
/// Along x it reads the colour and reduces it to the dark channel; along y the /// Along x it reads the colour and reduces it to the dark channel; along y the
/// dark channel is already in the scratch lane, so it reads that instead. /// dark channel's x minimum is already in the scratch lane, so it reads that
/// Doing the channel minimum again on the second axis would be reducing a /// instead. Doing the channel minimum again on the second axis would be
/// scalar and would quietly discard the x erosion. /// reducing a scalar and would quietly discard the x erosion.
/// ///
/// Neither stage touches `c`. The recovery needs the original colour *and* the /// The x pass does not touch `c`. The recovery needs the original colour *and*
/// veil in the same place at the same time, and the ping-pong hands each pass /// the veil in the same place at the same time, and the ping-pong hands each
/// only what the pass before it wrote — so the veil travels in `aux` and the /// pass only what the pass before it wrote — so the veil travels in `aux` and
/// colour rides through untouched. See [`DetailPass::wgsl`]. /// the colour rides through untouched. See [`DetailPass::wgsl`].
fn erode_run(axis: Axis) -> String { fn erode(axis: Axis) -> String {
let source = match axis { let source = match axis {
Axis::X => "dark_channel(tap(coord, OFFSET))", Axis::X => "dark_channel(tap(coord, OFFSET))",
Axis::Y => "tap_aux(coord, OFFSET)", Axis::Y => "tap_aux(coord, OFFSET)",
}; };
let first = source.replace("OFFSET", &axis.offset("0")); let head = source.replace("OFFSET", &axis.offset("o"));
let rest = source.replace("OFFSET", &axis.offset("i")); let rest = source.replace("OFFSET", &axis.offset("o + i"));
format!( format!(
"\ "\
// Half of the erosion's first stage: the minimum over `run` contiguous pixels, // The minimum over every pixel of the window along this axis, from `first`
// walking forward from this one. The second stage chains these together, and // for `width` pixels. A minimum is exact in any order, so this is the same
// the two structuring elements add up to the whole patch — which is why this // value, bit for bit, as chaining a run and a span over the same pixels.
// one is not centred and does not need to be. let o = i32(first);
let n = i32(run); let n = i32(width);
var veil = {first}; var veil = {head};
for (var i = 1; i < n; i = i + 1) {{ for (var i = 1; i < n; i = i + 1) {{
veil = min(veil, {rest}); veil = min(veil, {rest});
}} }}
@@ -500,38 +501,10 @@ aux = veil;"
) )
} }
/// The second stage: the minimum over `span` points spaced `stride` apart.
///
/// Each of those points already holds the minimum over the run that starts
/// there, so this reads the whole window while touching `span` pixels of it.
/// `shift` is what centres the composite on the pixel being written; without
/// it the veil would be measured from a patch lying entirely to one side, and
/// the correction would appear to lag the picture by half a patch.
fn erode_span(axis: Axis) -> String {
let offset = axis.offset("j * s - o");
let first = axis.offset("-o");
format!(
"\
// The erosion's second stage. `span` taps, spaced a whole run apart, each
// standing for the run that begins at it — so the minimum over the patch costs
// `run + span` taps rather than the `run * span` pixels it covers, and it is
// the exact minimum over all of them rather than a sample of them.
let n = i32(span);
let s = i32(stride);
let o = i32(shift);
var veil = tap_aux(coord, {first});
for (var j = 1; j < n; j = j + 1) {{
veil = min(veil, tap_aux(coord, {offset}));
}}
aux = veil;"
)
}
/// The recovery: invert the scattering model with the transmission the erosion /// The recovery: invert the scattering model with the transmission the erosion
/// implies. /// implies.
const CLEAR: &str = "\ const CLEAR: &str = "\
// The veil the four erosion passes measured: the smallest channel anywhere in // The veil the two erosions measured: the smallest channel anywhere in
// the patch around this pixel, which the dark-channel prior reads as the // the patch around this pixel, which the dark-channel prior reads as the
// airlight that has been composited over the scene here. // airlight that has been composited over the scene here.
// //
@@ -581,7 +554,7 @@ mod tests {
#[test] #[test]
fn dehaze_starts_neutral_and_costs_nothing() { fn dehaze_starts_neutral_and_costs_nothing() {
// The rule the whole pipeline rests on. An unedited photograph must not // The rule the whole pipeline rests on. An unedited photograph must not
// pay for a slider nobody has touched — and this one is five dispatches // pay for a slider nobody has touched — and this one is two dispatches
// when it is on, so "nothing" here is a worthwhile amount of nothing. // when it is on, so "nothing" here is a worthwhile amount of nothing.
assert!(!Dehaze::new().is_active()); assert!(!Dehaze::new().is_active());
assert!(composed(0.0, RenderScale::full((2000, 1500))).is_empty()); assert!(composed(0.0, RenderScale::full((2000, 1500))).is_empty());
@@ -623,7 +596,7 @@ mod tests {
// develop view about it would read as a bug. // develop view about it would read as a bug.
let tiny = RenderScale::full((48, 32)); let tiny = RenderScale::full((48, 32));
assert_eq!(Dehaze::with_amount(50.0).patch(tiny), 1); assert_eq!(Dehaze::with_amount(50.0).patch(tiny), 1);
assert_eq!(composed(50.0, tiny).len(), 5); assert_eq!(composed(50.0, tiny).len(), 2);
} }
#[test] #[test]
@@ -648,7 +621,8 @@ mod tests {
// Centred to within the pixel the odd surplus leaves over: the window // Centred to within the pixel the odd surplus leaves over: the window
// covers [-31, 32] around the pixel being written. // covers [-31, 32] around the pixel being written.
assert_eq!(split.shift, 31); assert_eq!(split.shift, 31);
assert_eq!(split.reach(), 31); assert_eq!((split.first(), split.width()), (-31, 64));
assert_eq!(split.extent(), 32);
} }
#[test] #[test]
@@ -662,33 +636,32 @@ mod tests {
assert_eq!(split.span, 2); assert_eq!(split.span, 2);
assert!(split.run * split.span >= 3, "the window is not covered"); assert!(split.run * split.span >= 3, "the window is not covered");
assert_eq!(split.shift, 1); assert_eq!(split.shift, 1);
assert_eq!(split.reach(), 1); // [-1, 2]: the surplus pixel is on the far side.
assert_eq!((split.first(), split.width()), (-1, 4));
assert_eq!(split.extent(), 2);
} }
#[test] #[test]
fn the_chain_is_four_erosions_and_a_recovery() { fn the_chain_is_an_erosion_per_axis_the_second_carrying_the_recovery() {
// The shape of the operation, asserted where it is cheap to assert. // The shape of the operation, asserted where it is cheap to assert.
// The erosions leave the colour alone and hand the veil forward in the // The x erosion leaves the colour alone and hands the veil forward in
// scratch lane; only the last pass touches `c`, which is what makes an // the scratch lane; only the y pass touches `c`, which is what makes an
// unsharp-mask-shaped operation expressible in a chain that hands each // unsharp-mask-shaped operation expressible in a chain that hands each
// pass exactly one texture. // pass exactly one texture. Two passes and not five: each extra pass
// is a render-sized read and write, which is what a pass costs (see
// "Why it is now two passes").
let composed = composed(60.0, RenderScale::full((2000, 1500))); let composed = composed(60.0, RenderScale::full((2000, 1500)));
let labels: Vec<&str> = composed.passes.iter().map(|p| p.label.as_str()).collect(); let labels: Vec<&str> = composed.passes.iter().map(|p| p.label.as_str()).collect();
assert_eq!( assert_eq!(labels, ["dehaze/veil-x", "dehaze/veil-y-clear"]);
labels,
[ // Both declare the whole window they read.
"dehaze/veil-run-x", let split = Split::of(Dehaze::with_amount(60.0).patch(RenderScale::full((2000, 1500))));
"dehaze/veil-span-x", assert!(composed.passes.iter().all(|p| p.radius == split.extent()));
"dehaze/veil-run-y",
"dehaze/veil-span-y",
"dehaze/clear",
]
);
// Only the last writes the display texture, so the output transform // Only the last writes the display texture, so the output transform
// happens exactly once (FR-DEV-2). // happens exactly once (FR-DEV-2).
assert!(composed.passes[..4].iter().all(|p| !p.writes_output)); assert!(!composed.passes[0].writes_output);
assert!(composed.passes[4].writes_output); assert!(composed.passes[1].writes_output);
// Nothing here uses the reduced chain — see the module documentation // Nothing here uses the reduced chain — see the module documentation
// for why a second operation cannot pick its own `output_scale` while // for why a second operation cannot pick its own `output_scale` while
@@ -730,7 +703,8 @@ mod tests {
// nothing to say so. // nothing to say so.
let composed = composed(60.0, RenderScale::full((2000, 1500))); let composed = composed(60.0, RenderScale::full((2000, 1500)));
assert!(composed.passes[0].source.contains("dark_channel(tap(coord")); assert!(composed.passes[0].source.contains("dark_channel(tap(coord"));
assert!(!composed.passes[2].source.contains("dark_channel(tap(coord")); assert!(!composed.passes[1].source.contains("dark_channel(tap(coord"));
assert!(composed.passes[1].source.contains("tap_aux(coord"));
// The helper is still emitted for every pass of the operation, and it // The helper is still emitted for every pass of the operation, and it
// must define the function it is named for or the shader fails to // must define the function it is named for or the shader fails to
// compile a long way from here. // compile a long way from here.

Some files were not shown because too many files have changed in this diff Show More